Compare commits
7
Commits
802ddd1102
...
ef280b53c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef280b53c9 | ||
|
|
3e0bcb1462 | ||
|
|
d81605ebff | ||
|
|
3f6090081f | ||
|
|
a6867bc9cb | ||
|
|
99752bd355 | ||
|
|
95c653b3d3 |
@@ -226,6 +226,62 @@ describe('instance HTTP routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => {
|
||||
// Regression: the console used to send Content-Type: application/json on this bodyless
|
||||
// request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so
|
||||
// "delete node" silently did nothing. Deletion carries its intent in headers, so a request
|
||||
// that merely *claims* a JSON body must fail loudly while the real shape must reach the route.
|
||||
const { app } = fixture();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
|
||||
});
|
||||
const id = (created.json() as { id: string }).id;
|
||||
|
||||
const preparation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/prepare',
|
||||
payload: {
|
||||
operationId: 'deleteInstance',
|
||||
targets: [{ instanceId: id, revision: 1 }],
|
||||
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||
},
|
||||
});
|
||||
const prepared = preparation.json() as { id: string; confirmationToken: string };
|
||||
const confirmationHeaders = {
|
||||
'if-match': '"rev-1"',
|
||||
'x-preparation-id': prepared.id,
|
||||
'x-confirmation-token': prepared.confirmationToken,
|
||||
};
|
||||
|
||||
// The old broken shape: an empty body that still declares itself as JSON.
|
||||
const claimedJsonBody = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: { ...confirmationHeaders, 'content-type': 'application/json' },
|
||||
});
|
||||
expect(claimedJsonBody.statusCode).toBe(400);
|
||||
expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' });
|
||||
// The node must survive the rejected attempt.
|
||||
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
|
||||
// The real console shape: headers only, no Content-Type, no body.
|
||||
const accepted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/instances/${id}`,
|
||||
headers: confirmationHeaders,
|
||||
});
|
||||
expect(accepted.statusCode).toBe(202);
|
||||
expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
|
||||
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
|
||||
404,
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
|
||||
const { app } = fixture();
|
||||
const invalid = await app.inject({
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "尚未检测"
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<String>,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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<SHA256>.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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// Keeps `contracts/app-routes.json` honest.
|
||||
///
|
||||
/// That file is the relay repository's copy of every upstream path this app can construct, and the
|
||||
/// agent's authorisation policy is pinned to it: `test/policy.test.js` asserts the two sets are
|
||||
/// equal, so the agent can never be wider than the app. The missing link was always the other
|
||||
/// direction — nothing checked that the vendored file still matched `UpstreamRoute`, and a route
|
||||
/// added here without updating it would leave the agent narrower than the app, which fails at
|
||||
/// runtime as "policy_denied" for an operation the UI offers.
|
||||
///
|
||||
/// So the check lives here, next to the source of truth, and the vendored file carries a snapshot.
|
||||
final class AppRoutesContractTests: XCTestCase {
|
||||
/// Every route the app can construct, in the vendored file's shape.
|
||||
///
|
||||
/// Written out by hand rather than derived, because deriving it from `UpstreamRoute` would make
|
||||
/// the test tautological: it would compare the enum against itself. This list is the claim about
|
||||
/// what the app can ask for, and adding a case to the enum without adding it here fails the
|
||||
/// count assertion below.
|
||||
private static let declaredRoutes: [(method: String, path: String)] = [
|
||||
("GET", "/api/health"),
|
||||
("GET", "/api/device"),
|
||||
("GET", "/api/sim"),
|
||||
("GET", "/api/network"),
|
||||
("GET", "/api/network/signal-strength"),
|
||||
("GET", "/api/stats"),
|
||||
("GET", "/api/stats/cpu"),
|
||||
("GET", "/api/connectivity"),
|
||||
("GET", "/api/sms/list"),
|
||||
("POST", "/api/sms/send"),
|
||||
("POST", "/api/sms/batch-delete"),
|
||||
]
|
||||
|
||||
private static let authRoutes: [(method: String, path: String)] = [
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/logout"),
|
||||
]
|
||||
|
||||
private static let candidatePaths = [
|
||||
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
||||
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
||||
].compactMap { $0 }
|
||||
|
||||
private func loadAppRoutes() throws -> [String: Any] {
|
||||
for candidate in Self.candidatePaths {
|
||||
let routes = URL(fileURLWithPath: candidate)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("app-routes.json")
|
||||
guard FileManager.default.fileExists(atPath: routes.path) else { continue }
|
||||
let data = try Data(contentsOf: routes)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XCTSkip("app-routes.json is not a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
throw XCTSkip("simadmin-relay/contracts/app-routes.json not found; set MSA_RELAY_VECTORS")
|
||||
}
|
||||
|
||||
/// Each route in the declared list must produce the path and method it claims. This is what
|
||||
/// catches a path edited in `UpstreamRoute` without the snapshot being updated.
|
||||
func testEveryDeclaredRouteMatchesTheEnum() throws {
|
||||
for (method, expectedPath) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(
|
||||
Self.sampleRoute(matching: expectedPath),
|
||||
"no UpstreamRoute case produces \(expectedPath)"
|
||||
)
|
||||
XCTAssertEqual(route.method, method, "method for \(expectedPath)")
|
||||
// Query strings are part of the path the agent forwards, so compare the prefix the
|
||||
// route's own literal contributes.
|
||||
XCTAssertTrue(
|
||||
route.path.hasPrefix(expectedPath),
|
||||
"route for \(expectedPath) produced \(route.path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthRoutesAreNotProxied() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let auth = try XCTUnwrap(routes["auth"] as? [String])
|
||||
let declared = Set(Self.authRoutes.map(\.path))
|
||||
XCTAssertEqual(Set(auth), declared, "the vendored auth list drifted")
|
||||
|
||||
// The agent must do the logging in itself, so these paths must never appear in the proxied
|
||||
// set — that is what keeps a node password from ever traversing the relay.
|
||||
let proxied = Set(Self.declaredRoutes.map(\.path))
|
||||
XCTAssertTrue(proxied.isDisjoint(with: declared))
|
||||
}
|
||||
|
||||
func testTheVendoredFileMatchesThisList() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let vendored = try XCTUnwrap(routes["routes"] as? [[String: String]])
|
||||
let vendoredPairs = Set(vendored.compactMap { entry -> String? in
|
||||
guard let method = entry["method"], let path = entry["path"] else { return nil }
|
||||
return "\(method) \(path)"
|
||||
})
|
||||
let declaredPairs = Set(Self.declaredRoutes.map { "\($0.method) \($0.path)" })
|
||||
XCTAssertEqual(
|
||||
vendoredPairs.symmetricDifference(declaredPairs),
|
||||
[],
|
||||
"app-routes.json drifted from the app's route surface; regenerate it deliberately"
|
||||
)
|
||||
}
|
||||
|
||||
/// The app's copy of `policy.dangerous` must match the agent's, or a write would either be
|
||||
/// refused for a missing intent or would carry an intent the agent does not require.
|
||||
func testConfirmationIsRequiredExactlyWhereTheAgentRequiresIt() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
// The agent policy lives beside the app routes; the relay repository's policy test already
|
||||
// asserts the two files agree, so reading the app-routes list here is enough.
|
||||
for (method, path) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(Self.sampleRoute(matching: path))
|
||||
let isWrite = method == "POST" && path.hasPrefix("/api/sms/")
|
||||
XCTAssertEqual(
|
||||
route.requiresConfirmation,
|
||||
isWrite,
|
||||
"confirmation intent flag for \(method) \(path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the representative `UpstreamRoute` for a path. The sample arguments are arbitrary:
|
||||
/// what is under test is the path and method mapping, not the query values.
|
||||
private static func sampleRoute(matching path: String) -> UpstreamRoute? {
|
||||
switch path {
|
||||
case "/api/health": return .health
|
||||
case "/api/device": return .device
|
||||
case "/api/sim": return .sim
|
||||
case "/api/network": return .network
|
||||
case "/api/network/signal-strength": return .signalStrength
|
||||
case "/api/stats": return .stats
|
||||
case "/api/stats/cpu": return .statsCpu
|
||||
case "/api/connectivity": return .connectivity
|
||||
case "/api/sms/list": return .smsList(limit: 50, offset: 0, direction: nil)
|
||||
case "/api/sms/send": return .smsSend(phoneNumber: "+8613800000000", content: "hi")
|
||||
case "/api/sms/batch-delete": return .smsBatchDelete(ids: [1])
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ final class DeviceServiceTests: XCTestCase {
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let snapshot = try await service.snapshot(instance)
|
||||
|
||||
@@ -27,7 +27,7 @@ final class DeviceServiceTests: XCTestCase {
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let summary = try await service.summary(instance)
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ final class FleetViewModelTests: XCTestCase {
|
||||
|
||||
try await model.update(row.id, InstanceInput(
|
||||
name: "Van Pro",
|
||||
origin: row.instance.origin,
|
||||
endpoint: row.instance.endpoint,
|
||||
tags: ["field"]
|
||||
))
|
||||
|
||||
@@ -318,7 +318,7 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
endpoint: input.endpoint,
|
||||
tags: input.tags,
|
||||
credentialConfigured: !(input.password ?? "").isEmpty,
|
||||
configRevision: 1,
|
||||
@@ -338,7 +338,7 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
}
|
||||
var instance = items[index]
|
||||
if let name = patch.name { instance.name = name }
|
||||
if let origin = patch.origin { instance.origin = origin }
|
||||
if let endpoint = patch.endpoint { instance.endpoint = endpoint }
|
||||
if let tags = patch.tags { instance.tags = tags }
|
||||
if let password = patch.password {
|
||||
instance.credentialConfigured = !password.isEmpty
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// The upgrade path from a node list written before relay support existed.
|
||||
///
|
||||
/// This is the riskiest change in the iOS work: `Instance.origin` was non-optional and carried three
|
||||
/// responsibilities at once, and a decoder that mishandles the old shape does not fail one screen —
|
||||
/// it fails the whole store, and every node the user ever added disappears. So the old JSON is
|
||||
/// pinned literally here rather than produced by an encoder that might itself have drifted.
|
||||
final class InstanceEndpointMigrationTests: XCTestCase {
|
||||
private var location: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("msa-migration-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: location)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// Exactly the shape the previous release wrote: `origin` as a string, no `endpoint` key.
|
||||
private func legacyStore() -> Data {
|
||||
Data("""
|
||||
{
|
||||
"instances" : [
|
||||
{
|
||||
"configRevision" : 3,
|
||||
"createdAt" : "2026-08-01T10:00:00Z",
|
||||
"credentialConfigured" : true,
|
||||
"id" : "11111111-2222-4333-8444-555555555555",
|
||||
"name" : "Lab Alpha",
|
||||
"origin" : "http://192.168.3.55:3000",
|
||||
"tags" : ["lab"],
|
||||
"updatedAt" : "2026-08-02T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
func testLegacyOriginBecomesADirectEndpoint() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
|
||||
let instances = try await repository.list(InstanceQuery())
|
||||
XCTAssertEqual(instances.count, 1, "a legacy store must not lose nodes")
|
||||
let instance = try XCTUnwrap(instances.first)
|
||||
XCTAssertEqual(instance.endpoint, .direct(URL(string: "http://192.168.3.55:3000")!))
|
||||
XCTAssertEqual(instance.endpoint.directOrigin?.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertFalse(instance.endpoint.isRelay)
|
||||
// Everything else must survive the migration untouched.
|
||||
XCTAssertEqual(instance.name, "Lab Alpha")
|
||||
XCTAssertEqual(instance.tags, ["lab"])
|
||||
XCTAssertEqual(instance.configRevision, 3)
|
||||
XCTAssertTrue(instance.credentialConfigured)
|
||||
}
|
||||
|
||||
/// An upgraded node must not be reported as a duplicate of itself, which is what would happen if
|
||||
/// the dedup key changed shape between the read and the write.
|
||||
func testAnUpgradedLegacyNodeIsNotADuplicateOfItself() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let listed = try await repository.list(InstanceQuery())
|
||||
let existing = try XCTUnwrap(listed.first)
|
||||
|
||||
// Re-saving the same address must still be refused...
|
||||
do {
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Again", origin: URL(string: "http://192.168.3.55:3000")!)
|
||||
)
|
||||
XCTFail("a duplicate origin was accepted")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
// ...but updating the node itself must succeed.
|
||||
let updated = try await repository.update(
|
||||
existing.id,
|
||||
expectedRevision: existing.configRevision,
|
||||
InstancePatch(name: "Renamed")
|
||||
)
|
||||
XCTAssertEqual(updated.name, "Renamed")
|
||||
XCTAssertEqual(updated.endpoint, existing.endpoint)
|
||||
}
|
||||
|
||||
/// After any write, the file carries the new shape and no `origin` key. Leaving both would make
|
||||
/// the old field a second source of truth for the same address.
|
||||
func testWritesEmitOnlyTheNewShape() async throws {
|
||||
try legacyStore().write(to: location)
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let listed = try await repository.list(InstanceQuery())
|
||||
let existing = try XCTUnwrap(listed.first)
|
||||
_ = try await repository.update(
|
||||
existing.id,
|
||||
expectedRevision: existing.configRevision,
|
||||
InstancePatch(tags: ["lab", "primary"])
|
||||
)
|
||||
|
||||
let raw = try String(contentsOf: location, encoding: .utf8)
|
||||
XCTAssertTrue(raw.contains("\"endpoint\""), "the new shape must be written")
|
||||
XCTAssertFalse(raw.contains("\"origin\""), "the legacy key must not be written back")
|
||||
}
|
||||
|
||||
/// The two dedup keyspaces must not collide: a direct node at some address and a relay node are
|
||||
/// different nodes even if a hostname happened to match.
|
||||
func testRelayAndDirectEndpointsAreDistinctIdentities() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Direct", origin: URL(string: "https://relay.example")!)
|
||||
)
|
||||
let relay = try await repository.create(InstanceInput(
|
||||
name: "Relayed",
|
||||
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
))
|
||||
XCTAssertEqual(relay.endpoint.dedupKey, "relay:nd_7f3a9c2e")
|
||||
let all = try await repository.list(InstanceQuery())
|
||||
XCTAssertEqual(all.count, 2)
|
||||
|
||||
// The same node id from the same relay is the same instance, so it is refused.
|
||||
do {
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Again",
|
||||
endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
))
|
||||
XCTFail("the same relay node was added twice")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-filled relay endpoint must be refused at the repository rather than accepted and then
|
||||
/// failing in the transport as an unreadable response, which is indistinguishable to the user
|
||||
/// from "the node is broken".
|
||||
func testAnIncompleteRelayEndpointIsRefused() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
var cases: [RelayEndpoint] = []
|
||||
|
||||
var wrongKeySize = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
wrongKeySize.agentSigningKey = Data(repeating: 1, count: 31)
|
||||
cases.append(wrongKeySize)
|
||||
|
||||
var plainHTTP = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
plainHTTP.relayBaseURL = URL(string: "http://relay.example")!
|
||||
cases.append(plainHTTP)
|
||||
|
||||
var badNodeID = Self.relayEndpoint(nodeID: "node-1")
|
||||
badNodeID.nodeID = "node-1"
|
||||
cases.append(badNodeID)
|
||||
|
||||
var badDeviceID = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
badDeviceID.deviceID = "phone"
|
||||
cases.append(badDeviceID)
|
||||
|
||||
var shortToken = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
shortToken.deviceToken = "abc"
|
||||
cases.append(shortToken)
|
||||
|
||||
for (index, endpoint) in cases.enumerated() {
|
||||
do {
|
||||
_ = try await repository.create(
|
||||
InstanceInput(name: "Broken \(index)", endpoint: .relay(endpoint))
|
||||
)
|
||||
XCTFail("incomplete relay endpoint \(index) was accepted")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .invalidOrigin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRelayEndpointRoundTripsThroughTheStore() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let endpoint = Self.relayEndpoint(nodeID: "nd_7f3a9c2e")
|
||||
let created = try await repository.create(
|
||||
InstanceInput(name: "Relayed", endpoint: .relay(endpoint), tags: ["remote"])
|
||||
)
|
||||
|
||||
// A second repository over the same file proves the bytes on disk are what decode, not an
|
||||
// in-memory copy.
|
||||
let reopened = FileInstanceRepository(location: location)
|
||||
let fetched = try await reopened.get(created.id)
|
||||
let loaded = try XCTUnwrap(fetched)
|
||||
XCTAssertEqual(loaded.endpoint, .relay(endpoint))
|
||||
XCTAssertEqual(loaded.endpoint.relay?.agentSigningKey, endpoint.agentSigningKey)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.agentKxKey, endpoint.agentKxKey)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.deviceToken, endpoint.deviceToken)
|
||||
XCTAssertEqual(loaded.endpoint.relay?.nodeName, "Lab Alpha")
|
||||
}
|
||||
|
||||
/// The two modes must not share a credential string: in relay mode the phone holds no node
|
||||
/// password, and saying otherwise is a false statement about where the secret lives.
|
||||
func testCredentialWordingDiffersByMode() {
|
||||
let direct = InstanceEndpoint.direct(URL(string: "http://192.168.1.20:3000")!)
|
||||
let relay = InstanceEndpoint.relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
XCTAssertNotEqual(direct.credentialDescription, relay.credentialDescription)
|
||||
XCTAssertTrue(relay.credentialDescription.contains("agent"))
|
||||
}
|
||||
|
||||
/// Freshness uses the vocabulary that already exists in `packages/contracts`, with the four
|
||||
/// values spelled the same, so the app and the console describe the same state with one word.
|
||||
func testFreshnessUsesTheSharedVocabulary() {
|
||||
XCTAssertEqual(Set(SnapshotFreshness.allCases.map(\.rawValue)),
|
||||
["fresh", "stale", "expired", "unknown"])
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 2), .fresh)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 40), .stale)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: 600), .expired)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: nil), .unknown)
|
||||
XCTAssertEqual(SnapshotFreshness.classify(age: -1), .unknown)
|
||||
}
|
||||
|
||||
static func relayEndpoint(nodeID: String) -> RelayEndpoint {
|
||||
RelayEndpoint(
|
||||
relayBaseURL: URL(string: "https://relay.example")!,
|
||||
nodeID: nodeID,
|
||||
agentSigningKey: Data(repeating: 0xA1, count: 32),
|
||||
agentKxKey: Data(repeating: 0xB2, count: 32),
|
||||
deviceToken: String(repeating: "c3", count: 24),
|
||||
deviceID: "ap_5b1d0000",
|
||||
nodeName: "Lab Alpha"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ final class InstanceRepositoryTests: XCTestCase {
|
||||
|
||||
let created = try await repository.create(input)
|
||||
XCTAssertEqual(created.name, "Lab Alpha")
|
||||
XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.endpoint.displayAddress, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.tags, ["primary", "lab"])
|
||||
XCTAssertEqual(created.configRevision, 1)
|
||||
XCTAssertTrue(created.credentialConfigured)
|
||||
|
||||
@@ -11,12 +11,12 @@ final class InstanceSessionServiceTests: XCTestCase {
|
||||
|
||||
let result = try await service.login(instance, password: "secret")
|
||||
XCTAssertTrue(result.authenticated)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
|
||||
XCTAssertEqual(cookie, "simadmin_session=token")
|
||||
|
||||
let rejected = try await service.login(instance, password: "bad")
|
||||
XCTAssertFalse(rejected.authenticated)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.endpoint.directOrigin!)
|
||||
XCTAssertEqual(removed, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ final class MessageServiceTests: XCTestCase {
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
let page = try await service.messages([instance], limit: 2)
|
||||
|
||||
@@ -30,7 +30,7 @@ final class MessageServiceTests: XCTestCase {
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
await store.set(instance.id, origin: instance.endpoint.directOrigin!, cookie: "simadmin_session=token")
|
||||
|
||||
try await service.send(instance, phoneNumber: "13800138000", content: "hello")
|
||||
XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello"))
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// The pairing exchange, checked against a node-shaped peer.
|
||||
///
|
||||
/// Pairing is the one moment the phone is inside the network and the only time the node's password
|
||||
/// is involved at all, so the interesting properties are that the phone sends its real public keys,
|
||||
/// that it records the id the node assigns, and that each failure mode is distinguishable — a wrong
|
||||
/// password and a closed window call for different actions.
|
||||
final class RelayPairingTests: XCTestCase {
|
||||
private final class PairingStub: URLProtocol, @unchecked Sendable {
|
||||
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
|
||||
nonisolated(unsafe) static var lastBody: Data?
|
||||
nonisolated(unsafe) static var lastURL: URL?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
let body = request.httpBody ?? Self.drain(request.httpBodyStream) ?? Data()
|
||||
Self.lastBody = body
|
||||
Self.lastURL = request.url
|
||||
guard let handler = Self.handler else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
do {
|
||||
let (status, data) = try handler(request, body)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
private static func drain(_ stream: InputStream?) -> Data? {
|
||||
guard let stream else { return nil }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable {
|
||||
let read = stream.read(&buffer, maxLength: buffer.count)
|
||||
if read <= 0 { break }
|
||||
data.append(buffer, count: read)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private func makePairing() -> RelayPairing {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [PairingStub.self]
|
||||
return RelayPairing(session: URLSession(configuration: configuration))
|
||||
}
|
||||
|
||||
private func makeIdentity() -> DeviceIdentity {
|
||||
DeviceIdentity(
|
||||
deviceID: "",
|
||||
signingKey: Curve25519.Signing.PrivateKey(),
|
||||
agreementKey: Curve25519.KeyAgreement.PrivateKey()
|
||||
)
|
||||
}
|
||||
|
||||
private func successBody(agentSigning: Data, agentKx: Data) -> Data {
|
||||
Data("""
|
||||
{
|
||||
"nodeID": "nd_7f3a9c2e",
|
||||
"appID": "ap_5b1d0000",
|
||||
"agentSignPub": "\(agentSigning.base64EncodedString())",
|
||||
"agentKxPub": "\(agentKx.base64EncodedString())",
|
||||
"relayURL": "https://relay.example",
|
||||
"deviceToken": "0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
PairingStub.handler = nil
|
||||
PairingStub.lastBody = nil
|
||||
PairingStub.lastURL = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testPairingSendsTheRealPublicKeysAndReturnsTheAssignedIdentity() async throws {
|
||||
let identity = makeIdentity()
|
||||
let agentSigning = Curve25519.Signing.PrivateKey()
|
||||
let agentKx = Curve25519.KeyAgreement.PrivateKey()
|
||||
PairingStub.handler = { _, _ in
|
||||
(200, self.successBody(
|
||||
agentSigning: agentSigning.publicKey.rawRepresentation,
|
||||
agentKx: agentKx.publicKey.rawRepresentation
|
||||
))
|
||||
}
|
||||
|
||||
let result = try await makePairing().pair(
|
||||
host: "192.168.1.10",
|
||||
password: "hunter2",
|
||||
deviceName: "chick",
|
||||
identity: identity
|
||||
)
|
||||
|
||||
// The keys the phone sent must be the ones it will sign and decrypt with; the node stores
|
||||
// them as its trust anchor, and a mismatch here is an unverifiable request later.
|
||||
let sent = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: try XCTUnwrap(PairingStub.lastBody)) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(sent["appSignPub"] as? String, identity.signingPublicKey.base64EncodedString())
|
||||
XCTAssertEqual(sent["appKxPub"] as? String, identity.agreementPublicKey.base64EncodedString())
|
||||
XCTAssertEqual(sent["password"] as? String, "hunter2")
|
||||
XCTAssertEqual(sent["deviceName"] as? String, "chick")
|
||||
XCTAssertEqual(PairingStub.lastURL?.absoluteString, "http://192.168.1.10:3001/provision")
|
||||
|
||||
// The id is the node's choice, and the returned keys become the phone's trust anchor.
|
||||
XCTAssertEqual(result.deviceID, "ap_5b1d0000")
|
||||
XCTAssertEqual(result.endpoint.nodeID, "nd_7f3a9c2e")
|
||||
XCTAssertEqual(result.endpoint.deviceID, "ap_5b1d0000")
|
||||
XCTAssertEqual(result.endpoint.agentSigningKey, agentSigning.publicKey.rawRepresentation)
|
||||
XCTAssertEqual(result.endpoint.agentKxKey, agentKx.publicKey.rawRepresentation)
|
||||
XCTAssertEqual(result.endpoint.deviceToken, "0123456789abcdef0123456789abcdef")
|
||||
XCTAssertEqual(result.endpoint.relayBaseURL.absoluteString, "https://relay.example")
|
||||
}
|
||||
|
||||
/// Each failure mode must be its own error: "wrong password" is a retype, "window closed" is a
|
||||
/// walk to the node, and neither is fixed by the other's action.
|
||||
func testFailuresAreDistinguishable() async {
|
||||
let identity = makeIdentity()
|
||||
let cases: [(Int, Data, RelayPairing.Failure)] = [
|
||||
(401, Data(#"{"error":"no"}"#.utf8), .rejected),
|
||||
(410, Data(#"{"error":"closed"}"#.utf8), .windowClosed),
|
||||
(500, Data(#"{"error":"boom"}"#.utf8), .malformedResponse),
|
||||
]
|
||||
for (status, body, expected) in cases {
|
||||
PairingStub.handler = { _, _ in (status, body) }
|
||||
do {
|
||||
_ = try await makePairing().pair(
|
||||
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
|
||||
)
|
||||
XCTFail("status \(status) was accepted")
|
||||
} catch let failure as RelayPairing.Failure {
|
||||
XCTAssertEqual(failure, expected, "status \(status)")
|
||||
} catch {
|
||||
XCTFail("status \(status) produced \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed or truncated response must be refused rather than half-accepted: an endpoint
|
||||
/// missing its keys would fail later as an unreadable response, which the user reads as a broken
|
||||
/// node rather than as a bad pairing.
|
||||
func testAMalformedResponseIsRefused() async {
|
||||
let identity = makeIdentity()
|
||||
let good = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation
|
||||
let goodKx = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
let bodies: [Data] = [
|
||||
Data("not json".utf8),
|
||||
Data(#"{"nodeID":"nd_7f3a9c2e"}"#.utf8),
|
||||
// A key that is not 32 bytes decodes fine as base64 and is wrong for the format.
|
||||
Data("""
|
||||
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(Data(repeating: 1, count: 31).base64EncodedString())",
|
||||
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":"t"}
|
||||
""".utf8),
|
||||
// A plain-HTTP relay would make the end-to-end encryption decorative.
|
||||
Data("""
|
||||
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
|
||||
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"http://relay.example","deviceToken":"t"}
|
||||
""".utf8),
|
||||
// No token: the phone could never authenticate to the relay.
|
||||
Data("""
|
||||
{"nodeID":"nd_7f3a9c2e","appID":"ap_5b1d0000","agentSignPub":"\(good.base64EncodedString())",
|
||||
"agentKxPub":"\(goodKx.base64EncodedString())","relayURL":"https://relay.example","deviceToken":""}
|
||||
""".utf8),
|
||||
]
|
||||
for body in bodies {
|
||||
PairingStub.handler = { _, _ in (200, body) }
|
||||
do {
|
||||
_ = try await makePairing().pair(
|
||||
host: "192.168.1.10", password: "x", deviceName: "d", identity: identity
|
||||
)
|
||||
XCTFail("accepted \(String(decoding: body, as: UTF8.self))")
|
||||
} catch let failure as RelayPairing.Failure {
|
||||
XCTAssertEqual(failure, .malformedResponse)
|
||||
} catch {
|
||||
XCTFail("produced \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAnUnreachableNodeIsReportedAsSuch() async {
|
||||
PairingStub.handler = { _, _ in throw URLError(.cannotConnectToHost) }
|
||||
do {
|
||||
_ = try await makePairing().pair(
|
||||
host: "192.168.1.10", password: "x", deviceName: "d", identity: makeIdentity()
|
||||
)
|
||||
XCTFail("an unreachable node was accepted")
|
||||
} catch let failure as RelayPairing.Failure {
|
||||
XCTAssertEqual(failure, .unreachable)
|
||||
} catch {
|
||||
XCTFail("produced \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity file is the one thing a pairing cannot be redone without.
|
||||
final class DeviceIdentityStoreTests: XCTestCase {
|
||||
private var location: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("msa-identity-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: location)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testTheIdentityIsStableAcrossLoads() throws {
|
||||
let store = FileDeviceIdentityStore(location: location)
|
||||
let first = try store.loadOrCreate()
|
||||
let second = try store.loadOrCreate()
|
||||
XCTAssertEqual(first.signingPublicKey, second.signingPublicKey)
|
||||
XCTAssertEqual(first.agreementPublicKey, second.agreementPublicKey)
|
||||
XCTAssertTrue(first.deviceID.isEmpty, "the phone cannot know its id before the node assigns one")
|
||||
|
||||
// A fresh store over the same file must find the same keys, or a restart would silently
|
||||
// invalidate every pairing.
|
||||
let reopened = FileDeviceIdentityStore(location: location)
|
||||
XCTAssertEqual(try reopened.loadOrCreate().signingPublicKey, first.signingPublicKey)
|
||||
}
|
||||
|
||||
/// The node verified the public keys it was given, so recording its id must not regenerate them.
|
||||
func testAssigningTheDeviceIDKeepsTheKeys() throws {
|
||||
let store = FileDeviceIdentityStore(location: location)
|
||||
let original = try store.loadOrCreate()
|
||||
let assigned = try store.assignDeviceID("ap_5b1d0000")
|
||||
XCTAssertEqual(assigned.deviceID, "ap_5b1d0000")
|
||||
XCTAssertEqual(assigned.signingPublicKey, original.signingPublicKey)
|
||||
XCTAssertEqual(assigned.agreementPublicKey, original.agreementPublicKey)
|
||||
|
||||
let reloaded = try FileDeviceIdentityStore(location: location).loadOrCreate()
|
||||
XCTAssertEqual(reloaded.deviceID, "ap_5b1d0000")
|
||||
XCTAssertEqual(reloaded.signingPublicKey, original.signingPublicKey)
|
||||
}
|
||||
|
||||
func testTheFileIsNotWorldReadable() throws {
|
||||
_ = try FileDeviceIdentityStore(location: location).loadOrCreate()
|
||||
let mode = try FileManager.default
|
||||
.attributesOfItem(atPath: location.path)[.posixPermissions] as? NSNumber
|
||||
XCTAssertEqual(mode?.int16Value, 0o600)
|
||||
}
|
||||
|
||||
func testResetForgetsTheIdentity() throws {
|
||||
let store = FileDeviceIdentityStore(location: location)
|
||||
let first = try store.loadOrCreate()
|
||||
try store.reset()
|
||||
let second = try store.loadOrCreate()
|
||||
XCTAssertNotEqual(first.signingPublicKey, second.signingPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// The router is the only place that decides which path a request takes.
|
||||
final class NodeTransportRouterTests: XCTestCase {
|
||||
private struct Recorder: NodeTransport {
|
||||
let label: String
|
||||
let recorder: @Sendable (String) -> Void
|
||||
func send(
|
||||
_ route: UpstreamRoute,
|
||||
instanceID: UUID,
|
||||
endpoint: InstanceEndpoint
|
||||
) async throws -> UpstreamResponse {
|
||||
recorder(label)
|
||||
return UpstreamResponse(status: 200, data: Data())
|
||||
}
|
||||
}
|
||||
|
||||
func testTheRouterPicksThePathFromTheEndpoint() async throws {
|
||||
let seen = LockedStrings()
|
||||
let router = NodeTransportRouter(
|
||||
direct: Recorder(label: "direct") { seen.append($0) },
|
||||
relay: Recorder(label: "relay") { seen.append($0) }
|
||||
)
|
||||
|
||||
_ = try await router.send(
|
||||
.health,
|
||||
instanceID: UUID(),
|
||||
endpoint: .direct(URL(string: "http://192.168.1.20:3000")!)
|
||||
)
|
||||
_ = try await router.send(
|
||||
.health,
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
)
|
||||
XCTAssertEqual(seen.values, ["direct", "relay"])
|
||||
}
|
||||
|
||||
/// Handing a relay instance to the direct path is a wiring bug, not a network condition, so it
|
||||
/// must fail rather than dialing something arbitrary.
|
||||
func testTheDirectPathRefusesARelayEndpoint() async {
|
||||
let transport = DirectTransport(client: NoopClient(), sessions: InstanceSessionStore())
|
||||
do {
|
||||
_ = try await transport.send(
|
||||
.health,
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(InstanceEndpointMigrationTests.relayEndpoint(nodeID: "nd_7f3a9c2e"))
|
||||
)
|
||||
XCTFail("the direct path accepted a relay endpoint")
|
||||
} catch let error as UpstreamError {
|
||||
XCTAssertEqual(error, .invalidRequest)
|
||||
} catch {
|
||||
XCTFail("produced \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoopClient: SimAdminClientProtocol {
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
XCTFail("the direct client must not be dialed for a relay endpoint")
|
||||
return UpstreamResponse(status: 200, data: Data())
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedStrings: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [String] = []
|
||||
|
||||
func append(_ value: String) {
|
||||
lock.withLock { storage.append(value) }
|
||||
}
|
||||
|
||||
var values: [String] {
|
||||
lock.withLock { storage }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// The phone's half of the protocol, checked against a peer that behaves like the Go agent.
|
||||
///
|
||||
/// The peer here is built from the same primitives the agent uses, in the opposite direction: it
|
||||
/// verifies the request signature with the phone's public key, decrypts the payload with the agent's
|
||||
/// private X25519 key, recomputes the confirmation intent, and answers with a signature and a
|
||||
/// ciphertext the phone has to accept. A test that only stubbed the transport would prove the
|
||||
/// plumbing; this proves the protocol, which is the part that has three implementations and no
|
||||
/// shared code.
|
||||
final class RelayTransportTests: XCTestCase {
|
||||
// MARK: - Agent-shaped peer
|
||||
|
||||
/// Stands in for the node's agent and for the relay at once: the relay is a dumb pipe, so the
|
||||
/// only interesting behaviour is what the agent does with the envelope.
|
||||
private struct AgentPeer {
|
||||
let nodeID: String
|
||||
let deviceID: String
|
||||
let signingKey: Curve25519.Signing.PrivateKey
|
||||
let agreementKey: Curve25519.KeyAgreement.PrivateKey
|
||||
|
||||
init(nodeID: String = "nd_7f3a9c2e", deviceID: String = "ap_5b1d0000") {
|
||||
self.nodeID = nodeID
|
||||
self.deviceID = deviceID
|
||||
self.signingKey = Curve25519.Signing.PrivateKey()
|
||||
self.agreementKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
|
||||
/// What the phone needs in order to reach this peer.
|
||||
func endpoint(relayBaseURL: URL = URL(string: "https://relay.example")!) -> RelayEndpoint {
|
||||
RelayEndpoint(
|
||||
relayBaseURL: relayBaseURL,
|
||||
nodeID: nodeID,
|
||||
agentSigningKey: signingKey.publicKey.rawRepresentation,
|
||||
agentKxKey: agreementKey.publicKey.rawRepresentation,
|
||||
deviceToken: "device-token-abcdef",
|
||||
deviceID: deviceID,
|
||||
nodeName: "Lab Alpha"
|
||||
)
|
||||
}
|
||||
|
||||
struct Received {
|
||||
var inner: RelayWire.InnerRequest
|
||||
var envelope: RelayWire.Envelope
|
||||
}
|
||||
|
||||
/// Verifies and decrypts one request the way the agent does, in the agent's order.
|
||||
func receive(_ raw: Data, phonePublicKey: Curve25519.Signing.PublicKey) throws -> Received {
|
||||
let envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
|
||||
try RelayWire.verifyRequest(envelope, publicKey: phonePublicKey)
|
||||
let shared = try RelayWire.sharedSecret(privateKey: agreementKey, peerPublic: envelope.ek)
|
||||
let (key, iv) = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: nodeID,
|
||||
requestID: envelope.req,
|
||||
response: false
|
||||
)
|
||||
let plaintext = try RelayWire.open(envelope.p, key: key, iv: iv)
|
||||
let inner = try JSONDecoder().decode(RelayWire.InnerRequest.self, from: plaintext)
|
||||
return Received(inner: inner, envelope: envelope)
|
||||
}
|
||||
|
||||
/// Answers the way the agent does: a fresh ephemeral key, encrypted to the phone's request
|
||||
/// ephemeral, signed with the agent's long-term key, and with `app` omitted from the signed
|
||||
/// canonical form.
|
||||
func answer(
|
||||
to request: RelayWire.Envelope,
|
||||
status: Int,
|
||||
outcome: String?,
|
||||
body: Data?
|
||||
) throws -> Data {
|
||||
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
|
||||
let shared = try RelayWire.sharedSecret(
|
||||
privateKey: ephemeral,
|
||||
peerPublic: request.ek
|
||||
)
|
||||
let (key, iv) = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: nodeID,
|
||||
requestID: request.req,
|
||||
response: true
|
||||
)
|
||||
let inner = RelayWire.InnerResponse(status: status, outcome: outcome, body: body)
|
||||
let plaintext = try JSONEncoder().encode(inner)
|
||||
var envelope = RelayWire.Envelope(
|
||||
node: nodeID,
|
||||
req: request.req,
|
||||
app: nil,
|
||||
ts: request.ts + 1,
|
||||
ek: ephemeral.publicKey.rawRepresentation,
|
||||
p: try RelayWire.seal(plaintext, key: key, iv: iv)
|
||||
)
|
||||
envelope.s = try signingKey.signature(for: RelayWire.canonical(envelope, response: true))
|
||||
return try JSONEncoder().encode(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// A URLProtocol stub, so the transport under test runs its real code path: real envelope
|
||||
/// construction, real signing, real HTTP handling.
|
||||
private final class RelayStub: URLProtocol, @unchecked Sendable {
|
||||
nonisolated(unsafe) static var handler: (@Sendable (URLRequest, Data) throws -> (Int, Data))?
|
||||
nonisolated(unsafe) static var lastRequest: URLRequest?
|
||||
nonisolated(unsafe) static var lastBody: Data?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
// `URLProtocol` hands the body as a stream, so it has to be drained here to get bytes.
|
||||
let body = request.httpBody ?? Self.drain(request.httpBodyStream)
|
||||
Self.lastRequest = request
|
||||
Self.lastBody = body
|
||||
guard let handler = Self.handler else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
do {
|
||||
let (status, data) = try handler(request, body ?? Data())
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["content-type": "application/problem+json"]
|
||||
)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
private static func drain(_ stream: InputStream?) -> Data? {
|
||||
guard let stream else { return nil }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable {
|
||||
let read = stream.read(&buffer, maxLength: buffer.count)
|
||||
if read <= 0 { break }
|
||||
data.append(buffer, count: read)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTransport(
|
||||
identity: DeviceIdentity,
|
||||
now: @escaping @Sendable () -> Date = { Date() }
|
||||
) -> RelayTransport {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [RelayStub.self]
|
||||
return RelayTransport(
|
||||
identity: identity,
|
||||
session: URLSession(configuration: configuration),
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
private func makeIdentity() -> DeviceIdentity {
|
||||
DeviceIdentity(
|
||||
deviceID: "ap_5b1d0000",
|
||||
signingKey: Curve25519.Signing.PrivateKey(),
|
||||
agreementKey: Curve25519.KeyAgreement.PrivateKey()
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
RelayStub.handler = nil
|
||||
RelayStub.lastRequest = nil
|
||||
RelayStub.lastBody = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Round trip
|
||||
|
||||
func testAReadRoundTripsThroughAnAgentShapedPeer() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
var received: AgentPeer.Received?
|
||||
RelayStub.handler = { request, body in
|
||||
received = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
let answer = try peer.answer(
|
||||
to: received!.envelope,
|
||||
status: 200,
|
||||
outcome: AgentOutcome.ok.rawValue,
|
||||
body: Data(#"{"status":"up"}"#.utf8)
|
||||
)
|
||||
return (200, answer)
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
let response = try await transport.send(
|
||||
.health,
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(peer.endpoint())
|
||||
)
|
||||
|
||||
XCTAssertEqual(response.status, 200)
|
||||
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"status":"up"}"#)
|
||||
let seen = try XCTUnwrap(received)
|
||||
XCTAssertEqual(seen.inner.m, "GET")
|
||||
XCTAssertEqual(seen.inner.p, "/api/health")
|
||||
XCTAssertNil(seen.inner.intent, "a read must not carry a confirmation intent")
|
||||
XCTAssertEqual(seen.envelope.app, "ap_5b1d0000")
|
||||
}
|
||||
|
||||
/// The path carries its query string, and the agent forwards it verbatim: the phone must not
|
||||
/// assemble one from parts, because a part the agent reassembled is a part a relay could steer.
|
||||
func testThePathCarriesItsQueryString() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
var path: String?
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
path = seen.inner.p
|
||||
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
_ = try await transport.send(
|
||||
.smsList(limit: 25, offset: 50, direction: "incoming"),
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(peer.endpoint())
|
||||
)
|
||||
XCTAssertEqual(path, "/api/sms/list?limit=25&offset=50&direction=incoming")
|
||||
}
|
||||
|
||||
/// A dangerous write carries an intent that the agent recomputes from the bytes it is about to
|
||||
/// forward. If the phone's digest were computed over different bytes, the agent would refuse it
|
||||
/// — which is the property that makes a relay unable to edit an SMS in flight.
|
||||
func testADangerousWriteCarriesAnIntentTheAgentRecomputes() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
var inner: RelayWire.InnerRequest?
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
inner = seen.inner
|
||||
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
_ = try await transport.send(
|
||||
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(peer.endpoint())
|
||||
)
|
||||
|
||||
let seen = try XCTUnwrap(inner)
|
||||
let intent = try XCTUnwrap(seen.intent, "a dangerous write must carry an intent")
|
||||
let recomputed = try RelayWire.intentDigest(
|
||||
method: "POST",
|
||||
path: "/api/sms/send",
|
||||
body: seen.body
|
||||
)
|
||||
XCTAssertEqual(intent, recomputed, "the agent recomputes this digest and refuses a mismatch")
|
||||
|
||||
// And the digest must not survive an edit to the body, which is the attack it exists for.
|
||||
let edited = try RelayWire.intentDigest(
|
||||
method: "POST",
|
||||
path: "/api/sms/send",
|
||||
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
|
||||
)
|
||||
XCTAssertNotEqual(intent, edited)
|
||||
}
|
||||
|
||||
/// The relay must not be able to answer without the agent's key. This is the check that makes
|
||||
/// "the relay cannot forge a response" a fact rather than an assumption.
|
||||
func testAnUnsignedAnswerIsRejected() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
|
||||
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
|
||||
// A forged answer: correctly shaped, wrong signer.
|
||||
let forger = Curve25519.Signing.PrivateKey()
|
||||
envelope.s = try forger.signature(for: RelayWire.canonical(envelope, response: true))
|
||||
return (200, try JSONEncoder().encode(envelope))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
await assertThrows(.relayResponseUnverifiable) {
|
||||
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay that swaps in a different ciphertext cannot produce a valid signature over it either,
|
||||
/// since the signature covers the ciphertext.
|
||||
func testATamperedCiphertextIsRejected() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
let raw = try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8))
|
||||
var envelope = try JSONDecoder().decode(RelayWire.Envelope.self, from: raw)
|
||||
var ciphertext = envelope.p
|
||||
ciphertext[ciphertext.count - 1] ^= 0x01
|
||||
envelope.p = ciphertext
|
||||
return (200, try JSONEncoder().encode(envelope))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
await assertThrows(.relayResponseUnverifiable) {
|
||||
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
|
||||
}
|
||||
}
|
||||
|
||||
/// An answer to a different request id must be discarded: the signature covers `req`, so this
|
||||
/// catches both a confused relay and a replay of an earlier answer.
|
||||
func testAnAnswerForADifferentRequestIsRejected() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
var misdirected = seen.envelope
|
||||
misdirected.req = UUID().uuidString.lowercased()
|
||||
// Re-signing with the real key would make it valid; the point is that the phone checks
|
||||
// the id before it checks anything else, so an unsigned mismatch is enough.
|
||||
misdirected.s = Data(repeating: 0, count: 64)
|
||||
let raw = try JSONEncoder().encode(misdirected)
|
||||
return (200, raw)
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
await assertThrows(.relayResponseMismatch) {
|
||||
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error mapping
|
||||
|
||||
/// Every relay error code must arrive as its own typed error. Collapsing them would put the
|
||||
/// user back to one message for four different situations.
|
||||
func testRelayProblemCodesMapToDistinctErrors() {
|
||||
let cases: [(RelayErrorCode, UpstreamError)] = [
|
||||
(.unauthorized, .relayRejectedDevice),
|
||||
(.nodeUnknown, .relayNodeUnknown),
|
||||
(.agentOffline, .agentOffline),
|
||||
(.mailboxFull, .nodeBusy),
|
||||
(.agentTimeout, .agentTimeout),
|
||||
(.badEnvelope, .relayBadEnvelope),
|
||||
(.payloadTooLarge, .payloadTooLarge),
|
||||
(.rateLimited, .relayRateLimited),
|
||||
(.internalError, .relayUnreachable),
|
||||
]
|
||||
for (code, expected) in cases {
|
||||
let body = Data(#"{"code":"\#(code.rawValue)","status":500}"#.utf8)
|
||||
XCTAssertEqual(RelayTransport.problem(from: body, status: 500), expected, code.rawValue)
|
||||
}
|
||||
// An unrecognised code on a 5xx is a relay fault; on a 4xx it is a protocol mismatch.
|
||||
XCTAssertEqual(
|
||||
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 503),
|
||||
.relayUnreachable
|
||||
)
|
||||
XCTAssertEqual(
|
||||
RelayTransport.problem(from: Data(#"{"code":"something_new"}"#.utf8), status: 400),
|
||||
.invalidResponse
|
||||
)
|
||||
}
|
||||
|
||||
func testAnAgentRefusalArrivesAsItsOwnError() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
let cases: [(String, UpstreamError)] = [
|
||||
(AgentOutcome.nodeAuthFailed.rawValue, .nodeAuthFailed),
|
||||
(AgentOutcome.policyDenied.rawValue, .relayPolicyDenied),
|
||||
(AgentOutcome.confirmationMismatch.rawValue, .confirmationRejected),
|
||||
(AgentOutcome.expired.rawValue, .envelopeExpired),
|
||||
(AgentOutcome.replay.rawValue, .envelopeExpired),
|
||||
(AgentOutcome.rateLimited.rawValue, .nodeRateLimited),
|
||||
(AgentOutcome.badSignature.rawValue, .envelopeRejected),
|
||||
]
|
||||
for (outcome, expected) in cases {
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
return (200, try peer.answer(to: seen.envelope, status: 403, outcome: outcome, body: nil))
|
||||
}
|
||||
let transport = makeTransport(identity: identity)
|
||||
await assertThrows(expected, "outcome \(outcome)") {
|
||||
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An upstream error from the node is the one branch where the node's own status reaches the
|
||||
/// caller: the agent did its job, SimAdmin answered with an error, and the status is meaningful.
|
||||
func testAnUpstreamErrorKeepsTheNodeStatus() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
return (200, try peer.answer(
|
||||
to: seen.envelope,
|
||||
status: 502,
|
||||
outcome: AgentOutcome.upstreamError.rawValue,
|
||||
body: Data(#"{"error":"upstream"}"#.utf8)
|
||||
))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
let response = try await transport.send(.health, instanceID: UUID(), endpoint: .relay(peer.endpoint()))
|
||||
XCTAssertEqual(response.status, 502)
|
||||
XCTAssertEqual(String(decoding: response.data, as: UTF8.self), #"{"error":"upstream"}"#)
|
||||
}
|
||||
|
||||
/// The relay is a bearer-authenticated hop, and the token must travel as a bearer header rather
|
||||
/// than in the envelope: it is the relay's abuse control, not part of the signed material.
|
||||
func testTheRequestCarriesTheDeviceBearerAndNeverAPassword() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, body in
|
||||
let seen = try peer.receive(body, phonePublicKey: identity.signingKey.publicKey)
|
||||
return (200, try peer.answer(to: seen.envelope, status: 200, outcome: "ok", body: Data("{}".utf8)))
|
||||
}
|
||||
|
||||
let transport = makeTransport(identity: identity)
|
||||
_ = try await transport.send(
|
||||
.smsSend(phoneNumber: "+8613800000000", content: "hello"),
|
||||
instanceID: UUID(),
|
||||
endpoint: .relay(peer.endpoint())
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(RelayStub.lastRequest)
|
||||
XCTAssertEqual(request.url?.path, "/v1/request")
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(
|
||||
request.value(forHTTPHeaderField: "authorization"),
|
||||
"Bearer device-token-abcdef"
|
||||
)
|
||||
// The node password never appears anywhere in what leaves the phone.
|
||||
let body = String(decoding: try XCTUnwrap(RelayStub.lastBody), as: UTF8.self)
|
||||
XCTAssertFalse(body.lowercased().contains("password"))
|
||||
}
|
||||
|
||||
/// Plain HTTP to a relay would make the end-to-end encryption decorative, so it is refused
|
||||
/// before any traffic is sent.
|
||||
func testAPlainHTTPRelayIsRefused() async throws {
|
||||
let peer = AgentPeer()
|
||||
let identity = makeIdentity()
|
||||
RelayStub.handler = { _, _ in
|
||||
XCTFail("a plain-HTTP relay must be refused before any request is made")
|
||||
return (500, Data())
|
||||
}
|
||||
let transport = makeTransport(identity: identity)
|
||||
var endpoint = peer.endpoint(relayBaseURL: URL(string: "http://relay.example")!)
|
||||
endpoint.relayBaseURL = URL(string: "http://relay.example")!
|
||||
await assertThrows(.invalidRequest) {
|
||||
try await transport.send(.health, instanceID: UUID(), endpoint: .relay(endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Freshness and status vocabulary
|
||||
|
||||
func testProbeStatusMappingKeepsTheRelayCasesApart() {
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .agentOffline, error: UpstreamError.agentOffline), .offline)
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .relayUnreachable, error: UpstreamError.relayUnreachable), .offline)
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .relayRejectedDevice, error: UpstreamError.relayRejectedDevice), .offline)
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .nodeAuthFailed, error: UpstreamError.nodeAuthFailed), .degraded)
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .confirmationRejected, error: UpstreamError.confirmationRejected), .degraded)
|
||||
XCTAssertEqual(ConnectionProbe.status(for: .unauthorized, error: UpstreamError.unauthorized), .authRequired)
|
||||
// Every relay-mode case must have its own message. A generic fallback is the failure this
|
||||
// whole vocabulary exists to prevent, so the check is that the text is specific rather than
|
||||
// merely present.
|
||||
let generic = "请求失败:"
|
||||
var seen = Set<String>()
|
||||
for error in Self.allRelayErrors {
|
||||
let text = ErrorMessage.describe(error, nodeName: "Lab Alpha")
|
||||
XCTAssertFalse(text.isEmpty)
|
||||
XCTAssertFalse(text.hasPrefix(generic), "\(error) fell back to the generic message")
|
||||
XCTAssertTrue(seen.insert(text).inserted, "\(error) shares a message with another case")
|
||||
}
|
||||
XCTAssertEqual(seen.count, Self.allRelayErrors.count)
|
||||
}
|
||||
|
||||
static let allRelayErrors: [UpstreamError] = [
|
||||
.relayUnreachable, .relayTimeout, .relayRejectedDevice, .relayNodeUnknown, .agentOffline,
|
||||
.nodeBusy, .agentTimeout, .relayBadEnvelope, .relayRateLimited, .relayPolicyDenied,
|
||||
.confirmationRejected, .envelopeExpired, .nodeRateLimited, .nodeAuthFailed, .payloadTooLarge,
|
||||
.relayResponseUnverifiable, .relayResponseMismatch, .envelopeRejected,
|
||||
]
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func assertThrows(
|
||||
_ expected: UpstreamError,
|
||||
_ message: String = "",
|
||||
_ body: () async throws -> UpstreamResponse,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
_ = try await body()
|
||||
XCTFail("expected \(expected) but the call succeeded. \(message)", file: file, line: line)
|
||||
} catch let error as UpstreamError {
|
||||
XCTAssertEqual(error, expected, message, file: file, line: line)
|
||||
} catch {
|
||||
XCTFail("expected \(expected), got \(error). \(message)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// Recomputes `contracts/test-vectors.json` in Swift.
|
||||
///
|
||||
/// The Go agent, the Node reference implementation and this app share no code, so agreement here is
|
||||
/// evidence rather than a tautology: three encoders producing the same bytes for the same inputs is
|
||||
/// what makes "the phone signs, the agent verifies" true across a language boundary. A failure in
|
||||
/// this file means the encoding drifted, not that this side is broken.
|
||||
final class RelayWireTests: XCTestCase {
|
||||
/// The vector file lives in the relay repository. Both paths are tried so the test works from
|
||||
/// the app package and from a checkout where the two repositories sit side by side; when
|
||||
/// neither exists the test is skipped rather than failed, because a missing sibling repository
|
||||
/// is not an encoding bug.
|
||||
private static let candidatePaths = [
|
||||
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
||||
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../../simadmin-relay/contracts/test-vectors.json",
|
||||
].compactMap { $0 }
|
||||
|
||||
private func loadVectors() throws -> [String: Any] {
|
||||
for path in Self.candidatePaths where FileManager.default.fileExists(atPath: path) {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XCTSkip("vectors at \(path) are not a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
throw XCTSkip("simadmin-relay/contracts/test-vectors.json not found; set MSA_RELAY_VECTORS")
|
||||
}
|
||||
|
||||
private func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func unhex(_ text: String) -> Data {
|
||||
var out = Data()
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
guard let next = text.index(index, offsetBy: 2, limitedBy: text.endIndex),
|
||||
let byte = UInt8(text[index..<next], radix: 16)
|
||||
else {
|
||||
XCTFail("not hex: \(text)")
|
||||
return Data()
|
||||
}
|
||||
out.append(byte)
|
||||
index = next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// The envelope in the vector file is JSON, so its byte fields are base64 there while the
|
||||
/// key-material sections are hex. Both decoders are needed and mixing them up produces an empty
|
||||
/// Data that fails as a length error three assertions later.
|
||||
private func unbase64(_ text: String) -> Data {
|
||||
guard let data = Data(base64Encoded: text) else {
|
||||
XCTFail("not base64: \(text)")
|
||||
return Data()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// RFC 8032 seed halves, which are the 32-byte raw representations CryptoKit wants. The vector
|
||||
/// file stores the full 64-byte expanded form that Go's ed25519 package uses.
|
||||
private func seed(_ expanded: String) -> Data {
|
||||
unhex(String(expanded.prefix(64)))
|
||||
}
|
||||
|
||||
// MARK: - Envelope encoding
|
||||
|
||||
func testCanonicalBytesAndSignaturesMatchTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let canonical = vectors["canonical"] as? [String: Any],
|
||||
let input = canonical["input"] as? [String: Any],
|
||||
let bytes = canonical["bytes"] as? [String: String],
|
||||
let signatures = canonical["signature"] as? [String: String],
|
||||
let publicKeyHex = canonical["publicKey"] as? String,
|
||||
let responsePublicKeyHex = canonical["responsePublicKey"] as? String
|
||||
else {
|
||||
return XCTFail("the canonical section is missing or malformed")
|
||||
}
|
||||
|
||||
let envelope = RelayWire.Envelope(
|
||||
v: input["v"] as? UInt64 ?? 0,
|
||||
node: input["node"] as? String ?? "",
|
||||
req: input["req"] as? String ?? "",
|
||||
app: input["app"] as? String,
|
||||
ts: input["ts"] as? UInt64 ?? 0,
|
||||
ek: unbase64(input["ek"] as? String ?? ""),
|
||||
alg: input["alg"] as? String ?? "",
|
||||
p: unbase64(input["p"] as? String ?? "")
|
||||
)
|
||||
|
||||
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: false)), bytes["request"])
|
||||
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: true)), bytes["response"])
|
||||
|
||||
let appSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
||||
let agentSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(agentSignKeyHex))
|
||||
|
||||
// The public halves published in the vector file must be the ones these private keys derive,
|
||||
// or the vector proves nothing about key ownership.
|
||||
XCTAssertEqual(hex(appSigningKey.publicKey.rawRepresentation), publicKeyHex)
|
||||
XCTAssertEqual(hex(agentSigningKey.publicKey.rawRepresentation), responsePublicKeyHex)
|
||||
|
||||
// CryptoKit's Ed25519 signing is randomised, unlike Go's and Node's deterministic RFC 8032
|
||||
// implementation: signing the same bytes twice yields two different, equally valid
|
||||
// signatures. So the cross-language check here cannot be "the bytes are equal" — it is the
|
||||
// stronger property that actually matters, which is that each side verifies what the other
|
||||
// produced. Both directions are asserted, because a phone whose signatures the agent
|
||||
// rejected would be a total failure that byte-equality would never have caught either.
|
||||
try RelayWire.verifyRequest(
|
||||
envelopeWithSignature(envelope, unhex(signatures["request"] ?? ""), app: true),
|
||||
publicKey: appSigningKey.publicKey
|
||||
)
|
||||
try RelayWire.verifyResponse(
|
||||
envelopeWithSignature(envelope, unhex(signatures["response"] ?? ""), app: false),
|
||||
publicKey: agentSigningKey.publicKey
|
||||
)
|
||||
|
||||
var signedRequest = envelope
|
||||
try RelayWire.signRequest(&signedRequest, privateKey: appSigningKey)
|
||||
try RelayWire.verifyRequest(signedRequest, publicKey: appSigningKey.publicKey)
|
||||
// And the Go side must accept what this side signed: the vectors cannot prove that from
|
||||
// here, so it is asserted by the agent's own test suite against a signature captured from
|
||||
// a device. What can be checked locally is that the signing input is the vector's bytes.
|
||||
XCTAssertEqual(
|
||||
hex(try RelayWire.canonical(signedRequest, response: false)),
|
||||
bytes["request"],
|
||||
"the phone must sign exactly the bytes the agent verifies"
|
||||
)
|
||||
|
||||
// Domain separation: a request signature must not verify as a response signature.
|
||||
XCTAssertThrowsError(try RelayWire.verifyResponse(signedRequest, publicKey: appSigningKey.publicKey))
|
||||
}
|
||||
|
||||
/// Rebuilds an envelope carrying a signature produced elsewhere, since the response form has no
|
||||
/// `app` field and the request form does.
|
||||
private func envelopeWithSignature(
|
||||
_ envelope: RelayWire.Envelope,
|
||||
_ signature: Data,
|
||||
app: Bool
|
||||
) -> RelayWire.Envelope {
|
||||
var copy = envelope
|
||||
copy.app = app ? envelope.app : nil
|
||||
copy.s = signature
|
||||
return copy
|
||||
}
|
||||
|
||||
/// RFC 8032 test vector private keys, repeated here so the test does not depend on the vector
|
||||
/// file to know whose key is whose.
|
||||
private let privateKeyHex =
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
||||
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
private let agentSignKeyHex =
|
||||
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"
|
||||
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||
|
||||
func testAOneByteChangeBreaksTheSignature() throws {
|
||||
let envelope = RelayWire.Envelope(
|
||||
node: "nd_7f3a9c2e",
|
||||
req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
app: "ap_5b1d",
|
||||
ts: 1_727_289_600,
|
||||
ek: Data(repeating: 7, count: 32),
|
||||
p: Data([0, 1, 2, 0xFE, 0xFF])
|
||||
)
|
||||
let key = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
||||
var signed = envelope
|
||||
try RelayWire.signRequest(&signed, privateKey: key)
|
||||
try RelayWire.verifyRequest(signed, publicKey: key.publicKey)
|
||||
|
||||
// Retargeting the node is what a compromised relay would try first.
|
||||
var retargeted = signed
|
||||
retargeted.node = "nd_other001"
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(retargeted, publicKey: key.publicKey)) { error in
|
||||
XCTAssertEqual(error as? RelayWire.Failure, .signatureDoesNotVerify)
|
||||
}
|
||||
// Swapping the ciphertext is the second thing it would try.
|
||||
var edited = signed
|
||||
edited.p = Data([9, 9, 9])
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(edited, publicKey: key.publicKey))
|
||||
// The ephemeral public key is covered, so a substituted one cannot be hidden.
|
||||
var swapped = signed
|
||||
swapped.ek = Data(repeating: 3, count: 32)
|
||||
XCTAssertThrowsError(try RelayWire.verifyRequest(swapped, publicKey: key.publicKey))
|
||||
}
|
||||
|
||||
// MARK: - Key derivation and AEAD
|
||||
|
||||
func testKeyDerivationAndAEADMatchTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let x25519 = vectors["x25519"] as? [String: String],
|
||||
let aead = vectors["aead"] as? [String: String]
|
||||
else {
|
||||
return XCTFail("the x25519 or aead section is missing")
|
||||
}
|
||||
|
||||
let privateKey = try Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: unhex(x25519["appPrivate"] ?? "")
|
||||
)
|
||||
let shared = try RelayWire.sharedSecret(
|
||||
privateKey: privateKey,
|
||||
peerPublic: unhex(x25519["agentPublic"] ?? "")
|
||||
)
|
||||
XCTAssertEqual(hex(shared), x25519["sharedSecret"])
|
||||
|
||||
let request = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: "nd_7f3a9c2e",
|
||||
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
response: false
|
||||
)
|
||||
XCTAssertEqual(hex(request.key.withUnsafeBytes { Data($0) }), x25519["requestKey"])
|
||||
XCTAssertEqual(hex(request.iv), x25519["requestIV"])
|
||||
|
||||
let response = try RelayWire.deriveKey(
|
||||
shared: shared,
|
||||
nodeID: "nd_7f3a9c2e",
|
||||
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
||||
response: true
|
||||
)
|
||||
XCTAssertEqual(hex(response.key.withUnsafeBytes { Data($0) }), x25519["responseKey"])
|
||||
XCTAssertEqual(hex(response.iv), x25519["responseIV"])
|
||||
// Direction separation: the same shared secret must not produce the same key or nonce.
|
||||
XCTAssertNotEqual(x25519["requestKey"], x25519["responseKey"])
|
||||
XCTAssertNotEqual(x25519["requestIV"], x25519["responseIV"])
|
||||
|
||||
let sealed = try RelayWire.seal(
|
||||
unhex(aead["plaintext"] ?? ""),
|
||||
key: request.key,
|
||||
iv: request.iv
|
||||
)
|
||||
XCTAssertEqual(hex(sealed), aead["ciphertext"])
|
||||
let opened = try RelayWire.open(sealed, key: request.key, iv: request.iv)
|
||||
XCTAssertEqual(hex(opened), aead["plaintext"])
|
||||
}
|
||||
|
||||
func testHKDFMatchesRFC5869() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let hkdf = vectors["hkdf"] as? [String: Any] else {
|
||||
return XCTFail("the hkdf section is missing")
|
||||
}
|
||||
let output = try RelayWire.hkdf(
|
||||
secret: unhex(hkdf["secret"] as? String ?? ""),
|
||||
salt: unhex(hkdf["salt"] as? String ?? ""),
|
||||
info: unhex(hkdf["info"] as? String ?? ""),
|
||||
length: hkdf["length"] as? Int ?? 0
|
||||
)
|
||||
XCTAssertEqual(hex(output), hkdf["output"] as? String)
|
||||
}
|
||||
|
||||
/// A tampered ciphertext must fail to authenticate rather than decrypt to something.
|
||||
func testATamperedCiphertextDoesNotOpen() throws {
|
||||
let key = SymmetricKey(data: Data(repeating: 1, count: 32))
|
||||
let iv = Data(repeating: 2, count: 12)
|
||||
var sealed = try RelayWire.seal(Data("hello".utf8), key: key, iv: iv)
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
XCTAssertThrowsError(try RelayWire.open(sealed, key: key, iv: iv)) { error in
|
||||
XCTAssertEqual(error as? RelayWire.Failure, .ciphertextFailedToAuthenticate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Intent digest
|
||||
|
||||
func testIntentDigestMatchesTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let intent = vectors["intent"] as? [String: String] else {
|
||||
return XCTFail("the intent section is missing")
|
||||
}
|
||||
let digest = try RelayWire.intentDigest(
|
||||
method: intent["method"] ?? "",
|
||||
path: intent["path"] ?? "",
|
||||
body: Data((intent["body"] ?? "").utf8)
|
||||
)
|
||||
XCTAssertEqual(hex(digest), intent["digest"])
|
||||
|
||||
// Formatting must not matter; content must.
|
||||
let reordered = try RelayWire.intentDigest(
|
||||
method: "post",
|
||||
path: "/api/sms/send",
|
||||
body: Data(#"{"content":"hello","phone_number":"+8613800000000"}"#.utf8)
|
||||
)
|
||||
XCTAssertEqual(hex(reordered), intent["digest"])
|
||||
let edited = try RelayWire.intentDigest(
|
||||
method: "POST",
|
||||
path: "/api/sms/send",
|
||||
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
|
||||
)
|
||||
XCTAssertNotEqual(hex(edited), intent["digest"])
|
||||
}
|
||||
|
||||
// MARK: - Canonical JSON
|
||||
|
||||
func testCanonicalJSONMatchesTheGoAgent() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let pairs = vectors["canonicalJson"] as? [[String: String]] else {
|
||||
return XCTFail("the canonicalJson section is missing")
|
||||
}
|
||||
for pair in pairs {
|
||||
let input = pair["input"] ?? ""
|
||||
let expected = pair["output"] ?? ""
|
||||
let got = try CanonicalJSON.canonicalize(input)
|
||||
XCTAssertEqual(String(decoding: got, as: UTF8.self), expected, "canonical JSON for \(input)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCanonicalJSONRejectsEveryInputTheVectorsMarkInvalid() throws {
|
||||
let vectors = try loadVectors()
|
||||
guard let rejected = vectors["canonicalJsonRejected"] as? [String] else {
|
||||
return XCTFail("the canonicalJsonRejected section is missing")
|
||||
}
|
||||
for input in rejected {
|
||||
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCanonicalJSONRejectsWhatTheProtocolWillNotHash() {
|
||||
for input in ["{", #"{"a":1} trailing"#, #"{"a":+1}"#, #"{"a":.5}"#, #"{"a":NaN}"#,
|
||||
#"{"a":1.0}"#, #"{"a":1e3}"#, #"{"a":01}"#, #"{"a":-0}"#] {
|
||||
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
||||
}
|
||||
XCTAssertEqual(try CanonicalJSON.canonicalize(""), Data())
|
||||
XCTAssertEqual(try CanonicalJSON.canonicalize(" "), Data())
|
||||
}
|
||||
|
||||
/// The three key-sort shortcuts disagree, so this pins the one the protocol uses: UTF-8 byte
|
||||
/// order. Swift's default `String` comparison uses Unicode collation, which would put "é"
|
||||
/// before "z"; Foundation's `.sortedKeys` does the same.
|
||||
func testCanonicalJSONSortsKeysByUTF8Bytes() throws {
|
||||
XCTAssertEqual(
|
||||
String(decoding: try CanonicalJSON.canonicalize(#"{"é":1,"z":2}"#), as: UTF8.self),
|
||||
#"{"z":2,"é":1}"#
|
||||
)
|
||||
XCTAssertEqual(
|
||||
String(decoding: try CanonicalJSON.canonicalize("{\"\u{10000}\":1,\"\u{FFFD}\":2}"), as: UTF8.self),
|
||||
"{\"\u{FFFD}\":2,\"\u{10000}\":1}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,12 +109,14 @@ describe('instance API data source', () => {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'If-Match': '"rev-3"',
|
||||
'X-Preparation-Id': 'prep-1',
|
||||
'X-Confirmation-Token': token,
|
||||
},
|
||||
});
|
||||
// A bodyless request must not claim a JSON body: the server's parser rejects the empty body
|
||||
// with 400 before the confirmation headers are ever read, so deletion never happens.
|
||||
expect(fetch.mock.calls[2]?.[1]?.headers).not.toHaveProperty('Content-Type');
|
||||
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
|
||||
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
|
||||
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
|
||||
|
||||
@@ -89,9 +89,17 @@ interface JsonResponse {
|
||||
readonly response: Response;
|
||||
}
|
||||
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
|
||||
// Content-Type describes the body. Sending it on a bodyless request makes the server's JSON
|
||||
// parser reject the empty body with 400 before the route runs — which silently killed every
|
||||
// DELETE (deletion) and POST (test-connection) that carries its intent in headers alone.
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw await safeError(response);
|
||||
return {
|
||||
|
||||
@@ -198,6 +198,7 @@ xcodebuild -scheme MultiSimAdmin \
|
||||
## 11. 文档索引
|
||||
|
||||
- [docs/ios-native.md](ios-native.md):本文,现行说明
|
||||
- [docs/ios-relay-architecture.md](ios-relay-architecture.md):双通道(内网直通 + 公网中继)设计稿,未实施
|
||||
- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 构建 10 发布说明与校验值
|
||||
- [docs/ios-native-0.2.1.md](ios-native-0.2.1.md):0.2.1 构建 6 发布说明与校验值
|
||||
- [docs/ios-native-0.2.0.md](ios-native-0.2.0.md):0.2.0 发布说明
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# iOS 双通道方案:内网直通 + 中继到节点 agent
|
||||
|
||||
状态:设计稿(未实施)。目标是让 iPhone 在不出 VPN、不做内网穿透映射的前提下管理内网节点,
|
||||
同时保留现有局域网直通能力。
|
||||
|
||||
## 1. 约束与结论
|
||||
|
||||
三条约束里只能同时满足两条,这是本方案的全部出发点:
|
||||
|
||||
1. 手机不依赖 VPN 客户端。
|
||||
2. 内网不存在任何公网可解析的入口(端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 的公网
|
||||
hostname 都算,判定标准是"打到这个名字能不能进内网",与连接建立方向无关)。
|
||||
3. 中继服务器不参与信任。
|
||||
|
||||
因此拓扑必须是:手机 → 公网中继 → 节点 agent,其中 agent 只发起出站连接,且中继**看不懂**
|
||||
也**改不动**流经它的指令。第 3 条不是靠"尽量安全"实现的,而是靠逐条签名 + 逐条加密实现的;
|
||||
少了密码学,这套东西就只是一个更难用的 VPN。
|
||||
|
||||
```text
|
||||
iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) <──出站 HTTPS 长轮询── 节点A agent ──HTTP──> 127.0.0.1:3000
|
||||
每节点一条 agent 连接,agent 只被允许访问本机回环
|
||||
```
|
||||
|
||||
`<──` 表示连接由 agent 侧发起,内网无任何监听端口。
|
||||
|
||||
> **实现修订(2026-09)**:agent↔中继采用**长轮询 HTTPS**而非 WSS。理由是实现约束而非协议偏好:
|
||||
> agent 必须只用 Go 标准库交叉编译(WebSocket 的 framing/masking/ping-pong/close 要几百行且需要
|
||||
> 自己测试),而"连接由内网侧发起、内网无监听"这一安全性质与承载协议无关。握手为两段式
|
||||
> challenge-response(`/v1/agent/hello` → 签服务端随机数),投递为 `/v1/agent/poll`(25s 挂起)
|
||||
> + `/v1/agent/result`,代码在 `simadmin-relay/agent/internal/transport/` 与 `relay/src/`。
|
||||
|
||||
## 2. 为什么是"每节点一个 agent"而不是"内网一个网关 agent"
|
||||
|
||||
用户提出的这个选择是正确的,且理由比运维便利性更强:**它把 SSRF 跳板的爆炸半径从整个内网收缩到
|
||||
一台本来就已经物理接触的节点。**
|
||||
|
||||
网关型 agent 必须能访问 `192.168.1.0/24`,于是一条合法签名的指令可以拿它当跳板去扫内网任意
|
||||
主机任意端口(`http://192.168.1.1:80/admin`、`http://169.254.169.254/...`)。要堵住就得再加
|
||||
一份 origin 白名单,而白名单本身又是一个需要人维护、容易配错的安全边界。
|
||||
|
||||
每节点 agent 则可以把出口**硬编码为回环**:agent 启动时校验目标 host 必须是 `127.0.0.1` 或
|
||||
`::1`,否则拒绝启动。这条规则不需要配置、不需要白名单、不可能配错。
|
||||
|
||||
代价是 agent 数量 = 节点数量,每台都要装、都要配对、都要保活。以及:如果某台节点上的 SimAdmin
|
||||
并不在回环上(比如跑在另一台机器上),本方案对它不适用,需要退回网关型并显式配置 CIDR 白名单。
|
||||
|
||||
## 3. 密码学信封
|
||||
|
||||
密钥(每设备一套,每节点一套):
|
||||
|
||||
| 密钥 | 算法 | 用途 |
|
||||
|---|---|---|
|
||||
| `K_app_sign` | Ed25519 | 手机签名请求信封,agent 验签 |
|
||||
| `K_app_kx` | X25519(静态) | 手机接收响应时的 DH 一方 |
|
||||
| `K_agent_sign` | Ed25519 | agent 签名响应,手机验签 |
|
||||
| `K_agent_kx` | X25519(静态) | agent 接收请求时的 DH 一方 |
|
||||
|
||||
请求信封(手机 → 中继 → agent):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"v": 1,
|
||||
"node": "nd_7f3a...",
|
||||
"req": "<uuid4>",
|
||||
"app": "ap_5b1d...",
|
||||
"ts": 1727289600,
|
||||
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
|
||||
"ek": "<base64 32B>", // 本次请求的临时 X25519 公钥
|
||||
"p": "<base64 AES-256-GCM>",
|
||||
"s": "<base64 Ed25519>"
|
||||
}
|
||||
```
|
||||
|
||||
短字段名是刻意的:这是三语言共享的线上格式,Go / Node / Swift 各自的结构体标签必须逐字一致,
|
||||
而长名在手写解码器里最容易抄错。字段名以 `agent/internal/wire/wire.go` 为权威。
|
||||
|
||||
`s` 覆盖 `v,node,req,app,ts,ek,p` 的规范化字节串(固定字段序 + 每个字段 uint32be 长度前缀,
|
||||
不做 JSON 字符串拼接签名)。这一点是实质性的:签名包含 `node`,中继就无法把一个对节点 A 有效
|
||||
的请求改投给节点 B;签名包含 `req` 与 `ek`,就无法做响应错配或临时公钥替换;签名包含 `app`,
|
||||
中继就无法把一台手机的流量改标成另一台的。响应信封用另一个域字符串 `msa-relay-v1-resp` 且
|
||||
**不含 `app`**,因此两个方向的签名不能互换。
|
||||
|
||||
规范化字节串的确切定义(含 `v`/`ts` 也带 `00000008` 长度前缀这一条)以
|
||||
`contracts/test-vectors.json` 为准,该文件由 `agent/cmd/msa-vectors` 生成,并由 Node
|
||||
(`test/wire.test.js`)与 Swift 各自重算。三语言互不共享代码,因此它们的字节一致是真实证据,
|
||||
不一致则说明编码漂移,而不是"某一端坏了"。
|
||||
|
||||
`alg` 只有唯一合法值,且在任何其他字段之前校验。未知值一律硬错误,不做算法协商——降级攻击
|
||||
就发生在协商里。
|
||||
|
||||
`payload` 解密后的内层:
|
||||
|
||||
```jsonc
|
||||
{ "m": "POST", "p": "/api/sms/send",
|
||||
"intent": "<base64 sha256(upper(trim(m))|p|canonicalJSON(body))>",
|
||||
"body": "<base64 原始请求体>" }
|
||||
```
|
||||
|
||||
`body` 以原始字节透传,agent 不解析也不重新序列化:一旦重新编码 JSON,intent 所覆盖的字节就
|
||||
和真正转发上去的字节不同,确认语义随之失效。`p` 携带完整路径与查询串,agent 不做路径拼接,
|
||||
因此不存在来自中继的路径穿越面。
|
||||
|
||||
`ts` 由信封层统一负责新鲜度(`|now - ts| > clockSkewSeconds` 即拒),内层不再带 `deadline`
|
||||
字段:两套过期语义只会互相冲突,而且信封层的那一个受签名保护,内层的那一个不受。
|
||||
|
||||
密钥派生:请求方向 `HKDF(X25519(appEphPriv, K_agent_kx.pub), salt=nodeID, info="msa-relay-v1-req")`,
|
||||
agent 侧用 `X25519(K_agent_kx.priv, ek)` 得到同一密钥;响应方向由 agent 每次新生成临时密钥对,
|
||||
把公钥放在响应信封的 `ek` 里(同样被签名覆盖),与**手机本次请求的 `ek`** 做 DH——即响应方向是
|
||||
临时×临时,手机不需要在配对后保留可用的 `K_app_kx` 私钥于响应路径(第 11 节第 5 项的缓存方案
|
||||
因此已由实现取代)。
|
||||
|
||||
**这里的前向安全性质要说准,不能含糊。** 请求方向是"临时-静态"单消息 DH(HPKE base 模式的
|
||||
同一形状),因此只对**发送方**(手机)的长期密钥泄露是前向安全的,对**接收方**(agent)的不是;
|
||||
响应方向(实现后)双向临时,任一方长期密钥泄露都不能解密历史响应:
|
||||
|
||||
- 请求方向的 `ek` 以明文出现在被签名的信封里。日后 `K_agent_kx.priv` 泄露的人,可以用它和录到的
|
||||
`ek` 直接算出历史请求的密钥,从而读出历史请求内容。
|
||||
- 反过来,`appEphPriv` 与 `agentEphPriv` 用后即弃且从不上传线,所以只泄露"发送方"的长期密钥
|
||||
不足以解密历史。
|
||||
|
||||
> **实现修订**:响应方向已改为对手机**本次请求的临时 `ek`** 加密,无需额外往返,因此上段"建议的
|
||||
> 缓存方案"对响应方向已无必要。残留的弱化只剩请求方向:要让请求也对 `K_agent_kx` 泄露前向安全,
|
||||
> 才需要手机缓存 agent 的当前临时公钥(bootstrap 第一条除外)。是否值得做,留待 P4 后再定。
|
||||
|
||||
GCM IV 取 `HKDF(S_req, salt=reqID, info="iv")` 的前 12 字节,显式派生而不是全零。虽然每个请求
|
||||
都用一次性密钥、全零 IV 在实践中不会重用,但一旦有人日后把 `S_req` 改成按会话复用(很常见的
|
||||
"优化"),全零 IV 立刻变成 nonce-reuse 灾难;显式派生把这条退路堵死。
|
||||
|
||||
重放防护:agent 维护 `reqID` 的 LRU(建议 4096 条)拒绝重复;拒绝 `|now - ts| > 120s` 的请求;
|
||||
不执行过期请求。不需要额外的 nonce 字段——`req` 是 uuid4 且被签名覆盖,中继无法把一个有效请求
|
||||
的 `req` 换掉再投,因此它本身就是唯一性来源。
|
||||
|
||||
时钟偏移是本方案最容易被忽略的故障源:节点设备如果长期离线且时钟漂移,表现为"所有指令都被拒",
|
||||
所以 agent 日志必须把拒绝原因写成可区分的三类(签名无效 / 重放 / 超时),手机侧错误文案也要能
|
||||
区分,否则排查会非常痛苦。
|
||||
|
||||
## 4. agent 侧的授权语义
|
||||
|
||||
**中继不得授权任何事。** 危险写的确认必须由手机产生、由 agent 校验,因为中继在本方案里是
|
||||
不可信方。这与现有控制台的做法不同:`server/proxy/confirmations.js` 的一次性令牌是**服务端签发**
|
||||
的,在"服务端可信"的前提下成立;搬到中继模式后签发方变成不可信方,语义就空了。
|
||||
|
||||
替代做法:危险写要求内层带 `confirm.intent`,其值为用户明确点击确认后计算的
|
||||
`sha256(method|path|规范化 body)`。agent 重算并比对,因此 intent 与实际操作强绑定——中继无法
|
||||
拿一个"确认过发短信"的 intent 去执行"改 APN"。同时 agent 本地限流(默认危险写 6 次/分钟)并
|
||||
落审计日志。
|
||||
|
||||
诚实说明弱化点:这防的是**中继和外部攻击者**,防不了手机本身被控。手机丢了要靠中继侧吊销设备
|
||||
(第 5 节),而吊销是"拒绝投递",agent 侧仍会接受该密钥的指令,直到你重新配对。要做到 agent
|
||||
侧即时吊销,必须让 agent 可编辑受信设备密钥集,并在每次连上中继时拉一次密钥集快照——建议纳入
|
||||
第一期,因为它决定了"丢手机"这件事的真实后果。
|
||||
|
||||
## 5. 中继
|
||||
|
||||
职责只有四件:鉴权、投递、关联、超时。它没有解密能力,也不需要节点凭据。
|
||||
|
||||
- 设备鉴权:配对时下发的长随机 bearer token(仅用于访问控制与滥用限制,**不构成信任**)。
|
||||
- agent 鉴权:bearer token + 用 `K_agent_sign` 签一个服务端随机数。仅偷到 token 无法冒充 agent。
|
||||
- 注册表:`nodeID -> 唯一活跃会话`(长轮询挂起槽,后到的 agent 握手顶掉前一个);agent 断线即标记不可达,不做请求排队等待重连。
|
||||
- 邮箱:每节点一条有界 FIFO(深度建议 8),`reqID` 关联请求与响应,30s 超时向上返回
|
||||
- 错误一律用仓库既有的 RFC 9457 信封 `ProblemDetails`(`packages/contracts/src/errors.ts`),
|
||||
不另造形状;其中 `requestId` 直接取 `reqID`,这样中继日志、控制台 request coordinator 与手机
|
||||
三方可用同一个 id 串起来查。超时返回 `504` + `code: "relay_agent_timeout"`,不重试。有界队列
|
||||
是刻意的:宁可明确失败,也不要让手机以为"发出去了"。
|
||||
- 日志:只记元数据(nodeID、reqID、方法类别、字节数、时延、结果码)。这是本方案无法消除的泄露面。
|
||||
|
||||
## 6. iOS 侧改造
|
||||
|
||||
### 6.1 端点模型
|
||||
|
||||
现在 `Instance.origin: URL` 是非可选的,并且同时承担三件事:请求目标、去重键
|
||||
(`FileInstanceRepository` 用 `$0.origin == origin` 抛 `duplicateOrigin`)、会话 Cookie 的绑定域
|
||||
(`InstanceSessionStore` 按 `(UUID, origin)` 存,origin 变了即失效)。中继模式下这三个职责都要
|
||||
重新安置,所以必须引入端点抽象而不是给 `Instance` 加一个可选的 `relayNodeID` 字段:
|
||||
|
||||
```swift
|
||||
public enum InstanceEndpoint: Codable, Equatable, Sendable {
|
||||
case direct(URL)
|
||||
case relay(RelayEndpoint)
|
||||
}
|
||||
|
||||
public struct RelayEndpoint: Codable, Equatable, Sendable {
|
||||
public var relayBaseURL: URL
|
||||
public var nodeID: String
|
||||
public var agentSigningKey: Data // Ed25519 pub, 32B
|
||||
public var agentKxKey: Data // X25519 pub, 32B
|
||||
public var deviceToken: Data // 中继 bearer
|
||||
}
|
||||
```
|
||||
|
||||
迁移:`Instance` 的 `Codable` 改为容忍旧数据——`init(from:)` 先试 `endpoint`,缺失时把
|
||||
`origin` 读成 `.direct(origin)`。去重键换成 `endpoint.dedupKey`(直通为 origin 绝对串,中继为
|
||||
`relay:<nodeID>`)。写盘时统一输出 `endpoint`。旧包升级后行为不变,且不会把已有节点判成重复。
|
||||
|
||||
`credentialConfigured` 的语义要按模式分叉:直通表示"本机存了该节点密码",中继表示"agent 侧已
|
||||
配置凭据"。两者不能混用同一个文案,否则用户会以为中继模式下手机里还有密码。
|
||||
|
||||
### 6.2 传输层
|
||||
|
||||
```swift
|
||||
public protocol NodeTransport: Sendable {
|
||||
func send(_ route: UpstreamRoute, endpoint: InstanceEndpoint) async throws -> UpstreamResponse
|
||||
}
|
||||
```
|
||||
|
||||
- `DirectTransport` 就是现有 `SimAdminClient` 的行为,含逐节点登录与 Cookie 管理,**代码路径
|
||||
与今天完全一致**,不引入任何行为差异。
|
||||
- `RelayTransport` 只做信封构造、签名、加密、HTTPS 投递、验签、解密;不碰 Cookie,节点密码
|
||||
从不出手机(agent 自己登录 `127.0.0.1`)。
|
||||
|
||||
`UpstreamRoute` 无需改动:它已经是 `method + path + body` 的纯数据描述,天然可序列化进信封。
|
||||
这是现有设计里少数直接能复用的部分。
|
||||
|
||||
### 6.3 模式选择
|
||||
|
||||
建议**显式优先**:每个实例一个明确模式,"自动"后置。理由是自动回退(直通短超时 1.5s 后转中继)
|
||||
在非内网时会把每个读的延迟翻倍,且失败原因变得难以归因——用户看到的是"时快时慢",而不是
|
||||
"不在内网"。自动模式应当等到中继跑稳、且能拿到网络可达性信号之后再上。
|
||||
|
||||
界面必须做两件事,否则运维上不可接受:
|
||||
|
||||
1. 节点卡片显示通道徽标(直通 / 中继)。没有它,"这台为什么慢"无法回答。
|
||||
2. 错误分层。至少区分「中继不可达」「agent 离线」「信封被拒(验签或重放)」「节点返回错误」
|
||||
四类。现有 `UpstreamError` 有 7 个分支,但传输层失败全部塌进 `transportFailed` 与
|
||||
`invalidResponse`,无法表达上面这四类的区别;节点返回错误还能靠状态码分支,中继自身的失败
|
||||
则完全没有对应分支。中继模式需要新增独立分支,不要复用 `transportFailed` 兜底,否则用户
|
||||
看到的永远是同一句"请求失败"。
|
||||
|
||||
错误码 vocabulary 建议落在 `packages/contracts` 里以 const 数组枚举(照 `CAPABILITY_STATUSES`
|
||||
的现有写法),由中继、agent、iOS 三方共享。现状是 `ProblemDetails.code` 在 OpenAPI 里只是
|
||||
无约束 `string`,全仓没有枚举,所以这是新增而非复用——但必须在第一期就枚举出来,否则三方
|
||||
各写各的字符串,跨端排查立刻失效。
|
||||
|
||||
### 6.4 密钥存放
|
||||
|
||||
按既有约定,设备私钥与节点凭据同放应用沙盒文件,不进钥匙串。需要明确记录的代价:沙盒文件在
|
||||
越狱设备上可被读取,Secure Enclave(`SecureEnclaveKey`)能消除这一点,但与"不用钥匙串"的
|
||||
既定决策以及 TrollStore 签名流程冲突。**这是本方案唯一悬而未决的安全取舍,需要你定。**
|
||||
|
||||
好消息:Ed25519 / X25519 / AES-GCM / HKDF 全部在 CryptoKit 内,iOS 16 起可用,**零新增依赖**。
|
||||
|
||||
## 7. 配对流程
|
||||
|
||||
利用"配对时手机一定在内网"这个事实,不需要扫码:
|
||||
|
||||
1. 节点上启动 agent,它临时在内网接口监听 `:3001` 的管理端口,仅开放配对与自毁两个端点。
|
||||
2. 手机在直通模式下已经能访问该节点,于是把 `{appSignPub, appKxPub, deviceName}` POST 到
|
||||
`http://<节点内网IP>:3001/provision`,以节点密码作为共享秘密完成鉴权。
|
||||
3. agent 返回 `{nodeID, agentSignPub, agentKxPub, relayURL, deviceToken}`,手机存为该实例的中继端点。
|
||||
4. agent 关闭管理监听,此后只出站连中继。
|
||||
|
||||
第 4 步是必须的:管理端口长期开着就等于在本方案之外又开了一个能注入受信设备的洞。
|
||||
|
||||
> **实现修订(2026-09)**:窗口由 `msa-agent --pair` 显式开启(默认 `:3001`),且第 4 步由三个
|
||||
> 独立关闭条件保证——首次成功配对即关、失败尝试达 10 次即关、10 分钟无人配对即关。`appKxPub`
|
||||
> 仍按原样接收(线上格式不变),但响应方向改用手机请求临时 `ek` 后 agent 并不使用它。设备的中继
|
||||
> bearer 由 agent 在配对时生成并持久化于配置文件(0600 原子写),中继侧设备注册改为 agent 握手后
|
||||
> POST `/v1/agent/devices` 全量上报:中继重启可自愈,节点 destroy 设备列表后的下一次握手即吊销
|
||||
> 该节点上报的全部 bearer。操作员手写的 `MSA_DEVICES` 条目为 static,任何 agent 上报不得覆盖。
|
||||
> 代码:`agent/internal/pair/`、`relay/src/auth.js#registerDevices`。
|
||||
|
||||
## 8. 策略文件必须共享,不能各自实现
|
||||
|
||||
`server/proxy/policy.js` 的 `DEFAULT_READ_PATHS` / `DEFAULT_WRITE_PATHS` 是本项目里最有价值的
|
||||
现成资产,但它是 JS。如果 agent 用 Go/Rust 写,就会变成"两份手抄的白名单",而两份策略必然
|
||||
漂移——漂移的后果是同一操作在网页控制台被拒、在手机中继下被放行,这类 bug 极难发现。
|
||||
|
||||
因此第一步应当是把策略抽成数据文件(`packages/contracts/src/proxy-policy.json`),控制台与
|
||||
agent 都从它加载,并加一个测试断言两侧解析结果一致。这一步很小,但它决定后面所有安全性质
|
||||
是否真的成立。
|
||||
|
||||
## 9. 实施阶段
|
||||
|
||||
| 阶段 | 内容 | 依赖 |
|
||||
|---|---|---|
|
||||
| P1 | 契约先行:策略抽为共享 JSON、枚举中继错误码、把 `FRESHNESS_STATUSES` 引到 iOS;加双端一致性测试 | 无 |
|
||||
| P2 | agent:出站长轮询、信封验签解密、策略执行、回环硬校验、审计日志、本机登录、配对端点 | P1 |
|
||||
| P3 | 中继:设备/agent 鉴权、注册表、有界邮箱、超时、元数据日志 | P1 |
|
||||
| P4 | iOS:`InstanceEndpoint` + 迁移 + `NodeTransport` 抽象 + 密钥与配对 | P2,P3 |
|
||||
| P5 | iOS:通道徽标、分层错误、危险写确认、吊销与轮换入口 | P4 |
|
||||
| P6 | 文档、部署脚本、渗透自查(重点:回环硬校验、重放、队列堆积) | P5 |
|
||||
|
||||
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:部署文档(`simadmin-relay/docs/deployment.md`)与渗透自查(`test/adversarial.test.js`,
|
||||
> 12 项)已落地。自查找出一处真实缺陷并已修复:中继的 pending 表只按 `reqID` 索引,
|
||||
> `/v1/agent/result` 未校验提交方是否该请求所属节点的 agent,因此被控节点只要知道另一个节点的
|
||||
> 在途 `reqID` 即可抢先应答,把它变成 orphan 并让合法应答被丢弃。修复方式是在
|
||||
> `registry.resolve` 上增加 nodeID 校验(`relay/src/registry.js`)。
|
||||
>
|
||||
> 另有一个**端到端测试**(`test/e2e.test.js`):它编译并运行真实的 Go agent,经 TLS 接到真实
|
||||
> 中继,由模拟手机发起签名请求。它一次抓出三个"单元测试全绿、合起来坏了"的缺陷,值得逐条记录,
|
||||
> 因为三个都是接缝问题:
|
||||
>
|
||||
> 1. `transport.Client` 把会话 token 写进了 `c.token`(bearer 字段),于是轮询拿会话 token 当
|
||||
> bearer 发,中继一律 401;症状是 agent 连上后无限重连,日志说"凭据被拒"。两个凭据现在分开。
|
||||
> 2. `main.loop` 每个周期都重新握手。中继每节点只保留一个会话,新握手顶掉旧的并丢弃其挂起的
|
||||
> 轮询,于是两次握手之间到达的请求被投进已不存在的会话、以"agent 离线"超时,而 agent 全程
|
||||
> 在线。改为仅在会话失效或传输失败时重新握手。
|
||||
> 3. `auth.registerDevices` 把"已存在"一律跳过,随后又被"本轮未声明"的回收循环删除,所以节点的
|
||||
> 第二次握手会注销自己刚注册的设备。改为本节点已持有即确认(幂等)——重连与重握手是常态。
|
||||
>
|
||||
> 结论:契约与单元测试能证明两侧各自正确,证明不了两侧接得上。上线前必须跑一次端到端。
|
||||
>
|
||||
> 另:agent 支持 `relayCAFile`(PEM 钉 CA,**替代**系统信任库而非关闭校验),端到端测试用自签
|
||||
> 证书 + 钉 CA 走的就是这条真实校验路径。
|
||||
>
|
||||
> **Swift 侧签名不是逐字节可复算的**:CryptoKit 的 Ed25519 签名是随机化的(同一密钥同一消息
|
||||
> 两次签名结果不同),Go 与 Node 用的是 RFC 8032 确定性实现。因此 `RelayWireTests` 校验的是
|
||||
> 更强的性质——双方各自验证对方产出的签名,以及签名输入与向量中的规范化字节完全相等——而不是
|
||||
> 签名本身相等。这一点已实测确认:CryptoKit 能验证 Go 生成的签名,Go 也能验证 CryptoKit 生成的
|
||||
> 签名。
|
||||
|
||||
## 10. 已知代价
|
||||
|
||||
- 中继被攻破 = 服务不可用(拒绝投递),不等于数据泄露。这是本方案换来的东西。
|
||||
- 元数据仍然泄露给 VPS:谁在何时对哪个节点做了哪类操作、响应多大。要连这个都不要,只能回到 VPN。
|
||||
- 读操作两跳延迟,实时性弱于直通,节点状态必须能表达"这是多久以前的"。仓库里已有
|
||||
`FRESHNESS_STATUSES`(`fresh / stale / expired / unknown`,`packages/contracts/src/instances.ts`),
|
||||
但 iOS 侧目前**完全没有**这套 vocabulary,只有概览副标题一句"刚刚更新"。中继模式必须把
|
||||
freshness 落到 iOS 数据模型与卡片上(沿用同一组四个状态值,不要另起名),否则用户无法判断
|
||||
一个"在线"是当下的还是 40 秒前的——这在中继模式下是实质性误导。
|
||||
- 每台节点多一个常驻进程,Android 上会被 Doze 杀,需要前台服务与电池优化豁免;这是运维负担,
|
||||
不是理论问题。
|
||||
- 内网仍无任何公网入口,但**出站被完全掐断的环境(仅白名单出网的内网)会让 agent 无法工作**。
|
||||
|
||||
## 11. 已确认项与仍待确认项
|
||||
|
||||
已确认(据此实现):
|
||||
|
||||
1. 节点设备为 **ARM 架构 Debian**,且 SimAdmin 与 agent 同机运行。因此 agent 是 Go 静态二进制,
|
||||
交叉编译 `linux/arm64` 与 `linux/arm`,回环硬校验(第 2 节)对所有节点成立,无需 CIDR 白名单。
|
||||
2. 设备私钥放在**应用沙盒文件**,不进钥匙串(6.4)。代价已记录,此处不再悬置。
|
||||
3. 8788 控制台与中继**不同机**,理由见第 2 项旧表:同机会把控制台推到公网口。
|
||||
|
||||
仍待确认:
|
||||
|
||||
4. 第 10 节的元数据泄露(谁在何时对哪个节点做了哪类操作)是否接受。这是本方案不可消除的泄露面。
|
||||
5. ~~双临时缓存方案~~ **已定**:响应方向实现为对手机请求临时 `ek` 加密(双向临时),无需缓存;
|
||||
请求方向维持临时-静态,即明确接受"`K_agent_kx.priv` 泄露可解密历史请求"。若要连请求方向也
|
||||
补齐,再评估缓存方案(代价见第 3 节修订注)。
|
||||
6. agent 侧即时吊销(每次连上中继拉一次受信设备密钥集快照)是否纳入第一期。不纳入则"丢手机"
|
||||
的真实后果是:中继能拒绝投递,但 agent 仍接受该密钥的指令直到重新配对。
|
||||
|
||||
## 12. 本方案明确不做的事
|
||||
|
||||
- 不做端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 公网 hostname。
|
||||
- 不让中继持有或代管节点密码。
|
||||
- 不在 iOS app 内嵌 VPN 网络扩展(需要 Apple 签发的 entitlement 与带 app-extension 的
|
||||
bundle,当前签名流程不适合)。
|
||||
- 不引入"自动选路"作为默认行为。
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user