Hybrid Post-Quantum Encryption for gRPC

ML-KEM + X25519 key exchange with ChaCha20-Poly1305 transport encryption.

10 min read | KEIBIDROP Series

Why Hybrid

Quantum computers will break RSA and elliptic curve cryptography. The timeline is uncertain; the practical threat is "harvest now, decrypt later," where adversaries collect encrypted traffic today and store it until quantum hardware can break the encryption.

For any data with value beyond a five-year horizon, this is already a relevant concern.

Post-quantum algorithms like ML-KEM (formerly Kyber, standardized as FIPS 203) are designed to resist quantum attacks, but they have not endured decades of cryptanalysis like RSA or elliptic curves; a classical attack could still be found.

The hybrid approach combines a classical algorithm (X25519) with a post-quantum algorithm (ML-KEM-1024) for key exchange. If either one holds, the combined secret is secure. The transport layer then uses ChaCha20-Poly1305 for symmetric authenticated encryption.

NIST recommends hybrid constructions during the PQC transition period. Chrome and Signal already use hybrid key exchange in production.

The Handshake

KEIBIDROP's handshake establishes a shared secret between two peers. The implementation lives in pkg/session/handshake.go. The protocol has four steps:

  1. Classical ECDH: Both peers generate ephemeral X25519 keypairs and exchange public keys. This produces a 32-byte classical shared secret. See the X25519 encapsulation/decapsulation.
  2. Post-quantum encapsulation: The initiator generates an ML-KEM-1024 keypair and sends the encapsulation key. The responder encapsulates a shared secret against that key, producing a ciphertext. The initiator decapsulates to recover the same secret. ML-KEM decapsulation happens at line 107.
  3. Combine secrets: Both shared secrets are fed into HKDF-SHA512 with a domain separation label to derive a single 256-bit session key. The derivation function is in pkg/crypto/asymmetric.go.
  4. Derive session key: The combined key initializes ChaCha20-Poly1305 for all subsequent gRPC traffic.
// Simplified handshake flow
//
// Alice (initiator)                    Bob (responder)
// -----------------                    ---------------
// x25519_priv, x25519_pub = GenerateX25519()
//                          --x25519_pub-->
//                                      x25519_priv, x25519_pub = GenerateX25519()
//                          <--x25519_pub--
// classical_ss = ECDH(x25519_priv, bob_x25519_pub)
//
// mlkem_dk, mlkem_ek = mlkem.GenerateKey1024()
//                          --mlkem_ek---->
//                                      pq_ss, ciphertext = mlkem.Encapsulate(mlkem_ek)
//                          <--ciphertext--
// pq_ss = mlkem.Decapsulate(mlkem_dk, ciphertext)
//
// session_key = HKDF(classical_ss || pq_ss, "KeibiDrop-ChaCha20-Poly1305-SEK")

Source: pkg/session/handshake.go

Why Not TLS 1.3

TLS 1.3 is the right choice for client-server architectures with a certificate authority. KEIBIDROP has different requirements:

Implementing your own handshake is dangerous if done wrong. We mitigate this by using only standard library primitives (crypto/mlkem, crypto/ecdh, golang.org/x/crypto/hkdf) and keeping the protocol simple enough to audit in an afternoon.

Key Encapsulation with ML-KEM

ML-KEM (Module-Lattice Key Encapsulation Mechanism) was standardized by NIST as FIPS 203 in August 2024. Go 1.24 includes it in the standard library as crypto/mlkem.

import "crypto/mlkem"

// Key generation (initiator side)
decapsulationKey, err := mlkem.GenerateKey1024()
if err != nil {
    return fmt.Errorf("mlkem keygen: %w", err)
}
encapsulationKey := decapsulationKey.EncapsulationKey()

// Send encapsulationKey.Bytes() to the responder
// ...

// Encapsulation (responder side)
ek, err := mlkem.NewEncapsulationKey1024(receivedKeyBytes)
if err != nil {
    return fmt.Errorf("mlkem parse ek: %w", err)
}
sharedSecret, ciphertext := ek.Encapsulate()

// Send ciphertext back to the initiator
// ...

// Decapsulation (initiator side)
sharedSecret, err := decapsulationKey.Decapsulate(ciphertext)
if err != nil {
    return fmt.Errorf("mlkem decaps: %w", err)
}

