π 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.
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.