Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/client_encryption.rs

#![cfg(all(feature = "server", feature = "client"))]

//! **Encrypted repositories over gunnar's OWN client.**
//!
//! `encrypted_repo.rs` proves encryption with stock `git` on the wire and
//! gunnar only as the filter. This file puts the `gunnar` binary — the
//! client-only atom, `--no-default-features --features client` in
//! `.nornir/build-variants.toml` — on the wire as well: `gunnar clone`,
//! `gunnar push` and `gunnar fetch` against the in-process server of
//! `common::Harness`, over real SSH, with nothing mocked.
//!
//! ```text
//!   alice ─ gunnar crypt init ─ git commit (filter) ─ gunnar push ─▶ server: envelopes only
//!   bob   ─ gunnar clone ─ gunnar crypt unlock ─▶ plaintext, byte for byte
//! ```
//!
//! `git` is still what runs the clean/smudge filter (a filter is a `git`
//! contract; `gunnar crypt` is the program it calls), and it reads the work
//! tree's history for the assertions. Every byte that crosses the network is
//! carried by the gunnar client.

mod common;

use std::path::{Path, PathBuf};

use common::member::{read, User};
use common::{tools_present, Harness};
use gunnar_core::{HashKind, Level, RepoRef};
use gunnar_crypt::envelope;

const NS: &str = "team";
const REPO: &str = "crypt";
const SECRET: &str = "the vault combination is 31-41-59\n";

fn user(h: &Harness, name: &'static str, level: Option<Level>) -> User {
    User::new(h, name, level, NS, REPO)
}

fn url(h: &Harness) -> String {
    h.url(NS, REPO)
}

/// `gunnar push <url> main` from `w`, signed in with `u`'s key.
fn gunnar_push(h: &Harness, u: &User, w: &Path) -> common::Output {
    u.gunnar(
        w,
        &[
            "push",
            &url(h),
            "refs/heads/main:refs/heads/main",
            "-i",
            u.key.to_str().unwrap(),
        ],
    )
}

/// `gunnar clone <url> <dest>`, signed in with `u`'s key.
fn gunnar_clone(h: &Harness, u: &User, dest: &Path) {
    u.gunnar(
        &u.home,
        &[
            "clone",
            &url(h),
            dest.to_str().unwrap(),
            "-i",
            u.key.to_str().unwrap(),
        ],
    )
    .expect_ok("gunnar clone");
    // `gunnar crypt` asks the forge origin names for the person's encryption key.
    if !u.git(dest, &["remote", "get-url", "origin"]).ok {
        u.git(dest, &["remote", "add", "origin", &url(h)])
            .expect_ok("remote add");
    }
}

/// `gunnar fetch` into `w`, then fast-forward `main` with git (so the smudge
/// filter runs on what came in).
fn gunnar_pull(h: &Harness, u: &User, w: &Path) {
    u.gunnar(w, &["fetch", &url(h), "-i", u.key.to_str().unwrap()])
        .expect_ok("gunnar fetch");
    u.git(w, &["merge", "-q", "--ff-only", "refs/remotes/origin/main"])
        .expect_ok("fast-forward to what gunnar fetched");
}

fn commit(u: &User, w: &Path, files: &[(&str, &[u8])], message: &str) {
    for (path, bytes) in files {
        let p = w.join(path);
        if let Some(dir) = p.parent() {
            std::fs::create_dir_all(dir).unwrap();
        }
        std::fs::write(p, bytes).unwrap();
        u.git(w, &["add", path]).expect_ok("git add");
    }
    u.git(w, &["commit", "-q", "-m", message])
        .expect_ok("git commit");
}

/// A new repository in `u.work()`, encrypted to `u` (+ `also` by SSH key).
fn start_encrypted(h: &Harness, u: &User, also: &[&User]) -> PathBuf {
    let w = u.work();
    std::fs::create_dir_all(&w).unwrap();
    u.git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    // The forge keeps the person's encryption key (CRYPT-YUBI): crypt init asks origin.
    u.git(&w, &["remote", "add", "origin", &url(h)])
        .expect_ok("remote add");
    u.gunnar(&w, &["crypt", "init", "--member", u.name])
        .expect_ok("gunnar crypt init");
    for m in also {
        u.gunnar(&w, &["crypt", "add-member", m.name, "--key", &m.public])
            .expect_ok("gunnar crypt add-member");
    }
    w
}

