diff --git a/hub/internal/wgsync/client.go b/hub/internal/wgsync/client.go index da3c511..e787101 100644 --- a/hub/internal/wgsync/client.go +++ b/hub/internal/wgsync/client.go @@ -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) diff --git a/hub/internal/wgsync/client_test.go b/hub/internal/wgsync/client_test.go index c585931..875c95e 100644 --- a/hub/internal/wgsync/client_test.go +++ b/hub/internal/wgsync/client_test.go @@ -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)