Skip to content

Commit 7bd7f43

Browse files
committed
internal/ecies: add encrypt/decrypt with ECIES
This commit creates a simple encryption and decryption function that uses ChaCha20Poly1305 for tne symmetric encryption and HKDF with SHA256 for the key derivation. The shared key generation is not part of these functions, because we'll need to use lnd RPCs in some cases to be able to derive it.
1 parent 324bce0 commit 7bd7f43

File tree

3 files changed

+337
-1
lines changed

3 files changed

+337
-1
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ require (
4040
github.com/prometheus/client_golang v1.14.0
4141
github.com/stretchr/testify v1.10.0
4242
github.com/urfave/cli v1.22.14
43+
golang.org/x/crypto v0.36.0
4344
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8
4445
golang.org/x/net v0.38.0
4546
golang.org/x/sync v0.12.0
@@ -188,7 +189,6 @@ require (
188189
go.uber.org/atomic v1.10.0 // indirect
189190
go.uber.org/multierr v1.6.0 // indirect
190191
go.uber.org/zap v1.23.0 // indirect
191-
golang.org/x/crypto v0.36.0 // indirect
192192
golang.org/x/mod v0.21.0 // indirect
193193
golang.org/x/sys v0.31.0 // indirect
194194
golang.org/x/text v0.23.0 // indirect

internal/ecies/ecies.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// This package implements an ECIES (Elliptic Curve Integrated Encryption
2+
// Scheme) encryption. It uses ChaCha20Poly1305 for encryption and HKDF with
3+
// SHA256 for key derivation. The package provides functions to encrypt and
4+
// decrypt messages using a shared secret derived between two parties using ECDH
5+
// (Elliptic Curve Diffie-Hellman).
6+
7+
package ecies
8+
9+
import (
10+
crand "crypto/rand"
11+
"crypto/sha256"
12+
"fmt"
13+
"io"
14+
15+
"github.com/btcsuite/btcd/btcec/v2"
16+
"golang.org/x/crypto/chacha20poly1305"
17+
"golang.org/x/crypto/hkdf"
18+
)
19+
20+
// EncryptSha256ChaCha20Poly1305 encrypts the given message using
21+
// ChaCha20Poly1305 with a shared secret (usually derived using ECDH between the
22+
// sender's ephemeral key and the receiver's public key) that is stretched using
23+
// HKDF with SHA256. The cipher also authenticates the additional data.
24+
// The output is a byte slice containing:
25+
//
26+
// <12 bytes nonce> <... bytes ciphertext>
27+
func EncryptSha256ChaCha20Poly1305(sharedSecret [32]byte, msg []byte,
28+
additionalData []byte) ([]byte, error) {
29+
30+
// We begin by stretching the shared secret using HKDF with SHA256.
31+
stretchedKey, err := HkdfSha256(sharedSecret[:])
32+
if err != nil {
33+
return nil, fmt.Errorf("cannot derive hkdf key: %w", err)
34+
}
35+
36+
// We can now create a new ChaCha20Poly1305 AEAD cipher using the
37+
// stretched key.
38+
aead, err := chacha20poly1305.New(stretchedKey[:])
39+
if err != nil {
40+
return nil, fmt.Errorf("cannot create new chacha20poly1305 "+
41+
"cipher: %w", err)
42+
}
43+
44+
// Select a random nonce, and leave capacity for the ciphertext.
45+
nonce := make(
46+
[]byte, aead.NonceSize(),
47+
aead.NonceSize()+len(msg)+aead.Overhead(),
48+
)
49+
50+
if _, err := crand.Read(nonce); err != nil {
51+
return nil, fmt.Errorf("cannot read random nonce: %w", err)
52+
}
53+
54+
ciphertext := aead.Seal(nonce, nonce, msg, additionalData)
55+
56+
return ciphertext, nil
57+
}
58+
59+
// DecryptSha256ChaCha20Poly1305 decrypts the given ciphertext using
60+
// ChaCha20Poly1305 with a shared secret (usually derived using ECDH between the
61+
// sender's ephemeral key and the receiver's public key) that is stretched using
62+
// HKDF with SHA256. The cipher also verifies the authenticity of the additional
63+
// data. The ciphertext must be in the format:
64+
//
65+
// <12 bytes nonce> <... bytes ciphertext>
66+
func DecryptSha256ChaCha20Poly1305(sharedSecret [32]byte, msg []byte,
67+
additionalData []byte) ([]byte, error) {
68+
69+
// Before we start, we check that the ciphertext is at least 12 bytes
70+
// long. This is the minimum size for a valid ciphertext, as it contains
71+
// the nonce (12 bytes).
72+
if len(msg) < chacha20poly1305.NonceSize {
73+
return nil, fmt.Errorf("ciphertext too short: %d bytes "+
74+
"given, %d bytes minimum", len(msg),
75+
chacha20poly1305.NonceSize)
76+
}
77+
78+
// We begin by stretching the shared secret using HKDF with SHA256.
79+
stretchedKey, err := HkdfSha256(sharedSecret[:])
80+
if err != nil {
81+
return nil, fmt.Errorf("cannot derive hkdf key: %w", err)
82+
}
83+
84+
// We can now create a new ChaCha20Poly1305 AEAD cipher using the
85+
// stretched key.
86+
aead, err := chacha20poly1305.New(stretchedKey[:])
87+
if err != nil {
88+
return nil, fmt.Errorf("cannot create new chacha20poly1305 "+
89+
"cipher: %w", err)
90+
}
91+
92+
// Split nonce and ciphertext.
93+
nonce, ciphertext := msg[:aead.NonceSize()], msg[aead.NonceSize():]
94+
95+
// Decrypt the message and check it wasn't tampered with.
96+
plaintext, err := aead.Open(nil, nonce, ciphertext, additionalData)
97+
if err != nil {
98+
return nil, fmt.Errorf("cannot decrypt message: %w", err)
99+
}
100+
101+
return plaintext, nil
102+
}
103+
104+
// HkdfSha256 derives a 32-byte key from the given secret using HKDF with
105+
// SHA256.
106+
func HkdfSha256(secret []byte) ([32]byte, error) {
107+
var key [32]byte
108+
kdf := hkdf.New(sha256.New, secret, nil, nil)
109+
if _, err := io.ReadFull(kdf, key[:]); err != nil {
110+
return [32]byte{}, fmt.Errorf("cannot read secret from HKDF "+
111+
"reader: %w", err)
112+
}
113+
114+
return key, nil
115+
}
116+
117+
// ECDH performs a scalar multiplication (ECDH-like operation) between the
118+
// target private key and remote public key. The output returned will be
119+
// the sha256 of the resulting shared point serialized in compressed format. If
120+
// k is our private key, and P is the public key, we perform the following
121+
// operation:
122+
//
123+
// sx = k*P
124+
// s = sha256(sx.SerializeCompressed())
125+
func ECDH(privKey *btcec.PrivateKey, pub *btcec.PublicKey) ([32]byte, error) {
126+
var (
127+
pubJacobian btcec.JacobianPoint
128+
s btcec.JacobianPoint
129+
)
130+
pub.AsJacobian(&pubJacobian)
131+
132+
btcec.ScalarMultNonConst(&privKey.Key, &pubJacobian, &s)
133+
s.ToAffine()
134+
sPubKey := btcec.NewPublicKey(&s.X, &s.Y)
135+
return sha256.Sum256(sPubKey.SerializeCompressed()), nil
136+
}

internal/ecies/ecies_test.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package ecies
2+
3+
import (
4+
"bytes"
5+
crand "crypto/rand"
6+
"math/rand/v2"
7+
"testing"
8+
9+
"github.com/btcsuite/btcd/btcec/v2"
10+
"github.com/stretchr/testify/require"
11+
"golang.org/x/crypto/chacha20poly1305"
12+
)
13+
14+
// TestEncryptDecryptSha256ChaCha20Poly1305 tests the
15+
// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions. It
16+
// generates a shared secret using ECDH between a sender and receiver key pair,
17+
// encrypts a message using the shared secret, and then decrypts it to verify
18+
// that the original message is recovered.
19+
func TestEncryptDecryptSha256ChaCha20Poly1305(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
message []byte
23+
additionalData []byte
24+
}{
25+
{
26+
name: "short message",
27+
message: []byte("hello"),
28+
},
29+
{
30+
name: "short message with AD",
31+
message: []byte("hello"),
32+
additionalData: []byte("additional data"),
33+
},
34+
{
35+
name: "empty message",
36+
message: nil,
37+
},
38+
{
39+
name: "long message",
40+
message: bytes.Repeat([]byte("a"), 1024),
41+
},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
senderPriv, err := btcec.NewPrivateKey()
47+
require.NoError(t, err)
48+
49+
receiverPriv, err := btcec.NewPrivateKey()
50+
require.NoError(t, err)
51+
receiverPub := receiverPriv.PubKey()
52+
53+
sharedSecret, err := ECDH(senderPriv, receiverPub)
54+
require.NoError(t, err)
55+
56+
// Encrypt the message.
57+
ciphertext, err := EncryptSha256ChaCha20Poly1305(
58+
sharedSecret, tt.message, tt.additionalData,
59+
)
60+
require.NoError(t, err)
61+
62+
require.NotContains(t, ciphertext, tt.message)
63+
require.GreaterOrEqual(
64+
t, len(ciphertext), chacha20poly1305.NonceSize,
65+
)
66+
67+
// Decrypt the message.
68+
plaintext, err := DecryptSha256ChaCha20Poly1305(
69+
sharedSecret, ciphertext, tt.additionalData,
70+
)
71+
require.NoError(t, err)
72+
73+
// Verify the decrypted message matches the original.
74+
require.Equal(t, tt.message, plaintext)
75+
})
76+
}
77+
}
78+
79+
// TestEncryptDecryptSha256ChaCha20Poly1305Random tests the
80+
// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions
81+
// with random messages.
82+
func TestEncryptDecryptSha256ChaCha20Poly1305Random(t *testing.T) {
83+
for i := 0; i < 100; i++ {
84+
msgLen := rand.Int()%65536 + 1
85+
msg := make([]byte, msgLen)
86+
_, err := crand.Read(msg)
87+
require.NoError(t, err)
88+
89+
ad := make([]byte, 32)
90+
_, err = crand.Read(ad)
91+
require.NoError(t, err)
92+
93+
senderPriv, err := btcec.NewPrivateKey()
94+
require.NoError(t, err)
95+
96+
receiverPriv, err := btcec.NewPrivateKey()
97+
require.NoError(t, err)
98+
receiverPub := receiverPriv.PubKey()
99+
100+
sharedSecret, err := ECDH(senderPriv, receiverPub)
101+
require.NoError(t, err)
102+
103+
// Encrypt the message.
104+
ciphertext, err := EncryptSha256ChaCha20Poly1305(
105+
sharedSecret, msg, ad,
106+
)
107+
require.NoError(t, err)
108+
109+
require.NotContains(t, ciphertext, msg)
110+
require.GreaterOrEqual(t, len(ciphertext), 32)
111+
112+
// Decrypt the message.
113+
plaintext, err := DecryptSha256ChaCha20Poly1305(
114+
sharedSecret, ciphertext, ad,
115+
)
116+
require.NoError(t, err)
117+
118+
// Verify the decrypted message matches the original.
119+
require.Equal(t, msg, plaintext)
120+
}
121+
}
122+
123+
// EncryptSha256ChaCha20Poly1305 tests the performance of the
124+
// EncryptSha256ChaCha20Poly1305 function.
125+
func BenchmarkEncryptSha256ChaCha20Poly1305(b *testing.B) {
126+
senderPriv, err := btcec.NewPrivateKey()
127+
require.NoError(b, err)
128+
129+
receiverPriv, err := btcec.NewPrivateKey()
130+
require.NoError(b, err)
131+
receiverPub := receiverPriv.PubKey()
132+
133+
sharedSecret, err := ECDH(senderPriv, receiverPub)
134+
require.NoError(b, err)
135+
136+
longMessage := bytes.Repeat([]byte("secret"), 10240)
137+
ad := bytes.Repeat([]byte("ad"), 1024)
138+
139+
b.ResetTimer()
140+
for i := 0; i < b.N; i++ {
141+
_, err := EncryptSha256ChaCha20Poly1305(
142+
sharedSecret, longMessage, ad,
143+
)
144+
if err != nil {
145+
b.Fail()
146+
}
147+
}
148+
}
149+
150+
// BenchmarkDecryptSha256Aes256 tests the performance of the
151+
// DecryptSha256ChaCha20Poly1305 function.
152+
func BenchmarkDecryptSha256ChaCha20Poly1305(b *testing.B) {
153+
senderPriv, err := btcec.NewPrivateKey()
154+
require.NoError(b, err)
155+
156+
receiverPriv, err := btcec.NewPrivateKey()
157+
require.NoError(b, err)
158+
receiverPub := receiverPriv.PubKey()
159+
160+
sharedSecret, err := ECDH(senderPriv, receiverPub)
161+
require.NoError(b, err)
162+
163+
longMessage := bytes.Repeat([]byte("secret"), 10240)
164+
ad := bytes.Repeat([]byte("ad"), 1024)
165+
166+
ciphertext, err := EncryptSha256ChaCha20Poly1305(
167+
sharedSecret, longMessage, ad,
168+
)
169+
require.NoError(b, err)
170+
171+
b.ResetTimer()
172+
for i := 0; i < b.N; i++ {
173+
_, err := DecryptSha256ChaCha20Poly1305(
174+
sharedSecret, ciphertext, ad,
175+
)
176+
if err != nil {
177+
b.Fail()
178+
}
179+
}
180+
}
181+
182+
// FuzzEncryptSha256ChaCha20Poly1305 is a fuzz test for the
183+
// EncryptSha256ChaCha20Poly1305 function.
184+
func FuzzEncryptSha256ChaCha20Poly1305(f *testing.F) {
185+
f.Fuzz(func(t *testing.T, secretBytes, msg, ad []byte) {
186+
var sharedSecret [32]byte
187+
copy(sharedSecret[:], secretBytes)
188+
_, _ = EncryptSha256ChaCha20Poly1305(sharedSecret, msg, ad)
189+
})
190+
}
191+
192+
// FuzzDecryptSha256ChaCha20Poly1305 is a fuzz test for the
193+
// DecryptSha256ChaCha20Poly1305 function.
194+
func FuzzDecryptSha256ChaCha20Poly1305(f *testing.F) {
195+
f.Fuzz(func(t *testing.T, secretBytes, msg, ad []byte) {
196+
var sharedSecret [32]byte
197+
copy(sharedSecret[:], secretBytes)
198+
_, _ = DecryptSha256ChaCha20Poly1305(sharedSecret, msg, ad)
199+
})
200+
}

0 commit comments

Comments
 (0)