dotfiles
Repo

secrets

Edit on GitHub

Modules: modules/nixos/core/secrets.nix, modules/nixos/core/sync-secrets.sh, secrets/.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 is a system command built from modules/nixos/core/sync-secrets.sh, and its build puts sops, bw, jq and git on its own PATH, so it runs from any shell. 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 secrets/.sops.yaml are who CAN decrypt; each private half lives outside git.

RecipientWhere the private key isWhy it exists
host/var/lib/sops-nix/key.txt on the machinewhat sops-nix uses at boot to populate /run/secrets, and what you carry across a cutover
backupoffline, generated 04/08/2026a 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:

SecretOwnerWhy
rclone_gdrive_confv1cferr 0400the ~/Drive mount is a --user service and has to read it without sudo
restic_password, restic_password_arch_kingstonv1cferr 0400restic mount is only browsable by WHOEVER MOUNTED IT
ntfy_topicv1cferr 0400consumed by user tooling and --user timers
gmail_app_passwordv1cferr 0400git send-email runs as the user; a credential helper in modules/home/shell/git.nix reads it
soloist_api_keyv1cferr 0400, as the soloist.env templateSoloist is a --user service; the EnvironmentFile keeps it out of the store, and systemd expands it into --api-key, the only way the binary takes it
home_assistant_tokenv1cferr 0400a long-lived token for the Home Assistant API, used by the home-automation repo's tooling as the user; an admin token, so it never goes into a flag or an environment that ps shows
openrouter_api_keyv1cferr 0400opencode reads it through {file:} in its generated config, and the claude wrapper exports it for the OpenRouter MCP's Bearer header, so the key never reaches the store

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.

Home secrets: the three kinds

Decided in ADR 0016: Bitwarden is the single source for the home too, and the kind of secret decides where it lives.

KindExamplesWhere it lives
config: the machine needs the valueAPI tokens, VPN passwords, the Drive remoteBitwarden, then this pipeline, then /run/secrets
session: an app logs in and refreshes itgh (in the keyring), vercel, azure, docker, browsersthe app's state, kept by my.persist; never copied into sops
cold: recovery material nothing readsthe age key backup, recovery codes, old WireGuard and router configsonly in Bitwarden (secure note or attachment), no copy in ~

.google_authenticator is the exception that stays a file: the PAM module rewrites it, so it cannot live in the read-only /run/secrets. Bitwarden keeps a copy as a cold secret.

Editing by hand

The obvious command does NOT work, and it was written down wrong in this repo until 16/08/2026, in secrets/.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 .#ex-b560m-v5

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, plus the rules' path: updatekeys is the one command here that READS the rules, they live in secrets/.sops.yaml since 01/10/2026, and sudo drops the devShell's SOPS_CONFIG, so the flag carries it:

sudo EDITOR=nano SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
  nix shell nixpkgs#sops -c sops --config secrets/.sops.yaml 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

secrets/.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: modules/nixos/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.