feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误

- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
This commit is contained in:
chick
2026-09-16 18:57:41 +08:00
parent a6867bc9cb
commit 3f6090081f
37 changed files with 4129 additions and 199 deletions
@@ -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) }
}