gunnar-crypt 1.5.1

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
//! **The user's encryption key** (CRYPT-YUBI, owner 2026-10-26): one key per
//! user, opened by any one unlock method; a repository's ring is wrapped only
//! to members' user public keys. Runs native and wasm32 (`tests/wasm-cross.sh`).

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

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

/// The TEST SSH key of `tests/vectors/` (used nowhere else).
#[cfg(not(target_arch = "wasm32"))]
const TEST_KEY: &str = include_str!("vectors/test_ed25519");
const TEST_KEY_PUB: &str = include_str!("vectors/test_ed25519.pub");

fn unhex(s: &str) -> Vec<u8> {
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}

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

/// ★ The derivation against an independent implementation
/// (`tests/vectors/check-security-key.py`: Python `cryptography`, no gunnar
/// code): the same key and credential always give the same recipient.
#[test]
fn a_security_key_gives_the_vector_recipient_on_this_target() {
    let p: [u8; 32] = std::array::from_fn(|i| i as u8);
    let cred: Vec<u8> = (1..=16).collect();
    assert_eq!(security_key::recipient(&p, &cred), "age163l93dqcd6xl9yc689yhcmmhdjva74a5haahfj30khnlc7j9qy9str6pg0");
    let cred: Vec<u8> = (0..64).collect();
    assert_eq!(security_key::recipient(&prf(0xA5), &cred), "age1zc0xvt6zgy283sz0dpjfculu3stwkc4u4hvmd8gde0h7pmh4g5wqkweyay");
    assert_eq!(security_key::recipient(&prf(0xA5), &cred), security_key::recipient(&prf(0xA5), &cred), "fixed per credential");
    assert_ne!(security_key::recipient(&prf(0xA5), &cred), security_key::recipient(&prf(0xA5), &cred[..63]), "the credential id is context");
    assert_eq!(
        security_key::hmac_secret_salt().to_vec(),
        unhex("fdac5f81e153833ee47bdb6ddbd5e912c26d2e3ad614d73295f23efc611d5e3b"),
        "the CTAP2 salt is WebAuthn's PRF salt, so the CLI and the browser get one secret"
    );
}

/// ★ Open with a security key only: enrol (the recipient), wrap, open with the
/// same PRF secret; another security key is refused by name.
#[test]
fn a_user_key_opens_with_its_security_key_and_not_with_another() {
    let user = UserKey::generate(Level::Classic).unwrap();
    let cred = b"credential-1";
    let wrap = user.wrap_to_security_key(&security_key::recipient(&prf(7), cred)).unwrap();
    let opened = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(7), cred), &user.public()).unwrap();
    assert_eq!(opened.public(), user.public());
    let other = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(8), cred), &user.public());
    assert_eq!(other.unwrap_err(), UserKeyError::WrongKey);
    let other_cred = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(7), b"credential-2"), &user.public());
    assert_eq!(other_cred.unwrap_err(), UserKeyError::WrongKey);
    assert!(UserKeyError::WrongKey.to_string().contains("not one of your unlock methods"));
}

/// ★ A wrap that holds a DIFFERENT user key than the account publishes (one
/// replaced on the server) is refused, though it opens.
#[test]
fn a_substituted_wrap_is_refused() {
    let user = UserKey::generate(Level::Classic).unwrap();
    let planted = UserKey::generate(Level::Classic).unwrap();
    let wrap = planted.wrap_to_security_key(&security_key::recipient(&prf(1), b"c")).unwrap();
    let r = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(1), b"c"), &user.public());
    assert_eq!(r.unwrap_err(), UserKeyError::NotThisAccountsKey);
}

/// ★ Method-agnostic: a ring wrapped to the user's public key opens through
/// either method, and adding a method touches no repository (the ring's bytes
/// are the same before and after).
#[test]
fn any_method_opens_every_ring_and_adding_one_touches_no_repository() {
    let user = UserKey::generate(Level::Classic).unwrap();
    let ring = Keyring::generate();
    let mut members = Members::new();
    members.add("alice", &user.public()).unwrap();
    let keys = wrap::wrap(&ring, &members).unwrap();
    let keys_before = keys.clone();

    let by_key_one = user.wrap_to_security_key(&security_key::recipient(&prf(1), b"one")).unwrap();
    // A second security key, enrolled while the first is open.
    let open_one = userkey::open(by_key_one.as_bytes(), &security_key::identity(&prf(1), b"one"), &user.public()).unwrap();
    let by_key_two = open_one.wrap_to_security_key(&security_key::recipient(&prf(2), b"two")).unwrap();
    for (w, id) in [(&by_key_one, security_key::identity(&prf(1), b"one")), (&by_key_two, security_key::identity(&prf(2), b"two"))] {
        let k = userkey::open(w.as_bytes(), &id, &user.public()).unwrap();
        let got = wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap();
        assert_eq!(got, ring);
    }
    assert_eq!(keys, keys_before, "no repository was touched");
}

