- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
344 lines
15 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|