From 4882d7cde517b94b2efcfd1fad58b87e4f41fc6d Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Sun, 17 Aug 2025 22:55:10 -0500 Subject: [PATCH] WIP. --- Sources/Packages/Localizable.xcstrings | 13 + .../Sources/SecretAgentKit/Agent.swift | 10 +- .../Sources/SecretKit/Erasers/AnySecret.swift | 33 +- .../SecretKit/Erasers/AnySecretStore.swift | 14 +- .../Sources/SecretKit/KeychainTypes.swift | 6 +- .../SecretKit/OpenSSH/OpenSSHKeyWriter.swift | 30 +- .../SecretKit/Types/CreationOptions.swift | 52 +++ .../Sources/SecretKit/Types/Secret.swift | 56 ++- .../Sources/SecretKit/Types/SecretStore.swift | 6 +- .../SecureEnclaveCryptoKitStore.swift | 326 ++++++++++++++++++ .../SecureEnclaveSecret.swift | 33 +- .../SecureEnclaveStore.swift | 179 +++++----- .../SmartCardSecretKit/SmartCardSecret.swift | 6 +- .../SmartCardSecretKit/SmartCardStore.swift | 26 +- Sources/SecretAgent/Notifier.swift | 2 +- .../Preview Content/PreviewStore.swift | 12 +- .../Secretive/Views/CreateSecretView.swift | 2 +- .../Secretive/Views/SecretListItemView.swift | 2 +- 18 files changed, 615 insertions(+), 193 deletions(-) create mode 100644 Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift create mode 100644 Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveCryptoKitStore.swift diff --git a/Sources/Packages/Localizable.xcstrings b/Sources/Packages/Localizable.xcstrings index 2fba308..f16555a 100644 --- a/Sources/Packages/Localizable.xcstrings +++ b/Sources/Packages/Localizable.xcstrings @@ -1239,6 +1239,16 @@ } } }, + "auth_context_request_signature_description_%@_%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "auth_context_request_signature_description_%1$@_%2$@" + } + } + } + }, "auth_context_request_verify_description" : { "comment" : "When the user performs a signature verification action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.", "extractionState" : "manual", @@ -1316,6 +1326,9 @@ } } } + }, + "auth_context_request_verify_description_%@" : { + }, "copyable_click_to_copy_button" : { "extractionState" : "manual", diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index 4638e3b..bc8b44b 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -93,7 +93,7 @@ extension Agent { for secret in secrets { let keyBlob = writer.data(secret: secret) - let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! + let curveData = writer.curveType(for: secret.keyType).data(using: .utf8)! keyData.append(writer.lengthAndData(of: keyBlob)) keyData.append(writer.lengthAndData(of: curveData)) @@ -138,15 +138,15 @@ extension Agent { let signed = try await store.sign(data: dataToSign, with: secret, for: provenance) let derSignature = signed - let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! + let curveData = writer.curveType(for: secret.keyType).data(using: .utf8)! // Convert from DER formatted rep to raw (r||s) let rawRepresentation: Data - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): + switch (secret.keyType.algorithm, secret.keyType.size) { + case (.ecdsa, 256): rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation - case (.ellipticCurve, 384): + case (.ecdsa, 384): rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation default: throw AgentError.unsupportedKeyType diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift index 88991dc..249106c 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift @@ -7,10 +7,10 @@ public struct AnySecret: Secret, @unchecked Sendable { private let hashable: AnyHashable private let _id: () -> AnyHashable private let _name: () -> String - private let _algorithm: () -> Algorithm - private let _keySize: () -> Int - private let _requiresAuthentication: () -> Bool + private let _keyType: () -> KeyType + private let _authenticationRequirement: () -> AuthenticationRequirement private let _publicKey: () -> Data + private let _publicKeyAttribution: () -> String? public init(_ secret: T) where T: Secret { if let secret = secret as? AnySecret { @@ -18,19 +18,19 @@ public struct AnySecret: Secret, @unchecked Sendable { hashable = secret.hashable _id = secret._id _name = secret._name - _algorithm = secret._algorithm - _keySize = secret._keySize - _requiresAuthentication = secret._requiresAuthentication + _keyType = secret._keyType + _authenticationRequirement = secret._authenticationRequirement _publicKey = secret._publicKey + _publicKeyAttribution = secret._publicKeyAttribution } else { base = secret as Any self.hashable = secret _id = { secret.id as AnyHashable } _name = { secret.name } - _algorithm = { secret.algorithm } - _keySize = { secret.keySize } - _requiresAuthentication = { secret.requiresAuthentication } + _keyType = { secret.keyType } + _authenticationRequirement = { secret.authenticationRequirement } _publicKey = { secret.publicKey } + _publicKeyAttribution = { secret.publicKeyAttribution } } } @@ -42,21 +42,22 @@ public struct AnySecret: Secret, @unchecked Sendable { _name() } - public var algorithm: Algorithm { - _algorithm() + public var keyType: KeyType { + _keyType() } - public var keySize: Int { - _keySize() - } - public var requiresAuthentication: Bool { - _requiresAuthentication() + public var authenticationRequirement: AuthenticationRequirement { + _authenticationRequirement() } public var publicKey: Data { _publicKey() } + + public var publicKeyAttribution: String? { + _publicKeyAttribution() + } public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { lhs.hashable == rhs.hashable diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index f62bab1..de0d13c 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -68,19 +68,21 @@ public class AnySecretStore: SecretStore, @unchecked Sendable { public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable { - private let _create: @Sendable (String, Bool) async throws -> Void + private let _create: @Sendable (String, Attributes) async throws -> Void private let _delete: @Sendable (AnySecret) async throws -> Void private let _update: @Sendable (AnySecret, String) async throws -> Void + private let _supportedKeyTypes: @Sendable () -> [KeyType] public init(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { - _create = { try await secretStore.create(name: $0, requiresAuthentication: $1) } + _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) } + _supportedKeyTypes = { secretStore.supportedKeyTypes } super.init(secretStore) } - public func create(name: String, requiresAuthentication: Bool) async throws { - try await _create(name, requiresAuthentication) + public func create(name: String, attributes: Attributes) async throws { + try await _create(name, attributes) } public func delete(secret: AnySecret) async throws { @@ -91,4 +93,8 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab try await _update(secret, name) } + public var supportedKeyTypes: [KeyType] { + _supportedKeyTypes() + } + } diff --git a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift index cfea466..c3d6231 100644 --- a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift +++ b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift @@ -54,10 +54,10 @@ public extension SecretStore { /// - allowRSA: Whether or not RSA key types should be permited. /// - Returns: The appropriate algorithm. func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): + switch (secret.keyType.algorithm, secret.keyType.size) { + case (.ecdsa, 256): return .ecdsaSignatureMessageX962SHA256 - case (.ellipticCurve, 384): + case (.ecdsa, 384): return .ecdsaSignatureMessageX962SHA384 case (.rsa, 1024), (.rsa, 2048): guard allowRSA else { fatalError() } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift index cca64df..765c291 100644 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift @@ -11,15 +11,15 @@ public struct OpenSSHKeyWriter: Sendable { /// Generates an OpenSSH data payload identifying the secret. /// - Returns: OpenSSH data payload identifying the secret. public func data(secret: SecretType) -> Data { - lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) + - lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) + + lengthAndData(of: curveType(for: secret.keyType).data(using: .utf8)!) + + lengthAndData(of: curveIdentifier(for: secret.keyType).data(using: .utf8)!) + lengthAndData(of: secret.publicKey) } /// Generates an OpenSSH string representation of the secret. /// - Returns: OpenSSH string representation of the secret. public func openSSHString(secret: SecretType, comment: String? = nil) -> String { - [curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment] + [curveType(for: secret.keyType), data(secret: secret).base64EncodedString(), comment] .compactMap { $0 } .joined(separator: " ") } @@ -60,14 +60,16 @@ extension OpenSSHKeyWriter { /// - algorithm: The algorithm to identify. /// - length: The key length of the algorithm. /// - Returns: The OpenSSH identifier for the algorithm. - public func curveType(for algorithm: Algorithm, length: Int) -> String { - switch algorithm { - case .ellipticCurve: - return "ecdsa-sha2-nistp" + String(describing: length) + public func curveType(for keyType: KeyType) -> String { + switch keyType.algorithm { + case .ecdsa: + "ecdsa-sha2-nistp" + String(describing: keyType.size) case .rsa: // All RSA keys use the same 512 bit hash function, per // https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key - return "rsa-sha2-512" + "rsa-sha2-512" + case .mldsa: + "unknown" } } @@ -76,13 +78,15 @@ extension OpenSSHKeyWriter { /// - algorithm: The algorithm to identify. /// - length: The key length of the algorithm. /// - Returns: The OpenSSH identifier for the algorithm. - private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String { - switch algorithm { - case .ellipticCurve: - return "nistp" + String(describing: length) + private func curveIdentifier(for keyType: KeyType) -> String { + switch keyType.algorithm { + case .ecdsa: + "nistp" + String(describing: keyType.size) + case .mldsa: + "unknown" case .rsa: // All RSA keys use the same 512 bit hash function - return "rsa-sha2-512" + "rsa-sha2-512" } } diff --git a/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift new file mode 100644 index 0000000..4ce1b3f --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct Attributes: Sendable, Codable { + + /// The type of key involved. + public var keyType: KeyType + + /// The authentication requirements for the key. This is simply a description of the option recorded at creation – modifying it doers not modify the key's authentication requirements. + public let authentication: AuthenticationRequirement + + /// The string appended to the end of the SSH Public Key. + /// If nil, a default value will be used. + public var publicKeyAttribution: String? + + public init( + keyType: KeyType, + authentication: AuthenticationRequirement = .presenceRequired, + publicKeyAttribution: String? = nil + ) { + assert(authentication != .unknown, "Secrets cannot be created with an unknown authentication requirement.") + self.keyType = keyType + self.authentication = authentication + self.publicKeyAttribution = publicKeyAttribution + } + + public struct UnsupportedOptionError: Error { + package init() {} + } + +} + +/// The option specified +public enum AuthenticationRequirement: String, Hashable, Sendable, Codable { + + /// Authentication is not required for usage. + case notRequired + + /// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry.. + case presenceRequired + + /// ONLY the current set of biometric data, as matching at time of creation, is accepted. + /// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password. + case biometryCurrent + + /// The authentication requirement was not recorded at creation, and is unknown. + case unknown + + /// Whether or not the key is known to require authentication. + public var required: Bool { + self == .presenceRequired || self == .biometryCurrent + } +} diff --git a/Sources/Packages/Sources/SecretKit/Types/Secret.swift b/Sources/Packages/Sources/SecretKit/Types/Secret.swift index e4cdb22..1759f61 100644 --- a/Sources/Packages/Sources/SecretKit/Types/Secret.swift +++ b/Sources/Packages/Sources/SecretKit/Types/Secret.swift @@ -6,42 +6,60 @@ public protocol Secret: Identifiable, Hashable, Sendable { /// A user-facing string identifying the Secret. var name: String { get } /// The algorithm this secret uses. - var algorithm: Algorithm { get } - /// The key size for the secret. - var keySize: Int { get } + var keyType: KeyType { get } /// Whether the secret requires authentication before use. - var requiresAuthentication: Bool { get } + var authenticationRequirement: AuthenticationRequirement { get } /// The public key data for the secret. var publicKey: Data { get } + /// An attribution string to apply to the generated public key. + var publicKeyAttribution: String? { get } } -/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported. -public enum Algorithm: Hashable, Sendable { +/// The type of algorithm the Secret uses. +public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible { + + public enum Algorithm: Hashable, Sendable, Codable { + case ecdsa + case mldsa + case rsa + } - case ellipticCurve - case rsa + public var algorithm: Algorithm + public var size: Int + + public init(algorithm: Algorithm, size: Int) { + self.algorithm = algorithm + self.size = size + } /// Initializes the Algorithm with a secAttr representation of an algorithm. /// - Parameter secAttr: the secAttr, represented as an NSNumber. - public init(secAttr: NSNumber) { + public init?(secAttr: NSNumber, size: Int) { let secAttrString = secAttr.stringValue as CFString switch secAttrString { case kSecAttrKeyTypeEC: - self = .ellipticCurve + algorithm = .ecdsa case kSecAttrKeyTypeRSA: - self = .rsa + algorithm = .rsa default: - fatalError() + return nil + } + self.size = size + } + + public var secAttrKeyType: CFString? { + switch algorithm { + case .ecdsa: + kSecAttrKeyTypeEC + case .rsa: + kSecAttrKeyTypeRSA + default: + nil } } - public var secAttrKeyType: CFString { - switch self { - case .ellipticCurve: - return kSecAttrKeyTypeEC - case .rsa: - return kSecAttrKeyTypeRSA - } + public var description: String { + "\(algorithm)-\(size)" } } diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index c47c17e..df8abb6 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -55,8 +55,8 @@ public protocol SecretStoreModifiable: SecretStore { /// Creates a new ``Secret`` in the store. /// - Parameters: /// - name: The user-facing name for the ``Secret``. - /// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret. - func create(name: String, requiresAuthentication: Bool) async throws + /// - attributes: A struct describing the options for creating the key. + func create(name: String, attributes: Attributes) async throws /// Deletes a Secret in the store. /// - Parameters: @@ -68,6 +68,8 @@ public protocol SecretStoreModifiable: SecretStore { /// - secret: The ``Secret`` to update. /// - name: The new name for the Secret. func update(secret: SecretType, name: String) async throws + + var supportedKeyTypes: [KeyType] { get } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveCryptoKitStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveCryptoKitStore.swift new file mode 100644 index 0000000..3f1dc41 --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveCryptoKitStore.swift @@ -0,0 +1,326 @@ +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. + @available(macOS 14, *) + @Observable public final class CryptoKitStore: SecretStoreModifiable { + + @MainActor public var secrets: [Secret] = [] + public var isAvailable: Bool { + CryptoKit.SecureEnclave.isAvailable + } + public let id = UUID() + public let name = String(localized: .secureEnclave) + private let persistentAuthenticationHandler = PersistentAuthenticationHandler() + + /// Initializes a Store. + @MainActor public init() { + loadSecrets() + Task { + for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { + await reloadSecretsInternal(notifyAgent: false) + } + } + } + + // 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).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() + } + + } + + public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { + let context = LAContext() + context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)") + context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") + let attributes = KeychainDictionary([ + kSecClass: kSecClassKey, + kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel: secret.id as CFData, + kSecAttrKeyType: Constants.keyClass, + kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, + kSecAttrApplicationTag: Constants.keyTag, + kSecUseAuthenticationContext: context, + kSecReturnRef: true + ]) + var verifyError: SecurityError? + 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 + let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError) + if !verified, let verifyError { + if verifyError.takeUnretainedValue() ~= .verifyError { + return false + } else { + throw SigningError(error: verifyError) + } + } + return verified + } + + 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) + } + + public func reloadSecrets() async { + await reloadSecretsInternal(notifyAgent: false) + } + + // MARK: SecretStoreModifiable + + public 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 reloadSecretsInternal() + } + + public func delete(secret: Secret) async throws { + 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 reloadSecretsInternal() + } + + public func update(secret: Secret, name: String) 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 var supportedKeyTypes: [KeyType] { + [ + .init(algorithm: .ecdsa, size: 256), + .init(algorithm: .mldsa, size: 65), + .init(algorithm: .mldsa, size: 87), + ] + } + + } + +} + +@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, + 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.rawRepresentation + 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: 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) + } + } + +} + +@available(macOS 14, *) +extension SecureEnclave.CryptoKitStore { + + enum Constants { + static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) + 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/SecureEnclaveSecret.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift index 530d01e..f1e2f4a 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift @@ -9,11 +9,38 @@ extension SecureEnclave { public let id: Data public let name: String - public let algorithm = Algorithm.ellipticCurve - public let keySize = 256 - public let requiresAuthentication: Bool + public let keyType: KeyType + public let authenticationRequirement: AuthenticationRequirement + public let publicKeyAttribution: String? public let publicKey: Data + init( + id: Data, + name: String, + authenticationRequirement: AuthenticationRequirement, + publicKey: Data, + ) { + self.id = id + self.name = name + self.keyType = .init(algorithm: .ecdsa, size: 256) + self.authenticationRequirement = authenticationRequirement + self.publicKeyAttribution = nil + self.publicKey = publicKey + } + + init( + id: String, + name: String, + publicKey: Data, + attributes: Attributes + ) { + self.id = Data(id.utf8) + self.name = name + self.keyType = attributes.keyType + self.authenticationRequirement = attributes.authentication + self.publicKeyAttribution = attributes.publicKeyAttribution + self.publicKey = publicKey + } } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index f999b7e..c5dda43 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -16,6 +16,10 @@ extension SecureEnclave { } 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. @@ -28,77 +32,10 @@ extension SecureEnclave { } } - // MARK: Public API + // MARK: - Public API - public func create(name: String, requiresAuthentication: Bool) async throws { - var accessError: SecurityError? - let flags: SecAccessControlCreateFlags - if requiresAuthentication { - flags = [.privateKeyUsage, .userPresence] - } else { - flags = .privateKeyUsage - } - let access = - SecAccessControlCreateWithFlags(kCFAllocatorDefault, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - flags, - &accessError) as Any - if let error = accessError { - throw error.takeRetainedValue() as Error - } + // MARK: SecretStore - 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() - } - - 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() - } - - public func update(secret: Secret, name: String) 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 sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { let context: LAContext if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { @@ -183,6 +120,78 @@ extension SecureEnclave { await reloadSecretsInternal(notifyAgent: false) } + // 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() + } + + 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() + } + + public func update(secret: Secret, name: String) 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() + } + + } } @@ -217,43 +226,13 @@ extension SecureEnclave.Store { var publicUntyped: CFTypeRef? SecItemCopyMatching(publicAttributes, &publicUntyped) guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } - let privateAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.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 privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in - let id = next[kSecAttrApplicationLabel] as! Data - partialResult[id] = next - } - let authNotRequiredAccessControl: SecAccessControl = - SecAccessControlCreateWithFlags(kCFAllocatorDefault, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [.privateKeyUsage], - nil)! - 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 - let privateKey = privateMapped[id] - let requiresAuth: Bool - if let authRequirements = privateKey?[kSecAttrAccessControl] { - // Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality. - requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl - } else { - requiresAuth = false - } - return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) + return SecureEnclave.Secret(id: id, name: name, authenticationRequirement: .unknown, publicKey: publicKey) } secrets.append(contentsOf: wrapped) } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift index 655214f..9855c1f 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift @@ -9,10 +9,10 @@ extension SmartCard { public let id: Data public let name: String - public let algorithm: Algorithm - public let keySize: Int - public let requiresAuthentication: Bool = false + public let keyType: KeyType + public let authenticationRequirement: AuthenticationRequirement = .unknown public let publicKey: Data + public var publicKeyAttribution: String? = nil } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index 3f06773..653e728 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -52,14 +52,6 @@ extension SmartCard { // MARK: Public API - public func create(name: String) throws { - fatalError("Keys must be created on the smart card.") - } - - public func delete(secret: Secret) throws { - fatalError("Keys must be deleted on the smart card.") - } - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { guard let tokenID = await state.tokenID else { fatalError() } let context = LAContext() @@ -91,8 +83,8 @@ extension SmartCard { public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { let attributes = KeychainDictionary([ - kSecAttrKeyType: secret.algorithm.secAttrKeyType, - kSecAttrKeySizeInBits: secret.keySize, + kSecAttrKeyType: secret.keyType.secAttrKeyType as Any, + kSecAttrKeySizeInBits: secret.keyType.size, kSecAttrKeyClass: kSecAttrKeyClassPublic ]) var verifyError: SecurityError? @@ -182,13 +174,13 @@ extension SmartCard.Store { let wrapped = typed.map { let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) let tokenID = $0[kSecAttrApplicationLabel] as! Data - let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) + let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber let keySize = $0[kSecAttrKeySizeInBits] as! Int let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKey = publicKeyAttributes[kSecValueData] as! Data - return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) + return SmartCard.Secret(id: tokenID, name: name, keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, publicKey: publicKey) } state.secrets.append(contentsOf: wrapped) } @@ -210,8 +202,8 @@ extension SmartCard.Store { context.localizedReason = String(localized: .authContextRequestEncryptDescription(secretName: secret.name)) context.localizedCancelTitle = String(localized: .authContextRequestDenyButton) let attributes = KeychainDictionary([ - kSecAttrKeyType: secret.algorithm.secAttrKeyType, - kSecAttrKeySizeInBits: secret.keySize, + kSecAttrKeyType: secret.keyType.secAttrKeyType as Any, + kSecAttrKeySizeInBits: secret.keyType.size, kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecUseAuthenticationContext: context ]) @@ -263,10 +255,10 @@ extension SmartCard.Store { } private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm { - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): + switch (secret.keyType.algorithm, secret.keyType.size) { + case (.ecdsa, 256): return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM - case (.ellipticCurve, 384): + case (.ecdsa, 384): return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM case (.rsa, 1024), (.rsa, 2048): return .rsaEncryptionOAEPSHA512AESGCM diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index 62540b8..fa48cdd 100644 --- a/Sources/SecretAgent/Notifier.swift +++ b/Sources/SecretAgent/Notifier.swift @@ -69,7 +69,7 @@ final class Notifier: Sendable { notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.interruptionLevel = .timeSensitive - if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication { + if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required { notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier } if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { diff --git a/Sources/Secretive/Preview Content/PreviewStore.swift b/Sources/Secretive/Preview Content/PreviewStore.swift index c0bdd85..461c030 100644 --- a/Sources/Secretive/Preview Content/PreviewStore.swift +++ b/Sources/Secretive/Preview Content/PreviewStore.swift @@ -9,11 +9,10 @@ extension Preview { let id = UUID().uuidString let name: String - let algorithm = Algorithm.ellipticCurve - let keySize = 256 - let requiresAuthentication: Bool = false + let keyType = KeyType(algorithm: .ecdsa, size: 256) + let authenticationRequirement = AuthenticationRequirement.presenceRequired let publicKey = UUID().uuidString.data(using: .utf8)! - + var publicKeyAttribution: String? } } @@ -62,6 +61,9 @@ extension Preview { let id = UUID() var name: String { "Modifiable Preview Store" } let secrets: [Secret] + var supportedKeyTypes: [KeyType] { + [.init(algorithm: .ecdsa, size: 256)] + } init(secrets: [Secret]) { self.secrets = secrets @@ -91,7 +93,7 @@ extension Preview { } - func create(name: String, requiresAuthentication: Bool) throws { + func create(name: String, attributes: Attributes) throws { } func delete(secret: Preview.Secret) throws { diff --git a/Sources/Secretive/Views/CreateSecretView.swift b/Sources/Secretive/Views/CreateSecretView.swift index 7900f16..1425415 100644 --- a/Sources/Secretive/Views/CreateSecretView.swift +++ b/Sources/Secretive/Views/CreateSecretView.swift @@ -46,7 +46,7 @@ struct CreateSecretView: View { func save() { Task { - try! await store.create(name: name, requiresAuthentication: requiresAuthentication) + try! await store.create(name: name, attributes: .init(keyType: .init(algorithm: .ecdsa, size: 256), authentication: .presenceRequired, publicKeyAttribution: nil)) showing = false } } diff --git a/Sources/Secretive/Views/SecretListItemView.swift b/Sources/Secretive/Views/SecretListItemView.swift index 58b0f32..1e16d12 100644 --- a/Sources/Secretive/Views/SecretListItemView.swift +++ b/Sources/Secretive/Views/SecretListItemView.swift @@ -26,7 +26,7 @@ struct SecretListItemView: View { var body: some View { NavigationLink(value: secret) { - if secret.requiresAuthentication { + if secret.authenticationRequirement.required { HStack { Text(secret.name) Spacer()