This commit is contained in:
Max Goedjen 2025-08-16 14:55:54 -05:00
parent c227c90fd4
commit 52e61735c9
No known key found for this signature in database
16 changed files with 198 additions and 220 deletions

View File

@ -83,7 +83,6 @@ extension Updater {
let latestVersion = SemVer(release.name) let latestVersion = SemVer(release.name)
if latestVersion > currentVersion { if latestVersion > currentVersion {
await MainActor.run { await MainActor.run {
print("SET \(release)")
state.update = release state.update = release
} }
} }

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import os
/// 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: Observable, Sendable { public protocol UpdaterProtocol: Observable, Sendable {

View File

@ -23,7 +23,7 @@ public final class Agent: Sendable {
self.storeList = storeList self.storeList = storeList
self.witness = witness self.witness = witness
Task { @MainActor in Task { @MainActor in
certificateHandler.reloadCertificates(for: storeList.allSecrets) await certificateHandler.reloadCertificates(for: storeList.allSecrets)
} }
} }
@ -87,7 +87,7 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() async -> Data { func identities() async -> Data {
let secrets = await storeList.allSecrets let secrets = await storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets) await certificateHandler.reloadCertificates(for: secrets)
var count = secrets.count var count = secrets.count
var keyData = Data() var keyData = Data()
@ -97,7 +97,7 @@ extension Agent {
keyData.append(writer.lengthAndData(of: keyBlob)) keyData.append(writer.lengthAndData(of: keyBlob))
keyData.append(writer.lengthAndData(of: curveData)) keyData.append(writer.lengthAndData(of: curveData))
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) { if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData)) keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name)) keyData.append(writer.lengthAndData(of: name))
count += 1 count += 1
@ -119,7 +119,7 @@ extension Agent {
let payloadHash = reader.readNextChunk() let payloadHash = reader.readNextChunk()
let hash: Data let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is // Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) { if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey hash = certificatePublicKey
} else { } else {
hash = payloadHash hash = payloadHash
@ -192,8 +192,9 @@ extension Agent {
/// Gives any store with no loaded secrets a chance to reload. /// Gives any store with no loaded secrets a chance to reload.
func reloadSecretsIfNeccessary() async { func reloadSecretsIfNeccessary() async {
for store in await storeList.stores { for store in await storeList.stores {
if store.secrets.isEmpty { if await store.secrets.isEmpty {
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.") let name = await store.name
logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
await store.reloadSecrets() await store.reloadSecrets()
} }
} }
@ -203,15 +204,15 @@ extension Agent {
/// - Parameter hash: The hash to match against. /// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found. /// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? { func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
await storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in for store in await storeList.stores {
let allMatching = store.secrets.filter { secret in let allMatching = await store.secrets.filter { secret in
hash == writer.data(secret: secret) hash == writer.data(secret: secret)
} }
if let matching = allMatching.first { if let matching = allMatching.first {
return (store, matching) return (store, matching)
} }
return nil }
}.first return nil
} }
} }

View File

@ -4,11 +4,11 @@ import Combine
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
public class AnySecretStore: SecretStore, @unchecked Sendable { public class AnySecretStore: SecretStore, @unchecked Sendable {
let base: Any let base: any Sendable
private let _isAvailable: @Sendable () -> Bool private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @Sendable () -> String private let _name: @MainActor @Sendable () -> String
private let _secrets: @Sendable () -> [AnySecret] private let _secrets: @MainActor @Sendable () -> [AnySecret]
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
@ -28,7 +28,7 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
_reloadSecrets = { await secretStore.reloadSecrets() } _reloadSecrets = { await secretStore.reloadSecrets() }
} }
public var isAvailable: Bool { @MainActor public var isAvailable: Bool {
return _isAvailable() return _isAvailable()
} }
@ -36,11 +36,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
return _id() return _id()
} }
public var name: String { @MainActor public var name: String {
return _name() return _name()
} }
public var secrets: [AnySecret] { @MainActor public var secrets: [AnySecret] {
return _secrets() return _secrets()
} }

View File

