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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user