Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Encrypted config files

A secrets.json in a repository is a problem everyone recognises. Encrypt it with age and it decrypts at load time:

config.toml            plain, in the repository
secrets.json.age       ciphertext, in the repository
#![allow(unused)]
fn main() {
// Once, before anything loads. A key is a process-wide fact, so this is a
// process-wide setting.
dynamic_config::set_decryptor(dynamic_config::age::Age::from_environment()?)?;

#[dynamic_config(files = ["config.toml", "secrets.json.age"], key = "db")]
#[derive(Deserialize)]
struct DbConfig {
    host: String,
    #[config(secret)]
    password: String,
}
}

The .age suffix marks the file as encrypted; the extension under it says what the plaintext is, so secrets.json.age is JSON. Everything else is unchanged — same precedence, same profile variants (secrets.production.json.age), watched the same way, skipped if it is not there, and a value traced back to it names the file rather than "an inline source".

The key comes from SOPS_AGE_KEY_FILE, AGE_IDENTITY_FILE or AGE_SECRET_KEY, in that order — the SOPS variable first, because a machine set up for SOPS already has it. Age::from_identity_file, Age::from_key and Age::from_passphrase name one explicitly. Both binary and armored files are read without being told which.

A file this key cannot open is an error naming the file, not a file quietly skipped: a configuration that silently lost its secrets is worse than one that refuses to start.

What it does not do

It does not keep secrets out of memory. The resolved configuration holds every value, because that is what configuration is — a program that can use a password can read it. The decrypted text is zeroized once parsed, and #[config(secret)] keeps values out of logs, but neither is a claim about process memory.

The cache is still plaintext. save has an encrypting counterpart — save_encrypted, taking the recipients at the call site, because who may read this file is a decision about that write rather than a property of the process. The last-known-good cache writes plaintext and says so.

It is not SOPS. SOPS encrypts values in place and verifies a MAC over the document — a format worth implementing properly or not at all. What is here instead is the Decryptor trait: implement it, install it, and any scheme works, including shelling out to sops -d.

#![allow(unused)]
fn main() {
impl Decryptor for MyScheme {
    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, Error> { .. }
    fn describe(&self) -> String { "my-kms".to_owned() }
}
}