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 ee71a39735e55e1cd5ea2fd40ab2fcf3e9ef8636
parent c8eecf0a58ec5f26b62de49e9df95871f736f76d
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 15:07:33 -0700

kit: harden secure store contract

- normalize secure-store keys before Keychain account use
- route namespace service names through shared validation
- add an in-memory secure store for reusable tests
- expand secure-store policy and namespace coverage

Diffstat:
MSources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift | 22+++++++++++-----------
MSources/RadrootsKit/RadrootsSecureStore.swift | 30+++++++++++++++++++++++++++---
ASources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift | 39+++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTests/RadrootsSecureStoreTests.swift | 40++++++++++++++++++++++++++++++++++++++++
5 files changed, 192 insertions(+), 14 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift b/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift @@ -72,28 +72,28 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche } 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) + let status = SecItemDelete(try namespaceQuery(namespace) 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] { - [ + let normalizedKey = try key.normalized() + return [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: try key.serviceName(servicePrefix: servicePrefix), - kSecAttrAccount as String: key.name + kSecAttrService as String: try normalizedKey.serviceName(servicePrefix: servicePrefix), + kSecAttrAccount as String: normalizedKey.name ] } - func namespaceQuery(_ namespace: String) -> [String: Any] { - [ + func namespaceQuery(_ namespace: String) throws -> [String: Any] { + return [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "\(servicePrefix).\(namespace)" + kSecAttrService as String: try RadrootsSecureStoreKey.serviceName( + servicePrefix: servicePrefix, + namespace: namespace + ) ] } diff --git a/Sources/RadrootsKit/RadrootsSecureStore.swift b/Sources/RadrootsKit/RadrootsSecureStore.swift @@ -42,19 +42,43 @@ public struct RadrootsSecureStoreKey: Hashable, Sendable { self.name = name } + public func normalized() throws -> Self { + Self( + namespace: try Self.normalizedNamespace(namespace), + name: try Self.normalizedName(name) + ) + } + public func serviceName(servicePrefix: String) throws -> String { + try Self.serviceName(servicePrefix: servicePrefix, namespace: namespace) + } + + public static func serviceName(servicePrefix: String, namespace: String) throws -> String { + "\(try normalizedServicePrefix(servicePrefix)).\(try normalizedNamespace(namespace))" + } + + public static func normalizedServicePrefix(_ 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") } + return trimmedPrefix + } + + public static func normalizedNamespace(_ namespace: String) throws -> String { + let trimmedNamespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedNamespace.isEmpty else { throw RadrootsAppleSecurityError.invalidRequest("secure store namespace cannot be empty") } - guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return trimmedNamespace + } + + public static func normalizedName(_ name: String) throws -> String { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { throw RadrootsAppleSecurityError.invalidRequest("secure store key name cannot be empty") } - return "\(trimmedPrefix).\(trimmedNamespace)" + return trimmedName } } diff --git a/Sources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift b/Sources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift @@ -0,0 +1,75 @@ +import Foundation +import RadrootsKit + +public final class RadrootsInMemorySecureStore: RadrootsSecureStore, @unchecked Sendable { + private struct Entry: Sendable { + let value: Data + let policy: RadrootsSecretAccessPolicy + } + + private let lock = NSLock() + private var entries: [RadrootsSecureStoreKey: Entry] + + public init() { + self.entries = [:] + } + + public func put( + _ value: Data, + for key: RadrootsSecureStoreKey, + policy: RadrootsSecretAccessPolicy + ) throws { + let normalizedKey = try key.normalized() + lock.lock() + defer { lock.unlock() } + entries[normalizedKey] = Entry(value: value, policy: policy) + } + + public func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { + let normalizedKey = try key.normalized() + lock.lock() + defer { lock.unlock() } + return entries[normalizedKey] != nil + } + + public func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + let normalizedKey = try key.normalized() + lock.lock() + defer { lock.unlock() } + return entries[normalizedKey]?.value + } + + public func delete(_ key: RadrootsSecureStoreKey) throws { + let normalizedKey = try key.normalized() + lock.lock() + defer { lock.unlock() } + entries.removeValue(forKey: normalizedKey) + } + + public func deleteNamespace(_ namespace: String) throws { + let normalizedNamespace = try RadrootsSecureStoreKey.normalizedNamespace(namespace) + lock.lock() + defer { lock.unlock() } + entries = entries.filter { key, _ in + key.namespace != normalizedNamespace + } + } + + public func policy(for key: RadrootsSecureStoreKey) throws -> RadrootsSecretAccessPolicy? { + let normalizedKey = try key.normalized() + lock.lock() + defer { lock.unlock() } + return entries[normalizedKey]?.policy + } + + public func keys() -> [RadrootsSecureStoreKey] { + lock.lock() + defer { lock.unlock() } + return entries.keys.sorted { + if $0.namespace == $1.namespace { + return $0.name < $1.name + } + return $0.namespace < $1.namespace + } + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import RadrootsKit import RadrootsKitTesting @Test func deterministicLaunchConfigurationAddsStableLocaleArguments() { @@ -30,3 +31,41 @@ import RadrootsKitTesting "C": "keep" ]) } + +@Test func inMemorySecureStoreRoundTripsAndNormalizesKeys() throws { + let store = RadrootsInMemorySecureStore() + let key = RadrootsSecureStoreKey(namespace: " identity ", name: " selected ") + + try store.put(Data("secret".utf8), for: key) + + #expect(try store.contains(RadrootsSecureStoreKey(namespace: "identity", name: "selected"))) + #expect(try store.get(key) == Data("secret".utf8)) + #expect(store.keys() == [RadrootsSecureStoreKey(namespace: "identity", name: "selected")]) +} + +@Test func inMemorySecureStoreRecordsPolicyWithoutReturningSecret() throws { + let store = RadrootsInMemorySecureStore() + let key = RadrootsSecureStoreKey(namespace: "identity", name: "selected") + + try store.put( + Data("secret".utf8), + for: key, + policy: .userPresenceLocalSecret + ) + + #expect(try store.policy(for: key) == .userPresenceLocalSecret) + #expect(try store.contains(key)) +} + +@Test func inMemorySecureStoreDeletesNamespace() throws { + let store = RadrootsInMemorySecureStore() + let selected = RadrootsSecureStoreKey(namespace: " identity ", name: "selected") + let relay = RadrootsSecureStoreKey(namespace: "relay", name: "selected") + + try store.put(Data("secret".utf8), for: selected) + try store.put(Data("relay".utf8), for: relay) + try store.deleteNamespace(" identity ") + + #expect(try store.get(selected) == nil) + #expect(try store.get(relay) == Data("relay".utf8)) +} diff --git a/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift b/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift @@ -8,6 +8,15 @@ import Testing #expect(try key.serviceName(servicePrefix: "org.radroots.test") == "org.radroots.test.session") } +@Test func secureStoreKeyNormalizesServicePrefixNamespaceAndName() throws { + let key = RadrootsSecureStoreKey(namespace: " session ", name: " token ") + let normalizedKey = try key.normalized() + + #expect(normalizedKey.namespace == "session") + #expect(normalizedKey.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) { @@ -15,6 +24,37 @@ import Testing } } +@Test func secureStoreKeyRejectsBlankName() throws { + let key = RadrootsSecureStoreKey(namespace: "session", name: " ") + #expect(throws: RadrootsAppleSecurityError.self) { + _ = try key.normalized() + } +} + +@Test func secureStoreKeyRejectsBlankServicePrefix() throws { + let key = RadrootsSecureStoreKey(namespace: "session", name: "token") + #expect(throws: RadrootsAppleSecurityError.self) { + _ = try key.serviceName(servicePrefix: " ") + } +} + +@Test func keychainBaseQueryUsesNormalizedAccountName() throws { + let store = RadrootsAppleKeychainSecureStore(servicePrefix: " org.radroots.tests ") + let query = try store.baseQuery( + for: RadrootsSecureStoreKey(namespace: " identity ", name: " secret ") + ) + + #expect(query[kSecAttrService as String] as? String == "org.radroots.tests.identity") + #expect(query[kSecAttrAccount as String] as? String == "secret") +} + +@Test func keychainNamespaceQueryUsesSharedValidation() throws { + let store = RadrootsAppleKeychainSecureStore(servicePrefix: " org.radroots.tests ") + let query = try store.namespaceQuery(" identity ") + + #expect(query[kSecAttrService as String] as? String == "org.radroots.tests.identity") +} + @Test func keychainStoreRoundTripsLocalSecret() throws { let store = RadrootsAppleKeychainSecureStore( servicePrefix: "org.radroots.tests.\(UUID().uuidString)"