//! The quantum-safe level: age's hybrid ML-KEM-768 + X25519 recipient
//! (`gunnar_crypt::pq`) against the C2SP CCTV vectors, the per-repository
//! level, and the key ring checked against the members file without a key.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Read as _;
use std::path::Path;
use std::process::Command;
use age::armor::ArmoredReader;
use age::secrecy::ExposeSecret as _;
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{KeyKind, Level, Members, MembersError};
use gunnar_crypt::policy::{self, Refusal};
use gunnar_crypt::pq;
use gunnar_crypt::wrap::{self, UnwrapError, WrapError};
use sha2::{Digest as _, Sha256};
// ── the C2SP CCTV vectors ───────────────────────────────────────────────────
struct Vector {
name: String,
expect: String,
payload: Option<String>,
identities: Vec<String>,
file: Vec<u8>,
}
fn vectors() -> Vec<Vector> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/cctv-age");
let mut out = Vec::new();
let mut names: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.contains("hybrid"))
.collect();
names.sort();
for name in names {
let raw = std::fs::read(dir.join(&name)).unwrap();
let split = raw.windows(2).position(|w| w == b"\n\n").unwrap();
let head = std::str::from_utf8(&raw[..split]).unwrap();
let mut body = raw[split + 2..].to_vec();
let mut v = Vector {
name: name.clone(),
expect: String::new(),
payload: None,
identities: vec![],
file: vec![],
};
for line in head.lines() {
let (k, val) = line.split_once(": ").unwrap();
match k {
"expect" => v.expect = val.to_string(),
"payload" => v.payload = Some(val.to_string()),
"identity" => v.identities.push(val.to_string()),
"compressed" => {
assert_eq!(val, "zlib");
let mut d = Vec::new();
flate2::read::ZlibDecoder::new(&body[..])
.read_to_end(&mut d)
.unwrap();
body = d;
}
_ => {}
}
}
v.file = body;
out.push(v);
}
out
}
/// What age says of a vector with our identity: `success` (and the payload's
/// SHA-256), `no match`, or `header failure`.
fn outcome(v: &Vector) -> (String, Option<String>) {
let ids: Vec<Box<dyn age::Identity>> = v
.identities
.iter()
.map(|s| {
if s.starts_with("AGE-SECRET-KEY-PQ-") {
Box::new(s.parse::<pq::Identity>().unwrap()) as Box<dyn age::Identity>
} else {
Box::new(s.parse::<age::x25519::Identity>().unwrap()) as Box<dyn age::Identity>
}
})
.collect();
let decryptor = match age::Decryptor::new(ArmoredReader::new(&v.file[..])) {
Ok(d) => d,
Err(_) => return ("header failure".into(), None),
};
match decryptor.decrypt(ids.iter().map(|i| i.as_ref())) {
Ok(mut r) => {
let mut pt = Vec::new();
match r.read_to_end(&mut pt) {
Ok(_) => ("success".into(), Some(hex(&Sha256::digest(&pt)))),
Err(_) => ("payload failure".into(), None),
}
}
Err(age::DecryptError::NoMatchingKeys) => ("no match".into(), None),
Err(_) => ("header failure".into(), None),
}
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn every_cctv_hybrid_vector_gives_the_expected_outcome() {
let all = vectors();
assert_eq!(all.len(), 19, "the 19 hybrid vectors of C2SP CCTV");
let mut wrong = Vec::new();
for v in &all {
let (got, payload) = outcome(v);
if got != v.expect || (got == "success" && payload != v.payload) {
wrong.push(format!("{}: expected {}, got {got}", v.name, v.expect));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// The rage test pair (str4d/rage#632): an identity and the recipient it
/// gives, so the bech32 forms are pinned in both directions.
const TEST_IDENTITY: &str =
"AGE-SECRET-KEY-PQ-1XX76JRALNLXDMEW0CRK45QMCCH4X06SE84UN3VPM33W6HWDX0H3SK3ZQFR";
const TEST_RECIPIENT: &str = "age1pq1x34nzsvr0rxjsgdn8zgyhfe8j7ceq5r9rdelkjuh3y235jzxshfg87pzf5zrqtzdxz95paef6caq5aapdmwjjqpjfdyxnzr2zampc3uxy0dg4z2n2gm9su72p0pc3u0jvev55l694v78snxg3yzvcl7yda0eyytqj6a0ec477lnhcy5hzpz4zq3pxanve4cn62gqj3pjy5lqj9c6kyj4v2z8alktn8zh99970x79gjkv7522hv9kfz35zsnxhsx8wwtmu9cy3ftzjgwcp4sshn3llnylnpdsyz5jm72vefv4x5vfwytrefxg4wq3mv42wcrvkj742479zrxzpvp2p3e9fed9f0739vcu80r7ma28qfhnvlv4gfzel9q654dj3zmuvvz893azhxdvs9fxd0r7jzchzcfcs5mkyyjxhw0n2z6dvp9yn9qfdp29h0azxqyjw6v7fhyuzj7zel0uq6j9rd7wgrpz7mf5dnj43jwsgvrc8qcnhy7tu6dkdujuxzkp9xj43xe8h92ktre2a3u3s8mm5mrp9nr9pwkgtz4mdlq9hgn4fps4k57ff6wddn2fy23t47sm20r8km8sd2pcyyafnet8f0dajsrlyjeah4n3mssr6aseevuuskdvq5lzguyvpgwpta742c6698vgutzqgny8usfg0w2he7kq5vyxjd0f9hqg8xk26y9e4th0gezq92q4cpp5p2y9hf5f2cje5l0c3sa3a2qxmm38pxxvhxh99yzmfz0zk7r2s64nnwjhkfgfr3gf8xnmppcgmaykvh5sh6g7vk9790rf8ws0axmr2t7z8aae5fq2029uvcn2ghgt4fu4wgwdc0k0cz52qkvwmuzj8p8k5jgf3xzk5zmrkavjekjrpeq408xz3zxazwkc6tyfmhayrkfpjhwtz5mp8j8guqe43k2q6m2kte03vrw27y3wmqyu5etmt9dnkwcnnpmu9gz9dekfhdevf42ucshphnrk38ra6hx8w5f8q5ru0xdhrjxmwqf6cused7zc5xvq43r0zscjglpwlptpwydhqw64xz7ptjdyeyzpq2zkxtmzg29gzjpvzva4d3l0cenn9xs297wf4y4ukwrunf57xj6pm7nvrkwvtrt8hwcmgv8x7ajw7258ugf9wvkmk4052ekg87tw5vnx8nq2swyzv77v8yqlwsenvamr0zssknwts8rrhfuwj7ykysnq9jxy0uv3kuyt22djszjdtvpz6d0s0kwh8ryynddzud92emeyvvyqktd0jtj7rvvg5gch25v8smlvny3kvn5gagyz475ze2y6q466xqmz2n3hs77lddeqyta2nch5k2u5yacuk9ywnwfdzvyejnucz724hj77hrrmakm7pr3kxsrxq22ejexlud9fy2kdqmkg5yncz7jm5wv2qjk5w5kvcpqsry2yqffh2la52dxfjkjq5rzhjzeyn6dupn0qwtyv7s4lwg3xdarsdlwe2y3tujy480y7z39q259fzx6jhd2j0f5hagqpcpees7hzc2yrk5cy788uk3s7qvp5cpepx24gvws3m2g433exgwppnkjscec8qu4y9z9r7vccexjcjaen42245lmgmxmuavg9alej92322gvvyy2t6267v09ch64y0m53jff0vjj96s0ypk60hr3jw4myd6m5hpn3xjstx7tl2szhpr5qe8jj08ydjc4wy2rch2fhuy3pdfjax5awe9j99ly5hkntzz9fe5zatgjvzdd0kgtxs25njnajyf6ssekp7gelxquusn4pt25czh3scj68kq79wdn5tgm6yvm9nzavrg043x3msnygf8dweknw5jmqd0uvny6ttsn09508k0c55zfnegrm9efhxpfqdkmhh6gjtqmwze9pyyzk3tlhl53k2ykx3qheyty7saeq0d3fzv49zc0k";
#[test]
fn the_identity_and_recipient_encodings_are_pinned() {
let id: pq::Identity = TEST_IDENTITY.parse().unwrap();
let r = id.to_public().to_string();
assert_eq!(r, TEST_RECIPIENT);
assert_eq!(r.parse::<pq::Recipient>().unwrap().to_string(), r);
assert_eq!(id.to_secret_string().expose_secret(), TEST_IDENTITY);
assert_eq!(gunnar_crypt::members::classify(&r), Ok(KeyKind::AgePq));
// The shape a build without `pq-keys` (the server's gate) checks.
assert!(gunnar_crypt::members::looks_like_pq_key(&r));
assert!(!gunnar_crypt::members::looks_like_pq_key(&r[..r.len() - 1]));
}
/// The stanza this crate writes has the shape of the vectors' stanzas — the
/// same type, one 1120-byte argument, a 32-byte body — and the vectors' own
/// stanzas parse the same way through the server's header reader. (HPKE's
/// encapsulation is random, so bytes can only be pinned through decryption:
/// every vector above, and Go age opening what we write.)
#[test]
fn the_stanza_written_has_the_vectors_shape() {
let v = vectors().into_iter().find(|v| v.name == "hybrid").unwrap();
let theirs = policy::age_stanzas(&v.file).unwrap();
assert_eq!(theirs.len(), 1);
let id: pq::Identity = v.identities[0].parse().unwrap();
let mut members = Members::at(gunnar_crypt::members::Level::Classic);
members.set_level(Level::QuantumSafe).unwrap();
members.add("v", &id.to_public().to_string()).unwrap();
let ours = policy::age_stanzas(
wrap::wrap(&Keyring::generate(), &members)
.unwrap()
.as_bytes(),
)
.unwrap();
let ours: Vec<_> = ours
.into_iter()
.filter(|s| !s.tag.ends_with("-grease"))
.collect();
assert_eq!(ours.len(), 1);
for s in [&theirs[0], &ours[0]] {
assert_eq!(s.tag, "mlkem768x25519");
assert_eq!(s.args.len(), 1);
assert_eq!(s.args[0].len(), 1494, "1120 bytes, base64 without padding");
assert_eq!(s.body_len, 32);
}
}
// ── the quantum-safe level ──────────────────────────────────────────────────
fn pq_members(keys: &[(&str, &pq::Identity)]) -> Members {
let mut m = Members::at(gunnar_crypt::members::Level::Classic);
m.set_level(Level::QuantumSafe).unwrap();
for (name, id) in keys {
m.add(name, &id.to_public().to_string()).unwrap();
}
m
}
#[test]
fn a_quantum_safe_ring_round_trips_and_a_non_member_cannot_open_it() {
let (alice, bob, carol) = (
pq::Identity::generate(),
pq::Identity::generate(),
pq::Identity::generate(),
);
let members = pq_members(&[("alice", &alice), ("bob", &bob)]);
let ring = Keyring::generate();
let file = wrap::wrap(&ring, &members).unwrap();
let stanzas = policy::age_stanzas(file.as_bytes()).unwrap();
assert_eq!(
stanzas.iter().filter(|s| s.tag == "mlkem768x25519").count(),
2,
"one hybrid stanza per member key"
);
assert!(
stanzas
.iter()
.all(|s| s.tag == "mlkem768x25519" || s.tag.ends_with("-grease")),
"nothing classical: {stanzas:?}"
);
for id in [&alice, &bob] {
assert_eq!(
wrap::unwrap(file.as_bytes(), &[id as &dyn age::Identity]).unwrap(),
ring
);
}
assert!(matches!(
wrap::unwrap(file.as_bytes(), &[&carol as &dyn age::Identity]),
Err(UnwrapError::NotARecipient)
));
// The identity file `gunnar crypt keygen` writes reads back.
let text = pq::identity_file(&alice, "2026-10-06T00:00:00Z");
assert!(text.contains(&format!("# public key: {}", alice.to_public())));
let back = wrap::pq_identity(&text).unwrap();
assert_eq!(
wrap::unwrap(file.as_bytes(), &[back.as_ref()]).unwrap(),
ring
);
}
fn x25519() -> String {
age::x25519::Identity::generate().to_public().to_string()
}
#[test]
fn a_classical_key_is_refused_when_the_repository_is_quantum_safe() {
let alice_classic = x25519();
let alice_pq = pq::Identity::generate();
// The members file refuses an X25519 user key at the quantum-safe level…
let mut m = pq_members(&[("alice", &alice_pq)]);
let err = m.add("alice", &alice_classic).unwrap_err();
assert!(matches!(
err,
MembersError::WrongLevel {
level: Level::QuantumSafe,
..
}
));
assert!(
err.to_string().contains("quantum-safe encryption key"),
"{err}"
);
// …a level cannot be raised over one…
let mut classic = Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &alice_classic).unwrap();
assert!(classic.set_level(Level::QuantumSafe).is_err());
// …a classic repository refuses an age1pq key (age never mixes them)…
assert!(matches!(
classic.add("bob", &alice_pq.to_public().to_string()),
Err(MembersError::WrongLevel {
level: Level::Classic,
..
})
));
// …and no level wraps to an SSH key: it is an unlock method.
let ssh_line = gunnar_crypt::members::MemberKey {
name: "alice".into(),
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeFakeFakeFakeFakeFakeFakeFakeFakeFakeFake"
.into(),
kind: KeyKind::SshEd25519,
};
let why = match wrap::recipient(&ssh_line) {
Err(why) => why,
Ok(_) => panic!("an SSH key became a ring recipient"),
};
assert!(why.contains("unlock method"), "{why}");
let _ = WrapError::NoMembers;
}
#[test]
fn the_level_line_round_trips() {
let a = pq::Identity::generate();
let m = pq_members(&[("alice", &a)]);
let text = m.to_text();
assert!(text.lines().any(|l| l == "level quantum-safe"), "{text}");
let back = Members::parse(&text).unwrap();
assert_eq!(back.level(), Level::QuantumSafe);
assert_eq!(back, m);
assert_eq!(
Members::at(gunnar_crypt::members::Level::Classic)
.to_text()
.lines()
.filter(|l| l.starts_with("level"))
.count(),
0
);
assert!(matches!(
Members::parse("level post-quantum-3000\n"),
Err(MembersError::BadLevel(_))
));
}
// ── the key ring against the members file, without a key ───────────────────
/// The armored age file's binary form.
fn binary(file: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
ArmoredReader::new(file).read_to_end(&mut out).unwrap();
out
}
/// An age header (binary) with the stanza lines of `a` followed by those of
/// `b`, and `a`'s MAC: what a hand-edited ring looks like to a server, which
/// cannot check the MAC.
fn spliced(a: &str, b: &str) -> Vec<u8> {
fn parts(file: &str) -> (Vec<u8>, Vec<u8>) {
let bin = binary(file.as_bytes());
let start = bin.iter().position(|b| *b == b'\n').unwrap() + 1;
let mac = bin.windows(5).position(|w| w == b"\n--- ").unwrap() + 1;
(bin[start..mac].to_vec(), bin[mac..].to_vec())
}
let (stanzas_a, mac_and_payload) = parts(a);
let (stanzas_b, _) = parts(b);
let mut out = b"age-encryption.org/v1\n".to_vec();
out.extend(stanzas_a);
out.extend(stanzas_b);
out.extend(mac_and_payload);
out
}
#[test]
fn a_good_ring_matches_its_members_at_both_levels() {
let mut classic = Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &x25519()).unwrap();
classic.add("bob", &x25519()).unwrap();
classic.add("recovery", &x25519()).unwrap();
let file = wrap::wrap(&Keyring::generate(), &classic).unwrap();
assert_eq!(
policy::check_ring(&classic.to_text(), file.as_bytes()),
Ok(())
);
assert_eq!(
policy::check_ring(&classic.to_text(), &binary(file.as_bytes())),
Ok(())
);
let (p, q) = (pq::Identity::generate(), pq::Identity::generate());
let qs = pq_members(&[("alice", &p), ("recovery", &q)]);
let file = wrap::wrap(&Keyring::generate(), &qs).unwrap();
assert_eq!(policy::check_ring(&qs.to_text(), file.as_bytes()), Ok(()));
}
#[test]
fn an_extra_stanza_or_a_wrong_count_is_refused() {
let alice = age::x25519::Identity::generate();
let mut listed = Members::at(gunnar_crypt::members::Level::Classic);
listed.add("alice", &alice.to_public().to_string()).unwrap();
let mut wider = listed.clone();
wider.add("mallory", &x25519()).unwrap();
// An extra stanza: the ring is also wrapped to a key nobody listed.
let extra = wrap::wrap(&Keyring::generate(), &wider).unwrap();
let r = policy::check_ring(&listed.to_text(), extra.as_bytes());
assert!(
matches!(r, Err(Refusal::RingMismatch(ref w)) if w.contains("more X25519")),
"{r:?}"
);
// A listed key with no stanza (the count is short).
let short = wrap::wrap(&Keyring::generate(), &listed).unwrap();
let r = policy::check_ring(&wider.to_text(), short.as_bytes());
assert!(
matches!(r, Err(Refusal::RingMismatch(ref w)) if w.contains("have no stanza")),
"{r:?}"
);
// An SSH stanza (the old format) is refused in every repository.
let ssh_ring = {
let r: age::ssh::Recipient = include_str!("vectors/test_ed25519.pub")
.trim()
.parse()
.unwrap();
let enc =
age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient)).unwrap();
let mut out = Vec::new();
let mut w = enc.wrap_output(&mut out).unwrap();
std::io::Write::write_all(&mut w, b"x").unwrap();
w.finish().unwrap();
out
};
let r = policy::check_ring(&listed.to_text(), &ssh_ring);
assert!(
matches!(r, Err(Refusal::RingMismatch(ref w)) if w.contains("ssh-ed25519 stanza")),
"{r:?}"
);
// Anonymous hybrid stanzas: the count is what can be checked.
let (p, q) = (pq::Identity::generate(), pq::Identity::generate());
let one = pq_members(&[("alice", &p)]);
let two = pq_members(&[("alice", &p), ("bob", &q)]);
let wrapped_two = wrap::wrap(&Keyring::generate(), &two).unwrap();
let r = policy::check_ring(&one.to_text(), wrapped_two.as_bytes());
assert!(
matches!(r, Err(Refusal::RingMismatch(ref w)) if w.contains("more mlkem768x25519")),
"{r:?}"
);
// The client refuses such a ring before it unwraps anything.
assert!(matches!(
wrap::unwrap_checked(extra.as_bytes(), &listed.to_text(), &[&alice]),
Err(UnwrapError::Mismatch(_))
));
assert!(wrap::unwrap_checked(short.as_bytes(), &listed.to_text(), &[&alice]).is_ok());
}
#[test]
fn a_classical_or_mixed_ring_is_refused_in_a_quantum_safe_repository() {
let alice_pq = pq::Identity::generate();
let qs = pq_members(&[("alice", &alice_pq)]);
let mut classic = Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &x25519()).unwrap();
let classical = wrap::wrap(&Keyring::generate(), &classic).unwrap();
let r = policy::check_ring(&qs.to_text(), classical.as_bytes());
assert!(
matches!(r, Err(Refusal::ClassicalInQuantumSafe { ref stanza }) if stanza == "X25519"),
"{r:?}"
);
// Mixed: the hybrid stanza AND a classical one in one header (age never
// writes this; a hand-edited file can).
let hybrid = wrap::wrap(&Keyring::generate(), &qs).unwrap();
let mixed = spliced(&hybrid, &classical);
assert!(policy::age_stanzas(&mixed)
.unwrap()
.iter()
.any(|s| s.tag == "X25519"));
let r = policy::check_ring(&qs.to_text(), &mixed);
assert!(
matches!(r, Err(Refusal::ClassicalInQuantumSafe { .. })),
"{r:?}"
);
assert!(r
.unwrap_err()
.to_string()
.contains("quantum-safe encryption keys"));
}
// ── interop with Go age (v1.3+), when its binaries are at hand ──────────────
/// `GUNNAR_CRYPT_GO_AGE=<dir with age and age-keygen>` (Go age v1.3 or
/// newer): a ring this crate wraps opens with `age -d`, and a file `age`
/// encrypts to an `age-keygen -pq` key opens here. Without it, skipped.
#[test]
fn go_age_opens_what_we_wrap_and_we_open_what_it_wraps() {
let Some(dir) = std::env::var_os("GUNNAR_CRYPT_GO_AGE") else {
eprintln!("skipped: set GUNNAR_CRYPT_GO_AGE to a Go age v1.3+ directory");
return;
};
let bin = |name: &str| Path::new(&dir).join(name);
let tmp = tempfile::tempdir().unwrap();
// gunnar → Go age: our identity file, our wrapped ring.
let ours = pq::Identity::generate();
std::fs::write(
tmp.path().join("ours.key"),
pq::identity_file(&ours, "test").as_bytes(),
)
.unwrap();
let mut members = Members::at(gunnar_crypt::members::Level::Classic);
members.set_level(Level::QuantumSafe).unwrap();
members.add("me", &ours.to_public().to_string()).unwrap();
let ring = Keyring::generate();
std::fs::write(
tmp.path().join("keys"),
wrap::wrap(&ring, &members).unwrap(),
)
.unwrap();
let out = Command::new(bin("age"))
.args(["-d", "-i"])
.arg(tmp.path().join("ours.key"))
.arg(tmp.path().join("keys"))
.output()
.unwrap();
assert!(
out.status.success(),
"Go age: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
Keyring::parse(std::str::from_utf8(&out.stdout).unwrap()).unwrap(),
ring
);
// Go age derives the same public key from our identity file.
let y = Command::new(bin("age-keygen"))
.arg("-y")
.arg(tmp.path().join("ours.key"))
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&y.stdout).trim(),
ours.to_public().to_string()
);
// Go age → gunnar: its `-pq` key, its file.
let k = Command::new(bin("age-keygen"))
.args(["-pq", "-o"])
.arg(tmp.path().join("go.key"))
.output()
.unwrap();
assert!(k.status.success(), "{}", String::from_utf8_lossy(&k.stderr));
let theirs_text = std::fs::read_to_string(tmp.path().join("go.key")).unwrap();
let theirs = pq::parse_identity_file(&theirs_text).unwrap();
let y = Command::new(bin("age-keygen"))
.arg("-y")
.arg(tmp.path().join("go.key"))
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&y.stdout).trim(),
theirs.to_public().to_string()
);
std::fs::write(tmp.path().join("payload"), b"a payload Go age sealed\n").unwrap();
let e = Command::new(bin("age"))
.args(["-a", "-r", &theirs.to_public().to_string(), "-o"])
.arg(tmp.path().join("go.age"))
.arg(tmp.path().join("payload"))
.output()
.unwrap();
assert!(e.status.success(), "{}", String::from_utf8_lossy(&e.stderr));
let file = std::fs::read(tmp.path().join("go.age")).unwrap();
let stanzas = policy::age_stanzas(&file).unwrap();
assert!(
stanzas.iter().any(|s| s.tag == "mlkem768x25519"),
"{stanzas:?}"
);
let decryptor = age::Decryptor::new(ArmoredReader::new(&file[..])).unwrap();
let mut r = decryptor
.decrypt(std::iter::once(&theirs as &dyn age::Identity))
.unwrap();
let mut pt = Vec::new();
r.read_to_end(&mut pt).unwrap();
assert_eq!(pt, b"a payload Go age sealed\n");
eprintln!("GO-AGE-INTEROP-OK both ways");
}