From 8f98e3a2c8a8e8c692f1325ae18a390ada134630 Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Mon, 19 Jan 2026 13:33:16 +0000 Subject: [PATCH 1/3] add initial (unused) RSA envelope encryption This commit adds support for envelope encryption using an RSA key. Note: This encryption is specifically for disco-agent and not for venafi-kubernetes-agent although the code here is not used in either agent yet. Signed-off-by: Ashley Davis --- internal/envelope/encryptor.go | 95 ++++++++++++++++++ internal/envelope/encryptor_test.go | 149 ++++++++++++++++++++++++++++ internal/envelope/keys.go | 57 +++++++++++ internal/envelope/keys_test.go | 144 +++++++++++++++++++++++++++ internal/envelope/types.go | 26 +++++ 5 files changed, 471 insertions(+) create mode 100644 internal/envelope/encryptor.go create mode 100644 internal/envelope/encryptor_test.go create mode 100644 internal/envelope/keys.go create mode 100644 internal/envelope/keys_test.go create mode 100644 internal/envelope/types.go diff --git a/internal/envelope/encryptor.go b/internal/envelope/encryptor.go new file mode 100644 index 00000000..d8b4ea48 --- /dev/null +++ b/internal/envelope/encryptor.go @@ -0,0 +1,95 @@ +package envelope + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "fmt" +) + +const ( + // aesKeySize is the size of the AES-256 key in bytes; aes.NewCipher generates cipher.Block based + // on the size of key passed in + aesKeySize = 32 + + // nonceSize is the size of the AES-GCM nonce in bytes. NB: Nonce sizes can be security critical. + // Reusing a nonce with the same key breaks AES-256 GCM completely. + // Due to the birthday paradox, the risk of reusing (randomly-generated) nonces can be quite high. + // This package is assumed to be used in contexts where a new key is generated for each encryption operation, + // so the nonce size doesn't matter. + nonceSize = 12 + + // minRSAKeySize is the minimum RSA key size in bits; we'd expect that keys will be larger but 2048 is a sane floor + minRSAKeySize = 2048 +) + +// NewEncryptor creates a new Encryptor with the provided RSA public key. +// The RSA key must be at least minRSAKeySize bits +func NewEncryptor(publicKey *rsa.PublicKey) (*Encryptor, error) { + if publicKey == nil { + return nil, fmt.Errorf("RSA public key cannot be nil") + } + + // Validate key size + keySize := publicKey.N.BitLen() + if keySize < minRSAKeySize { + return nil, fmt.Errorf("RSA key size must be at least %d bits, got %d bits", minRSAKeySize, keySize) + } + + return &Encryptor{ + rsaPublicKey: publicKey, + }, nil +} + +// Encrypt performs envelope encryption on the provided data. +// It generates a random AES-256 key, encrypts the data with AES-256-GCM, +// then encrypts the AES key with RSA-OAEP-SHA256. +func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { + if len(data) == 0 { + return nil, fmt.Errorf("data to encrypt cannot be empty") + } + + aesKey := make([]byte, aesKeySize) + if _, err := rand.Read(aesKey); err != nil { + return nil, fmt.Errorf("failed to generate AES key: %w", err) + } + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM cipher: %w", err) + } + + encryptedData := &EncryptedData{ + EncryptedKey: nil, + EncryptedData: nil, + Nonce: make([]byte, nonceSize), + } + + // Generate random nonce + if _, err := rand.Read(encryptedData.Nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + + encryptedData.EncryptedData = gcm.Seal(nil, encryptedData.Nonce, data, nil) + + // Encrypt AES key with RSA-OAEP-SHA256 + encryptedData.EncryptedKey, err = rsa.EncryptOAEP( + sha256.New(), + rand.Reader, + e.rsaPublicKey, + aesKey, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to encrypt AES key with RSA: %w", err) + } + + return encryptedData, nil +} diff --git a/internal/envelope/encryptor_test.go b/internal/envelope/encryptor_test.go new file mode 100644 index 00000000..a73e4b41 --- /dev/null +++ b/internal/envelope/encryptor_test.go @@ -0,0 +1,149 @@ +package envelope_test + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jetstack/preflight/internal/envelope" +) + +func TestNewEncryptor_ValidKeys(t *testing.T) { + tests := []struct { + name string + keySize int + }{ + {"2048 bits", 2048}, + {"3072 bits", 3072}, + {"4096 bits", 4096}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, tt.keySize) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.NoError(t, err) + require.NotNil(t, enc) + }) + } +} + +func TestNewEncryptor_RejectsSmallKeys(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "must be at least 2048 bits") +} + +func TestNewEncryptor_NilKey(t *testing.T) { + enc, err := envelope.NewEncryptor(nil) + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "cannot be nil") +} + +func TestEncrypt_VariousDataSizes(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.NoError(t, err) + + tests := []struct { + name string + dataSize int + }{ + {"small (10 bytes)", 10}, + {"medium (1 KB)", 1024}, + {"large (1 MB)", 1024 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := make([]byte, tt.dataSize) + _, err := rand.Read(data) + require.NoError(t, err) + + result, err := enc.Encrypt(data) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify all fields are populated + require.NotEmpty(t, result.EncryptedKey) + require.NotEmpty(t, result.EncryptedData) + require.NotEmpty(t, result.Nonce) + + // Verify nonce is correct size (12 bytes for GCM) + require.Len(t, result.Nonce, 12) + + // Verify encrypted data differs from input + require.NotEqual(t, data, result.EncryptedData) + }) + } +} + +func TestEncrypt_EmptyData(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.NoError(t, err) + + result, err := enc.Encrypt([]byte{}) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "cannot be empty") +} + +func TestEncrypt_NonDeterministic(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.NoError(t, err) + + data := []byte("test data for encryption") + + // Encrypt the same data twice + result1, err := enc.Encrypt(data) + require.NoError(t, err) + + result2, err := enc.Encrypt(data) + require.NoError(t, err) + + // Nonces should be different (random) + require.NotEqual(t, result1.Nonce, result2.Nonce) + + // Encrypted data should be different due to different nonces + require.NotEqual(t, result1.EncryptedData, result2.EncryptedData) + + // Encrypted keys should be different due to RSA-OAEP randomness + require.NotEqual(t, result1.EncryptedKey, result2.EncryptedKey) +} + +func TestEncrypt_AllFieldsPopulated(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + enc, err := envelope.NewEncryptor(&key.PublicKey) + require.NoError(t, err) + + data := []byte("test data") + result, err := enc.Encrypt(data) + require.NoError(t, err) + + require.NotNil(t, result) + require.NotEmpty(t, result.EncryptedKey, "EncryptedKey should be populated") + require.NotEmpty(t, result.EncryptedData, "EncryptedData should be populated") + require.NotEmpty(t, result.Nonce, "Nonce should be populated") + + // Verify encrypted key size is appropriate for RSA 2048 + require.Equal(t, 256, len(result.EncryptedKey), "EncryptedKey should be 256 bytes for RSA 2048") +} diff --git a/internal/envelope/keys.go b/internal/envelope/keys.go new file mode 100644 index 00000000..404965a8 --- /dev/null +++ b/internal/envelope/keys.go @@ -0,0 +1,57 @@ +package envelope + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" +) + +// This file contains helpers for loading keys. In practice we'll retrieve keys in some format from a DisCo endpoint + +// LoadPublicKeyFromPEM parses an RSA public key from PEM-encoded bytes. +// The PEM block should be of type "PUBLIC KEY" or "RSA PUBLIC KEY". +func LoadPublicKeyFromPEM(pemBytes []byte) (*rsa.PublicKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + // Try parsing as PKIX public key first (most common format) + if block.Type == "PUBLIC KEY" { + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKIX public key: %w", err) + } + + rsaKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("key is not an RSA public key, got %T", pubKey) + } + + return rsaKey, nil + } + + // Try parsing as PKCS1 RSA public key + if block.Type == "RSA PUBLIC KEY" { + rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS1 RSA public key: %w", err) + } + + return rsaKey, nil + } + + return nil, fmt.Errorf("unsupported PEM block type: %s (expected PUBLIC KEY or RSA PUBLIC KEY)", block.Type) +} + +// LoadPublicKeyFromPEMFile reads and parses an RSA public key from a PEM file. +func LoadPublicKeyFromPEMFile(path string) (*rsa.PublicKey, error) { + pemBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read PEM file: %w", err) + } + + return LoadPublicKeyFromPEM(pemBytes) +} diff --git a/internal/envelope/keys_test.go b/internal/envelope/keys_test.go new file mode 100644 index 00000000..d31bffe3 --- /dev/null +++ b/internal/envelope/keys_test.go @@ -0,0 +1,144 @@ +package envelope_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jetstack/preflight/internal/envelope" +) + +func generateTestKeyPEM(t *testing.T, keySize int, pemType string) []byte { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, keySize) + require.NoError(t, err) + + var pemBytes []byte + if pemType == "PUBLIC KEY" { + // PKIX format + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + pemBytes = pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + }) + } else { + // PKCS1 format + publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) + + pemBytes = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: publicKeyBytes, + }) + } + + require.NotNil(t, pemBytes) + return pemBytes +} + +func TestLoadPublicKeyFromPEM_PKIX(t *testing.T) { + pemBytes := generateTestKeyPEM(t, 2048, "PUBLIC KEY") + + key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + require.NoError(t, err) + require.NotNil(t, key) + require.Equal(t, 2048, key.N.BitLen()) +} + +func TestLoadPublicKeyFromPEM_PKCS1(t *testing.T) { + pemBytes := generateTestKeyPEM(t, 2048, "RSA PUBLIC KEY") + + key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + require.NoError(t, err) + require.NotNil(t, key) + require.Equal(t, 2048, key.N.BitLen()) +} + +func TestLoadPublicKeyFromPEM_InvalidPEM(t *testing.T) { + invalidPEM := []byte("this is not a valid PEM") + + key, err := envelope.LoadPublicKeyFromPEM(invalidPEM) + require.Error(t, err) + require.Nil(t, key) + require.Contains(t, err.Error(), "failed to decode PEM block") +} + +func TestLoadPublicKeyFromPEM_WrongPEMType(t *testing.T) { + // Create a PEM block with wrong type + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + require.Error(t, err) + require.Nil(t, key) + require.Contains(t, err.Error(), "unsupported PEM block type") +} + +func TestLoadPublicKeyFromPEM_NonRSAKey(t *testing.T) { + // Generate a real ECDSA key and try to load it as RSA + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Marshal as PKIX public key + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&ecdsaKey.PublicKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + }) + + key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + require.Error(t, err) + require.Nil(t, key) + require.Contains(t, err.Error(), "not an RSA public key") +} + +func TestLoadPublicKeyFromPEMFile_ValidFile(t *testing.T) { + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "test_key.pem") + + pemBytes := generateTestKeyPEM(t, 2048, "PUBLIC KEY") + err := os.WriteFile(keyPath, pemBytes, 0600) + require.NoError(t, err) + + key, err := envelope.LoadPublicKeyFromPEMFile(keyPath) + require.NoError(t, err) + require.NotNil(t, key) + require.Equal(t, 2048, key.N.BitLen()) +} + +func TestLoadPublicKeyFromPEMFile_MissingFile(t *testing.T) { + key, err := envelope.LoadPublicKeyFromPEMFile("/nonexistent/path/key.pem") + require.Error(t, err) + require.Nil(t, key) + require.Contains(t, err.Error(), "failed to read PEM file") +} + +func TestLoadPublicKeyFromPEMFile_InvalidContent(t *testing.T) { + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "invalid_key.pem") + + err := os.WriteFile(keyPath, []byte("not a valid PEM"), 0600) + require.NoError(t, err) + + key, err := envelope.LoadPublicKeyFromPEMFile(keyPath) + require.Error(t, err) + require.Nil(t, key) +} diff --git a/internal/envelope/types.go b/internal/envelope/types.go new file mode 100644 index 00000000..55c6b4a3 --- /dev/null +++ b/internal/envelope/types.go @@ -0,0 +1,26 @@ +package envelope + +import "crypto/rsa" + +// Encryptor provides envelope encryption using RSA for key wrapping +// and AES-256-GCM for data encryption. +type Encryptor struct { + rsaPublicKey *rsa.PublicKey +} + +// EncryptedData contains the result of envelope encryption. +// It includes the encrypted data, the encrypted AES key which was used for encrypting the original data, +// and the nonce needed for AES-GCM decryption. +type EncryptedData struct { + // EncryptedKey is the AES-256 key encrypted with RSA-OAEP-SHA256. + // This is ciphertext and should only be decryptable by the holder of the corresponding RSA private key. + EncryptedKey []byte `json:"encrypted_key"` + + // EncryptedData is the actual data encrypted using AES-256-GCM. + // This is ciphertext and requires the AES key (after RSA decryption) and nonce for decryption. + EncryptedData []byte `json:"encrypted_data"` + + // Nonce is the 12-byte nonce used for AES-GCM encryption. + // This is intentionally plaintext. + Nonce []byte `json:"nonce"` +} From 5baafe8a516ae1613a639043c42a32177d325b58 Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Wed, 21 Jan 2026 14:18:33 +0000 Subject: [PATCH 2/3] envelope encryption: address review comments Mostly this focuses on documentation to make the purpose of the chosen functions clear Also moves Encryptor to a better home Signed-off-by: Ashley Davis --- internal/envelope/doc.go | 12 +++++++++++ internal/envelope/encryptor.go | 39 ++++++++++++++++++++++++---------- internal/envelope/types.go | 8 ------- 3 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 internal/envelope/doc.go diff --git a/internal/envelope/doc.go b/internal/envelope/doc.go new file mode 100644 index 00000000..38146de0 --- /dev/null +++ b/internal/envelope/doc.go @@ -0,0 +1,12 @@ +// Package envelope implements RSA envelope encryption, intended to be used to secure sensitive Secret data from a cluster +// being being sent to an external system. This protects against threats such as TLS interception middleware. +// +// Envelope encryption uses a combination of asymmetric encryption and symmetric encryption; since asymmetric encryption is +// slow and has size limits, we generate a random symmetric key for each encryption operation, use that to encrypt the data, +// then encrypt the symmetric key with the provided RSA public key. The recipient can then use their RSA private key to +// decrypt the symmetric key, then use that to decrypt the data. +// +// This implementation uses RSA-OAEP with SHA-256 for asymmetric encryption, and AES-256-GCM for symmetric encryption. +// +// In some documentation, the asymmetric key is called the "key encryption key" (KEK) and the symmetric key is called the "data encryption key" (DEK). +package envelope diff --git a/internal/envelope/encryptor.go b/internal/envelope/encryptor.go index d8b4ea48..6d021a59 100644 --- a/internal/envelope/encryptor.go +++ b/internal/envelope/encryptor.go @@ -11,20 +11,20 @@ import ( const ( // aesKeySize is the size of the AES-256 key in bytes; aes.NewCipher generates cipher.Block based - // on the size of key passed in + // on the size of key passed in, and 32 bytes corresponds to a 256-bit AES key aesKeySize = 32 - // nonceSize is the size of the AES-GCM nonce in bytes. NB: Nonce sizes can be security critical. - // Reusing a nonce with the same key breaks AES-256 GCM completely. - // Due to the birthday paradox, the risk of reusing (randomly-generated) nonces can be quite high. - // This package is assumed to be used in contexts where a new key is generated for each encryption operation, - // so the nonce size doesn't matter. - nonceSize = 12 - // minRSAKeySize is the minimum RSA key size in bits; we'd expect that keys will be larger but 2048 is a sane floor + // to enforce to ensure that a weak key can't accidentally be used minRSAKeySize = 2048 ) +// Encryptor provides envelope encryption using RSA for key wrapping +// and AES-256-GCM for data encryption. +type Encryptor struct { + rsaPublicKey *rsa.PublicKey +} + // NewEncryptor creates a new Encryptor with the provided RSA public key. // The RSA key must be at least minRSAKeySize bits func NewEncryptor(publicKey *rsa.PublicKey) (*Encryptor, error) { @@ -56,6 +56,14 @@ func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { return nil, fmt.Errorf("failed to generate AES key: %w", err) } + // zero the key from memory before the function returns + // TODO: in go1.26+, consider using secret.Do in this function + defer func() { + for i := range aesKey { + aesKey[i] = 0 + } + }() + block, err := aes.NewCipher(aesKey) if err != nil { return nil, fmt.Errorf("failed to create AES cipher: %w", err) @@ -69,17 +77,26 @@ func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { encryptedData := &EncryptedData{ EncryptedKey: nil, EncryptedData: nil, - Nonce: make([]byte, nonceSize), + Nonce: make([]byte, gcm.NonceSize()), } - // Generate random nonce + // Generate a random nonce for AES-GCM. + // Security: Nonces must never be re-used for a given key. Since we generate a new AES key for each encryption, + // the risk of nonce reuse is not a concern here. if _, err := rand.Read(encryptedData.Nonce); err != nil { return nil, fmt.Errorf("failed to generate nonce: %w", err) } + // Seal encrypts and authenticates the data. This could include additional authenticated data, + // but we don't make use of that here. + // First nil: allocate new slice for output. + // Last nil: no additional authenticated data (AAD) needed. + encryptedData.EncryptedData = gcm.Seal(nil, encryptedData.Nonce, data, nil) - // Encrypt AES key with RSA-OAEP-SHA256 + // Encrypt AES key with RSA-OAEP-SHA256. The nil parameter means no additional + // context data is mixed into the hash; this could be used to disambiguate different uses of the same key, + // but we only have one use for the key here. encryptedData.EncryptedKey, err = rsa.EncryptOAEP( sha256.New(), rand.Reader, diff --git a/internal/envelope/types.go b/internal/envelope/types.go index 55c6b4a3..dc8c8383 100644 --- a/internal/envelope/types.go +++ b/internal/envelope/types.go @@ -1,13 +1,5 @@ package envelope -import "crypto/rsa" - -// Encryptor provides envelope encryption using RSA for key wrapping -// and AES-256-GCM for data encryption. -type Encryptor struct { - rsaPublicKey *rsa.PublicKey -} - // EncryptedData contains the result of envelope encryption. // It includes the encrypted data, the encrypted AES key which was used for encrypting the original data, // and the nonce needed for AES-GCM decryption. From aa5dbacc80947aeaf58123f0e15249e29d5553da Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Wed, 21 Jan 2026 15:42:13 +0000 Subject: [PATCH 3/3] add higher level data types, move RSA to subpackage Signed-off-by: Ashley Davis --- internal/envelope/doc.go | 16 ++-- internal/envelope/rsa/doc.go | 3 + internal/envelope/{ => rsa}/encryptor.go | 24 +++++- internal/envelope/{ => rsa}/encryptor_test.go | 74 ++++++++++++++----- internal/envelope/{ => rsa}/keys.go | 2 +- internal/envelope/{ => rsa}/keys_test.go | 20 ++--- internal/envelope/types.go | 25 +++++-- 7 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 internal/envelope/rsa/doc.go rename internal/envelope/{ => rsa}/encryptor.go (81%) rename internal/envelope/{ => rsa}/encryptor_test.go (62%) rename internal/envelope/{ => rsa}/keys.go (98%) rename internal/envelope/{ => rsa}/keys_test.go (85%) diff --git a/internal/envelope/doc.go b/internal/envelope/doc.go index 38146de0..751b5bbd 100644 --- a/internal/envelope/doc.go +++ b/internal/envelope/doc.go @@ -1,12 +1,12 @@ -// Package envelope implements RSA envelope encryption, intended to be used to secure sensitive Secret data from a cluster -// being being sent to an external system. This protects against threats such as TLS interception middleware. +// Package envelope provides types and interfaces for envelope encryption. // -// Envelope encryption uses a combination of asymmetric encryption and symmetric encryption; since asymmetric encryption is -// slow and has size limits, we generate a random symmetric key for each encryption operation, use that to encrypt the data, -// then encrypt the symmetric key with the provided RSA public key. The recipient can then use their RSA private key to -// decrypt the symmetric key, then use that to decrypt the data. +// Envelope encryption combines asymmetric and symmetric cryptography to +// efficiently encrypt data. The EncryptedData type holds the result, and +// the Encryptor interface defines the encryption operation. // -// This implementation uses RSA-OAEP with SHA-256 for asymmetric encryption, and AES-256-GCM for symmetric encryption. +// Implementations are available in subpackages: // -// In some documentation, the asymmetric key is called the "key encryption key" (KEK) and the symmetric key is called the "data encryption key" (DEK). +// - internal/envelope/rsa: RSA-OAEP + AES-256-GCM +// +// See subpackage documentation for usage examples. package envelope diff --git a/internal/envelope/rsa/doc.go b/internal/envelope/rsa/doc.go new file mode 100644 index 00000000..9817f844 --- /dev/null +++ b/internal/envelope/rsa/doc.go @@ -0,0 +1,3 @@ +// Package rsa implements RSA envelope encryption, conforming to the interface in the envelope package. +// It uses RSA-OAEP with SHA-256 for key encryption, and AES-256-GCM for data encryption. +package rsa diff --git a/internal/envelope/encryptor.go b/internal/envelope/rsa/encryptor.go similarity index 81% rename from internal/envelope/encryptor.go rename to internal/envelope/rsa/encryptor.go index 6d021a59..7e29066c 100644 --- a/internal/envelope/encryptor.go +++ b/internal/envelope/rsa/encryptor.go @@ -1,4 +1,4 @@ -package envelope +package rsa import ( "crypto/aes" @@ -7,6 +7,8 @@ import ( "crypto/rsa" "crypto/sha256" "fmt" + + "github.com/jetstack/preflight/internal/envelope" ) const ( @@ -17,17 +19,24 @@ const ( // minRSAKeySize is the minimum RSA key size in bits; we'd expect that keys will be larger but 2048 is a sane floor // to enforce to ensure that a weak key can't accidentally be used minRSAKeySize = 2048 + + // keyAlgorithmIdentifier is set in EncryptedData to identify the key wrapping algorithm used in this package + keyAlgorithmIdentifier = "RSA-OAEP-SHA256" ) +// Compile-time check that Encryptor implements envelope.Encryptor +var _ envelope.Encryptor = (*Encryptor)(nil) + // Encryptor provides envelope encryption using RSA for key wrapping // and AES-256-GCM for data encryption. type Encryptor struct { + keyID string rsaPublicKey *rsa.PublicKey } // NewEncryptor creates a new Encryptor with the provided RSA public key. // The RSA key must be at least minRSAKeySize bits -func NewEncryptor(publicKey *rsa.PublicKey) (*Encryptor, error) { +func NewEncryptor(keyID string, publicKey *rsa.PublicKey) (*Encryptor, error) { if publicKey == nil { return nil, fmt.Errorf("RSA public key cannot be nil") } @@ -38,7 +47,12 @@ func NewEncryptor(publicKey *rsa.PublicKey) (*Encryptor, error) { return nil, fmt.Errorf("RSA key size must be at least %d bits, got %d bits", minRSAKeySize, keySize) } + if len(keyID) == 0 { + return nil, fmt.Errorf("keyID cannot be empty") + } + return &Encryptor{ + keyID: keyID, rsaPublicKey: publicKey, }, nil } @@ -46,7 +60,7 @@ func NewEncryptor(publicKey *rsa.PublicKey) (*Encryptor, error) { // Encrypt performs envelope encryption on the provided data. // It generates a random AES-256 key, encrypts the data with AES-256-GCM, // then encrypts the AES key with RSA-OAEP-SHA256. -func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { +func (e *Encryptor) Encrypt(data []byte) (*envelope.EncryptedData, error) { if len(data) == 0 { return nil, fmt.Errorf("data to encrypt cannot be empty") } @@ -74,7 +88,9 @@ func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { return nil, fmt.Errorf("failed to create GCM cipher: %w", err) } - encryptedData := &EncryptedData{ + encryptedData := &envelope.EncryptedData{ + KeyID: e.keyID, + KeyAlgorithm: keyAlgorithmIdentifier, EncryptedKey: nil, EncryptedData: nil, Nonce: make([]byte, gcm.NonceSize()), diff --git a/internal/envelope/encryptor_test.go b/internal/envelope/rsa/encryptor_test.go similarity index 62% rename from internal/envelope/encryptor_test.go rename to internal/envelope/rsa/encryptor_test.go index a73e4b41..ede0ea3b 100644 --- a/internal/envelope/encryptor_test.go +++ b/internal/envelope/rsa/encryptor_test.go @@ -1,15 +1,36 @@ -package envelope_test +package rsa import ( "crypto/rand" "crypto/rsa" + "sync" "testing" "github.com/stretchr/testify/require" +) + +const testKeyID = "test-key-id" - "github.com/jetstack/preflight/internal/envelope" +var ( + testKeyOnce sync.Once + internalTestKey *rsa.PrivateKey ) +// testKey generates and returns a singleton RSA private key for testing purposes, +// to avoid needing to generate a new key for each test. +func testKey() *rsa.PrivateKey { + testKeyOnce.Do(func() { + key, err := rsa.GenerateKey(rand.Reader, minRSAKeySize) + if err != nil { + panic("failed to generate test RSA key: " + err.Error()) + } + + internalTestKey = key + }) + + return internalTestKey +} + func TestNewEncryptor_ValidKeys(t *testing.T) { tests := []struct { name string @@ -25,7 +46,7 @@ func TestNewEncryptor_ValidKeys(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, tt.keySize) require.NoError(t, err) - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.NoError(t, err) require.NotNil(t, enc) }) @@ -36,24 +57,32 @@ func TestNewEncryptor_RejectsSmallKeys(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.Error(t, err) require.Nil(t, enc) require.Contains(t, err.Error(), "must be at least 2048 bits") } func TestNewEncryptor_NilKey(t *testing.T) { - enc, err := envelope.NewEncryptor(nil) + enc, err := NewEncryptor(testKeyID, nil) require.Error(t, err) require.Nil(t, enc) require.Contains(t, err.Error(), "cannot be nil") } +func TestNewEncryptor_EmptyKeyID(t *testing.T) { + key := testKey() + + enc, err := NewEncryptor("", &key.PublicKey) + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "keyID cannot be empty") +} + func TestEncrypt_VariousDataSizes(t *testing.T) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + key := testKey() - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.NoError(t, err) tests := []struct { @@ -80,6 +109,10 @@ func TestEncrypt_VariousDataSizes(t *testing.T) { require.NotEmpty(t, result.EncryptedData) require.NotEmpty(t, result.Nonce) + // Verify KeyID and KeyAlgorithm are set correctly + require.Equal(t, testKeyID, result.KeyID) + require.Equal(t, keyAlgorithmIdentifier, result.KeyAlgorithm) + // Verify nonce is correct size (12 bytes for GCM) require.Len(t, result.Nonce, 12) @@ -90,10 +123,9 @@ func TestEncrypt_VariousDataSizes(t *testing.T) { } func TestEncrypt_EmptyData(t *testing.T) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + key := testKey() - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.NoError(t, err) result, err := enc.Encrypt([]byte{}) @@ -103,10 +135,9 @@ func TestEncrypt_EmptyData(t *testing.T) { } func TestEncrypt_NonDeterministic(t *testing.T) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + key := testKey() - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.NoError(t, err) data := []byte("test data for encryption") @@ -118,6 +149,12 @@ func TestEncrypt_NonDeterministic(t *testing.T) { result2, err := enc.Encrypt(data) require.NoError(t, err) + // Verify KeyID and KeyAlgorithm are set correctly in both results + require.Equal(t, testKeyID, result1.KeyID) + require.Equal(t, keyAlgorithmIdentifier, result1.KeyAlgorithm) + require.Equal(t, testKeyID, result2.KeyID) + require.Equal(t, keyAlgorithmIdentifier, result2.KeyAlgorithm) + // Nonces should be different (random) require.NotEqual(t, result1.Nonce, result2.Nonce) @@ -129,10 +166,9 @@ func TestEncrypt_NonDeterministic(t *testing.T) { } func TestEncrypt_AllFieldsPopulated(t *testing.T) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + key := testKey() - enc, err := envelope.NewEncryptor(&key.PublicKey) + enc, err := NewEncryptor(testKeyID, &key.PublicKey) require.NoError(t, err) data := []byte("test data") @@ -144,6 +180,10 @@ func TestEncrypt_AllFieldsPopulated(t *testing.T) { require.NotEmpty(t, result.EncryptedData, "EncryptedData should be populated") require.NotEmpty(t, result.Nonce, "Nonce should be populated") + // Verify KeyID and KeyAlgorithm are set correctly + require.Equal(t, testKeyID, result.KeyID, "KeyID should match the encryptor's keyID") + require.Equal(t, keyAlgorithmIdentifier, result.KeyAlgorithm, "KeyAlgorithm should be the value of keyAlgorithmIdentifier") + // Verify encrypted key size is appropriate for RSA 2048 require.Equal(t, 256, len(result.EncryptedKey), "EncryptedKey should be 256 bytes for RSA 2048") } diff --git a/internal/envelope/keys.go b/internal/envelope/rsa/keys.go similarity index 98% rename from internal/envelope/keys.go rename to internal/envelope/rsa/keys.go index 404965a8..3cf5e96b 100644 --- a/internal/envelope/keys.go +++ b/internal/envelope/rsa/keys.go @@ -1,4 +1,4 @@ -package envelope +package rsa import ( "crypto/rsa" diff --git a/internal/envelope/keys_test.go b/internal/envelope/rsa/keys_test.go similarity index 85% rename from internal/envelope/keys_test.go rename to internal/envelope/rsa/keys_test.go index d31bffe3..cccdb475 100644 --- a/internal/envelope/keys_test.go +++ b/internal/envelope/rsa/keys_test.go @@ -1,4 +1,4 @@ -package envelope_test +package rsa_test import ( "crypto/ecdsa" @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/jetstack/preflight/internal/envelope" + internalrsa "github.com/jetstack/preflight/internal/envelope/rsa" ) func generateTestKeyPEM(t *testing.T, keySize int, pemType string) []byte { @@ -49,7 +49,7 @@ func generateTestKeyPEM(t *testing.T, keySize int, pemType string) []byte { func TestLoadPublicKeyFromPEM_PKIX(t *testing.T) { pemBytes := generateTestKeyPEM(t, 2048, "PUBLIC KEY") - key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + key, err := internalrsa.LoadPublicKeyFromPEM(pemBytes) require.NoError(t, err) require.NotNil(t, key) require.Equal(t, 2048, key.N.BitLen()) @@ -58,7 +58,7 @@ func TestLoadPublicKeyFromPEM_PKIX(t *testing.T) { func TestLoadPublicKeyFromPEM_PKCS1(t *testing.T) { pemBytes := generateTestKeyPEM(t, 2048, "RSA PUBLIC KEY") - key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + key, err := internalrsa.LoadPublicKeyFromPEM(pemBytes) require.NoError(t, err) require.NotNil(t, key) require.Equal(t, 2048, key.N.BitLen()) @@ -67,7 +67,7 @@ func TestLoadPublicKeyFromPEM_PKCS1(t *testing.T) { func TestLoadPublicKeyFromPEM_InvalidPEM(t *testing.T) { invalidPEM := []byte("this is not a valid PEM") - key, err := envelope.LoadPublicKeyFromPEM(invalidPEM) + key, err := internalrsa.LoadPublicKeyFromPEM(invalidPEM) require.Error(t, err) require.Nil(t, key) require.Contains(t, err.Error(), "failed to decode PEM block") @@ -84,7 +84,7 @@ func TestLoadPublicKeyFromPEM_WrongPEMType(t *testing.T) { Bytes: privateKeyBytes, }) - key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + key, err := internalrsa.LoadPublicKeyFromPEM(pemBytes) require.Error(t, err) require.Nil(t, key) require.Contains(t, err.Error(), "unsupported PEM block type") @@ -104,7 +104,7 @@ func TestLoadPublicKeyFromPEM_NonRSAKey(t *testing.T) { Bytes: publicKeyBytes, }) - key, err := envelope.LoadPublicKeyFromPEM(pemBytes) + key, err := internalrsa.LoadPublicKeyFromPEM(pemBytes) require.Error(t, err) require.Nil(t, key) require.Contains(t, err.Error(), "not an RSA public key") @@ -118,14 +118,14 @@ func TestLoadPublicKeyFromPEMFile_ValidFile(t *testing.T) { err := os.WriteFile(keyPath, pemBytes, 0600) require.NoError(t, err) - key, err := envelope.LoadPublicKeyFromPEMFile(keyPath) + key, err := internalrsa.LoadPublicKeyFromPEMFile(keyPath) require.NoError(t, err) require.NotNil(t, key) require.Equal(t, 2048, key.N.BitLen()) } func TestLoadPublicKeyFromPEMFile_MissingFile(t *testing.T) { - key, err := envelope.LoadPublicKeyFromPEMFile("/nonexistent/path/key.pem") + key, err := internalrsa.LoadPublicKeyFromPEMFile("/nonexistent/path/key.pem") require.Error(t, err) require.Nil(t, key) require.Contains(t, err.Error(), "failed to read PEM file") @@ -138,7 +138,7 @@ func TestLoadPublicKeyFromPEMFile_InvalidContent(t *testing.T) { err := os.WriteFile(keyPath, []byte("not a valid PEM"), 0600) require.NoError(t, err) - key, err := envelope.LoadPublicKeyFromPEMFile(keyPath) + key, err := internalrsa.LoadPublicKeyFromPEMFile(keyPath) require.Error(t, err) require.Nil(t, key) } diff --git a/internal/envelope/types.go b/internal/envelope/types.go index dc8c8383..83fe5ea2 100644 --- a/internal/envelope/types.go +++ b/internal/envelope/types.go @@ -1,18 +1,31 @@ package envelope // EncryptedData contains the result of envelope encryption. -// It includes the encrypted data, the encrypted AES key which was used for encrypting the original data, -// and the nonce needed for AES-GCM decryption. +// It includes the encrypted data, the encrypted symmetric key which was used for encrypting the original data, +// and the nonce needed for the symmetric decryption. type EncryptedData struct { - // EncryptedKey is the AES-256 key encrypted with RSA-OAEP-SHA256. - // This is ciphertext and should only be decryptable by the holder of the corresponding RSA private key. + // KeyID is the identifier of the asymmetric key used to encrypt the AES key. + KeyID string `json:"key_id"` + + // KeyAlgorithm is the algorithm of the asymmetric key used to encrypt the AES key. + KeyAlgorithm string `json:"key_algorithm"` + + // EncryptedKey is an encrypted AES-256-GCM symmetric key, used to encrypt EncryptedData. + // This is ciphertext and should only be decryptable by the holder of the private key. EncryptedKey []byte `json:"encrypted_key"` - // EncryptedData is the actual data encrypted using AES-256-GCM. - // This is ciphertext and requires the AES key (after RSA decryption) and nonce for decryption. + // EncryptedData is the actual data encrypted using the AES-256-GCM in EncryptedKey. + // This is ciphertext and requires the decrypted AES key and nonce for decryption. EncryptedData []byte `json:"encrypted_data"` // Nonce is the 12-byte nonce used for AES-GCM encryption. // This is intentionally plaintext. Nonce []byte `json:"nonce"` } + +// Encryptor performs envelope encryption on arbitrary data. +type Encryptor interface { + // Encrypt encrypts data using envelope encryption, returning the resulting data along + // with identifiers of the asymmetric key used to encrypt the AES key. + Encrypt(data []byte) (*EncryptedData, error) +}