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.rs

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