secrets¶
Modules: system/core/secrets.nix,
scripts/sync-secrets.sh, .sops.yaml
Bitwarden is the source of truth, sops is the vault, and the repo never holds a credential (rule 12).
How the two layers fit¶
The PUBLIC index secrets/bitwarden-secrets.json maps
name-in-sops to item-in-Bitwarden. It is not a secret, so it goes into git. From it:
- Nix GENERATES the
sops.secrets.<name>entries on its own, so no declaring one by hand again. sync-secretspulls the values from Bitwarden and writes them ENCRYPTED intosecrets.yaml(throughsops set), which is what keeps the rebuild PURE, with no--impure. It writes only what is NEW: a key whose value DIFFERS from the vault's is listed and the run stops, so overwriting one is a decision (--yes, or answering the prompt) and never a side effect.
Adding a secret: register it in Bitwarden, add 1 line to the JSON, run sync-secrets, then
nixos-rebuild switch. sync-secrets needs sops on the PATH and it is in NO profile: it comes
from the devShell, which direnv enters on a cd into the repo. Outside it, the run dies partway
through, so the script checks up front; from a bare shell use
nix shell nixpkgs#sops -c ./scripts/sync-secrets.sh. Secrets that do NOT come from Bitwarden (the user's password hash, for
instance) stay declared by hand.
The order matters. Entering the index makes sops DECLARE the secret, and a declared secret
whose key is not in secrets.yaml yet passes the BUILD and breaks at ACTIVATION with
secret does not exist, taking the switch down. That is why the conditional declarations exist:
the module stays inert until sync-secrets has run.
Two recipients, on purpose¶
The public keys in .sops.yaml are who CAN decrypt; each private half lives outside git.
| Recipient | Where the private key is | Why it exists |
|---|---|---|
| host | /var/lib/sops-nix/key.txt on the machine |
what sops-nix uses at boot to populate /run/secrets, and what you carry across a cutover |
| backup | offline, generated 04/08/2026 | a SINGLE recipient means IRREVERSIBLE loss of every secret the day that key disappears |
sops has no recovery. Before the offline key, the only backup of the host key was Bitwarden, which made Bitwarden the SPOF of everything. With two, losing one is an annoyance; losing both is the disaster.
The host anchor said nixos_seagate until 04/08/2026: the key was BORN on that host and was
carried in the cutover to the Kingston (01/08/2026), so it is the same key under an old name.
creation_rules only applies to a NEW file. Adding a recipient does NOT re-encrypt what
already exists, so without running sops updatekeys secrets/secrets.yaml the new key decrypts
nothing and the backup is imaginary.
The regex only catches secrets/*.yaml. bitwarden-secrets.json is plain text ON PURPOSE, but a
.json that some day holds a secret would NOT be encrypted by that rule.
Who can read what, and why some secrets are user-owned¶
Most secrets are root-only. These are not, and each has a reason:
| Secret | Owner | Why |
|---|---|---|
rclone_gdrive_conf |
v1cferr 0400 | the ~/Drive mount is a --user service and has to read it without sudo |
restic_password, restic_password_arch_kingston |
v1cferr 0400 | restic mount is only browsable by WHOEVER MOUNTED IT |
deepl_api_key, ntfy_topic |
v1cferr 0400 | consumed by user tooling and --user timers |
The restic one is the least obvious and the most defensible: a FUSE mount is private by default,
which this config already proved inside out, since restic as ROOT could not even lstat the
USER's FUSE mount at ~/FAI-workstation. Mounting with sudo gives a folder Dolphin does not open,
so mounting as the user requires reading the password without sudo. It is not privilege
escalation: it is the backup password for THAT SAME USER'S data, and whoever already is v1cferr
has the original files.
rclone_gdrive_conf stays OUT of Bitwarden on purpose: it is MULTILINE and sync-secrets does a
sops set with single-line JSON, which would break it. And unlike the restic password, the OAuth
token is regenerable, so it does not need the vault.
Editing by hand¶
The obvious command does NOT work, and it was written down wrong in this repo until
16/08/2026, in .sops.yaml's header and then here. sops secrets/secrets.yaml on its own fails
with Failed to get the data key required to decrypt the SOPS file, listing every default
location it searched. None of them is the right one: the age key belongs to ROOT and lives at
/var/lib/sops-nix/key.txt, which sops does not look in.
cd ~/Projects/GitHub/v1cferr/dotfiles
sudo EDITOR=nano SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix shell nixpkgs#sops -c sops secrets/secrets.yaml
# sops rewrote the file AS ROOT, and sync-secrets runs as the user:
sudo chown v1cferr:users secrets/secrets.yaml && chmod 644 secrets/secrets.yaml
# WITHOUT THIS the services keep the OLD value:
sudo nixos-rebuild switch --flake .#nixos-kingston
Three things that each cost a failed attempt:
SOPS_AGE_KEY_FILEis not optional. Without it the error names eight locations it tried and none is/var/lib/sops-nix/key.txt, so it reads like a corrupted vault when it is only a key sops was never told about.- The
chownafterwards. The file isv1cferr:usersat 0644 precisely sosync-secretscan write to it; editing as root flips it and the next sync fails. - The
rebuildis MANDATORY. sops-nix only decrypts into/run/secretsat ACTIVATION, so saving the yaml changes nothing that is running. Restart the affected service too. - A hand edit is UNDONE by the next
sync-secrets. The sync writes vault to file and never the reverse, so a value changed only here goes back to Bitwarden's on the next run, whatever that run was actually FOR. It killed the FAI VPN on 18/08/2026: the new AD password went into sops on 12/08 and never into the vault, a sync on 17/08 meant only to addntfy_topicreverted it as a side effect, and nothing broke until the next boot renewed/run/secrets, six days after the edit that doomed it. Change it IN BITWARDEN and sync; hand-edit only what the vault does not hold.
For updatekeys (after adding a recipient) the same environment applies:
sudo EDITOR=nano SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix shell nixpkgs#sops -c sops updatekeys -y secrets/secrets.yaml
sync-secrets does NOT replace this: it runs sops set on the EXISTING file, which already
requires a key that decrypts.
Two recipients, and the updatekeys trap¶
.sops.yaml lists the PUBLIC keys of who CAN decrypt; each one's private half lives OUTSIDE git.
There are TWO on purpose:
- host:
/var/lib/sops-nix/key.txton the machine. It is what sops-nix uses at boot to populate/run/secrets, and what you carry across a cutover. The anchor saidnixos_seagateuntil 04/08/2026, because the key was BORN on that host and was carried in the cutover to the Kingston (01/08/2026): the same key, a new host, an old name. - backup: an OFFLINE key (generated 04/08/2026) that does NOT sit on a machine nor in the cloud in the clear. It exists because a SINGLE recipient means IRREVERSIBLE loss of every secret in the repo the day that key disappears, since sops has no recovery and its only backup was Bitwarden, the SPOF of everything. With two, losing one is an annoyance; losing both is the disaster.
The commands are in Editing by hand above.
The index and the vault are TWO declarations¶
bitwarden-secrets.json is not just a lookup table: system/core/secrets.nix turns every key of
it into a sops.secrets.<name> through lib.mapAttrs. So the index DECLARES the secret and the
yaml holds the VALUE, and a secret can be declared twice over when it also has a hand-written
override for owner/mode.
Removing a secret therefore takes three deletions, and doing one of them alone fails at
nixos-rebuild with sops-install-secrets: the key '<name>' cannot be found. dead-config's
sixth check catches that mismatch at commit time instead; see
dead-config.md.
WARNING: creation_rules only applies to a NEW file. Adding a recipient does NOT re-encrypt
what already exists, so without running updatekeys the new key decrypts nothing and the backup is
imaginary.
And the regex only catches secrets/*.yaml. bitwarden-secrets.json is plain text ON PURPOSE (a
name-in-sops to item-in-Bitwarden map, with no credential), but a .json that some day holds a
secret would NOT be encrypted by this rule.