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.
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:
- 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.
- 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.
- 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. - 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:
- KEIBIDROP is peer-to-peer; there is no certificate authority and no server with a CA-signed certificate. Both peers are equal.
- Mutual authentication via fingerprints exchanged out-of-band (through Signal, Telegram, etc.). TLS client certificates are clunky for this use case. Fingerprint computation is in
ProtocolFingerprintV0(). - As of early 2026, TLS 1.3 PQC extensions are still drafts and Go's
crypto/tlsdoes not support them yet, so we need PQC through other means. - By implementing the handshake ourselves, we control exactly which algorithms are used, with no negotiation and no fallback to weaker ciphers.
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
"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
- ML-KEM-1024 ciphertexts are 1568 bytes compared to 32 bytes for X25519. This adds about 3KB to the handshake. For a one-time operation it is negligible, but it matters if you are doing frequent rekeying (see the rekey post for how we handle that).
- The ML-KEM exchange adds one additional round trip to the handshake. We pipeline it with the X25519 exchange to minimize latency.
- Go 1.24's
crypto/mlkemis solid but new. We pin the Go version in our build and monitor the security advisory feed. - You cannot test quantum resistance; no quantum computer exists that can break these algorithms today. You test correctness (both sides derive the same key), interoperability, and resistance to known classical attacks. See Testing P2P Systems for our testing approach.
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:
- ML-KEM implementations must be constant-time. Go's standard library implementation is; if you use third-party libraries, verify this. Timing side channels can leak key material.
- Both X25519 and ML-KEM key generation depend on high-quality randomness. We use
crypto/randexclusively. Never seed frommath/randor predictable sources. - On shared hardware (cloud VMs), cache timing and power analysis attacks are real concerns. ML-KEM's lattice operations have different side-channel characteristics than elliptic curves. The Go standard library includes countermeasures.
- The entire security model depends on users verifying fingerprints. The fingerprint exchange is mandatory and visible in the UI. There is no "skip verification" option. Fingerprint computation uses SHA-512 over the concatenated public keys; see
ProtocolFingerprintV0().
Future
NIST finalized ML-KEM (FIPS 203) in August 2024. The broader ecosystem is catching up:
- Draft RFCs for hybrid key exchange in TLS 1.3 are progressing. Once finalized, browsers and standard libraries will support PQC natively.
- FIPS 204 (ML-DSA) standardizes post-quantum digital signatures. We plan to use ML-DSA for peer identity verification in a future release, replacing Ed25519.
- FIPS 205 (SLH-DSA) provides a hash-based signature scheme as an alternative to lattice-based ML-DSA. Having two PQC signature options provides better resilience if one is broken.
- With ML-KEM in the Go standard library since Go 1.24, we expect ML-DSA support in a future Go release.