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)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user