diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 61fc722..c9ce8d4 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -18,6 +18,7 @@ extension SecureEnclave { public let id = UUID() public let name = String(localized: .secureEnclave) private let persistentAuthenticationHandler = PersistentAuthenticationHandler() + private let signingSerializer = SigningSerializer() /// Initializes a Store. @MainActor public init() { @@ -38,6 +39,16 @@ extension SecureEnclave { // MARK: SecretStore public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + if secret.attributes.authentication.required { + return try await signingSerializer.serialize { [self] in + try await self.performSign(data: data, with: secret, for: provenance) + } + } else { + return try await performSign(data: data, with: secret, for: provenance) + } + } + + private func performSign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { var context: LAContext if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { context = unsafe existing.context @@ -85,7 +96,6 @@ extension SecureEnclave { default: throw UnsupportedAlgorithmError() } - } public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? { diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SigningSerializer.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SigningSerializer.swift new file mode 100644 index 0000000..d813340 --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SigningSerializer.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Serializes signing operations for protected keys to prevent LAContext conflicts. +/// macOS only allows one biometric authentication prompt at a time, so concurrent +/// requests for protected Secure Enclave keys must be queued. +actor SigningSerializer { + private var isProcessing = false + private var waiters: [CheckedContinuation] = [] + + func serialize(operation: @escaping @Sendable () async throws -> T) async throws -> T { + // If someone is already processing, wait in line + if isProcessing { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + isProcessing = true + + defer { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + isProcessing = false + } + } + + return try await operation() + } +}