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