oddlama's blog

Evaluation time secrets in Nix: Importing encrypted nix files

Dealing with secret values in NixOS is probably one of the first obstacles you come across when managing a NixOS-based server. In case you are not entirely familiar with the problem, the main issue is that any values part of a configuration file will inevitably end up in the nix store under /nix/store/<hash>..., and all of these paths are world-readable.

However, there are different kinds of secret values:

  • The classical "true secrets" which might be a private key, password, API token or something similar. There already are many great solutions to manage these, either by uploading them off-channel or by storing them in an encrypted form in the nix store via agenix or sops-nix. 1
  • But then there is also personally identifiable information (PII) like MAC addresses, domain names, your home address, etc. - and what I would call "weak secrets", like a properly hashed password. I would not want to disclose this information publicly, although it is not strictly a problem to do so. And having this information in the world-readable Nix store is most likely fine, too.

Usually, we mean the former when referring to secrets, but today we will focus on the latter. One of the great things about the Nix ecosystem is that many people are willing to share their configuration files publicly on GitHub or on other git forges, which makes it so convenient to check how other people have dealt with a particular service or program. On the other hand, as soon as a module contains PII, it is often the simplest solution to just remove it from the public repository and put it into a private one. But this is not without drawbacks as you now have two git repositories for one project which need to be kept in sync.

Generally, handling PII with Nix is quite different from handling true secrets simply because we require the information at evaluation time. There just is no easy way to insert MAC addresses into systemd or udev configuration files at runtime or to remove a username from your home-manager configuration. Pretty much everything that doesn't have a passwordFile (or equivalent) option cannot be offloaded to when the system is running. We somehow have to make the information available to Nix when we evaluate the config. So let's have a look at the different ways this is commonly done and how I chose to do it.

Splitting repositories

As mentioned previously, a simple solution to that problem is to just split any sensitive information off to a second repository. Just from my experience, I would say this is the most common way to deal with it.

Some people just move all relevant files there, but of course, you can get more sophisticated and have a private/secrets.nix file containing only the sensitive stuff. It is both simple and effective but some might find that it is inconvenient to manually ensure that both repositories are in sync. If you are tracking repository commits via a lock file or flake.lock for reproducibility, you will have to make a second commit to the public repository each time.

I'm not a huge fan of having to maintain two repositories that work like a single repository, but this is of course my personal preference. If you are searching for a dead simple solution, this is it.

Using git-crypt

So the next idea that comes to mind is to use git-crypt to transparently encrypt the relevant files in the repository. The idea behind these tools is to filter files through a custom tool by utilizing .gitattributes when they are committed or checked out, which automatically en- or decrypts them accordingly. Let's say from now on we would want to encrypt everything in secrets/, then we can do:

$ git-crypt init
$ echo 'secrets/** filter=git-crypt diff=git-crypt' >> .gitattributes
$ git-crypt add-gpg-user <USER_ID>

Or after checking out the repository somewhere else:

$ git-crypt unlock

This works pretty well, but you need to remember to unlock git-crypt whenever you clone the repository, so keep that in mind if you want to build your configuration in a CI. There are also some limitations of git-crypt itself. Notably, one cannot easily delete old GPG keys or symmetric keys to rotate them. Of course, an attacker might have access to your public repository history anyway, so revoking access is mostly relevant for future secrets. Also watch out for issues with third-party git GUIs, which can leave files in an unencrypted state.

I have used git-crypt before but noticed that I wanted something more direct, that doesn't require an additional tool and where I could use age instead of GPG, which allows me to reuse the same keys as for agenix. Only later I found out about git-agecrypt (which is git-crypt with age), and might be a good compromise. Truthfully, I was also a bit worried about one day accidentally committing some stuff in an unencrypted state after a git-crypt lock - although that would've been on me.

Importing encrypted nix files

So ultimately I was searching for a way to solve the problem in Nix by using import with encrypted .nix files. Imagine import ./secrets.nix.age which magically prompts for decryption if necessary. But alas, this is impossible with pure Nix simply because decryption is fundamentally an impure operation. It always requires a secondary input (your private key or password) which obviously cannot be known or given to Nix beforehand. So the only way to achieve this is to add support to Nix itself.

