crates/veilvoice-crypto/src/hybrid.rs

what this file is for · veilvoice-crypto · 524 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! 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.

use crate::{Error, Secret};
use hkdf::Hkdf;
use ml_kem::kem::{Decapsulate, Encapsulate};
use ml_kem::{EncodedSizeUser, KemCore, MlKem768};
use sha2::Sha256;
use x25519_dalek::{PublicKey as XPublicKey, StaticSecret as XSecret};
use zeroize::Zeroize;


/// Domain separation string, so keys derived here can never collide with keys
/// derived by any other part of the system.
const HKDF_INFO: &[u8] = b"veilvoice/v1/hybrid-kem/x25519+ml-kem-768";



/// Encoded length of the X25519 public key.
pub const X25519_PUB_LEN: usize = 32;


/// Encoded length of the ML-KEM-768 encapsulation (public) key.
pub const MLKEM_EK_LEN: usize = 1184;


/// Encoded length of an ML-KEM-768 ciphertext.
pub const MLKEM_CT_LEN: usize = 1088;


/// Encoded length of the ML-KEM-768 decapsulation (private) key.
pub const MLKEM_DK_LEN: usize = 2400;


/// Encoded length of the X25519 private scalar.
pub const X25519_SECRET_LEN: usize = 32;


/// Total encoded length of a [`SecretKey`].
pub const SECRET_KEY_LEN: usize = X25519_SECRET_LEN + MLKEM_DK_LEN;


/// Total encoded length of a [`PublicKey`].
pub const PUBLIC_KEY_LEN: usize = X25519_PUB_LEN + MLKEM_EK_LEN;


/// Total encoded length of an [`Encapsulation`].
pub const ENCAPSULATION_LEN: usize = X25519_PUB_LEN + MLKEM_CT_LEN;



type MlKemDk = <MlKem768 as KemCore>::DecapsulationKey;


type MlKemEk = <MlKem768 as KemCore>::EncapsulationKey;



/// A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation
/// key. Safe to publish.
#[derive(Clone)]
pub struct PublicKey {
    x: XPublicKey,
    ml: MlKemEk,
}



/// A recipient's private key. Zeroized on drop by the underlying types.
pub struct SecretKey {
    x: XSecret,
    ml: MlKemDk,
}



/// The public values a sender transmits so the recipient can recover the shared
/// secret. Safe to store next to the ciphertext.
#[derive(Clone)]
pub struct Encapsulation {
    /// Sender's ephemeral X25519 public key.
    pub x_ephemeral: [u8; X25519_PUB_LEN],
    /// ML-KEM-768 ciphertext.
    pub ml_ciphertext: [u8; MLKEM_CT_LEN],
}


impl PublicKey {

    /// Serialise to `PUBLIC_KEY_LEN` bytes.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(PUBLIC_KEY_LEN);
        out.extend_from_slice(self.x.as_bytes());
        out.extend_from_slice(&self.ml.as_bytes());
        out
    }



    /// Parse from exactly `PUBLIC_KEY_LEN` bytes.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
        if bytes.len() != PUBLIC_KEY_LEN {
            return Err(Error::BadKeyEncoding);
        }
        let mut x = [0u8; X25519_PUB_LEN];
        x.copy_from_slice(&bytes[..X25519_PUB_LEN]);
        let ml_bytes = ml_kem::Encoded::<MlKemEk>::try_from(&bytes[X25519_PUB_LEN..])
            .map_err(|_| Error::BadKeyEncoding)?;
        Ok(Self {
            x: XPublicKey::from(x),
            ml: MlKemEk::from_bytes(&ml_bytes),
        })
    }

}

impl Encapsulation {

