crates/gunnar-policy/src/gates/encrypted.rs
//! The encrypted-repository gate: on a repository flagged ENCRYPTED, a push
//! that would put plaintext on the server is refused.
//!
//! The server holds no key and never decrypts. It can still check FORMAT: the
//! pushed trees are walked, and every new blob must be a gunnar-crypt envelope,
//! except the three files the scheme itself needs (the rule table is
//! `gunnar_crypt::policy`, shared with the client). Symbolic links are refused,
//! because git stores a link's target unencrypted and never filters it.
//!
//! **The key ring against the members** (2026-10-06, crypt-pq): when a push
//! brings a new `.gunnar-crypt` tree whose `keys` or `members` changed, the
//! ring's age header is read — no key needed — and must match the members
//! file one to one (`gunnar_crypt::policy::check_ring`): one stanza per
//! listed key, an SSH stanza by its key's tag, and in a QUANTUM-SAFE
//! repository (`level quantum-safe`) nothing but `mlkem768x25519` stanzas. A
//! classical or mixed ring there is refused, in quarantine like plaintext.
//!
//! Only NEW trees and blobs are looked at: anything older was accepted when it
//! arrived, and a repository flagged encrypted after it had history keeps that
//! history (the design document says so).
//!
//! **Before anything is stored** (2026-10-06, crypt-level2 item 4 pulled
//! forward): the same walk ([`check_pushed`]) runs on the pushed pack in
//! quarantine — resolved, never stored — before its bytes are written or
//! framed in the transaction log the twin replays (`gunnar-server`'s
//! transport). A refused plaintext push leaves nothing on the server and
//! nothing for the twin. This gate runs again after the store, as the net.
use std::collections::{HashMap, HashSet};
use gix_hash::ObjectId;
use gunnar_crypt::policy::Shape;
use gunnar_store::git_storage_trait::ObjType;
use gunnar_store::object::{self, EntryKind, TreeIter};
use crate::context::PushContext;
use crate::error::{Error, Result};
use crate::{Gate, Verdict};
/// See the module documentation.
#[derive(Clone, Copy, Debug, Default)]
pub struct EncryptedGate;
impl EncryptedGate {
/// The gate.
pub fn new() -> Self {
EncryptedGate
}
}
/// **What the walk reads**: a pushed object's resolved kind, a tree's or a
/// commit's inflated bytes, and a blob's [`Shape`] — from the stored
/// repository ([`EncryptedGate`]) or from a pack in quarantine (the
/// transport, before anything is stored). One walk, two sources.
pub trait PushedObjects {
/// The resolved kind of `id`, `None` when this source does not hold it.
fn kind(&self, id: &ObjectId) -> Result<Option<ObjType>>;
/// The inflated bytes of a tree or a commit.
fn bytes(&self, id: &ObjectId, what: &'static str) -> Result<Vec<u8>>;
/// A blob's shape (envelope, age file, neither).
fn shape(&self, id: &ObjectId) -> Result<Shape>;
/// The bytes of one of the scheme's own small files (`.gunnar-crypt/keys`
/// or `members`), pushed now or already stored; `None` when it is larger
/// than `gunnar_crypt::policy::MAX_SCHEME_FILE` (never read whole).
fn blob(&self, id: &ObjectId) -> Result<Option<Vec<u8>>>;
}
/// [`PushedObjects`] over the stored repository (`PushContext::objects`).
struct Stored<'c, 'a>(&'c PushContext<'a>);
impl PushedObjects for Stored<'_, '_> {
fn kind(&self, id: &ObjectId) -> Result<Option<ObjType>> {
Ok(self
.0
.objects
.header(id.as_bytes())
.map_err(Error::backend)?
.map(|(k, _)| k))
}
fn bytes(&self, id: &ObjectId, what: &'static str) -> Result<Vec<u8>> {
self.0
.objects
.read(id.as_bytes())
.map_err(Error::backend)?
.map(|(_, b)| b)
.ok_or(Error::MissingObject { oid: *id, what })
}
fn shape(&self, id: &ObjectId) -> Result<Shape> {
Ok(Shape::of(&self.bytes(id, "a pushed blob")?))
}
fn blob(&self, id: &ObjectId) -> Result<Option<Vec<u8>>> {
let size = self
.0
.objects
.header(id.as_bytes())
.map_err(Error::backend)?
.map(|(_, n)| n);
if size.is_some_and(|n| n > gunnar_crypt::policy::MAX_SCHEME_FILE as u64) {
return Ok(None);
}
self.bytes(id, "the key ring or the members file").map(Some)
}
}
/// The scheme's directory, as the walk's prefix spells it.
const SCHEME_DIR: &str = ".gunnar-crypt/";
struct Walk<'o> {
objects: &'o dyn PushedObjects,
new: HashSet<ObjectId>,
walked: HashSet<ObjectId>,
shapes: HashMap<ObjectId, Shape>,
named: HashSet<ObjectId>,
}
impl Walk<'_> {
fn shape(&mut self, id: &ObjectId) -> Result<Shape> {
if let Some(s) = self.shapes.get(id) {
return Ok(*s);
}
let s = self.objects.shape(id)?;
self.shapes.insert(*id, s);
Ok(s)
}
/// A new `.gunnar-crypt` tree: if its key ring or its members file is
/// new, the ring must match the members (`check_ring`).
fn ring(&mut self, entries: &[(ObjectId, EntryKind, String)]) -> Result<Option<String>> {
let find = |name: &str| {
entries
.iter()
.find(|(_, k, n)| {
n == name && matches!(k, EntryKind::Blob | EntryKind::BlobExecutable)
})
.map(|(oid, _, _)| *oid)
};
let (keys, members) = (find("keys"), find("members"));
if !keys
.into_iter()
.chain(members)
.any(|id| self.new.contains(&id))
{
return Ok(None);
}
let Some(keys) = keys else {
return Ok(None);
};
let Some(members) = members else {
return Ok(Some(format!(
"{} without {}: the server checks the ring against its members",
gunnar_crypt::paths::KEYS,
gunnar_crypt::paths::MEMBERS
)));
};
let too_large = |path: &str| {
Some(format!(
"{path} is larger than {} MiB or not plain text; the server checks the key ring \
against the members file and reads neither past that",
gunnar_crypt::policy::MAX_SCHEME_FILE >> 20
))
};
let Some(members) = self.objects.blob(&members)? else {
return Ok(too_large(gunnar_crypt::paths::MEMBERS));
};
let Some(keys) = self.objects.blob(&keys)? else {
return Ok(too_large(gunnar_crypt::paths::KEYS));
};
let members = String::from_utf8_lossy(&members).into_owned();
Ok(gunnar_crypt::policy::check_ring(&members, &keys)
.err()
.map(|refusal| refusal.to_string()))
}
/// Walk a NEW tree; `Some(reason)` at the first refusal.
fn tree(&mut self, id: ObjectId, prefix: &str) -> Result<Option<String>> {
if !self.new.contains(&id) || !self.walked.insert(id) {
return Ok(None);
}
let bytes = self.objects.bytes(&id, "a pushed tree")?;
let entries: Vec<(ObjectId, EntryKind, String)> = TreeIter::from_bytes(&bytes, id.kind())
.named()
.map(|e| {
e.map(|e| (e.oid, e.kind, String::from_utf8_lossy(e.name).into_owned()))
.map_err(|err| Error::decode(id, err))
})
.collect::<Result<_>>()?;
if prefix == SCHEME_DIR {
if let Some(r) = self.ring(&entries)? {
return Ok(Some(r));
}
}
for (oid, kind, name) in entries {
let path = format!("{prefix}{name}");
match kind {
EntryKind::Tree => {
if let Some(r) = self.tree(oid, &format!("{path}/"))? {
return Ok(Some(r));
}
}
EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => {
self.named.insert(oid);
if !self.new.contains(&oid) {
continue;
}
let shape = self.shape(&oid)?;
// The rule table the client shares (gunnar-crypt).
if let Err(refusal) =
gunnar_crypt::policy::check(&path, kind == EntryKind::Link, shape)
{
return Ok(Some(refusal.to_string()));
}
}
EntryKind::Commit => {}
}
}
Ok(None)
}
}
/// ★ **The encrypted-repository rule over a push's NEW objects**, read from
/// `objects`: every new tree a new commit reaches is walked and every new blob
/// it names must pass `gunnar_crypt::policy`; a new blob no new tree names
/// must be ciphertext. `Verdict::reject` names the first offence.
pub fn check_pushed(new_objects: &[ObjectId], objects: &dyn PushedObjects) -> Result<Verdict> {
let mut walk = Walk {
objects,
new: new_objects.iter().copied().collect(),
walked: HashSet::new(),
shapes: HashMap::new(),
named: HashSet::new(),
};
let mut parents = Vec::new();
let mut blobs = Vec::new();
for id in new_objects {
let Some(kind) = objects.kind(id)? else {
return Err(Error::MissingObject {
oid: *id,
what: "its type",
});
};
match kind {
ObjType::Commit => {
let bytes = objects.bytes(id, "a pushed commit")?;
let tree = object::commit_links(&bytes, id.kind(), &mut parents)
.map_err(|err| Error::decode(*id, err))?;
if let Some(reason) = walk.tree(tree, "")? {
return Ok(Verdict::reject(format!("encrypted repository: {reason}")));
}
}
ObjType::Blob => blobs.push(*id),
_ => {}
}
}
// A blob no pushed tree names (a hand-built pack can carry one): it
// has no path to be excused by, so it must be ciphertext.
for id in blobs {
if walk.named.contains(&id) {
continue;
}
let s = walk.shape(&id)?;
if !s.envelope && !s.age {
return Ok(Verdict::reject(format!(
"encrypted repository: blob {id} is plaintext and no pushed tree names it; \
the server stores ciphertext only"
)));
}
}
Ok(Verdict::Allow)
}
/// **The members file's level at `commit`** — `None` when the commit has no
/// `.gunnar-crypt/members` (or it is unreadable: the ring check names that).
fn level_at(
objects: &dyn PushedObjects,
commit: &ObjectId,
) -> Result<Option<gunnar_crypt::members::Level>> {
let bytes = objects.bytes(commit, "a commit")?;
let tree = object::commit_links(&bytes, commit.kind(), &mut Vec::new())
.map_err(|err| Error::decode(*commit, err))?;
let entry = |tree: ObjectId, name: &str| -> Result<Option<(ObjectId, EntryKind)>> {
let bytes = objects.bytes(&tree, "a tree")?;
for e in TreeIter::from_bytes(&bytes, tree.kind()).named() {
let e = e.map_err(|err| Error::decode(tree, err))?;
if e.name == name.as_bytes() {
return Ok(Some((e.oid, e.kind)));
}
}
Ok(None)
};
let Some((dir, EntryKind::Tree)) = entry(tree, SCHEME_DIR.trim_end_matches('/'))? else {
return Ok(None);
};
let Some((members, EntryKind::Blob | EntryKind::BlobExecutable)) = entry(dir, "members")?
else {
return Ok(None);
};
let Some(text) = objects.blob(&members)? else {
return Ok(None);
};
Ok(
gunnar_crypt::members::Members::parse(&String::from_utf8_lossy(&text))
.ok()
.map(|m| m.level()),
)
}
/// ★ **The level only goes up** (wiring tracer B 2026-10-08): a branch whose
/// stored members file says `level quantum-safe` is not moved to a commit whose
/// members file says less, and a NEW branch is held to the default branch's level. Each file of the downgrade is consistent with the
/// other — only the stored level can refuse it.
fn ratchet(ctx: &PushContext<'_>, objects: &dyn PushedObjects) -> Result<Verdict> {
use gunnar_crypt::members::Level;
// A NEW reference has no old tip: it is compared with the default branch (the one HEAD
// names), so a classical ring is not pushed beside a quantum-safe repository as a fresh
// branch. Read once, only when a push creates a reference.
let default_tip = || -> Result<Option<ObjectId>> {
let Some(target) = ctx
.objects
.head()
.map_err(Error::backend)?
.and_then(|h| h.symref_target)
else {
return Ok(None);
};
let rows = ctx.objects.refs().map_err(Error::backend)?;
Ok(rows
.iter()
.find(|r| r.name == target)
.and_then(|r| r.oid.as_deref())
.and_then(|raw| ObjectId::try_from(raw).ok()))
};
for u in ctx.updates {
let Some(new) = u.new else { continue };
let old = match u.old {
Some(old) => old,
None => match default_tip()? {
Some(tip) => tip,
None => continue,
},
};
if level_at(objects, &old)? != Some(Level::QuantumSafe) {
continue;
}
if level_at(objects, &new)? != Some(Level::QuantumSafe) {
return Ok(Verdict::Reject(crate::Rejection {
ref_name: Some(u.name.clone()),
reason: format!(
"encrypted repository: {} is quantum-safe and this push takes it back to a \
classical key ring ({} no longer says `level quantum-safe`); a quantum-safe \
repository does not go back",
u.name,
gunnar_crypt::paths::MEMBERS
),
}));
}
}
Ok(Verdict::Allow)
}
impl Gate for EncryptedGate {
fn name(&self) -> &str {
"encrypted"
}
fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
let stored = Stored(ctx);
match check_pushed(ctx.new_objects, &stored)? {
Verdict::Allow => ratchet(ctx, &stored),
refused => Ok(refused),
}
}
}
#[cfg(test)]
mod tests {
//! The ring check over a push's objects, from a source in memory (the
//! quarantine and the stored repository are the two real ones; the CLI
//! suite pushes through both).
use super::*;
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members};
use gunnar_crypt::{pq, wrap};
#[derive(Default)]
struct Mem {
objects: HashMap<ObjectId, (ObjType, Vec<u8>)>,
next: u8,
}
impl Mem {
fn put(&mut self, kind: ObjType, bytes: Vec<u8>) -> ObjectId {
self.next += 1;
let id = ObjectId::from_bytes_or_panic(&[self.next; 20]);
self.objects.insert(id, (kind, bytes));
id
}
fn tree(&mut self, entries: &[(&str, &str, ObjectId)]) -> ObjectId {
let mut b = Vec::new();
for (mode, name, id) in entries {
b.extend_from_slice(format!("{mode} {name}\0").as_bytes());
b.extend_from_slice(id.as_bytes());
}
self.put(ObjType::Tree, b)
}
/// A commit of `.gunnar-crypt/{members,keys}` (plus `.gitattributes`).
fn commit(&mut self, members: ObjectId, keys: ObjectId) -> ObjectId {
let attrs = self.put(ObjType::Blob, b"* filter=gunnar-crypt\n".to_vec());
let dir = self.tree(&[("100644", "keys", keys), ("100644", "members", members)]);
let root = self.tree(&[
("100644", ".gitattributes", attrs),
("40000", ".gunnar-crypt", dir),
]);
self.put(
ObjType::Commit,
format!("tree {}\n\nring\n", root.to_hex()).into_bytes(),
)
}
}
impl PushedObjects for Mem {
fn kind(&self, id: &ObjectId) -> Result<Option<ObjType>> {
Ok(self.objects.get(id).map(|(k, _)| *k))
}
fn bytes(&self, id: &ObjectId, what: &'static str) -> Result<Vec<u8>> {
self.objects
.get(id)
.map(|(_, b)| b.clone())
.ok_or(Error::MissingObject { oid: *id, what })
}
fn shape(&self, id: &ObjectId) -> Result<Shape> {
Ok(Shape::of(&self.bytes(id, "a blob")?))
}
fn blob(&self, id: &ObjectId) -> Result<Option<Vec<u8>>> {
let b = self.bytes(id, "a blob")?;
Ok((b.len() <= gunnar_crypt::policy::MAX_SCHEME_FILE).then_some(b))
}
}
fn quantum_safe(ids: &[&pq::Identity]) -> Members {
let mut m = Members::at(gunnar_crypt::members::Level::Classic);
m.set_level(Level::QuantumSafe).unwrap();
for (i, id) in ids.iter().enumerate() {
m.add(&format!("m{i}"), &id.to_public().to_string())
.unwrap();
}
m
}
fn classic(n: usize) -> Members {
let mut m = Members::at(gunnar_crypt::members::Level::Classic);
for i in 0..n {
m.add(
&format!("m{i}"),
&age::x25519::Identity::generate().to_public().to_string(),
)
.unwrap();
}
m
}
/// Everything in `mem` is new except `old`.
fn verdict(mem: &Mem, old: &[ObjectId]) -> Verdict {
let new: Vec<ObjectId> = mem
.objects
.keys()
.filter(|id| !old.contains(id))
.copied()
.collect();
check_pushed(&new, mem).unwrap()
}
fn push(members: &Members, keys: &str) -> Verdict {
let mut mem = Mem::default();
let m = mem.put(ObjType::Blob, members.to_text().into_bytes());
let k = mem.put(ObjType::Blob, keys.as_bytes().to_vec());
mem.commit(m, k);
verdict(&mem, &[])
}
use gunnar_crypt::wrap::age;
#[test]
fn a_quantum_safe_repository_takes_a_hybrid_ring_and_refuses_a_classical_one() {
let (a, b) = (pq::Identity::generate(), pq::Identity::generate());
let qs = quantum_safe(&[&a, &b]);
let ring = Keyring::generate();
assert_eq!(push(&qs, &wrap::wrap(&ring, &qs).unwrap()), Verdict::Allow);
// The same members, a ring wrapped classically.
let classical = wrap::wrap(&ring, &classic(2)).unwrap();
let v = push(&qs, &classical);
let why = v.reason().unwrap_or_default().to_string();
assert!(
why.contains("X25519 stanza") && why.contains("quantum-safe"),
"{v:?}"
);
}
#[test]
fn a_members_change_alone_is_checked_against_the_stored_ring() {
// The ring is already stored (not pushed now); the push only raises
// the level in the members file. The stored ring is classical.
let a = pq::Identity::generate();
let mut mem = Mem::default();
let stored_ring = mem.put(
ObjType::Blob,
wrap::wrap(&Keyring::generate(), &classic(1))
.unwrap()
.into_bytes(),
);
let m = mem.put(ObjType::Blob, quantum_safe(&[&a]).to_text().into_bytes());
mem.commit(m, stored_ring);
let v = verdict(&mem, &[stored_ring]);
assert!(
v.reason().unwrap_or_default().contains("quantum-safe"),
"{v:?}"
);
}
#[test]
fn a_ring_with_a_stanza_the_members_do_not_list_is_refused_at_either_level() {
let three = classic(3);
let ring = wrap::wrap(&Keyring::generate(), &three).unwrap();
let mut two = Members::at(gunnar_crypt::members::Level::Classic);
for e in three.entries().iter().take(2) {
two.add(&e.name, &e.key).unwrap();
}
let v = push(&two, &ring);
assert!(
v.reason()
.unwrap_or_default()
.contains("more X25519 stanzas"),
"{v:?}"
);
assert_eq!(push(&three, &ring), Verdict::Allow);
let (a, b) = (pq::Identity::generate(), pq::Identity::generate());
let ring = wrap::wrap(&Keyring::generate(), &quantum_safe(&[&a, &b])).unwrap();
let v = push(&quantum_safe(&[&a]), &ring);
assert!(
v.reason()
.unwrap_or_default()
.contains("more mlkem768x25519 stanzas"),
"{v:?}"
);
}
#[test]
fn an_oversized_members_file_is_refused_without_being_read() {
let a = pq::Identity::generate();
let qs = quantum_safe(&[&a]);
let mut big = qs.to_text();
big.push_str(&"# padding\n".repeat((5 << 20) / 10));
let mut mem = Mem::default();
let m = mem.put(ObjType::Blob, big.into_bytes());
let k = mem.put(
ObjType::Blob,
wrap::wrap(&Keyring::generate(), &qs).unwrap().into_bytes(),
);
mem.commit(m, k);
let v = verdict(&mem, &[]);
assert!(
v.reason().unwrap_or_default().contains("larger than 4 MiB"),
"{v:?}"
);
}
#[test]
fn a_ring_without_a_members_file_is_refused() {
let mut mem = Mem::default();
let k = mem.put(
ObjType::Blob,
wrap::wrap(&Keyring::generate(), &classic(1))
.unwrap()
.into_bytes(),
);
let dir = mem.tree(&[("100644", "keys", k)]);
let root = mem.tree(&[("40000", ".gunnar-crypt", dir)]);
mem.put(
ObjType::Commit,
format!("tree {}\n\nx\n", root.to_hex()).into_bytes(),
);
let v = verdict(&mem, &[]);
assert!(v.reason().unwrap_or_default().contains("without"), "{v:?}");
}
/// One file at the root, committed.
fn one_file(mem: &mut Mem, name: &str, bytes: Vec<u8>) {
let blob = mem.put(ObjType::Blob, bytes);
let root = mem.tree(&[("100644", name, blob)]);
mem.put(
ObjType::Commit,
format!("tree {}\n\nfile\n", root.to_hex()).into_bytes(),
);
}
fn file_verdict(name: &str, bytes: Vec<u8>) -> Verdict {
let mut mem = Mem::default();
one_file(&mut mem, name, bytes);
verdict(&mem, &[])
}
#[test]
fn a_chunked_v2_envelope_is_sealed_to_the_gate_and_plaintext_is_not() {
use gunnar_crypt::envelope;
let ring = Keyring::generate();
// Several 1 MiB blocks, the last one short: the format check reads
// the header and the length, no key.
let plain: Vec<u8> = (0..(2usize << 20) + 12345)
.map(|i| (i * 7 % 251) as u8)
.collect();
let v2 = envelope::seal(ring.newest(), &plain);
assert_eq!(envelope::version_of(&v2), Some(2));
assert_eq!(file_verdict("data/big.bin", v2.clone()), Verdict::Allow);
// An unchanged file a client sealed before v2 is still accepted.
let v1 = envelope::seal_v1(ring.newest(), b"sealed in 2026-09\n");
assert_eq!(file_verdict("old.txt", v1), Verdict::Allow);
// Plaintext, and v2 cut inside a block's tag, are refused.
let v = file_verdict("data/big.bin", plain);
assert!(
v.reason().unwrap_or_default().contains("plaintext"),
"{v:?}"
);
let cut = v2[..envelope::V2_HEADER_LEN + 7].to_vec();
let v = file_verdict("data/big.bin", cut);
assert!(
v.reason().unwrap_or_default().contains("plaintext"),
"{v:?}"
);
}
}