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

RadrootsIdentityCustody.swift (36797B)


      1 import Foundation
      2 
      3 public struct RadrootsIdentityCustodyConfiguration: Sendable {
      4     public let namespace: String
      5     public let secretPolicy: RadrootsSecretAccessPolicy
      6 
      7     public init(
      8         namespace: String,
      9         secretPolicy: RadrootsSecretAccessPolicy = .userPresenceLocalSecret
     10     ) throws {
     11         let normalized = namespace.trimmingCharacters(in: .whitespacesAndNewlines)
     12         guard !normalized.isEmpty,
     13               normalized.utf8.count <= 128,
     14               !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
     15         else {
     16             throw RadrootsIdentityCustodyError.invalidConfiguration
     17         }
     18         self.namespace = normalized
     19         self.secretPolicy = secretPolicy
     20     }
     21 }
     22 
     23 public actor RadrootsIdentityCustody {
     24     private struct UnlockedSession {
     25         let record: RadrootsIdentityPublicRecord
     26         let signerHandle: String
     27         let generation: UInt64
     28     }
     29 
     30     private enum TransactionKind: String, Codable {
     31         case replace
     32         case delete
     33     }
     34 
     35     private enum TransactionPhase: String, Codable {
     36         case prepared
     37         case activeSecretCommitted
     38         case metadataCommitted
     39         case activeSecretRemoved
     40         case metadataRemoved
     41     }
     42 
     43     private struct TransactionJournal: Codable {
     44         let version: UInt16
     45         let operationID: String
     46         let kind: TransactionKind
     47         var phase: TransactionPhase
     48         let previous: RadrootsIdentityPublicRecord?
     49         let candidate: RadrootsIdentityPublicRecord?
     50         let startedAtUnixMilliseconds: UInt64
     51 
     52         func validated() throws -> Self {
     53             guard version == 1,
     54                   RadrootsOpaqueSignRequest.canonicalUUID(operationID),
     55                   startedAtUnixMilliseconds > 0,
     56                   (kind == .replace) == (candidate != nil)
     57             else {
     58                 throw RadrootsIdentityCustodyError.corruptMetadata
     59             }
     60             if let previous {
     61                 _ = try RadrootsIdentityPublicRecord(
     62                     identityHandle: previous.identityHandle,
     63                     publicKeyHex: previous.publicKeyHex,
     64                     label: previous.label,
     65                     createdAtUnixMilliseconds: previous.createdAtUnixMilliseconds,
     66                     updatedAtUnixMilliseconds: previous.updatedAtUnixMilliseconds
     67                 )
     68             }
     69             if let candidate {
     70                 _ = try RadrootsIdentityPublicRecord(
     71                     identityHandle: candidate.identityHandle,
     72                     publicKeyHex: candidate.publicKeyHex,
     73                     label: candidate.label,
     74                     createdAtUnixMilliseconds: candidate.createdAtUnixMilliseconds,
     75                     updatedAtUnixMilliseconds: candidate.updatedAtUnixMilliseconds
     76                 )
     77             }
     78             return self
     79         }
     80     }
     81 
     82     private let configuration: RadrootsIdentityCustodyConfiguration
     83     private let secureStore: any RadrootsSecureStore
     84     private let metadataStore: any RadrootsIdentityMetadataStore
     85     private let userPresence: any RadrootsUserPresence
     86     private let protectedData: RadrootsProtectedDataProvider
     87     private let now: @Sendable () -> UInt64
     88     private let cryptography = RadrootsIdentityCryptography()
     89     private var session: UnlockedSession?
     90     private var generation: UInt64 = 0
     91     private var activeOperations = Set<String>()
     92     private var cancelledOperations: [String: UInt64] = [:]
     93     private let maximumActiveSigningOperations = 8
     94 
     95     public init(
     96         configuration: RadrootsIdentityCustodyConfiguration,
     97         secureStore: any RadrootsSecureStore,
     98         metadataStore: any RadrootsIdentityMetadataStore,
     99         userPresence: any RadrootsUserPresence,
    100         protectedData: RadrootsProtectedDataProvider = .available,
    101         now: @escaping @Sendable () -> UInt64 = {
    102             UInt64(Date().timeIntervalSince1970 * 1000)
    103         }
    104     ) {
    105         self.configuration = configuration
    106         self.secureStore = secureStore
    107         self.metadataStore = metadataStore
    108         self.userPresence = userPresence
    109         self.protectedData = protectedData
    110         self.now = now
    111     }
    112 
    113     public func snapshot() -> RadrootsIdentitySnapshot {
    114         do {
    115             let record = try loadRecord()
    116             if try loadJournal() != nil {
    117                 return Self.snapshot(.recoveryRequired, record, nil, "identity.transaction_pending")
    118             }
    119             let hasSecret = try secureStore.contains(secretKey(.active))
    120             guard record != nil || hasSecret else {
    121                 return Self.snapshot(.absent, nil, nil, nil)
    122             }
    123             guard let record, hasSecret else {
    124                 return Self.snapshot(.corrupt, record, nil, "identity.inconsistent_state")
    125             }
    126             guard protectedData.currentState() == .available else {
    127                 return Self.snapshot(
    128                     .protectedDataUnavailable, record, nil, "identity.protected_data_unavailable"
    129                 )
    130             }
    131             if let session, session.record == record {
    132                 return Self.snapshot(.unlocked, record, session.signerHandle, nil)
    133             }
    134             return Self.snapshot(.locked, record, nil, nil)
    135         } catch RadrootsIdentityCustodyError.corruptMetadata {
    136             return Self.snapshot(.corrupt, nil, nil, "identity.corrupt_metadata")
    137         } catch {
    138             return Self.snapshot(.recoveryRequired, nil, nil, "identity.storage_unavailable")
    139         }
    140     }
    141 
    142     @discardableResult
    143     public func recover() throws -> RadrootsIdentitySnapshot {
    144         try requireProtectedData()
    145         guard var journal = try loadJournal() else {
    146             try cleanupTransactionSecrets()
    147             return snapshot()
    148         }
    149         session = nil
    150         generation &+= 1
    151         do {
    152             switch journal.kind {
    153             case .replace:
    154                 try recoverReplace(&journal)
    155             case .delete:
    156                 try recoverDelete(&journal)
    157             }
    158             return snapshot()
    159         } catch let error as RadrootsIdentityCustodyError {
    160             throw error
    161         } catch {
    162             throw RadrootsIdentityCustodyError.recoveryRequired
    163         }
    164     }
    165 
    166     @discardableResult
    167     public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot {
    168         try requireUncancelledTask()
    169         _ = try recover()
    170         guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else {
    171             throw RadrootsIdentityCustodyError.identityAlreadyExists
    172         }
    173         let expectedGeneration = generation
    174         try await requireUserPresence(reason: "Create your local Nostr identity.")
    175         guard generation == expectedGeneration else {
    176             throw RadrootsIdentityCustodyError.recoveryRequired
    177         }
    178         let secret = try cryptography.generateSecret()
    179         return try commitReplacement(
    180             material: RadrootsIdentitySecretMaterial(rawRepresentation: secret),
    181             label: label,
    182             importedCreatedAt: nil,
    183             replaceExisting: false
    184         )
    185     }
    186 
    187     @discardableResult
    188     public func importIdentity(
    189         _ material: RadrootsIdentitySecretMaterial,
    190         label: String? = nil,
    191         replaceExisting: Bool = false
    192     ) async throws -> RadrootsIdentitySnapshot {
    193         try requireUncancelledTask()
    194         _ = try recover()
    195         _ = try cryptography.publicKeyHex(for: material.copyBytes())
    196         let existing = try loadRecord()
    197         let hasActive = try secureStore.contains(secretKey(.active))
    198         guard (existing != nil) == hasActive else {
    199             throw RadrootsIdentityCustodyError.inconsistentState
    200         }
    201         if existing != nil, !replaceExisting {
    202             throw RadrootsIdentityCustodyError.identityAlreadyExists
    203         }
    204         let expectedGeneration = generation
    205         try await requireUserPresence(reason: "Import your local Nostr identity.")
    206         guard generation == expectedGeneration else {
    207             throw RadrootsIdentityCustodyError.recoveryRequired
    208         }
    209         return try commitReplacement(
    210             material: material,
    211             label: label,
    212             importedCreatedAt: nil,
    213             replaceExisting: replaceExisting
    214         )
    215     }
    216 
    217     @discardableResult
    218     public func importPortableIdentity(
    219         _ envelope: RadrootsIdentityPortabilityEnvelope,
    220         passphrase: RadrootsIdentityPassphrase,
    221         replaceExisting: Bool = false
    222     ) async throws -> RadrootsIdentitySnapshot {
    223         try requireUncancelledTask()
    224         let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase)
    225         _ = try recover()
    226         let existing = try loadRecord()
    227         let hasActive = try secureStore.contains(secretKey(.active))
    228         guard (existing != nil) == hasActive else {
    229             throw RadrootsIdentityCustodyError.inconsistentState
    230         }
    231         if existing != nil, !replaceExisting {
    232             throw RadrootsIdentityCustodyError.identityAlreadyExists
    233         }
    234         let expectedGeneration = generation
    235         try await requireUserPresence(reason: "Import your encrypted Nostr identity.")
    236         guard generation == expectedGeneration else {
    237             throw RadrootsIdentityCustodyError.recoveryRequired
    238         }
    239         return try commitReplacement(
    240             material: opened.0,
    241             label: opened.1,
    242             importedCreatedAt: opened.2,
    243             replaceExisting: replaceExisting
    244         )
    245     }
    246 
    247     public func exportPortableIdentity(
    248         passphrase: RadrootsIdentityPassphrase
    249     ) async throws -> RadrootsIdentityPortabilityEnvelope {
    250         try requireUncancelledTask()
    251         try requireProtectedData()
    252         guard let session else {
    253             throw RadrootsIdentityCustodyError.identityLocked
    254         }
    255         let expectedGeneration = session.generation
    256         try await requireUserPresence(reason: "Export your encrypted Nostr identity.")
    257         guard let current = self.session,
    258               current.generation == expectedGeneration,
    259               current.signerHandle == session.signerHandle
    260         else {
    261             throw RadrootsIdentityCustodyError.staleSigner
    262         }
    263         var secret = try readSecret(.active)
    264         defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) }
    265         guard try cryptography.publicKeyHex(for: secret) == current.record.publicKeyHex else {
    266             throw RadrootsIdentityCustodyError.inconsistentState
    267         }
    268         return try RadrootsIdentityPortabilityCodec.seal(
    269             secret: secret,
    270             record: current.record,
    271             passphrase: passphrase
    272         )
    273     }
    274 
    275     @discardableResult
    276     public func migrateLegacyIdentity(
    277         from legacyKey: RadrootsSecureStoreKey,
    278         label: String? = nil
    279     ) async throws -> RadrootsIdentitySnapshot {
    280         try await migrateLegacyIdentity(from: legacyKey, expectedIdentity: nil, label: label)
    281     }
    282 
    283     @discardableResult
    284     public func migrateLegacyIdentity(
    285         from legacyKey: RadrootsSecureStoreKey,
    286         expectedPublicKeyHex: String,
    287         label: String? = nil
    288     ) async throws -> RadrootsIdentitySnapshot {
    289         guard expectedPublicKeyHex.count == 64,
    290               expectedPublicKeyHex.utf8.allSatisfy({ (48 ... 57).contains($0) || (97 ... 102).contains($0) })
    291         else {
    292             throw RadrootsIdentityCustodyError.invalidMetadata
    293         }
    294         return try await migrateLegacyIdentity(
    295             from: legacyKey, expectedIdentity: expectedPublicKeyHex, label: label
    296         )
    297     }
    298 
    299     private func migrateLegacyIdentity(
    300         from legacyKey: RadrootsSecureStoreKey,
    301         expectedIdentity: String?,
    302         label: String?
    303     ) async throws -> RadrootsIdentitySnapshot {
    304         try requireUncancelledTask()
    305         _ = try recover()
    306         try requireProtectedData()
    307         if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) {
    308             if let expectedIdentity, expectedIdentity != existing.publicKeyHex {
    309                 throw RadrootsIdentityCustodyError.inconsistentState
    310             }
    311             guard let legacy = try secureStore.get(legacyKey) else {
    312                 if expectedIdentity != nil {
    313                     try validateActiveSecret(for: existing)
    314                     try requireUncancelledTask()
    315                 }
    316                 return snapshot()
    317             }
    318             guard let text = String(data: legacy, encoding: .utf8),
    319                   let material = try? RadrootsIdentitySecretMaterial(importText: text),
    320                   try cryptography.publicKeyHex(for: material.copyBytes()) == existing.publicKeyHex
    321             else {
    322                 throw RadrootsIdentityCustodyError.inconsistentState
    323             }
    324             try validateActiveSecret(for: existing)
    325             try requireUncancelledTask()
    326             try secureStore.delete(legacyKey)
    327             return snapshot()
    328         }
    329         guard let legacy = try secureStore.get(legacyKey),
    330               let text = String(data: legacy, encoding: .utf8)
    331         else {
    332             throw RadrootsIdentityCustodyError.identityNotFound
    333         }
    334         let material = try RadrootsIdentitySecretMaterial(importText: text)
    335         if let expectedIdentity,
    336            try cryptography.publicKeyHex(for: material.copyBytes()) != expectedIdentity
    337         {
    338             throw RadrootsIdentityCustodyError.inconsistentState
    339         }
    340         let result = try await importIdentity(material, label: label)
    341         try validateActiveSecret(for: requiredRecord())
    342         try requireUncancelledTask()
    343         do {
    344             try secureStore.delete(legacyKey)
    345         } catch {
    346             throw RadrootsIdentityCustodyError.recoveryRequired
    347         }
    348         return result
    349     }
    350 
    351     private func validateActiveSecret(for record: RadrootsIdentityPublicRecord) throws {
    352         var active = try readSecret(.active)
    353         defer { active.resetBytes(in: active.startIndex ..< active.endIndex) }
    354         guard try cryptography.publicKeyHex(for: active) == record.publicKeyHex else {
    355             throw RadrootsIdentityCustodyError.inconsistentState
    356         }
    357     }
    358 
    359     @discardableResult
    360     public func unlockIdentity() async throws -> RadrootsIdentitySnapshot {
    361         try requireUncancelledTask()
    362         _ = try recover()
    363         try requireProtectedData()
    364         let record = try requiredRecord()
    365         let expectedGeneration = generation
    366         try await requireUserPresence(reason: "Unlock your local Nostr identity.")
    367         guard generation == expectedGeneration else {
    368             throw RadrootsIdentityCustodyError.staleSigner
    369         }
    370         var secret = try readSecret(.active)
    371         defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) }
    372         guard try cryptography.publicKeyHex(for: secret) == record.publicKeyHex else {
    373             throw RadrootsIdentityCustodyError.inconsistentState
    374         }
    375         generation &+= 1
    376         let handle = UUID().uuidString.lowercased()
    377         session = UnlockedSession(record: record, signerHandle: handle, generation: generation)
    378         return Self.snapshot(.unlocked, record, handle, nil)
    379     }
    380 
    381     @discardableResult
    382     public func selectIdentity(identityHandle: String) async throws -> RadrootsIdentitySnapshot {
    383         guard RadrootsIdentityPublicRecord.validHandle(identityHandle) else {
    384             throw RadrootsIdentityCustodyError.invalidMetadata
    385         }
    386         let record = try requiredRecord()
    387         guard record.identityHandle == identityHandle else {
    388             throw RadrootsIdentityCustodyError.identityNotFound
    389         }
    390         return try await unlockIdentity()
    391     }
    392 
    393     public func lockIdentity() {
    394         generation &+= 1
    395         session = nil
    396         let cancellationTime = now()
    397         for operationID in activeOperations {
    398             cancelledOperations[operationID] = cancellationTime
    399         }
    400         pruneCancellations()
    401     }
    402 
    403     @discardableResult
    404     public func deleteIdentity() async throws -> RadrootsIdentitySnapshot {
    405         try requireUncancelledTask()
    406         _ = try recover()
    407         try requireProtectedData()
    408         let record = try requiredRecord()
    409         let expectedGeneration = generation
    410         try await requireUserPresence(reason: "Delete your local Nostr identity.")
    411         guard generation == expectedGeneration,
    412               try requiredRecord() == record
    413         else {
    414             throw RadrootsIdentityCustodyError.recoveryRequired
    415         }
    416         lockIdentity()
    417         let operationID = UUID().uuidString.lowercased()
    418         let backup = try readSecret(.active)
    419         try secureStore.put(backup, for: secretKey(.backup), policy: configuration.secretPolicy)
    420         var journal = TransactionJournal(
    421             version: 1,
    422             operationID: operationID,
    423             kind: .delete,
    424             phase: .prepared,
    425             previous: record,
    426             candidate: nil,
    427             startedAtUnixMilliseconds: now()
    428         )
    429         try writeJournal(journal)
    430         do {
    431             try secureStore.delete(secretKey(.active))
    432             journal.phase = .activeSecretRemoved
    433             try writeJournal(journal)
    434             try metadataStore.delete(.activeIdentity)
    435             journal.phase = .metadataRemoved
    436             try writeJournal(journal)
    437             try cleanupTransactionSecrets()
    438             try metadataStore.delete(.transactionJournal)
    439             return Self.snapshot(.absent, nil, nil, nil)
    440         } catch {
    441             throw RadrootsIdentityCustodyError.recoveryRequired
    442         }
    443     }
    444 
    445     @discardableResult
    446     public func repairCorruptMetadata(label: String? = nil) async throws -> RadrootsIdentitySnapshot {
    447         try requireProtectedData()
    448         guard try loadJournal() == nil, try secureStore.contains(secretKey(.active)) else {
    449             throw RadrootsIdentityCustodyError.recoveryRequired
    450         }
    451         let expectedGeneration = generation
    452         try await requireUserPresence(reason: "Repair your local Nostr identity.")
    453         guard generation == expectedGeneration else {
    454             throw RadrootsIdentityCustodyError.staleSigner
    455         }
    456         var secret = try readSecret(.active)
    457         defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) }
    458         let publicKey = try cryptography.publicKeyHex(for: secret)
    459         let timestamp = now()
    460         let record = try makeRecord(
    461             publicKeyHex: publicKey,
    462             label: label,
    463             createdAt: timestamp,
    464             updatedAt: timestamp
    465         )
    466         try saveRecord(record)
    467         try metadataStore.delete(.quarantinedMetadata)
    468         generation &+= 1
    469         let handle = UUID().uuidString.lowercased()
    470         session = UnlockedSession(record: record, signerHandle: handle, generation: generation)
    471         return Self.snapshot(.unlocked, record, handle, nil)
    472     }
    473 
    474     public func sign(_ request: RadrootsOpaqueSignRequest) async throws -> RadrootsOpaqueSignature {
    475         try requireProtectedData()
    476         guard now() <= request.deadlineUnixMilliseconds else {
    477             throw RadrootsIdentityCustodyError.timedOut
    478         }
    479         guard cancelledOperations.removeValue(forKey: request.operationID) == nil else {
    480             throw RadrootsIdentityCustodyError.cancelled
    481         }
    482         guard activeOperations.count < maximumActiveSigningOperations else {
    483             throw RadrootsIdentityCustodyError.signingSaturated
    484         }
    485         guard activeOperations.insert(request.operationID).inserted else {
    486             throw RadrootsIdentityCustodyError.duplicateOperation
    487         }
    488         defer { activeOperations.remove(request.operationID) }
    489         guard let session else {
    490             throw RadrootsIdentityCustodyError.identityLocked
    491         }
    492         guard session.signerHandle == request.signerHandle,
    493               session.record.publicKeyHex == request.publicKeyHex
    494         else {
    495             throw RadrootsIdentityCustodyError.staleSigner
    496         }
    497         let expectedGeneration = session.generation
    498         try await requireUserPresence(reason: signingReason(request.purpose))
    499         guard cancelledOperations.removeValue(forKey: request.operationID) == nil else {
    500             throw RadrootsIdentityCustodyError.cancelled
    501         }
    502         guard now() <= request.deadlineUnixMilliseconds else {
    503             throw RadrootsIdentityCustodyError.timedOut
    504         }
    505         guard let current = self.session,
    506               current.generation == expectedGeneration,
    507               current.signerHandle == request.signerHandle,
    508               current.record.publicKeyHex == request.publicKeyHex
    509         else {
    510             throw RadrootsIdentityCustodyError.staleSigner
    511         }
    512         var secret = try readSecret(.active)
    513         defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) }
    514         guard try cryptography.publicKeyHex(for: secret) == request.publicKeyHex else {
    515             throw RadrootsIdentityCustodyError.inconsistentState
    516         }
    517         let signature = try cryptography.sign(secret: secret, digest: request.digest)
    518         guard
    519             cryptography.verify(
    520                 signature: signature,
    521                 digest: request.digest,
    522                 publicKeyHex: request.publicKeyHex
    523             )
    524         else {
    525             throw RadrootsIdentityCustodyError.invalidSignature
    526         }
    527         return try RadrootsOpaqueSignature(
    528             operationID: request.operationID,
    529             publicKeyHex: request.publicKeyHex,
    530             signature: signature,
    531             purpose: request.purpose
    532         )
    533     }
    534 
    535     public func cancelSigning(operationID: String) throws {
    536         guard RadrootsOpaqueSignRequest.canonicalUUID(operationID) else {
    537             throw RadrootsIdentityCustodyError.invalidSignRequest
    538         }
    539         cancelledOperations[operationID] = now()
    540         pruneCancellations()
    541     }
    542 
    543     private func commitReplacement(
    544         material: RadrootsIdentitySecretMaterial,
    545         label: String?,
    546         importedCreatedAt: UInt64?,
    547         replaceExisting: Bool
    548     ) throws -> RadrootsIdentitySnapshot {
    549         try requireProtectedData()
    550         let previous = try loadRecord()
    551         let hasActive = try secureStore.contains(secretKey(.active))
    552         guard (previous != nil) == hasActive else {
    553             throw RadrootsIdentityCustodyError.inconsistentState
    554         }
    555         if previous != nil, !replaceExisting {
    556             throw RadrootsIdentityCustodyError.identityAlreadyExists
    557         }
    558         let candidateSecret = material.copyBytes()
    559         let publicKey = try cryptography.publicKeyHex(for: candidateSecret)
    560         let timestamp = now()
    561         let createdAt: UInt64 = if let previous, previous.publicKeyHex == publicKey {
    562             previous.createdAtUnixMilliseconds
    563         } else {
    564             importedCreatedAt ?? timestamp
    565         }
    566         let candidate = try makeRecord(
    567             publicKeyHex: publicKey,
    568             label: label,
    569             createdAt: createdAt,
    570             updatedAt: max(timestamp, createdAt)
    571         )
    572         if hasActive {
    573             let current = try readSecret(.active)
    574             try secureStore.put(current, for: secretKey(.backup), policy: configuration.secretPolicy)
    575         } else {
    576             try secureStore.delete(secretKey(.backup))
    577         }
    578         try secureStore.put(
    579             candidateSecret, for: secretKey(.candidate), policy: configuration.secretPolicy
    580         )
    581         var journal = TransactionJournal(
    582             version: 1,
    583             operationID: UUID().uuidString.lowercased(),
    584             kind: .replace,
    585             phase: .prepared,
    586             previous: previous,
    587             candidate: candidate,
    588             startedAtUnixMilliseconds: timestamp
    589         )
    590         try writeJournal(journal)
    591         do {
    592             try secureStore.put(
    593                 candidateSecret, for: secretKey(.active), policy: configuration.secretPolicy
    594             )
    595             journal.phase = .activeSecretCommitted
    596             try writeJournal(journal)
    597             try saveRecord(candidate)
    598             journal.phase = .metadataCommitted
    599             try writeJournal(journal)
    600             try cleanupTransactionSecrets()
    601             try metadataStore.delete(.transactionJournal)
    602         } catch {
    603             session = nil
    604             generation &+= 1
    605             throw RadrootsIdentityCustodyError.recoveryRequired
    606         }
    607         generation &+= 1
    608         let handle = UUID().uuidString.lowercased()
    609         session = UnlockedSession(record: candidate, signerHandle: handle, generation: generation)
    610         return Self.snapshot(.unlocked, candidate, handle, nil)
    611     }
    612 
    613     private func recoverReplace(_ journal: inout TransactionJournal) throws {
    614         guard let candidate = journal.candidate else {
    615             throw RadrootsIdentityCustodyError.corruptMetadata
    616         }
    617         var activeMatches =
    618             try secret(.active).map {
    619                 try cryptography.publicKeyHex(for: $0) == candidate.publicKeyHex
    620             } ?? false
    621         if !activeMatches {
    622             if let candidateSecret = try secret(.candidate) {
    623                 guard try cryptography.publicKeyHex(for: candidateSecret) == candidate.publicKeyHex else {
    624                     throw RadrootsIdentityCustodyError.corruptMetadata
    625                 }
    626                 try secureStore.put(
    627                     candidateSecret,
    628                     for: secretKey(.active),
    629                     policy: configuration.secretPolicy
    630                 )
    631                 activeMatches = true
    632             } else {
    633                 try rollbackReplacement(journal)
    634                 return
    635             }
    636         }
    637         guard activeMatches else {
    638             throw RadrootsIdentityCustodyError.recoveryRequired
    639         }
    640         journal.phase = .activeSecretCommitted
    641         try writeJournal(journal)
    642         try saveRecord(candidate)
    643         journal.phase = .metadataCommitted
    644         try writeJournal(journal)
    645         try cleanupTransactionSecrets()
    646         try metadataStore.delete(.transactionJournal)
    647     }
    648 
    649     private func rollbackReplacement(_ journal: TransactionJournal) throws {
    650         if let previous = journal.previous {
    651             guard let backup = try secret(.backup),
    652                   try cryptography.publicKeyHex(for: backup) == previous.publicKeyHex
    653             else {
    654                 throw RadrootsIdentityCustodyError.recoveryRequired
    655             }
    656             try secureStore.put(backup, for: secretKey(.active), policy: configuration.secretPolicy)
    657             try saveRecord(previous)
    658         } else {
    659             try secureStore.delete(secretKey(.active))
    660             try metadataStore.delete(.activeIdentity)
    661         }
    662         try cleanupTransactionSecrets()
    663         try metadataStore.delete(.transactionJournal)
    664     }
    665 
    666     private func recoverDelete(_ journal: inout TransactionJournal) throws {
    667         try secureStore.delete(secretKey(.active))
    668         journal.phase = .activeSecretRemoved
    669         try writeJournal(journal)
    670         try metadataStore.delete(.activeIdentity)
    671         journal.phase = .metadataRemoved
    672         try writeJournal(journal)
    673         try cleanupTransactionSecrets()
    674         try metadataStore.delete(.transactionJournal)
    675     }
    676 
    677     private func requireUserPresence(reason: String) async throws {
    678         try requireUncancelledTask()
    679         try requireProtectedData()
    680         let request: RadrootsUserPresenceRequest
    681         do {
    682             request = try RadrootsUserPresenceRequest(reason: reason)
    683         } catch {
    684             throw RadrootsIdentityCustodyError.invalidConfiguration
    685         }
    686         do {
    687             let result = try await userPresence.verify(request)
    688             try requireUncancelledTask()
    689             guard result.verified else {
    690                 throw RadrootsIdentityCustodyError.userPresenceRequired
    691             }
    692         } catch let error as RadrootsIdentityCustodyError {
    693             throw error
    694         } catch let error as RadrootsUserPresenceError {
    695             switch error {
    696             case .userCancelled:
    697                 throw RadrootsIdentityCustodyError.cancelled
    698             case .timeout:
    699                 throw RadrootsIdentityCustodyError.timedOut
    700             default:
    701                 throw RadrootsIdentityCustodyError.userPresenceRequired
    702             }
    703         } catch {
    704             throw RadrootsIdentityCustodyError.userPresenceRequired
    705         }
    706         try requireProtectedData()
    707     }
    708 
    709     private func requireUncancelledTask() throws {
    710         guard !Task.isCancelled else {
    711             throw RadrootsIdentityCustodyError.cancelled
    712         }
    713     }
    714 
    715     private func requireProtectedData() throws {
    716         guard protectedData.currentState() == .available else {
    717             throw RadrootsIdentityCustodyError.protectedDataUnavailable
    718         }
    719     }
    720 
    721     private func requiredRecord() throws -> RadrootsIdentityPublicRecord {
    722         guard let record = try loadRecord() else {
    723             throw RadrootsIdentityCustodyError.identityNotFound
    724         }
    725         guard try secureStore.contains(secretKey(.active)) else {
    726             throw RadrootsIdentityCustodyError.inconsistentState
    727         }
    728         return record
    729     }
    730 
    731     private func loadRecord() throws -> RadrootsIdentityPublicRecord? {
    732         guard let data = try metadataStore.data(for: .activeIdentity) else {
    733             return nil
    734         }
    735         do {
    736             let decoded = try JSONDecoder().decode(RadrootsIdentityPublicRecord.self, from: data)
    737             return try RadrootsIdentityPublicRecord(
    738                 identityHandle: decoded.identityHandle,
    739                 publicKeyHex: decoded.publicKeyHex,
    740                 label: decoded.label,
    741                 createdAtUnixMilliseconds: decoded.createdAtUnixMilliseconds,
    742                 updatedAtUnixMilliseconds: decoded.updatedAtUnixMilliseconds
    743             )
    744         } catch {
    745             do {
    746                 try metadataStore.put(data, for: .quarantinedMetadata)
    747             } catch {
    748                 throw RadrootsIdentityCustodyError.storageUnavailable
    749             }
    750             throw RadrootsIdentityCustodyError.corruptMetadata
    751         }
    752     }
    753 
    754     private func saveRecord(_ record: RadrootsIdentityPublicRecord) throws {
    755         do {
    756             let encoder = JSONEncoder()
    757             encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
    758             try metadataStore.put(encoder.encode(record), for: .activeIdentity)
    759         } catch let error as RadrootsIdentityCustodyError {
    760             throw error
    761         } catch {
    762             throw RadrootsIdentityCustodyError.storageUnavailable
    763         }
    764     }
    765 
    766     private func loadJournal() throws -> TransactionJournal? {
    767         guard let data = try metadataStore.data(for: .transactionJournal) else {
    768             return nil
    769         }
    770         do {
    771             return try JSONDecoder().decode(TransactionJournal.self, from: data).validated()
    772         } catch let error as RadrootsIdentityCustodyError {
    773             throw error
    774         } catch {
    775             throw RadrootsIdentityCustodyError.corruptMetadata
    776         }
    777     }
    778 
    779     private func writeJournal(_ journal: TransactionJournal) throws {
    780         do {
    781             let encoder = JSONEncoder()
    782             encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
    783             try metadataStore.put(encoder.encode(journal.validated()), for: .transactionJournal)
    784         } catch let error as RadrootsIdentityCustodyError {
    785             throw error
    786         } catch {
    787             throw RadrootsIdentityCustodyError.storageUnavailable
    788         }
    789     }
    790 
    791     private enum SecretSlot: String {
    792         case active = "active_secret_v1"
    793         case candidate = "candidate_secret_v1"
    794         case backup = "backup_secret_v1"
    795     }
    796 
    797     private func secretKey(_ slot: SecretSlot) -> RadrootsSecureStoreKey {
    798         RadrootsSecureStoreKey(namespace: configuration.namespace, name: slot.rawValue)
    799     }
    800 
    801     private func secret(_ slot: SecretSlot) throws -> Data? {
    802         do {
    803             return try secureStore.get(secretKey(slot))
    804         } catch {
    805             throw RadrootsIdentityCustodyError.storageUnavailable
    806         }
    807     }
    808 
    809     private func readSecret(_ slot: SecretSlot) throws -> Data {
    810         guard let secret = try secret(slot) else {
    811             throw RadrootsIdentityCustodyError.inconsistentState
    812         }
    813         guard secret.count == 32 else {
    814             throw RadrootsIdentityCustodyError.invalidSecret
    815         }
    816         return secret
    817     }
    818 
    819     private func cleanupTransactionSecrets() throws {
    820         do {
    821             try secureStore.delete(secretKey(.candidate))
    822             try secureStore.delete(secretKey(.backup))
    823         } catch {
    824             throw RadrootsIdentityCustodyError.storageUnavailable
    825         }
    826     }
    827 
    828     private func makeRecord(
    829         publicKeyHex: String,
    830         label: String?,
    831         createdAt: UInt64,
    832         updatedAt: UInt64
    833     ) throws -> RadrootsIdentityPublicRecord {
    834         try RadrootsIdentityPublicRecord(
    835             identityHandle: cryptography.identityHandle(forPublicKeyHex: publicKeyHex),
    836             publicKeyHex: publicKeyHex,
    837             label: label,
    838             createdAtUnixMilliseconds: createdAt,
    839             updatedAtUnixMilliseconds: updatedAt
    840         )
    841     }
    842 
    843     private func signingReason(_ purpose: RadrootsOpaqueSignPurpose) -> String {
    844         switch purpose {
    845         case .nostrEvent:
    846             "Sign this Nostr event with your local identity."
    847         case .blossomUpload:
    848             "Authorize this Blossom media upload with your local identity."
    849         }
    850     }
    851 
    852     private func pruneCancellations() {
    853         guard cancelledOperations.count > 128 else {
    854             return
    855         }
    856         let ordered = cancelledOperations.sorted { $0.value < $1.value }
    857         for (operationID, _) in ordered.prefix(cancelledOperations.count - 128) {
    858             cancelledOperations.removeValue(forKey: operationID)
    859         }
    860     }
    861 
    862     private static func snapshot(
    863         _ state: RadrootsIdentityState,
    864         _ identity: RadrootsIdentityPublicRecord?,
    865         _ signerHandle: String?,
    866         _ recoveryCode: String?
    867     ) -> RadrootsIdentitySnapshot {
    868         RadrootsIdentitySnapshot(
    869             state: state,
    870             identity: identity,
    871             signerHandle: signerHandle,
    872             recoveryCode: recoveryCode
    873         )
    874     }
    875 }
    876 
    877 public final class RadrootsOpaqueSignerCancellation: @unchecked Sendable {
    878     private let lock = NSLock()
    879     private var isCancelled = false
    880     private let cancelAction: @Sendable () -> Void
    881 
    882     init(cancelAction: @escaping @Sendable () -> Void) {
    883         self.cancelAction = cancelAction
    884     }
    885 
    886     public func cancel() {
    887         lock.lock()
    888         guard !isCancelled else {
    889             lock.unlock()
    890             return
    891         }
    892         isCancelled = true
    893         lock.unlock()
    894         cancelAction()
    895     }
    896 }
    897 
    898 public final class RadrootsOpaqueSignerBridge: Sendable {
    899     private let custody: RadrootsIdentityCustody
    900 
    901     public init(custody: RadrootsIdentityCustody) {
    902         self.custody = custody
    903     }
    904 
    905     @discardableResult
    906     public func submit(
    907         _ request: RadrootsOpaqueSignRequest,
    908         completion:
    909         @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void
    910     ) -> RadrootsOpaqueSignerCancellation {
    911         let completionState = RadrootsOpaqueSignerCompletion(completion: completion)
    912         let custody = custody
    913         let task = Task {
    914             do {
    915                 if Task.isCancelled {
    916                     try await custody.cancelSigning(operationID: request.operationID)
    917                 }
    918                 try await completionState.finish(.success(custody.sign(request)))
    919             } catch let error as RadrootsIdentityCustodyError {
    920                 completionState.finish(.failure(error))
    921             } catch {
    922                 completionState.finish(.failure(.cryptographyFailed))
    923             }
    924         }
    925         return RadrootsOpaqueSignerCancellation {
    926             task.cancel()
    927             Task {
    928                 try? await custody.cancelSigning(operationID: request.operationID)
    929             }
    930         }
    931     }
    932 }
    933 
    934 private final class RadrootsOpaqueSignerCompletion: @unchecked Sendable {
    935     private let lock = NSLock()
    936     private var completion:
    937         (@Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void)?
    938 
    939     init(
    940         completion:
    941         @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void
    942     ) {
    943         self.completion = completion
    944     }
    945 
    946     func finish(_ result: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) {
    947         lock.lock()
    948         let callback = completion
    949         completion = nil
    950         lock.unlock()
    951         callback?(result)
    952     }
    953 }