Why Secrets Are Hard with Declarative Config
Why Secrets Are Hard with Declarative Config
Nix wants every input in the expression. Attackers want every input too. The boring default is: treat /nix/store as public, never put secret bytes in Nix, and decrypt only onto /run at boot.
Mental model
/nix/store items are world-readable. Binary caches replicate them. CI logs echo eval results. A private git repo does not make a string in configuration.nix private — the next nix copy to a cache, a coworker, or a backup disk copies the secret.
| Safe in the flake | Never in the flake |
|---|---|
Path /run/secrets/db |
Password string |
| Age public keys | Age private keys |
| sops file names | Decrypted YAML |
hashedPasswordFile = "/persist/…" |
initialPassword = "hunter2" |
Builds are sandboxed: they cannot prompt you. If a derivation “needs” the production DB password, the design is wrong. Fetch private source with access-tokens. Fetch private runtime secrets on the machine that runs the unit.
git → encrypted blob → eval knows the path
boot → decrypt to tmpfs /run/secrets → unit EnvironmentFile
Worked examples
Case 1: Prove the leak
Save as leak.nix:
# leak.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.writeText "database.conf" ''
password = SuperSecretPassword123
''nix-build leak.nix
cat result
stat -c '%a %n' "$(readlink -f result)"Output:
password = SuperSecretPassword123
444 /nix/store/…-database.conf
Mode r--r--r--. Any local uid can cat it. nix copy --to ssh://untrusted sends it off-box.
Case 2: builtins.readFile is the same leak
Save as read.nix:
# read.nix
builtins.readFile ./prod.passwordecho 'hunter2' > prod.password
nix-instantiate --eval read.nixOutput:
"hunter2\n"
The string is now in the eval. If a module interpolates it into a unit, it is in the store. Use readFile only with public files (a banner, a JSON schema).
Case 3: Reference a runtime path
Save as safe.nix:
# safe.nix
{ config, pkgs, ... }:
{
systemd.services.desk-api = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.hello}/bin/hello";
EnvironmentFile = "/run/secrets/desk-api.env";
DynamicUser = true;
};
};
}Eval contains the path. The file is created at boot by sops-nix / agenix / Vault agent. Check:
nix why-depends /run/current-system /nix/store/…-database.conf || echo 'not in the OS closure (good)'ExecStart=… --token hunter2 is the same leak as Case 1. EnvironmentFile / LoadCredential are the boring APIs.
Case 4: Hashes versus plaintext
hashedPassword = "$6$…" is still in the store. It is an offline crack target, not a live password. Prefer hashedPasswordFile on a 0400 persist path. Never initialPassword. Same for users.users.root.hashedPassword.
# hash-ok-ish.nix
{
users.users.deskadmin.hashedPasswordFile = "/persist/secrets/deskadmin.hash";
}sudo install -m 0400 /dev/null /persist/secrets/deskadmin.hash
# write the mkpasswd hash there; not into gitCase 5: Hunt the closure
# after a switch, on a lab VM — dummy strings only
sudo grep -R "SuperSecret" /nix/store 2>/dev/null | headIf grep hits, that generation is tainted. Roll back, rotate, GC, and if you pushed a cache, consider the secret public. nix flake check must not need production secret values.
nix why-depends /run/current-system /nix/store/…-database.confA hit means the OS closure still references the leak. Deleting the file from git is not enough until a new generation GC’s that path — and every cache that already copied it.
The trap
The trap is private GitHub = safe. Clones, Actions artifacts, nix copy to the team cache, and a laptop left at security all see the store. Encrypt in git (sops/age) so the repo is safe to copy. Decrypt on the host.
The other trap is putting the sops private key in the flake so CI can decrypt. Then CI logs are the leak. CI uses a runner identity; laptops use laptop keys.
A third: systemd.services.*.environment.DB_PASSWORD = "…" or extraConfig interpolating builtins.readFile. LoadCredential / EnvironmentFile of a path is the API.
The boring rule
- Store is public. Write configs as if a stranger can
catevery NAR. - Paths in Nix. Bytes on
/run(tmpfs) or a0400persist file. - No
readFileof secrets. No secret strings inenvironmentor argv. - After a leak: rotate, then assume copies exist (cache, clones, backups).
nix flake checkmust not need production secret values.
Try this
- Case 1, then
stat -c '%a %n' $(readlink -f result)— expect444. - Put a dummy token in a
.nixfile,nixos-rebuild dry-build,grepthe built unit in the store. Remove it. git grep -nE 'initialPassword|privateKey =|password =' -- '*.nix'on the desk repo.- Confirm
/run/secretsis tmpfs (findmnt /run) on a sops-enabled host.