Current Behavior
Wave's SSH client deviates from standard publickey authentication behavior, and the deviation is invisible in Wave's own diagnostics.
FIDO2/SK keys (Windows Hello, YubiKey, any FIDO2 authenticator) require a user-presence check for every signature, so a prompt appears for each key the agent offers. Declining a prompt is part of normal use: with several keys loaded, the prompt is how you find out which key is being offered, and "not this one" is a routine answer. Standard SSH clients treat a declined signature as "try the next identity". Wave treats it as fatal and kills the handshake:
text[conndebug] ERROR ssh auth/negotiation: ssh: handshake failed: agent: failed to sign challenge
The same two-key setup behaves correctly in ssh.exe and in the other third-party terminals programs.
Result of step 3 (see below on how to reproduce): immediate handshake failure; the second key is never offered.
Expected Behavior
The user-presence prompt for the second key appears, as with ssh.exe: standard SSH clients treat a declined signature as "try the next identity" and move on.
Separately expected: agent activity (dial failures, key-listing errors, per-key attempts) visible in conndebug and logs, so deviations like this are diagnosable.
Steps To Reproduce
ssh-add two security keys that require user presence confirmation into the Windows OpenSSH agent, both authorized on the target server.
- Connect to the host in Wave.
- Cancel the user-presence prompt that appears for the first key.
- Notice that the second user-presence prompt is not getting presented (the expected behavior) and it instead aborts the login auth.
Wave Version
0.14.5 (202604161539)
Platform
Windows
OS Version/Distribution
Windows 11
Architecture
x64
Anything else?
Additional environment detail: Client: Windows 11, current Wave release, OpenSSH_for_Windows_9.5p2, agent at \\.\pipe\openssh-ssh-agent. Server: Debian 13, OpenSSH 10.0p2. Keys: one ecdsa-sk backed by Windows Hello, one ed25519-sk backed by a YubiKey 5. Both public keys are in the server's authorized_keys.
Root cause
Wave hands agent signers to ssh.RetryableAuthMethod in pkg/remote/sshclient.go (createPublicKeyCallback / createClientConfig). In golang.org/x/crypto/ssh, an error from Signer.Sign (which is what a cancelled agent prompt becomes) is treated as fatal: retryableAuthMethod.auth stops on any error (if ok != authFailure || err != nil), so the remaining queued signers are never tried.
A second defect compounds the first: Wave does not log or surface any of its agent activity, so the deviation is undiagnosable from inside the product. Agent problems never reach conndebug. The pipe-dial error is only written to the backend log via log.Printf, the Signers() error is discarded (authSockSigners, _ = agentClient.Signers()), and agent key attempts produce no conndebug line at all. I spent a while suspecting my ssh config when the actual issue was the agent state.
Proposed fix
The patch below wraps agent signers so that a failed signature is logged and replaced with a deliberately invalid one. The server rejects it as a normal auth failure and the retry loop continues with the next key, which matches OpenSSH behavior. This is the same trick createDummySigner in this file already uses for unparseable key files. The wrapper implements ssh.AlgorithmSigner so RSA rsa-sha2-* negotiation keeps working. The patch also adds conndebug lines for agent dial failures, listing errors, identity counts, and each key attempt.
One side effect worth knowing: each rejected signature consumes one of the server's MaxAuthTries slots, the same as a rejected key offer from OpenSSH.
Patch: pkg/remote/sshclient.go + new pkg/remote/sshsigners.go
diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go
index b1c9fac..60b9b68 100644
--- a/pkg/remote/sshclient.go
+++ b/pkg/remote/sshclient.go
@@ -310,6 +310,8 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnK
if len(*authSockSignersPtr) != 0 {
authSockSigner := (*authSockSignersPtr)[0]
*authSockSignersPtr = (*authSockSignersPtr)[1:]
+ blocklogger.Infof(connCtx, "[conndebug] trying agent identity %s %s...\n",
+ authSockSigner.PublicKey().Type(), ssh.FingerprintSHA256(authSockSigner.PublicKey()))
return []ssh.Signer{authSockSigner}, nil
}
@@ -772,10 +774,22 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor
if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) && agentPath != "" {
conn, err := dialIdentityAgent(agentPath)
if err != nil {
+ blocklogger.Infof(connCtx, "[conndebug] failed to open identity agent socket %q: %v\n", agentPath, err)
log.Printf("Failed to open Identity Agent Socket %q: %v", agentPath, err)
} else {
agentClient = agent.NewClient(conn)
- authSockSigners, _ = agentClient.Signers()
+ agentSigners, err := agentClient.Signers()
+ if err != nil {
+ blocklogger.Infof(connCtx, "[conndebug] failed to list identity agent keys: %v\n", err)
+ log.Printf("Failed to list identity agent keys: %v", err)
+ }
+ blocklogger.Infof(connCtx, "[conndebug] identity agent provided %d identities\n", len(agentSigners))
+ // wrap agent signers so a failed/cancelled signature (e.g. a
+ // dismissed security-key user-presence prompt) skips to the
+ // next identity instead of aborting the whole handshake
+ for _, agentSigner := range agentSigners {
+ authSockSigners = append(authSockSigners, failoverSigner{signer: agentSigner, connCtx: connCtx})
+ }
}
}
diff --git a/pkg/remote/sshsigners.go b/pkg/remote/sshsigners.go
new file mode 100644
index 0000000..3f98123
--- /dev/null
+++ b/pkg/remote/sshsigners.go
@@ -0,0 +1,79 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package remote
+
+import (
+ "context"
+ "io"
+
+ "github.com/wavetermdev/waveterm/pkg/blocklogger"
+ "golang.org/x/crypto/ssh"
+)
+
+// failoverSigner wraps an ssh.Signer (typically an agent-backed signer) so
+// that a signing failure -- e.g. the user cancelling a FIDO2/security-key
+// user-presence prompt, or the agent declining to sign -- does not abort the
+// entire SSH handshake. The golang.org/x/crypto/ssh client treats an error
+// from Signer.Sign as fatal for the whole publickey auth method (it
+// terminates RetryableAuthMethod), so without this wrapper a single
+// cancelled prompt prevents all remaining identities from being tried.
+//
+// On a signing error we log to conndebug and return a deliberately invalid
+// signature instead. The server rejects it as a normal authentication
+// failure, which allows the retryable publickey method to continue with the
+// next identity. This mirrors OpenSSH behavior (a declined signature moves
+// on to the next key) and follows the same approach as createDummySigner,
+// which is already used for unparseable identity files.
+//
+// Note: each rejected signature consumes one of the server's MaxAuthTries
+// slots, exactly as a rejected key offer from OpenSSH would.
+type failoverSigner struct {
+ signer ssh.Signer
+ connCtx context.Context
+}
+
+func (f failoverSigner) PublicKey() ssh.PublicKey {
+ return f.signer.PublicKey()
+}
+
+func (f failoverSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {
+ sig, err := f.signer.Sign(rand, data)
+ if err == nil {
+ return sig, nil
+ }
+ f.logSignFailure(err)
+ return f.invalidSignature(), nil
+}
+
+// SignWithAlgorithm implements ssh.AlgorithmSigner. Agent signers from
+// golang.org/x/crypto/ssh/agent implement ssh.AlgorithmSigner, so the type
+// assertion below holds for the agent-backed signers this wrapper is used
+// with; the fallback exists for safety with plain signers.
+func (f failoverSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
+ if as, ok := f.signer.(ssh.AlgorithmSigner); ok {
+ sig, err := as.SignWithAlgorithm(rand, data, algorithm)
+ if err == nil {
+ return sig, nil
+ }
+ f.logSignFailure(err)
+ return f.invalidSignature(), nil
+ }
+ return f.Sign(rand, data)
+}
+
+func (f failoverSigner) logSignFailure(err error) {
+ blocklogger.Infof(f.connCtx, "[conndebug] agent signing failed for key %s %s (%v); continuing with next identity\n",
+ f.signer.PublicKey().Type(), ssh.FingerprintSHA256(f.signer.PublicKey()), err)
+}
+
+// invalidSignature returns a syntactically valid but cryptographically
+// invalid signature. The server rejects it with a normal
+// SSH_MSG_USERAUTH_FAILURE rather than a protocol error, letting
+// authentication continue with the next identity.
+func (f failoverSigner) invalidSignature() *ssh.Signature {
+ return &ssh.Signature{
+ Format: f.signer.PublicKey().Type(),
+ Blob: []byte("invalid-signature-identity-skipped"),
+ }
+}
The patch is offered under the project's Apache-2.0 license, so feel free to take it as-is or rework it. I could not compile it locally, so treat it as a starting point rather than a tested change. Happy to open it as a PR and iterate if the approach looks right to you.
Platform scope
Reproduced on Windows with the Windows OpenSSH agent, but the code path is platform-independent: the same abort happens with any agent (including $SSH_AUTH_SOCK agents on macOS and Linux) whenever a signature fails, and the proposed fix is platform-neutral.
References
Current Behavior
Wave's SSH client deviates from standard publickey authentication behavior, and the deviation is invisible in Wave's own diagnostics.
FIDO2/SK keys (Windows Hello, YubiKey, any FIDO2 authenticator) require a user-presence check for every signature, so a prompt appears for each key the agent offers. Declining a prompt is part of normal use: with several keys loaded, the prompt is how you find out which key is being offered, and "not this one" is a routine answer. Standard SSH clients treat a declined signature as "try the next identity". Wave treats it as fatal and kills the handshake:
text[conndebug] ERROR ssh auth/negotiation: ssh: handshake failed: agent: failed to sign challengeThe same two-key setup behaves correctly in ssh.exe and in the other third-party terminals programs.
Result of step 3 (see below on how to reproduce): immediate handshake failure; the second key is never offered.
Expected Behavior
The user-presence prompt for the second key appears, as with ssh.exe: standard SSH clients treat a declined signature as "try the next identity" and move on.
Separately expected: agent activity (dial failures, key-listing errors, per-key attempts) visible in
conndebugand logs, so deviations like this are diagnosable.Steps To Reproduce
ssh-addtwo security keys that require user presence confirmation into the Windows OpenSSH agent, both authorized on the target server.Wave Version
0.14.5 (202604161539)
Platform
Windows
OS Version/Distribution
Windows 11
Architecture
x64
Anything else?
Additional environment detail: Client: Windows 11, current Wave release, OpenSSH_for_Windows_9.5p2, agent at
\\.\pipe\openssh-ssh-agent. Server: Debian 13, OpenSSH 10.0p2. Keys: oneecdsa-skbacked by Windows Hello, oneed25519-skbacked by a YubiKey 5. Both public keys are in the server's authorized_keys.Root cause
Wave hands agent signers to
ssh.RetryableAuthMethodinpkg/remote/sshclient.go(createPublicKeyCallback/createClientConfig). Ingolang.org/x/crypto/ssh, an error fromSigner.Sign(which is what a cancelled agent prompt becomes) is treated as fatal:retryableAuthMethod.authstops on any error (if ok != authFailure || err != nil), so the remaining queued signers are never tried.A second defect compounds the first: Wave does not log or surface any of its agent activity, so the deviation is undiagnosable from inside the product. Agent problems never reach conndebug. The pipe-dial error is only written to the backend log via
log.Printf, theSigners()error is discarded (authSockSigners, _ = agentClient.Signers()), and agent key attempts produce no conndebug line at all. I spent a while suspecting my ssh config when the actual issue was the agent state.Proposed fix
The patch below wraps agent signers so that a failed signature is logged and replaced with a deliberately invalid one. The server rejects it as a normal auth failure and the retry loop continues with the next key, which matches OpenSSH behavior. This is the same trick
createDummySignerin this file already uses for unparseable key files. The wrapper implementsssh.AlgorithmSignerso RSArsa-sha2-*negotiation keeps working. The patch also adds conndebug lines for agent dial failures, listing errors, identity counts, and each key attempt.One side effect worth knowing: each rejected signature consumes one of the server's
MaxAuthTriesslots, the same as a rejected key offer from OpenSSH.Patch: pkg/remote/sshclient.go + new pkg/remote/sshsigners.go
Platform scope
Reproduced on Windows with the Windows OpenSSH agent, but the code path is platform-independent: the same abort happens with any agent (including
$SSH_AUTH_SOCKagents on macOS and Linux) whenever a signature fails, and the proposed fix is platform-neutral.References