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(mut key) = std::env::var(AGE_KEY) {
117            if !key.is_empty() {
118                let parsed = Self::from_key(&key);
119
120                // The copy of the secret key this function made is wiped
121                // before it is dropped; the identity `from_key` built keeps
122                // its own protected copy.
123                {
124                    use zeroize::Zeroize;
125
126                    key.zeroize();
127                }
128
129                return parsed;
130            }
131        }
132
133        Err(Error::decrypt(format!(
134            "no age key in the environment; set {SOPS_KEY_FILE}, {AGE_KEY_FILE} or {AGE_KEY}"
135        )))
136    }
137
138    /// Decrypts with a passphrase.
139    ///
140    /// The `age -p` form. Weaker than a key file and deliberately awkward to
141    /// automate — a passphrase a program can read is a passphrase in an
142    /// environment variable — but it is what a hand-encrypted file uses.
143    #[must_use]
144    pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
145        let identity =
146            age::scrypt::Identity::new(age::secrecy::SecretString::from(passphrase.into()));
147
148        Self {
149            identities: vec![Box::new(identity)],
150            described: "age, passphrase".to_owned(),
151        }
152    }
153
154    fn from_identities(
155        identities: Vec<Box<dyn Identity + Send + Sync>>,
156        described: String,
157    ) -> Result<Self, Error> {
158        if identities.is_empty() {
159            return Err(Error::decrypt(format!("{described}: no identities")));
160        }
161
162        Ok(Self {
163            identities,
164            described,
165        })
166    }
167}
168
169impl Decryptor for Age {
170    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> {
171        // Armored input is detected rather than configured: a repository that
172        // switched to `age -a` for a readable diff should not also need a code
173        // change.
174        let reader = age::armor::ArmoredReader::new(ciphertext);
175
176        let decryptor = age::Decryptor::new_buffered(reader)
177            .map_err(|error| Error::decrypt(format!("not a usable age file: {error}")))?;
178
179        let mut plaintext = decryptor
180            .decrypt(
181                self.identities
182                    .iter()
183                    .map(|identity| identity.as_ref() as _),
184            )
185            .map_err(|error| match error {
186                // Two ways of saying the same actionable thing: a key file that
187                // is not a recipient gives `NoMatchingKeys`, and a passphrase
188                // that is wrong gives `DecryptionFailed`. "Decryption failed"
189                // on its own tells nobody what to do about it.
190                age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed => {
191                    Error::decrypt(
192                        "none of the configured identities is a recipient of this file; \
193                         wrong key, or wrong passphrase",
194                    )
195                }
196                error => Error::decrypt(error.to_string()),
197            })?;
198
199        let mut bytes = Vec::new();
200
201        if let Err(error) = plaintext.read_to_end(&mut bytes) {
202            // A failure halfway leaves however much *did* decrypt sitting in
203            // the buffer — plaintext secrets, wiped before the buffer drops.
204            {
205                use zeroize::Zeroize;
206
207                bytes.zeroize();
208            }
209
210            return Err(Error::decrypt(format!("the payload is damaged: {error}")));
211        }
212
213        Ok(bytes)
214    }
215
216    fn describe(&self) -> String {
217        self.described.clone()
218    }
219}
220
221/// Who may read a file this program encrypts.
222///
223/// The other half of [`Age`]. Recipients are public keys, so this holds no
224/// secret and its `Debug` says how many there are rather than which.
225///
226/// ```no_run
227/// use dynamic_config::age::Recipients;
228///
229/// // The `age1…` line `age-keygen` prints, or a `recipients.txt` of them.
230/// let recipients = Recipients::from_public_keys(["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"])?;
231/// # Ok::<(), dynamic_config::Error>(())
232/// ```
233pub struct Recipients {
234    recipients: Vec<Box<dyn age::Recipient + Send + Sync>>,
235    described: String,
236}
237
238impl Recipients {
239    /// Parses `age1…` public keys.
240    ///
241    /// Several because a file usually has to be readable by more than one
242    /// party — a deployment key and the operator who has to fix it at three in
243    /// the morning.
244    ///
245    /// # Errors
246    ///
247    /// If any of them is not an age public key, or the list is empty: a file
248    /// encrypted to nobody cannot be read by anybody.
249    pub fn from_public_keys<I, S>(keys: I) -> Result<Self, Error>
250    where
251        I: IntoIterator<Item = S>,
252        S: AsRef<str>,
253    {
254        let mut recipients: Vec<Box<dyn age::Recipient + Send + Sync>> = Vec::new();
255
256        for key in keys {
257            let key = key.as_ref().trim();
258
259            // A `recipients.txt` is comments and blank lines as well as keys.
260            if key.is_empty() || key.starts_with('#') {
261                continue;
262            }
263
264            let parsed: age::x25519::Recipient = key.parse().map_err(|error| {
265                Error::decrypt(format!("`{key}` is not an age recipient: {error}"))
266            })?;
267
268            recipients.push(Box::new(parsed));
269        }
270
271        if recipients.is_empty() {
272            return Err(Error::decrypt(
273                "no recipients; a file encrypted to nobody cannot be read by anybody",
274            ));
275        }
276
277        let described = format!("age, {} recipient(s)", recipients.len());
278
279        Ok(Self {
280            recipients,
281            described,
282        })
283    }
284
285    /// Reads recipients from a file, one `age1…` key per line.
286    ///
287    /// The `recipients.txt` shape `age -R` takes. Comments and blank lines are
288    /// skipped.
289    ///
290    /// # Errors
291    ///
292    /// If the file cannot be read, or holds something that is not a recipient.
293    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
294        let path = path.as_ref();
295
296        let text = std::fs::read_to_string(path)
297            .map_err(|error| Error::decrypt(format!("cannot read {}: {error}", path.display())))?;
298
299        let mut recipients = Self::from_public_keys(text.lines())?;
300        recipients.described = format!("age, recipients from {}", path.display());
301
302        Ok(recipients)
303    }
304
305    /// Encrypts to a passphrase instead of a key.
306    ///
307    /// Matches [`Age::from_passphrase`] on the reading side. Weaker than a key
308    /// and awkward to automate, which is the point: a passphrase a program can
309    /// read is a passphrase in an environment variable.
310    #[must_use]
311    pub fn from_passphrase(passphrase: impl Into<String>) -> Self {
312        let recipient =
313            age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.into()));
314
315        Self {
316            recipients: vec![Box::new(recipient)],
317            described: "age, passphrase".to_owned(),
318        }
319    }
320}
321
322impl Encryptor for Recipients {
323    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
324        let recipients = self
325            .recipients
326            .iter()
327            .map(|recipient| recipient.as_ref() as &dyn age::Recipient);
328
329        // `age::encrypt` takes a single recipient; the multi-recipient path
330        // goes through the protocol type, which is also where the streaming
331        // writer lives.
332        let encryptor = age::Encryptor::with_recipients(recipients)
333            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
334
335        let mut ciphertext = Vec::new();
336
337        let mut writer = encryptor
338            .wrap_output(&mut ciphertext)
339            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
340
341        writer
342            .write_all(plaintext)
343            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
344
345        // Not `flush`: the age writer needs `finish` to emit the final chunk,
346        // and a file missing it decrypts as truncated rather than failing.
347        writer
348            .finish()
349            .map_err(|error| Error::decrypt(format!("encrypting failed: {error}")))?;
350
351        Ok(ciphertext)
352    }
353
354    fn describe(&self) -> String {
355        self.described.clone()
356    }
357}
358
359impl std::fmt::Debug for Recipients {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        f.debug_struct("Recipients")
362            .field("recipients", &self.recipients.len())
363            .field("from", &self.described)
364            .finish()
365    }
366}
367
368impl std::fmt::Debug for Age {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.debug_struct("Age")
371            .field("identities", &self.identities.len())
372            .field("from", &self.described)
373            .finish()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    /// Encrypts with a passphrase, so the tests have real ciphertext without a
382    /// key file or a fixture that could rot.
383    fn encrypt(plaintext: &[u8], passphrase: &str) -> Vec<u8> {
384        let recipient =
385            age::scrypt::Recipient::new(age::secrecy::SecretString::from(passphrase.to_owned()));
386
387        age::encrypt(&recipient, plaintext).expect("encrypting should succeed")
388    }
389
390    #[test]
391    fn a_file_encrypted_to_a_passphrase_comes_back() {
392        let ciphertext = encrypt(br#"{"db": {"host": "localhost"}}"#, "hunter2");
393
394        let plaintext = Age::from_passphrase("hunter2")
395            .decrypt(&ciphertext)
396            .expect("the passphrase matches");
397
398        assert_eq!(plaintext, br#"{"db": {"host": "localhost"}}"#);
399    }
400
401    #[test]
402    fn the_wrong_passphrase_says_so_rather_than_returning_rubbish() {
403        let ciphertext = encrypt(b"{}", "hunter2");
404
405        let error = Age::from_passphrase("hunter3")
406            .decrypt(&ciphertext)
407            .expect_err("the passphrase does not match");
408
409        assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
410        assert!(error.to_string().contains("recipient"), "{error}");
411    }
412
413    #[test]
414    fn something_that_is_not_an_age_file_is_a_clear_error() {
415        let error = Age::from_passphrase("hunter2")
416            .decrypt(br#"{"db": {"host": "localhost"}}"#)
417            .expect_err("plaintext is not an age file");
418
419        assert!(
420            error.to_string().contains("not a usable age file"),
421            "{error}"
422        );
423    }
424
425    #[test]
426    fn an_armored_file_is_read_without_being_told() {
427        let recipient =
428            age::scrypt::Recipient::new(age::secrecy::SecretString::from("hunter2".to_owned()));
429        let armored =
430            age::encrypt_and_armor(&recipient, b"{\"db\": {}}").expect("armoring should succeed");
431
432        assert!(
433            armored.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"),
434            "{armored}"
435        );
436
437        let plaintext = Age::from_passphrase("hunter2")
438            .decrypt(armored.as_bytes())
439            .expect("armor is detected, not configured");
440
441        assert_eq!(plaintext, b"{\"db\": {}}");
442    }
443
444    #[test]
445    fn an_identity_file_is_read_from_disk() {
446        let identity = age::x25519::Identity::generate();
447        let key = identity.to_string();
448
449        // A fresh directory per run: two test processes sharing a fixed name
450        // under `temp_dir()` would race on the `remove_dir_all`.
451        let directory = tempfile::tempdir().unwrap();
452
453        let path = directory.path().join("key.txt");
454        std::fs::write(
455            &path,
456            format!(
457                "# a comment\n{}\n",
458                age::secrecy::ExposeSecret::expose_secret(&key)
459            ),
460        )
461        .unwrap();
462
463        let age = Age::from_identity_file(&path).expect("the file holds one identity");
464
465        assert!(age.describe().contains("key.txt"), "{}", age.describe());
466
467        let ciphertext = age::encrypt(&identity.to_public(), b"{\"db\": {}}").unwrap();
468
469        assert_eq!(age.decrypt(&ciphertext).unwrap(), b"{\"db\": {}}");
470    }
471
472    #[test]
473    fn a_file_that_holds_no_identity_says_so() {
474        let directory = tempfile::tempdir().unwrap();
475
476        let path = directory.path().join("key.txt");
477        std::fs::write(&path, "# nothing but a comment\n").unwrap();
478
479        let error = Age::from_identity_file(&path).expect_err("there is no key in there");
480
481        assert_eq!(error.kind(), crate::ErrorKind::Decrypt);
482    }
483
484    #[test]
485    fn a_missing_identity_file_names_itself() {
486        let error = Age::from_identity_file("/no/such/key.txt").expect_err("there is no such file");
487
488        assert!(error.to_string().contains("/no/such/key.txt"), "{error}");
489    }
490
491    #[test]
492    fn debug_never_prints_a_key() {
493        let printed = format!("{:?}", Age::from_passphrase("hunter2"));
494
495        assert!(!printed.contains("hunter2"), "{printed}");
496    }
497}