Migration.

This commit is contained in:
Max Goedjen 2025-08-24 02:23:59 -07:00
parent 4f691e4e69
commit 452aee59b1
No known key found for this signature in database
8 changed files with 330 additions and 546 deletions

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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<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) {
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 {}
}

View File

@ -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 {}
}

View File

@ -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
}
}

View File

@ -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
}()

View File

@ -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
}()