Gunnar

nordisk/tunnr-mullvad

src/pq.rs

//! **The quantum-resistant tunnel**, and the exact half of it a pure-Rust
//! appliance can do today.
//!
//! # What Mullvad's PQ tunnel actually is
//!
//! It is a **WireGuard pre-shared key**. Not a new transport, not a second
//! tunnel, not a replacement for x25519 — the ordinary `[Peer] PresharedKey`
//! field, mixed into the Noise handshake on top of the ephemeral ECDH. The data
//! plane has carried one all along (`PeerSpec::preshared_key`, handed to
//! boringtun's `Tunn::new`), so **no packet code changes for this feature.**
//! What changes is where the key comes from.
//!
//! Measured 2026-09-05: the key exchange runs **inside an already-established
//! tunnel**, to a service reachable only through it, and the client then
//! **reconfigures in place** — there is no teardown and no second handshake
//! from scratch.
//!
//! Two consequences that are the whole reason this module exists:
//!
//! 1. **The secret cannot be seeded.** It does not exist until the device is
//!    already up. That makes it the one PSK road that is legitimate on an
//!    appliance whose config arrives on unauthenticated removable media — it
//!    never passes a config parser at all.
//! 2. **It is two KEMs, not one.** ML-KEM-1024 *and* HQC-256, combined.
//!    (Mullvad swapped the second from Classic McEliece in 2025-06; a blog post
//!    still saying McEliece is stale.) Two algorithms on different hardness
//!    assumptions, so a break of one leaves the other standing.
//!
//! # Both KEMs are here — and the first version of this module said otherwise
//!
//! ⚠ **A RETRACTED CLAIM, kept because the shape of the mistake is the lesson.**
//! This module shipped saying *"there is no pure-Rust HQC-256"* and carried a
//! `HqcGap` type to embody it. That was false. It came from a survey taken
//! before the crates existed, was never checked against the registry, and was
//! then repeated three times — including in a status report — on the strength of
//! having been written down once. **A fact restated is not a fact verified.**
//!
//! Measured against crates.io on 2026-09-05: four pure-Rust HQC crates, none of
//! them building C. [`hqc-kem`](https://crates.io/crates/hqc-kem) is taken
//! because it is the only one that can be CHECKED — it ships the official
//! `.rsp` known-answer vectors and tests all three levels against them, and
//! corrupting one `ss` line in `kat/hqc-5.rsp` turns `test_hqc256_kat` red, so
//! the vectors are read rather than merely shipped. See `Cargo.toml` for the
//! full comparison and the numbers.
//!
//! So both halves are real here: [`EphemeralMlKem`] and [`EphemeralHqc`].
//!
//! # What is still NOT implemented, and why that is the honest half
//!
//! The **wire framing** of Mullvad's ephemeral-peer exchange — the service it
//! is spoken to, the message encoding, the order of the two KEMs in the
//! transcript, and the KDF that combines them. That is protocol detail no
//! reachable, documented source establishes: the relay list is public and was
//! measured, but the in-tunnel PQ service needs a paid tunnel to observe and its
//! definition lives in a GPL-3.0 client this repository's clean-room rule keeps
//! us out of. Writing a framing from memory would be a guess wearing a test's
//! clothes.
//!
//! [`derive_psk`] therefore still refuses — but on [`PqError::NoFraming`], the
//! honest reason, where it used to refuse on a missing algorithm. **A PSK
//! combined by a transcript we invented is not Mullvad's PQ tunnel**, and a
//! secret that *looks* like the real thing is worse than no secret, because the
//! operator stops looking.
//!
//! Observing that exchange is ordinary interoperability work and is what closes
//! this: a Mullvad account, their own open client, and a packet capture. Nothing
//! about it requires reading their source.

use ml_kem::array::Array;
use ml_kem::kem::{Encapsulate, Kem, KeyExport, TryDecapsulate, TryKeyInit};
use ml_kem::{Ciphertext, EncapsulationKey, Key, MlKem1024};

/// The size of a WireGuard pre-shared key, in bytes. Fixed by WireGuard, not
/// by us: it is mixed into the Noise chaining key as 32 raw bytes.
pub const PSK_LEN: usize = 32;

/// The size of an ML-KEM shared secret, in bytes (FIPS 203).
pub const ML_KEM_SHARED_SECRET_LEN: usize = 32;

