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

nordisk/gunnar

crates/gunnar-crypt/src/policy.rs

//! What a server checks on a push to an ENCRYPTED repository.
//!
//! The server holds no key and never decrypts. What it can still do is refuse
//! a push that would put plaintext on it: a clone that was never unlocked, a
//! file added before `gunnar crypt init`, a tool that bypassed the filter.
//! Every blob reachable from the pushed commits must be:
//!
//! | path | must be |
//! |---|---|
//! | `.gitattributes` (root only) | anything — it names the filter and must stay readable |
//! | `.gunnar-crypt/members` | anything — public keys only |
//! | `.gunnar-crypt/keys` | an age file (armored or binary) |
//! | any other path | a gunnar-crypt envelope ([`crate::envelope::is_envelope`]) |
//!
//! Symbolic links are refused outright: git stores a link's target as a blob
//! and never runs a filter on it, so the target would be plaintext. A gitlink
//! (submodule entry) carries no blob and is not checked here; its commit id is
//! visible, which the design document lists among the leaks.
//!
//! **The key ring against the members** ([`check_ring`]): whenever a push
//! changes `.gunnar-crypt/keys` or `.gunnar-crypt/members`, the stanzas in the
//! ring's age header — readable without any key — must match the members file
//! one to one: one stanza per listed key, an `ssh-ed25519`/`ssh-rsa` stanza
//! carrying the tag of a listed key, no type the repository's level does not
//! allow (a quantum-safe repository: `mlkem768x25519` only), and nothing else
//! but the one random `…-grease` stanza age adds to every file. A member who
//! holds the file key could otherwise append a stanza for someone the members
//! file does not name and nobody's unwrap would notice. `X25519` and
//! `mlkem768x25519` stanzas are anonymous by design, so for those the count
//! and the type are what can be checked. The client runs the same check
//! before it accepts a ring (`gunnar crypt unlock`).
//!
//! This is a FORMAT check. It cannot prove that an envelope was sealed under
//! the repository's key (that needs the key); it proves the client ran the
//! filter. A member who deliberately pushes junk that starts with the magic is
//! not stopped, and does not need to be: they could as well push the plaintext
//! to any other server.

use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
use base64::Engine as _;
use sha2::{Digest as _, Sha256};

use crate::envelope;
use crate::members::{KeyKind, Level, Members};
use crate::paths;

/// The binary age header line.
pub const AGE_BINARY_PREFIX: &[u8] = b"age-encryption.org/v1\n";
/// The armored age header line.
pub const AGE_ARMOR_PREFIX: &[u8] = b"-----BEGIN AGE ENCRYPTED FILE-----";

/// Is `data` an age file (binary or armored)?
pub fn is_age_file(data: &[u8]) -> bool {
    let trimmed = trim_leading_ws(data);
    data.starts_with(AGE_BINARY_PREFIX) || trimmed.starts_with(AGE_ARMOR_PREFIX)
}

fn trim_leading_ws(mut d: &[u8]) -> &[u8] {
    while let [first, rest @ ..] = d {
        if first.is_ascii_whitespace() {
            d = rest;
        } else {
            break;
        }
    }
    d
}

/// One blob's verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    /// A plaintext blob where an envelope belongs.
    Plaintext {
        /// The path, as found in the tree.
        path: String,
    },
    /// `.gunnar-crypt/keys` that is not an age file.
    KeysNotAge,
    /// A symbolic link (its target would be plaintext).
    Symlink {
        /// The path, as found in the tree.
        path: String,
    },
    /// A quantum-safe repository's key ring carries a stanza a quantum
    /// computer could open (an SSH or X25519 wrap).
    ClassicalInQuantumSafe {
        /// The stanza type.
        stanza: String,
    },
    /// The key ring's stanzas do not match the members file.
    RingMismatch(String),
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Refusal::Plaintext { path } => write!(
                f,
                "{path} is plaintext, and this repository is encrypted: the server stores \
                 ciphertext only. Run `gunnar crypt unlock` in this clone, then \
                 `git add --renormalize .` and commit again."
            ),
            Refusal::KeysNotAge => write!(
                f,
                "{} must be an age file (the key ring wrapped to the members); \
                 write it with `gunnar crypt`",
                paths::KEYS
            ),
            Refusal::Symlink { path } => write!(
                f,
                "{path} is a symbolic link: git stores a link's target unencrypted, so \
                 encrypted repositories refuse links"
            ),
            Refusal::ClassicalInQuantumSafe { stanza } => write!(
                f,
                "{} carries a {stanza} stanza, and this repository is quantum-safe \
                 (`level quantum-safe` in {}): its key ring is wrapped only to age1pq1… \
                 (ML-KEM-768 + X25519) keys, because one classical wrap beside them would be \
                 the weak link. Wrap it to the members' quantum-safe encryption keys (age1pq1…) only",
                paths::KEYS,
                paths::MEMBERS
            ),
            Refusal::RingMismatch(why) => write!(
                f,
                "{} does not match {}: {why}. Write both with `gunnar crypt`",
                paths::KEYS,
                paths::MEMBERS
            ),
        }
    }
}

