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,142 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// Keeps `contracts/app-routes.json` honest.
|
||||
///
|
||||
/// That file is the relay repository's copy of every upstream path this app can construct, and the
|
||||
/// agent's authorisation policy is pinned to it: `test/policy.test.js` asserts the two sets are
|
||||
/// equal, so the agent can never be wider than the app. The missing link was always the other
|
||||
/// direction — nothing checked that the vendored file still matched `UpstreamRoute`, and a route
|
||||
/// added here without updating it would leave the agent narrower than the app, which fails at
|
||||
/// runtime as "policy_denied" for an operation the UI offers.
|
||||
///
|
||||
/// So the check lives here, next to the source of truth, and the vendored file carries a snapshot.
|
||||
final class AppRoutesContractTests: XCTestCase {
|
||||
/// Every route the app can construct, in the vendored file's shape.
|
||||
///
|
||||
/// Written out by hand rather than derived, because deriving it from `UpstreamRoute` would make
|
||||
/// the test tautological: it would compare the enum against itself. This list is the claim about
|
||||
/// what the app can ask for, and adding a case to the enum without adding it here fails the
|
||||
/// count assertion below.
|
||||
private static let declaredRoutes: [(method: String, path: String)] = [
|
||||
("GET", "/api/health"),
|
||||
("GET", "/api/device"),
|
||||
("GET", "/api/sim"),
|
||||
("GET", "/api/network"),
|
||||
("GET", "/api/network/signal-strength"),
|
||||
("GET", "/api/stats"),
|
||||
("GET", "/api/stats/cpu"),
|
||||
("GET", "/api/connectivity"),
|
||||
("GET", "/api/sms/list"),
|
||||
("POST", "/api/sms/send"),
|
||||
("POST", "/api/sms/batch-delete"),
|
||||
]
|
||||
|
||||
private static let authRoutes: [(method: String, path: String)] = [
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/logout"),
|
||||
]
|
||||
|
||||
private static let candidatePaths = [
|
||||
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
||||
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
||||
].compactMap { $0 }
|
||||
|
||||
private func loadAppRoutes() throws -> [String: Any] {
|
||||
for candidate in Self.candidatePaths {
|
||||
let routes = URL(fileURLWithPath: candidate)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("app-routes.json")
|
||||
guard FileManager.default.fileExists(atPath: routes.path) else { continue }
|
||||
let data = try Data(contentsOf: routes)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XCTSkip("app-routes.json is not a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
throw XCTSkip("simadmin-relay/contracts/app-routes.json not found; set MSA_RELAY_VECTORS")
|
||||
}
|
||||
|
||||
/// Each route in the declared list must produce the path and method it claims. This is what
|
||||
/// catches a path edited in `UpstreamRoute` without the snapshot being updated.
|
||||
func testEveryDeclaredRouteMatchesTheEnum() throws {
|
||||
for (method, expectedPath) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(
|
||||
Self.sampleRoute(matching: expectedPath),
|
||||
"no UpstreamRoute case produces \(expectedPath)"
|
||||
)
|
||||
XCTAssertEqual(route.method, method, "method for \(expectedPath)")
|
||||
// Query strings are part of the path the agent forwards, so compare the prefix the
|
||||
// route's own literal contributes.
|
||||
XCTAssertTrue(
|
||||
route.path.hasPrefix(expectedPath),
|
||||
"route for \(expectedPath) produced \(route.path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthRoutesAreNotProxied() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let auth = try XCTUnwrap(routes["auth"] as? [String])
|
||||
let declared = Set(Self.authRoutes.map(\.path))
|
||||
XCTAssertEqual(Set(auth), declared, "the vendored auth list drifted")
|
||||
|
||||
// The agent must do the logging in itself, so these paths must never appear in the proxied
|
||||
// set — that is what keeps a node password from ever traversing the relay.
|
||||
let proxied = Set(Self.declaredRoutes.map(\.path))
|
||||
XCTAssertTrue(proxied.isDisjoint(with: declared))
|
||||
}
|
||||
|
||||
func testTheVendoredFileMatchesThisList() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let vendored = try XCTUnwrap(routes["routes"] as? [[String: String]])
|
||||
let vendoredPairs = Set(vendored.compactMap { entry -> String? in
|
||||
guard let method = entry["method"], let path = entry["path"] else { return nil }
|
||||
return "\(method) \(path)"
|
||||
})
|
||||
let declaredPairs = Set(Self.declaredRoutes.map { "\($0.method) \($0.path)" })
|
||||
XCTAssertEqual(
|
||||
vendoredPairs.symmetricDifference(declaredPairs),
|
||||
[],
|
||||
"app-routes.json drifted from the app's route surface; regenerate it deliberately"
|
||||
)
|
||||
}
|
||||
|
||||
/// The app's copy of `policy.dangerous` must match the agent's, or a write would either be
|
||||
/// refused for a missing intent or would carry an intent the agent does not require.
|
||||
func testConfirmationIsRequiredExactlyWhereTheAgentRequiresIt() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
// The agent policy lives beside the app routes; the relay repository's policy test already
|
||||
// asserts the two files agree, so reading the app-routes list here is enough.
|
||||
for (method, path) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(Self.sampleRoute(matching: path))
|
||||
let isWrite = method == "POST" && path.hasPrefix("/api/sms/")
|
||||
XCTAssertEqual(
|
||||
route.requiresConfirmation,
|
||||
isWrite,
|
||||
"confirmation intent flag for \(method) \(path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the representative `UpstreamRoute` for a path. The sample arguments are arbitrary:
|
||||
/// what is under test is the path and method mapping, not the query values.
|
||||
private static func sampleRoute(matching path: String) -> UpstreamRoute? {
|
||||
switch path {
|
||||
case "/api/health": return .health
|
||||
case "/api/device": return .device
|
||||
case "/api/sim": return .sim
|
||||
case "/api/network": return .network
|
||||
case "/api/network/signal-strength": return .signalStrength
|
||||
case "/api/stats": return .stats
|
||||
case "/api/stats/cpu": return .statsCpu
|
||||
case "/api/connectivity": return .connectivity
|
||||
case "/api/sms/list": return .smsList(limit: 50, offset: 0, direction: nil)
|
||||
case "/api/sms/send": return .smsSend(phoneNumber: "+8613800000000", content: "hi")
|
||||
case "/api/sms/batch-delete": return .smsBatchDelete(ids: [1])
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ final class DeviceServiceTests: XCTestCase {
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let snapshot = try await service.snapshot(instance)
|
||||
|
||||
@@ -27,7 +27,7 @@ final class DeviceServiceTests: XCTestCase {
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let summary = try await service.summary(instance)
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ final class FleetViewModelTests: XCTestCase {
|
||||
|
||||
try await model.update(row.id, InstanceInput(
|
||||
name: "Van Pro",
|
||||
origin: row.instance.origin,
|
||||
endpoint: row.instance.endpoint,
|
||||
tags: ["field"]
|
||||
))
|
||||
|
||||
@@ -318,7 +318,7 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
endpoint: input.endpoint,
|
||||
tags: input.tags,
|
||||
credentialConfigured: !(input.password ?? "").isEmpty,
|
||||
configRevision: 1,
|
||||
@@ -338,7 +338,7 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
}
|
||||
var instance = items[index]
|
||||
if let name = patch.name { instance.name = name }
|
||||
if let origin = patch.origin { instance.origin = origin }
|
||||
if let endpoint = patch.endpoint { instance.endpoint = endpoint }
|
||||
if let tags = patch.tags { instance.tags = tags }
|
||||
if let password = patch.password {
|
||||
instance.credentialConfigured = !password.isEmpty
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// The upgrade path from a node list written before relay support existed.
|
||||
///
|
||||
/// This is the riskiest change in the iOS work: `Instance.origin` was non-optional and carried three
|
||||
/// responsibilities at once, and a decoder that mishandles the old shape does not fail one screen —
|
||||
/// it fails the whole store, and every node the user ever added disappears. So the old JSON is
|
||||
/// pinned literally here rather than produced by an encoder that might itself have drifted.
|
||||
final class InstanceEndpointMigrationTests: XCTestCase {
|
||||
private var location: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("msa-migration-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: location)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// Exactly the shape the previous release wrote: `origin` as a string, no `endpoint` key.
|
||||
private func legacyStore() -> Data {
|
||||
Data("""
|
||||
{
|
||||
"instances" : [
|
||||
{
|
||||
"configRevision" : 3,
|
||||
"createdAt" : "2026-08-01T10:00:00Z",
|
||||
"credentialConfigured" : true,
|
||||
"id" : "11111111-2222-4333-8444-555555555555",
|
||||
"name" : "Lab Alpha",
|
||||
"origin" : "http://192.168.3.55:3000",
|
||||
"tags" : ["lab"],
|
||||
"updatedAt" : "2026-08-02T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
func testLegacyOriginBecomesADirectEndpoint() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
|
||||
let instances = try await repository.list(InstanceQuery())
|
||||
XCTAssertEqual(instances.count, 1, "a legacy store must not lose nodes")
|
||||
let instance = try XCTUnwrap(instances.first)
|
||||
XCTAssertEqual(instance.endpoint, .direct(URL(string: "http://192.168.3.55:3000")!))
|
||||
XCTAssertEqual(instance.endpoint.directOrigin?.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertFalse(instance.endpoint.isRelay)
|
||||
// Everything else must survive the migration untouched.
|
||||
XCTAssertEqual(instance.name, "Lab Alpha")
|
||||
XCTAssertEqual(instance.tags, ["lab"])
|
||||
XCTAssertEqual(instance.configRevision, 3)
|
||||
XCTAssertTrue(instance.credentialConfigured)
|
||||
}
|
||||
|
||||
/// An upgraded node must not be reported as a duplicate of itself, which is what would happen if
|
||||
/// the dedup key changed shape between the read and the write.
|
||||
func testAnUpgradedLegacyNodeIsNotADuplicateOfItself() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let listed = try await repository.list(InstanceQuery())
|
||||
let existing = try XCTUnwrap(listed.first)
|
||||
|
||||
// Re-saving the same address must still be refused...
|
||||
do {
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Again", origin: URL(string: "http://192.168.3.55:3000")!)
|
||||
)
|
||||
XCTFail("a duplicate origin was accepted")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
// ...but updating the node itself must succeed.
|
||||
let updated = try await repository.update(
|
||||
existing.id,
|
||||
expectedRevision: existing.configRevision,
|
||||
InstancePatch(name: "Renamed")
|
||||
)
|
||||
XCTAssertEqual(updated.name, "Renamed")
|
||||
XCTAssertEqual(updated.endpoint, existing.endpoint)
|
||||
}
|
||||
|
||||
/// After any write, the file carries the new shape and no `origin` key. Leaving both would make
|
||||
/// the old field a second source of truth for the same address.
|
||||
func testWritesEmitOnlyTheNewShape() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let listed = try await repository.list(InstanceQuery())
|
||||
let existing = try XCTUnwrap(listed.first)
|
||||
_ = try await repository.update(
|
||||
existing.id,
|
||||
expectedRevision: existing.configRevision,
|
||||
InstancePatch(tags: ["lab", "primary"])
|
||||
)
|
||||
|
||||
let raw = try String(contentsOf: location, encoding: .utf8)
|
||||
XCTAssertTrue(raw.contains("\"endpoint\""), "the new shape must be written")
|
||||
XCTAssertFalse(raw.contains("\"origin\""), "the legacy key must not be written back")
|
||||
}
|
||||
|
||||
/// The two dedup keyspaces must not collide: a direct node at some address and a relay node are
|
||||
/// different nodes even if a hostname happened to match.
|
||||
func testRelayAndDirectEndpointsAreDistinctIdentities() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Direct", origin: URL(string: "https://relay.example")!)
|
||||
)
|
||||
let relay = try await repository.create(InstanceInput(
|
||||
name: "Relayed",
|
||||
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
))
|
||||
XCTAssertEqual(relay.endpoint.dedupKey, "relay:nd_7f3a9c2e")
|
||||
let all = try await repository.list(InstanceQuery())
|
||||
XCTAssertEqual(all.count, 2)
|
||||
|
||||
// The same node id from the same relay is the same instance, so it is refused.
|
||||
do {
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Again",
|
||||
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
))
|
||||
XCTFail("the same relay node was added twice")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-filled relay endpoint must be refused at the repository rather than accepted and then
|
||||
/// failing in the transport as an unreadable response, which is indistinguishable to the user
|
||||
/// from "the node is broken".
|
||||
func testAnIncompleteRelayEndpointIsRefused() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
var cases: [RelayEndpoint] = []
|
||||
|
||||
var wrongKeySize = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
wrongKeySize.agentSigningKey = Data(repeating: 1, count: 31)
|
||||
cases.append(wrongKeySize)
|
||||
|
||||
var plainHTTP = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
plainHTTP.relayBaseURL = URL(string: "http://relay.example")!
|
||||
cases.append(plainHTTP)
|
||||
|
||||
var badNodeID = Self.relayEndpoint(nodeID: "node-1")
|
||||
badNodeID.nodeID = "node-1"
|
||||
cases.append(badNodeID)
|
||||
|
||||
var badDeviceID = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
badDeviceID.deviceID = "phone"
|
||||
cases.append(badDeviceID)
|
||||
|
||||
var shortToken = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
shortToken.deviceToken = "abc"
|
||||
cases.append(shortToken)
|
||||
|
||||
for (index, endpoint) in cases.enumerated() {
|
||||
do {
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Broken \(index)", endpoint: .relay(endpoint))
|
||||
)
|
||||
XCTFail("incomplete relay endpoint \(index) was accepted")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .invalidOrigin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRelayEndpointRoundTripsThroughTheStore() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let endpoint = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
let created = try await repository.create(
|
||||
InstanceInput(name: "Relayed", endpoint: .relay(endpoint), tags: ["remote"])
|
||||
)
|
||||
|
||||
// A second repository over the same file proves the bytes on disk are what decode, not an
|
||||
// in-memory copy.
|
||||
let reopened = FileInstanceRepository(location: location)
|
||||
let fetched = try await reopened.get(created.id)
|
||||
let loaded = try XCTUnwrap(fetched)
|
||||
XCTAssertEqual(loaded.endpoint, .relay(endpoint))
|
||||
XCTAssertEqual(loaded.endpoint.relay?.agentSigningKey, endpoint.agentSigningKey)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.agentKxKey, endpoint.agentKxKey)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.deviceToken, endpoint.deviceToken)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.nodeName, "Lab Alpha")
|
||||
}
|
||||
|
||||
/// The two modes must not share a credential string: in relay mode the phone holds no node
|
||||
/// password, and saying otherwise is a false statement about where the secret lives.
|
||||
func testCredentialWordingDiffersByMode() {
|
||||
let direct = InstanceEndpoint.direct(URL(string: "http://192.168.1.20:3000")!)
|
||||
let relay = InstanceEndpoint.relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
XCTAssertNotEqual(direct.credentialDescription, relay.credentialDescription)
|
||||
XCTAssertTrue(relay.credentialDescription.contains("agent"))
|
||||
}
|
||||
|
||||
/// Freshness uses the vocabulary that already exists in `packages/contracts`, with the four
|
||||
/// values spelled the same, so the app and the console describe the same state with one word.
|
||||
func testFreshnessUsesTheSharedVocabulary() {
|
||||
XCTAssertEqual(Set(SnapshotFreshness.allCases.map(\.rawValue)),
|
||||
["fresh", "stale", "expired", "unknown"])
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 2), .fresh)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 40), .stale)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 600), .expired)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: nil), .unknown)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: -1), .unknown)
|
||||
}
|
||||
|
||||
static func relayEndpoint(nodeID: String) -> RelayEndpoint {
|
||||
RelayEndpoint(
|
||||
relayBaseURL: URL(string: "https://relay.example")!,
|
||||
nodeID: nodeID,
|
||||
agentSigningKey: Data(repeating: 0xA1, count: 32),
|
||||
agentKxKey: Data(repeating: 0xB2, count: 32),
|
||||
deviceToken: String(repeating: "c3", count: 24),
|
||||
deviceID: "ap_5b1d0000",
|
||||
nodeName: "Lab Alpha"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ final class InstanceRepositoryTests: XCTestCase {
|
||||
|
||||
let created = try await repository.create(input)
|
||||
XCTAssertEqual(created.name, "Lab Alpha")
|
||||
XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.endpoint.displayAddress, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.tags, ["primary", "lab"])
|
||||
XCTAssertEqual(created.configRevision, 1)
|
||||
XCTAssertTrue(created.credentialConfigured)
|
||||
|
||||
@@ -11,12 +11,12 @@ final class InstanceSessionServiceTests: XCTestCase {
|
||||
|
||||
let result = try await service.login(instance, password: "secret")
|
||||
XCTAssertTrue(result.authenticated)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
|
||||
XCTAssertEqual(cookie, "simadmin_session=token")
|
||||
|
||||
let rejected = try await service.login(instance, password: "bad")
|
||||
XCTAssertFalse(rejected.authenticated)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
|
||||
XCTAssertEqual(removed, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ final class MessageServiceTests: XCTestCase {
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let page = try await service.messages([instance], limit: 2)
|
||||
|
||||
@@ -30,7 +30,7 @@ final class MessageServiceTests: XCTestCase {
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
try await service.send(instance, phoneNumber: "13800138000", content: "hello")
|
||||
XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello"))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// Recomputes `contracts/test-vectors.json` in Swift.
|
||||
///
|
||||
/// The Go agent, the Node reference implementation and this app share no code, so agreement here is
|
||||
/// evidence rather than a tautology: three encoders producing the same bytes for the same inputs is
|
||||
/// what makes "the phone signs, the agent verifies" true across a language boundary. A failure in
|
||||
/// this file means the encoding drifted, not that this side is broken.
|
||||
final class RelayWireTests: XCTestCase {
|
||||
/// The vector file lives in the relay repository. Both paths are tried so the test works from
|
||||
/// the app package and from a checkout where the two repositories sit side by side; when
|
||||
/// neither exists the test is skipped rather than failed, because a missing sibling repository
|
||||
/// is not an encoding bug.
|
||||
private static let candidatePaths = [
|
||||
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
||||
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../../simadmin-relay/contracts/test-vectors.json",
|
||||
].compactMap { $0 }
|
||||
|
||||
private func loadVectors() throws -> [String: Any] {
|
||||
for path in Self.candidatePaths where FileManager.default.fileExists(atPath: path) {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XCTSkip("vectors at \(path) are not a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
throw XCTSkip("simadmin-relay/contracts/test-vectors.json not found; set MSA_RELAY_VECTORS")
|
||||
}
|
||||
|
||||
private func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func unhex(_ text: String) -> Data {
|
||||
var out = Data()
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
guard let next = text.index(index, offsetBy: 2, limitedBy: text.endIndex),
|
||||
let byte = UInt8(text[index..<next], radix: 16)
|
||||
else {
|
||||
XCTFail("not hex: \(text)")
|
||||
return Data()
|
||||
}
|
||||
out.append(byte)
|
||||
index = next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// The envelope in the vector file is JSON, so its byte fields are base64 there while the
|
||||
/// key-material sections are hex. Both decoders are needed and mixing them up produces an empty
|
||||
/// Data that fails as a length error three assertions later.
|
||||
private func unbase64(_ text: String) -> Data {
|
||||
guard let data = Data(base64Encoded: text) else {
|
||||
XCTFail("not base64: \(text)")
|
||||
return Data()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// RFC 8032 seed halves, which are the 32-byte raw representations CryptoKit wants. The vector
|
||||
/// file stores the full 64-byte expanded form that Go's ed25519 package uses.
|
||||
private func seed(_ expanded: String) -> Data {
|
||||
unhex(String(expanded.prefix(64)))
|
||||
}
|
||||
|
||||
// MARK: - Envelope encoding
|
||||
|
||||
func testCanonicalBytesAndSignaturesMatchTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let canonical = vectors["canonical"] as? [String: Any],
|
||||
let input = canonical["input"] as? [String: Any],
|
||||
let bytes = canonical["bytes"] as? [String: String],
|
||||
let signatures = canonical["signature"] as? [String: String],
|
||||
let publicKeyHex = canonical["publicKey"] as? String,
|
||||
let responsePublicKeyHex = canonical["responsePublicKey"] as? String
|
||||
else {
|
||||
return XCTFail("the canonical section is missing or malformed")
|
||||
}
|
||||
|
||||
let envelope = RelayWire.Envelope(
|
||||
v: input["v"] as? UInt64 ?? 0,
|
||||
node: input["node"] as? String ?? "",
|
||||
req: input["req"] as? String ?? "",
|
||||
app: input["app"] as? String,
|
||||
ts: input["ts"] as? UInt64 ?? 0,
|
||||
ek: unbase64(input["ek"] as? String ?? ""),
|
||||
alg: input["alg"] as? String ?? "",
|
||||
p: unbase64(input["p"] as? String ?? "")
|
||||
)
|
||||
|
||||
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: false)), bytes["request"])
|
||||
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: true)), bytes["response"])
|
||||
|
||||
let appSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
||||
let agentSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(agentSignKeyHex))
|
||||
|
||||
// The public halves published in the vector file must be the ones these private keys derive,
|
||||
// or the vector proves nothing about key ownership.
|
||||
XCTAssertEqual(hex(appSigningKey.publicKey.rawRepresentation), publicKeyHex)
|
||||
XCTAssertEqual(hex(agentSigningKey.publicKey.rawRepresentation), responsePublicKeyHex)
|
||||
|
||||
// CryptoKit's Ed25519 signing is randomised, unlike Go's and Node's deterministic RFC 8032
|
||||
// implementation: signing the same bytes twice yields two different, equally valid
|
||||
// signatures. So the cross-language check here cannot be "the bytes are equal" — it is the
|
||||
// stronger property that actually matters, which is that each side verifies what the other
|
||||
// produced. Both directions are asserted, because a phone whose signatures the agent
|
||||
// rejected would be a total failure that byte-equality would never have caught either.
|
||||
try RelayWire.verifyRequest(
|
||||
envelopeWithSignature(envelope, unhex(signatures["request"] ?? ""), app: true),
|
||||
publicKey: appSigningKey.publicKey
|
||||
)
|
||||
try RelayWire.verifyResponse(
|
||||
envelopeWithSignature(envelope, unhex(signatures["response"] ?? ""), app: false),
|
||||
publicKey: agentSigningKey.publicKey
|
||||
)
|
||||
|
||||
var signedRequest = envelope
|
||||
try RelayWire.signRequest(&signedRequest, privateKey: appSigningKey)
|
||||
try RelayWire.verifyRequest(signedRequest, publicKey: appSigningKey.publicKey)
|
||||
// And the Go side must accept what this side signed: the vectors cannot prove that from
|
||||
// here, so it is asserted by the agent's own test suite against a signature captured from
|
||||
// a device. What can be checked locally is that the signing input is the vector's bytes.
|
||||
XCTAssertEqual(
|
||||
hex(try RelayWire.canonical(signedRequest, response: false)),
|
||||
bytes["request"],
|
||||
"the phone must sign exactly the bytes the agent verifies"
|
||||
)
|
||||
|
||||
// Domain separation: a request signature must not verify as a response signature.
|
||||
XCTAssertThrowsError(try RelayWire.verifyResponse(signedRequest, publicKey: appSigningKey.publicKey))
|
||||
}
|
||||
|
||||
/// Rebuilds an envelope carrying a signature produced elsewhere, since the response form has no
|
||||
/// `app` field and the request form does.
|
||||
private func envelopeWithSignature(
|
||||
_ envelope: RelayWire.Envelope,
|
||||
_ signature: Data,
|
||||
app: Bool
|
||||
) -> RelayWire.Envelope {
|
||||
var copy = envelope
|
||||
copy.app = app ? envelope.app : nil
|
||||
copy.s = signature
|
||||
return copy
|
||||
}
|
||||
|
||||
/// RFC 8032 test vector private keys, repeated here so the test does not depend on the vector
|
||||
/// file to know whose key is whose.
|
||||
private let privateKeyHex =
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
private let agentSignKeyHex =
|
||||
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"
|
||||
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||
|
||||
func testAOneByteChangeBreaksTheSignature() throws {
|
||||
let envelope = RelayWire.Envelope(
|
||||
node: "nd_7f3a9c2e",
|
||||
req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
app: "ap_5b1d",
|
||||
ts: 1_727_289_600,
|
||||
ek: Data(repeating: 7, count: 32),
|
||||
p: Data([0, 1, 2, 0xFE, 0xFF])
|
||||
)
|
||||
let key = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
||||
var signed = envelope
|
||||
try RelayWire.signRequest(&signed, privateKey: key)
|
||||
try RelayWire.verifyRequest(signed, publicKey: key.publicKey)
|
||||
|
||||
// Retargeting the node is what a compromised relay would try first.
|
||||
var retargeted = signed
|
||||
retargeted.node = "nd_other001"
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(retargeted, publicKey: key.publicKey)) { error in
|
||||
XCTAssertEqual(error as? RelayWire.Failure, .signatureDoesNotVerify)
|
||||
}
|
||||
// Swapping the ciphertext is the second thing it would try.
|
||||
var edited = signed
|
||||
edited.p = Data([9, 9, 9])
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(edited, publicKey: key.publicKey))
|
||||
// The ephemeral public key is covered, so a substituted one cannot be hidden.
|
||||
var swapped = signed
|
||||
swapped.ek = Data(repeating: 3, count: 32)
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(swapped, publicKey: key.publicKey))
|
||||
}
|
||||
|
||||
// MARK: - Key derivation and AEAD
|
||||
|
||||
func testKeyDerivationAndAEADMatchTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let x25519 = vectors["x25519"] as? [String: String],
|
||||
let aead = vectors["aead"] as? [String: String]
|
||||
else {
|
||||
return XCTFail("the x25519 or aead section is missing")
|
||||
}
|
||||
|
||||
let privateKey = try Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: unhex(x25519["appPrivate"] ?? "")
|
||||
)
|
||||
let shared = try RelayWire.sharedSecret(
|
||||
privateKey: privateKey,
|
||||
peerPublic: unhex(x25519["agentPublic"] ?? "")
|
||||
)
|
||||
XCTAssertEqual(hex(shared), x25519["sharedSecret"])
|
||||
|
||||
let request = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: "nd_7f3a9c2e",
|
||||
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
response: false
|
||||
)
|
||||
XCTAssertEqual(hex(request.key.withUnsafeBytes { Data($0) }), x25519["requestKey"])
|
||||
XCTAssertEqual(hex(request.iv), x25519["requestIV"])
|
||||
|
||||
let response = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: "nd_7f3a9c2e",
|
||||
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
response: true
|
||||
)
|
||||
XCTAssertEqual(hex(response.key.withUnsafeBytes { Data($0) }), x25519["responseKey"])
|
||||
XCTAssertEqual(hex(response.iv), x25519["responseIV"])
|
||||
// Direction separation: the same shared secret must not produce the same key or nonce.
|
||||
XCTAssertNotEqual(x25519["requestKey"], x25519["responseKey"])
|
||||
XCTAssertNotEqual(x25519["requestIV"], x25519["responseIV"])
|
||||
|
||||
let sealed = try RelayWire.seal(
|
||||
unhex(aead["plaintext"] ?? ""),
|
||||
key: request.key,
|
||||
iv: request.iv
|
||||
)
|
||||
XCTAssertEqual(hex(sealed), aead["ciphertext"])
|
||||
let opened = try RelayWire.open(sealed, key: request.key, iv: request.iv)
|
||||
XCTAssertEqual(hex(opened), aead["plaintext"])
|
||||
}
|
||||
|
||||
func testHKDFMatchesRFC5869() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let hkdf = vectors["hkdf"] as? [String: Any] else {
|
||||
return XCTFail("the hkdf section is missing")
|
||||
}
|
||||
let output = try RelayWire.hkdf(
|
||||
secret: unhex(hkdf["secret"] as? String ?? ""),
|
||||
salt: unhex(hkdf["salt"] as? String ?? ""),
|
||||
info: unhex(hkdf["info"] as? String ?? ""),
|
||||
length: hkdf["length"] as? Int ?? 0
|
||||
)
|
||||
XCTAssertEqual(hex(output), hkdf["output"] as? String)
|
||||
}
|
||||
|
||||
/// A tampered ciphertext must fail to authenticate rather than decrypt to something.
|
||||
func testATamperedCiphertextDoesNotOpen() throws {
|
||||
let key = SymmetricKey(data: Data(repeating: 1, count: 32))
|
||||
let iv = Data(repeating: 2, count: 12)
|
||||
var sealed = try RelayWire.seal(Data("hello".utf8), key: key, iv: iv)
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
XCTAssertThrowsError(try RelayWire.open(sealed, key: key, iv: iv)) { error in
|
||||
XCTAssertEqual(error as? RelayWire.Failure, .ciphertextFailedToAuthenticate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Intent digest
|
||||
|
||||
func testIntentDigestMatchesTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let intent = vectors["intent"] as? [String: String] else {
|
||||
return XCTFail("the intent section is missing")
|
||||
}
|
||||
let digest = try RelayWire.intentDigest(
|
||||
method: intent["method"] ?? "",
|
||||
path: intent["path"] ?? "",
|
||||
body: Data((intent["body"] ?? "").utf8)
|
||||
)
|
||||
XCTAssertEqual(hex(digest), intent["digest"])
|
||||
|
||||
// Formatting must not matter; content must.
|
||||
let reordered = try RelayWire.intentDigest(
|
||||
method: "post",
|
||||
path: "/api/sms/send",
|
||||
body: Data(#"{"content":"hello","phone_number":"+8613800000000"}"#.utf8)
|
||||
)
|
||||
XCTAssertEqual(hex(reordered), intent["digest"])
|
||||
let edited = try RelayWire.intentDigest(
|
||||
method: "POST",
|
||||
path: "/api/sms/send",
|
||||
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
|
||||
)
|
||||
XCTAssertNotEqual(hex(edited), intent["digest"])
|
||||
}
|
||||
|
||||
// MARK: - Canonical JSON
|
||||
|
||||
func testCanonicalJSONMatchesTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let pairs = vectors["canonicalJson"] as? [[String: String]] else {
|
||||
return XCTFail("the canonicalJson section is missing")
|
||||
}
|
||||
for pair in pairs {
|
||||
let input = pair["input"] ?? ""
|
||||
let expected = pair["output"] ?? ""
|
||||
let got = try CanonicalJSON.canonicalize(input)
|
||||
XCTAssertEqual(String(decoding: got, as: UTF8.self), expected, "canonical JSON for \(input)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCanonicalJSONRejectsEveryInputTheVectorsMarkInvalid() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let rejected = vectors["canonicalJsonRejected"] as? [String] else {
|
||||
return XCTFail("the canonicalJsonRejected section is missing")
|
||||
}
|
||||
for input in rejected {
|
||||
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCanonicalJSONRejectsWhatTheProtocolWillNotHash() {
|
||||
for input in ["{", #"{"a":1} trailing"#, #"{"a":+1}"#, #"{"a":.5}"#, #"{"a":NaN}"#,
|
||||
#"{"a":1.0}"#, #"{"a":1e3}"#, #"{"a":01}"#, #"{"a":-0}"#] {
|
||||
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
||||
}
|
||||
XCTAssertEqual(try CanonicalJSON.canonicalize(""), Data())
|
||||
XCTAssertEqual(try CanonicalJSON.canonicalize(" "), Data())
|
||||
}
|
||||
|
||||
/// The three key-sort shortcuts disagree, so this pins the one the protocol uses: UTF-8 byte
|
||||
/// order. Swift's default `String` comparison uses Unicode collation, which would put "é"
|
||||
/// before "z"; Foundation's `.sortedKeys` does the same.
|
||||
func testCanonicalJSONSortsKeysByUTF8Bytes() throws {
|
||||
XCTAssertEqual(
|
||||
String(decoding: try CanonicalJSON.canonicalize(#"{"é":1,"z":2}"#), as: UTF8.self),
|
||||
#"{"z":2,"é":1}"#
|
||||
)
|
||||
XCTAssertEqual(
|
||||
String(decoding: try CanonicalJSON.canonicalize("{\"\u{10000}\":1,\"\u{FFFD}\":2}"), as: UTF8.self),
|
||||
"{\"\u{FFFD}\":2,\"\u{10000}\":1}"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user