/// Every blob a commit's tree names, as (oid, path).
fn tree_blobs(u: &User, w: &Path, rev: &str) -> Vec<(String, String)> {
    let out = u.git(w, &["ls-tree", "-r", rev]);
    out.expect_ok("ls-tree");
    out.stdout
        .lines()
        .map(|l| {
            let (meta, path) = l.split_once('\t').unwrap();
            (
                meta.split(' ').nth(2).unwrap().to_string(),
                path.to_string(),
            )
        })
        .collect()
}

fn blob_of(u: &User, w: &Path, path: &str) -> String {
    tree_blobs(u, w, "HEAD")
        .into_iter()
        .find(|(_, p)| p == path)
        .unwrap_or_else(|| panic!("{path} is not in HEAD"))
        .0
}

/// The bytes the SERVER stores for an object, or `None`.
fn stored(h: &Harness, oid: &str) -> Option<Vec<u8>> {
    let entry = h
        .stores
        .catalog()
        .get(&RepoRef::new(NS, REPO).unwrap())
        .unwrap()
        .expect("the repository exists");
    let store = h.stores.repo_store(&entry).unwrap();
    let id = gix_hash::ObjectId::from_hex(oid.as_bytes()).unwrap();
    gunnar_store::git_storage_trait::GitServe::read(store.serve(), id.as_bytes())
        .expect("ask the store")
        .map(|(_, bytes)| bytes)
}

/// The server's `refs/heads/main`, or `None`.
fn server_main(h: &Harness) -> Option<String> {
    let entry = h
        .stores
        .catalog()
        .open(&RepoRef::new(NS, REPO).unwrap())
        .unwrap();
    let store = h.stores.repo_store(&entry).unwrap();
    let refs = gunnar_store::git_storage_trait::GitOps::refs(store.serve()).unwrap();
    refs.iter()
        .find(|r| r.name == "refs/heads/main")
        .and_then(|r| r.oid.as_ref())
        .map(|oid| {
            gix_hash::ObjectId::try_from(oid.as_slice())
                .unwrap()
                .to_string()
        })
}

fn contains(hay: &[u8], needle: &str) -> bool {
    hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}

/// Deterministic, incompressible bytes (xorshift64*), so a "large" file is
/// large on the wire too and not a few KB of zlib.
fn noise(len: usize, mut seed: u64) -> Vec<u8> {
    let mut out = Vec::with_capacity(len + 8);
    while out.len() < len {
        seed ^= seed >> 12;
        seed ^= seed << 25;
        seed ^= seed >> 27;
        out.extend_from_slice(&seed.wrapping_mul(0x2545_F491_4F6C_DD1D).to_le_bytes());
    }
    out.truncate(len);
    out
}

fn head(u: &User, w: &Path) -> String {
    let out = u.git(w, &["rev-parse", "HEAD"]);
    out.expect_ok("rev-parse");
    out.stdout.trim().to_string()
}

