1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
|
// rsh is rsend's SSH transport. It is invoked three ways:
//
// 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 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, 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:
//
// RSH_KEY path to the ed25519 private key
// RSH_KEY_DATA the private key itself (PEM), preferred over RSH_KEY so the
// app need never write the plaintext key to disk
// RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode)
// RSH_PORT TCP port (optional, default 22)
//
// 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 (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// 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 {
var ee *ssh.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitStatus())
}
fmt.Fprintf(os.Stderr, "rsh: %v\n", err)
os.Exit(1)
}
}
func run(args []string, in io.Reader, out, errw io.Writer) error {
if len(args) >= 1 {
switch args[0] {
case "-keygen":
if len(args) != 1 {
return errors.New("usage: rsh -keygen")
}
return keygen(out)
case "-pubkey":
if len(args) != 1 {
return errors.New("usage: rsh -pubkey")
}
return pubkey(out)
case "-scan":
if len(args) != 2 {
return errors.New("usage: rsh -scan USER@HOST")
}
return scan(args[1], out)
}
}
return transport(args, in, out, errw)
}
// transport runs the remote command and bridges I/O, verifying the host key
// strictly against RSH_KNOWN_HOSTS.
func transport(args []string, in io.Reader, out, errw io.Writer) error {
user, host, cmd, err := parseTransport(args)
if err != nil {
return err
}
if user == "" {
return errors.New("no remote user (expected -l USER or USER@HOST)")
}
if len(cmd) == 0 {
return errors.New("no remote command")
}
kh := os.Getenv("RSH_KNOWN_HOSTS")
if kh == "" {
return errors.New("RSH_KNOWN_HOSTS not set")
}
cb, err := knownhosts.New(kh)
if err != nil {
return fmt.Errorf("known_hosts: %w (run Test connection first)", err)
}
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, port, cb, algos)
if err != nil {
return err
}
defer client.Close()
go keepAlive(client)
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
session.Stdin = in
session.Stdout = out
session.Stderr = errw
return session.Run(strings.Join(cmd, " "))
}
// scan connects (authenticating, so it also proves the key is installed),
// captures the presented host key, and prints its SHA256 fingerprint and a
// known_hosts line for the caller to pin.
func scan(target string, out io.Writer) error {
user, host := splitUserHost(target)
if user == "" {
return errors.New("scan target must be USER@HOST")
}
port, err := sshPort()
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, port, capture, algos)
if err != nil {
return err
}
client.Close()
line := knownhosts.Line([]string{addr}, hostKey)
// 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.
func keygen(out io.Writer) error {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
block, err := ssh.MarshalPrivateKey(priv, "rsend")
if err != nil {
return err
}
return pem.Encode(out, block)
}
// pubkey loads the private key (RSH_KEY_DATA or RSH_KEY) and prints its public
// key in authorized_keys format. The app uses it to validate and display a key
// the user imported: if loadSigner accepts it here, the transport will too.
func pubkey(out io.Writer) error {
signer, err := loadSigner()
if err != nil {
return err
}
_, err = out.Write(ssh.MarshalAuthorizedKey(signer.PublicKey()))
return err
}
// 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 != "" {
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)
}
if err != nil {
return nil, err
}
if signer.PublicKey().Type() != ssh.KeyAlgoED25519 {
return nil, fmt.Errorf("client key must be %s", ssh.KeyAlgoED25519)
}
return signer, nil
}
// dial opens an SSH connection authenticated with the configured key, verifying
// the host key with hostKey and offering only the host-key algorithms in algos.
func dial(user, host string, port int, hostKey ssh.HostKeyCallback, algos []string) (*ssh.Client, error) {
signer, err := loadSigner()
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,
HostKeyAlgorithms: algos,
Timeout: timeout,
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, fmt.Errorf("%s%w", unreachablePrefix, err)
}
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
conn.Close()
return nil, err
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg)
if err != nil {
conn.Close()
return nil, err
}
if err := conn.SetDeadline(time.Time{}); err != nil {
clientConn.Close()
return nil, err
}
return ssh.NewClient(clientConn, chans, reqs), nil
}
// parseTransport extracts the user, host, and remote command from the argument
// vector rsync hands to its remote shell: an optional "-l USER", the host
// (possibly USER@HOST), then the remote command.
func parseTransport(args []string) (user, host string, cmd []string, err error) {
i := 0
for i < len(args) {
a := args[i]
if a == "-l" {
if i+1 >= len(args) {
return "", "", nil, errors.New("-l requires an argument")
}
user = args[i+1]
i += 2
continue
}
if strings.HasPrefix(a, "-") {
return "", "", nil, fmt.Errorf("unexpected option %q", a)
}
break
}
if i >= len(args) {
return "", "", nil, errors.New("missing host")
}
cmd = args[i+1:]
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], 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 time.Duration(n) * time.Second, nil
}
func sshPort() (int, error) {
s := os.Getenv("RSH_PORT")
if s == "" {
return 22, nil
}
p, err := strconv.Atoi(s)
if err != nil || p < 1 || p > 65535 {
return 0, fmt.Errorf("invalid RSH_PORT %q", s)
}
return p, nil
}
// keepAlive pings the server so a long transfer is not dropped by an idle NAT.
// It returns once the connection is closed.
func keepAlive(client *ssh.Client) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
return
}
}
}
|