From 1bcbc93103114bf0410d61dc93ee28e63a994fc6 Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Mon, 19 Jan 2026 13:33:58 +0000 Subject: [PATCH] wip: hpke Signed-off-by: Ashley Davis --- go.mod | 1 + go.sum | 2 + internal/hpke/decryptor.go | 130 ++++++++++++++++++++++++++++ internal/hpke/decryptor_test.go | 144 ++++++++++++++++++++++++++++++ internal/hpke/encryptor.go | 149 ++++++++++++++++++++++++++++++++ internal/hpke/encryptor_test.go | 116 +++++++++++++++++++++++++ internal/hpke/types.go | 30 +++++++ 7 files changed, 572 insertions(+) create mode 100644 internal/hpke/decryptor.go create mode 100644 internal/hpke/decryptor_test.go create mode 100644 internal/hpke/encryptor.go create mode 100644 internal/hpke/encryptor_test.go create mode 100644 internal/hpke/types.go diff --git a/go.mod b/go.mod index 0c074ebf..1b8d50df 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ module github.com/jetstack/preflight go 1.24.4 require ( + filippo.io/hpke v0.4.0 github.com/Venafi/vcert/v5 v5.12.2 github.com/cenkalti/backoff/v5 v5.0.3 github.com/fatih/color v1.18.0 diff --git a/go.sum b/go.sum index 7a717d60..47915a4d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Venafi/vcert/v5 v5.12.2 h1:Ee3/A9fZRiisuwuz22/Nqgl19H0ztQjWv35AC63qPcA= diff --git a/internal/hpke/decryptor.go b/internal/hpke/decryptor.go new file mode 100644 index 00000000..3d051904 --- /dev/null +++ b/internal/hpke/decryptor.go @@ -0,0 +1,130 @@ +package hpke + +import ( + "fmt" + + "filippo.io/hpke" +) + +// NewDecryptor creates a new Decryptor with the provided HPKE private key. +// It uses the default algorithms: X25519 KEM, HKDF-SHA256, and AES-256-GCM. +func NewDecryptor(privateKey hpke.PrivateKey) (*Decryptor, error) { + if privateKey == nil { + return nil, fmt.Errorf("HPKE private key cannot be nil") + } + + return &Decryptor{ + privateKey: privateKey, + kdf: DefaultKDF(), + aead: DefaultAEAD(), + }, nil +} + +// NewDecryptorWithAlgorithms creates a new Decryptor with custom algorithms. +// The KDF and AEAD must match those used during encryption. +// Note: The KEM is determined by the private key itself. +func NewDecryptorWithAlgorithms(privateKey hpke.PrivateKey, kdf hpke.KDF, aead hpke.AEAD) (*Decryptor, error) { + if privateKey == nil { + return nil, fmt.Errorf("HPKE private key cannot be nil") + } + + if kdf == nil { + return nil, fmt.Errorf("KDF cannot be nil") + } + + if aead == nil { + return nil, fmt.Errorf("AEAD cannot be nil") + } + + return &Decryptor{ + privateKey: privateKey, + kdf: kdf, + aead: aead, + }, nil +} + +// Decrypt decrypts the provided EncryptedData using HPKE. +func (d *Decryptor) Decrypt(encrypted *EncryptedData) ([]byte, error) { + if encrypted == nil { + return nil, fmt.Errorf("encrypted data cannot be nil") + } + + if len(encrypted.EncapsulatedKey) == 0 { + return nil, fmt.Errorf("encapsulated key cannot be empty") + } + + if len(encrypted.Ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext cannot be empty") + } + + // NewRecipient creates a receiver context from the encapsulated key. + // The info parameter must match what was used during encryption (nil in our case). + recipient, err := hpke.NewRecipient(encrypted.EncapsulatedKey, d.privateKey, d.kdf, d.aead, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE recipient: %w", err) + } + + // Open decrypts and authenticates the ciphertext. + plaintext, err := recipient.Open(nil, encrypted.Ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to open HPKE ciphertext: %w", err) + } + + return plaintext, nil +} + +// DecryptWithInfo decrypts data that was encrypted with application-specific context information. +// The info parameter must match what was used during encryption. +func (d *Decryptor) DecryptWithInfo(encrypted *EncryptedData, info []byte) ([]byte, error) { + if encrypted == nil { + return nil, fmt.Errorf("encrypted data cannot be nil") + } + + if len(encrypted.EncapsulatedKey) == 0 { + return nil, fmt.Errorf("encapsulated key cannot be empty") + } + + if len(encrypted.Ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext cannot be empty") + } + + recipient, err := hpke.NewRecipient(encrypted.EncapsulatedKey, d.privateKey, d.kdf, d.aead, info) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE recipient: %w", err) + } + + plaintext, err := recipient.Open(nil, encrypted.Ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to open HPKE ciphertext: %w", err) + } + + return plaintext, nil +} + +// DecryptWithAAD decrypts data that was encrypted with additional authenticated data. +// The aad parameter must match what was used during encryption. +func (d *Decryptor) DecryptWithAAD(encrypted *EncryptedData, aad []byte) ([]byte, error) { + if encrypted == nil { + return nil, fmt.Errorf("encrypted data cannot be nil") + } + + if len(encrypted.EncapsulatedKey) == 0 { + return nil, fmt.Errorf("encapsulated key cannot be empty") + } + + if len(encrypted.Ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext cannot be empty") + } + + recipient, err := hpke.NewRecipient(encrypted.EncapsulatedKey, d.privateKey, d.kdf, d.aead, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE recipient: %w", err) + } + + plaintext, err := recipient.Open(aad, encrypted.Ciphertext) + if err != nil { + return nil, fmt.Errorf("failed to open HPKE ciphertext: %w", err) + } + + return plaintext, nil +} diff --git a/internal/hpke/decryptor_test.go b/internal/hpke/decryptor_test.go new file mode 100644 index 00000000..59a9c348 --- /dev/null +++ b/internal/hpke/decryptor_test.go @@ -0,0 +1,144 @@ +package hpke_test + +import ( + "testing" + + "github.com/jetstack/preflight/internal/hpke" + "github.com/stretchr/testify/require" +) + +func TestNewDecryptor_NilKey(t *testing.T) { + dec, err := hpke.NewDecryptor(nil) + require.Error(t, err) + require.Nil(t, dec) + require.Contains(t, err.Error(), "cannot be nil") +} + +func TestEncryptDecrypt_RoundTrip(t *testing.T) { + publicKey, privateKey, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey) + require.NoError(t, err) + + dec, err := hpke.NewDecryptor(privateKey) + require.NoError(t, err) + + originalData := []byte("sensitive data to encrypt and decrypt") + + // Encrypt + encrypted, err := enc.Encrypt(originalData) + require.NoError(t, err) + require.NotNil(t, encrypted) + + // Decrypt + decrypted, err := dec.Decrypt(encrypted) + require.NoError(t, err) + require.Equal(t, originalData, decrypted) +} + +func TestEncryptDecrypt_WithInfo(t *testing.T) { + publicKey, privateKey, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey) + require.NoError(t, err) + + dec, err := hpke.NewDecryptor(privateKey) + require.NoError(t, err) + + originalData := []byte("sensitive data") + info := []byte("application-specific context") + + // Encrypt with info + encrypted, err := enc.EncryptWithInfo(originalData, info) + require.NoError(t, err) + + // Decrypt with matching info + decrypted, err := dec.DecryptWithInfo(encrypted, info) + require.NoError(t, err) + require.Equal(t, originalData, decrypted) + + // Decrypt with wrong info should fail + _, err = dec.DecryptWithInfo(encrypted, []byte("wrong info")) + require.Error(t, err) + + // Decrypt without info should fail + _, err = dec.Decrypt(encrypted) + require.Error(t, err) +} + +func TestEncryptDecrypt_WithAAD(t *testing.T) { + publicKey, privateKey, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey) + require.NoError(t, err) + + dec, err := hpke.NewDecryptor(privateKey) + require.NoError(t, err) + + originalData := []byte("sensitive data") + aad := []byte("additional authenticated data") + + // Encrypt with AAD + encrypted, err := enc.EncryptWithAAD(originalData, aad) + require.NoError(t, err) + + // Decrypt with matching AAD + decrypted, err := dec.DecryptWithAAD(encrypted, aad) + require.NoError(t, err) + require.Equal(t, originalData, decrypted) + + // Decrypt with wrong AAD should fail + _, err = dec.DecryptWithAAD(encrypted, []byte("wrong aad")) + require.Error(t, err) + + // Decrypt without AAD should fail + _, err = dec.Decrypt(encrypted) + require.Error(t, err) +} + +func TestDecrypt_WrongKey(t *testing.T) { + publicKey1, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + _, privateKey2, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey1) + require.NoError(t, err) + + dec, err := hpke.NewDecryptor(privateKey2) + require.NoError(t, err) + + data := []byte("test data") + encrypted, err := enc.Encrypt(data) + require.NoError(t, err) + + // Decryption with wrong key should fail + _, err = dec.Decrypt(encrypted) + require.Error(t, err) +} + +func TestDecrypt_CorruptedData(t *testing.T) { + publicKey, privateKey, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey) + require.NoError(t, err) + + dec, err := hpke.NewDecryptor(privateKey) + require.NoError(t, err) + + data := []byte("test data") + encrypted, err := enc.Encrypt(data) + require.NoError(t, err) + + // Corrupt the ciphertext + encrypted.Ciphertext[0] ^= 0xFF + + // Decryption should fail due to authentication failure + _, err = dec.Decrypt(encrypted) + require.Error(t, err) +} diff --git a/internal/hpke/encryptor.go b/internal/hpke/encryptor.go new file mode 100644 index 00000000..f72c456c --- /dev/null +++ b/internal/hpke/encryptor.go @@ -0,0 +1,149 @@ +package hpke + +import ( + "crypto/ecdh" + "fmt" + + "filippo.io/hpke" +) + +// DefaultKEM returns the recommended KEM (Key Encapsulation Mechanism). +// Uses X25519-based Diffie-Hellman KEM. +func DefaultKEM() hpke.KEM { + return hpke.DHKEM(ecdh.X25519()) +} + +// DefaultKDF returns the recommended KDF (Key Derivation Function). +// Uses HKDF with SHA-256. +func DefaultKDF() hpke.KDF { + return hpke.HKDFSHA256() +} + +// DefaultAEAD returns the recommended AEAD (Authenticated Encryption with Associated Data). +// Uses AES-256-GCM. +func DefaultAEAD() hpke.AEAD { + return hpke.AES256GCM() +} + +// NewEncryptor creates a new Encryptor with the provided HPKE public key. +// It uses the default algorithms: X25519 KEM, HKDF-SHA256, and AES-256-GCM. +func NewEncryptor(publicKey hpke.PublicKey) (*Encryptor, error) { + if publicKey == nil { + return nil, fmt.Errorf("HPKE public key cannot be nil") + } + + return &Encryptor{ + publicKey: publicKey, + kdf: DefaultKDF(), + aead: DefaultAEAD(), + }, nil +} + +// NewEncryptorWithAlgorithms creates a new Encryptor with custom algorithms. +// Use this if you need to use different KDF or AEAD algorithms. +// Note: The KEM is determined by the public key itself. +func NewEncryptorWithAlgorithms(publicKey hpke.PublicKey, kdf hpke.KDF, aead hpke.AEAD) (*Encryptor, error) { + if publicKey == nil { + return nil, fmt.Errorf("HPKE public key cannot be nil") + } + + if kdf == nil { + return nil, fmt.Errorf("KDF cannot be nil") + } + + if aead == nil { + return nil, fmt.Errorf("AEAD cannot be nil") + } + + return &Encryptor{ + publicKey: publicKey, + kdf: kdf, + aead: aead, + }, nil +} + +// Encrypt performs HPKE encryption on the provided data. +// HPKE combines key encapsulation and authenticated encryption in a single operation. +// It returns the encapsulated key and ciphertext that can be used for decryption. +func (e *Encryptor) Encrypt(data []byte) (*EncryptedData, error) { + if len(data) == 0 { + return nil, fmt.Errorf("data to encrypt cannot be empty") + } + + // NewSender creates a sender context and generates an encapsulated key. + // The info parameter is application-specific context information (we use nil for now). + encapsulatedKey, sender, err := hpke.NewSender(e.publicKey, e.kdf, e.aead, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE sender: %w", err) + } + + // Seal encrypts the plaintext with authenticated encryption. + // The aad (additional authenticated data) parameter is optional (we use nil). + ciphertext, err := sender.Seal(nil, data) + if err != nil { + return nil, fmt.Errorf("failed to seal data with HPKE: %w", err) + } + + return &EncryptedData{ + EncapsulatedKey: encapsulatedKey, + Ciphertext: ciphertext, + }, nil +} + +// EncryptWithInfo performs HPKE encryption with application-specific context information. +// The info parameter is bound to the encryption operation and must be provided during decryption. +func (e *Encryptor) EncryptWithInfo(data []byte, info []byte) (*EncryptedData, error) { + if len(data) == 0 { + return nil, fmt.Errorf("data to encrypt cannot be empty") + } + + encapsulatedKey, sender, err := hpke.NewSender(e.publicKey, e.kdf, e.aead, info) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE sender: %w", err) + } + + ciphertext, err := sender.Seal(nil, data) + if err != nil { + return nil, fmt.Errorf("failed to seal data with HPKE: %w", err) + } + + return &EncryptedData{ + EncapsulatedKey: encapsulatedKey, + Ciphertext: ciphertext, + }, nil +} + +// EncryptWithAAD performs HPKE encryption with additional authenticated data. +// The aad parameter is authenticated but not encrypted. +func (e *Encryptor) EncryptWithAAD(data []byte, aad []byte) (*EncryptedData, error) { + if len(data) == 0 { + return nil, fmt.Errorf("data to encrypt cannot be empty") + } + + encapsulatedKey, sender, err := hpke.NewSender(e.publicKey, e.kdf, e.aead, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HPKE sender: %w", err) + } + + ciphertext, err := sender.Seal(aad, data) + if err != nil { + return nil, fmt.Errorf("failed to seal data with HPKE: %w", err) + } + + return &EncryptedData{ + EncapsulatedKey: encapsulatedKey, + Ciphertext: ciphertext, + }, nil +} + +// GenerateKeyPair generates a new HPKE key pair using the default KEM (X25519). +// This is a helper function for testing and key generation. +func GenerateKeyPair() (hpke.PublicKey, hpke.PrivateKey, error) { + kem := DefaultKEM() + privateKey, err := kem.GenerateKey() + if err != nil { + return nil, nil, fmt.Errorf("failed to generate HPKE key pair: %w", err) + } + publicKey := privateKey.PublicKey() + return publicKey, privateKey, nil +} diff --git a/internal/hpke/encryptor_test.go b/internal/hpke/encryptor_test.go new file mode 100644 index 00000000..c5654a2f --- /dev/null +++ b/internal/hpke/encryptor_test.go @@ -0,0 +1,116 @@ +package hpke_test + +import ( + "crypto/rand" + "testing" + + "github.com/jetstack/preflight/internal/hpke" + "github.com/stretchr/testify/require" +) + +func TestNewEncryptor_ValidKey(t *testing.T) { + publicKey, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(publicKey) + require.NoError(t, err) + require.NotNil(t, enc) +} + +func TestNewEncryptor_NilKey(t *testing.T) { + enc, err := hpke.NewEncryptor(nil) + require.Error(t, err) + require.Nil(t, enc) + require.Contains(t, err.Error(), "cannot be nil") +} + +func TestEncrypt_VariousDataSizes(t *testing.T) { + publicKey, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(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.EncapsulatedKey) + require.NotEmpty(t, result.Ciphertext) + + // Verify ciphertext differs from input + require.NotEqual(t, data, result.Ciphertext) + }) + } +} + +func TestEncrypt_EmptyData(t *testing.T) { + publicKey, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(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) { + publicKey, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(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) + + // Encapsulated keys should be different (random for each encryption) + require.NotEqual(t, result1.EncapsulatedKey, result2.EncapsulatedKey) + + // Ciphertexts should be different + require.NotEqual(t, result1.Ciphertext, result2.Ciphertext) +} + +func TestEncrypt_AllFieldsPopulated(t *testing.T) { + publicKey, _, err := hpke.GenerateKeyPair() + require.NoError(t, err) + + enc, err := hpke.NewEncryptor(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.EncapsulatedKey, "EncapsulatedKey should be populated") + require.NotEmpty(t, result.Ciphertext, "Ciphertext should be populated") + + // Verify encapsulated key size is appropriate for X25519 (32 bytes) + require.Equal(t, 32, len(result.EncapsulatedKey), "EncapsulatedKey should be 32 bytes for X25519") +} diff --git a/internal/hpke/types.go b/internal/hpke/types.go new file mode 100644 index 00000000..1e7bff85 --- /dev/null +++ b/internal/hpke/types.go @@ -0,0 +1,30 @@ +package hpke + +import "filippo.io/hpke" + +// Encryptor provides envelope encryption using HPKE (Hybrid Public Key Encryption). +// HPKE combines key encapsulation and authenticated encryption in a single operation. +type Encryptor struct { + publicKey hpke.PublicKey + kdf hpke.KDF + aead hpke.AEAD +} + +// Decryptor provides HPKE decryption using a private key. +type Decryptor struct { + privateKey hpke.PrivateKey + kdf hpke.KDF + aead hpke.AEAD +} + +// EncryptedData contains the result of HPKE encryption. +// Unlike RSA envelope encryption, HPKE integrates key encapsulation and data encryption, +// so we only need the encapsulated key and the ciphertext. +type EncryptedData struct { + // EncapsulatedKey is the KEM (Key Encapsulation Mechanism) output that + // allows the recipient to derive the shared secret + EncapsulatedKey []byte + + // Ciphertext is the encrypted data including the authentication tag + Ciphertext []byte +}