hybrid.rs

crates/veilvoice-crypto/src/hybrid.rs

veilvoice-crypto · 524 lines · read the source here · or on GitHub

Post-quantum hybrid key encapsulation: X25519 + ML-KEM-768.

Why hybrid

ML-KEM (FIPS 203, formerly Kyber) is believed secure against a quantum adversary, but it is young, and lattice schemes have had implementation breaks. X25519 is battle-tested but falls to a cryptographically relevant quantum computer. Running both and mixing the two shared secrets means an attacker must break both: the construction is at least as strong as the stronger of the two, and it degrades gracefully if either one is broken. This is the same reasoning behind the hybrids now deployed in TLS.

It matters here specifically because of harvest-now-decrypt-later: a recording captured today can be stored until quantum hardware exists. A tool whose whole purpose is protecting who is speaking has to assume the adversary is patient.

The combiner

The two shared secrets are mixed with HKDF-SHA256 rather than concatenated or XORed. The input keying material is the X25519 shared secret followed by the ML-KEM one. The salt is the transcript of the exchange: the ephemeral X25519 public key, the ML-KEM ciphertext and the recipient's X25519 public key. The info string names the construction and its version. Binding the transcript is what stops an attacker who can substitute one half of the exchange from steering the result, and is what keeps the combiner robust if one KEM's ciphertexts turn out to be malleable.

The recipient's ML-KEM encapsulation key is not in the salt, and does not need to be. FIPS 203 derives the ML-KEM shared secret from the message and a hash of the encapsulation key, so that key is bound through the secret itself. X25519 makes no such promise, which is why its public key is bound here by hand. This is the shape of the X-Wing combiner, the ML-KEM secret, the X25519 secret, the X25519 ciphertext and public key under a label, with HKDF in place of one SHA3-256 call. Changing it now would change every key and every container already made, and would not change what an attacker can do.

In plain words

This is for encrypting a recording to somebody else's key rather than to a passphrase, and it uses two different systems at once.

One is the kind in use everywhere today. The other is designed to resist a quantum computer, which does not yet exist in a useful form but which somebody recording traffic now would be counting on later.

Both have to be broken to read the file. Using two means that if the newer one turns out to have a flaw, you are no worse off than with the old one alone, and if the old one falls to a quantum computer, the newer one still holds.

WHAT THIS FILE CONTAINS

524 lines defining 15 functions (10 public), 4 types and 9 constants. Everything below is read out of the source, so it cannot disagree with the code.

The types it owns.

  • struct PublicKey line 88 · A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation key.
  • struct SecretKey line 94 · A recipient's private key.
  • struct Encapsulation line 102 · The public values a sender transmits so the recipient can recover the shared secret.
  • struct OsRng line 299 · Bridges the OS CSPRNG to the rand_core traits the KEM crates expect.

What happens when it runs. These are the ways in: public, and nothing else in this file calls them, so they are what an outside caller reaches first.

  • PublicKey::to_bytes line 111 · Serialise to PUBLIC_KEY_LEN bytes.
  • PublicKey::from_bytes line 119 · Parse from exactly PUBLIC_KEY_LEN bytes.
  • Encapsulation::to_bytes line 136 · Serialise to ENCAPSULATION_LEN bytes.
  • Encapsulation::from_bytes line 144 · Parse from exactly ENCAPSULATION_LEN bytes.
  • SecretKey::generate line 161 · Generate a fresh key pair from the OS CSPRNG.
  • SecretKey::to_bytes line 182 · Serialise to SECRET_KEY_LEN bytes.
  • SecretKey::from_bytes line 191 · Parse from exactly SECRET_KEY_LEN bytes.
  • SecretKey::public_key line 206 · The matching public key.
  • SecretKey::decapsulate line 214 · Recover the shared secret from a sender's Encapsulation.
    reaches combine
  • PublicKey::encapsulate line 236 · Produce a shared secret for this recipient, plus the public values they need in order to recover it.
    reaches combine

WHAT CALLS WHAT

PublicKey::to_bytes line 111 PublicKey::from_bytes line 119 Encapsulation::to_bytes line 136 Encapsulation::from_bytes line 144 SecretKey::generate line 161 SecretKey::to_bytes line 182 SecretKey::from_bytes line 191 SecretKey::public_key line 206 SecretKey::decapsulate line 214 PublicKey::encapsulate line 236 combine line 266 OsRng::next_u32 line 302 OsRng::next_u64 line 307 OsRng::fill_bytes line 324 OsRng::try_fill_bytes line 327 entry: a way in: public, and nothing in this file calls it helper: private to this file dashed: a call that goes back up, or across a wrapped rank The functions this file defines, and the calls between them. An edge means the callee's name appears, called, inside the caller's body. This is a syntactic reading, not a type-resolved one.

The functions this file defines, and the calls between them. An edge means the callee's name appears, called, inside the caller's body. This is a syntactic reading, not a type-resolved one.