/// The size of an HQC-256 shared secret, in bytes — read from the
/// implementation rather than written down as a number that could drift.
pub const HQC_SHARED_SECRET_LEN: usize = hqc_kem::hqc256::SHARED_SECRET_SIZE;

/// This end of an HQC-256 exchange: the decapsulation (private) key kept on the
/// device, the encapsulation (public) key sent to the peer.
///
/// The same shape as [`EphemeralMlKem`] on purpose. Mullvad's construction is
/// two KEMs on different hardness assumptions — a lattice one and a code-based
/// one — so a break of either leaves the other standing, and the appliance
/// holds a decapsulation key for each.
///
/// `Debug` prints no key material. The underlying `hqc_kem::DecapsulationKey`
/// does not derive `Debug` either and zeroizes on drop; this impl exists so
/// that the wrapper cannot re-open a hole the dependency closed.
pub struct EphemeralHqc {
    decapsulation_key: hqc_kem::hqc256::DecapsulationKey,
    encapsulation_key: hqc_kem::hqc256::EncapsulationKey,
}

impl std::fmt::Debug for EphemeralHqc {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EphemeralHqc")
            .field("decapsulation_key", &"[redacted]")
            .field("encapsulation_key_len", &hqc_kem::hqc256::PUBLIC_KEY_SIZE)
            .finish()
    }
}

impl EphemeralHqc {
    /// Mint a fresh HQC-256 keypair from the OS RNG.
    pub fn generate() -> EphemeralHqc {
        let mut rng = rand::rng();
        let (encapsulation_key, decapsulation_key) = hqc_kem::hqc256::generate_key(&mut rng);
        EphemeralHqc {
            decapsulation_key,
            encapsulation_key,
        }
    }

    /// The public (encapsulation) key, as the bytes that go on the wire.
    pub fn public_bytes(&self) -> Vec<u8> {
        self.encapsulation_key.as_ref().to_vec()
    }

    /// Decapsulate the peer's ciphertext into the shared secret.
    ///
    /// A ciphertext of the wrong length is refused rather than truncated. That
    /// matters for the same reason as in [`EphemeralMlKem::receive`]: HQC's
    /// decapsulation is **implicitly rejecting** too — a wrong-but-well-formed
    /// ciphertext yields a deterministic pseudorandom secret instead of an
    /// error, which the dependency's own `implicit_rejection_256` test asserts.
    /// So a mis-framed exchange leaves two ends holding different 32-byte
    /// "shared" secrets and a tunnel that fails every handshake with nothing in
    /// any log.
    ///
    /// **The guard is `Ciphertext::try_from`, not a length branch of our own.**
    /// This function had one, and MEASURED 2026-09-05 it was dead code: replacing
    /// the branch with `if false` left the whole suite green, because `try_from`
    /// rejects a wrong length by itself. Two spellings of one check is the
    /// duplication that drifts, so only the dependency's remains and this maps
    /// its refusal onto a named error that says both lengths.
    pub fn receive(&self, ciphertext: &[u8]) -> Result<[u8; HQC_SHARED_SECRET_LEN], PqError> {
        let want = hqc_kem::hqc256::CIPHERTEXT_SIZE;
        let ct = hqc_kem::hqc256::Ciphertext::try_from(ciphertext).map_err(|_| {
            PqError::HqcCiphertextLength {
                got: ciphertext.len(),
                want,
            }
        })?;
        let secret = self.decapsulation_key.decapsulate(&ct);
        let mut out = [0u8; HQC_SHARED_SECRET_LEN];
        out.copy_from_slice(secret.as_ref());
        Ok(out)
    }
}

/// The other side of the HQC-256 exchange: encapsulate to a peer's public key.
///
/// Present for the same reason as [`encapsulate_to`] — so the round trip can be
/// driven end to end without a counterparty. An appliance holds the
/// decapsulation key; the relay encapsulates.
pub fn encapsulate_hqc_to(
    public_bytes: &[u8],
) -> Result<(Vec<u8>, [u8; HQC_SHARED_SECRET_LEN]), PqError> {
    if public_bytes.len() != hqc_kem::hqc256::PUBLIC_KEY_SIZE {
        return Err(PqError::PublicKeyLength { got: public_bytes.len() });
    }
    let ek = hqc_kem::hqc256::EncapsulationKey::try_from(public_bytes)
        .map_err(|_| PqError::PublicKeyLength { got: public_bytes.len() })?;
    let mut rng = rand::rng();
    let (ct, ss) = ek.encapsulate(&mut rng);
    let mut out = [0u8; HQC_SHARED_SECRET_LEN];
    out.copy_from_slice(ss.as_ref());
    Ok((ct.as_ref().to_vec(), out))
}

