From d0f207271c2f015553da98281bd9f29bed8d8e67 Mon Sep 17 00:00:00 2001 From: OleksandrMorskyi Date: Fri, 30 Oct 2026 08:35:44 +0100 Subject: [PATCH 1/2] Restrict SSH host key algorithms to the configured host key types (#892) --- docs/how-tos/provide-ssh-host-keys.md | 3 + internal/storage/ssh/hostkey.go | 55 ++++++++ internal/storage/ssh/hostkey_test.go | 195 ++++++++++++++++++++++++++ internal/storage/ssh/ssh.go | 4 + 4 files changed, 257 insertions(+) create mode 100644 internal/storage/ssh/hostkey.go create mode 100644 internal/storage/ssh/hostkey_test.go diff --git a/docs/how-tos/provide-ssh-host-keys.md b/docs/how-tos/provide-ssh-host-keys.md index c98795a7..31036d1c 100644 --- a/docs/how-tos/provide-ssh-host-keys.md +++ b/docs/how-tos/provide-ssh-host-keys.md @@ -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. diff --git a/internal/storage/ssh/hostkey.go b/internal/storage/ssh/hostkey.go new file mode 100644 index 00000000..09250d66 --- /dev/null +++ b/internal/storage/ssh/hostkey.go @@ -0,0 +1,55 @@ +// Copyright 2026 - offen.software +// 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") } diff --git a/internal/storage/ssh/hostkey_test.go b/internal/storage/ssh/hostkey_test.go new file mode 100644 index 00000000..68cea424 --- /dev/null +++ b/internal/storage/ssh/hostkey_test.go @@ -0,0 +1,195 @@ +// Copyright 2026 - offen.software +// 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() + } +} diff --git a/internal/storage/ssh/ssh.go b/internal/storage/ssh/ssh.go index 6b120b69..837b3b78 100644 --- a/internal/storage/ssh/ssh.go +++ b/internal/storage/ssh/ssh.go @@ -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) { From a1ed25d78aaa3ddf6231dacce748add3fec6292f Mon Sep 17 00:00:00 2001 From: OleksandrMorskyi Date: Fri, 30 Oct 2026 14:46:37 +0100 Subject: [PATCH 2/2] Test pinning an ED25519 host key in the SSH integration test (#892) --- test/ssh/docker-compose.yml | 2 +- test/ssh/run.sh | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/test/ssh/docker-compose.yml b/test/ssh/docker-compose.yml index 0a2e0920..b41a3356 100644 --- a/test/ssh/docker-compose.yml +++ b/test/ssh/docker-compose.yml @@ -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 diff --git a/test/ssh/run.sh b/test/ssh/run.sh index 5364d8aa..76c7956f 100755 --- a/test/ssh/run.sh +++ b/test/ssh/run.sh @@ -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."