fs: tls: add --client-pass support for encrypted --client-key files

This also widens the supported types

- Unencrypted PKCS#1 ("BEGIN RSA PRIVATE KEY")
- Unencrypted PKCS#8 ("BEGIN PRIVATE KEY")
- Encrypted PKCS#8 ("BEGIN ENCRYPTED PRIVATE KEY")
- Legacy PEM encryption (e.g., DEK-Info headers), which are automatically detected.
This commit is contained in:
Nick Craig-Wood
2025-08-20 16:27:42 +01:00
parent e7a2b322ec
commit cfd0d28742
4 changed files with 303 additions and 29 deletions

119
bin/make-test-certs.sh Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
# Create test TLS certificates for use with rclone.
OUT_DIR="${OUT_DIR:-./tls-test}"
CA_SUBJ="${CA_SUBJ:-/C=US/ST=Test/L=Test/O=Test Org/OU=Test Unit/CN=Test Root CA}"
SERVER_CN="${SERVER_CN:-localhost}"
CLIENT_CN="${CLIENT_CN:-Test Client}"
CLIENT_KEY_PASS="${CLIENT_KEY_PASS:-testpassword}"
CA_DAYS=${CA_DAYS:-3650}
SERVER_DAYS=${SERVER_DAYS:-825}
CLIENT_DAYS=${CLIENT_DAYS:-825}
mkdir -p "$OUT_DIR"
cd "$OUT_DIR"
# Create OpenSSL config
# CA extensions
cat > ca_openssl.cnf <<'EOF'
[ ca_ext ]
basicConstraints = critical, CA:true, pathlen:1
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
EOF
# Server extensions (SAN includes localhost + loopback IP)
cat > server_openssl.cnf <<EOF
[ server_ext ]
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = ${SERVER_CN}
IP.1 = 127.0.0.1
EOF
# Client extensions (for mTLS client auth)
cat > client_openssl.cnf <<'EOF'
[ client_ext ]
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
EOF
echo "Create CA key, CSR, and self-signed CA cert"
if [ ! -f ca.key.pem ]; then
openssl genrsa -out ca.key.pem 4096
chmod 600 ca.key.pem
fi
openssl req -new -key ca.key.pem -subj "$CA_SUBJ" -out ca.csr.pem
openssl x509 -req -in ca.csr.pem -signkey ca.key.pem \
-sha256 -days "$CA_DAYS" \
-extfile ca_openssl.cnf -extensions ca_ext \
-out ca.cert.pem
echo "Create server key (NO PASSWORD) and cert signed by CA"
openssl genrsa -out server.key.pem 2048
chmod 600 server.key.pem
openssl req -new -key server.key.pem -subj "/CN=${SERVER_CN}" -out server.csr.pem
openssl x509 -req -in server.csr.pem \
-CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial \
-out server.cert.pem -days "$SERVER_DAYS" -sha256 \
-extfile server_openssl.cnf -extensions server_ext
echo "Create client key (PASSWORD-PROTECTED), CSR, and cert"
openssl genrsa -aes256 -passout pass:"$CLIENT_KEY_PASS" -out client.key.pem 2048
chmod 600 client.key.pem
openssl req -new -key client.key.pem -passin pass:"$CLIENT_KEY_PASS" \
-subj "/CN=${CLIENT_CN}" -out client.csr.pem
openssl x509 -req -in client.csr.pem \
-CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial \
-out client.cert.pem -days "$CLIENT_DAYS" -sha256 \
-extfile client_openssl.cnf -extensions client_ext
echo "Verify chain"
openssl verify -CAfile ca.cert.pem server.cert.pem client.cert.pem
echo "Done"
echo
echo "Summary"
echo "-------"
printf "%-22s %s\n" \
"CA key:" "ca.key.pem" \
"CA cert:" "ca.cert.pem" \
"Server key:" "server.key.pem (no password)" \
"Server CSR:" "server.csr.pem" \
"Server cert:" "server.cert.pem (SAN: ${SERVER_CN}, 127.0.0.1)" \
"Client key:" "client.key.pem (encrypted)" \
"Client CSR:" "client.csr.pem" \
"Client cert:" "client.cert.pem" \
"Client key password:" "$CLIENT_KEY_PASS"
echo
echo "Test rclone server"
echo
echo "rclone serve http -vv --addr :8080 --cert ${OUT_DIR}/server.cert.pem --key ${OUT_DIR}/server.key.pem --client-ca ${OUT_DIR}/ca.cert.pem ."
echo
echo "Test rclone client"
echo
echo "rclone lsf :http: --http-url 'https://localhost:8080' --ca-cert ${OUT_DIR}/ca.cert.pem --client-cert ${OUT_DIR}/client.cert.pem --client-key ${OUT_DIR}/client.key.pem --client-pass \$(rclone obscure $CLIENT_KEY_PASS)"
echo