/// This end of an ML-KEM-1024 exchange: the decapsulation (private) key kept
/// on the device, and the encapsulation (public) key sent to the peer.
///
/// **The private half never leaves this struct**, which is why the struct
/// exists instead of a tuple: `Debug` prints the public key's length and
/// nothing else, so an ephemeral private key cannot reach a log through a
/// `{:?}` the way a derived `Debug` would let it.
pub struct EphemeralMlKem {
    decapsulation_key: ml_kem::DecapsulationKey<MlKem1024>,
    encapsulation_key: EncapsulationKey<MlKem1024>,
}

impl std::fmt::Debug for EphemeralMlKem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EphemeralMlKem")
            .field("decapsulation_key", &"[redacted]")
            .field("encapsulation_key_len", &self.public_bytes().len())
            .finish()
    }
}

impl EphemeralMlKem {
    /// Mint a fresh ML-KEM-1024 keypair from the OS RNG.
    ///
    /// Ephemeral in the strict sense: one exchange, one keypair. The value is
    /// meant to be consumed by [`EphemeralMlKem::receive`] and dropped.
    pub fn generate() -> EphemeralMlKem {
        let (decapsulation_key, encapsulation_key) = MlKem1024::generate_keypair();
        EphemeralMlKem {
            decapsulation_key,
            encapsulation_key,
        }
    }

    /// The public (encapsulation) key, as the bytes that go on the wire.
    pub fn public_bytes(&self) -> Vec<u8> {
        self.encapsulation_key.to_bytes().to_vec()
    }

    /// Decapsulate the peer's ciphertext into the shared secret.
    ///
    /// A ciphertext of the wrong length is refused rather than truncated. This
    /// matters more than it looks: ML-KEM's decapsulation is *implicitly
    /// rejecting* — a wrong-but-well-formed ciphertext yields a deterministic
    /// pseudorandom secret instead of an error, by design. So a length check is
    /// the only place a framing bug can be caught at all, and without it a
    /// mis-framed exchange produces two ends that each hold a different
    /// 32-byte "shared" secret and a tunnel that fails every handshake with no
    /// error anywhere.
    pub fn receive(&self, ciphertext: &[u8]) -> Result<[u8; ML_KEM_SHARED_SECRET_LEN], PqError> {
        let want = Self::ciphertext_len();
        if ciphertext.len() != want {
            return Err(PqError::CiphertextLength {
                got: ciphertext.len(),
                want,
            });
        }
        let ct: Ciphertext<MlKem1024> =
            Array::try_from(ciphertext).map_err(|_| PqError::CiphertextLength {
                got: ciphertext.len(),
                want,
            })?;
        let secret = self
            .decapsulation_key
            .try_decapsulate(&ct)
            .map_err(|_| PqError::Decapsulation)?;
        let mut out = [0u8; ML_KEM_SHARED_SECRET_LEN];
        out.copy_from_slice(&secret[..]);
        Ok(out)
    }

    /// The exact ML-KEM-1024 ciphertext length, taken from the implementation
    /// rather than written down as a number that could drift from it.
    pub fn ciphertext_len() -> usize {
        Ciphertext::<MlKem1024>::default().len()
    }
}

/// The other side of the exchange: encapsulate to a peer's public key.
///
/// Present so the round trip can be driven end to end in a test without a
/// counterparty. It is not what an appliance does — an appliance holds the
/// decapsulation key and the *relay* encapsulates — and saying so here keeps
/// anyone from wiring it in backwards.
pub fn encapsulate_to(
    public_bytes: &[u8],
) -> Result<(Vec<u8>, [u8; ML_KEM_SHARED_SECRET_LEN]), PqError> {
    let key: Key<EncapsulationKey<MlKem1024>> = Array::try_from(public_bytes)
        .map_err(|_| PqError::PublicKeyLength { got: public_bytes.len() })?;
    let ek = <EncapsulationKey<MlKem1024> as TryKeyInit>::new(&key)
        .map_err(|_| PqError::PublicKeyLength { got: public_bytes.len() })?;
    let (ct, secret) = ek.encapsulate();
    let mut out = [0u8; ML_KEM_SHARED_SECRET_LEN];
    out.copy_from_slice(&secret[..]);
    Ok((ct.to_vec(), out))
}

