From 6d1e82cddee680d5329be20183962b56bdd1da09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 30 Dec 2025 19:52:36 +0000 Subject: [PATCH] agent: serialize signing to fix concurrent auth prompts Multiple concurrent SSH connections requesting signatures can trigger simultaneous LAContext authentication prompts. macOS only allows one biometric/password prompt at a time, causing the others to fail with "agent refused operation". Add SigningSerializer actor that queues signing operations, ensuring only one LAContext prompt is active at a time. Waiting operations are resumed in order after the current one completes. Fixes: https://github.com/maxgoedjen/secretive/issues/532 --- .../Sources/SecretAgentKit/Agent.swift | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index ba66603..7b2bf16 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -5,6 +5,34 @@ import SecretKit import AppKit import SSHProtocolKit +/// Serializes signing operations to prevent concurrent LAContext authentication prompts. +/// When multiple SSH connections request signatures simultaneously, this ensures only one +/// biometric/password prompt is shown at a time. +private actor SigningSerializer { + private var isRunning = false + private var waiting: [CheckedContinuation] = [] + + func serialize(_ operation: @Sendable () async throws -> T) async rethrows -> T { + // Wait in line if another operation is running + if isRunning { + await withCheckedContinuation { continuation in + waiting.append(continuation) + } + } + + isRunning = true + defer { + if waiting.isEmpty { + isRunning = false + } else { + waiting.removeFirst().resume() + } + } + + return try await operation() + } +} + /// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores. public final class Agent: Sendable { @@ -13,6 +41,7 @@ public final class Agent: Sendable { private let publicKeyWriter = OpenSSHPublicKeyWriter() private let signatureWriter = OpenSSHSignatureWriter() private let certificateHandler = OpenSSHCertificateHandler() + private let signingSerializer = SigningSerializer() private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") /// Initializes an agent with a store list and a witness. @@ -102,16 +131,19 @@ extension Agent { throw NoMatchingKeyError() } - try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) + // Serialize signing to prevent concurrent LAContext prompts + return try await signingSerializer.serialize { [witness, store, signatureWriter, logger] in + try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) - let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) - let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) + let rawRepresentation = try await store.sign(data: data, with: secret, for: provenance) + let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) - try await witness?.witness(accessTo: secret, from: store, by: provenance) + try await witness?.witness(accessTo: secret, from: store, by: provenance) - logger.debug("Agent signed request") + logger.debug("Agent signed request") - return signedData + return signedData + } } }