Luckily, Nix does have a plugin system (kind of)! There is an option for nix called plugin-files which you can put into your nix.conf:

A list of plugin files to be loaded by Nix. Each of these files will be dlopened by Nix, allowing them to affect execution through static initialization. [...]

Trying nix-plugins

So by providing a suitable object file, we can inject code into nix. Fortunately, we don't even have to write it from scratch since there already exists a project called nix-plugins that provides a generic plugin, which:

  • Adds a new nix option called extra-builtins-file where we can specify a nix file. That file must return an attrset where each member will become a new builtin.
  • Actually, the file contains a function that returns an attrset, because we are given some extra stuff to use. Most importantly a function called exec which we may use to execute arbitrary commands.

So a dead simple extra-builtins.nix file would look like this:

_args: {
  helloWorld = "Hello World!";
}

Now we just have to define the two options plugin-files and extra-builtins-file in our nix config. Since we don't want to enable this system-wide, a much better option is to use NIX_CONFIG to define and add these on top of the system's main nix.conf for this session.

$ nix build --no-link --print-out-paths nixpkgs#nix-plugins
/nix/store/0q842xsb1yddrn8jxi2dm6njry1cxjmd-nix-plugins-14.0.0

$ export NIX_CONFIG="
plugins-files=/nix/store/0q842xsb1yddrn8jxi2dm6njry1cxjmd-nix-plugins-14.0.0/lib/nix/plugins
extra-builtins-file=/absolute/path/to/extra-builtins.nix
"
$ nix repl
nix-repl> builtins.extraBuiltins.helloWorld
"Hello World!"

Nice! But this is nothing special yet, we could have had this before. What's new is that we can access the exec function from the arguments that were given to us. This lets us run any program we like. So let's change the example to run echo instead:

{exec, ...}: {
  helloWorld = exec ["/usr/bin/env" "bash" "-c" "echo Hello World"];
}

But after restarting the repl and evaluating our builtin, we are greeted with an error:

$ nix repl
nix-repl> builtins.extraBuiltins.helloWorld
       # [...]
       (stack trace truncated; use '--show-trace' to show the full trace)
       error: undefined variable 'Hello'
       at Β«stringΒ»:1:1:
            1| Hello World
             | ^
            2|
nix-repl>

Apparently this is because the result of the exec command is directly interpreted as nix code! So exec is both executing the command and then also executing the result as nix code, which of course is very handy. To fix the error we just have to provide quotes in the output to make it valid nix code:

{exec, ...}: {
  helloWorld = exec ["/usr/bin/env" "bash" "-c" "echo '\"Hello World\"'"];
}
$ nix repl
nix-repl> builtins.extraBuiltins.helloWorld
"Hello World!"

Importing secrets with nix-plugins

Now we can actually try to create a new function that acts like import but decrypts the given file first. Since we can now execute arbitrary code as part of any nix expression, we must be very careful to not break any internal assumptions. We want to ensure that the encrypted import function is deterministic and only works when given a path primitive, so it stays pure (ignoring the impurity of the decryption).

We could now directly call age or gpg in our new builtin to perform the decryption, but since we need to provide a valid nix expression as the output, it may be a better idea to first call a wrapper script which then outputs a path to the decrypted nix file. This adds some flexibility where we can later decide to make a version that doesn't directly call import, but outputs the decrypted path instead. This may be beneficial when the decrypted file is a NixOS module which we want to pass to NixOS's imports = [ ... ];, since it can then add information about the source file in case any errors occur in that file.

Later this wrapper can also be used to locally cache the decrypted output based on the input hash, or to add an interaction-free mode for usage in a CI. But first, let's get started by defining a new builtin importEncrypted:

{exec, ...}: let
  assertMsg = pred: msg: pred || builtins.throw msg;
in {
  importEncrypted = identity: nixFile:
    assert assertMsg (builtins.isPath nixFile) "The file to decrypt must be given as a path to prevent impurity.";
      import (exec [./decrypt.sh identity nixFile]);
}

