Proxy through

This commit is contained in:
Max Goedjen 2025-08-23 22:47:39 -07:00
parent d34d26884e
commit 3a62a855df
No known key found for this signature in database
9 changed files with 252 additions and 201 deletions

View File

@ -66,7 +66,7 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> [KeyType]
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, attributes: $1) }
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }

View File

@ -20,7 +20,7 @@ import Observation
/// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store)
let modifiable = AnySecretStoreModifiable(store)
if modifiableStore == nil {
modifiableStore = modifiable
}

View File

@ -1,7 +1,7 @@
import Foundation
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore: Identifiable, Sendable {
public protocol SecretStore<SecretType>: Identifiable, Sendable {
associatedtype SecretType: Secret
@ -41,7 +41,7 @@ public protocol SecretStore: Identifiable, Sendable {
}
/// A SecretStore that the Secretive admin app can modify.
public protocol SecretStoreModifiable: SecretStore {
public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// Creates a new ``Secret`` in the store.
/// - Parameters:

View File

@ -41,6 +41,7 @@ extension SecureEnclave {
self.publicKey = publicKey
self.attributes = attributes
}
}
}

View File

@ -8,8 +8,15 @@ import SecretKit
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave.
/// Under the hood, this proxies to two sub-stores both are backed by the Secure Enclave.
/// One is a legacy store (VanillaKeychainStore) which stores NIST-P256 keys directly in the keychain.
/// The other (CryptoKitStore) stores the keys using CryptoKit, and supports additional key types.
@Observable public final class Store: SecretStoreModifiable {
@MainActor private let cryptoKit = CryptoKitStore()
@MainActor private let vanillaKeychain = VanillaKeychainStore()
@MainActor private var secretSourceMap: [Secret: Source] = [:]
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable
@ -17,159 +24,102 @@ extension SecureEnclave {
public let id = UUID()
public let name = String(localized: .secureEnclave)
public var supportedKeyTypes: [KeyType] {
[KeyType(algorithm: .ecdsa, size: 256)]
cryptoKit.supportedKeyTypes
}
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
// MARK: SecretStore
/// Initializes a Store.
@MainActor public init() {
loadSecrets()
reloadSecrets()
Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
reloadSecretsInternal(notifyAgent: false)
}
}
}
// MARK: - Public API
// MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
let context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
@MainActor public func reloadSecrets() {
reloadSecretsInternal(notifyAgent: false)
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
try await store(for: secret)
.sign(data: data, with: secret, for: provenance)
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> (any SecretKit.PersistedAuthenticationContext)? {
await store(for: secret)
.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
try await store(for: secret)
.persistAuthentication(secret: secret, forDuration: duration)
}
public func reloadSecrets() async {
await reloadSecretsInternal(notifyAgent: false)
await reloadSecretsInternal()
}
// MARK: SecretStoreModifiable
public func create(name: String, attributes: Attributes) async throws {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags
if attributes.authentication.required {
flags = [.privateKeyUsage, .userPresence]
} else {
flags = .privateKeyUsage
}
let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags,
&accessError) as Any
if let error = accessError {
throw error.takeRetainedValue() as Error
}
let attributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecPrivateKeyAttrs: [
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
if let error = createKeyError {
throw error.takeRetainedValue() as Error
}
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
throw KeychainError(statusCode: nil)
}
try savePublicKey(publicKey, name: name)
await reloadSecretsInternal()
try await cryptoKit.create(name: name, attributes: attributes)
}
public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
try await store(for: secret)
.delete(secret: secret)
}
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
public func update(secret: Secret, name: String, attributes: SecretKit.Attributes) async throws {
try await store(for: secret)
.update(secret: secret, name: name, attributes: attributes)
}
}
}
extension SecureEnclave.Store {
fileprivate enum Source {
case cryptoKit
case vanilla
}
@MainActor func store(for secret: SecretType) -> any SecretStoreModifiable<SecretType> {
switch secretSourceMap[secret, default: .cryptoKit] {
case .cryptoKit:
cryptoKit
case .vanilla:
vanillaKeychain
}
}
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) {
let before = secrets
secrets.removeAll()
loadSecrets()
if secrets != before {
var mapped: [SecretType: Source] = [:]
var new: [SecretType] = []
cryptoKit.reloadSecrets()
new.append(contentsOf: cryptoKit.secrets)
for secret in cryptoKit.secrets {
mapped[secret] = .cryptoKit
}
vanillaKeychain.reloadSecrets()
new.append(contentsOf: vanillaKeychain.secrets)
for secret in vanillaKeychain.secrets {
mapped[secret] = .vanilla
}
secretSourceMap = mapped
secrets = new
if new != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
@ -177,60 +127,13 @@ extension SecureEnclave.Store {
}
}
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var publicUntyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SecureEnclave.Secret(id: id, name: name, authenticationRequirement: .unknown, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
/// Saves a public key.
/// - Parameters:
/// - publicKey: The public key to save.
/// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecValueRef: publicKey,
kSecAttrIsPermanent: true,
kSecReturnData: true,
kSecAttrLabel: name
])
let status = SecItemAdd(attributes, nil)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
}
}
extension SecureEnclave {
public enum Constants {
public static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
public static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
static let unauthenticatedThreshold: TimeInterval = 0.05
enum Constants {
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
}
}

View File

@ -9,15 +9,14 @@ import os
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave using CryptoKit API.
@available(macOS 14, *)
@Observable public final class CryptoKitStore: SecretStoreModifiable {
@Observable final class CryptoKitStore: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool {
@MainActor var secrets: [Secret] = []
var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable
}
public let id = UUID()
public let name = String(localized: .secureEnclave)
let id = UUID()
let name = String(localized: .secureEnclave)
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
@ -25,7 +24,7 @@ extension SecureEnclave {
loadSecrets()
Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
reloadSecrets()
}
}
}
@ -47,7 +46,7 @@ extension SecureEnclave {
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrService: SecureEnclave.Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self),
kSecReturnAttributes: true,
@ -95,7 +94,7 @@ extension SecureEnclave {
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyClass,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
@ -128,8 +127,9 @@ extension SecureEnclave {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
public func reloadSecrets() async {
await reloadSecretsInternal(notifyAgent: false)
@MainActor public func reloadSecrets() {
secrets.removeAll()
loadSecrets()
}
// MARK: SecretStoreModifiable
@ -171,13 +171,13 @@ extension SecureEnclave {
throw Attributes.UnsupportedOptionError()
}
try saveKey(dataRep, name: name, attributes: attributes)
await reloadSecretsInternal()
await reloadSecrets()
}
public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrService: SecureEnclave.Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self)
])
@ -185,7 +185,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
await reloadSecrets()
}
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
@ -202,7 +202,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecretsInternal()
await reloadSecrets()
}
public var supportedKeyTypes: [KeyType] {
@ -217,28 +217,13 @@ extension SecureEnclave {
}
@available(macOS 14, *)
extension SecureEnclave.CryptoKitStore {
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets
secrets.removeAll()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}
}
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrService: SecureEnclave.Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitAll,
@ -290,7 +275,7 @@ extension SecureEnclave.CryptoKitStore {
let attributes = try JSONEncoder().encode(attributes)
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrService: SecureEnclave.Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrAccount: UUID().uuidString,
@ -306,11 +291,9 @@ extension SecureEnclave.CryptoKitStore {
}
@available(macOS 14, *)
extension SecureEnclave.CryptoKitStore {
enum Constants {
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let keyClass = kSecClassGenericPassword as String
}

View File

@ -0,0 +1,166 @@
import Foundation
import Observation
import Security
import CryptoKit
import LocalAuthentication
import SecretKit
extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave.
@Observable public final class VanillaKeychainStore: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable
}
public let id = UUID()
public let name = String(localized: .secureEnclave)
public var supportedKeyTypes: [KeyType] {
[KeyType(algorithm: .ecdsa, size: 256)]
}
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
@MainActor public init() {
loadSecrets()
}
// MARK: - Public API
// MARK: SecretStore
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
let context: LAContext
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
@MainActor public func reloadSecrets() {
secrets.removeAll()
loadSecrets()
}
// MARK: SecretStoreModifiable
public func create(name: String, attributes: Attributes) async throws {
throw DeprecatedCreationStore()
}
public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecrets()
}
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
await reloadSecrets()
}
}
}
extension SecureEnclave.VanillaKeychainStore {
/// Loads all secrets from the store.
@MainActor private func loadSecrets() {
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = privateTyped.map {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SecureEnclave.Secret(id: id, name: name, authenticationRequirement: .unknown, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
}
extension SecureEnclave.VanillaKeychainStore {
public struct DeprecatedCreationStore: Error {}
}
extension SecureEnclave.VanillaKeychainStore {
public enum Constants {
public static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
}
}

View File

@ -12,7 +12,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList()
// list.add(store: SecureEnclave.CryptoKitStore())
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list

View File

@ -10,7 +10,6 @@ extension EnvironmentValues {
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SecureEnclave.CryptoKitStore())
list.add(store: SmartCard.Store())
return list
}()