commit 32d2f84aa73a2003307627d23972de177c2187a0
parent c9fecf9079b7c0d62413fe6b9526643eb0a7701c
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 13:19:57 -0700
user-presence: add Apple implementation
- make RadrootsAppleUserPresence implement the shared user-presence protocol
- isolate LocalAuthentication policy and error mapping inside AppleKit
- add adapter-driven tests for status, verification, and timeout behavior
- update secure-store coverage to use the protocol-backed status API
Diffstat:
3 files changed, 264 insertions(+), 72 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleUserPresence.swift b/Sources/RadrootsKit/RadrootsAppleUserPresence.swift
@@ -1,85 +1,68 @@
import Foundation
#if canImport(LocalAuthentication)
-import LocalAuthentication
+@preconcurrency import LocalAuthentication
#endif
-public enum RadrootsAppleUserPresencePolicy: Sendable, Equatable {
- case deviceOwnerAuthentication
- case deviceOwnerAuthenticationWithBiometrics
-}
-
-public enum RadrootsAppleUserPresenceSupport: Sendable, Equatable {
- case none
- case deviceCredential
- case biometricsOrDeviceCredential
-}
-
-public enum RadrootsAppleBiometryKind: Sendable, Equatable {
- case none
- case touchID
- case faceID
- case opticID
- case unknown
-}
-
-public struct RadrootsAppleUserPresenceStatus: Sendable, Equatable {
- public let support: RadrootsAppleUserPresenceSupport
- public let biometryKind: RadrootsAppleBiometryKind
- public let canEvaluateDeviceCredential: Bool
- public let canEvaluateBiometrics: Bool
+public struct RadrootsAppleUserPresenceAdapters: Sendable {
+ public let currentStatus: @Sendable () async throws -> RadrootsUserPresenceStatus
+ public let verify: @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
public init(
- support: RadrootsAppleUserPresenceSupport,
- biometryKind: RadrootsAppleBiometryKind,
- canEvaluateDeviceCredential: Bool,
- canEvaluateBiometrics: Bool
+ currentStatus: @escaping @Sendable () async throws -> RadrootsUserPresenceStatus,
+ verify: @escaping @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
) {
- self.support = support
- self.biometryKind = biometryKind
- self.canEvaluateDeviceCredential = canEvaluateDeviceCredential
- self.canEvaluateBiometrics = canEvaluateBiometrics
+ self.currentStatus = currentStatus
+ self.verify = verify
}
-}
-
-public actor RadrootsAppleUserPresence {
- public init() {}
- public func currentStatus() -> RadrootsAppleUserPresenceStatus {
+ public static func live(callbackTimeout: TimeInterval = 30) -> Self {
#if canImport(LocalAuthentication)
- Self.status(for: LAContext())
+ Self(
+ currentStatus: {
+ Self.status(for: LAContext())
+ },
+ verify: { request in
+ let context = LAContext()
+ return try await Self.verify(
+ request,
+ context: context,
+ callbackTimeout: callbackTimeout
+ )
+ }
+ )
#else
- RadrootsAppleUserPresenceStatus(
- support: .none,
- biometryKind: .none,
- canEvaluateDeviceCredential: false,
- canEvaluateBiometrics: false
+ Self(
+ currentStatus: {
+ throw RadrootsUserPresenceError.unavailable("user presence is unavailable")
+ },
+ verify: { _ in
+ throw RadrootsUserPresenceError.unavailable("user presence is unavailable")
+ }
)
#endif
}
+}
- public func verify(
- reason: String,
- policy: RadrootsAppleUserPresencePolicy = .deviceOwnerAuthentication
- ) async throws -> Bool {
- #if canImport(LocalAuthentication)
- let context = LAContext()
- return try await withCheckedThrowingContinuation { continuation in
- context.evaluatePolicy(Self.platformPolicy(policy), localizedReason: reason) { success, error in
- if let error {
- continuation.resume(throwing: Self.adapt(error: error))
- } else {
- continuation.resume(returning: success)
- }
- }
- }
- #else
- throw RadrootsAppleSecurityError.unavailable("local authentication is unavailable")
- #endif
+public final class RadrootsAppleUserPresence: RadrootsUserPresence, Sendable {
+ private let adapters: RadrootsAppleUserPresenceAdapters
+
+ public init(adapters: RadrootsAppleUserPresenceAdapters = RadrootsAppleUserPresenceAdapters.live()) {
+ self.adapters = adapters
+ }
+
+ public func currentStatus() async throws -> RadrootsUserPresenceStatus {
+ try await adapters.currentStatus()
+ }
+
+ public func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
+ try await adapters.verify(request)
}
+}
- #if canImport(LocalAuthentication)
- static func platformPolicy(_ policy: RadrootsAppleUserPresencePolicy) -> LAPolicy {
+#if canImport(LocalAuthentication)
+extension RadrootsAppleUserPresenceAdapters {
+ static func platformPolicy(_ policy: RadrootsUserPresencePolicy) -> LAPolicy {
switch policy {
case .deviceOwnerAuthentication:
.deviceOwnerAuthentication
@@ -88,7 +71,7 @@ public actor RadrootsAppleUserPresence {
}
}
- static func status(for context: LAContext) -> RadrootsAppleUserPresenceStatus {
+ static func status(for context: LAContext) -> RadrootsUserPresenceStatus {
var biometricsError: NSError?
let canEvaluateBiometrics = context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
@@ -101,7 +84,7 @@ public actor RadrootsAppleUserPresence {
error: &deviceCredentialError
)
- let support: RadrootsAppleUserPresenceSupport
+ let support: RadrootsUserPresenceSupport
if canEvaluateBiometrics {
support = .biometricsOrDeviceCredential
} else if canEvaluateDeviceCredential {
@@ -110,7 +93,7 @@ public actor RadrootsAppleUserPresence {
support = .none
}
- return RadrootsAppleUserPresenceStatus(
+ return RadrootsUserPresenceStatus(
support: support,
biometryKind: biometryKind(context.biometryType),
canEvaluateDeviceCredential: canEvaluateDeviceCredential,
@@ -118,7 +101,7 @@ public actor RadrootsAppleUserPresence {
)
}
- static func biometryKind(_ biometryType: LABiometryType) -> RadrootsAppleBiometryKind {
+ static func biometryKind(_ biometryType: LABiometryType) -> RadrootsBiometryKind {
switch biometryType {
case .none:
.none
@@ -133,7 +116,33 @@ public actor RadrootsAppleUserPresence {
}
}
- static func adapt(error: Error) -> RadrootsAppleSecurityError {
+ static func verify(
+ _ request: RadrootsUserPresenceRequest,
+ context: LAContext,
+ callbackTimeout: TimeInterval
+ ) async throws -> RadrootsUserPresenceResult {
+ try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback(
+ timeout: callbackTimeout,
+ timeoutMessage: "timed out while completing user presence verification"
+ ) { completion in
+ context.evaluatePolicy(
+ platformPolicy(request.policy),
+ localizedReason: request.reason
+ ) { success, error in
+ if let error {
+ completion(.failure(adapt(error: error)))
+ } else {
+ completion(.success(RadrootsUserPresenceResult(policy: request.policy, verified: success)))
+ }
+ }
+ }
+ }
+
+ static func adapt(error: Error) -> RadrootsUserPresenceError {
+ if let error = error as? RadrootsUserPresenceError {
+ return error
+ }
+
if let error = error as? LAError {
switch error.code {
case .userCancel, .userFallback:
@@ -148,7 +157,79 @@ public actor RadrootsAppleUserPresence {
return .permanentFailure(error.localizedDescription)
}
}
+
return .permanentFailure(error.localizedDescription)
}
- #endif
+}
+#endif
+
+enum RadrootsAppleUserPresenceAsyncSupport {
+ static func awaitCallback<Value: Sendable>(
+ timeout: TimeInterval,
+ timeoutMessage: String,
+ _ body: (@escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void) -> Void
+ ) async throws -> Value {
+ let state = RadrootsAppleUserPresenceAsyncCallbackState<Value>()
+ 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(timeoutMessage)))
+ }
+ }
+ } onCancel: {
+ state.resume(.failure(.userCancelled("user presence verification was cancelled")))
+ }
+ }
+
+ private static func timeoutNanoseconds(_ timeout: TimeInterval) throws -> UInt64 {
+ guard timeout.isFinite, timeout > 0 else {
+ throw RadrootsUserPresenceError.invalidRequest("user presence timeout must be finite and greater than zero")
+ }
+ let nanoseconds = timeout * 1_000_000_000
+ guard nanoseconds <= Double(UInt64.max) else {
+ throw RadrootsUserPresenceError.invalidRequest("user presence timeout is too large")
+ }
+ return UInt64(nanoseconds)
+ }
+}
+
+private final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable>: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation<Value, any Error>?
+ private var didResolve = false
+
+ func install(_ continuation: CheckedContinuation<Value, any Error>) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !didResolve else {
+ continuation.resume(throwing: RadrootsUserPresenceError.transientFailure("user presence verification already resolved"))
+ return
+ }
+ self.continuation = continuation
+ }
+
+ func resume(_ result: Result<Value, RadrootsUserPresenceError>) {
+ let pending: CheckedContinuation<Value, any Error>?
+ lock.lock()
+ if didResolve {
+ lock.unlock()
+ return
+ }
+ 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)
+ }
+ }
}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift b/Tests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift
@@ -0,0 +1,111 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func appleUserPresenceReportsStatusThroughAdapter() async throws {
+ let expectedStatus = RadrootsUserPresenceStatus(
+ support: .deviceCredential,
+ biometryKind: .none,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: false
+ )
+ let service = RadrootsAppleUserPresence(
+ adapters: RadrootsAppleUserPresenceAdapters(
+ currentStatus: {
+ expectedStatus
+ },
+ verify: { request in
+ RadrootsUserPresenceResult(policy: request.policy, verified: true)
+ }
+ )
+ )
+
+ let status = try await service.currentStatus()
+
+ #expect(status == expectedStatus)
+}
+
+@Test func appleUserPresenceVerifiesThroughAdapter() async throws {
+ let request = try RadrootsUserPresenceRequest(
+ policy: .deviceOwnerAuthenticationWithBiometrics,
+ reason: "Unlock local Nostr identity"
+ )
+ let service = RadrootsAppleUserPresence(
+ adapters: RadrootsAppleUserPresenceAdapters(
+ currentStatus: {
+ .unavailable
+ },
+ verify: { request in
+ RadrootsUserPresenceResult(policy: request.policy, verified: true)
+ }
+ )
+ )
+
+ let result = try await service.verify(request)
+
+ #expect(result.policy == .deviceOwnerAuthenticationWithBiometrics)
+ #expect(result.verified)
+}
+
+@Test func appleUserPresencePropagatesAdapterFailures() async throws {
+ let service = RadrootsAppleUserPresence(
+ adapters: RadrootsAppleUserPresenceAdapters(
+ currentStatus: {
+ .unavailable
+ },
+ verify: { _ in
+ throw RadrootsUserPresenceError.unavailable("user presence unavailable")
+ }
+ )
+ )
+
+ await #expect(throws: RadrootsUserPresenceError.unavailable("user presence unavailable")) {
+ try await service.verify(RadrootsUserPresenceRequest(reason: "Delete local Nostr identity"))
+ }
+}
+
+@Test func appleUserPresenceAsyncSupportTimesOutUnresolvedCallbacks() async {
+ await #expect(throws: RadrootsUserPresenceError.timeout("timed out")) {
+ let _: Bool = try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback(
+ timeout: 0.001,
+ timeoutMessage: "timed out"
+ ) { _ in }
+ }
+}
+
+#if canImport(LocalAuthentication)
+import LocalAuthentication
+
+@Test func appleUserPresenceMapsLocalAuthenticationPolicies() {
+ #expect(
+ RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthentication) ==
+ LAPolicy.deviceOwnerAuthentication
+ )
+ #expect(
+ RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthenticationWithBiometrics) ==
+ LAPolicy.deviceOwnerAuthenticationWithBiometrics
+ )
+}
+
+@Test func appleUserPresenceMapsLocalAuthenticationErrors() {
+ assertUserPresenceError(
+ RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.userCancel)),
+ matches: { if case .userCancelled = $0 { true } else { false } }
+ )
+ assertUserPresenceError(
+ RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.biometryNotAvailable)),
+ matches: { if case .unavailable = $0 { true } else { false } }
+ )
+ assertUserPresenceError(
+ RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.authenticationFailed)),
+ matches: { if case .permissionDenied = $0 { true } else { false } }
+ )
+}
+
+private func assertUserPresenceError(
+ _ error: RadrootsUserPresenceError,
+ matches: (RadrootsUserPresenceError) -> Bool
+) {
+ #expect(matches(error))
+}
+#endif
diff --git a/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift b/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift
@@ -146,9 +146,9 @@ import Testing
#expect(try store.get(key) == nil)
}
-@Test func userPresenceStatusIsInspectable() async {
+@Test func userPresenceStatusIsInspectable() async throws {
let userPresence = RadrootsAppleUserPresence()
- let status = await userPresence.currentStatus()
+ let status = try await userPresence.currentStatus()
switch status.support {
case .none, .deviceCredential, .biometricsOrDeviceCredential:
break