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

View File

@@ -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<UUID>)
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<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.
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<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 {
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<SecretType: Secret>(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<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 _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<SecretStoreType>(_ 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)
}

View File

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

View File

@@ -1,4 +1,5 @@
import Foundation
import LocalAuthentication
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore<SecretType>: Identifiable, Sendable {
@@ -20,7 +21,7 @@ public protocol SecretStore<SecretType>: 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

View File

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

View File

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

View File

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