From a6b9fbc085201f35187c55cbe1d38e96efe11fa4 Mon Sep 17 00:00:00 2001 From: "zhenxing.shen" Date: Sun, 28 Jun 2026 14:04:27 +0800 Subject: [PATCH 1/3] fix(ssh): wrap agent signers to continue to next identity on signing failure When an SSH agent signer fails to sign (e.g. user cancels a FIDO2/security-key user-presence prompt), golang.org/x/crypto/ssh treats the error as fatal and aborts the entire publickey authentication, preventing remaining identities from being tried. This deviates from OpenSSH behavior where a declined signature simply moves on to the next key. This change wraps agent-backed signers in a failoverSigner that returns a deliberately invalid signature on signing failure instead of propagating the error. The server rejects the invalid signature as a normal auth failure, allowing RetryableAuthMethod to continue with the next identity. Also adds conndebug logging for: - Agent socket dial failures - Agent key listing errors - Number of identities provided by the agent - Each agent identity being attempted - Per-key signing failures Fixes #3365 --- pkg/remote/sshclient.go | 13 +++++++++- pkg/remote/sshsigners.go | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 pkg/remote/sshsigners.go diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index b1c9faca21..20cdd3c490 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,19 @@ 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)) + 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 0000000000..aa635fd8d1 --- /dev/null +++ b/pkg/remote/sshsigners.go @@ -0,0 +1,51 @@ +// 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" +) + +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 + } + 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) + return f.invalidSignature(), nil +} + +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 + } + 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) + return f.invalidSignature(), nil + } + return f.Sign(rand, data) +} + +func (f failoverSigner) invalidSignature() *ssh.Signature { + return &ssh.Signature{ + Format: f.signer.PublicKey().Type(), + Blob: []byte("invalid-signature-identity-skipped"), + } +} From 5c73f06b22c30b92250cd9d5edbbcd73f2e4d5d0 Mon Sep 17 00:00:00 2001 From: "zhenxing.shen" Date: Tue, 30 Jun 2026 09:20:31 +0800 Subject: [PATCH 2/3] fix(ssh): preserve requested algorithm in failover signature format - invalidSignature now accepts a format parameter - SignWithAlgorithm passes the requested algorithm instead of key type - Add docstrings for failoverSigner and all methods to meet coverage Addresses CodeRabbit review comment on PR #3401 --- pkg/remote/sshsigners.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pkg/remote/sshsigners.go b/pkg/remote/sshsigners.go index aa635fd8d1..20e01a2636 100644 --- a/pkg/remote/sshsigners.go +++ b/pkg/remote/sshsigners.go @@ -11,15 +11,23 @@ import ( "golang.org/x/crypto/ssh" ) +// failoverSigner wraps an ssh.Signer so that a signing failure from one +// agent identity does not abort authentication; instead it returns a +// synthesized invalid signature, allowing the SSH client to try the next +// identity (matching OpenSSH's failover behavior). type failoverSigner struct { signer ssh.Signer connCtx context.Context } +// PublicKey returns the public key of the wrapped signer. func (f failoverSigner) PublicKey() ssh.PublicKey { return f.signer.PublicKey() } +// Sign signs the data with the wrapped signer. On failure it logs the error +// and returns an invalid placeholder signature so the client can continue to +// the next identity. func (f failoverSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { sig, err := f.signer.Sign(rand, data) if err == nil { @@ -27,9 +35,13 @@ func (f failoverSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, 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) - return f.invalidSignature(), nil + return f.invalidSignature(f.signer.PublicKey().Type()), nil } +// SignWithAlgorithm signs the data with the wrapped signer using the requested +// algorithm. On failure it logs the error and returns an invalid placeholder +// signature whose Format matches the requested algorithm, allowing the client +// to try the next identity. 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) @@ -38,14 +50,17 @@ func (f failoverSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm } 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) - return f.invalidSignature(), nil + return f.invalidSignature(algorithm), nil } return f.Sign(rand, data) } -func (f failoverSigner) invalidSignature() *ssh.Signature { +// invalidSignature constructs a placeholder ssh.Signature with the given +// format and a clearly-invalid blob. Returning this (rather than an error) +// lets the SSH client move on to the next offered identity. +func (f failoverSigner) invalidSignature(format string) *ssh.Signature { return &ssh.Signature{ - Format: f.signer.PublicKey().Type(), + Format: format, Blob: []byte("invalid-signature-identity-skipped"), } } From 8843a0f1b0ebc6a4b3727555db2d915edfc8876e Mon Sep 17 00:00:00 2001 From: "zhenxing.shen" Date: Sun, 5 Jul 2026 09:35:17 +0800 Subject: [PATCH 3/3] docs: add docstrings for failoverSigner and invalidSignature --- pkg/remote/sshsigners.go | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/pkg/remote/sshsigners.go b/pkg/remote/sshsigners.go index 20e01a2636..87846d2bfd 100644 --- a/pkg/remote/sshsigners.go +++ b/pkg/remote/sshsigners.go @@ -11,23 +11,29 @@ import ( "golang.org/x/crypto/ssh" ) -// failoverSigner wraps an ssh.Signer so that a signing failure from one -// agent identity does not abort authentication; instead it returns a -// synthesized invalid signature, allowing the SSH client to try the next -// identity (matching OpenSSH's failover behavior). +// failoverSigner wraps an ssh.Signer to implement SSH agent identity failover. +// When signing with one agent identity fails, instead of aborting the entire +// authentication attempt, it produces a synthesized invalid signature that +// causes the SSH client to try the next available identity, matching OpenSSH's +// default multi-identity authentication behavior. It implements both +// ssh.Signer and ssh.AlgorithmSigner. type failoverSigner struct { signer ssh.Signer connCtx context.Context } -// PublicKey returns the public key of the wrapped signer. +// PublicKey returns the public key associated with the wrapped signer. +// It implements ssh.Signer. func (f failoverSigner) PublicKey() ssh.PublicKey { return f.signer.PublicKey() } -// Sign signs the data with the wrapped signer. On failure it logs the error -// and returns an invalid placeholder signature so the client can continue to -// the next identity. +// Sign signs the given data using the wrapped signer. If signing succeeds, the +// valid signature is returned. If signing fails (e.g. because the agent cannot +// authenticate with this particular identity), the error is logged via +// blocklogger and an invalid placeholder signature is returned instead of an +// error, allowing the SSH client to proceed to the next offered identity. +// It implements ssh.Signer. func (f failoverSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) { sig, err := f.signer.Sign(rand, data) if err == nil { @@ -38,10 +44,12 @@ func (f failoverSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error return f.invalidSignature(f.signer.PublicKey().Type()), nil } -// SignWithAlgorithm signs the data with the wrapped signer using the requested -// algorithm. On failure it logs the error and returns an invalid placeholder -// signature whose Format matches the requested algorithm, allowing the client -// to try the next identity. +// SignWithAlgorithm signs the given data using the wrapped signer with the +// requested signature algorithm. If the wrapped signer supports +// ssh.AlgorithmSigner, the algorithm is forwarded; if signing fails in that +// case an invalid signature with the requested algorithm format is returned. +// If the wrapped signer does not implement ssh.AlgorithmSigner, it falls back +// to Sign. It implements ssh.AlgorithmSigner. 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) @@ -56,8 +64,9 @@ func (f failoverSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm } // invalidSignature constructs a placeholder ssh.Signature with the given -// format and a clearly-invalid blob. Returning this (rather than an error) -// lets the SSH client move on to the next offered identity. +// format and an obviously-invalid blob. Returning this sentinel signature +// instead of propagating the signing error causes the SSH client to skip +// this identity and move on to the next one offered by the agent. func (f failoverSigner) invalidSignature(format string) *ssh.Signature { return &ssh.Signature{ Format: format,