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:
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)",