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

nordisk/gunnar

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:?}"
        );
    }
}