    /// Serialise to `ENCAPSULATION_LEN` bytes.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(ENCAPSULATION_LEN);
        out.extend_from_slice(&self.x_ephemeral);
        out.extend_from_slice(&self.ml_ciphertext);
        out
    }



    /// Parse from exactly `ENCAPSULATION_LEN` bytes.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
        if bytes.len() != ENCAPSULATION_LEN {
            return Err(Error::BadKeyEncoding);
        }
        let mut x_ephemeral = [0u8; X25519_PUB_LEN];
        let mut ml_ciphertext = [0u8; MLKEM_CT_LEN];
        x_ephemeral.copy_from_slice(&bytes[..X25519_PUB_LEN]);
        ml_ciphertext.copy_from_slice(&bytes[X25519_PUB_LEN..]);
        Ok(Self {
            x_ephemeral,
            ml_ciphertext,
        })
    }

}

impl SecretKey {

    /// Generate a fresh key pair from the OS CSPRNG.
    pub fn generate() -> Result<(Self, PublicKey), Error> {
        let mut rng = OsRng;
        let x = XSecret::random_from_rng(&mut rng);
        let x_pub = XPublicKey::from(&x);
        let (ml_dk, ml_ek) = MlKem768::generate(&mut rng);
        Ok((
            Self { x, ml: ml_dk },
            PublicKey {
                x: x_pub,
                ml: ml_ek,
            },
        ))
    }



    /// Serialise to `SECRET_KEY_LEN` bytes.
    ///
    /// The result is returned inside a [`Secret`], not a plain `Vec`: this is
    /// the private key, and it must not sit in ordinary heap memory waiting to
    /// be swapped out. Callers should hand it straight to
    /// [`crate::container::seal_with_password`] rather than write it in the
    /// clear.
    pub fn to_bytes(&self) -> Secret {
        let mut out = Secret::zeroed(SECRET_KEY_LEN);
        let bytes = out.expose_mut();
        bytes[..X25519_SECRET_LEN].copy_from_slice(&self.x.to_bytes());
        bytes[X25519_SECRET_LEN..].copy_from_slice(&self.ml.as_bytes());
        out
    }



    /// Parse from exactly `SECRET_KEY_LEN` bytes.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
        if bytes.len() != SECRET_KEY_LEN {
            return Err(Error::BadKeyEncoding);
        }
        let mut x = [0u8; X25519_SECRET_LEN];
        x.copy_from_slice(&bytes[..X25519_SECRET_LEN]);
        let ml_bytes = ml_kem::Encoded::<MlKemDk>::try_from(&bytes[X25519_SECRET_LEN..])
            .map_err(|_| Error::BadKeyEncoding)?;
        Ok(Self {
            x: XSecret::from(x),
            ml: MlKemDk::from_bytes(&ml_bytes),
        })
    }



    /// The matching public key.
    pub fn public_key(&self) -> PublicKey {
        PublicKey {
            x: XPublicKey::from(&self.x),
            ml: self.ml.encapsulation_key().clone(),
        }
    }



    /// Recover the shared secret from a sender's [`Encapsulation`].
    pub fn decapsulate(&self, enc: &Encapsulation) -> Result<Secret, Error> {
        let x_peer = XPublicKey::from(enc.x_ephemeral);
        let x_shared = self.x.diffie_hellman(&x_peer);

        let ct = ml_kem::Ciphertext::<MlKem768>::try_from(&enc.ml_ciphertext[..])
            .map_err(|_| Error::BadKeyEncoding)?;
        let mut ml_shared = self.ml.decapsulate(&ct).map_err(|_| Error::Decapsulate)?;

        let x_self = XPublicKey::from(&self.x);
        let out = combine(x_shared.as_bytes(), &ml_shared, enc, x_self.as_bytes());
        // `x_shared` is an `x25519_dalek::SharedSecret`, which zeroizes itself
        // on drop. The ML-KEM half is a plain `Array<u8, U32>` and does not, so
        // without this the post-quantum shared secret would be the one piece of
        // key material in this crate left lying in freed memory.
        ml_shared.zeroize();
        out
    }

}

impl PublicKey {

