gunnar-crypt 1.6.0

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
//! **Quantum-safe is the default** (lane CRYPT-PQ-DEFAULT, owner 2026-10-30): a
//! new repository's members file and a new person's key are quantum-safe unless
//! asked otherwise; a members file WITHOUT a level line stays classic, so an
//! existing repository keeps its level; a classic repository becomes
//! quantum-safe only when every member has a quantum-safe key; and every unlock
//! method opens a quantum-safe key, each saying how strong its own wrap is.

#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::wasm_bindgen_test as test;

use std::collections::BTreeMap;

use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members};
use gunnar_crypt::userkey::{self, recovery_code, security_key, UserKey};
use gunnar_crypt::wrap;

/// ★ A new repository and a new key are quantum-safe unless asked otherwise.
#[test]
fn a_new_repository_and_a_new_key_are_quantum_safe_by_default() {
    assert_eq!(Level::NEW, Level::QuantumSafe);
    assert_eq!(
        Level::default(),
        Level::NEW,
        "the API's default is the new default"
    );
    assert_eq!(
        Level::UNSTATED,
        Level::Classic,
        "a file without a level line reads classic"
    );
    let fresh = Members::new();
    assert_eq!(
        fresh.level(),
        Level::QuantumSafe,
        "a new members file is quantum-safe"
    );
    assert!(
        fresh.to_text().lines().any(|l| l == "level quantum-safe"),
        "{}",
        fresh.to_text()
    );
    assert_eq!(
        Members::at(Level::Classic).level(),
        Level::Classic,
        "classic is a visible choice"
    );
    assert!(
        !Members::at(Level::Classic).to_text().contains("level"),
        "a classic file keeps its old shape"
    );
    let key = UserKey::generate(Level::NEW).unwrap();
    assert!(
        key.public().starts_with("age1pq1"),
        "a new key is quantum-safe: {}",
        key.public()
    );
    assert_eq!("classic".parse::<Level>().unwrap(), Level::Classic);
    assert_eq!("quantum-safe".parse::<Level>().unwrap(), Level::QuantumSafe);
    let bad = "post-quantum".parse::<Level>().unwrap_err();
    assert!(
        bad.contains("classic") && bad.contains("quantum-safe"),
        "{bad}"
    );
}

/// ★ An existing repository keeps its level: a members file without a level
/// line is classic, as it always was; nothing is migrated.
#[test]
fn a_members_file_without_a_level_line_stays_classic() {
    let classic = UserKey::generate(Level::Classic).unwrap();
    let text = format!("# gunnar-crypt members v1\nalice {}\n", classic.public());
    let m = Members::parse(&text).unwrap();
    assert_eq!(m.level(), Level::Classic);
    assert_eq!(
        Members::parse(&m.to_text()).unwrap(),
        m,
        "it round-trips classic"
    );
}

/// ★ `gunnar crypt level quantum-safe`: a classic repository is re-wrapped to
/// quantum-safe keys only when EVERY member has one; a member without is named,
/// and nothing changes.
#[test]
fn a_classic_repository_becomes_quantum_safe_only_with_a_pq_key_for_every_member() {
    let (a, b) = (
        UserKey::generate(Level::Classic).unwrap(),
        UserKey::generate(Level::Classic).unwrap(),
    );
    let mut m = Members::at(Level::Classic);
    m.add("alice", &a.public()).unwrap();
    m.add("bob", &b.public()).unwrap();
    let (pa, pb) = (
        UserKey::generate(Level::QuantumSafe).unwrap(),
        UserKey::generate(Level::QuantumSafe).unwrap(),
    );

    let mut only_alice = BTreeMap::new();
    only_alice.insert("alice".to_string(), pa.public());
    let refused = m.to_quantum_safe(&only_alice).unwrap_err();
    assert_eq!(refused.missing, vec!["bob".to_string()]);
    let words = refused.to_string();
    assert!(
        words.contains("bob") && words.contains("age1pq1"),
        "{words}"
    );

    let mut classic_for_bob = only_alice.clone();
    classic_for_bob.insert("bob".to_string(), b.public());
    let refused = m.to_quantum_safe(&classic_for_bob).unwrap_err();
    assert_eq!(
        refused.missing,
        vec!["bob".to_string()],
        "a classic key is no quantum-safe key"
    );

    let mut both = only_alice;
    both.insert("bob".to_string(), pb.public());
    let up = m.to_quantum_safe(&both).unwrap();
    assert_eq!(up.level(), Level::QuantumSafe);
    assert_eq!(
        up.names(),
        vec!["alice", "bob"],
        "the same members, in the same order"
    );
    assert!(up.entries().iter().all(|e| e.key.starts_with("age1pq1")));
    assert_eq!(
        m.level(),
        Level::Classic,
        "the classic file itself is untouched"
    );

    // The ring, rotated and re-wrapped, opens with the quantum-safe keys only,
    // and holds every epoch the classic ring held.
    let mut ring = Keyring::generate();
    let before = ring.clone();
    ring.rotate();
    let keys = wrap::wrap(&ring, &up).unwrap();
    let opened = wrap::unwrap_checked(keys.as_bytes(), &up.to_text(), &[pb.identity()]).unwrap();
    assert_eq!(opened, ring);
    before.check_continuity(&opened).unwrap();
    assert!(
        wrap::unwrap_checked(keys.as_bytes(), &up.to_text(), &[b.identity()]).is_err(),
        "bob's classic key opens nothing"
    );

    let already = up.to_quantum_safe(&BTreeMap::new()).unwrap();
    assert_eq!(already, up, "a quantum-safe file keeps its own keys");
}

