diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index e41a1ef..ed623d5 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -123,7 +123,7 @@ extension Agent { } func signWithoutRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data { - let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, context: authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false)) + let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, context: nil) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false) logger.debug("Agent signed request") @@ -131,20 +131,24 @@ extension Agent { } func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data { - let context: any AuthenticationContextProtocol - let offerPersistence: Bool - if let existing = await authenticationHandler.existingAuthenticationContextProtocol(secret: secret), existing.valid { - context = existing - offerPersistence = false - logger.debug("Using existing auth context") - } else { - context = authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false) - offerPersistence = secret.authenticationRequirement.required - logger.debug("Creating fresh auth context") - } - let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance, context: context) - let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) - try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: offerPersistence) +// let context: any AuthenticationContextProtocol +// let offerPersistence: Bool +// if let existing = await authenticationHandler.existingAuthenticationContextProtocol(for: SignatureRequest(secret: secret, provenance: provenance)) { +// context = existing +// offerPersistence = false +// logger.debug("Using existing auth context") +// } else { +// context = authenticationHandler.createAuthenticationContext(for: SignatureRequest(secret: secret, provenance: provenance)) +// offerPersistence = secret.authenticationRequirement.required +// logger.debug("Creating fresh auth context") +// } + + + + let context = try await authenticationHandler.waitForAuthentication(for: SignatureRequest(secret: secret, provenance: provenance)) + let result = try await store.sign(data: data, with: secret, for: provenance, context: context.laContext) + let signedData = signatureWriter.data(secret: secret, signature: result) + try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false) // FIXME: THIS logger.debug("Agent signed request") return signedData } diff --git a/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift b/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift index 02e4a52..e814dda 100644 --- a/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift +++ b/Sources/Packages/Sources/SecretAgentKit/AuthenticationHandler.swift @@ -8,9 +8,15 @@ public final class AuthenticationContext: AuthenticationContextProtocol { public let secret: AnySecret /// The LAContext used to authorize the persistent context. public let laContext: LAContext - /// An expiration date for the context. - /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. - let monotonicExpiration: UInt64 + + enum Validity { + /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. + case time(monotonicExpiration: UInt64) + case requestIDs(Set) + case exclusive(UUID) + } + + let validity: Validity /// Initializes a context. /// - Parameters: @@ -21,18 +27,31 @@ public final class AuthenticationContext: AuthenticationContextProtocol { self.secret = AnySecret(secret) self.laContext = context let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value - self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) + self.validity = .time(monotonicExpiration: clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)) + } + + init(secret: SecretType, context: LAContext, requestIDs: Set) { + self.secret = AnySecret(secret) + self.laContext = context + self.validity = .requestIDs(requestIDs) + } + + init(secret: SecretType, context: LAContext, requestID: UUID) { + self.secret = AnySecret(secret) + self.laContext = context + self.validity = .exclusive(requestID) } /// A boolean describing whether or not the context is still valid. - public var valid: Bool { - clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration - } - - public var expiration: Date { - let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) - let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value - return Date(timeIntervalSinceNow: remainingInSeconds) + public func valid(for request: SignatureRequest) -> Bool { + switch validity { + case .time(let monotonicExpiration): + clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration + case .requestIDs(let set): + set.contains(request.id) + case .exclusive(let id): + id == request.id + } } } @@ -40,19 +59,61 @@ public final class AuthenticationContext: AuthenticationContextProtocol { public actor AuthenticationHandler { private var persistedContexts: [AnySecret: AuthenticationContext] = [:] + private var holdingRequests: Set = [] + private var activeTask: Task? - public init() { + private var lastBatchAuthPresentation: Set? + private var presentBatchAuth: ((Set, @Sendable (Set) async throws -> Void) async throws -> Void)? + + public init(presentBatchAuth: ((Set, @Sendable (Set) async throws -> Void) async throws -> Void)?) { + self.presentBatchAuth = presentBatchAuth } - public nonisolated func createAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance, preauthorize: Bool) -> AuthenticationContextProtocol { - let newContext = LAContext() - newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) - newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) - return AuthenticationContext(secret: secret, context: newContext, duration: 0) + public func waitForAuthentication(for request: SignatureRequest) async throws -> any AuthenticationContextProtocol { + if let existing = existingAuthenticationContext(for: request) { return existing } + holdingRequests.insert(request) + defer { holdingRequests.remove(request) } + while holdingRequests.count > 1 { + if hasBatchableRequests, holdingRequests != lastBatchAuthPresentation { + activeTask?.cancel() + lastBatchAuthPresentation = holdingRequests + try await presentBatchAuth?(holdingRequests) { + try await persistAuthentication(for: $0) + } + } + if let preauthorized = existingAuthenticationContext(for: request) { + return preauthorized + } + try await Task.sleep(for: .milliseconds(100)) + } + let laContext = LAContext() + laContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: request.provenance.origin.displayName, secretName: request.secret.name)) + laContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + let context = AuthenticationContext(secret: request.secret, context: laContext, requestID: request.id) + + activeTask = Task { + _ = try? await laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: laContext.localizedReason) + } + _ = try await activeTask?.value + if activeTask?.isCancelled ?? false { + while true { + if let preauthorized = existingAuthenticationContext(for: request) { + return preauthorized + } + try await Task.sleep(for: .milliseconds(100)) + } + } + return context } - public func existingAuthenticationContextProtocol(secret: SecretType) -> AuthenticationContextProtocol? { - guard let persisted = persistedContexts[AnySecret(secret)], persisted.valid else { return nil } + private var hasBatchableRequests: Bool { + guard presentBatchAuth != nil else { return false } + // FIXME: THIS + return holdingRequests.count > 1 + } + + private func existingAuthenticationContext(for request: SignatureRequest) -> (any AuthenticationContextProtocol)? { + guard let persisted = persistedContexts[request.secret], persisted.valid(for: request) else { return nil } return persisted } @@ -74,5 +135,19 @@ public actor AuthenticationHandler { persistedContexts[AnySecret(secret)] = context } + private func persistAuthentication(for requests: Set) async throws { + activeTask?.cancel() + guard let first = requests.first else { return } + let newContext = LAContext() + newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + + newContext.localizedReason = String("Multiple") +// newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString)) + let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) + guard success else { return } + let context = AuthenticationContext(secret: first.secret, context: newContext, requestIDs: Set(requests.map(\.id))) + persistedContexts[AnySecret(first.secret)] = context + } + } diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index bd57ae0..7919332 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -9,7 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { private let _id: @Sendable () -> UUID private let _name: @MainActor @Sendable () -> String private let _secrets: @MainActor @Sendable () -> [AnySecret] - private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, AuthenticationContextProtocol) async throws -> Data + private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, LAContext?) async throws -> Data private let _reloadSecrets: @Sendable () async -> Void public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { @@ -38,7 +38,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { return _secrets() } - public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data { + public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data { try await _sign(data, secret, provenance, context) } diff --git a/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift index 87cc7a5..c22dec0 100644 --- a/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift +++ b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift @@ -2,13 +2,27 @@ import Foundation import LocalAuthentication /// Protocol describing an authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time. -public protocol AuthenticationContextProtocol: Sendable { +public protocol AuthenticationContextProtocol: Sendable, Identifiable { /// Whether the context remains valid. - var valid: Bool { get } - /// The date at which the authorization expires and the context becomes invalid. - var expiration: Date { get } var secret: AnySecret { get } var laContext: LAContext { get } + + func valid(for request: SignatureRequest) -> Bool + +} + +public struct SignatureRequest: Identifiable, Hashable, Sendable { + public let id: UUID + public let date: Date + public let secret: AnySecret + public let provenance: SigningRequestProvenance + + public init(secret: AnySecret, provenance: SigningRequestProvenance) { + self.id = UUID() + self.date = Date() + self.secret = secret + self.provenance = provenance + } } diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index 8d2fe4f..08064a1 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -1,4 +1,5 @@ import Foundation +import LocalAuthentication /// Manages access to Secrets, and performs signature operations on data using those Secrets. public protocol SecretStore: Identifiable, Sendable { @@ -20,7 +21,7 @@ public protocol SecretStore: Identifiable, Sendable { /// - secret: The ``Secret`` to sign with. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - Returns: The signed data. - func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data + func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data /// Requests that the store reload secrets from any backing store, if neccessary. func reloadSecrets() async diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 6b6e7cd..d72f07f 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -36,7 +36,7 @@ extension SecureEnclave { // MARK: SecretStore - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data { + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data { let queryAttributes = KeychainDictionary([ kSecClass: Constants.keyClass, @@ -62,15 +62,15 @@ extension SecureEnclave { switch attributes.keyType { case .ecdsa256: - let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context) return try key.signature(for: data).rawRepresentation case .mldsa65: guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) + let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context) return try key.signature(for: data) case .mldsa87: guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } - let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) + let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context) return try key.signature(for: data) default: throw UnsupportedAlgorithmError() diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index b1d8abf..522939c 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -56,14 +56,15 @@ extension SmartCard { // MARK: Public API - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data { + + public func sign(data: Data, with secret: SmartCard.Secret, for provenance: SigningRequestProvenance, context: LAContext?) async throws -> Data { guard let tokenID = await state.tokenID else { fatalError() } let attributes = KeychainDictionary([ kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrApplicationLabel: secret.id as CFData, kSecAttrTokenID: tokenID, - kSecUseAuthenticationContext: context, + kSecUseAuthenticationContext: context!, // FIXME: THIS kSecReturnRef: true ]) var untyped: CFTypeRef? diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index 8ab7d25..14e7c35 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -22,7 +22,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() private let updater = Updater(checkOnLaunch: true) private let notifier = Notifier() - private let authenticationHandler = AuthenticationHandler() + private let authenticationHandler = AuthenticationHandler { pending, authorize in + print(pending) + print("Waiting") +// Task { + try await Task.sleep(for: .seconds(3)) + try await authorize(pending) +// } + } private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory) private lazy var agent: Agent = { Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier)