diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index ba66603..b34d720 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -9,6 +9,7 @@ import SSHProtocolKit public final class Agent: Sendable { private let storeList: SecretStoreList + private let authenticationHandler: AuthenticationHandler private let witness: SigningWitness? private let publicKeyWriter = OpenSSHPublicKeyWriter() private let signatureWriter = OpenSSHSignatureWriter() @@ -19,9 +20,10 @@ public final class Agent: Sendable { /// - Parameters: /// - storeList: The `SecretStoreList` to make available. /// - witness: A witness to notify of requests. - public init(storeList: SecretStoreList, witness: SigningWitness? = nil) { + public init(storeList: SecretStoreList, authenticationHandler: AuthenticationHandler, witness: SigningWitness? = nil) { logger.debug("Agent is running") self.storeList = storeList + self.authenticationHandler = authenticationHandler self.witness = witness Task { @MainActor in await certificateHandler.reloadCertificates(for: storeList.allSecrets) @@ -104,10 +106,19 @@ extension Agent { try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) - let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) + let context: any AuthenticationContextProtocol + let offerPersistence: Bool + if let existing = await authenticationHandler.existingAuthenticationContextProtocol(secret: secret), existing.valid { + context = existing + offerPersistence = false + } else { + context = authenticationHandler.createAuthenticationContext(secret: secret, provenance: provenance, preauthorize: false) + offerPersistence = secret.authenticationRequirement.required + } + 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) + try await witness?.witness(accessTo: secret, from: store, by: provenance, offerPersistence: offerPersistence) logger.debug("Agent signed request") diff --git a/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift b/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift index 2e6ab49..a8cc2e6 100644 --- a/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift +++ b/Sources/Packages/Sources/SecretAgentKit/SigningWitness.swift @@ -17,6 +17,6 @@ public protocol SigningWitness: Sendable { /// - secret: The `Secret` that will was used to sign the request. /// - store: The `Store` that signed the request.. /// - provenance: A `SigningRequestProvenance` object describing the origin of the request. - func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async throws + func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async throws } diff --git a/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift b/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift deleted file mode 100644 index db9f039..0000000 --- a/Sources/Packages/Sources/SecretKit/Convenience/PersistentAuthenticationHandler.swift +++ /dev/null @@ -1,69 +0,0 @@ -import LocalAuthentication - -/// A context describing a persisted authentication. -package final class PersistentAuthenticationContext: PersistedAuthenticationContext { - - /// The Secret to persist authentication for. - let secret: SecretType - /// The LAContext used to authorize the persistent context. - package nonisolated(unsafe) let context: LAContext - /// An expiration date for the context. - /// - Note - Monotonic time instead of Date() to prevent people setting the clock back. - let monotonicExpiration: UInt64 - - /// Initializes a context. - /// - Parameters: - /// - secret: The Secret to persist authentication for. - /// - context: The LAContext used to authorize the persistent context. - /// - duration: The duration of the authorization context, in seconds. - init(secret: SecretType, context: LAContext, duration: TimeInterval) { - self.secret = secret - unsafe self.context = context - let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value - self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds) - } - - /// A boolean describing whether or not the context is still valid. - package var valid: Bool { - clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration - } - - package 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) - } -} - -package actor PersistentAuthenticationHandler: Sendable { - - private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext] = [:] - - package init() { - } - - package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext? { - guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } - return persisted - } - - package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws { - let newContext = LAContext() - newContext.touchIDAuthenticationAllowableReuseDuration = duration - newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) - - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .spellOut - formatter.allowedUnits = [.hour, .minute, .day] - - - let durationString = formatter.string(from: duration)! - 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 = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration) - persistedAuthenticationContexts[secret] = context - } - -} - diff --git a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md index 8798ca6..7882234 100644 --- a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md +++ b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md @@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores. ### Authentication Persistence -- ``PersistedAuthenticationContext`` +- ``AuthenticationContextProtocol`` ### Errors diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index f163879..bd57ae0 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -1,4 +1,5 @@ import Foundation +import LocalAuthentication /// Type eraser for SecretStore. open class AnySecretStore: SecretStore, @unchecked Sendable { @@ -8,9 +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) async throws -> Data - private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? - private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void + private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance, AuthenticationContextProtocol) async throws -> Data private let _reloadSecrets: @Sendable () async -> Void public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStore { @@ -19,9 +18,7 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { _name = { secretStore.name } _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) } + _sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2, context: $3) } _reloadSecrets = { await secretStore.reloadSecrets() } } @@ -41,16 +38,8 @@ open class AnySecretStore: SecretStore, @unchecked Sendable { return _secrets() } - public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) async throws -> Data { - try await _sign(data, secret, provenance) - } - - public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? { - await _existingPersistedAuthenticationContext(secret) - } - - public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) async throws { - try await _persistAuthentication(secret, duration) + public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data { + try await _sign(data, secret, provenance, context) } public func reloadSecrets() async { diff --git a/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift new file mode 100644 index 0000000..87cc7a5 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/Types/AuthenticationContext.swift @@ -0,0 +1,14 @@ +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 { + /// 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 } +} diff --git a/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift b/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift deleted file mode 100644 index edd6dea..0000000 --- a/Sources/Packages/Sources/SecretKit/Types/PersistedAuthenticationContext.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -/// Protocol describing a persisted 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 PersistedAuthenticationContext: Sendable { - /// 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 } -} diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index 42b4db9..8d2fe4f 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -20,20 +20,7 @@ public protocol SecretStore: 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) async throws -> Data - - /// Checks to see if there is currently a valid persisted authentication for a given secret. - /// - 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? - - /// Persists user authorization for access to a secret. - /// - Parameters: - /// - secret: The ``Secret`` to persist the authorization for. - /// - duration: The duration that the authorization should persist for. - /// - 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 sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data /// Requests that the store reload secrets from any backing store, if neccessary. func reloadSecrets() async diff --git a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift index 2216f45..320fe58 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SigningRequestProvenance.swift @@ -2,13 +2,17 @@ import Foundation import AppKit /// Describes the chain of applications that requested a signature operation. -public struct SigningRequestProvenance: Equatable, Sendable { +public struct SigningRequestProvenance: Hashable, Sendable { /// 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` public var chain: [Process] - public init(root: Process) { + + public var date: Date + + public init(root: Process, date: Date = .now) { self.chain = [root] + self.date = date } } @@ -30,7 +34,7 @@ extension SigningRequestProvenance { extension SigningRequestProvenance { /// Describes a process in a `SigningRequestProvenance` chain. - public struct Process: Equatable, Sendable { + public struct Process: Hashable, Sendable { /// The pid of the process. public let pid: Int32 diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 61fc722..6b6e7cd 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -17,7 +17,6 @@ extension SecureEnclave { } public let id = UUID() public let name = String(localized: .secureEnclave) - private let persistentAuthenticationHandler = PersistentAuthenticationHandler() /// Initializes a Store. @MainActor public init() { @@ -37,16 +36,7 @@ extension SecureEnclave { // MARK: SecretStore - 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) { - context = unsafe existing.context - } else { - let newContext = LAContext() - newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) - newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) - context = newContext - } + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol) async throws -> Data { let queryAttributes = KeychainDictionary([ kSecClass: Constants.keyClass, @@ -72,15 +62,15 @@ extension SecureEnclave { switch attributes.keyType { case .ecdsa256: - let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context) + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) 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) + let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) 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) + let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData, authenticationContext: context.laContext) return try key.signature(for: data) default: throw UnsupportedAlgorithmError() @@ -88,14 +78,6 @@ extension SecureEnclave { } - public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) - } - - public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { - try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) - } - @MainActor public func reloadSecrets() { let before = secrets secrets.removeAll() diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index 77ed5e2..b1d8abf 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -34,7 +34,6 @@ extension SmartCard { public var secrets: [Secret] { state.secrets } - private let persistentAuthenticationHandler = PersistentAuthenticationHandler() /// Initializes a Store. public init() { @@ -57,17 +56,8 @@ extension SmartCard { // MARK: Public API - 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, context: AuthenticationContextProtocol) async throws -> Data { guard let tokenID = await state.tokenID else { fatalError() } - var context: LAContext - if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { - context = unsafe existing.context - } else { - let newContext = LAContext() - newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name)) - newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton) - context = newContext - } let attributes = KeychainDictionary([ kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, @@ -93,14 +83,6 @@ extension SmartCard { return signature as Data } - public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { - await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) - } - - public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { - try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) - } - /// Reloads all secrets from the store. @MainActor public func reloadSecrets() { reloadSecretsInternal() diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift index 381f14d..6d04f29 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift @@ -49,7 +49,7 @@ extension Stub { print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))") } - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data { guard !shouldThrow else { throw NSError(domain: "test", code: 0, userInfo: nil) } @@ -57,7 +57,7 @@ extension Stub { return try privateKey.signature(for: data).rawRepresentation } - public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { + public func existingAuthenticationContextProtocol(secret: Stub.Secret) -> AuthenticationContextProtocol? { nil } diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index 40a11a3..8ab7d25 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -22,9 +22,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() private let updater = Updater(checkOnLaunch: true) private let notifier = Notifier() + private let authenticationHandler = AuthenticationHandler() private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory) private lazy var agent: Agent = { - Agent(storeList: storeList, witness: notifier) + Agent(storeList: storeList, authenticationHandler: authenticationHandler, witness: notifier) }() private lazy var socketController: SocketController = { let path = URL.socketPath as String @@ -50,6 +51,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } + Task { [notifier, authenticationHandler] in + await notifier.registerPersistenceHandler { + try await authenticationHandler.persistAuthentication(secret: $0, forDuration: $1) + } + } Task { for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) { try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index fa48cdd..bdb50a8 100644 --- a/Sources/SecretAgent/Notifier.swift +++ b/Sources/SecretAgent/Notifier.swift @@ -5,6 +5,8 @@ import SecretKit import SecretAgentKit import Brief +typealias PersistAction = (@Sendable (AnySecret, TimeInterval) async throws -> Void) + final class Notifier: Sendable { private let notificationDelegate = NotificationDelegate() @@ -15,6 +17,12 @@ final class Notifier: Sendable { let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: []) let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory]) + UNUserNotificationCenter.current().delegate = notificationDelegate + + } + + func registerPersistenceHandler(action: @escaping PersistAction) async { let rawDurations = [ Measurement(value: 1, unit: UnitDuration.minutes), Measurement(value: 5, unit: UnitDuration.minutes), @@ -24,11 +32,9 @@ final class Notifier: Sendable { let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: []) var allPersistenceActions = [doNotPersistAction] - let formatter = DateComponentsFormatter() formatter.unitsStyle = .spellOut formatter.allowedUnits = [.hour, .minute, .day] - var identifiers: [String: TimeInterval] = [:] for duration in rawDurations { let seconds = duration.converted(to: .seconds).value @@ -43,16 +49,11 @@ final class Notifier: Sendable { if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) { persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle") } - UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) - UNUserNotificationCenter.current().delegate = notificationDelegate - - Task { - await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in - guard let duration = duration else { return } - try? await store.persistAuthentication(secret: secret, forDuration: duration) - } - } + var categories = await UNUserNotificationCenter.current().notificationCategories() + categories.insert(persistAuthenticationCategory) + UNUserNotificationCenter.current().setNotificationCategories(categories) + await notificationDelegate.state.setPersistenceState(options: identifiers, action: action) } func prompt() { @@ -60,7 +61,7 @@ final class Notifier: Sendable { notificationCenter.requestAuthorization(options: .alert) { _, _ in } } - func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { + func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async { await notificationDelegate.state.setPending(secret: secret, store: store) let notificationCenter = UNUserNotificationCenter.current() let notificationContent = UNMutableNotificationContent() @@ -69,7 +70,7 @@ final class Notifier: Sendable { notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.interruptionLevel = .timeSensitive - if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required { + if offerPersistence { notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier } if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { @@ -103,8 +104,8 @@ extension Notifier: SigningWitness { func speakNowOrForeverHoldYourPeace(forAccessTo 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) + func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, offerPersistence: Bool) async throws { + await notify(accessTo: secret, from: store, by: provenance, offerPersistence: offerPersistence) } } @@ -133,28 +134,24 @@ extension Notifier { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { fileprivate actor State { - typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) typealias IgnoreAction = (@Sendable (Release) async -> Void) fileprivate var release: Release? fileprivate var ignoreAction: IgnoreAction? fileprivate var persistAction: PersistAction? fileprivate var persistOptions: [String: TimeInterval] = [:] - fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] func setPending(secret: AnySecret, store: AnySecretStore) { pendingPersistableSecrets[secret.id.description] = secret - pendingPersistableStores[store.id.description] = store } - func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? { + func retrievePending(secretID: String, optionID: String) -> (AnySecret, TimeInterval)? { guard let secret = pendingPersistableSecrets[secretID], - let store = pendingPersistableStores[storeID], let options = persistOptions[optionID] else { return nil } pendingPersistableSecrets.removeValue(forKey: secretID) - return (secret, store, options) + return (secret, options) } func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) { @@ -202,13 +199,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se } func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { - guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, - let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else { + guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String else { return } let optionID = response.actionIdentifier - guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return } - await state.persistAction?(secret, store, persistOptions) + guard let (secret, persistOptions) = await state.retrievePending(secretID: secretID, optionID: optionID) else { return } + try? await state.persistAction?(secret, persistOptions) } diff --git a/Sources/Secretive/Preview Content/PreviewStore.swift b/Sources/Secretive/Preview Content/PreviewStore.swift index 63e7507..84e83e8 100644 --- a/Sources/Secretive/Preview Content/PreviewStore.swift +++ b/Sources/Secretive/Preview Content/PreviewStore.swift @@ -38,11 +38,11 @@ extension Preview { self.init(secrets: new) } - func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { + func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data { return data } - func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { + func existingAuthenticationContextProtocol(secret: Preview.Secret) -> AuthenticationContextProtocol? { nil } @@ -82,11 +82,11 @@ extension Preview { self.init(secrets: new) } - func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data { + func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance, context: AuthenticationContextProtocol?) throws -> Data { return data } - func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? { + func existingAuthenticationContextProtocol(secret: Preview.Secret) -> AuthenticationContextProtocol? { nil }