diff --git a/internal/envelope/doc.go b/internal/envelope/doc.go new file mode 100644 index 00000000..751b5bbd --- /dev/null +++ b/internal/envelope/doc.go @@ -0,0 +1,12 @@ +// Package envelope provides types and interfaces for envelope encryption. +// +// 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. +// +// Implementations are available in subpackages: +// +// - 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/rsa/encryptor.go b/internal/envelope/rsa/encryptor.go new file mode 100644 index 00000000..7e29066c --- /dev/null +++ b/internal/envelope/rsa/encryptor.go @@ -0,0 +1,128 @@ +package rsa + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "fmt" + + "github.com/jetstack/preflight/internal/envelope" +) + +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, and 32 bytes corresponds to a 256-bit AES key + aesKeySize = 32 + + // 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(keyID string, 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) + } + + if len(keyID) == 0 { + return nil, fmt.Errorf("keyID cannot be empty") + } + + return &Encryptor{ + keyID: keyID, + 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) (*envelope.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) + } + + // 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) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM cipher: %w", err) + } + + encryptedData := &envelope.EncryptedData{ + KeyID: e.keyID, + KeyAlgorithm: keyAlgorithmIdentifier, + EncryptedKey: nil, + EncryptedData: nil, + Nonce: make([]byte, gcm.NonceSize()), + } + + // 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. 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, + 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/rsa/encryptor_test.go b/internal/envelope/rsa/encryptor_test.go new file mode 100644 index 00000000..ede0ea3b --- /dev/null +++ b/internal/envelope/rsa/encryptor_test.go @@ -0,0 +1,189 @@ +package rsa + +import ( + "crypto/rand" + "crypto/rsa" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +const testKeyID = "test-key-id" + +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 + 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 := NewEncryptor(testKeyID, &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 := 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 := 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 := testKey() + + enc, err := NewEncryptor(testKeyID, &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 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) + + // Verify encrypted data differs from input + require.NotEqual(t, data, result.EncryptedData) + }) + } +} + +func TestEncrypt_EmptyData(t *testing.T) { + key := testKey() + + enc, err := NewEncryptor(testKeyID, &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 := testKey() + + enc, err := NewEncryptor(testKeyID, &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) + + // 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) + + // 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 := testKey() + + enc, err := NewEncryptor(testKeyID, &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 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/rsa/keys.go b/internal/envelope/rsa/keys.go new file mode 100644 index 00000000..3cf5e96b --- /dev/null +++ b/internal/envelope/rsa/keys.go @@ -0,0 +1,57 @@ +package rsa + +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/rsa/keys_test.go b/internal/envelope/rsa/keys_test.go new file mode 100644 index 00000000..cccdb475 --- /dev/null +++ b/internal/envelope/rsa/keys_test.go @@ -0,0 +1,144 @@ +package rsa_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + internalrsa "github.com/jetstack/preflight/internal/envelope/rsa" +) + +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 := internalrsa.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 := internalrsa.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 := internalrsa.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 := internalrsa.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 := internalrsa.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 := 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 := internalrsa.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 := internalrsa.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..83fe5ea2 --- /dev/null +++ b/internal/envelope/types.go @@ -0,0 +1,31 @@ +package envelope + +// EncryptedData contains the result of envelope encryption. +// 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 { + // 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 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) +}