field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraIdentityStore.swift (13357B)


      1 import CryptoKit
      2 import Foundation
      3 import RadrootsKit
      4 
      5 enum TeraAppIdentityState: String, Sendable, Equatable {
      6     case absent
      7     case locked
      8     case unlocked
      9     case protectedDataUnavailable
     10     case recoveryRequired
     11     case corrupt
     12 }
     13 
     14 struct TeraAppIdentity: Sendable, Equatable {
     15     let state: TeraAppIdentityState
     16     let identityHandle: String?
     17     let publicKeyHex: String?
     18     let label: String?
     19     let signerGeneration: String?
     20     let recoveryCode: String?
     21 }
     22 
     23 struct TeraStableVisualIdentity: Sendable, Equatable {
     24     let digestHex: String
     25     let paletteIndex: Int
     26 
     27     init(publicKeyHex: String, paletteCount: Int = 12) {
     28         let digest = SHA256.hash(data: Data("radroots.avatar.v1:\(publicKeyHex)".utf8))
     29         digestHex = digest.map { String(format: "%02x", $0) }.joined()
     30         paletteIndex = Int(Array(digest)[0]) % max(1, paletteCount)
     31     }
     32 }
     33 
     34 enum TeraIdentityStoreError: Error, Sendable, Equatable {
     35     case corruptLegacyMetadata
     36     case custody(String)
     37     case unavailable
     38 }
     39 
     40 extension TeraIdentityStoreError: LocalizedError {
     41     var errorDescription: String? {
     42         switch self {
     43         case .corruptLegacyMetadata:
     44             "Legacy identity metadata is corrupt and requires recovery."
     45         case .custody:
     46             "The local identity needs attention before Tera can continue."
     47         case .unavailable:
     48             "The local identity is unavailable."
     49         }
     50     }
     51 }
     52 
     53 actor TeraIdentityStore {
     54     private struct LegacyMetadata: Codable {
     55         let selectedIdentityId: String
     56         let publicKeyHex: String
     57         let publicKeyNpub: String
     58         let label: String?
     59         let updatedAtUnix: UInt64
     60     }
     61 
     62     private let custody: RadrootsIdentityCustody
     63     private let secureStore: any RadrootsSecureStore
     64     private let servicePrefix: String
     65     private let userDefaults: UserDefaults
     66 
     67     init(
     68       custody: RadrootsIdentityCustody,
     69       secureStore: any RadrootsSecureStore,
     70       servicePrefix: String,
     71       userDefaults: UserDefaults = .standard
     72     ) {
     73         self.custody = custody
     74         self.secureStore = secureStore
     75         self.servicePrefix = servicePrefix
     76         self.userDefaults = userDefaults
     77     }
     78 
     79     @MainActor
     80     static func production(
     81       servicePrefix: String,
     82       protectedDataAvailable: @escaping @Sendable () -> Bool,
     83       qualification: TeraRemoteQualificationEnvironment? = nil
     84     ) throws -> TeraIdentityStore {
     85         let namespace = "radroots_identity_v1"
     86         let secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
     87         let configuration: RadrootsIdentityCustodyConfiguration
     88         let userPresence: any RadrootsUserPresence
     89         #if DEBUG
     90             if let qualification, qualification.automatesIdentity {
     91                 configuration = try RadrootsIdentityCustodyConfiguration(
     92                   namespace: namespace,
     93                   secretPolicy: .secureLocalSecret
     94                 )
     95                 userPresence = try TeraRemoteQualificationUserPresence(
     96                   mode: qualification.networkMode
     97                 )
     98             } else {
     99                 configuration = try RadrootsIdentityCustodyConfiguration(namespace: namespace)
    100                 userPresence = RadrootsAppleUserPresence()
    101             }
    102         #else
    103             _ = qualification
    104             configuration = try RadrootsIdentityCustodyConfiguration(namespace: namespace)
    105             userPresence = RadrootsAppleUserPresence()
    106         #endif
    107         let custody = try RadrootsIdentityCustody(
    108           configuration: configuration,
    109           secureStore: secureStore,
    110           metadataStore: RadrootsAppleIdentityMetadataStore(
    111             namespace: namespace,
    112             keyPrefix: qualification?.identityMetadataKeyPrefix
    113                     ?? "org.radroots.ios.identity"
    114           ),
    115           userPresence: userPresence,
    116           protectedData: RadrootsProtectedDataProvider {
    117                 protectedDataAvailable() ? .available : .unavailable
    118             }
    119         )
    120         return TeraIdentityStore(
    121           custody: custody,
    122           secureStore: secureStore,
    123           servicePrefix: servicePrefix
    124         )
    125     }
    126 
    127     func loadAndMigrate() async throws -> TeraAppIdentity {
    128         let initial = await custody.snapshot()
    129         guard [.absent, .locked, .unlocked].contains(initial.state) else {
    130             return Self.appIdentity(initial)
    131         }
    132 
    133         let legacySecretKey = RadrootsSecureStoreKey(
    134           namespace: "nostr_identity",
    135           name: "selected_secret_hex"
    136         )
    137         let hasLegacySecret: Bool
    138         do {
    139             hasLegacySecret = try secureStore.contains(legacySecretKey)
    140         } catch {
    141             throw TeraIdentityStoreError.unavailable
    142         }
    143         let legacyMetadata = try loadLegacyMetadata()
    144         guard hasLegacySecret || legacyMetadata != nil else {
    145             return Self.appIdentity(initial)
    146         }
    147         guard hasLegacySecret || initial.state != .absent else {
    148             throw TeraIdentityStoreError.corruptLegacyMetadata
    149         }
    150         return TeraAppIdentity(
    151           state: .recoveryRequired,
    152           identityHandle: initial.identity?.identityHandle,
    153           publicKeyHex: legacyMetadata?.publicKeyHex.lowercased() ?? initial.identity?.publicKeyHex,
    154           label: legacyMetadata?.label ?? initial.identity?.label,
    155           signerGeneration: nil,
    156           recoveryCode: "identity.legacy_migration_required"
    157         )
    158     }
    159 
    160     private func migrateLegacyIdentity() async throws -> TeraAppIdentity {
    161         let legacySecretKey = RadrootsSecureStoreKey(
    162           namespace: "nostr_identity",
    163           name: "selected_secret_hex"
    164         )
    165         let legacyMetadata = try loadLegacyMetadata()
    166         do {
    167             let migrated: RadrootsIdentitySnapshot = if let metadata = legacyMetadata {
    168                 try await custody.migrateLegacyIdentity(
    169                   from: legacySecretKey,
    170                   expectedPublicKeyHex: metadata.publicKeyHex.lowercased(),
    171                   label: metadata.label
    172                 )
    173             } else {
    174                 try await custody.migrateLegacyIdentity(from: legacySecretKey)
    175             }
    176             deleteLegacyMetadata()
    177             return Self.appIdentity(migrated)
    178         } catch let error as TeraIdentityStoreError {
    179             throw error
    180         } catch let error as RadrootsIdentityCustodyError {
    181             throw TeraIdentityStoreError.custody(error.code)
    182         } catch {
    183             throw TeraIdentityStoreError.unavailable
    184         }
    185     }
    186 
    187     func create(label: String? = nil) async throws -> TeraAppIdentity {
    188         try await requireNoLegacyRecovery()
    189         return try await custody.createIdentity(label: label).appValue
    190     }
    191 
    192     func importIdentity(
    193       _ material: RadrootsIdentitySecretMaterial,
    194       label: String? = nil
    195     ) async throws -> TeraAppIdentity {
    196         try await requireNoLegacyRecovery()
    197         return try await custody.importIdentity(material, label: label).appValue
    198     }
    199 
    200     func snapshot() async -> TeraAppIdentity {
    201         await custody.snapshot().appValue
    202     }
    203 
    204     func removeSigningKey(expected: TeraAppIdentity) async throws -> TeraAppIdentity {
    205         try Task.checkCancellation()
    206         try await requireNoLegacyRecovery()
    207         guard await snapshot() == expected, expected.state == .unlocked else {
    208             throw TeraIdentityStoreError.unavailable
    209         }
    210         // The custody owner rechecks identity/generation after user presence.
    211         // A returned absent snapshot is a committed fact even after late cancellation.
    212         return try await custody.deleteIdentity().appValue
    213     }
    214 
    215     func unlock() async throws -> TeraAppIdentity {
    216         try await custody.unlockIdentity().appValue
    217     }
    218 
    219     func recover() async throws -> TeraAppIdentity {
    220         let recovered = try await custody.recover()
    221         let pending = try await loadAndMigrate()
    222         if pending.recoveryCode == "identity.legacy_migration_required" {
    223             return try await migrateLegacyIdentity()
    224         }
    225         return recovered.appValue
    226     }
    227 
    228     private func requireNoLegacyRecovery() async throws {
    229         guard try await loadAndMigrate().state != .recoveryRequired else {
    230             throw TeraIdentityStoreError.custody("identity.recovery_required")
    231         }
    232     }
    233 
    234     func lock() async {
    235         await custody.lockIdentity()
    236     }
    237 
    238     func signer(for identity: TeraAppIdentity) throws -> any TeraRuntimeSigner {
    239         guard identity.state == .unlocked,
    240               let signerHandle = identity.signerGeneration,
    241               let publicKeyHex = identity.publicKeyHex
    242         else {
    243             throw TeraIdentityStoreError.unavailable
    244         }
    245         return TeraAppleCustodySigner(
    246           custody: custody,
    247           signerHandle: signerHandle,
    248           publicKeyHex: publicKeyHex
    249         )
    250     }
    251 
    252     private func loadLegacyMetadata() throws -> LegacyMetadata? {
    253         let key = "field_ios.identity.public_metadata.\(servicePrefix)"
    254         guard let data = userDefaults.data(forKey: key) else { return nil }
    255         guard let value = try? JSONDecoder().decode(LegacyMetadata.self, from: data),
    256               value.publicKeyHex.count == 64,
    257               value.publicKeyHex.allSatisfy(\.isHexDigit)
    258         else {
    259             throw TeraIdentityStoreError.corruptLegacyMetadata
    260         }
    261         return value
    262     }
    263 
    264     private func deleteLegacyMetadata() {
    265         userDefaults.removeObject(
    266             forKey: "field_ios.identity.public_metadata.\(servicePrefix)"
    267         )
    268     }
    269 
    270     private static func appIdentity(_ snapshot: RadrootsIdentitySnapshot) -> TeraAppIdentity {
    271         TeraAppIdentity(
    272           state: snapshot.state.appValue,
    273           identityHandle: snapshot.identity?.identityHandle,
    274           publicKeyHex: snapshot.identity?.publicKeyHex,
    275           label: snapshot.identity?.label,
    276           signerGeneration: snapshot.signerHandle,
    277           recoveryCode: snapshot.recoveryCode
    278         )
    279     }
    280 }
    281 
    282 private final class TeraAppleCustodySigner: TeraRuntimeSigner, @unchecked Sendable {
    283     private let custody: RadrootsIdentityCustody
    284     private let signerHandle: String
    285     private let publicKeyHex: String
    286 
    287     init(custody: RadrootsIdentityCustody, signerHandle: String, publicKeyHex: String) {
    288         self.custody = custody
    289         self.signerHandle = signerHandle
    290         self.publicKeyHex = publicKeyHex
    291     }
    292 
    293     func availability() async -> TeraRuntimeSignerAvailability {
    294         let snapshot = await custody.snapshot()
    295         return switch snapshot.state {
    296         case .unlocked where snapshot.signerHandle == signerHandle:
    297             TeraRuntimeSignerAvailability.ready
    298         case .locked:
    299             TeraRuntimeSignerAvailability.locked
    300         default:
    301             TeraRuntimeSignerAvailability.unavailable
    302         }
    303     }
    304 
    305     func sign(_ request: TeraRuntimeSigningRequest) async -> TeraRuntimeSigningOutcome {
    306         guard request.publicKeyHex == publicKeyHex,
    307               request.digest.count == 32
    308         else {
    309             return .invalidated
    310         }
    311         do {
    312             let result = try await custody.sign(
    313                 RadrootsOpaqueSignRequest(
    314                   operationID: request.operationID,
    315                   signerHandle: signerHandle,
    316                   publicKeyHex: request.publicKeyHex,
    317                   digest: request.digest,
    318                   purpose: request.purpose.appleValue,
    319                   deadlineUnixMilliseconds: request.deadlineUnixMilliseconds
    320                 )
    321             )
    322             return .signed(signatureHex: result.signature.map { String(format: "%02x", $0) }.joined())
    323         } catch let error as RadrootsIdentityCustodyError {
    324             return switch error {
    325             case .identityLocked, .userPresenceRequired:
    326                 TeraRuntimeSigningOutcome.locked
    327             case .cancelled:
    328                 TeraRuntimeSigningOutcome.cancelled
    329             case .timedOut:
    330                 TeraRuntimeSigningOutcome.timedOut
    331             case .staleSigner, .invalidSignRequest, .invalidSignature:
    332                 TeraRuntimeSigningOutcome.invalidated
    333             case .protectedDataUnavailable, .storageUnavailable:
    334                 TeraRuntimeSigningOutcome.unavailable
    335             default:
    336                 TeraRuntimeSigningOutcome.failed
    337             }
    338         } catch {
    339             return .failed
    340         }
    341     }
    342 }
    343 
    344 private extension RadrootsIdentitySnapshot {
    345     var appValue: TeraAppIdentity {
    346         TeraAppIdentity(
    347           state: state.appValue,
    348           identityHandle: identity?.identityHandle,
    349           publicKeyHex: identity?.publicKeyHex,
    350           label: identity?.label,
    351           signerGeneration: signerHandle,
    352           recoveryCode: recoveryCode
    353         )
    354     }
    355 }
    356 
    357 private extension RadrootsIdentityState {
    358     var appValue: TeraAppIdentityState {
    359         switch self {
    360         case .absent: .absent
    361         case .locked: .locked
    362         case .unlocked: .unlocked
    363         case .protectedDataUnavailable: .protectedDataUnavailable
    364         case .recoveryRequired: .recoveryRequired
    365         case .corrupt: .corrupt
    366         }
    367     }
    368 }
    369 
    370 private extension TeraRuntimeSigningPurpose {
    371     var appleValue: RadrootsOpaqueSignPurpose {
    372         switch self {
    373         case .nostrEvent: .nostrEvent
    374         case .blossomUpload: .blossomUpload
    375         }
    376     }
    377 }