Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/how-tos/provide-ssh-host-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@ Starting with version 2.49.2, this is prevented, requiring you to either:
- provide the location of a valid known_hosts file by setting `SSH_KNOWN_HOSTS_FILE`
- dangerously set `SSH_INSECURE_IGNORE_HOST_KEY` to ignore host key checks

The key may be of any type the server offers (e.g. ED25519, ECDSA or RSA).
Only the configured key types are negotiated with the server, so it presents a matching key.

{: .warning }
Only set `SSH_INSECURE_IGNORE_HOST_KEY` as a last resort or in entirely airgapped environments.
55 changes: 55 additions & 0 deletions internal/storage/ssh/hostkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2026 - offen.software <hioffen@posteo.de>
// SPDX-License-Identifier: MPL-2.0

package ssh

import (
"errors"
"net"
"slices"

"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)

// hostKeyAlgorithms returns the host key algorithms that can be negotiated
// for a host key of the given type. RSA keys are signed with SHA-2 variants
// of the algorithm, so these are offered in addition to the key type itself.
func hostKeyAlgorithms(keyType string) []string {
switch keyType {
case ssh.KeyAlgoRSA:
return []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSA}
case ssh.CertAlgoRSAv01:
return []string{ssh.CertAlgoRSASHA512v01, ssh.CertAlgoRSASHA256v01, ssh.CertAlgoRSAv01}
default:
return []string{keyType}
}
}

// knownHostKeyAlgorithms returns the host key algorithms of all keys the
// known_hosts callback knows for the given host. It returns nil if no key is
// known, so the client's default preference is used.
func knownHostKeyAlgorithms(cb ssh.HostKeyCallback, hostName, port string) []string {
// A key that matches no entry makes the callback report the known keys.
err := cb(net.JoinHostPort(hostName, port), &net.TCPAddr{IP: net.IPv4zero}, unknownKey{})
var keyErr *knownhosts.KeyError
if !errors.As(err, &keyErr) {
return nil
}
var algorithms []string
for _, known := range keyErr.Want {
for _, algorithm := range hostKeyAlgorithms(known.Key.Type()) {
if !slices.Contains(algorithms, algorithm) {
algorithms = append(algorithms, algorithm)
}
}
}
return algorithms
}

// unknownKey is a public key that matches no known_hosts entry.
type unknownKey struct{}

func (unknownKey) Type() string { return "unknown" }
func (unknownKey) Marshal() []byte { return []byte("unknown") }
func (unknownKey) Verify(_ []byte, _ *ssh.Signature) error { return errors.New("unknown key") }
195 changes: 195 additions & 0 deletions internal/storage/ssh/hostkey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright 2026 - offen.software <hioffen@posteo.de>
// SPDX-License-Identifier: MPL-2.0

package ssh

import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"net"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"

"github.com/offen/docker-volume-backup/internal/storage"
"github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)

func TestHostKeyAlgorithms(t *testing.T) {
tests := []struct {
keyType string
want []string
}{
{gossh.KeyAlgoED25519, []string{gossh.KeyAlgoED25519}},
{gossh.KeyAlgoECDSA521, []string{gossh.KeyAlgoECDSA521}},
{gossh.KeyAlgoRSA, []string{gossh.KeyAlgoRSASHA512, gossh.KeyAlgoRSASHA256, gossh.KeyAlgoRSA}},
}
for _, test := range tests {
t.Run(test.keyType, func(t *testing.T) {
if got := hostKeyAlgorithms(test.keyType); !slices.Equal(got, test.want) {
t.Fatalf("hostKeyAlgorithms(%q) = %v; want %v", test.keyType, got, test.want)
}
})
}
}

func TestKnownHostKeyAlgorithms(t *testing.T) {
ed25519Key, ecdsaKey := generateHostKeys(t)
file := writeKnownHosts(t, "127.0.0.1:2222", ed25519Key.PublicKey(), ecdsaKey.PublicKey())
cb, err := knownhosts.New(file)
if err != nil {
t.Fatal(err)
}
want := []string{gossh.KeyAlgoED25519, gossh.KeyAlgoECDSA256}
if got := knownHostKeyAlgorithms(cb, "127.0.0.1", "2222"); !slices.Equal(got, want) {
t.Fatalf("known host = %v; want %v", got, want)
}
if got := knownHostKeyAlgorithms(cb, "127.0.0.1", "22"); got != nil {
t.Fatalf("unknown host = %v; want nil", got)
}
}

// The server offers ED25519 and ECDSA host keys. Without restricting the host
// key algorithms, the client negotiates ECDSA and a pinned ED25519 key fails.
func TestNewStorageBackendVerifiesPinnedHostKeyOfAnyType(t *testing.T) {
ed25519Key, ecdsaKey := generateHostKeys(t)
address := startSFTPServer(t, ed25519Key, ecdsaKey)
host, port, err := net.SplitHostPort(address)
if err != nil {
t.Fatal(err)
}
otherKey, _ := generateHostKeys(t)

tests := []struct {
name string
config Config
wantErr bool
}{
{name: "public key ed25519", config: Config{HostPublicKey: authorizedKey(ed25519Key)}},
{name: "public key ecdsa", config: Config{HostPublicKey: authorizedKey(ecdsaKey)}},
{name: "public key mismatch", config: Config{HostPublicKey: authorizedKey(otherKey)}, wantErr: true},
{name: "known hosts ed25519", config: Config{KnownHostsFile: writeKnownHosts(t, address, ed25519Key.PublicKey())}},
{name: "known hosts ecdsa", config: Config{KnownHostsFile: writeKnownHosts(t, address, ecdsaKey.PublicKey())}},
{name: "known hosts mismatch", config: Config{KnownHostsFile: writeKnownHosts(t, address, otherKey.PublicKey())}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config := test.config
config.HostName, config.Port, config.User, config.RemotePath = host, port, "test", "/backups"
_, cleanup, err := NewStorageBackend(config, func(storage.LogLevel, string, string, ...any) {})
t.Cleanup(func() { _ = cleanup() })
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "mismatch") {
t.Fatalf("expected host key mismatch, got %v", err)
}
return
}
if err != nil {
t.Fatalf("expected connection to succeed, got %v", err)
}
})
}
}

