Files
multi-simadmin/apps/ios/Tests/MultiSimAdminTests/RelayPairingTests.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

344 lines
15 KiB
Swift

import CryptoKit
import XCTest
@testable import MultiSimAdmin
/// The pairing exchange, checked against a node-shaped peer.
///
/// Pairing is the one moment the phone is inside the network and the only time the node's password
/// is involved at all, so the interesting properties are that the phone sends its real public keys,
/// that it records the id the node assigns, and that each failure mode is distinguishable — a wrong
/// password and a closed window call for different actions.
final class RelayPairingTests: XCTestCase {
private final class PairingStub: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
nonisolated(unsafe) static var lastBody: Data?
nonisolated(unsafe) static var lastURL: URL?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let body = request.httpBody ?? Self.drain(request.httpBodyStream) ?? Data()
Self.lastBody = body
Self.lastURL = request.url
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (status, data) = try handler(request, body)
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
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 makePairing() -> RelayPairing {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [PairingStub.self]
return RelayPairing(session: URLSession(configuration: configuration))
}
private func makeIdentity() -> DeviceIdentity {
DeviceIdentity(
deviceID: "",
signingKey: Curve25519.Signing.PrivateKey(),
agreementKey: Curve25519.KeyAgreement.PrivateKey()
)
}
private func successBody(agentSigning: Data, agentKx: Data) -> Data {
Data("""
{
"nodeID": "nd_7f3a9c2e",
"appID": "ap_5b1d0000",
"agentSignPub": "\(agentSigning.base64EncodedString())",
"agentKxPub": "\(agentKx.base64EncodedString())",
"relayURL": "https://relay.example",
"deviceToken": "0123456789abcdef0123456789abcdef"
}
""".utf8)
}
override func tearDown() {
PairingStub.handler = nil
PairingStub.lastBody = nil
PairingStub.lastURL = nil
super.tearDown()
}
func testPairingSendsTheRealPublicKeysAndReturnsTheAssignedIdentity() async throws {
let identity = makeIdentity()
let agentSigning = Curve25519.Signing.PrivateKey()
let agentKx = Curve25519.KeyAgreement.PrivateKey()
PairingStub.handler = { _, _ in
(200, self.successBody(
agentSigning: agentSigning.publicKey.rawRepresentation,
agentKx: agentKx.publicKey.rawRepresentation
))
}
let result = try await makePairing().pair(
host: "192.168.1.10",
password: "hunter2",
deviceName: "chick",
identity: identity
)
// The keys the phone sent must be the ones it will sign and decrypt with; the node stores
// them as its trust anchor, and a mismatch here is an unverifiable request later.
let sent = try XCTUnwrap(
try JSONSerialization.jsonObject(with: try XCTUnwrap(PairingStub.lastBody)) as? [String: Any]
)
XCTAssertEqual(sent["appSignPub"] as? String, identity.signingPublicKey.base64EncodedString())
XCTAssertEqual(sent["appKxPub"] as? String, identity.agreementPublicKey.base64EncodedString())
XCTAssertEqual(sent["password"] as? String, "hunter2")
XCTAssertEqual(sent["deviceName"] as? String, "chick")
XCTAssertEqual(PairingStub.lastURL?.absoluteString, "http://192.168.1.10:3001/provision")
// The id is the node's choice, and the returned keys become the phone's trust anchor.
XCTAssertEqual(result.deviceID, "ap_5b1d0000")
XCTAssertEqual(result.endpoint.nodeID, "nd_7f3a9c2e")
XCTAssertEqual(result.endpoint.deviceID, "ap_5b1d0000")
XCTAssertEqual(result.endpoint.agentSigningKey, agentSigning.publicKey.rawRepresentation)
XCTAssertEqual(result.endpoint.agentKxKey, agentKx.publicKey.rawRepresentation)
XCTAssertEqual(result.endpoint.deviceToken, "0123456789abcdef0123456789abcdef")
XCTAssertEqual(result.endpoint.relayBaseURL.absoluteString, "https://relay.example")
}
/// Each failure mode must be its own error: "wrong password" is a retype, "window closed" is a
/// walk to the node, and neither is fixed by the other's action.
func testFailuresAreDistinguishable() async {
let identity = makeIdentity()
let cases: [(Int, Data, RelayPairing.Failure)] = [
(401, Data(#"{"error":"no"}"#.utf8), .rejected),
(410, Data(#"{"error":"closed"}"#.utf8), .windowClosed),
(500, Data(#"{"error":"boom"}"#.utf8), .malformedResponse),
]
for (status, body, expected) in cases {
PairingStub.handler = { _, _ in (status, body) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
)
XCTFail("status \(status) was accepted")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, expected, "status \(status)")
} catch {
XCTFail("status \(status) produced \(error)")
}
}
}
/// A malformed or truncated response must be refused rather than half-accepted: an endpoint
/// missing its keys would fail later as an unreadable response, which the user reads as a broken
/// node rather than as a bad pairing.
func testAMalformedResponseIsRefused() async {
let identity = makeIdentity()
let good = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
let goodKx = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let bodies: [Data] = [
Data("not json".utf8),
Data(#"{"nodeID":"nd_7f3a9c2e"}"#.utf8),
// A key that is not 32 bytes decodes fine as base64 and is wrong for the format.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(Data(repeating: 1, count: 31).base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":"t"}
""".utf8),
// A plain-HTTP relay would make the end-to-end encryption decorative.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"http://relay.example","deviceToken":"t"}
""".utf8),
// No token: the phone could never authenticate to the relay.
Data("""
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":""}
""".utf8),
]
for body in bodies {
PairingStub.handler = { _, _ in (200, body) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
)
XCTFail("accepted \(String(decoding: body, as: UTF8.self))")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, .malformedResponse)
} catch {
XCTFail("produced \(error)")
}
}
}
func testAnUnreachableNodeIsReportedAsSuch() async {
PairingStub.handler = { _, _ in throw URLError(.cannotConnectToHost) }
do {
_ = try await makePairing().pair(
host: "192.168.1.10", password: "x", deviceName: "d", identity: makeIdentity()
)
XCTFail("an unreachable node was accepted")
} catch let failure as RelayPairing.Failure {
XCTAssertEqual(failure, .unreachable)
} catch {
XCTFail("produced \(error)")
}
}
}
/// The identity file is the one thing a pairing cannot be redone without.
final class DeviceIdentityStoreTests: XCTestCase {
private var location: URL!
override func setUp() {
super.setUp()
location = FileManager.default.temporaryDirectory
.appendingPathComponent("msa-identity-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: location)
super.tearDown()
}
func testTheIdentityIsStableAcrossLoads() throws {
let store = FileDeviceIdentityStore(location: location)
let first = try store.loadOrCreate()
let second = try store.loadOrCreate()
XCTAssertEqual(first.signingPublicKey, second.signingPublicKey)
XCTAssertEqual(first.agreementPublicKey, second.agreementPublicKey)
XCTAssertTrue(first.deviceID.isEmpty, "the phone cannot know its id before the node assigns one")
// A fresh store over the same file must find the same keys, or a restart would silently
// invalidate every pairing.
let reopened = FileDeviceIdentityStore(location: location)
XCTAssertEqual(try reopened.loadOrCreate().signingPublicKey, first.signingPublicKey)
}
/// The node verified the public keys it was given, so recording its id must not regenerate them.
func testAssigningTheDeviceIDKeepsTheKeys() throws {
let store = FileDeviceIdentityStore(location: location)
let original = try store.loadOrCreate()
let assigned = try store.assignDeviceID("ap_5b1d0000")
XCTAssertEqual(assigned.deviceID, "ap_5b1d0000")
XCTAssertEqual(assigned.signingPublicKey, original.signingPublicKey)
XCTAssertEqual(assigned.agreementPublicKey, original.agreementPublicKey)
let reloaded = try FileDeviceIdentityStore(location: location).loadOrCreate()
XCTAssertEqual(reloaded.deviceID, "ap_5b1d0000")
XCTAssertEqual(reloaded.signingPublicKey, original.signingPublicKey)
}
func testTheFileIsNotWorldReadable() throws {
_ = try FileDeviceIdentityStore(location: location).loadOrCreate()
let mode = try FileManager.default
.attributesOfItem(atPath: location.path)[.posixPermissions] as? NSNumber
XCTAssertEqual(mode?.int16Value, 0o600)
}
func testResetForgetsTheIdentity() throws {
let store = FileDeviceIdentityStore(location: location)
let first = try store.loadOrCreate()
try store.reset()
let second = try store.loadOrCreate()
XCTAssertNotEqual(first.signingPublicKey, second.signingPublicKey)
}
}
/// The router is the only place that decides which path a request takes.
final class NodeTransportRouterTests: XCTestCase {
private struct Recorder: NodeTransport {
let label: String
let recorder: @Sendable (String) -> Void
func send(
_ route: UpstreamRoute,
instanceID: UUID,
endpoint: InstanceEndpoint
) async throws -> UpstreamResponse {
recorder(label)
return UpstreamResponse(status: 200, data: Data())
}
}
func testTheRouterPicksThePathFromTheEndpoint() async throws {
let seen = LockedStrings()
let router = NodeTransportRouter(
direct: Recorder(label: "direct") { seen.append($0) },
relay: Recorder(label: "relay") { seen.append($0) }
)
_ = try await router.send(
.health,
instanceID: UUID(),
endpoint: .direct(URL(string: "http://192.168.1.20:3000")!)
)
_ = try await router.send(
.health,
instanceID: UUID(),
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
)
XCTAssertEqual(seen.values, ["direct", "relay"])
}
/// Handing a relay instance to the direct path is a wiring bug, not a network condition, so it
/// must fail rather than dialing something arbitrary.
func testTheDirectPathRefusesARelayEndpoint() async {
let transport = DirectTransport(client: NoopClient(), sessions: InstanceSessionStore())
do {
_ = try await transport.send(
.health,
instanceID: UUID(),
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
)
XCTFail("the direct path accepted a relay endpoint")
} catch let error as UpstreamError {
XCTAssertEqual(error, .invalidRequest)
} catch {
XCTFail("produced \(error)")
}
}
private struct NoopClient: SimAdminClientProtocol {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
XCTFail("the direct client must not be dialed for a relay endpoint")
return UpstreamResponse(status: 200, data: Data())
}
}
private final class LockedStrings: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String] = []
func append(_ value: String) {
lock.withLock { storage.append(value) }
}
var values: [String] {
lock.withLock { storage }
}
}
}