-
Notifications
You must be signed in to change notification settings - Fork 24
Add initial (unused) RSA envelope encryption #756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
SgtCoDFish marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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, | ||
SgtCoDFish marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to encrypt AES key with RSA: %w", err) | ||
| } | ||
|
|
||
SgtCoDFish marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return encryptedData, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.