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.
This commit is contained in:
Nick Craig-Wood
2026-07-27 14:57:21 +01:00
parent 83a366beae
commit b2aa82061f
3 changed files with 1104 additions and 18 deletions
+383 -11
View File
@@ -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 \"<algo> <base64>\", 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, "<unparseable>")
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) {
+606
View File
@@ -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)