Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/envelope/doc.go
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions internal/envelope/rsa/doc.go
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions internal/envelope/rsa/encryptor.go
Original file line number Diff line number Diff line change
@@ -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
}
189 changes: 189 additions & 0 deletions internal/envelope/rsa/encryptor_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
57 changes: 57 additions & 0 deletions internal/envelope/rsa/keys.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading