apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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 }