age_core/format.rs
1//! Core types and encoding operations used by the age file format.
2
3use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
4use rand::{
5 distributions::{Distribution, Uniform},
6 thread_rng, RngCore,
7};
8use secrecy::{ExposeSecret, ExposeSecretMut, SecretBox};
9
10/// The prefix identifying an age stanza.
11const STANZA_TAG: &str = "-> ";
12
13/// The length of an age file key.
14pub const FILE_KEY_BYTES: usize = 16;
15
16/// A file key for encrypting or decrypting an age file.
17pub struct FileKey(SecretBox<[u8; FILE_KEY_BYTES]>);
18
19impl FileKey {
20 /// Creates a file key using a pre-boxed key.
21 pub fn new(file_key: Box<[u8; FILE_KEY_BYTES]>) -> Self {
22 Self(SecretBox::new(file_key))
23 }
24
25 /// Creates a file key using a function that can initialize the key in-place.
26 pub fn init_with_mut(ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES])) -> Self {
27 Self(SecretBox::init_with_mut(ctr))
28 }
29
30 /// Same as [`Self::init_with_mut`], but the constructor can be fallible.
31 pub fn try_init_with_mut<E>(
32 ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES]) -> Result<(), E>,
33 ) -> Result<Self, E> {
34 let mut file_key = SecretBox::new(Box::new([0; FILE_KEY_BYTES]));
35 ctr(file_key.expose_secret_mut())?;
36 Ok(Self(file_key))
37 }
38}
39
40impl ExposeSecret<[u8; FILE_KEY_BYTES]> for FileKey {
41 fn expose_secret(&self) -> &[u8; FILE_KEY_BYTES] {
42 self.0.expose_secret()
43 }
44}
45
46/// A section of the age header that encapsulates the file key as encrypted to a specific
47/// recipient.
48///
49/// This is the reference type; see [`Stanza`] for the owned type.
50#[derive(Debug)]
51pub struct AgeStanza<'a> {
52 /// A tag identifying this stanza type.
53 pub tag: &'a str,
54 /// Zero or more arguments.
55 pub args: Vec<&'a str>,
56 /// The body of the stanza, containing a wrapped [`FileKey`].
57 ///
58 /// Represented as the set of Base64-encoded lines for efficiency (so the caller can
59 /// defer the cost of decoding until the structure containing this stanza has been
60 /// fully-parsed).
61 body: Vec<&'a [u8]>,
62}
63
64impl<'a> AgeStanza<'a> {
65 /// Decodes and returns the body of this stanza.
66 pub fn body(&self) -> Vec<u8> {
67 // An AgeStanza will always contain at least one chunk.
68 let (partial_chunk, full_chunks) = self.body.split_last().unwrap();
69
70 // This is faster than collecting from a flattened iterator.
71 let mut data = vec![0; full_chunks.len() * 64 + partial_chunk.len()];
72 for (i, chunk) in full_chunks.iter().enumerate() {
73 // These chunks are guaranteed to be full by construction.
74 data[i * 64..(i + 1) * 64].copy_from_slice(chunk);
75 }
76 data[full_chunks.len() * 64..].copy_from_slice(partial_chunk);
77
78 // The chunks are guaranteed to contain Base64 characters by construction.
79 BASE64_STANDARD_NO_PAD.decode(&data).unwrap()
80 }
81}
82
83/// A section of the age header that encapsulates the file key as encrypted to a specific
84/// recipient.
85///
86/// This is the owned type; see [`AgeStanza`] for the reference type.
87#[derive(Debug, PartialEq, Eq)]
88pub struct Stanza {
89 /// A tag identifying this stanza type.
90 pub tag: String,
91 /// Zero or more arguments.
92 pub args: Vec<String>,
93 /// The body of the stanza, containing a wrapped [`FileKey`].
94 pub body: Vec<u8>,
95}
96
97impl From<AgeStanza<'_>> for Stanza {
98 fn from(stanza: AgeStanza<'_>) -> Self {
99 let body = stanza.body();
100 Stanza {
101 tag: stanza.tag.to_string(),
102 args: stanza.args.into_iter().map(|s| s.to_string()).collect(),
103 body,
104 }
105 }
106}
107
108/// Checks whether the string is a valid age "arbitrary string" (`1*VCHAR` in ABNF).
109pub fn is_arbitrary_string<S: AsRef<str>>(s: &S) -> bool {
110 let s = s.as_ref();
111 !s.is_empty()
112 && s.chars().all(|c| match u8::try_from(c) {
113 Ok(u) => (33..=126).contains(&u),
114 Err(_) => false,
115 })
116}
117
118/// Creates a random recipient stanza that exercises the joint in the age v1 format.
119///
120/// This function is guaranteed to return a valid stanza, but makes no other guarantees
121/// about the stanza's fields.
122pub fn grease_the_joint() -> Stanza {
123 // Generate arbitrary strings between 1 and 9 characters long.
124 fn gen_arbitrary_string<R: RngCore>(rng: &mut R) -> String {
125 let length = Uniform::from(1..9).sample(rng);
126 Uniform::from(33..=126)
127 .sample_iter(rng)
128 .map(char::from)
129 .take(length)
130 .collect()
131 }
132
133 let mut rng = thread_rng();
134
135 // Add a suffix to the random tag so users know what is going on.
136 let tag = format!("{}-grease", gen_arbitrary_string(&mut rng));
137
138 // Between this and the above generation bounds, the first line of the recipient
139 // stanza will be between eight and 66 characters.
140 let args = (0..Uniform::from(0..5).sample(&mut rng))
141 .map(|_| gen_arbitrary_string(&mut rng))
142 .collect();
143
144 // A length between 0 and 100 bytes exercises the following stanza bodies:
145 // - Empty
146 // - Single short-line
147 // - Single full-line
148 // - Two lines, second short
149 // - Two lines, both full
150 // - Three lines, last short
151 let mut body = vec![0; Uniform::from(0..100).sample(&mut rng)];
152 rng.fill_bytes(&mut body);
153
154 Stanza { tag, args, body }
155}
156
157/// Decoding operations for age types.
158pub mod read {
159 use nom::{
160 branch::alt,
161 bytes::streaming::{tag, take_while1, take_while_m_n},
162 character::streaming::newline,
163 combinator::{map, map_opt, opt, verify},
164 multi::{many_till, separated_list1},
165 sequence::{pair, preceded, terminated},
166 IResult, Parser,
167 };
168
169 use super::{AgeStanza, STANZA_TAG};
170
171 fn is_base64_char(c: u8) -> bool {
172 // Check against the ASCII values of the standard Base64 character set.
173 matches!(
174 c,
175 // A..=Z | a..=z | 0..=9 | + | /
176 65..=90 | 97..=122 | 48..=57 | 43 | 47,
177 )
178 }
179
180 /// Returns true if the byte is one of the specific ASCII values of the standard
181 /// Base64 character set which leave trailing bits when they occur as the last
182 /// character in an encoding of length 2 mod 4.
183 fn base64_has_no_trailing_bits_2(c: &u8) -> bool {
184 // With two trailing characters, the last character has up to four trailing bits.
185 matches!(
186 c,
187 // A | Q | g | w
188 65 | 81 | 103 | 119,
189 )
190 }
191
192 /// Returns true if the byte is one of the specific ASCII values of the standard
193 /// Base64 character set which leave trailing bits when they occur as the last
194 /// character in an encoding of length 3 mod 4.
195 fn base64_has_no_trailing_bits_3(c: &u8) -> bool {
196 // With three trailing characters, the last character has up to two trailing bits.
197 matches!(
198 c,
199 // A | E | I | M | Q | U | Y | c | g | k | o | s | w | 0 | 4 | 8
200 65 | 69 | 73 | 77 | 81 | 85 | 89 | 99 | 103 | 107 | 111 | 115 | 119 | 48 | 52 | 56,
201 )
202 }
203
204 /// Reads an age "arbitrary string".
205 ///
206 /// From the age specification:
207 /// ```text
208 /// ... an arbitrary string is a sequence of ASCII characters with values 33 to 126.
209 /// ```
210 pub fn arbitrary_string(input: &[u8]) -> IResult<&[u8], &str> {
211 map(take_while1(|c| (33..=126).contains(&c)), |bytes| {
212 // Safety: ASCII bytes are valid UTF-8
213 unsafe { std::str::from_utf8_unchecked(bytes) }
214 })
215 .parse(input)
216 }
217
218 fn wrapped_encoded_data(input: &[u8]) -> IResult<&[u8], Vec<&[u8]>> {
219 map(
220 many_till(
221 // Any body lines before the last MUST be full-length.
222 terminated(take_while_m_n(64, 64, is_base64_char), newline),
223 // Last body line:
224 // - MUST be short (empty if necessary).
225 // - MUST be a valid Base64 length (i.e. the length must not be 1 mod 4).
226 // - MUST NOT leave trailing bits (if the length is 2 or 3 mod 4).
227 verify(
228 terminated(take_while_m_n(0, 63, is_base64_char), newline),
229 |line: &[u8]| match line.len() % 4 {
230 0 => true,
231 1 => false,
232 2 => base64_has_no_trailing_bits_2(line.last().unwrap()),
233 3 => base64_has_no_trailing_bits_3(line.last().unwrap()),
234 // No other cases, but Rust wants an exhaustive match on u8.
235 _ => unreachable!(),
236 },
237 ),
238 ),
239 |(full_chunks, partial_chunk): (Vec<&[u8]>, &[u8])| {
240 let mut chunks = full_chunks;
241 chunks.push(partial_chunk);
242 chunks
243 },
244 )
245 .parse(input)
246 }
247
248 fn legacy_wrapped_encoded_data(input: &[u8]) -> IResult<&[u8], Vec<&[u8]>> {
249 map_opt(
250 separated_list1(newline, take_while1(is_base64_char)),
251 |chunks: Vec<&[u8]>| {
252 // Enforce that the only chunk allowed to be shorter than 64 characters
253 // is the last chunk, and that its length must not be 1 mod 4.
254 let (partial_chunk, full_chunks) = chunks.split_last().unwrap();
255 if full_chunks.iter().any(|s| s.len() != 64)
256 || partial_chunk.len() > 64
257 || partial_chunk.len() % 4 == 1
258 || (partial_chunk.len() % 4 == 2
259 && !base64_has_no_trailing_bits_2(partial_chunk.last().unwrap()))
260 || (partial_chunk.len() % 4 == 3
261 && !base64_has_no_trailing_bits_3(partial_chunk.last().unwrap()))
262 {
263 None
264 } else {
265 Some(chunks)
266 }
267 },
268 )
269 .parse(input)
270 }
271
272 /// Reads an age stanza.
273 ///
274 /// From the age spec:
275 /// ```text
276 /// Each recipient stanza starts with a line beginning with -> and its type name,
277 /// followed by zero or more SP-separated arguments. The type name and the arguments
278 /// are arbitrary strings. Unknown recipient types are ignored. The rest of the
279 /// recipient stanza is a body of canonical base64 from RFC 4648 without padding
280 /// wrapped at exactly 64 columns.
281 /// ```
282 pub fn age_stanza(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
283 map(
284 pair(
285 preceded(
286 tag(STANZA_TAG),
287 terminated(separated_list1(tag(" "), arbitrary_string), newline),
288 ),
289 wrapped_encoded_data,
290 ),
291 |(mut args, body)| {
292 let tag = args.remove(0);
293 AgeStanza { tag, args, body }
294 },
295 )
296 .parse(input)
297 }
298
299 fn legacy_age_stanza_inner(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
300 map(
301 pair(
302 preceded(tag(STANZA_TAG), separated_list1(tag(" "), arbitrary_string)),
303 terminated(opt(preceded(newline, legacy_wrapped_encoded_data)), newline),
304 ),
305 |(mut args, body)| {
306 let tag = args.remove(0);
307 AgeStanza {
308 tag,
309 args,
310 body: body.unwrap_or_else(|| vec![&[]]),
311 }
312 },
313 )
314 .parse(input)
315 }
316
317 /// Reads a age stanza, allowing the legacy encoding of an body.
318 ///
319 /// From the age spec:
320 /// ```text
321 /// Each recipient stanza starts with a line beginning with -> and its type name,
322 /// followed by zero or more SP-separated arguments. The type name and the arguments
323 /// are arbitrary strings. Unknown recipient types are ignored. The rest of the
324 /// recipient stanza is a body of canonical base64 from RFC 4648 without padding
325 /// wrapped at exactly 64 columns.
326 /// ```
327 ///
328 /// The spec was originally unclear about how to encode a stanza body. Both age and
329 /// rage implemented the encoding in a way such that a stanza with a body of length of
330 /// 0 mod 64 was indistinguishable from an incomplete stanza. The spec now requires a
331 /// stanza body to always be terminated with a short line (empty if necessary). This
332 /// API exists to handle files that include the legacy encoding. The only known
333 /// generator of 0 mod 64 bodies is [`grease_the_joint`], so this should only affect
334 /// age files encrypted with beta versions of the `age` or `rage` crates.
335 ///
336 /// [`grease_the_joint`]: super::grease_the_joint
337 pub fn legacy_age_stanza(input: &[u8]) -> IResult<&[u8], AgeStanza<'_>> {
338 alt((age_stanza, legacy_age_stanza_inner)).parse(input)
339 }
340
341 #[cfg(test)]
342 mod tests {
343 use super::*;
344
345 #[test]
346 fn base64_padding_rejected() {
347 assert!(wrapped_encoded_data(b"Tm8gcGFkZGluZyE\n").is_ok());
348 assert!(wrapped_encoded_data(b"Tm8gcGFkZGluZyE=\n").is_err());
349 // Internal padding is also rejected.
350 assert!(wrapped_encoded_data(b"SW50ZXJuYWwUGFk\n").is_ok());
351 assert!(wrapped_encoded_data(b"SW50ZXJuYWw=UGFk\n").is_err());
352 }
353 }
354}
355
356/// Encoding operations for age types.
357pub mod write {
358 use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
359 use cookie_factory::{
360 combinator::string,
361 multi::separated_list,
362 sequence::{pair, tuple},
363 SerializeFn, WriteContext,
364 };
365 use std::io::Write;
366 use std::iter;
367
368 use super::STANZA_TAG;
369
370 fn wrapped_encoded_data<'a, W: 'a + Write>(data: &[u8]) -> impl SerializeFn<W> + 'a {
371 let encoded = BASE64_STANDARD_NO_PAD.encode(data);
372
373 move |mut w: WriteContext<W>| {
374 let mut s = encoded.as_str();
375
376 // Write full body lines.
377 while s.len() >= 64 {
378 let (l, r) = s.split_at(64);
379 w = pair(string(l), string("\n"))(w)?;
380 s = r;
381 }
382
383 // Last body line MUST be short (empty if necessary).
384 pair(string(s), string("\n"))(w)
385 }
386 }
387
388 /// Writes an age stanza.
389 pub fn age_stanza<'a, W: 'a + Write, S: AsRef<str>>(
390 tag: &'a str,
391 args: &'a [S],
392 body: &'a [u8],
393 ) -> impl SerializeFn<W> + 'a {
394 pair(
395 tuple((
396 string(STANZA_TAG),
397 separated_list(
398 string(" "),
399 iter::once(tag)
400 .chain(args.iter().map(|s| s.as_ref()))
401 .map(string),
402 ),
403 string("\n"),
404 )),
405 wrapped_encoded_data(body),
406 )
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
413 use nom::error::ErrorKind;
414
415 use super::{read, write};
416
417 #[test]
418 fn parse_age_stanza() {
419 let test_tag = "X25519";
420 let test_args = &["CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20"];
421 let test_body = BASE64_STANDARD_NO_PAD
422 .decode("C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig")
423 .unwrap();
424
425 // The only body line is short, so we don't need a trailing empty line.
426 let test_stanza = "-> X25519 CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20
427C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig
428";
429
430 let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
431 assert_eq!(stanza.tag, test_tag);
432 assert_eq!(stanza.args, test_args);
433 assert_eq!(stanza.body(), test_body);
434
435 let mut buf = vec![];
436 cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, &test_body), &mut buf)
437 .unwrap();
438 assert_eq!(buf, test_stanza.as_bytes());
439 }
440
441 #[test]
442 fn age_stanza_with_empty_body() {
443 let test_tag = "empty-body";
444 let test_args = &["some", "arguments"];
445 let test_body = &[];
446
447 // The body is empty, so it is represented with an empty line.
448 let test_stanza = "-> empty-body some arguments
449
450";
451
452 let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
453 assert_eq!(stanza.tag, test_tag);
454 assert_eq!(stanza.args, test_args);
455 assert_eq!(stanza.body(), test_body);
456
457 let mut buf = vec![];
458 cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, test_body), &mut buf)
459 .unwrap();
460 assert_eq!(buf, test_stanza.as_bytes());
461 }
462
463 #[test]
464 fn age_stanza_with_full_body() {
465 let test_tag = "full-body";
466 let test_args = &["some", "arguments"];
467 let test_body = BASE64_STANDARD_NO_PAD
468 .decode("xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA")
469 .unwrap();
470
471 // The body fills a complete line, so it requires a trailing empty line.
472 let test_stanza = "-> full-body some arguments
473xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA
474
475";
476
477 let (_, stanza) = read::age_stanza(test_stanza.as_bytes()).unwrap();
478 assert_eq!(stanza.tag, test_tag);
479 assert_eq!(stanza.args, test_args);
480 assert_eq!(stanza.body(), test_body);
481
482 let mut buf = vec![];
483 cookie_factory::gen_simple(write::age_stanza(test_tag, test_args, &test_body), &mut buf)
484 .unwrap();
485 assert_eq!(buf, test_stanza.as_bytes());
486 }
487
488 #[test]
489 fn age_stanza_with_legacy_full_body() {
490 let test_tag = "full-body";
491 let test_args = &["some", "arguments"];
492 let test_body = BASE64_STANDARD_NO_PAD
493 .decode("xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA")
494 .unwrap();
495
496 // The body fills a complete line, but lacks a trailing empty line.
497 let test_stanza = "-> full-body some arguments
498xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJCRB0ErXA
499--- header end
500";
501
502 // The normal parser returns an error.
503 assert!(read::age_stanza(test_stanza.as_bytes()).is_err());
504
505 // We can parse with the legacy parser
506 let (_, stanza) = read::legacy_age_stanza(test_stanza.as_bytes()).unwrap();
507 assert_eq!(stanza.tag, test_tag);
508 assert_eq!(stanza.args, test_args);
509 assert_eq!(stanza.body(), test_body);
510 }
511
512 #[test]
513 fn age_stanza_invalid_last_line() {
514 // Artifact found by cargo-fuzz on commit 81f91581bf7e21075519dc23e4a28b4d201dd784
515 // We add an extra newline to the artifact so that we would "correctly" trigger
516 // the bug in the legacy part of `read::legacy_age_stanza`.
517 let artifact = "-> H
518/
519
520";
521
522 // The stanza parser requires the last body line is short (possibly empty), so
523 // should reject this artifact.
524 match read::age_stanza(artifact.as_bytes()) {
525 Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
526 Err(e) => panic!("Unexpected error: {}", e),
527 Ok((rest, stanza)) => {
528 assert_eq!(rest, b"\n");
529 // This is where the fuzzer triggered a panic.
530 let _ = stanza.body();
531 // We should never reach here either before or after the bug was fixed,
532 // because the body length is invalid.
533 panic!("Invalid test case was parsed without error");
534 }
535 }
536
537 // The legacy parser accepts this artifact by ignoring the invalid body line,
538 // because bodies were allowed to be omitted.
539 let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
540 // The remainder should the invalid body line. If the standard parser were fixed
541 // but the legacy parser was not, this would only contain a single newline.
542 assert_eq!(rest, b"/\n\n");
543 // This is where the fuzzer would have triggered a panic if it were using the
544 // legacy parser.
545 let body = stanza.body();
546 assert!(body.is_empty());
547 }
548
549 #[test]
550 fn age_stanza_last_line_two_trailing_chars() {
551 // Artifact found by cargo-fuzz on commit 8da15148fc005a48ffeb43eb76dab478eb2fdf72
552 // We add an extra newline to the artifact so that we would "correctly" trigger
553 // the bug in the legacy part of `read::legacy_age_stanza`.
554 let artifact = "-> '
555dy
556
557";
558
559 // The stanza parser requires the last body line is short (possibly empty), so
560 // should reject this artifact.
561 match read::age_stanza(artifact.as_bytes()) {
562 Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
563 Err(e) => panic!("Unexpected error: {}", e),
564 Ok((rest, stanza)) => {
565 assert_eq!(rest, b"\n");
566 // This is where the fuzzer triggered a panic.
567 let _ = stanza.body();
568 // We should never reach here either before or after the bug was fixed,
569 // because the last body line has trailing bits.
570 panic!("Invalid test case was parsed without error");
571 }
572 }
573
574 // The legacy parser accepts this artifact by ignoring the invalid body line,
575 // because bodies were allowed to be omitted.
576 let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
577 // The remainder should the invalid body line. If the standard parser were fixed
578 // but the legacy parser was not, this would only contain a single newline.
579 assert_eq!(rest, b"dy\n\n");
580 // This is where the fuzzer would have triggered a panic if it were using the
581 // legacy parser.
582 let body = stanza.body();
583 assert!(body.is_empty());
584 }
585
586 #[test]
587 fn age_stanza_last_line_three_trailing_chars() {
588 // Artifact found by cargo-fuzz after age_stanza_last_line_two_trailing_chars was
589 // incorrectly fixed.
590 let artifact = "-> h
591ddd
592
593";
594
595 // The stanza parser requires the last body line is short (possibly empty), so
596 // should reject this artifact.
597 match read::age_stanza(artifact.as_bytes()) {
598 Err(nom::Err::Error(e)) => assert_eq!(e.code, ErrorKind::TakeWhileMN),
599 Err(e) => panic!("Unexpected error: {}", e),
600 Ok((rest, stanza)) => {
601 assert_eq!(rest, b"\n");
602 // This is where the fuzzer triggered a panic.
603 let _ = stanza.body();
604 // We should never reach here either before or after the bug was fixed,
605 // because the last body line has trailing bits.
606 panic!("Invalid test case was parsed without error");
607 }
608 }
609
610 // The legacy parser accepts this artifact by ignoring the invalid body line,
611 // because bodies were allowed to be omitted.
612 let (rest, stanza) = read::legacy_age_stanza(artifact.as_bytes()).unwrap();
613 // The remainder should the invalid body line. If the standard parser were fixed
614 // but the legacy parser was not, this would only contain a single newline.
615 assert_eq!(rest, b"ddd\n\n");
616 // This is where the fuzzer would have triggered a panic if it were using the
617 // legacy parser.
618 let body = stanza.body();
619 assert!(body.is_empty());
620 }
621}