mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-04-10 11:17:24 +02:00
Compare commits
2 Commits
main
...
auth_split
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b12d6df1e | ||
|
|
b68c82ae69 |
@@ -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")
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import LocalAuthentication
|
||||
@unsafe @preconcurrency import LocalAuthentication
|
||||
import SecretKit
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||
public final class AuthenticationContext: AuthenticationContextProtocol {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: SecretType
|
||||
public let secret: AnySecret
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
package nonisolated(unsafe) let context: LAContext
|
||||
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
|
||||
@@ -16,38 +17,46 @@ package final class PersistentAuthenticationContext<SecretType: Secret>: Persist
|
||||
/// - 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
|
||||
init<SecretType: Secret>(secret: SecretType, context: LAContext, duration: TimeInterval) {
|
||||
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)
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
package var valid: Bool {
|
||||
public var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
package var expiration: Date {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
public actor AuthenticationHandler: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
private var persistedContexts: [AnySecret: AuthenticationContext] = [:]
|
||||
|
||||
package init() {
|
||||
public init() {
|
||||
}
|
||||
|
||||
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
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 existingAuthenticationContextProtocol<SecretType: Secret>(secret: SecretType) -> AuthenticationContextProtocol? {
|
||||
guard let persisted = persistedContexts[AnySecret(secret)], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||
public func persistAuthentication<SecretType: Secret>(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
@@ -61,8 +70,8 @@ package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
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
|
||||
let context = AuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
persistedContexts[AnySecret(secret)] = context
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
||||
|
||||
### Authentication Persistence
|
||||
|
||||
- ``PersistedAuthenticationContext``
|
||||
- ``AuthenticationContextProtocol``
|
||||
|
||||
### Errors
|
||||
|
||||
|
||||
@@ -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<SecretStoreType>(_ 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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -20,20 +20,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) 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,6 @@ extension SecureEnclave {
|
||||
}
|
||||
public let id = UUID()
|
||||
public let name = String(localized: .secureEnclave)
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -34,7 +34,6 @@ extension SmartCard {
|
||||
public var secrets: [Secret] {
|
||||
state.secrets
|
||||
}
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user