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
//! **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::at(gunnar_crypt::members::Level::Classic);
    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::at(gunnar_crypt::members::Level::Classic);
    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::at(gunnar_crypt::members::Level::Classic);
    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::at(gunnar_crypt::members::Level::Classic);
    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::at(gunnar_crypt::members::Level::Classic);
    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
    );
}