Skip to main content

dynamic_config/
decrypt.rs

1//! Config files that are encrypted on disk.
2//!
3//! A `secrets.json` in a repository is a problem everyone recognises. The usual
4//! answer is to encrypt it — `age` is the modern one — and decrypt it wherever
5//! it is used, which for a configuration file means at load time.
6//!
7//! ```text
8//! config.toml            plain, in the repository
9//! secrets.json.age       ciphertext, in the repository
10//! ```
11//!
12//! ```rust,no_run
13//! # #[cfg(feature = "age")] {
14//! # use serde::Deserialize;
15//! // Once, before anything loads. A key is a process-wide fact, so this is a
16//! // process-wide setting.
17//! dynamic_config::set_decryptor(
18//!     dynamic_config::age::Age::from_environment()?,
19//! )
20//! .ok();
21//!
22//! #[dynamic_config::dynamic_config]
23//! #[derive(Deserialize)]
24//! struct DbConfig {
25//!     host: String,
26//!     #[config(secret)]
27//!     password: String,
28//! }
29//!
30//! DbConfig::builder("db")
31//!     .file("config.toml")
32//!     .encrypted_file("secrets.json.age")
33//!     .init()?;
34//! # }
35//! # Ok::<(), Box<dyn std::error::Error>>(())
36//! ```
37//!
38//! The `.age` suffix is what marks a file as encrypted; the extension *under*
39//! it says what the plaintext is, so `secrets.json.age` is JSON. Everything
40//! else is unchanged: it is a file like any other, in the same precedence
41//! order, watched the same way, and a profile variant is
42//! `secrets.production.json.age`.
43//!
44//! # What this does not do
45//!
46//! **It does not keep secrets out of memory.** The resolved configuration holds
47//! every value, because that is what configuration *is* — a program that can
48//! use a password can read it. The decrypted text is zeroized once parsed, and
49//! `#[config(secret)]` keeps values out of logs, but neither is a claim about
50//! process memory.
51//!
52//! **It does not encrypt anything.** `save` and the last-known-good cache both
53//! write plaintext, and say so. Decryption is for the file the program *reads*.
54//!
55//! # Bringing your own scheme
56//!
57//! [`Decryptor`] is behind the `decrypt` feature, which `age` turns on for you.
58//! Enable it directly for a scheme of your own — SOPS through `sops -d`, a KMS,
59//! anything — and everything else stays as it is, including the wiping.
60//!
61//! **It is not SOPS.** SOPS encrypts values in place, leaving the keys
62//! readable, and verifies a MAC over the whole document — a format worth
63//! implementing properly or not at all. What is here instead is
64//! [`Decryptor`]: implement it, install it, and any scheme works, including
65//! shelling out to `sops -d`.
66
67use std::sync::OnceLock;
68
69use crate::error::{Error, ErrorKind};
70
71/// Turns the bytes of an encrypted config file into configuration text.
72///
73/// One implementation ships — [`age::Age`](crate::age::Age) — and this trait is
74/// why there could be others. A program using SOPS, a KMS, or an internal
75/// scheme implements this and installs it; nothing else in the crate changes.
76pub trait Decryptor: Send + Sync + 'static {
77    /// Decrypts `ciphertext` into configuration text.
78    ///
79    /// The returned bytes are parsed as UTF-8 and zeroized afterwards.
80    ///
81    /// # Errors
82    ///
83    /// Whatever going wrong looks like for this scheme. Use
84    /// [`Error::decrypt`] so the failure is categorised consistently.
85    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error>;
86
87    /// How to name this decryptor in an error.
88    fn describe(&self) -> String;
89}
90
91/// Turns configuration text into the bytes of an encrypted file.
92///
93/// The other direction from [`Decryptor`], and deliberately *not* installed
94/// process-wide: who may read a file this program writes is a decision about
95/// that write, not a property of the process. It is passed to `save_encrypted`
96/// at the call site, where somebody can see which recipients they chose.
97pub trait Encryptor: Send + Sync {
98    /// Encrypts `plaintext` into the bytes to write.
99    ///
100    /// # Errors
101    ///
102    /// Whatever going wrong looks like for this scheme. Use
103    /// [`Error::decrypt`](crate::Error::decrypt), which covers both directions.
104    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error>;
105
106    /// How to name this encryptor in an error.
107    fn describe(&self) -> String;
108}
109
110static DECRYPTOR: OnceLock<Box<dyn Decryptor>> = OnceLock::new();
111
112/// Installs the decryptor, once per process.
113///
114/// Process-wide rather than per config type, because a decryption key is a
115/// process-wide fact: the program either can read its own secrets or cannot.
116/// Two config types needing two different keys is a shape this deliberately
117/// does not model.
118///
119/// Call it before the first `init()`. An encrypted file loaded without one
120/// fails with an error saying so, rather than being silently skipped — a
121/// configuration that quietly lost its secrets is worse than one that refuses
122/// to start.
123///
124/// # Errors
125///
126/// If one is already installed. The rejected decryptor is returned rather than
127/// dropped, so a caller can tell "already set" from "failed".
128pub fn set_decryptor(decryptor: impl Decryptor) -> Result<(), Box<dyn Decryptor>> {
129    // `OnceLock::set` wants the error type to be `Debug`; a trait object is not,
130    // and requiring `Debug` of every decryptor to satisfy a `Result` would be
131    // the tail wagging the dog.
132    match DECRYPTOR.set(Box::new(decryptor)) {
133        Ok(()) => Ok(()),
134        Err(rejected) => Err(rejected),
135    }
136}
137
138/// Whether a decryptor is installed.
139pub fn has_decryptor() -> bool {
140    DECRYPTOR.get().is_some()
141}
142
143/// Decrypts `ciphertext` with the installed decryptor.
144///
145/// `path` only names the file in errors.
146pub(crate) fn decrypt(ciphertext: &[u8], path: &str) -> Result<Plaintext, Error> {
147    let Some(decryptor) = DECRYPTOR.get() else {
148        return Err(Error::new(
149            ErrorKind::Decrypt,
150            format!(
151                "{path} is encrypted and no decryptor is installed; call \
152                 `dynamic_config::set_decryptor` before loading"
153            ),
154        ));
155    };
156
157    let plaintext = decryptor
158        .decrypt(ciphertext)
159        .map_err(|error| error.prepend_key(format!("{path} ({})", decryptor.describe())))?;
160
161    Plaintext::new(plaintext, path)
162}
163
164/// Decrypted configuration text, wiped when it goes out of scope.
165///
166/// Defence in depth rather than a guarantee: the values end up in the resolved
167/// configuration regardless, because that is what configuration is. What this
168/// buys is that the *whole file* — including anything the struct does not read —
169/// does not sit in a buffer for the life of the process.
170pub(crate) struct Plaintext {
171    text: String,
172}
173
174impl Plaintext {
175    fn new(bytes: Vec<u8>, path: &str) -> Result<Self, Error> {
176        match String::from_utf8(bytes) {
177            Ok(text) => Ok(Self { text }),
178            Err(error) => {
179                let rendered = error.utf8_error().to_string();
180
181                // The failure path wipes too. `FromUtf8Error` hands the bytes
182                // back and would otherwise drop them unwiped — and decrypted
183                // secrets sitting in freed memory is the exact thing this type
184                // exists to prevent.
185                let mut bytes = error.into_bytes();
186
187                {
188                    use zeroize::Zeroize;
189
190                    bytes.zeroize();
191                }
192
193                Err(Error::new(
194                    ErrorKind::Decrypt,
195                    format!("{path}: the decrypted bytes are not UTF-8: {rendered}"),
196                ))
197            }
198        }
199    }
200
201    pub(crate) fn text(&self) -> &str {
202        &self.text
203    }
204}
205
206impl Drop for Plaintext {
207    fn drop(&mut self) {
208        use zeroize::Zeroize;
209
210        self.text.zeroize();
211    }
212}
213
214impl std::fmt::Debug for Plaintext {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        // Never the contents: this type exists because they are secret.
217        f.debug_struct("Plaintext")
218            .field("bytes", &self.text.len())
219            .finish()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn plaintext_never_prints_what_it_holds() {
229        let plaintext = Plaintext::new(b"{\"password\": \"hunter2\"}".to_vec(), "x").unwrap();
230
231        let printed = format!("{plaintext:?}");
232
233        assert!(!printed.contains("hunter2"), "{printed}");
234        assert!(
235            printed.contains("23"),
236            "the length is fine to show: {printed}"
237        );
238    }
239
240    #[test]
241    fn bytes_that_are_not_text_name_the_file() {
242        let error = Plaintext::new(vec![0xff, 0xfe], "secrets.json.age").unwrap_err();
243
244        assert_eq!(error.kind(), ErrorKind::Decrypt);
245        assert!(error.to_string().contains("secrets.json.age"), "{error}");
246    }
247
248    #[test]
249    fn an_encrypted_file_with_no_decryptor_says_what_to_call() {
250        // The static is process-wide and other tests may install one, so this
251        // asserts on the message rather than on the absence.
252        if has_decryptor() {
253            return;
254        }
255
256        let error = decrypt(b"whatever", "secrets.json.age").unwrap_err();
257
258        assert_eq!(error.kind(), ErrorKind::Decrypt);
259        assert!(error.to_string().contains("set_decryptor"), "{error}");
260    }
261}