- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
147 lines
5.7 KiB
Swift
147 lines
5.7 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|