Archived
Check NixOS configurations / eval-hosts (push) Failing after 11m5s
Audited the working tree and full git history for committed secrets
(gitleaks + trufflehog + manual grep, see secrets-inventory.md, kept
local/gitignored per the spec). Found: a password hash shared by root
and the nixos user across every host, two live Beszel monitoring
tokens, and a GitHub fine-grained PAT embedded in a home-manager
nix.conf.
Migrates all of them to sops-nix:
- .sops.yaml + secrets/*.yaml, encrypted for admin + the age keys
derived (via ssh-to-age) from each live host's existing SSH host
key — no new key material transferred to any machine.
- users.users.{root,nixos}.hashedPasswordFile replaces the inline
hashedPassword shared by every target.
- The GitHub PAT moves from a home-manager-managed, store-visible
nix.conf to a sops.templates-rendered file included via nix.conf's
native !include, system-wide instead of per-user.
- Beszel TOKEN moves from `environment` (store-visible) to
`environmentFile` (runtime-only via sops.templates); the dead
commented-out docker token is removed from the tree entirely.
Added a tracked pre-commit hook (gitleaks protect --staged, wired via
core.hooksPath) so a secret can't be committed by accident again, and
documented the sops workflow in README.md.
Structural verification only: all 17 flake targets evaluate, and
`nix build --dry-run --no-link` succeeds for the three currently
deployed hosts. Per CLAUDE.md, actual `nixos-rebuild switch` — the
step that confirms secrets decrypt and services start on a real
machine — is left for manual verification.
Git history still contains the original plaintext secrets; scrubbing
history (Milestone 3) and rotating every credential (Milestone 4) are
separate, deliberately gated steps per remove-sensetive-info-refactor.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
9.8 KiB
Markdown
135 lines
9.8 KiB
Markdown
# 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."<name>/hashedPassword"`, reference via `users.users.<name>.hashedPasswordFile = config.sops.secrets."<name>/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."<service>/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.<name>.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 .#<host>` succeeds for every host.
|
|
- `sudo nixos-rebuild switch --flake .#<host>` 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 <file>` 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)
|