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 c8eecf0a58ec5f26b62de49e9df95871f736f76d
parent 91da1787ca4af03c61a9bf1dd10e61b66746f126
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 02:00:54 -0700

kit: harden secure store policy checks

Diffstat:
MSources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift | 52+++++++++++++++++++++++++++++++++++++++++++++++-----
MSources/RadrootsKit/RadrootsSecureStore.swift | 1+
MTests/RadrootsKitTests/RadrootsSecureStoreTests.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 98 insertions(+), 5 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift b/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift @@ -18,10 +18,11 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche var query = try baseQuery(for: key) query[kSecValueData as String] = value - if policy.userPresenceRequired { - query[kSecAttrAccessControl as String] = try accessControl(for: policy) + let mapping = keychainPolicyMapping(for: policy) + if mapping.usesAccessControl { + query[kSecAttrAccessControl as String] = try accessControl(for: mapping) } else { - query[kSecAttrAccessible as String] = accessibilityConstant(for: policy) + query[kSecAttrAccessible as String] = mapping.accessibilityConstant } let status = SecItemAdd(query as CFDictionary, nil) @@ -30,6 +31,20 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche } } + public func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { + var query = try baseQuery(for: key) + query[kSecMatchLimit as String] = kSecMatchLimitOne + + let status = SecItemCopyMatching(query as CFDictionary, nil) + if status == errSecItemNotFound { + return false + } + guard status == errSecSuccess else { + throw Self.mapStatus(status, defaultMessage: "keychain presence check failed") + } + return true + } + public func get(_ key: RadrootsSecureStoreKey) throws -> Data? { var query = try baseQuery(for: key) query[kSecReturnData as String] = true @@ -95,12 +110,24 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche } } + func keychainPolicyMapping(for policy: RadrootsSecretAccessPolicy) -> RadrootsKeychainSecretPolicyMapping { + RadrootsKeychainSecretPolicyMapping( + accessibilityConstant: accessibilityConstant(for: policy), + usesAccessControl: policy.userPresenceRequired, + accessControlFlags: policy.userPresenceRequired ? .userPresence : [] + ) + } + func accessControl(for policy: RadrootsSecretAccessPolicy) throws -> SecAccessControl { + try accessControl(for: keychainPolicyMapping(for: policy)) + } + + func accessControl(for mapping: RadrootsKeychainSecretPolicyMapping) throws -> SecAccessControl { var error: Unmanaged<CFError>? guard let accessControl = SecAccessControlCreateWithFlags( nil, - accessibilityConstant(for: policy), - .userPresence, + mapping.accessibilityConstant, + mapping.accessControlFlags, &error ) else { let message = (error?.takeRetainedValue() as Error?)?.localizedDescription @@ -127,3 +154,18 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche } } } + +struct RadrootsKeychainSecretPolicyMapping: Equatable { + let accessibilityConstant: CFString + let usesAccessControl: Bool + let accessControlFlags: SecAccessControlCreateFlags + + static func == ( + lhs: RadrootsKeychainSecretPolicyMapping, + rhs: RadrootsKeychainSecretPolicyMapping + ) -> Bool { + String(lhs.accessibilityConstant) == String(rhs.accessibilityConstant) + && lhs.usesAccessControl == rhs.usesAccessControl + && lhs.accessControlFlags == rhs.accessControlFlags + } +} diff --git a/Sources/RadrootsKit/RadrootsSecureStore.swift b/Sources/RadrootsKit/RadrootsSecureStore.swift @@ -64,6 +64,7 @@ public protocol RadrootsSecureStore: AnyObject, Sendable { for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy ) throws + func contains(_ key: RadrootsSecureStoreKey) throws -> Bool func get(_ key: RadrootsSecureStoreKey) throws -> Data? func delete(_ key: RadrootsSecureStoreKey) throws func deleteNamespace(_ namespace: String) throws diff --git a/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift b/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import Security import Testing @testable import RadrootsKit @@ -28,6 +29,55 @@ import Testing #expect(try store.get(key) == nil) } +@Test func keychainStoreChecksPresenceWithoutReturningSecret() throws { + let store = RadrootsAppleKeychainSecureStore( + servicePrefix: "org.radroots.tests.\(UUID().uuidString)" + ) + let key = RadrootsSecureStoreKey(namespace: "presence", name: "token") + + #expect(try store.contains(key) == false) + + try store.put(Data("secret-token".utf8), for: key) + #expect(try store.contains(key) == true) + + try store.delete(key) + #expect(try store.contains(key) == false) +} + +@Test func secureLocalSecretMapsToDeviceLocalWhenUnlockedKeychainPolicy() { + let store = RadrootsAppleKeychainSecureStore() + let mapping = store.keychainPolicyMapping(for: .secureLocalSecret) + + #expect(String(mapping.accessibilityConstant) == String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) + #expect(mapping.usesAccessControl == false) + #expect(mapping.accessControlFlags.isEmpty) +} + +@Test func userPresenceLocalSecretMapsToDeviceLocalWhenUnlockedAccessControl() throws { + let store = RadrootsAppleKeychainSecureStore() + let mapping = store.keychainPolicyMapping(for: .userPresenceLocalSecret) + + #expect(String(mapping.accessibilityConstant) == String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) + #expect(mapping.usesAccessControl == true) + #expect(mapping.accessControlFlags == .userPresence) + _ = try store.accessControl(for: mapping) +} + +@Test func afterFirstUnlockPolicyCanRemainDeviceLocal() { + let store = RadrootsAppleKeychainSecureStore() + let mapping = store.keychainPolicyMapping( + for: RadrootsSecretAccessPolicy( + accessibility: .afterFirstUnlock, + deviceLocalOnly: true, + userPresenceRequired: false + ) + ) + + #expect(String(mapping.accessibilityConstant) == String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) + #expect(mapping.usesAccessControl == false) + #expect(mapping.accessControlFlags.isEmpty) +} + @Test func resetAllowsMissingState() throws { let request = RadrootsAppLocalStateResetRequest( appIdentifier: "org.radroots.tests.\(UUID().uuidString)",