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 SAME vectors, run native (`cargo test`) AND wasm32
//! (`cargo test --target wasm32-unknown-unknown`, wasm-bindgen-test under
//! node). This is the proof that the CLI and the browser console speak one
//! format:
//!
//! * native → wasm: the envelopes and the key file in `tests/vectors/` were
//!   written by the native build; the wasm build opens and unwraps them.
//! * wasm → native: sealing is deterministic, so the wasm build sealing the
//!   same plaintexts must produce the very bytes the native build opens. The
//!   age wrap is randomised, so `tests/wasm-cross.sh` carries the key file the
//!   wasm build wraps over to the native build, which must unwrap it.
//!
//! * the browser's whole path: a user key wrapped to a security key's
//!   recipient (`user-key-security-key.age`), opened with the TEST PRF output,
//!   then the ring (`keys.age`, wrapped to that user key) unwrapped with it.
//!
//! All keys here are TEST keys, made for these vectors and used nowhere else.

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

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::Members;
use gunnar_crypt::{envelope, userkey, wrap};

const KEYRING: &str = include_str!("vectors/keyring.txt");
const CASES: &str = include_str!("vectors/cases.txt");
const TEST_KEY: &str = include_str!("vectors/test_ed25519");
const KEYS_FILE: &str = include_str!("vectors/keys.age");
/// The TEST user key the ring in `keys.age` is wrapped to.
const TEST_USER_KEY: &str = include_str!("vectors/test_user_key");
/// That user key wrapped to the TEST SSH key, and to the TEST security key.
const USER_KEY_SSH: &str = include_str!("vectors/user-key-ssh.age");
const USER_KEY_SK: &str = include_str!("vectors/user-key-security-key.age");
/// The TEST security key: its PRF output and credential id.
const TEST_PRF: [u8; 32] = [0x5A; 32];
const TEST_CREDENTIAL: &[u8] = b"gunnar-crypt test credential";
const DIFF_OLD: &str = include_str!("vectors/diff_old.txt");
const DIFF_NEW: &str = include_str!("vectors/diff_new.txt");
const DIFF_UNIFIED: &str = include_str!("vectors/diff_unified.txt");

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

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()
}

/// `(name, plaintext, envelope)` for every case.
fn cases() -> Vec<(String, Vec<u8>, Vec<u8>)> {
    CASES
        .lines()
        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
        .map(|l| {
            let mut p = l.split(' ');
            let name = p.next().unwrap().to_string();
            let plain = B64.decode(p.next().unwrap()).unwrap();
            let env = unhex(p.next().unwrap());
            (name, plain, env)
        })
        .collect()
}

fn ring() -> Keyring {
    Keyring::parse(KEYRING).unwrap()
}

#[test]
fn the_vector_envelopes_open_on_this_target() {
    let ring = ring();
    for (name, plain, env) in cases() {
        assert_eq!(envelope::open(&ring, &env).unwrap(), plain, "case {name}");
    }
}

/// These are v1 envelopes (whole file), which every gunnar still opens; new
/// seals are v2 (`tests/chunked.rs`, `vectors/cases-v2.txt`).
#[test]
fn sealing_v1_on_this_target_gives_the_v1_vector_bytes() {
    let ring = ring();
    for (name, plain, env) in cases() {
        let epoch = envelope::epoch_of(&env).unwrap();
        let sealed = envelope::seal_v1(ring.get(epoch).unwrap(), &plain);
        assert_eq!(hex(&sealed), hex(&env), "case {name}");
    }
}

fn user_public() -> String {
    test_user_key().public()
}

fn test_user_key() -> userkey::UserKey {
    userkey::UserKey::X25519(TEST_USER_KEY.trim().parse().unwrap())
}

fn members() -> Members {
    let mut m = Members::at(gunnar_crypt::members::Level::Classic);
    m.add("vector", &user_public()).unwrap();
    m
}

#[test]
fn the_vector_key_file_unwraps_on_this_target() {
    let got = wrap::unwrap_checked(
        KEYS_FILE.as_bytes(),
        &members().to_text(),
        &[test_user_key().identity()],
    )
    .unwrap();
    assert_eq!(got, ring());
}