/// ★ Add a member offline: the ring is wrapped to bob's PUBLIC user key; bob
/// is not present, and opens it later with his own method.
#[test]
fn a_member_is_added_without_being_present() {
    let alice = UserKey::generate(Level::Classic).unwrap();
    let bob = UserKey::generate(Level::Classic).unwrap();
    let bob_public = bob.public();
    let ring = Keyring::generate();
    let mut members = Members::new();
    members.add("alice", &alice.public()).unwrap();
    members.add("bob", &bob_public).unwrap();
    let keys = wrap::wrap(&ring, &members).unwrap();
    let bob_wrap = bob.wrap_to_recovery_code(&recovery_code::from_bytes(&[3; 20])).unwrap();
    drop(bob);
    let bob = userkey::open_with_recovery_code(bob_wrap.as_bytes(), &recovery_code::from_bytes(&[3; 20]), &bob_public).unwrap();
    assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[bob.identity()]).unwrap(), ring);
    let stranger = UserKey::generate(Level::Classic).unwrap();
    assert!(matches!(
        wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[stranger.identity()]),
        Err(UnwrapError::NotARecipient)
    ));
}

/// ★ A repository never names an SSH key: it is refused by name as a member.
#[test]
fn an_ssh_key_is_refused_as_a_member_by_name() {
    let mut m = Members::new();
    let e = m.add("alice", TEST_KEY_PUB.trim()).unwrap_err();
    assert_eq!(e, MembersError::SshKeyIsAMethod { name: "alice".into() });
    assert!(e.to_string().contains("encryption key"), "{e}");
    let e = m.add("alice", "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t").unwrap_err();
    assert_eq!(e, MembersError::SshKeyIsAMethod { name: "alice".into() });
}

/// ★ A recovery code: generated codes round-trip, a typed one is normalised,
/// a wrong one is refused by name, a malformed one too.
#[test]
fn a_recovery_code_opens_and_a_wrong_one_is_named() {
    let code = recovery_code::from_bytes(&[0xFF; 20]);
    assert_eq!(code.len(), 32 + 7);
    assert_eq!(&*recovery_code::normalize(&code.to_lowercase().replace('-', " ")).unwrap(), &code.replace('-', ""));
    let user = UserKey::generate(Level::Classic).unwrap();
    let wrap = user.wrap_to_recovery_code(&code).unwrap();
    assert!(userkey::open_with_recovery_code(wrap.as_bytes(), &code, &user.public()).is_ok());
    let wrong = recovery_code::from_bytes(&[0xFE; 20]);
    assert_eq!(userkey::open_with_recovery_code(wrap.as_bytes(), &wrong, &user.public()).unwrap_err(), UserKeyError::WrongKey);
    assert_eq!(userkey::open_with_recovery_code(wrap.as_bytes(), "1234", &user.public()).unwrap_err(), UserKeyError::BadRecoveryCode);
}

/// ★ A wrap that opens to something else than a user key is never taken.
#[test]
fn a_wrap_that_holds_no_user_key_is_refused() {
    let id = security_key::identity(&prf(9), b"c");
    let mut out = Vec::new();
    {
        use std::io::Write as _;
        let r = id.to_public();
        let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(&r as &dyn gunnar_crypt::wrap::age::Recipient)).unwrap();
        let mut w = enc.wrap_output(&mut out).unwrap();
        w.write_all(b"gunnar-crypt keyring v1\n").unwrap();
        w.finish().unwrap();
    }
    assert_eq!(userkey::open(&out, &id, "age1x").unwrap_err(), UserKeyError::NotAUserKey);
    assert!(matches!(userkey::open(b"not age", &id, "age1x"), Err(UserKeyError::Malformed(_))));
}

/// ★ The SSH unlock method (the CLI): the user key wrapped to the TEST SSH
/// public key opens with its private key FILE, and the ring with it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_ssh_key_file_opens_the_user_key_and_every_ring() {
    let user = UserKey::generate(Level::Classic).unwrap();
    let w = user.wrap_to_ssh(TEST_KEY_PUB).unwrap();
    assert!(gunnar_crypt::policy::age_stanzas(w.as_bytes()).unwrap().iter().any(|s| s.tag == "ssh-ed25519"));
    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();
    let ring = Keyring::generate();
    let mut members = Members::new();
    members.add("alice", &user.public()).unwrap();
    let keys = wrap::wrap(&ring, &members).unwrap();
    assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(), ring);
    let stanzas = gunnar_crypt::policy::age_stanzas(keys.as_bytes()).unwrap();
    assert!(stanzas.iter().all(|s| s.tag == "X25519" || s.tag.ends_with("-grease")), "the ring names no SSH key: {stanzas:?}");
}

/// ★ A quantum-safe user key (the CLI): a quantum-safe ring opens with it, and
/// it travels inside a wrap like a classic one.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_quantum_safe_user_key_opens_a_quantum_safe_ring() {
    let user = UserKey::generate(Level::QuantumSafe).unwrap();
    assert!(user.public().starts_with("age1pq1"));
    let w = user.wrap_to_ssh(TEST_KEY_PUB).unwrap();
    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();
    let mut members = Members::new();
    members.set_level(Level::QuantumSafe).unwrap();
    members.add("alice", &k.public()).unwrap();
    let ring = Keyring::generate();
    let keys = wrap::wrap(&ring, &members).unwrap();
    assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(), ring);
}