diff options
Diffstat (limited to 'rsh/main.go')
| -rw-r--r-- | rsh/main.go | 222 |
1 files changed, 189 insertions, 33 deletions
diff --git a/rsh/main.go b/rsh/main.go index 68b6e49..9906966 100644 --- a/rsh/main.go +++ b/rsh/main.go @@ -3,11 +3,13 @@ // rsh [-l USER] [USER@]HOST CMD... rsync remote shell (rsync -e), strict // rsh -keygen generate an ed25519 key, print the private key (PEM) // rsh -pubkey print the pubkey for RSH_KEY_DATA/RSH_KEY -// rsh -scan USER@HOST connect, print host-key fingerprint + line +// rsh -scan USER@HOST connect, print host-key type + fingerprint + line // // Transport mode mirrors what ssh does for rsync: it dials the host, runs the // remote command, and bridges stdin/stdout/stderr. Host keys are verified -// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud. +// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud, and +// only the already-pinned key type is offered, so a server that switches key +// type fails at negotiation rather than presenting an unverified key. // Key, known_hosts path, and port come from the environment because rsync owns // the argument vector: // @@ -17,7 +19,9 @@ // RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode) // RSH_PORT TCP port (optional, default 22) // -// Pure Go, no cgo. +// The only non-standard Go code linked here is golang.org/x/crypto. The +// Android binaries are built with cgo against Bionic (see rsh/build.sh); the +// host binary is not. package main import ( @@ -37,7 +41,31 @@ import ( "golang.org/x/crypto/ssh/knownhosts" ) -const dialTimeout = 30 * time.Second +// defaultDialTimeout bounds the TCP connect and the SSH handshake. A firewall +// that drops packets instead of refusing them makes a connect hang for the +// whole budget, so keep it short: a reachable host completes the handshake +// well inside this even on poor mobile networks. +const defaultDialTimeout = 10 * time.Second + +// unreachablePrefix marks a failure to reach the host at all, as opposed to a +// rejected key or a mismatched host key. The app greps rsync's merged output +// for it so it can skip the remaining folders on the same dead remote instead +// of paying the timeout once per folder. +const unreachablePrefix = "unreachable: " + +// scanAlgos is the host-key preference used when first pinning a host. The +// x/crypto default puts ssh-ed25519 last, behind ecdsa, rsa and dss, so a stock +// server would get its ecdsa key pinned while the README tells the user to +// verify the ed25519 one. Prefer ed25519, accept the other modern types if that +// is all the server has, and never ssh-rsa (SHA-1) or ssh-dss. +var scanAlgos = []string{ + ssh.KeyAlgoED25519, + ssh.KeyAlgoECDSA256, + ssh.KeyAlgoECDSA384, + ssh.KeyAlgoECDSA521, + ssh.KeyAlgoRSASHA512, + ssh.KeyAlgoRSASHA256, +} func main() { if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { @@ -94,7 +122,23 @@ func transport(args []string, in io.Reader, out, errw io.Writer) error { if err != nil { return fmt.Errorf("known_hosts: %w (run Test connection first)", err) } - client, err := dial(user, host, cb) + port, err := sshPort() + if err != nil { + return err + } + algos, err := pinnedAlgos(kh, knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))) + if err != nil { + return err + } + if len(algos) == 0 { + // No literal pin for this address. The file may still authorise the + // host through a hashed or wildcard entry, which the literal match + // deliberately does not understand, so fall back to the modern + // preference and let the callback decide. Verification stays strict: + // an unknown or changed key still fails loud, just after the dial. + algos = scanAlgos + } + client, err := dial(user, host, cb, algos) if err != nil { return err } @@ -124,23 +168,96 @@ func scan(target string, out io.Writer) error { if err != nil { return err } + addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) + // Prefer the key type already pinned for this host, then fall back to the + // general preference. A server usually offers several types; picking a + // different one than last time would reproduce a different line and read as + // a host-key change on a server nobody touched. Preferring rather than + // requiring matters: negotiation walks the client list in order, so an + // untouched server still reproduces its pinned line, while one whose key + // type was genuinely rotated presents the new key and reaches the "host key + // changed" prompt instead of failing with no common algorithm and no way to + // accept the new key short of deleting the remote. + algos := scanAlgos + if kh := os.Getenv("RSH_KNOWN_HOSTS"); kh != "" { + if pinned, perr := pinnedAlgos(kh, addr); perr == nil && len(pinned) > 0 { + algos = append(append([]string{}, pinned...), scanAlgos...) + } + } var hostKey ssh.PublicKey capture := func(_ string, _ net.Addr, key ssh.PublicKey) error { hostKey = key return nil } - client, err := dial(user, host, capture) + client, err := dial(user, host, capture, algos) if err != nil { return err } client.Close() - addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) line := knownhosts.Line([]string{addr}, hostKey) - fmt.Fprintf(out, "%s\n%s\n", ssh.FingerprintSHA256(hostKey), line) + // The type belongs next to the fingerprint: the user is told to compare it + // against a specific key file on the server, and "SHA256:..." alone does not + // say which one. + fmt.Fprintf(out, "%s %s\n%s\n", hostKey.Type(), ssh.FingerprintSHA256(hostKey), line) return nil } +// pinnedAlgos returns the host-key algorithms to offer for addr: exactly the +// key types already pinned for it. Offering only the pinned type turns a server +// that switches key type into a loud negotiation failure instead of a silent +// prompt to trust a key the user never verified. +// +// rsend writes this file itself from its own pins, one exact unhashed address +// per line (knownhosts.Line), so matching the address literally is enough. +func pinnedAlgos(khPath, addr string) ([]string, error) { + data, err := os.ReadFile(khPath) + if err != nil { + return nil, fmt.Errorf("known_hosts: %w", err) + } + var algos []string + seen := make(map[string]bool) + for rest := data; len(rest) > 0; { + marker, hosts, key, _, next, perr := ssh.ParseKnownHosts(rest) + if perr == io.EOF { + break + } + if perr != nil { + return nil, fmt.Errorf("known_hosts: %w", perr) + } + rest = next + // A @revoked line names a key that must never be accepted, and a + // @cert-authority line names a signing key rather than a host key. + // Neither says anything about what this host may present. + if marker != "" { + continue + } + for _, h := range hosts { + if h != addr { + continue + } + for _, a := range algosForKeyType(key.Type()) { + if !seen[a] { + seen[a] = true + algos = append(algos, a) + } + } + } + } + return algos, nil +} + +// algosForKeyType maps a pinned known_hosts key type to the signature +// algorithms a server may use with it. Only RSA differs: an "ssh-rsa" pin +// names the same key as an rsa-sha2-* signature. Plain ssh-rsa is SHA-1 and is +// left out, matching scanAlgos and OpenSSH's own default. +func algosForKeyType(t string) []string { + if t == ssh.KeyAlgoRSA { + return []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256} + } + return []string{t} +} + // keygen generates an ed25519 key and prints the private key in PEM form to // out. Nothing touches disk: the caller owns persistence (the app stores it // encrypted) and derives the public key with -pubkey. @@ -171,23 +288,33 @@ func pubkey(out io.Writer) error { // loadSigner reads the private key from RSH_KEY_DATA (the key itself) or, if // that is unset, the file named by RSH_KEY. func loadSigner() (ssh.Signer, error) { + var signer ssh.Signer + var err error if data := os.Getenv("RSH_KEY_DATA"); data != "" { - return ssh.ParsePrivateKey([]byte(data)) - } - keyPath := os.Getenv("RSH_KEY") - if keyPath == "" { - return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set") + signer, err = ssh.ParsePrivateKey([]byte(data)) + } else { + keyPath := os.Getenv("RSH_KEY") + if keyPath == "" { + return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set") + } + pemBytes, readErr := os.ReadFile(keyPath) + if readErr != nil { + return nil, fmt.Errorf("read key: %w", readErr) + } + signer, err = ssh.ParsePrivateKey(pemBytes) } - pemBytes, err := os.ReadFile(keyPath) if err != nil { - return nil, fmt.Errorf("read key: %w", err) + return nil, err + } + if signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + return nil, fmt.Errorf("client key must be %s", ssh.KeyAlgoED25519) } - return ssh.ParsePrivateKey(pemBytes) + return signer, nil } // dial opens an SSH connection authenticated with the configured key, verifying -// the host key with hostKey. -func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) { +// the host key with hostKey and offering only the host-key algorithms in algos. +func dial(user, host string, hostKey ssh.HostKeyCallback, algos []string) (*ssh.Client, error) { signer, err := loadSigner() if err != nil { return nil, err @@ -196,18 +323,23 @@ func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) { if err != nil { return nil, err } + timeout, err := dialTimeout() + if err != nil { + return nil, err + } cfg := &ssh.ClientConfig{ - User: user, - Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, - HostKeyCallback: hostKey, - Timeout: dialTimeout, + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: hostKey, + HostKeyAlgorithms: algos, + Timeout: timeout, } addr := net.JoinHostPort(host, strconv.Itoa(port)) - conn, err := net.DialTimeout("tcp", addr, dialTimeout) + conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { - return nil, err + return nil, fmt.Errorf("%s%w", unreachablePrefix, err) } - if err := conn.SetDeadline(time.Now().Add(dialTimeout)); err != nil { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { conn.Close() return nil, err } @@ -246,22 +378,46 @@ func parseTransport(args []string) (user, host string, cmd []string, err error) if i >= len(args) { return "", "", nil, errors.New("missing host") } - host = args[i] cmd = args[i+1:] - if u, h := splitUserHost(host); u != "" { - host = h - if user == "" { - user = u - } + u, h := splitUserHost(args[i]) + host = h + if u != "" && user == "" { + user = u } return user, host, cmd, nil } func splitUserHost(s string) (user, host string) { if i := strings.Index(s, "@"); i >= 0 { - return s[:i], s[i+1:] + return s[:i], unbracket(s[i+1:]) + } + return "", unbracket(s) +} + +// unbracket strips the brackets people habitually put around an IPv6 literal. +// A bare literal already works, because net.JoinHostPort adds the brackets and +// knownhosts.Normalize takes them back off; a pre-bracketed one would be +// double-bracketed into an address that can be neither dialled nor matched +// against a pin. +func unbracket(h string) string { + if len(h) > 1 && h[0] == '[' && h[len(h)-1] == ']' { + return h[1 : len(h)-1] + } + return h +} + +// dialTimeout returns the connect budget, overridable in whole seconds with +// RSH_CONNECT_TIMEOUT for running rsh by hand against a slow or filtered host. +func dialTimeout() (time.Duration, error) { + s := os.Getenv("RSH_CONNECT_TIMEOUT") + if s == "" { + return defaultDialTimeout, nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 3600 { + return 0, fmt.Errorf("invalid RSH_CONNECT_TIMEOUT %q", s) } - return "", s + return time.Duration(n) * time.Second, nil } func sshPort() (int, error) { |