aboutsummaryrefslogtreecommitdiff
path: root/rsh/transport_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'rsh/transport_test.go')
-rw-r--r--rsh/transport_test.go305
1 files changed, 303 insertions, 2 deletions
diff --git a/rsh/transport_test.go b/rsh/transport_test.go
index 7ff74b1..d06f086 100644
--- a/rsh/transport_test.go
+++ b/rsh/transport_test.go
@@ -2,6 +2,10 @@ package main
import (
"bytes"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/pem"
"errors"
"io"
"net"
@@ -10,6 +14,7 @@ import (
"strconv"
"strings"
"testing"
+ "time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
@@ -126,6 +131,68 @@ func TestTransportPropagatesExitCode(t *testing.T) {
}
}
+func TestDialTimeoutFromEnv(t *testing.T) {
+ t.Setenv("RSH_CONNECT_TIMEOUT", "")
+ if d, err := dialTimeout(); err != nil || d != defaultDialTimeout {
+ t.Fatalf("unset: got %v, %v; want %v", d, err, defaultDialTimeout)
+ }
+ t.Setenv("RSH_CONNECT_TIMEOUT", "3")
+ if d, err := dialTimeout(); err != nil || d != 3*time.Second {
+ t.Fatalf("set: got %v, %v; want 3s", d, err)
+ }
+ for _, bad := range []string{"0", "-1", "abc", "3.5", "99999"} {
+ t.Setenv("RSH_CONNECT_TIMEOUT", bad)
+ if _, err := dialTimeout(); err == nil {
+ t.Fatalf("RSH_CONNECT_TIMEOUT=%q: expected error, got nil", bad)
+ }
+ }
+}
+
+// A host we cannot reach must be reported with the marker the app greps for,
+// so it can skip the rest of the folders bound to that remote.
+func TestUnreachableHostIsMarked(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // A port with nothing behind it: the connect fails before any handshake.
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ dead := l.Addr().(*net.TCPAddr).Port
+ l.Close()
+
+ // Pin the dead port so the setup is the realistic one: a host rsend has
+ // synced with before and now cannot reach.
+ kh := writeKnownHosts(t, "127.0.0.1", dead, srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, dead)
+ err = transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected an error dialing a closed port, got nil")
+ }
+ if !strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("error %q does not carry %q", err, unreachablePrefix)
+ }
+}
+
+// An auth or host-key failure is not "unreachable": the host answered. Marking
+// it would make the app skip folders that a retry could still fix.
+func TestHostKeyMismatchIsNotMarkedUnreachable(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+ _, wrong := genClientKey(t)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), wrong)
+ setEnv(t, keyPath, kh, srv.port())
+
+ err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected a host-key mismatch error, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("host-key mismatch wrongly marked unreachable: %v", err)
+ }
+}
+
func TestPubkeyMatchesKeygen(t *testing.T) {
keyPath, pub := genClientKey(t)
keyData, err := os.ReadFile(keyPath)
@@ -156,6 +223,22 @@ func TestPubkeyRejectsMissingKey(t *testing.T) {
}
}
+func TestPubkeyRejectsNonEd25519Key(t *testing.T) {
+ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := ssh.MarshalPrivateKey(private, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("RSH_KEY", "")
+ t.Setenv("RSH_KEY_DATA", string(pem.EncodeToMemory(block)))
+ if err := pubkey(io.Discard); err == nil || !strings.Contains(err.Error(), "ssh-ed25519") {
+ t.Fatalf("expected ed25519-only error, got %v", err)
+ }
+}
+
func TestScanPrintsFingerprintAndLine(t *testing.T) {
keyPath, pub := genClientKey(t)
srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
@@ -170,8 +253,10 @@ func TestScanPrintsFingerprintAndLine(t *testing.T) {
if len(lines) != 2 {
t.Fatalf("want fingerprint + known_hosts line, got %q", out.String())
}
- if !strings.HasPrefix(lines[0], "SHA256:") {
- t.Errorf("fingerprint: %q", lines[0])
+ // Line 0 is "<keytype> SHA256:<base64>": the type is what the user compares
+ // against a specific key file on the server.
+ if lines[0] != "ssh-ed25519 "+ssh.FingerprintSHA256(srv.hostKey.PublicKey()) {
+ t.Errorf("type + fingerprint line: %q", lines[0])
}
// The printed known_hosts line must validate the real host key.
khPath := filepath.Join(t.TempDir(), "kh")
@@ -189,6 +274,216 @@ func TestScanPrintsFingerprintAndLine(t *testing.T) {
}
}
+// A stock sshd offers several host key types. x/crypto's default client
+// preference puts ssh-ed25519 last, behind ecdsa and rsa, so without an
+// explicit policy rsend would pin the ecdsa key while the README tells the user
+// to verify the ed25519 fingerprint. Scanning must pick ed25519.
+func TestScanPrefersEd25519(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServerKeys(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }, true)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := "ssh-ed25519 " + ssh.FingerprintSHA256(srv.hostKey.PublicKey())
+ if lines[0] != want {
+ t.Fatalf("scan pinned the wrong host key:\n got %q\nwant %q", lines[0], want)
+ }
+}
+
+// Once a type is pinned, only that type may be offered, so a server that also
+// has an ecdsa key cannot be negotiated onto it.
+func TestTransportOffersOnlyPinnedKeyType(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int {
+ io.Copy(stdout, stdin)
+ return 0
+ }
+ srv := newTestServerKeys(t, pub, echo, true)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ want := []byte("pinned to ed25519")
+ var out bytes.Buffer
+ if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil {
+ t.Fatalf("transport: %v", err)
+ }
+ if !bytes.Equal(out.Bytes(), want) {
+ t.Fatalf("bridged data mismatch: %q", out.Bytes())
+ }
+}
+
+// A pin for a key type the server no longer has must fail loud at negotiation
+// rather than fall through to some other key the user never verified.
+func TestTransportRejectsWhenPinnedTypeAbsent(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // Pin an ecdsa key; the server only has ed25519.
+ epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ epub, err := ssh.NewPublicKey(&epriv.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), epub)
+ setEnv(t, keyPath, kh, srv.port())
+
+ err = transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected a failure when the pinned key type is unavailable, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("a reachable host must not be marked unreachable: %v", err)
+ }
+}
+
+// With nothing pinned for this host:port the algorithm policy has nothing to
+// go on, so it falls back to the modern preference and lets the known_hosts
+// callback decide. The host must still be rejected, loudly, and not be mistaken
+// for unreachable.
+func TestTransportRejectsUnpinnedHost(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+ // Pin a different port, so the file parses but holds nothing for this one.
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port()+1, srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected an unknown-host error, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("a reachable host must not be marked unreachable: %v", err)
+ }
+}
+
+// Re-scanning a host that is already pinned must reproduce the pinned key type,
+// not the scan-time preference. Otherwise a config pinned by an older rsend
+// (which negotiated ecdsa) would scan ed25519 and look like a key change on a
+// server nobody touched.
+func TestScanReproducesThePinnedKeyType(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServerKeys(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }, true)
+
+ // Pin the ecdsa key, as an older rsend would have.
+ ecdsaPub := srv.extraKey.PublicKey()
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), ecdsaPub)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := ecdsaPub.Type() + " " + ssh.FingerprintSHA256(ecdsaPub)
+ if lines[0] != want {
+ t.Fatalf("scan did not reproduce the pinned key:\n got %q\nwant %q", lines[0], want)
+ }
+ // The reproduced known_hosts line must equal the stored pin exactly, or the
+ // app's pin comparison reports a change.
+ stored, err := os.ReadFile(kh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lines[1] != strings.TrimSpace(string(stored)) {
+ t.Fatalf("reproduced line differs from the pin:\n got %q\nwant %q", lines[1], strings.TrimSpace(string(stored)))
+ }
+}
+
+// A host whose key type was genuinely rotated must still be scannable, or the
+// app can never show its "host key changed" prompt and the user has to delete
+// and recreate the remote. Preferring the pinned type is right; requiring it is
+// not.
+func TestScanStillWorksAfterAKeyTypeRotation(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ // The server now has only ed25519, as if its ecdsa key had been removed.
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // The stored pin is an ecdsa key the server no longer offers.
+ epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ epub, err := ssh.NewPublicKey(&epriv.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), epub)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan must reach the new key so the caller can warn about it: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := "ssh-ed25519 " + ssh.FingerprintSHA256(srv.hostKey.PublicKey())
+ if lines[0] != want {
+ t.Fatalf("scan did not report the rotated key:\n got %q\nwant %q", lines[0], want)
+ }
+}
+
+func TestPinnedAlgos(t *testing.T) {
+ _, pub := genClientKey(t)
+ kh := writeKnownHosts(t, "10.0.0.1", 2222, pub)
+ addr := knownhosts.Normalize(net.JoinHostPort("10.0.0.1", "2222"))
+
+ got, err := pinnedAlgos(kh, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0] != ssh.KeyAlgoED25519 {
+ t.Fatalf("pinned algos: got %v want [%s]", got, ssh.KeyAlgoED25519)
+ }
+ // A host with no pin yields nothing rather than a default.
+ other, err := pinnedAlgos(kh, knownhosts.Normalize(net.JoinHostPort("10.0.0.2", "22")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(other) != 0 {
+ t.Fatalf("unpinned host: got %v want none", other)
+ }
+ // An ssh-rsa pin allows the SHA-2 signature algorithms and not SHA-1.
+ got = algosForKeyType(ssh.KeyAlgoRSA)
+ if len(got) != 2 || got[0] != ssh.KeyAlgoRSASHA512 || got[1] != ssh.KeyAlgoRSASHA256 {
+ t.Fatalf("rsa expansion: %v", got)
+ }
+ for _, a := range got {
+ if a == ssh.KeyAlgoRSA {
+ t.Fatal("plain ssh-rsa (SHA-1) must not be offered")
+ }
+ }
+}
+
+// A @revoked entry names a key that must never be trusted, and @cert-authority
+// names a signing key, not a host key. Neither may contribute an algorithm.
+func TestPinnedAlgosIgnoresMarkers(t *testing.T) {
+ _, pub := genClientKey(t)
+ addr := knownhosts.Normalize(net.JoinHostPort("10.0.0.9", "22"))
+ line := knownhosts.Line([]string{addr}, pub)
+ p := filepath.Join(t.TempDir(), "kh")
+ if err := os.WriteFile(p, []byte("@revoked "+line+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pinnedAlgos(p, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("a revoked entry must contribute nothing, got %v", got)
+ }
+}
+
func TestParseTransport(t *testing.T) {
cases := []struct {
args []string
@@ -202,6 +497,12 @@ func TestParseTransport(t *testing.T) {
{[]string{"host"}, "", "host", "", false},
{[]string{"-l"}, "", "", "", true},
{[]string{"-p", "22", "host", "x"}, "", "", "", true},
+ // A bare IPv6 literal already works; a bracketed one must not survive
+ // into net.JoinHostPort, which would double-bracket it.
+ {[]string{"-l", "u", "2001:db8::1", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"-l", "u", "[2001:db8::1]", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"u@[2001:db8::1]", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"[::1]"}, "", "::1", "", false},
}
for _, c := range cases {
u, h, cmd, err := parseTransport(c.args)