/// **Push and clone an encrypted repository with the gunnar client, both
/// ways, including a 6 MiB incompressible file.**
#[test]
fn the_gunnar_client_round_trips_an_encrypted_repository_with_a_large_file() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Write));

    let big = noise(6 * 1024 * 1024, 0x9E37_79B9_7F4A_7C15);
    let w = start_encrypted(&h, &alice, &[&bob]);
    commit(
        &alice,
        &w,
        &[("secret.txt", SECRET.as_bytes()), ("blobs/big.bin", &big)],
        "the secret and a big file",
    );
    gunnar_push(&h, &alice, &w).expect_ok("gunnar push of an encrypted repository");
    assert_eq!(server_main(&h).as_deref(), Some(head(&alice, &w).as_str()));

    // The server holds envelopes for every content file — the big one too.
    for (oid, path) in tree_blobs(&alice, &w, "HEAD") {
        let bytes = stored(&h, &oid).unwrap_or_else(|| panic!("the server lacks {path}"));
        if path == ".gitattributes" || path.starts_with(".gunnar-crypt/") {
            continue;
        }
        assert!(
            envelope::is_envelope(&bytes),
            "{path} is stored as plaintext"
        );
        assert!(
            !contains(&bytes, "vault combination"),
            "{path} leaks the secret"
        );
    }
    let big_stored = stored(&h, &blob_of(&alice, &w, "blobs/big.bin")).unwrap();
    assert!(
        big_stored.len() >= big.len(),
        "the stored envelope ({} B) is smaller than the plaintext ({} B)",
        big_stored.len(),
        big.len()
    );

    // bob: `gunnar clone` gets ciphertext, `gunnar crypt unlock` opens it.
    let bw = bob.home.join("clone");
    gunnar_clone(&h, &bob, &bw);
    assert!(
        envelope::is_envelope(&read(bw.join("secret.txt"))),
        "a fresh clone is sealed"
    );
    assert!(envelope::is_envelope(&read(bw.join("blobs/big.bin"))));
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
    assert!(
        read(bw.join("blobs/big.bin")) == big,
        "the 6 MiB file round-trips byte for byte"
    );
    let status = bob.git(&bw, &["status", "--porcelain"]);
    status.expect_ok("status");
    assert_eq!(
        status.stdout.trim(),
        "",
        "an unlocked gunnar clone is clean"
    );

    // bob writes back with `gunnar push`; alice `gunnar fetch`es it.
    commit(
        &bob,
        &bw,
        &[("plan.md", b"# plan\nbob's part\n")],
        "bob's plan",
    );
    gunnar_push(&h, &bob, &bw).expect_ok("bob's gunnar push");
    assert!(envelope::is_envelope(
        &stored(&h, &blob_of(&bob, &bw, "plan.md")).unwrap()
    ));
    gunnar_pull(&h, &alice, &w);
    assert_eq!(read(w.join("plan.md")), b"# plan\nbob's part\n");
    assert_eq!(head(&alice, &w), head(&bob, &bw));
}

/// **A member added by SSH key reads everything; once removed, new content
/// is closed to them** — the key ring wrapped and re-wrapped by `gunnar
/// crypt`, carried by `gunnar push` / `gunnar clone` / `gunnar fetch`.
#[test]
fn a_member_is_added_and_removed_over_the_gunnar_client() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let carol = user(&h, "carol", Some(Level::Write));

    let w = start_encrypted(&h, &alice, &[]);
    commit(&alice, &w, &[("secret.txt", SECRET.as_bytes())], "secret");
    gunnar_push(&h, &alice, &w).expect_ok("push");

    // Not yet a member: carol may clone (ACL) but cannot unlock.
    let cw = carol.home.join("clone");
    gunnar_clone(&h, &carol, &cw);
    let refused = carol.gunnar(&cw, &["crypt", "unlock"]);
    refused.expect_failure("carol is not a member yet");
    assert!(
        refused
            .stderr
            .contains("you are not a member of this encrypted repository"),
        "{}",
        refused.stderr
    );

    // alice adds her by her encryption PUBLIC key (she need not be present).
    alice
        .gunnar(
            &w,
            &["crypt", "add-member", "carol", "--key", &carol.public],
        )
        .expect_ok("add carol");
    gunnar_push(&h, &alice, &w).expect_ok("push the re-wrapped ring");
    gunnar_pull(&h, &carol, &cw);
    carol
        .gunnar(&cw, &["crypt", "unlock"])
        .expect_ok("carol unlocks after being added");
    assert_eq!(
        read(cw.join("secret.txt")),
        SECRET.as_bytes(),
        "and reads old content"
    );

    // Removed: a new epoch she never gets.
    let out = alice.gunnar(&w, &["crypt", "remove-member", "carol"]);
    out.expect_ok("remove carol");
    assert!(out.stdout.contains("epoch 2"), "{}", out.stdout);
    commit(
        &alice,
        &w,
        &[("after.txt", b"written after carol left\n")],
        "after",
    );
    gunnar_push(&h, &alice, &w).expect_ok("push after removal");
    let after = stored(&h, &blob_of(&alice, &w, "after.txt")).unwrap();
    assert_eq!(
        envelope::epoch_of(&after),
        Some(2),
        "new content is sealed under the new epoch"
    );

    gunnar_pull(&h, &carol, &cw);
    assert!(
        envelope::is_envelope(&read(cw.join("after.txt"))),
        "new content stays sealed"
    );
    assert_eq!(
        read(cw.join("secret.txt")),
        SECRET.as_bytes(),
        "what she had, she keeps"
    );
    carol
        .gunnar(&cw, &["crypt", "unlock"])
        .expect_failure("carol is no longer a recipient");

    // A fresh clone by the removed member opens nothing at all.
    let fresh = carol.home.join("fresh");
    gunnar_clone(&h, &carol, &fresh);
    carol
        .gunnar(&fresh, &["crypt", "unlock"])
        .expect_failure("a removed member's fresh clone cannot unlock");
    assert!(envelope::is_envelope(&read(fresh.join("secret.txt"))));
}