    /// Produce a shared secret for this recipient, plus the public values they
    /// need in order to recover it.
    pub fn encapsulate(&self) -> Result<(Secret, Encapsulation), Error> {
        let mut rng = OsRng;
        let x_eph = XSecret::random_from_rng(&mut rng);
        let x_eph_pub = XPublicKey::from(&x_eph);
        let x_shared = x_eph.diffie_hellman(&self.x);

        let (ml_ct, mut ml_shared) = self
            .ml
            .encapsulate(&mut rng)
            .map_err(|_| Error::Encapsulate)?;

        let mut ml_ciphertext = [0u8; MLKEM_CT_LEN];
        ml_ciphertext.copy_from_slice(&ml_ct);
        let enc = Encapsulation {
            x_ephemeral: *x_eph_pub.as_bytes(),
            ml_ciphertext,
        };

        let secret = combine(x_shared.as_bytes(), &ml_shared, &enc, self.x.as_bytes());
        // See the note in `decapsulate`: the ML-KEM shared secret does not
        // zeroize itself.
        ml_shared.zeroize();
        Ok((secret?, enc))
    }

}


/// Mix both shared secrets, with the exchange's transcript as the salt.
///
/// What is and is not in the transcript, and why, is argued in the module
/// note under "The combiner".
fn combine(
    x_shared: &[u8; 32],

    ml_shared: &[u8],
    enc: &Encapsulation,
    recipient_x_pub: &[u8; 32],
) -> Result<Secret, Error> {
    let mut ikm = Vec::with_capacity(32 + ml_shared.len());
    ikm.extend_from_slice(x_shared);
    ikm.extend_from_slice(ml_shared);

    // Binding the transcript is what stops an attacker who can replace one
    // half of the exchange from influencing the derived key.
    let mut transcript = Vec::with_capacity(ENCAPSULATION_LEN + 32);
    transcript.extend_from_slice(&enc.x_ephemeral);
    transcript.extend_from_slice(&enc.ml_ciphertext);
    transcript.extend_from_slice(recipient_x_pub);

    let hk = Hkdf::<Sha256>::new(Some(&transcript), &ikm);
    let mut out = Secret::zeroed(32);
    let mut info = Vec::with_capacity(HKDF_INFO.len());
    info.extend_from_slice(HKDF_INFO);
    hk.expand(&info, out.expose_mut()).map_err(|_| Error::Kdf)?;

    // The raw inputs must not outlive the derivation.
    ikm.zeroize();
    Ok(out)
}


/// Bridges the OS CSPRNG to the `rand_core` traits the KEM crates expect.
///
/// `getrandom` is already the entropy source everywhere else in VeilVoice, so
/// routing these through it keeps the whole crate on one source rather than
/// pulling in a second RNG stack.
struct OsRng;


impl rand_core::RngCore for OsRng {

    fn next_u32(&mut self) -> u32 {
        let mut b = [0u8; 4];
        self.fill_bytes(&mut b);
        u32::from_le_bytes(b)
    }


    fn next_u64(&mut self) -> u64 {
        let mut b = [0u8; 8];
        self.fill_bytes(&mut b);
        u64::from_le_bytes(b)
    }


    /// # Why this one panics
    ///
    /// `RngCore::fill_bytes` has no error return: the trait's contract is that
    /// it either fills the buffer or does not come back. The alternatives are
    /// worse than a panic. Leaving `dest` as it was, or zeroing it, hands
    /// predictable bytes to whatever is drawing a key from them, silently, and
    /// a key derived from a buffer of zeros is not a key.
    ///
    /// `try_fill_bytes` below is the fallible form, and every caller in this
    /// crate that can report a failure uses `getrandom` directly rather than
    /// coming through here. This exists for the KEM crates, which take an
    /// `RngCore` and nothing else.
    fn fill_bytes(&mut self, dest: &mut [u8]) {
        getrandom::getrandom(dest).expect("OS CSPRNG unavailable");
    }


    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
        getrandom::getrandom(dest)
            .map_err(|_| rand_core::Error::from(core::num::NonZeroU32::new(1).unwrap()))
    }

}