@ -1,14 +1,13 @@
import Foundation import Foundation
import OSLog import OSLog
import os
/// Manages storage and lookup for OpenSSH certificates. /// Manages storage and lookup for OpenSSH certificates.
public final class OpenSSHCertificateHandler: Sendable { public actor 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 let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:]) private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
public init() { public init() {
@ -21,21 +20,11 @@ public final class OpenSSHCertificateHandler: Sendable {
logger.log("No certificates, short circuiting") logger.log("No certificates, short circuiting")
return return
} }
keyBlobsAndNames.withLock { keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
$0 = secrets.reduce(into: [:]) { partialResult, next in partialResult[next] = try? loadKeyblobAndName(for: next)
partialResult[next] = try? loadKeyblobAndName(for: next)
}
} }
} }
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - 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
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames.withLock { $0[AnySecret(secret)] != nil }
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported /// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from /// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil. /// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
@ -64,7 +53,7 @@ public final class OpenSSHCertificateHandler: Sendable {
/// - 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.withLock { $0[AnySecret(secret)] } keyBlobsAndNames[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

@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
associatedtype SecretType: Secret associatedtype SecretType: Secret
/// A boolean indicating whether or not the store is available. /// A boolean indicating whether or not the store is available.
var isAvailable: Bool { get } @MainActor var isAvailable: Bool { get }
/// A unique identifier for the store. /// A unique identifier for the store.
var id: UUID { get } var id: UUID { get }
/// A user-facing name for the store. /// A user-facing name for the store.
var name: String { get } @MainActor var name: String { get }
/// The secrets the store manages. /// The secrets the store manages.
var secrets: [SecretType] { get } @MainActor var secrets: [SecretType] { get }
/// Signs a data payload with a specified Secret. /// Signs a data payload with a specified Secret.
/// - Parameters: /// - Parameters:

View File

@ -0,0 +1,37 @@
import LocalAuthentication
import SecretKit
extension SecureEnclave {
actor PersistentAuthenticationHandler: Sendable {
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
let newContext = LAContext()
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
} else {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
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
}
}
}

View File

