Skip to content

ecies: add version byte prefix to encoding format #1619

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 2 commits into from
Jun 25, 2025
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
97 changes: 74 additions & 23 deletions internal/ecies/ecies.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,44 @@ const (
protocolName = "ECIES-HKDF-SHA256-XCHA20POLY1305"
)

// Version represents the version of the ECIES encoding format.
type Version uint8

const (
// VersionUndefined is the undefined version of the ECIES encoding
// format. It is used to indicate that the version is not set or
// that the version is unknown.
VersionUndefined Version = 0

// VersionV1 represents the initial version of the ECIES encoding
// format.
VersionV1 Version = 1

// LatestVersion is the latest supported protocol version.
latestVersion = VersionV1
)

// String returns the string representation of the version.
func (v Version) String() string {
switch v {
case VersionUndefined:
return "Undefined"
case VersionV1:
return "V1"
default:
return fmt.Sprintf("Unknown(%d)", v)
}
}

// 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>
// <1 byte version> <1 byte AD length> <* bytes AD> <24 bytes nonce>
// <* bytes ciphertext>
func EncryptSha256ChaCha20Poly1305(sharedSecret [32]byte, msg []byte,
additionalData []byte) ([]byte, error) {

Expand Down Expand Up @@ -69,44 +99,59 @@ func EncryptSha256ChaCha20Poly1305(sharedSecret [32]byte, msg []byte,
ciphertext := aead.Seal(nonce, nonce, msg, additionalData)

var result bytes.Buffer
result.WriteByte(byte(latestVersion))
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:
// ExtractAdditionalData extracts the version, 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))
// <1 byte version> <1 byte AD length> <* bytes AD> <24 bytes nonce>
// <* bytes ciphertext>
func ExtractAdditionalData(msg []byte) (Version, []byte, []byte, error) {
// We need at least 2 bytes for the version and additional data length.
if len(msg) < 2 {
return VersionUndefined, nil, nil, fmt.Errorf("ciphertext "+
"too short: %d bytes given, 2 bytes minimum", len(msg))
}

// Extract the additional data length from the first byte of the
// Extract the version from the first byte of the ciphertext.
version := Version(msg[0])

// Check if the version is supported. We currently only support the
// latest version. Return an error early if the version is not supported
// as the encoding format may be incompatible with the current
// implementation.
if version != latestVersion {
return VersionUndefined, nil, nil, fmt.Errorf("unsupported "+
"version: %s", version)
}

// Extract the additional data length from the second byte of the
// ciphertext.
additionalDataLen := int(msg[0])
additionalDataLen := int(msg[1])

// 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 +
// 2+adLength+24+16 bytes long. This is the minimum size for a valid
// ciphertext, as it contains the version (1 byte), additional data
// length (1 byte), the additional data (additionalDataLen bytes), the
// nonce (24 bytes) and the overhead (16 bytes).
minLength := 2 + 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)
return VersionUndefined, 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:]
additionalData := msg[2 : 2+additionalDataLen]
msg = msg[2+additionalDataLen:]

return additionalData, msg, nil
return version, additionalData, msg, nil
}

// DecryptSha256ChaCha20Poly1305 decrypts the given ciphertext using
Expand All @@ -116,16 +161,22 @@ func ExtractAdditionalData(msg []byte) ([]byte, []byte, error) {
// 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>
// <1 byte version> <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)
version, additionalData, remainder, err := ExtractAdditionalData(msg)
if err != nil {
return nil, err
}

// Currently, only the latest version is supported.
if version != latestVersion {
return nil, fmt.Errorf("unsupported version: %s", version)
}

// We begin by hardening the shared secret against brute forcing by
// using HKDF with SHA256.
stretchedKey, err := HkdfSha256(sharedSecret[:], []byte(protocolName))
Expand Down
53 changes: 51 additions & 2 deletions internal/ecies/ecies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import (
"golang.org/x/crypto/chacha20poly1305"
)

// TestVersion tests the Version type and its String method.
func TestVersion(t *testing.T) {
require.Equal(t, "Undefined", VersionUndefined.String())
require.Equal(t, "V1", VersionV1.String())
require.Equal(t, "Unknown(255)", Version(255).String())
}

// TestEncryptDecryptSha256ChaCha20Poly1305 tests the
// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions. It
// generates a shared secret using ECDH between a sender and receiver key pair,
Expand Down Expand Up @@ -75,6 +82,11 @@ func TestEncryptDecryptSha256ChaCha20Poly1305(t *testing.T) {
t, len(ciphertext), chacha20poly1305.NonceSize,
)

// Verify the version byte is correct.
actualVersionByte := ciphertext[0]
require.Equal(t, byte(latestVersion), actualVersionByte)
require.Equal(t, byte(1), actualVersionByte)

// Decrypt the message.
plaintext, err := DecryptSha256ChaCha20Poly1305(
sharedSecret, ciphertext,
Expand All @@ -87,6 +99,32 @@ func TestEncryptDecryptSha256ChaCha20Poly1305(t *testing.T) {
}
}

// TestUnsupportedVersion tests that decryption fails with unsupported versions.
func TestUnsupportedVersion(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)

// Create a valid ciphertext.
ciphertext, err := EncryptSha256ChaCha20Poly1305(
sharedSecret, []byte("test"), []byte("ad"),
)
require.NoError(t, err)

// Modify the version byte to an unsupported version.
ciphertext[0] = byte(latestVersion + 1)

// Attempt to decrypt should fail.
_, err = DecryptSha256ChaCha20Poly1305(sharedSecret, ciphertext)
require.ErrorContains(t, err, "unsupported version:")
}

// TestEncryptDecryptSha256ChaCha20Poly1305Random tests the
// EncryptSha256ChaCha20Poly1305 and DecryptSha256ChaCha20Poly1305 functions
// with random messages.
Expand Down Expand Up @@ -120,6 +158,11 @@ func TestEncryptDecryptSha256ChaCha20Poly1305Random(t *testing.T) {
require.NotContains(t, ciphertext, msg)
require.GreaterOrEqual(t, len(ciphertext), 32)

// Verify the version byte is correct.
actualVersionByte := ciphertext[0]
require.Equal(t, byte(latestVersion), actualVersionByte)
require.Equal(t, byte(1), actualVersionByte)

// Decrypt the message.
plaintext, err := DecryptSha256ChaCha20Poly1305(
sharedSecret, ciphertext,
Expand All @@ -145,7 +188,10 @@ func BenchmarkEncryptSha256ChaCha20Poly1305(b *testing.B) {
require.NoError(b, err)

longMessage := bytes.Repeat([]byte("secret"), 10240)
ad := bytes.Repeat([]byte("ad"), 1024)

// Generate additional data with length 200 bytes, within 255-byte
// limit.
ad := bytes.Repeat([]byte("a"), 200)

b.ResetTimer()
for i := 0; i < b.N; i++ {
Expand All @@ -172,7 +218,10 @@ func BenchmarkDecryptSha256ChaCha20Poly1305(b *testing.B) {
require.NoError(b, err)

longMessage := bytes.Repeat([]byte("secret"), 10240)
ad := bytes.Repeat([]byte("ad"), 1024)

// Generate additional data with length 200 bytes, within 255-byte
// limit.
ad := bytes.Repeat([]byte("a"), 200)

ciphertext, err := EncryptSha256ChaCha20Poly1305(
sharedSecret, longMessage, ad,
Expand Down
Loading