app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit d4f23ffc62ee929a5711edf73e9fcc68481c289e
parent 6c050db7688d49d378a4954b92d961d55ab1b750
Author: triesap <tyson@radroots.org>
Date:   Fri, 20 Mar 2026 22:52:53 +0000

build: add shared apple security package

- add the `RadRootsAppleSecurity` Swift package under `native/apple/swift`
- add Apple Keychain, access policy, user presence, and C ABI primitives for later Rust integration
- wire the iOS host project to the local package through Xcodegen
- document the `native/` boundary and Apple package test command in repo guidance

Diffstat:
M.gitignore | 2++
MAGENTS.md | 2++
MCONTRIBUTING.md | 9++++++++-
Anative/apple/swift/RadRootsAppleSecurity/Package.swift | 36++++++++++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretAccessPolicy.swift | 28++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretKey.swift | 21+++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecurityError.swift | 28++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anative/apple/swift/RadRootsAppleSecurity/Tests/RadRootsAppleSecurityTests/RadRootsAppleSecurityTests.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplatforms/ios/project.yml | 7+++++++
12 files changed, 651 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore @@ -2,6 +2,8 @@ /.trunk/ /crates/web/.trunk/ /crates/web/dist/ +/native/apple/swift/**/.build/ +/native/apple/swift/**/.swiftpm/ /platforms/ios/.derived-data/ /platforms/ios/*.xcodeproj/ diff --git a/AGENTS.md b/AGENTS.md @@ -49,6 +49,8 @@ Before making substantial changes: - Keep the repository root as the workspace root. - Keep shared application code under `crates/core`. - Keep target launchers and bridge crates under `crates/`. +- Keep reusable platform-native libraries under `native/`. +- Keep native host projects under `platforms/`. - Add new crates only when they represent a durable architectural boundary. - Keep manifests, paths, and crate boundaries simple and intentional. - Do not reintroduce obsolete framework scaffolding unless the requested change explicitly requires it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Rad Roots is an open-source application. Contributions are welcome, including bu ## Scope -This repository is the standalone Rad Roots application repository. The main application code is organized under `crates/`. +This repository is the standalone Rad Roots application repository. Shared Rust application code is organized under `crates/`. Reusable native libraries are organized under `native/`, and native host projects are organized under `platforms/`. ## Prerequisites @@ -92,6 +92,13 @@ cd crates/web env -u NO_COLOR trunk serve --open ``` +Test the Apple native security package: + +```bash +cd native/apple/swift/RadRootsAppleSecurity +swift test +``` + ## Contribution Guidelines - Keep changes scoped to a single coherent change. diff --git a/native/apple/swift/RadRootsAppleSecurity/Package.swift b/native/apple/swift/RadRootsAppleSecurity/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "RadRootsAppleSecurity", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library( + name: "RadRootsAppleSecurity", + targets: ["RadRootsAppleSecurity"] + ), + .library( + name: "RadRootsAppleSecurityFFI", + targets: ["RadRootsAppleSecurityFFI"] + ) + ], + targets: [ + .target( + name: "RadRootsAppleSecurity", + path: "Sources/RadRootsAppleSecurity" + ), + .target( + name: "RadRootsAppleSecurityFFI", + dependencies: ["RadRootsAppleSecurity"], + path: "Sources/RadRootsAppleSecurityFFI" + ), + .testTarget( + name: "RadRootsAppleSecurityTests", + dependencies: ["RadRootsAppleSecurity", "RadRootsAppleSecurityFFI"], + path: "Tests/RadRootsAppleSecurityTests" + ) + ] +) diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift @@ -0,0 +1,106 @@ +import Foundation +import Security + +public final class RadRootsAppleKeychainSecretStore: @unchecked Sendable { + public let servicePrefix: String + + public init(servicePrefix: String = "org.radroots.app.apple-security") { + self.servicePrefix = servicePrefix + } + + public func put( + _ value: Data, + for key: RadRootsAppleSecretKey, + policy: RadRootsAppleSecretAccessPolicy = .secureLocalSecret + ) throws { + try delete(key) + + let query = try writeQuery(for: key, value: value, policy: policy) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw Self.mapSecurityStatus(status, defaultMessage: "keychain write failed") + } + } + + public func get(_ key: RadRootsAppleSecretKey) throws -> Data? { + var query = 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.mapSecurityStatus(status, defaultMessage: "keychain read failed") + } + guard let data = result as? Data else { + throw RadRootsAppleSecurityError.permanentFailure( + "keychain read returned an invalid value type" + ) + } + return data + } + + public func contains(_ key: RadRootsAppleSecretKey) throws -> Bool { + try get(key) != nil + } + + public func delete(_ key: RadRootsAppleSecretKey) throws { + let status = SecItemDelete(baseQuery(for: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw Self.mapSecurityStatus(status, defaultMessage: "keychain delete failed") + } + } + + func baseQuery(for key: RadRootsAppleSecretKey) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: key.serviceName(servicePrefix: servicePrefix), + kSecAttrAccount as String: key.name + ] + } + + func writeQuery( + for key: RadRootsAppleSecretKey, + value: Data, + policy: RadRootsAppleSecretAccessPolicy + ) throws -> [String: Any] { + var query = baseQuery(for: key) + query[kSecValueData as String] = value + query[kSecAttrAccessible as String] = accessibilityConstant(for: policy) + return query + } + + func accessibilityConstant(for policy: RadRootsAppleSecretAccessPolicy) -> CFString { + switch (policy.accessibility, policy.deviceLocalOnly) { + case (.whenUnlocked, true): + return kSecAttrAccessibleWhenUnlockedThisDeviceOnly + case (.whenUnlocked, false): + return kSecAttrAccessibleWhenUnlocked + case (.afterFirstUnlock, true): + return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + case (.afterFirstUnlock, false): + return kSecAttrAccessibleAfterFirstUnlock + } + } + + static func mapSecurityStatus( + _ status: OSStatus, + defaultMessage: String + ) -> RadRootsAppleSecurityError { + switch status { + case errSecAuthFailed: + return .permissionDenied(defaultMessage) + case errSecInteractionNotAllowed: + return .transientFailure(defaultMessage) + case errSecUserCanceled: + return .userCancelled(defaultMessage) + case errSecNotAvailable: + return .unavailable(defaultMessage) + default: + return .keychainStatus(status, defaultMessage) + } + } +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretAccessPolicy.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretAccessPolicy.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum RadRootsAppleSecretAccessibility: Int32, Sendable { + case whenUnlocked = 0 + case afterFirstUnlock = 1 +} + +public struct RadRootsAppleSecretAccessPolicy: Sendable, Equatable { + public let accessibility: RadRootsAppleSecretAccessibility + public let deviceLocalOnly: Bool + public let userPresenceRequired: Bool + + public init( + accessibility: RadRootsAppleSecretAccessibility, + deviceLocalOnly: Bool, + userPresenceRequired: Bool + ) { + self.accessibility = accessibility + self.deviceLocalOnly = deviceLocalOnly + self.userPresenceRequired = userPresenceRequired + } + + public static let secureLocalSecret = Self( + accessibility: .whenUnlocked, + deviceLocalOnly: true, + userPresenceRequired: false + ) +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretKey.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretKey.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct RadRootsAppleSecretKey: Hashable, Sendable { + public let namespace: String + public let name: String + + public init(namespace: String, name: String) throws { + guard !namespace.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("secret namespace cannot be empty") + } + guard !name.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("secret name cannot be empty") + } + self.namespace = namespace + self.name = name + } + + func serviceName(servicePrefix: String) -> String { + "\(servicePrefix).\(namespace)" + } +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecurityError.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecurityError.swift @@ -0,0 +1,28 @@ +import Foundation +import Security + +public enum RadRootsAppleSecurityError: Error, Sendable { + case invalidRequest(String) + case permissionDenied(String) + case userCancelled(String) + case unavailable(String) + case transientFailure(String) + case permanentFailure(String) + case keychainStatus(OSStatus, String) +} + +extension RadRootsAppleSecurityError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .invalidRequest(message), + let .permissionDenied(message), + let .userCancelled(message), + let .unavailable(message), + let .transientFailure(message), + let .permanentFailure(message): + return message + case let .keychainStatus(status, message): + return "\(message) (status \(status))" + } + } +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift @@ -0,0 +1,159 @@ +import Foundation + +#if canImport(LocalAuthentication) +import LocalAuthentication +#endif + +public enum RadRootsAppleUserPresencePolicy: Sendable { + case deviceOwnerAuthentication + case deviceOwnerAuthenticationWithBiometrics +} + +public enum RadRootsAppleUserPresenceSupport: Sendable { + case none + case deviceCredential + case biometricsOrDeviceCredential +} + +public enum RadRootsAppleBiometryKind: Sendable { + case none + case touchID + case faceID + case opticID + case unknown +} + +public struct RadRootsAppleUserPresenceStatus: Sendable { + 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) + let context = LAContext() + return Self.makeStatus(context: context) + #else + return 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.makePolicy(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) + private static func makePolicy(_ policy: RadRootsAppleUserPresencePolicy) -> LAPolicy { + switch policy { + case .deviceOwnerAuthentication: + return .deviceOwnerAuthentication + case .deviceOwnerAuthenticationWithBiometrics: + return .deviceOwnerAuthenticationWithBiometrics + } + } + + private static func makeStatus(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: makeBiometryKind(context.biometryType), + canEvaluateDeviceCredential: canEvaluateDeviceCredential, + canEvaluateBiometrics: canEvaluateBiometrics + ) + } + + private static func makeBiometryKind(_ biometryType: LABiometryType) -> RadRootsAppleBiometryKind { + switch biometryType { + case .none: + return .none + case .touchID: + return .touchID + case .faceID: + return .faceID + case .opticID: + return .opticID + @unknown default: + return .unknown + } + } + + private static func adapt(error: Error) -> RadRootsAppleSecurityError { + if let laError = error as? LAError { + switch laError.code { + case .userCancel, .userFallback: + return .userCancelled(laError.localizedDescription) + case .appCancel, .systemCancel, .notInteractive: + return .transientFailure(laError.localizedDescription) + case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet: + return .unavailable(laError.localizedDescription) + case .authenticationFailed: + return .permissionDenied(laError.localizedDescription) + default: + return .permanentFailure(laError.localizedDescription) + } + } + + return .permanentFailure(error.localizedDescription) + } + #endif +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift @@ -0,0 +1,194 @@ +import Foundation +import RadRootsAppleSecurity + +private let defaultServicePrefix = "org.radroots.app.apple-security" + +private enum RadRootsAppleFFIStatus: Int32 { + case success = 0 + case notFound = 1 + case invalidInput = 2 + case error = 3 +} + +@_cdecl("radroots_apple_secret_store_put") +public func radroots_apple_secret_store_put( + _ servicePrefix: UnsafePointer<CChar>?, + _ namespace: UnsafePointer<CChar>?, + _ name: UnsafePointer<CChar>?, + _ valuePtr: UnsafePointer<UInt8>?, + _ valueLen: Int, + _ accessibilityRaw: Int32, + _ deviceLocalOnlyRaw: Int32, + _ userPresenceRequiredRaw: Int32, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + let store = try makeStore(servicePrefix: servicePrefix) + let key = try makeKey(namespace: namespace, name: name) + let policy = try makePolicy( + accessibilityRaw: accessibilityRaw, + deviceLocalOnlyRaw: deviceLocalOnlyRaw, + userPresenceRequiredRaw: userPresenceRequiredRaw + ) + guard let valuePtr else { + throw RadRootsAppleSecurityError.invalidRequest("secret value pointer cannot be null") + } + let value = Data(bytes: valuePtr, count: valueLen) + try store.put(value, for: key, policy: policy) + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + +@_cdecl("radroots_apple_secret_store_get") +public func radroots_apple_secret_store_get( + _ servicePrefix: UnsafePointer<CChar>?, + _ namespace: UnsafePointer<CChar>?, + _ name: UnsafePointer<CChar>?, + _ valueOut: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>?, + _ valueLenOut: UnsafeMutablePointer<Int>?, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + guard let valueOut, let valueLenOut else { + throw RadRootsAppleSecurityError.invalidRequest("output buffers cannot be null") + } + let store = try makeStore(servicePrefix: servicePrefix) + let key = try makeKey(namespace: namespace, name: name) + guard let value = try store.get(key) else { + valueOut.pointee = nil + valueLenOut.pointee = 0 + return RadRootsAppleFFIStatus.notFound.rawValue + } + + let output = UnsafeMutablePointer<UInt8>.allocate(capacity: value.count) + value.copyBytes(to: output, count: value.count) + valueOut.pointee = output + valueLenOut.pointee = value.count + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + +@_cdecl("radroots_apple_secret_store_contains") +public func radroots_apple_secret_store_contains( + _ servicePrefix: UnsafePointer<CChar>?, + _ namespace: UnsafePointer<CChar>?, + _ name: UnsafePointer<CChar>?, + _ containsOut: UnsafeMutablePointer<Int32>?, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + guard let containsOut else { + throw RadRootsAppleSecurityError.invalidRequest("contains output cannot be null") + } + let store = try makeStore(servicePrefix: servicePrefix) + let key = try makeKey(namespace: namespace, name: name) + containsOut.pointee = try store.contains(key) ? 1 : 0 + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + +@_cdecl("radroots_apple_secret_store_delete") +public func radroots_apple_secret_store_delete( + _ servicePrefix: UnsafePointer<CChar>?, + _ namespace: UnsafePointer<CChar>?, + _ name: UnsafePointer<CChar>?, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + let store = try makeStore(servicePrefix: servicePrefix) + let key = try makeKey(namespace: namespace, name: name) + try store.delete(key) + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + +@_cdecl("radroots_apple_buffer_free") +public func radroots_apple_buffer_free( + _ buffer: UnsafeMutablePointer<UInt8>?, + _ length: Int +) { + guard let buffer else { + return + } + buffer.deallocate() + _ = length +} + +@_cdecl("radroots_apple_c_string_free") +public func radroots_apple_c_string_free(_ string: UnsafeMutablePointer<CChar>?) { + string?.deallocate() +} + +private func makeStore( + servicePrefix: UnsafePointer<CChar>? +) throws -> RadRootsAppleKeychainSecretStore { + let service = servicePrefix.map(String.init(cString:)) ?? defaultServicePrefix + guard !service.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("service prefix cannot be empty") + } + return RadRootsAppleKeychainSecretStore(servicePrefix: service) +} + +private func makeKey( + namespace: UnsafePointer<CChar>?, + name: UnsafePointer<CChar>? +) throws -> RadRootsAppleSecretKey { + guard let namespace, let name else { + throw RadRootsAppleSecurityError.invalidRequest("secret namespace and name are required") + } + return try RadRootsAppleSecretKey( + namespace: String(cString: namespace), + name: String(cString: name) + ) +} + +private func makePolicy( + accessibilityRaw: Int32, + deviceLocalOnlyRaw: Int32, + userPresenceRequiredRaw: Int32 +) throws -> RadRootsAppleSecretAccessPolicy { + guard let accessibility = RadRootsAppleSecretAccessibility(rawValue: accessibilityRaw) else { + throw RadRootsAppleSecurityError.invalidRequest("invalid accessibility value") + } + return RadRootsAppleSecretAccessPolicy( + accessibility: accessibility, + deviceLocalOnly: deviceLocalOnlyRaw != 0, + userPresenceRequired: userPresenceRequiredRaw != 0 + ) +} + +private func setError( + _ error: Error, + into errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) { + guard let errorOut else { + return + } + errorOut.pointee = duplicateCString(error.localizedDescription) +} + +private func statusForError(_ error: Error) -> Int32 { + if case RadRootsAppleSecurityError.invalidRequest = error { + return RadRootsAppleFFIStatus.invalidInput.rawValue + } + return RadRootsAppleFFIStatus.error.rawValue +} + +private func duplicateCString(_ value: String) -> UnsafeMutablePointer<CChar>? { + let bytes = Array(value.utf8CString) + let pointer = UnsafeMutablePointer<CChar>.allocate(capacity: bytes.count) + pointer.initialize(from: bytes, count: bytes.count) + return pointer +} diff --git a/native/apple/swift/RadRootsAppleSecurity/Tests/RadRootsAppleSecurityTests/RadRootsAppleSecurityTests.swift b/native/apple/swift/RadRootsAppleSecurity/Tests/RadRootsAppleSecurityTests/RadRootsAppleSecurityTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Security +@testable import RadRootsAppleSecurity +@testable import RadRootsAppleSecurityFFI +import Testing + +struct RadRootsAppleSecurityTests { + @Test + func secretKeyRejectsEmptyNamespace() throws { + #expect(throws: RadRootsAppleSecurityError.self) { + _ = try RadRootsAppleSecretKey(namespace: "", name: "secret") + } + } + + @Test + func secretKeyRejectsEmptyName() throws { + #expect(throws: RadRootsAppleSecurityError.self) { + _ = try RadRootsAppleSecretKey(namespace: "nostr", name: "") + } + } + + @Test + func baseQueryUsesStableServicePrefixAndAccountName() throws { + let store = RadRootsAppleKeychainSecretStore(servicePrefix: "org.radroots.app.nostr") + let key = try RadRootsAppleSecretKey(namespace: "accounts", name: "account-1") + + let query = store.baseQuery(for: key) + + #expect(query[kSecAttrService as String] as? String == "org.radroots.app.nostr.accounts") + #expect(query[kSecAttrAccount as String] as? String == "account-1") + #expect(query[kSecClass as String] != nil) + } + + @Test + func secureLocalSecretDefaultsToDeviceLocalWhenUnlocked() { + let policy = RadRootsAppleSecretAccessPolicy.secureLocalSecret + + #expect(policy.accessibility == .whenUnlocked) + #expect(policy.deviceLocalOnly) + #expect(!policy.userPresenceRequired) + } + + @Test + func accessibilityConstantMatchesPolicy() { + let store = RadRootsAppleKeychainSecretStore() + let localPolicy = RadRootsAppleSecretAccessPolicy( + accessibility: .whenUnlocked, + deviceLocalOnly: true, + userPresenceRequired: false + ) + let syncedPolicy = RadRootsAppleSecretAccessPolicy( + accessibility: .afterFirstUnlock, + deviceLocalOnly: false, + userPresenceRequired: false + ) + + #expect(store.accessibilityConstant(for: localPolicy) == kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + #expect(store.accessibilityConstant(for: syncedPolicy) == kSecAttrAccessibleAfterFirstUnlock) + } +} diff --git a/platforms/ios/project.yml b/platforms/ios/project.yml @@ -17,6 +17,11 @@ configs: Debug: debug Release: release +packages: + RadRootsAppleSecurity: + path: ../../native/apple/swift/RadRootsAppleSecurity + group: Native/Apple/Swift + targetTemplates: app_base: type: application @@ -61,6 +66,8 @@ targetTemplates: - sdk: Foundation.framework - sdk: CoreFoundation.framework - sdk: Metal.framework + - package: RadRootsAppleSecurity + product: RadRootsAppleSecurityFFI targets: RadRootsIOS: