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