From 198761f541ed162d1628cb53c3761489a8208ef2 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Sat, 14 Mar 2026 14:53:26 -0700 Subject: [PATCH] Batch processing WIP --- .../Sources/SecretAgentKit/Agent.swift | 12 +- .../AuthorizationCoordinator.swift | 111 ++++++++++++++++++ .../PersistentAuthenticationHandler.swift | 38 +++++- .../SecretKit/Erasers/AnySecretStore.swift | 20 ++-- .../Sources/SecretKit/Types/SecretStore.swift | 4 +- .../Types/SigningRequestProvenance.swift | 17 ++- .../SecureEnclaveStore.swift | 10 +- .../SmartCardSecretKit/SmartCardStore.swift | 10 +- Sources/SecretAgent/Notifier.swift | 25 +++- 9 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 Sources/Packages/Sources/SecretAgentKit/AuthorizationCoordinator.swift diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index ba66603..b5b81ad 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -14,6 +14,7 @@ public final class Agent: Sendable { private let signatureWriter = OpenSSHSignatureWriter() private let certificateHandler = OpenSSHCertificateHandler() private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") + private let authorizationCoordinator = AuthorizationCoordinator() /// Initializes an agent with a store list and a witness. /// - Parameters: @@ -102,8 +103,15 @@ extension Agent { throw NoMatchingKeyError() } + let decision = try await authorizationCoordinator.waitForAccessIfNeeded(to: secret, provenance: provenance) + switch decision { + case .proceed: + break + case .promptForSharedAuth: + try? await store.persistAuthentication(secret: secret, forProvenance: provenance) + try await authorizationCoordinator.completedPersistence() + } try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) - let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) @@ -111,6 +119,8 @@ extension Agent { logger.debug("Agent signed request") + try await authorizationCoordinator.completedAuthorization() + return signedData } diff --git a/Sources/Packages/Sources/SecretAgentKit/AuthorizationCoordinator.swift b/Sources/Packages/Sources/SecretAgentKit/AuthorizationCoordinator.swift new file mode 100644 index 0000000..3ba35b5 --- /dev/null +++ b/Sources/Packages/Sources/SecretAgentKit/AuthorizationCoordinator.swift @@ -0,0 +1,111 @@ +import Foundation +import SecretKit +import os +import LocalAuthentication + +struct PendingRequest: Identifiable, Hashable, CustomStringConvertible { + let id: UUID = UUID() + let secret: AnySecret + let provenance: SigningRequestProvenance + + var description: String { + "\(id.uuidString) - \(secret.name) \(provenance.origin.displayName)" + } + + func batchable(with request: PendingRequest) -> Bool { + secret == request.secret && + provenance.isSameProvenance(as: request.provenance) + + } +} + +enum Decision { + case proceed + case promptForSharedAuth +} + +actor RequestHolder { + + var pending: [PendingRequest] = [] + var active: PendingRequest? + var preauthorized: PendingRequest? + + func shouldBlock(_ request: PendingRequest) -> Bool { + if let preauthorized, preauthorized.batchable(with: request) { + print("Batching: \(request)") + pending.removeAll(where: { $0 == request }) + return false + } + let isTurn = request.id == active?.id + if isTurn { + print("turn \(request)") + return false + } + if pending.isEmpty && active == nil { + active = request + return false + } else if !pending.contains(where: { $0.id == request.id }) { + pending.append(request) + } + return true + } + + func clear() { + if let preauthorized, allBatchable(with: preauthorized).isEmpty { + self.preauthorized = nil + } + if !pending.isEmpty { + let next = pending.removeFirst() + active = next + } else { + active = nil + } + } + + func allBatchable(with request: PendingRequest) -> [PendingRequest] { + pending.filter { $0.batchable(with: request) } + } + + func completedPersistence() { + self.preauthorized = active + } +} + +final class AuthorizationCoordinator: Sendable { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "AuthorizationCoordinator") + private let holder = RequestHolder() + + public func waitForAccessIfNeeded(to secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Decision { + // Block on unknown, since we don't really have any way to check. + if secret.authenticationRequirement == .unknown { + logger.warning("\(secret.name) has unknown authentication requirement.") + } + guard secret.authenticationRequirement != .notRequired else { + logger.debug("\(secret.name) does not require authentication, continuing.") + return .proceed + } + logger.debug("\(secret.name) requires authentication.") + let pending = PendingRequest(secret: secret, provenance: provenance) + while !Task.isCancelled, await holder.shouldBlock(pending) { + logger.debug("\(pending) waiting.") + try await Task.sleep(for: .milliseconds(100)) + } + if await holder.preauthorized == nil, await holder.allBatchable(with: pending).count > 0 { + logger.debug("\(pending) batch suggestion.") + return .promptForSharedAuth + } + logger.debug("\(pending) continuing") + return .proceed + } + + func completedAuthorization() async throws { + await holder.clear() + } + + func completedPersistence() async throws { + await holder.completedPersistence() + } + + +} diff --git a/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift b/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift index db9f039..d6ab656 100644 --- a/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift +++ b/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift @@ -1,7 +1,7 @@ import LocalAuthentication /// A context describing a persisted authentication. -package final class PersistentAuthenticationContext: PersistedAuthenticationContext { +package struct PersistentAuthenticationContext: PersistedAuthenticationContext { /// The Secret to persist authentication for. let secret: SecretType @@ -35,16 +35,27 @@ package final class PersistentAuthenticationContext: Persist } } +struct ScopedPersistentAuthenticationContext: Hashable { + let provenance: SigningRequestProvenance + let secret: SecretType +} + package actor PersistentAuthenticationHandler: Sendable { - private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext] = [:] + private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext] = [:] + private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext: PersistentAuthenticationContext] = [:] package init() { } - package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext? { - guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } - return persisted + package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext? { + if let unscopedPersistence = unscopedPersistedAuthenticationContexts[secret], unscopedPersistence.valid { + return unscopedPersistence + } + if let scopedPersistence = scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)], scopedPersistence.valid { + return scopedPersistence + } + return nil } package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws { @@ -62,7 +73,22 @@ package actor PersistentAuthenticationHandler: Sendable { let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) guard success else { return } let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) - persistedAuthenticationContexts[secret] = context + unscopedPersistedAuthenticationContexts[secret] = context + } + + package func persistAuthentication(secret: SecretType, provenance: SigningRequestProvenance) async throws { + let newContext = LAContext() + + // FIXME: TEMPORARY + let duration: TimeInterval = 10000 + newContext.touchIDAuthenticationAllowableReuseDuration = duration + newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) + + newContext.localizedReason = "Batch requests" + let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) + guard success else { return } + let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) + scopedPersistedAuthenticationContexts[.init(provenance: provenance, secret: secret)] = context } } diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index f163879..040ffd4 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -9,8 +9,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { private let _name: @MainActor @Sendable () -> String private let _secrets: @MainActor @Sendable () -> [AnySecret] private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data - private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? - private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void + private let _existingPersistedAuthenticationContext: @Sendable (AnySecret, SigningRequestProvenance) async -> PersistedAuthenticationContext? + private let _persistAuthenticationForDuration: @Sendable (AnySecret, TimeInterval) async throws -> Void + private let _persistAuthenticationForProvenance: @Sendable (AnySecret, SigningRequestProvenance) async throws -> Void private let _reloadSecrets: @Sendable () async -> Void public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { @@ -20,8 +21,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { _id = { secretStore.id } _secrets = { secretStore.secrets.map { AnySecret($0) } } _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } - _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) } - _persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } + _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType, provenance: $1) } + _persistAuthenticationForDuration = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) } + _persistAuthenticationForProvenance = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forProvenance: $1) } _reloadSecrets = { await secretStore.reloadSecrets() } } @@ -45,12 +47,16 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { try await _sign(data, secret, provenance) } - public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? { - await _existingPersistedAuthenticationContext(secret) + public func existingPersistedAuthenticationContext(secret: AnySecret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? { + await _existingPersistedAuthenticationContext(secret, provenance) } public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws { - try await _persistAuthentication(secret, duration) + try await _persistAuthenticationForDuration(secret, duration) + } + + public func persistAuthentication(secret: AnySecret, forProvenance provenance: SigningRequestProvenance) async throws { + try await _persistAuthenticationForProvenance(secret, provenance) } public func reloadSecrets() async { diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index 42b4db9..4d9acf9 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -26,7 +26,7 @@ public protocol SecretStore: Identifiable, Sendable { /// - Parameters: /// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - Returns: A persisted authentication context, if a valid one exists. - func existingPersistedAuthenticationContext(secret: SecretType) async -> PersistedAuthenticationContext? + func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? /// Persists user authorization for access to a secret. /// - Parameters: @@ -35,6 +35,8 @@ public protocol SecretStore: Identifiable, Sendable { /// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret. func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws + func persistAuthentication(secret: SecretType, forProvenance provenance: SigningRequestProvenance) async throws + /// Requests that the store reload secrets from any backing store, if neccessary. func reloadSecrets() async diff --git a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift index 2216f45..611c92f 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift @@ -2,7 +2,7 @@ import Foundation import AppKit /// Describes the chain of applications that requested a signature operation. -public struct SigningRequestProvenance: Equatable, Sendable { +public struct SigningRequestProvenance: Equatable, Sendable, Hashable { /// A list of processes involved in the request. /// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app` @@ -25,12 +25,16 @@ extension SigningRequestProvenance { chain.allSatisfy { $0.validSignature } } + public func isSameProvenance(as other: SigningRequestProvenance) -> Bool { + zip(chain, other.chain).allSatisfy { $0.isSameProcess(as: $1) } + } + } extension SigningRequestProvenance { /// Describes a process in a `SigningRequestProvenance` chain. - public struct Process: Equatable, Sendable { + public struct Process: Equatable, Sendable, Hashable { /// The pid of the process. public let pid: Int32 @@ -71,6 +75,15 @@ extension SigningRequestProvenance { appName ?? processName } + // Whether the + public func isSameProcess(as other: Process) -> Bool { + processName == other.processName && + appName == other.appName && + iconURL == other.iconURL && + path == other.path && + validSignature == other.validSignature + } + } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 61fc722..6302a7c 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -39,7 +39,7 @@ extension SecureEnclave { 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) { + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) { context = unsafe existing.context } else { let newContext = LAContext() @@ -88,14 +88,18 @@ extension SecureEnclave { } - public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) + public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? { + await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) } public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) } + public func persistAuthentication(secret: SecureEnclave.Secret, forProvenance provenance: SigningRequestProvenance) async throws { + try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance) + } + @MainActor public func reloadSecrets() { let before = secrets secrets.removeAll() diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index 77ed5e2..5307c8a 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -60,7 +60,7 @@ extension SmartCard { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { guard let tokenID = await state.tokenID else { fatalError() } var context: LAContext - if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) { context = unsafe existing.context } else { let newContext = LAContext() @@ -93,14 +93,18 @@ extension SmartCard { return signature as Data } - public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) + public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? { + await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) } public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) } + public func persistAuthentication(secret: Secret, forProvenance provenance: SigningRequestProvenance) async throws { + try await persistentAuthenticationHandler.persistAuthentication(secret: secret, provenance: provenance) + } + /// Reloads all secrets from the store. @MainActor public func reloadSecrets() { reloadSecretsInternal() diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index fa48cdd..28bedbe 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.authenticationRequirement.required { + if await store.existingPersistedAuthenticationContext(secret: secret, provenance: provenance) == nil && secret.authenticationRequirement.required { notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier } if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { @@ -79,6 +79,25 @@ final class Notifier: Sendable { try? await notificationCenter.add(request) } + func notify(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { + await notificationDelegate.state.setPending(secret: secret, store: store) + let notificationCenter = UNUserNotificationCenter.current() + let notificationContent = UNMutableNotificationContent() + notificationContent.title = "pending" //String(localized: .signedNotificationTitle(appName: provenance.origin.displayName)) + notificationContent.subtitle = "pending" //String(localized: .signedNotificationDescription(secretName: secret.name)) + notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description + notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description + notificationContent.interruptionLevel = .timeSensitive + notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier + notificationContent.threadIdentifier = "\(secret.id)_\(provenance.hashValue)" + if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { + notificationContent.attachments = [attachment] + } + let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) + try? await notificationCenter.add(request) + + } + func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async { await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore) let notificationCenter = UNUserNotificationCenter.current() @@ -103,6 +122,10 @@ extension Notifier: SigningWitness { func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { } + func witness(pendingAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { + await notify(pendingAccessTo: secret, from: store, by: provenance) + } + func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws { await notify(accessTo: secret, from: store, by: provenance) }