/// What can go wrong in the KEM half.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PqError {
    /// The ciphertext was not ML-KEM-1024's length. See
    /// [`EphemeralMlKem::receive`] for why this check is load-bearing.
    CiphertextLength {
        /// The length that arrived.
        got: usize,
        /// ML-KEM-1024's actual ciphertext length.
        want: usize,
    },
    /// The peer's encapsulation key was not the right length.
    PublicKeyLength {
        /// The length that arrived.
        got: usize,
    },
    /// The ciphertext was not HQC-256's length. Same reasoning as
    /// [`PqError::CiphertextLength`]: HQC decapsulation is implicitly rejecting.
    HqcCiphertextLength {
        /// The length that arrived.
        got: usize,
        /// HQC-256's actual ciphertext length.
        want: usize,
    },
    /// The implementation rejected the ciphertext outright.
    Decapsulation,
    /// **The PSK cannot be derived because the WIRE FRAMING is unknown.**
    ///
    /// Both KEMs are implemented. What is missing is Mullvad's transcript: the
    /// service the exchange is spoken to, the encoding, the order of the two
    /// secrets, and the KDF that combines them. Inventing one produces two ends
    /// that each hold a different 32 bytes and a tunnel that fails every
    /// handshake with no error anywhere — the same failure mode the length
    /// checks above exist to prevent, one layer up.
    ///
    /// This replaced an `Incomplete(HqcGap)` that blamed a missing algorithm.
    /// The algorithm was never missing; the survey that said so was wrong.
    NoFraming,
}

impl std::fmt::Display for PqError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PqError::CiphertextLength { got, want } => write!(
                f,
                "ML-KEM-1024 ciphertext is {got} bytes, expected {want} — decapsulation is \
                 implicitly rejecting, so a wrong length would otherwise yield a plausible \
                 32-byte secret that the other end does not share"
            ),
            PqError::PublicKeyLength { got } => {
                write!(f, "ML-KEM-1024 encapsulation key is {got} bytes, which is not its size")
            }
            PqError::HqcCiphertextLength { got, want } => write!(
                f,
                "HQC-256 ciphertext is {got} bytes, expected {want} — decapsulation is \
                 implicitly rejecting here too, so a wrong length would otherwise yield a \
                 plausible secret that the other end does not share"
            ),
            PqError::Decapsulation => f.write_str("ML-KEM-1024 decapsulation refused the ciphertext"),
            PqError::NoFraming => f.write_str(
                "the PSK cannot be derived: ML-KEM-1024 and HQC-256 are both implemented, but \
                 Mullvad's ephemeral-peer WIRE FRAMING is not — the service, the encoding, the \
                 order of the two secrets in the transcript and the KDF that combines them are \
                 not established by any reachable documented source, and a transcript invented \
                 here would produce a PSK the relay does not share. Observing a real exchange \
                 (an account, their own open client, a capture) is what closes this",
            ),
        }
    }
}

