This commit is contained in:
Max Goedjen 2025-08-17 11:56:35 -05:00
parent 52e61735c9
commit e9a5729e07
No known key found for this signature in database
5 changed files with 65 additions and 74 deletions

View File

@ -3,6 +3,41 @@ import SecretKit
extension SecureEnclave {
/// A context describing a persisted authentication.
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
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: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
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.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
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)
}
}
actor PersistentAuthenticationHandler: Sendable {
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]

View File

@ -19,9 +19,9 @@ extension SecureEnclave {
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
/// Initializes a Store.
public init() {
@MainActor public init() {
loadSecrets()
Task {
await loadSecrets()
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false)
}
@ -194,7 +194,7 @@ extension SecureEnclave.Store {
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets
secrets.removeAll()
await loadSecrets()
loadSecrets()
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
@ -204,7 +204,7 @@ extension SecureEnclave.Store {
}
/// Loads all secrets from the store.
private func loadSecrets() async {
@MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
@ -255,10 +255,8 @@ extension SecureEnclave.Store {
}
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
}
Task { @MainActor in
secrets.append(contentsOf: wrapped)
}
}
/// Saves a public key.
/// - Parameters:
@ -292,42 +290,3 @@ extension SecureEnclave {
}
}
extension SecureEnclave {
/// A context describing a persisted authentication.
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
/// The LAContext used to authorize the persistent context.
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: Secret, context: LAContext, duration: TimeInterval) {
self.secret = secret
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.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
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)
}
}
}

View File

@ -6,19 +6,23 @@ import SmartCardSecretKit
import Brief
extension EnvironmentValues {
private static let _secretStoreList: SecretStoreList = {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList()
Task { @MainActor in
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
}
return list
}()
@Entry var secretStoreList = _secretStoreList
private static let _agentStatusChecker = AgentStatusChecker()
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
private static let _updater: any UpdaterProtocol = Updater(checkOnLaunch: true)
@Entry var updater: any UpdaterProtocol = _updater
@MainActor var secretStoreList: SecretStoreList {
EnvironmentValues._secretStoreList
}
}
@main
@ -33,8 +37,7 @@ struct Secretive: App {
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
// This one is explicitly injected via environment to support hasRunSetup.
// .environment(Updater(checkOnLaunch: hasRunSetup))
.environment(EnvironmentValues._secretStoreList)
.onAppear {
if !hasRunSetup {
showingSetup = true

View File

@ -13,7 +13,7 @@ struct ContentView: View {
@State var activeSecret: AnySecret?
@Environment(\.colorScheme) var colorScheme
@Environment(\.secretStoreList) private var storeList: SecretStoreList
@Environment(\.secretStoreList) private var storeList
@Environment(\.updater) private var updater: any UpdaterProtocol
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol

View File

@ -6,7 +6,7 @@ struct StoreListView: View {
@Binding var activeSecret: AnySecret?
@Environment(\.secretStoreList) private var storeList: SecretStoreList
@Environment(\.secretStoreList) private var storeList
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
@ -22,9 +22,6 @@ struct StoreListView: View {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
if store.secrets.isEmpty {
EmptyStoreView(store: store)
} else {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
@ -37,24 +34,21 @@ struct StoreListView: View {
}
}
}
}
} detail: {
if let activeSecret {
SecretDetailView(secret: activeSecret)
} else if let nextDefaultSecret {
// This just means onAppear hasn't executed yet.
// Do this to avoid a blip.
SecretDetailView(secret: nextDefaultSecret)
} else {
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
}
}
.navigationSplitViewStyle(.balanced)
.onAppear {
withObservationTracking {
_ = nextDefaultSecret
} onChange: {
Task { @MainActor in
activeSecret = nextDefaultSecret
}
}
}
.frame(minWidth: 100, idealWidth: 240)
}