@ -4,47 +4,28 @@ import Security
import CryptoKit import CryptoKit
@preconcurrency import LocalAuthentication @preconcurrency import LocalAuthentication
import SecretKit import SecretKit
import os
public extension OSAllocatedUnfairLock where State: Sendable {
var lockedValue: State {
get {
withLock { $0 }
}
nonmutating set {
withLock { $0 = newValue }
}
}
}
extension SecureEnclave { extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave. /// An implementation of Store backed by the Secure Enclave.
@Observable public final class Store: SecretStoreModifiable { @Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = []
public var isAvailable: Bool { public var isAvailable: Bool {
CryptoKit.SecureEnclave.isAvailable CryptoKit.SecureEnclave.isAvailable
} }
public let id = UUID() public let id = UUID()
public let name = String(localized: "secure_enclave") public let name = String(localized: "secure_enclave")
public var secrets: [Secret] { private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
_secrets.lockedValue
}
private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: [])
private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:])
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
Task { Task {
await loadSecrets()
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false) await reloadSecretsInternal(notifyAgent: false)
} }
} }
loadSecrets()
} }
// MARK: Public API // MARK: Public API
@ -118,9 +99,9 @@ extension SecureEnclave {
await reloadSecretsInternal() await reloadSecretsInternal()
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
let context: LAContext let context: LAContext
if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid { if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context context = existing.context
} else { } else {
let newContext = LAContext() let newContext = LAContext()
@ -190,30 +171,12 @@ extension SecureEnclave {
return verified return verified
} }
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil } await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
return persisted
} }
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
let newContext = LAContext() try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
newContext.touchIDAuthenticationAllowableReuseDuration = duration
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day]
if let durationString = formatter.string(from: duration) {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
} else {
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
}
guard try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) else { return }
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
self.persistedAuthenticationContexts.withLock {
$0[secret] = context
}
} }
public func reloadSecrets() async { public func reloadSecrets() async {
@ -228,12 +191,10 @@ extension SecureEnclave.Store {
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well. /// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecretsInternal(notifyAgent: Bool = true) async { @MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets let before = secrets
_secrets.withLock { secrets.removeAll()
$0.removeAll() await loadSecrets()
}
loadSecrets()
if secrets != before { if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self) NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent { if notifyAgent {
@ -243,7 +204,7 @@ extension SecureEnclave.Store {
} }
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { private func loadSecrets() async {
let publicAttributes = KeychainDictionary([ let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
@ -294,8 +255,8 @@ extension SecureEnclave.Store {
} }
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
} }
_secrets.withLock { Task { @MainActor in
$0.append(contentsOf: wrapped) secrets.append(contentsOf: wrapped)
} }
} }
@ -335,7 +296,7 @@ extension SecureEnclave {
extension SecureEnclave { extension SecureEnclave {
/// A context describing a persisted authentication. /// A context describing a persisted authentication.
private final class PersistentAuthenticationContext: PersistedAuthenticationContext { final class PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for. /// The Secret to persist authentication for.
let secret: Secret let secret: Secret

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import os
import Observation import Observation
import Security import Security
import CryptoTokenKit import CryptoTokenKit
@ -8,37 +7,39 @@ import SecretKit
extension SmartCard { extension SmartCard {
private struct State { @MainActor @Observable fileprivate final class State {
var isAvailable = false var isAvailable = false
var name = String(localized: "smart_card") var name = String(localized: "smart_card")
var secrets: [Secret] = [] var secrets: [Secret] = []
let watcher = TKTokenWatcher() let watcher = TKTokenWatcher()
var tokenID: String? = nil var tokenID: String? = nil
nonisolated init() {}
} }
/// An implementation of Store backed by a Smart Card. /// An implementation of Store backed by a Smart Card.
@Observable public final class Store: SecretStore { @Observable public final class Store: SecretStore {
private let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init()) private let state = State()
public var isAvailable: Bool { public var isAvailable: Bool {
state.withLock { $0.isAvailable } state.isAvailable
} }
public let id = UUID() public let id = UUID()
public var name: String { @MainActor public var name: String {
state.withLock { $0.name } state.name
} }
public var secrets: [Secret] { public var secrets: [Secret] {
state.withLock { $0.secrets } state.secrets
} }
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
state.withLock { state in Task { @MainActor in
if let tokenID = state.tokenID { if let tokenID = state.tokenID {
state.isAvailable = true state.isAvailable = true
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID) state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
} }
loadSecrets()
state.watcher.setInsertionHandler { id in state.watcher.setInsertionHandler { id in
// Setting insertion handler will cause it to be called immediately. // Setting insertion handler will cause it to be called immediately.
// Make a thread jump so we don't hit a recursive lock attempt. // Make a thread jump so we don't hit a recursive lock attempt.
@ -47,7 +48,6 @@ extension SmartCard {
} }
} }
} }
loadSecrets()
} }
// MARK: Public API // MARK: Public API
@ -60,8 +60,8 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.") fatalError("Keys must be deleted on the smart card.")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
@ -120,7 +120,7 @@ extension SmartCard {
} }
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
public func reloadSecrets() { @MainActor public func reloadSecrets() {
reloadSecretsInternal() reloadSecretsInternal()
} }
@ -130,14 +130,11 @@ extension SmartCard {
extension SmartCard.Store { extension SmartCard.Store {
private func reloadSecretsInternal() { @MainActor private func reloadSecretsInternal() {
let before = state.withLock { let before = state.secrets
$0.isAvailable = $0.tokenID != nil state.isAvailable = state.tokenID != nil
let before = $0.secrets state.secrets.removeAll()
$0.secrets.removeAll() loadSecrets()
return before
}
self.loadSecrets()
if self.secrets != before { if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self) NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
} }
@ -145,37 +142,31 @@ extension SmartCard.Store {
/// Resets the token ID and reloads secrets. /// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was inserted. /// - Parameter tokenID: The ID of the token that was inserted.
private func smartcardInserted(for tokenID: String? = nil) { @MainActor private func smartcardInserted(for tokenID: String? = nil) {
state.withLock { state in
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return } guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
guard state.tokenID == nil else { return } guard state.tokenID == nil else { return }
guard !string.contains("setoken") else { return } guard !string.contains("setoken") else { return }
state.tokenID = string state.tokenID = string
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string) state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
state.tokenID = string state.tokenID = string
}
} }
/// Resets the token ID and reloads secrets. /// Resets the token ID and reloads secrets.
/// - Parameter tokenID: The ID of the token that was removed. /// - Parameter tokenID: The ID of the token that was removed.
private func smartcardRemoved(for tokenID: String? = nil) { @MainActor private func smartcardRemoved(for tokenID: String? = nil) {
state.withLock { state.tokenID = nil
$0.tokenID = nil
}
reloadSecrets() reloadSecrets()
} }
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { @MainActor private func loadSecrets() {
guard let tokenID = state.withLock({ $0.tokenID }) else { return } guard let tokenID = state.tokenID else { return }
let fallbackName = String(localized: "smart_card") let fallbackName = String(localized: "smart_card")
state.withLock { if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName { state.name = driverName
$0.name = driverName } else {
} else { state.name = fallbackName
$0.name = fallbackName
}
} }
let attributes = KeychainDictionary([ let attributes = KeychainDictionary([
@ -199,9 +190,7 @@ extension SmartCard.Store {
let publicKey = publicKeyAttributes[kSecValueData] as! Data let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
} }
state.withLock { state.secrets.append(contentsOf: wrapped)
$0.secrets.append(contentsOf: wrapped)
}
} }
} }
@ -244,8 +233,8 @@ extension SmartCard.Store {
/// - secret: The secret to decrypt with. /// - secret: The secret to decrypt with.
/// - Returns: The decrypted data. /// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged. /// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) throws -> Data { public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)") context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")

View File

@ -59,7 +59,7 @@ import Foundation
} }
@Test @Test
func greatestSelectedIfOldPatchIsPublishedLater() async throws { @MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
// If 2.x.x series has been published, and a patch for 1.x.x is issued // If 2.x.x series has been published, and a patch for 1.x.x is issued
// 2.x.x should still be selected if user can run it. // 2.x.x should still be selected if user can run it.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
@ -77,7 +77,7 @@ import Foundation
} }
@Test @Test
func latestVersionIsRunnable() async throws { @MainActor func latestVersionIsRunnable() async throws {
// If the 2.x.x series has been published but the user can't run it // If the 2.x.x series has been published but the user can't run it
// the last version the user can run should be selected. // the last version the user can run should be selected.
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0")) let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))

View File

@ -1,10 +1,8 @@
import Foundation import Foundation
import os
import Testing import Testing
import CryptoKit import CryptoKit
@testable import SecretKit @testable import SecretKit
@testable import SecretAgentKit @testable import SecretAgentKit
import Common
@Suite struct AgentTests { @Suite struct AgentTests {
@ -21,7 +19,7 @@ import Common
@Test func identitiesList() async { @Test func identitiesList() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple) #expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
@ -31,7 +29,7 @@ import Common
@Test func noMatchingIdentities() async { @Test func noMatchingIdentities() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(stubWriter.data == Constants.Responses.requestFailure) #expect(stubWriter.data == Constants.Responses.requestFailure)
@ -42,7 +40,7 @@ import Common
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk() _ = requestReader.readNextChunk()
let dataToSign = requestReader.readNextChunk() let dataToSign = requestReader.readNextChunk()
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
let outer = OpenSSHReader(data: stubWriter.data[5...]) let outer = OpenSSHReader(data: stubWriter.data[5...])
@ -64,7 +62,7 @@ import Common
rs.append(s) rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign) let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
let store = list.stores.first! let store = await list.stores.first!
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret)) let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret)) let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
@ -80,7 +78,7 @@ import Common
@Test func witnessObjectionStopsRequest() async { @Test func witnessObjectionStopsRequest() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witness = StubWitness(speakNow: { _,_ in let witness = StubWitness(speakNow: { _,_ in
return true return true
}, witness: { _, _ in }) }, witness: { _, _ in })
@ -91,44 +89,43 @@ import Common
@Test func witnessSignature() async { @Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let witnessed: OSAllocatedUnfairLock<Bool> = .init(uncheckedState: false) nonisolated(unsafe) var witnessed = false
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessed.lockedValue = true witnessed = true
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
let value = witnessed.lockedValue #expect(witnessed)
#expect(value)
} }
@Test func requestTracing() async { @Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
let speakNowTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil) nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
let witnessTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil) nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
speakNowTrace.lockedValue = trace speakNowTrace = trace
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessTrace.lockedValue = trace witnessTrace = trace
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessTrace.lockedValue == speakNowTrace.lockedValue) #expect(witnessTrace == speakNowTrace)
#expect(witnessTrace.lockedValue?.origin.displayName == "Finder") #expect(witnessTrace?.origin.displayName == "Finder")
#expect(witnessTrace.lockedValue?.origin.validSignature == true) #expect(witnessTrace?.origin.validSignature == true)
#expect(witnessTrace.lockedValue?.origin.parentPID == 1) #expect(witnessTrace?.origin.parentPID == 1)
} }
// MARK: Exception Handling // MARK: Exception Handling
@Test func signatureException() async { @Test func signatureException() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
let store = list.stores.first?.base as! Stub.Store let store = await list.stores.first?.base as! Stub.Store
store.shouldThrow = true store.shouldThrow = true
let agent = Agent(storeList: list) let agent = Agent(storeList: list)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
@ -148,7 +145,7 @@ import Common
extension AgentTests { extension AgentTests {
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList { @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
let store = Stub.Store() let store = Stub.Store()
store.secrets.append(contentsOf: secrets) store.secrets.append(contentsOf: secrets)
let storeList = SecretStoreList() let storeList = SecretStoreList()

View File

@ -4,7 +4,6 @@ import AppKit
import SecretKit import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import os
final class Notifier: Sendable { final class Notifier: Sendable {
@ -30,14 +29,13 @@ final class Notifier: Sendable {
formatter.unitsStyle = .spellOut formatter.unitsStyle = .spellOut
formatter.allowedUnits = [.hour, .minute, .day] formatter.allowedUnits = [.hour, .minute, .day]
var identifiers: [String: TimeInterval] = [:]
for duration in rawDurations { for duration in rawDurations {
let seconds = duration.converted(to: .seconds).value let seconds = duration.converted(to: .seconds).value
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.state.withLock { state in identifiers[identifier] = seconds
state.persistOptions[identifier] = seconds
}
allPersistenceActions.append(action) allPersistenceActions.append(action)
} }
@ -48,8 +46,8 @@ final class Notifier: Sendable {
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
UNUserNotificationCenter.current().delegate = notificationDelegate UNUserNotificationCenter.current().delegate = notificationDelegate
notificationDelegate.state.withLock { state in Task {
state.persistAuthentication = { secret, store, duration in await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
guard let duration = duration else { return } guard let duration = duration else { return }
try? await store.persistAuthentication(secret: secret, forDuration: duration) try? await store.persistAuthentication(secret: secret, forDuration: duration)
} }
@ -63,10 +61,7 @@ final class Notifier: Sendable {
} }
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.state.withLock { state in await notificationDelegate.state.setPending(secret: secret, store: 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)")
@ -84,11 +79,8 @@ final class Notifier: Sendable {
try? await notificationCenter.add(request) try? await notificationCenter.add(request)
} }
func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) { func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) async {
notificationDelegate.state.withLock { [update] state in await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: 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 {
@ -101,7 +93,7 @@ final class Notifier: Sendable {
notificationContent.body = update.body notificationContent.body = update.body
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
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)
} }
} }
@ -140,18 +132,45 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
struct State { fileprivate actor State {
typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias Ignore = ((Release) -> Void) typealias IgnoreAction = (@Sendable (Release) -> Void)
fileprivate var release: Release? fileprivate var release: Release?
fileprivate var ignore: Ignore? fileprivate var ignoreAction: IgnoreAction?
fileprivate var persistAuthentication: PersistAuthentication? fileprivate var persistAction: PersistAction?
fileprivate var persistOptions: [String: TimeInterval] = [:] fileprivate var persistOptions: [String: TimeInterval] = [:]
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:] fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] 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)? {
guard let secret = pendingPersistableSecrets[secretID],
let store = pendingPersistableStores[storeID],
let options = persistOptions[optionID] else {
return nil
}
pendingPersistableSecrets.removeValue(forKey: secretID)
return (secret, store, options)
}
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
self.persistOptions = options
self.persistAction = action
}
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
self.release = release
self.ignoreAction = ignoreAction
}
} }
fileprivate let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init()) fileprivate let state = State()
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
@ -161,7 +180,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
let category = response.notification.request.content.categoryIdentifier let category = response.notification.request.content.categoryIdentifier
switch category { switch category {
case Notifier.Constants.updateCategoryIdentitifier: case Notifier.Constants.updateCategoryIdentitifier:
handleUpdateResponse(response: response) await handleUpdateResponse(response: response)
case Notifier.Constants.persistAuthenticationCategoryIdentitifier: case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
await handlePersistAuthenticationResponse(response: response) await handlePersistAuthenticationResponse(response: response)
default: default:
@ -169,18 +188,16 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
} }
func handleUpdateResponse(response: UNNotificationResponse) { func handleUpdateResponse(response: UNNotificationResponse) async {
let id = response.actionIdentifier let id = response.actionIdentifier
state.withLock { state in guard let update = await state.release else { return }
guard let update = state.release else { return } switch id {
switch id { case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: NSWorkspace.shared.open(update.html_url)
NSWorkspace.shared.open(update.html_url) case Notifier.Constants.ignoreActionIdentitifier:
case Notifier.Constants.ignoreActionIdentitifier: await state.ignoreAction?(update)
state.ignore?(update) default:
default: fatalError()
fatalError()
}
} }
} }
@ -189,17 +206,9 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else { let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
return return
} }
let id = response.actionIdentifier let optionID = response.actionIdentifier
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in await state.persistAction?(secret, store, persistOptions)
guard let secret = state.pendingPersistableSecrets[secretID],
let store = state.pendingPersistableStores[storeID]
else { return (nil, nil, nil, nil) }
state.pendingPersistableSecrets[secretID] = nil
return (secret, store, state.persistOptions[id], state.persistAuthentication)
}
guard let secret, let store, let persistOptions else { return }
await callback?(secret, store, persistOptions)
} }

