# Spec: Remove Sensitive Information from NixOS Flake ## Goal Every secret currently readable in plaintext anywhere in this repo (working tree *and* git history) gets removed, replaced with `sops-nix`-managed encrypted references, and rotated. When this is done, the repo should be safe to make public without exposing anything about the systems it configures. Treat this as three sequential milestones. Do not start git history rewriting (Milestone 3) until Milestones 1 and 2 are fully verified and the flake still builds. This should be its own branch (`refactor/secrets`) until fully verified, then merged. --- ## Milestone 1 — Audit Before touching anything, produce a complete inventory. Do not guess at scope — grep the whole tree and the whole history. 1. Run a secret scanner across the working tree and full history. Use both, since they catch different things: - `gitleaks detect --source . -v --log-opts="--all"` (scans history too) - `trufflehog git file://. --since-commit=$(git rev-list --max-parents=0 HEAD) --only-verified=false` If neither is installed, add them via a temporary `nix-shell -p gitleaks trufflehog` — don't install anything globally on the host. 2. Manually grep for the categories below, since scanners miss config-specific patterns: - `hashedPassword`, `password`, `initialPassword`, `initialHashedPassword` in any `users.users.*` block - `age.secrets`, `sops.secrets` (if any partial secrets work already exists — check for it) - PSK / `preSharedKey`, `privateKeyFile` inline values (vs. file references) for WireGuard - `authKey`, `apiToken`, `api_key`, `token =`, `secret =` in service modules (Tailscale, Cloudflare, backup tools, etc.) - SSH private key material: search for `BEGIN OPENSSH PRIVATE KEY` / `BEGIN RSA PRIVATE KEY` literals - TLS cert/key pairs committed under e.g. `secrets/`, `certs/`, `pki/` - Real name, personal email, home address, or anything in comments/hostnames that maps a machine to your physical identity or network layout (e.g. hostnames like `wayne-desktop`, static LAN IPs, ISP-identifying info) - `.env` files, `secrets.nix`, `secrets.yaml`, or any file that looks like it was meant to be gitignored but wasn't 3. Produce `secrets-inventory.md` (temporary, delete before finishing) listing: file path, line, secret type, and which host/service it belongs to. This becomes the checklist for Milestone 2 — every row must be either migrated to sops or deleted, with nothing left unaccounted for. --- ## Milestone 2 — Migrate to sops-nix ### 2.1 Set up sops-nix 1. Add the flake input: ```nix sops-nix.url = "github:Mic92/sops-nix"; sops-nix.inputs.nixpkgs.follows = "nixpkgs"; ``` 2. Import `sops-nix.nixosModules.sops` into each host's module list (or into a shared `common.nix` if all hosts use it). 3. Generate an age keypair **per host** (not one shared key for everything — a compromised host shouldn't decrypt every other host's secrets): ``` nix-shell -p age --run "age-keygen -o /var/lib/sops-nix/key.txt" ``` Print the public key (`age-keygen -y`) for each host — you'll need it for `.sops.yaml`. 4. Also generate one age key for yourself (your admin workstation) so you can edit secrets without needing to SSH into a host: store it at `~/.config/sops/age/keys.txt`, back it up somewhere outside this repo (password manager, offline). **If this key is lost, every secret encrypted with it is unrecoverable — losing the age key is equivalent to losing the secrets.** 5. Create `.sops.yaml` at the repo root defining creation rules: which age public keys can decrypt which secrets files, keyed by path regex, so e.g. `secrets/hostA.yaml` is decryptable by your admin key + hostA's key, `secrets/hostB.yaml` by your admin key + hostB's key. ### 2.2 Migrate each secret category from the inventory For each row in `secrets-inventory.md`: - **Password hashes**: generate hash with `mkpasswd -m sha-512` (or `bcrypt` if your setup wants that), store under `sops.secrets."/hashedPassword"`, reference via `users.users..hashedPasswordFile = config.sops.secrets."/hashedPassword".path;`. Do not put the *plaintext* password anywhere, only the hash, and only the hash goes into the encrypted sops file. - **API tokens / auth keys**: move the raw value into the per-host sops YAML, reference in the module via `config.sops.secrets."/token".path` — most NixOS service modules that take a token also accept a `*File` variant (e.g. `environmentFile`, `tokenFile`); use that instead of passing the value directly. - **Private keys / certs**: move the PEM/key content wholesale into a sops secret, output as a file with appropriate `sops.secrets..path`, `owner`, `mode`, `restartUnits` so the depending service (sshd, wireguard, nginx) reloads when the secret changes. - **Personal/identifying info**: this doesn't belong in sops (it's not "secret," it's just information you don't want public). Replace real names/emails with placeholders or move to a small untracked `local.nix` that's `.gitignore`'d and imported conditionally, with a documented template (`local.nix.example`) committed instead. ### 2.3 Verify before moving on - `nixos-rebuild dry-build --flake .#` succeeds for every host. - `sudo nixos-rebuild switch --flake .#` on at least one real machine (or a VM) confirms secrets decrypt and services start. - Confirm decrypted secrets land under `/run/secrets/` (not the Nix store — anything placed in `/nix/store` is world-readable by design, so sops-nix's runtime-only placement is the whole point; double check no module accidentally pulls a secret path into a store-built config file). - Re-run the grep/scanner sweep from Milestone 1 against the *working tree only* (not history yet) — it should now come back clean. --- ## Milestone 3 — Scrub git history Do this only after Milestone 2 is merged to your main branch and confirmed working, since it rewrites every commit SHA from the point of the earliest offending commit onward. **This is destructive and irreversible on your local clone. Back up first:** ``` cp -r /path/to/nixos-repo /path/to/nixos-repo-backup-$(date +%F) ``` 1. Install `git-filter-repo` (not the older `git filter-branch` / BFG — filter-repo is the currently maintained, faster, safer tool): ``` nix-shell -p git-filter-repo ``` 2. Use the `secrets-inventory.md` list to build a list of literal strings/paths to strip. Two approaches, use both: - Path-based: if whole files were secret (e.g. `secrets.nix`, a `.env`, a private key file), remove them entirely from history: ``` git filter-repo --path secrets.nix --path .env --invert-paths ``` - Value-based: for secrets embedded inline in files you're keeping (not deleting the whole file), use `--replace-text` with a file listing each literal secret string to replace with `***REMOVED***`: ``` git filter-repo --replace-text expressions.txt ``` 3. After filtering, verify: run the Milestone 1 scanners again against full history (`--log-opts="--all"`). They must come back clean. 4. Force-push the rewritten history: ``` git push origin --force --all git push origin --force --tags ``` 5. **Every other clone of this repo (other machines, WSL instances, CI) must be deleted and re-cloned fresh** — a `git pull` against rewritten history will not work cleanly and risks resurrecting the old commits. Don't try to reconcile old clones; throw them away and re-clone. 6. If this repo has ever been pushed to a public host (GitHub, etc.) or a fork/mirror exists, treat every secret that was ever in history as **permanently compromised regardless of the rewrite** — caches, forks, and Wayback-style archives can retain old commits indefinitely. History scrubbing prevents *future* exposure via `git clone`; it does not undo past exposure. --- ## Milestone 4 — Rotate everything Because the secrets were exposed in history (even briefly, even in a private repo), the migration is not complete until every credential in the inventory has been **rotated**, not just re-encrypted. Re-encrypting an already-leaked value protects it going forward but doesn't undo the leak. For each row in the original inventory: - Password hashes → change the actual account password, regenerate the hash, update the sops file. - API tokens/auth keys → revoke the old token in the issuing service's dashboard (Cloudflare, Tailscale, backup provider, etc.) and generate a new one. - SSH/WireGuard private keys → generate new keypairs, update the corresponding public key wherever it's trusted (authorized_keys, peer configs, etc.), retire the old ones. - TLS certs → reissue if the private key was exposed. Keep `secrets-inventory.md` open during this step and check off each row as rotated. Delete the file only once every row is checked off — it should not be committed. --- ## Ongoing prevention Add a pre-commit hook (or a `nix flake check` step) running `gitleaks protect --staged` so a secret can't be committed again by accident. Document in the repo README (briefly) that new secrets go through `sops ` to edit, never as plaintext in a tracked file. --- ## Definition of done - [ ] Milestone 1 inventory complete and reviewed - [ ] All hosts have per-host age keys; admin key backed up outside the repo - [ ] Every inventoried secret migrated to sops-nix, referenced via `*File`/`sops.secrets.*.path`, nothing plaintext in the working tree - [ ] `nixos-rebuild dry-build` and at least one real `switch` verified per host - [ ] Working-tree scanner sweep clean - [ ] History rewritten with `git-filter-repo`, force-pushed, full-history scanner sweep clean - [ ] All other clones deleted and re-cloned from the rewritten history - [ ] Every credential in the original inventory rotated (not just re-encrypted) - [ ] Pre-commit secret scanning hook added - [ ] `secrets-inventory.md` deleted from the working directory (never committed)