impl rand_core::CryptoRng for OsRng {}

#[cfg(test)]
mod tests {
    use super::*;

    /// The adapter that hands the operating system's randomness to the KEM
    /// crates actually hands over randomness.
    ///
    /// **Round thirty-three.** Mutation testing replaced `next_u32` and
    /// `next_u64` with functions returning a constant, and replaced
    /// `try_fill_bytes` with one that returns `Ok(())` without touching the
    /// buffer. All three survived the suite, which is to say nothing in this
    /// crate checked that its only randomness adapter emits anything. A key
    /// derived from a buffer these left alone would be a key an attacker
    /// already knows, and every test here would still have passed.
    ///
    /// Not a statistical test: this asks whether bytes arrive at all, which is
    /// the failure a mutant (or a stubbed build) produces. `getrandom` is the
    /// system's own source and is not this crate's to assess.
    #[test]
    fn the_randomness_adapter_actually_fills_what_it_is_given() {
        use rand_core::RngCore as _;
        let mut rng = OsRng;

        // Distinct draws. Two equal `u32`s happen once in four billion; eight
        // equal ones do not happen, and a constant gives exactly that.
        let words: Vec<u32> = (0..8).map(|_| rng.next_u32()).collect();
        assert!(
            words.iter().any(|w| *w != words[0]),
            "next_u32 returned the same value eight times: {words:?}"
        );
        let longs: Vec<u64> = (0..8).map(|_| rng.next_u64()).collect();
        assert!(
            longs.iter().any(|l| *l != longs[0]),
            "next_u64 returned the same value eight times: {longs:?}"
        );

        // And the fallible form fills rather than merely succeeding. A buffer
        // of thirty-two zeros staying zero is the exact shape of the mutant.
        let mut buffer = [0u8; 32];
        rng.try_fill_bytes(&mut buffer)
            .expect("the OS randomness source is available in a test run");
        assert!(
            buffer.iter().any(|b| *b != 0),
            "try_fill_bytes returned Ok and left the buffer untouched"
        );

        // The infallible form, which is the one the KEM crates call.
        let mut filled = [0u8; 32];
        rng.fill_bytes(&mut filled);
        assert!(filled.iter().any(|b| *b != 0));
        assert_ne!(filled, buffer, "two draws produced identical bytes");
    }

