1use 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
47pub const AGE_BINARY_PREFIX: &[u8] = b"age-encryption.org/v1\n";
49pub const AGE_ARMOR_PREFIX: &[u8] = b"-----BEGIN AGE ENCRYPTED FILE-----";
51
52pub 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#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Refusal {
72 Plaintext {
74 path: String,
76 },
77 KeysNotAge,
79 Symlink {
81 path: String,
83 },
84 ClassicalInQuantumSafe {
87 stanza: String,
89 },
90 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct Shape {
137 pub envelope: bool,
139 pub age: bool,
141}
142
143impl Shape {
144 pub fn of(data: &[u8]) -> Shape {
146 Shape {
147 envelope: envelope::is_envelope(data),
148 age: is_age_file(data),
149 }
150 }
151}
152
153pub fn check_blob(path: &str, is_symlink: bool, data: &[u8]) -> Result<(), Refusal> {
155 check(path, is_symlink, Shape::of(data))
156}
157
158pub 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 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
187const AGE_ARMOR_END: &str = "-----END AGE ENCRYPTED FILE-----";
191pub const MAX_SCHEME_FILE: usize = 4 << 20;
196
197#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct StanzaHead {
201 pub tag: String,
203 pub args: Vec<String>,
205 pub body_len: usize,
207}
208
209pub 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
273pub 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
280fn 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
291fn 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
305pub 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 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}