RadrootsIdentityCustodyTests.swift (21436B)
1 import Foundation 2 @testable import RadrootsKit 3 import RadrootsKitTesting 4 import Testing 5 6 private let identityTestNow: UInt64 = 1_800_000_000_000 7 private let aliceSecretHex = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5" 8 private let alicePublicKeyHex = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" 9 private let aliceNsec = "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz" 10 private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f1a3fe1a96ff8" 11 12 @Test func identitySecretMaterialParsesHexAndNsecWithoutDebugDisclosure() throws { 13 let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased()) 14 let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec) 15 16 #expect(hex.copyBytes() == nsec.copyBytes()) 17 #expect(hex.copyBytes().count == 32) 18 #expect(!String(reflecting: hex).contains(aliceSecretHex)) 19 #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { 20 _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid") 21 } 22 } 23 24 @Test func identityLifecycleIsOneActiveOpaqueAndSignatureBound() async throws { 25 let fixture = try makeIdentityFixture() 26 #expect(await fixture.custody.snapshot().state == .absent) 27 28 let imported = try await fixture.custody.importIdentity( 29 RadrootsIdentitySecretMaterial(importText: aliceSecretHex), 30 label: " Alice " 31 ) 32 #expect(imported.state == .unlocked) 33 #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) 34 #expect(imported.identity?.label == "Alice") 35 let firstHandle = try #require(imported.signerHandle) 36 37 await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) { 38 try await fixture.custody.importIdentity( 39 RadrootsIdentitySecretMaterial(importText: bobSecretHex) 40 ) 41 } 42 43 let digest = Data(repeating: 0x42, count: 32) 44 let request = try RadrootsOpaqueSignRequest( 45 operationID: UUID().uuidString.lowercased(), 46 signerHandle: firstHandle, 47 publicKeyHex: alicePublicKeyHex, 48 digest: digest, 49 purpose: .nostrEvent, 50 deadlineUnixMilliseconds: identityTestNow + 1000 51 ) 52 let signature = try await fixture.custody.sign(request) 53 #expect(signature.operationID == request.operationID) 54 #expect(signature.signature.count == 64) 55 #expect( 56 RadrootsIdentityCryptography().verify( 57 signature: signature.signature, 58 digest: digest, 59 publicKeyHex: alicePublicKeyHex 60 ) 61 ) 62 #expect(!String(reflecting: request).contains(digest.base64EncodedString())) 63 #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString())) 64 65 await fixture.custody.lockIdentity() 66 #expect(await fixture.custody.snapshot().state == .locked) 67 let unlocked = try await fixture.custody.selectIdentity( 68 identityHandle: #require(imported.identity?.identityHandle) 69 ) 70 #expect(unlocked.state == .unlocked) 71 #expect(unlocked.signerHandle != firstHandle) 72 await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) { 73 try await fixture.custody.selectIdentity( 74 identityHandle: "rrid1_\(String(repeating: "0", count: 64))" 75 ) 76 } 77 await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { 78 try await fixture.custody.sign(request) 79 } 80 } 81 82 @Test func identitySigningRejectsTimeoutCancellationAndWrongBinding() async throws { 83 let fixture = try makeIdentityFixture() 84 let imported = try await fixture.custody.importIdentity( 85 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 86 ) 87 let handle = try #require(imported.signerHandle) 88 let operationID = UUID().uuidString.lowercased() 89 let expired = try RadrootsOpaqueSignRequest( 90 operationID: operationID, 91 signerHandle: handle, 92 publicKeyHex: alicePublicKeyHex, 93 digest: Data(repeating: 1, count: 32), 94 purpose: .blossomUpload, 95 deadlineUnixMilliseconds: identityTestNow - 1 96 ) 97 await #expect(throws: RadrootsIdentityCustodyError.timedOut) { 98 try await fixture.custody.sign(expired) 99 } 100 101 try await fixture.custody.cancelSigning(operationID: operationID) 102 let cancelled = try RadrootsOpaqueSignRequest( 103 operationID: operationID, 104 signerHandle: handle, 105 publicKeyHex: alicePublicKeyHex, 106 digest: Data(repeating: 1, count: 32), 107 purpose: .blossomUpload, 108 deadlineUnixMilliseconds: identityTestNow + 1 109 ) 110 await #expect(throws: RadrootsIdentityCustodyError.cancelled) { 111 try await fixture.custody.sign(cancelled) 112 } 113 114 let wrongKey = String(repeating: "0", count: 64) 115 let wrongBinding = try RadrootsOpaqueSignRequest( 116 operationID: UUID().uuidString.lowercased(), 117 signerHandle: handle, 118 publicKeyHex: wrongKey, 119 digest: Data(repeating: 2, count: 32), 120 purpose: .nostrEvent, 121 deadlineUnixMilliseconds: identityTestNow + 1 122 ) 123 await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { 124 try await fixture.custody.sign(wrongBinding) 125 } 126 } 127 128 @Test func identityReplacementAndDeleteRecoverAfterMetadataFailures() async throws { 129 let fixture = try makeIdentityFixture() 130 fixture.metadata.failNext(.write, slot: .activeIdentity) 131 await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { 132 try await fixture.custody.importIdentity( 133 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 134 ) 135 } 136 #expect(await fixture.custody.snapshot().state == .recoveryRequired) 137 let recovered = try await fixture.custody.recover() 138 #expect(recovered.state == .locked) 139 #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex) 140 141 fixture.metadata.failNext(.delete, slot: .activeIdentity) 142 await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { 143 try await fixture.custody.deleteIdentity() 144 } 145 #expect(await fixture.custody.snapshot().state == .recoveryRequired) 146 #expect(try await fixture.custody.recover().state == .absent) 147 #expect(fixture.secureStore.keys().isEmpty) 148 } 149 150 @Test func corruptMetadataIsDistinctFromAbsenceAndCanBeRepaired() async throws { 151 let fixture = try makeIdentityFixture() 152 _ = try await fixture.custody.importIdentity( 153 RadrootsIdentitySecretMaterial(importText: aliceSecretHex), 154 label: "Before" 155 ) 156 await fixture.custody.lockIdentity() 157 fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity) 158 159 let corrupt = await fixture.custody.snapshot() 160 #expect(corrupt.state == .corrupt) 161 #expect(corrupt.recoveryCode == "identity.corrupt_metadata") 162 #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8)) 163 164 let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered") 165 #expect(repaired.state == .unlocked) 166 #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex) 167 #expect(repaired.identity?.label == "Recovered") 168 #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil) 169 } 170 171 @Test func corruptTransactionJournalRequiresRecoveryWithoutClaimingAbsence() async throws { 172 let fixture = try makeIdentityFixture() 173 _ = try await fixture.custody.importIdentity( 174 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 175 ) 176 await fixture.custody.lockIdentity() 177 fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal) 178 179 let snapshot = await fixture.custody.snapshot() 180 #expect(snapshot.state == .corrupt) 181 #expect(snapshot.identity == nil) 182 #expect(snapshot.recoveryCode == "identity.corrupt_metadata") 183 await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) { 184 try await fixture.custody.recover() 185 } 186 } 187 188 @Test func identityCustodyRoundTripsAgainstAppleStorageAdapters() async throws { 189 let suffix = UUID().uuidString.lowercased() 190 let namespace = "native-storage-\(suffix)" 191 let servicePrefix = "org.radroots.tests.identity.\(suffix)" 192 let suiteName = "org.radroots.tests.identity.metadata.\(suffix)" 193 let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) 194 let defaults = try #require(UserDefaults(suiteName: suiteName)) 195 defer { 196 try? keychain.deleteNamespace(namespace) 197 defaults.removePersistentDomain(forName: suiteName) 198 } 199 let custody = try RadrootsIdentityCustody( 200 configuration: RadrootsIdentityCustodyConfiguration( 201 namespace: namespace, 202 secretPolicy: .secureLocalSecret 203 ), 204 secureStore: keychain, 205 metadataStore: RadrootsAppleIdentityMetadataStore( 206 namespace: namespace, 207 userDefaults: defaults 208 ), 209 userPresence: RadrootsFakeUserPresence(), 210 now: { identityTestNow } 211 ) 212 213 let imported = try await custody.importIdentity( 214 RadrootsIdentitySecretMaterial(importText: aliceSecretHex), 215 label: "Native" 216 ) 217 #expect(imported.state == .unlocked) 218 #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) 219 220 await custody.lockIdentity() 221 #expect(await custody.snapshot().state == .locked) 222 #expect(try await custody.unlockIdentity().state == .unlocked) 223 #expect(try await custody.deleteIdentity().state == .absent) 224 #expect( 225 try keychain.contains( 226 RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") 227 ) == false 228 ) 229 } 230 231 @Test func protectedDataAndUserPresenceFailuresDoNotClaimIdentitySuccess() async throws { 232 let secureStore = RadrootsInMemorySecureStore() 233 let metadata = RadrootsInMemoryIdentityMetadataStore() 234 let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false)) 235 let custody = try RadrootsIdentityCustody( 236 configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"), 237 secureStore: secureStore, 238 metadataStore: metadata, 239 userPresence: presence, 240 protectedData: RadrootsProtectedDataProvider { .available }, 241 now: { identityTestNow } 242 ) 243 await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) { 244 try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex)) 245 } 246 #expect(await custody.snapshot().state == .absent) 247 248 let unavailable = try RadrootsIdentityCustody( 249 configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"), 250 secureStore: RadrootsInMemorySecureStore(), 251 metadataStore: RadrootsInMemoryIdentityMetadataStore(), 252 userPresence: RadrootsFakeUserPresence(), 253 protectedData: RadrootsProtectedDataProvider { .locked }, 254 now: { identityTestNow } 255 ) 256 await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) { 257 try await unavailable.createIdentity() 258 } 259 #expect(await unavailable.snapshot().state == .absent) 260 } 261 262 @Test func encryptedPortabilityRoundTripsAcrossIndependentHostsAndRejectsTampering() async throws { 263 let source = try makeIdentityFixture(namespace: "portability-source") 264 _ = try await source.custody.importIdentity( 265 RadrootsIdentitySecretMaterial(importText: aliceNsec), 266 label: "Portable" 267 ) 268 let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple") 269 let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) 270 let serialized = envelope.serializedRepresentation 271 let rendered = String(decoding: serialized, as: UTF8.self) 272 #expect(envelope.version == 1) 273 #expect(!rendered.contains(aliceSecretHex)) 274 #expect(!rendered.contains(aliceNsec)) 275 #expect(!String(reflecting: passphrase).contains("correct horse")) 276 277 let destination = try makeIdentityFixture(namespace: "portability-destination") 278 let imported = try await destination.custody.importPortableIdentity( 279 envelope, 280 passphrase: passphrase 281 ) 282 #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) 283 #expect(imported.identity?.label == "Portable") 284 285 let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong") 286 await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) { 287 try await wrongDestination.custody.importPortableIdentity( 288 envelope, 289 passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long") 290 ) 291 } 292 293 let unsupported = serialized.replacingOccurrences( 294 of: Data("\"version\":1".utf8), 295 with: Data("\"version\":2".utf8) 296 ) 297 #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) { 298 _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported) 299 } 300 } 301 302 @Test func legacyIdentityMigrationIsIdempotentAndDeletesOnlyAfterCommit() async throws { 303 let fixture = try makeIdentityFixture(namespace: "legacy-test") 304 let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") 305 try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) 306 307 let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated") 308 #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex) 309 #expect(try fixture.secureStore.get(legacyKey) == nil) 310 311 try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) 312 let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey) 313 #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex) 314 #expect(try fixture.secureStore.get(legacyKey) == nil) 315 } 316 317 @Test(arguments: [Data(repeating: 0, count: 32), Data([1]), Data(repeating: 1, count: 32)]) 318 func legacyMigrationRetainsGoodLegacyWhenActiveSecretIsInvalid(active: Data) async throws { 319 let namespace = UUID().uuidString.lowercased() 320 let fixture = try makeIdentityFixture(namespace: namespace) 321 let original = try await fixture.custody.importIdentity( 322 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 323 ) 324 let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") 325 let legacy = Data(aliceSecretHex.utf8) 326 try fixture.secureStore.put(legacy, for: legacyKey) 327 try fixture.secureStore.put( 328 active, for: RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") 329 ) 330 await #expect(throws: (any Error).self) { 331 try await fixture.custody.migrateLegacyIdentity(from: legacyKey) 332 } 333 #expect(try fixture.secureStore.get(legacyKey) == legacy) 334 #expect(await fixture.custody.snapshot().identity == original.identity) 335 #expect(try fixture.secureStore.get( 336 RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") 337 ) == active) 338 } 339 340 @Test func legacyMigrationReadDenialPreservesLegacyAndInstalledIdentity() async throws { 341 let namespace = UUID().uuidString.lowercased() 342 let store = UnreadableActiveIdentityStore() 343 let custody = try RadrootsIdentityCustody( 344 configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), 345 secureStore: store, metadataStore: RadrootsInMemoryIdentityMetadataStore(), 346 userPresence: RadrootsFakeUserPresence(), now: { identityTestNow } 347 ) 348 let original = try await custody.importIdentity( 349 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 350 ) 351 let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") 352 try store.put(Data(aliceSecretHex.utf8), for: key) 353 await #expect(throws: RadrootsIdentityCustodyError.storageUnavailable) { 354 try await custody.migrateLegacyIdentity(from: key) 355 } 356 #expect(try store.get(key) == Data(aliceSecretHex.utf8)) 357 #expect(await custody.snapshot().identity == original.identity) 358 } 359 360 private final class UnreadableActiveIdentityStore: RadrootsSecureStore, Sendable { 361 private let backing = RadrootsInMemorySecureStore() 362 363 func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws { 364 try backing.put(value, for: key, policy: policy) 365 } 366 367 func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { try backing.contains(key) } 368 func delete(_ key: RadrootsSecureStoreKey) throws { try backing.delete(key) } 369 func deleteNamespace(_ namespace: String) throws { try backing.deleteNamespace(namespace) } 370 371 func get(_ key: RadrootsSecureStoreKey) throws -> Data? { 372 guard key.name != "active_secret_v1" else { 373 throw RadrootsAppleSecurityError.permissionDenied 374 } 375 return try backing.get(key) 376 } 377 } 378 379 @Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws { 380 let secureStore = RadrootsInMemorySecureStore() 381 let metadata = RadrootsInMemoryIdentityMetadataStore() 382 let presence = ControllableIdentityPresence() 383 let custody = try RadrootsIdentityCustody( 384 configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"), 385 secureStore: secureStore, 386 metadataStore: metadata, 387 userPresence: presence, 388 now: { identityTestNow } 389 ) 390 let imported = try await custody.importIdentity( 391 RadrootsIdentitySecretMaterial(importText: aliceSecretHex) 392 ) 393 let signerHandle = try #require(imported.signerHandle) 394 await presence.suspendNextVerification() 395 let request = try RadrootsOpaqueSignRequest( 396 operationID: UUID().uuidString.lowercased(), 397 signerHandle: signerHandle, 398 publicKeyHex: alicePublicKeyHex, 399 digest: Data(repeating: 9, count: 32), 400 purpose: .nostrEvent, 401 deadlineUnixMilliseconds: identityTestNow + 1 402 ) 403 let result = LockedIdentityResult() 404 let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) { 405 result.record($0) 406 } 407 while await presence.pendingVerificationCount == 0 { 408 await Task.yield() 409 } 410 cancellation.cancel() 411 await presence.resumePending(verified: true) 412 while result.count == 0 { 413 await Task.yield() 414 } 415 #expect(result.count == 1) 416 guard case .failure(.cancelled) = result.value else { 417 Issue.record("expected one cancelled completion") 418 return 419 } 420 } 421 422 private struct IdentityFixture { 423 let custody: RadrootsIdentityCustody 424 let secureStore: RadrootsInMemorySecureStore 425 let metadata: RadrootsInMemoryIdentityMetadataStore 426 } 427 428 private func makeIdentityFixture(namespace: String = UUID().uuidString.lowercased()) throws 429 -> IdentityFixture 430 { 431 let secureStore = RadrootsInMemorySecureStore() 432 let metadata = RadrootsInMemoryIdentityMetadataStore() 433 let custody = try RadrootsIdentityCustody( 434 configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), 435 secureStore: secureStore, 436 metadataStore: metadata, 437 userPresence: RadrootsFakeUserPresence(), 438 now: { identityTestNow } 439 ) 440 return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata) 441 } 442 443 private actor ControllableIdentityPresence: RadrootsUserPresence { 444 private var suspendNext = false 445 private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = [] 446 447 func currentStatus() async throws -> RadrootsUserPresenceStatus { 448 RadrootsUserPresenceStatus( 449 support: .biometricsOrDeviceCredential, 450 biometryKind: .faceID, 451 canEvaluateDeviceCredential: true, 452 canEvaluateBiometrics: true 453 ) 454 } 455 456 func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { 457 guard suspendNext else { 458 return RadrootsUserPresenceResult(policy: request.policy, verified: true) 459 } 460 suspendNext = false 461 return await withCheckedContinuation { continuation in 462 pending.append(continuation) 463 } 464 } 465 466 func suspendNextVerification() { 467 suspendNext = true 468 } 469 470 var pendingVerificationCount: Int { 471 pending.count 472 } 473 474 func resumePending(verified: Bool) { 475 let continuations = pending 476 pending.removeAll() 477 for continuation in continuations { 478 continuation.resume( 479 returning: RadrootsUserPresenceResult( 480 policy: .deviceOwnerAuthentication, 481 verified: verified 482 ) 483 ) 484 } 485 } 486 } 487 488 private final class LockedIdentityResult: @unchecked Sendable { 489 private let lock = NSLock() 490 private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = [] 491 492 var count: Int { 493 lock.lock() 494 defer { lock.unlock() } 495 return results.count 496 } 497 498 var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? { 499 lock.lock() 500 defer { lock.unlock() } 501 return results.first 502 } 503 504 func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { 505 lock.lock() 506 results.append(value) 507 lock.unlock() 508 } 509 } 510 511 private extension Data { 512 func replacingOccurrences(of needle: Data, with replacement: Data) -> Data { 513 guard let range = range(of: needle) else { 514 return self 515 } 516 var copy = self 517 copy.replaceSubrange(range, with: replacement) 518 return copy 519 } 520 }