Files
rclone/backend/sftp/sftp_internal_test.go
T
Nick Craig-Wood b2aa82061f 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.
2026-07-27 14:57:21 +01:00

734 lines
25 KiB
Go

//go:build !plan9
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) {
for i, test := range []struct {
unescaped, escaped string
}{
{"", ""},
{"/this/is/harmless", "/this/is/harmless"},
{"$(rm -rf /)", "\\$\\(rm\\ -rf\\ /\\)"},
{"/test/\n", "/test/'\n'"},
{":\"'", ":\\\"\\'"},
} {
got, err := quoteOrEscapeShellPath("unix", test.unescaped)
assert.NoError(t, err)
assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
}
}
func TestShellEscapeCmd(t *testing.T) {
for i, test := range []struct {
unescaped, escaped string
ok bool
}{
{"", "\"\"", true},
{"c:/this/is/harmless", "\"c:/this/is/harmless\"", true},
{"c:/test&notepad", "\"c:/test&notepad\"", true},
{"c:/test\"&\"notepad", "", false},
} {
got, err := quoteOrEscapeShellPath("cmd", test.unescaped)
if test.ok {
assert.NoError(t, err)
assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
} else {
assert.Error(t, err)
}
}
}
func TestShellEscapePowerShell(t *testing.T) {
for i, test := range []struct {
unescaped, escaped string
}{
{"", "''"},
{"c:/this/is/harmless", "'c:/this/is/harmless'"},
{"c:/test&notepad", "'c:/test&notepad'"},
{"c:/test\"&\"notepad", "'c:/test\"&\"notepad'"},
{"c:/test'&'notepad", "'c:/test''&''notepad'"},
} {
got, err := quoteOrEscapeShellPath("powershell", test.unescaped)
assert.NoError(t, err)
assert.Equal(t, test.escaped, got, fmt.Sprintf("Test %d unescaped = %q", i, test.unescaped))
}
}
func TestRemotePathEncodesRemoteNames(t *testing.T) {
f := &Fs{
absRoot: "/srv/root",
opt: Options{
Enc: encoder.Display | encoder.EncodeColon,
},
}
assert.Equal(t, "/srv/root/dir/file\uFF1Aname", f.remotePath("dir/file:name"))
assert.Equal(t, "/srv/root", f.remotePath(""))
}
func TestRemoteShellPathEncodesRemoteNames(t *testing.T) {
f := &Fs{
absRoot: "/srv/root",
opt: Options{
Enc: encoder.Display | encoder.EncodeColon,
},
}
assert.Equal(t, "/srv/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))
}
func TestRemoteShellPathEncodesPathOverrideNames(t *testing.T) {
f := &Fs{
absRoot: "/srv/root",
opt: Options{
Enc: encoder.Display | encoder.EncodeColon,
PathOverride: "/shell/root",
},
}
assert.Equal(t, "/shell/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))
f.opt.PathOverride = "@/volume"
assert.Equal(t, "/volume/srv/root/dir/file\uFF1Aname", f.remoteShellPath("dir/file:name"))
}
func TestParseHash(t *testing.T) {
for i, test := range []struct {
sshOutput, checksum string
}{
{"8dbc7733dbd10d2efc5c0a0d8dad90f958581821 RELEASE.md\n", "8dbc7733dbd10d2efc5c0a0d8dad90f958581821"},
{"03cfd743661f07975fa2f1220c5194cbaff48451 -\n", "03cfd743661f07975fa2f1220c5194cbaff48451"},
} {
got := parseHash([]byte(test.sshOutput))
assert.Equal(t, test.checksum, got, fmt.Sprintf("Test %d sshOutput = %q", i, test.sshOutput))
}
}
// 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
usage [3]int64
}{
{"Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/root 91283092 81111888 10154820 89% /", [3]int64{93473886208, 83058573312, 10398535680}},
{"Filesystem 1K-blocks Used Available Use% Mounted on\ntmpfs 818256 1636 816620 1% /run", [3]int64{837894144, 1675264, 836218880}},
{"Filesystem 1024-blocks Used Available Capacity iused ifree %iused Mounted on\n/dev/disk0s2 244277768 94454848 149566920 39% 997820 4293969459 0% /", [3]int64{250140434432, 96721764352, 153156526080}},
} {
gotSpaceTotal, gotSpaceUsed, gotSpaceAvail := parseUsage([]byte(test.sshOutput))
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)