Files
multi-simadmin/apps/ios/Tests/MultiSimAdminTests/RelayTransportTests.swift
T
chick 3f6090081f feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

507 lines
23 KiB
Swift

import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// The phone's half of the protocol, checked against a peer that behaves like the Go agent.
///
/// The peer here is built from the same primitives the agent uses, in the opposite direction: it
/// verifies the request signature with the phone's public key, decrypts the payload with the agent's
/// private X25519 key, recomputes the confirmation intent, and answers with a signature and a
/// ciphertext the phone has to accept. A test that only stubbed the transport would prove the
/// plumbing; this proves the protocol, which is the part that has three implementations and no
/// shared code.
final class RelayTransportTests: XCTestCase {
// MARK: - Agent-shaped peer
/// Stands in for the node's agent and for the relay at once: the relay is a dumb pipe, so the
/// only interesting behaviour is what the agent does with the envelope.
private struct AgentPeer {
let nodeID: String
let deviceID: String
let signingKey: Curve25519.Signing.PrivateKey
let agreementKey: Curve25519.KeyAgreement.PrivateKey
init(nodeID: String = "nd_7f3a9c2e", deviceID: String = "ap_5b1d0000") {
self.nodeID = nodeID
self.deviceID = deviceID
self.signingKey = Curve25519.Signing.PrivateKey()
self.agreementKey = Curve25519.KeyAgreement.PrivateKey()
}
/// What the phone needs in order to reach this peer.
func endpoint(relayBaseURL: URL = URL(string: "https://relay.example")!) -> RelayEndpoint {
RelayEndpoint(
relayBaseURL: relayBaseURL,
nodeID: nodeID,
agentSigningKey: signingKey.publicKey.rawRepresentation,
agentKxKey: agreementKey.publicKey.rawRepresentation,
deviceToken: "device-token-abcdef",
deviceID: deviceID,
nodeName: "Lab Alpha"
)
}
struct Received {
var inner: RelayWire.InnerRequest
var envelope: RelayWire.Envelope
}
/// Verifies and decrypts one request the way the agent does, in the agent's order.
func receive(_ raw: Data, phonePublicKey: Curve25519.Signing.PublicKey) throws -> Received {
let envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
try RelayWire.verifyRequest(envelope, publicKey: phonePublicKey)
let shared = try RelayWire.sharedSecret(privateKey: agreementKey, peerPublic: envelope.ek)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: nodeID,
requestID: envelope.req,
response: false
)
let plaintext = try RelayWire.open(envelope.p, key: key, iv: iv)
let inner = try JSONDecoder().decode(RelayWire.InnerRequest.self, from: plaintext)
return Received(inner: inner, envelope: envelope)
}
/// Answers the way the agent does: a fresh ephemeral key, encrypted to the phone's request
/// ephemeral, signed with the agent's long-term key, and with `app` omitted from the signed
/// canonical form.
func answer(
to request: RelayWire.Envelope,
status: Int,
outcome: String?,
body: Data?
) throws -> Data {
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let shared = try RelayWire.sharedSecret(
privateKey: ephemeral,
peerPublic: request.ek
)
let (key, iv) = try RelayWire.deriveKey(
shared: shared,
nodeID: nodeID,
requestID: request.req,
response: true
)
let inner = RelayWire.InnerResponse(status: status, outcome: outcome, body: body)
let plaintext = try JSONEncoder().encode(inner)
var envelope = RelayWire.Envelope(
node: nodeID,
req: request.req,
app: nil,
ts: request.ts + 1,
ek: ephemeral.publicKey.rawRepresentation,
p: try RelayWire.seal(plaintext, key: key, iv: iv)
)
envelope.s = try signingKey.signature(for: RelayWire.canonical(envelope, response: true))
return try JSONEncoder().encode(envelope)
}
}
/// A URLProtocol stub, so the transport under test runs its real code path: real envelope
/// construction, real signing, real HTTP handling.
private final class RelayStub: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
nonisolated(unsafe) static var lastRequest: URLRequest?
nonisolated(unsafe) static var lastBody: Data?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
// `URLProtocol` hands the body as a stream, so it has to be drained here to get bytes.
let body = request.httpBody ?? Self.drain(request.httpBodyStream)
Self.lastRequest = request
Self.lastBody = body
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (status, data) = try handler(request, body ?? Data())
let response = HTTPURLResponse(
url: request.url!, statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["content-type": "application/problem+json"]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
private static func drain(_ stream: InputStream?) -> Data? {
guard let stream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: buffer.count)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}
private func makeTransport(
identity: DeviceIdentity,
now: @escaping @Sendable () -> Date = { Date() }
) -> RelayTransport {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [RelayStub.self]
return RelayTransport(
identity: identity,
session: URLSession(configuration: configuration),
now: now
)
}
private func makeIdentity() -> DeviceIdentity {
DeviceIdentity(
deviceID: "ap_5b1d0000",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
}
override func tearDown() {
RelayStub.handler = nil
RelayStub.lastRequest = nil
RelayStub.lastBody = nil
super.tearDown()
}
// MARK: - Round trip
func testAReadRoundTripsThroughAnAgentShapedPeer() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var received: AgentPeer.Received?
RelayStub.handler = { request, body in
received = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let answer = try peer.answer(
to: received!.envelope,
status: 200,
outcome: AgentOutcome.ok.rawValue,
body: Data(#"{"status":"up"}"#.utf8)
)
return (200, answer)
}
let transport = makeTransport(identity: identity)
let response = try await transport.send(
.health,
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
XCTAssertEqual(response.status, 200)
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"status":"up"}"#)
let seen = try XCTUnwrap(received)
XCTAssertEqual(seen.inner.m, "GET")
XCTAssertEqual(seen.inner.p, "/api/health")
XCTAssertNil(seen.inner.intent, "a read must not carry a confirmation intent")
XCTAssertEqual(seen.envelope.app, "ap_5b1d0000")
}
/// The path carries its query string, and the agent forwards it verbatim: the phone must not
/// assemble one from parts, because a part the agent reassembled is a part a relay could steer.
func testThePathCarriesItsQueryString() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var path: String?
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
path = seen.inner.p
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsList(limit: 25, offset: 50, direction: "incoming"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
XCTAssertEqual(path, "/api/sms/list?limit=25&offset=50&direction=incoming")
}
/// A dangerous write carries an intent that the agent recomputes from the bytes it is about to
/// forward. If the phone's digest were computed over different bytes, the agent would refuse it
/// — which is the property that makes a relay unable to edit an SMS in flight.
func testADangerousWriteCarriesAnIntentTheAgentRecomputes() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
var inner: RelayWire.InnerRequest?
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
inner = seen.inner
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
let seen = try XCTUnwrap(inner)
let intent = try XCTUnwrap(seen.intent, "a dangerous write must carry an intent")
let recomputed = try RelayWire.intentDigest(
method: "POST",
path: "/api/sms/send",
body: seen.body
)
XCTAssertEqual(intent, recomputed, "the agent recomputes this digest and refuses a mismatch")
// And the digest must not survive an edit to the body, which is the attack it exists for.
let edited = try RelayWire.intentDigest(
method: "POST",
path: "/api/sms/send",
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
)
XCTAssertNotEqual(intent, edited)
}
/// The relay must not be able to answer without the agent's key. This is the check that makes
/// "the relay cannot forge a response" a fact rather than an assumption.
func testAnUnsignedAnswerIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
// A forged answer: correctly shaped, wrong signer.
let forger = Curve25519.Signing.PrivateKey()
envelope.s = try forger.signature(for: RelayWire.canonical(envelope, response: true))
return (200, try JSONEncoder().encode(envelope))
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseUnverifiable) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
/// A relay that swaps in a different ciphertext cannot produce a valid signature over it either,
/// since the signature covers the ciphertext.
func testATamperedCiphertextIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
var ciphertext = envelope.p
ciphertext[ciphertext.count - 1] ^= 0x01
envelope.p = ciphertext
return (200, try JSONEncoder().encode(envelope))
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseUnverifiable) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
/// An answer to a different request id must be discarded: the signature covers `req`, so this
/// catches both a confused relay and a replay of an earlier answer.
func testAnAnswerForADifferentRequestIsRejected() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
var misdirected = seen.envelope
misdirected.req = UUID().uuidString.lowercased()
// Re-signing with the real key would make it valid; the point is that the phone checks
// the id before it checks anything else, so an unsigned mismatch is enough.
misdirected.s = Data(repeating: 0, count: 64)
let raw = try JSONEncoder().encode(misdirected)
return (200, raw)
}
let transport = makeTransport(identity: identity)
await assertThrows(.relayResponseMismatch) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
// MARK: - Error mapping
/// Every relay error code must arrive as its own typed error. Collapsing them would put the
/// user back to one message for four different situations.
func testRelayProblemCodesMapToDistinctErrors() {
let cases: [(RelayErrorCode, UpstreamError)] = [
(.unauthorized, .relayRejectedDevice),
(.nodeUnknown, .relayNodeUnknown),
(.agentOffline, .agentOffline),
(.mailboxFull, .nodeBusy),
(.agentTimeout, .agentTimeout),
(.badEnvelope, .relayBadEnvelope),
(.payloadTooLarge, .payloadTooLarge),
(.rateLimited, .relayRateLimited),
(.internalError, .relayUnreachable),
]
for (code, expected) in cases {
let body = Data(#"{"code":"\#(code.rawValue)","status":500}"#.utf8)
XCTAssertEqual(RelayTransport.problem(from: body, status: 500), expected, code.rawValue)
}
// An unrecognised code on a 5xx is a relay fault; on a 4xx it is a protocol mismatch.
XCTAssertEqual(
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 503),
.relayUnreachable
)
XCTAssertEqual(
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 400),
.invalidResponse
)
}
func testAnAgentRefusalArrivesAsItsOwnError() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
let cases: [(String, UpstreamError)] = [
(AgentOutcome.nodeAuthFailed.rawValue, .nodeAuthFailed),
(AgentOutcome.policyDenied.rawValue, .relayPolicyDenied),
(AgentOutcome.confirmationMismatch.rawValue, .confirmationRejected),
(AgentOutcome.expired.rawValue, .envelopeExpired),
(AgentOutcome.replay.rawValue, .envelopeExpired),
(AgentOutcome.rateLimited.rawValue, .nodeRateLimited),
(AgentOutcome.badSignature.rawValue, .envelopeRejected),
]
for (outcome, expected) in cases {
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(to: seen.envelope, status: 403, outcome: outcome, body: nil))
}
let transport = makeTransport(identity: identity)
await assertThrows(expected, "outcome \(outcome)") {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
}
}
}
/// An upstream error from the node is the one branch where the node's own status reaches the
/// caller: the agent did its job, SimAdmin answered with an error, and the status is meaningful.
func testAnUpstreamErrorKeepsTheNodeStatus() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(
to: seen.envelope,
status: 502,
outcome: AgentOutcome.upstreamError.rawValue,
body: Data(#"{"error":"upstream"}"#.utf8)
))
}
let transport = makeTransport(identity: identity)
let response = try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
XCTAssertEqual(response.status, 502)
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"error":"upstream"}"#)
}
/// The relay is a bearer-authenticated hop, and the token must travel as a bearer header rather
/// than in the envelope: it is the relay's abuse control, not part of the signed material.
func testTheRequestCarriesTheDeviceBearerAndNeverAPassword() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, body in
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
}
let transport = makeTransport(identity: identity)
_ = try await transport.send(
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
instanceID: UUID(),
endpoint: .relay(peer.endpoint())
)
let request = try XCTUnwrap(RelayStub.lastRequest)
XCTAssertEqual(request.url?.path, "/v1/request")
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(
request.value(forHTTPHeaderField: "authorization"),
"Bearer device-token-abcdef"
)
// The node password never appears anywhere in what leaves the phone.
let body = String(decoding: try XCTUnwrap(RelayStub.lastBody), as: UTF8.self)
XCTAssertFalse(body.lowercased().contains("password"))
}
/// Plain HTTP to a relay would make the end-to-end encryption decorative, so it is refused
/// before any traffic is sent.
func testAPlainHTTPRelayIsRefused() async throws {
let peer = AgentPeer()
let identity = makeIdentity()
RelayStub.handler = { _, _ in
XCTFail("a plain-HTTP relay must be refused before any request is made")
return (500, Data())
}
let transport = makeTransport(identity: identity)
var endpoint = peer.endpoint(relayBaseURL: URL(string: "http://relay.example")!)
endpoint.relayBaseURL = URL(string: "http://relay.example")!
await assertThrows(.invalidRequest) {
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(endpoint))
}
}
// MARK: - Freshness and status vocabulary
func testProbeStatusMappingKeepsTheRelayCasesApart() {
XCTAssertEqual(ConnectionProbe.status(for: .agentOffline, error: UpstreamError.agentOffline), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .relayUnreachable, error: UpstreamError.relayUnreachable), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .relayRejectedDevice, error: UpstreamError.relayRejectedDevice), .offline)
XCTAssertEqual(ConnectionProbe.status(for: .nodeAuthFailed, error: UpstreamError.nodeAuthFailed), .degraded)
XCTAssertEqual(ConnectionProbe.status(for: .confirmationRejected, error: UpstreamError.confirmationRejected), .degraded)
XCTAssertEqual(ConnectionProbe.status(for: .unauthorized, error: UpstreamError.unauthorized), .authRequired)
// Every relay-mode case must have its own message. A generic fallback is the failure this
// whole vocabulary exists to prevent, so the check is that the text is specific rather than
// merely present.
let generic = "请求失败:"
var seen = Set<String>()
for error in Self.allRelayErrors {
let text = ErrorMessage.describe(error, nodeName: "Lab Alpha")
XCTAssertFalse(text.isEmpty)
XCTAssertFalse(text.hasPrefix(generic), "\(error) fell back to the generic message")
XCTAssertTrue(seen.insert(text).inserted, "\(error) shares a message with another case")
}
XCTAssertEqual(seen.count, Self.allRelayErrors.count)
}
static let allRelayErrors: [UpstreamError] = [
.relayUnreachable, .relayTimeout, .relayRejectedDevice, .relayNodeUnknown, .agentOffline,
.nodeBusy, .agentTimeout, .relayBadEnvelope, .relayRateLimited, .relayPolicyDenied,
.confirmationRejected, .envelopeExpired, .nodeRateLimited, .nodeAuthFailed, .payloadTooLarge,
.relayResponseUnverifiable, .relayResponseMismatch, .envelopeRejected,
]
// MARK: - Helpers
private func assertThrows(
_ expected: UpstreamError,
_ message: String = "",
_ body: () async throws -> UpstreamResponse,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await body()
XCTFail("expected \(expected) but the call succeeded. \(message)", file: file, line: line)
} catch let error as UpstreamError {
XCTAssertEqual(error, expected, message, file: file, line: line)
} catch {
XCTFail("expected \(expected), got \(error). \(message)", file: file, line: line)
}
}
}