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
}