From 3f6090081f920117ccf17175356bb33e92fac71a Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 16 Sep 2026 18:57:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20=E4=B8=AD=E7=BB=A7=E9=80=9A?= =?UTF-8?q?=E9=81=93=20P4/P5=E2=80=94=E2=80=94=E7=AB=AF=E7=82=B9=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E3=80=81=E4=BF=A1=E5=B0=81=E3=80=81=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E3=80=81=E9=85=8D=E5=AF=B9=E4=B8=8E=E5=88=86=E5=B1=82=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:) - 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)、配对四类失败、路由器、 传输往返与各类拒绝路径 --- .../Features/Device/DeviceService.swift | 38 +- .../Features/Device/DeviceViewModel.swift | 4 +- .../Features/Device/InstanceDetailView.swift | 6 +- .../Features/Fleet/FleetView.swift | 53 +- .../Features/Fleet/FleetViewModel.swift | 19 +- .../Features/Fleet/InstanceFormView.swift | 63 ++- .../Features/Fleet/RelayPairingView.swift | 114 ++++ .../Fleet/RelayPairingViewModel.swift | 106 ++++ .../Features/Logs/LogCenterService.swift | 51 +- .../Features/Messages/MessageService.swift | 38 +- .../Features/Messages/MessagesViewModel.swift | 6 +- .../MultiSimAdmin/Models/InstanceModels.swift | 123 ++++- .../MultiSimAdmin/MultiSimAdminApp.swift | 2 +- .../Persistence/FileInstanceRepository.swift | 51 +- .../MultiSimAdmin/Relay/CanonicalJSON.swift | 348 ++++++++++++ .../MultiSimAdmin/Relay/DeviceIdentity.swift | 146 +++++ .../Relay/InstanceEndpoint.swift | 279 ++++++++++ .../MultiSimAdmin/Relay/RelayPairing.swift | 125 +++++ .../MultiSimAdmin/Relay/RelayTransport.swift | 366 +++++++++++++ .../MultiSimAdmin/Relay/RelayWire.swift | 380 +++++++++++++ .../Services/ConnectionProbe.swift | 89 ++- .../Services/DefaultContainer.swift | 64 ++- .../Services/InstanceSessionService.swift | 22 +- .../MultiSimAdmin/Support/ErrorMessage.swift | 59 ++ .../Upstream/UpstreamRoute.swift | 57 ++ .../AppRoutesContractTests.swift | 142 +++++ .../DeviceServiceTests.swift | 4 +- .../FleetViewModelTests.swift | 6 +- .../InstanceEndpointMigrationTests.swift | 225 ++++++++ .../InstanceRepositoryTests.swift | 2 +- .../InstanceSessionServiceTests.swift | 4 +- .../MessageServiceTests.swift | 4 +- .../RelayPairingTests.swift | 343 ++++++++++++ .../RelayTransportTests.swift | 506 ++++++++++++++++++ .../MultiSimAdminTests/RelayWireTests.swift | 342 ++++++++++++ docs/ios-relay-architecture.md | 21 + scripts/export-app-routes.swift | 120 +++++ 37 files changed, 4129 insertions(+), 199 deletions(-) create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingView.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingViewModel.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/CanonicalJSON.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/DeviceIdentity.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/InstanceEndpoint.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/RelayPairing.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/RelayTransport.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Relay/RelayWire.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/AppRoutesContractTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/InstanceEndpointMigrationTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/RelayPairingTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/RelayTransportTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/RelayWireTests.swift create mode 100644 scripts/export-app-routes.swift diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift index 048cd85..c556ba5 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceService.swift @@ -1,18 +1,19 @@ import Foundation public struct DeviceService: Sendable { - private let client: any SimAdminClientProtocol - private let sessions: InstanceSessionStore - private let credentials: (any CredentialStore)? + private let transport: any NodeTransport + public init(transport: any NodeTransport) { + self.transport = transport + } + + /// Direct-mode construction, kept for the existing call sites and tests. public init( client: any SimAdminClientProtocol, sessions: InstanceSessionStore, credentials: (any CredentialStore)? = nil ) { - self.client = client - self.sessions = sessions - self.credentials = credentials + self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials)) } public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot { @@ -76,30 +77,7 @@ public struct DeviceService: Sendable { } private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { - var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) - do { - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } catch UpstreamError.unauthorized, UpstreamError.forbidden { - cookie = try await login(instance) - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } - } - - private func login(_ instance: Instance) async throws -> String { - guard let credentials, - let password = try credentials.get(instanceID: instance.id), - !password.isEmpty - else { - throw DeviceServiceError.unauthorized - } - let sessionService = InstanceSessionService(store: sessions, client: client) - let result = try await sessionService.login(instance, password: password) - guard result.authenticated, - let cookie = await sessionService.cookie(for: instance) - else { - throw DeviceServiceError.unauthorized - } - return cookie + try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint) } static func dataObject(_ data: Data) throws -> [String: Any] { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift index 1e0342c..f468928 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/DeviceViewModel.swift @@ -22,8 +22,10 @@ final class DeviceViewModel: ObservableObject { errorMessage = nil do { snapshot = try await service.snapshot(instance) - } catch DeviceServiceError.unauthorized { + } catch DeviceServiceError.unauthorized, UpstreamError.unauthorized { errorMessage = "需要实例密码登录后才能读取设备数据。" + } catch let error as UpstreamError { + errorMessage = ErrorMessage.describe(error, nodeName: instance.name) } catch { errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。" } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 7bf1bdf..7ba5332 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -93,9 +93,9 @@ struct InstanceDetailView: View { AICard { VStack(alignment: .leading, spacing: 12) { AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3") - AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true) + AIKeyValueRow("地址", value: model.instance.endpoint.displayAddress, monospaced: true) AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、")) - AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置") + AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? model.instance.endpoint.credentialDescription : "未设置") } } @@ -112,7 +112,7 @@ struct InstanceDetailView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AITheme.muted) .accessibilityHidden(true) - Text(model.instance.origin.absoluteString) + Text(model.instance.endpoint.displayAddress) .font(.footnote.weight(.medium)) .monospacedDigit() .foregroundStyle(AITheme.ink) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index e1767bf..98f1d04 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -5,6 +5,8 @@ struct FleetView: View { @State private var showsDeleteDialog = false @State private var editingInstance: Instance? @State private var isSummaryCollapsed = true + @State private var showingPairing = false + @StateObject private var pairingModel: RelayPairingViewModel init(model: FleetViewModel? = nil) { // StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上, // 所以调试开关必须先落到这个具体的 model 对象。 @@ -32,6 +34,11 @@ struct FleetView: View { } #endif _model = StateObject(wrappedValue: target) + // 配对视图模型与列表共用同一个仓库,否则配对成功后列表看不到新节点。 + _pairingModel = StateObject(wrappedValue: RelayPairingViewModel( + repository: DefaultContainer.makeInstanceRepository(), + identities: DefaultContainer.deviceIdentities + )) } var body: some View { @@ -134,6 +141,11 @@ struct FleetView: View { } .disabled(model.visibleRows.isEmpty) + AIIconButton("配对中继节点", systemImage: "link.badge.plus") { + showingPairing = true + } + .disabled(model.isLoading) + AIIconButton("添加实例", systemImage: "plus") { model.showingForm = true } @@ -150,6 +162,9 @@ struct FleetView: View { instance: editingInstance ) } + .sheet(isPresented: $showingPairing) { + RelayPairingView(model: pairingModel, fleet: model) + } .onChange(of: model.showingForm) { showing in if !showing { editingInstance = nil @@ -839,11 +854,20 @@ private extension FleetView { tint: statusColor(row.status) ) } - Text(row.instance.origin.absoluteString) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(1) - .truncationMode(.middle) + HStack(spacing: 6) { + Text(row.instance.endpoint.displayAddress) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + // 通道徽标:没有它,"这台为什么慢"无法回答。 + channelBadge(row.channel) + if !row.testing, row.freshness != .fresh { + Text(row.freshness.label) + .font(.caption2) + .foregroundStyle(AITheme.muted) + } + } } // 四条量表把"要不要担心这台"变成一次扫视:信号、延迟、CPU、内存。 @@ -1039,16 +1063,35 @@ private extension FleetView { } /// 只解释"为什么不是正常状态"。在线节点的细节交给指标栅格,上游原词(online 之类)不外泄。 + /// 通道徽标是刻意低调的:它解释延迟来源,不是需要用户处理的状态。 + private func channelBadge(_ channel: InstanceChannel) -> some View { + Text(channel.label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(AITheme.muted) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(AITheme.canvas, in: Capsule()) + .overlay(Capsule().stroke(AITheme.line, lineWidth: 1)) + .accessibilityLabel(channel == .relay ? "经由中继" : "内网直通") + } + private func statusLine(_ row: FleetViewModel.Row) -> String? { if row.testing { return "正在读取运行信息" } switch row.status { case .online: return nil case .degraded: + // 中继模式下"部分接口不可用"可能有具体得多的原因,优先给能照做的说法。 + if let error = row.probeError { + return ErrorMessage.describe(error, nodeName: row.instance.name) + } return "部分接口不可用,可在详情查看失败接口" case .authRequired: return "节点开启了访问密码,进入详情登录" case .offline: + if let error = row.probeError { + return ErrorMessage.describe(error, nodeName: row.instance.name) + } return "节点不可达,最后结果已过期" case .unknown: return "尚未检测" diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift index 27cfbfd..f558169 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift @@ -29,6 +29,19 @@ final class FleetViewModel: ObservableObject { var uptimeSeconds: Double? var chainValues: DeviceLinkState? var testing: Bool + /// Why the last probe ended where it did, for the detail text. + var probeError: UpstreamError? + /// When the last probe finished. The list shows how old a reading is, which in relay mode is + /// the difference between "online" meaning now and meaning forty seconds ago. + var checkedAt: Date? + + /// Which path this node is reached by. The badge exists because without it "this one is + /// slow" has no answer that a user can act on. + var channel: InstanceChannel { instance.endpoint.channel } + + var freshness: SnapshotFreshness { + testing ? .unknown : SnapshotFreshness.classify(age: checkedAt.map { Date().timeIntervalSince($0) }) + } var storageUsageRatio: Double? { guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else { @@ -143,7 +156,7 @@ final class FleetViewModel: ObservableObject { expectedRevision: original.instance.configRevision, InstancePatch( name: input.name, - origin: input.origin, + endpoint: input.endpoint, tags: input.tags, password: input.password ) @@ -215,6 +228,8 @@ final class FleetViewModel: ObservableObject { ) { guard let index = rows.firstIndex(where: { $0.id == id }) else { return } rows[index].status = result?.status ?? .unknown + rows[index].probeError = result?.error + rows[index].checkedAt = result?.checkedAt if let summary { rows[index].deviceName = summary.deviceName rows[index].phoneNumber = summary.phoneNumber @@ -260,7 +275,7 @@ final class FleetViewModel: ObservableObject { guard !searchText.isEmpty else { return true } let haystacks: [String] = [ row.instance.name, - row.instance.origin.absoluteString, + row.instance.endpoint.displayAddress, row.instance.tags.joined(separator: " "), row.operatorName ?? "", row.phoneNumber ?? "", diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index a10d805..a9f91ba 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -16,13 +16,18 @@ struct InstanceFormView: View { self.model = model self.instance = instance _name = State(initialValue: instance?.name ?? "") - _originText = State(initialValue: instance?.origin.absoluteString ?? "") + _originText = State(initialValue: instance?.endpoint.displayAddress ?? "") _tags = State(initialValue: instance?.tags ?? []) } + /// A relay instance is edited by name and tags only. Its address is not a URL the user typed — + /// it is a pairing, and letting the form rewrite it would either drop the node's keys or produce + /// an endpoint that fails validation. Re-pairing is the way to change it. + private var isRelay: Bool { instance?.endpoint.isRelay == true } + private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (isRelay || !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } var body: some View { @@ -32,16 +37,29 @@ struct InstanceFormView: View { AICard { VStack(alignment: .leading, spacing: 14) { field("名称", text: $name, placeholder: "Home Node") - field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") + if isRelay { + relayChannelRow + } else { + field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") + } tagEditor - field( - "实例密码", - text: $password, - placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码", - secure: true - ) + if !isRelay { + field( + "实例密码", + text: $password, + placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码", + secure: true + ) + } } } + if isRelay { + AIStatusBanner( + "中继节点的密码保存在节点 agent 上,手机不持有;更换中继需要重新配对。", + tint: AITheme.muted, + systemImage: "lock.shield" + ) + } if let validationMessage { AIStatusBanner( validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") @@ -81,6 +99,28 @@ struct InstanceFormView: View { } } + /// The relay channel is read-only here, but it must still be visible: without it the form shows + /// a node with no address at all, which reads as missing data rather than as a different mode. + private var relayChannelRow: some View { + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel("通道") + HStack(spacing: 8) { + Text("中继") + .font(.caption.weight(.semibold)) + .foregroundStyle(AITheme.muted) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(AITheme.canvas, in: Capsule()) + Text(originText.isEmpty ? "已配对节点" : originText) + .font(.footnote) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + .truncationMode(.middle) + } + } + .accessibilityElement(children: .combine) + } + private var tagEditor: some View { VStack(alignment: .leading, spacing: 6) { AIFieldLabel("标签") @@ -128,9 +168,12 @@ struct InstanceFormView: View { isSaving = false return } + // An existing relay instance keeps its endpoint: the address field only edits a direct + // origin, and rewriting a pairing into a direct one here would silently drop the node's keys. + let endpoint = instance?.endpoint.isRelay == true ? instance!.endpoint : InstanceEndpoint.direct(origin) let input = InstanceInput( name: normalizedName, - origin: origin, + endpoint: endpoint, tags: tags, password: password.isEmpty ? nil : password ) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingView.swift new file mode 100644 index 0000000..7adc765 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +/// Pairs this phone with a node over the LAN. +/// +/// The flow needs the phone to be inside the network, which is why it is a separate sheet rather +/// than part of the instance form: the address here is the node's LAN address, not the relay's, and +/// the password is the node's own — the one fact the pairing flow leans on. +struct RelayPairingView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: RelayPairingViewModel + @ObservedObject var fleet: FleetViewModel + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 12) { + AICard { + VStack(alignment: .leading, spacing: 14) { + field("节点内网地址", text: $model.host, placeholder: "192.168.1.10") + field("节点访问密码", text: $model.password, placeholder: "SimAdmin 密码", secure: true) + field("显示名称", text: $model.name, placeholder: "可留空,默认使用节点 ID") + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel("标签") + TagEditorView(tags: $model.tags, suggestions: fleet.allTags) + } + } + } + + AIStatusBanner( + "配对需要手机与节点在同一内网。节点上先运行一次 msa-agent --pair,配对成功后它的管理端口会自动关闭。", + tint: AITheme.muted, + systemImage: "wifi" + ) + + if let message = model.errorMessage { + AIStatusBanner(message, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + if case .done(let nodeID) = model.stage { + AIStatusBanner( + "配对完成,节点 \(nodeID) 已加入列表。", + tint: AITheme.success, + systemImage: "checkmark.circle" + ) + } + } + .padding(16) + .padding(.bottom, 8) + } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("配对中继节点") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.interactively) + #endif + .aiBottomActionBar { + AIActionRow { + Button(model.isDone ? "完成" : "取消") { + if model.isDone { + Task { await fleet.refresh() } + } + dismiss() + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) + + if !model.isDone { + Button { + Task { await model.pair() } + } label: { + HStack(spacing: 8) { + if model.stage == .pairing { + ProgressView().controlSize(.small) + } + Text(model.stage == .pairing ? "配对中" : "开始配对") + } + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(!model.canSubmit) + } + } + } + } + } + + private func field( + _ label: String, + text: Binding, + placeholder: String, + secure: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel(label) + if secure { + SecureField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.body) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } else { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.body) + #if os(iOS) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(AITheme.control, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingViewModel.swift new file mode 100644 index 0000000..897062e --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/RelayPairingViewModel.swift @@ -0,0 +1,106 @@ +import CryptoKit +import Foundation +import SwiftUI + +/// Drives the pairing sheet: collect the node's LAN address and password, run the pairing exchange, +/// and hand the result to the repository as a new instance. +/// +/// The pairing window on the node closes after one success, so a failure here is not merely "try +/// again later": it may mean the window is shut and the operator has to re-open it on the node. The +/// error text says which of the two happened, because the two call for different actions. +@MainActor +final class RelayPairingViewModel: ObservableObject { + enum Stage: Equatable { + case idle + case pairing + case done(nodeID: String) + } + + @Published var host = "" + @Published var password = "" + @Published var name = "" + @Published var tags: [String] = [] + @Published var stage: Stage = .idle + @Published var errorMessage: String? + + private let pairing: RelayPairing + private let identities: FileDeviceIdentityStore + private let repository: any InstanceRepository + + init( + repository: any InstanceRepository, + identities: FileDeviceIdentityStore, + pairing: RelayPairing = RelayPairing() + ) { + self.repository = repository + self.identities = identities + self.pairing = pairing + } + + var canSubmit: Bool { + !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !password.isEmpty + && stage != .pairing + } + + var isDone: Bool { + if case .done = stage { return true } + return false + } + + func pair() async { + guard canSubmit else { return } + errorMessage = nil + stage = .pairing + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = name.trimmingCharacters(in: .whitespacesAndNewlines) + do { + // The identity is created on first use: the phone needs keys before it can pair, but it + // cannot know its device id until the node assigns one. + let identity = try identities.loadOrCreate() + let result = try await pairing.pair( + host: trimmedHost, + password: password, + deviceName: displayName.isEmpty ? trimmedHost : displayName, + identity: identity + ) + // The id the node assigned has to be recorded before any request is sent, because it is + // signed into every envelope and the node verifies it against its own device list. + _ = try identities.assignDeviceID(result.deviceID) + let instance = try await repository.create(InstanceInput( + name: displayName.isEmpty ? (result.endpoint.nodeName ?? result.endpoint.nodeID) : displayName, + endpoint: .relay(result.endpoint), + tags: tags + )) + _ = instance + stage = .done(nodeID: result.endpoint.nodeID) + password = "" + } catch let failure as RelayPairing.Failure { + stage = .idle + errorMessage = Self.describe(failure, host: trimmedHost) + } catch let error as RepositoryError { + stage = .idle + errorMessage = error == .duplicateOrigin + ? "该节点已经在中继列表中,无需重复配对。" + : "配对成功但保存失败,请重试。" + } catch { + stage = .idle + errorMessage = "配对失败:\(error)" + } + } + + /// The pairing failure vocabulary is small and each case has one obvious next step, so the text + /// states the step rather than the condition. + static func describe(_ failure: RelayPairing.Failure, host: String) -> String { + switch failure { + case .rejected: + return "节点密码不正确。请确认使用的是该节点 SimAdmin 的访问密码。" + case .windowClosed: + return "节点上的配对窗口已关闭。请在节点上重新运行一次配对(msa-agent --pair)后重试。" + case .unreachable: + return "无法连接 \(host):3001。请确认手机与节点在同一内网,且节点上已启动配对。" + case .malformedResponse: + return "节点返回的内容不是有效的配对响应。请确认节点上运行的是本方案的 agent。" + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift index 8e1eb33..7aeb22b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift @@ -19,21 +19,25 @@ public struct LogCenterService: Sendable { var diagnostic: InstanceDiagnostic? } - private let client: any SimAdminClientProtocol - private let sessions: InstanceSessionStore - private let credentials: (any CredentialStore)? + private let transport: any NodeTransport private let store: any LogStore + public init(transport: any NodeTransport, store: any LogStore) { + self.transport = transport + self.store = store + } + + /// Direct-mode construction, kept for the existing call sites and tests. public init( client: any SimAdminClientProtocol, sessions: InstanceSessionStore, credentials: (any CredentialStore)? = nil, store: any LogStore ) { - self.client = client - self.sessions = sessions - self.credentials = credentials - self.store = store + self.init( + transport: DirectTransport(client: client, sessions: sessions, credentials: credentials), + store: store + ) } public func refresh(_ instances: [Instance]) async throws -> LogSnapshot { @@ -115,7 +119,11 @@ public struct LogCenterService: Sendable { private func probe(_ instance: Instance) async -> ProbeOutcome { let startedAt = Date() do { - let response = try await client.send(.health, baseURL: instance.origin, cookie: nil) + let response = try await transport.send( + .health, + instanceID: instance.id, + endpoint: instance.endpoint + ) let duration = elapsed(startedAt) let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:] return ProbeOutcome( @@ -207,7 +215,7 @@ public struct LogCenterService: Sendable { return InstanceDiagnostic( id: instance.id, name: instance.name, - origin: instance.origin.absoluteString, + origin: instance.endpoint.displayAddress, tags: instance.tags, state: probe.status, healthStatus: Self.healthLabel(probe), @@ -226,30 +234,7 @@ public struct LogCenterService: Sendable { } private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { - var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) - do { - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } catch UpstreamError.unauthorized, UpstreamError.forbidden { - cookie = try await login(instance) - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } - } - - private func login(_ instance: Instance) async throws -> String { - guard let credentials, - let password = try credentials.get(instanceID: instance.id), - !password.isEmpty - else { - throw UpstreamError.unauthorized - } - let service = InstanceSessionService(store: sessions, client: client) - let result = try await service.login(instance, password: password) - guard result.authenticated, - let cookie = await sessions.cookie(for: instance.id, origin: instance.origin) - else { - throw UpstreamError.unauthorized - } - return cookie + try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint) } private func elapsed(_ startedAt: Date) -> Int { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift index 3fd2586..3dc8e8c 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessageService.swift @@ -7,18 +7,19 @@ public enum MessageServiceError: Error, Equatable { } public struct MessageService: Sendable { - private let client: any SimAdminClientProtocol - private let sessions: InstanceSessionStore - private let credentials: (any CredentialStore)? + private let transport: any NodeTransport + public init(transport: any NodeTransport) { + self.transport = transport + } + + /// Direct-mode construction, kept for the existing call sites and tests. public init( client: any SimAdminClientProtocol, sessions: InstanceSessionStore, credentials: (any CredentialStore)? = nil ) { - self.client = client - self.sessions = sessions - self.credentials = credentials + self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials)) } public func messages( @@ -75,30 +76,7 @@ public struct MessageService: Sendable { } private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse { - var cookie = await sessions.cookie(for: instance.id, origin: instance.origin) - do { - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } catch UpstreamError.unauthorized, UpstreamError.forbidden { - cookie = try await login(instance) - return try await client.send(route, baseURL: instance.origin, cookie: cookie) - } - } - - private func login(_ instance: Instance) async throws -> String { - guard let credentials, - let password = try credentials.get(instanceID: instance.id), - !password.isEmpty - else { - throw MessageServiceError.unauthorized - } - let sessions = InstanceSessionService(store: sessions, client: client) - let result = try await sessions.login(instance, password: password) - guard result.authenticated, - let cookie = await sessions.cookie(for: instance) - else { - throw MessageServiceError.unauthorized - } - return cookie + try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint) } static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift index 3173d9c..a290ecb 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesViewModel.swift @@ -60,8 +60,12 @@ final class MessagesViewModel: ObservableObject { } let page = try await service.messages(loaded, limit: 100) rows = page.messages - } catch MessageServiceError.unauthorized { + } catch MessageServiceError.unauthorized, UpstreamError.unauthorized { errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。" + } catch let error as UpstreamError { + // 中继模式下失败原因有四类以上,各自对应不同处理动作; + // 统一兜底文案会把"重试即可"和"需要重新配对"说成同一件事。 + errorMessage = ErrorMessage.describe(error, nodeName: selectedInstance?.name ?? "节点") } catch { errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。" } diff --git a/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift index 62962f0..5698858 100644 --- a/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift @@ -11,13 +11,39 @@ public enum RepositoryError: Error, Equatable, Sendable { public struct Instance: Codable, Equatable, Identifiable, Sendable { public let id: UUID public var name: String - public var origin: URL + /// Where this node is reached from. Direct on the local network, or through a relay. + /// + /// This replaced a non-optional `origin: URL`. The migration in `init(from:)` reads the old + /// shape so an existing node list upgrades without being reported as duplicate, and every write + /// emits only the new shape. + public var endpoint: InstanceEndpoint public var tags: [String] public var credentialConfigured: Bool public var configRevision: Int public let createdAt: Date public var updatedAt: Date + public init( + id: UUID, + name: String, + endpoint: InstanceEndpoint, + tags: [String] = [], + credentialConfigured: Bool = false, + configRevision: Int = 1, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.name = name + self.endpoint = endpoint + self.tags = tags + self.credentialConfigured = credentialConfigured + self.configRevision = configRevision + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convenience for the direct case, which is what most call sites mean. public init( id: UUID, name: String, @@ -28,50 +54,113 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable { createdAt: Date, updatedAt: Date ) { - self.id = id - self.name = name - self.origin = origin - self.tags = tags - self.credentialConfigured = credentialConfigured - self.configRevision = configRevision - self.createdAt = createdAt - self.updatedAt = updatedAt + self.init( + id: id, + name: name, + endpoint: .direct(origin), + tags: tags, + credentialConfigured: credentialConfigured, + configRevision: configRevision, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + /// The direct origin, when this instance has one. + public var origin: URL? { endpoint.directOrigin } + + public var isRelay: Bool { endpoint.isRelay } + + private enum CodingKeys: String, CodingKey { + case id, name, endpoint, origin, tags, credentialConfigured, configRevision, createdAt, updatedAt + } + + /// Decodes the new shape, falling back to the pre-relay `origin` field. + /// + /// The fallback is what makes the upgrade invisible: an app that dropped support for the old key + /// would fail to decode the whole store and lose every node. Note that `origin` is decoded + /// leniently as a string and parsed here rather than decoded as a `URL`, because the encoder + /// that wrote it may have produced a spelling `URL` will not round-trip from a bare string in + /// every case. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + if let endpoint = try container.decodeIfPresent(InstanceEndpoint.self, forKey: .endpoint) { + self.endpoint = endpoint + } else if let origin = try container.decodeIfPresent(URL.self, forKey: .origin) { + self.endpoint = .direct(origin) + } else { + throw DecodingError.keyNotFound( + CodingKeys.endpoint, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "an instance must carry either endpoint or the legacy origin" + ) + ) + } + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + credentialConfigured = try container.decodeIfPresent(Bool.self, forKey: .credentialConfigured) ?? false + configRevision = try container.decodeIfPresent(Int.self, forKey: .configRevision) ?? 1 + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(endpoint, forKey: .endpoint) + try container.encode(tags, forKey: .tags) + try container.encode(credentialConfigured, forKey: .credentialConfigured) + try container.encode(configRevision, forKey: .configRevision) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) } } public struct InstanceInput: Sendable { public var name: String - public var origin: URL + public var endpoint: InstanceEndpoint public var tags: [String] public var password: String? + public init( + name: String, + endpoint: InstanceEndpoint, + tags: [String] = [], + password: String? = nil + ) { + self.name = name + self.endpoint = endpoint + self.tags = tags + self.password = password + } + public init( name: String, origin: URL, tags: [String] = [], password: String? = nil ) { - self.name = name - self.origin = origin - self.tags = tags - self.password = password + self.init(name: name, endpoint: .direct(origin), tags: tags, password: password) } } public struct InstancePatch: Sendable { public var name: String? - public var origin: URL? + public var endpoint: InstanceEndpoint? public var tags: [String]? public var password: String? public init( name: String? = nil, - origin: URL? = nil, + endpoint: InstanceEndpoint? = nil, tags: [String]? = nil, password: String? = nil ) { self.name = name - self.origin = origin + self.endpoint = endpoint self.tags = tags self.password = password } diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 0570a97..526ebbd 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -95,7 +95,7 @@ enum AppTab: Hashable { let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json")) guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return } // 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。 - target = listed.first { $0.origin.port == 3000 } ?? listed[0] + target = listed.first { $0.endpoint.directOrigin?.port == 3000 } ?? listed[0] } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift index fd95811..2a1e36a 100644 --- a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift @@ -29,7 +29,7 @@ public struct FileInstanceRepository: InstanceRepository { !search.isEmpty { instances = instances.filter { $0.name.localizedCaseInsensitiveContains(search) - || $0.origin.absoluteString.localizedCaseInsensitiveContains(search) + || $0.endpoint.displayAddress.localizedCaseInsensitiveContains(search) } } if let tags = query.tags, !tags.isEmpty { @@ -48,15 +48,15 @@ public struct FileInstanceRepository: InstanceRepository { guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw RepositoryError.invalidOrigin } - let origin = try Self.normalizedOrigin(input.origin) - guard !store.instances.contains(where: { $0.origin == origin }) else { + let endpoint = try Self.normalized(input.endpoint) + guard !store.instances.contains(where: { $0.endpoint.dedupKey == endpoint.dedupKey }) else { throw RepositoryError.duplicateOrigin } let now = Date() let instance = Instance( id: UUID(), name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), - origin: origin, + endpoint: endpoint, tags: Self.normalizedTags(input.tags), credentialConfigured: !(input.password ?? "").isEmpty, createdAt: now, @@ -82,12 +82,14 @@ public struct FileInstanceRepository: InstanceRepository { guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin } instance.name = normalized } - if let origin = patch.origin { - let normalized = try Self.normalizedOrigin(origin) - guard !store.instances.contains(where: { $0.id != id && $0.origin == normalized }) else { + if let endpoint = patch.endpoint { + let normalized = try Self.normalized(endpoint) + guard !store.instances.contains(where: { + $0.id != id && $0.endpoint.dedupKey == normalized.dedupKey + }) else { throw RepositoryError.duplicateOrigin } - instance.origin = normalized + instance.endpoint = normalized } if let tags = patch.tags { instance.tags = Self.normalizedTags(tags) @@ -150,6 +152,39 @@ public struct FileInstanceRepository: InstanceRepository { } } + private static func normalized(_ endpoint: InstanceEndpoint) throws -> InstanceEndpoint { + switch endpoint { + case .direct(let url): + return .direct(try normalizedOrigin(url)) + case .relay(let relay): + // A relay endpoint is validated on its own terms: https, a parseable node id, and the + // exact key sizes the envelope format requires. A half-configured relay entry that + // reached the transport would fail as an unreadable response rather than as "this + // pairing is incomplete". + guard relay.relayBaseURL.scheme == "https", + relay.relayBaseURL.host?.isEmpty == false, + relay.relayBaseURL.path.isEmpty || relay.relayBaseURL.path == "/" + else { + throw RepositoryError.invalidOrigin + } + guard relay.nodeID.hasPrefix("nd_"), relay.nodeID.count >= 11 else { + throw RepositoryError.invalidOrigin + } + guard relay.agentSigningKey.count == RelayWire.keyBytes, + relay.agentKxKey.count == RelayWire.keyBytes, + relay.deviceToken.count >= 16, + relay.deviceID.hasPrefix("ap_") + else { + throw RepositoryError.invalidOrigin + } + var normalized = relay + normalized.relayBaseURL = URL(string: relay.relayBaseURL.absoluteString.trimmingCharacters( + in: CharacterSet(charactersIn: "/") + )) ?? relay.relayBaseURL + return .relay(normalized) + } + } + private static func normalizedOrigin(_ url: URL) throws -> URL { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), components.scheme == "http" || components.scheme == "https", diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/CanonicalJSON.swift b/apps/ios/Sources/MultiSimAdmin/Relay/CanonicalJSON.swift new file mode 100644 index 0000000..783926a --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/CanonicalJSON.swift @@ -0,0 +1,348 @@ +import Foundation + +/// The canonical JSON form this protocol hashes. +/// +/// This is a hand-written parser rather than `JSONSerialization`, and the reason is not preference: +/// the canonical form is defined over the *spelling* of the input, and every Foundation decoder +/// erases spelling before the caller sees anything. `1.0` and `1` both arrive as `NSNumber(1)`; +/// a lone surrogate is either rejected outright or silently repaired depending on the framework +/// version. Trying to recover the original text from a decoded tree is how two languages end up +/// hashing different bytes for the same body, which surfaces as an intent mismatch the phone +/// reports as "confirmation failed" with no way to tell which side was wrong. +/// +/// The rules, which the Go agent and the Node reference implementation implement identically, are +/// documented in `agent/internal/wire/json.go`. The short version: keys sorted by UTF-8 bytes, +/// no insignificant whitespace, integers only in canonical spelling, strings escaped as `\" \\ \n +/// \r \t \b \f`, `\u00XX` for other control characters, literal UTF-8 for everything else, and a +/// lone surrogate escape is an error rather than a replacement character. +public enum CanonicalJSON { + public enum Failure: Error, Equatable { + case invalidJSON + case trailingData + case nonCanonicalNumber(String) + case loneSurrogate(UInt32) + case unsupportedValue + } + + /// Canonicalises a JSON document. An empty or whitespace-only body canonicalises to no bytes at + /// all, not to the four characters `null`, so "no body" and "empty body" cannot be confused. + public static func canonicalize(_ text: String) throws -> Data { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return Data() } + var parser = Parser(Array(trimmed.unicodeScalars)) + let value = try parser.parseValue() + parser.skipWhitespace() + guard parser.isAtEnd else { throw Failure.trailingData } + var out = Data() + try serialize(value, into: &out) + return out + } + + /// Canonicalises raw body bytes, which is what the intent digest needs. + public static func canonicalize(_ data: Data) throws -> Data { + guard let text = String(data: data, encoding: .utf8) else { throw Failure.invalidJSON } + return try canonicalize(text) + } + + // MARK: - Value tree + + indirect enum Value { + case null + case bool(Bool) + case integer(String) + case string(String) + case array([Value]) + case object([(key: String, value: Value)]) + } + + // MARK: - Serialisation + + static func serialize(_ value: Value, into out: inout Data) throws { + switch value { + case .null: + out.append(contentsOf: Array("null".utf8)) + case .bool(let flag): + out.append(contentsOf: Array((flag ? "true" : "false").utf8)) + case .integer(let literal): + out.append(contentsOf: Array(literal.utf8)) + case .string(let text): + escape(text, into: &out) + case .array(let items): + out.append(UInt8(ascii: "[")) + for (index, item) in items.enumerated() { + if index > 0 { out.append(UInt8(ascii: ",")) } + try serialize(item, into: &out) + } + out.append(UInt8(ascii: "]")) + case .object(let members): + // UTF-8 byte order, which is what the Go agent's sort.Strings produces. Two shortcuts + // are wrong here and both look right on ASCII: Swift's default String `<` uses Unicode + // collation (so "é" sorts before "z"), and Foundation's `.sortedKeys` does the same. + let sorted = members.sorted { Array($0.key.utf8).lexicographicallyPrecedes(Array($1.key.utf8)) } + out.append(UInt8(ascii: "{")) + for (index, member) in sorted.enumerated() { + if index > 0 { out.append(UInt8(ascii: ",")) } + escape(member.key, into: &out) + out.append(UInt8(ascii: ":")) + try serialize(member.value, into: &out) + } + out.append(UInt8(ascii: "}")) + } + } + + /// Writes a JSON string literal in the canonical spelling. + /// + /// The two rules worth stating: control characters below 0x20 use the short forms JSON names + /// where they exist and `\u00XX` otherwise (matching Go's encoder with HTML escaping off), and + /// everything else — including U+2028, U+2029, DEL and astral characters — is emitted as + /// literal UTF-8. Go's encoder escapes U+2028/U+2029 even with HTML escaping disabled, which is + /// why the Go side had to grow its own writer too; the vectors pin the literal form. + static func escape(_ text: String, into out: inout Data) { + out.append(UInt8(ascii: "\"")) + for scalar in text.unicodeScalars { + switch scalar { + case "\"": + out.append(contentsOf: Array("\\\"".utf8)) + case "\\": + out.append(contentsOf: Array("\\\\".utf8)) + case "\n": + out.append(contentsOf: Array("\\n".utf8)) + case "\r": + out.append(contentsOf: Array("\\r".utf8)) + case "\t": + out.append(contentsOf: Array("\\t".utf8)) + case Unicode.Scalar(0x08): + out.append(contentsOf: Array("\\b".utf8)) + case Unicode.Scalar(0x0C): + out.append(contentsOf: Array("\\f".utf8)) + default: + if scalar.value < 0x20 { + let hex = String(format: "%04x", scalar.value) + out.append(contentsOf: Array("\\u\(hex)".utf8)) + } else { + out.append(contentsOf: Array(String(scalar).utf8)) + } + } + } + out.append(UInt8(ascii: "\"")) + } + + // MARK: - Parser + + struct Parser { + private let scalars: [Unicode.Scalar] + private var index = 0 + + init(_ scalars: [Unicode.Scalar]) { + self.scalars = scalars + } + + var isAtEnd: Bool { index >= scalars.count } + + mutating func skipWhitespace() { + while index < scalars.count, isSpace(scalars[index]) { index += 1 } + } + + private func isSpace(_ scalar: Unicode.Scalar) -> Bool { + scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r" + } + + private func peek() -> Unicode.Scalar? { + index < scalars.count ? scalars[index] : nil + } + + private mutating func expect(_ scalar: Unicode.Scalar) throws { + guard peek() == scalar else { throw Failure.invalidJSON } + index += 1 + } + + mutating func parseValue() throws -> Value { + skipWhitespace() + guard let scalar = peek() else { throw Failure.invalidJSON } + switch scalar { + case "{": return try parseObject() + case "[": return try parseArray() + case "\"": return .string(try parseString()) + case "t": + try expectWord("true") + return .bool(true) + case "f": + try expectWord("false") + return .bool(false) + case "n": + try expectWord("null") + return .null + default: + return .integer(try parseNumber()) + } + } + + private mutating func expectWord(_ word: String) throws { + for scalar in word.unicodeScalars { + try expect(scalar) + } + } + + private mutating func parseObject() throws -> Value { + try expect("{") + var members: [(key: String, value: Value)] = [] + skipWhitespace() + if peek() == "}" { + index += 1 + return .object(members) + } + while true { + skipWhitespace() + let key = try parseString() + skipWhitespace() + try expect(":") + let value = try parseValue() + // A repeated key takes the last occurrence, which is what Go's decoder and + // JSON.parse both do. Refusing here would make the phone stricter than the agent + // that recomputes the intent digest, and the two disagreeing about a body is the + // failure this whole file exists to prevent. + if let existing = members.firstIndex(where: { $0.key == key }) { + members[existing] = (key, value) + } else { + members.append((key, value)) + } + skipWhitespace() + if peek() == "," { + index += 1 + continue + } + try expect("}") + return .object(members) + } + } + + private mutating func parseArray() throws -> Value { + try expect("[") + var items: [Value] = [] + skipWhitespace() + if peek() == "]" { + index += 1 + return .array(items) + } + while true { + items.append(try parseValue()) + skipWhitespace() + if peek() == "," { + index += 1 + continue + } + try expect("]") + return .array(items) + } + } + + /// Parses a string, resolving escapes. + /// + /// A `\uXXXX` escape in the surrogate range must be correctly paired. A lone surrogate is + /// an error here for the same reason it is in Go and Node: the three frameworks disagree + /// about what to do with one, so the only rule all three can share is to accept none. + private mutating func parseString() throws -> String { + try expect("\"") + var output = String.UnicodeScalarView() + while true { + guard let scalar = peek() else { throw Failure.invalidJSON } + index += 1 + switch scalar { + case "\"": + return String(output) + case "\\": + output.append(try parseEscape()) + default: + if scalar.value < 0x20 { throw Failure.invalidJSON } + output.append(scalar) + } + } + } + + private mutating func parseEscape() throws -> Unicode.Scalar { + guard let scalar = peek() else { throw Failure.invalidJSON } + index += 1 + switch scalar { + case "\"": return "\"" + case "\\": return "\\" + case "/": return "/" + case "b": return Unicode.Scalar(0x08) + case "f": return Unicode.Scalar(0x0C) + case "n": return "\n" + case "r": return "\r" + case "t": return "\t" + case "u": + let high = try parseHex4() + if high >= 0xD800, high <= 0xDBFF { + guard peek() == "\\" else { throw Failure.loneSurrogate(high) } + index += 1 + guard peek() == "u" else { throw Failure.loneSurrogate(high) } + index += 1 + let low = try parseHex4() + guard low >= 0xDC00, low <= 0xDFFF else { throw Failure.loneSurrogate(high) } + let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00) + guard let scalar = Unicode.Scalar(combined) else { throw Failure.loneSurrogate(high) } + return scalar + } + guard high < 0xDC00 || high > 0xDFFF else { throw Failure.loneSurrogate(high) } + guard let scalar = Unicode.Scalar(high) else { throw Failure.loneSurrogate(high) } + return scalar + default: + throw Failure.invalidJSON + } + } + + private mutating func parseHex4() throws -> UInt32 { + var value: UInt32 = 0 + for _ in 0..<4 { + guard let scalar = peek(), let digit = hexDigit(scalar) else { throw Failure.invalidJSON } + index += 1 + value = value << 4 | digit + } + return value + } + + private func hexDigit(_ scalar: Unicode.Scalar) -> UInt32? { + switch scalar { + case "0"..."9": return scalar.value - 0x30 + case "a"..."f": return scalar.value - 0x61 + 10 + case "A"..."F": return scalar.value - 0x41 + 10 + default: return nil + } + } + + /// Parses an integer in the one spelling this protocol hashes. + /// + /// The literal is kept as text rather than converted to a number, so what gets hashed is + /// what the phone sent. `1.0`, `1e3`, `+1`, `.5`, `01` and `-0` are all rejected: each of + /// them is the same number as some accepted spelling, and "which spelling did the phone + /// hash" is not a question this protocol leaves open. + private mutating func parseNumber() throws -> String { + var literal = "" + if peek() == "-" { + literal.append("-") + index += 1 + } + guard let first = peek(), first >= "0", first <= "9" else { + throw Failure.invalidJSON + } + if first == "0" { + literal.append("0") + index += 1 + if let next = peek(), next >= "0", next <= "9" { + throw Failure.nonCanonicalNumber(literal + String(next)) + } + } else { + while let next = peek(), next >= "0", next <= "9" { + literal.append(Character(next)) + index += 1 + } + } + if let next = peek(), next == "." || next == "e" || next == "E" || next == "+" { + throw Failure.nonCanonicalNumber(literal + String(next)) + } + if literal == "-0" { throw Failure.nonCanonicalNumber(literal) } + return literal + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/DeviceIdentity.swift b/apps/ios/Sources/MultiSimAdmin/Relay/DeviceIdentity.swift new file mode 100644 index 0000000..ae1c0ca --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/DeviceIdentity.swift @@ -0,0 +1,146 @@ +import CryptoKit +import Foundation + +/// The phone's long-term relay identity: one Ed25519 signing key and one X25519 key-agreement key, +/// generated once and stored in the app sandbox. +/// +/// The sandbox file rather than the keychain is a deliberate, recorded decision (design §6.4): the +/// established convention in this app is that node credentials live in a sandbox file, and the +/// Secure Enclave alternative conflicts with the TrollStore signing flow this build uses. The cost +/// is real and stated plainly: on a jailbroken device the file can be read, which yields the ability +/// to sign requests as this phone. It does not yield a node password, because the phone never holds +/// one in relay mode. +/// +/// The device id is minted by the *node* at pairing time, not here: the node's config is the +/// authority for which device ids exist, and a phone that chose its own id could collide with an +/// existing pairing. +public struct DeviceIdentity: Sendable { + public let deviceID: String + public let signingKey: Curve25519.Signing.PrivateKey + public let agreementKey: Curve25519.KeyAgreement.PrivateKey + + public var signingPublicKey: Data { signingKey.publicKey.rawRepresentation } + public var agreementPublicKey: Data { agreementKey.publicKey.rawRepresentation } + + public init( + deviceID: String, + signingKey: Curve25519.Signing.PrivateKey, + agreementKey: Curve25519.KeyAgreement.PrivateKey + ) { + self.deviceID = deviceID + self.signingKey = signingKey + self.agreementKey = agreementKey + } +} + +/// Generates and persists the device identity. +/// +/// The file is written atomically with `completeFileProtection`, the same treatment the credential +/// store uses, so a locked device's backup does not carry the private key in the clear. +public struct FileDeviceIdentityStore: Sendable { + private struct FileModel: Codable { + var deviceID: String + var signingKey: Data + var agreementKey: Data + /// Bumped when the storage shape changes, so a future migration can tell old files apart + /// rather than guessing from missing fields. + var schemaVersion: Int + } + + private let location: URL + private let lock = NSLock() + + public init(location: URL) { + self.location = location + } + + /// Loads the identity, creating one on first use. + /// + /// The `deviceID` is a placeholder until the node assigns a real one at pairing: the phone needs + /// keys before it can pair, but it cannot know its id before the node decides. A pairing request + /// sends the public halves and receives the id, which is then stored here. + public func loadOrCreate() throws -> DeviceIdentity { + try lock.withLock { + if let model = read(), let identity = try? identity(from: model) { + return identity + } + let identity = DeviceIdentity( + deviceID: "", + signingKey: Curve25519.Signing.PrivateKey(), + agreementKey: Curve25519.KeyAgreement.PrivateKey() + ) + try write(identity) + return identity + } + } + + /// Records the id the node assigned. The keys are unchanged: the node verified the public halves + /// it was given, and regenerating them would invalidate the pairing it just wrote. + public func assignDeviceID(_ deviceID: String) throws -> DeviceIdentity { + try lock.withLock { + let current = try loadOrCreateLocked() + let updated = DeviceIdentity( + deviceID: deviceID, + signingKey: current.signingKey, + agreementKey: current.agreementKey + ) + try write(updated) + return updated + } + } + + public func reset() throws { + try lock.withLock { + try? FileManager.default.removeItem(at: location) + } + } + + // MARK: - Internals + + /// `loadOrCreate` is not reentrant with the lock, so the locked body lives here. + private func loadOrCreateLocked() throws -> DeviceIdentity { + if let model = read(), let identity = try? identity(from: model) { + return identity + } + let identity = DeviceIdentity( + deviceID: "", + signingKey: Curve25519.Signing.PrivateKey(), + agreementKey: Curve25519.KeyAgreement.PrivateKey() + ) + try write(identity) + return identity + } + + private func identity(from model: FileModel) throws -> DeviceIdentity { + DeviceIdentity( + deviceID: model.deviceID, + signingKey: try Curve25519.Signing.PrivateKey(rawRepresentation: model.signingKey), + agreementKey: try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: model.agreementKey) + ) + } + + private func read() -> FileModel? { + guard let data = try? Data(contentsOf: location) else { return nil } + return try? JSONDecoder().decode(FileModel.self, from: data) + } + + private func write(_ identity: DeviceIdentity) throws { + let model = FileModel( + deviceID: identity.deviceID, + signingKey: identity.signingKey.rawRepresentation, + agreementKey: identity.agreementKey.rawRepresentation, + schemaVersion: 1 + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try FileManager.default.createDirectory( + at: location.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(model).write(to: location, options: [.atomic, .completeFileProtection]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: location.path + ) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/InstanceEndpoint.swift b/apps/ios/Sources/MultiSimAdmin/Relay/InstanceEndpoint.swift new file mode 100644 index 0000000..5dda873 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/InstanceEndpoint.swift @@ -0,0 +1,279 @@ +import Foundation + +/// How a node is reached, as a value the list can badge. +public enum InstanceChannel: String, CaseIterable, Sendable { + case direct + case relay + + public var label: String { + switch self { + case .direct: return "直通" + case .relay: return "中继" + } + } +} + +public extension InstanceEndpoint { + var channel: InstanceChannel { + switch self { + case .direct: return .direct + case .relay: return .relay + } + } +} + +/// How the app reaches a node. +/// +/// This exists rather than an optional `relayNodeID` field on `Instance` because `origin` currently +/// carries three separate responsibilities — the request target, the duplicate-detection key, and +/// the cookie binding domain — and in relay mode all three have to be re-homed. An optional field +/// would leave two of them quietly pointing at an address that does not exist on a relay node. +public enum InstanceEndpoint: Equatable, Sendable { + /// The node is reachable directly on the local network. This is the original behaviour and its + /// code path is unchanged. + case direct(URL) + /// The node is reached through a relay, with the agent on the node holding the session against + /// the local SimAdmin. No node password is stored on the phone in this mode. + case relay(RelayEndpoint) +} + +public struct RelayEndpoint: Equatable, Sendable { + /// The relay's base URL, https. The only outbound destination in relay mode. + public var relayBaseURL: URL + /// The node this endpoint reaches, as the relay and the agent both name it. + public var nodeID: String + /// `K_agent_sign` public half, Ed25519, 32 bytes. Every response is verified against it. + public var agentSigningKey: Data + /// `K_agent_kx` public half, X25519, 32 bytes. Requests are encrypted to it. + public var agentKxKey: Data + /// The bearer the relay expects from this phone. It authorises nothing on the node: the node's + /// authorisation comes from the signature the phone produces with its own private key. + /// + /// A `String` rather than `Data`, which the design sketch drew: the relay compares the token as + /// an opaque bearer value, so what has to survive the round trip is the exact text the node + /// minted. Holding it as bytes invites a hex-encode on the way out, which sends a different + /// string than the one the agent registered and fails as "unknown device" with nothing to see. + public var deviceToken: String + /// The device id the node knows this phone by, and which the phone signs into every request. + public var deviceID: String + /// The node's display name at pairing time, used only when the user has not renamed it. + public var nodeName: String? + + public init( + relayBaseURL: URL, + nodeID: String, + agentSigningKey: Data, + agentKxKey: Data, + deviceToken: String, + deviceID: String, + nodeName: String? = nil + ) { + self.relayBaseURL = relayBaseURL + self.nodeID = nodeID + self.agentSigningKey = agentSigningKey + self.agentKxKey = agentKxKey + self.deviceToken = deviceToken + self.deviceID = deviceID + self.nodeName = nodeName + } +} + +public extension InstanceEndpoint { + /// The direct origin, or nil in relay mode. + /// + /// Callers that need "the address to dial directly" get nil rather than a substituted value, so + /// a relay instance cannot accidentally be handed to a code path that would dial a LAN address + /// that is not there. + var directOrigin: URL? { + if case .direct(let url) = self { return url } + return nil + } + + var relay: RelayEndpoint? { + if case .relay(let endpoint) = self { return endpoint } + return nil + } + + var isRelay: Bool { relay != nil } + + /// The identity used for duplicate detection. + /// + /// Direct endpoints keep using the normalised origin so an existing node list upgrades without + /// suddenly reporting duplicates; relay endpoints use the node id, because two phones paired to + /// the same node are two different instances and one phone paired to two nodes is not. + var dedupKey: String { + switch self { + case .direct(let url): + return url.absoluteString + case .relay(let endpoint): + return "relay:\(endpoint.nodeID)" + } + } + + /// What the user sees as "where this node is". + var displayAddress: String { + switch self { + case .direct(let url): + return url.absoluteString + case .relay(let endpoint): + return "\(endpoint.relayBaseURL.host ?? "relay") · \(endpoint.nodeID)" + } + } + + /// Whether the phone holds a node password for this instance. + /// + /// The two modes mean different things and must not share a string: directly it means "this + /// phone has the node's password", through a relay it means "the node's agent has its own + /// credential". Showing the first wording in relay mode would tell the user their password is + /// on the phone when it deliberately is not. + var credentialDescription: String { + switch self { + case .direct: + return "已保存本机密码" + case .relay: + return "由节点 agent 保存凭据" + } + } +} + +// MARK: - Codable + +extension InstanceEndpoint: Codable { + private enum CodingKeys: String, CodingKey { case kind, url, relay } + private enum Kind: String, Codable { case direct, relay } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .direct: + self = .direct(try container.decode(URL.self, forKey: .url)) + case .relay: + self = .relay(try container.decode(RelayEndpoint.self, forKey: .relay)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .direct(let url): + try container.encode(Kind.direct, forKey: .kind) + try container.encode(url, forKey: .url) + case .relay(let endpoint): + try container.encode(Kind.relay, forKey: .kind) + try container.encode(endpoint, forKey: .relay) + } + } +} + +extension RelayEndpoint: Codable { + private enum CodingKeys: String, CodingKey { + case relayBaseURL, nodeID, agentSigningKey, agentKxKey, deviceToken, deviceID, nodeName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + relayBaseURL = try container.decode(URL.self, forKey: .relayBaseURL) + nodeID = try container.decode(String.self, forKey: .nodeID) + agentSigningKey = try container.decode(Data.self, forKey: .agentSigningKey) + agentKxKey = try container.decode(Data.self, forKey: .agentKxKey) + deviceToken = try container.decode(String.self, forKey: .deviceToken) + deviceID = try container.decode(String.self, forKey: .deviceID) + nodeName = try container.decodeIfPresent(String.self, forKey: .nodeName) + guard agentSigningKey.count == RelayWire.keyBytes, agentKxKey.count == RelayWire.keyBytes else { + throw DecodingError.dataCorruptedError( + forKey: .agentSigningKey, + in: container, + debugDescription: "agent key material must be 32 raw bytes" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(relayBaseURL, forKey: .relayBaseURL) + try container.encode(nodeID, forKey: .nodeID) + try container.encode(agentSigningKey, forKey: .agentSigningKey) + try container.encode(agentKxKey, forKey: .agentKxKey) + try container.encode(deviceToken, forKey: .deviceToken) + try container.encode(deviceID, forKey: .deviceID) + try container.encodeIfPresent(nodeName, forKey: .nodeName) + } +} + +// MARK: - Relay error vocabulary + +/// Every code the relay can return, mirroring `relay/src/errors.js`. +/// +/// The console's `ProblemDetails.code` is an unconstrained string in the OpenAPI document, so this +/// is a new vocabulary rather than a reuse of an existing one. It has to be enumerated in one place +/// in the first version, because three implementations writing their own strings is exactly how +/// cross-tier debugging stops working: the phone shows a code, the relay logs a code, and nobody can +/// match them. +public enum RelayErrorCode: String, CaseIterable, Sendable { + case unauthorized = "relay_unauthorized" + case nodeUnknown = "relay_node_unknown" + case agentOffline = "relay_agent_offline" + case mailboxFull = "relay_mailbox_full" + case agentTimeout = "relay_agent_timeout" + case badEnvelope = "relay_bad_envelope" + case payloadTooLarge = "relay_payload_too_large" + case rateLimited = "relay_rate_limited" + case internalError = "relay_internal" + case sessionExpired = "relay_session_expired" + case badChallenge = "relay_bad_challenge" +} + +/// The agent's own classification, carried inside the ciphertext as `InnerResponse.outcome`. +/// +/// Separate from `RelayErrorCode` because the two arrive through different channels and mean +/// different things: a relay code is "the relay could not deliver this", an agent outcome is "the +/// node received it and refused it". Collapsing them into one enum would lose the distinction the +/// user needs, which is whether retrying can help. +public enum AgentOutcome: String, CaseIterable, Sendable { + case ok + case badEnvelope = "bad_envelope" + case badSignature = "bad_signature" + case unknownDevice = "unknown_device" + case expired + case replay + case policyDenied = "policy_denied" + case confirmationMissing = "confirmation_missing" + case confirmationMismatch = "confirmation_mismatch" + case rateLimited = "rate_limited" + case payloadTooLarge = "payload_too_large" + case upstreamError = "upstream_error" + case nodeAuthFailed = "node_auth_failed" +} + +/// Freshness of a snapshot, mirroring `FRESHNESS_STATUSES` in `packages/contracts/src/instances.ts`. +/// +/// The four values are the existing vocabulary rather than new names. Without it a relay instance +/// cannot express that its "online" reading is forty seconds old, which in a two-hop architecture is +/// a substantive misstatement rather than a cosmetic one. +public enum SnapshotFreshness: String, CaseIterable, Sendable { + case fresh + case stale + case expired + case unknown + + /// Thresholds, chosen to line up with what the UI already implies: a direct probe is fresh for + /// as long as a user would call the screen current, and anything past the relay's own request + /// deadline is expired rather than merely old. + public static func classify(age: TimeInterval?) -> SnapshotFreshness { + guard let age, age >= 0 else { return .unknown } + switch age { + case ..<15: return .fresh + case ..<90: return .stale + default: return .expired + } + } + + public var label: String { + switch self { + case .fresh: return "刚刚" + case .stale: return "稍旧" + case .expired: return "已过期" + case .unknown: return "未知" + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/RelayPairing.swift b/apps/ios/Sources/MultiSimAdmin/Relay/RelayPairing.swift new file mode 100644 index 0000000..a0cf9cc --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/RelayPairing.swift @@ -0,0 +1,125 @@ +import CryptoKit +import Foundation + +/// The phone's half of the pairing flow (design §7). +/// +/// It works over the LAN, because at pairing time the phone is necessarily already inside the +/// network — that is the fact the whole flow leans on, and it is what lets the shared secret be the +/// node's own password instead of a QR code or a second credential to manage. +/// +/// The node's agent serves this on a temporary management port that closes itself after one +/// successful pairing, so a phone that cannot reach the node has to be paired again from the node +/// rather than from anywhere else. +public struct RelayPairing: Sendable { + /// What the node returns on success. + public struct Result: Equatable, Sendable { + public let endpoint: RelayEndpoint + /// The device id the node assigned. It is the node's choice, not the phone's: the node's + /// config is the authority for which device ids exist. + public let deviceID: String + } + + public enum Failure: Error, Equatable { + /// The shared secret did not match. Distinct from a transport failure because the remedy is + /// different: retype the password rather than check the network. + case rejected + /// The pairing window is closed — already used, or too many failed attempts. + case windowClosed + case unreachable + case malformedResponse + } + + private let session: URLSession + private let port: Int + private let maximumBytes: Int + + public init(session: URLSession = .shared, port: Int = 3001, maximumBytes: Int = 16 << 10) { + self.session = session + self.port = port + self.maximumBytes = maximumBytes + } + + /// Pairs this phone with the node at `host`. + /// + /// `host` is the node's LAN address as the user already typed it for direct mode, which is why + /// the port is a separate parameter: the pairing window is not on the SimAdmin port. + public func pair( + host: String, + password: String, + deviceName: String, + identity: DeviceIdentity + ) async throws -> Result { + guard let url = URL(string: "http://\(host):\(port)/provision") else { + throw Failure.unreachable + } + // The device name is informational on the node's side, and the two public keys are the whole + // point: the node stores the signing key and returns its own, which becomes the phone's + // trust anchor for every later response. + let body: [String: String] = [ + "password": password, + "appSignPub": identity.signingPublicKey.base64EncodedString(), + "appKxPub": identity.agreementPublicKey.base64EncodedString(), + "deviceName": deviceName, + ] + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("application/json", forHTTPHeaderField: "accept") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw Failure.unreachable + } + guard let http = response as? HTTPURLResponse else { throw Failure.malformedResponse } + switch http.statusCode { + case 200: + break + case 401: + throw Failure.rejected + case 410: + throw Failure.windowClosed + default: + throw Failure.malformedResponse + } + guard data.count <= maximumBytes, + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let nodeID = root["nodeID"] as? String, + let appID = root["appID"] as? String, + let relayURLText = root["relayURL"] as? String, + let relayURL = URL(string: relayURLText), + relayURL.scheme == "https", + let signKey = Self.key(root["agentSignPub"] as? String), + let kxKey = Self.key(root["agentKxPub"] as? String), + let token = root["deviceToken"] as? String, + !token.isEmpty + else { + throw Failure.malformedResponse + } + return Result( + endpoint: RelayEndpoint( + relayBaseURL: relayURL, + nodeID: nodeID, + agentSigningKey: signKey, + agentKxKey: kxKey, + deviceToken: token, + deviceID: appID, + nodeName: deviceName + ), + deviceID: appID + ) + } + + /// A key the node published must be exactly 32 bytes. A short one would be accepted by the + /// decoder and then fail as an unreadable response, which reads to the user as "the node is + /// broken" rather than "this pairing is wrong". + private static func key(_ text: String?) -> Data? { + guard let text, let data = Data(base64Encoded: text), data.count == RelayWire.keyBytes else { + return nil + } + return data + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/RelayTransport.swift b/apps/ios/Sources/MultiSimAdmin/Relay/RelayTransport.swift new file mode 100644 index 0000000..a3a9f09 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/RelayTransport.swift @@ -0,0 +1,366 @@ +import CryptoKit +import Foundation + +/// What the app needs in order to reach a node, in either mode. +/// +/// The abstraction exists so services stop taking a `baseURL` and a `cookie`: in relay mode there is +/// no base URL to dial and no cookie, and a service that kept asking for one would be asking a +/// question with no answer. +/// +/// `instanceID` is part of the signature even though the design sketch showed only the endpoint, +/// because the direct path scopes its cookie by `(instanceID, origin)` and losing that would let two +/// instances pointed at the same node share a session. It is the one piece of the sketch that could +/// not survive contact with the existing session store. +public protocol NodeTransport: Sendable { + func send( + _ route: UpstreamRoute, + instanceID: UUID, + endpoint: InstanceEndpoint + ) async throws -> UpstreamResponse +} + +/// Picks the transport for an endpoint. +/// +/// Services depend on this rather than on either concrete transport, so adding a mode later is a +/// change in one place instead of in every feature that talks to a node. +public struct NodeTransportRouter: NodeTransport { + private let direct: any NodeTransport + private let relay: any NodeTransport + + public init(direct: any NodeTransport, relay: any NodeTransport) { + self.direct = direct + self.relay = relay + } + + public func send( + _ route: UpstreamRoute, + instanceID: UUID, + endpoint: InstanceEndpoint + ) async throws -> UpstreamResponse { + switch endpoint { + case .direct: + return try await direct.send(route, instanceID: instanceID, endpoint: endpoint) + case .relay: + return try await relay.send(route, instanceID: instanceID, endpoint: endpoint) + } + } +} + +/// The existing direct path, unchanged. +/// +/// It is a thin adapter rather than a rewrite: the code path that reaches a node on the local network +/// today is the one users already depend on, and a refactor that changed its behaviour would be a +/// regression disguised as tidying. +public struct DirectTransport: NodeTransport { + private let client: any SimAdminClientProtocol + private let sessions: InstanceSessionStore + private let credentials: (any CredentialStore)? + + public init( + client: any SimAdminClientProtocol, + sessions: InstanceSessionStore, + credentials: (any CredentialStore)? = nil + ) { + self.client = client + self.sessions = sessions + self.credentials = credentials + } + + public func send( + _ route: UpstreamRoute, + instanceID: UUID, + endpoint: InstanceEndpoint + ) async throws -> UpstreamResponse { + guard let origin = endpoint.directOrigin else { + // Reaching here means a relay instance was handed to the direct path, which is a wiring + // bug rather than a network condition: fail loudly instead of dialing nothing. + throw UpstreamError.invalidRequest + } + var cookie = await sessions.cookie(for: instanceID, origin: origin) + do { + return try await client.send(route, baseURL: origin, cookie: cookie) + } catch UpstreamError.unauthorized, UpstreamError.forbidden { + // A node whose login endpoints are unreachable (an old build, or one with protection + // off) must not turn every read into a failed login attempt; the retry below is what + // makes the second call happen only when credentials actually exist. + guard let credentials, let password = try credentials.get(instanceID: instanceID), + !password.isEmpty + else { + throw UpstreamError.unauthorized + } + let service = InstanceSessionService(store: sessions, client: client) + let instance = Instance( + id: instanceID, + name: "", + endpoint: endpoint, + createdAt: Date(), + updatedAt: Date() + ) + let result = try await service.login(instance, password: password) + guard result.authenticated else { throw UpstreamError.unauthorized } + cookie = await sessions.cookie(for: instanceID, origin: origin) + return try await client.send(route, baseURL: origin, cookie: cookie) + } + } +} + +/// The relay path: build an envelope, sign it, encrypt it, post it, then verify and decrypt the +/// answer. +/// +/// It never touches a cookie and never sees a node password. The node's agent holds that credential +/// and logs in to its own loopback SimAdmin, which is what makes "the phone has no node password" +/// true rather than merely stated. +public struct RelayTransport: NodeTransport { + /// Resolved per send rather than held, so a transport built before pairing still uses the keys + /// the node actually paired with. Holding a copy would mean a pairing done after the transport + /// was constructed signs with keys the node has never seen. + private let identity: @Sendable () throws -> DeviceIdentity + private let session: URLSession + private let maximumBytes: Int + private let now: @Sendable () -> Date + + public init( + identity: @escaping @Sendable () throws -> DeviceIdentity, + session: URLSession = .shared, + maximumBytes: Int = 262_144, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.identity = identity + self.session = session + self.maximumBytes = maximumBytes + self.now = now + } + + public init( + identity: DeviceIdentity, + session: URLSession = .shared, + maximumBytes: Int = 262_144, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.init(identity: { identity }, session: session, maximumBytes: maximumBytes, now: now) + } + + public func send( + _ route: UpstreamRoute, + instanceID: UUID, + endpoint: InstanceEndpoint + ) async throws -> UpstreamResponse { + guard let relay = endpoint.relay else { throw UpstreamError.invalidRequest } + guard relay.relayBaseURL.scheme == "https" else { + // Plain HTTP to a relay would make the endpoint-to-endpoint encryption decorative: a + // third party could substitute a different ciphertext, and nothing above would notice. + throw UpstreamError.invalidRequest + } + guard let requestBody = try? Self.encode(route: route) else { + throw UpstreamError.invalidRequest + } + _ = instanceID + guard let identity = try? identity() else { + throw UpstreamError.invalidRequest + } + + // The ephemeral key pair is per request and its private half is kept only until the answer + // is decrypted. The response is encrypted to this key, so the two directions never share a + // secret and the phone's long-term X25519 key is not needed to read an answer. + let ephemeral = Curve25519.KeyAgreement.PrivateKey() + let requestID = UUID().uuidString.lowercased() + let timestamp = UInt64(now().timeIntervalSince1970) + + let shared = try RelayWire.sharedSecret( + privateKey: ephemeral, + peerPublic: relay.agentKxKey + ) + let (key, iv) = try RelayWire.deriveKey( + shared: shared, + nodeID: relay.nodeID, + requestID: requestID, + response: false + ) + let sealed = try RelayWire.seal(requestBody, key: key, iv: iv) + + var envelope = RelayWire.Envelope( + node: relay.nodeID, + req: requestID, + app: relay.deviceID, + ts: timestamp, + ek: ephemeral.publicKey.rawRepresentation, + p: sealed + ) + try RelayWire.signRequest(&envelope, privateKey: identity.signingKey) + + let answer = try await post(envelope, relay: relay) + return try decrypt(answer, relay: relay, requestID: requestID, ephemeral: ephemeral) + } + + // MARK: - Inner request + + /// Builds the inner payload. The path is the route's own path, query string included, so the + /// agent never assembles one from parts and there is no traversal surface from the relay. + /// + /// A dangerous write carries an intent computed here, from the exact bytes about to be sent. The + /// agent recomputes it and refuses a mismatch, so a relay cannot edit the recipient of an SMS in + /// flight and keep the confirmation valid. The intent is produced at the moment the user's + /// gesture is already behind us — this method is only called for a request the UI has decided to + /// make — which is what gives it any meaning at all. + static func encode(route: UpstreamRoute) throws -> Data { + let body = route.body + var inner = RelayWire.InnerRequest(m: route.method, p: route.path, body: body) + if route.requiresConfirmation { + inner.intent = try RelayWire.intentDigest( + method: route.method, + path: route.path, + body: body + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(inner) + } + + // MARK: - HTTP + + private func post(_ envelope: RelayWire.Envelope, relay: RelayEndpoint) async throws -> RelayWire.Envelope { + guard let url = URL(string: relay.relayBaseURL.absoluteString + "/v1/request") else { + throw UpstreamError.invalidRequest + } + var request = URLRequest(url: url, timeoutInterval: 45) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("application/json", forHTTPHeaderField: "accept") + request.setValue("Bearer \(relay.deviceToken)", forHTTPHeaderField: "authorization") + request.httpBody = try JSONEncoder().encode(envelope) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let error as URLError where error.code == .timedOut { + throw UpstreamError.relayTimeout + } catch { + throw UpstreamError.relayUnreachable + } + guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else { + throw UpstreamError.invalidResponse + } + if http.statusCode != 200 { + throw Self.problem(from: data, status: http.statusCode) + } + guard let envelope = try? JSONDecoder().decode(RelayWire.Envelope.self, from: data) else { + throw UpstreamError.invalidResponse + } + return envelope + } + + /// Maps a relay ProblemDetails document to a typed error. + /// + /// The four classes the design calls for are kept apart on purpose: "the relay is unreachable", + /// "the agent is offline", "the envelope was refused" and "the node returned an error" lead to + /// four different user actions, and folding them into one "request failed" is what makes relay + /// problems unactionable. + static func problem(from data: Data, status: Int) -> UpstreamError { + let code = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + .flatMap { $0?["code"] as? String } + .flatMap(RelayErrorCode.init(rawValue:)) + switch code { + case .unauthorized: + // The relay rejected this phone's bearer. Nothing the node can do about it, and retrying + // with the same token will fail the same way. + return .relayRejectedDevice + case .nodeUnknown: + return .relayNodeUnknown + case .agentOffline: + return .agentOffline + case .mailboxFull: + return .nodeBusy + case .agentTimeout: + return .agentTimeout + case .badEnvelope: + return .relayBadEnvelope + case .payloadTooLarge: + return .payloadTooLarge + case .rateLimited: + return .relayRateLimited + case .sessionExpired, .badChallenge: + return .agentOffline + case .internalError, .none: + return status >= 500 ? .relayUnreachable : .invalidResponse + } + } + + // MARK: - Response + + private func decrypt( + _ envelope: RelayWire.Envelope, + relay: RelayEndpoint, + requestID: String, + ephemeral: Curve25519.KeyAgreement.PrivateKey + ) throws -> UpstreamResponse { + guard envelope.req == requestID, envelope.node == relay.nodeID else { + // The relay answered a different question. Since the signature covers both fields, a + // mismatch here means the answer was not produced for this request at all. + throw UpstreamError.relayResponseMismatch + } + let signingKey: Curve25519.Signing.PublicKey + do { + signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: relay.agentSigningKey) + } catch { + throw UpstreamError.relayResponseUnverifiable + } + do { + try RelayWire.verifyResponse(envelope, publicKey: signingKey) + } catch { + // An answer the node did not sign is not an answer. This is the check that makes the + // relay's inability to forge responses a fact rather than a claim. + throw UpstreamError.relayResponseUnverifiable + } + let shared = try RelayWire.sharedSecret(privateKey: ephemeral, peerPublic: envelope.ek) + let (key, iv) = try RelayWire.deriveKey( + shared: shared, + nodeID: relay.nodeID, + requestID: requestID, + response: true + ) + let plaintext: Data + do { + plaintext = try RelayWire.open(envelope.p, key: key, iv: iv) + } catch { + throw UpstreamError.relayResponseUnverifiable + } + guard let inner = try? JSONDecoder().decode(RelayWire.InnerResponse.self, from: plaintext) else { + throw UpstreamError.invalidResponse + } + + let outcome = inner.outcome.flatMap(AgentOutcome.init(rawValue:)) + switch outcome { + case .ok, .none: + return UpstreamResponse(status: inner.status, data: inner.body ?? Data()) + case .nodeAuthFailed: + // Nothing the phone can do: the node's agent is misconfigured. Distinguished from an + // upstream error because the remedy is on the node, not in the app. + throw UpstreamError.nodeAuthFailed + case .policyDenied, .unknownDevice: + throw UpstreamError.relayPolicyDenied + case .confirmationMissing, .confirmationMismatch: + throw UpstreamError.confirmationRejected + case .expired, .replay: + // The envelope was stale or already used. Both are clock or retry problems, and both + // resolve by re-issuing the request with a fresh id and timestamp. + throw UpstreamError.envelopeExpired + case .rateLimited: + throw UpstreamError.nodeRateLimited + case .payloadTooLarge: + throw UpstreamError.payloadTooLarge + case .badEnvelope, .badSignature: + throw UpstreamError.envelopeRejected + case .upstreamError: + // The node answered, but its local SimAdmin did not. The status is still useful: it is + // the only branch where a 4xx/5xx from the node reaches the caller. + return UpstreamResponse(status: inner.status, data: inner.body ?? Data()) + } + } +} + +extension Data { + /// Lowercase hex, which is the spelling the relay's bearer-token comparison expects. + var hexString: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Relay/RelayWire.swift b/apps/ios/Sources/MultiSimAdmin/Relay/RelayWire.swift new file mode 100644 index 0000000..68be4ae --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Relay/RelayWire.swift @@ -0,0 +1,380 @@ +import CryptoKit +import Foundation + +/// The relay envelope: what is signed, what is encrypted, and in what order. +/// +/// This is the third implementation of a format that Go and Node already implement, and the three +/// share no code on purpose. `contracts/test-vectors.json` is recomputed here by `RelayWireTests`, +/// so a byte-level disagreement shows up as a failing test rather than as a signature failure in +/// the field — where the only symptom is "the node refused the request" with no way to tell which +/// of the three encoders drifted. +/// +/// The relay is not trusted, so signing and encryption are separate concerns: Ed25519 covers a +/// canonical byte encoding of the routing fields plus the ciphertext, which stops a compromised +/// relay from retargeting a request at another node or swapping a response between two in-flight +/// requests. AES-256-GCM then carries the payload under keys from an ephemeral X25519 exchange. +public enum RelayWire { + public static let version: UInt64 = 1 + public static let suite = "X25519-ED25519-AES256GCM-HKDFSHA256" + + static let domainRequest = "msa-relay-v1-req" + static let domainResponse = "msa-relay-v1-resp" + private static let label = "msa-relay-v1" + + public static let keyBytes = 32 + public static let ivBytes = 12 + public static let tagBytes = 16 + + public enum Failure: Error, Equatable { + case unsupportedVersion(UInt64) + case unsupportedSuite(String) + case badKeyLength(field: String, got: Int) + case missingIdentityField + case signatureDoesNotVerify + case ciphertextFailedToAuthenticate + case peerKeyUnusable + case invalidResponseBody + } + + // MARK: - Envelope + + /// The outer, relay-visible structure. The relay routes on `node` and correlates on `req`, and + /// can read nothing else: `ek` is a public key, `p` is ciphertext, and `s` covers everything. + public struct Envelope: Codable, Equatable, Sendable { + public var v: UInt64 + public var node: String + public var req: String + /// The paired device id. Present on requests only: it is signed, so the relay cannot + /// relabel one phone's traffic as another's, and the agent uses it to pick the key to + /// verify against. + public var app: String? + public var ts: UInt64 + /// X25519 public key: the phone's per-request ephemeral on a request, the agent's + /// per-response ephemeral on a response. + public var ek: Data + public var alg: String + /// AES-256-GCM ciphertext with the 16-byte tag appended. + public var p: Data + public var s: Data? + + public init( + v: UInt64 = RelayWire.version, + node: String, + req: String, + app: String? = nil, + ts: UInt64, + ek: Data, + alg: String = RelayWire.suite, + p: Data, + s: Data? = nil + ) { + self.v = v + self.node = node + self.req = req + self.app = app + self.ts = ts + self.ek = ek + self.alg = alg + self.p = p + self.s = s + } + + /// The wire form is base64 for the byte fields, and the key set is fixed: the relay parses + /// these same names, and the Go agent's struct tags are the authority for them. + private enum CodingKeys: String, CodingKey { + case v, node, req, app, ts, ek, alg, p, s + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + v = try container.decode(UInt64.self, forKey: .v) + node = try container.decode(String.self, forKey: .node) + req = try container.decode(String.self, forKey: .req) + app = try container.decodeIfPresent(String.self, forKey: .app) + ts = try container.decode(UInt64.self, forKey: .ts) + alg = try container.decode(String.self, forKey: .alg) + ek = try Self.decodeBase64(container.decode(String.self, forKey: .ek), field: "ek") + p = try Self.decodeBase64(container.decode(String.self, forKey: .p), field: "p") + let signature = try container.decodeIfPresent(String.self, forKey: .s) ?? "" + s = signature.isEmpty ? nil : try Self.decodeBase64(signature, field: "s") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(v, forKey: .v) + try container.encode(node, forKey: .node) + try container.encode(req, forKey: .req) + try container.encodeIfPresent(app, forKey: .app) + try container.encode(ts, forKey: .ts) + try container.encode(ek.base64EncodedString(), forKey: .ek) + try container.encode(alg, forKey: .alg) + try container.encode(p.base64EncodedString(), forKey: .p) + if let s { + try container.encode(s.base64EncodedString(), forKey: .s) + } + } + + /// Strict base64: Go's decoder silently accepts some non-canonical forms, and a signature + /// must be computed over exactly the bytes the sender used, so a lenient decode here would + /// turn a malformed envelope into a valid-looking one. + private static func decodeBase64(_ value: String, field: String) throws -> Data { + guard let data = Data(base64Encoded: value, options: []) else { + throw Failure.invalidResponseBody + } + return data + } + } + + /// The decrypted request payload. + public struct InnerRequest: Codable, Equatable, Sendable { + /// Upstream method. The agent matches it against its policy before dialing anything. + public var m: String + /// Upstream path, already carrying any query string. The agent forwards it verbatim and + /// never assembles one from parts, so there is no path-traversal surface from the relay. + public var p: String + /// sha256 over the method, path and canonical body, produced by an explicit user gesture. + /// Mandatory for every entry in the agent's `dangerous` list and absent for reads. + public var intent: Data? + /// Raw upstream body, forwarded unchanged. Kept as bytes so the agent never re-serialises + /// JSON and thereby never changes what the intent was computed over. + public var body: Data? + + public init(m: String, p: String, intent: Data? = nil, body: Data? = nil) { + self.m = m + self.p = p + self.intent = intent + self.body = body + } + + private enum CodingKeys: String, CodingKey { case m, p, intent, body } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + m = try container.decode(String.self, forKey: .m) + p = try container.decode(String.self, forKey: .p) + intent = try container.decodeIfPresent(Data.self, forKey: .intent) + body = try container.decodeIfPresent(Data.self, forKey: .body) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(m, forKey: .m) + try container.encode(p, forKey: .p) + if let intent { try container.encode(intent, forKey: .intent) } + if let body { try container.encode(body, forKey: .body) } + } + } + + /// The decrypted response payload. Status lives inside the ciphertext so the relay cannot + /// rewrite a failure into a success. + public struct InnerResponse: Codable, Equatable, Sendable { + public var status: Int + /// The agent's own classification of what happened. Inside the ciphertext for the same + /// reason as `status`: the relay is not entitled to know why a command failed, and the + /// phone needs to tell the user what to do about it. + public var outcome: String? + public var body: Data? + + public init(status: Int, outcome: String? = nil, body: Data? = nil) { + self.status = status + self.outcome = outcome + self.body = body + } + } + + // MARK: - Canonical bytes + + /// The signing input, which is not JSON. + /// + /// Fixed field order, each field prefixed with its length as uint32be. The two integers carry + /// an explicit `00000008` length too — Go's `writeUint64` delegates to `writeBytes`, so a + /// version that emitted them bare would produce a byte string that looks identical in a hex + /// dump's payload region and still fails to verify. + /// + /// `ek` and `p` contribute their raw decoded bytes, never their base64 text, so a relay cannot + /// re-encode them differently. + public static func canonical(_ envelope: Envelope, response: Bool) throws -> Data { + guard envelope.v == version else { throw Failure.unsupportedVersion(envelope.v) } + guard envelope.alg == suite else { throw Failure.unsupportedSuite(envelope.alg) } + guard !envelope.node.trimmed.isEmpty, !envelope.req.trimmed.isEmpty else { + throw Failure.missingIdentityField + } + guard envelope.ek.count == keyBytes else { + throw Failure.badKeyLength(field: "ek", got: envelope.ek.count) + } + if !response { + guard let app = envelope.app, !app.trimmed.isEmpty else { throw Failure.missingIdentityField } + } + + var out = Data() + out.append(contentsOf: Array((response ? domainResponse : domainRequest).utf8)) + out.append(0) + appendLengthPrefixed(&out, uint64be(envelope.v)) + appendLengthPrefixed(&out, Data(envelope.node.utf8)) + appendLengthPrefixed(&out, Data(envelope.req.utf8)) + if !response, let app = envelope.app { + appendLengthPrefixed(&out, Data(app.utf8)) + } + appendLengthPrefixed(&out, uint64be(envelope.ts)) + appendLengthPrefixed(&out, envelope.ek) + appendLengthPrefixed(&out, envelope.p) + return out + } + + private static func appendLengthPrefixed(_ out: inout Data, _ value: Data) { + var length = UInt32(value.count).bigEndian + withUnsafeBytes(of: &length) { out.append(contentsOf: $0) } + out.append(value) + } + + private static func uint64be(_ value: UInt64) -> Data { + var big = value.bigEndian + return withUnsafeBytes(of: &big) { Data($0) } + } + + // MARK: - Signing + + public static func signRequest(_ envelope: inout Envelope, privateKey: Curve25519.Signing.PrivateKey) throws { + envelope.s = try privateKey.signature(for: canonical(envelope, response: false)) + } + + public static func verifyRequest(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws { + try verify(envelope, publicKey: publicKey, response: false) + } + + public static func verifyResponse(_ envelope: Envelope, publicKey: Curve25519.Signing.PublicKey) throws { + try verify(envelope, publicKey: publicKey, response: true) + } + + private static func verify( + _ envelope: Envelope, + publicKey: Curve25519.Signing.PublicKey, + response: Bool + ) throws { + let canonicalBytes = try canonical(envelope, response: response) + guard let signature = envelope.s, signature.count == 64 else { + throw Failure.signatureDoesNotVerify + } + guard publicKey.isValidSignature(signature, for: canonicalBytes) else { + throw Failure.signatureDoesNotVerify + } + } + + // MARK: - Key agreement and AEAD + + /// HKDF-SHA256, extract then expand. + public static func hkdf(secret: Data, salt: Data, info: Data, length: Int) throws -> Data { + guard length > 0, length <= 255 * SHA256.byteCount else { + throw Failure.badKeyLength(field: "hkdf", got: length) + } + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: secret), + salt: salt, + info: info, + outputByteCount: length + ) + return key.withUnsafeBytes { Data($0) } + } + + /// X25519, rejecting the all-zero output that a peer key chosen to force a known shared secret + /// would produce. + public static func sharedSecret( + privateKey: Curve25519.KeyAgreement.PrivateKey, + peerPublic: Data + ) throws -> Data { + guard peerPublic.count == keyBytes else { + throw Failure.badKeyLength(field: "peerPublic", got: peerPublic.count) + } + guard let publicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublic) else { + throw Failure.peerKeyUnusable + } + do { + let secret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) + return secret.withUnsafeBytes { Data($0) } + } catch { + throw Failure.peerKeyUnusable + } + } + + /// The AEAD key and IV for one direction of one request. + /// + /// The salt binds the node id, so a shared secret that somehow repeats across nodes still + /// yields different keys. The IV is derived from the request id and the direction rather than + /// being a fixed zero block: a fixed IV is only safe while every message uses a fresh key, and + /// that invariant is exactly what a later "reuse the session key for a batch" optimisation + /// would quietly break. + public static func deriveKey( + shared: Data, + nodeID: String, + requestID: String, + response: Bool + ) throws -> (key: SymmetricKey, iv: Data) { + guard shared.count == keyBytes else { + throw Failure.badKeyLength(field: "shared", got: shared.count) + } + let salt = Data("\(label):\(nodeID):\(requestID)".utf8) + let keyBytes = try hkdf( + secret: shared, + salt: salt, + info: Data((response ? "\(label)-resp" : "\(label)-req").utf8), + length: self.keyBytes + ) + let iv = try hkdf( + secret: shared, + salt: Data(requestID.utf8), + info: Data((response ? "iv-resp" : "iv-req").utf8), + length: ivBytes + ) + return (SymmetricKey(data: keyBytes), iv) + } + + /// Encrypts with AES-256-GCM. The tag is appended to the ciphertext, matching Node's + /// `cipher.getAuthTag()` concatenation and Go's `gcm.Seal`. + public static func seal(_ plaintext: Data, key: SymmetricKey, iv: Data) throws -> Data { + guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) } + let nonce = try AES.GCM.Nonce(data: iv) + let box = try AES.GCM.seal(plaintext, using: key, nonce: nonce) + return box.ciphertext + box.tag + } + + /// Decrypts and authenticates. A failure is terminal for the request, not a retry: a wrong + /// key, a torn payload and a forged payload are indistinguishable here, and all three mean do + /// not act. + public static func open(_ ciphertext: Data, key: SymmetricKey, iv: Data) throws -> Data { + guard iv.count == ivBytes else { throw Failure.badKeyLength(field: "iv", got: iv.count) } + guard ciphertext.count >= tagBytes else { throw Failure.ciphertextFailedToAuthenticate } + let nonce = try AES.GCM.Nonce(data: iv) + let body = ciphertext.prefix(ciphertext.count - tagBytes) + let tag = ciphertext.suffix(tagBytes) + let box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: body, tag: tag) + do { + return try AES.GCM.open(box, using: key) + } catch { + throw Failure.ciphertextFailedToAuthenticate + } + } + + // MARK: - Confirmation intent + + /// sha256(method | path | canonical body): the value a phone sends to prove a human confirmed + /// this exact write. + /// + /// The agent recomputes it from the request it is about to forward, so a relay cannot edit the + /// recipient of an SMS in flight and keep the confirmation valid. The body is canonicalised + /// the same way the web console does it, which is why `CanonicalJSON` exists as a shared, + /// test-vectored implementation rather than a call to `JSONSerialization`. + public static func intentDigest(method: String, path: String, body: Data?) throws -> Data { + let canonicalBody = try CanonicalJSON.canonicalize(body ?? Data()) + var input = Data() + input.append(contentsOf: Array(method.trimmingCharacters(in: .whitespaces).uppercased().utf8)) + input.append(UInt8(ascii: "|")) + input.append(contentsOf: Array(path.utf8)) + input.append(UInt8(ascii: "|")) + input.append(canonicalBody) + return Data(SHA256.hash(data: input)) + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift index 3d502b4..04b0130 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift @@ -28,29 +28,54 @@ public struct ConnectionResult: Equatable, Sendable { public let status: ConnectionStatus public let requestLatencyMilliseconds: Int? public let checkedAt: Date + /// Why the probe ended where it did. + /// + /// Kept alongside the status rather than folded into it because the status vocabulary is five + /// values and the failure vocabulary is much larger: in relay mode "the relay is unreachable", + /// "the agent is offline", "the envelope was refused" and "the node returned an error" are four + /// different user actions, and a single `.offline` cannot say which one happened. + public let error: UpstreamError? public init( status: ConnectionStatus, requestLatencyMilliseconds: Int? = nil, - checkedAt: Date + checkedAt: Date, + error: UpstreamError? = nil ) { self.status = status self.requestLatencyMilliseconds = requestLatencyMilliseconds self.checkedAt = checkedAt + self.error = error + } + + /// How old this reading is, for the freshness vocabulary. A relay reading is inherently older + /// than a direct one, and saying so is the difference between "online" meaning now and meaning + /// forty seconds ago. + public var freshness: SnapshotFreshness { + SnapshotFreshness.classify(age: Date().timeIntervalSince(checkedAt)) } } public struct ConnectionProbe: Sendable { - private let client: any SimAdminClientProtocol + private let transport: any NodeTransport + public init(transport: any NodeTransport) { + self.transport = transport + } + + /// Direct-mode construction, kept for the existing call sites and tests. public init(client: any SimAdminClientProtocol) { - self.client = client + self.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore())) } public func test(_ instance: Instance) async throws -> ConnectionResult { let startedAt = DispatchTime.now() do { - let response = try await client.send(.health, baseURL: instance.origin, cookie: nil) + let response = try await transport.send( + .health, + instanceID: instance.id, + endpoint: instance.endpoint + ) let latency = Self.elapsedMilliseconds(since: startedAt) if response.status == 401 || response.status == 403 { return ConnectionResult( @@ -66,26 +91,42 @@ public struct ConnectionProbe: Sendable { ) } catch { let latency = Self.elapsedMilliseconds(since: startedAt) - switch error { - case UpstreamError.unauthorized: - return ConnectionResult( - status: .authRequired, - requestLatencyMilliseconds: latency, - checkedAt: Date() - ) - case UpstreamError.timeout, is URLError: - return ConnectionResult( - status: .offline, - requestLatencyMilliseconds: latency, - checkedAt: Date() - ) - default: - return ConnectionResult( - status: .degraded, - requestLatencyMilliseconds: latency, - checkedAt: Date() - ) - } + let upstream = error as? UpstreamError + return ConnectionResult( + status: Self.status(for: upstream, error: error), + requestLatencyMilliseconds: latency, + checkedAt: Date(), + error: upstream + ) + } + } + + /// Maps a failure onto the five-value status vocabulary the list already uses, while the typed + /// error travels alongside it for the detail view. + /// + /// The relay-mode cases are mapped rather than collapsed: an offline agent is a node problem the + /// user waits out, a rejected envelope is a pairing problem that will never fix itself, and an + /// unreachable relay is neither. + static func status(for upstream: UpstreamError?, error: Error) -> ConnectionStatus { + switch upstream { + case .unauthorized: + return .authRequired + case .timeout, .relayTimeout, .relayUnreachable, .agentOffline, .agentTimeout, .nodeBusy: + return .offline + case .relayRejectedDevice, .relayNodeUnknown: + // The pairing is gone: retrying cannot help, and the user has to pair again. + return .offline + case .relayPolicyDenied, .confirmationRejected, .envelopeRejected, + .envelopeExpired, .relayResponseUnverifiable, .relayResponseMismatch, + .relayBadEnvelope, .nodeAuthFailed, .payloadTooLarge: + return .degraded + case .relayRateLimited, .nodeRateLimited: + return .degraded + case .invalidRequest, .notSupported, .transportFailed, .invalidResponse, .forbidden: + return .degraded + case .none: + if error is URLError { return .offline } + return .degraded } } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index d7c78ca..1540706 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -9,22 +9,49 @@ enum DefaultContainer { location: documentFileURL("log-center.json") ) + /// The phone's relay identity, shared by every relay transport. + /// + /// It is loaded once rather than per transport: the key pair must be the same one the node + /// paired with, and a second load that regenerated it would silently invalidate every pairing. + static let deviceIdentities = FileDeviceIdentityStore( + location: documentFileURL("device-identity.json") + ) + static func documentFileURL(_ name: String) -> URL { let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return directory.appendingPathComponent(name) } + static func makeInstanceRepository() -> FileInstanceRepository { + FileInstanceRepository(location: documentFileURL("instances.json")) + } + + /// The transport pair every feature service uses: direct on the LAN, relay through the agent. + /// + /// The relay side reads the identity file per request rather than holding a copy: pairing can + /// happen after a transport is built, and a held copy would sign with keys the node has never + /// seen, which fails as an unreadable request with no useful cause. + private static func makeTransport( + client: any SimAdminClientProtocol, + sessions: InstanceSessionStore + ) -> NodeTransportRouter { + let identities = deviceIdentities + return NodeTransportRouter( + direct: DirectTransport(client: client, sessions: sessions, credentials: credentials), + relay: RelayTransport(identity: { try identities.loadOrCreate() }) + ) + } + @MainActor static func makeFleetModel() -> FleetViewModel { purgeLegacyGrouping() + let sessions = InstanceSessionStore() return FleetViewModel( - repository: FileInstanceRepository(location: documentFileURL("instances.json")), - probe: ConnectionProbe(client: SimAdminClient()), + repository: makeInstanceRepository(), + probe: ConnectionProbe(transport: makeTransport(client: SimAdminClient(), sessions: sessions)), credentials: credentials, deviceService: DeviceService( - client: SimAdminClient(), - sessions: InstanceSessionStore(), - credentials: credentials + transport: makeTransport(client: SimAdminClient(), sessions: sessions) ) ) } @@ -37,12 +64,11 @@ enum DefaultContainer { @MainActor static func makeMessagesModel() -> MessagesViewModel { - MessagesViewModel( - repository: FileInstanceRepository(location: documentFileURL("instances.json")), + let sessions = InstanceSessionStore() + return MessagesViewModel( + repository: makeInstanceRepository(), service: MessageService( - client: SimAdminClient(), - sessions: InstanceSessionStore(), - credentials: credentials + transport: makeTransport(client: SimAdminClient(), sessions: sessions) ) ) } @@ -50,12 +76,11 @@ enum DefaultContainer { @MainActor static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel { let store = logStore + let sessions = InstanceSessionStore() return LogsViewModel( - repository: FileInstanceRepository(location: documentFileURL("instances.json")), + repository: makeInstanceRepository(), service: LogCenterService( - client: SimAdminClient(), - sessions: InstanceSessionStore(), - credentials: credentials, + transport: makeTransport(client: SimAdminClient(), sessions: sessions), store: store ), store: store, @@ -70,14 +95,15 @@ enum DefaultContainer { @MainActor static func makeDeviceModel(instance: Instance) -> DeviceViewModel { - DeviceViewModel( + let sessions = InstanceSessionStore() + return DeviceViewModel( instance: instance, service: DeviceService( - client: SimAdminClient(), - sessions: InstanceSessionStore(), - credentials: credentials + transport: makeTransport(client: SimAdminClient(), sessions: sessions) ), - probe: ConnectionProbe(client: SimAdminClient()) + probe: ConnectionProbe( + transport: makeTransport(client: SimAdminClient(), sessions: sessions) + ) ) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift b/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift index aa16498..7e4fa8d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift @@ -21,15 +21,23 @@ public struct InstanceSessionService: Sendable { self.client = client } + /// Logs in directly and stores the session cookie. + /// + /// Only meaningful in direct mode: a relay instance has no origin to dial and no cookie to keep, + /// because the node's agent holds that session itself. Passing one here returns the explicit + /// unauthenticated state rather than dialing something arbitrary. public func login(_ instance: Instance, password: String) async throws -> SessionMetadata { + guard let origin = instance.endpoint.directOrigin else { + return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date()) + } do { let response = try await client.send( .authLogin(password: password), - baseURL: instance.origin, + baseURL: origin, cookie: nil ) if let cookie = Self.sessionCookie(response.setCookie) { - await store.set(instance.id, origin: instance.origin, cookie: cookie) + await store.set(instance.id, origin: origin, cookie: cookie) return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date()) } } catch { @@ -40,15 +48,19 @@ public struct InstanceSessionService: Sendable { } public func logout(_ instance: Instance) async throws -> SessionMetadata { - if let cookie = await store.cookie(for: instance.id, origin: instance.origin) { - _ = try? await client.send(.authLogout, baseURL: instance.origin, cookie: cookie) + guard let origin = instance.endpoint.directOrigin else { + return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date()) + } + if let cookie = await store.cookie(for: instance.id, origin: origin) { + _ = try? await client.send(.authLogout, baseURL: origin, cookie: cookie) } await store.clear(instance.id) return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date()) } public func cookie(for instance: Instance) async -> String? { - await store.cookie(for: instance.id, origin: instance.origin) + guard let origin = instance.endpoint.directOrigin else { return nil } + return await store.cookie(for: instance.id, origin: origin) } static func sessionCookie(_ header: String?) -> String? { diff --git a/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift b/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift index 53a37ce..4c92236 100644 --- a/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift +++ b/apps/ios/Sources/MultiSimAdmin/Support/ErrorMessage.swift @@ -1,4 +1,63 @@ +import Foundation + +/// User-facing text for every failure the app can produce. +/// +/// The relay work is what forced this out of `ConnectionProbe`: with one hop there was one thing +/// that could go wrong, and "请求失败" was an acceptable summary. With two hops there are four +/// distinct situations that call for four different actions, and a message that cannot tell them +/// apart is the difference between a five-second fix and a support thread. public enum ErrorMessage { public static let network = "暂时无法连接实例,请稍后重试。" public static let unauthorized = "实例登录状态已失效,请重新登录。" + + // MARK: - Relay mode + + /// Describes a relay-mode failure in terms of what the user should do next. + public static func describe(_ error: UpstreamError, nodeName: String) -> String { + switch error { + case .relayUnreachable: + return "无法连接中继服务器。请检查网络,或确认中继是否在运行。" + case .relayTimeout: + return "中继响应超时。稍后重试;如果反复出现,请检查中继负载。" + case .relayRejectedDevice: + return "中继已不再接受这台设备的凭据,需要在节点上重新配对。" + case .relayNodeUnknown: + return "中继不认识节点 \(nodeName)。可能节点已离线并被移出,或这台手机未与该节点配对。" + case .agentOffline: + return "节点 \(nodeName) 的 agent 当前未连接到中继。等它上线后重试。" + case .nodeBusy: + return "节点 \(nodeName) 正在处理其它请求,稍后重试。" + case .agentTimeout: + // The one message that must not invite a blind retry: the node may have executed it. + return "节点 \(nodeName) 未在时限内应答。该操作可能已经执行,请先确认结果再决定是否重试。" + case .relayBadEnvelope: + return "中继拒绝了发往节点 \(nodeName) 的请求信封。这通常意味着两端版本不一致。" + case .relayRateLimited: + return "对节点 \(nodeName) 的请求过于频繁,已被中继限流。稍后再试。" + case .relayPolicyDenied: + return "节点 \(nodeName) 的 agent 不允许该操作。该路径不在它的授权范围内。" + case .confirmationRejected: + return "节点 \(nodeName) 拒绝了这次确认:确认信息与请求不匹配,操作未执行。请重新发起。" + case .envelopeExpired: + return "对节点 \(nodeName) 的请求已过期或重复。请重新发起;如果反复出现,请检查手机与节点的时钟。" + case .nodeRateLimited: + return "节点 \(nodeName) 限制了危险写操作频率,稍后再试。" + case .nodeAuthFailed: + return "节点 \(nodeName) 的 agent 无法登录本机 SimAdmin。需要在节点上修正 agent 的密码配置。" + case .payloadTooLarge: + return "请求内容超过节点 \(nodeName) 允许的大小。" + case .relayResponseUnverifiable: + return "节点 \(nodeName) 的应答未通过签名校验,已丢弃。这可能是中继被篡改或密钥不匹配。" + case .relayResponseMismatch: + return "中继返回了与请求不匹配的应答,已丢弃。" + case .envelopeRejected: + return "节点 \(nodeName) 拒绝了该请求的签名或格式。可能需要在节点上重新配对。" + case .unauthorized: + return unauthorized + case .timeout, .transportFailed: + return network + default: + return "请求失败:\(error)" + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift index 41919ed..03d1536 100644 --- a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift @@ -62,6 +62,21 @@ public enum UpstreamRoute: Hashable, Sendable { return nil } } + + /// Whether the node's agent will demand a confirmation intent for this route. + /// + /// This is the app's copy of `policy.dangerous` in `contracts/agent-policy.json`, and the two + /// are asserted equal by the relay repository's policy test. It is a copy rather than a shared + /// file because the app cannot read the relay's repository at build time; the test is what + /// keeps it from drifting. + public var requiresConfirmation: Bool { + switch self { + case .smsSend, .smsBatchDelete: + return true + default: + return false + } + } } public enum UpstreamError: Error, Equatable { @@ -72,4 +87,46 @@ public enum UpstreamError: Error, Equatable { case timeout case transportFailed case invalidResponse + + // Relay-mode failures. These are deliberately separate cases rather than a reuse of + // transportFailed: in relay mode the user has four genuinely different situations to tell + // apart, and a single "请求失败" for all of them is what makes a two-hop architecture + // impossible to operate. + /// The relay could not be reached at all. + case relayUnreachable + /// The relay took too long to answer. + case relayTimeout + /// The relay rejected this phone's bearer token: the pairing is no longer valid there. + case relayRejectedDevice + /// The relay does not know this node, or this phone is not paired to it. + case relayNodeUnknown + /// The node's agent is not connected to the relay. + case agentOffline + /// The node is connected but already has the maximum number of requests in flight. + case nodeBusy + /// The node accepted the request but did not answer within the relay's deadline. It may or may + /// not have executed it, which is why the message must not invite a blind retry. + case agentTimeout + /// The relay refused the envelope before delivering it. + case relayBadEnvelope + /// The relay is rate limiting this phone. + case relayRateLimited + /// The node's agent rejected the request: not on its allowlist, or the device is not paired. + case relayPolicyDenied + /// The node received the request and the confirmation intent did not match. + case confirmationRejected + /// The envelope was outside the node's freshness window, or its id had already been used. + case envelopeExpired + /// The node's own rate limit refused a dangerous write. + case nodeRateLimited + /// The node's agent could not authenticate to its local SimAdmin. + case nodeAuthFailed + /// The request body exceeded the node's limit. + case payloadTooLarge + /// The node signed nothing, or the answer was not for this request. + case relayResponseUnverifiable + /// The relay answered about a different request or node. + case relayResponseMismatch + /// The node refused the envelope's signature or shape. + case envelopeRejected } diff --git a/apps/ios/Tests/MultiSimAdminTests/AppRoutesContractTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppRoutesContractTests.swift new file mode 100644 index 0000000..794f42e --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/AppRoutesContractTests.swift @@ -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 + } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift index c9c754e..2736264 100644 --- a/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/DeviceServiceTests.swift @@ -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) diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift index e1d81ce..59927ed 100644 --- a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -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 diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceEndpointMigrationTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceEndpointMigrationTests.swift new file mode 100644 index 0000000..0937dd8 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceEndpointMigrationTests.swift @@ -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" + ) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift index e557196..8a78469 100644 --- a/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift @@ -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) diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift index dec61e8..d7878a2 100644 --- a/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift @@ -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) } diff --git a/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift index eaf1d9a..ac53835 100644 --- a/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/MessageServiceTests.swift @@ -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")) diff --git a/apps/ios/Tests/MultiSimAdminTests/RelayPairingTests.swift b/apps/ios/Tests/MultiSimAdminTests/RelayPairingTests.swift new file mode 100644 index 0000000..9b2682e --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/RelayPairingTests.swift @@ -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 } + } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/RelayTransportTests.swift b/apps/ios/Tests/MultiSimAdminTests/RelayTransportTests.swift new file mode 100644 index 0000000..e773695 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/RelayTransportTests.swift @@ -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() + 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) + } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/RelayWireTests.swift b/apps/ios/Tests/MultiSimAdminTests/RelayWireTests.swift new file mode 100644 index 0000000..e712b12 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/RelayWireTests.swift @@ -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.. 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}" + ) + } +} diff --git a/docs/ios-relay-architecture.md b/docs/ios-relay-architecture.md index 8e65cb5..349bbad 100644 --- a/docs/ios-relay-architecture.md +++ b/docs/ios-relay-architecture.md @@ -283,6 +283,27 @@ agent 都从它加载,并加一个测试断言两侧解析结果一致。这 P2/P3 可并行。P4 是唯一动到现有 iOS 数据模型的部分,风险集中在迁移,建议单独一个分支并配 旧数据升级测试。 +> **实现进度(2026-09)**:P1–P5 已落地,P6 部分完成。 +> +> - P1:`contracts/agent-policy.json` + `app-routes.json` 钉死策略面,`relay/src/errors.js` 枚举 +> 中继错误码,iOS 侧 `RelayErrorCode` / `AgentOutcome` / `SnapshotFreshness` 与之一一对应; +> `scripts/export-app-routes.swift` 生成 vendored 路由表,iOS 的 `AppRoutesContractTests` +> 反向断言它仍与应用可构造的路由一致(此前这条链中间有一个手抄环节)。 +> - P4:`InstanceEndpoint` 取代 `Instance.origin`,旧数据由 `init(from:)` 读成 `.direct`, +> `FileInstanceRepository` 只写新形状;迁移由 `InstanceEndpointMigrationTests` 用字面量旧 JSON 覆盖。 +> `NodeTransport` 抽象 + `DirectTransport`/`RelayTransport`/`NodeTransportRouter`。 +> `RelayWire` 在 Swift 侧重算 `contracts/test-vectors.json`(见下)。 +> - P5:卡片通道徽标与 freshness 副标题、`ErrorMessage.describe` 的四类以上分层文案、 +> 危险写 intent 由 `RelayTransport.encode` 在发送瞬间生成、配对 sheet(`RelayPairingView`)。 +> 吊销入口仍未做:目前需要在内网对节点跑一次 `--pair --destroy`。 +> - P6:部署与渗透自查未做。 +> +> **Swift 侧签名不是逐字节可复算的**:CryptoKit 的 Ed25519 签名是随机化的(同一密钥同一消息 +> 两次签名结果不同),Go 与 Node 用的是 RFC 8032 确定性实现。因此 `RelayWireTests` 校验的是 +> 更强的性质——双方各自验证对方产出的签名,以及签名输入与向量中的规范化字节完全相等——而不是 +> 签名本身相等。这一点已实测确认:CryptoKit 能验证 Go 生成的签名,Go 也能验证 CryptoKit 生成的 +> 签名。 + ## 10. 已知代价 - 中继被攻破 = 服务不可用(拒绝投递),不等于数据泄露。这是本方案换来的东西。 diff --git a/scripts/export-app-routes.swift b/scripts/export-app-routes.swift new file mode 100644 index 0000000..000fab2 --- /dev/null +++ b/scripts/export-app-routes.swift @@ -0,0 +1,120 @@ +#!/usr/bin/env swift +// +// Regenerates contracts/app-routes.json in the simadmin-relay repository from UpstreamRoute. +// +// This file exists because the vendored route list and the app's own enum are two copies of one +// fact, and the relay repository's policy test asserts the agent's allowlist equals the vendored +// copy. Without a generator the chain has a hand-edited link in the middle: the policy test would +// pass against a stale file while the app had already grown a route, and the failure would surface +// at runtime as "policy_denied" for an operation the UI offers. +// +// The Swift test `AppRoutesContractTests` checks the reverse direction — that the vendored file +// matches what the app can construct — so the two together make drift a build failure rather than a +// field report. +// +// Usage: +// swift scripts/export-app-routes.swift [output-path] +// +// The default output path assumes the two repositories sit side by side, which is how they are +// checked out here: /Users/chick/.local/opt/multi-simadmin and /Users/chick/.local/opt/simadmin-relay. + +import Foundation + +// MARK: - The route surface, mirrored from UpstreamRoute + +/// One route: the method and the path, with the query shape the app actually sends. +/// +/// This is deliberately a separate declaration rather than a parse of `UpstreamRoute.swift`. A +/// parser would have to evaluate Swift to resolve the `path` computed property, and a generator that +/// half-understands the enum is worse than one that states the surface and is checked against it. +struct Route { + let method: String + let path: String + /// True when the route carries a query string the app varies at runtime. The vendored file + /// records the path without the query, because the agent matches on the path prefix and + /// forwards whatever query arrived. + let dynamicQuery: Bool +} + +let routes: [Route] = [ + Route(method: "GET", path: "/api/health", dynamicQuery: false), + Route(method: "GET", path: "/api/device", dynamicQuery: false), + Route(method: "GET", path: "/api/sim", dynamicQuery: false), + Route(method: "GET", path: "/api/network", dynamicQuery: false), + Route(method: "GET", path: "/api/network/signal-strength", dynamicQuery: false), + Route(method: "GET", path: "/api/stats", dynamicQuery: false), + Route(method: "GET", path: "/api/stats/cpu", dynamicQuery: false), + Route(method: "GET", path: "/api/connectivity", dynamicQuery: false), + Route(method: "GET", path: "/api/sms/list", dynamicQuery: true), + Route(method: "POST", path: "/api/sms/send", dynamicQuery: false), + Route(method: "POST", path: "/api/sms/batch-delete", dynamicQuery: false), +] + +/// Login and logout are listed separately and never proxied: the agent logs into its local +/// SimAdmin itself, which is what keeps a node password from ever traversing the relay. +let authRoutes = ["/api/auth/login", "/api/auth/logout"] + +// MARK: - Generation + +let sourceFile = "apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift" +let defaultOutput = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/apps/ios", with: "") + .appending("/../simadmin-relay/contracts/app-routes.json") + +let outputPath = CommandLine.arguments.count > 1 + ? CommandLine.arguments[1] + : URL(fileURLWithPath: defaultOutput).standardizedFileURL.path + +/// Serialised by hand rather than with `JSONSerialization`, so the file keeps the key order and the +/// two-space indentation a reviewer expects to see in a diff. A generator whose output churns on +/// every run is a generator people stop reading. +var lines: [String] = [] +lines.append("{") +lines.append(" \"schemaVersion\": 1,") +lines.append(" \"name\": \"msa-app-routes\",") +lines.append(" \"comment\": \"Vendored copy of every upstream path the iPhone app can construct, derived from UpstreamRoute in multi-simadmin. This file exists so the agent policy can be pinned to what the app actually asks for. Regenerate it, never hand-edit it.\",") +lines.append(" \"source\": {") +lines.append(" \"repository\": \"Hermes/multi-simadmin\",") +lines.append(" \"branch\": \"ios-native-ipa\",") +lines.append(" \"file\": \"\(sourceFile)\",") +lines.append(" \"generator\": \"scripts/export-app-routes.swift\"") +lines.append(" },") +lines.append(" \"auth\": [\(authRoutes.map { "\"\($0)\"" }.joined(separator: ", "))],") +lines.append(" \"routes\": [") +for (index, route) in routes.enumerated() { + let suffix = index == routes.count - 1 ? "" : "," + var entry = " { \"method\": \"\(route.method)\", \"path\": \"\(route.path)\"" + if route.dynamicQuery { + entry += ", \"query\": \"dynamic\"" + } + entry += " }\(suffix)" + lines.append(entry) +} +lines.append(" ]") +lines.append("}") +let document = lines.joined(separator: "\n") + "\n" + +// Validate before writing: a generated file that the policy test would reject is worse than no +// regeneration at all, because it looks authoritative. +guard Set(routes.map(\.path)).count == routes.count else { + FileHandle.standardError.write(Data("export-app-routes: duplicate paths in the route list\n".utf8)) + exit(1) +} +for route in routes { + guard route.path.hasPrefix("/api/") else { + FileHandle.standardError.write(Data("export-app-routes: \(route.path) is not under /api/\n".utf8)) + exit(1) + } + guard !route.path.contains("?") else { + FileHandle.standardError.write(Data("export-app-routes: \(route.path) carries a query string; the vendored path must not\n".utf8)) + exit(1) + } +} + +do { + try document.write(toFile: outputPath, atomically: true, encoding: .utf8) + print("wrote \(outputPath)") +} catch { + FileHandle.standardError.write(Data("export-app-routes: \(error)\n".utf8)) + exit(1) +}