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

commit 1981db67ea91dab278bf0c2a9781cc53b9d1e47e
parent 342a9c08d4eff1bedc5f25900bb166aa9eb531e2
Author: triesap <tyson@radroots.org>
Date:   Thu, 24 Sep 2026 16:31:43 +0000

identity: preserve cancellation and legacy custody

- Serialize authentication launch and context invalidation
- Retain cancellation before continuation installation
- Validate active keys before deleting legacy custody
- Verify 301 package tests and unchanged public API

Diffstat:
MREADME | 8++++++++
MSources/RadrootsKit/RadrootsAppleUserPresence.swift | 92++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
MSources/RadrootsKit/RadrootsIdentityCustody.swift | 12++++++++++++
MTests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsUserPresenceCancellationTests.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 267 insertions(+), 38 deletions(-)

diff --git a/README b/README @@ -25,6 +25,14 @@ original identity and reconcile before retrying. The transfer receipt envelope has a separate bounded-capacity error. Neither error authorizes deletion of unresolved receipts, staged media or leases, or automatic network retry. +## Identity custody + +User-presence cancellation and timeout invalidate the active authentication +context. Cancellation before evaluation prevents its launch. Legacy identity +migration removes the legacy secret only after reading and validating the +retained active key against the same public identity. An unavailable or invalid +active key preserves legacy custody for explicit recovery. + ## Copyright Except as otherwise noted, all files in the `apple_kit` distribution are diff --git a/Sources/RadrootsKit/RadrootsAppleUserPresence.swift b/Sources/RadrootsKit/RadrootsAppleUserPresence.swift @@ -163,7 +163,8 @@ extension RadrootsAppleUserPresenceAdapters { ) async throws -> RadrootsUserPresenceResult { try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback( timeout: callbackTimeout, - timeoutMessage: "timed out while completing user presence verification" + timeoutMessage: "timed out while completing user presence verification", + invalidate: { context.invalidate() } ) { completion in context.evaluatePolicy( platformPolicy(request.policy), @@ -186,19 +187,16 @@ enum RadrootsAppleUserPresenceAsyncSupport { static func awaitCallback<Value: Sendable>( timeout: TimeInterval, timeoutMessage: String, - _ body: (@escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void) -> Void + invalidate: @escaping @Sendable () -> Void = {}, + _ body: @escaping @Sendable ( + @escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void + ) -> Void ) async throws -> Value { - let state = RadrootsAppleUserPresenceAsyncCallbackState<Value>() + let nanoseconds = try timeoutNanoseconds(timeout) + let state = RadrootsAppleUserPresenceAsyncCallbackState<Value>(invalidate: invalidate) return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in - state.install(continuation) - body { result in - state.resume(result) - } - Task { - try? await Task.sleep(nanoseconds: try Self.timeoutNanoseconds(timeout)) - state.resume(.failure(.timeout)) - } + state.install(continuation, timeoutNanoseconds: nanoseconds, body: body) } } onCancel: { state.resume(.failure(.userCancelled)) @@ -210,47 +208,65 @@ enum RadrootsAppleUserPresenceAsyncSupport { throw RadrootsUserPresenceError.invalidRequest } let nanoseconds = timeout * 1_000_000_000 - guard nanoseconds <= Double(UInt64.max) else { + guard nanoseconds >= 1, nanoseconds < Double(UInt64.max) else { throw RadrootsUserPresenceError.invalidRequest } return UInt64(nanoseconds) } } -private final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable>: +final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable>: @unchecked Sendable { - private let lock = NSLock() + // All mutable state and context start/invalidation run on this serial queue. + // Cancellation queued before installation cannot launch evaluatePolicy; + // cancellation after evaluation starts invalidates that same context. + // Foreign callbacks only enqueue resolution, including synchronous callbacks. + private let queue = DispatchQueue(label: "org.radroots.user-presence") + private let invalidate: @Sendable () -> Void private var continuation: CheckedContinuation<Value, any Error>? - private var didResolve = false + private var result: Result<Value, RadrootsUserPresenceError>? + private var timer: Task<Void, Never>? - func install(_ continuation: CheckedContinuation<Value, any Error>) { - lock.lock() - defer { lock.unlock() } - guard !didResolve else { - continuation.resume(throwing: RadrootsUserPresenceError.transientFailure) - return - } - self.continuation = continuation + init(invalidate: @escaping @Sendable () -> Void) { + self.invalidate = invalidate } - func resume(_ result: Result<Value, RadrootsUserPresenceError>) { - let pending: CheckedContinuation<Value, any Error>? - lock.lock() - if didResolve { - lock.unlock() - return + func install( + _ continuation: CheckedContinuation<Value, any Error>, + timeoutNanoseconds: UInt64, + body: @escaping @Sendable ( + @escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void + ) -> Void + ) { + queue.async { + if let result = self.result { + continuation.resume(with: result.mapError { $0 as any Error }) + return + } + self.continuation = continuation + self.timer = Task { + do { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + self.resume(.failure(.timeout)) + } catch { + // Completed evaluations cancel their timer; no second result. + } + } + body { [weak self] in self?.resume($0) } } - didResolve = true - pending = continuation - continuation = nil - lock.unlock() + } - switch result { - case .success(let value): - pending?.resume(returning: value) - case .failure(let error): - pending?.resume(throwing: error) + func resume(_ result: Result<Value, RadrootsUserPresenceError>) { + queue.async { + guard self.result == nil else { return } + self.result = result + self.timer?.cancel() + self.timer = nil + self.invalidate() + let pending = self.continuation + self.continuation = nil + pending?.resume(with: result.mapError { $0 as any Error }) } } } diff --git a/Sources/RadrootsKit/RadrootsIdentityCustody.swift b/Sources/RadrootsKit/RadrootsIdentityCustody.swift @@ -273,6 +273,8 @@ public actor RadrootsIdentityCustody { from legacyKey: RadrootsSecureStoreKey, label: String? = nil ) async throws -> RadrootsIdentitySnapshot { + _ = try recover() + try requireProtectedData() if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) { guard let legacy = try secureStore.get(legacyKey) else { return snapshot() @@ -283,6 +285,7 @@ public actor RadrootsIdentityCustody { else { throw RadrootsIdentityCustodyError.inconsistentState } + try validateActiveSecret(for: existing) try secureStore.delete(legacyKey) return snapshot() } @@ -293,6 +296,7 @@ public actor RadrootsIdentityCustody { } let material = try RadrootsIdentitySecretMaterial(importText: text) let result = try await importIdentity(material, label: label) + try validateActiveSecret(for: requiredRecord()) do { try secureStore.delete(legacyKey) } catch { @@ -301,6 +305,14 @@ public actor RadrootsIdentityCustody { return result } + private func validateActiveSecret(for record: RadrootsIdentityPublicRecord) throws { + var active = try readSecret(.active) + defer { active.resetBytes(in: active.startIndex ..< active.endIndex) } + guard try cryptography.publicKeyHex(for: active) == record.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + } + @discardableResult public func unlockIdentity() async throws -> RadrootsIdentitySnapshot { _ = try recover() diff --git a/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift b/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift @@ -314,6 +314,68 @@ private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f #expect(try fixture.secureStore.get(legacyKey) == nil) } +@Test(arguments: [Data(repeating: 0, count: 32), Data([1]), Data(repeating: 1, count: 32)]) +func legacyMigrationRetainsGoodLegacyWhenActiveSecretIsInvalid(active: Data) async throws { + let namespace = UUID().uuidString.lowercased() + let fixture = try makeIdentityFixture(namespace: namespace) + let original = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + let legacy = Data(aliceSecretHex.utf8) + try fixture.secureStore.put(legacy, for: legacyKey) + try fixture.secureStore.put( + active, for: RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") + ) + await #expect(throws: (any Error).self) { + try await fixture.custody.migrateLegacyIdentity(from: legacyKey) + } + #expect(try fixture.secureStore.get(legacyKey) == legacy) + #expect(await fixture.custody.snapshot().identity == original.identity) + #expect(try fixture.secureStore.get( + RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") + ) == active) +} + +@Test func legacyMigrationReadDenialPreservesLegacyAndInstalledIdentity() async throws { + let namespace = UUID().uuidString.lowercased() + let store = UnreadableActiveIdentityStore() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), + secureStore: store, metadataStore: RadrootsInMemoryIdentityMetadataStore(), + userPresence: RadrootsFakeUserPresence(), now: { identityTestNow } + ) + let original = try await custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + try store.put(Data(aliceSecretHex.utf8), for: key) + await #expect(throws: RadrootsIdentityCustodyError.storageUnavailable) { + try await custody.migrateLegacyIdentity(from: key) + } + #expect(try store.get(key) == Data(aliceSecretHex.utf8)) + #expect(await custody.snapshot().identity == original.identity) +} + +private final class UnreadableActiveIdentityStore: RadrootsSecureStore, Sendable { + private let backing = RadrootsInMemorySecureStore() + + func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws { + try backing.put(value, for: key, policy: policy) + } + + func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { try backing.contains(key) } + func delete(_ key: RadrootsSecureStoreKey) throws { try backing.delete(key) } + func deleteNamespace(_ namespace: String) throws { try backing.deleteNamespace(namespace) } + + func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + guard key.name != "active_secret_v1" else { + throw RadrootsAppleSecurityError.permissionDenied + } + return try backing.get(key) + } +} + @Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws { let secureStore = RadrootsInMemorySecureStore() let metadata = RadrootsInMemoryIdentityMetadataStore() diff --git a/Tests/RadrootsKitTests/RadrootsUserPresenceCancellationTests.swift b/Tests/RadrootsKitTests/RadrootsUserPresenceCancellationTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing + +@testable import RadrootsKit + +@Test func presenceCancellationBeforeContinuationInstallationRetainsCancellation() async { + let state = RadrootsAppleUserPresenceAsyncCallbackState<Bool>(invalidate: {}) + state.resume(.failure(.userCancelled)) + await #expect(throws: RadrootsUserPresenceError.userCancelled) { + try await withCheckedThrowingContinuation { continuation in + state.install(continuation, timeoutNanoseconds: 1_000_000_000) { _ in + Issue.record("Cancelled presence must not start authentication") + } + } + } +} + +@Test(arguments: [Double.nan, Double.infinity, 0, -1, Double(UInt64.max) / 1e9]) +func presenceRejectsInvalidTimeoutBeforeStarting(timeout: Double) async { + await #expect(throws: RadrootsUserPresenceError.invalidRequest) { + let _: Bool = try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback( + timeout: timeout, timeoutMessage: "unused" + ) { _ in + Issue.record("Invalid timeout must not start authentication") + } + } +} + +#if canImport(LocalAuthentication) + import LocalAuthentication + + @Test func presenceAlreadyCancelledTaskInvalidatesContextWithoutEvaluation() async throws { + let context = ControlledPresenceContext() + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await RadrootsAppleUserPresenceAdapters.verify( + RadrootsUserPresenceRequest(reason: "Test cancellation"), + context: context, callbackTimeout: 1 + ) + } + await #expect(throws: RadrootsUserPresenceError.userCancelled) { try await task.value } + #expect(context.counts == [0, 1]) + } + + @Test func presenceCancellationDuringEvaluationInvalidatesAndIgnoresLateSuccess() async throws { + let context = ControlledPresenceContext() + let task = Task { + try await RadrootsAppleUserPresenceAdapters.verify( + RadrootsUserPresenceRequest(reason: "Test cancellation"), + context: context, callbackTimeout: 1 + ) + } + for await _ in context.started { break } + task.cancel() + await #expect(throws: RadrootsUserPresenceError.userCancelled) { try await task.value } + context.complete(success: true) + context.complete(success: false) + #expect(context.counts == [1, 1]) + } + + @Test func presenceTimeoutInvalidatesActualContext() async throws { + let context = ControlledPresenceContext() + await #expect(throws: RadrootsUserPresenceError.timeout) { + try await RadrootsAppleUserPresenceAdapters.verify( + RadrootsUserPresenceRequest(reason: "Test timeout"), + context: context, callbackTimeout: 0.001 + ) + } + #expect(context.counts == [1, 1]) + } + + @Test func presenceSuccessAndDuplicateCallbackResolveOnce() async throws { + let context = ControlledPresenceContext() + let task = Task { + try await RadrootsAppleUserPresenceAdapters.verify( + RadrootsUserPresenceRequest(reason: "Test success"), + context: context, callbackTimeout: 1 + ) + } + for await _ in context.started { break } + context.complete(success: true) + context.complete(success: false) + #expect(try await task.value.verified) + #expect(context.counts == [1, 1]) + } + + // NSLock protects test observations and the callback across the operation queue. + private final class ControlledPresenceContext: LAContext, @unchecked Sendable { + private let lock = NSLock() + private var evaluations = 0 + private var invalidations = 0 + private var reply: (@Sendable (Bool, (any Error)?) -> Void)? + let started: AsyncStream<Void> + private let signal: AsyncStream<Void>.Continuation + + override init() { + (started, signal) = AsyncStream.makeStream() + super.init() + } + + override func evaluatePolicy( + _ policy: LAPolicy, localizedReason: String, + reply: @escaping @Sendable (Bool, (any Error)?) -> Void + ) { + lock.lock() + evaluations += 1 + self.reply = reply + lock.unlock() + signal.yield(()) + } + + override func invalidate() { + lock.lock() + invalidations += 1 + lock.unlock() + } + + func complete(success: Bool) { + lock.lock() + let callback = reply + lock.unlock() + callback?(success, nil) + } + + var counts: [Int] { + lock.lock() + defer { lock.unlock() } + return [evaluations, invalidations] + } + } +#endif