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:
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