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