field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }