mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-04-21 00:27:24 +02:00
Messy auth batching infra WIP
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user