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