Batch processing WIP

This commit is contained in:
Max Goedjen
2026-03-14 14:53:26 -07:00
parent 4033a5b947
commit 198761f541
9 changed files with 223 additions and 24 deletions

View File

@@ -14,6 +14,7 @@ public final class Agent: Sendable {
private let signatureWriter = OpenSSHSignatureWriter() private let signatureWriter = OpenSSHSignatureWriter()
private let certificateHandler = OpenSSHCertificateHandler() private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") 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. /// Initializes an agent with a store list and a witness.
/// - Parameters: /// - Parameters:
@@ -102,8 +103,15 @@ extension Agent {
throw NoMatchingKeyError() 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) try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
@@ -111,6 +119,8 @@ extension Agent {
logger.debug("Agent signed request") logger.debug("Agent signed request")
try await authorizationCoordinator.completedAuthorization()
return signedData return signedData
} }

View File

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

View File

@@ -1,7 +1,7 @@
import LocalAuthentication import LocalAuthentication
/// A context describing a persisted authentication. /// A context describing a persisted authentication.
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext { package struct PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
/// The Secret to persist authentication for. /// The Secret to persist authentication for.
let secret: SecretType let secret: SecretType
@@ -35,16 +35,27 @@ package final class PersistentAuthenticationContext<SecretType: Secret>: Persist
} }
} }
struct ScopedPersistentAuthenticationContext<SecretType: Secret>: Hashable {
let provenance: SigningRequestProvenance
let secret: SecretType
}
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable { package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:] private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext<SecretType>: PersistentAuthenticationContext<SecretType>] = [:]
package init() { package init() {
} }
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? { package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext<SecretType>? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } if let unscopedPersistence = unscopedPersistedAuthenticationContexts[secret], unscopedPersistence.valid {
return persisted 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 { package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
@@ -62,7 +73,22 @@ package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
guard success else { return } guard success else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) 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
} }
} }

View File

@@ -9,8 +9,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
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) async throws -> Data private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? private let _existingPersistedAuthenticationContext: @Sendable (AnySecret, SigningRequestProvenance) async -> PersistedAuthenticationContext?
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void private let _persistAuthenticationForDuration: @Sendable (AnySecret, TimeInterval) async throws -> Void
private let _persistAuthenticationForProvenance: @Sendable (AnySecret, SigningRequestProvenance) async throws -> Void
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 {
@@ -20,8 +21,9 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
_id = { secretStore.id } _id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } } _secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } _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) } _existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType, provenance: $1) }
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $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() } _reloadSecrets = { await secretStore.reloadSecrets() }
} }
@@ -45,12 +47,16 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
try await _sign(data, secret, provenance) try await _sign(data, secret, provenance)
} }
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: AnySecret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await _existingPersistedAuthenticationContext(secret) await _existingPersistedAuthenticationContext(secret, provenance)
} }
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws { 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 { public func reloadSecrets() async {

View File

@@ -26,7 +26,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists. /// - 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. /// Persists user authorization for access to a secret.
/// - Parameters: /// - Parameters:
@@ -35,6 +35,8 @@ public protocol SecretStore<SecretType>: 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. /// - 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, 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. /// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets() async func reloadSecrets() async

View File

@@ -2,7 +2,7 @@ import Foundation
import AppKit import AppKit
/// Describes the chain of applications that requested a signature operation. /// 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. /// 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` /// - 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 } chain.allSatisfy { $0.validSignature }
} }
public func isSameProvenance(as other: SigningRequestProvenance) -> Bool {
zip(chain, other.chain).allSatisfy { $0.isSameProcess(as: $1) }
}
} }
extension SigningRequestProvenance { extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain. /// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable, Sendable { public struct Process: Equatable, Sendable, Hashable {
/// The pid of the process. /// The pid of the process.
public let pid: Int32 public let pid: Int32
@@ -71,6 +75,15 @@ extension SigningRequestProvenance {
appName ?? processName 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
}
} }
} }

View File

@@ -39,7 +39,7 @@ extension SecureEnclave {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
var context: LAContext 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 context = unsafe existing.context
} else { } else {
let newContext = LAContext() let newContext = LAContext()
@@ -88,14 +88,18 @@ extension SecureEnclave {
} }
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
} }
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) 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() { @MainActor public func reloadSecrets() {
let before = secrets let before = secrets
secrets.removeAll() secrets.removeAll()

View File

@@ -60,7 +60,7 @@ extension SmartCard {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
var context: LAContext 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 context = unsafe existing.context
} else { } else {
let newContext = LAContext() let newContext = LAContext()
@@ -93,14 +93,18 @@ extension SmartCard {
return signature as Data return signature as Data
} }
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret, provenance: SigningRequestProvenance) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret, provenance: provenance)
} }
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) 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. /// Reloads all secrets from the store.
@MainActor public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()

View File

@@ -69,7 +69,7 @@ final class Notifier: Sendable {
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive 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 notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
} }
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { 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) 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 { func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore) await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
let notificationCenter = UNUserNotificationCenter.current() 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 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 { func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws {
await notify(accessTo: secret, from: store, by: provenance) await notify(accessTo: secret, from: store, by: provenance)
} }