Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Relay/RelayTransport.swift
T
chick 3f6090081f 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)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

367 lines
15 KiB
Swift

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() }
}