apple_kit

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

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:
MSources/RadrootsKit/RadrootsAppleUserPresence.swift | 221++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
ATests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTests/RadrootsSecureStoreTests.swift | 4++--
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