crates/gunnar-cli/tests/encrypted_repo.rs
#![cfg(all(feature = "server", feature = "client"))]
//! **Encrypted repositories, Level 1: stock git, a gunnar filter, a real
//! gunnar server that only ever holds ciphertext.**
//!
//! Every user here is a separate HOME with their own ed25519 key at
//! `~/.ssh/id_ed25519`: the same key logs in over SSH and unwraps the
//! repository key. The server is the in-process gunnar the rest of this suite
//! uses, and its stored objects are read straight out of its store.
//!
//! ```text
//! alice ─ gunnar crypt init/add-member ─▶ .gunnar-crypt/keys (age, to alice+bob)
//! alice ─ git push ─▶ gunnar ─▶ store: envelopes only
//! bob ─ git clone + gunnar crypt unlock ─▶ plaintext
//! carol ─ git clone (Read grant, no key) ─▶ ciphertext, unlock refused
//! ```
mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::member::{output, read, User};
use common::{tools_present, Harness, Output};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef};
use gunnar_crypt::envelope;
const NS: &str = "team";
const REPO: &str = "vault";
const SECRET: &str = "the launch code is 0000-1111\n";
fn user(h: &Harness, name: &'static str, level: Option<Level>) -> User {
User::new(h, name, level, NS, REPO)
}
/// alice makes the repository, encrypts it to alice (+ the given members),
/// commits the secret and pushes.
fn alice_starts(h: &Harness, alice: &User, also: &[&User]) {
let w = alice.work();
std::fs::create_dir_all(&w).unwrap();
alice
.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
// The forge first: it keeps alice's encryption key (CRYPT-YUBI).
alice
.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
alice
.gunnar(&w, &["crypt", "init", "--member", alice.name])
.expect_ok("gunnar crypt init");
for m in also {
let key_file = alice.home.join(format!("{}.pub", m.name));
std::fs::write(&key_file, &m.public).unwrap();
alice
.gunnar(
&w,
&[
"crypt",
"add-member",
m.name,
"--key",
key_file.to_str().unwrap(),
],
)
.expect_ok("gunnar crypt add-member");
}
std::fs::write(w.join("secret.txt"), SECRET).unwrap();
alice.git(&w, &["add", "secret.txt"]).expect_ok("git add");
alice
.git(&w, &["commit", "-q", "-m", "the secret"])
.expect_ok("git commit");
alice
.git(&w, &["push", "-q", "-u", "origin", "main"])
.expect_ok("alice's push");
}
/// A person whose ONE encryption key is quantum-safe (age1pq1…), opened by their SSH key.
fn quantum_safe_user(h: &Harness, name: &'static str) -> User {
let mut u = User::without_encryption_key(h, name, Some(Level::Write), NS, REPO);
u.public = common::member::give_an_encryption_key_at(
h,
name,
&u.ssh_public,
gunnar_crypt::members::Level::QuantumSafe,
);
u
}
/// A classic (X25519) encryption public key nobody here holds.
fn classic_key() -> String {
gunnar_crypt::wrap::age::x25519::Identity::generate()
.to_public()
.to_string()
}
/// [`alice_starts`] for a quantum-safe repository, said explicitly: `crypt init --level quantum-safe`.
fn alice_starts_quantum_safe(h: &Harness, alice: &User) {
let w = alice.work();
std::fs::create_dir_all(&w).unwrap();
alice
.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
alice
.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
alice
.gunnar(
&w,
&[
"crypt",
"init",
"--member",
alice.name,
"--level",
"quantum-safe",
],
)
.expect_ok("crypt init --level quantum-safe");
std::fs::write(w.join("secret.txt"), SECRET).unwrap();
alice.git(&w, &["add", "secret.txt"]).expect_ok("git add");
alice
.git(&w, &["commit", "-q", "-m", "the secret"])
.expect_ok("git commit");
alice
.git(&w, &["push", "-q", "-u", "origin", "main"])
.expect_ok("the hybrid ring is accepted");
}
fn clone(h: &Harness, u: &User) -> PathBuf {
let w = u.work();
u.git(
&u.home,
&["clone", "-q", &h.url(NS, REPO), w.to_str().unwrap()],
)
.expect_ok("git clone");
w
}
/// Every object id a commit's tree names, with its 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()
}
/// The bytes the SERVER stores for an object.
fn stored(h: &Harness, oid: &str) -> 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")
.expect("the server holds the object")
.1
}
fn contains(hay: &[u8], needle: &str) -> bool {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn two_members_share_a_repo_and_a_third_with_access_sees_only_ciphertext() {
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 carol = user(&h, "carol", Some(Level::Read));
alice_starts(&h, &alice, &[&bob]);
// In alice's own clone the file is plaintext: the filter is transparent.
assert_eq!(read(alice.work().join("secret.txt")), SECRET.as_bytes());
// ── what the SERVER holds: envelopes, an age key file, public keys ──
for (oid, path) in tree_blobs(&alice, &alice.work(), "HEAD") {
let bytes = stored(&h, &oid);
assert!(
!contains(&bytes, "launch code"),
"{path} is plaintext on the server"
);
match path.as_str() {
".gitattributes" => assert!(contains(&bytes, "filter=gunnar-crypt")),
".gunnar-crypt/members" => {
assert!(
contains(&bytes, &format!("alice {}", alice.public))
&& contains(&bytes, &format!("bob {}", bob.public)),
"the members file names user encryption keys"
)
}
".gunnar-crypt/keys" => assert!(
bytes.starts_with(b"-----BEGIN AGE ENCRYPTED FILE-----"),
"the key file is an age file"
),
_ => assert!(
envelope::is_envelope(&bytes),
"{path} must be stored as a gunnar-crypt envelope"
),
}
}
// ── bob: a member. Clone shows ciphertext until he unlocks. ──
let bw = clone(&h, &bob);
assert!(envelope::is_envelope(&read(bw.join("secret.txt"))));
bob.gunnar(&bw, &["crypt", "unlock"])
.expect_ok("bob unlocks with ~/.ssh/id_ed25519");
assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
let status = bob.git(&bw, &["status", "--porcelain"]);
status.expect_ok("status");
assert_eq!(status.stdout.trim(), "", "an unlocked clone is clean");
// bob writes; alice reads it back through the server.
std::fs::write(bw.join("plan.md"), "# plan\nbob's part\n").unwrap();
bob.git(&bw, &["add", "plan.md"]).expect_ok("add");
bob.git(&bw, &["commit", "-q", "-m", "bob's plan"])
.expect_ok("commit");
bob.git(&bw, &["push", "-q"]).expect_ok("bob's push");
alice
.git(&alice.work(), &["pull", "-q", "--ff-only"])
.expect_ok("alice pulls");
assert_eq!(read(alice.work().join("plan.md")), b"# plan\nbob's part\n");
let plan_oid = tree_blobs(&bob, &bw, "HEAD")
.into_iter()
.find(|(_, p)| p == "plan.md")
.unwrap()
.0;
assert!(envelope::is_envelope(&stored(&h, &plan_oid)));
// ── carol: may clone (Read grant), is not a member. ──
let cw = clone(&h, &carol);
let theirs = read(cw.join("secret.txt"));
assert!(envelope::is_envelope(&theirs), "carol sees ciphertext");
assert!(!contains(&theirs, "launch code"));
let refused = carol.gunnar(&cw, &["crypt", "unlock"]);
refused.expect_failure("carol is not a member");
assert!(
refused
.stderr
.contains("you are not a member of this encrypted repository"),
"the refusal says why: {}",
refused.stderr
);
assert!(envelope::is_envelope(&read(cw.join("secret.txt"))));
}
#[test]
fn adding_a_member_needs_no_presence_and_lets_them_read_everything() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
let dave = user(&h, "dave", Some(Level::Read));
alice_starts(&h, &alice, &[]);
let dw = clone(&h, &dave);
dave.gunnar(&dw, &["crypt", "unlock"])
.expect_failure("not a member yet");
// alice wraps the key ring to dave's PUBLIC key; dave does nothing.
let key_file = alice.home.join("dave.pub");
std::fs::write(&key_file, &dave.public).unwrap();
alice
.gunnar(
&alice.work(),
&[
"crypt",
"add-member",
"dave",
"--key",
key_file.to_str().unwrap(),
],
)
.expect_ok("add dave");
alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");
dave.git(&dw, &["pull", "-q", "--ff-only"])
.expect_ok("dave pulls");
dave.gunnar(&dw, &["crypt", "unlock"])
.expect_ok("dave unlocks");
assert_eq!(read(dw.join("secret.txt")), SECRET.as_bytes());
}
#[test]
fn a_security_key_ssh_key_cannot_be_a_member_and_is_told_why() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
alice_starts(&h, &alice, &[]);
let sk = alice.home.join("yubi.pub");
std::fs::write(
&sk,
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEX/dQ0v4127bEo8eeG1EV0ApO2lWbSnN6RWusn/NjqIAAAABHNzaDo= erin@yubikey\n",
)
.unwrap();
let out = alice.gunnar(
&alice.work(),
&["crypt", "add-member", "erin", "--key", sk.to_str().unwrap()],
);
out.expect_failure("an sk- key is refused");
assert!(
out.stderr.contains("cannot be a member key") && out.stderr.contains("encryption key"),
"{}",
out.stderr
);
}
#[test]
fn removing_a_member_rotates_and_closes_new_content_to_them() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
let bob = user(&h, "bob", Some(Level::Write));
alice_starts(&h, &alice, &[&bob]);
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "unlock"])
.expect_ok("bob unlocks");
let out = alice.gunnar(&alice.work(), &["crypt", "remove-member", "bob"]);
out.expect_ok("remove bob");
// Honest about what removal cannot do.
assert!(out.stdout.contains("can still read"), "{}", out.stdout);
assert!(out.stdout.contains("epoch 2"), "{}", out.stdout);
let members = String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap();
assert!(!members.contains("bob "), "{members}");
std::fs::write(alice.work().join("after.txt"), "written after bob left\n").unwrap();
alice
.git(&alice.work(), &["add", "after.txt"])
.expect_ok("add");
alice
.git(&alice.work(), &["commit", "-q", "-m", "after"])
.expect_ok("commit");
alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");
let after_oid = tree_blobs(&alice, &alice.work(), "HEAD")
.into_iter()
.find(|(_, p)| p == "after.txt")
.unwrap()
.0;
assert_eq!(envelope::epoch_of(&stored(&h, &after_oid)), Some(2));
// bob pulls (his ACL grant is still there in this test, to show the key
// alone keeps him out). The pull itself works; the new file stays sealed.
let pull = bob.git(&bw, &["pull", "-q", "--ff-only"]);
pull.expect_ok("bob pulls");
assert!(envelope::is_envelope(&read(bw.join("after.txt"))));
// What he had, he keeps: nothing can take that back.
assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
let refused = bob.gunnar(&bw, &["crypt", "unlock"]);
refused.expect_failure("bob is no longer a recipient");
assert!(envelope::is_envelope(&read(bw.join("after.txt"))));
// alice (still a member) reads both, old and new.
assert_eq!(
read(alice.work().join("after.txt")),
b"written after bob left\n"
);
}
#[test]
fn rotation_keeps_old_content_readable_and_git_status_quiet() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
alice_starts(&h, &alice, &[]);
let w = alice.work();
alice.gunnar(&w, &["crypt", "rotate"]).expect_ok("rotate");
// Touch without changing: git re-runs the clean filter, which must hand
// back the SAME blob (sealed under epoch 1), not a new epoch-2 one.
std::fs::write(w.join("secret.txt"), SECRET).unwrap();
let status = alice.git(&w, &["status", "--porcelain"]);
status.expect_ok("status");
assert_eq!(
status.stdout.trim(),
"",
"an unchanged file stays unchanged after a rotation"
);
std::fs::write(w.join("secret.txt"), "the launch code is now 2222\n").unwrap();
alice
.git(&w, &["commit", "-q", "-am", "new code"])
.expect_ok("commit");
let (new_oid, _) = tree_blobs(&alice, &w, "HEAD")
.into_iter()
.find(|(_, p)| p == "secret.txt")
.unwrap();
let raw = Command::new("git")
.current_dir(&w)
.args(["cat-file", "blob", &new_oid])
.output()
.unwrap()
.stdout;
assert_eq!(
envelope::epoch_of(&raw),
Some(2),
"new content is sealed under the new epoch"
);
// The old version still opens: the diff (the same code the browser runs)
// shows both sides in plaintext.
let diff = alice.gunnar(&w, &["crypt", "diff", "HEAD~1", "HEAD"]);
diff.expect_ok("gunnar crypt diff");
assert!(
diff.stdout.contains("-the launch code is 0000-1111"),
"{}",
diff.stdout
);
assert!(
diff.stdout.contains("+the launch code is now 2222"),
"{}",
diff.stdout
);
// …and git's own diff, through the textconv driver.
let gd = alice.git(&w, &["diff", "HEAD~1", "HEAD", "--", "secret.txt"]);
gd.expect_ok("git diff");
assert!(
gd.stdout.contains("+the launch code is now 2222"),
"{}",
gd.stdout
);
}
#[test]
fn concurrent_edits_merge_on_the_client_in_plaintext() {
assert!(tools_present());
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 w = alice.work();
std::fs::create_dir_all(&w).unwrap();
alice_starts(&h, &alice, &[&bob]);
let lines: String = (1..=12).map(|i| format!("line {i}\n")).collect();
std::fs::write(w.join("list.txt"), &lines).unwrap();
alice.git(&w, &["add", "list.txt"]).expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "list"])
.expect_ok("commit");
alice.git(&w, &["push", "-q"]).expect_ok("push");
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "unlock"]).expect_ok("unlock");
std::fs::write(
bw.join("list.txt"),
lines.replace("line 2\n", "line 2 (bob)\n"),
)
.unwrap();
bob.git(&bw, &["commit", "-q", "-am", "bob"])
.expect_ok("commit");
bob.git(&bw, &["push", "-q"]).expect_ok("push");
std::fs::write(
w.join("list.txt"),
lines.replace("line 11\n", "line 11 (alice)\n"),
)
.unwrap();
alice
.git(&w, &["commit", "-q", "-am", "alice"])
.expect_ok("commit");
alice
.git(&w, &["pull", "-q", "--no-rebase", "--no-edit"])
.expect_ok("the merge driver merges the decrypted texts");
let merged = String::from_utf8(read(w.join("list.txt"))).unwrap();
assert!(
merged.contains("line 2 (bob)") && merged.contains("line 11 (alice)"),
"{merged}"
);
alice.git(&w, &["push", "-q"]).expect_ok("push the merge");
let (oid, _) = tree_blobs(&alice, &w, "HEAD")
.into_iter()
.find(|(_, p)| p == "list.txt")
.unwrap();
assert!(
envelope::is_envelope(&stored(&h, &oid)),
"the merge result is sealed too"
);
}
#[test]
fn a_substituted_key_ring_is_refused_by_a_member_who_holds_the_real_one() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
let bob = user(&h, "bob", Some(Level::Write));
alice_starts(&h, &alice, &[&bob]);
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "unlock"]).expect_ok("unlock");
// Someone who never held the keys (an operator, say) makes a FRESH ring
// and wraps it to the members' public keys, which are no secret. Anyone
// who accepted it would seal new content under a key that someone knows.
let members = gunnar_crypt::members::Members::parse(
&String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap(),
)
.unwrap();
let forged =
gunnar_crypt::wrap::wrap(&gunnar_crypt::keyring::Keyring::generate(), &members).unwrap();
std::fs::write(alice.work().join(".gunnar-crypt/keys"), forged).unwrap();
alice
.git(&alice.work(), &["commit", "-q", "-am", "nothing to see"])
.expect_ok("commit");
alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");
bob.git(&bw, &["pull", "-q", "--ff-only"]).expect_ok("pull");
let out = bob.gunnar(&bw, &["crypt", "unlock"]);
out.expect_failure("the substituted ring is refused");
assert!(out.stderr.contains("refused"), "{}", out.stderr);
// …and bob's clone keeps sealing under the REAL ring.
std::fs::write(bw.join("new.txt"), "still private\n").unwrap();
bob.git(&bw, &["add", "new.txt"]).expect_ok("add");
let staged = bob.git(&bw, &["ls-files", "-s", "new.txt"]);
let oid = staged.stdout.split(' ').nth(1).unwrap().to_string();
let raw = Command::new("git")
.current_dir(&bw)
.args(["cat-file", "blob", &oid])
.output()
.unwrap()
.stdout;
assert_eq!(envelope::epoch_of(&raw), Some(1));
}
#[test]
fn a_locked_clone_cannot_commit_plaintext_through_the_filter() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
alice_starts(&h, &alice, &[]);
let w = alice.work();
alice.gunnar(&w, &["crypt", "lock"]).expect_ok("lock");
assert!(
envelope::is_envelope(&read(w.join("secret.txt"))),
"locked = ciphertext on disk"
);
assert!(
!w.join(".git/gunnar-crypt/keyring").exists(),
"the local key ring is gone"
);
alice
.gunnar(&w, &["crypt", "unlock"])
.expect_ok("unlock again");
assert_eq!(read(w.join("secret.txt")), SECRET.as_bytes());
let status = alice.gunnar(&w, &["crypt", "status"]);
status.expect_ok("status");
assert!(status.stdout.contains("unlocked"), "{}", status.stdout);
assert!(status.stdout.contains("alice"), "{}", status.stdout);
}
// ── Level 2, first slice: the server's "encrypted" flag ─────────────────────
fn set_encrypted(h: &Harness, on: bool) {
h.stores
.catalog()
.set_encrypted(&RepoRef::new(NS, REPO).unwrap(), on)
.expect("flag the repository");
}
fn remote_main(u: &User, w: &Path) -> String {
let out = u.git(w, &["ls-remote", "origin", "refs/heads/main"]);
out.expect_ok("ls-remote");
out.stdout
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
#[test]
fn an_encrypted_repository_refuses_plaintext_and_takes_ciphertext() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
set_encrypted(&h, true);
// A clone that never ran `gunnar crypt`: plain git, plaintext. Refused,
// and the refusal says what to do.
let plain = alice.home.join("plain");
std::fs::create_dir_all(&plain).unwrap();
alice
.git(&plain, &["init", "-q", "-b", "main"])
.expect_ok("init");
std::fs::write(plain.join("notes.txt"), "the merger closes friday\n").unwrap();
alice.git(&plain, &["add", "notes.txt"]).expect_ok("add");
alice
.git(&plain, &["commit", "-q", "-m", "notes"])
.expect_ok("commit");
alice
.git(&plain, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote");
let refused = alice.git(&plain, &["push", "origin", "main"]);
refused.expect_failure("plaintext is refused by an encrypted repository");
assert!(
refused.stderr.contains("notes.txt is plaintext")
&& refused.stderr.contains("gunnar crypt unlock"),
"the refusal names the file and the fix: {}",
refused.stderr
);
assert_eq!(
remote_main(&alice, &plain),
"",
"a refused push moves no reference"
);
// The proper flow: sealed by the filter, accepted.
alice_starts(&h, &alice, &[]);
let w = alice.work();
let head = alice
.git(&w, &["rev-parse", "HEAD"])
.stdout
.trim()
.to_string();
assert_eq!(remote_main(&alice, &w), head);
// A file slipped past the filter (written straight into the index).
std::fs::write(w.join("leak.txt"), "plaintext that skipped the filter\n").unwrap();
let oid = alice.git(&w, &["hash-object", "-w", "--no-filters", "leak.txt"]);
oid.expect_ok("hash-object");
alice
.git(
&w,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{},leak.txt", oid.stdout.trim()),
],
)
.expect_ok("update-index");
alice
.git(&w, &["commit", "-q", "-m", "leak"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("a blob that skipped the filter is refused");
assert!(
refused.stderr.contains("leak.txt is plaintext"),
"{}",
refused.stderr
);
assert_eq!(remote_main(&alice, &w), head);
alice
.git(&w, &["reset", "-q", "--hard", "HEAD~1"])
.expect_ok("reset");
let _ = std::fs::remove_file(w.join("leak.txt"));
// A symbolic link: git stores its target unencrypted. Refused.
#[cfg(unix)]
{
std::os::unix::fs::symlink("secret.txt", w.join("link")).unwrap();
alice.git(&w, &["add", "link"]).expect_ok("add the link");
alice
.git(&w, &["commit", "-q", "-m", "link"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("a symlink is refused");
assert!(
refused.stderr.contains("symbolic link"),
"{}",
refused.stderr
);
alice
.git(&w, &["reset", "-q", "--hard", "HEAD~1"])
.expect_ok("reset");
}
// New sealed content is still accepted.
std::fs::write(w.join("more.txt"), "sealed as usual\n").unwrap();
alice.git(&w, &["add", "more.txt"]).expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "more"])
.expect_ok("commit");
alice
.git(&w, &["push", "-q", "origin", "main"])
.expect_ok("ciphertext is accepted");
}
// ── add-member from the keys gunnar already holds (Repos.RecipientKeys) ─────
impl User {
fn gunnar_cli(&self, cwd: &Path, args: &[&str]) -> Output {
let mut c = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
c.current_dir(cwd).args(args);
self.env(&mut c);
output(c, args, "gunnar-cli")
}
}
#[test]
fn a_member_is_added_from_the_ssh_keys_gunnar_already_holds() {
assert!(tools_present());
let h = Harness::start_with_grpc(NS, REPO, HashKind::Sha1);
let at = h.grpc_addr.expect("the control plane is up").to_string();
let alice = user(&h, "alice", Some(Level::Write));
let bob = user(&h, "bob", Some(Level::Read));
let frank = user(&h, "frank", None);
alice_starts(&h, &alice, &[]);
let w = alice.work();
let key = alice.key.to_str().unwrap();
// bob: nothing asked of him; his key comes from gunnar.
let added = alice.gunnar_cli(
&w,
&[
"crypt",
"add-member",
"bob",
"--from-server",
"--fingerprint",
&gunnar_crypt::door::fingerprint(&bob.public),
"--at",
&at,
"-i",
key,
],
);
added.expect_ok("add bob from the server's keys");
// The key gunnar handed out is named by its fingerprint (RecipientKey.fingerprint, wiring
// tracer B 2026-10-08), so the adder can check it with bob before pushing.
let bob_fp = gunnar_core::auth::fingerprint_of_blob(bob.public.as_bytes()).to_string();
assert!(
added.stdout.contains(&bob_fp),
"add-member names the key it wrapped to: {}",
added.stdout
);
let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
let bob_body = bob.public.as_str();
assert!(
members.contains(bob_body),
"bob's stored key is now a recipient: {members}"
);
alice.git(&w, &["push", "-q"]).expect_ok("push");
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "unlock"])
.expect_ok("bob unlocks with the key gunnar held");
assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
// frank has an account and keys but no access to the repository: gunnar
// will not hand his keys out for it.
let refused = alice.gunnar_cli(
&w,
&[
"crypt",
"add-member",
"frank",
"--from-server",
"--at",
&at,
"-i",
key,
],
);
refused.expect_failure("no keys for someone who cannot read the repository");
assert!(refused.stderr.contains("cannot read"), "{}", refused.stderr);
let _ = frank;
// erin is a reader with no encryption key.
let auth = h.stores.auth();
auth.add_principal(Principal::new("erin", "erin", PrincipalKind::User).unwrap())
.unwrap();
auth.grant(Grant::repo(
"erin",
RepoRef::new(NS, REPO).unwrap(),
Level::Read,
))
.unwrap();
// erin has no encryption key yet: told, in words, how to make one.
let out = alice.gunnar_cli(
&w,
&[
"crypt",
"add-member",
"erin",
"--from-server",
"--at",
&at,
"-i",
key,
],
);
out.expect_failure("a member without an encryption key cannot be a recipient");
assert!(
out.stderr.contains("no encryption key for erin")
&& out.stderr.contains("gunnar crypt key new"),
"{}",
out.stderr
);
}
// ── add-member from the server over the FORGE's SSH door (no control plane) ──
//
// An installed estate exposes its forge's SSH door (tcp/2222 through the front)
// and its console, never the control plane: gRPC is bound to the WireGuard
// overlay. So `--from-server` with no `--at` asks the forge it pushes to —
// the origin's own SSH door, signed in with the member's own key, the same
// `ssh` git runs — and gunnar answers with the SAME rule `Repos.RecipientKeys`
// keeps: the caller reads the repository, and so does the person named.
#[test]
fn a_member_is_added_from_the_forges_ssh_door_with_no_control_plane() {
assert!(tools_present());
// `Harness::start`: SSH only, no control plane bound at all.
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 frank = user(&h, "frank", None);
alice_starts(&h, &alice, &[]);
let w = alice.work();
// The plain `gunnar` binary (no control feature): bob's key comes from
// the forge alice pushes to.
// ★ RA4-1: the server's word for bob's key is not taken alone: its
// fingerprint is shown, and confirming it is asked for.
let unconfirmed = alice.gunnar(&w, &["crypt", "add-member", "bob", "--from-server"]);
unconfirmed.expect_failure("an unconfirmed key from the server is not wrapped to");
let fp = gunnar_crypt::door::fingerprint(&bob.public);
assert!(
unconfirmed.stderr.contains(&format!("--fingerprint {fp}")),
"{}",
unconfirmed.stderr
);
let wrong = alice.gunnar(
&w,
&[
"crypt",
"add-member",
"bob",
"--from-server",
"--fingerprint",
"SHA256:not-bobs",
],
);
wrong.expect_failure("a fingerprint that is not the key's is refused");
alice
.gunnar(
&w,
&[
"crypt",
"add-member",
"bob",
"--from-server",
"--fingerprint",
&fp,
],
)
.expect_ok("add bob from the keys the forge holds, over its SSH door");
let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
let bob_body = bob.public.as_str();
assert!(
members.contains(bob_body),
"bob's stored key is now a recipient: {members}"
);
alice.git(&w, &["push", "-q"]).expect_ok("push");
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "unlock"])
.expect_ok("bob unlocks with the key the forge handed out");
assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
// frank cannot read the repository: no keys of his for it, in gunnar's words.
let refused = alice.gunnar(&w, &["crypt", "add-member", "frank", "--from-server"]);
refused.expect_failure("no keys for someone who cannot read the repository");
assert!(
refused.stderr.contains("frank cannot read team/vault"),
"{}",
refused.stderr
);
// frank asking for bob's keys: he cannot read the repository himself, and
// is told what a stranger is told — nothing about whether it exists.
let fw = frank.home.join("probe");
std::fs::create_dir_all(&fw).unwrap();
frank
.git(&fw, &["init", "-q", "-b", "main"])
.expect_ok("init");
frank
.git(&fw, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
frank
.gunnar(&fw, &["crypt", "init", "--member", "frank", "--no-commit"])
.expect_ok("frank's own ring");
let hidden = frank.gunnar(&fw, &["crypt", "add-member", "bob", "--from-server"]);
hidden.expect_failure("a caller who cannot read the repository gets no keys");
assert!(
hidden
.stderr
.contains("repository not found, or you do not have access"),
"{}",
hidden.stderr
);
assert!(!hidden.stderr.contains(bob_body), "{}", hidden.stderr);
}
// ── a refused push leaves NOTHING: not on main's disk, not for the twin ─────
//
// crypt-level2 item 4, pulled forward (2026-10-06, cov-06 measured it on the
// estate: a refused plaintext push left its pack on main until gc AND framed
// it into the transaction log the twin replays — level 1's promise broken).
// The gate decides on the pushed pack BEFORE it is stored or framed: a
// refusal stores no object and appends no `Pack` frame.
fn pack_frames(h: &Harness) -> usize {
let mut n = 0;
h.stores
.txlog()
.scan_meta(|_, kind, _| {
if gunnar_core::txlog::Kind::from_u16(kind) == Some(gunnar_core::txlog::Kind::Pack) {
n += 1;
}
})
.expect("scan the transaction log");
n
}
fn held(h: &Harness, oid: &str) -> bool {
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")
.is_some()
}
#[test]
fn a_refused_plaintext_push_is_never_stored_and_never_framed_for_the_twin() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
set_encrypted(&h, true);
alice_starts(&h, &alice, &[]);
let w = alice.work();
let frames_before = pack_frames(&h);
assert!(
frames_before >= 1,
"the accepted sealed push is a Pack frame"
);
std::fs::write(
w.join("leak.txt"),
"plaintext that must never reach the server\n",
)
.unwrap();
let oid = alice.git(&w, &["hash-object", "-w", "--no-filters", "leak.txt"]);
oid.expect_ok("hash-object");
let oid = oid.stdout.trim().to_string();
alice
.git(
&w,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{oid},leak.txt"),
],
)
.expect_ok("update-index");
alice
.git(&w, &["commit", "-q", "-m", "leak"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("plaintext is refused");
assert!(
refused.stderr.contains("leak.txt is plaintext")
&& refused.stderr.contains("gunnar crypt unlock"),
"the refusal still names the file and the fix: {}",
refused.stderr
);
assert!(
!held(&h, &oid),
"the refused plaintext blob {oid} is in the server's store"
);
assert_eq!(
pack_frames(&h),
frames_before,
"the refused pack was framed for the twin"
);
// Sealed content is still taken, and framed.
alice
.git(&w, &["reset", "-q", "--hard", "HEAD~1"])
.expect_ok("reset");
let _ = std::fs::remove_file(w.join("leak.txt"));
std::fs::write(w.join("more.txt"), "sealed as usual\n").unwrap();
alice.git(&w, &["add", "more.txt"]).expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "more"])
.expect_ok("commit");
alice
.git(&w, &["push", "-q", "origin", "main"])
.expect_ok("ciphertext is accepted");
assert_eq!(
pack_frames(&h),
frames_before + 1,
"the accepted pack is one frame"
);
}
// ── the quantum-safe level (crypt-pq) ────────────────────────────────────────
//
// A member's quantum-safe key is a separate age1pq1… key (age's hybrid
// ML-KEM-768 + X25519) made by `gunnar crypt keygen` on their own machine. A
// quantum-safe repository's key ring is wrapped to those keys ONLY; the gate
// refuses anything classical in it, in quarantine.
#[test]
fn keygen_makes_a_private_0600_key_here_and_never_overwrites_it() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", None);
let out = alice.gunnar(&alice.home, &["crypt", "keygen"]);
out.expect_ok("keygen");
assert!(
out.stdout.contains("cannot be quantum-safe recipients")
&& out.stdout.contains("never leaves this machine"),
"keygen says what the key is and why the SSH key is not it: {}",
out.stdout
);
let key = alice.pq_key();
let text = String::from_utf8(read(&key)).unwrap();
assert!(
text.lines().any(|l| l.starts_with("AGE-SECRET-KEY-PQ-1")),
"an age identity file"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the private key is the owner's only");
}
let public = std::fs::read_to_string(key.with_extension("key.pub")).unwrap();
assert!(public.starts_with("age1pq1"), "{public}");
assert!(
!out.stdout.contains("AGE-SECRET-KEY"),
"the private key is never printed"
);
let again = alice.gunnar(&alice.home, &["crypt", "keygen"]);
again.expect_failure("an existing key is never overwritten silently");
assert_eq!(String::from_utf8(read(&key)).unwrap(), text);
}
/// **keygen sends a member to the console, never to a control address**
/// (wiring tracer B 2026-10-08): on an installed estate the control plane sits
/// on the estate's private overlay and no member reaches it, so
/// `gunnar-cli control … --at <control address>` was a dead end printed to
/// every member.
#[test]
fn keygen_points_a_member_to_the_consoles_keys_page_never_to_a_control_address() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", None);
let out = alice.gunnar(&alice.home, &["crypt", "keygen"]);
out.expect_ok("keygen");
assert!(
out.stdout.contains("keys page in the console"),
"{}",
out.stdout
);
assert!(
out.stdout.contains("gunnar crypt key add-pq"),
"keygen's file is an unlock method (CRYPT-PQ-DEFAULT): {}",
out.stdout
);
for dead_end in ["--at <control address>", "gunnar-cli control"] {
assert!(
!out.stdout.contains(dead_end),
"{dead_end:?} is printed to a member: {}",
out.stdout
);
}
}
/// ★ **`crypt init` names its first member by the account the forge says,
/// not by `$USER`** (wiring tracer B 2026-10-08): `add-member --from-server`
/// looks every member up by that name, and a login on the member's own machine
/// that differs from the account name broke it later. Without a forge to ask,
/// `--member` is required.
#[test]
fn crypt_init_names_its_member_by_the_forge_account_not_by_user() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
let w = alice.work();
std::fs::create_dir_all(&w).unwrap();
alice
.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
let mut bare = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
bare.current_dir(&w).args(["crypt", "init", "--no-commit"]);
alice.env(&mut bare);
bare.env("USER", "not-the-account-name");
let bare = output(bare, &["crypt", "init", "--no-commit"], "gunnar");
bare.expect_failure("no forge to ask and no --member: $USER is never the guess");
assert!(
bare.stderr.contains("--member"),
"it says what to pass: {}",
bare.stderr
);
alice
.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
let mut init = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
init.current_dir(&w).args(["crypt", "init", "--no-commit"]);
alice.env(&mut init);
init.env("USER", "not-the-account-name");
output(init, &["crypt", "init", "--no-commit"], "gunnar")
.expect_ok("crypt init asks the forge");
let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
assert!(
members.lines().any(|l| l.starts_with("alice ")),
"the forge's account name: {members}"
);
assert!(!members.contains("not-the-account-name"), "{members}");
}
#[test]
fn a_quantum_safe_repository_refuses_a_classical_ring_before_storing_it() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = quantum_safe_user(&h, "alice");
set_encrypted(&h, true);
alice_starts_quantum_safe(&h, &alice);
let w = alice.work();
let frames = pack_frames(&h);
// A ring wrapped classically again (to an X25519 key) beside a members
// file that still says quantum-safe: the weak link the level forbids.
let ring = gunnar_crypt::keyring::Keyring::parse(
&String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
)
.unwrap();
let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &classic_key()).unwrap();
let classical = gunnar_crypt::wrap::wrap(&ring, &classic).unwrap();
std::fs::write(w.join(".gunnar-crypt/keys"), &classical).unwrap();
alice
.git(&w, &["add", ".gunnar-crypt/keys"])
.expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "classical ring"])
.expect_ok("commit");
let keys_blob = alice
.git(&w, &["rev-parse", "HEAD:.gunnar-crypt/keys"])
.stdout
.trim()
.to_string();
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("a classical ring is refused in a quantum-safe repository");
assert!(
refused.stderr.contains("X25519 stanza") && refused.stderr.contains("quantum-safe"),
"{}",
refused.stderr
);
assert!(
!held(&h, &keys_blob),
"the refused ring is in the server's store"
);
assert_eq!(
pack_frames(&h),
frames,
"the refused pack was framed for the twin"
);
// Mixed: the hybrid stanza AND the classical one (a member who holds the
// file key can write that header). Refused the same way.
alice
.git(&w, &["reset", "-q", "--hard", "HEAD~1"])
.expect_ok("reset");
let mut both = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
both.add("alice", &classic_key()).unwrap();
let hybrid = String::from_utf8(read(w.join(".gunnar-crypt/keys"))).unwrap();
let mixed = splice(&hybrid, &gunnar_crypt::wrap::wrap(&ring, &both).unwrap());
std::fs::write(w.join(".gunnar-crypt/keys"), &mixed).unwrap();
alice
.git(&w, &["add", ".gunnar-crypt/keys"])
.expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "mixed ring"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("a mixed ring is refused in a quantum-safe repository");
assert!(
refused.stderr.contains("quantum-safe"),
"{}",
refused.stderr
);
assert_eq!(pack_frames(&h), frames);
}
/// ★ **A quantum-safe repository does not go back to classical** (wiring
/// tracer B 2026-10-08). The gate read the level from the PUSHED members file
/// only, so a writer who dropped `level quantum-safe` from it and wrapped the
/// ring to an SSH key again was accepted — every later epoch exposed to a
/// harvested ring. The level only goes up: the branch's stored members file
/// says what it was.
#[test]
fn a_quantum_safe_repository_refuses_going_back_to_classical() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = quantum_safe_user(&h, "alice");
set_encrypted(&h, true);
alice_starts_quantum_safe(&h, &alice);
let w = alice.work();
let tip = remote_main(&alice, &w);
// The members file says classic again, and the ring matches it: each file
// alone is consistent, so only the stored level can refuse it.
let ring = gunnar_crypt::keyring::Keyring::parse(
&String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
)
.unwrap();
let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &classic_key()).unwrap();
std::fs::write(w.join(".gunnar-crypt/members"), classic.to_text()).unwrap();
std::fs::write(
w.join(".gunnar-crypt/keys"),
gunnar_crypt::wrap::wrap(&ring, &classic).unwrap(),
)
.unwrap();
alice
.git(&w, &["add", ".gunnar-crypt/members", ".gunnar-crypt/keys"])
.expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "back to classical"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "main"]);
refused.expect_failure("a quantum-safe repository does not go back to classical");
assert!(
refused.stderr.contains("quantum-safe"),
"the refusal says why: {}",
refused.stderr
);
assert_eq!(remote_main(&alice, &w), tip, "the branch did not move");
}
/// ★ **A new branch of a quantum-safe repository is quantum-safe too**
/// (wiring tracer B 2026-10-08): the level only goes up, and a NEW reference
/// has no old tip to compare with — so it is compared with the default
/// branch. A writer who pushes the classical ring as a fresh branch is
/// refused, and no such branch appears on the server.
#[test]
fn a_new_branch_of_a_quantum_safe_repository_cannot_be_classical() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = quantum_safe_user(&h, "alice");
set_encrypted(&h, true);
alice_starts_quantum_safe(&h, &alice);
let w = alice.work();
let ring = gunnar_crypt::keyring::Keyring::parse(
&String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
)
.unwrap();
let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
classic.add("alice", &classic_key()).unwrap();
alice
.git(&w, &["checkout", "-q", "-b", "classic"])
.expect_ok("a new branch");
std::fs::write(w.join(".gunnar-crypt/members"), classic.to_text()).unwrap();
std::fs::write(
w.join(".gunnar-crypt/keys"),
gunnar_crypt::wrap::wrap(&ring, &classic).unwrap(),
)
.unwrap();
alice
.git(&w, &["add", ".gunnar-crypt/members", ".gunnar-crypt/keys"])
.expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "a classical branch"])
.expect_ok("commit");
let refused = alice.git(&w, &["push", "origin", "classic"]);
refused.expect_failure("a new branch of a quantum-safe repository may not be classical");
assert!(
refused.stderr.contains("quantum-safe"),
"the refusal says why: {}",
refused.stderr
);
let listed = alice.git(&w, &["ls-remote", "origin", "refs/heads/classic"]);
assert!(
listed.stdout.trim().is_empty(),
"the classical branch is on the server: {}",
listed.stdout
);
}
/// A binary age file whose header holds `a`'s stanzas, then `b`'s, and `a`'s
/// MAC: what a hand-edited ring looks like (the server cannot check the MAC).
fn splice(a: &str, b: &str) -> Vec<u8> {
use std::io::Read as _;
fn parts(file: &str) -> (Vec<u8>, Vec<u8>) {
let mut bin = Vec::new();
gunnar_crypt::wrap::age::armor::ArmoredReader::new(file.as_bytes())
.read_to_end(&mut bin)
.unwrap();
let start = bin.iter().position(|b| *b == b'\n').unwrap() + 1;
let mac = bin.windows(5).position(|w| w == b"\n--- ").unwrap() + 1;
(bin[start..mac].to_vec(), bin[mac..].to_vec())
}
let (sa, rest) = parts(a);
let (sb, _) = parts(b);
[b"age-encryption.org/v1\n".to_vec(), sa, sb, rest].concat()
}
// ── CRYPT-YUBI (2026-10-26): one encryption key per person ─────────────────
/// ★ **SSH must still work** (owner, 2026-10-26 ~22:10): a person with ONLY an
/// SSH key does the whole flow in the CLI — `crypt init` makes their
/// encryption key (wrapped to the SSH key, kept at the forge), push, a fresh
/// clone, unlock, add a member who is not present, remove a member, rotate.
#[test]
fn a_person_with_only_an_ssh_key_does_the_whole_flow_in_the_cli() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
let bob = User::without_encryption_key(&h, "bob", Some(Level::Write), NS, REPO);
alice_starts(&h, &alice, &[]);
let made = h
.stores
.encryption_keys()
.get("alice")
.unwrap()
.expect("crypt init made alice's encryption key");
assert_eq!(
(made.methods.len(), made.methods[0].kind),
(1, gunnar_core::encryption_key::MethodKind::Ssh)
);
let members = String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap();
assert!(
members.contains(&format!("alice {}", made.public_key)) && !members.contains("ssh-"),
"{members}"
);
// A fresh clone of her own opens with her SSH key alone.
let again = alice.home.join("again");
alice
.git(
&alice.home,
&["clone", "-q", &h.url(NS, REPO), again.to_str().unwrap()],
)
.expect_ok("clone");
assert!(envelope::is_envelope(&read(again.join("secret.txt"))));
alice
.gunnar(&again, &["crypt", "unlock"])
.expect_ok("alice unlocks a fresh clone with her SSH key");
assert_eq!(read(again.join("secret.txt")), SECRET.as_bytes());
// bob makes his key once; alice adds him while he is not there.
let bw = clone(&h, &bob);
bob.gunnar(&bw, &["crypt", "key", "new"])
.expect_ok("bob makes his encryption key");
let w = alice.work();
let bob_public = h
.stores
.encryption_keys()
.public_of("bob")
.unwrap()
.expect("bob's key");
alice
.gunnar(
&w,
&[
"crypt",
"add-member",
"bob",
"--from-server",
"--fingerprint",
&gunnar_crypt::door::fingerprint(&bob_public),
],
)
.expect_ok("add bob from the forge");
alice.git(&w, &["push", "-q"]).expect_ok("push");
bob.git(&bw, &["pull", "-q", "--ff-only"])
.expect_ok("bob pulls");
bob.gunnar(&bw, &["crypt", "unlock"])
.expect_ok("bob unlocks with his SSH key");
assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
// Rotate, then remove bob: new content is closed to him.
alice.gunnar(&w, &["crypt", "rotate"]).expect_ok("rotate");
alice
.gunnar(&w, &["crypt", "remove-member", "bob"])
.expect_ok("remove bob");
std::fs::write(w.join("later.txt"), "after bob\n").unwrap();
alice.git(&w, &["add", "later.txt"]).expect_ok("add");
alice
.git(&w, &["commit", "-q", "-m", "later"])
.expect_ok("commit");
alice.git(&w, &["push", "-q"]).expect_ok("push");
bob.git(&bw, &["pull", "-q", "--ff-only"])
.expect_ok("bob pulls");
let refused = bob.gunnar(&bw, &["crypt", "unlock"]);
refused.expect_failure("bob is no member any more");
assert!(
refused.stderr.contains("you are not a member"),
"{}",
refused.stderr
);
assert!(
envelope::is_envelope(&read(bw.join("later.txt"))),
"new content stays sealed to bob"
);
}
/// ★ **Adding or removing an unlock method touches no repository**: a printed
/// recovery code added and removed again, the ring and the members file
/// unchanged, the SSH key still opening it; the last method is never removed.
#[test]
fn adding_and_removing_an_unlock_method_touches_no_repository() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = user(&h, "alice", Some(Level::Write));
alice_starts(&h, &alice, &[]);
let w = alice.work();
let before = (
read(w.join(".gunnar-crypt/keys")),
read(w.join(".gunnar-crypt/members")),
remote_main(&alice, &w),
);
let added = alice.gunnar(&w, &["crypt", "key", "add-recovery-code"]);
added.expect_ok("a recovery code");
assert!(
added.stdout.contains("No repository changed"),
"{}",
added.stdout
);
let key = h.stores.encryption_keys().get("alice").unwrap().unwrap();
assert_eq!(key.methods.len(), 2);
let status = alice.gunnar(&w, &["crypt", "key", "status"]);
status.expect_ok("status");
assert!(
status.stdout.contains("recovery-code") && status.stdout.contains("ssh"),
"{}",
status.stdout
);
let code_id = key
.methods
.iter()
.find(|m| m.kind == gunnar_core::encryption_key::MethodKind::RecoveryCode)
.unwrap()
.id
.clone();
alice
.gunnar(&w, &["crypt", "key", "remove", &code_id])
.expect_ok("remove the code");
let ssh_id = h
.stores
.encryption_keys()
.get("alice")
.unwrap()
.unwrap()
.methods[0]
.id
.clone();
let last = alice.gunnar(&w, &["crypt", "key", "remove", &ssh_id]);
last.expect_failure("the last method stays");
assert!(
last.stderr.contains("last unlock method"),
"{}",
last.stderr
);
let after = (
read(w.join(".gunnar-crypt/keys")),
read(w.join(".gunnar-crypt/members")),
remote_main(&alice, &w),
);
assert!(before == after, "no repository was touched");
alice.gunnar(&w, &["crypt", "lock"]).expect_ok("lock");
alice
.gunnar(&w, &["crypt", "unlock"])
.expect_ok("the SSH key still opens it");
}
/// ★ **RA4-1: a server that swaps a person's key AND its wrap is refused.**
/// alice's machine pinned her encryption key; a forge that answers another key
/// for her (here: another server where "alice" holds an operator's key, wrapped
/// to alice's real SSH key, so it opens) is refused by name at init and at
/// unlock, and nothing is wrapped to the operator's key.
#[test]
fn a_forge_that_swaps_the_persons_key_and_wrap_is_refused_by_the_pin() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
alice_starts(&h, &alice, &[]);
let pins = alice.home.join(".config/gunnar/encryption-pins");
let pinned = std::fs::read_to_string(&pins).expect("crypt init pinned alice's own key");
let real = h
.stores
.encryption_keys()
.get("alice")
.unwrap()
.unwrap()
.public_key;
// RA5-4: `<forge> self <host key> <account> <key>`, one own pin per forge host.
assert!(
pinned
.lines()
.any(|l| l.split_ascii_whitespace().nth(1) == Some("self")
&& l.ends_with(&format!(" alice {real}"))),
"{pinned}"
);
// The hostile forge: the same host, alice's SSH key, but an operator's key.
let h2 = Harness::start(NS, REPO, HashKind::Sha1);
let alice2 = User::without_encryption_key(&h2, "alice", Some(Level::Write), NS, REPO);
std::fs::copy(alice.home.join(".ssh/id_ed25519"), &alice2.key).unwrap();
std::fs::write(
alice2.home.join(".ssh/id_ed25519.pub"),
format!("{}\n", alice.ssh_public),
)
.unwrap();
h2.stores
.auth()
.register_key("alice", &alice.ssh_public, None)
.unwrap();
let operator = common::member::give_an_encryption_key(&h2, "alice", &alice.ssh_public);
assert_ne!(operator, real);
std::fs::create_dir_all(alice2.home.join(".config/gunnar")).unwrap();
std::fs::copy(&pins, alice2.home.join(".config/gunnar/encryption-pins")).unwrap();
let w = alice2.work();
std::fs::create_dir_all(&w).unwrap();
alice2
.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
alice2
.git(&w, &["remote", "add", "origin", &h2.url(NS, REPO)])
.expect_ok("remote add");
let refused = alice2.gunnar(&w, &["crypt", "init", "--member", "alice"]);
refused.expect_failure("init refuses a key the pin does not know");
// Another server: its SSH host key is not the pinned one (RA5-4), refused before the key is read.
assert!(
refused
.stderr
.contains("another SSH host key than the one this machine pinned"),
"{}",
refused.stderr
);
assert!(
!w.join(".gunnar-crypt/keys").exists(),
"no ring was wrapped to the operator's key"
);
let status = alice2.gunnar(&w, &["crypt", "key", "status"]);
status.expect_failure("status refuses it too");
}
/// The forge's SSH door, asked directly as alice's machine would.
fn alice_forge_says(u: &User, w: &Path, verb: &str) -> String {
let mut c = Command::new("sh");
c.current_dir(w)
.arg("-c")
.arg("$GIT_SSH_COMMAND \"$@\"")
.arg("ssh");
let origin = u.git(w, &["remote", "get-url", "origin"]).stdout.clone();
let rest = origin.trim().strip_prefix("ssh://").unwrap();
let (authority, _) = rest.split_once('/').unwrap();
let (host, port) = authority.rsplit_once(':').unwrap();
c.args(["-p", port, host, verb]);
u.env(&mut c);
String::from_utf8(c.output().unwrap().stdout).unwrap()
}
/// ★ **RA5-4: the pin is not keyed by the forge's own word for who you are.**
/// alice's machine pinned her encryption key at this forge. The forge (the
/// same server, the same host key) then answers `gunnar-whoami` with another
/// name, `alice-2`, which holds an operator's key wrapped to alice's real SSH
/// key. Before, no pin was found under `self:alice-2`, the operator's key was
/// pinned "the first time" (one stderr line) and every new ring was wrapped to
/// it. Now the forge's other name is refused by name, and so is a name that is
/// not the operator's configured `gunnar.account`; nothing is wrapped; only
/// the explicit `gunnar crypt key repin --fingerprint` takes the new key.
#[test]
fn a_forge_that_answers_another_name_is_refused_and_nothing_is_wrapped_to_it() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
alice_starts(&h, &alice, &[]);
let pins = alice.home.join(".config/gunnar/encryption-pins");
let real = h
.stores
.encryption_keys()
.get("alice")
.unwrap()
.unwrap()
.public_key;
// The hostile forge: alice's SSH key now signs in as "alice-2", who holds an operator's key.
let auth = h.stores.auth();
let parsed = gunnar_core::ssh_key::PublicKey::from_openssh(&alice.ssh_public).unwrap();
auth.revoke_key(&gunnar_core::auth::Fingerprint::of_key(&parsed))
.unwrap()
.expect("alice's key was registered");
auth.add_principal(Principal::new("alice-2", "alice-2", PrincipalKind::User).unwrap())
.unwrap();
auth.register_key("alice-2", &alice.ssh_public, None)
.unwrap();
auth.grant(Grant::repo(
"alice-2",
RepoRef::new(NS, REPO).unwrap(),
Level::Write,
))
.unwrap();
let operator = common::member::give_an_encryption_key(&h, "alice-2", &alice.ssh_public);
assert_ne!(operator, real);
let w = alice.home.join("second");
std::fs::create_dir_all(&w).unwrap();
alice
.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
alice
.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
assert_eq!(
alice_forge_says(&alice, &w, "gunnar-whoami").trim(),
"alice-2",
"the forge lies about the name"
);
let pins_before = std::fs::read_to_string(&pins).unwrap();
for args in [
&["crypt", "init"][..],
&["crypt", "init", "--member", "alice"][..],
&["crypt", "key", "status"][..],
] {
let refused = alice.gunnar(&w, args);
refused.expect_failure("the forge's other name is refused");
assert!(
refused
.stderr
.contains("now says you are alice-2, and this machine pinned you as alice"),
"{args:?}: {}",
refused.stderr
);
assert!(
refused.stderr.contains("gunnar crypt key repin"),
"{}",
refused.stderr
);
assert!(
!refused.stderr.contains("pinned your encryption key"),
"no new pin: {}",
refused.stderr
);
}
assert!(
!w.join(".gunnar-crypt/keys").exists(),
"no ring was wrapped to the operator's key"
);
assert_eq!(
std::fs::read_to_string(&pins).unwrap(),
pins_before,
"the pins are unchanged"
);
// A machine with no pin yet, whose operator configured who they are: the other name is refused too.
std::fs::rename(&pins, pins.with_extension("away")).unwrap();
alice
.git(&w, &["config", "gunnar.account", "alice"])
.expect_ok("configure the account");
let refused = alice.gunnar(&w, &["crypt", "init"]);
refused.expect_failure("not the configured account");
assert!(
refused
.stderr
.contains("says you are alice-2, and gunnar.account says alice"),
"{}",
refused.stderr
);
assert!(!pins.exists(), "nothing was pinned");
assert!(!w.join(".gunnar-crypt/keys").exists());
alice
.git(&w, &["config", "--unset", "gunnar.account"])
.expect_ok("unset");
std::fs::rename(pins.with_extension("away"), &pins).unwrap();
// Only the explicit re-pin, with the new key's fingerprint read out, takes it.
let fp = gunnar_crypt::door::fingerprint(&operator);
let wrong = alice.gunnar(
&w,
&[
"crypt",
"key",
"repin",
"--fingerprint",
"SHA256:not-this-one",
],
);
wrong.expect_failure("a wrong fingerprint re-pins nothing");
assert_eq!(std::fs::read_to_string(&pins).unwrap(), pins_before);
let repinned = alice.gunnar(&w, &["crypt", "key", "repin", "--fingerprint", &fp]);
repinned.expect_ok("the explicit re-pin");
assert!(
repinned.stdout.contains("alice-2") && repinned.stdout.contains(&fp),
"{}",
repinned.stdout
);
alice
.gunnar(&w, &["crypt", "key", "status"])
.expect_ok("the re-pinned key is taken");
}
// ── CRYPT-PQ-DEFAULT (owner 2026-10-30): quantum-safe is the default ────────
/// The members file of the work tree `w`.
fn members_at(w: &Path) -> String {
String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap()
}
/// The age stanza types of the work tree's key ring (age's grease left out).
fn ring_stanzas(w: &Path) -> Vec<String> {
gunnar_crypt::policy::age_stanzas(&read(w.join(".gunnar-crypt/keys")))
.unwrap()
.into_iter()
.map(|s| s.tag)
.filter(|t| !t.ends_with("-grease"))
.collect()
}
/// A person whose ONE encryption key is classic (X25519), opened by their SSH key.
fn classic_user(h: &Harness, name: &'static str) -> User {
let mut u = User::without_encryption_key(h, name, Some(Level::Write), NS, REPO);
u.public = common::member::give_an_encryption_key_at(
h,
name,
&u.ssh_public,
gunnar_crypt::members::Level::Classic,
);
u
}
/// A git work tree for `u` with the forge as origin, nothing committed.
fn empty_work(h: &Harness, u: &User) -> PathBuf {
let w = u.work();
std::fs::create_dir_all(&w).unwrap();
u.git(&w, &["init", "-q", "-b", "main"])
.expect_ok("git init");
u.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
.expect_ok("remote add");
w
}
/// Commit the secret in `w` and push it.
fn commit_and_push(u: &User, w: &Path) {
std::fs::write(w.join("secret.txt"), SECRET).unwrap();
u.git(w, &["add", "secret.txt"]).expect_ok("git add");
u.git(w, &["commit", "-q", "-m", "the secret"])
.expect_ok("git commit");
u.git(w, &["push", "-q", "-u", "origin", "main"])
.expect_ok("push");
}
/// ★ **A new repository is quantum-safe unless asked otherwise.** A person with
/// only an SSH key runs `gunnar crypt init`: their key is made quantum-safe, the
/// members file says `level quantum-safe`, the ring carries hybrid stanzas only,
/// the server takes it, and a fresh clone opens with the SSH key. The SSH key's
/// wrap of the new key is classical, and init says so plainly.
#[test]
fn crypt_init_starts_quantum_safe_by_default() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
set_encrypted(&h, true);
let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
let w = empty_work(&h, &alice);
let init = alice.gunnar(&w, &["crypt", "init", "--member", "alice"]);
init.expect_ok("gunnar crypt init, no level asked for");
let made = h
.stores
.encryption_keys()
.public_of("alice")
.unwrap()
.expect("init made alice's key");
assert!(
made.starts_with("age1pq1"),
"a new key is quantum-safe: {made}"
);
let members = members_at(&w);
assert!(
members.lines().any(|l| l == "level quantum-safe"),
"{members}"
);
assert!(members.contains(&format!("alice {made}")), "{members}");
let stanzas = ring_stanzas(&w);
assert!(
!stanzas.is_empty() && stanzas.iter().all(|t| t == "mlkem768x25519"),
"{stanzas:?}"
);
assert!(
init.stderr.contains("not quantum-safe") && init.stderr.contains("gunnar crypt key add-pq"),
"the SSH wrap's strength is said plainly: {}",
init.stderr
);
commit_and_push(&alice, &w);
let again = alice.home.join("again");
alice
.git(
&alice.home,
&["clone", "-q", &h.url(NS, REPO), again.to_str().unwrap()],
)
.expect_ok("clone");
alice
.gunnar(&again, &["crypt", "unlock"])
.expect_ok("the SSH key opens the quantum-safe key");
assert_eq!(read(again.join("secret.txt")), SECRET.as_bytes());
let status = alice.gunnar(&again, &["crypt", "status"]);
status.expect_ok("status");
assert!(status.stdout.contains("quantum-safe"), "{}", status.stdout);
}
/// ★ **Classic is a visible opt-out.** A person whose one key is classic is
/// told how to start a classic repository (`--level classic`) and nothing is
/// written; with it, the repository is classic in the old shape.
#[test]
fn a_classic_key_starts_a_classic_repository_only_when_asked() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = classic_user(&h, "alice");
let w = empty_work(&h, &alice);
let refused = alice.gunnar(&w, &["crypt", "init", "--member", "alice"]);
refused.expect_failure("a classic key cannot be a member of a quantum-safe repository");
assert!(
refused.stderr.contains("--level classic") && refused.stderr.contains("classic"),
"the refusal names the opt-out: {}",
refused.stderr
);
assert!(!w.join(".gunnar-crypt").exists(), "nothing was written");
alice
.gunnar(
&w,
&["crypt", "init", "--member", "alice", "--level", "classic"],
)
.expect_ok("crypt init --level classic");
let members = members_at(&w);
assert!(
!members.contains("level"),
"a classic file keeps its old shape: {members}"
);
assert!(
members.contains(&format!("alice {}", alice.public)),
"{members}"
);
assert!(
ring_stanzas(&w).iter().all(|t| t == "X25519"),
"{:?}",
ring_stanzas(&w)
);
commit_and_push(&alice, &w);
let status = alice.gunnar(&w, &["crypt", "status"]);
status.expect_ok("status");
assert!(status.stdout.contains("classic"), "{}", status.stdout);
}
/// ★ **`gunnar crypt key new` makes a quantum-safe key unless `--classic`**, and
/// says plainly that the SSH key's wrap of a quantum-safe key is classical;
/// `key status` marks each method's wrap.
#[test]
fn key_new_makes_a_quantum_safe_key_unless_classic_is_asked_for() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
let bob = User::without_encryption_key(&h, "bob", Some(Level::Write), NS, REPO);
let aw = empty_work(&h, &alice);
let made = alice.gunnar(&aw, &["crypt", "key", "new"]);
made.expect_ok("key new");
let public = h
.stores
.encryption_keys()
.public_of("alice")
.unwrap()
.expect("alice's key");
assert!(public.starts_with("age1pq1"), "{public}");
let said = format!("{}{}", made.stdout, made.stderr);
assert!(
said.contains("not quantum-safe"),
"the SSH wrap's strength is said: {said}"
);
let status = alice.gunnar(&aw, &["crypt", "key", "status"]);
status.expect_ok("key status");
assert!(
status.stdout.contains("quantum-safe key") && status.stdout.contains("not quantum-safe"),
"{}",
status.stdout
);
let bw = empty_work(&h, &bob);
let classic = bob.gunnar(&bw, &["crypt", "key", "new", "--classic"]);
classic.expect_ok("key new --classic");
let public = h
.stores
.encryption_keys()
.public_of("bob")
.unwrap()
.expect("bob's key");
assert!(
public.starts_with("age1") && !public.starts_with("age1pq1"),
"{public}"
);
}
/// ★ **`gunnar crypt level quantum-safe`**: a classic repository is re-wrapped to
/// quantum-safe keys only when every member has one. Members without one are
/// named and nothing changes; with a quantum-safe key for each, the ring is
/// rotated and wrapped to hybrid stanzas only, the server takes it, and the
/// level never goes back.
#[test]
fn crypt_level_quantum_safe_upgrades_only_with_a_pq_key_for_every_member() {
assert!(tools_present());
let h = Harness::start(NS, REPO, HashKind::Sha1);
set_encrypted(&h, true);
let alice = classic_user(&h, "alice");
let bob = classic_user(&h, "bob");
let w = empty_work(&h, &alice);
alice
.gunnar(
&w,
&["crypt", "init", "--member", "alice", "--level", "classic"],
)
.expect_ok("a classic repository");
let bob_key = alice.home.join("bob.pub");
std::fs::write(&bob_key, &bob.public).unwrap();
alice
.gunnar(
&w,
&[
"crypt",
"add-member",
"bob",
"--key",
bob_key.to_str().unwrap(),
],
)
.expect_ok("add bob");
commit_and_push(&alice, &w);
let shown = alice.gunnar(&w, &["crypt", "level"]);
shown.expect_ok("crypt level");
assert!(shown.stdout.contains("classic"), "{}", shown.stdout);
let before = (
members_at(&w),
remote_main(&alice, &w),
alice.git(&w, &["rev-parse", "HEAD"]).stdout,
);
let refused = alice.gunnar(&w, &["crypt", "level", "quantum-safe", "--from-server"]);
refused.expect_failure("alice and bob have classic keys only");
assert!(
refused.stderr.contains("alice") && refused.stderr.contains("bob"),
"every member without one is named: {}",
refused.stderr
);
assert!(refused.stderr.contains("age1pq1"), "{}", refused.stderr);
assert_eq!(
before,
(
members_at(&w),
remote_main(&alice, &w),
alice.git(&w, &["rev-parse", "HEAD"]).stdout
),
"nothing changed"
);
let (pa, pb) = (alice.keygen(), bob.keygen());
let done = alice.gunnar(
&w,
&[
"crypt",
"level",
"quantum-safe",
"--key",
&format!("alice={pa}"),
"--key",
&format!("bob={pb}"),
],
);
done.expect_ok("every member has a quantum-safe key");
let members = members_at(&w);
assert!(
members.lines().any(|l| l == "level quantum-safe"),
"{members}"
);
assert!(
members.contains(&format!("alice {pa}")) && members.contains(&format!("bob {pb}")),
"{members}"
);
assert!(
ring_stanzas(&w).iter().all(|t| t == "mlkem768x25519"),
"{:?}",
ring_stanzas(&w)
);
assert!(
done.stdout.contains("epoch 2"),
"the ring is rotated: {}",
done.stdout
);
alice
.git(&w, &["push", "-q"])
.expect_ok("the server takes classic → quantum-safe");
// bob's quantum-safe key file opens the new ring, and the old epoch is in it.
let bob_file = std::fs::read_to_string(bob.pq_key()).unwrap();
let id = gunnar_crypt::wrap::pq_identity(&bob_file).unwrap();
let ring = gunnar_crypt::wrap::unwrap_checked(
&read(w.join(".gunnar-crypt/keys")),
&members,
&[id.as_ref()],
)
.unwrap();
assert_eq!(ring.keys().len(), 2);
let back = alice.gunnar(&w, &["crypt", "level", "classic"]);
back.expect_failure("the level only goes up");
assert!(back.stderr.contains("only goes up"), "{}", back.stderr);
}