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:
@@ -79,7 +79,13 @@ func (c *Client) Push(ctx context.Context, payload []byte) error {
|
|||||||
User: c.user,
|
User: c.user,
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(c.signer)},
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(c.signer)},
|
||||||
HostKeyCallback: ssh.FixedHostKey(c.hostKey),
|
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}
|
dialer := net.Dialer{Timeout: c.timeout}
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
|
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ package wgsync
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io"
|
"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) {
|
func TestNew_BadInputsFailEarly(t *testing.T) {
|
||||||
clientPEM, _ := testKeys(t)
|
clientPEM, _ := testKeys(t)
|
||||||
_, hostSigner := testKeys(t)
|
_, hostSigner := testKeys(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user