View File

@@ -3000,6 +3000,20 @@ The `--client-key` flag is required too when using this.
This loads the PEM encoded client side private key used for mutual TLS This loads the PEM encoded client side private key used for mutual TLS
authentication. Used in conjunction with `--client-cert`. authentication. Used in conjunction with `--client-cert`.
Supported types are:
- Unencrypted PKCS#1 ("BEGIN RSA PRIVATE KEY")
- Unencrypted PKCS#8 ("BEGIN PRIVATE KEY")
- Encrypted PKCS#8 ("BEGIN ENCRYPTED PRIVATE KEY")
- Legacy PEM encryption (e.g., DEK-Info headers), which are automatically detected.
### --client-pass string
This can be used to supply an optional password to decrypt the client key file.
**NB** the password should be obscured so it should be the output of
`rclone obscure YOURPASSWORD`.
### --no-check-certificate ### --no-check-certificate
`--no-check-certificate` controls whether a client verifies the `--no-check-certificate` controls whether a client verifies the

View File

@@ -438,6 +438,12 @@ var ConfigOptionsInfo = Options{{
Default: "", Default: "",
Help: "Client SSL private key (PEM) for mutual TLS auth", Help: "Client SSL private key (PEM) for mutual TLS auth",
Groups: "Networking", Groups: "Networking",
}, {
Name: "client_pass",
Default: "",
Help: "Password for client SSL private key (PEM) for mutual TLS auth (obscured)",
Groups: "Networking",
IsPassword: true,
}, { }, {
Name: "multi_thread_cutoff", Name: "multi_thread_cutoff",
Default: SizeSuffix(256 * 1024 * 1024), Default: SizeSuffix(256 * 1024 * 1024),
@@ -644,6 +650,7 @@ type ConfigInfo struct {
CaCert []string `config:"ca_cert"` // Client Side CA CaCert []string `config:"ca_cert"` // Client Side CA
ClientCert string `config:"client_cert"` // Client Side Cert ClientCert string `config:"client_cert"` // Client Side Cert
ClientKey string `config:"client_key"` // Client Side Key ClientKey string `config:"client_key"` // Client Side Key
ClientPass string `config:"client_pass"` // Client Side Key Password (obscured)
MultiThreadCutoff SizeSuffix `config:"multi_thread_cutoff"` MultiThreadCutoff SizeSuffix `config:"multi_thread_cutoff"`
MultiThreadStreams int `config:"multi_thread_streams"` MultiThreadStreams int `config:"multi_thread_streams"`
MultiThreadSet bool `config:"multi_thread_set"` // whether MultiThreadStreams was set (set in fs/config/configflags) MultiThreadSet bool `config:"multi_thread_set"` // whether MultiThreadStreams was set (set in fs/config/configflags)

View File

@@ -6,6 +6,8 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/pem"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -18,7 +20,9 @@ import (
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting" "github.com/rclone/rclone/fs/accounting"
"github.com/rclone/rclone/fs/config/obscure"
"github.com/rclone/rclone/lib/structs" "github.com/rclone/rclone/lib/structs"
"github.com/youmark/pkcs8"
"golang.org/x/net/publicsuffix" "golang.org/x/net/publicsuffix"
) )
@@ -48,6 +52,156 @@ func ResetTransport() {
noTransport = new(sync.Once) noTransport = new(sync.Once)
} }
// LoadKeyPair loads a TLS certificate and private key from PEM-encoded files,
// with extended support for encrypted private keys.
//
// This function is designed as a robust replacement for tls.X509KeyPair,
// providing the same core functionality but adding support for
// password-protected private keys.
//
// The certificate file (certFile) must contain one or more PEM-encoded
// certificates. The first certificate is treated as the leaf certificate, and
// any subsequent certificates are treated as its chain.
//
// The key file (keyFile) must contain a PEM-encoded private key. Supported
// formats are:
//
// - Unencrypted PKCS#1 ("BEGIN RSA PRIVATE KEY")
// - Unencrypted PKCS#8 ("BEGIN PRIVATE KEY")
// - Encrypted PKCS#8 ("BEGIN ENCRYPTED PRIVATE KEY")
// - Legacy PEM encryption (e.g., DEK-Info headers), which are automatically detected.
//
// The password parameter is used to decrypt the private key. If the
// key is not encrypted, this parameter is ignored and can be an empty
// string. The password should be an obscured string.
//
// On success, it returns a fully populated tls.Certificate struct, including the
// Leaf certificate field.
func LoadKeyPair(certFile, keyFile, password string) (cert tls.Certificate, err error) {
certPEM, err := os.ReadFile(certFile)
if err != nil {
return cert, fmt.Errorf("read cert: %w", err)
}
keyPEM, err := os.ReadFile(keyFile)
if err != nil {
return cert, fmt.Errorf("read key: %w", err)
}
if password != "" {
password, err = obscure.Reveal(password)
if err != nil {
return cert, fmt.Errorf("reveal key password: %w", err)
}
}
// Fast path: unencrypted PKCS#1/PKCS#8
cert, err = tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
if len(cert.Certificate) == 0 {
return cert, errors.New("no certificates parsed")
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return cert, fmt.Errorf("parse leaf: %w", err)
}
cert.Leaf = leaf
return cert, nil
}
// Decrypt / parse key manually
block, rest := pem.Decode(keyPEM)
if block == nil {
return cert, errors.New("no PEM block in key")
}
if len(rest) != 0 {
fs.Debugf(nil, "Trailing data (%d bytes) in key PEM loaded from %q", len(rest), keyFile)
}
var privKey any
switch {
case block.Type == "ENCRYPTED PRIVATE KEY":
if password == "" {
return cert, errors.New("key is encrypted but no --client-pass provided")
}
privKey, err = pkcs8.ParsePKCS8PrivateKey(block.Bytes, []byte(password))
if err != nil {
return cert, fmt.Errorf("parse encrypted PKCS#8: %w", err)
}
case x509.IsEncryptedPEMBlock(block): //nolint:staticcheck // this is Legacy and insecure
if password == "" {
return cert, errors.New("key is encrypted but no --client-pass provided")
}
der, err := x509.DecryptPEMBlock(block, []byte(password)) //nolint:staticcheck // this is Legacy and insecure
if err != nil {
return cert, fmt.Errorf("decrypt PEM key: %w", err)
}
// Try PKCS#8, then RSA PKCS#1, then EC
if k, kerr1 := x509.ParsePKCS8PrivateKey(der); kerr1 == nil {
privKey = k
} else if k, kerr2 := x509.ParsePKCS1PrivateKey(der); kerr2 == nil {
privKey = k
} else if k, kerr3 := x509.ParseECPrivateKey(der); kerr3 == nil {
privKey = k
} else {
return cert, fmt.Errorf("parse decrypted key: pkcs8: %v, pkcs1: %v, ec: %v", kerr1, kerr2, kerr3)
}
default:
// Unencrypted specific types
switch block.Type {
case "PRIVATE KEY":
k, kerr := x509.ParsePKCS8PrivateKey(block.Bytes)
if kerr != nil {
return cert, fmt.Errorf("parse PKCS#8: %w", kerr)
}
privKey = k
case "RSA PRIVATE KEY":
k, kerr := x509.ParsePKCS1PrivateKey(block.Bytes)
if kerr != nil {
return cert, fmt.Errorf("parse PKCS#1 RSA: %w", kerr)
}
privKey = k
case "EC PRIVATE KEY":
k, kerr := x509.ParseECPrivateKey(block.Bytes)
if kerr != nil {
return cert, fmt.Errorf("parse EC: %w", kerr)
}
privKey = k
default:
return cert, fmt.Errorf("unsupported key type %q", block.Type)
}
}
// Build cert chain from PEM
var certDERs [][]byte
for rest := certPEM; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
if b.Type == "CERTIFICATE" {
certDERs = append(certDERs, b.Bytes)
}
}
if len(certDERs) == 0 {
return cert, fmt.Errorf("no CERTIFICATE blocks in %s", certFile)
}
cert = tls.Certificate{
Certificate: certDERs,
PrivateKey: privKey,
}
// Leaf is always the first certificate
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return cert, fmt.Errorf("parse leaf: %w", err)
}
return cert, nil
}
// NewTransportCustom returns an http.RoundTripper with the correct timeouts. // NewTransportCustom returns an http.RoundTripper with the correct timeouts.
// The customize function is called if set to give the caller an opportunity to // The customize function is called if set to give the caller an opportunity to
// customize any defaults in the Transport. // customize any defaults in the Transport.
@@ -85,17 +239,10 @@ func NewTransportCustom(ctx context.Context, customize func(*http.Transport)) *T
if ci.ClientCert == "" || ci.ClientKey == "" { if ci.ClientCert == "" || ci.ClientKey == "" {
fs.Fatalf(nil, "Both --client-cert and --client-key must be set") fs.Fatalf(nil, "Both --client-cert and --client-key must be set")
} }
cert, err := tls.LoadX509KeyPair(ci.ClientCert, ci.ClientKey) cert, err := LoadKeyPair(ci.ClientCert, ci.ClientKey, ci.ClientPass)
if err != nil { if err != nil {
fs.Fatalf(nil, "Failed to load --client-cert/--client-key pair: %v", err) fs.Fatalf(nil, "Failed to load --client-cert/--client-key pair: %v", err)
} }
if cert.Leaf == nil {
// Leaf is always the first certificate
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
if err != nil {
fs.Fatalf(nil, "Failed to parse the certificate")
}
}
t.TLSClientConfig.Certificates = []tls.Certificate{cert} t.TLSClientConfig.Certificates = []tls.Certificate{cert}
} }
@@ -187,14 +334,12 @@ func NewClientWithUnixSocket(ctx context.Context, path string) *http.Client {
// * Updates metrics // * Updates metrics
type Transport struct { type Transport struct {
*http.Transport *http.Transport
ci *fs.ConfigInfo
dump fs.DumpFlags dump fs.DumpFlags
filterRequest func(req *http.Request) filterRequest func(req *http.Request)
userAgent string userAgent string
headers []*fs.HTTPOption headers []*fs.HTTPOption
metrics *Metrics metrics *Metrics
// Filename of the client cert in case we need to reload it
clientCert string
clientKey string
// Mutex for serializing attempts at reloading the certificates // Mutex for serializing attempts at reloading the certificates
reloadMutex sync.Mutex reloadMutex sync.Mutex
} }
@@ -203,13 +348,12 @@ type Transport struct {
// roundtrips including the body if logBody is set. // roundtrips including the body if logBody is set.
func newTransport(ci *fs.ConfigInfo, transport *http.Transport) *Transport { func newTransport(ci *fs.ConfigInfo, transport *http.Transport) *Transport {
return &Transport{ return &Transport{
Transport: transport, Transport: transport,
dump: ci.Dump, ci: ci,
userAgent: ci.UserAgent, dump: ci.Dump,
headers: ci.Headers, userAgent: ci.UserAgent,
metrics: DefaultMetrics, headers: ci.Headers,
clientCert: ci.ClientCert, metrics: DefaultMetrics,
clientKey: ci.ClientKey,
} }
} }
@@ -309,20 +453,10 @@ func (t *Transport) reloadCertificates() {
if !isCertificateExpired(t.TLSClientConfig) { if !isCertificateExpired(t.TLSClientConfig) {
return return
} }
cert, err := LoadKeyPair(t.ci.ClientCert, t.ci.ClientKey, t.ci.ClientPass)
cert, err := tls.LoadX509KeyPair(t.clientCert, t.clientKey)
if err != nil { if err != nil {
fs.Fatalf(nil, "Failed to load --client-cert/--client-key pair: %v", err) fs.Fatalf(nil, "Failed to load --client-cert/--client-key pair: %v", err)
} }
// Check if we need to parse the certificate again, we need it
// for checking the expiration date
if cert.Leaf == nil {
// Leaf is always the first certificate
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
if err != nil {
fs.Fatalf(nil, "Failed to parse the certificate")
}
}
t.TLSClientConfig.Certificates = []tls.Certificate{cert} t.TLSClientConfig.Certificates = []tls.Certificate{cert}
} }