feat: extend certificate pinning to all TLS protocols and add SSH
host-key pinning
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user