This commit is contained in:
Max Goedjen 2025-01-05 16:07:11 -08:00
parent 304741e019
commit 576e625b8f
No known key found for this signature in database
14 changed files with 147 additions and 77 deletions

View File

@ -2,7 +2,7 @@ import Foundation
import Synchronization import Synchronization
/// A protocol for retreiving the latest available version of an app. /// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: ObservableObject { public protocol UpdaterProtocol: Observable {
/// The latest update /// The latest update
var update: Release? { get } var update: Release? { get }

View File

@ -5,7 +5,7 @@ import SecretKit
import AppKit import AppKit
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores. /// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public final class Agent { public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let witness: SigningWitness? private let witness: SigningWitness?

View File

@ -2,7 +2,7 @@ import Foundation
import SecretKit import SecretKit
/// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access. /// A protocol that allows conformers to be notified of access to secrets, and optionally prevent access.
public protocol SigningWitness { public protocol SigningWitness: Sendable {
/// A ridiculously named method that notifies the callee that a signing operation is about to be performed using a secret. The callee may `throw` an `Error` to prevent access from occurring. /// A ridiculously named method that notifies the callee that a signing operation is about to be performed using a secret. The callee may `throw` an `Error` to prevent access from occurring.
/// - Parameters: /// - Parameters:

View File

@ -1,13 +1,14 @@
import Foundation import Foundation
import OSLog import OSLog
import Synchronization
/// Manages storage and lookup for OpenSSH certificates. /// Manages storage and lookup for OpenSSH certificates.
public final class OpenSSHCertificateHandler { public final class OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] private let keyBlobsAndNames: Mutex<[AnySecret: (Data, Data)]> = .init([:])
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
public init() { public init() {
@ -20,8 +21,10 @@ public final class OpenSSHCertificateHandler {
logger.log("No certificates, short circuiting") logger.log("No certificates, short circuiting")
return return
} }
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in keyBlobsAndNames.withLock {
partialResult[next] = try? loadKeyblobAndName(for: next) $0 = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
}
} }
} }
@ -29,7 +32,10 @@ public final class OpenSSHCertificateHandler {
/// - Parameter secret: The secret to check for a certificate. /// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret /// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool { public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames[AnySecret(secret)] != nil keyBlobsAndNames.withLock {
$0[AnySecret(secret)] != nil
}
} }
@ -61,7 +67,9 @@ public final class OpenSSHCertificateHandler {
/// - Parameter secret: The secret to search for a certificate with /// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? { public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)] keyBlobsAndNames.withLock {
$0[AnySecret(secret)]
}
} }
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``

View File

@ -2,7 +2,7 @@ import Foundation
import CryptoKit import CryptoKit
/// Generates OpenSSH representations of Secrets. /// Generates OpenSSH representations of Secrets.
public struct OpenSSHKeyWriter { public struct OpenSSHKeyWriter: Sendable {
/// Initializes the writer. /// Initializes the writer.
public init() { public init() {

View File

@ -2,7 +2,7 @@ import Foundation
import OSLog import OSLog
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts. /// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
public final class PublicKeyFileStoreController { public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String private let directory: String

View File

@ -1,13 +1,21 @@
import Foundation import Foundation
import Observation import Observation
import Synchronization
/// A "Store Store," which holds a list of type-erased stores. /// A "Store Store," which holds a list of type-erased stores.
@Observable public final class SecretStoreList: ObservableObject { @Observable public final class SecretStoreList: Sendable {
/// The Stores managed by the SecretStoreList. /// The Stores managed by the SecretStoreList.
public var stores: [AnySecretStore] = [] public var stores: [AnySecretStore] {
__stores.withLock { $0 }
}
private let __stores: Mutex<[AnySecretStore]> = .init([])
/// A modifiable store, if one is available. /// A modifiable store, if one is available.
public var modifiableStore: AnySecretStoreModifiable? public var modifiableStore: AnySecretStoreModifiable? {
__modifiableStore.withLock { $0 }
}
private let __modifiableStore: Mutex<AnySecretStoreModifiable?> = .init(nil)
/// Initializes a SecretStoreList. /// Initializes a SecretStoreList.
public init() { public init() {
@ -15,23 +23,33 @@ import Observation
/// Adds a non-type-erased SecretStore to the list. /// Adds a non-type-erased SecretStore to the list.
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) { public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
stores.append(AnySecretStore(store)) __stores.withLock {
$0.append(AnySecretStore(store))
}
} }
/// Adds a non-type-erased modifiable SecretStore. /// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) { public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store) let modifiable = AnySecretStoreModifiable(modifiable: store)
modifiableStore = modifiable __modifiableStore.withLock {
stores.append(modifiable) $0 = modifiable
}
__stores.withLock {
$0.append(modifiable)
}
} }
/// A boolean describing whether there are any Stores available. /// A boolean describing whether there are any Stores available.
public var anyAvailable: Bool { public var anyAvailable: Bool {
stores.reduce(false, { $0 || $1.isAvailable }) __stores.withLock {
$0.reduce(false, { $0 || $1.isAvailable })
}
} }
public var allSecrets: [AnySecret] { public var allSecrets: [AnySecret] {
stores.flatMap(\.secrets) __stores.withLock {
$0.flatMap(\.secrets)
}
} }
} }

View File

@ -6,6 +6,7 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import Observation
@main @main
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
@ -31,19 +32,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) { func applicationDidFinishLaunching(_ aNotification: Notification) {
logger.debug("SecretAgent finished launching") logger.debug("SecretAgent finished launching")
// DispatchQueue.main.async { Task { @MainActor in
// self.socketController.handler = self.agent.handle(reader:writer:) socketController.handler = { [agent] reader, writer in
// } await agent.handle(reader: reader, writer: writer)
// NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in }
// try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) }
// } Task {
for await _ in NotificationCenter.default.notifications(named: .secretStoreReloaded) {
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
}
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
notifier.prompt() notifier.prompt()
// updateSink = updater.$update.sink { update in _ = withObservationTracking {
// guard let update = update else { return } updater.update
// self.notifier.notify(update: update, ignore: self.updater.ignore(release:)) } onChange: { [updater, notifier] in
// } notifier.notify(update: updater.update!) { release in
Task {
await updater.ignore(release: release)
}
}
}
} }
} }

