aboutsummaryrefslogtreecommitdiff
path: root/rsh/main.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-16 00:00:00 +0000
committerLena <lena@omega>2026-08-16 00:00:00 +0000
commitbeaa0c970d6c3538119b8a34a3f2f754b45e54cf (patch)
tree0a4be0a63a5c5b40f3db66a7e2b6c603e60d8473 /rsh/main.go
parent0890d139e5fa501561e469aca7854791a936fcb6 (diff)
downloadrsend-beaa0c970d6c3538119b8a34a3f2f754b45e54cf.tar.gz
native: harden rsync and SSH transport
Update rsync to 3.5.0 and Go to 1.26.6. Bound SSH handshakes, pin host-key types, and build 16 KB-aligned hardened executables.
Diffstat (limited to 'rsh/main.go')
-rw-r--r--rsh/main.go222
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) {