hub: wgsync fix — constrain HostKeyAlgorithms to the pinned key's type

Live S1 validation caught it: a stock multi-hostkey sshd presented ECDSA while
we pin ed25519 → FixedHostKey refused a legitimate server. Regression test with
an in-process dual-hostkey server (fails without the fix — red-proofed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-03 23:52:08 +02:00
parent 6f5fb19a64
commit 0fa7ea1da1
2 changed files with 82 additions and 1 deletions
+7 -1
View File
@@ -79,7 +79,13 @@ func (c *Client) Push(ctx context.Context, payload []byte) error {
User: c.user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(c.signer)},
HostKeyCallback: ssh.FixedHostKey(c.hostKey),
Timeout: c.timeout,
// Constrain negotiation to the PINNED key's algorithm. Without this the default
// algorithm preference makes a multi-hostkey sshd (stock: ECDSA + ed25519) present a
// different key type than the pinned one — FixedHostKey then refuses a LEGITIMATE
// server. Found live in the S1 validation (host key mismatch against the real sshd);
// regression: TestPush_MultiHostkeyServerStillMatchesPin.
HostKeyAlgorithms: []string{c.hostKey.Type()},
Timeout: c.timeout,
}
dialer := net.Dialer{Timeout: c.timeout}
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
+75
View File
@@ -7,7 +7,9 @@ package wgsync
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"io"
@@ -204,6 +206,79 @@ func TestPush_WrongHostKeyRefused(t *testing.T) {
}
}
// TestPush_MultiHostkeyServerStillMatchesPin reproduces the live S1 failure: a server holding
// MULTIPLE host keys (like a stock sshd: ECDSA + ed25519) must still present the PINNED type.
// Without constraining ClientConfig.HostKeyAlgorithms to the pinned key's algorithm, default
// negotiation can select the other key and FixedHostKey refuses a legitimate server.
func TestPush_MultiHostkeyServerStillMatchesPin(t *testing.T) {
clientPEM, clientSigner := testKeys(t)
_, edSigner := testKeys(t)
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa: %v", err)
}
ecSigner, err := ssh.NewSignerFromKey(ecKey)
if err != nil {
t.Fatalf("ecdsa signer: %v", err)
}
// Server holds BOTH keys, ECDSA added first (the tempting default pick).
cfg := &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if bytes.Equal(key.Marshal(), clientSigner.PublicKey().Marshal()) {
return &ssh.Permissions{}, nil
}
return nil, io.EOF
},
}
cfg.AddHostKey(ecSigner)
cfg.AddHostKey(edSigner)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
sconn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
return
}
defer sconn.Close()
go ssh.DiscardRequests(reqs)
for newCh := range chans {
ch, chReqs, err := newCh.Accept()
if err != nil {
continue
}
go func() {
for req := range chReqs {
if req.Type == "exec" {
req.Reply(true, nil)
io.ReadAll(ch)
ch.Write([]byte(`{"status":"ok","applied":0}`))
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0}))
ch.Close()
return
}
req.Reply(false, nil)
}
}()
}
}()
// Pin the ed25519 key — Push must negotiate exactly that type and succeed.
c := newTestClient(t, ln.Addr().String(), hostKeyLine(t, edSigner), clientPEM)
if err := c.Push(context.Background(), []byte(`{"peers":[]}`)); err != nil {
t.Fatalf("Push against multi-hostkey server with correct pin: %v", err)
}
}
func TestNew_BadInputsFailEarly(t *testing.T) {
clientPEM, _ := testKeys(t)
_, hostSigner := testKeys(t)