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 }