crates/gunnar-crypt/tests/pq_stress.rs
//! **The quantum-safe level may be slower; it must never crash.** A ring
//! with 500 hybrid members, thousands of random mutations of a real ring,
//! malformed and oversized stanzas: each is refused cleanly (an `Err`, never
//! a panic), and nothing is read past the 4 MiB bound.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Read as _;
use age::armor::ArmoredReader;
use age_core::format::Stanza;
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members};
use gunnar_crypt::policy::{self, Refusal, MAX_SCHEME_FILE};
use gunnar_crypt::pq;
use gunnar_crypt::wrap::{self, age, UnwrapError};
fn stanza(tag: &str, args: Vec<String>, body: Vec<u8>) -> Stanza {
Stanza {
tag: tag.to_string(),
args,
body,
}
}
/// A small deterministic generator (xorshift64*): the same mutations every run.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn quantum_safe(ids: &[pq::Identity]) -> Members {
let mut m = Members::new();
m.set_level(Level::QuantumSafe).unwrap();
for (i, id) in ids.iter().enumerate() {
m.add(&format!("m{i}"), &id.to_public().to_string())
.unwrap();
}
m
}
fn binary(armored: &str) -> Vec<u8> {
let mut out = Vec::new();
ArmoredReader::new(armored.as_bytes())
.read_to_end(&mut out)
.unwrap();
out
}
#[test]
fn a_500_member_quantum_safe_ring_wraps_checks_and_opens_for_the_last_member() {
let ids: Vec<pq::Identity> = (0..500).map(|_| pq::Identity::generate()).collect();
let members = quantum_safe(&ids);
let ring = Keyring::generate();
let file = wrap::wrap(&ring, &members).unwrap();
assert!(file.len() < MAX_SCHEME_FILE, "{} bytes", file.len());
let text = members.to_text();
assert_eq!(policy::check_ring(&text, file.as_bytes()), Ok(()));
// The last member: age tries every anonymous stanza before theirs. It
// must finish, and soon: a 500-member header is ~1 MB.
let last = ids[499].clone();
let (tx, rx) = std::sync::mpsc::channel();
let (f, t) = (file.clone(), text.clone());
std::thread::spawn(move || {
let _ = tx.send(wrap::unwrap_checked(
f.as_bytes(),
&t,
&[&last as &dyn age::Identity],
));
});
let opened = rx
.recv_timeout(std::time::Duration::from_secs(60))
.expect("the last of 500 members did not open the ring within 60 s");
assert_eq!(opened.unwrap(), ring);
// One fewer listed member: refused, by count.
let fewer = quantum_safe(&ids[..499]);
assert!(matches!(
policy::check_ring(&fewer.to_text(), file.as_bytes()),
Err(Refusal::RingMismatch(_))
));
}
/// Line `k` of `m` (split on `\n`) deleted, or doubled, and the lines joined again with
/// `\n` between them, so nothing else moves.
///
/// GUN-PQ-STRESS-FLAKE (found 2026-11-03, lane gun-rel, by 100 more random key sets in
/// release: 11 such rounds in one set): the old writer put a `\n` after EVERY piece,
/// the last one too. For a binary file that ends in `\n` (one in 256: the payload's last
/// byte is random) the last piece is empty, and deleting it wrote the file back
/// unchanged. age then rightly opened the unchanged file, and the count said `opened 1`.
/// No crypto fault: a round that changed nothing. Every round changes the file now.
fn line_deleted_or_doubled(m: &[u8], k: usize, delete: bool) -> Vec<u8> {
let mut lines: Vec<&[u8]> = m.split(|b| *b == b'\n').collect();
if delete {
lines.remove(k);
} else {
lines.insert(k, lines[k]);
}
lines.join(&b'\n')
}
#[test]
fn a_deleted_or_doubled_line_always_changes_the_file() {
// The trap: a file ending in `\n` has an empty last piece.
for base in [
&b"a\nb\n"[..],
b"a\nb",
b"\n",
b"a\n\n",
b"age\n-> x\n--- m\n\x00\n",
] {
let pieces = base.split(|b| *b == b'\n').count();
for k in 0..pieces {
for delete in [true, false] {
assert_ne!(
line_deleted_or_doubled(base, k, delete),
base,
"piece {k} of {base:?} {} left the file unchanged",
if delete { "deleted" } else { "doubled" }
);
}
}
}
}
#[test]
fn thousands_of_random_mutations_are_refused_cleanly_never_a_panic() {
let ids: Vec<pq::Identity> = (0..3).map(|_| pq::Identity::generate()).collect();
let members = quantum_safe(&ids).to_text();
let ring = Keyring::generate();
let armored = wrap::wrap(&ring, &quantum_safe(&ids)).unwrap();
let bin = binary(&armored);
let header_end = bin.windows(5).position(|w| w == b"\n--- ").unwrap() + 1;
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// `armor_only`: an ARMORED round whose change is armor whitespace only (below).
let (mut refused, mut opened, mut armor_only) = (0usize, 0usize, 0usize);
for round in 0..4000 {
let base: &[u8] = if round % 4 == 0 {
armored.as_bytes()
} else {
&bin
};
let mut m = base.to_vec();
// Mostly the header, where the stanzas are.
let span = if round % 4 == 0 { m.len() } else { header_end };
match round % 7 {
0 => {
let i = rng.below(span);
m[i] ^= 1 << rng.below(8);
}
1 => m.truncate(rng.below(m.len())),
2 => {
let i = rng.below(span);
let junk: Vec<u8> = (0..rng.below(64) + 1).map(|_| rng.next() as u8).collect();
m.splice(i..i, junk);
}
3 => {
// A base64 character of the hybrid enc replaced by ANOTHER.
let from = rng.below(span);
if let Some(i) = (from..span)
.chain(0..from)
.find(|&i| m[i].is_ascii_alphanumeric())
{
let was = m[i];
while m[i] == was {
m[i] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
[rng.below(64)];
}
}
}
4 => {
// A whole header line deleted or doubled.
let lines = m.split(|b| *b == b'\n').count();
let k = rng.below(lines.min(60));
let delete = rng.below(2) == 0;
m = line_deleted_or_doubled(&m, k, delete);
}
5 => {
let i = rng.below(span);
m.drain(i..(i + rng.below(32) + 1).min(m.len()));
}
_ => {
let i = rng.below(span);
m[i] = m[i].wrapping_add(1 + rng.below(255) as u8);
}
}
let _ = policy::age_stanzas(&m);
let _ = policy::check_ring(&members, &m);
match wrap::unwrap_checked(&m, &members, &[&ids[rng.below(3)] as &dyn age::Identity]) {
// age's header MAC: whatever still opens is the ring itself.
// Nothing changed may open (measured 2026-10-06: the earlier
// "opened" rounds had replaced a byte with itself or skipped a
// non-base64 byte — no change at all; with every round changing
// a byte, all 4,000 are refused). Should one ever open, it must
// be the same file underneath (armor whitespace only), or age's
// header MAC / STREAM let a change through: MALLEABLE.
Ok(got) => {
assert_eq!(
got, ring,
"round {round}: a mutated file opened to a different ring"
);
let mut under = Vec::new();
let same =
ArmoredReader::new(&m[..]).read_to_end(&mut under).is_ok() && under == bin;
assert!(
same,
"round {round}: MALLEABLE — a changed file (kind {}) still opened",
round % 7
);
// GUN-PQ-STRESS-FLAKE (lane gun-rel, 2026-11-03; 11 such rounds in 400
// random key sets): a truncation or deletion that takes only the armor's
// final newline leaves the same file underneath, and age rightly opens it.
// That is the "armor whitespace only" case above, not a change to the
// file: counted apart. A BINARY round has no such slack, so any binary
// round that opens still fails the count below.
if round % 4 == 0 {
armor_only += 1;
} else {
opened += 1;
}
}
Err(_) => refused += 1,
}
}
eprintln!(
"4000 mutations: {refused} refused, {opened} opened, {armor_only} armor whitespace only"
);
// Every round changes at least one byte, and every change to the file underneath is
// refused.
assert_eq!((refused + armor_only, opened), (4000, 0));
}
#[test]
fn malformed_hybrid_stanzas_are_refused_by_the_identity_itself() {
let id = pq::Identity::generate();
let good = {
let armored = wrap::wrap(
&Keyring::generate(),
&quantum_safe(std::slice::from_ref(&id)),
)
.unwrap();
policy::age_stanzas(armored.as_bytes())
.unwrap()
.into_iter()
.find(|s| s.tag == "mlkem768x25519")
.unwrap()
};
let arg = good.args[0].clone();
let mut rng = Rng(7);
let cases: Vec<Stanza> = vec![
stanza("mlkem768x25519", vec![], vec![0; 32]),
stanza("mlkem768x25519", vec![arg.clone(), "x".into()], vec![0; 32]),
stanza(
"mlkem768x25519",
vec![arg[..arg.len() - 4].to_string()],
vec![0; 32],
),
stanza("mlkem768x25519", vec![format!("{arg}AAAA")], vec![0; 32]),
stanza("mlkem768x25519", vec!["!!!!".into()], vec![0; 32]),
stanza("mlkem768x25519", vec![arg.clone()], vec![]),
stanza("mlkem768x25519", vec![arg.clone()], vec![0; 48]),
stanza("mlkem768x25519", vec![arg.clone()], vec![0; 32]),
stanza("mlkem768x25519", vec!["A".repeat(8 << 20)], vec![0; 32]),
stanza("mlkem768x25519", vec![arg.clone()], vec![0; 8 << 20]),
];
for (i, s) in cases.iter().enumerate() {
match age::Identity::unwrap_stanza(&id, s) {
None | Some(Err(_)) => {}
Some(Ok(_)) => panic!("case {i}: a malformed stanza gave a file key"),
}
}
// A thousand random args and bodies around the right sizes.
for _ in 0..1000 {
let len = 1494 + rng.below(9) - 4;
let a: String = (0..len)
.map(|_| {
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[rng.below(64)]
as char
})
.collect();
let body: Vec<u8> = (0..30 + rng.below(5)).map(|_| rng.next() as u8).collect();
let r = age::Identity::unwrap_stanza(&id, &stanza("mlkem768x25519", vec![a], body));
assert!(!matches!(r, Some(Ok(_))), "a random stanza gave a file key");
}
}
#[test]
fn an_oversized_ring_or_members_file_is_refused_before_it_is_parsed() {
let id = pq::Identity::generate();
let members = quantum_safe(std::slice::from_ref(&id)).to_text();
// A header whose one stanza argument is 5 MiB.
let mut big = b"age-encryption.org/v1\n-> mlkem768x25519 ".to_vec();
big.extend(std::iter::repeat_n(b'A', 5 << 20));
big.extend_from_slice(b"\n\n--- AAAA\n");
assert_eq!(policy::age_stanzas(&big), None);
assert!(
matches!(policy::check_ring(&members, &big), Err(Refusal::RingMismatch(ref w)) if w.contains("larger than 4 MiB"))
);
assert!(matches!(
wrap::unwrap_checked(&big, &members, &[&id as &dyn age::Identity]),
Err(UnwrapError::Mismatch(_))
));
// Under the bound but not the shape age writes: refused by shape.
let mut odd = b"age-encryption.org/v1\n-> mlkem768x25519 ".to_vec();
odd.extend(std::iter::repeat_n(b'A', 3 << 20));
odd.extend_from_slice(b"\n\n--- AAAA\n");
assert!(
matches!(policy::check_ring(&members, &odd), Err(Refusal::RingMismatch(ref w)) if w.contains("not the shape"))
);
// An oversized members file.
let huge = format!("{members}{}", "# padding\n".repeat((5 << 20) / 10));
let ring = wrap::wrap(
&Keyring::generate(),
&quantum_safe(std::slice::from_ref(&id)),
)
.unwrap();
assert!(
matches!(policy::check_ring(&huge, ring.as_bytes()), Err(Refusal::RingMismatch(ref w)) if w.contains("larger than 4 MiB"))
);
}