/// ★ The browser's path on the vectors: security key → user key → ring.
#[test]
fn the_vector_user_key_opens_with_the_security_key_and_then_the_ring() {
    let id = userkey::security_key::identity(&TEST_PRF, TEST_CREDENTIAL);
    let user = userkey::open(USER_KEY_SK.as_bytes(), &id, &user_public()).unwrap();
    let got = wrap::unwrap_checked(
        KEYS_FILE.as_bytes(),
        &members().to_text(),
        &[user.identity()],
    )
    .unwrap();
    assert_eq!(got, ring());
}

/// ★ The CLI's path on the vectors: SSH key file → user key → ring.
#[test]
fn the_vector_user_key_opens_with_the_ssh_key_file() {
    let id = wrap::ssh_identity(TEST_KEY.as_bytes(), None, wrap::NoPassphrase).unwrap();
    let user = userkey::open(USER_KEY_SSH.as_bytes(), id.as_ref(), &user_public()).unwrap();
    assert_eq!(user.public(), user_public());
}

#[test]
fn a_key_file_wrapped_on_this_target_unwraps_and_is_carried_across() {
    let file = wrap::wrap(&ring(), &members()).unwrap();
    assert_eq!(
        wrap::unwrap(file.as_bytes(), &[test_user_key().identity()]).unwrap(),
        ring()
    );
    // And the user key, wrapped here to the TEST security key.
    let sk = test_user_key()
        .wrap_to_security_key(&userkey::security_key::recipient(
            &TEST_PRF,
            TEST_CREDENTIAL,
        ))
        .unwrap();
    // tests/wasm-cross.sh picks these lines up from the wasm run and hands them
    // to the native build (`a_key_file_wrapped_by_wasm_unwraps_natively`).
    let line = format!("GUNNAR-CRYPT-CROSS {}", B64.encode(file.as_bytes()));
    let sk_line = format!("GUNNAR-CRYPT-CROSS-USER-KEY {}", B64.encode(sk.as_bytes()));
    #[cfg(target_arch = "wasm32")]
    {
        wasm_bindgen_test::console_log!("{line}");
        wasm_bindgen_test::console_log!("{sk_line}");
    }
    #[cfg(not(target_arch = "wasm32"))]
    let _ = (line, sk_line);
}

#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_key_file_wrapped_by_wasm_unwraps_natively() {
    let Ok(path) = std::env::var("GUNNAR_CRYPT_WASM_WRAPPED") else {
        eprintln!("not under tests/wasm-cross.sh: nothing carried over from wasm");
        return;
    };
    let carried = std::fs::read_to_string(path).unwrap();
    let mut lines = carried.lines();
    let file = B64.decode(lines.next().unwrap().trim()).unwrap();
    assert_eq!(
        wrap::unwrap(&file, &[test_user_key().identity()]).unwrap(),
        ring()
    );
    let sk = B64
        .decode(lines.next().expect("the wasm user-key wrap").trim())
        .unwrap();
    let id = userkey::security_key::identity(&TEST_PRF, TEST_CREDENTIAL);
    assert_eq!(
        userkey::open(&sk, &id, &user_public()).unwrap().public(),
        user_public()
    );
    eprintln!("CROSS-OK: a key file and a user-key wrap made by the wasm32 build opened natively");
}

#[test]
fn the_diff_of_two_sealed_versions_is_the_vector_diff() {
    let ring = ring();
    let key = ring.newest();
    let old = envelope::seal(key, DIFF_OLD.as_bytes());
    let new = envelope::seal(key, DIFF_NEW.as_bytes());
    let d = gunnar_crypt::diff_blobs(&ring, Some(&old), Some(&new)).unwrap();
    assert_eq!(gunnar_crypt::diff::unified(&d), DIFF_UNIFIED);
}

#[test]
fn an_unknown_epoch_is_named_on_this_target() {
    let ring = ring();
    let removed = ring.truncated(1);
    let (_, _, env) = cases()
        .into_iter()
        .find(|(_, _, e)| envelope::epoch_of(e) == Some(2))
        .expect("a case sealed under epoch 2");
    assert_eq!(
        envelope::open(&removed, &env),
        Err(envelope::OpenError::UnknownEpoch(2))
    );
}