/// **Quantum-safe (age1pq1, ML-KEM-768 + X25519 / X-Wing) members over the
/// gunnar client**: a ring wrapped only to hybrid keys, a PQ member added,
/// and an SSH key no longer enough.
#[test]
fn a_quantum_safe_repository_round_trips_over_the_gunnar_client() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    // alice makes her quantum-safe encryption key at her first such repository;
    // bob has one already; carol's is classic.
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    let mut bob = User::without_encryption_key(&h, "bob", Some(Level::Write), NS, REPO);
    bob.public = common::member::give_an_encryption_key_at(
        &h,
        "bob",
        &bob.ssh_public,
        gunnar_crypt::members::Level::QuantumSafe,
    );
    let mut carol = User::without_encryption_key(&h, "carol", Some(Level::Read), NS, REPO);
    carol.public = common::member::give_an_encryption_key_at(
        &h,
        "carol",
        &carol.ssh_public,
        gunnar_crypt::members::Level::Classic,
    );
    assert!(
        bob.public.starts_with("age1pq1"),
        "a hybrid recipient: {}",
        bob.public
    );

    let w = alice.work();
    std::fs::create_dir_all(&w).unwrap();
    alice
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("init");
    alice
        .git(&w, &["remote", "add", "origin", &url(&h)])
        .expect_ok("remote add");
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "init",
                "--member",
                "alice",
                "--level",
                "quantum-safe",
            ],
        )
        .expect_ok("crypt init --level quantum-safe");
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "add-member",
                "bob",
                "--from-server",
                "--fingerprint",
                &gunnar_crypt::door::fingerprint(&bob.public),
            ],
        )
        .expect_ok("add bob by the quantum-safe key gunnar holds for him");
    let refused = alice.gunnar(&w, &["crypt", "add-member", "carol", "--from-server"]);
    refused.expect_failure("a classic encryption key cannot join a quantum-safe ring");
    assert!(
        refused.stderr.contains("quantum-safe"),
        "{}",
        refused.stderr
    );
    let stanzas: Vec<String> =
        gunnar_crypt::policy::age_stanzas(&read(w.join(".gunnar-crypt/keys")))
            .expect("an age header")
            .into_iter()
            .map(|s| s.tag)
            .filter(|t| !t.ends_with("-grease"))
            .collect();
    assert_eq!(
        stanzas,
        vec!["mlkem768x25519", "mlkem768x25519"],
        "hybrid stanzas only"
    );

    commit(
        &alice,
        &w,
        &[("secret.txt", SECRET.as_bytes())],
        "pq secret",
    );
    gunnar_push(&h, &alice, &w).expect_ok("push a quantum-safe repository");

    let bw = bob.home.join("clone");
    gunnar_clone(&h, &bob, &bw);
    assert!(envelope::is_envelope(&read(bw.join("secret.txt"))));
    // His SSH key opens his quantum-safe encryption key, which opens the ring.
    bob.gunnar(&bw, &["crypt", "unlock", "-i", bob.key.to_str().unwrap()])
        .expect_ok("bob unlocks with his SSH key");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
}

