field_ios

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

FieldSecureIdentityStore.swift (9653B)


      1 import Foundation
      2 import RadrootsKit
      3 import Security
      4 
      5 enum FieldSecureIdentityStoreError: LocalizedError {
      6     case missingSecureStoreServicePrefix
      7     case missingBundleIdentifier
      8     case invalidStoredSecret
      9     case missingSelectedSecret
     10     case missingSecureStoreAccessPolicy
     11     case invalidSecureStoreAccessPolicy(String)
     12     case randomSecretGenerationFailed(Int32)
     13     case forcedImportRestoreFailure
     14 
     15     var errorDescription: String? {
     16         switch self {
     17         case .missingSecureStoreServicePrefix:
     18             "Missing RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX."
     19         case .missingBundleIdentifier:
     20             "Missing field iOS bundle identifier."
     21         case .invalidStoredSecret:
     22             "Stored Nostr identity secret is invalid."
     23         case .missingSelectedSecret:
     24             "No selected Nostr identity secret is available in secure store."
     25         case .missingSecureStoreAccessPolicy:
     26             "Missing RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY."
     27         case .invalidSecureStoreAccessPolicy(let value):
     28             "Invalid RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY: \(value)."
     29         case .randomSecretGenerationFailed(let status):
     30             "Secure Nostr identity generation failed with status \(status)."
     31         case .forcedImportRestoreFailure:
     32             "Forced identity import restore failure."
     33         }
     34     }
     35 }
     36 
     37 enum FieldSecureIdentityAccessPolicy: String {
     38     case userPresenceLocal = "user_presence_local"
     39     case secureLocal = "secure_local"
     40 
     41     var storePolicy: RadrootsSecretAccessPolicy {
     42         switch self {
     43         case .userPresenceLocal:
     44             .userPresenceLocalSecret
     45         case .secureLocal:
     46             .secureLocalSecret
     47         }
     48     }
     49 }
     50 
     51 struct FieldSecureIdentityStore {
     52     static let namespace = "nostr_identity"
     53     static let selectedSecretName = "selected_secret_hex"
     54 
     55     let servicePrefix: String
     56     private let store: any RadrootsSecureStore
     57 
     58     init(servicePrefix: String) {
     59         self.servicePrefix = servicePrefix
     60         self.store = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
     61     }
     62 
     63     init(servicePrefix: String, store: any RadrootsSecureStore) {
     64         self.servicePrefix = servicePrefix
     65         self.store = store
     66     }
     67 
     68     static func configured() throws -> FieldSecureIdentityStore {
     69         guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else {
     70             throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix
     71         }
     72         return FieldSecureIdentityStore(servicePrefix: servicePrefix)
     73     }
     74 
     75     func loadSelectedSecretHex() throws -> String? {
     76         guard let data = try store.get(Self.selectedSecretKey) else {
     77             return nil
     78         }
     79         guard let value = String(data: data, encoding: .utf8) else {
     80             throw FieldSecureIdentityStoreError.invalidStoredSecret
     81         }
     82         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
     83         return trimmed.isEmpty ? nil : trimmed
     84     }
     85 
     86     func restoreStoredIdentity(
     87         label: String?,
     88         using service: FieldRuntimeService
     89     ) async throws -> NostrIdentityRecord {
     90         guard let secret = try loadSelectedSecretHex() else {
     91             throw FieldSecureIdentityStoreError.missingSelectedSecret
     92         }
     93         return try await service.nostrIdentityRestoreHostCustodySecret(
     94             secretKey: secret,
     95             label: label,
     96             makeSelected: true
     97         )
     98     }
     99 
    100     func importSecret(
    101         _ secret: String,
    102         label: String?,
    103         using service: FieldRuntimeService
    104     ) async throws -> NostrIdentityRecord {
    105         let trimmed = try normalizedSecret(secret)
    106         let previousSecret = try loadSelectedSecretHex()
    107         _ = try await service.nostrIdentityValidateHostCustodySecret(secretKey: trimmed)
    108         let stagedRecord = try await restoreHostCustodySecret(
    109             trimmed,
    110             label: label,
    111             makeSelected: false,
    112             using: service
    113         )
    114         do {
    115             try saveSelectedSecret(trimmed)
    116         } catch {
    117             await restorePreviousRuntimeIdentity(previousSecret, using: service)
    118             await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service)
    119             throw error
    120         }
    121         do {
    122             return try await restoreHostCustodySecret(
    123                 trimmed,
    124                 label: label,
    125                 makeSelected: true,
    126                 using: service
    127             )
    128         } catch {
    129             try? restorePreviousSelectedSecret(previousSecret)
    130             await restorePreviousRuntimeIdentity(previousSecret, using: service)
    131             await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service)
    132             throw error
    133         }
    134     }
    135 
    136     func createIdentity(
    137         label: String?,
    138         using service: FieldRuntimeService
    139     ) async throws -> NostrIdentityRecord {
    140         var lastError: Error?
    141         for _ in 0..<8 {
    142             let secret = try Self.generateSecretHex()
    143             do {
    144                 return try await importSecret(secret, label: label, using: service)
    145             } catch {
    146                 lastError = error
    147             }
    148         }
    149         throw lastError ?? FieldSecureIdentityStoreError.missingSelectedSecret
    150     }
    151 
    152     func deleteSelectedSecret() throws {
    153         try store.delete(Self.selectedSecretKey)
    154     }
    155 
    156     static func secureStoreServiceName(servicePrefix: String) throws -> String {
    157         try selectedSecretKey.serviceName(servicePrefix: servicePrefix)
    158     }
    159 
    160     static func configuredAccessPolicy() throws -> FieldSecureIdentityAccessPolicy {
    161         guard let rawValue = BuildConfig.string(.keychainAccessPolicy) else {
    162             throw FieldSecureIdentityStoreError.missingSecureStoreAccessPolicy
    163         }
    164         guard let policy = FieldSecureIdentityAccessPolicy(rawValue: rawValue) else {
    165             throw FieldSecureIdentityStoreError.invalidSecureStoreAccessPolicy(rawValue)
    166         }
    167         return policy
    168     }
    169 
    170     static func generateSecretHex() throws -> String {
    171         var bytes = [UInt8](repeating: 0, count: 32)
    172         let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
    173         guard status == errSecSuccess else {
    174             throw FieldSecureIdentityStoreError.randomSecretGenerationFailed(status)
    175         }
    176         return bytes.map { String(format: "%02x", $0) }.joined()
    177     }
    178 
    179     private func saveSelectedSecret(_ secret: String) throws {
    180         let trimmed = try normalizedSecret(secret)
    181         try store.put(
    182             Data(trimmed.utf8),
    183             for: Self.selectedSecretKey,
    184             policy: try Self.configuredAccessPolicy().storePolicy
    185         )
    186     }
    187 
    188     private func restorePreviousSelectedSecret(_ previousSecret: String?) throws {
    189         if let previousSecret {
    190             try saveSelectedSecret(previousSecret)
    191         } else {
    192             try deleteSelectedSecret()
    193         }
    194     }
    195 
    196     private func restoreHostCustodySecret(
    197         _ secret: String,
    198         label: String?,
    199         makeSelected: Bool,
    200         using service: FieldRuntimeService
    201     ) async throws -> NostrIdentityRecord {
    202         #if DEBUG
    203         try FieldSecureIdentityImportRestoreFailureUITestHook.throwIfRequested(makeSelected: makeSelected)
    204         #endif
    205         return try await service.nostrIdentityRestoreHostCustodySecret(
    206             secretKey: secret,
    207             label: label,
    208             makeSelected: makeSelected
    209         )
    210     }
    211 
    212     private func restorePreviousRuntimeIdentity(
    213         _ previousSecret: String?,
    214         using service: FieldRuntimeService
    215     ) async {
    216         guard let previousSecret else {
    217             return
    218         }
    219         _ = try? await service.nostrIdentityRestoreHostCustodySecret(
    220             secretKey: previousSecret,
    221             label: nil,
    222             makeSelected: true
    223         )
    224     }
    225 
    226     private func removeStagedIdentityIfNeeded(
    227         _ stagedRecord: NostrIdentityRecord,
    228         previousSecret: String?,
    229         using service: FieldRuntimeService
    230     ) async {
    231         if let previousSecret,
    232            let previous = try? await service.nostrIdentityValidateHostCustodySecret(secretKey: previousSecret),
    233            previous.id == stagedRecord.id {
    234             return
    235         }
    236         try? await service.nostrIdentityRemove(identityId: stagedRecord.id)
    237     }
    238 
    239     private func normalizedSecret(_ secret: String) throws -> String {
    240         let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
    241         guard !trimmed.isEmpty else {
    242             throw FieldSecureIdentityStoreError.missingSelectedSecret
    243         }
    244         return trimmed
    245     }
    246 
    247     private static var selectedSecretKey: RadrootsSecureStoreKey {
    248         RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName)
    249     }
    250 }
    251 
    252 #if DEBUG
    253 private enum FieldSecureIdentityImportRestoreFailureUITestHook {
    254     private static let phaseKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_RESTORE_FAILURE_PHASE"
    255 
    256     static func throwIfRequested(makeSelected: Bool) throws {
    257         guard FieldUITestHarness.isRequested,
    258               let rawPhase = FieldUITestHarness.string(phaseKey)?.lowercased() else {
    259             return
    260         }
    261         switch rawPhase {
    262         case "any":
    263             throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
    264         case "stage" where !makeSelected:
    265             throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
    266         case "select" where makeSelected:
    267             throw FieldSecureIdentityStoreError.forcedImportRestoreFailure
    268         default:
    269             return
    270         }
    271     }
    272 }
    273 #endif