Skip to main content

gunnar_crypt/
policy.rs

1//! What a server checks on a push to an ENCRYPTED repository.
2//!
3//! The server holds no key and never decrypts. What it can still do is refuse
4//! a push that would put plaintext on it: a clone that was never unlocked, a
5//! file added before `gunnar crypt init`, a tool that bypassed the filter.
6//! Every blob reachable from the pushed commits must be:
7//!
8//! | path | must be |
9//! |---|---|
10//! | `.gitattributes` (root only) | anything — it names the filter and must stay readable |
11//! | `.gunnar-crypt/members` | anything — public keys only |
12//! | `.gunnar-crypt/keys` | an age file (armored or binary) |
13//! | any other path | a gunnar-crypt envelope ([`crate::envelope::is_envelope`]) |
14//!
15//! Symbolic links are refused outright: git stores a link's target as a blob
16//! and never runs a filter on it, so the target would be plaintext. A gitlink
17//! (submodule entry) carries no blob and is not checked here; its commit id is
18//! visible, which the design document lists among the leaks.
19//!
20//! **The key ring against the members** ([`check_ring`]): whenever a push
21//! changes `.gunnar-crypt/keys` or `.gunnar-crypt/members`, the stanzas in the
22//! ring's age header — readable without any key — must match the members file
23//! one to one: one stanza per listed key, an `ssh-ed25519`/`ssh-rsa` stanza
24//! carrying the tag of a listed key, no type the repository's level does not
25//! allow (a quantum-safe repository: `mlkem768x25519` only), and nothing else
26//! but the one random `…-grease` stanza age adds to every file. A member who
27//! holds the file key could otherwise append a stanza for someone the members
28//! file does not name and nobody's unwrap would notice. `X25519` and
29//! `mlkem768x25519` stanzas are anonymous by design, so for those the count
30//! and the type are what can be checked. The client runs the same check
31//! before it accepts a ring (`gunnar crypt unlock`).
32//!
33//! This is a FORMAT check. It cannot prove that an envelope was sealed under
34//! the repository's key (that needs the key); it proves the client ran the
35//! filter. A member who deliberately pushes junk that starts with the magic is
36//! not stopped, and does not need to be: they could as well push the plaintext
37//! to any other server.
38
39use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
40use base64::Engine as _;
41use sha2::{Digest as _, Sha256};
42
43use crate::envelope;
44use crate::members::{KeyKind, Level, Members};
45use crate::paths;
46
47/// The binary age header line.
48pub const AGE_BINARY_PREFIX: &[u8] = b"age-encryption.org/v1\n";
49/// The armored age header line.
50pub const AGE_ARMOR_PREFIX: &[u8] = b"-----BEGIN AGE ENCRYPTED FILE-----";
51
52/// Is `data` an age file (binary or armored)?
53pub fn is_age_file(data: &[u8]) -> bool {
54    let trimmed = trim_leading_ws(data);
55    data.starts_with(AGE_BINARY_PREFIX) || trimmed.starts_with(AGE_ARMOR_PREFIX)
56}
57
58fn trim_leading_ws(mut d: &[u8]) -> &[u8] {
59    while let [first, rest @ ..] = d {
60        if first.is_ascii_whitespace() {
61            d = rest;
62        } else {
63            break;
64        }
65    }
66    d
67}
68
69/// One blob's verdict.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Refusal {
72    /// A plaintext blob where an envelope belongs.
73    Plaintext {
74        /// The path, as found in the tree.
75        path: String,
76    },
77    /// `.gunnar-crypt/keys` that is not an age file.
78    KeysNotAge,
79    /// A symbolic link (its target would be plaintext).
80    Symlink {
81        /// The path, as found in the tree.
82        path: String,
83    },
84    /// A quantum-safe repository's key ring carries a stanza a quantum
85    /// computer could open (an SSH or X25519 wrap).
86    ClassicalInQuantumSafe {
87        /// The stanza type.
88        stanza: String,
89    },
90    /// The key ring's stanzas do not match the members file.
91    RingMismatch(String),
92}
93
94impl std::fmt::Display for Refusal {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Refusal::Plaintext { path } => write!(
98                f,
99                "{path} is plaintext, and this repository is encrypted: the server stores \
100                 ciphertext only. Run `gunnar crypt unlock` in this clone, then \
101                 `git add --renormalize .` and commit again."
102            ),
103            Refusal::KeysNotAge => write!(
104                f,
105                "{} must be an age file (the key ring wrapped to the members); \
106                 write it with `gunnar crypt`",
107                paths::KEYS
108            ),
109            Refusal::Symlink { path } => write!(
110                f,
111                "{path} is a symbolic link: git stores a link's target unencrypted, so \
112                 encrypted repositories refuse links"
113            ),
114            Refusal::ClassicalInQuantumSafe { stanza } => write!(
115                f,
116                "{} carries a {stanza} stanza, and this repository is quantum-safe \
117                 (`level quantum-safe` in {}): its key ring is wrapped only to age1pq1… \
118                 (ML-KEM-768 + X25519) keys, because one classical wrap beside them would be \
119                 the weak link. Wrap it to the members' quantum-safe encryption keys (age1pq1…) only",
120                paths::KEYS,
121                paths::MEMBERS
122            ),
123            Refusal::RingMismatch(why) => write!(
124                f,
125                "{} does not match {}: {why}. Write both with `gunnar crypt`",
126                paths::KEYS,
127                paths::MEMBERS
128            ),
129        }
130    }
131}
132
133/// What a blob's bytes are, as far as the check cares. A server reads a blob
134/// once and checks it under every path that names it.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct Shape {
137    /// A gunnar-crypt envelope.
138    pub envelope: bool,
139    /// An age file.
140    pub age: bool,
141}
142
143impl Shape {
144    /// Classify `data`.
145    pub fn of(data: &[u8]) -> Shape {
146        Shape {
147            envelope: envelope::is_envelope(data),
148            age: is_age_file(data),
149        }
150    }
151}
152
153/// Check one blob found at `path` (slash-separated, relative to the root).
154pub fn check_blob(path: &str, is_symlink: bool, data: &[u8]) -> Result<(), Refusal> {
155    check(path, is_symlink, Shape::of(data))
156}
157
158/// [`check_blob`] for a blob already classified.
159pub fn check(path: &str, is_symlink: bool, shape: Shape) -> Result<(), Refusal> {
160    if is_symlink {
161        return Err(Refusal::Symlink {
162            path: path.to_string(),
163        });
164    }
165    // A `.gitattributes` at ANY depth: the client's depth-free `.gitattributes !filter` keeps
166    // every one plaintext, and git reads them before a clone is unlocked (wiring tracer B
167    // 2026-10-08: the server allowed the root one only, and refused the rest with advice that
168    // could not fix it).
169    let attributes =
170        path == paths::GITATTRIBUTES || path.ends_with(&format!("/{}", paths::GITATTRIBUTES));
171    match path {
172        _ if attributes => Ok(()),
173        paths::MEMBERS => Ok(()),
174        paths::KEYS => {
175            if shape.age {
176                Ok(())
177            } else {
178                Err(Refusal::KeysNotAge)
179            }
180        }
181        _ if shape.envelope => Ok(()),
182        _ => Err(Refusal::Plaintext {
183            path: path.to_string(),
184        }),
185    }
186}
187
188// ── the key ring's age header, read without a key ────────────────────────────
189
190/// The armored age footer line.
191const AGE_ARMOR_END: &str = "-----END AGE ENCRYPTED FILE-----";
192/// The largest key ring or members file the ring check reads, and a bound on
193/// what a hostile push can make anyone parse. A quantum-safe member costs
194/// ~2.1 KB of armored ring (and ~2 KB of members file), so this caps a
195/// quantum-safe repository at ~2,000 member keys (a classic one at ~28,000).
196pub const MAX_SCHEME_FILE: usize = 4 << 20;
197
198/// One recipient stanza of an age header: its type, its arguments and the
199/// length of its body. What the server can see of a wrap.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct StanzaHead {
202    /// The type (`ssh-ed25519`, `X25519`, `mlkem768x25519`, …).
203    pub tag: String,
204    /// Its arguments.
205    pub args: Vec<String>,
206    /// The decoded body's length.
207    pub body_len: usize,
208}
209
210/// The recipient stanzas of an age file (armored or binary), `None` unless
211/// its header parses (c2sp.org/age: `-> type args…`, a base64 body in lines of
212/// 64 columns ending with a shorter one, then the `---` MAC line).
213pub fn age_stanzas(data: &[u8]) -> Option<Vec<StanzaHead>> {
214    if data.len() > MAX_SCHEME_FILE {
215        return None;
216    }
217    let binary;
218    let data = if trim_leading_ws(data).starts_with(AGE_ARMOR_PREFIX) {
219        let text = std::str::from_utf8(data).ok()?;
220        let mut b64 = String::new();
221        let mut inside = false;
222        for line in text.lines().map(str::trim) {
223            if line.as_bytes() == AGE_ARMOR_PREFIX {
224                inside = true;
225            } else if line == AGE_ARMOR_END {
226                break;
227            } else if inside {
228                b64.push_str(line);
229            }
230        }
231        binary = STANDARD.decode(b64.as_bytes()).ok()?;
232        &binary[..]
233    } else {
234        data
235    };
236    let mut lines = data.split(|b| *b == b'\n');
237    if lines.next()? != b"age-encryption.org/v1" {
238        return None;
239    }
240    let mut stanzas = Vec::new();
241    let mut line = lines.next()?;
242    loop {
243        if line.starts_with(b"--- ") {
244            return Some(stanzas);
245        }
246        let head = std::str::from_utf8(line.strip_prefix(b"-> ")?).ok()?;
247        let mut words = head.split(' ');
248        let tag = words.next().filter(|t| !t.is_empty())?.to_string();
249        let args: Vec<String> = words.map(str::to_string).collect();
250        if args.iter().any(String::is_empty) {
251            return None;
252        }
253        let mut body = Vec::new();
254        loop {
255            let l = lines.next()?;
256            body.extend_from_slice(l);
257            if l.len() < 64 {
258                break;
259            }
260            if l.len() > 64 {
261                return None;
262            }
263        }
264        let body_len = STANDARD_NO_PAD.decode(&body).ok()?.len();
265        stanzas.push(StanzaHead {
266            tag,
267            args,
268            body_len,
269        });
270        line = lines.next()?;
271    }
272}
273
274/// The tag age gives a stanza wrapped to an SSH key (`ssh-ed25519`,
275/// `ssh-rsa`): the first 4 bytes of SHA-256 over the key's wire blob, base64.
276pub fn ssh_stanza_tag(public_key: &str) -> Option<String> {
277    let blob = STANDARD
278        .decode(public_key.split_ascii_whitespace().nth(1)?)
279        .ok()?;
280    Some(STANDARD_NO_PAD.encode(&Sha256::digest(&blob)[..4]))
281}
282
283/// The one stanza age itself adds to every file it writes: a random type
284/// ending in `-grease`, up to four short random arguments and a short random
285/// body. It carries no key for anyone and is allowed once, at that size.
286fn is_grease(s: &StanzaHead) -> bool {
287    s.tag.ends_with("-grease")
288        && s.tag.len() <= 8 + "-grease".len()
289        && s.args.len() <= 4
290        && s.args.iter().all(|a| a.len() <= 8)
291        && s.body_len < 100
292}
293
294/// Is a member stanza the size age writes for its type? (c2sp.org/age: an
295/// X25519 share and an ssh-ed25519 share are 32 bytes, an SSH tag 4, a hybrid
296/// `enc` 1120; a wrapped file key is 32 bytes, an RSA-OAEP one the modulus.)
297fn well_formed(s: &StanzaHead) -> bool {
298    let len = |i: usize| s.args.get(i).map_or(0, String::len);
299    match s.tag.as_str() {
300        "ssh-ed25519" => len(0) == 6 && len(1) == 43 && s.body_len == 32,
301        "ssh-rsa" => len(0) == 6 && (128..=2048).contains(&s.body_len),
302        "X25519" => len(0) == 43 && s.body_len == 32,
303        crate::members::PQ_STANZA => len(0) == 1494 && s.body_len == 32,
304        _ => false,
305    }
306}
307
308/// ★ **The key ring against the members file**, without a key: see the
309/// module documentation. `members` is the text of `.gunnar-crypt/members`,
310/// `keys` the bytes of `.gunnar-crypt/keys`.
311pub fn check_ring(members: &str, keys: &[u8]) -> Result<(), Refusal> {
312    for (path, len) in [(paths::MEMBERS, members.len()), (paths::KEYS, keys.len())] {
313        if len > MAX_SCHEME_FILE {
314            return Err(Refusal::RingMismatch(format!(
315                "{path} is larger than {} MiB, which no key ring of gunnar's needs",
316                MAX_SCHEME_FILE >> 20
317            )));
318        }
319    }
320    let members = Members::parse(members)
321        .map_err(|e| Refusal::RingMismatch(format!("{} is not readable ({e})", paths::MEMBERS)))?;
322    let stanzas = age_stanzas(keys).ok_or(Refusal::KeysNotAge)?;
323    let level = members.level();
324    let mut grease = 0;
325    // What each listed key should put in the header — its stanza type and,
326    // for an SSH key, its tag — counted, so a large ring is one pass.
327    let mut expected: std::collections::HashMap<(&'static str, Option<String>), usize> =
328        std::collections::HashMap::new();
329    for m in members.entries() {
330        let tag = match m.kind {
331            KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
332            KeyKind::Age | KeyKind::AgePq => None,
333        };
334        *expected.entry((m.kind.stanza_type(), tag)).or_default() += 1;
335    }
336    for s in &stanzas {
337        if is_grease(s) && grease == 0 {
338            grease += 1;
339            continue;
340        }
341        if level == Level::QuantumSafe && s.tag != crate::members::PQ_STANZA {
342            return Err(Refusal::ClassicalInQuantumSafe {
343                stanza: s.tag.clone(),
344            });
345        }
346        if !level.stanza_types().contains(&s.tag.as_str()) {
347            return Err(Refusal::RingMismatch(format!(
348                "it carries a {} stanza, which no member key of a {} repository makes",
349                s.tag,
350                level.word()
351            )));
352        }
353        let arity = match s.tag.as_str() {
354            "ssh-ed25519" => 2,
355            _ => 1,
356        };
357        let tag = match s.tag.as_str() {
358            "ssh-ed25519" | "ssh-rsa" => s.args.first().cloned(),
359            _ => None,
360        };
361        if s.args.len() != arity || !well_formed(s) {
362            return Err(Refusal::RingMismatch(format!(
363                "a {} stanza that is not the shape age writes ({} argument(s), a {}-byte body)",
364                s.tag,
365                s.args.len(),
366                s.body_len
367            )));
368        }
369        let left = level
370            .stanza_types()
371            .iter()
372            .find(|t| **t == s.tag)
373            .and_then(|t| expected.get_mut(&(*t, tag.clone())))
374            .filter(|n| **n > 0);
375        let Some(left) = left else {
376            return Err(Refusal::RingMismatch(match tag {
377                Some(tag) => format!(
378                    "it is wrapped to an {} key (tag {tag}) that no member line lists",
379                    s.tag
380                ),
381                None => format!(
382                    "it carries more {} stanzas than the members file lists {} keys",
383                    s.tag, s.tag
384                ),
385            }));
386        };
387        *left -= 1;
388    }
389    let missing: usize = expected.values().sum();
390    if missing > 0 {
391        let first = members
392            .entries()
393            .iter()
394            .find(|m| {
395                let tag = match m.kind {
396                    KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
397                    _ => None,
398                };
399                expected
400                    .get(&(m.kind.stanza_type(), tag))
401                    .is_some_and(|n| *n > 0)
402            })
403            .map(|m| format!("{}'s {} key", m.name, m.kind.stanza_type()))
404            .unwrap_or_default();
405        return Err(Refusal::RingMismatch(format!(
406            "{missing} member key(s) have no stanza (the first: {first})"
407        )));
408    }
409    Ok(())
410}