/// Writes the vector files. Run once, natively, on purpose:
/// `GUNNAR_CRYPT_WRITE_VECTORS=1 cargo test -p gunnar-crypt --test vectors -- --ignored`
#[cfg(not(target_arch = "wasm32"))]
#[test]
#[ignore]
fn write_vectors() {
    use gunnar_crypt::keyring::{DataKey, IdKey};
    if std::env::var("GUNNAR_CRYPT_WRITE_VECTORS").is_err() {
        return;
    }
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors");
    let id: [u8; 32] = std::array::from_fn(|i| i as u8);
    let k1: [u8; 64] = std::array::from_fn(|i| (i * 3 + 1) as u8);
    let k2: [u8; 64] = std::array::from_fn(|i| (0x80 + i) as u8);
    let mut ring = Keyring::new(IdKey::from_bytes(id), DataKey::from_bytes(1, k1));
    // A second epoch by hand, so the vectors stay fixed.
    let text = format!("{}epoch 2 {}\n", ring.to_text().as_str(), B64.encode(k2));
    ring = Keyring::parse(&text).unwrap();
    std::fs::write(dir.join("keyring.txt"), ring.to_text().as_bytes()).unwrap();

    let plains: [(&str, u32, &[u8]); 5] = [
        ("empty", 1, b""),
        ("hello", 1, b"hello, world\n"),
        ("binary", 1, &[0u8, 255, 1, 254, 0, 0, 7]),
        ("utf8", 2, "räksmörgås ✓\n".as_bytes()),
        ("long", 2, &[b'x'; 4099]),
    ];
    let mut cases = String::from("# name plaintext(base64) envelope(hex) — TEST KEYS ONLY\n");
    for (name, epoch, plain) in plains {
        let env = envelope::seal_v1(ring.get(epoch).unwrap(), plain);
        cases.push_str(&format!("{name} {} {}\n", B64.encode(plain), hex(&env)));
    }
    std::fs::write(dir.join("cases.txt"), cases).unwrap();

    // The TEST user key: fixed bytes, so the vectors stay fixed.
    let user = userkey::UserKey::X25519(userkey::security_key::identity(
        &[0x11; 32],
        b"gunnar-crypt test user key",
    ));
    std::fs::write(
        dir.join("test_user_key"),
        format!("{}\n", age_secret(&user)),
    )
    .unwrap();
    let mut members = Members::at(gunnar_crypt::members::Level::Classic);
    members.add("vector", &user.public()).unwrap();
    std::fs::write(dir.join("keys.age"), wrap::wrap(&ring, &members).unwrap()).unwrap();
    let ssh_pub = std::fs::read_to_string(dir.join("test_ed25519.pub")).unwrap();
    std::fs::write(
        dir.join("user-key-ssh.age"),
        user.wrap_to_ssh(ssh_pub.trim()).unwrap(),
    )
    .unwrap();
    std::fs::write(
        dir.join("user-key-security-key.age"),
        user.wrap_to_security_key(&userkey::security_key::recipient(
            &TEST_PRF,
            TEST_CREDENTIAL,
        ))
        .unwrap(),
    )
    .unwrap();

    let d = gunnar_crypt::diff::hunks(
        &std::fs::read_to_string(dir.join("diff_old.txt")).unwrap(),
        &std::fs::read_to_string(dir.join("diff_new.txt")).unwrap(),
    );
    std::fs::write(
        dir.join("diff_unified.txt"),
        gunnar_crypt::diff::unified(&d),
    )
    .unwrap();
}

#[cfg(not(target_arch = "wasm32"))]
fn age_secret(user: &userkey::UserKey) -> String {
    use gunnar_crypt::wrap::age::secrecy::ExposeSecret as _;
    match user {
        userkey::UserKey::X25519(id) => id.to_string().expose_secret().to_string(),
        _ => unreachable!("the TEST user key is X25519"),
    }
}