Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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