← Back

Managing secrets with agenix

2021-07-27

It’s considered bad practice to put sensitive information into your Nix configuration unencrypted. The primary concern is that anything Nix touches ends up in the Nix store, and the store is readable to every process and user on your computer. The second concern is that your configuration files themselves are now sensitive, so you can’t share them on GitHub or other public channels. So, how do you effectively manage your secrets? There are actually many proposed solutions to this issue, but I have generally found them to not be worth the hassle, and instead just always set them up manually. That is, until I discovered agenix.

agenix requires almost no setup or overhead, and yet works seamlessly across multiple machines and users. We use it at $work, I use it in my personal config, and I have been very happy with it.

Unfortunately, I find that the manual makes agenix seem more complicated than it is, and doesn’t really highlight what makes it so nice to use. So, here is more evangelism for niche technologies a brief, opinionated guide to agenix.

NixOS configuration

Start by importing the agenix module into your system configuration. The most straightforward way is just importing the tarball directly1, but you can of course use your preferred method of dependency management here (niv, flakes, channels, etc.).

Now, for every secret, you pass it an age-encrypted file as an argument, you ask (or specify) where the decrypted file goes, and then at boot time the file gets decrypted and put in the promised location. That’s it.

Example

As an example, let’s say we’re setting up an OpenVPN configuration:

{
  # Option 1
  services.openvpn.myServer.config = ''
    auth-user-pass path/to/credentials.txt
  '';
  # Option 2
  services.openvpn.myServer.config = ''
    auth-user-pass ${./path/to/credentials.txt}
  '';
}

Option 1 avoids the nix store, but it is not declarative; we need to manually make sure that the path/to/credentials.txt actually exists.

With option 2, we have nix manage the file for us, but now it ends up in the nix store, making it world-readable.

With agenix, we get the best of both worlds:

{
  age.secrets.myServerCredentials.file = path/to/credentials.age;
  services.openvpn.myServer.config = ''
    auth-user-pass ${config.age.secrets.myServerCredentials.path}
  '';
}

The only thing nix ever sees is the encrypted .age file, so no unencrypted secrets will touch the nix store. By default, config.age.secrets.myServerCredentials.path will evaluate to /run/secrets/myServerCredentials. During early boot our file gets decrypted and put there.

That’s all there is to it in terms of configuration, but it leaves questions like how you actually encrypt your file, or how the system decrypts the file.

Working with secrets

agenix is powered by age. age doesn’t have many features2, but it does have two that make it ideal for this use case: multiple recipients, and encryption using SSH keys.

You have an SSH key, and your systems each have SSH keys34. Yours is the one typically found in ~/.ssh/, and the one for your system is in /etc/ssh/ (or reported by ssh-keyscan localhost). This is perfect because (again) age can use SSH keys for encryption, and more importantly these are exactly the recipients of secrets: you, because you want to view/edit secrets, and your systems, because they need to decrypt them at boot. In other words, all the cryptographic infrastructure agenix uses is already in place! This is why there was so little configuration required in the previous step, and fortunately this also extends to actually setting up/working with our secrets.

Let’s see how we actually encrypt a file.

  1. Start by making a folder in your nix config called secrets. This folder will contain your secrets, as well as a secrets.nix file.

  2. In the secrets folder, create your secrets.nix file. secrets.nix determines what files are encrypted with what keys. While secrets.nix is a nix file, it is not in any way actually part of the rest of your nix configuration, you never import it anywhere. It is only used when encrypting secrets (which we will do in the next step), it dictates what files can be viewed with what public keys. This is the example secrets.nix file from the tutorial:

let
  user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH";
  user2 = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCoQ9S7V+CufAgwoehnf2TqsJ9LTsu8pUA3FgpS2mdVwcMcTs++8P5sQcXHLtDmNLpWN4k7NQgxaY1oXy5e25x/4VhXaJXWEt3luSw+Phv/PB2+aGLvqCUirsLTAD2r7ieMhd/pcVf/HlhNUQgnO1mupdbDyqZoGD/uCcJiYav8i/V7nJWJouHA8yq31XS2yqXp9m3VC7UZZHzUsVJA9Us5YqF0hKYeaGruIHR2bwoDF9ZFMss5t6/pzxMljU/ccYwvvRDdI7WX4o4+zLuZ6RWvsU6LGbbb0pQdB72tlV41fSefwFsk4JRdKbyV3Xjf25pV4IXOTcqhy+4JTB/jXxrF";
  users = [ user1 user2 ];

  system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE";
  system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1";
  systems = [ system1 system2 ];
in
{
  "secret1.age".publicKeys = [ user1 system1 ];
  "secret2.age".publicKeys = users ++ systems;
}
  1. Use agenix -e <filename> to edit files. One thing to note is that the system module doesn’t install the agenix executable – you still need to get that somewhere. In my flake-based config it’s just
{ environment.systemPackages = [ inputs.agenix.defaultPackage."${system}" ]; }

That’s it! You now have secure declarative management of secrets. The only other thing to be aware of is that you need to rekey your secrets if you change what files have access to them, which is a matter of running agenix --rekey.

Caveats

Configuration

One thing to watch out for is that incorporating your secrets into your config is not always as easy as it is in the OpenVPN example above. You might want to use secrets for options that were not designed to be kept secret, or use agenix for things that aren’t configured through nix at all.

Two examples from my own config:

  1. I want to add secret binary caches, but the nix.binaryCaches option only takes strings, it does not take file paths. That means there is no way of using the option without it ending up in the nix store. So, I sidestep the usual cache declaration mechanism and go through nix.extraOptions and the new !include directive.

  2. AWS credentials aren’t configured through nix, it relies on a file being present in a designated location in your root/user folder. So, we just override the target path so it writes to /root/.aws/credentials directly.

The takeaway is that sometimes it just requires some creativity, but it is not unimaginable that you have secrets for which agenix just does not work out. As far as I know this is mostly just an issue with nix itself, and applies to all secret management solutions.

Some thoughts on security

I am not a security expert, the author of agenix is not a security expert, so if you’re like me, you may be hesitant of trusting agenix with your secrets. I’m not here to convince you of anything, but here are some findings that I personally found convincing.

First of all, the author of age itself is a security expert. I found some discussion of potential attack vectors against age, but based on my understanding of the underlying reasoning, this would not apply to agenix since we’re not dealing with not high-volume network traffic all encrypted with the same key.

Second, you can very easily audit agenix yourself, it’s just 150 lines of pretty understandable bash in the encryption code, and another 100 in the nix module.

For what it’s worth, I trust it enough to make my encrypted secrets public and then brag about it in a blog post, so at least if something breaks we’re both screwed.


  1. { imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/master.tar.gz"}/modules/age" ]; }
    ↩︎
  2. A good thing for a cryptographic application!↩︎

  3. Provided you have ssh, i.e. services.openssh, enabled. If not, you manually have to specify age.sshKeyPaths.↩︎

  4. More info on the distinction between user and system keys here.↩︎

← Back