mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-03-30 13:57:25 +02:00
Batch processing WIP
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user