diff --git a/Sources/Packages/Sources/Brief/Updater.swift b/Sources/Packages/Sources/Brief/Updater.swift index 4c0c6c5..f79effe 100644 --- a/Sources/Packages/Sources/Brief/Updater.swift +++ b/Sources/Packages/Sources/Brief/Updater.swift @@ -83,7 +83,6 @@ extension Updater { let latestVersion = SemVer(release.name) if latestVersion > currentVersion { await MainActor.run { - print("SET \(release)") state.update = release } } diff --git a/Sources/Packages/Sources/Brief/UpdaterProtocol.swift b/Sources/Packages/Sources/Brief/UpdaterProtocol.swift index 1077e84..b9df10a 100644 --- a/Sources/Packages/Sources/Brief/UpdaterProtocol.swift +++ b/Sources/Packages/Sources/Brief/UpdaterProtocol.swift @@ -1,5 +1,4 @@ import Foundation -import os /// A protocol for retreiving the latest available version of an app. public protocol UpdaterProtocol: Observable, Sendable { diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index 968d22d..4638e3b 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -23,7 +23,7 @@ public final class Agent: Sendable { self.storeList = storeList self.witness = witness 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. func identities() async -> Data { let secrets = await storeList.allSecrets - certificateHandler.reloadCertificates(for: secrets) + await certificateHandler.reloadCertificates(for: secrets) var count = secrets.count var keyData = Data() @@ -97,7 +97,7 @@ extension Agent { keyData.append(writer.lengthAndData(of: keyBlob)) 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: name)) count += 1 @@ -119,7 +119,7 @@ extension Agent { let payloadHash = reader.readNextChunk() let hash: Data // 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 } else { hash = payloadHash @@ -192,8 +192,9 @@ extension Agent { /// Gives any store with no loaded secrets a chance to reload. func reloadSecretsIfNeccessary() async { for store in await storeList.stores { - if store.secrets.isEmpty { - logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.") + if await store.secrets.isEmpty { + let name = await store.name + logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.") await store.reloadSecrets() } } @@ -203,15 +204,15 @@ extension Agent { /// - Parameter hash: The hash to match against. /// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found. func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? { - await storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in - let allMatching = store.secrets.filter { secret in + for store in await storeList.stores { + let allMatching = await store.secrets.filter { secret in hash == writer.data(secret: secret) } if let matching = allMatching.first { return (store, matching) } - return nil - }.first + } + return nil } } diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index af6e9b3..f62bab1 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -4,11 +4,11 @@ import Combine /// Type eraser for SecretStore. public class AnySecretStore: SecretStore, @unchecked Sendable { - let base: Any - private let _isAvailable: @Sendable () -> Bool + let base: any Sendable + private let _isAvailable: @MainActor @Sendable () -> Bool private let _id: @Sendable () -> UUID - private let _name: @Sendable () -> String - private let _secrets: @Sendable () -> [AnySecret] + private let _name: @MainActor @Sendable () -> String + private let _secrets: @MainActor @Sendable () -> [AnySecret] private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext? @@ -28,7 +28,7 @@ public class AnySecretStore: SecretStore, @unchecked Sendable { _reloadSecrets = { await secretStore.reloadSecrets() } } - public var isAvailable: Bool { + @MainActor public var isAvailable: Bool { return _isAvailable() } @@ -36,11 +36,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable { return _id() } - public var name: String { + @MainActor public var name: String { return _name() } - public var secrets: [AnySecret] { + @MainActor public var secrets: [AnySecret] { return _secrets() } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift index de304c0..dbfcc7a 100644 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift @@ -1,14 +1,13 @@ import Foundation import OSLog -import os /// Manages storage and lookup for OpenSSH certificates. -public final class OpenSSHCertificateHandler: Sendable { +public actor OpenSSHCertificateHandler: Sendable { private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let writer = OpenSSHKeyWriter() - private let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:]) + private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] /// Initializes an OpenSSHCertificateHandler. public init() { @@ -21,21 +20,11 @@ public final class OpenSSHCertificateHandler: Sendable { logger.log("No certificates, short circuiting") return } - keyBlobsAndNames.withLock { - $0 = secrets.reduce(into: [:]) { partialResult, next in - partialResult[next] = try? loadKeyblobAndName(for: next) - } + keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in + 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(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 /// - 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. @@ -64,7 +53,7 @@ public final class OpenSSHCertificateHandler: Sendable { /// - Parameter secret: The secret to search for a certificate with /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. public func keyBlobAndName(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`` diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index 0114af7..c47c17e 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable { associatedtype SecretType: Secret /// 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. var id: UUID { get } /// A user-facing name for the store. - var name: String { get } + @MainActor var name: String { get } /// The secrets the store manages. - var secrets: [SecretType] { get } + @MainActor var secrets: [SecretType] { get } /// Signs a data payload with a specified Secret. /// - Parameters: diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift new file mode 100644 index 0000000..c07a102 --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/PersistentAuthenticationHandler.swift @@ -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 + } + + } + +} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index f31d576..61bf961 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -4,47 +4,28 @@ import Security import CryptoKit @preconcurrency import LocalAuthentication import SecretKit -import os - -public extension OSAllocatedUnfairLock where State: Sendable { - - var lockedValue: State { - get { - withLock { $0 } - } - nonmutating set { - withLock { $0 = newValue } - } - } - -} - extension SecureEnclave { /// An implementation of Store backed by the Secure Enclave. @Observable public final class Store: SecretStoreModifiable { + @MainActor public var secrets: [Secret] = [] public var isAvailable: Bool { CryptoKit.SecureEnclave.isAvailable } public let id = UUID() public let name = String(localized: "secure_enclave") - public var secrets: [Secret] { - _secrets.lockedValue - } - private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: []) - - private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:]) + private let persistentAuthenticationHandler = PersistentAuthenticationHandler() /// Initializes a Store. public init() { Task { + await loadSecrets() for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { await reloadSecretsInternal(notifyAgent: false) } } - loadSecrets() } // MARK: Public API @@ -118,9 +99,9 @@ extension SecureEnclave { 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 - if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid { + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { context = existing.context } else { let newContext = LAContext() @@ -190,30 +171,12 @@ extension SecureEnclave { return verified } - public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { - guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil } - return persisted + public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { + await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) } public 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)") - } - 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 - } + try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration) } public func reloadSecrets() async { @@ -228,12 +191,10 @@ extension SecureEnclave.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. - private func reloadSecretsInternal(notifyAgent: Bool = true) async { + @MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async { let before = secrets - _secrets.withLock { - $0.removeAll() - } - loadSecrets() + secrets.removeAll() + await loadSecrets() if secrets != before { NotificationCenter.default.post(name: .secretStoreReloaded, object: self) if notifyAgent { @@ -243,7 +204,7 @@ extension SecureEnclave.Store { } /// Loads all secrets from the store. - private func loadSecrets() { + private func loadSecrets() async { let publicAttributes = KeychainDictionary([ kSecClass: kSecClassKey, kSecAttrKeyType: SecureEnclave.Constants.keyType, @@ -294,8 +255,8 @@ extension SecureEnclave.Store { } return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) } - _secrets.withLock { - $0.append(contentsOf: wrapped) + Task { @MainActor in + secrets.append(contentsOf: wrapped) } } @@ -335,7 +296,7 @@ extension SecureEnclave { extension SecureEnclave { /// A context describing a persisted authentication. - private final class PersistentAuthenticationContext: PersistedAuthenticationContext { + final class PersistentAuthenticationContext: PersistedAuthenticationContext { /// The Secret to persist authentication for. let secret: Secret diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index 56f802d..f9ed8f8 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -1,5 +1,4 @@ import Foundation -import os import Observation import Security import CryptoTokenKit @@ -8,37 +7,39 @@ import SecretKit extension SmartCard { - private struct State { + @MainActor @Observable fileprivate final class State { var isAvailable = false var name = String(localized: "smart_card") var secrets: [Secret] = [] let watcher = TKTokenWatcher() var tokenID: String? = nil + nonisolated init() {} } /// An implementation of Store backed by a Smart Card. @Observable public final class Store: SecretStore { - private let state: OSAllocatedUnfairLock = .init(uncheckedState: .init()) + private let state = State() public var isAvailable: Bool { - state.withLock { $0.isAvailable } + state.isAvailable } public let id = UUID() - public var name: String { - state.withLock { $0.name } + @MainActor public var name: String { + state.name } public var secrets: [Secret] { - state.withLock { $0.secrets } + state.secrets } /// Initializes a Store. public init() { - state.withLock { state in + Task { @MainActor in if let tokenID = state.tokenID { state.isAvailable = true state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID) } + loadSecrets() state.watcher.setInsertionHandler { id in // Setting insertion handler will cause it to be called immediately. // Make a thread jump so we don't hit a recursive lock attempt. @@ -47,7 +48,6 @@ extension SmartCard { } } } - loadSecrets() } // MARK: Public API @@ -60,8 +60,8 @@ extension SmartCard { fatalError("Keys must be deleted on the smart card.") } - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { - guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + guard let tokenID = await state.tokenID else { fatalError() } let context = LAContext() context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)") context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") @@ -120,7 +120,7 @@ extension SmartCard { } /// Reloads all secrets from the store. - public func reloadSecrets() { + @MainActor public func reloadSecrets() { reloadSecretsInternal() } @@ -130,14 +130,11 @@ extension SmartCard { extension SmartCard.Store { - private func reloadSecretsInternal() { - let before = state.withLock { - $0.isAvailable = $0.tokenID != nil - let before = $0.secrets - $0.secrets.removeAll() - return before - } - self.loadSecrets() + @MainActor private func reloadSecretsInternal() { + let before = state.secrets + state.isAvailable = state.tokenID != nil + state.secrets.removeAll() + loadSecrets() if self.secrets != before { NotificationCenter.default.post(name: .secretStoreReloaded, object: self) } @@ -145,37 +142,31 @@ extension SmartCard.Store { /// Resets the token ID and reloads secrets. /// - Parameter tokenID: The ID of the token that was inserted. - private func smartcardInserted(for tokenID: String? = nil) { - state.withLock { state in + @MainActor private func smartcardInserted(for tokenID: String? = nil) { guard let string = state.watcher.nonSecureEnclaveTokens.first else { return } guard state.tokenID == nil else { return } guard !string.contains("setoken") else { return } state.tokenID = string state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string) state.tokenID = string - } } /// Resets the token ID and reloads secrets. /// - Parameter tokenID: The ID of the token that was removed. - private func smartcardRemoved(for tokenID: String? = nil) { - state.withLock { - $0.tokenID = nil - } + @MainActor private func smartcardRemoved(for tokenID: String? = nil) { + state.tokenID = nil reloadSecrets() } /// Loads all secrets from the store. - private func loadSecrets() { - guard let tokenID = state.withLock({ $0.tokenID }) else { return } + @MainActor private func loadSecrets() { + guard let tokenID = state.tokenID else { return } let fallbackName = String(localized: "smart_card") - state.withLock { - if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName { - $0.name = driverName - } else { - $0.name = fallbackName - } + if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName { + state.name = driverName + } else { + state.name = fallbackName } let attributes = KeychainDictionary([ @@ -199,9 +190,7 @@ extension SmartCard.Store { let publicKey = publicKeyAttributes[kSecValueData] as! Data return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey) } - state.withLock { - $0.secrets.append(contentsOf: wrapped) - } + state.secrets.append(contentsOf: wrapped) } } @@ -244,8 +233,8 @@ extension SmartCard.Store { /// - secret: The secret to decrypt with. /// - 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. - public func decrypt(data: Data, with secret: SecretType) throws -> Data { - guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() } + public func decrypt(data: Data, with secret: SecretType) async throws -> Data { + guard let tokenID = await state.tokenID else { fatalError() } let context = LAContext() context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)") context.localizedCancelTitle = String(localized: "auth_context_request_deny_button") diff --git a/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift b/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift index ef5dd19..a75c2ad 100644 --- a/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift +++ b/Sources/Packages/Tests/BriefTests/ReleaseParsingTests.swift @@ -59,7 +59,7 @@ import Foundation } @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 // 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")) @@ -77,7 +77,7 @@ import Foundation } @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 // 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")) diff --git a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift index ef55363..63734ef 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift @@ -1,10 +1,8 @@ import Foundation -import os import Testing import CryptoKit @testable import SecretKit @testable import SecretAgentKit -import Common @Suite struct AgentTests { @@ -21,7 +19,7 @@ import Common @Test func identitiesList() async { 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) await agent.handle(reader: stubReader, writer: stubWriter) #expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple) @@ -31,7 +29,7 @@ import Common @Test func noMatchingIdentities() async { 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) await agent.handle(reader: stubReader, writer: stubWriter) #expect(stubWriter.data == Constants.Responses.requestFailure) @@ -42,7 +40,7 @@ import Common let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) _ = 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) await agent.handle(reader: stubReader, writer: stubWriter) let outer = OpenSSHReader(data: stubWriter.data[5...]) @@ -64,7 +62,7 @@ import Common rs.append(s) let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) 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 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)) @@ -80,7 +78,7 @@ import Common @Test func witnessObjectionStopsRequest() async { 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 return true }, witness: { _, _ in }) @@ -91,44 +89,43 @@ import Common @Test func witnessSignature() async { let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) - let witnessed: OSAllocatedUnfairLock = .init(uncheckedState: false) + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret]) + nonisolated(unsafe) var witnessed = false let witness = StubWitness(speakNow: { _, trace in return false }, witness: { _, trace in - witnessed.lockedValue = true + witnessed = true }) let agent = Agent(storeList: list, witness: witness) await agent.handle(reader: stubReader, writer: stubWriter) - let value = witnessed.lockedValue - #expect(value) + #expect(witnessed) } @Test func requestTracing() async { let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) - let speakNowTrace: OSAllocatedUnfairLock = .init(uncheckedState: nil) - let witnessTrace: OSAllocatedUnfairLock = .init(uncheckedState: nil) + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret]) + nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance? + nonisolated(unsafe) var witnessTrace: SigningRequestProvenance? let witness = StubWitness(speakNow: { _, trace in - speakNowTrace.lockedValue = trace + speakNowTrace = trace return false }, witness: { _, trace in - witnessTrace.lockedValue = trace + witnessTrace = trace }) let agent = Agent(storeList: list, witness: witness) await agent.handle(reader: stubReader, writer: stubWriter) - #expect(witnessTrace.lockedValue == speakNowTrace.lockedValue) - #expect(witnessTrace.lockedValue?.origin.displayName == "Finder") - #expect(witnessTrace.lockedValue?.origin.validSignature == true) - #expect(witnessTrace.lockedValue?.origin.parentPID == 1) + #expect(witnessTrace == speakNowTrace) + #expect(witnessTrace?.origin.displayName == "Finder") + #expect(witnessTrace?.origin.validSignature == true) + #expect(witnessTrace?.origin.parentPID == 1) } // MARK: Exception Handling @Test func signatureException() async { let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) - let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let store = list.stores.first?.base as! Stub.Store + let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) + let store = await list.stores.first?.base as! Stub.Store store.shouldThrow = true let agent = Agent(storeList: list) await agent.handle(reader: stubReader, writer: stubWriter) @@ -148,7 +145,7 @@ import Common extension AgentTests { - func storeList(with secrets: [Stub.Secret]) -> SecretStoreList { + @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList { let store = Stub.Store() store.secrets.append(contentsOf: secrets) let storeList = SecretStoreList() diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index b2dbd36..4b11f3e 100644 --- a/Sources/SecretAgent/Notifier.swift +++ b/Sources/SecretAgent/Notifier.swift @@ -4,7 +4,6 @@ import AppKit import SecretKit import SecretAgentKit import Brief -import os final class Notifier: Sendable { @@ -30,14 +29,13 @@ final class Notifier: Sendable { formatter.unitsStyle = .spellOut formatter.allowedUnits = [.hour, .minute, .day] + var identifiers: [String: TimeInterval] = [:] for duration in rawDurations { let seconds = duration.converted(to: .seconds).value guard let string = formatter.string(from: seconds)?.capitalized else { continue } let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)") let action = UNNotificationAction(identifier: identifier, title: string, options: []) - notificationDelegate.state.withLock { state in - state.persistOptions[identifier] = seconds - } + identifiers[identifier] = seconds allPersistenceActions.append(action) } @@ -48,8 +46,8 @@ final class Notifier: Sendable { UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory]) UNUserNotificationCenter.current().delegate = notificationDelegate - notificationDelegate.state.withLock { state in - state.persistAuthentication = { secret, store, duration in + 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) } @@ -63,10 +61,7 @@ final class Notifier: Sendable { } func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async { - notificationDelegate.state.withLock { state in - state.pendingPersistableSecrets[secret.id.description] = secret - state.pendingPersistableStores[store.id.description] = store - } + await notificationDelegate.state.setPending(secret: secret, store: store) let notificationCenter = UNUserNotificationCenter.current() let notificationContent = UNMutableNotificationContent() notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)") @@ -84,11 +79,8 @@ final class Notifier: Sendable { try? await notificationCenter.add(request) } - func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) { - notificationDelegate.state.withLock { [update] state in - state.release = update - state.ignore = ignore - } + func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) async { + await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore) let notificationCenter = UNUserNotificationCenter.current() let notificationContent = UNMutableNotificationContent() if update.critical { @@ -101,7 +93,7 @@ final class Notifier: Sendable { notificationContent.body = update.body notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier 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 { - struct State { - typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) - typealias Ignore = ((Release) -> Void) + fileprivate actor State { + typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void) + typealias IgnoreAction = (@Sendable (Release) -> Void) fileprivate var release: Release? - fileprivate var ignore: Ignore? - fileprivate var persistAuthentication: PersistAuthentication? + 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)? { + 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 = .init(uncheckedState: .init()) + fileprivate let state = State() func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { @@ -161,7 +180,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se let category = response.notification.request.content.categoryIdentifier switch category { case Notifier.Constants.updateCategoryIdentitifier: - handleUpdateResponse(response: response) + await handleUpdateResponse(response: response) case Notifier.Constants.persistAuthenticationCategoryIdentitifier: await handlePersistAuthenticationResponse(response: response) default: @@ -169,18 +188,16 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se } } - func handleUpdateResponse(response: UNNotificationResponse) { + func handleUpdateResponse(response: UNNotificationResponse) async { let id = response.actionIdentifier - state.withLock { state in - guard let update = state.release else { return } - switch id { - case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: - NSWorkspace.shared.open(update.html_url) - case Notifier.Constants.ignoreActionIdentitifier: - state.ignore?(update) - default: - fatalError() - } + guard let update = await state.release else { return } + switch id { + case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: + NSWorkspace.shared.open(update.html_url) + case Notifier.Constants.ignoreActionIdentitifier: + await state.ignoreAction?(update) + default: + fatalError() } } @@ -189,17 +206,9 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else { return } - let id = response.actionIdentifier - - let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in - 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) + 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) } diff --git a/Sources/Secretive/Localizable.xcstrings b/Sources/Secretive/Localizable.xcstrings index 4302219..3dcedc8 100644 --- a/Sources/Secretive/Localizable.xcstrings +++ b/Sources/Secretive/Localizable.xcstrings @@ -2511,9 +2511,6 @@ } } } - }, - "No Update: %@" : { - }, "no_secure_storage_description" : { "localizations" : { diff --git a/Sources/Secretive/Preview Content/PreviewUpdater.swift b/Sources/Secretive/Preview Content/PreviewUpdater.swift index b246c78..77fbeea 100644 --- a/Sources/Secretive/Preview Content/PreviewUpdater.swift +++ b/Sources/Secretive/Preview Content/PreviewUpdater.swift @@ -1,25 +1,21 @@ import Foundation -import os import Observation import Brief -@Observable final class PreviewUpdater: UpdaterProtocol { +@Observable @MainActor final class PreviewUpdater: UpdaterProtocol { - var update: Release? { - _update.lockedValue - } - let _update: OSAllocatedUnfairLock = .init(uncheckedState: nil) + var update: Release? = nil let testBuild = false init(update: Update = .none) { switch update { case .none: - _update.lockedValue = nil + self.update = nil 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: - _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") } } diff --git a/Sources/Secretive/Views/ContentView.swift b/Sources/Secretive/Views/ContentView.swift index 0e9dbc9..8bcbe0a 100644 --- a/Sources/Secretive/Views/ContentView.swift +++ b/Sources/Secretive/Views/ContentView.swift @@ -91,8 +91,6 @@ extension ContentView { .popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in UpdateDetailView(update: update) } - } else { - Text("No Update: \(updater.update as Any)") } } diff --git a/Sources/Secretive/Views/StoreListView.swift b/Sources/Secretive/Views/StoreListView.swift index 4e2dced..ac36bc0 100644 --- a/Sources/Secretive/Views/StoreListView.swift +++ b/Sources/Secretive/Views/StoreListView.swift @@ -42,12 +42,18 @@ struct StoreListView: View { if let activeSecret { SecretDetailView(secret: activeSecret) } else { - EmptyStoreView(store: storeList.stores.first) + EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first) } } .navigationSplitViewStyle(.balanced) .onAppear { - activeSecret = nextDefaultSecret + withObservationTracking { + _ = nextDefaultSecret + } onChange: { + Task { @MainActor in + activeSecret = nextDefaultSecret + } + } } .frame(minWidth: 100, idealWidth: 240) @@ -57,7 +63,7 @@ struct StoreListView: View { extension StoreListView { private var nextDefaultSecret: AnySecret? { - return storeList.stores.compactMap(\.secrets.first).first + return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first } }