View File

@ -4,8 +4,9 @@ import AppKit
import SecretKit import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import Synchronization
class Notifier { final class Notifier: Sendable {
private let notificationDelegate = NotificationDelegate() private let notificationDelegate = NotificationDelegate()
@ -34,7 +35,9 @@ class Notifier {
guard let string = formatter.string(from: seconds)?.capitalized else { continue } guard let string = formatter.string(from: seconds)?.capitalized else { continue }
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)") let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
let action = UNNotificationAction(identifier: identifier, title: string, options: []) let action = UNNotificationAction(identifier: identifier, title: string, options: [])
notificationDelegate.persistOptions[identifier] = seconds notificationDelegate.state.withLock { state in
state.persistOptions[identifier] = seconds
}
allPersistenceActions.append(action) allPersistenceActions.append(action)
} }
@ -45,9 +48,11 @@ class Notifier {
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate UNUserNotificationCenter.current().delegate = notificationDelegate
notificationDelegate.persistAuthentication = { secret, store, duration in notificationDelegate.state.withLock { state in
guard let duration = duration else { return } state.persistAuthentication = { secret, store, duration in
try? await store.persistAuthentication(secret: secret, forDuration: duration) guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration)
}
} }
} }
@ -58,8 +63,10 @@ class Notifier {
} }
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
notificationDelegate.pendingPersistableSecrets[secret.id.description] = secret notificationDelegate.state.withLock { state in
notificationDelegate.pendingPersistableStores[store.id.description] = store state.pendingPersistableSecrets[secret.id.description] = secret
state.pendingPersistableStores[store.id.description] = store
}
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)") notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
@ -74,12 +81,14 @@ class Notifier {
notificationContent.attachments = [attachment] notificationContent.attachments = [attachment]
} }
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
notificationCenter.add(request, withCompletionHandler: nil) try? await notificationCenter.add(request)
} }
func notify(update: Release, ignore: ((Release) -> Void)?) { func notify(update: Release, ignore: ((Release) -> Void)?) {
notificationDelegate.release = update notificationDelegate.state.withLock { [update] state in
notificationDelegate.ignore = ignore state.release = update
// state.ignore = ignore
}
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
if update.critical { if update.critical {
@ -129,15 +138,21 @@ extension Notifier {
} }
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
fileprivate var release: Release?
fileprivate var ignore: ((Release) -> Void)?
fileprivate var persistAuthentication: ((AnySecret, AnySecretStore, TimeInterval?) async -> Void)?
fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
struct State {
typealias PersistAuthentication = ((AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias Ignore = ((Release) -> Void)
fileprivate var release: Release?
fileprivate var ignore: Ignore?
fileprivate var persistAuthentication: PersistAuthentication?
fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
}
fileprivate let state: Mutex<State> = .init(.init())
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
} }
@ -155,27 +170,34 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
func handleUpdateResponse(response: UNNotificationResponse) { func handleUpdateResponse(response: UNNotificationResponse) {
guard let update = release else { return } state.withLock { state in
switch response.actionIdentifier { guard let update = state.release else { return }
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: switch response.actionIdentifier {
NSWorkspace.shared.open(update.html_url) case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
case Notifier.Constants.ignoreActionIdentitifier: NSWorkspace.shared.open(update.html_url)
ignore?(update) case Notifier.Constants.ignoreActionIdentitifier:
default: state.ignore?(update)
fatalError() default:
fatalError()
}
} }
} }
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, let secret = pendingPersistableSecrets[secretID], // let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String, let store = pendingPersistableStores[storeID] // guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, let secret = state.pendingPersistableSecrets[secretID],
else { return } // let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String, let store = state.pendingPersistableStores[storeID]
pendingPersistableSecrets[secretID] = nil // else { return (nil, nil, nil, nil) }
await persistAuthentication?(secret, store, persistOptions[response.actionIdentifier]) // state.pendingPersistableSecrets[secretID] = nil
// return (secret, store, state.persistOptions[response.actionIdentifier], state.persistAuthentication)
// }
// guard let secret, let store, let persistOptions else { return }
// await callback?(secret, store, persistOptions)
} }
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.list, .banner]) func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.list, .banner]
} }
} }

