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 envelope, the key ring, the members file and the server's check.

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

use gunnar_crypt::envelope::{self, OpenError};
use gunnar_crypt::keyring::{ContinuityError, DataKey, IdKey, Keyring, KeyringError};
use gunnar_crypt::members::{classify, KeyKind, KeyRefusal, Members, MembersError};
use gunnar_crypt::policy::{self, Refusal};

const ED: &str =
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl alice@laptop";
const SK: &str = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEX/dQ0v4127bEo8eeG1EV0ApO2lWbSnN6RWusn/NjqIAAAABHNzaDo= bob@yubikey";

fn ring2() -> Keyring {
    let mut r = Keyring::new(
        IdKey::from_bytes([3u8; 32]),
        DataKey::from_bytes(1, [7u8; 64]),
    );
    r.rotate();
    r
}

#[test]
fn an_envelope_round_trips_and_is_deterministic() {
    let ring = Keyring::generate();
    let a = envelope::seal(ring.newest(), b"the secret recipe\n");
    let b = envelope::seal(ring.newest(), b"the secret recipe\n");
    assert_eq!(
        a, b,
        "same key + same content = same blob, or git status never settles"
    );
    assert!(envelope::is_envelope(&a));
    assert_eq!(envelope::epoch_of(&a), Some(1));
    assert!(
        !a.windows(6).any(|w| w == b"secret"),
        "plaintext must not show through"
    );
    assert_eq!(envelope::open(&ring, &a).unwrap(), b"the secret recipe\n");
}

#[test]
fn empty_content_seals_too() {
    let ring = Keyring::generate();
    let e = envelope::seal(ring.newest(), b"");
    // v2: the header and one empty block's tag.
    assert_eq!(e.len(), envelope::V2_HEADER_LEN + envelope::TAG_LEN);
    assert_eq!(envelope::open(&ring, &e).unwrap(), b"");
}

#[test]
fn a_different_key_gives_a_different_blob_so_ids_are_keyed() {
    // The blob id is a hash of the envelope, and the envelope depends on the
    // secret key: knowing a file does not let anyone compute its id here.
    let a = envelope::seal(Keyring::generate().newest(), b"README\n");
    let b = envelope::seal(Keyring::generate().newest(), b"README\n");
    assert_ne!(a, b);
}

#[test]
fn a_tampered_envelope_or_a_wrong_key_does_not_open() {
    let ring = Keyring::generate();
    let mut e = envelope::seal(ring.newest(), b"payload");
    let other = Keyring::generate();
    assert_eq!(envelope::open(&other, &e), Err(OpenError::Corrupt));
    let last = e.len() - 1;
    e[last] ^= 1;
    assert_eq!(envelope::open(&ring, &e), Err(OpenError::Corrupt));
}

#[test]
fn the_epoch_is_authenticated() {
    let ring = ring2();
    let mut e = envelope::seal(ring.get(1).unwrap(), b"old content");
    // Claim epoch 2: the header is the associated data, so this cannot open.
    e[12] = 2;
    assert_eq!(envelope::open(&ring, &e), Err(OpenError::Corrupt));
}

#[test]
fn rotation_seals_new_content_under_the_new_epoch_and_old_content_still_opens() {
    let mut ring = Keyring::generate();
    let old = envelope::seal(ring.newest(), b"before");
    assert_eq!(ring.rotate(), 2);
    let new = envelope::seal(ring.newest(), b"after");
    assert_eq!(envelope::epoch_of(&new), Some(2));
    assert_eq!(envelope::open(&ring, &old).unwrap(), b"before");
    // A removed member kept only epoch 1: the new content is closed to them.
    let kept = Keyring::parse(&ring.truncated(1).to_text()).unwrap();
    assert_eq!(envelope::open(&kept, &old).unwrap(), b"before");
    assert_eq!(envelope::open(&kept, &new), Err(OpenError::UnknownEpoch(2)));
}

#[test]
fn not_an_envelope_and_a_future_version_are_named() {
    let ring = Keyring::generate();
    assert_eq!(
        envelope::open(&ring, b"plain text"),
        Err(OpenError::NotEnvelope)
    );
    let mut e = envelope::seal(ring.newest(), b"x");
    e[8] = 9;
    assert!(!envelope::is_envelope(&e));
    assert_eq!(
        envelope::open(&ring, &e),
        Err(OpenError::UnsupportedVersion(9))
    );
}

#[test]
fn the_key_ring_text_round_trips_and_never_debug_prints_a_key() {
    let ring = ring2();
    let text = ring.to_text();
    assert!(text.starts_with("gunnar-crypt keyring v1\n"));
    assert_eq!(Keyring::parse(&text).unwrap(), ring);
    let dbg = format!("{ring:?}");
    assert!(text.lines().nth(1).unwrap().starts_with("id-key "));
    let b64 = text.lines().nth(2).unwrap().split(' ').nth(2).unwrap();
    assert!(
        !dbg.contains(b64),
        "Debug must not print key material: {dbg}"
    );
    assert_eq!(ring.get(1).unwrap().id().len(), 16);
}