    #[test]
    fn encapsulate_then_decapsulate_agrees() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let (sender_secret, enc) = pk.encapsulate().unwrap();
        let recipient_secret = sk.decapsulate(&enc).unwrap();
        assert_eq!(sender_secret, recipient_secret);
        assert_eq!(sender_secret.len(), 32);
    }

    #[test]
    fn a_different_recipient_cannot_recover_it() {
        let (_, pk) = SecretKey::generate().unwrap();
        let (other_sk, _) = SecretKey::generate().unwrap();
        let (sender_secret, enc) = pk.encapsulate().unwrap();
        // ML-KEM decapsulation is designed never to fail outright (implicit
        // rejection), so the check that matters is that the *value* differs.
        if let Ok(wrong) = other_sk.decapsulate(&enc) {
            assert_ne!(sender_secret, wrong);
        }
    }

    #[test]
    fn each_encapsulation_is_fresh() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let (s1, e1) = pk.encapsulate().unwrap();
        let (s2, e2) = pk.encapsulate().unwrap();
        assert_ne!(s1, s2);
        assert_ne!(e1.to_bytes(), e2.to_bytes());
        assert_eq!(sk.decapsulate(&e1).unwrap(), s1);
        assert_eq!(sk.decapsulate(&e2).unwrap(), s2);
    }

    /// The hybrid must fail if the classical half is tampered with, because otherwise
    /// it would be no stronger than ML-KEM alone.
    #[test]
    fn tampering_with_the_x25519_half_changes_the_secret() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let (secret, mut enc) = pk.encapsulate().unwrap();
        enc.x_ephemeral[0] ^= 1;
        let got = sk.decapsulate(&enc).unwrap();
        assert_ne!(secret, got);
    }

    /// ...and equally if the post-quantum half is tampered with.
    #[test]
    fn tampering_with_the_ml_kem_half_changes_the_secret() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let (secret, mut enc) = pk.encapsulate().unwrap();
        enc.ml_ciphertext[0] ^= 1;
        if let Ok(got) = sk.decapsulate(&enc) {
            assert_ne!(secret, got);
        }
    }

    #[test]
    fn public_keys_round_trip_through_bytes() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let bytes = pk.to_bytes();
        assert_eq!(bytes.len(), PUBLIC_KEY_LEN);
        let parsed = PublicKey::from_bytes(&bytes).unwrap();
        let (secret, enc) = parsed.encapsulate().unwrap();
        assert_eq!(sk.decapsulate(&enc).unwrap(), secret);
    }

    #[test]
    fn encapsulations_round_trip_through_bytes() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let (secret, enc) = pk.encapsulate().unwrap();
        let bytes = enc.to_bytes();
        assert_eq!(bytes.len(), ENCAPSULATION_LEN);
        let parsed = Encapsulation::from_bytes(&bytes).unwrap();
        assert_eq!(sk.decapsulate(&parsed).unwrap(), secret);
    }

    #[test]
    fn malformed_encodings_are_rejected() {
        assert!(matches!(
            PublicKey::from_bytes(&[0u8; 10]),
            Err(Error::BadKeyEncoding)
        ));
        assert!(matches!(
            Encapsulation::from_bytes(&[0u8; 10]),
            Err(Error::BadKeyEncoding)
        ));
        assert!(matches!(
            SecretKey::from_bytes(&[0u8; 10]),
            Err(Error::BadKeyEncoding)
        ));
    }

    #[test]
    fn secret_keys_round_trip_through_bytes() {
        let (sk, pk) = SecretKey::generate().unwrap();
        let encoded = sk.to_bytes();
        assert_eq!(encoded.len(), SECRET_KEY_LEN);

        let restored = SecretKey::from_bytes(encoded.expose()).unwrap();
        let (secret, enc) = pk.encapsulate().unwrap();
        assert_eq!(
            restored.decapsulate(&enc).unwrap(),
            secret,
            "a reloaded key must open what the original could"
        );
    }

    /// The encoded private key must come back in protected storage, not a bare
    /// `Vec` that could be swapped to disk.
    #[test]
    fn encoded_secret_key_is_protected_and_opaque() {
        let (sk, _) = SecretKey::generate().unwrap();
        let encoded = sk.to_bytes();
        assert!(format!("{encoded:?}").contains("redacted"));
        assert!(
            encoded.expose().iter().any(|&b| b != 0),
            "encoding should not be empty"
        );
    }

    #[test]
    fn public_key_can_be_recovered_from_the_secret_key() {
        let (sk, pk) = SecretKey::generate().unwrap();
        assert_eq!(sk.public_key().to_bytes(), pk.to_bytes());

        // And the recovered public key really works.
        let (secret, enc) = sk.public_key().encapsulate().unwrap();
        assert_eq!(sk.decapsulate(&enc).unwrap(), secret);
    }

    #[test]
    fn declared_encoding_lengths_match_the_implementation() {
        let (sk, pk) = SecretKey::generate().unwrap();
        assert_eq!(pk.to_bytes().len(), PUBLIC_KEY_LEN);
        assert_eq!(sk.to_bytes().len(), SECRET_KEY_LEN);
        let (_, enc) = pk.encapsulate().unwrap();
        assert_eq!(enc.to_bytes().len(), ENCAPSULATION_LEN);
    }
}