diff --git a/go.mod b/go.mod index de529532f..fa7b5c5ea 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/prometheus/client_golang v1.14.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli v1.22.14 + golang.org/x/crypto v0.36.0 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 golang.org/x/net v0.38.0 golang.org/x/sync v0.12.0 @@ -188,7 +189,6 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.23.0 // indirect - golang.org/x/crypto v0.36.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/internal/ecies/ecies.go b/internal/ecies/ecies.go new file mode 100644 index 000000000..c72cf73b3 --- /dev/null +++ b/internal/ecies/ecies.go @@ -0,0 +1,189 @@ +// This package implements an ECIES (Elliptic Curve Integrated Encryption +// Scheme) encryption. It uses ChaCha20Poly1305 for encryption and HKDF with +// SHA256 for key derivation. The package provides functions to encrypt and +// decrypt messages using a shared secret derived between two parties using ECDH +// (Elliptic Curve Diffie-Hellman). + +package ecies + +import ( + "bytes" + crand "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "math" + + "github.com/btcsuite/btcd/btcec/v2" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +const ( + // protocolName is the name of the protocol used for encryption and + // decryption. This is used to salt the HKDF key derivation. + protocolName = "ECIES-HKDF-SHA256-XCHA20POLY1305" +) + +// EncryptSha256ChaCha20Poly1305 encrypts the given message using +// ChaCha20Poly1305 with a shared secret (usually derived using ECDH between the +// sender's ephemeral key and the receiver's public key) that is hardened using +// HKDF with SHA256. The cipher also authenticates the additional data and +// prepends it to the returned encrypted message. The additional data is limited +// to at most 255 bytes. The output is a byte slice containing: +// +// <1 byte AD length> <* bytes AD> <24 bytes nonce> <* bytes ciphertext> +func EncryptSha256ChaCha20Poly1305(sharedSecret [32]byte, msg []byte, + additionalData []byte) ([]byte, error) { + + if len(additionalData) > math.MaxUint8 { + return nil, fmt.Errorf("additional data too long: %d bytes "+ + "given, 255 bytes maximum", len(additionalData)) + } + + // We begin by hardening the shared secret against brute forcing by + // using HKDF with SHA256. + stretchedKey, err := HkdfSha256(sharedSecret[:], []byte(protocolName)) + if err != nil { + return nil, fmt.Errorf("cannot derive hkdf key: %w", err) + } + + // We can now create a new XChaCha20Poly1305 AEAD cipher using the + // stretched key. + aead, err := chacha20poly1305.NewX(stretchedKey[:]) + if err != nil { + return nil, fmt.Errorf("cannot create new chacha20poly1305 "+ + "cipher: %w", err) + } + + // Select a random nonce, and leave capacity for the ciphertext. + nonce := make( + []byte, aead.NonceSize(), + aead.NonceSize()+len(msg)+aead.Overhead(), + ) + + if _, err := crand.Read(nonce); err != nil { + return nil, fmt.Errorf("cannot read random nonce: %w", err) + } + + ciphertext := aead.Seal(nonce, nonce, msg, additionalData) + + var result bytes.Buffer + result.WriteByte(byte(len(additionalData))) + result.Write(additionalData) + result.Write(ciphertext) + + return result.Bytes(), nil +} + +// ExtractAdditionalData extracts the additional data and the ciphertext from +// the given message. The message must be in the format: +// +// <1 byte AD length> <* bytes AD> <24 bytes nonce> <* bytes ciphertext> +func ExtractAdditionalData(msg []byte) ([]byte, []byte, error) { + // We need at least 1 byte for the additional data length. + if len(msg) < 1 { + return nil, nil, fmt.Errorf("ciphertext too short: %d bytes "+ + "given, 1 byte minimum", len(msg)) + } + + // Extract the additional data length from the first byte of the + // ciphertext. + additionalDataLen := int(msg[0]) + + // Before we start, we check that the ciphertext is at least + // 1+adLength+24+16 bytes long. This is the minimum size for a valid + // ciphertext, as it contains the additional data length (1 byte), the + // additional data (additionalDataLen bytes), the nonce (24 bytes) and + // the overhead (16 bytes). + minLength := 1 + additionalDataLen + chacha20poly1305.NonceSizeX + + chacha20poly1305.Overhead + if len(msg) < minLength { + return nil, nil, fmt.Errorf("ciphertext too short: %d bytes "+ + "given, %d bytes minimum", len(msg), minLength) + } + + additionalData := msg[1 : 1+additionalDataLen] + msg = msg[1+additionalDataLen:] + + return additionalData, msg, nil +} + +// DecryptSha256ChaCha20Poly1305 decrypts the given ciphertext using +// ChaCha20Poly1305 with a shared secret (usually derived using ECDH between the +// sender's ephemeral key and the receiver's public key) that is hardened using +// HKDF with SHA256. The cipher also authenticates the additional data and +// prepends it to the returned encrypted message. The additional data is limited +// to at most 255 bytes. The ciphertext must be in the format: +// +// <1 byte AD length> <* bytes AD> <24 bytes nonce> <* bytes ciphertext> +func DecryptSha256ChaCha20Poly1305(sharedSecret [32]byte, + msg []byte) ([]byte, error) { + + // Make sure the message correctly encodes the additional data. + additionalData, remainder, err := ExtractAdditionalData(msg) + if err != nil { + return nil, err + } + + // We begin by hardening the shared secret against brute forcing by + // using HKDF with SHA256. + stretchedKey, err := HkdfSha256(sharedSecret[:], []byte(protocolName)) + if err != nil { + return nil, fmt.Errorf("cannot derive hkdf key: %w", err) + } + + // We can now create a new XChaCha20Poly1305 AEAD cipher using the + // stretched key. + aead, err := chacha20poly1305.NewX(stretchedKey[:]) + if err != nil { + return nil, fmt.Errorf("cannot create new chacha20poly1305 "+ + "cipher: %w", err) + } + + // Split additional data, nonce and ciphertext. + nonce := remainder[:aead.NonceSize()] + ciphertext := remainder[aead.NonceSize():] + + // Decrypt the message and check it wasn't tampered with. + plaintext, err := aead.Open(nil, nonce, ciphertext, additionalData) + if err != nil { + return nil, fmt.Errorf("cannot decrypt message: %w", err) + } + + return plaintext, nil +} + +// HkdfSha256 derives a 32-byte key from the given secret and salt using HKDF +// with SHA256. +func HkdfSha256(secret, salt []byte) ([32]byte, error) { + var key [32]byte + kdf := hkdf.New(sha256.New, secret, salt, nil) + if _, err := io.ReadFull(kdf, key[:]); err != nil { + return [32]byte{}, fmt.Errorf("cannot read secret from HKDF "+ + "reader: %w", err) + } + + return key, nil +} + +// ECDH performs a scalar multiplication (ECDH-like operation) between the +// target private key and remote public key. The output returned will be +// the sha256 of the resulting shared point serialized in compressed format. If +// k is our private key, and P is the public key, we perform the following +// operation: +// +// sx = k*P +// s = sha256(sx.SerializeCompressed()) +func ECDH(privKey *btcec.PrivateKey, pub *btcec.PublicKey) ([32]byte, error) { + var ( + pubJacobian btcec.JacobianPoint + s btcec.JacobianPoint + ) + pub.AsJacobian(&pubJacobian) + + btcec.ScalarMultNonConst(&privKey.Key, &pubJacobian, &s) + s.ToAffine() + sPubKey := btcec.NewPublicKey(&s.X, &s.Y) + return sha256.Sum256(sPubKey.SerializeCompressed()), nil +} diff --git a/internal/ecies/ecies_test.go b/internal/ecies/ecies_test.go new file mode 100644 index 000000000..1b0cdedca --- /dev/null +++ b/internal/ecies/ecies_test.go @@ -0,0 +1,211 @@ +package ecies + +import ( + "bytes" + crand "crypto/rand" + "math/rand/v2" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/chacha20poly1305" +) + +// TestEncryptDecryptSha256ChaCha20Poly1305 tests the +// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions. It +// generates a shared secret using ECDH between a sender and receiver key pair, +// encrypts a message using the shared secret, and then decrypts it to verify +// that the original message is recovered. +func TestEncryptDecryptSha256ChaCha20Poly1305(t *testing.T) { + tests := []struct { + name string + message []byte + additionalData []byte + expectedErr string + }{ + { + name: "short message", + message: []byte("hello"), + }, + { + name: "short message with AD", + message: []byte("hello"), + additionalData: []byte("additional data"), + }, + { + name: "empty message", + message: nil, + }, + { + name: "long message", + message: bytes.Repeat([]byte("a"), 1024), + }, + { + name: "additional data too long", + message: []byte("hello"), + additionalData: bytes.Repeat([]byte("a"), 256), + expectedErr: "additional data too long", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + senderPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + receiverPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + receiverPub := receiverPriv.PubKey() + + sharedSecret, err := ECDH(senderPriv, receiverPub) + require.NoError(t, err) + + // Encrypt the message. + ciphertext, err := EncryptSha256ChaCha20Poly1305( + sharedSecret, tt.message, tt.additionalData, + ) + + if tt.expectedErr != "" { + require.ErrorContains(t, err, tt.expectedErr) + return + } + + require.NotContains(t, ciphertext, tt.message) + require.GreaterOrEqual( + t, len(ciphertext), chacha20poly1305.NonceSize, + ) + + // Decrypt the message. + plaintext, err := DecryptSha256ChaCha20Poly1305( + sharedSecret, ciphertext, + ) + require.NoError(t, err) + + // Verify the decrypted message matches the original. + require.Equal(t, tt.message, plaintext) + }) + } +} + +// TestEncryptDecryptSha256ChaCha20Poly1305Random tests the +// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions +// with random messages. +func TestEncryptDecryptSha256ChaCha20Poly1305Random(t *testing.T) { + for i := 0; i < 200; i++ { + msgLen := rand.Int()%65536 + 1 + msg := make([]byte, msgLen) + _, err := crand.Read(msg) + require.NoError(t, err) + + ad := make([]byte, 32) + _, err = crand.Read(ad) + require.NoError(t, err) + + senderPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + receiverPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + receiverPub := receiverPriv.PubKey() + + sharedSecret, err := ECDH(senderPriv, receiverPub) + require.NoError(t, err) + + // Encrypt the message. + ciphertext, err := EncryptSha256ChaCha20Poly1305( + sharedSecret, msg, ad, + ) + require.NoError(t, err) + + require.NotContains(t, ciphertext, msg) + require.GreaterOrEqual(t, len(ciphertext), 32) + + // Decrypt the message. + plaintext, err := DecryptSha256ChaCha20Poly1305( + sharedSecret, ciphertext, + ) + require.NoError(t, err) + + // Verify the decrypted message matches the original. + require.Equal(t, msg, plaintext) + } +} + +// EncryptSha256ChaCha20Poly1305 tests the performance of the +// EncryptSha256ChaCha20Poly1305 function. +func BenchmarkEncryptSha256ChaCha20Poly1305(b *testing.B) { + senderPriv, err := btcec.NewPrivateKey() + require.NoError(b, err) + + receiverPriv, err := btcec.NewPrivateKey() + require.NoError(b, err) + receiverPub := receiverPriv.PubKey() + + sharedSecret, err := ECDH(senderPriv, receiverPub) + require.NoError(b, err) + + longMessage := bytes.Repeat([]byte("secret"), 10240) + ad := bytes.Repeat([]byte("ad"), 1024) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := EncryptSha256ChaCha20Poly1305( + sharedSecret, longMessage, ad, + ) + if err != nil { + b.Fail() + } + } +} + +// BenchmarkDecryptSha256Aes256 tests the performance of the +// DecryptSha256ChaCha20Poly1305 function. +func BenchmarkDecryptSha256ChaCha20Poly1305(b *testing.B) { + senderPriv, err := btcec.NewPrivateKey() + require.NoError(b, err) + + receiverPriv, err := btcec.NewPrivateKey() + require.NoError(b, err) + receiverPub := receiverPriv.PubKey() + + sharedSecret, err := ECDH(senderPriv, receiverPub) + require.NoError(b, err) + + longMessage := bytes.Repeat([]byte("secret"), 10240) + ad := bytes.Repeat([]byte("ad"), 1024) + + ciphertext, err := EncryptSha256ChaCha20Poly1305( + sharedSecret, longMessage, ad, + ) + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := DecryptSha256ChaCha20Poly1305( + sharedSecret, ciphertext, + ) + if err != nil { + b.Fail() + } + } +} + +// FuzzEncryptSha256ChaCha20Poly1305 is a fuzz test for the +// EncryptSha256ChaCha20Poly1305 function. +func FuzzEncryptSha256ChaCha20Poly1305(f *testing.F) { + f.Fuzz(func(t *testing.T, secretBytes, msg, ad []byte) { + var sharedSecret [32]byte + copy(sharedSecret[:], secretBytes) + _, _ = EncryptSha256ChaCha20Poly1305(sharedSecret, msg, ad) + }) +} + +// FuzzDecryptSha256ChaCha20Poly1305 is a fuzz test for the +// DecryptSha256ChaCha20Poly1305 function. +func FuzzDecryptSha256ChaCha20Poly1305(f *testing.F) { + f.Fuzz(func(t *testing.T, secretBytes, msg []byte) { + var sharedSecret [32]byte + copy(sharedSecret[:], secretBytes) + _, _ = DecryptSha256ChaCha20Poly1305(sharedSecret, msg) + }) +}