/// What a blob's bytes are, as far as the check cares. A server reads a blob
/// once and checks it under every path that names it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shape {
    /// A gunnar-crypt envelope.
    pub envelope: bool,
    /// An age file.
    pub age: bool,
}

impl Shape {
    /// Classify `data`.
    pub fn of(data: &[u8]) -> Shape {
        Shape {
            envelope: envelope::is_envelope(data),
            age: is_age_file(data),
        }
    }
}

/// Check one blob found at `path` (slash-separated, relative to the root).
pub fn check_blob(path: &str, is_symlink: bool, data: &[u8]) -> Result<(), Refusal> {
    check(path, is_symlink, Shape::of(data))
}

/// [`check_blob`] for a blob already classified.
pub fn check(path: &str, is_symlink: bool, shape: Shape) -> Result<(), Refusal> {
    if is_symlink {
        return Err(Refusal::Symlink {
            path: path.to_string(),
        });
    }
    // A `.gitattributes` at ANY depth: the client's depth-free `.gitattributes !filter` keeps
    // every one plaintext, and git reads them before a clone is unlocked (wiring tracer B
    // 2026-10-08: the server allowed the root one only, and refused the rest with advice that
    // could not fix it).
    let attributes =
        path == paths::GITATTRIBUTES || path.ends_with(&format!("/{}", paths::GITATTRIBUTES));
    match path {
        _ if attributes => Ok(()),
        paths::MEMBERS => Ok(()),
        paths::KEYS => {
            if shape.age {
                Ok(())
            } else {
                Err(Refusal::KeysNotAge)
            }
        }
        _ if shape.envelope => Ok(()),
        _ => Err(Refusal::Plaintext {
            path: path.to_string(),
        }),
    }
}

// ── the key ring's age header, read without a key ────────────────────────────

/// The armored age footer line.
const AGE_ARMOR_END: &str = "-----END AGE ENCRYPTED FILE-----";
/// The largest key ring or members file the ring check reads, and a bound on
/// what a hostile push can make anyone parse. A quantum-safe member costs
/// ~2.1 KB of armored ring (and ~2 KB of members file), so this caps a
/// quantum-safe repository at ~2,000 member keys (a classic one at ~28,000).
pub const MAX_SCHEME_FILE: usize = 4 << 20;

/// One recipient stanza of an age header: its type, its arguments and the
/// length of its body. What the server can see of a wrap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StanzaHead {
    /// The type (`ssh-ed25519`, `X25519`, `mlkem768x25519`, …).
    pub tag: String,
    /// Its arguments.
    pub args: Vec<String>,
    /// The decoded body's length.
    pub body_len: usize,
}

/// The recipient stanzas of an age file (armored or binary), `None` unless
/// its header parses (c2sp.org/age: `-> type args…`, a base64 body in lines of
/// 64 columns ending with a shorter one, then the `---` MAC line).
pub fn age_stanzas(data: &[u8]) -> Option<Vec<StanzaHead>> {
    if data.len() > MAX_SCHEME_FILE {
        return None;
    }
    let binary;
    let data = if trim_leading_ws(data).starts_with(AGE_ARMOR_PREFIX) {
        let text = std::str::from_utf8(data).ok()?;
        let mut b64 = String::new();
        let mut inside = false;
        for line in text.lines().map(str::trim) {
            if line.as_bytes() == AGE_ARMOR_PREFIX {
                inside = true;
            } else if line == AGE_ARMOR_END {
                break;
            } else if inside {
                b64.push_str(line);
            }
        }
        binary = STANDARD.decode(b64.as_bytes()).ok()?;
        &binary[..]
    } else {
        data
    };
    let mut lines = data.split(|b| *b == b'\n');
    if lines.next()? != b"age-encryption.org/v1" {
        return None;
    }
    let mut stanzas = Vec::new();
    let mut line = lines.next()?;
    loop {
        if line.starts_with(b"--- ") {
            return Some(stanzas);
        }
        let head = std::str::from_utf8(line.strip_prefix(b"-> ")?).ok()?;
        let mut words = head.split(' ');
        let tag = words.next().filter(|t| !t.is_empty())?.to_string();
        let args: Vec<String> = words.map(str::to_string).collect();
        if args.iter().any(String::is_empty) {
            return None;
        }
        let mut body = Vec::new();
        loop {
            let l = lines.next()?;
            body.extend_from_slice(l);
            if l.len() < 64 {
                break;
            }
            if l.len() > 64 {
                return None;
            }
        }
        let body_len = STANDARD_NO_PAD.decode(&body).ok()?.len();
        stanzas.push(StanzaHead {
            tag,
            args,
            body_len,
        });
        line = lines.next()?;
    }
}

/// The tag age gives a stanza wrapped to an SSH key (`ssh-ed25519`,
/// `ssh-rsa`): the first 4 bytes of SHA-256 over the key's wire blob, base64.
pub fn ssh_stanza_tag(public_key: &str) -> Option<String> {
    let blob = STANDARD
        .decode(public_key.split_ascii_whitespace().nth(1)?)
        .ok()?;
    Some(STANDARD_NO_PAD.encode(&Sha256::digest(&blob)[..4]))
}

