RadrootsCustodyAdmissionTests.swift (9205B)
1 import Foundation 2 import RadrootsKitTesting 3 import Testing 4 5 @testable import RadrootsKit 6 7 @Test func guardedLegacyMigrationRejectsMetadataMismatchBeforeImportOrDeletion() async throws { 8 let fixture = try AdmissionFixture() 9 let legacy = Data(String(repeating: "01", count: 32).utf8) 10 try fixture.secure.put(legacy, for: fixture.legacyKey) 11 await #expect(throws: RadrootsIdentityCustodyError.inconsistentState) { 12 try await fixture.custody.migrateLegacyIdentity( 13 from: fixture.legacyKey, expectedPublicKeyHex: String(repeating: "ab", count: 32) 14 ) 15 } 16 #expect(await fixture.custody.snapshot().state == .absent) 17 #expect(try fixture.secure.get(fixture.legacyKey) == legacy) 18 } 19 20 @Test func guardedLegacyReplayPreservesInstalledIdentityAndValidatesAbsentLegacy() async throws { 21 let fixture = try AdmissionFixture() 22 let installed = try await fixture.importIdentity() 23 let record = try #require(installed.identity) 24 let legacy = Data(String(repeating: "01", count: 32).utf8) 25 try fixture.secure.put(legacy, for: fixture.legacyKey) 26 await #expect(throws: RadrootsIdentityCustodyError.inconsistentState) { 27 try await fixture.custody.migrateLegacyIdentity( 28 from: fixture.legacyKey, expectedPublicKeyHex: String(repeating: "ab", count: 32) 29 ) 30 } 31 #expect(try fixture.secure.get(fixture.legacyKey) == legacy) 32 let replayed = try await fixture.custody.migrateLegacyIdentity( 33 from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex 34 ) 35 #expect(replayed.identity == record) 36 #expect(try fixture.secure.get(fixture.legacyKey) == nil) 37 #expect(try await fixture.custody.migrateLegacyIdentity( 38 from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex 39 ).identity == record) 40 try fixture.secure.put(Data(repeating: 0, count: 32), for: fixture.activeKey) 41 await #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { 42 try await fixture.custody.migrateLegacyIdentity( 43 from: fixture.legacyKey, expectedPublicKeyHex: record.publicKeyHex 44 ) 45 } 46 #expect(await fixture.custody.snapshot().identity == record) 47 } 48 49 @Test(arguments: ["", "ab", String(repeating: "AB", count: 32), String(repeating: "g", count: 64)]) 50 func guardedLegacyMigrationRejectsNoncanonicalExpectedIdentity(expected: String) async throws { 51 let fixture = try AdmissionFixture() 52 await #expect(throws: RadrootsIdentityCustodyError.invalidMetadata) { 53 try await fixture.custody.migrateLegacyIdentity( 54 from: fixture.legacyKey, expectedPublicKeyHex: expected 55 ) 56 } 57 #expect(await fixture.custody.snapshot().state == .absent) 58 #expect(fixture.secure.keys().isEmpty) 59 } 60 61 @Test func alreadyCancelledCustodyRequestNeverStartsPresenceOrCreatesKey() async throws { 62 let fixture = try AdmissionFixture() 63 let task = Task { 64 withUnsafeCurrentTask { $0?.cancel() } 65 return try await fixture.custody.createIdentity() 66 } 67 await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } 68 #expect(await fixture.presence.requests == 0) 69 #expect(fixture.secure.keys().isEmpty) 70 #expect(await fixture.custody.snapshot().state == .absent) 71 } 72 73 @Test(arguments: ["create", "import", "replace", "unlock", "export", "delete"]) 74 func cancelledPresenceSuccessCannotAuthorizeCustodyEffects(operation: String) async throws { 75 let fixture = try AdmissionFixture() 76 if !["create", "import"].contains(operation) { _ = try await fixture.importIdentity() } 77 if operation == "unlock" { await fixture.custody.lockIdentity() } 78 let before = await fixture.custody.snapshot() 79 let original = try fixture.secure.get(fixture.activeKey) 80 await fixture.presence.arm() 81 let task = Task { 82 switch operation { 83 case "create": _ = try await fixture.custody.createIdentity() 84 case "import", "replace": 85 _ = try await fixture.custody.importIdentity( 86 RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 2, count: 32)), 87 replaceExisting: operation == "replace" 88 ) 89 case "unlock": _ = try await fixture.custody.unlockIdentity() 90 case "export": 91 _ = try await fixture.custody.exportPortableIdentity( 92 passphrase: RadrootsIdentityPassphrase("synthetic custody test passphrase") 93 ) 94 case "delete": _ = try await fixture.custody.deleteIdentity() 95 default: Issue.record("Unknown test operation") 96 } 97 } 98 for await _ in fixture.presence.entered { break } 99 task.cancel() 100 await fixture.presence.release() 101 await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } 102 let after = await fixture.custody.snapshot() 103 #expect(after.identity == before.identity) 104 #expect(after.state == before.state) 105 #expect(try fixture.secure.get(fixture.activeKey) == original) 106 #expect(fixture.secure.keys().allSatisfy { $0 == fixture.activeKey }) 107 } 108 109 @Test func guardedLegacyImportKeepsExpectedIdentityAcrossFreshCustodyInstance() async throws { 110 let reference = try AdmissionFixture() 111 let expected = try #require(try await reference.importIdentity().identity?.publicKeyHex) 112 let secure = RadrootsInMemorySecureStore() 113 let metadata = RadrootsInMemoryIdentityMetadataStore() 114 let configuration = try RadrootsIdentityCustodyConfiguration(namespace: UUID().uuidString.lowercased()) 115 let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") 116 try secure.put(Data(String(repeating: "01", count: 32).utf8), for: key) 117 let first = RadrootsIdentityCustody( 118 configuration: configuration, secureStore: secure, metadataStore: metadata, 119 userPresence: CustodyPresenceGate() 120 ) 121 let migrated = try await first.migrateLegacyIdentity(from: key, expectedPublicKeyHex: expected) 122 #expect(migrated.identity?.publicKeyHex == expected) 123 #expect(try secure.get(key) == nil) 124 let restarted = RadrootsIdentityCustody( 125 configuration: configuration, secureStore: secure, metadataStore: metadata, 126 userPresence: CustodyPresenceGate() 127 ) 128 let replayed = try await restarted.migrateLegacyIdentity(from: key, expectedPublicKeyHex: expected) 129 #expect(replayed.identity == migrated.identity) 130 #expect(replayed.state == .locked) 131 } 132 133 @Test func cancelledPortableImportDoesNotInstallOpenedIdentity() async throws { 134 let source = try AdmissionFixture() 135 _ = try await source.importIdentity() 136 let passphrase = try RadrootsIdentityPassphrase("synthetic portable test passphrase") 137 let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) 138 let destination = try AdmissionFixture() 139 await destination.presence.arm() 140 let task = Task { 141 try await destination.custody.importPortableIdentity(envelope, passphrase: passphrase) 142 } 143 for await _ in destination.presence.entered { break } 144 task.cancel() 145 await destination.presence.release() 146 await #expect(throws: RadrootsIdentityCustodyError.cancelled) { try await task.value } 147 #expect(await destination.custody.snapshot().state == .absent) 148 #expect(destination.secure.keys().isEmpty) 149 } 150 151 private struct AdmissionFixture: Sendable { 152 let secure = RadrootsInMemorySecureStore() 153 let presence = CustodyPresenceGate() 154 let custody: RadrootsIdentityCustody 155 let activeKey: RadrootsSecureStoreKey 156 let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") 157 158 init() throws { 159 let namespace = UUID().uuidString.lowercased() 160 activeKey = RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") 161 custody = try RadrootsIdentityCustody( 162 configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), 163 secureStore: secure, metadataStore: RadrootsInMemoryIdentityMetadataStore(), 164 userPresence: presence 165 ) 166 } 167 168 func importIdentity() async throws -> RadrootsIdentitySnapshot { 169 try await custody.importIdentity( 170 RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 1, count: 32)) 171 ) 172 } 173 } 174 175 private actor CustodyPresenceGate: RadrootsUserPresence { 176 nonisolated let entered: AsyncStream<Void> 177 private let signal: AsyncStream<Void>.Continuation 178 private var pending: CheckedContinuation<Void, Never>? 179 private var blocks = false 180 private(set) var requests = 0 181 182 init() { (entered, signal) = AsyncStream.makeStream() } 183 func currentStatus() async throws -> RadrootsUserPresenceStatus { .unavailable } 184 func arm() { blocks = true } 185 func release() { 186 pending?.resume() 187 pending = nil 188 } 189 190 func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { 191 requests += 1 192 if blocks { 193 blocks = false 194 await withCheckedContinuation { continuation in 195 pending = continuation 196 signal.yield(()) 197 } 198 } 199 return RadrootsUserPresenceResult(policy: request.policy, verified: true) 200 } 201 }