diff options
| -rwxr-xr-x | rsh/build.sh | 36 | ||||
| -rw-r--r-- | rsh/e2e_test.go | 64 | ||||
| -rw-r--r-- | rsh/go.mod | 6 | ||||
| -rw-r--r-- | rsh/go.sum | 12 | ||||
| -rw-r--r-- | rsh/main.go | 222 | ||||
| -rw-r--r-- | rsh/sshserver_test.go | 42 | ||||
| -rw-r--r-- | rsh/transport_test.go | 305 | ||||
| -rwxr-xr-x | rsync/build.sh | 74 | ||||
| -rw-r--r-- | rsync/rsync-3.4.1.tar.gz.sha256 | 1 | ||||
| -rw-r--r-- | rsync/rsync-3.5.0.tar.gz.sha256 | 1 | ||||
| -rw-r--r-- | versions | 10 |
11 files changed, 685 insertions, 88 deletions
diff --git a/rsh/build.sh b/rsh/build.sh index a3b2cad..f91343a 100755 --- a/rsh/build.sh +++ b/rsh/build.sh @@ -9,9 +9,15 @@ # build targets glibc's linker and will not exec on a phone. set -eu -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +# shellcheck source=versions . "$root/versions" +command -v bash >/dev/null 2>&1 || { + echo "rsh: bash is required by the Android NDK compiler launchers" >&2 + exit 1 +} + # Locate the NDK toolchain. ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}} if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then @@ -34,28 +40,44 @@ if [ ! -d "$tc" ]; then echo "rsh: NDK toolchain not found at $tc" >&2 exit 1 fi +output= +trap 'if [ -n "$output" ]; then rm -f "$output"; fi' 0 1 2 3 15 +# -buildvcs=false as well as -trimpath: Go otherwise stamps the commit, the +# commit time and a dirty flag into the binary, so the same source builds +# differently depending on the checkout it was built from, or on whether git is +# even installed. ci/verify-repro.sh cannot catch that, because it builds twice +# from one tree. ldflags="-s -w -buildid=" +# NDK r27 needs both page-size flags for 16 KB Android devices. +android_ldflags="$ldflags -extldflags=-Wl,-z,relro,-z,now,-z,noexecstack,-z,max-page-size=16384,-z,common-page-size=16384" cd "$root/rsh" for abi in $ABIS; do case "$abi" in - arm64-v8a) goarch=arm64 goarm= cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;; + arm64-v8a) goarch=arm64 goarm='' cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;; armeabi-v7a) goarch=arm goarm=7 cc="armv7a-linux-androideabi${ANDROID_MIN_SDK}-clang" ;; - x86_64) goarch=amd64 goarm= cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;; - x86) goarch=386 goarm= cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;; + x86_64) goarch=amd64 goarm='' cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;; + x86) goarch=386 goarm='' cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;; *) echo "rsh: unknown ABI $abi" >&2; exit 1 ;; esac dest="$root/app/src/main/jniLibs/$abi" mkdir -p "$dest" echo "rsh: building $abi (android/$goarch)" + output="$dest/libxrsh.so.part.$$" CGO_ENABLED=1 GOOS=android GOARCH="$goarch" GOARM="$goarm" \ - CC="$tc/$cc" GOFLAGS=-trimpath CGO_CFLAGS="-ffile-prefix-map=$root=." \ - go build -buildmode=pie -ldflags "$ldflags" -o "$dest/libxrsh.so" . + CC="$tc/$cc" GOFLAGS=-trimpath \ + CGO_CFLAGS="-fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$root=." \ + go build -buildvcs=false -buildmode=pie -ldflags "$android_ldflags" -o "$output" . + mv "$output" "$dest/libxrsh.so" + output= done # Host binary (no NDK, cgo off) for tests and manual debugging. mkdir -p "$root/out" echo "rsh: building host binary -> out/rsh" -CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags "$ldflags" -o "$root/out/rsh" . +output="$root/out/rsh.part.$$" +CGO_ENABLED=0 GOFLAGS=-trimpath go build -buildvcs=false -ldflags "$ldflags" -o "$output" . +mv "$output" "$root/out/rsh" +output= diff --git a/rsh/e2e_test.go b/rsh/e2e_test.go index 88cd317..e6f82b9 100644 --- a/rsh/e2e_test.go +++ b/rsh/e2e_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "io" "os" "os/exec" "path/filepath" @@ -28,26 +29,69 @@ func TestEndToEndRealRsync(t *testing.T) { } keyPath, pub := genClientKey(t) - srv := newTestServer(t, pub, shellExec) + remoteRoot := t.TempDir() + execute := func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int { + return shellExecIn(remoteRoot, cmd, stdin, stdout, stderr) + } + srv := newTestServer(t, pub, execute) kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey()) src := t.TempDir() - dst := t.TempDir() + // A normal rsync-over-SSH destination may contain shell punctuation. rsync + // escapes the remote command before handing it to rsh; this catches any + // transport change that loses those escapes while joining the command. + marker := filepath.Join(remoteRoot, "injected") + dst := filepath.Join(t.TempDir(), "backup path;literal$dollar'quote$(touch injected)") mustWrite(t, filepath.Join(src, "a.txt"), "alpha") mustWrite(t, filepath.Join(src, "sub", "b.bin"), "\x00\x01\x02\x03beta") mustWrite(t, filepath.Join(src, "sub", "c.txt"), "gamma gamma gamma") - cmd := exec.Command(rsyncBin, "-a", "-e", rshBin, src+"/", "u@127.0.0.1:"+dst+"/") - cmd.Env = append(os.Environ(), - "RSH_KEY="+keyPath, - "RSH_KNOWN_HOSTS="+kh, - "RSH_PORT="+strconv.Itoa(srv.port()), - ) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("rsync: %v\n%s", err, out) + runRsync := func(mirror bool) { + t.Helper() + args := []string{ + "-rt", "--out-format=%o %n", "--partial-dir=.rsend-partial", "--timeout=300", + "--no-perms", "--no-owner", "--no-group", "--omit-dir-times", "-e", rshBin, + } + if mirror { + args = append(args, "--delete-after") + } + args = append(args, src+"/", "u@127.0.0.1:"+dst+"/") + cmd := exec.Command(rsyncBin, args...) + cmd.Env = append(os.Environ(), + "RSH_KEY="+keyPath, + "RSH_KNOWN_HOSTS="+kh, + "RSH_PORT="+strconv.Itoa(srv.port()), + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("rsync: %v\n%s", err, out) + } } + runRsync(false) checkSame(t, src, dst) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("remote path was evaluated by the shell: %s", marker) + } + + // An empty but readable source is a valid mirror. The app intentionally + // follows rsync here: every ordinary item in the destination is removed. + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(src, entry.Name())); err != nil { + t.Fatal(err) + } + } + runRsync(true) + entries, err = os.ReadDir(dst) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("empty mirror left destination entries: %v", entries) + } } func mustWrite(t *testing.T, path, content string) { @@ -1,7 +1,7 @@ module rsend/rsh -go 1.25.0 +go 1.26.6 -require golang.org/x/crypto v0.53.0 +require golang.org/x/crypto v0.54.0 -require golang.org/x/sys v0.46.0 // indirect +require golang.org/x/sys v0.47.0 // indirect @@ -1,6 +1,6 @@ -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 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) { diff --git a/rsh/sshserver_test.go b/rsh/sshserver_test.go index 087bff4..f238e0b 100644 --- a/rsh/sshserver_test.go +++ b/rsh/sshserver_test.go @@ -1,7 +1,9 @@ package main import ( + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "errors" "fmt" @@ -20,14 +22,24 @@ type execHandler func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int // testServer is a minimal in-process SSH server for tests: one host key, one // accepted client public key, and a pluggable exec handler. No sshd needed. type testServer struct { - ln net.Listener - hostKey ssh.Signer - authPub ssh.PublicKey - handle execHandler + ln net.Listener + hostKey ssh.Signer + extraKey ssh.Signer + authPub ssh.PublicKey + handle execHandler } func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *testServer { t.Helper() + return newTestServerKeys(t, clientPub, handle, false) +} + +// newTestServerKeys builds the server with an ed25519 host key and, when +// alsoECDSA is set, an ECDSA one too. A server offering both is the stock sshd +// case: x/crypto's default client preference would pick the ECDSA key, so this +// is what proves rsh's host-key algorithm policy is doing its job. +func newTestServerKeys(t *testing.T, clientPub ssh.PublicKey, handle execHandler, alsoECDSA bool) *testServer { + t.Helper() _, hpriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -36,11 +48,21 @@ func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *t if err != nil { t.Fatal(err) } + var extra ssh.Signer + if alsoECDSA { + epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if extra, err = ssh.NewSignerFromKey(epriv); err != nil { + t.Fatal(err) + } + } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } - s := &testServer{ln: ln, hostKey: signer, authPub: clientPub, handle: handle} + s := &testServer{ln: ln, hostKey: signer, extraKey: extra, authPub: clientPub, handle: handle} go s.serve() t.Cleanup(func() { ln.Close() }) return s @@ -58,6 +80,9 @@ func (s *testServer) serve() { }, } cfg.AddHostKey(s.hostKey) + if s.extraKey != nil { + cfg.AddHostKey(s.extraKey) + } for { nConn, err := s.ln.Accept() if err != nil { @@ -107,15 +132,16 @@ func (s *testServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) } } -// shellExec runs cmd via /bin/sh -c, exactly as sshd would, wiring the channel -// to the process. Used by the real-rsync end-to-end test. +// shellExecIn runs cmd via /bin/sh -c, exactly as sshd would, wiring the +// channel to the process. Used by the real-rsync end-to-end test. // // stdin is fed through a detached goroutine rather than c.Stdin so that Wait // does not block on the stdin copy: rsync's remote receiver exits while the // client still holds the channel open, and a c.Run with a non-file Stdin would // deadlock waiting for that copy to finish. -func shellExec(cmd string, stdin io.Reader, stdout, stderr io.Writer) int { +func shellExecIn(dir, cmd string, stdin io.Reader, stdout, stderr io.Writer) int { c := exec.Command("/bin/sh", "-c", cmd) + c.Dir = dir c.Stdout = stdout c.Stderr = stderr inPipe, err := c.StdinPipe() 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) diff --git a/rsync/build.sh b/rsync/build.sh index 0a7fcda..1af7fbb 100755 --- a/rsync/build.sh +++ b/rsync/build.sh @@ -5,9 +5,15 @@ # nothing outside Bionic; zlib and popt come from rsync's bundled copies. set -eu -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +# shellcheck source=versions . "$root/versions" +command -v bash >/dev/null 2>&1 || { + echo "rsync: bash is required by the Android NDK compiler launchers" >&2 + exit 1 +} + # Locate the NDK toolchain. ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}} if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then @@ -34,21 +40,35 @@ fi work="$root/rsync/work" tarball="$root/rsync/rsync-${RSYNC_VERSION}.tar.gz" src="$work/rsync-${RSYNC_VERSION}" +part="$tarball.part.$$" +output= +trap 'if [ -n "$part" ]; then rm -f "$part"; fi; if [ -n "$output" ]; then rm -f "$output"; fi' 0 1 2 3 15 # Fetch and verify the pinned source tarball. mkdir -p "$work" +if [ -f "$tarball" ] && ! ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" >/dev/null 2>&1 ); then + echo "rsync: removing corrupt $(basename "$tarball")" >&2 + rm -f "$tarball" +fi if [ ! -f "$tarball" ]; then echo "rsync: fetching $RSYNC_URL" - curl -fsSL "$RSYNC_URL" -o "$tarball" + # Download aside and rename, so an interrupted fetch leaves no truncated + # tarball for the next run to pick up. Verify before publishing it. + curl --fail --silent --show-error --location --retry 3 \ + --connect-timeout 30 --speed-limit 1024 --speed-time 60 \ + "$RSYNC_URL" -o "$part" + want=$(awk 'NR == 1 { print $1 }' "$root/rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256") + actual=$(sha256sum "$part") + [ "${actual%% *}" = "$want" ] || { + echo "rsync: checksum failed for $(basename "$tarball")" >&2 + exit 1 + } + mv "$part" "$tarball" + part= fi echo "rsync: verifying sha256" ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" ) -# No system libraries, no docs (md2man needs python3), no iconv. -flags="--disable-md2man --disable-openssl --disable-xxhash --disable-zstd \ ---disable-lz4 --disable-iconv --disable-acl-support --disable-xattr-support \ ---with-included-popt --with-included-zlib" - for abi in $ABIS; do case "$abi" in arm64-v8a) host=aarch64-linux-android cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;; @@ -62,18 +82,44 @@ for abi in $ABIS; do rm -rf "$src" tar xzf "$tarball" -C "$work" + # CXX must be pinned like CC: rsync's SIMD checksum is C++, and configure + # otherwise falls back to the host g++, leaking a host-compiled object + # into the target binary (or failing where the host lacks ifunc). echo "rsync: configuring $abi" - ( cd "$src" && \ - CC="$tc/$cc" AR="$tc/llvm-ar" RANLIB="$tc/llvm-ranlib" \ - CFLAGS="-O2 -fPIE -ffile-prefix-map=$src=." LDFLAGS="-pie" \ - ./configure --host="$host" $flags >/dev/null ) + ( cd "$src" && + # No system libraries, generated docs, iconv, ACLs, or xattrs. + set -- --disable-md2man --disable-openssl --disable-xxhash \ + --disable-zstd --disable-lz4 --disable-iconv \ + --disable-acl-support --disable-xattr-support \ + --with-included-popt --with-included-zlib \ + --with-nobody-user=nobody --with-nobody-group=nobody + # NDK r27 needs both page-size flags for 16 KB Android devices. + # rsync parses peer-controlled data in C. Keep the compiler defenses + # explicit rather than relying on whichever defaults an NDK release + # happens to select. + CC="$tc/$cc" CXX="$tc/${cc}++" AR="$tc/llvm-ar" RANLIB="$tc/llvm-ranlib" \ + rsync_cv_HAVE_SECURE_MKSTEMP=yes \ + CFLAGS="-O2 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$src=." \ + CXXFLAGS="-O2 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$src=." \ + LDFLAGS="-pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384" \ + ./configure --host="$host" "$@" >/dev/null ) + # Bundled popt leaves conditional variables unused on Android. Append the + # two narrow suppressions after rsync's own -Wall flags. + build_cflags=$(sed -n 's/^CFLAGS=//p' "$src/Makefile") + if [ -z "$build_cflags" ]; then + echo "rsync: generated Makefile has no CFLAGS" >&2 + exit 1 + fi echo "rsync: building $abi" - ( cd "$src" && make -s rsync ) + ( cd "$src" && make -s CFLAGS="$build_cflags -Wno-unused-variable -Wno-unused-but-set-variable" rsync ) dest="$root/app/src/main/jniLibs/$abi" mkdir -p "$dest" - cp "$src/rsync" "$dest/libxrsync.so" - "$tc/llvm-strip" "$dest/libxrsync.so" + output="$dest/libxrsync.so.part.$$" + cp "$src/rsync" "$output" + "$tc/llvm-strip" "$output" + mv "$output" "$dest/libxrsync.so" + output= echo "rsync: $abi -> jniLibs/$abi/libxrsync.so" done diff --git a/rsync/rsync-3.4.1.tar.gz.sha256 b/rsync/rsync-3.4.1.tar.gz.sha256 deleted file mode 100644 index 1c230fd..0000000 --- a/rsync/rsync-3.4.1.tar.gz.sha256 +++ /dev/null @@ -1 +0,0 @@ -2924bcb3a1ed8b551fc101f740b9f0fe0a202b115027647cf69850d65fd88c52 rsync-3.4.1.tar.gz diff --git a/rsync/rsync-3.5.0.tar.gz.sha256 b/rsync/rsync-3.5.0.tar.gz.sha256 new file mode 100644 index 0000000..a91a5d7 --- /dev/null +++ b/rsync/rsync-3.5.0.tar.gz.sha256 @@ -0,0 +1 @@ +c7ffd1ef653e99540f661e47cb00b7f9cad1ee6b972399b16f93d672656e0d33 rsync-3.5.0.tar.gz @@ -5,12 +5,14 @@ # Native rsync, built from source via the Android NDK. The tarball sha256 lives # in rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256, which the build verifies. -RSYNC_VERSION=3.4.1 +RSYNC_VERSION=3.5.0 RSYNC_URL=https://download.samba.org/pub/rsync/src/rsync-${RSYNC_VERSION}.tar.gz -# Go SSH transport (rsh). Matches the go.mod go directive; x/crypto needs 1.25+. -GO_VERSION=1.25.0 -GO_SHA256=2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613 +# Go SSH transport (rsh). Matches the go.mod go directive. +GO_VERSION=1.26.6 +GO_SHA256=708effb774be8237570d0add163225abbdfaf4fca28b2611df167beba4feef89 +X_CRYPTO_VERSION=0.54.0 +X_SYS_VERSION=0.47.0 # Android toolchain. ANDROID_NDK is the sdkmanager package revision. # JDK_RELEASE is the exact Temurin GA build; JDK_VERSION its major. |