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