Since I'll be using age to decrypt, the function requires an identity for decryption. For me, this is just my YubiKey key grab which I use via age-plugin-yubikey but could theoretically also be an SSH key or any other age identity (you could use a TPM for example). A very simple decrypt.sh script would work like this:

#!/usr/bin/env bash
set -euo pipefail
# create a temporary file
TMP=$(mktemp --suff .nix) || { echo "Failed to create a temporary file to decrypt '$2'!" >&2; exit 1; }
# decrypt input (if this fails, the script exits with an unsuccessful code causing an evaluation error)
umask 077
rage --decrypt --identity "$1" --output "$TMP" "$2"
# print the filename of the result
echo "$TMP"

This will try to decrypt whatever file is given with the provided identity, place the cleartext result in a new .nix file in /tmp and return the path to that file. This file is then imported by our builtin and so we can now finally import encrypted files:

$ nix repl
nix-repl> secrets = builtins.extraBuiltins.importEncrypted ./yubikey-keygrab.txt ./secrets.nix.age;
# <I'm prompted to unlock my YubiKey>
nix-repl> :p secrets
{ home = { latitude = "22.4438556"; longitude = "-74.2203343"; }; }

So we now can decrypt secrets at evaluation time in nix! Unfortunately, it's mildly inconvenient to enter a PIN or password each time I want to build my config. Since we are only dealing with PII and not true secrets, I don't mind having the decrypted content around on my machine for a longer time. So I extended the wrapper script to cache the output by using a deterministic path based on the sha512sum of the encrypted content. It certainly makes my life a lot easier, but if your threat model is different you can of course leave the script as-is.

The cache can even live outside of the repository, meaning I don't have to worry about adding the decrypted files to git accidentally - I settled on /var/tmp so it persists across reboots. If you want to have a look at the extended script, you can view it here in my nix-config repository.

Automated setup in a devShell

There is one additional caveat which I ignored previously that we have to address. Using nix-plugins requires a matching version of Nix to work, since it links against the internal nix functions and these change frequently with new versions. A mismatch between the two programs may cause an error when calling nix:

error: could not dynamically open plugin file '[...]/lib/nix/plugins/libnix-extra-builtins.so':
undefined symbol: _ZN3nix17prim_importNativeERNS_9EvalStateERKNS_3PosEPPNS_5ValueERS5

A pretty simple solution to this is to use both nix and nix-plugins from the same version of nixpkgs, which is trivial when you have a devshell in use already. And while we're at it we can also use it to export the NIX_PLUGINS. This means we now just have to enter the devshell with nix-shell shell.nix (or nix develop if you are using flakes) and everything will be set up automatically. Here's an example shell:

{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell {
  nativeBuildInputs = [
    # Make sure to grab nix to match nix-plugins below
    pkgs.nix
    # Anything required for the decryption script
    pkgs.age
    # Your other stuff ...
  ];

  # We use ${./.} here to get a stable path to our extra-builtins.nix file potentially from
  # the nix store, which will allow this to be used with pure evaluation (default for flakes).
  shellHook = ''
    export NIX_CONFIG="
      plugin-files = ${pkgs.nix-plugins}/lib/nix/plugins
      extra-builtins-file = ${./.}/extra-builtins.nix
    "
  '';
}

Conclusion

We've seen different ways to manage private information in a nix repository, from a simple split-repository over git-crypt to our custom solution. We learned about Nix's plugin system and the related nix-plugins project which allowed us to define our own importEncrypted function that we can now use directly from nix.

Is the final solution better than the alternatives? Probably not, but I would say it isn't too bad either. I definitely had a lot of fun tinkering around and was able to make something that I like using. In any case, I hope you enjoyed it and maybe learned something interesting today.


1

Regarding true secrets, there is a long-standing issue to add first-class support for private files to Nix, which could simplify some things, but for now, we have to make do without it. And even if there was support to have private files in the nix store, it would not directly solve all problems. Many people first build their configuration on another machine and then deploy it to a remote server from there. In this case, the build machine will also get a copy of the secret placed in its store, which you likely want to avoid. So it won't be a universal solution.