/// **The server refuses plaintext into an encrypted repository, and the
/// gunnar client reports it and exits non-zero** — with the ref unmoved and
/// the blob never stored.
#[test]
fn the_server_refuses_plaintext_pushed_by_the_gunnar_client() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    h.stores
        .catalog()
        .set_encrypted(&RepoRef::new(NS, REPO).unwrap(), true)
        .expect("flag the repository encrypted");

    // A clone that never ran `gunnar crypt`: plaintext.
    let plain = alice.home.join("plain");
    std::fs::create_dir_all(&plain).unwrap();
    alice
        .git(&plain, &["init", "-q", "-b", "main"])
        .expect_ok("init");
    commit(
        &alice,
        &plain,
        &[("notes.txt", b"the merger closes friday\n")],
        "notes",
    );
    let refused = gunnar_push(&h, &alice, &plain);
    refused.expect_failure("plaintext is refused by an encrypted repository");
    let said = format!("{}{}", refused.stdout, refused.stderr);
    assert!(
        said.contains("notes.txt is plaintext"),
        "the refusal names the file: {said}"
    );
    assert!(!said.contains("panicked"), "{said}");
    assert_eq!(server_main(&h), None, "a refused push moves no reference");
    assert_eq!(
        stored(&h, &blob_of(&alice, &plain, "notes.txt")),
        None,
        "a refused plaintext blob is never stored"
    );

    // The same user, through the filter, is accepted.
    let w = start_encrypted(&h, &alice, &[]);
    commit(
        &alice,
        &w,
        &[("notes.txt", b"the merger closes friday\n")],
        "sealed notes",
    );
    gunnar_push(&h, &alice, &w).expect_ok("ciphertext is accepted");
    assert_eq!(server_main(&h).as_deref(), Some(head(&alice, &w).as_str()));
}

/// **A wrong key fails cleanly**: an unrelated SSH key, a file that is not a
/// key at all, and a key path that does not exist each exit non-zero with a
/// message and no panic, and leave the work tree sealed.
#[test]
fn a_wrong_key_fails_cleanly_and_leaves_the_clone_sealed() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Read));
    let w = start_encrypted(&h, &alice, &[&bob]);
    commit(&alice, &w, &[("secret.txt", SECRET.as_bytes())], "secret");
    gunnar_push(&h, &alice, &w).expect_ok("push");

    let bw = bob.home.join("clone");
    gunnar_clone(&h, &bob, &bw);

    let unrelated = bob.home.join("unrelated_ed25519");
    std::fs::write(&unrelated, gunnar_core::mint_ed25519().unwrap()).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&unrelated, std::fs::Permissions::from_mode(0o600)).unwrap();
    }
    let garbage = bob.home.join("not-a-key");
    std::fs::write(&garbage, b"this is not a private key\n").unwrap();
    let missing = bob.home.join("no-such-key");

    for (what, key) in [
        ("an unrelated SSH key", &unrelated),
        ("a file that is no key", &garbage),
        ("a key path that does not exist", &missing),
    ] {
        let out = bob.gunnar(&bw, &["crypt", "unlock", "-i", key.to_str().unwrap()]);
        out.expect_failure(what);
        assert!(
            !out.stderr.trim().is_empty(),
            "{what}: a refusal must say something"
        );
        assert!(!out.stderr.contains("panicked"), "{what}: {}", out.stderr);
        assert!(
            envelope::is_envelope(&read(bw.join("secret.txt"))),
            "{what}: the work tree stays sealed"
        );
    }
    let unrelated_out = bob.gunnar(&bw, &["crypt", "unlock", "-i", unrelated.to_str().unwrap()]);
    assert!(
        unrelated_out
            .stderr
            .contains("not one of your unlock methods"),
        "{}",
        unrelated_out.stderr
    );

    // The right key still works afterwards: nothing was left half-unlocked.
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob's own key opens it");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
}