//! **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"
);
}
}