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 }