This commit is contained in:
Max Goedjen 2025-08-17 22:55:10 -05:00
parent 9749cd6f3e
commit 4882d7cde5
No known key found for this signature in database
18 changed files with 615 additions and 193 deletions

View File

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

View File

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

View File

@ -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<T>(_ 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

View File

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

View File

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

View File

@ -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<SecretType: Secret>(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<SecretType: Secret>(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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: 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
}
}

View File

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