The same graph as Mermaid source
%%{init: {"theme":"base","themeVariables":{"background":"#1a1b26","primaryColor":"#1f2335","primaryTextColor":"#c0caf5","primaryBorderColor":"#7aa2f7","secondaryColor":"#16161e","tertiaryColor":"#16161e","lineColor":"#737aa2","textColor":"#c0caf5","mainBkg":"#1f2335","nodeBorder":"#7aa2f7","clusterBkg":"#16161e","clusterBorder":"#2f3549","fontFamily":"ui-monospace, SFMono-Regular, Consolas, monospace","fontSize":"14px"}}}%%
flowchart TD
    n_to_bytes(["PublicKey::to_bytes<br/>line 111"])
    n_from_bytes(["PublicKey::from_bytes<br/>line 119"])
    n_to_bytes(["Encapsulation::to_bytes<br/>line 136"])
    n_from_bytes(["Encapsulation::from_bytes<br/>line 144"])
    n_generate(["SecretKey::generate<br/>line 161"])
    n_to_bytes(["SecretKey::to_bytes<br/>line 182"])
    n_from_bytes(["SecretKey::from_bytes<br/>line 191"])
    n_public_key(["SecretKey::public_key<br/>line 206"])
    n_decapsulate(["SecretKey::decapsulate<br/>line 214"])
    n_encapsulate(["PublicKey::encapsulate<br/>line 236"])
    n_combine["combine<br/>line 266"]
    n_next_u32["OsRng::next_u32<br/>line 302"]
    n_next_u64["OsRng::next_u64<br/>line 307"]
    n_fill_bytes["OsRng::fill_bytes<br/>line 324"]
    n_try_fill_bytes["OsRng::try_fill_bytes<br/>line 327"]
    n_decapsulate --> n_combine
    n_encapsulate --> n_combine
    n_next_u32 --> n_fill_bytes
    n_next_u64 --> n_fill_bytes
    click n_to_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L111" "open the source"
    click n_from_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L119" "open the source"
    click n_to_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L136" "open the source"
    click n_from_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L144" "open the source"
    click n_generate href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L161" "open the source"
    click n_to_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L182" "open the source"
    click n_from_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L191" "open the source"
    click n_public_key href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L206" "open the source"
    click n_decapsulate href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L214" "open the source"
    click n_encapsulate href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L236" "open the source"
    click n_combine href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L266" "open the source"
    click n_next_u32 href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L302" "open the source"
    click n_next_u64 href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L307" "open the source"
    click n_fill_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L324" "open the source"
    click n_try_fill_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hybrid.rs#L327" "open the source"
    classDef entry fill:#1f2335,stroke:#7aa2f7,color:#c0caf5
    class n_to_bytes,n_from_bytes,n_to_bytes,n_from_bytes,n_generate,n_to_bytes,n_from_bytes,n_public_key,n_decapsulate,n_encapsulate entry
    classDef helper fill:#1f2335,stroke:#bb9af7,color:#c0caf5
    class n_combine,n_next_u32,n_next_u64,n_fill_bytes,n_try_fill_bytes helper

This site loads no third-party script, so it cannot run Mermaid; the diagram above is the same nodes and edges drawn by the generator instead. GitHub renders the source below directly.

ITEMS

ItemLineDocumentation
HKDF_INFO const63Domain separation string, so keys derived here can never collide with keys derived by any other part of the system.
X25519_PUB_LEN pub const66Encoded length of the X25519 public key.
MLKEM_EK_LEN pub const68Encoded length of the ML-KEM-768 encapsulation (public) key.
MLKEM_CT_LEN pub const70Encoded length of an ML-KEM-768 ciphertext.
MLKEM_DK_LEN pub const72Encoded length of the ML-KEM-768 decapsulation (private) key.
X25519_SECRET_LEN pub const74Encoded length of the X25519 private scalar.
SECRET_KEY_LEN pub const76Total encoded length of a SecretKey.
PUBLIC_KEY_LEN pub const78Total encoded length of a PublicKey.
ENCAPSULATION_LEN pub const80Total encoded length of an Encapsulation.
MlKemDk type82
MlKemEk type83
PublicKey pub struct88A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation key.
SecretKey pub struct94A recipient's private key.
Encapsulation pub struct102The public values a sender transmits so the recipient can recover the shared secret.
PublicKey::to_bytes pub fn111Serialise to PUBLIC_KEY_LEN bytes.
PublicKey::from_bytes pub fn119Parse from exactly PUBLIC_KEY_LEN bytes.
Encapsulation::to_bytes pub fn136Serialise to ENCAPSULATION_LEN bytes.
Encapsulation::from_bytes pub fn144Parse from exactly ENCAPSULATION_LEN bytes.
SecretKey::generate pub fn161Generate a fresh key pair from the OS CSPRNG.
SecretKey::to_bytes pub fn182Serialise to SECRET_KEY_LEN bytes.
SecretKey::from_bytes pub fn191Parse from exactly SECRET_KEY_LEN bytes.
SecretKey::public_key pub fn206The matching public key.
SecretKey::decapsulate pub fn214Recover the shared secret from a sender's Encapsulation.
PublicKey::encapsulate pub fn236Produce a shared secret for this recipient, plus the public values they need in order to recover it.
combine fn266Mix both shared secrets, with the exchange's transcript as the salt.
OsRng struct299Bridges the OS CSPRNG to the rand_core traits the KEM crates expect.
OsRng::next_u32 fn302
OsRng::next_u64 fn307
OsRng::fill_bytes fn324RngCore::fill_bytes has no error return: the trait's contract is that it either fills the buffer or does not come back.
OsRng::try_fill_bytes fn327