From d62bea778db705ba873415de350252bbc2cba056 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 14 May 2026 23:21:15 +0200 Subject: [PATCH] fix(client/tls): correct PEM label and add Ed25519 key support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLSClientAuth marshaled RSA keys as PKCS#1 and ECDSA keys as SEC1, then labeled both PEM blocks as "PRIVATE KEY" — a label conventionally reserved for PKCS#8. The output round-tripped through tls.X509KeyPair only because Go's parser is lenient; stricter consumers would reject it. Switch to a single x509.MarshalPKCS8PrivateKey call, which matches the "PRIVATE KEY" label naturally and transparently supports Ed25519 (previously rejected by the type switch's default branch). Also fix gencerts_test.go: writePKCS1KeyPair wrote PKCS#1 RSA bytes under an "EC PRIVATE KEY" label — same class of latent mislabel. Adds an Ed25519 subtest under TestRuntimeTLSOptions. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frederic BIDON --- client/gencerts_test.go | 2 +- client/runtime_tls_test.go | 40 ++++++++++++++++++++++++++++++++++++++ client/tls.go | 24 +++++++---------------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/client/gencerts_test.go b/client/gencerts_test.go index 66a2a541..e818aca6 100644 --- a/client/gencerts_test.go +++ b/client/gencerts_test.go @@ -279,7 +279,7 @@ func writePKCS1KeyPair(dir, name string, key *rsa.PrivateKey, certDER []byte) er // Write private key keyDER := x509.MarshalPKCS1PrivateKey(key) - if err := writePEM(keyPath, "EC PRIVATE KEY", keyDER); err != nil { + if err := writePEM(keyPath, "RSA PRIVATE KEY", keyDER); err != nil { return err } diff --git a/client/runtime_tls_test.go b/client/runtime_tls_test.go index a8a9a6b8..d62753a2 100644 --- a/client/runtime_tls_test.go +++ b/client/runtime_tls_test.go @@ -5,10 +5,14 @@ package client import ( "crypto" + "crypto/ed25519" + "crypto/rand" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/json" "errors" + "math/big" "net/http" "net/http/httptest" "net/url" @@ -67,6 +71,19 @@ func TestRuntimeTLSOptions(t *testing.T) { assert.Len(t, cfg.Certificates, 1) }) + t.Run("with TLSAuthConfig configured with loaded Ed25519 key/certificate", func(t *testing.T) { + edKey, edCert := newEd25519KeyCert(t) + opts := TLSClientOptions{ + LoadedKey: edKey, + LoadedCertificate: edCert, + } + + cfg, err := TLSClientAuth(opts) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Len(t, cfg.Certificates, 1) + }) + t.Run("with TLSAuthConfig configured with loaded Certificate Authority", func(t *testing.T) { opts := TLSClientOptions{ LoadedCA: fixtures.RSA.LoadedCA, @@ -259,6 +276,29 @@ type ( } ) +func newEd25519KeyCert(t testing.TB) (ed25519.PrivateKey, *x509.Certificate) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{CommonName: "ed25519-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + return priv, cert +} + func systemCAPool(t testing.TB) *x509.CertPool { if goruntime.GOOS == "windows" { // Windows doesn't have the system cert pool. diff --git a/client/tls.go b/client/tls.go index cd1f1ed5..469c7529 100644 --- a/client/tls.go +++ b/client/tls.go @@ -5,12 +5,9 @@ package client import ( "crypto" - "crypto/ecdsa" - "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" - "errors" "fmt" "net/http" "os" @@ -47,7 +44,7 @@ type TLSClientOptions struct { // LoadedCAPool specifies a pool of RootCAs to use when validating the server's TLS certificate. // If set, it will be combined with the other loaded certificates (see LoadedCA and CA). - // If neither LoadedCA or CA is set, the provided pool with override the system + // If neither LoadedCA or CA is set, the provided pool will override the system // certificate pool. // The caller must not use the supplied pool after calling TLSClientAuth. LoadedCAPool *x509.CertPool @@ -114,18 +111,11 @@ func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) { block := pem.Block{Type: "CERTIFICATE", Bytes: opts.LoadedCertificate.Raw} certPem := pem.EncodeToMemory(&block) - var keyBytes []byte - switch k := opts.LoadedKey.(type) { - case *rsa.PrivateKey: - keyBytes = x509.MarshalPKCS1PrivateKey(k) - case *ecdsa.PrivateKey: - var err error - keyBytes, err = x509.MarshalECPrivateKey(k) - if err != nil { - return nil, fmt.Errorf("tls client priv key: %v", err) - } - default: - return nil, errors.New("tls client priv key: unsupported key type") + // PKCS#8 covers RSA, ECDSA, Ed25519, X25519 (the key types tls.X509KeyPair + // understands) and pairs with the canonical "PRIVATE KEY" PEM label. + keyBytes, err := x509.MarshalPKCS8PrivateKey(opts.LoadedKey) + if err != nil { + return nil, fmt.Errorf("tls client priv key: %v", err) } block = pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes} @@ -166,7 +156,7 @@ func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) { cfg.RootCAs = opts.LoadedCAPool } - // apply servername overrride + // apply servername override if opts.ServerName != "" { cfg.InsecureSkipVerify = false cfg.ServerName = opts.ServerName