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 PublicKeyline 88 · A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation key.struct SecretKeyline 94 · A recipient's private key.struct Encapsulationline 102 · The public values a sender transmits so the recipient can recover the shared secret.struct OsRngline 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_bytesline 111 · Serialise to PUBLIC_KEY_LEN bytes.PublicKey::from_bytesline 119 · Parse from exactly PUBLIC_KEY_LEN bytes.Encapsulation::to_bytesline 136 · Serialise to ENCAPSULATION_LEN bytes.Encapsulation::from_bytesline 144 · Parse from exactly ENCAPSULATION_LEN bytes.SecretKey::generateline 161 · Generate a fresh key pair from the OS CSPRNG.SecretKey::to_bytesline 182 · Serialise to SECRET_KEY_LEN bytes.SecretKey::from_bytesline 191 · Parse from exactly SECRET_KEY_LEN bytes.SecretKey::public_keyline 206 · The matching public key.SecretKey::decapsulateline 214 · Recover the shared secret from a sender's Encapsulation.
reachescombinePublicKey::encapsulateline 236 · Produce a shared secret for this recipient, plus the public values they need in order to recover it.
reachescombine
WHAT CALLS WHAT
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
| Item | Line | Documentation |
|---|---|---|
HKDF_INFO const | 63 | Domain separation string, so keys derived here can never collide with keys derived by any other part of the system. |
X25519_PUB_LEN pub const | 66 | Encoded length of the X25519 public key. |
MLKEM_EK_LEN pub const | 68 | Encoded length of the ML-KEM-768 encapsulation (public) key. |
MLKEM_CT_LEN pub const | 70 | Encoded length of an ML-KEM-768 ciphertext. |
MLKEM_DK_LEN pub const | 72 | Encoded length of the ML-KEM-768 decapsulation (private) key. |
X25519_SECRET_LEN pub const | 74 | Encoded length of the X25519 private scalar. |
SECRET_KEY_LEN pub const | 76 | Total encoded length of a SecretKey. |
PUBLIC_KEY_LEN pub const | 78 | Total encoded length of a PublicKey. |
ENCAPSULATION_LEN pub const | 80 | Total encoded length of an Encapsulation. |
MlKemDk type | 82 | |
MlKemEk type | 83 | |
PublicKey pub struct | 88 | A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation key. |
SecretKey pub struct | 94 | A recipient's private key. |
Encapsulation pub struct | 102 | The public values a sender transmits so the recipient can recover the shared secret. |
PublicKey::to_bytes pub fn | 111 | Serialise to PUBLIC_KEY_LEN bytes. |
PublicKey::from_bytes pub fn | 119 | Parse from exactly PUBLIC_KEY_LEN bytes. |
Encapsulation::to_bytes pub fn | 136 | Serialise to ENCAPSULATION_LEN bytes. |
Encapsulation::from_bytes pub fn | 144 | Parse from exactly ENCAPSULATION_LEN bytes. |
SecretKey::generate pub fn | 161 | Generate a fresh key pair from the OS CSPRNG. |
SecretKey::to_bytes pub fn | 182 | Serialise to SECRET_KEY_LEN bytes. |
SecretKey::from_bytes pub fn | 191 | Parse from exactly SECRET_KEY_LEN bytes. |
SecretKey::public_key pub fn | 206 | The matching public key. |
SecretKey::decapsulate pub fn | 214 | Recover the shared secret from a sender's Encapsulation. |
PublicKey::encapsulate pub fn | 236 | Produce a shared secret for this recipient, plus the public values they need in order to recover it. |
combine fn | 266 | Mix both shared secrets, with the exchange's transcript as the salt. |
OsRng struct | 299 | Bridges the OS CSPRNG to the rand_core traits the KEM crates expect. |
OsRng::next_u32 fn | 302 | |
OsRng::next_u64 fn | 307 | |
OsRng::fill_bytes fn | 324 | RngCore::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 fn | 327 |