#[test]
fn a_payload_that_is_not_a_key_ring_is_refused() {
    assert_eq!(
        Keyring::parse("AWS_SECRET=hunter2\n"),
        Err(KeyringError::NotAKeyring)
    );
    assert_eq!(
        Keyring::parse("gunnar-crypt keyring v1\n"),
        Err(KeyringError::Empty)
    );
    let gap = "gunnar-crypt keyring v1\nepoch 2 AAAA\n";
    assert!(Keyring::parse(gap).is_err());
    // Every ring carries the fixed id key; one without it is refused.
    let mut no_id = String::from("gunnar-crypt keyring v1\n");
    no_id.push_str(Keyring::generate().to_text().lines().nth(2).unwrap());
    assert_eq!(Keyring::parse(&no_id), Err(KeyringError::BadIdKey));
}

#[test]
fn continuity_refuses_a_ring_that_drops_or_replaces_a_known_epoch() {
    let ring = ring2();
    let mut grown = ring.clone();
    grown.rotate();
    assert_eq!(ring.check_continuity(&grown), Ok(()));
    // Someone who never held the keys makes a whole new ring…
    let substituted = Keyring::generate();
    assert_eq!(
        ring.check_continuity(&substituted),
        Err(ContinuityError::IdKeyChanged)
    );
    // …or copies the (leaked) id key but cannot copy the epoch keys.
    let mut forged = Keyring::new(ring.id_key().clone(), DataKey::from_bytes(1, [9u8; 64]));
    forged.rotate();
    assert_eq!(
        ring.check_continuity(&forged),
        Err(ContinuityError::Replaced(1))
    );
    let shorter = ring.truncated(1);
    assert_eq!(
        ring.check_continuity(&shorter),
        Err(ContinuityError::Dropped(2))
    );
}

#[test]
fn key_kinds_and_the_security_key_refusal() {
    assert_eq!(classify(ED), Ok(KeyKind::SshEd25519));
    assert_eq!(
        classify("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ x"),
        Ok(KeyKind::SshRsa)
    );
    assert_eq!(
        classify("age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq"),
        Ok(KeyKind::Age)
    );
    let sk = classify(SK).unwrap_err();
    assert!(matches!(sk, KeyRefusal::SecurityKeyOnly(_)));
    assert!(sk.to_string().contains("can only sign"), "{sk}");
    assert!(matches!(
        classify("ecdsa-sha2-nistp256 AAAAE2VjZHNh x"),
        Err(KeyRefusal::Unsupported(_))
    ));
    assert_eq!(classify("hello"), Err(KeyRefusal::Malformed));
}

#[test]
fn the_members_file_round_trips_adds_and_removes() {
    let mut m = Members::at(gunnar_crypt::members::Level::Classic);
    let alice = gunnar_crypt::wrap::age::x25519::Identity::generate()
        .to_public()
        .to_string();
    m.add("alice", &alice).unwrap();
    m.add(
        "recovery",
        "age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq",
    )
    .unwrap();
    // SSH keys, security-key ones too, are unlock methods: never members.
    for ssh in [ED, SK] {
        assert!(
            matches!(m.add("bob", ssh), Err(MembersError::SshKeyIsAMethod { .. })),
            "{ssh}"
        );
    }
    assert!(matches!(
        m.add("alice", &alice),
        Err(MembersError::Duplicate(_))
    ));
    assert!(matches!(
        m.add("al ice", &alice),
        Err(MembersError::BadName(_))
    ));
    let text = m.to_text();
    assert!(text.starts_with("# gunnar-crypt members v1"));
    let back = Members::parse(&text).unwrap();
    assert_eq!(back, m);
    assert_eq!(back.names(), vec!["alice", "recovery"]);
    let mut back = back;
    assert_eq!(back.remove("alice"), 1);
    assert!(!back.contains("alice"));
}

#[test]
fn the_server_check_wants_envelopes_except_the_schemes_own_files() {
    let ring = Keyring::generate();
    let env = envelope::seal(ring.newest(), b"fn main() {}\n");
    assert_eq!(policy::check_blob("src/main.rs", false, &env), Ok(()));
    assert_eq!(
        policy::check_blob("src/main.rs", false, b"fn main() {}\n"),
        Err(Refusal::Plaintext {
            path: "src/main.rs".into()
        })
    );
    assert_eq!(
        policy::check_blob(".gitattributes", false, b"* filter=gunnar-crypt\n"),
        Ok(())
    );
    assert_eq!(
        policy::check_blob(".gunnar-crypt/members", false, ED.as_bytes()),
        Ok(())
    );
    assert_eq!(
        policy::check_blob(
            ".gunnar-crypt/keys",
            false,
            b"-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n"
        ),
        Ok(())
    );
    assert_eq!(
        policy::check_blob(".gunnar-crypt/keys", false, b"epoch 1 AAAA"),
        Err(Refusal::KeysNotAge)
    );
    // A .gitattributes at ANY depth is plaintext, as the client's depth-free
    // `.gitattributes !filter` keeps it (wiring tracer B 2026-10-08); a lookalike
    // name, or any other plaintext blob beside it, is still refused.
    assert_eq!(
        policy::check_blob("sub/.gitattributes", false, b"x"),
        Ok(())
    );
    assert!(policy::check_blob("sub/.gitattributes.bak", false, b"x").is_err());
    assert!(policy::check_blob("sub/notes.txt", false, b"x").is_err());
    // A symlink's target is plaintext in git: refused even if it "looks" sealed.
    assert!(matches!(
        policy::check_blob("link", true, &env),
        Err(Refusal::Symlink { .. })
    ));
    let msg = Refusal::Plaintext { path: "a".into() }.to_string();
    assert!(msg.contains("gunnar crypt unlock"), "{msg}");
}