/// Derive the WireGuard pre-shared key from **both** KEM shared secrets.
///
/// Still returns [`PqError::NoFraming`] — but for a different reason than it
/// used to. Both halves now exist and both are exercised right here, so the
/// refusal is no longer "an algorithm is missing"; it is "we do not know the
/// transcript". That distinction is the whole point of this function's
/// existence: it names precisely what is absent, and what is absent is now one
/// packet capture away rather than a cryptographic implementation away.
///
/// The safety property is unchanged and is the reason nothing here is
/// "temporarily" wired up: **no code path in this crate produces a PSK and
/// hands it to a tunnel.** A combining step invented to make this compile would
/// yield a key the relay does not share, and a tunnel that fails every
/// handshake with no error anywhere — which is worse than refusing, because it
/// looks like it works until it does not.
pub fn derive_psk() -> Result<[u8; PSK_LEN], PqError> {
    // Both halves run. They are dropped unused, deliberately: this proves the
    // two implementations are present and callable, and it keeps the refusal
    // below from being the *only* thing the function does — a refusal that
    // never touched the code it refuses over would rot silently.
    let ml_kem = EphemeralMlKem::generate();
    let hqc = EphemeralHqc::generate();
    debug_assert!(!ml_kem.public_bytes().is_empty());
    debug_assert_eq!(hqc.public_bytes().len(), hqc_kem::hqc256::PUBLIC_KEY_SIZE);
    Err(PqError::NoFraming)
}

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

    /// The KEM half is real: a full ML-KEM-1024 round trip, both ends agreeing
    /// on the same 32 bytes.
    ///
    /// This is not a self-round-trip of our own encoder — the property being
    /// asserted is FIPS 203's, driven through the RustCrypto implementation,
    /// and it fails if either direction is wired wrong.
    #[test]
    fn an_ml_kem_1024_exchange_agrees_on_a_shared_secret() {
        let device = EphemeralMlKem::generate();
        let public = device.public_bytes();
        let (ciphertext, peer_secret) = encapsulate_to(&public).expect("encapsulates");
        let device_secret = device.receive(&ciphertext).expect("decapsulates");
        assert_eq!(device_secret, peer_secret);
        assert_eq!(device_secret.len(), ML_KEM_SHARED_SECRET_LEN);
        // …and it is not the identity value. A secret of all zeroes would
        // satisfy the equality above while proving nothing at all.
        assert_ne!(device_secret, [0u8; ML_KEM_SHARED_SECRET_LEN]);
    }

    /// RED-WHEN-BROKEN — two independent exchanges must not agree. Without
    /// this, a `receive` that returned a constant would pass the round-trip
    /// test above.
    #[test]
    fn two_exchanges_do_not_produce_the_same_secret() {
        let a = EphemeralMlKem::generate();
        let b = EphemeralMlKem::generate();
        assert_ne!(a.public_bytes(), b.public_bytes(), "fresh keypairs differ");
        let (ct_a, s_a) = encapsulate_to(&a.public_bytes()).unwrap();
        let (ct_b, s_b) = encapsulate_to(&b.public_bytes()).unwrap();
        assert_ne!(s_a, s_b);
        assert_ne!(ct_a, ct_b);
        // The crossed pair: `b`'s key against `a`'s ciphertext. ML-KEM is
        // IMPLICITLY REJECTING, so this does NOT error — it yields a different
        // secret, and that is precisely why length checking is the only
        // framing guard available.
        let crossed = b.receive(&ct_a).expect("implicit rejection is not an error");
        assert_ne!(
            crossed, s_a,
            "a ciphertext for another key must not decapsulate to that key's secret"
        );
    }

    /// RED-WHEN-BROKEN — the length guard. A truncated or padded ciphertext
    /// must error rather than produce a plausible secret.
    #[test]
    fn a_wrong_length_ciphertext_is_refused_rather_than_silently_wrong() {
        let device = EphemeralMlKem::generate();
        let (ct, _) = encapsulate_to(&device.public_bytes()).unwrap();
        let want = EphemeralMlKem::ciphertext_len();
        assert_eq!(ct.len(), want, "the round trip uses the length we check for");

        for wrong in [
            Vec::new(),
            ct[..ct.len() - 1].to_vec(),
            ct[1..].to_vec(),
            [ct.clone(), vec![0]].concat(),
            vec![0u8; 32],
        ] {
            let got = wrong.len();
            assert_eq!(
                device.receive(&wrong),
                Err(PqError::CiphertextLength { got, want }),
                "a {got}-byte ciphertext must be refused"
            );
        }
        // The identity guard: the correct length still works.
        assert!(device.receive(&ct).is_ok());
    }

    #[test]
    fn a_wrong_length_public_key_is_refused() {
        let device = EphemeralMlKem::generate();
        let public = device.public_bytes();
        assert!(encapsulate_to(&public).is_ok());
        for wrong in [Vec::new(), public[..public.len() - 1].to_vec(), vec![0u8; 32]] {
            let got = wrong.len();
            assert_eq!(
                encapsulate_to(&wrong).map(|_| ()),
                Err(PqError::PublicKeyLength { got })
            );
        }
    }

    /// The HQC-256 half is real: a full round trip, both ends on the same 32
    /// bytes. The property is HQC's, driven through `hqc-kem`, and it fails if
    /// either direction is wired wrong.
    #[test]
    fn an_hqc_256_exchange_agrees_on_a_shared_secret() {
        let device = EphemeralHqc::generate();
        let public = device.public_bytes();
        assert_eq!(public.len(), hqc_kem::hqc256::PUBLIC_KEY_SIZE);
        let (ciphertext, peer_secret) = encapsulate_hqc_to(&public).expect("encapsulates");
        assert_eq!(ciphertext.len(), hqc_kem::hqc256::CIPHERTEXT_SIZE);
        let device_secret = device.receive(&ciphertext).expect("decapsulates");
        assert_eq!(device_secret, peer_secret);
        // …and not the identity value, which would satisfy the equality above
        // while proving nothing.
        assert_ne!(device_secret, [0u8; HQC_SHARED_SECRET_LEN]);
    }

    /// RED-WHEN-BROKEN — two independent HQC exchanges must not agree. Without
    /// it, a `receive` returning a constant would pass the round trip above.
    #[test]
    fn two_hqc_exchanges_do_not_produce_the_same_secret() {
        let a = EphemeralHqc::generate();
        let b = EphemeralHqc::generate();
        assert_ne!(a.public_bytes(), b.public_bytes(), "fresh keypairs differ");
        let (_, s_a) = encapsulate_hqc_to(&a.public_bytes()).unwrap();
        let (_, s_b) = encapsulate_hqc_to(&b.public_bytes()).unwrap();
        assert_ne!(s_a, s_b);
    }

    /// A wrong-length HQC ciphertext is REFUSED, not truncated.
    ///
    /// This is the load-bearing check, not a tidiness one: HQC decapsulation is
    /// implicitly rejecting, so without the length test a mis-framed exchange
    /// yields a plausible 32-byte secret the other end does not share, and the
    /// tunnel fails every handshake with no error anywhere.
    #[test]
    fn a_wrong_length_hqc_ciphertext_is_refused() {
        let device = EphemeralHqc::generate();
        let want = hqc_kem::hqc256::CIPHERTEXT_SIZE;
        for got in [0usize, want - 1, want + 1] {
            assert_eq!(
                device.receive(&vec![0u8; got]).map(|_| ()),
                Err(PqError::HqcCiphertextLength { got, want }),
                "a {got}-byte ciphertext must be refused, not decapsulated"
            );
        }
    }

    /// **RED BY CONSTRUCTION — the FRAMING gap**, which is what is actually
    /// missing now that both KEMs are implemented.
    ///
    /// This test exists to fail the day somebody makes `derive_psk` return a
    /// key from a transcript nobody verified against a real exchange. It
    /// replaces one that asserted a missing ALGORITHM — a claim that was wrong
    /// when it was written and was repeated three times before being checked.
    #[test]
    fn the_psk_is_refused_because_the_wire_framing_is_unknown() {
        assert_eq!(derive_psk(), Err(PqError::NoFraming));
        // The refusal must SAY what is missing, so an operator reading a log
        // learns the reason rather than seeing "error".
        let msg = PqError::NoFraming.to_string();
        for expected in ["ML-KEM-1024", "HQC-256", "FRAMING", "transcript"] {
            assert!(msg.contains(expected), "the refusal names {expected}: {msg}");
        }
        // …and it must NOT still be blaming the algorithm that now exists.
        for stale in ["pqcrypto-hqc", "PQClean", "no pure-Rust"] {
            assert!(!msg.contains(stale), "the refusal still cites {stale}: {msg}");
        }
        // BOTH halves genuinely work, which is what makes the refusal a
        // statement about the protocol rather than about our own broken code.
        let d = EphemeralMlKem::generate();
        let (ct, s) = encapsulate_to(&d.public_bytes()).unwrap();
        assert_eq!(d.receive(&ct).unwrap(), s);
        let h = EphemeralHqc::generate();
        let (hct, hs) = encapsulate_hqc_to(&h.public_bytes()).unwrap();
        assert_eq!(h.receive(&hct).unwrap(), hs);
    }

    /// The HQC decapsulation key must not be printable either — the same hole
    /// that `wg-appliance-core 0.1.1` and `0.1.2` shipped, one layer out.
    #[test]
    fn debug_does_not_print_the_ephemeral_hqc_private_key() {
        let d = EphemeralHqc::generate();
        for rendered in [format!("{d:?}"), format!("{d:#?}")] {
            assert!(rendered.contains("[redacted]"), "{rendered}");
            assert!(!rendered.contains("decapsulation_key: ["), "{rendered}");
        }
    }

    /// The ephemeral private key must not be printable. A `{:?}` on a struct
    /// holding a decapsulation key used to be all it took to put one in a log.
    #[test]
    fn debug_does_not_print_the_ephemeral_private_key() {
        let d = EphemeralMlKem::generate();
        let rendered = format!("{d:?}");
        assert!(rendered.contains("[redacted]"), "{rendered}");
        // The public key's LENGTH is fine to print; its bytes are not in there
        // either, which is checked by looking for a long run of hex/base64.
        assert!(
            !rendered.contains(&format!("{:?}", d.public_bytes())),
            "the key bytes themselves are not rendered"
        );
    }
}