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