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}