This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/docs/secrets-management.md
Padreug 773632562e feat(dev-env): wire shared pre-commit secret scanner via core.hooksPath
Ships `modules/dev-env/scripts/git-hooks/pre-commit` — the same
secret-scanner pattern omnixy uses, lightly adapted (drops the
omnixy-specific test_auth.py skip, generic header comment).

New option `lnbits-sensei.devEnv.gitHooks.enable` (off by default).
When on, modules/dev-env/config.nix installs the hook at
`~/.local/share/lnbits-sensei/git-hooks/pre-commit` and sets the
consumer's git `core.hooksPath` to that directory, so every repo on
the machine picks it up without per-repo wiring.

The hook refuses to commit obvious secrets (PRIVATE KEY blocks,
`password=…`, `secret=…`, `api_key=…`, `admin_key=…`, AWS keys,
non-placeholder POSTGRES_PASSWORD) and unencrypted sops files
(checks for a top-level `sops:` block AND `mac: ENC[…]` — either
signal alone is forgeable). False positives are handled via
`# pragma: allowlist secret` line- or block-level markers (gitleaks
convention).

docs/secrets-management.md gets a new subsection covering what the
hook does, when to enable it, and the false-positive escape hatches.
The Pitfalls section's reference to "the pre-commit hook most
consumers use" is replaced with a concrete pointer to this option.

`nix flake check --no-build` stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:21:27 +02:00

343 lines
11 KiB
Markdown

