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 = path == paths::GITATTRIBUTES || path.ends_with(&format!("/{}", paths::GITATTRIBUTES));
170    match path {
171        _ if attributes => Ok(()),
172        paths::MEMBERS => Ok(()),
173        paths::KEYS => {
174            if shape.age {
175                Ok(())
176            } else {
177                Err(Refusal::KeysNotAge)
178            }
179        }
180        _ if shape.envelope => Ok(()),
181        _ => Err(Refusal::Plaintext {
182            path: path.to_string(),
183        }),
184    }
185}
186
187// ── the key ring's age header, read without a key ────────────────────────────
188
189/// The armored age footer line.
190const AGE_ARMOR_END: &str = "-----END AGE ENCRYPTED FILE-----";
191/// The largest key ring or members file the ring check reads, and a bound on
192/// what a hostile push can make anyone parse. A quantum-safe member costs
193/// ~2.1 KB of armored ring (and ~2 KB of members file), so this caps a
194/// quantum-safe repository at ~2,000 member keys (a classic one at ~28,000).
195pub const MAX_SCHEME_FILE: usize = 4 << 20;
196
197/// One recipient stanza of an age header: its type, its arguments and the
198/// length of its body. What the server can see of a wrap.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct StanzaHead {
201    /// The type (`ssh-ed25519`, `X25519`, `mlkem768x25519`, …).
202    pub tag: String,
203    /// Its arguments.
204    pub args: Vec<String>,
205    /// The decoded body's length.
206    pub body_len: usize,
207}
208
209/// The recipient stanzas of an age file (armored or binary), `None` unless
210/// its header parses (c2sp.org/age: `-> type args…`, a base64 body in lines of
211/// 64 columns ending with a shorter one, then the `---` MAC line).
212pub fn age_stanzas(data: &[u8]) -> Option<Vec<StanzaHead>> {
213    if data.len() > MAX_SCHEME_FILE {
214        return None;
215    }
216    let binary;
217    let data = if trim_leading_ws(data).starts_with(AGE_ARMOR_PREFIX) {
218        let text = std::str::from_utf8(data).ok()?;
219        let mut b64 = String::new();
220        let mut inside = false;
221        for line in text.lines().map(str::trim) {
222            if line.as_bytes() == AGE_ARMOR_PREFIX {
223                inside = true;
224            } else if line == AGE_ARMOR_END {
225                break;
226            } else if inside {
227                b64.push_str(line);
228            }
229        }
230        binary = STANDARD.decode(b64.as_bytes()).ok()?;
231        &binary[..]
232    } else {
233        data
234    };
235    let mut lines = data.split(|b| *b == b'\n');
236    if lines.next()? != b"age-encryption.org/v1" {
237        return None;
238    }
239    let mut stanzas = Vec::new();
240    let mut line = lines.next()?;
241    loop {
242        if line.starts_with(b"--- ") {
243            return Some(stanzas);
244        }
245        let head = std::str::from_utf8(line.strip_prefix(b"-> ")?).ok()?;
246        let mut words = head.split(' ');
247        let tag = words.next().filter(|t| !t.is_empty())?.to_string();
248        let args: Vec<String> = words.map(str::to_string).collect();
249        if args.iter().any(String::is_empty) {
250            return None;
251        }
252        let mut body = Vec::new();
253        loop {
254            let l = lines.next()?;
255            body.extend_from_slice(l);
256            if l.len() < 64 {
257                break;
258            }
259            if l.len() > 64 {
260                return None;
261            }
262        }
263        let body_len = STANDARD_NO_PAD.decode(&body).ok()?.len();
264        stanzas.push(StanzaHead {
265            tag,
266            args,
267            body_len,
268        });
269        line = lines.next()?;
270    }
271}
272
273/// The tag age gives a stanza wrapped to an SSH key (`ssh-ed25519`,
274/// `ssh-rsa`): the first 4 bytes of SHA-256 over the key's wire blob, base64.
275pub fn ssh_stanza_tag(public_key: &str) -> Option<String> {
276    let blob = STANDARD.decode(public_key.split_ascii_whitespace().nth(1)?).ok()?;
277    Some(STANDARD_NO_PAD.encode(&Sha256::digest(&blob)[..4]))
278}
279
280/// The one stanza age itself adds to every file it writes: a random type
281/// ending in `-grease`, up to four short random arguments and a short random
282/// body. It carries no key for anyone and is allowed once, at that size.
283fn is_grease(s: &StanzaHead) -> bool {
284    s.tag.ends_with("-grease")
285        && s.tag.len() <= 8 + "-grease".len()
286        && s.args.len() <= 4
287        && s.args.iter().all(|a| a.len() <= 8)
288        && s.body_len < 100
289}
290
291/// Is a member stanza the size age writes for its type? (c2sp.org/age: an
292/// X25519 share and an ssh-ed25519 share are 32 bytes, an SSH tag 4, a hybrid
293/// `enc` 1120; a wrapped file key is 32 bytes, an RSA-OAEP one the modulus.)
294fn well_formed(s: &StanzaHead) -> bool {
295    let len = |i: usize| s.args.get(i).map_or(0, String::len);
296    match s.tag.as_str() {
297        "ssh-ed25519" => len(0) == 6 && len(1) == 43 && s.body_len == 32,
298        "ssh-rsa" => len(0) == 6 && (128..=2048).contains(&s.body_len),
299        "X25519" => len(0) == 43 && s.body_len == 32,
300        crate::members::PQ_STANZA => len(0) == 1494 && s.body_len == 32,
301        _ => false,
302    }
303}
304
305/// ★ **The key ring against the members file**, without a key: see the
306/// module documentation. `members` is the text of `.gunnar-crypt/members`,
307/// `keys` the bytes of `.gunnar-crypt/keys`.
308pub fn check_ring(members: &str, keys: &[u8]) -> Result<(), Refusal> {
309    for (path, len) in [(paths::MEMBERS, members.len()), (paths::KEYS, keys.len())] {
310        if len > MAX_SCHEME_FILE {
311            return Err(Refusal::RingMismatch(format!(
312                "{path} is larger than {} MiB, which no key ring of gunnar's needs",
313                MAX_SCHEME_FILE >> 20
314            )));
315        }
316    }
317    let members = Members::parse(members)
318        .map_err(|e| Refusal::RingMismatch(format!("{} is not readable ({e})", paths::MEMBERS)))?;
319    let stanzas = age_stanzas(keys).ok_or(Refusal::KeysNotAge)?;
320    let level = members.level();
321    let mut grease = 0;
322    // What each listed key should put in the header — its stanza type and,
323    // for an SSH key, its tag — counted, so a large ring is one pass.
324    let mut expected: std::collections::HashMap<(&'static str, Option<String>), usize> =
325        std::collections::HashMap::new();
326    for m in members.entries() {
327        let tag = match m.kind {
328            KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
329            KeyKind::Age | KeyKind::AgePq => None,
330        };
331        *expected.entry((m.kind.stanza_type(), tag)).or_default() += 1;
332    }
333    for s in &stanzas {
334        if is_grease(s) && grease == 0 {
335            grease += 1;
336            continue;
337        }
338        if level == Level::QuantumSafe && s.tag != crate::members::PQ_STANZA {
339            return Err(Refusal::ClassicalInQuantumSafe {
340                stanza: s.tag.clone(),
341            });
342        }
343        if !level.stanza_types().contains(&s.tag.as_str()) {
344            return Err(Refusal::RingMismatch(format!(
345                "it carries a {} stanza, which no member key of a {} repository makes",
346                s.tag,
347                level.word()
348            )));
349        }
350        let arity = match s.tag.as_str() {
351            "ssh-ed25519" => 2,
352            _ => 1,
353        };
354        let tag = match s.tag.as_str() {
355            "ssh-ed25519" | "ssh-rsa" => s.args.first().cloned(),
356            _ => None,
357        };
358        if s.args.len() != arity || !well_formed(s) {
359            return Err(Refusal::RingMismatch(format!(
360                "a {} stanza that is not the shape age writes ({} argument(s), a {}-byte body)",
361                s.tag,
362                s.args.len(),
363                s.body_len
364            )));
365        }
366        let left = level
367            .stanza_types()
368            .iter()
369            .find(|t| **t == s.tag)
370            .and_then(|t| expected.get_mut(&(*t, tag.clone())))
371            .filter(|n| **n > 0);
372        let Some(left) = left else {
373            return Err(Refusal::RingMismatch(match tag {
374                Some(tag) => format!(
375                    "it is wrapped to an {} key (tag {tag}) that no member line lists",
376                    s.tag
377                ),
378                None => format!(
379                    "it carries more {} stanzas than the members file lists {} keys",
380                    s.tag,
381                    s.tag
382                ),
383            }));
384        };
385        *left -= 1;
386    }
387    let missing: usize = expected.values().sum();
388    if missing > 0 {
389        let first = members
390            .entries()
391            .iter()
392            .find(|m| {
393                let tag = match m.kind {
394                    KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
395                    _ => None,
396                };
397                expected.get(&(m.kind.stanza_type(), tag)).is_some_and(|n| *n > 0)
398            })
399            .map(|m| format!("{}'s {} key", m.name, m.kind.stanza_type()))
400            .unwrap_or_default();
401        return Err(Refusal::RingMismatch(format!(
402            "{missing} member key(s) have no stanza (the first: {first})"
403        )));
404    }
405    Ok(())
406}