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

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
/// 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.
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 {
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
private var unscopedPersistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
private var scopedPersistedAuthenticationContexts: [ScopedPersistentAuthenticationContext<SecretType>: PersistentAuthenticationContext<SecretType>] = [:]
package init() {
}
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
package func existingPersistedAuthenticationContext(secret: SecretType, provenance: SigningRequestProvenance) -> PersistentAuthenticationContext<SecretType>? {
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<SecretType: Secret>: 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
}
}

View File

@@ -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<SecretStoreType>(_ 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 {

View File

@@ -26,7 +26,7 @@ public protocol SecretStore<SecretType>: 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<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.
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

View File

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

View File

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

View File

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