Messy auth batching infra WIP

This commit is contained in:
Max Goedjen
2026-04-11 00:46:39 -07:00
parent 8696a2c9c0
commit c6c4ea60be
8 changed files with 151 additions and 49 deletions

View File

@@ -123,7 +123,7 @@ extension Agent {
} }
func signWithoutRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data { 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) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false) try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false)
logger.debug("Agent signed request") logger.debug("Agent signed request")
@@ -131,20 +131,24 @@ extension Agent {
} }
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data { func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
let context: any AuthenticationContextProtocol // let context: any AuthenticationContextProtocol
let offerPersistence: Bool // let offerPersistence: Bool
if let existing = await authenticationHandler.existingAuthenticationContextProtocol(secret: secret), existing.valid { // if let existing = await authenticationHandler.existingAuthenticationContextProtocol(for: SignatureRequest(secret: secret, provenance: provenance)) {
context = existing // context = existing
offerPersistence = false // offerPersistence = false
logger.debug("Using existing auth context") // logger.debug("Using existing auth context")
} else { // } else {
context = authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false) // context = authenticationHandler.createAuthenticationContext(for: SignatureRequest(secret: secret, provenance: provenance))
offerPersistence = secret.authenticationRequirement.required // offerPersistence = secret.authenticationRequirement.required
logger.debug("Creating fresh auth context") // 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 = 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") logger.debug("Agent signed request")
return signedData return signedData
} }

View File

@@ -8,9 +8,15 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
public let secret: AnySecret public let secret: AnySecret
/// The LAContext used to authorize the persistent context. /// The LAContext used to authorize the persistent context.
public let laContext: LAContext public let laContext: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back. enum Validity {
let monotonicExpiration: UInt64 /// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
case time(monotonicExpiration: UInt64)
case requestIDs(Set<UUID>)
case exclusive(UUID)
}
let validity: Validity
/// Initializes a context. /// Initializes a context.
/// - Parameters: /// - Parameters:
@@ -21,18 +27,31 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
self.secret = AnySecret(secret) self.secret = AnySecret(secret)
self.laContext = context self.laContext = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value 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<SecretType: Secret>(secret: SecretType, context: LAContext, requestIDs: Set<UUID>) {
self.secret = AnySecret(secret)
self.laContext = context
self.validity = .requestIDs(requestIDs)
}
init<SecretType: Secret>(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. /// A boolean describing whether or not the context is still valid.
public var valid: Bool { public func valid(for request: SignatureRequest) -> Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration switch validity {
} case .time(let monotonicExpiration):
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
public var expiration: Date { case .requestIDs(let set):
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC) set.contains(request.id)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value case .exclusive(let id):
return Date(timeIntervalSinceNow: remainingInSeconds) id == request.id
}
} }
} }
@@ -40,19 +59,61 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
public actor AuthenticationHandler { public actor AuthenticationHandler {
private var persistedContexts: [AnySecret: AuthenticationContext] = [:] private var persistedContexts: [AnySecret: AuthenticationContext] = [:]
private var holdingRequests: Set<SignatureRequest> = []
private var activeTask: Task<Void, any Error>?
public init() { private var lastBatchAuthPresentation: Set<SignatureRequest>?
private var presentBatchAuth: ((Set<SignatureRequest>, @Sendable (Set<SignatureRequest>) async throws -> Void) async throws -> Void)?
public init(presentBatchAuth: ((Set<SignatureRequest>, @Sendable (Set<SignatureRequest>) async throws -> Void) async throws -> Void)?) {
self.presentBatchAuth = presentBatchAuth
} }
public nonisolated func createAuthenticationContext<SecretType: Secret>(secret: SecretType, provenance: SigningRequestProvenance, preauthorize: Bool) -> AuthenticationContextProtocol { public func waitForAuthentication(for request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
let newContext = LAContext() if let existing = existingAuthenticationContext(for: request) { return existing }
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) holdingRequests.insert(request)
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) defer { holdingRequests.remove(request) }
return AuthenticationContext(secret: secret, context: newContext, duration: 0) 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<SecretType: Secret>(secret: SecretType) -> AuthenticationContextProtocol? { private var hasBatchableRequests: Bool {
guard let persisted = persistedContexts[AnySecret(secret)], persisted.valid else { return nil } 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 return persisted
} }
@@ -74,5 +135,19 @@ public actor AuthenticationHandler {
persistedContexts[AnySecret(secret)] = context persistedContexts[AnySecret(secret)] = context
} }
private func persistAuthentication(for requests: Set<SignatureRequest>) 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
}
} }

View File

@@ -9,7 +9,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: @MainActor @Sendable () -> [AnySecret] 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 private let _reloadSecrets: @Sendable () async -> Void
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore {
@@ -38,7 +38,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
return _secrets() 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) try await _sign(data, secret, provenance, context)
} }

View File

@@ -2,13 +2,27 @@ import Foundation
import LocalAuthentication 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. /// 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. /// 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 secret: AnySecret { get }
var laContext: LAContext { 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
}
} }

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import LocalAuthentication
/// Manages access to Secrets, and performs signature operations on data using those Secrets. /// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore<SecretType>: Identifiable, Sendable { public protocol SecretStore<SecretType>: Identifiable, Sendable {
@@ -20,7 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - secret: The ``Secret`` to sign with. /// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: The signed data. /// - 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. /// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async func reloadSecrets() async

View File

@@ -36,7 +36,7 @@ extension SecureEnclave {
// MARK: SecretStore // 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([ let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
@@ -62,15 +62,15 @@ extension SecureEnclave {
switch attributes.keyType { switch attributes.keyType {
case .ecdsa256: 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 return try key.signature(for: data).rawRepresentation
case .mldsa65: case .mldsa65:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } 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) return try key.signature(for: data)
case .mldsa87: case .mldsa87:
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() } 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) return try key.signature(for: data)
default: default:
throw UnsupportedAlgorithmError() throw UnsupportedAlgorithmError()

View File

@@ -56,14 +56,15 @@ extension SmartCard {
// MARK: Public API // 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() } guard let tokenID = await state.tokenID else { fatalError() }
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData, kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID, kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context, kSecUseAuthenticationContext: context!, // FIXME: THIS
kSecReturnRef: true kSecReturnRef: true
]) ])
var untyped: CFTypeRef? var untyped: CFTypeRef?

View File

@@ -22,7 +22,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}() }()
private let updater = Updater(checkOnLaunch: true) private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier() 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 let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
private lazy var agent: Agent = { private lazy var agent: Agent = {
Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier) Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier)