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 {
|
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)
|
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false)
|
try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: false)
|
||||||
logger.debug("Agent signed request")
|
logger.debug("Agent signed request")
|
||||||
@@ -131,20 +131,24 @@ extension Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
|
func signWithRequiredAuthentication(data: Data, store: AnySecretStore, secret: AnySecret, provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
let context: any AuthenticationContextProtocol
|
// let context: any AuthenticationContextProtocol
|
||||||
let offerPersistence: Bool
|
// let offerPersistence: Bool
|
||||||
if let existing = await authenticationHandler.existingAuthenticationContextProtocol(secret: secret), existing.valid {
|
// if let existing = await authenticationHandler.existingAuthenticationContextProtocol(for: SignatureRequest(secret: secret, provenance: provenance)) {
|
||||||
context = existing
|
// context = existing
|
||||||
offerPersistence = false
|
// offerPersistence = false
|
||||||
logger.debug("Using existing auth context")
|
// logger.debug("Using existing auth context")
|
||||||
} else {
|
// } else {
|
||||||
context = authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false)
|
// context = authenticationHandler.createAuthenticationContext(for: SignatureRequest(secret: secret, provenance: provenance))
|
||||||
offerPersistence = secret.authenticationRequirement.required
|
// offerPersistence = secret.authenticationRequirement.required
|
||||||
logger.debug("Creating fresh auth context")
|
// 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 = 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")
|
logger.debug("Agent signed request")
|
||||||
return signedData
|
return signedData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
|
|||||||
public let secret: AnySecret
|
public let secret: AnySecret
|
||||||
/// The LAContext used to authorize the persistent context.
|
/// The LAContext used to authorize the persistent context.
|
||||||
public let laContext: LAContext
|
public let laContext: LAContext
|
||||||
/// An expiration date for the context.
|
|
||||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
enum Validity {
|
||||||
let monotonicExpiration: UInt64
|
/// - 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.
|
/// Initializes a context.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -21,18 +27,31 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
|
|||||||
self.secret = AnySecret(secret)
|
self.secret = AnySecret(secret)
|
||||||
self.laContext = context
|
self.laContext = context
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
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.
|
/// A boolean describing whether or not the context is still valid.
|
||||||
public var valid: Bool {
|
public func valid(for request: SignatureRequest) -> Bool {
|
||||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
switch validity {
|
||||||
}
|
case .time(let monotonicExpiration):
|
||||||
|
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||||
public var expiration: Date {
|
case .requestIDs(let set):
|
||||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
set.contains(request.id)
|
||||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
case .exclusive(let id):
|
||||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
id == request.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -40,19 +59,61 @@ public final class AuthenticationContext: AuthenticationContextProtocol {
|
|||||||
public actor AuthenticationHandler {
|
public actor AuthenticationHandler {
|
||||||
|
|
||||||
private var persistedContexts: [AnySecret: AuthenticationContext] = [:]
|
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 {
|
public func waitForAuthentication(for request: SignatureRequest) async throws -> any AuthenticationContextProtocol {
|
||||||
let newContext = LAContext()
|
if let existing = existingAuthenticationContext(for: request) { return existing }
|
||||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
holdingRequests.insert(request)
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
defer { holdingRequests.remove(request) }
|
||||||
return AuthenticationContext(secret: secret, context: newContext, duration: 0)
|
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? {
|
private var hasBatchableRequests: Bool {
|
||||||
guard let persisted = persistedContexts[AnySecret(secret)], persisted.valid else { return nil }
|
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
|
return persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,5 +135,19 @@ public actor AuthenticationHandler {
|
|||||||
persistedContexts[AnySecret(secret)] = context
|
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 _id: @Sendable () -> UUID
|
||||||
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, AuthenticationContextProtocol) async throws -> Data
|
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, LAContext?) async throws -> Data
|
||||||
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 {
|
||||||
@@ -38,7 +38,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
return _secrets()
|
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)
|
try await _sign(data, secret, provenance, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,27 @@ import Foundation
|
|||||||
import LocalAuthentication
|
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.
|
/// 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.
|
/// 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 secret: AnySecret { get }
|
||||||
|
|
||||||
var laContext: LAContext { 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 Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
||||||
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||||
@@ -20,7 +21,7 @@ public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
|||||||
/// - secret: The ``Secret`` to sign with.
|
/// - secret: The ``Secret`` to sign with.
|
||||||
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
|
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
|
||||||
/// - Returns: The signed data.
|
/// - 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.
|
/// Requests that the store reload secrets from any backing store, if neccessary.
|
||||||
func reloadSecrets() async
|
func reloadSecrets() async
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
// MARK: SecretStore
|
// 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([
|
let queryAttributes = KeychainDictionary([
|
||||||
kSecClass: Constants.keyClass,
|
kSecClass: Constants.keyClass,
|
||||||
@@ -62,15 +62,15 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
switch attributes.keyType {
|
switch attributes.keyType {
|
||||||
case .ecdsa256:
|
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
|
return try key.signature(for: data).rawRepresentation
|
||||||
case .mldsa65:
|
case .mldsa65:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
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)
|
return try key.signature(for: data)
|
||||||
case .mldsa87:
|
case .mldsa87:
|
||||||
guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
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)
|
return try key.signature(for: data)
|
||||||
default:
|
default:
|
||||||
throw UnsupportedAlgorithmError()
|
throw UnsupportedAlgorithmError()
|
||||||
|
|||||||
@@ -56,14 +56,15 @@ extension SmartCard {
|
|||||||
|
|
||||||
// MARK: Public API
|
// 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() }
|
guard let tokenID = await state.tokenID else { fatalError() }
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
kSecAttrApplicationLabel: secret.id as CFData,
|
||||||
kSecAttrTokenID: tokenID,
|
kSecAttrTokenID: tokenID,
|
||||||
kSecUseAuthenticationContext: context,
|
kSecUseAuthenticationContext: context!, // FIXME: THIS
|
||||||
kSecReturnRef: true
|
kSecReturnRef: true
|
||||||
])
|
])
|
||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}()
|
}()
|
||||||
private let updater = Updater(checkOnLaunch: true)
|
private let updater = Updater(checkOnLaunch: true)
|
||||||
private let notifier = Notifier()
|
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 let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory)
|
||||||
private lazy var agent: Agent = {
|
private lazy var agent: Agent = {
|
||||||
Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier)
|
Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier)
|
||||||
|
|||||||
Reference in New Issue
Block a user