func generateHostKeys(t *testing.T) (gossh.Signer, gossh.Signer) {
t.Helper()
_, ed25519Private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
ed25519Signer, err := gossh.NewSignerFromKey(ed25519Private)
if err != nil {
t.Fatal(err)
}
ecdsaPrivate, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
ecdsaSigner, err := gossh.NewSignerFromKey(ecdsaPrivate)
if err != nil {
t.Fatal(err)
}
return ed25519Signer, ecdsaSigner
}

func authorizedKey(signer gossh.Signer) string {
return string(gossh.MarshalAuthorizedKey(signer.PublicKey()))
}

func writeKnownHosts(t *testing.T, address string, keys ...gossh.PublicKey) string {
t.Helper()
var lines []string
for _, key := range keys {
lines = append(lines, knownhosts.Line([]string{knownhosts.Normalize(address)}, key))
}
file := filepath.Join(t.TempDir(), "known_hosts")
if err := os.WriteFile(file, []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil {
t.Fatal(err)
}
return file
}

// startSFTPServer starts an in-memory SFTP server presenting the given host keys.
func startSFTPServer(t *testing.T, hostKeys ...gossh.Signer) string {
t.Helper()
config := &gossh.ServerConfig{NoClientAuth: true}
for _, hostKey := range hostKeys {
config.AddHostKey(hostKey)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
var workers sync.WaitGroup
t.Cleanup(func() {
_ = listener.Close()
workers.Wait()
})
workers.Go(func() {
for {
connection, err := listener.Accept()
if err != nil {
return
}
workers.Go(func() { serveSFTP(connection, config) })
}
})
return listener.Addr().String()
}

func serveSFTP(connection net.Conn, config *gossh.ServerConfig) {
defer func() { _ = connection.Close() }()
_ = connection.SetDeadline(time.Now().Add(15 * time.Second))
server, channels, requests, err := gossh.NewServerConn(connection, config)
if err != nil {
return
}
defer func() { _ = server.Close() }()
go gossh.DiscardRequests(requests)
for incoming := range channels {
if incoming.ChannelType() != "session" {
_ = incoming.Reject(gossh.UnknownChannelType, "expected session")
continue
}
channel, requests, err := incoming.Accept()
if err != nil {
return
}
go func() {
for request := range requests {
_ = request.Reply(request.Type == "subsystem", nil)
}
}()
requestServer := sftp.NewRequestServer(channel, sftp.InMemHandler())
_ = requestServer.Serve()
_ = requestServer.Close()
}
}
4 changes: 4 additions & 0 deletions internal/storage/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,16 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, func(
return nil, noop, errwrap.Wrap(err, "error parsing public host key")
}
sshClientConfig.HostKeyCallback = ssh.FixedHostKey(cb)
// Without this, the server presents its key for the client's default
// preference (ECDSA first), so a pinned key of another type never matches.
sshClientConfig.HostKeyAlgorithms = hostKeyAlgorithms(cb.Type())
} else if opts.KnownHostsFile != "" {
cb, err := knownhosts.New(opts.KnownHostsFile)
if err != nil {
return nil, noop, errwrap.Wrap(err, "error parsing knownhosts file")
}
sshClientConfig.HostKeyCallback = cb
sshClientConfig.HostKeyAlgorithms = knownHostKeyAlgorithms(cb, opts.HostName, opts.Port)
}

connect := func() (sftpClient, func() error, error) {
Expand Down
2 changes: 1 addition & 1 deletion test/ssh/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
BACKUP_PRUNING_LEEWAY: 5s
BACKUP_PRUNING_PREFIX: test
SSH_HOST_NAME: ssh
SSH_HOST_PUBLIC_KEY_FILE: /ssh_config_data/ssh_host_keys/ssh_host_ecdsa_key.pub
SSH_HOST_PUBLIC_KEY_FILE: /ssh_config_data/ssh_host_keys/ssh_host_${SSH_HOST_KEY_TYPE:-ecdsa}_key.pub
SSH_PORT: 2222
SSH_USER: test
SSH_REMOTE_PATH: /tmp
Expand Down
21 changes: 21 additions & 0 deletions test/ssh/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,24 @@ docker run --rm \
ash -c 'test ! -f /ssh_data/test-hostnametoken-old.tar.gz && test -f /ssh_data/test-hostnametoken.tar.gz'

pass "Old remote backup has been pruned, new one is still present."

# The fourth part checks that a host key other than the client's preferred
# ECDSA key can be pinned, as the server offers both ECDSA and ED25519 keys.

SSH_HOST_KEY_TYPE="ed25519" docker compose up -d
sleep 5

docker run --rm \
-v ssh_ssh_backup_data:/ssh_data \
alpine \
ash -c 'rm -f /ssh_data/test-hostnametoken.tar.gz'

info "Create backup with pinned ED25519 host key"
docker compose exec backup backup

docker run --rm \
-v ssh_ssh_backup_data:/ssh_data \
alpine \
ash -c 'test -f /ssh_data/test-hostnametoken.tar.gz'

pass "Backup with pinned ED25519 host key has been uploaded."
Loading