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:
+383
-11
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user