/// The one stanza age itself adds to every file it writes: a random type
/// ending in `-grease`, up to four short random arguments and a short random
/// body. It carries no key for anyone and is allowed once, at that size.
fn is_grease(s: &StanzaHead) -> bool {
    s.tag.ends_with("-grease")
        && s.tag.len() <= 8 + "-grease".len()
        && s.args.len() <= 4
        && s.args.iter().all(|a| a.len() <= 8)
        && s.body_len < 100
}

/// Is a member stanza the size age writes for its type? (c2sp.org/age: an
/// X25519 share and an ssh-ed25519 share are 32 bytes, an SSH tag 4, a hybrid
/// `enc` 1120; a wrapped file key is 32 bytes, an RSA-OAEP one the modulus.)
fn well_formed(s: &StanzaHead) -> bool {
    let len = |i: usize| s.args.get(i).map_or(0, String::len);
    match s.tag.as_str() {
        "ssh-ed25519" => len(0) == 6 && len(1) == 43 && s.body_len == 32,
        "ssh-rsa" => len(0) == 6 && (128..=2048).contains(&s.body_len),
        "X25519" => len(0) == 43 && s.body_len == 32,
        crate::members::PQ_STANZA => len(0) == 1494 && s.body_len == 32,
        _ => false,
    }
}

/// ★ **The key ring against the members file**, without a key: see the
/// module documentation. `members` is the text of `.gunnar-crypt/members`,
/// `keys` the bytes of `.gunnar-crypt/keys`.
pub fn check_ring(members: &str, keys: &[u8]) -> Result<(), Refusal> {
    for (path, len) in [(paths::MEMBERS, members.len()), (paths::KEYS, keys.len())] {
        if len > MAX_SCHEME_FILE {
            return Err(Refusal::RingMismatch(format!(
                "{path} is larger than {} MiB, which no key ring of gunnar's needs",
                MAX_SCHEME_FILE >> 20
            )));
        }
    }
    let members = Members::parse(members)
        .map_err(|e| Refusal::RingMismatch(format!("{} is not readable ({e})", paths::MEMBERS)))?;
    let stanzas = age_stanzas(keys).ok_or(Refusal::KeysNotAge)?;
    let level = members.level();
    let mut grease = 0;
    // What each listed key should put in the header — its stanza type and,
    // for an SSH key, its tag — counted, so a large ring is one pass.
    let mut expected: std::collections::HashMap<(&'static str, Option<String>), usize> =
        std::collections::HashMap::new();
    for m in members.entries() {
        let tag = match m.kind {
            KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
            KeyKind::Age | KeyKind::AgePq => None,
        };
        *expected.entry((m.kind.stanza_type(), tag)).or_default() += 1;
    }
    for s in &stanzas {
        if is_grease(s) && grease == 0 {
            grease += 1;
            continue;
        }
        if level == Level::QuantumSafe && s.tag != crate::members::PQ_STANZA {
            return Err(Refusal::ClassicalInQuantumSafe {
                stanza: s.tag.clone(),
            });
        }
        if !level.stanza_types().contains(&s.tag.as_str()) {
            return Err(Refusal::RingMismatch(format!(
                "it carries a {} stanza, which no member key of a {} repository makes",
                s.tag,
                level.word()
            )));
        }
        let arity = match s.tag.as_str() {
            "ssh-ed25519" => 2,
            _ => 1,
        };
        let tag = match s.tag.as_str() {
            "ssh-ed25519" | "ssh-rsa" => s.args.first().cloned(),
            _ => None,
        };
        if s.args.len() != arity || !well_formed(s) {
            return Err(Refusal::RingMismatch(format!(
                "a {} stanza that is not the shape age writes ({} argument(s), a {}-byte body)",
                s.tag,
                s.args.len(),
                s.body_len
            )));
        }
        let left = level
            .stanza_types()
            .iter()
            .find(|t| **t == s.tag)
            .and_then(|t| expected.get_mut(&(*t, tag.clone())))
            .filter(|n| **n > 0);
        let Some(left) = left else {
            return Err(Refusal::RingMismatch(match tag {
                Some(tag) => format!(
                    "it is wrapped to an {} key (tag {tag}) that no member line lists",
                    s.tag
                ),
                None => format!(
                    "it carries more {} stanzas than the members file lists {} keys",
                    s.tag, s.tag
                ),
            }));
        };
        *left -= 1;
    }
    let missing: usize = expected.values().sum();
    if missing > 0 {
        let first = members
            .entries()
            .iter()
            .find(|m| {
                let tag = match m.kind {
                    KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
                    _ => None,
                };
                expected
                    .get(&(m.kind.stanza_type(), tag))
                    .is_some_and(|n| *n > 0)
            })
            .map(|m| format!("{}'s {} key", m.name, m.kind.stanza_type()))
            .unwrap_or_default();
        return Err(Refusal::RingMismatch(format!(
            "{missing} member key(s) have no stanza (the first: {first})"
        )));
    }
    Ok(())
}