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