# Secrets management with sops-nix
A beginner-friendly walkthrough of getting secrets out of your
`.env` files and into `git` safely, using
[sops-nix](https://github.com/Mic92/sops-nix). Assumes no prior
familiarity with sops, age, or NixOS secret management.
## Why bother
Most LNbits dev setups start with secrets in `.env`:
```env
LNBITS_ADMIN_KEY=changeme-real-key-goes-here
POSTGRES_PASSWORD=changeme-real-password-goes-here
```
This works, but `.env` files have two failure modes:
1. **They land in git accidentally.** A `git add -A` in the wrong
moment, a forgotten `.gitignore` line, and the key history-leaks
into a public repo. Rotating after the fact is painful — there's
no fixing what's already in `git log`.
2. **They live as plaintext on disk.** A backup of your dev box, a
shared filesystem snapshot, a forgotten copy in `~/Downloads/`
all paths to the same compromise.
sops + age fixes both. Secret values get encrypted into a YAML file
checked into your repo. The encrypted file is safe to commit, push,
and back up. Decryption happens transparently at NixOS activation
time on the host that has the matching private key. Each secret
becomes a file under `/run/secrets/<name>` that your services read.
## What's in the box (lnbits-sensei scaffold)
This repo already wires sops-nix:
- **`flake.nix`** declares `sops-nix` as a flake input.
- **`modules/secrets.nix`** imports the NixOS module and points it at
`secrets/${hostName}.yaml` + your private key at
`~/.config/sops/age/keys.txt`.
- **`.sops.yaml`** declares the recipients (which public keys can
decrypt files under `secrets/`).
- **`secrets/`** is gitignored except for `*.yaml` (which is
encrypted) and `README.md`.
- **`configuration.nix`** imports `modules/secrets.nix`.
- **`modules/dev-env/scripts/git-hooks/pre-commit`** is the shared
secret-scanner hook. Opt in by setting
`lnbits-sensei.devEnv.gitHooks.enable = true;` — see below.
All of this is **inert until you create your first encrypted file**.
`nix flake check` stays green meanwhile because the module gates on
`builtins.pathExists`.
### The pre-commit hook (recommended)
`lnbits-sensei.devEnv.gitHooks.enable = true` installs a single
secret-scanner pre-commit hook under
`~/.local/share/lnbits-sensei/git-hooks/` and sets git's
`core.hooksPath` so **every** repo on the machine picks it up
without per-repo wiring. The hook refuses to commit:
- Lines matching `PRIVATE KEY`, `BEGIN RSA/EC/OPENSSH PRIVATE`,
`password=…`, `secret=…`, `api_key=…`, `admin_key=…`,
`AWS_SECRET_ACCESS_KEY`, `POSTGRES_PASSWORD=…` (unless the value
is `example|changeme|placeholder`).
- Files under `secrets/` matching `*.yaml` that lack a `sops:`
metadata block (i.e. unencrypted secrets files staged by accident).
False positives (legitimate matches on placeholder values, prose
comments, test fixtures) are handled via line- or block-level
`# pragma: allowlist secret` markers — same convention as gitleaks.
See the hook source for the full marker semantics.
Last resort: `git commit --no-verify` bypasses the hook entirely.
Use sparingly, after confirming the diff doesn't actually contain
secret material.
## Step-by-step
### 1. Install the tools
`sops` and `age` ship in nixpkgs:
```nix
# in modules/packages.nix or home.nix
environment.systemPackages = with pkgs; [ sops age ];
```
After `nixos-rebuild switch`, `sops` and `age-keygen` are on PATH.
For an ad-hoc install without rebuilding:
```sh
nix-shell -p sops age
```
### 2. Generate your age key
The age private key is the one piece of state that lives outside git
— it's how this machine decrypts secrets at activation time. Generate
it once per host:
```sh
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
```
The file looks like:
```
# created: 2026-05-26T...
# public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AGE-SECRET-KEY-1YYYY...
```
The line starting with `AGE-SECRET-KEY-` is your **private key**.
**Back it up somewhere safe** (a password manager, a hardware key, a
trusted offline drive). If you lose it, you lose access to every
secret encrypted to it — there is no recovery.
Print just the **public key** (you'll paste this into `.sops.yaml`):
```sh
age-keygen -y ~/.config/sops/age/keys.txt
# age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 3. Add your public key to `.sops.yaml`
Open `.sops.yaml` at the repo root. Replace the placeholder
`age1REPLACEME...` with your real public key from the previous step:
```yaml
keys:
- &admin age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
creation_rules:
- path_regex: secrets/.*\.yaml$
key_groups:
- age:
- *admin
```
What this says: any file matching `secrets/*.yaml` should be
encrypted for the recipients listed under `key_groups.age`. Right
now that's just `*admin` (a YAML anchor pointing at your key). When
you have collaborators, you'll add their public keys as additional
recipients here.
### 4. Create your first encrypted secrets file
`sops` is the editor wrapper. It opens your `$EDITOR` on a plaintext
view of the file; on save it encrypts in place.
```sh
sops secrets/<hostName>.yaml
```
(Use the same `hostName` you put in `settings.nix` — that's what
`modules/secrets.nix` looks for.)
Your editor opens an empty buffer. Write your secrets as plain YAML:
```yaml
lnbits-admin-key: changeme-real-key-goes-here
postgres:
lnbits-password: changeme-real-password-goes-here
```
Save and exit. sops encrypts in place. Cat the file to see what
landed on disk:
```sh
cat secrets/<hostName>.yaml
```
You'll see the keys are now opaque base64 blobs, and a `sops:`
metadata block at the bottom describes which recipients can decrypt.
This file is **safe to commit and push**.
### 5. Declare each secret in your NixOS config
For each secret you want exposed to a service, add a
`sops.secrets.<name>` declaration in the consuming module:
```nix
# in the module that consumes the secret
{ config, ... }:
{
sops.secrets.lnbits-admin-key = {
mode = "0400";
owner = config.lnbits-sensei.user;
};
}
```
The `<name>` matches the YAML key in your encrypted file. Dots in
the key become nested paths under `/run/secrets/`:
| YAML key | Runtime file |
|---|---|
| `lnbits-admin-key` | `/run/secrets/lnbits-admin-key` |
| `postgres/lnbits-password` (nested) | `/run/secrets/postgres/lnbits-password` |
### 6. Reference the secret from a service
Read the runtime path via `config.sops.secrets.<name>.path`:
```nix
# example: passing the LNbits admin key file to a service
services.someThing.adminKeyFile =
config.sops.secrets.lnbits-admin-key.path;
```
Use a file-path reference (not the value directly) wherever the
service supports one — that way the secret never ends up in the
Nix store, just at `/run/secrets/<name>` on the running host.
### 7. Activate
```sh
sudo nixos-rebuild switch --flake .#<hostName>
```
At activation, sops-nix decrypts `secrets/<hostName>.yaml` using the
private key at `~/.config/sops/age/keys.txt`, writes each declared
secret as a file under `/run/secrets/`, and your service reads from
the file path. Done.
## Common operations
**Edit a secret:**
```sh
sops secrets/<hostName>.yaml
# editor opens with plaintext view; sops re-encrypts on save
```
**View a secret without editing:**
```sh
sops -d secrets/<hostName>.yaml | grep lnbits-admin-key
```
**Rotate to new recipients (e.g. add a collaborator):**
1. Add their public key to `.sops.yaml`:
```yaml
keys:
- &admin age1xxx...
- &alice age1zzz...
creation_rules:
- path_regex: secrets/.*\.yaml$
key_groups:
- age:
- *admin
- *alice
```
2. Re-encrypt every file to the new recipient set:
```sh
sops updatekeys secrets/<hostName>.yaml
```
**Remove a recipient:** edit `.sops.yaml`, then `sops updatekeys`.
**Also rotate the underlying secret values** — anything the removed
party could previously decrypt is now compromised.
## Multi-host / server deployment
When you have one repo deploying multiple machines, each machine
needs its own age private key, and each machine's secrets file
needs to be encrypted to that machine's public key in addition to
the operator's.
**On each NixOS host (one-time):**
```sh
sudo mkdir -p /var/lib/sops-nix
sudo age-keygen -o /var/lib/sops-nix/key.txt
sudo chmod 600 /var/lib/sops-nix/key.txt
sudo age-keygen -y /var/lib/sops-nix/key.txt
# copy this public key into .sops.yaml as a new recipient
```
In `modules/secrets.nix`, swap `age.keyFile` for the host-local path:
```nix
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
```
Then in `.sops.yaml`, list every host's public key alongside the
operator's, scoped via `path_regex` if you want per-host isolation:
```yaml
keys:
- &admin age1xxx... # operator (laptop / dev box)
- &host-a age1aaa... # server A
- &host-b age1bbb... # server B
creation_rules:
- path_regex: secrets/host-a\.yaml$
key_groups:
- age:
- *admin
- *host-a
- path_regex: secrets/host-b\.yaml$
key_groups:
- age:
- *admin
- *host-b
```
That way `host-a` can only decrypt its own secrets, `host-b` only
its own, and the operator can decrypt both.
## Pitfalls
- **Don't commit unencrypted YAML under `secrets/`.** The shared
pre-commit hook (enable via `devEnv.gitHooks.enable = true`)
catches the obvious cases by checking for a `sops:` metadata
block + `mac: ENC[…]` line — both are required for a real
sops-encrypted file. Always verify with `cat secrets/<file>.yaml`
that you see metadata + base64 blobs, not plaintext, before
trusting the hook.
- **Don't lose the private key.** No recovery. Back up the file at
`~/.config/sops/age/keys.txt` (or `/var/lib/sops-nix/key.txt` on
servers) somewhere safe and offline.
- **`updatekeys` re-encrypts but doesn't rotate secret values.** If
you're removing a recipient because they shouldn't have access
anymore, also change the underlying secrets — they could've
cached the decrypted values while they had access.
- **`sops -d` prints plaintext.** Don't pipe it into anything that
logs (`tee`, journalctl, history-aware shells). Use the file path
via `config.sops.secrets.<name>.path` in NixOS config; never
hardcode the decrypted value.
- **`environment.etc` writes to the Nix store** — readable by every
user on the host. Use `sops.secrets.<name>` (writes to
`/run/secrets/` with mode-bits you control) instead.
- **`builtins.pathExists` is checked at eval time.** If you delete
`secrets/<hostName>.yaml` after a successful build, the next
rebuild silently drops the sops block — the failure surfaces only
when a service tries to read a missing `/run/secrets/<name>` file.
Don't delete a host's encrypted file unless you also remove every
`sops.secrets.<name>` referencing it.