feat: extend certificate pinning to all TLS protocols and add SSH
host-key pinning
This commit is contained in:
@@ -4,15 +4,39 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback, error) {
|
||||
// hostKeyCallback builds the ssh.HostKeyCallback used for dialing.
|
||||
//
|
||||
// Precedence:
|
||||
//
|
||||
// 1. pinnedHostKey — when set, only a host whose key matches the
|
||||
// given fingerprint is accepted. known_hosts and --ssh-insecure
|
||||
// are both ignored because pinning is a stricter guarantee.
|
||||
// 2. insecure --ssh-insecure skips verification entirely.
|
||||
// 3. known_hosts file (default ~/.ssh/known_hosts).
|
||||
func hostKeyCallback(knownHostsPath string, insecure bool, pinnedHostKey string) (ssh.HostKeyCallback, error) {
|
||||
pinned := normalizePinnedHostKey(pinnedHostKey)
|
||||
if pinned != "" {
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
actual := sshFingerprintSHA256(key)
|
||||
if actual == pinned {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("host key pinning failed: server key %s does not match pinned %s", actual, pinned)
|
||||
}, nil
|
||||
}
|
||||
|
||||
if insecure {
|
||||
return ssh.InsecureIgnoreHostKey(), nil
|
||||
}
|
||||
@@ -31,3 +55,60 @@ func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback,
|
||||
|
||||
return knownhosts.New(knownHostsPath)
|
||||
}
|
||||
|
||||
// sshFingerprintSHA256 returns the OpenSSH-style SHA-256 fingerprint
|
||||
// of an SSH public key, e.g. "SHA256:abc123...". This matches the
|
||||
// output of `ssh-keygen -lf <keyfile>`.
|
||||
func sshFingerprintSHA256(key ssh.PublicKey) string {
|
||||
hash := sha256.Sum256(key.Marshal())
|
||||
return "SHA256:" + base64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// normalizePinnedHostKey accepts either the OpenSSH fingerprint form
|
||||
// ("SHA256:<base64>") or a raw lowercase-hex SHA-256 of the key wire
|
||||
// format, and returns the canonical OpenSSH fingerprint form. An empty
|
||||
// input returns an empty string.
|
||||
func normalizePinnedHostKey(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "SHA256:") {
|
||||
return s
|
||||
}
|
||||
// Raw hex form — convert to base64 to match the OpenSSH fingerprint.
|
||||
if decoded := tryHexDecode(s); decoded != nil {
|
||||
return "SHA256:" + base64.StdEncoding.EncodeToString(decoded)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// tryHexDecode decodes a lowercase-or-uppercase hex string. Returns nil
|
||||
// on any error or odd length.
|
||||
func tryHexDecode(s string) []byte {
|
||||
if len(s)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(s); i += 2 {
|
||||
hi, ok1 := hexNibble(s[i])
|
||||
lo, ok2 := hexNibble(s[i+1])
|
||||
if !ok1 || !ok2 {
|
||||
return nil
|
||||
}
|
||||
out[i/2] = hi<<4 | lo
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hexNibble(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, true
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestHostKeyCallbackInsecure(t *testing.T) {
|
||||
cb, err := hostKeyCallback("", true)
|
||||
cb, err := hostKeyCallback("", true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("hostKeyCallback: %v", err)
|
||||
}
|
||||
@@ -18,8 +21,61 @@ func TestHostKeyCallbackInsecure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackMissingFile(t *testing.T) {
|
||||
_, err := hostKeyCallback("/nonexistent/known_hosts", false)
|
||||
_, err := hostKeyCallback("/nonexistent/known_hosts", false, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing known_hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackPinned(t *testing.T) {
|
||||
// Pinning takes precedence over insecure and known_hosts: the
|
||||
// callback is returned without touching the filesystem.
|
||||
cb, err := hostKeyCallback("/nonexistent/known_hosts", false, "SHA256:abcdefghijklmnopqrstuvwxyz0123456789+/=")
|
||||
if err != nil {
|
||||
t.Fatalf("hostKeyCallback with pin: %v", err)
|
||||
}
|
||||
if cb == nil {
|
||||
t.Fatal("expected callback for pinned host key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePinnedHostKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
{"openssh form preserved", "SHA256:abc123", "SHA256:abc123"},
|
||||
{"hex converted to base64", "00", "SHA256:AA=="},
|
||||
{"invalid hex falls through", "nothex", "nothex"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := normalizePinnedHostKey(c.in); got != c.want {
|
||||
t.Errorf("normalizePinnedHostKey(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFingerprintSHA256(t *testing.T) {
|
||||
// Use a fixed test key so the fingerprint is deterministic.
|
||||
// Generated with ssh-keygen -t ed25519 -f /tmp/test_key -N ""
|
||||
// and parsed here. We use ssh.ParseAuthorizedKey on a known-good
|
||||
// public key line.
|
||||
pubLine := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+HHi0MtRj4VPl8mdP8gniGZDRb0SZTdI2TPxRyCm1H test@example.com"
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthorizedKey: %v", err)
|
||||
}
|
||||
fp := sshFingerprintSHA256(key)
|
||||
if !strings.HasPrefix(fp, "SHA256:") {
|
||||
t.Errorf("fingerprint should start with SHA256:, got %q", fp)
|
||||
}
|
||||
// The fingerprint must be deterministic for the same key.
|
||||
if fp != sshFingerprintSHA256(key) {
|
||||
t.Error("fingerprint should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ type client struct {
|
||||
maxPacket uint32
|
||||
}
|
||||
|
||||
func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool) (*client, error) {
|
||||
func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool, pinnedHostKey string) (*client, error) {
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
@@ -127,7 +127,7 @@ func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool)
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure)
|
||||
hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure, pinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -639,7 +639,7 @@ func download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadRes
|
||||
return downloadRecursiveSequential(ctx, req)
|
||||
}
|
||||
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -816,7 +816,7 @@ func newSFTPClientPool(ctx context.Context, req *core.DownloadRequest, size int)
|
||||
all := make([]*client, 0, size)
|
||||
avail := make(chan *client, size)
|
||||
for i := 0; i < size; i++ {
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
for _, existing := range all {
|
||||
existing.close()
|
||||
@@ -854,7 +854,7 @@ func (p *sftpClientPool) close() {
|
||||
// downloadRecursiveParallel and is enabled when req.RecursiveParallel
|
||||
// is greater than one.
|
||||
func downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ func TestIsEOF(t *testing.T) {
|
||||
|
||||
func TestDialTimeout(t *testing.T) {
|
||||
u, _ := url.Parse("sftp://user@127.0.0.1:1/")
|
||||
_, err := dial(context.Background(), u, "", true)
|
||||
_, err := dial(context.Background(), u, "", true, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user