/// A TEST PRF output: what a security key would answer.
fn prf(fill: u8) -> [u8; 32] {
    [fill; 32]
}

/// ★ Every unlock method opens a quantum-safe key: the security key (PRF /
/// hmac-secret through HKDF), the `age1pq1…` key file and the recovery code —
/// and each method says whether its own wrap is quantum-safe.
#[test]
fn a_quantum_safe_key_opens_with_its_security_key_key_file_and_recovery_code() {
    let user = UserKey::generate(Level::NEW).unwrap();
    let mut members = Members::new();
    members.add("alice", &user.public()).unwrap();
    let ring = Keyring::generate();
    let keys = wrap::wrap(&ring, &members).unwrap();

    let cred = b"credential-pq";
    let sk = user
        .wrap_to_security_key(&security_key::recipient(&prf(9), cred))
        .unwrap();
    let k = userkey::open(
        sk.as_bytes(),
        &security_key::identity(&prf(9), cred),
        &user.public(),
    )
    .unwrap();
    assert_eq!(
        wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(),
        ring,
        "security key"
    );

    let file = gunnar_crypt::pq::Identity::generate();
    let w = user.wrap_to_pq(&file.to_public().to_string()).unwrap();
    let k = userkey::open(w.as_bytes(), &file, &user.public()).unwrap();
    assert_eq!(
        wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(),
        ring,
        "age1pq1 key file"
    );

    let code = recovery_code::generate();
    let w = user.wrap_to_recovery_code(&code).unwrap();
    let k = userkey::open_with_recovery_code(w.as_bytes(), &code, &user.public()).unwrap();
    assert_eq!(
        wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(),
        ring,
        "recovery code"
    );

    assert!(userkey::wrap_is_quantum_safe("age-pq", ""));
    assert!(userkey::wrap_is_quantum_safe("recovery-code", ""));
    assert!(!userkey::wrap_is_quantum_safe("ssh", ""));
    // CRYPT-PQ-SK: the security key's v1 wrap (age X25519) is not; its v2 wrap is.
    assert!(!userkey::wrap_is_quantum_safe("security-key", &sk));
    let v2 = user.wrap_to_security_key_v2(&prf(9), cred).unwrap();
    assert!(userkey::wrap_is_quantum_safe("security-key", &v2));
    assert!(
        !userkey::wrap_is_quantum_safe("something-else", &v2),
        "an unknown kind is never called quantum-safe"
    );
}

/// ★ The SSH unlock method wraps a quantum-safe key too, with a CLASSICAL
/// stanza — and the words the CLI and the console show say so plainly. The key
/// stays quantum-safe; nothing falls back to a classic key.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_ssh_wrap_of_a_quantum_safe_key_is_classical_and_says_so() {
    const TEST_KEY: &str = include_str!("vectors/test_ed25519");
    const TEST_KEY_PUB: &str = include_str!("vectors/test_ed25519.pub");
    let user = UserKey::generate(Level::NEW).unwrap();
    let w = user.wrap_to_ssh(TEST_KEY_PUB).unwrap();
    let stanzas = gunnar_crypt::policy::age_stanzas(w.as_bytes()).unwrap();
    assert!(
        stanzas.iter().any(|s| s.tag == "ssh-ed25519"),
        "{stanzas:?}"
    );
    let ssh = wrap::ssh_identity(TEST_KEY.as_bytes(), None, wrap::NoPassphrase).unwrap();
    let k = userkey::open(w.as_bytes(), ssh.as_ref(), &user.public()).unwrap();
    assert_eq!(
        k.level(),
        Level::QuantumSafe,
        "the key inside is the quantum-safe one"
    );
    let words = userkey::SSH_WRAP_IS_CLASSICAL;
    for needed in [
        "not quantum-safe",
        "X25519",
        "RSA",
        "gunnar crypt key add-pq",
        "recovery code",
        "gunnar crypt key remove",
    ] {
        assert!(words.contains(needed), "{needed:?} missing from: {words}");
    }
}