Skip to main content

dynamic_config/
age.rs

1//! `age`-encrypted config files.
2//!
3//! [`age`] is a small, modern file-encryption format: one binary or armored
4//! blob per file, recipients in the header, no key management to speak of. It
5//! is what `secrets.json.age` in a repository is encrypted with, and this is the
6//! [`Decryptor`] that reads it.
7//!
8//! ```sh
9//! age-keygen -o key.txt
10//! age -r "$(grep 'public key' key.txt | cut -d: -f2 | tr -d ' ')" \
11//!     -o secrets.json.age secrets.json
12//! rm secrets.json
13//! ```
14//!
15//! ```rust,no_run
16//! // The key comes from the environment, so it is not in the source or the
17//! // repository. `SOPS_AGE_KEY_FILE` is honoured too, because a machine that
18//! // already has one set should not need a second.
19//! dynamic_config::set_decryptor(dynamic_config::age::Age::from_environment()?).ok();
20//! # Ok::<(), Box<dyn std::error::Error>>(())
21//! ```
22//!
23//! Both binary and armored (`-a`, the `-----BEGIN AGE ENCRYPTED FILE-----`
24//! form) files are read, without being told which — a repository that switched
25//! to armor for a readable diff should not need a code change.
26//!
27//! [`age`]: https://age-encryption.org
28
29use std::io::{Read, Write};
30
31use age::Identity;
32
33use crate::decrypt::{Decryptor, Encryptor};
34use crate::error::Error;
35
36/// Where `sops` looks for an age key file. Honoured first, because a machine
37/// set up for SOPS already has it.
38const SOPS_KEY_FILE: &str = "SOPS_AGE_KEY_FILE";
39
40/// Where a bare `age` setup usually keeps one.
41const AGE_KEY_FILE: &str = "AGE_IDENTITY_FILE";
42
43/// A literal key, for a deployment that injects it rather than mounting a file.
44const AGE_KEY: &str = "AGE_SECRET_KEY";
45
46/// Decrypts `age`-encrypted config files.
47///
48/// Holds one or more identities. A file is decrypted by whichever of them the
49/// recipients match, so a key being rotated is a matter of listing both for a
50/// while rather than a flag day.
51pub struct Age {
52    identities: Vec<Box<dyn Identity + Send + Sync>>,
53    described: String,
54}
55
56impl Age {
57    /// Reads identities from an `age` key file.
58    ///
59    /// The file `age-keygen` writes: one or more `AGE-SECRET-KEY-…` lines, with
60    /// comments allowed.
61    ///
62    /// # Errors
63    ///
64    /// If the file cannot be read, holds no identity, or holds something that
65    /// is not one.
66    pub fn from_identity_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
67        let path = path.as_ref();
68        let named = path.display().to_string();
69
70        let file = age::IdentityFile::from_file(named.clone())
71            .map_err(|error| Error::decrypt(format!("cannot read {named}: {error}")))?;
72
73        let identities = file.into_identities().map_err(|error| {
74            Error::decrypt(format!("{named} holds no usable identity: {error}"))
75        })?;
76
77        Self::from_identities(identities, format!("age, keys from {named}"))
78    }
79
80    /// Parses identities from text, for a key that arrives in a variable rather
81    /// than a file.
82    ///
83    /// # Errors
84    ///
85    /// If the text holds no identity, or holds something that is not one.
86    pub fn from_key(text: &str) -> Result<Self, Error> {
87        let file = age::IdentityFile::from_buffer(std::io::BufReader::new(text.as_bytes()))
88            .map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
89
90        let identities = file
91            .into_identities()
92            .map_err(|error| Error::decrypt(format!("the key is not an age identity: {error}")))?;
93
94        Self::from_identities(identities, "age, key from the environment".to_owned())
95    }
96
97    /// Reads a key from the environment.
98    ///
99    /// In order: `SOPS_AGE_KEY_FILE`, `AGE_IDENTITY_FILE`, `AGE_SECRET_KEY`.
100    /// The SOPS variable comes first because a machine set up for SOPS already
101    /// has it, and asking for a second one that means the same thing is how
102    /// deployments end up with two.
103    ///
104    /// # Errors
105    ///
106    /// If none of them is set, or the one that is cannot be read.
107    pub fn from_environment() -> Result<Self, Error> {
108        for variable in [SOPS_KEY_FILE, AGE_KEY_FILE] {
109            if let Ok(path) = std::env::var(variable) {
110                if !path.is_empty() {
111                    return Self::from_identity_file(path);
112                }
113            }
114        }
115
116        if let Ok(key) = std::env::var(AGE_KEY) {
117            if !key.is_empty() {
118                return Self::from_key(&key);
119            }
120        }
121
122        Err(Error::decrypt(format!(
123            "no age key in the environment; set {SOPS_KEY_FILE}, {AGE_KEY_FILE} or {AGE_KEY}"
124        )))
125    }
126
127    /// Decrypts with a passphrase.
128    ///
129    /// The `age -p` form. Weaker than a key file and deliberately awkward to
130    /// automate — a passphrase a program can read is a passphrase in an
131    /// environment variable — but it is what a hand-encrypted file uses.
132    #[must_use]
133    pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
134        let identity =
135            age::scrypt::Identity::new(age::secrecy::SecretString::from(passphrase.into()));
136
137        Self {
138            identities: vec![Box::new(identity)],
139            described: "age, passphrase".to_owned(),
140        }
141    }
142
143    fn from_identities(
144        identities: Vec<Box<dyn Identity + Send + Sync>>,
145        described: String,
146    ) -> Result<Self, Error> {
147        if identities.is_empty() {
148            return Err(Error::decrypt(format!("{described}: no identities")));
149        }
150
151        Ok(Self {
152            identities,
153            described,
154        })
155    }
156}
157
158impl Decryptor for Age {
159    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> {
160        // Armored input is detected rather than configured: a repository that
161        // switched to `age -a` for a readable diff should not also need a code
162        // change.
163        let reader = age::armor::ArmoredReader::new(ciphertext);
164
165        let decryptor = age::Decryptor::new_buffered(reader)
166            .map_err(|error| Error::decrypt(format!("not a usable age file: {error}")))?;
167
168        let mut plaintext = decryptor
169            .decrypt(
170                self.identities
171                    .iter()
172                    .map(|identity| identity.as_ref() as _),
173            )
174            .map_err(|error| match error {
175                // Two ways of saying the same actionable thing: a key file that
176                // is not a recipient gives `NoMatchingKeys`, and a passphrase
177                // that is wrong gives `DecryptionFailed`. "Decryption failed"
178                // on its own tells nobody what to do about it.
179                age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed => {
180                    Error::decrypt(
181                        "none of the configured identities is a recipient of this file; \
182                         wrong key, or wrong passphrase",
183                    )
184                }
185                error => Error::decrypt(error.to_string()),
186            })?;
187
188        let mut bytes = Vec::new();
189
190        plaintext
191            .read_to_end(&mut bytes)
192            .map_err(|error| Error::decrypt(format!("the payload is damaged: {error}")))?;
193
194        Ok(bytes)
195    }
196
197    fn describe(&self) -> String {
198        self.described.clone()
199    }
200}
201
202/// Who may read a file this program encrypts.
203///
204/// The other half of [`Age`]. Recipients are public keys, so this holds no
205/// secret and its `Debug` says how many there are rather than which.
206///
207/// ```no_run
208/// use dynamic_config::age::Recipients;
209///
210/// // The `age1…` line `age-keygen` prints, or a `recipients.txt` of them.
211/// let recipients = Recipients::from_public_keys(["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"])?;
212/// # Ok::<(), dynamic_config::Error>(())
213/// ```
214pub struct Recipients {
215    recipients: Vec<Box<dyn age::Recipient + Send + Sync>>,
216    described: String,
217}
218
219impl Recipients {
220    /// Parses `age1…` public keys.
221    ///
222    /// Several because a file usually has to be readable by more than one
223    /// party — a deployment key and the operator who has to fix it at three in
224    /// the morning.
225    ///
226    /// # Errors
227    ///
228    /// If any of them is not an age public key, or the list is empty: a file
229    /// encrypted to nobody cannot be read by anybody.
230    pub fn from_public_keys<I, S>(keys: I) -> Result<Self, Error>
231    where
232        I: IntoIterator<Item = S>,
233        S: AsRef<str>,
234    {
235        let mut recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
236
237        for key in keys {
238            let key = key.as_ref().trim();
239
240            // A `recipients.txt` is comments and blank lines as well as keys.
241            if key.is_empty() || key.starts_with('#') {
242                continue;
243            }
244
245            let parsed: age::x25519::Recipient = key.parse().map_err(|error| {
246                Error::decrypt(format!("`{key}` is not an age recipient: {error}"))
247            })?;
248
249            recipients.push(Box::new(parsed));
250        }
251
252        if recipients.is_empty() {
253            return Err(Error::decrypt(
254                "no recipients; a file encrypted to nobody cannot be read by anybody",
255            ));
256        }
257
258        let described = format!("age, {} recipient(s)", recipients.len());
259
260        Ok(Self {
261            recipients,
262            described,
263        })
264    }
265
266    /// Reads recipients from a file, one `age1…` key per line.
267    ///
268    /// The `recipients.txt` shape `age -R` takes. Comments and blank lines are
269    /// skipped.
270    ///
271    /// # Errors
272    ///
273    /// If the file cannot be read, or holds something that is not a recipient.
274    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
275        let path = path.as_ref();
276
277        let text = std::fs::read_to_string(path)
278            .map_err(|error| Error::decrypt(format!("cannot read {}: {error}", path.display())))?;
279
280        let mut recipients = Self::from_public_keys(text.lines())?;
281        recipients.described = format!("age, recipients from {}", path.display());
282
283        Ok(recipients)
284    }
285
286    /// Encrypts to a passphrase instead of a key.
287    ///
288    /// Matches [`Age::from_passphrase`] on the reading side. Weaker than a key
289    /// and awkward to automate, which is the point: a passphrase a program can
290    /// read is a passphrase in an environment variable.
291    #[must_use]
292    pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
293        let recipient =
294            age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.into()));
295
296        Self {
297            recipients: vec![Box::new(recipient)],
298            described: "age, passphrase".to_owned(),
299        }
300    }
301}
302
303impl Encryptor for Recipients {
304    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
305        let recipients = self
306            .recipients
307            .iter()
308            .map(|recipient| recipient.as_ref() as &dyn age::Recipient);
309
310        // `age::encrypt` takes a single recipient; the multi-recipient path
311        // goes through the protocol type, which is also where the streaming
312        // writer lives.
313        let encryptor = age::Encryptor::with_recipients(recipients)
314            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
315
316        let mut ciphertext = Vec::new();
317
318        let mut writer = encryptor
319            .wrap_output(&mut ciphertext)
320            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
321
322        writer
323            .write_all(plaintext)
324            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
325
326        // Not `flush`: the age writer needs `finish` to emit the final chunk,
327        // and a file missing it decrypts as truncated rather than failing.
328        writer
329            .finish()
330            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
331
332        Ok(ciphertext)
333    }
334
335    fn describe(&self) -> String {
336        self.described.clone()
337    }
338}
339
340impl std::fmt::Debug for Recipients {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("Recipients")
343            .field("recipients", &self.recipients.len())
344            .field("from", &self.described)
345            .finish()
346    }
347}
348
349impl std::fmt::Debug for Age {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        f.debug_struct("Age")
352            .field("identities", &self.identities.len())
353            .field("from", &self.described)
354            .finish()
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    /// Encrypts with a passphrase, so the tests have real ciphertext without a
363    /// key file or a fixture that could rot.
364    fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
365        let recipient =
366            age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.to_owned()));
367
368        age::encrypt(&recipient, plaintext).expect("encrypting should succeed")
369    }
370
371    #[test]
372    fn a_file_encrypted_to_a_passphrase_comes_back() {
373        let ciphertext = encrypt(br#"{"db": {"host": "localhost"}}"#, "hunter2");
374
375        let plaintext = Age::from_passphrase("hunter2")
376            .decrypt(&ciphertext)
377            .expect("the passphrase matches");
378
379        assert_eq!(plaintext, br#"{"db": {"host": "localhost"}}"#);
380    }
381
382    #[test]
383    fn the_wrong_passphrase_says_so_rather_than_returning_rubbish() {
384        let ciphertext = encrypt(b"{}", "hunter2");
385
386        let error = Age::from_passphrase("hunter3")
387            .decrypt(&ciphertext)
388            .expect_err("the passphrase does not match");
389
390        assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
391        assert!(error.to_string().contains("recipient"), "{error}");
392    }
393
394    #[test]
395    fn something_that_is_not_an_age_file_is_a_clear_error() {
396        let error = Age::from_passphrase("hunter2")
397            .decrypt(br#"{"db": {"host": "localhost"}}"#)
398            .expect_err("plaintext is not an age file");
399
400        assert!(
401            error.to_string().contains("not a usable age file"),
402            "{error}"
403        );
404    }
405
406    #[test]
407    fn an_armored_file_is_read_without_being_told() {
408        let recipient =
409            age::scrypt::Recipient::new(age::secrecy::SecretString::from("hunter2".to_owned()));
410        let armored =
411            age::encrypt_and_armor(&recipient, b"{\"db\": {}}").expect("armoring should succeed");
412
413        assert!(
414            armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"),
415            "{armored}"
416        );
417
418        let plaintext = Age::from_passphrase("hunter2")
419            .decrypt(armored.as_bytes())
420            .expect("armor is detected, not configured");
421
422        assert_eq!(plaintext, b"{\"db\": {}}");
423    }
424
425    #[test]
426    fn an_identity_file_is_read_from_disk() {
427        let identity = age::x25519::Identity::generate();
428        let key = identity.to_string();
429
430        // A fresh directory per run: two test processes sharing a fixed name
431        // under `temp_dir()` would race on the `remove_dir_all`.
432        let directory = tempfile::tempdir().unwrap();
433
434        let path = directory.path().join("key.txt");
435        std::fs::write(
436            &path,
437            format!(
438                "# a comment\n{}\n",
439                age::secrecy::ExposeSecret::expose_secret(&key)
440            ),
441        )
442        .unwrap();
443
444        let age = Age::from_identity_file(&path).expect("the file holds one identity");
445
446        assert!(age.describe().contains("key.txt"), "{}", age.describe());
447
448        let ciphertext = age::encrypt(&identity.to_public(), b"{\"db\": {}}").unwrap();
449
450        assert_eq!(age.decrypt(&ciphertext).unwrap(), b"{\"db\": {}}");
451    }
452
453    #[test]
454    fn a_file_that_holds_no_identity_says_so() {
455        let directory = tempfile::tempdir().unwrap();
456
457        let path = directory.path().join("key.txt");
458        std::fs::write(&path, "# nothing but a comment\n").unwrap();
459
460        let error = Age::from_identity_file(&path).expect_err("there is no key in there");
461
462        assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
463    }
464
465    #[test]
466    fn a_missing_identity_file_names_itself() {
467        let error = Age::from_identity_file("/no/such/key.txt").expect_err("there is no such file");
468
469        assert!(error.to_string().contains("/no/such/key.txt"), "{error}");
470    }
471
472    #[test]
473    fn debug_never_prints_a_key() {
474        let printed = format!("{:?}", Age::from_passphrase("hunter2"));
475
476        assert!(!printed.contains("hunter2"), "{printed}");
477    }
478}