From b2aa82061ffdf4e61e5e796ccbb91fdf44fcf8cd Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Thu, 28 May 2026 16:09:28 +0100 Subject: [PATCH] sftp: add --sftp-pin-host-key - Trust On First Use host key pinning Add two new options, pin_host_key and host_keys, that together provide a TOFU host-key validation mode for users who don't maintain a known_hosts file. When --sftp-pin-host-key is used, rclone records the server's host key into host_keys on the first successful connection and verifies it on every subsequent connection. host_keys is always validated when non-empty, so it can also be used by hand to pin a known fingerprint without enabling TOFU writing. known_hosts_file takes precedence if both are set. SSH host certificates are rejected with a clear message pointing at known_hosts_file. On-the-fly remotes log a warning since the captured key cannot be persisted. --- backend/sftp/sftp.go | 394 ++++++++++++++++++- backend/sftp/sftp_internal_test.go | 606 +++++++++++++++++++++++++++++ docs/content/sftp.md | 122 +++++- 3 files changed, 1104 insertions(+), 18 deletions(-) diff --git a/backend/sftp/sftp.go b/backend/sftp/sftp.go index 1d14e17f2..cd118f1f4 100644 --- a/backend/sftp/sftp.go +++ b/backend/sftp/sftp.go @@ -6,14 +6,17 @@ package sftp import ( "bytes" "context" + "encoding/base64" "errors" "fmt" "io" iofs "io/fs" + "net" "net/url" "os" "path" "regexp" + "sort" "strconv" "strings" "sync" @@ -27,6 +30,7 @@ import ( "github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/config/configstruct" "github.com/rclone/rclone/fs/config/obscure" + "github.com/rclone/rclone/fs/fserrors" "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/lib/encoder" "github.com/rclone/rclone/lib/env" @@ -45,6 +49,7 @@ const ( maxSleep = 2 * time.Second decayConstant = 2 // bigger for slower decay, exponential keepAliveInterval = time.Minute // send keepalives every this long while running commands + maxHostKeys = 16 // caps the number of host_keys entries ) var ( @@ -121,6 +126,49 @@ Set this value to enable server host key validation.` + env.ShellExpandHelp, Value: "~/.ssh/known_hosts", Help: "Use OpenSSH's known_hosts file.", }}, + }, { + Name: "pin_host_key", + Default: false, + Hide: fs.OptionHideConfigurator, + Help: `Pin the server host key on first connection (Trust On First Use). + +Intended for one-time use as the ` + "`--sftp-pin-host-key`" + ` command-line +flag. Run rclone once with the flag and the server's host key will be +recorded into the host_keys config option. On subsequent runs (without +the flag) host_keys is consulted and any mismatch is refused. + +Setting this option persistently in the config file is not +recommended. While it is set, rclone will also accept any new +host key algorithm the server later presents, which widens the trust +surface beyond the initial pin. To pin a new key after a legitimate +key change, re-run with the flag. + +The first connection is unauthenticated, so ideally do it over a +trusted network or cross-check the fingerprint rclone logs against +one provided out of band. + +If known_hosts_file is also set, that takes precedence and this option +is ignored.`, + Advanced: true, + }, { + Name: "host_keys", + Default: fs.CommaSepList{}, + Help: `Pinned host keys for this remote, used to verify the server. + +Comma-separated list of "algo base64-key" entries (the same format as +the second and third fields of an OpenSSH known_hosts line). Usually +populated automatically by running once with --sftp-pin-host-key, but +can be set by hand to pin a server's public key obtained out of band. +Note that each entry is the complete public key, not its SHA256 +fingerprint. + +When non-empty, the offered host key must match one of the entries or +the connection is refused. To re-pin after a legitimate key change, +clear this option and reconnect with --sftp-pin-host-key, or edit the +value directly. + +At most ` + strconv.Itoa(maxHostKeys) + ` entries may be pinned.`, + Advanced: true, }, { Name: "key_use_agent", Help: `When set forces the usage of the ssh-agent. @@ -576,6 +624,8 @@ type Options struct { PubKey string `config:"pubkey"` PubKeyFile string `config:"pubkey_file"` KnownHostsFile string `config:"known_hosts_file"` + PinHostKey bool `config:"pin_host_key"` + HostKeys fs.CommaSepList `config:"host_keys"` KeyUseAgent bool `config:"key_use_agent"` UseInsecureCipher bool `config:"use_insecure_cipher"` DisableHashCheck bool `config:"disable_hashcheck"` @@ -636,6 +686,22 @@ type Fs struct { sessions atomic.Int32 // count in use sessions tokens *pacer.TokenDispenser proxyURL *url.URL // address of HTTP proxy read from environment + + hostKeysMu sync.RWMutex + hostKeys map[string][][]byte // algo -> list of trusted marshalled key bytes + tofu bool // accept and pin unknown host keys on first use +} + +// pendingKey records a host key accepted via --sftp-pin-host-key +// during the SSH handshake of a single connection but not yet +// persisted to the config file. We only commit it after that +// connection's authentication succeeds, so a failed login can't pin a +// key. +type pendingKey struct { + algo string + marshalled []byte + fingerprint string + hostname string } // Object is a remote SFTP file that has been stat'd (so it exists, but is not necessarily open for reading) @@ -728,7 +794,22 @@ func (f *Fs) sftpConnection(ctx context.Context) (c *conn, err error) { err: make(chan error, 1), } if len(f.opt.SSH) == 0 { - c.sshClient, err = f.newSSHClientInternal(ctx, "tcp", f.opt.Host+":"+f.opt.Port, f.config) + sshConfig := f.config + var pending *pendingKey + if f.tofu { + // Give this dial its own host key callback and pending key + // slot so a key stashed here can only be committed by this + // connection, and only if its own authentication succeeds. + configCopy := *f.config + configCopy.HostKeyCallback = f.hostKeyCallback(&pending) + sshConfig = &configCopy + } + c.sshClient, err = f.newSSHClientInternal(ctx, "tcp", f.opt.Host+":"+f.opt.Port, sshConfig) + if err == nil && pending != nil { + // ssh.NewClientConn only returns success once the server is + // authenticated, so the stashed key is safe to persist. + f.commitHostKey(pending) + } } else { c.sshClient, err = f.newSSHClientExternal() } @@ -911,6 +992,250 @@ func (f *Fs) drainPool(ctx context.Context) (err error) { return nil } +// parseHostKeysField parses the host_keys config option, a list of +// "algo base64-key" entries (the second and third fields of an OpenSSH +// known_hosts line). Returns a map from algorithm name to the list of +// marshalled public-key bytes pinned for that algorithm. Empty input +// yields an empty (non-nil) map. +// +// Each entry must be a valid SSH public key (not a certificate) whose +// key type matches the stated algorithm. Duplicate entries are +// dropped, and more than maxHostKeys distinct entries is an error. +func parseHostKeysField(entries fs.CommaSepList) (map[string][][]byte, error) { + out := make(map[string][][]byte) + total := 0 +entries: + for i, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + fields := strings.Fields(entry) + if len(fields) != 2 { + return nil, fmt.Errorf("host_keys entry %d: expected \" \", got %q", i+1, entry) + } + algo := fields[0] + marshalled, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil { + return nil, fmt.Errorf("host_keys entry %d (%s): invalid base64: %w", i+1, algo, err) + } + pk, err := ssh.ParsePublicKey(marshalled) + if err != nil { + return nil, fmt.Errorf("host_keys entry %d (%s): not a valid SSH public key: %w", i+1, algo, err) + } + if _, isCert := pk.(*ssh.Certificate); isCert { + return nil, fmt.Errorf("host_keys entry %d (%s): SSH certificates cannot be pinned; use known_hosts_file with an @cert-authority entry instead", i+1, algo) + } + if pk.Type() != algo { + hint := "" + if pk.Type() == ssh.KeyAlgoRSA && (algo == ssh.KeyAlgoRSASHA256 || algo == ssh.KeyAlgoRSASHA512) { + // A common slip: rsa-sha2-* name signature algorithms, + // but an RSA public key blob's format is ssh-rsa. + hint = fmt.Sprintf(" (%s is a signature algorithm, use %s)", algo, ssh.KeyAlgoRSA) + } + return nil, fmt.Errorf("host_keys entry %d: stated algorithm %q doesn't match the key's type %q%s", i+1, algo, pk.Type(), hint) + } + for _, existing := range out[algo] { + if bytes.Equal(existing, marshalled) { + continue entries + } + } + total++ + if total > maxHostKeys { + return nil, fmt.Errorf("host_keys has more than %d entries; trim it or use known_hosts_file instead", maxHostKeys) + } + out[algo] = append(out[algo], marshalled) + } + return out, nil +} + +// formatHostKeysField formats the in-memory pinned host keys back into a +// CommaSepList of "algo base64" entries in a deterministic order so +// config-file diffs stay clean. +func formatHostKeysField(keys map[string][][]byte) fs.CommaSepList { + algos := make([]string, 0, len(keys)) + for algo := range keys { + algos = append(algos, algo) + } + sort.Strings(algos) + var parts fs.CommaSepList + for _, algo := range algos { + // sort the keys within an algorithm for deterministic output + list := append([][]byte(nil), keys[algo]...) + sort.Slice(list, func(i, j int) bool { return bytes.Compare(list[i], list[j]) < 0 }) + for _, k := range list { + parts = append(parts, algo+" "+base64.StdEncoding.EncodeToString(k)) + } + } + return parts +} + +// pinnedHostKeyAlgorithms returns the host-key algorithms to negotiate +// for the pinned keys, in a deterministic order. +// +// RSA public keys report their type as "ssh-rsa" whichever signature +// algorithm signed the handshake, so a pinned "ssh-rsa" entry is +// expanded to the rsa-sha2 signature algorithms too. Offering only +// "ssh-rsa" would restrict the negotiation to SHA-1 signatures, which +// modern servers (OpenSSH >= 8.8) refuse by default. +func pinnedHostKeyAlgorithms(keys map[string][][]byte) []string { + algos := make([]string, 0, len(keys)+2) + for algo := range keys { + if algo == ssh.KeyAlgoRSA { + algos = append(algos, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256) + } + algos = append(algos, algo) + } + sort.Strings(algos) + return algos +} + +// formatPinnedFingerprints returns a comma-separated list of SHA256 +// fingerprints for the supplied marshalled public keys, suitable for +// inclusion in user-facing error messages. +func formatPinnedFingerprints(algo string, keys [][]byte) string { + fps := make([]string, 0, len(keys)) + for _, k := range keys { + pk, err := ssh.ParsePublicKey(k) + if err != nil { + fps = append(fps, "") + continue + } + fps = append(fps, ssh.FingerprintSHA256(pk)) + } + return strings.Join(fps, ", ") +} + +// hostKeyCallback returns an ssh.HostKeyCallback that validates the offered +// server key against f.hostKeys. +// +// When pin_host_key is set, pending must point at the dialing +// connection's own pending key slot: if no key is pinned yet for the +// offered algorithm the offered key is stashed there for the dialer to +// commit once its authentication succeeds. A nil pending disables +// accept-on-first-use, making the callback validate-only. +func (f *Fs) hostKeyCallback(pending **pendingKey) ssh.HostKeyCallback { + return func(hostname string, _ net.Addr, key ssh.PublicKey) error { + // SSH host certificates cannot be byte-pinned meaningfully: the + // certificate changes whenever it's re-signed even though the + // underlying CA is unchanged. + if _, isCert := key.(*ssh.Certificate); isCert { + return fserrors.NoLowLevelRetryError(fmt.Errorf("sftp: server presented an SSH certificate (%s) which pin_host_key cannot validate; use known_hosts_file with an @cert-authority entry instead", key.Type())) + } + algo := key.Type() + marshalled := key.Marshal() + + f.hostKeysMu.RLock() + trusted, have := f.hostKeys[algo] + f.hostKeysMu.RUnlock() + + if have { + for _, t := range trusted { + if bytes.Equal(t, marshalled) { + return nil + } + } + return fserrors.NoLowLevelRetryError(fmt.Errorf("sftp: host key mismatch for %s: server offered %s key with fingerprint %s; pinned fingerprints for this algorithm: %s -- if the server key has changed legitimately, clear host_keys and re-run with --sftp-pin-host-key", + hostname, algo, ssh.FingerprintSHA256(key), formatPinnedFingerprints(algo, trusted))) + } + + // No pinned key for this algorithm. Either accept if --sftp-pin-host-key is set or reject. + if !f.opt.PinHostKey || pending == nil { + return fserrors.NoLowLevelRetryError(fmt.Errorf("sftp: server offered %s key with fingerprint %s but no key is pinned for this algorithm in host_keys; re-run with --sftp-pin-host-key to accept on first use", + algo, ssh.FingerprintSHA256(key))) + } + // Cap the host_keys growth + f.hostKeysMu.RLock() + total := 0 + for _, list := range f.hostKeys { + total += len(list) + } + f.hostKeysMu.RUnlock() + if total >= maxHostKeys { + return fserrors.NoLowLevelRetryError(fmt.Errorf("sftp: host_keys already contains %d entries (cap %d); refusing to pin %s key with fingerprint %s -- clear or trim host_keys and re-run with --sftp-pin-host-key", + total, maxHostKeys, algo, ssh.FingerprintSHA256(key))) + } + // Stash the offered key for this connection's dialer to persist post-auth. + *pending = &pendingKey{ + algo: algo, + marshalled: marshalled, + fingerprint: ssh.FingerprintSHA256(key), + hostname: hostname, + } + fs.Logf(f, "Accepted %s host key %s for %s on first use", algo, ssh.FingerprintSHA256(key), hostname) + return nil + } +} + +// commitHostKey records a --sftp-pin-host-key accepted host key into +// both the durable config file and the in-memory f.hostKeys trust +// set. The in-memory update is what closes the trust loop after the +// callback deliberately left f.hostKeys untouched pre-auth. +func (f *Fs) commitHostKey(pk *pendingKey) { + // Hold the lock for the whole read-modify-write so concurrent + // commits from parallel dials can't lose each other's key. + f.hostKeysMu.Lock() + defer f.hostKeysMu.Unlock() + // Re-read the current stored value rather than reformatting our in-memory + // map. This narrows (but cannot close - the config layer has no locked + // read-modify-write) the window for clobbering an update made by a + // parallel rclone process. + current, _ := f.m.Get("host_keys") + var entries fs.CommaSepList + if err := entries.Set(current); err != nil { + fs.Errorf(f, "Not persisting %s host key %s for %s: existing host_keys value became unsplittable: %v -- fix or clear host_keys in the config and re-run with --sftp-pin-host-key", + pk.algo, pk.fingerprint, pk.hostname, err) + return + } + keys, err := parseHostKeysField(entries) + if err != nil { + // host_keys was parseable at NewFs time but isn't now... + snippet := current + const max = 120 + if len(snippet) > max { + snippet = snippet[:max] + "..." + } + fs.Errorf(f, "Not persisting %s host key %s for %s: existing host_keys value became unparseable (%d bytes: %q): %v -- fix or clear host_keys in the config and re-run with --sftp-pin-host-key", + pk.algo, pk.fingerprint, pk.hostname, len(current), snippet, err) + return + } + // De-duplicate before appending. Even if already present durably, sync + // the in-memory view in case it lagged behind the file. + alreadyPresent := false + for _, existing := range keys[pk.algo] { + if bytes.Equal(existing, pk.marshalled) { + alreadyPresent = true + break + } + } + if !alreadyPresent { + // Re-check the cap against the stored value: it may have grown + // since the callback checked it. + total := 0 + for _, list := range keys { + total += len(list) + } + if total >= maxHostKeys { + fs.Errorf(f, "Not persisting %s host key %s for %s: host_keys already contains %d entries (cap %d) -- clear or trim host_keys and re-run with --sftp-pin-host-key", + pk.algo, pk.fingerprint, pk.hostname, total, maxHostKeys) + return + } + keys[pk.algo] = append(keys[pk.algo], pk.marshalled) + } + + f.hostKeys = keys + + if alreadyPresent { + return + } + f.m.Set("host_keys", formatHostKeysField(keys).String()) + if strings.HasPrefix(f.name, ":") { + fs.Logf(f, "%s host key %s captured but not persisted (on-the-fly remote); first-connect protection only", pk.algo, pk.fingerprint) + } else { + fs.Logf(f, "Pinned %s host key %s for %s in config", pk.algo, pk.fingerprint, pk.hostname) + } +} + // NewFs creates a new Fs object from the name and root. It connects to // the host specified in the config file. func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { @@ -947,24 +1272,71 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e ClientVersion: "SSH-2.0-" + f.ci.UserAgent, } - if len(opt.HostKeyAlgorithms) != 0 { - sshConfig.HostKeyAlgorithms = []string(opt.HostKeyAlgorithms) + // pin_host_key and host_keys only apply when rclone does the host + // key checking itself: known_hosts_file takes precedence and the + // external ssh program does its own validation, so in either case + // host_keys is ignored entirely, without being parsed. + usePinning := (opt.PinHostKey || len(opt.HostKeys) > 0) && opt.KnownHostsFile == "" && len(opt.SSH) == 0 + var parsedHostKeys map[string][][]byte + if usePinning { + // Parse pinned host keys early so we can both validate the config + // and, when no host_key_algorithms is set, narrow the negotiated + // algorithm list to those we already trust. + parsed, err := parseHostKeysField(opt.HostKeys) + if err != nil { + return nil, fmt.Errorf("couldn't parse host_keys: %w", err) + } + parsedHostKeys = parsed + f.hostKeys = parsed } - if opt.KnownHostsFile != "" { + if len(opt.HostKeyAlgorithms) != 0 { + sshConfig.HostKeyAlgorithms = []string(opt.HostKeyAlgorithms) + } else if len(parsedHostKeys) > 0 && !opt.PinHostKey { + // Restrict negotiated host-key algorithms to those we have a + // pinned key for, so a server offering an additional unpinned + // algorithm doesn't cause a spurious mismatch when we'd happily + // accept the same server's pinned algorithm. + // + // Not done with pin_host_key set: there any algorithm the + // server presents can be accepted and pinned on first use, so + // the negotiation must stay open to new algorithms. + sshConfig.HostKeyAlgorithms = pinnedHostKeyAlgorithms(parsedHostKeys) + } + + // When the ssh option is set, the external ssh program makes the + // connection and does its own host key validation, so none of the + // host key checking configured below takes effect. + if len(opt.SSH) > 0 && (opt.PinHostKey || len(opt.HostKeys) > 0) { + fs.Logf(name, "pin_host_key and host_keys are ignored when the ssh option is set; the ssh program does its own host key validation") + } + + switch { + case opt.KnownHostsFile != "": hostcallback, err := knownhosts.New(env.ShellExpand(opt.KnownHostsFile)) if err != nil { return nil, fmt.Errorf("couldn't parse known_hosts_file: %w", err) } sshConfig.HostKeyCallback = hostcallback - } else { - // Set insecure HostKeyCallback if no known_hosts_file is - // configured. Rclone has no mechanism to manage - // known_hosts files so we can't enable host key - // validation by default. Users can enable it by setting - // known_hosts_file. See: https://rclone.org/sftp/#host-key-validation + if opt.PinHostKey || len(opt.HostKeys) > 0 { + fs.Logf(name, "known_hosts_file is set; ignoring pin_host_key and host_keys") + } + case usePinning: + // Pinning mode: validate against host_keys. When pin_host_key + // is set, each dial swaps in its own accept-and-stash callback + // (see sftpConnection), so the shared config only ever + // validates. + sshConfig.HostKeyCallback = f.hostKeyCallback(nil) + f.tofu = opt.PinHostKey + if opt.PinHostKey && len(parsedHostKeys) == 0 { + fs.Logf(name, "pin_host_key is set with no pinned host_keys yet; accepting the next server key on first use") + } + if opt.PinHostKey && strings.HasPrefix(name, ":") { + fs.Logf(name, "pin_host_key on an on-the-fly remote cannot persist the pinned key; the server key will be re-accepted on every run") + } + default: sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() - fs.Logf(name, "No host key validation is being performed. Set known_hosts_file to enable it. See: https://rclone.org/sftp/#host-key-validation") + fs.Logf(name, "No host key validation is being performed. Set known_hosts_file or use --sftp-pin-host-key to enable it. See: https://rclone.org/sftp/#host-key-validation") } if opt.UseInsecureCipher && (opt.Ciphers != nil || opt.KeyExchange != nil) { diff --git a/backend/sftp/sftp_internal_test.go b/backend/sftp/sftp_internal_test.go index 691c2068f..c50e8c374 100644 --- a/backend/sftp/sftp_internal_test.go +++ b/backend/sftp/sftp_internal_test.go @@ -3,11 +3,28 @@ package sftp import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/base64" "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" "testing" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/rclone/rclone/fs/config/obscure" + "github.com/rclone/rclone/fstest/fstests" "github.com/rclone/rclone/lib/encoder" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" ) func TestShellEscapeUnix(t *testing.T) { @@ -112,6 +129,519 @@ func TestParseHash(t *testing.T) { } } +// fakePublicKey is a minimal ssh.PublicKey for tests. Marshal() is the only +// method exercised by the host-key code (FingerprintSHA256 sha256s it). +type fakePublicKey struct { + keyType string + data []byte +} + +func (k *fakePublicKey) Type() string { return k.keyType } +func (k *fakePublicKey) Marshal() []byte { return k.data } +func (k *fakePublicKey) Verify(_ []byte, _ *ssh.Signature) error { return nil } + +// makeTestKeys returns n distinct marshalled ed25519 public keys. +func makeTestKeys(t *testing.T, n int) [][]byte { + keys := make([][]byte, n) + for i := range keys { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + sshPub, err := ssh.NewPublicKey(pub) + require.NoError(t, err) + keys[i] = sshPub.Marshal() + } + return keys +} + +// makeTestRSAKey returns a marshalled RSA public key. +func makeTestRSAKey(t *testing.T) []byte { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + sshPub, err := ssh.NewPublicKey(&priv.PublicKey) + require.NoError(t, err) + return sshPub.Marshal() +} + +func TestParseHostKeysField(t *testing.T) { + keys := makeTestKeys(t, 2) + keyA, keyB := keys[0], keys[1] + encA := base64.StdEncoding.EncodeToString(keyA) + encB := base64.StdEncoding.EncodeToString(keyB) + rsaKey := makeTestRSAKey(t) + encRSA := base64.StdEncoding.EncodeToString(rsaKey) + + for _, test := range []struct { + name string + input fs.CommaSepList + want map[string][][]byte + wantErr string + }{ + {name: "Empty", input: fs.CommaSepList{}, want: map[string][][]byte{}}, + {name: "EmptyEntrySkipped", input: fs.CommaSepList{""}, want: map[string][][]byte{}}, + {name: "Single", input: fs.CommaSepList{"ssh-ed25519 " + encA}, want: map[string][][]byte{"ssh-ed25519": {keyA}}}, + { + name: "MultiAlgo", + input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-rsa " + encRSA}, + want: map[string][][]byte{"ssh-ed25519": {keyA}, "ssh-rsa": {rsaKey}}, + }, + { + name: "MultiKeyPerAlgo", + input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-ed25519 " + encB}, + want: map[string][][]byte{"ssh-ed25519": {keyA, keyB}}, + }, + { + name: "EntryLevelWhitespaceTrimmed", + input: fs.CommaSepList{" ssh-ed25519 " + encA + " ", " ssh-rsa " + encRSA + "\t"}, + want: map[string][][]byte{"ssh-ed25519": {keyA}, "ssh-rsa": {rsaKey}}, + }, + { + name: "DuplicatesDropped", + input: fs.CommaSepList{"ssh-ed25519 " + encA, "ssh-ed25519 " + encA}, + want: map[string][][]byte{"ssh-ed25519": {keyA}}, + }, + {name: "MalformedTooFewFields", input: fs.CommaSepList{"ssh-ed25519"}, wantErr: "expected"}, + {name: "MalformedTooManyFields", input: fs.CommaSepList{"ssh-ed25519 " + encA + " trailing"}, wantErr: "expected"}, + {name: "MalformedBase64", input: fs.CommaSepList{"ssh-ed25519 not-base64-!!!"}, wantErr: "base64"}, + { + name: "NotAPublicKey", + input: fs.CommaSepList{"ssh-ed25519 " + base64.StdEncoding.EncodeToString([]byte("junk"))}, + wantErr: "not a valid SSH public key", + }, + { + // The stated algorithm must be the key's own format name. + name: "AlgoMismatch", + input: fs.CommaSepList{"ssh-rsa " + encA}, + wantErr: `doesn't match the key's type "ssh-ed25519"`, + }, + { + // rsa-sha2-* are signature algorithms; an RSA key blob's + // format is ssh-rsa, so hint at the correct spelling. + name: "RSASignatureAlgorithmHint", + input: fs.CommaSepList{"rsa-sha2-256 " + encRSA}, + wantErr: "use ssh-rsa", + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := parseHostKeysField(test.input) + if test.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } + + t.Run("Certificate", func(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := ssh.NewSignerFromKey(priv) + require.NoError(t, err) + cert := &ssh.Certificate{Key: signer.PublicKey(), CertType: ssh.HostCert, ValidBefore: ssh.CertTimeInfinity} + require.NoError(t, cert.SignCert(rand.Reader, signer)) + entry := cert.Type() + " " + base64.StdEncoding.EncodeToString(cert.Marshal()) + _, err = parseHostKeysField(fs.CommaSepList{entry}) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate") + }) + + t.Run("TooManyEntries", func(t *testing.T) { + var entries fs.CommaSepList + for _, key := range makeTestKeys(t, maxHostKeys+1) { + entries = append(entries, "ssh-ed25519 "+base64.StdEncoding.EncodeToString(key)) + } + _, err := parseHostKeysField(entries) + require.Error(t, err) + assert.Contains(t, err.Error(), "more than") + // Trimming to the cap parses fine. + _, err = parseHostKeysField(entries[:maxHostKeys]) + require.NoError(t, err) + }) +} + +func TestFormatHostKeysFieldRoundTrip(t *testing.T) { + // Pre-sort the ed25519 keys so the expected output can be written down. + edKeys := makeTestKeys(t, 3) + sort.Slice(edKeys, func(i, j int) bool { return bytes.Compare(edKeys[i], edKeys[j]) < 0 }) + rsaKey := makeTestRSAKey(t) + canonical := map[string][][]byte{ + "ssh-ed25519": {edKeys[0], edKeys[1], edKeys[2]}, + "ssh-rsa": {rsaKey}, + } + formatted := formatHostKeysField(canonical) + // Deterministic: algos sorted, keys within algo sorted by bytes. + expected := fs.CommaSepList{ + "ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[0]), + "ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[1]), + "ssh-ed25519 " + base64.StdEncoding.EncodeToString(edKeys[2]), + "ssh-rsa " + base64.StdEncoding.EncodeToString(rsaKey), + } + assert.Equal(t, expected, formatted) + // The CommaSepList wire form joins entries with commas. + assert.Equal(t, strings.Join(expected, ","), formatted.String()) + + parsed, err := parseHostKeysField(formatted) + require.NoError(t, err) + assert.Equal(t, canonical, parsed) + + // Format is independent of input ordering. + scrambled := map[string][][]byte{ + "ssh-rsa": {rsaKey}, + "ssh-ed25519": {edKeys[2], edKeys[0], edKeys[1]}, + } + assert.Equal(t, expected, formatHostKeysField(scrambled)) +} + +func TestPinnedHostKeyAlgorithms(t *testing.T) { + for _, test := range []struct { + name string + input map[string][][]byte + want []string + }{ + {name: "Empty", input: map[string][][]byte{}, want: []string{}}, + { + name: "NonRSA", + input: map[string][][]byte{"ssh-ed25519": {{0x01}}, "ecdsa-sha2-nistp256": {{0x02}}}, + want: []string{"ecdsa-sha2-nistp256", "ssh-ed25519"}, + }, + { + // "ssh-rsa" is a key format, not just a signature algorithm, so + // it must expand to the rsa-sha2 signature algorithms too. + name: "RSAExpanded", + input: map[string][][]byte{"ssh-rsa": {{0x01}}}, + want: []string{"rsa-sha2-256", "rsa-sha2-512", "ssh-rsa"}, + }, + { + name: "RSAAndOthers", + input: map[string][][]byte{"ssh-rsa": {{0x01}}, "ssh-ed25519": {{0x02}}}, + want: []string{"rsa-sha2-256", "rsa-sha2-512", "ssh-ed25519", "ssh-rsa"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, pinnedHostKeyAlgorithms(test.input)) + }) + } +} + +func TestHostKeyCallbackValidateMatch(t *testing.T) { + keyBytes := []byte("trusted-key-marshalled-bytes") + f := &Fs{ + hostKeys: map[string][][]byte{"ssh-ed25519": {keyBytes}}, + } + cb := f.hostKeyCallback(nil) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes}) + assert.NoError(t, err) +} + +func TestHostKeyCallbackValidateMismatch(t *testing.T) { + pinned := []byte("pinned-key-bytes") + offered := []byte("DIFFERENT-key-bytes") + f := &Fs{ + hostKeys: map[string][][]byte{"ssh-ed25519": {pinned}}, + } + cb := f.hostKeyCallback(nil) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: offered}) + require.Error(t, err) + // Mismatch message should include both fingerprints and a how-to hint. + assert.Contains(t, err.Error(), "host key mismatch") + assert.Contains(t, err.Error(), "--sftp-pin-host-key") +} + +func TestHostKeyCallbackRejectsCertificate(t *testing.T) { + f := &Fs{ + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + } + var pending *pendingKey + cb := f.hostKeyCallback(&pending) + + // Wrap a real Ed25519 key in an *ssh.Certificate so the type + // assertion in the callback fires. + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + sshPub, err := ssh.NewPublicKey(pub) + require.NoError(t, err) + cert := &ssh.Certificate{Key: sshPub} + + err = cb("example.com:22", nil, cert) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate") + assert.Nil(t, pending) +} + +func TestHostKeyCallbackPinHostKeyStashes(t *testing.T) { + mapper := configmap.Simple{} + f := &Fs{ + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + m: mapper, + } + keyBytes := []byte("fresh-key-bytes") + var pending *pendingKey + cb := f.hostKeyCallback(&pending) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes}) + assert.NoError(t, err) + require.NotNil(t, pending, "PinHostKey should stash the offered key") + assert.Equal(t, "ssh-ed25519", pending.algo) + assert.Equal(t, keyBytes, pending.marshalled) + // Neither durable nor in-memory state is touched in the callback: the + // commit happens only after authentication succeeds. + _, ok := mapper["host_keys"] + assert.False(t, ok, "callback must not persist; that's commitHostKey's job") + assert.Empty(t, f.hostKeys["ssh-ed25519"], "callback must not extend in-memory trust set pre-auth") +} + +func TestHostKeyCallbackRefusesAtCap(t *testing.T) { + // Populate host_keys at the cap, then attempt to PinHostKey a brand new + // algorithm. The callback must refuse to prevent unbounded growth. + f := &Fs{ + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + } + for i := 0; i < maxHostKeys; i++ { + algo := fmt.Sprintf("test-algo-%d", i) + f.hostKeys[algo] = [][]byte{{byte(i)}} + } + var pending *pendingKey + cb := f.hostKeyCallback(&pending) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("new-key")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cap") + // Nothing was stashed for persistence. + assert.Nil(t, pending) +} + +func TestHostKeyCallbackRefusesWhenNotPinning(t *testing.T) { + // pin_host_key=false AND no entry pinned for this algo -> reject. + f := &Fs{ + opt: Options{PinHostKey: false}, + hostKeys: map[string][][]byte{}, + } + cb := f.hostKeyCallback(nil) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("x")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--sftp-pin-host-key") +} + +func TestHostKeyCallbackNilPendingIsValidateOnly(t *testing.T) { + // Even with pin_host_key set, a callback with no pending slot (the + // shared validate-only config) must refuse an unpinned key rather + // than accept it without anywhere to stash it. + f := &Fs{ + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + } + cb := f.hostKeyCallback(nil) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: []byte("x")}) + require.Error(t, err) +} + +func TestHostKeyCallbackPinHostKeyRace(t *testing.T) { + mapper := configmap.Simple{} + f := &Fs{ + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + m: mapper, + } + keyBytes := []byte("race-key-bytes") + + // Each concurrent dial gets its own callback and pending key slot. + var wg sync.WaitGroup + const N = 16 + pendings := make([]*pendingKey, N) + wg.Add(N) + for i := 0; i < N; i++ { + go func(i int) { + defer wg.Done() + cb := f.hostKeyCallback(&pendings[i]) + err := cb("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyBytes}) + assert.NoError(t, err) + }(i) + } + wg.Wait() + for i := 0; i < N; i++ { + require.NotNil(t, pendings[i], "dial %d should have stashed its own key", i) + assert.Equal(t, keyBytes, pendings[i].marshalled) + } + // No mutation of f.hostKeys pre-auth, even under heavy parallel callbacks. + assert.Empty(t, f.hostKeys["ssh-ed25519"]) +} + +func TestHostKeyCallbackPendingKeyIsPerConnection(t *testing.T) { + // Two dials see different keys (e.g. the first connection failed + // authentication and a MITM answered the retry). Each callback must + // stash into its own slot so committing the successful connection's + // key can never pin the abandoned connection's key. + mapper := configmap.Simple{} + f := &Fs{ + name: "myremote", + opt: Options{PinHostKey: true}, + hostKeys: map[string][][]byte{}, + m: mapper, + } + keyA := []byte("key-from-failed-connection") + keyB := []byte("key-from-successful-connection") + + // Dial A: stashes key A, then authentication fails so it is abandoned. + var pendingA *pendingKey + require.NoError(t, f.hostKeyCallback(&pendingA)("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyA})) + require.NotNil(t, pendingA) + + // Dial B: presents a different key, which must be stashed in B's own + // slot, not silently accepted because A already stashed one. + var pendingB *pendingKey + require.NoError(t, f.hostKeyCallback(&pendingB)("example.com:22", nil, &fakePublicKey{keyType: "ssh-ed25519", data: keyB})) + require.NotNil(t, pendingB) + assert.Equal(t, keyB, pendingB.marshalled) + + // Only B authenticates, so only B's key is committed and pinned. + f.commitHostKey(pendingB) + assert.Equal(t, [][]byte{keyB}, f.hostKeys["ssh-ed25519"]) + assert.Equal(t, "ssh-ed25519 "+base64.StdEncoding.EncodeToString(keyB), mapper["host_keys"]) +} + +func TestNewFsHostKeysPrecedence(t *testing.T) { + // Base config pointing at a port nothing listens on, so if NewFs + // gets as far as connecting it fails with a dial error. + newConfig := func() configmap.Simple { + return configmap.Simple{ + "host": "127.0.0.1", + "port": "1", + "user": "testuser", + "pass": obscure.MustObscure("testpass"), + } + } + ctx, ci := fs.AddConfig(context.Background()) + ci.LowLevelRetries = 1 + + t.Run("MalformedHostKeysRejectedWhenPinning", func(t *testing.T) { + m := newConfig() + m.Set("host_keys", "this is not a valid entry") + _, err := NewFs(ctx, "test", "", m) + require.Error(t, err) + assert.Contains(t, err.Error(), "host_keys") + }) + + t.Run("MalformedHostKeysIgnoredWithKnownHostsFile", func(t *testing.T) { + // known_hosts_file takes precedence, so a bad host_keys value + // must not stop the connection: the failure must be the dial + // error, not a host_keys parse error. + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + require.NoError(t, os.WriteFile(knownHosts, nil, 0o600)) + m := newConfig() + m.Set("known_hosts_file", knownHosts) + m.Set("host_keys", "this is not a valid entry") + m.Set("pin_host_key", "true") + _, err := NewFs(ctx, "test", "", m) + require.Error(t, err) + assert.NotContains(t, err.Error(), "host_keys") + }) +} + +func TestCommitHostKey(t *testing.T) { + mapper := configmap.Simple{} + f := &Fs{ + name: "myremote", + hostKeys: map[string][][]byte{}, + m: mapper, + } + key1 := makeTestKeys(t, 1)[0] + key2 := makeTestRSAKey(t) + + f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key1, fingerprint: "SHA256:fp1", hostname: "h"}) + f.commitHostKey(&pendingKey{algo: "ssh-rsa", marshalled: key2, fingerprint: "SHA256:fp2", hostname: "h"}) + + stored, ok := mapper["host_keys"] + require.True(t, ok, "commitHostKey must persist to the configmap") + expected := "ssh-ed25519 " + base64.StdEncoding.EncodeToString(key1) + ",ssh-rsa " + base64.StdEncoding.EncodeToString(key2) + assert.Equal(t, expected, stored) + + // commitHostKey must also extend the in-memory trust set so the validate + // path on subsequent connections recognises the freshly-pinned keys. + assert.Equal(t, [][]byte{key1}, f.hostKeys["ssh-ed25519"]) + assert.Equal(t, [][]byte{key2}, f.hostKeys["ssh-rsa"]) + + // Second commit of the same key is a no-op (dedupe). + f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key1, fingerprint: "SHA256:fp1", hostname: "h"}) + assert.Equal(t, expected, mapper["host_keys"]) + assert.Equal(t, [][]byte{key1}, f.hostKeys["ssh-ed25519"]) +} + +func TestCommitHostKeyRefusesToOverwriteMalformed(t *testing.T) { + // host_keys parsed cleanly at NewFs time, then was hand-edited to + // something garbage before commit ran. We must not overwrite that + // value with just our new entry (which would destroy any other valid + // entries it once contained). + const garbage = "this is not a valid host_keys value" + mapper := configmap.Simple{"host_keys": garbage} + f := &Fs{ + name: "myremote", + hostKeys: map[string][][]byte{}, + m: mapper, + } + key := []byte("would-have-been-pinned") + f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: key, fingerprint: "SHA256:fp", hostname: "h"}) + + // Durable storage is preserved as-is. + assert.Equal(t, garbage, mapper["host_keys"]) + // In-memory trust set is NOT extended — we refuse to pretend we pinned. + assert.Empty(t, f.hostKeys["ssh-ed25519"]) +} + +func TestCommitHostKeyConcurrent(t *testing.T) { + // Concurrent commits from parallel dials must not lose each + // other's key in the read-modify-write of the stored value. + mapper := configmap.Simple{} + f := &Fs{ + name: "myremote", + hostKeys: map[string][][]byte{}, + m: mapper, + } + const N = 8 + keys := makeTestKeys(t, N) + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func(i int) { + defer wg.Done() + f.commitHostKey(&pendingKey{ + algo: "ssh-ed25519", + marshalled: keys[i], + fingerprint: fmt.Sprintf("SHA256:fp%d", i), + hostname: "h", + }) + }(i) + } + wg.Wait() + assert.Len(t, f.hostKeys["ssh-ed25519"], N) + stored, err := parseHostKeysField(fs.CommaSepList(strings.Split(mapper["host_keys"], ","))) + require.NoError(t, err) + assert.Len(t, stored["ssh-ed25519"], N) +} + +func TestCommitHostKeyRefusesAtCap(t *testing.T) { + // The stored host_keys value may have grown to the cap since the + // callback checked it (e.g. another connection committed first), so + // the cap must be re-checked against the re-read value. + keys := makeTestKeys(t, maxHostKeys+1) + entries := make(fs.CommaSepList, maxHostKeys) + for i := range entries { + entries[i] = "ssh-ed25519 " + base64.StdEncoding.EncodeToString(keys[i]) + } + mapper := configmap.Simple{"host_keys": entries.String()} + f := &Fs{ + name: "myremote", + hostKeys: map[string][][]byte{}, + m: mapper, + } + f.commitHostKey(&pendingKey{algo: "ssh-ed25519", marshalled: keys[maxHostKeys], fingerprint: "SHA256:fp", hostname: "h"}) + + // Stored value unchanged and the in-memory set not extended with the new key. + assert.Equal(t, entries.String(), mapper["host_keys"]) + assert.Empty(t, f.hostKeys["ssh-ed25519"]) +} + func TestParseUsage(t *testing.T) { for i, test := range []struct { sshOutput string @@ -125,3 +655,79 @@ func TestParseUsage(t *testing.T) { assert.Equal(t, test.usage, [3]int64{gotSpaceTotal, gotSpaceUsed, gotSpaceAvail}, fmt.Sprintf("Test %d sshOutput = %q", i, test.sshOutput)) } } + +// internalTestHostKeyPinning exercises the full PinHostKey host-key flow +// against the live SFTP server by mirroring the running Fs's connection +// details into a fresh configmap, then driving NewFs through +// pin/validate/mismatch. The pinned key is written back into the configmap +// so the assertions can read it straight from there. +func (f *Fs) internalTestHostKeyPinning(t *testing.T) { + ctx := context.Background() + + if len(f.opt.SSH) > 0 { + t.Skip("host key pinning is bypassed when the ssh option is set") + } + + // Mirror config from the running Fs into a fresh configmap. f.m.Get + // returns effective values (including defaults), so the mirrored map is + // self-contained and NewFs can be called directly with it. + m := configmap.Simple{} + for _, opt := range fs.MustFind("sftp").Options { + // known_hosts_file must not be mirrored as it takes precedence + // over pin_host_key and would disable the pinning under test. + if opt.Name == "pin_host_key" || opt.Name == "host_keys" || opt.Name == "known_hosts_file" { + continue + } + v, ok := f.m.Get(opt.Name) + if !ok || v == "" { + continue + } + m.Set(opt.Name, v) + } + m.Set("pin_host_key", "true") + + t.Run("FirstConnectPins", func(t *testing.T) { + _, err := NewFs(ctx, "pinhostkey", "", m) + if err != nil && strings.Contains(err.Error(), "SSH certificate") { + t.Skipf("server presents an SSH certificate; pin_host_key cannot validate: %v", err) + } + require.NoError(t, err) + require.Regexp(t, `^\S+ \S+`, m["host_keys"], + "PinHostKey should have written host_keys back into the configmap") + }) + + t.Run("SecondConnectValidates", func(t *testing.T) { + before := m["host_keys"] + _, err := NewFs(ctx, "pinhostkey", "", m) + require.NoError(t, err) + assert.Equal(t, before, m["host_keys"], + "validate path must not rewrite host_keys") + }) + + t.Run("MismatchRejected", func(t *testing.T) { + // Substitute a different valid ed25519 key so the mismatch (not the + // malformed-config) error path fires. This must run in validate-only + // mode: with pin_host_key still set the negotiation stays open to + // new algorithms and the server's key would be accepted and pinned + // on first use rather than mismatching. + m.Set("pin_host_key", "false") + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + sshPub, err := ssh.NewPublicKey(priv.Public()) + require.NoError(t, err) + m.Set("host_keys", "ssh-ed25519 "+base64.StdEncoding.EncodeToString(sshPub.Marshal())) + + _, err = NewFs(ctx, "pinhostkey", "", m) + require.Error(t, err) + assert.Contains(t, err.Error(), "host key mismatch") + }) +} + +// InternalTest dispatches integration-only tests that need a live SFTP +// connection. fstests.Run invokes this after the standard test suite. +func (f *Fs) InternalTest(t *testing.T) { + t.Run("HostKeyPinning", f.internalTestHostKeyPinning) +} + +// Check interface +var _ fstests.InternalTester = (*Fs)(nil) diff --git a/docs/content/sftp.md b/docs/content/sftp.md index 19dcc96ce..4cccb91d2 100644 --- a/docs/content/sftp.md +++ b/docs/content/sftp.md @@ -208,15 +208,25 @@ cat id_rsa-cert.pub id_rsa > merged_key ### Host key validation -By default rclone will not check the server's host key for validation. This -can allow an attacker to replace a server with their own and if you use -password authentication then this can lead to that password being exposed. +By default rclone will not check the server's host key for validation. +This can allow an attacker to replace a server with their own and if +you use password authentication then this can lead to that password +being exposed. Rclone will produce a warning `No host key validation +is being performed` each time the backend is started in this mode. -Host key matching, using standard `known_hosts` files can be turned on by -enabling the `known_hosts_file` option. This can point to the file maintained -by `OpenSSH` or can point to a unique file. +Host key matching, using standard ssh `known_hosts` files can be +turned on by enabling the `known_hosts_file` option. This can point to +the file maintained by `OpenSSH` or can point to a unique file. -e.g. using the OpenSSH `known_hosts` file: +Alternatively rclone can maintain server host keys in a `host_keys` +setting in the config file. This can be updated automatically with +`--sftp-pin-host-key`. + +These options are described below + +### Using the OpenSSH known_hosts file + +Using the OpenSSH `known_hosts` file looks like this: ```ini [remote] @@ -266,6 +276,104 @@ and you will need to add the appropriate `@cert-authority` entry. The `known_hosts_file` setting can be set during `rclone config` as an advanced option. +### Host key pinning + +As an alternative to maintaining a `known_hosts` file, rclone supports +Trust On First Use (TOFU) host key pinning via the `--sftp-pin-host-key` +command-line flag. + +The recommended workflow is: + +1. For the very first connection to a new remote, run rclone once with + `--sftp-pin-host-key`. Rclone records the server's host key into the + remote's `host_keys` config option and logs the SHA256 fingerprint: + + ```console + $ rclone --sftp-pin-host-key lsd remote: + 2026/01/01 12:00:00 NOTICE: sftp://sftpuser@example.com:22/: Accepted ssh-ed25519 host key SHA256:abc... for example.com:22 on first use + 2026/01/01 12:00:00 NOTICE: sftp://sftpuser@example.com:22/: Pinned ssh-ed25519 host key SHA256:abc... for example.com:22 in config + ``` + +2. For every subsequent run, omit the flag. Rclone consults the pinned + `host_keys` and refuses the connection on any mismatch. + +This is a strict improvement over the default of no host key validation, +but note that the first connection itself is unauthenticated. Rclone +detects later key changes, not a man-in-the-middle who is already on-path +the first time you connect. Ideally do the first connection over a trusted +network or cross-check the fingerprint rclone logs against one provided out +of band by the server operator. + +If `known_hosts_file` is also set it takes precedence and +`--sftp-pin-host-key` is ignored. + +After the first successful connection the config will contain a new line: + +```ini +[remote] +type = sftp +host = example.com +user = sftpuser +pass = +host_keys = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... +``` + +The `host_keys` field is always validated against the offered host key +when non-empty, so you can also pin a known key by hand without ever +running with the flag. Each entry is the complete public key - the +algorithm and base64 fields of a known_hosts line - not its SHA256 +fingerprint: + +```console +rclone config update remote host_keys "ssh-ed25519 AAAAC3..." +``` + +Setting `pin_host_key = true` persistently in the config file is not +recommended: while it is set, rclone will accept any new host key algorithm +the server later presents, widening the trust surface beyond the initial +pin. Using `--sftp-pin-host-key` as a one-shot flag keeps each +unauthenticated trust event a deliberate decision. + +#### Re-pinning after a legitimate key change + +If the server's host key is legitimately rotated, rclone will refuse the +connection with an error containing both the stored and offered SHA256 +fingerprints. To accept the new key, clear the stored value and re-run once +with the flag: + +```console +rclone config update remote host_keys "" +rclone --sftp-pin-host-key lsd remote: +``` + +Alternatively edit `host_keys` directly to replace or add the new entry. +Multiple entries (separated by commas) are supported. + +#### Limitations + +- If the server uses an `@cert-authority`-signed host certificate, + host key pinning cannot validate it (the certificate is re-issued + periodically even though the underlying CA is unchanged). Use + `known_hosts_file` with an `@cert-authority` entry instead. +- On-the-fly remotes (`:sftp,host=...:`) cannot persist the pinned key - + rclone will log a warning and re-accept the server key on every run, + so the flag only provides first-connect fingerprint logging. Named + remotes used with connection string overrides (`remote,port=2022:`) + are fine: the pinned key is saved to the remote's config section. +- If the `ssh` option is set, the configured ssh program makes the + connection and does its own host key validation, so `host_keys` is + not consulted and `--sftp-pin-host-key` pins nothing. +- Load-balanced SFTP endpoints that present a different host key per + backend node will produce a mismatch when a later connection lands on + a different node. Re-run with `--sftp-pin-host-key` after clearing + `host_keys`, or repeat the first-connect step until each node's key + has been observed, appending the additional entries to `host_keys` by + hand (comma-separated). +- rclone does not yet support OpenSSH's `hostkeys@openssh.com` extension, + the non-RFC mechanism that helps clients learn additional host keys + during rotation. This is being tracked upstream at + https://github.com/golang/go/issues/37245 + ### ssh-agent on macOS Note that there seem to be various problems with using an ssh-agent on