From 452aee59b1e3b2c8c1da3dc3be38c81971d96b74 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Sun, 24 Aug 2025 02:23:59 -0700 Subject: [PATCH] Migration. --- .../SecretKit/Types/CreationOptions.swift | 3 +- .../CryptoKitMigrator.swift | 96 ++++++ .../SecureEnclaveSecret.swift | 21 +- .../SecureEnclaveStore.swift | 306 +++++++++++++----- .../SecureEnclaveCryptoKitStore.swift | 274 ---------------- .../SecureEnclaveVanillaKeychainStore.swift | 166 ---------- Sources/SecretAgent/AppDelegate.swift | 5 +- Sources/Secretive/App.swift | 5 +- 8 files changed, 330 insertions(+), 546 deletions(-) create mode 100644 Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift delete mode 100644 Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveCryptoKitStore.swift delete mode 100644 Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveVanillaKeychainStore.swift diff --git a/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift index df9f33a..99ab8f3 100644 --- a/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift +++ b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift @@ -14,10 +14,9 @@ public struct Attributes: Sendable, Codable, Hashable { public init( keyType: KeyType, - authentication: AuthenticationRequirement = .presenceRequired, + authentication: AuthenticationRequirement, publicKeyAttribution: String? = nil ) { -// assert(authentication != .unknown, "Secrets cannot be created with an unknown authentication requirement.") self.keyType = keyType self.authentication = authentication self.publicKeyAttribution = publicKeyAttribution diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift new file mode 100644 index 0000000..21ff9ca --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift @@ -0,0 +1,96 @@ +import Foundation +import Security +import CryptoTokenKit +import CryptoKit +import SecretKit +import os + +extension SecureEnclave { + + public struct CryptoKitMigrator { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator") + + public init() { + } + + @MainActor public func migrate(to store: Store) throws { + let privateAttributes = KeychainDictionary([ + kSecClass: kSecClassKey, + kSecAttrKeyType: Constants.oldKeyType, + kSecAttrApplicationTag: SecureEnclave.Store.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 migratedPublicKeys = Set(store.secrets.map(\.publicKey)) + var migrated = false + for key in privateTyped { + let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) + let id = key[kSecAttrApplicationLabel] as! Data + guard !id.contains(Constants.migrationMagicNumber) else { + logger.log("Skipping \(name), already migrated.") + continue + } + let ref = key[kSecValueRef] as! SecKey + let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any] + let tokenObjectID = attributes[Constants.tokenObjectID] as! Data + let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl + // Best guess. + let auth: AuthenticationRequirement = String(describing: accessControl) + .contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown + let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID) + let secret = Secret(id: id, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth)) + guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else { + logger.log("Skipping \(name), public key already present. Marking as migrated.") + try markMigrated(secret: secret) + continue + } + logger.log("Migrating \(name).") + try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes) + logger.log("Migrated \(name).") + try markMigrated(secret: secret) + migrated = true + } + if migrated { + store.reloadSecrets() + } + } + + + + public func markMigrated(secret: Secret) throws { + let updateQuery = KeychainDictionary([ + kSecClass: kSecClassKey, + kSecAttrApplicationLabel: secret.id as CFData + ]) + + let newID = secret.id + Constants.migrationMagicNumber + let updatedAttributes = KeychainDictionary([ + kSecAttrApplicationLabel: newID as CFData + ]) + + let status = SecItemUpdate(updateQuery, updatedAttributes) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + } + + + } + +} + +extension SecureEnclave.CryptoKitMigrator { + + enum Constants { + public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String + public static let migrationMagicNumber = Data("_cryptokit_1".utf8) + public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString + } + +} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift index 1b12416..27e782e 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift @@ -14,29 +14,10 @@ extension SecureEnclave { init( id: Data, name: String, - authenticationRequirement: AuthenticationRequirement, - publicKey: Data, - ) { - self.id = id - self.name = name - self.publicKey = publicKey - self.attributes = Attributes( - keyType: .init( - algorithm: .ecdsa, - size: 256 - ), - authentication: authenticationRequirement, - publicKeyAttribution: nil - ) - } - - init( - id: String, - name: String, publicKey: Data, attributes: Attributes ) { - self.id = Data(id.utf8) + self.id = id self.name = name self.publicKey = publicKey self.attributes = attributes diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 79b086d..c46f614 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -2,82 +2,179 @@ import Foundation import Observation import Security import CryptoKit -import LocalAuthentication +@preconcurrency import LocalAuthentication import SecretKit +import os 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. + /// An implementation of Store backed by the Secure Enclave using CryptoKit API. @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 } public let id = UUID() public let name = String(localized: .secureEnclave) - public var supportedKeyTypes: [KeyType] { - cryptoKit.supportedKeyTypes - } - private let persistentAuthenticationHandler = PersistentAuthenticationHandler() - // MARK: SecretStore - /// Initializes a Store. @MainActor public init() { - reloadSecrets() + loadSecrets() Task { for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { - reloadSecretsInternal(notifyAgent: false) + reloadSecrets() } } } + // MARK: - Public API + + // MARK: SecretStore + + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + var context: LAContext + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { + context = existing.context + } else { + let newContext = LAContext() + newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") + context = newContext + } + context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") + + let queryAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccount: String(decoding: secret.id, as: UTF8.self), + kSecReturnAttributes: true, + kSecReturnData: true + ]) + var untyped: CFTypeRef? + let status = SecItemCopyMatching(queryAttributes, &untyped) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + guard let untypedSafe = untyped as? [CFString: Any] else { + throw KeychainError(statusCode: errSecSuccess) + } + guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data, + let keyData = untypedSafe[kSecValueData] as? Data else { + throw MissingAttributesError() + } + let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) + + switch (attributes.keyType.algorithm, attributes.keyType.size) { + case (.ecdsa, 256): + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) + return try key.signature(for: data).derRepresentation + case (.mldsa, 65): + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData) + return try key.signature(for: data) + case (.mldsa, 87): + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData) + return try key.signature(for: data) + default: + throw UnsupportedAlgorithmError() + } + + } + + 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() { reloadSecretsInternal(notifyAgent: false) } - 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 store(for: secret) - .persistAuthentication(secret: secret, forDuration: duration) - } - - public func reloadSecrets() async { - await reloadSecretsInternal() - } - // MARK: SecretStoreModifiable - + public func create(name: String, attributes: Attributes) async throws { - try await cryptoKit.create(name: name, attributes: attributes) + var accessError: SecurityError? + let flags: SecAccessControlCreateFlags = switch attributes.authentication { + case .notRequired: + [] + case .presenceRequired: + .userPresence + case .biometryCurrent: + .biometryCurrentSet + case .unknown: + fatalError() + } + let access = + SecAccessControlCreateWithFlags(kCFAllocatorDefault, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + flags, + &accessError) + if let error = accessError { + throw error.takeRetainedValue() as Error + } + let dataRep: Data + switch (attributes.keyType.algorithm, attributes.keyType.size) { + case (.ecdsa, 256): + let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!) + dataRep = created.dataRepresentation + case (.mldsa, 65): + guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() } + let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!) + dataRep = created.dataRepresentation + case (.mldsa, 87): + guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() } + let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!) + dataRep = created.dataRepresentation + default: + throw Attributes.UnsupportedOptionError() + } + try saveKey(dataRep, name: name, attributes: attributes) + await reloadSecrets() } public func delete(secret: Secret) async throws { - try await store(for: secret) - .delete(secret: secret) + let deleteAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccount: String(decoding: secret.id, as: UTF8.self) + ]) + let status = SecItemDelete(deleteAttributes) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + await reloadSecrets() } - public func update(secret: Secret, name: String, attributes: SecretKit.Attributes) async throws { - try await store(for: secret) - .update(secret: secret, name: name, attributes: attributes) + 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() + } + + public var supportedKeyTypes: [KeyType] { + [ + .init(algorithm: .ecdsa, size: 256), + .init(algorithm: .mldsa, size: 65), + .init(algorithm: .mldsa, size: 87), + ] } } @@ -86,40 +183,11 @@ extension SecureEnclave { extension SecureEnclave.Store { - fileprivate enum Source { - case cryptoKit - case vanilla - } - - - @MainActor func store(for secret: SecretType) -> any SecretStoreModifiable { - 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) { let before = secrets - 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 { + secrets.removeAll() + loadSecrets() + if secrets != before { NotificationCenter.default.post(name: .secretStoreReloaded, object: self) if notifyAgent { DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true) @@ -127,13 +195,87 @@ extension SecureEnclave.Store { } } - -} - -extension SecureEnclave { - - enum Constants { - static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) + /// Loads all secrets from the store. + @MainActor private func loadSecrets() { + let queryAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnAttributes: true + ]) + var untyped: CFTypeRef? + SecItemCopyMatching(queryAttributes, &untyped) + guard let typed = untyped as? [[CFString: Any]] else { return } + let wrapped: [SecureEnclave.Secret] = typed.compactMap { + do { + let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") + guard let attributesData = $0[kSecAttrGeneric] as? Data, + let idString = $0[kSecAttrAccount] as? String else { + throw MissingAttributesError() + } + let id = Data(idString.utf8) + let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) + let keyData = $0[kSecValueData] as! Data + let publicKey: Data + switch (attributes.keyType.algorithm, attributes.keyType.size) { + case (.ecdsa, 256): + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.x963Representation + case (.mldsa, 65): + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.rawRepresentation + case (.mldsa, 87): + guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } + let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.rawRepresentation + default: + throw UnsupportedAlgorithmError() + } + return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes) + } catch { + return nil + } + } + secrets.append(contentsOf: wrapped) } + /// Saves a public key. + /// - Parameters: + /// - key: The data representation key to save. + /// - name: A user-facing name for the key. + /// - attributes: Attributes of the key. + /// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate. + func saveKey(_ key: Data, name: String, attributes: Attributes) throws { + let attributes = try JSONEncoder().encode(attributes) + let keychainAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrAccount: UUID().uuidString, + kSecValueData: key, + kSecAttrLabel: name, + kSecAttrGeneric: attributes + ]) + let status = SecItemAdd(keychainAttributes, nil) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + } + +} + +extension SecureEnclave.Store { + + enum Constants { + static let keyClass = kSecClassGenericPassword as String + static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) + } + + struct UnsupportedAlgorithmError: Error {} + struct MissingAttributesError: Error {} + } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveCryptoKitStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveCryptoKitStore.swift deleted file mode 100644 index 19fbccb..0000000 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveCryptoKitStore.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Foundation -import Observation -import Security -import CryptoKit -@preconcurrency import LocalAuthentication -import SecretKit -import os - -extension SecureEnclave { - - /// An implementation of Store backed by the Secure Enclave using CryptoKit API. - @Observable final class CryptoKitStore: SecretStoreModifiable { - - @MainActor var secrets: [Secret] = [] - var isAvailable: Bool { - CryptoKit.SecureEnclave.isAvailable - } - let id = UUID() - let name = String(localized: .secureEnclave) - private let persistentAuthenticationHandler = PersistentAuthenticationHandler() - - /// Initializes a Store. - @MainActor init() { - loadSecrets() - Task { - for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { - reloadSecrets() - } - } - } - - // MARK: - Public API - - // MARK: SecretStore - - func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { - var context: LAContext - if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { - context = existing.context - } else { - let newContext = LAContext() - newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") - context = newContext - } - context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") - - let queryAttributes = KeychainDictionary([ - kSecClass: Constants.keyClass, - kSecAttrService: SecureEnclave.Constants.keyTag, - kSecUseDataProtectionKeychain: true, - kSecAttrAccount: String(decoding: secret.id, as: UTF8.self), - kSecReturnAttributes: true, - kSecReturnData: true - ]) - var untyped: CFTypeRef? - let status = SecItemCopyMatching(queryAttributes, &untyped) - if status != errSecSuccess { - throw KeychainError(statusCode: status) - } - guard let untypedSafe = untyped as? [CFString: Any] else { - throw KeychainError(statusCode: errSecSuccess) - } - guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data, - let keyData = untypedSafe[kSecValueData] as? Data else { - throw MissingAttributesError() - } - let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) - - switch (attributes.keyType.algorithm, attributes.keyType.size) { - case (.ecdsa, 256): - let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) - return try key.signature(for: data).rawRepresentation - case (.mldsa, 65): - guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData) - return try key.signature(for: data) - case (.mldsa, 87): - guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData) - return try key.signature(for: data) - default: - throw UnsupportedAlgorithmError() - } - - } - - func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) - } - - func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { - try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) - } - - @MainActor func reloadSecrets() { - secrets.removeAll() - loadSecrets() - } - - // MARK: SecretStoreModifiable - - func create(name: String, attributes: Attributes) async throws { - var accessError: SecurityError? - let flags: SecAccessControlCreateFlags = switch attributes.authentication { - case .notRequired: - [] - case .presenceRequired: - .userPresence - case .biometryCurrent: - .biometryCurrentSet - case .unknown: - fatalError() - } - let access = - SecAccessControlCreateWithFlags(kCFAllocatorDefault, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - flags, - &accessError) - if let error = accessError { - throw error.takeRetainedValue() as Error - } - let dataRep: Data - switch (attributes.keyType.algorithm, attributes.keyType.size) { - case (.ecdsa, 256): - let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!) - dataRep = created.dataRepresentation - case (.mldsa, 65): - guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() } - let created = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(accessControl: access!) - dataRep = created.dataRepresentation - case (.mldsa, 87): - guard #available(macOS 26.0, *) else { throw Attributes.UnsupportedOptionError() } - let created = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(accessControl: access!) - dataRep = created.dataRepresentation - default: - throw Attributes.UnsupportedOptionError() - } - try saveKey(dataRep, name: name, attributes: attributes) - await reloadSecrets() - } - - func delete(secret: Secret) async throws { - let deleteAttributes = KeychainDictionary([ - kSecClass: Constants.keyClass, - kSecAttrService: SecureEnclave.Constants.keyTag, - kSecUseDataProtectionKeychain: true, - kSecAttrAccount: String(decoding: secret.id, as: UTF8.self) - ]) - let status = SecItemDelete(deleteAttributes) - if status != errSecSuccess { - throw KeychainError(statusCode: status) - } - await reloadSecrets() - } - - 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() - } - - var supportedKeyTypes: [KeyType] { - [ - .init(algorithm: .ecdsa, size: 256), - .init(algorithm: .mldsa, size: 65), - .init(algorithm: .mldsa, size: 87), - ] - } - - } - -} - -extension SecureEnclave.CryptoKitStore { - - /// Loads all secrets from the store. - @MainActor private func loadSecrets() { - let queryAttributes = KeychainDictionary([ - kSecClass: Constants.keyClass, - kSecAttrService: SecureEnclave.Constants.keyTag, - kSecUseDataProtectionKeychain: true, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitAll, - kSecReturnAttributes: true - ]) - var untyped: CFTypeRef? - SecItemCopyMatching(queryAttributes, &untyped) - guard let typed = untyped as? [[CFString: Any]] else { return } - let wrapped: [SecureEnclave.Secret] = typed.compactMap { - do { - let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") - guard let attributesData = $0[kSecAttrGeneric] as? Data, - let id = $0[kSecAttrAccount] as? String else { - throw MissingAttributesError() - } - let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) - let keyData = $0[kSecValueData] as! Data - let publicKey: Data - switch (attributes.keyType.algorithm, attributes.keyType.size) { - case (.ecdsa, 256): - let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) - publicKey = key.publicKey.x963Representation - case (.mldsa, 65): - guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData) - publicKey = key.publicKey.rawRepresentation - case (.mldsa, 87): - guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData) - publicKey = key.publicKey.rawRepresentation - default: - throw UnsupportedAlgorithmError() - } - return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes) - } catch { - return nil - } - } - secrets.append(contentsOf: wrapped) - } - - /// Saves a public key. - /// - Parameters: - /// - key: The data representation key to save. - /// - name: A user-facing name for the key. - /// - attributes: Attributes of the key. - /// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate. - private func saveKey(_ key: Data, name: String, attributes: Attributes) throws { - let attributes = try JSONEncoder().encode(attributes) - let keychainAttributes = KeychainDictionary([ - kSecClass: Constants.keyClass, - kSecAttrService: SecureEnclave.Constants.keyTag, - kSecUseDataProtectionKeychain: true, - kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - kSecAttrAccount: UUID().uuidString, - kSecValueData: key, - kSecAttrLabel: name, - kSecAttrGeneric: attributes - ]) - let status = SecItemAdd(keychainAttributes, nil) - if status != errSecSuccess { - throw KeychainError(statusCode: status) - } - } - -} - -extension SecureEnclave.CryptoKitStore { - - enum Constants { - static let keyClass = kSecClassGenericPassword as String - } - - fileprivate protocol CryptoKitKey: Sendable { - init(dataRepresentation: Data, authenticationContext: LAContext?) throws - var dataRepresentation: Data { get } - } - - - struct UnsupportedAlgorithmError: Error {} - struct MissingAttributesError: Error {} - -} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveVanillaKeychainStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveVanillaKeychainStore.swift deleted file mode 100644 index d12dcb0..0000000 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStores/SecureEnclaveVanillaKeychainStore.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Foundation -import Observation -import Security -import CryptoKit -import LocalAuthentication -import SecretKit - -extension SecureEnclave { - - /// An implementation of Store backed by the Secure Enclave. - @Observable final class VanillaKeychainStore: SecretStoreModifiable { - - @MainActor var secrets: [Secret] = [] - var isAvailable: Bool { - CryptoKit.SecureEnclave.isAvailable - } - let id = UUID() - let name = String(localized: .secureEnclave) - var supportedKeyTypes: [KeyType] { - [KeyType(algorithm: .ecdsa, size: 256)] - } - - private let persistentAuthenticationHandler = PersistentAuthenticationHandler() - - /// Initializes a Store. - @MainActor init() { - loadSecrets() - } - - // MARK: - Public API - - // MARK: SecretStore - - 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 - } - - func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) - } - - func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { - try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) - } - - @MainActor func reloadSecrets() { - secrets.removeAll() - loadSecrets() - } - - // MARK: SecretStoreModifiable - - func create(name: String, attributes: Attributes) async throws { - throw DeprecatedCreationStore() - } - - 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() - } - - 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 - } - -} diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index c714dbf..8f21d34 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -12,7 +12,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private let storeList: SecretStoreList = { let list = SecretStoreList() - list.add(store: SecureEnclave.Store()) + let cryptoKit = SecureEnclave.Store() + let migrator = SecureEnclave.CryptoKitMigrator() + try? migrator.migrate(to: cryptoKit) + list.add(store: cryptoKit) list.add(store: SmartCard.Store()) return list }() diff --git a/Sources/Secretive/App.swift b/Sources/Secretive/App.swift index 5ab2a92..177beaf 100644 --- a/Sources/Secretive/App.swift +++ b/Sources/Secretive/App.swift @@ -9,7 +9,10 @@ extension EnvironmentValues { // This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip). @MainActor fileprivate static let _secretStoreList: SecretStoreList = { let list = SecretStoreList() - list.add(store: SecureEnclave.Store()) + let cryptoKit = SecureEnclave.Store() + let migrator = SecureEnclave.CryptoKitMigrator() + try? migrator.migrate(to: cryptoKit) + list.add(store: cryptoKit) list.add(store: SmartCard.Store()) return list }()