View File

@ -2511,9 +2511,6 @@
} }
} }
} }
},
"No Update: %@" : {
}, },
"no_secure_storage_description" : { "no_secure_storage_description" : {
"localizations" : { "localizations" : {

View File

@ -1,25 +1,21 @@
import Foundation import Foundation
import os
import Observation import Observation
import Brief import Brief
@Observable final class PreviewUpdater: UpdaterProtocol { @Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
var update: Release? { var update: Release? = nil
_update.lockedValue
}
let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
let testBuild = false let testBuild = false
init(update: Update = .none) { init(update: Update = .none) {
switch update { switch update {
case .none: case .none:
_update.lockedValue = nil self.update = nil
case .advisory: case .advisory:
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update") self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
case .critical: case .critical:
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update") self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
} }
} }

View File

@ -91,8 +91,6 @@ extension ContentView {
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in .popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
UpdateDetailView(update: update) UpdateDetailView(update: update)
} }
} else {
Text("No Update: \(updater.update as Any)")
} }
} }

View File

@ -42,12 +42,18 @@ struct StoreListView: View {
if let activeSecret { if let activeSecret {
SecretDetailView(secret: activeSecret) SecretDetailView(secret: activeSecret)
} else { } else {
EmptyStoreView(store: storeList.stores.first) EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
} }
} }
.navigationSplitViewStyle(.balanced) .navigationSplitViewStyle(.balanced)
.onAppear { .onAppear {
activeSecret = nextDefaultSecret withObservationTracking {
_ = nextDefaultSecret
} onChange: {
Task { @MainActor in
activeSecret = nextDefaultSecret
}
}
} }
.frame(minWidth: 100, idealWidth: 240) .frame(minWidth: 100, idealWidth: 240)
@ -57,7 +63,7 @@ struct StoreListView: View {
extension StoreListView { extension StoreListView {
private var nextDefaultSecret: AnySecret? { private var nextDefaultSecret: AnySecret? {
return storeList.stores.compactMap(\.secrets.first).first return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
} }
} }