View File

@ -5,9 +5,20 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import Brief import Brief
extension EnvironmentValues {
@Entry var secretStoreList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = AgentStatusChecker()
@Entry var updater: any UpdaterProtocol = Updater(checkOnLaunch: false)
}
@main @main
struct Secretive: App { struct Secretive: App {
private let storeList: SecretStoreList = { private let storeList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) list.add(store: SecureEnclave.Store())
@ -23,10 +34,10 @@ struct Secretive: App {
@SceneBuilder var body: some Scene { @SceneBuilder var body: some Scene {
WindowGroup { WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList) .environment(storeList)
.environmentObject(Updater(checkOnLaunch: hasRunSetup)) .environment(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker) .environment(agentStatusChecker)
.onAppear { .onAppear {
if !hasRunSetup { if !hasRunSetup {
showingSetup = true showingSetup = true

View File

@ -2,15 +2,16 @@ import Foundation
import Combine import Combine
import AppKit import AppKit
import SecretKit import SecretKit
import Observation
protocol AgentStatusCheckerProtocol: ObservableObject { protocol AgentStatusCheckerProtocol: Observable {
var running: Bool { get } var running: Bool { get }
var developmentBuild: Bool { get } var developmentBuild: Bool { get }
} }
class AgentStatusChecker: ObservableObject, AgentStatusCheckerProtocol { @Observable class AgentStatusChecker: AgentStatusCheckerProtocol {
@Published var running: Bool = false var running: Bool = false
init() { init() {
check() check()

View File

@ -4,7 +4,7 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit import SmartCardSecretKit
import Brief import Brief
struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentStatusCheckerProtocol>: View { struct ContentView: View {
@Binding var showingCreation: Bool @Binding var showingCreation: Bool
@Binding var runningSetup: Bool @Binding var runningSetup: Bool
@ -13,9 +13,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
@State var activeSecret: AnySecret.ID? @State var activeSecret: AnySecret.ID?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject private var storeList: SecretStoreList @Environment(\.secretStoreList) private var storeList: SecretStoreList
@EnvironmentObject private var updater: UpdaterType @Environment(\.updater) private var updater: any UpdaterProtocol
@EnvironmentObject private var agentStatusChecker: AgentStatusCheckerType @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
@State private var selectedUpdate: Release? @State private var selectedUpdate: Release?
@State private var showingAppPathNotice = false @State private var showingAppPathNotice = false

View File

@ -6,7 +6,7 @@ struct StoreListView: View {
@Binding var activeSecret: AnySecret.ID? @Binding var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList @Environment(SecretStoreList.self) private var storeList: SecretStoreList
private func secretDeleted(secret: AnySecret) { private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret activeSecret = nextDefaultSecret

View File

@ -3,7 +3,7 @@ import Brief
struct UpdateDetailView<UpdaterType: Updater>: View { struct UpdateDetailView<UpdaterType: Updater>: View {
@EnvironmentObject var updater: UpdaterType @Environment(UpdaterType.self) var updater: UpdaterType
let update: Release let update: Release