Skip to content

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:

  1. Nix GENERATES the sops.secrets.<name> entries on its own, so no declaring one by hand again.
  2. sync-secrets pulls the values from Bitwarden and writes them ENCRYPTED into secrets.yaml (through sops 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_FILE is 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 chown afterwards. The file is v1cferr:users at 0644 precisely so sync-secrets can write to it; editing as root flips it and the next sync fails.
  • The rebuild is MANDATORY. sops-nix only decrypts into /run/secrets at 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 add ntfy_topic reverted 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.txt on the machine. It is what sops-nix uses at boot to populate /run/secrets, and what you carry across a cutover. The anchor said nixos_seagate until 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.