Skip to main content

isb_core/secrets/
inline.rs

1//! age encryption, and the inline form compose files carry
2//! (`secrets: {x: {age: ...}}`).
3//!
4//! Inline ciphertext is written ASCII-armored (`-----BEGIN AGE ENCRYPTED
5//! FILE-----`), the format `age -a` writes and `age -d` reads, so it can be
6//! made and checked with the age CLI too. Decryption also accepts the binary
7//! format base64-encoded, on one line or several.
8
9use std::io::{Read, Write};
10
11use age::armor::{ArmoredReader, ArmoredWriter, Format};
12
13use super::Recipient;
14use crate::error::{Error, Result};
15
16const ARMOR_BEGIN: &str = "-----BEGIN AGE ENCRYPTED FILE-----";
17
18fn age_err(step: &str, e: impl std::fmt::Display) -> Error {
19    Error::invalid(format!("age {step}: {e}"))
20}
21
22fn encryptor(recipients: &[Recipient]) -> Result<age::Encryptor> {
23    if recipients.is_empty() {
24        return Err(Error::invalid("age encrypt: no recipients"));
25    }
26    age::Encryptor::with_recipients(recipients.iter().map(Recipient::as_age))
27        .map_err(|e| age_err("encrypt", e))
28}
29
30/// Binary age ciphertext of `value` to every recipient.
31pub fn encrypt(value: &[u8], recipients: &[Recipient]) -> Result<Vec<u8>> {
32    let mut out = Vec::with_capacity(value.len() + 256);
33    let mut w = encryptor(recipients)?
34        .wrap_output(&mut out)
35        .map_err(|e| age_err("encrypt", e))?;
36    w.write_all(value).map_err(|e| age_err("encrypt", e))?;
37    w.finish().map_err(|e| age_err("encrypt", e))?;
38    Ok(out)
39}
40
41/// Decrypt age ciphertext, binary or armored, with the first identity that
42/// fits.
43pub fn decrypt(ciphertext: &[u8], identities: &[&dyn age::Identity]) -> Result<Vec<u8>> {
44    let d =
45        age::Decryptor::new(ArmoredReader::new(ciphertext)).map_err(|e| age_err("decrypt", e))?;
46    if d.is_scrypt() {
47        return Err(Error::invalid(
48            "age decrypt: passphrase-encrypted values are not supported; encrypt to a recipient",
49        ));
50    }
51    let mut r = d
52        .decrypt(identities.iter().copied())
53        .map_err(|e| age_err("decrypt", e))?;
54    let mut out = Vec::new();
55    r.read_to_end(&mut out).map_err(|e| age_err("decrypt", e))?;
56    Ok(out)
57}
58
59/// ASCII-armored ciphertext for a compose file's `age:` field.
60pub fn encrypt_inline(value: &[u8], recipients: &[Recipient]) -> Result<String> {
61    let mut out = Vec::new();
62    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor)
63        .map_err(|e| age_err("encrypt", e))?;
64    let mut w = encryptor(recipients)?
65        .wrap_output(armor)
66        .map_err(|e| age_err("encrypt", e))?;
67    w.write_all(value).map_err(|e| age_err("encrypt", e))?;
68    w.finish()
69        .and_then(|a| a.finish())
70        .map_err(|e| age_err("encrypt", e))?;
71    String::from_utf8(out).map_err(|e| age_err("encrypt", e))
72}
73
74/// Decrypt an `age:` field: armored, or base64 of the binary format.
75pub fn decrypt_inline(text: &str, identities: &[&dyn age::Identity]) -> Result<Vec<u8>> {
76    let t = text.trim();
77    if t.starts_with(ARMOR_BEGIN) {
78        // The armor parser is strict about line endings; YAML block
79        // scalars may have added indentation-free trailing spaces.
80        let norm: String = t.lines().map(|l| l.trim_end().to_string() + "\n").collect();
81        return decrypt(norm.as_bytes(), identities);
82    }
83    let bin = crate::rpc::b64_decode(t).map_err(|_| {
84        Error::invalid(
85            "age: expected ASCII-armored ciphertext (age -a) or base64 of age's binary format",
86        )
87    })?;
88    decrypt(&bin, identities)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn ssh_identity() -> impl age::Identity {
96        let key = include_bytes!("testdata/break_glass_ed25519");
97        age::ssh::Identity::from_buffer(&key[..], None)
98            .unwrap()
99            .with_callbacks(age::NoCallbacks)
100    }
101
102    #[test]
103    fn inline_round_trip_armored_and_base64() {
104        let id = age::x25519::Identity::generate();
105        let rs = vec![Recipient::X25519(id.to_public())];
106        let a = encrypt_inline(b"s3cret\n", &rs).unwrap();
107        assert!(a.starts_with(ARMOR_BEGIN));
108        assert!(!a.contains("s3cret"));
109        assert_eq!(decrypt_inline(&a, &[&id]).unwrap(), b"s3cret\n");
110        // As a YAML block scalar would hand it back: indented lines trimmed,
111        // trailing whitespace added.
112        let messy: String = a.lines().map(|l| format!("{l}  \n")).collect();
113        assert_eq!(decrypt_inline(&messy, &[&id]).unwrap(), b"s3cret\n");
114        let b = crate::rpc::b64_encode(&encrypt(b"bin", &rs).unwrap());
115        assert_eq!(decrypt_inline(&b, &[&id]).unwrap(), b"bin");
116        // Wrapped base64 too.
117        let wrapped: String = b
118            .as_bytes()
119            .chunks(40)
120            .map(|c| String::from_utf8_lossy(c).into_owned() + "\n")
121            .collect();
122        assert_eq!(decrypt_inline(&wrapped, &[&id]).unwrap(), b"bin");
123        // The wrong key fails, and so does garbage.
124        let other = age::x25519::Identity::generate();
125        assert!(decrypt_inline(&a, &[&other]).is_err());
126        assert!(decrypt_inline("not age at all", &[&id]).is_err());
127        assert!(encrypt_inline(b"x", &[]).is_err());
128    }
129
130    #[test]
131    fn ssh_recipients_decrypt_with_the_ssh_key() {
132        let r = Recipient::parse(include_str!("testdata/break_glass_ed25519.pub")).unwrap();
133        let ct = encrypt(b"break glass", &[r]).unwrap();
134        let ssh = ssh_identity();
135        assert_eq!(decrypt(&ct, &[&ssh]).unwrap(), b"break glass");
136    }
137}