feat: extend certificate pinning to all TLS protocols and add SSH

host-key pinning
This commit is contained in:
2026-06-29 19:23:10 +02:00
parent e7ed3a334e
commit fc65ea8340
19 changed files with 355 additions and 25 deletions
+38 -3
View File
@@ -6,7 +6,10 @@ package ftp
import (
"bufio"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net"
@@ -35,6 +38,11 @@ type Client struct {
useTLS bool
passive bool
timeout time.Duration
// pinnedCertHash, when non-empty, requires the server's leaf
// certificate to match this SHA-256 hash (lowercase hex). Only
// meaningful for FTPS.
pinnedCertHash string
}
func NewClient(rawURL string, timeout time.Duration) (*Client, error) {
@@ -86,6 +94,13 @@ func NewClient(rawURL string, timeout time.Duration) (*Client, error) {
return c, nil
}
// SetPinnedCert configures certificate pinning for FTPS connections.
// The expectedHash is a SHA-256 hash of the leaf certificate, expressed
// as lowercase hex. Calling this on a plain-FTP client is a no-op.
func (c *Client) SetPinnedCert(expectedHash string) {
c.pinnedCertHash = strings.ToLower(strings.TrimSpace(expectedHash))
}
func (c *Client) Connect() error {
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
conn, err := net.DialTimeout("tcp", addr, c.timeout)
@@ -130,6 +145,23 @@ func (c *Client) Connect() error {
}
// Wrap connection with TLS
if c.pinnedCertHash != "" {
expected := c.pinnedCertHash
c.tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
for _, rawCert := range rawCerts {
cert, err := x509.ParseCertificate(rawCert)
if err != nil {
continue
}
hash := sha256.Sum256(cert.Raw)
if hex.EncodeToString(hash[:]) == expected {
return nil
}
}
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected)
}
}
tlsConn := tls.Client(c.conn, c.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("tls handshake failed: %w", err)
@@ -647,7 +679,7 @@ type clientPool struct {
// newClientPool opens `size` independent FTP connections to the same
// server. Connections are kept open until close() is called.
func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool, error) {
func newClientPool(rawURL string, timeout time.Duration, size int, pinnedCertHash string) (*clientPool, error) {
if size < 1 {
size = 1
}
@@ -661,6 +693,9 @@ func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool,
}
return nil, err
}
if pinnedCertHash != "" {
c.SetPinnedCert(pinnedCertHash)
}
if err := c.Connect(); err != nil {
for _, existing := range all {
_ = existing.Close()
@@ -774,11 +809,11 @@ func ftpTaskHandler(cpool *clientPool, progressFunc func(file string, current, t
// on the server side. The returned error is the first error encountered
// by any worker; other workers continue in the background until they
// hit ctx cancellation or finish their tasks.
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, progressFunc func(file string, current, total int64)) error {
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, pinnedCertHash string, progressFunc func(file string, current, total int64)) error {
if parallel < 1 {
parallel = 1
}
cpool, err := newClientPool(rawURL, timeout, parallel)
cpool, err := newClientPool(rawURL, timeout, parallel, pinnedCertHash)
if err != nil {
return err
}
+18
View File
@@ -380,3 +380,21 @@ func TestNewClientTLSConfig(t *testing.T) {
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default")
}
}
func TestSetPinnedCert(t *testing.T) {
client, err := NewClient("ftps://secure.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
// Pinning is off by default.
if client.pinnedCertHash != "" {
t.Errorf("pinnedCertHash should default to empty, got %q", client.pinnedCertHash)
}
// SetPinnedCert normalises to lowercase and trims whitespace.
client.SetPinnedCert(" ABCDEF0123456789 ")
if client.pinnedCertHash != "abcdef0123456789" {
t.Errorf("pinnedCertHash = %q, want %q", client.pinnedCertHash, "abcdef0123456789")
}
}
+4 -1
View File
@@ -79,6 +79,9 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
if err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to create FTP client: %v", err), nil, core.SafeURL(req.URL))
}
if req.PinnedCertHash != "" {
client.SetPinnedCert(req.PinnedCertHash)
}
if err := client.Connect(); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to connect: %v", err), nil, core.SafeURL(req.URL))
@@ -169,7 +172,7 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
// opens N control connections to avoid stepping on CWD state and
// to keep the existing client-side protocol serialisation.
if req.RecursiveParallel > 1 {
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, progressCallback); err != nil {
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, req.PinnedCertHash, progressCallback); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
}
} else {