apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

commit 80f40b55cdf903c1b6ebf46fef4f0da3437e8469
parent eda7033bb978ad72e976860ce6f528afccc3bb37
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 19:31:37 +0000

feat(identity): add native opaque signer custody

- keep one active Nostr identity in Apple-native protected storage
- bind opaque signing to operation, signer, public key, purpose, and deadline
- recover interrupted metadata and Keychain replacement or deletion transactions
- support authenticated encrypted portability with native and fault-injection tests

Diffstat:
APackage.resolved | 14++++++++++++++
MPackage.swift | 9+++++++++
ASources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsIdentityCryptography.swift | 332+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsIdentityCustody.swift | 886+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsIdentityCustodyTypes.swift | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift | 457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 2227 insertions(+), 0 deletions(-)

diff --git a/Package.resolved b/Package.resolved @@ -0,0 +1,14 @@ +{ + "originHash" : "5534f00c4fa123da227258814ec4154b45a69dd0d21cf8a595075c7c18367a20", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", + "state" : { + "revision" : "e70a10e036a55fffea31568f0af92d69b6d449cd" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift @@ -17,9 +17,18 @@ let package = Package( targets: ["RadrootsKitTesting"] ) ], + dependencies: [ + .package( + url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", + revision: "e70a10e036a55fffea31568f0af92d69b6d449cd" + ) + ], targets: [ .target( name: "RadrootsKit", + dependencies: [ + .product(name: "P256K", package: "swift-secp256k1") + ], linkerSettings: [ .linkedFramework("Security"), .linkedFramework("LocalAuthentication"), diff --git a/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift b/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift @@ -0,0 +1,58 @@ +import Foundation + +public final class RadrootsAppleIdentityMetadataStore: RadrootsIdentityMetadataStore, + @unchecked Sendable +{ + private let lock = NSLock() + private let userDefaults: UserDefaults + private let keyPrefix: String + + public init( + namespace: String, + userDefaults: UserDefaults = .standard, + keyPrefix: String = "org.radroots.kit.identity" + ) throws { + self.userDefaults = userDefaults + self.keyPrefix = try Self.normalizedPrefix(keyPrefix, namespace: namespace) + } + + public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { + lock.lock() + defer { lock.unlock() } + return userDefaults.data(forKey: key(for: slot)) + } + + public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { + guard !data.isEmpty, data.count <= 64 * 1_024 else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + lock.lock() + userDefaults.set(data, forKey: key(for: slot)) + lock.unlock() + } + + public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + userDefaults.removeObject(forKey: key(for: slot)) + lock.unlock() + } + + func key(for slot: RadrootsIdentityMetadataSlot) -> String { + "\(keyPrefix).\(slot.rawValue)" + } + + private static func normalizedPrefix(_ value: String, namespace: String) throws -> String { + let prefix = value.trimmingCharacters(in: .whitespacesAndNewlines) + let namespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !prefix.isEmpty, + prefix.utf8.count <= 128, + !namespace.isEmpty, + namespace.utf8.count <= 128, + !prefix.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains), + !namespace.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + return "\(prefix).\(namespace)" + } +} diff --git a/Sources/RadrootsKit/RadrootsIdentityCryptography.swift b/Sources/RadrootsKit/RadrootsIdentityCryptography.swift @@ -0,0 +1,332 @@ +import CryptoKit +import Foundation +import P256K +import Security + +public struct RadrootsIdentityPortabilityEnvelope: Sendable, CustomDebugStringConvertible { + private let serialized: Data + + public init(serializedRepresentation: Data) throws { + _ = try RadrootsIdentityPortabilityCodec.decodeWire(serializedRepresentation) + self.serialized = serializedRepresentation + } + + public var serializedRepresentation: Data { + serialized + } + + public var version: UInt16 { + (try? RadrootsIdentityPortabilityCodec.decodeWire(serialized).version) ?? 0 + } + + public var debugDescription: String { + "RadrootsIdentityPortabilityEnvelope(version: \(version), encryptedPayload: <redacted>)" + } +} + +struct RadrootsIdentityCryptography: Sendable { + func generateSecret() throws -> Data { + do { + return try P256K.Schnorr.PrivateKey().dataRepresentation + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + } + + func publicKeyHex(for secret: Data) throws -> String { + do { + return Self.hex(try P256K.Schnorr.PrivateKey(dataRepresentation: secret).xonly.bytes) + } catch { + throw RadrootsIdentityCustodyError.invalidSecret + } + } + + func identityHandle(forPublicKeyHex publicKeyHex: String) throws -> String { + guard let bytes = Self.decodeHex(publicKeyHex), bytes.count == 32 else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + return "rrid1_\(Self.hex(CryptoKit.SHA256.hash(data: bytes)))" + } + + func sign(secret: Data, digest: Data) throws -> Data { + guard digest.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + do { + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret) + var message = [UInt8](digest) + var auxiliary = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, auxiliary.count, &auxiliary) == errSecSuccess + else { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + let signature = try auxiliary.withUnsafeMutableBytes { buffer in + try key.signature( + message: &message, + auxiliaryRand: buffer.baseAddress, + strict: true + ) + } + let signatureData = signature.dataRepresentation + guard key.xonly.isValid(signature, for: &message) else { + throw RadrootsIdentityCustodyError.invalidSignature + } + return signatureData + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + } + + func verify(signature: Data, digest: Data, publicKeyHex: String) -> Bool { + guard signature.count == 64, + digest.count == 32, + let publicKey = Self.decodeHex(publicKeyHex), + publicKey.count == 32, + let parsedSignature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: signature) + else { + return false + } + let key = P256K.Schnorr.XonlyKey(dataRepresentation: publicKey) + var message = [UInt8](digest) + return key.isValid(parsedSignature, for: &message) + } + + static func hex<S: Sequence>(_ bytes: S) -> String where S.Element == UInt8 { + bytes.map { String(format: "%02x", $0) }.joined() + } + + static func decodeHex(_ value: String) -> Data? { + guard value.count.isMultiple(of: 2), + value.unicodeScalars.allSatisfy({ + (48...57).contains($0.value) || (97...102).contains($0.value) + }) + else { + return nil + } + var output = Data(capacity: value.count / 2) + var index = value.startIndex + while index < value.endIndex { + let next = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index..<next], radix: 16) else { + return nil + } + output.append(byte) + index = next + } + return output + } +} + +enum RadrootsIdentityPortabilityCodec { + static let version: UInt16 = 1 + static let kdf = "pbkdf2-hmac-sha256" + static let cipher = "aes-256-gcm" + static let iterations: UInt32 = 210_000 + static let maximumEnvelopeBytes = 128 * 1_024 + + struct Wire: Codable { + let version: UInt16 + let kdf: String + let iterations: UInt32 + let cipher: String + let publicKeyHex: String + let salt: Data + let nonce: Data + let ciphertext: Data + let tag: Data + } + + struct Plaintext: Codable { + let version: UInt16 + let secret: Data + let publicKeyHex: String + let label: String? + let createdAtUnixMilliseconds: UInt64 + } + + static func seal( + secret: Data, + record: RadrootsIdentityPublicRecord, + passphrase: RadrootsIdentityPassphrase + ) throws -> RadrootsIdentityPortabilityEnvelope { + var salt = [UInt8](repeating: 0, count: 16) + guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + let plaintext = Plaintext( + version: version, + secret: secret, + publicKeyHex: record.publicKeyHex, + label: record.label, + createdAtUnixMilliseconds: record.createdAtUnixMilliseconds + ) + var cleartext = try encoder().encode(plaintext) + defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) } + do { + let key = try deriveKey( + passphrase: passphrase.copyBytes(), + salt: Data(salt), + iterations: iterations + ) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal( + cleartext, + using: key, + nonce: nonce, + authenticating: aad( + version: version, + kdf: kdf, + iterations: iterations, + cipher: cipher, + publicKeyHex: record.publicKeyHex + ) + ) + let wire = Wire( + version: version, + kdf: kdf, + iterations: iterations, + cipher: cipher, + publicKeyHex: record.publicKeyHex, + salt: Data(salt), + nonce: nonce.withUnsafeBytes { Data($0) }, + ciphertext: sealed.ciphertext, + tag: sealed.tag + ) + return try RadrootsIdentityPortabilityEnvelope( + serializedRepresentation: encoder().encode(wire) + ) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + } + + static func open( + _ envelope: RadrootsIdentityPortabilityEnvelope, + passphrase: RadrootsIdentityPassphrase + ) throws -> (RadrootsIdentitySecretMaterial, String?, UInt64) { + let wire = try decodeWire(envelope.serializedRepresentation) + do { + let key = try deriveKey( + passphrase: passphrase.copyBytes(), + salt: wire.salt, + iterations: wire.iterations + ) + let nonce = try AES.GCM.Nonce(data: wire.nonce) + let box = try AES.GCM.SealedBox( + nonce: nonce, + ciphertext: wire.ciphertext, + tag: wire.tag + ) + var cleartext = try AES.GCM.open( + box, + using: key, + authenticating: aad( + version: wire.version, + kdf: wire.kdf, + iterations: wire.iterations, + cipher: wire.cipher, + publicKeyHex: wire.publicKeyHex + ) + ) + defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) } + let plaintext = try decoder().decode(Plaintext.self, from: cleartext) + guard plaintext.version == version, + plaintext.publicKeyHex == wire.publicKeyHex, + plaintext.createdAtUnixMilliseconds > 0 + else { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } + let material = try RadrootsIdentitySecretMaterial(rawRepresentation: plaintext.secret) + let derived = try RadrootsIdentityCryptography().publicKeyHex(for: plaintext.secret) + guard derived == wire.publicKeyHex else { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } + return (material, plaintext.label, plaintext.createdAtUnixMilliseconds) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } + } + + static func decodeWire(_ serialized: Data) throws -> Wire { + guard !serialized.isEmpty, serialized.count <= maximumEnvelopeBytes else { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + do { + let wire = try decoder().decode(Wire.self, from: serialized) + guard wire.version == version, + wire.kdf == kdf, + wire.iterations == iterations, + wire.cipher == cipher, + RadrootsIdentityPublicRecord.validHex(wire.publicKeyHex, byteCount: 32), + wire.salt.count == 16, + wire.nonce.count == 12, + !wire.ciphertext.isEmpty, + wire.ciphertext.count <= 64 * 1_024, + wire.tag.count == 16 + else { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + return wire + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + } + + private static func aad( + version: UInt16, + kdf: String, + iterations: UInt32, + cipher: String, + publicKeyHex: String + ) -> Data { + Data( + "org.radroots.identity.portability|\(version)|\(kdf)|\(iterations)|\(cipher)|\(publicKeyHex)" + .utf8) + } + + private static func deriveKey( + passphrase: Data, + salt: Data, + iterations: UInt32 + ) throws -> SymmetricKey { + guard !passphrase.isEmpty, iterations == Self.iterations else { + throw RadrootsIdentityCustodyError.invalidPassphrase + } + let key = SymmetricKey(data: passphrase) + var block = salt + block.append(contentsOf: [0, 0, 0, 1]) + var previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: block, using: key)) + var output = previous + if iterations > 1 { + for _ in 2...iterations { + previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: previous, using: key)) + for index in output.indices { + output[index] ^= previous[index] + } + } + } + defer { + previous.resetBytes(in: previous.startIndex..<previous.endIndex) + output.resetBytes(in: output.startIndex..<output.endIndex) + } + return SymmetricKey(data: output) + } + + private static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + private static func decoder() -> JSONDecoder { + JSONDecoder() + } +} diff --git a/Sources/RadrootsKit/RadrootsIdentityCustody.swift b/Sources/RadrootsKit/RadrootsIdentityCustody.swift @@ -0,0 +1,886 @@ +import Foundation + +public struct RadrootsIdentityCustodyConfiguration: Sendable { + public let namespace: String + public let secretPolicy: RadrootsSecretAccessPolicy + + public init( + namespace: String, + secretPolicy: RadrootsSecretAccessPolicy = .userPresenceLocalSecret + ) throws { + let normalized = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.utf8.count <= 128, + !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + self.namespace = normalized + self.secretPolicy = secretPolicy + } +} + +public actor RadrootsIdentityCustody { + private struct UnlockedSession { + let record: RadrootsIdentityPublicRecord + let signerHandle: String + let generation: UInt64 + } + + private enum TransactionKind: String, Codable { + case replace + case delete + } + + private enum TransactionPhase: String, Codable { + case prepared + case activeSecretCommitted + case metadataCommitted + case activeSecretRemoved + case metadataRemoved + } + + private struct TransactionJournal: Codable { + let version: UInt16 + let operationID: String + let kind: TransactionKind + var phase: TransactionPhase + let previous: RadrootsIdentityPublicRecord? + let candidate: RadrootsIdentityPublicRecord? + let startedAtUnixMilliseconds: UInt64 + + func validated() throws -> Self { + guard version == 1, + RadrootsOpaqueSignRequest.canonicalUUID(operationID), + startedAtUnixMilliseconds > 0, + (kind == .replace) == (candidate != nil) + else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + if let previous { + _ = try RadrootsIdentityPublicRecord( + identityHandle: previous.identityHandle, + publicKeyHex: previous.publicKeyHex, + label: previous.label, + createdAtUnixMilliseconds: previous.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: previous.updatedAtUnixMilliseconds + ) + } + if let candidate { + _ = try RadrootsIdentityPublicRecord( + identityHandle: candidate.identityHandle, + publicKeyHex: candidate.publicKeyHex, + label: candidate.label, + createdAtUnixMilliseconds: candidate.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: candidate.updatedAtUnixMilliseconds + ) + } + return self + } + } + + private let configuration: RadrootsIdentityCustodyConfiguration + private let secureStore: any RadrootsSecureStore + private let metadataStore: any RadrootsIdentityMetadataStore + private let userPresence: any RadrootsUserPresence + private let protectedData: RadrootsProtectedDataProvider + private let now: @Sendable () -> UInt64 + private let cryptography = RadrootsIdentityCryptography() + private var session: UnlockedSession? + private var generation: UInt64 = 0 + private var activeOperations = Set<String>() + private var cancelledOperations: [String: UInt64] = [:] + private let maximumActiveSigningOperations = 8 + + public init( + configuration: RadrootsIdentityCustodyConfiguration, + secureStore: any RadrootsSecureStore, + metadataStore: any RadrootsIdentityMetadataStore, + userPresence: any RadrootsUserPresence, + protectedData: RadrootsProtectedDataProvider = .available, + now: @escaping @Sendable () -> UInt64 = { + UInt64(Date().timeIntervalSince1970 * 1_000) + } + ) { + self.configuration = configuration + self.secureStore = secureStore + self.metadataStore = metadataStore + self.userPresence = userPresence + self.protectedData = protectedData + self.now = now + } + + public func snapshot() -> RadrootsIdentitySnapshot { + do { + let record = try loadRecord() + if try loadJournal() != nil { + return Self.snapshot(.recoveryRequired, record, nil, "identity.transaction_pending") + } + let hasSecret = try secureStore.contains(secretKey(.active)) + guard record != nil || hasSecret else { + return Self.snapshot(.absent, nil, nil, nil) + } + guard let record, hasSecret else { + return Self.snapshot(.corrupt, record, nil, "identity.inconsistent_state") + } + guard protectedData.currentState() == .available else { + return Self.snapshot( + .protectedDataUnavailable, record, nil, "identity.protected_data_unavailable") + } + if let session, session.record == record { + return Self.snapshot(.unlocked, record, session.signerHandle, nil) + } + return Self.snapshot(.locked, record, nil, nil) + } catch RadrootsIdentityCustodyError.corruptMetadata { + return Self.snapshot(.corrupt, nil, nil, "identity.corrupt_metadata") + } catch { + return Self.snapshot(.recoveryRequired, nil, nil, "identity.storage_unavailable") + } + } + + @discardableResult + public func recover() throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + guard var journal = try loadJournal() else { + try cleanupTransactionSecrets() + return snapshot() + } + session = nil + generation &+= 1 + do { + switch journal.kind { + case .replace: + try recoverReplace(&journal) + case .delete: + try recoverDelete(&journal) + } + return snapshot() + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + } + + @discardableResult + public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot { + _ = try recover() + guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Create your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + let secret = try cryptography.generateSecret() + return try commitReplacement( + material: RadrootsIdentitySecretMaterial(rawRepresentation: secret), + label: label, + importedCreatedAt: nil, + replaceExisting: false + ) + } + + @discardableResult + public func importIdentity( + _ material: RadrootsIdentitySecretMaterial, + label: String? = nil, + replaceExisting: Bool = false + ) async throws -> RadrootsIdentitySnapshot { + _ = try recover() + _ = try cryptography.publicKeyHex(for: material.copyBytes()) + let existing = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (existing != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if existing != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Import your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return try commitReplacement( + material: material, + label: label, + importedCreatedAt: nil, + replaceExisting: replaceExisting + ) + } + + @discardableResult + public func importPortableIdentity( + _ envelope: RadrootsIdentityPortabilityEnvelope, + passphrase: RadrootsIdentityPassphrase, + replaceExisting: Bool = false + ) async throws -> RadrootsIdentitySnapshot { + let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase) + _ = try recover() + let existing = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (existing != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if existing != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Import your encrypted Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return try commitReplacement( + material: opened.0, + label: opened.1, + importedCreatedAt: opened.2, + replaceExisting: replaceExisting + ) + } + + public func exportPortableIdentity( + passphrase: RadrootsIdentityPassphrase + ) async throws -> RadrootsIdentityPortabilityEnvelope { + try requireProtectedData() + guard let session else { + throw RadrootsIdentityCustodyError.identityLocked + } + let expectedGeneration = session.generation + try await requireUserPresence(reason: "Export your encrypted Nostr identity.") + guard let current = self.session, + current.generation == expectedGeneration, + current.signerHandle == session.signerHandle + else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == current.record.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + return try RadrootsIdentityPortabilityCodec.seal( + secret: secret, + record: current.record, + passphrase: passphrase + ) + } + + @discardableResult + public func migrateLegacyIdentity( + from legacyKey: RadrootsSecureStoreKey, + label: String? = nil + ) async throws -> RadrootsIdentitySnapshot { + if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) { + guard let legacy = try secureStore.get(legacyKey) else { + return snapshot() + } + guard let text = String(data: legacy, encoding: .utf8), + let material = try? RadrootsIdentitySecretMaterial(importText: text), + try cryptography.publicKeyHex(for: material.copyBytes()) == existing.publicKeyHex + else { + throw RadrootsIdentityCustodyError.inconsistentState + } + try secureStore.delete(legacyKey) + return snapshot() + } + guard let legacy = try secureStore.get(legacyKey), + let text = String(data: legacy, encoding: .utf8) + else { + throw RadrootsIdentityCustodyError.identityNotFound + } + let material = try RadrootsIdentitySecretMaterial(importText: text) + let result = try await importIdentity(material, label: label) + do { + try secureStore.delete(legacyKey) + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return result + } + + @discardableResult + public func unlockIdentity() async throws -> RadrootsIdentitySnapshot { + _ = try recover() + try requireProtectedData() + let record = try requiredRecord() + let expectedGeneration = generation + try await requireUserPresence(reason: "Unlock your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == record.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: record, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, record, handle, nil) + } + + @discardableResult + public func selectIdentity(identityHandle: String) async throws -> RadrootsIdentitySnapshot { + guard RadrootsIdentityPublicRecord.validHandle(identityHandle) else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + let record = try requiredRecord() + guard record.identityHandle == identityHandle else { + throw RadrootsIdentityCustodyError.identityNotFound + } + return try await unlockIdentity() + } + + public func lockIdentity() { + generation &+= 1 + session = nil + let cancellationTime = now() + for operationID in activeOperations { + cancelledOperations[operationID] = cancellationTime + } + pruneCancellations() + } + + @discardableResult + public func deleteIdentity() async throws -> RadrootsIdentitySnapshot { + _ = try recover() + try requireProtectedData() + let record = try requiredRecord() + let expectedGeneration = generation + try await requireUserPresence(reason: "Delete your local Nostr identity.") + guard generation == expectedGeneration, + try requiredRecord() == record + else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + lockIdentity() + let operationID = UUID().uuidString.lowercased() + let backup = try readSecret(.active) + try secureStore.put(backup, for: secretKey(.backup), policy: configuration.secretPolicy) + var journal = TransactionJournal( + version: 1, + operationID: operationID, + kind: .delete, + phase: .prepared, + previous: record, + candidate: nil, + startedAtUnixMilliseconds: now() + ) + try writeJournal(journal) + do { + try secureStore.delete(secretKey(.active)) + journal.phase = .activeSecretRemoved + try writeJournal(journal) + try metadataStore.delete(.activeIdentity) + journal.phase = .metadataRemoved + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + return Self.snapshot(.absent, nil, nil, nil) + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + } + + @discardableResult + public func repairCorruptMetadata(label: String? = nil) async throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + guard try loadJournal() == nil, try secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Repair your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } + let publicKey = try cryptography.publicKeyHex(for: secret) + let timestamp = now() + let record = try makeRecord( + publicKeyHex: publicKey, + label: label, + createdAt: timestamp, + updatedAt: timestamp + ) + try saveRecord(record) + try metadataStore.delete(.quarantinedMetadata) + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: record, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, record, handle, nil) + } + + public func sign(_ request: RadrootsOpaqueSignRequest) async throws -> RadrootsOpaqueSignature { + try requireProtectedData() + guard now() <= request.deadlineUnixMilliseconds else { + throw RadrootsIdentityCustodyError.timedOut + } + guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { + throw RadrootsIdentityCustodyError.cancelled + } + guard activeOperations.count < maximumActiveSigningOperations else { + throw RadrootsIdentityCustodyError.signingSaturated + } + guard activeOperations.insert(request.operationID).inserted else { + throw RadrootsIdentityCustodyError.duplicateOperation + } + defer { activeOperations.remove(request.operationID) } + guard let session else { + throw RadrootsIdentityCustodyError.identityLocked + } + guard session.signerHandle == request.signerHandle, + session.record.publicKeyHex == request.publicKeyHex + else { + throw RadrootsIdentityCustodyError.staleSigner + } + let expectedGeneration = session.generation + try await requireUserPresence(reason: signingReason(request.purpose)) + guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { + throw RadrootsIdentityCustodyError.cancelled + } + guard now() <= request.deadlineUnixMilliseconds else { + throw RadrootsIdentityCustodyError.timedOut + } + guard let current = self.session, + current.generation == expectedGeneration, + current.signerHandle == request.signerHandle, + current.record.publicKeyHex == request.publicKeyHex + else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == request.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + let signature = try cryptography.sign(secret: secret, digest: request.digest) + guard + cryptography.verify( + signature: signature, + digest: request.digest, + publicKeyHex: request.publicKeyHex + ) + else { + throw RadrootsIdentityCustodyError.invalidSignature + } + return try RadrootsOpaqueSignature( + operationID: request.operationID, + publicKeyHex: request.publicKeyHex, + signature: signature, + purpose: request.purpose + ) + } + + public func cancelSigning(operationID: String) throws { + guard RadrootsOpaqueSignRequest.canonicalUUID(operationID) else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + cancelledOperations[operationID] = now() + pruneCancellations() + } + + private func commitReplacement( + material: RadrootsIdentitySecretMaterial, + label: String?, + importedCreatedAt: UInt64?, + replaceExisting: Bool + ) throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + let previous = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (previous != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if previous != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let candidateSecret = material.copyBytes() + let publicKey = try cryptography.publicKeyHex(for: candidateSecret) + let timestamp = now() + let createdAt: UInt64 + if let previous, previous.publicKeyHex == publicKey { + createdAt = previous.createdAtUnixMilliseconds + } else { + createdAt = importedCreatedAt ?? timestamp + } + let candidate = try makeRecord( + publicKeyHex: publicKey, + label: label, + createdAt: createdAt, + updatedAt: max(timestamp, createdAt) + ) + if hasActive { + let current = try readSecret(.active) + try secureStore.put(current, for: secretKey(.backup), policy: configuration.secretPolicy) + } else { + try secureStore.delete(secretKey(.backup)) + } + try secureStore.put( + candidateSecret, for: secretKey(.candidate), policy: configuration.secretPolicy) + var journal = TransactionJournal( + version: 1, + operationID: UUID().uuidString.lowercased(), + kind: .replace, + phase: .prepared, + previous: previous, + candidate: candidate, + startedAtUnixMilliseconds: timestamp + ) + try writeJournal(journal) + do { + try secureStore.put( + candidateSecret, for: secretKey(.active), policy: configuration.secretPolicy) + journal.phase = .activeSecretCommitted + try writeJournal(journal) + try saveRecord(candidate) + journal.phase = .metadataCommitted + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } catch { + session = nil + generation &+= 1 + throw RadrootsIdentityCustodyError.recoveryRequired + } + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: candidate, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, candidate, handle, nil) + } + + private func recoverReplace(_ journal: inout TransactionJournal) throws { + guard let candidate = journal.candidate else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + var activeMatches = + try secret(.active).map { + try cryptography.publicKeyHex(for: $0) == candidate.publicKeyHex + } ?? false + if !activeMatches { + if let candidateSecret = try secret(.candidate) { + guard try cryptography.publicKeyHex(for: candidateSecret) == candidate.publicKeyHex else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + try secureStore.put( + candidateSecret, + for: secretKey(.active), + policy: configuration.secretPolicy + ) + activeMatches = true + } else { + try rollbackReplacement(journal) + return + } + } + guard activeMatches else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + journal.phase = .activeSecretCommitted + try writeJournal(journal) + try saveRecord(candidate) + journal.phase = .metadataCommitted + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func rollbackReplacement(_ journal: TransactionJournal) throws { + if let previous = journal.previous { + guard let backup = try secret(.backup), + try cryptography.publicKeyHex(for: backup) == previous.publicKeyHex + else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + try secureStore.put(backup, for: secretKey(.active), policy: configuration.secretPolicy) + try saveRecord(previous) + } else { + try secureStore.delete(secretKey(.active)) + try metadataStore.delete(.activeIdentity) + } + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func recoverDelete(_ journal: inout TransactionJournal) throws { + try secureStore.delete(secretKey(.active)) + journal.phase = .activeSecretRemoved + try writeJournal(journal) + try metadataStore.delete(.activeIdentity) + journal.phase = .metadataRemoved + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func requireUserPresence(reason: String) async throws { + try requireProtectedData() + let request: RadrootsUserPresenceRequest + do { + request = try RadrootsUserPresenceRequest(reason: reason) + } catch { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + do { + let result = try await userPresence.verify(request) + guard result.verified else { + throw RadrootsIdentityCustodyError.userPresenceRequired + } + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch let error as RadrootsUserPresenceError { + switch error { + case .userCancelled: + throw RadrootsIdentityCustodyError.cancelled + case .timeout: + throw RadrootsIdentityCustodyError.timedOut + default: + throw RadrootsIdentityCustodyError.userPresenceRequired + } + } catch { + throw RadrootsIdentityCustodyError.userPresenceRequired + } + try requireProtectedData() + } + + private func requireProtectedData() throws { + guard protectedData.currentState() == .available else { + throw RadrootsIdentityCustodyError.protectedDataUnavailable + } + } + + private func requiredRecord() throws -> RadrootsIdentityPublicRecord { + guard let record = try loadRecord() else { + throw RadrootsIdentityCustodyError.identityNotFound + } + guard try secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.inconsistentState + } + return record + } + + private func loadRecord() throws -> RadrootsIdentityPublicRecord? { + guard let data = try metadataStore.data(for: .activeIdentity) else { + return nil + } + do { + let decoded = try JSONDecoder().decode(RadrootsIdentityPublicRecord.self, from: data) + return try RadrootsIdentityPublicRecord( + identityHandle: decoded.identityHandle, + publicKeyHex: decoded.publicKeyHex, + label: decoded.label, + createdAtUnixMilliseconds: decoded.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: decoded.updatedAtUnixMilliseconds + ) + } catch { + do { + try metadataStore.put(data, for: .quarantinedMetadata) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + throw RadrootsIdentityCustodyError.corruptMetadata + } + } + + private func saveRecord(_ record: RadrootsIdentityPublicRecord) throws { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try metadataStore.put(try encoder.encode(record), for: .activeIdentity) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func loadJournal() throws -> TransactionJournal? { + guard let data = try metadataStore.data(for: .transactionJournal) else { + return nil + } + do { + return try JSONDecoder().decode(TransactionJournal.self, from: data).validated() + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.corruptMetadata + } + } + + private func writeJournal(_ journal: TransactionJournal) throws { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try metadataStore.put(try encoder.encode(journal.validated()), for: .transactionJournal) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private enum SecretSlot: String { + case active = "active_secret_v1" + case candidate = "candidate_secret_v1" + case backup = "backup_secret_v1" + } + + private func secretKey(_ slot: SecretSlot) -> RadrootsSecureStoreKey { + RadrootsSecureStoreKey(namespace: configuration.namespace, name: slot.rawValue) + } + + private func secret(_ slot: SecretSlot) throws -> Data? { + do { + return try secureStore.get(secretKey(slot)) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func readSecret(_ slot: SecretSlot) throws -> Data { + guard let secret = try secret(slot) else { + throw RadrootsIdentityCustodyError.inconsistentState + } + guard secret.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSecret + } + return secret + } + + private func cleanupTransactionSecrets() throws { + do { + try secureStore.delete(secretKey(.candidate)) + try secureStore.delete(secretKey(.backup)) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func makeRecord( + publicKeyHex: String, + label: String?, + createdAt: UInt64, + updatedAt: UInt64 + ) throws -> RadrootsIdentityPublicRecord { + try RadrootsIdentityPublicRecord( + identityHandle: cryptography.identityHandle(forPublicKeyHex: publicKeyHex), + publicKeyHex: publicKeyHex, + label: label, + createdAtUnixMilliseconds: createdAt, + updatedAtUnixMilliseconds: updatedAt + ) + } + + private func signingReason(_ purpose: RadrootsOpaqueSignPurpose) -> String { + switch purpose { + case .nostrEvent: + "Sign this Nostr event with your local identity." + case .blossomUpload: + "Authorize this Blossom media upload with your local identity." + } + } + + private func pruneCancellations() { + guard cancelledOperations.count > 128 else { + return + } + let ordered = cancelledOperations.sorted { $0.value < $1.value } + for (operationID, _) in ordered.prefix(cancelledOperations.count - 128) { + cancelledOperations.removeValue(forKey: operationID) + } + } + + private static func snapshot( + _ state: RadrootsIdentityState, + _ identity: RadrootsIdentityPublicRecord?, + _ signerHandle: String?, + _ recoveryCode: String? + ) -> RadrootsIdentitySnapshot { + RadrootsIdentitySnapshot( + state: state, + identity: identity, + signerHandle: signerHandle, + recoveryCode: recoveryCode + ) + } +} + +public final class RadrootsOpaqueSignerCancellation: @unchecked Sendable { + private let lock = NSLock() + private var isCancelled = false + private let cancelAction: @Sendable () -> Void + + init(cancelAction: @escaping @Sendable () -> Void) { + self.cancelAction = cancelAction + } + + public func cancel() { + lock.lock() + guard !isCancelled else { + lock.unlock() + return + } + isCancelled = true + lock.unlock() + cancelAction() + } +} + +public final class RadrootsOpaqueSignerBridge: Sendable { + private let custody: RadrootsIdentityCustody + + public init(custody: RadrootsIdentityCustody) { + self.custody = custody + } + + @discardableResult + public func submit( + _ request: RadrootsOpaqueSignRequest, + completion: + @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void + ) -> RadrootsOpaqueSignerCancellation { + let completionState = RadrootsOpaqueSignerCompletion(completion: completion) + let custody = custody + let task = Task { + do { + if Task.isCancelled { + try await custody.cancelSigning(operationID: request.operationID) + } + completionState.finish(.success(try await custody.sign(request))) + } catch let error as RadrootsIdentityCustodyError { + completionState.finish(.failure(error)) + } catch { + completionState.finish(.failure(.cryptographyFailed)) + } + } + return RadrootsOpaqueSignerCancellation { + task.cancel() + Task { + try? await custody.cancelSigning(operationID: request.operationID) + } + } + } +} + +private final class RadrootsOpaqueSignerCompletion: @unchecked Sendable { + private let lock = NSLock() + private var completion: + (@Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void)? + + init( + completion: + @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void + ) { + self.completion = completion + } + + func finish(_ result: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { + lock.lock() + let callback = completion + completion = nil + lock.unlock() + callback?(result) + } +} diff --git a/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift b/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift @@ -0,0 +1,399 @@ +import Foundation + +public enum RadrootsProtectedDataState: String, Codable, Sendable { + case available + case locked + case unavailable +} + +public struct RadrootsProtectedDataProvider: Sendable { + private let readState: @Sendable () -> RadrootsProtectedDataState + + public init(readState: @escaping @Sendable () -> RadrootsProtectedDataState) { + self.readState = readState + } + + public func currentState() -> RadrootsProtectedDataState { + readState() + } + + public static let available = Self { .available } +} + +public enum RadrootsIdentityMetadataSlot: String, Sendable { + case activeIdentity + case transactionJournal + case quarantinedMetadata +} + +public protocol RadrootsIdentityMetadataStore: AnyObject, Sendable { + func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? + func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws + func delete(_ slot: RadrootsIdentityMetadataSlot) throws +} + +public struct RadrootsIdentitySecretMaterial: Sendable, CustomDebugStringConvertible { + private let bytes: Data + + public init(rawRepresentation: Data) throws { + guard rawRepresentation.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSecret + } + self.bytes = rawRepresentation + } + + public init(importText: String) throws { + let normalized = importText.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.count == 64, let decoded = Self.decodeHex(normalized) { + try self.init(rawRepresentation: decoded) + return + } + guard let decoded = Self.decodeNsec(normalized) else { + throw RadrootsIdentityCustodyError.invalidSecret + } + try self.init(rawRepresentation: decoded) + } + + public var debugDescription: String { + "RadrootsIdentitySecretMaterial(<redacted>)" + } + + func copyBytes() -> Data { + bytes + } + + private static func decodeHex(_ value: String) -> Data? { + guard + value.unicodeScalars.allSatisfy({ + (48...57).contains($0.value) || (65...70).contains($0.value) + || (97...102).contains($0.value) + }) + else { + return nil + } + var output = Data(capacity: 32) + var index = value.startIndex + for _ in 0..<32 { + let next = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index..<next], radix: 16) else { + return nil + } + output.append(byte) + index = next + } + return output + } + + private static func decodeNsec(_ value: String) -> Data? { + guard value == value.lowercased(), + value.count <= 90, + let separator = value.lastIndex(of: "1"), + value[..<separator] == "nsec" + else { + return nil + } + let payloadStart = value.index(after: separator) + let encoded = value[payloadStart...] + guard encoded.count >= 6 else { + return nil + } + let alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + let reverse = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) }) + var values = [UInt8]() + values.reserveCapacity(encoded.count) + for character in encoded { + guard let value = reverse[character] else { + return nil + } + values.append(value) + } + guard bech32Polymod(hrp: "nsec", values: values) == 1 else { + return nil + } + return convertBits(Array(values.dropLast(6)), from: 5, to: 8, pad: false) + } + + private static func bech32Polymod(hrp: String, values: [UInt8]) -> UInt32 { + let generators: [UInt32] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3] + let expanded = hrp.utf8.map { $0 >> 5 } + [0] + hrp.utf8.map { $0 & 31 } + values + var checksum: UInt32 = 1 + for value in expanded { + let top = checksum >> 25 + checksum = (checksum & 0x01ff_ffff) << 5 ^ UInt32(value) + for (index, generator) in generators.enumerated() where ((top >> index) & 1) == 1 { + checksum ^= generator + } + } + return checksum + } + + private static func convertBits( + _ values: [UInt8], + from sourceBits: Int, + to targetBits: Int, + pad: Bool + ) -> Data? { + var accumulator = 0 + var bitCount = 0 + let maxValue = (1 << targetBits) - 1 + var output = Data() + for value in values { + guard Int(value) >> sourceBits == 0 else { + return nil + } + accumulator = (accumulator << sourceBits) | Int(value) + bitCount += sourceBits + while bitCount >= targetBits { + bitCount -= targetBits + output.append(UInt8((accumulator >> bitCount) & maxValue)) + } + } + if pad, bitCount > 0 { + output.append(UInt8((accumulator << (targetBits - bitCount)) & maxValue)) + } else if bitCount >= sourceBits || ((accumulator << (targetBits - bitCount)) & maxValue) != 0 { + return nil + } + return output.count == 32 ? output : nil + } +} + +public struct RadrootsIdentityPassphrase: Sendable, CustomDebugStringConvertible { + private let bytes: Data + + public init(_ value: String) throws { + let bytes = Data(value.utf8) + guard (12...1_024).contains(bytes.count), + !value.unicodeScalars.contains(where: { $0.value == 0 }) + else { + throw RadrootsIdentityCustodyError.invalidPassphrase + } + self.bytes = bytes + } + + public var debugDescription: String { + "RadrootsIdentityPassphrase(<redacted>)" + } + + func copyBytes() -> Data { + bytes + } +} + +public struct RadrootsIdentityPublicRecord: Codable, Equatable, Hashable, Sendable { + public let identityHandle: String + public let publicKeyHex: String + public let label: String? + public let createdAtUnixMilliseconds: UInt64 + public let updatedAtUnixMilliseconds: UInt64 + + public init( + identityHandle: String, + publicKeyHex: String, + label: String?, + createdAtUnixMilliseconds: UInt64, + updatedAtUnixMilliseconds: UInt64 + ) throws { + guard Self.validHandle(identityHandle), + Self.validHex(publicKeyHex, byteCount: 32), + createdAtUnixMilliseconds > 0, + updatedAtUnixMilliseconds >= createdAtUnixMilliseconds + else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + self.identityHandle = identityHandle + self.publicKeyHex = publicKeyHex + self.label = try Self.normalizedLabel(label) + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds + } + + static func normalizedLabel(_ value: String?) throws -> String? { + guard let value else { + return nil + } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.utf8.count <= 128, + !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + return normalized + } + + static func validHandle(_ value: String) -> Bool { + value.hasPrefix("rrid1_") && value.count == 70 + && validHex(String(value.dropFirst(6)), byteCount: 32) + } + + static func validHex(_ value: String, byteCount: Int) -> Bool { + value.count == byteCount * 2 + && value.unicodeScalars.allSatisfy { + (48...57).contains($0.value) || (97...102).contains($0.value) + } + } +} + +public enum RadrootsIdentityState: String, Codable, Sendable { + case absent + case locked + case unlocked + case protectedDataUnavailable + case recoveryRequired + case corrupt +} + +public struct RadrootsIdentitySnapshot: Equatable, Sendable { + public let state: RadrootsIdentityState + public let identity: RadrootsIdentityPublicRecord? + public let signerHandle: String? + public let recoveryCode: String? + + public init( + state: RadrootsIdentityState, + identity: RadrootsIdentityPublicRecord?, + signerHandle: String?, + recoveryCode: String? + ) { + self.state = state + self.identity = identity + self.signerHandle = signerHandle + self.recoveryCode = recoveryCode + } +} + +public enum RadrootsOpaqueSignPurpose: String, Codable, Sendable { + case nostrEvent = "nostr_event" + case blossomUpload = "blossom_upload" +} + +public struct RadrootsOpaqueSignRequest: Sendable, CustomDebugStringConvertible { + public let operationID: String + public let signerHandle: String + public let publicKeyHex: String + public let digest: Data + public let purpose: RadrootsOpaqueSignPurpose + public let deadlineUnixMilliseconds: UInt64 + + public init( + operationID: String, + signerHandle: String, + publicKeyHex: String, + digest: Data, + purpose: RadrootsOpaqueSignPurpose, + deadlineUnixMilliseconds: UInt64 + ) throws { + guard Self.canonicalUUID(operationID), + Self.canonicalUUID(signerHandle), + RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), + digest.count == 32, + deadlineUnixMilliseconds > 0 + else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + self.operationID = operationID + self.signerHandle = signerHandle + self.publicKeyHex = publicKeyHex + self.digest = digest + self.purpose = purpose + self.deadlineUnixMilliseconds = deadlineUnixMilliseconds + } + + public var debugDescription: String { + "RadrootsOpaqueSignRequest(operationID: \(operationID), purpose: \(purpose.rawValue), payload: <redacted>)" + } + + static func canonicalUUID(_ value: String) -> Bool { + UUID(uuidString: value)?.uuidString.lowercased() == value + } +} + +public struct RadrootsOpaqueSignature: Equatable, Sendable, CustomDebugStringConvertible { + public let operationID: String + public let publicKeyHex: String + public let signature: Data + public let purpose: RadrootsOpaqueSignPurpose + + public init( + operationID: String, + publicKeyHex: String, + signature: Data, + purpose: RadrootsOpaqueSignPurpose + ) throws { + guard RadrootsOpaqueSignRequest.canonicalUUID(operationID), + RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), + signature.count == 64 + else { + throw RadrootsIdentityCustodyError.invalidSignature + } + self.operationID = operationID + self.publicKeyHex = publicKeyHex + self.signature = signature + self.purpose = purpose + } + + public var debugDescription: String { + "RadrootsOpaqueSignature(operationID: \(operationID), purpose: \(purpose.rawValue), signature: <redacted>)" + } +} + +public enum RadrootsIdentityCustodyError: String, Error, Sendable { + case invalidConfiguration = "identity.invalid_configuration" + case invalidSecret = "identity.invalid_secret" + case invalidPassphrase = "identity.invalid_passphrase" + case invalidMetadata = "identity.invalid_metadata" + case corruptMetadata = "identity.corrupt_metadata" + case inconsistentState = "identity.inconsistent_state" + case identityAlreadyExists = "identity.already_exists" + case identityNotFound = "identity.not_found" + case identityLocked = "identity.locked" + case protectedDataUnavailable = "identity.protected_data_unavailable" + case userPresenceRequired = "identity.user_presence_required" + case invalidSignRequest = "identity.invalid_sign_request" + case staleSigner = "identity.stale_signer" + case duplicateOperation = "identity.duplicate_operation" + case signingSaturated = "identity.signing_saturated" + case cancelled = "identity.cancelled" + case timedOut = "identity.timed_out" + case invalidSignature = "identity.invalid_signature" + case recoveryRequired = "identity.recovery_required" + case unsupportedPortabilityEnvelope = "identity.unsupported_portability_envelope" + case portabilityAuthenticationFailed = "identity.portability_authentication_failed" + case storageUnavailable = "identity.storage_unavailable" + case cryptographyFailed = "identity.cryptography_failed" + + public var code: String { + rawValue + } +} + +extension RadrootsIdentityCustodyError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidConfiguration: "Identity storage configuration is invalid." + case .invalidSecret: "The identity secret is invalid." + case .invalidPassphrase: "The portability passphrase is invalid." + case .invalidMetadata: "Identity metadata is invalid." + case .corruptMetadata: "Identity metadata is corrupt and requires recovery." + case .inconsistentState: "Identity storage is inconsistent and requires recovery." + case .identityAlreadyExists: "A local identity already exists." + case .identityNotFound: "No local identity is available." + case .identityLocked: "The local identity is locked." + case .protectedDataUnavailable: "Protected identity data is unavailable." + case .userPresenceRequired: "User presence was not verified." + case .invalidSignRequest: "The signing request is invalid." + case .staleSigner: "The signer handle is no longer active." + case .duplicateOperation: "The signing operation is already active." + case .signingSaturated: "The signer is temporarily at capacity." + case .cancelled: "The signing operation was cancelled." + case .timedOut: "The signing operation timed out." + case .invalidSignature: "The signing result failed verification." + case .recoveryRequired: "Identity recovery must complete before continuing." + case .unsupportedPortabilityEnvelope: "The encrypted identity envelope is unsupported." + case .portabilityAuthenticationFailed: + "The encrypted identity envelope could not be authenticated." + case .storageUnavailable: "Identity storage is unavailable." + case .cryptographyFailed: "The identity cryptography operation failed." + } + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift b/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift @@ -0,0 +1,72 @@ +import Foundation +import RadrootsKit + +public final class RadrootsInMemoryIdentityMetadataStore: RadrootsIdentityMetadataStore, + @unchecked Sendable +{ + public enum Operation: Equatable, Sendable { + case read + case write + case delete + } + + public enum Failure: Error, Sendable { + case forced + } + + private let lock = NSLock() + private var values: [RadrootsIdentityMetadataSlot: Data] = [:] + private var nextFailure: (Operation, RadrootsIdentityMetadataSlot)? + + public init() {} + + public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.read, slot: slot) + return values[slot] + } + + public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.write, slot: slot) + values[slot] = data + } + + public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.delete, slot: slot) + values.removeValue(forKey: slot) + } + + public func failNext(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) { + lock.lock() + nextFailure = (operation, slot) + lock.unlock() + } + + public func rawData(for slot: RadrootsIdentityMetadataSlot) -> Data? { + lock.lock() + defer { lock.unlock() } + return values[slot] + } + + public func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot) { + lock.lock() + values[slot] = data + lock.unlock() + } + + private func failIfRequested(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) throws { + guard let failure = nextFailure, + failure.0 == operation, + failure.1 == slot + else { + return + } + nextFailure = nil + throw Failure.forced + } +} diff --git a/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift b/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift @@ -0,0 +1,457 @@ +import Foundation +import RadrootsKitTesting +import Testing + +@testable import RadrootsKit + +private let identityTestNow: UInt64 = 1_800_000_000_000 +private let aliceSecretHex = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5" +private let alicePublicKeyHex = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" +private let aliceNsec = "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz" +private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f1a3fe1a96ff8" + +@Test func identitySecretMaterialParsesHexAndNsecWithoutDebugDisclosure() throws { + let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased()) + let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec) + + #expect(hex.copyBytes() == nsec.copyBytes()) + #expect(hex.copyBytes().count == 32) + #expect(!String(reflecting: hex).contains(aliceSecretHex)) + #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { + _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid") + } +} + +@Test func identityLifecycleIsOneActiveOpaqueAndSignatureBound() async throws { + let fixture = try makeIdentityFixture() + #expect(await fixture.custody.snapshot().state == .absent) + + let imported = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: " Alice " + ) + #expect(imported.state == .unlocked) + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + #expect(imported.identity?.label == "Alice") + let firstHandle = try #require(imported.signerHandle) + + await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) { + try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: bobSecretHex) + ) + } + + let digest = Data(repeating: 0x42, count: 32) + let request = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: firstHandle, + publicKeyHex: alicePublicKeyHex, + digest: digest, + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1_000 + ) + let signature = try await fixture.custody.sign(request) + #expect(signature.operationID == request.operationID) + #expect(signature.signature.count == 64) + #expect( + RadrootsIdentityCryptography().verify( + signature: signature.signature, + digest: digest, + publicKeyHex: alicePublicKeyHex + ) + ) + #expect(!String(reflecting: request).contains(digest.base64EncodedString())) + #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString())) + + await fixture.custody.lockIdentity() + #expect(await fixture.custody.snapshot().state == .locked) + let unlocked = try await fixture.custody.selectIdentity( + identityHandle: try #require(imported.identity?.identityHandle) + ) + #expect(unlocked.state == .unlocked) + #expect(unlocked.signerHandle != firstHandle) + await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) { + try await fixture.custody.selectIdentity( + identityHandle: "rrid1_\(String(repeating: "0", count: 64))") + } + await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { + try await fixture.custody.sign(request) + } +} + +@Test func identitySigningRejectsTimeoutCancellationAndWrongBinding() async throws { + let fixture = try makeIdentityFixture() + let imported = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let handle = try #require(imported.signerHandle) + let operationID = UUID().uuidString.lowercased() + let expired = try RadrootsOpaqueSignRequest( + operationID: operationID, + signerHandle: handle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 1, count: 32), + purpose: .blossomUpload, + deadlineUnixMilliseconds: identityTestNow - 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.timedOut) { + try await fixture.custody.sign(expired) + } + + try await fixture.custody.cancelSigning(operationID: operationID) + let cancelled = try RadrootsOpaqueSignRequest( + operationID: operationID, + signerHandle: handle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 1, count: 32), + purpose: .blossomUpload, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { + try await fixture.custody.sign(cancelled) + } + + let wrongKey = String(repeating: "0", count: 64) + let wrongBinding = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: handle, + publicKeyHex: wrongKey, + digest: Data(repeating: 2, count: 32), + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { + try await fixture.custody.sign(wrongBinding) + } +} + +@Test func identityReplacementAndDeleteRecoverAfterMetadataFailures() async throws { + let fixture = try makeIdentityFixture() + fixture.metadata.failNext(.write, slot: .activeIdentity) + await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { + try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + } + #expect(await fixture.custody.snapshot().state == .recoveryRequired) + let recovered = try await fixture.custody.recover() + #expect(recovered.state == .locked) + #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex) + + fixture.metadata.failNext(.delete, slot: .activeIdentity) + await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { + try await fixture.custody.deleteIdentity() + } + #expect(await fixture.custody.snapshot().state == .recoveryRequired) + #expect(try await fixture.custody.recover().state == .absent) + #expect(fixture.secureStore.keys().isEmpty) +} + +@Test func corruptMetadataIsDistinctFromAbsenceAndCanBeRepaired() async throws { + let fixture = try makeIdentityFixture() + _ = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: "Before" + ) + await fixture.custody.lockIdentity() + fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity) + + let corrupt = await fixture.custody.snapshot() + #expect(corrupt.state == .corrupt) + #expect(corrupt.recoveryCode == "identity.corrupt_metadata") + #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8)) + + let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered") + #expect(repaired.state == .unlocked) + #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex) + #expect(repaired.identity?.label == "Recovered") + #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil) +} + +@Test func corruptTransactionJournalRequiresRecoveryWithoutClaimingAbsence() async throws { + let fixture = try makeIdentityFixture() + _ = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + await fixture.custody.lockIdentity() + fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal) + + let snapshot = await fixture.custody.snapshot() + #expect(snapshot.state == .corrupt) + #expect(snapshot.identity == nil) + #expect(snapshot.recoveryCode == "identity.corrupt_metadata") + await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) { + try await fixture.custody.recover() + } +} + +@Test func identityCustodyRoundTripsAgainstAppleStorageAdapters() async throws { + let suffix = UUID().uuidString.lowercased() + let namespace = "native-storage-\(suffix)" + let servicePrefix = "org.radroots.tests.identity.\(suffix)" + let suiteName = "org.radroots.tests.identity.metadata.\(suffix)" + let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { + try? keychain.deleteNamespace(namespace) + defaults.removePersistentDomain(forName: suiteName) + } + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration( + namespace: namespace, + secretPolicy: .secureLocalSecret + ), + secureStore: keychain, + metadataStore: RadrootsAppleIdentityMetadataStore( + namespace: namespace, + userDefaults: defaults + ), + userPresence: RadrootsFakeUserPresence(), + now: { identityTestNow } + ) + + let imported = try await custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: "Native" + ) + #expect(imported.state == .unlocked) + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + + await custody.lockIdentity() + #expect(await custody.snapshot().state == .locked) + #expect(try await custody.unlockIdentity().state == .unlocked) + #expect(try await custody.deleteIdentity().state == .absent) + #expect( + try keychain.contains( + RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") + ) == false) +} + +@Test func protectedDataAndUserPresenceFailuresDoNotClaimIdentitySuccess() async throws { + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false)) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"), + secureStore: secureStore, + metadataStore: metadata, + userPresence: presence, + protectedData: RadrootsProtectedDataProvider { .available }, + now: { identityTestNow } + ) + await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) { + try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex)) + } + #expect(await custody.snapshot().state == .absent) + + let unavailable = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"), + secureStore: RadrootsInMemorySecureStore(), + metadataStore: RadrootsInMemoryIdentityMetadataStore(), + userPresence: RadrootsFakeUserPresence(), + protectedData: RadrootsProtectedDataProvider { .locked }, + now: { identityTestNow } + ) + await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) { + try await unavailable.createIdentity() + } + #expect(await unavailable.snapshot().state == .absent) +} + +@Test func encryptedPortabilityRoundTripsAcrossIndependentHostsAndRejectsTampering() async throws { + let source = try makeIdentityFixture(namespace: "portability-source") + _ = try await source.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceNsec), + label: "Portable" + ) + let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple") + let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) + let serialized = envelope.serializedRepresentation + let rendered = String(decoding: serialized, as: UTF8.self) + #expect(envelope.version == 1) + #expect(!rendered.contains(aliceSecretHex)) + #expect(!rendered.contains(aliceNsec)) + #expect(!String(reflecting: passphrase).contains("correct horse")) + + let destination = try makeIdentityFixture(namespace: "portability-destination") + let imported = try await destination.custody.importPortableIdentity( + envelope, + passphrase: passphrase + ) + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + #expect(imported.identity?.label == "Portable") + + let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong") + await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) { + try await wrongDestination.custody.importPortableIdentity( + envelope, + passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long") + ) + } + + let unsupported = serialized.replacingOccurrences( + of: Data("\"version\":1".utf8), + with: Data("\"version\":2".utf8) + ) + #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) { + _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported) + } +} + +@Test func legacyIdentityMigrationIsIdempotentAndDeletesOnlyAfterCommit() async throws { + let fixture = try makeIdentityFixture(namespace: "legacy-test") + let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) + + let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated") + #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex) + #expect(try fixture.secureStore.get(legacyKey) == nil) + + try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) + let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey) + #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex) + #expect(try fixture.secureStore.get(legacyKey) == nil) +} + +@Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws { + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let presence = ControllableIdentityPresence() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"), + secureStore: secureStore, + metadataStore: metadata, + userPresence: presence, + now: { identityTestNow } + ) + let imported = try await custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let signerHandle = try #require(imported.signerHandle) + await presence.suspendNextVerification() + let request = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: signerHandle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 9, count: 32), + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + let result = LockedIdentityResult() + let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) { + result.record($0) + } + while await presence.pendingVerificationCount == 0 { + await Task.yield() + } + cancellation.cancel() + await presence.resumePending(verified: true) + while result.count == 0 { + await Task.yield() + } + #expect(result.count == 1) + guard case .failure(.cancelled) = result.value else { + Issue.record("expected one cancelled completion") + return + } +} + +private struct IdentityFixture { + let custody: RadrootsIdentityCustody + let secureStore: RadrootsInMemorySecureStore + let metadata: RadrootsInMemoryIdentityMetadataStore +} + +private func makeIdentityFixture(namespace: String = UUID().uuidString.lowercased()) throws + -> IdentityFixture +{ + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), + secureStore: secureStore, + metadataStore: metadata, + userPresence: RadrootsFakeUserPresence(), + now: { identityTestNow } + ) + return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata) +} + +private actor ControllableIdentityPresence: RadrootsUserPresence { + private var suspendNext = false + private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = [] + + func currentStatus() async throws -> RadrootsUserPresenceStatus { + RadrootsUserPresenceStatus( + support: .biometricsOrDeviceCredential, + biometryKind: .faceID, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: true + ) + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + guard suspendNext else { + return RadrootsUserPresenceResult(policy: request.policy, verified: true) + } + suspendNext = false + return await withCheckedContinuation { continuation in + pending.append(continuation) + } + } + + func suspendNextVerification() { + suspendNext = true + } + + var pendingVerificationCount: Int { + pending.count + } + + func resumePending(verified: Bool) { + let continuations = pending + pending.removeAll() + for continuation in continuations { + continuation.resume( + returning: RadrootsUserPresenceResult( + policy: .deviceOwnerAuthentication, + verified: verified + ) + ) + } + } +} + +private final class LockedIdentityResult: @unchecked Sendable { + private let lock = NSLock() + private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = [] + + var count: Int { + lock.lock() + defer { lock.unlock() } + return results.count + } + + var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? { + lock.lock() + defer { lock.unlock() } + return results.first + } + + func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { + lock.lock() + results.append(value) + lock.unlock() + } +} + +extension Data { + fileprivate func replacingOccurrences(of needle: Data, with replacement: Data) -> Data { + guard let range = range(of: needle) else { + return self + } + var copy = self + copy.replaceSubrange(range, with: replacement) + return copy + } +}