TeraIdentityRecoveryTests.swift (12258B)
1 import Foundation 2 import RadrootsKit 3 @testable import TeraApp 4 import UIKit 5 import XCTest 6 7 final class TeraIdentityRecoveryTests: XCTestCase { 8 func testMetadataMismatchRetainsLegacyAndDoesNotInstallReplacement() async throws { 9 let fixture = try IdentityRecoveryFixture() 10 defer { fixture.remove() } 11 try fixture.legacy(publicKey: String(repeating: "ab", count: 32)) 12 let store = try fixture.store() 13 do { 14 _ = try await store.recover() 15 XCTFail("Mismatched identity must not migrate") 16 } catch { 17 XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.inconsistent_state")) 18 } 19 let state = await store.snapshot() 20 XCTAssertEqual(state.state, .absent) 21 XCTAssertTrue(try fixture.secure.contains(fixture.legacyKey)) 22 XCTAssertNotNil(fixture.defaults.data(forKey: fixture.metadataKey)) 23 } 24 25 func testInterruptedLegacyCleanupRetainsOriginalIdentityAcrossRestart() async throws { 26 let fixture = try IdentityRecoveryFixture() 27 defer { fixture.remove() } 28 let store = try fixture.store() 29 try fixture.legacy() 30 fixture.secure.failLegacyDeletion = true 31 do { 32 _ = try await store.recover() 33 XCTFail("Interrupted cleanup must require recovery") 34 } catch { 35 XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.recovery_required")) 36 } 37 let installed = await store.snapshot() 38 let original = try XCTUnwrap(installed.publicKeyHex) 39 XCTAssertEqual(installed.state, .unlocked) 40 fixture.secure.failLegacyDeletion = false 41 fixture.metadata(publicKey: original) 42 let restarted = try fixture.store() 43 let pending = try await restarted.loadAndMigrate() 44 XCTAssertEqual(pending.state, .recoveryRequired) 45 XCTAssertEqual(pending.publicKeyHex, original) 46 XCTAssertNil(pending.signerGeneration) 47 let recovered = try await restarted.recover() 48 XCTAssertEqual(recovered.publicKeyHex, original) 49 XCTAssertEqual(recovered.state, .locked) 50 XCTAssertFalse(try fixture.secure.contains(fixture.legacyKey)) 51 XCTAssertNil(fixture.defaults.data(forKey: fixture.metadataKey)) 52 let unlocked = try await restarted.unlock() 53 XCTAssertEqual(unlocked.publicKeyHex, original) 54 } 55 56 func testMetadataOnlyReplayValidatesInstalledKeyBeforeCleanup() async throws { 57 let fixture = try IdentityRecoveryFixture() 58 defer { fixture.remove() } 59 let store = try fixture.store() 60 let installed = try await store.importIdentity(fixture.material()) 61 let original = try XCTUnwrap(installed.publicKeyHex) 62 fixture.metadata(publicKey: String(repeating: "ab", count: 32)) 63 do { 64 _ = try await store.recover() 65 XCTFail("Mismatched metadata cannot be discarded") 66 } catch { 67 XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.inconsistent_state")) 68 } 69 XCTAssertNotNil(fixture.defaults.data(forKey: fixture.metadataKey)) 70 fixture.metadata(publicKey: original) 71 let restarted = try fixture.store() 72 let recovered = try await restarted.recover() 73 XCTAssertEqual(recovered.publicKeyHex, original) 74 XCTAssertNil(fixture.defaults.data(forKey: fixture.metadataKey)) 75 } 76 77 func testPendingLegacyRecoveryRefusesStaleCreateAndImport() async throws { 78 let fixture = try IdentityRecoveryFixture() 79 defer { fixture.remove() } 80 try fixture.legacy() 81 let store = try fixture.store() 82 for create in [true, false] { 83 do { 84 if create { 85 _ = try await store.create() 86 } else { 87 _ = try await store.importIdentity(fixture.material()) 88 } 89 XCTFail("Pending legacy identity must not be replaced") 90 } catch { 91 XCTAssertEqual(error as? TeraIdentityStoreError, .custody("identity.recovery_required")) 92 } 93 } 94 let state = await store.snapshot() 95 XCTAssertEqual(state.state, .absent) 96 XCTAssertTrue(try fixture.secure.contains(fixture.legacyKey)) 97 } 98 99 func testLostOrCorruptInstalledKeyNeverCreatesReplacement() async throws { 100 for missing in [true, false] { 101 let fixture = try IdentityRecoveryFixture() 102 defer { fixture.remove() } 103 let store = try fixture.store() 104 let original = try await store.importIdentity(fixture.material()) 105 await store.lock() 106 let key = RadrootsSecureStoreKey(namespace: "radroots_identity_v1", name: "active_secret_v1") 107 if missing { 108 try fixture.secure.delete(key) 109 } else { 110 try fixture.secure.put(Data(repeating: 0, count: 32), for: key, policy: .secureLocalSecret) 111 } 112 let restarted = try fixture.store() 113 do { 114 _ = try await restarted.unlock() 115 XCTFail("Unavailable original key must not unlock") 116 } catch { 117 XCTAssertTrue(error is RadrootsIdentityCustodyError) 118 } 119 do { 120 _ = try await restarted.create() 121 XCTFail("Existing identity must not be silently replaced") 122 } catch { 123 XCTAssertTrue(error is RadrootsIdentityCustodyError) 124 } 125 let after = await restarted.snapshot() 126 XCTAssertEqual(after.publicKeyHex, original.publicKeyHex) 127 XCTAssertNil(after.signerGeneration) 128 XCTAssertEqual(try fixture.secure.get(key), missing ? nil : Data(repeating: 0, count: 32)) 129 } 130 } 131 132 func testProtectedDataAndDeniedPresenceRetainInstalledKey() async throws { 133 let fixture = try IdentityRecoveryFixture() 134 defer { fixture.remove() } 135 let original = try await fixture.store().importIdentity(fixture.material()) 136 let protected = try fixture.store(protected: true) 137 let state = try await protected.loadAndMigrate() 138 XCTAssertEqual(state.state, .protectedDataUnavailable) 139 XCTAssertEqual(state.publicKeyHex, original.publicKeyHex) 140 let denied = try fixture.store(presence: RefusingIdentityPresence()) 141 do { 142 _ = try await denied.unlock() 143 XCTFail("Presence denial must not unlock") 144 } catch { 145 XCTAssertTrue(error is RadrootsIdentityCustodyError) 146 } 147 let after = await denied.snapshot() 148 XCTAssertEqual(after.state, .locked) 149 XCTAssertEqual(after.publicKeyHex, original.publicKeyHex) 150 } 151 152 @MainActor 153 func testImportValidationAnnouncesOnlySafeGuidanceAndClearsInput() { 154 var announcements: [String] = [] 155 var errorChanges: [String?] = [] 156 var submissions = 0 157 let coordinator = TeraSecureIdentityImportField.Coordinator( 158 announce: { announcements.append($0) }, errorChanged: { errorChanges.append($0) }, 159 submit: { _ in submissions += 1 } 160 ) 161 let field = UITextField() 162 let error = UILabel() 163 coordinator.field = field 164 coordinator.errorLabel = error 165 field.text = "invalid-private-input-canary" 166 coordinator.submitIdentity() 167 XCTAssertEqual(field.text ?? "", "") 168 XCTAssertEqual(announcements, ["Enter a valid Nostr secret key."]) 169 XCTAssertEqual(error.text, announcements.first) 170 XCTAssertEqual(errorChanges, ["Enter a valid Nostr secret key."]) 171 XCTAssertEqual(submissions, 0) 172 173 field.text = String(repeating: "01", count: 32) 174 coordinator.submitIdentity() 175 XCTAssertEqual(field.text ?? "", "") 176 XCTAssertNil(error.text) 177 XCTAssertEqual(errorChanges, ["Enter a valid Nostr secret key.", nil]) 178 XCTAssertEqual(submissions, 1) 179 XCTAssertEqual(announcements.count, 1) 180 } 181 182 @MainActor 183 func testImportTeardownClearsSecretAndRejectsLateSubmit() { 184 var submissions = 0 185 let coordinator = TeraSecureIdentityImportField.Coordinator { _ in submissions += 1 } 186 let field = UITextField() 187 field.text = String(repeating: "01", count: 32) 188 field.delegate = coordinator 189 let error = UILabel() 190 error.text = "Validation failed" 191 coordinator.field = field 192 coordinator.errorLabel = error 193 TeraSecureIdentityImportField.dismantleUIView(UIView(), coordinator: coordinator) 194 coordinator.submitIdentity() 195 XCTAssertEqual(field.text ?? "", "") 196 XCTAssertNil(field.delegate) 197 XCTAssertNil(error.text) 198 XCTAssertNil(coordinator.field) 199 XCTAssertEqual(submissions, 0) 200 } 201 } 202 203 private struct IdentityRecoveryFixture { 204 let secure = LegacyDeletionFaultStore() 205 let metadataStore = InMemoryIdentityMetadataStore() 206 let prefix = "tera.identity.recovery.\(UUID().uuidString.lowercased())" 207 let defaults: UserDefaults 208 let legacyKey = RadrootsSecureStoreKey(namespace: "nostr_identity", name: "selected_secret_hex") 209 var metadataKey: String { 210 "field_ios.identity.public_metadata.\(prefix)" 211 } 212 213 init() throws { 214 defaults = try XCTUnwrap(UserDefaults(suiteName: prefix)) 215 } 216 217 func remove() { 218 defaults.removePersistentDomain(forName: prefix) 219 } 220 221 func material() throws -> RadrootsIdentitySecretMaterial { 222 try RadrootsIdentitySecretMaterial(rawRepresentation: Data(repeating: 1, count: 32)) 223 } 224 225 func legacy(publicKey: String? = nil) throws { 226 try secure.put(Data(String(repeating: "01", count: 32).utf8), for: legacyKey, policy: .secureLocalSecret) 227 if let publicKey { 228 metadata(publicKey: publicKey) 229 } 230 } 231 232 func metadata(publicKey: String) { 233 defaults.set(try? JSONSerialization.data(withJSONObject: [ 234 "selectedIdentityId": "legacy", "publicKeyHex": publicKey, 235 "publicKeyNpub": "legacy", "updatedAtUnix": 1, 236 ]), forKey: metadataKey) 237 } 238 239 func store( 240 presence: any RadrootsUserPresence = AllowingUserPresence(), 241 protected: Bool = false 242 ) throws -> TeraIdentityStore { 243 let custody = try RadrootsIdentityCustody( 244 configuration: RadrootsIdentityCustodyConfiguration(namespace: "radroots_identity_v1", secretPolicy: .secureLocalSecret), 245 secureStore: secure, metadataStore: metadataStore, userPresence: presence, 246 protectedData: RadrootsProtectedDataProvider { protected ? .unavailable : .available } 247 ) 248 let suiteName = prefix 249 let custodyDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) 250 return TeraIdentityStore(custody: custody, secureStore: secure, servicePrefix: suiteName, userDefaults: custodyDefaults) 251 } 252 } 253 254 private final class LegacyDeletionFaultStore: RadrootsSecureStore, @unchecked Sendable { 255 private let backing = InMemorySecureStore() 256 private let lock = NSLock() 257 private var fails = false 258 var failLegacyDeletion: Bool { 259 get { lock.withLock { fails } } 260 set { lock.withLock { fails = newValue } } 261 } 262 263 func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws { 264 try backing.put(value, for: key, policy: policy) 265 } 266 267 func get(_ key: RadrootsSecureStoreKey) throws -> Data? { 268 try backing.get(key) 269 } 270 271 func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { 272 try backing.contains(key) 273 } 274 275 func delete(_ key: RadrootsSecureStoreKey) throws { 276 if key.namespace == "nostr_identity", failLegacyDeletion { 277 throw TeraIdentityStoreError.unavailable 278 } 279 try backing.delete(key) 280 } 281 282 func deleteNamespace(_ namespace: String) throws { 283 try backing.deleteNamespace(namespace) 284 } 285 } 286 287 private struct RefusingIdentityPresence: RadrootsUserPresence { 288 func currentStatus() async throws -> RadrootsUserPresenceStatus { 289 .unavailable 290 } 291 292 func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { 293 RadrootsUserPresenceResult(policy: request.policy, verified: false) 294 } 295 }