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 22cf4fd131673bab9cd51d12969bac9df1c33993
parent 000587a80a582fafdb0e561dca449b8593514f81
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 21:27:09 -0700

package: add host service primitives

- expose RadrootsKit and RadrootsKitTesting products
- add typed secure-store and Keychain support
- add Apple user-presence inspection and verification
- add deterministic launch and local reset test helpers

Diffstat:
MPackage.swift | 36++++++++++++++++++++++++++++++++++--
ASources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleSecurityError.swift | 35+++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleUserPresence.swift | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsSecureStore.swift | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsAppLocalStateReset.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift | 36++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift | 41+++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsSecureStoreTests.swift | 38++++++++++++++++++++++++++++++++++++++
9 files changed, 603 insertions(+), 2 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -7,6 +7,38 @@ let package = Package( .iOS(.v18), .macOS(.v15) ], - products: [], - targets: [] + products: [ + .library( + name: "RadrootsKit", + targets: ["RadrootsKit"] + ), + .library( + name: "RadrootsKitTesting", + targets: ["RadrootsKitTesting"] + ) + ], + targets: [ + .target( + name: "RadrootsKit", + linkerSettings: [ + .linkedFramework("Security"), + .linkedFramework("LocalAuthentication") + ] + ), + .target( + name: "RadrootsKitTesting", + dependencies: ["RadrootsKit"], + linkerSettings: [ + .linkedFramework("Security") + ] + ), + .testTarget( + name: "RadrootsKitTests", + dependencies: ["RadrootsKit"] + ), + .testTarget( + name: "RadrootsKitTestingTests", + dependencies: ["RadrootsKitTesting"] + ) + ] ) diff --git a/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift b/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift @@ -0,0 +1,129 @@ +import Foundation +import Security + +public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unchecked Sendable { + public let servicePrefix: String + + public init(servicePrefix: String = "org.radroots.kit.secure-store") { + self.servicePrefix = servicePrefix + } + + public func put( + _ value: Data, + for key: RadrootsSecureStoreKey, + policy: RadrootsSecretAccessPolicy = .secureLocalSecret + ) throws { + try delete(key) + + var query = try baseQuery(for: key) + query[kSecValueData as String] = value + + if policy.userPresenceRequired { + query[kSecAttrAccessControl as String] = try accessControl(for: policy) + } else { + query[kSecAttrAccessible as String] = accessibilityConstant(for: policy) + } + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw Self.mapStatus(status, defaultMessage: "keychain write failed") + } + } + + public func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + var query = try baseQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw Self.mapStatus(status, defaultMessage: "keychain read failed") + } + guard let data = result as? Data else { + throw RadrootsAppleSecurityError.permanentFailure("keychain returned an invalid value type") + } + return data + } + + public func delete(_ key: RadrootsSecureStoreKey) throws { + let status = SecItemDelete(try baseQuery(for: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw Self.mapStatus(status, defaultMessage: "keychain delete failed") + } + } + + public func deleteNamespace(_ namespace: String) throws { + let trimmedNamespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedNamespace.isEmpty else { + throw RadrootsAppleSecurityError.invalidRequest("secure store namespace cannot be empty") + } + let status = SecItemDelete(namespaceQuery(trimmedNamespace) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw Self.mapStatus(status, defaultMessage: "keychain namespace delete failed") + } + } + + func baseQuery(for key: RadrootsSecureStoreKey) throws -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: try key.serviceName(servicePrefix: servicePrefix), + kSecAttrAccount as String: key.name + ] + } + + func namespaceQuery(_ namespace: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(servicePrefix).\(namespace)" + ] + } + + func accessibilityConstant(for policy: RadrootsSecretAccessPolicy) -> CFString { + switch (policy.accessibility, policy.deviceLocalOnly) { + case (.whenUnlocked, true): + kSecAttrAccessibleWhenUnlockedThisDeviceOnly + case (.whenUnlocked, false): + kSecAttrAccessibleWhenUnlocked + case (.afterFirstUnlock, true): + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + case (.afterFirstUnlock, false): + kSecAttrAccessibleAfterFirstUnlock + } + } + + func accessControl(for policy: RadrootsSecretAccessPolicy) throws -> SecAccessControl { + var error: Unmanaged<CFError>? + guard let accessControl = SecAccessControlCreateWithFlags( + nil, + accessibilityConstant(for: policy), + .userPresence, + &error + ) else { + let message = (error?.takeRetainedValue() as Error?)?.localizedDescription + ?? "keychain access control initialization failed" + throw RadrootsAppleSecurityError.invalidRequest(message) + } + return accessControl + } + + static func mapStatus(_ status: OSStatus, defaultMessage: String) -> RadrootsAppleSecurityError { + switch status { + case errSecItemNotFound: + .notFound(defaultMessage) + case errSecAuthFailed: + .permissionDenied(defaultMessage) + case errSecInteractionNotAllowed: + .transientFailure(defaultMessage) + case errSecUserCanceled: + .userCancelled(defaultMessage) + case errSecNotAvailable: + .unavailable(defaultMessage) + default: + .keychainStatus(status, defaultMessage) + } + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleSecurityError.swift b/Sources/RadrootsKit/RadrootsAppleSecurityError.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum RadrootsAppleSecurityError: Error, Equatable, Sendable { + case invalidRequest(String) + case notFound(String) + case permissionDenied(String) + case userCancelled(String) + case transientFailure(String) + case unavailable(String) + case permanentFailure(String) + case keychainStatus(Int32, String) +} + +extension RadrootsAppleSecurityError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .notFound(let message): + message + case .permissionDenied(let message): + message + case .userCancelled(let message): + message + case .transientFailure(let message): + message + case .unavailable(let message): + message + case .permanentFailure(let message): + message + case .keychainStatus(_, let message): + message + } + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleUserPresence.swift b/Sources/RadrootsKit/RadrootsAppleUserPresence.swift @@ -0,0 +1,154 @@ +import Foundation + +#if canImport(LocalAuthentication) +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 init( + support: RadrootsAppleUserPresenceSupport, + biometryKind: RadrootsAppleBiometryKind, + canEvaluateDeviceCredential: Bool, + canEvaluateBiometrics: Bool + ) { + self.support = support + self.biometryKind = biometryKind + self.canEvaluateDeviceCredential = canEvaluateDeviceCredential + self.canEvaluateBiometrics = canEvaluateBiometrics + } +} + +public actor RadrootsAppleUserPresence { + public init() {} + + public func currentStatus() -> RadrootsAppleUserPresenceStatus { + #if canImport(LocalAuthentication) + Self.status(for: LAContext()) + #else + RadrootsAppleUserPresenceStatus( + support: .none, + biometryKind: .none, + canEvaluateDeviceCredential: false, + canEvaluateBiometrics: false + ) + #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 + } + + #if canImport(LocalAuthentication) + static func platformPolicy(_ policy: RadrootsAppleUserPresencePolicy) -> LAPolicy { + switch policy { + case .deviceOwnerAuthentication: + .deviceOwnerAuthentication + case .deviceOwnerAuthenticationWithBiometrics: + .deviceOwnerAuthenticationWithBiometrics + } + } + + static func status(for context: LAContext) -> RadrootsAppleUserPresenceStatus { + var biometricsError: NSError? + let canEvaluateBiometrics = context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &biometricsError + ) + + var deviceCredentialError: NSError? + let canEvaluateDeviceCredential = context.canEvaluatePolicy( + .deviceOwnerAuthentication, + error: &deviceCredentialError + ) + + let support: RadrootsAppleUserPresenceSupport + if canEvaluateBiometrics { + support = .biometricsOrDeviceCredential + } else if canEvaluateDeviceCredential { + support = .deviceCredential + } else { + support = .none + } + + return RadrootsAppleUserPresenceStatus( + support: support, + biometryKind: biometryKind(context.biometryType), + canEvaluateDeviceCredential: canEvaluateDeviceCredential, + canEvaluateBiometrics: canEvaluateBiometrics + ) + } + + static func biometryKind(_ biometryType: LABiometryType) -> RadrootsAppleBiometryKind { + switch biometryType { + case .none: + .none + case .touchID: + .touchID + case .faceID: + .faceID + case .opticID: + .opticID + @unknown default: + .unknown + } + } + + static func adapt(error: Error) -> RadrootsAppleSecurityError { + if let error = error as? LAError { + switch error.code { + case .userCancel, .userFallback: + return .userCancelled(error.localizedDescription) + case .appCancel, .systemCancel, .notInteractive: + return .transientFailure(error.localizedDescription) + case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet: + return .unavailable(error.localizedDescription) + case .authenticationFailed: + return .permissionDenied(error.localizedDescription) + default: + return .permanentFailure(error.localizedDescription) + } + } + return .permanentFailure(error.localizedDescription) + } + #endif +} diff --git a/Sources/RadrootsKit/RadrootsSecureStore.swift b/Sources/RadrootsKit/RadrootsSecureStore.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum RadrootsSecretAccessibility: Sendable, Equatable { + case whenUnlocked + case afterFirstUnlock +} + +public struct RadrootsSecretAccessPolicy: Sendable, Equatable { + public let accessibility: RadrootsSecretAccessibility + public let deviceLocalOnly: Bool + public let userPresenceRequired: Bool + + public init( + accessibility: RadrootsSecretAccessibility, + deviceLocalOnly: Bool, + userPresenceRequired: Bool + ) { + self.accessibility = accessibility + self.deviceLocalOnly = deviceLocalOnly + self.userPresenceRequired = userPresenceRequired + } + + public static let secureLocalSecret = Self( + accessibility: .whenUnlocked, + deviceLocalOnly: true, + userPresenceRequired: false + ) + + public static let userPresenceLocalSecret = Self( + accessibility: .whenUnlocked, + deviceLocalOnly: true, + userPresenceRequired: true + ) +} + +public struct RadrootsSecureStoreKey: Hashable, Sendable { + public let namespace: String + public let name: String + + public init(namespace: String, name: String) { + self.namespace = namespace + self.name = name + } + + public func serviceName(servicePrefix: String) throws -> String { + let trimmedPrefix = servicePrefix.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedNamespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPrefix.isEmpty else { + throw RadrootsAppleSecurityError.invalidRequest("secure store service prefix cannot be empty") + } + guard !trimmedNamespace.isEmpty else { + throw RadrootsAppleSecurityError.invalidRequest("secure store namespace cannot be empty") + } + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw RadrootsAppleSecurityError.invalidRequest("secure store key name cannot be empty") + } + return "\(trimmedPrefix).\(trimmedNamespace)" + } +} + +public protocol RadrootsSecureStore: AnyObject, Sendable { + func put( + _ value: Data, + for key: RadrootsSecureStoreKey, + policy: RadrootsSecretAccessPolicy + ) throws + func get(_ key: RadrootsSecureStoreKey) throws -> Data? + func delete(_ key: RadrootsSecureStoreKey) throws + func deleteNamespace(_ namespace: String) throws +} + +extension RadrootsSecureStore { + public func put(_ value: Data, for key: RadrootsSecureStoreKey) throws { + try put(value, for: key, policy: .secureLocalSecret) + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsAppLocalStateReset.swift b/Sources/RadrootsKitTesting/RadrootsAppLocalStateReset.swift @@ -0,0 +1,60 @@ +import Foundation +import Security + +public struct RadrootsAppLocalStateResetRequest: Sendable, Equatable { + public let appIdentifier: String + public let keychainServiceNames: [String] + + public init(appIdentifier: String, keychainServiceNames: [String] = []) { + self.appIdentifier = appIdentifier + self.keychainServiceNames = keychainServiceNames + } +} + +public enum RadrootsAppLocalStateReset { + public static func reset(_ request: RadrootsAppLocalStateResetRequest) throws { + try clearApplicationSupport(appIdentifier: request.appIdentifier) + for serviceName in request.keychainServiceNames { + try clearKeychainService(serviceName) + } + } + + public static func clearApplicationSupport( + appIdentifier: String, + fileManager: FileManager = .default + ) throws { + let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppLocalStateResetError.invalidRequest("app identifier cannot be empty") + } + let root = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(trimmed, isDirectory: true) + if fileManager.fileExists(atPath: root.path) { + try fileManager.removeItem(at: root) + } + } + + public static func clearKeychainService(_ serviceName: String) throws { + let trimmed = serviceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppLocalStateResetError.invalidRequest("keychain service name cannot be empty") + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: trimmed + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw RadrootsAppLocalStateResetError.keychainStatus(status, "keychain service reset failed") + } + } +} + +public enum RadrootsAppLocalStateResetError: Error, Equatable, Sendable { + case invalidRequest(String) + case keychainStatus(Int32, String) +} diff --git a/Sources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift b/Sources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct RadrootsUITestLaunchConfiguration: Sendable, Equatable { + public let environment: [String: String] + public let arguments: [String] + + public init(environment: [String: String], arguments: [String]) { + self.environment = environment + self.arguments = arguments + } + + public static func deterministic( + environment: [String: String] = [:], + arguments: [String] = [], + language: String = "en", + locale: String = "en_US_POSIX" + ) -> Self { + Self( + environment: environment, + arguments: arguments + [ + "-AppleLanguages", + "(\(language))", + "-AppleLocale", + locale + ] + ) + } + + public func mergedEnvironment(over base: [String: String]) -> [String: String] { + var merged = base + for (key, value) in environment { + merged[key] = value + } + return merged + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import RadrootsKitTesting + +@Test func deterministicLaunchConfigurationAddsStableLocaleArguments() { + let config = RadrootsUITestLaunchConfiguration.deterministic( + environment: ["RADROOTS_TEST": "true"], + arguments: ["--radroots-test"] + ) + + #expect(config.environment["RADROOTS_TEST"] == "true") + #expect(config.arguments == [ + "--radroots-test", + "-AppleLanguages", + "(en)", + "-AppleLocale", + "en_US_POSIX" + ]) +} + +@Test func launchConfigurationMergesEnvironmentOverBaseValues() { + let config = RadrootsUITestLaunchConfiguration( + environment: ["A": "override", "B": "new"], + arguments: [] + ) + + #expect(config.mergedEnvironment(over: ["A": "old", "C": "keep"]) == [ + "A": "override", + "B": "new", + "C": "keep" + ]) +} + +@Test func resetAllowsMissingState() throws { + let request = RadrootsAppLocalStateResetRequest( + appIdentifier: "org.radroots.tests.\(UUID().uuidString)", + keychainServiceNames: ["org.radroots.tests.\(UUID().uuidString)"] + ) + + try RadrootsAppLocalStateReset.reset(request) +} diff --git a/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift b/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func secureStoreKeyBuildsServiceName() throws { + let key = RadrootsSecureStoreKey(namespace: "session", name: "token") + #expect(try key.serviceName(servicePrefix: "org.radroots.test") == "org.radroots.test.session") +} + +@Test func secureStoreKeyRejectsBlankNamespace() throws { + let key = RadrootsSecureStoreKey(namespace: " ", name: "token") + #expect(throws: RadrootsAppleSecurityError.self) { + _ = try key.serviceName(servicePrefix: "org.radroots.test") + } +} + +@Test func keychainStoreRoundTripsLocalSecret() throws { + let store = RadrootsAppleKeychainSecureStore( + servicePrefix: "org.radroots.tests.\(UUID().uuidString)" + ) + let key = RadrootsSecureStoreKey(namespace: "roundtrip", name: "token") + let data = Data("secret-token".utf8) + + try store.put(data, for: key) + #expect(try store.get(key) == data) + + try store.delete(key) + #expect(try store.get(key) == nil) +} + +@Test func userPresenceStatusIsInspectable() async { + let userPresence = RadrootsAppleUserPresence() + let status = await userPresence.currentStatus() + switch status.support { + case .none, .deviceCredential, .biometricsOrDeviceCredential: + break + } +}