Source: pkg/session/handshake.go (ML-KEM decapsulation), pkg/crypto/asymmetric.go (X25519)

ML-KEM-1024 provides NIST Security Level 5, roughly equivalent to AES-256. The encapsulation key is 1568 bytes and the ciphertext is 1568 bytes. Compare this to X25519 where public keys and shared secrets are 32 bytes each. The size increase is real but manageable for a one-time handshake.

Combining Secrets

The classical and post-quantum shared secrets must be combined carefully. Simple concatenation is not enough; we need domain separation to prevent cross-protocol attacks. The actual implementation is in deriveKeyInternal():

import (
    "crypto/sha512"
    "golang.org/x/crypto/hkdf"
)

func deriveKeyInternal(label string, secrets ...[]byte) ([]byte, error) {
    // Concatenate all shared secrets
    var combined []byte
    for _, s := range secrets {
        combined = append(combined, s...)
    }

    // Derive final key using HKDF-SHA512
    hkdfReader := hkdf.New(sha512.New, combined, nil, []byte(label))

    sessionKey := make([]byte, 32) // 256 bits for ChaCha20-Poly1305
    if _, err := io.ReadFull(hkdfReader, sessionKey); err != nil {
        return nil, fmt.Errorf("hkdf: %w", err)
    }

    return sessionKey, nil
}

// Called with:
// DeriveChaCha20Key(classicalSS, pqSS)
// label = "KeibiDrop-ChaCha20-Poly1305-SEK"

Source: pkg/crypto/asymmetric.go

HKDF with a domain-specific label ("KeibiDrop-ChaCha20-Poly1305-SEK") prevents the derived key from being usable in a different protocol context, even if an attacker replays the handshake.

gRPC Integration

KEIBIDROP encrypts at the connection layer rather than using TLS transport credentials for gRPC. The handshake produces a SecureConn that wraps the raw TCP connection with ChaCha20-Poly1305 encryption; gRPC then runs over this encrypted connection using insecure.NewCredentials(), because the transport is already encrypted at a lower layer.

The client connection setup uses a custom context dialer that provides the already-encrypted SecureConn:

// gRPC client connects over the already-encrypted SecureConn
conn, err := grpc.NewClient(
    "passthrough:///peer",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
        return secureConn, nil  // Already encrypted with ChaCha20-Poly1305
    }),
)

Source: pkg/logic/common/utils.go

The server side wraps the SecureConn in a single-connection listener. After the handshake, all gRPC traffic (file metadata, file chunks, sync notifications) flows over the encrypted connection. The gRPC layer is unaware that post-quantum encryption is happening underneath.

Symmetric Transport: SecureConn

Once the handshake completes, all data flows through SecureConn, which uses ChaCha20-Poly1305 with counter-based nonces. Nonces use a 4-byte direction prefix plus an 8-byte monotonic counter, defined in pkg/crypto/symmetric.go:

// Nonce structure: [4-byte direction prefix][8-byte counter] = 12 bytes
// Outbound: 0x4F555442 ("OUTB")
// Inbound:  0x494E4244 ("INBD")
//
// Counter-based nonces are deterministic and guarantee uniqueness.
// Direction prefixes prevent collision between inbound and outbound streams.

Source: pkg/crypto/symmetric.go, pkg/session/secureconn.go

For details on automatic key rotation during long sessions, see Forward Secrecy and Automatic Key Rotation.

Challenges

Performance

The hybrid handshake adds minimal overhead. ML-KEM operations (encapsulate, decapsulate) complete in the low single-digit milliseconds on modern hardware. The handshake happens once per connection; after that, ChaCha20-Poly1305 handles all data transfer at multi-gigabyte-per-second speeds. The post-quantum overhead is effectively invisible during actual file transfer.

Go's standard library implementations of both ML-KEM and X25519 include architecture-specific optimizations. On arm64 and amd64, the cryptographic primitives use assembly-optimized code paths.

Security Considerations

Several areas require careful attention:

Future

NIST finalized ML-KEM (FIPS 203) in August 2024. The broader ecosystem is catching up:

10 min read | KEIBIDROP Series | Technical Deep Dive | Relay Privacy | Forward Secrecy | Testing P2P