From c2978221d7e95b26980ffb23accee1eb097a930b Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 30 Sep 2025 11:46:58 +0200 Subject: [PATCH] Add PEM encoder helper Signed-off-by: Bryan Frimin --- pkg/crypto/pem/pem.go | 68 ++++++++ pkg/crypto/pem/pem_test.go | 340 +++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 pkg/crypto/pem/pem.go create mode 100644 pkg/crypto/pem/pem_test.go diff --git a/pkg/crypto/pem/pem.go b/pkg/crypto/pem/pem.go new file mode 100644 index 000000000..2e97e397d --- /dev/null +++ b/pkg/crypto/pem/pem.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package pem provides utilities for encoding certificates and keys in PEM format +package pem + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" +) + +func EncodeCertificate(der []byte) []byte { + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + } + return pem.EncodeToMemory(block) +} + +func EncodePrivateKey(key crypto.Signer) ([]byte, error) { + var keyDER []byte + var keyType string + + switch k := key.(type) { + case *ecdsa.PrivateKey: + der, err := x509.MarshalECPrivateKey(k) + if err != nil { + return nil, fmt.Errorf("cannot marshal EC private key: %w", err) + } + keyDER = der + keyType = "EC PRIVATE KEY" + case *rsa.PrivateKey: + keyDER = x509.MarshalPKCS1PrivateKey(k) + keyType = "RSA PRIVATE KEY" + case ed25519.PrivateKey: + der, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + return nil, fmt.Errorf("cannot marshal ED25519 private key: %w", err) + } + keyDER = der + keyType = "PRIVATE KEY" + default: + return nil, fmt.Errorf("unsupported key type: %T", key) + } + + block := &pem.Block{ + Type: keyType, + Bytes: keyDER, + } + + return pem.EncodeToMemory(block), nil +} diff --git a/pkg/crypto/pem/pem_test.go b/pkg/crypto/pem/pem_test.go new file mode 100644 index 000000000..c80a318d6 --- /dev/null +++ b/pkg/crypto/pem/pem_test.go @@ -0,0 +1,340 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package pem_test + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + _ "crypto/sha256" // for crypto.SHA256 + "crypto/x509" + "crypto/x509/pkix" + stdpem "encoding/pem" + "errors" + "math/big" + "testing" + "time" + + "github.com/getprobo/probo/pkg/crypto/pem" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeCertificate(t *testing.T) { + // Generate a test certificate + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "failed to generate private key") + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "test.example.com", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + DNSNames: []string{"test.example.com"}, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + SubjectKeyId: []byte{1, 2, 3, 4, 5}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + require.NoError(t, err, "failed to create certificate") + + // Test encoding + pemBytes := pem.EncodeCertificate(certDER) + + // Verify the result is valid PEM + block, rest := stdpem.Decode(pemBytes) + require.NotNil(t, block, "failed to decode PEM block") + assert.Empty(t, rest, "unexpected remaining bytes") + assert.Equal(t, "CERTIFICATE", block.Type, "incorrect block type") + assert.True(t, bytes.Equal(block.Bytes, certDER), "certificate DER bytes don't match") + + // Verify we can parse the certificate back + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err, "failed to parse certificate from PEM") + assert.Equal(t, "test.example.com", cert.Subject.CommonName, "incorrect common name") +} + +func TestEncodePrivateKey(t *testing.T) { + tests := []struct { + name string + generateKey func() (crypto.Signer, error) + expectedType string + parseFunc func([]byte) (crypto.Signer, error) + }{ + { + name: "ECDSA P256", + generateKey: func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + }, + expectedType: "EC PRIVATE KEY", + parseFunc: func(der []byte) (crypto.Signer, error) { + return x509.ParseECPrivateKey(der) + }, + }, + { + name: "ECDSA P384", + generateKey: func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + }, + expectedType: "EC PRIVATE KEY", + parseFunc: func(der []byte) (crypto.Signer, error) { + return x509.ParseECPrivateKey(der) + }, + }, + { + name: "RSA 2048", + generateKey: func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + }, + expectedType: "RSA PRIVATE KEY", + parseFunc: func(der []byte) (crypto.Signer, error) { + return x509.ParsePKCS1PrivateKey(der) + }, + }, + { + name: "ED25519", + generateKey: func() (crypto.Signer, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + return priv, err + }, + expectedType: "PRIVATE KEY", + parseFunc: func(der []byte) (crypto.Signer, error) { + key, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, err + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, errors.New("key is not a crypto.Signer") + } + return signer, nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate key + key, err := tt.generateKey() + require.NoError(t, err, "failed to generate key") + + // Encode to PEM + pemBytes, err := pem.EncodePrivateKey(key) + require.NoError(t, err, "failed to encode key") + + // Verify the result is valid PEM + block, rest := stdpem.Decode(pemBytes) + require.NotNil(t, block, "failed to decode PEM block") + assert.Empty(t, rest, "unexpected remaining bytes") + assert.Equal(t, tt.expectedType, block.Type, "incorrect block type") + + // Verify we can parse the key back + parsedKey, err := tt.parseFunc(block.Bytes) + require.NoError(t, err, "failed to parse key from PEM") + require.NotNil(t, parsedKey, "parsed key is nil") + + // For ECDSA keys, verify the curves match + if ecKey, ok := key.(*ecdsa.PrivateKey); ok { + parsedECKey, ok := parsedKey.(*ecdsa.PrivateKey) + require.True(t, ok, "parsed key is not ECDSA") + assert.Equal(t, ecKey.Curve.Params().Name, parsedECKey.Curve.Params().Name, "curve mismatch") + } + }) + } +} + +func TestEncodePrivateKeyUnsupportedType(t *testing.T) { + // Test with an unsupported key type + type unsupportedKey struct { + crypto.Signer + } + + key := &unsupportedKey{} + _, err := pem.EncodePrivateKey(key) + require.Error(t, err, "expected error for unsupported key type") + assert.Contains(t, err.Error(), "unsupported key type", "error should mention unsupported key type") +} + +func TestRoundTrip(t *testing.T) { + // Test that we can encode and decode keys without loss + keyTypes := []struct { + name string + gen func() (crypto.Signer, error) + }{ + {"EC256", func() (crypto.Signer, error) { return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) }}, + {"EC384", func() (crypto.Signer, error) { return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) }}, + {"RSA2048", func() (crypto.Signer, error) { return rsa.GenerateKey(rand.Reader, 2048) }}, + {"ED25519", func() (crypto.Signer, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + return priv, err + }}, + } + + for _, kt := range keyTypes { + t.Run(kt.name, func(t *testing.T) { + // Generate original key + originalKey, err := kt.gen() + require.NoError(t, err, "failed to generate key") + + // Encode to PEM + pemBytes, err := pem.EncodePrivateKey(originalKey) + require.NoError(t, err, "failed to encode key") + + // Decode PEM + block, _ := stdpem.Decode(pemBytes) + require.NotNil(t, block, "failed to decode PEM block") + + // Parse key based on type + var parsedKey crypto.Signer + switch block.Type { + case "EC PRIVATE KEY": + parsedKey, err = x509.ParseECPrivateKey(block.Bytes) + case "RSA PRIVATE KEY": + parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err == nil { + parsedKey = key.(crypto.Signer) + } + default: + t.Fatalf("unknown key type: %s", block.Type) + } + + require.NoError(t, err, "failed to parse key") + + // Test that both keys can sign + // Different key types need different signing approaches + testData := []byte("test data") + h := crypto.SHA256.New() + h.Write(testData) + hashed := h.Sum(nil) + + var dataToSign []byte + var hashFunc crypto.Hash + + switch originalKey.(type) { + case *rsa.PrivateKey: + dataToSign = hashed + hashFunc = crypto.SHA256 + case ed25519.PrivateKey: + dataToSign = testData + hashFunc = crypto.Hash(0) // ED25519 requires zero hash + default: + dataToSign = testData + hashFunc = crypto.SHA256 + } + + _, err = originalKey.Sign(rand.Reader, dataToSign, hashFunc) + require.NoError(t, err, "original key failed to sign") + + switch parsedKey.(type) { + case *rsa.PrivateKey: + dataToSign = hashed + hashFunc = crypto.SHA256 + case ed25519.PrivateKey: + dataToSign = testData + hashFunc = crypto.Hash(0) // ED25519 requires zero hash + default: + dataToSign = testData + hashFunc = crypto.SHA256 + } + + _, err = parsedKey.Sign(rand.Reader, dataToSign, hashFunc) + require.NoError(t, err, "parsed key failed to sign") + }) + } +} + +func TestEncodeCertificateEmptyDER(t *testing.T) { + // Test with empty DER bytes + pemBytes := pem.EncodeCertificate([]byte{}) + + block, _ := stdpem.Decode(pemBytes) + require.NotNil(t, block, "should encode empty DER") + assert.Equal(t, "CERTIFICATE", block.Type) + assert.Empty(t, block.Bytes) +} + +func TestEncodeCertificateLargeDER(t *testing.T) { + // Test with large DER bytes + largeDER := make([]byte, 10000) + for i := range largeDER { + largeDER[i] = byte(i % 256) + } + + pemBytes := pem.EncodeCertificate(largeDER) + + block, _ := stdpem.Decode(pemBytes) + require.NotNil(t, block) + assert.Equal(t, "CERTIFICATE", block.Type) + assert.Equal(t, largeDER, block.Bytes) +} + +func BenchmarkEncodeCertificate(b *testing.B) { + // Generate a test certificate DER + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + certDER, _ := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = pem.EncodeCertificate(certDER) + } +} + +func BenchmarkEncodePrivateKey(b *testing.B) { + benchmarks := []struct { + name string + key crypto.Signer + }{ + {"EC256", mustGenerateKey(func() (crypto.Signer, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + })}, + {"RSA2048", mustGenerateKey(func() (crypto.Signer, error) { + return rsa.GenerateKey(rand.Reader, 2048) + })}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := pem.EncodePrivateKey(bm.key) + if err != nil { + b.Fatal(err) + } + } + }) + } +} + +func mustGenerateKey(gen func() (crypto.Signer, error)) crypto.Signer { + key, err := gen() + if err != nil { + panic(err) + } + return key +} \ No newline at end of file