From 7af3bce544ebf28ec6b971c1c9fc4cc91b43495f Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 08:44:55 +0200 Subject: [PATCH 1/2] feat(secrets): scaffold sops-nix for declarative secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires sops-nix as a flake input and bakes the NixOS module into configuration.nix via modules/secrets.nix. Per-host defaults live in modules/secrets.nix: - defaultSopsFile = ../secrets/${settings.hostName}.yaml - defaultSopsFormat = yaml - age.keyFile = /home/${settings.user}/.config/sops/age/keys.txt The whole sops block is gated on `builtins.pathExists` so flake eval succeeds before the encrypted file is created — important during the scaffold-bootstrap phase where the consumer hasn't yet generated an age key. Adds .sops.yaml with a placeholder admin recipient (overwrite with your real age public key before encrypting anything) and a creation_rules block matching `secrets/*.yaml`. .gitignore loosened so `secrets/*.yaml` and `secrets/README.md` can be checked in while plaintext key material (`*.key`, `*.pem`) and anything else under `secrets/` stays ignored. The pre-commit secret scanner most consumers use is the second line of defense. secrets/README.md documents the workflow at the directory level. The substantive beginner walkthrough lands in a follow-up commit at docs/secrets-management.md. `nix flake check --no-build` stays green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 8 ++++++++ .sops.yaml | 21 +++++++++++++++++++++ configuration.nix | 3 +++ flake.lock | 23 ++++++++++++++++++++++- flake.nix | 11 +++++++++++ modules/secrets.nix | 42 ++++++++++++++++++++++++++++++++++++++++++ secrets/README.md | 39 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .sops.yaml create mode 100644 modules/secrets.nix create mode 100644 secrets/README.md diff --git a/.gitignore b/.gitignore index 695aa96..86829e9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ result-* .#* *.swp *.swo + +# Secrets — track only sops-encrypted .yaml files + the README; +# block plaintext keys and any other content under secrets/ +*.key +*.pem +secrets/* +!secrets/*.yaml +!secrets/README.md diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..c3f4ce1 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,21 @@ +# sops recipient declarations. +# +# Replace the placeholder below with YOUR age public key before +# encrypting any files. One-time setup on this machine: +# pragma: allowlist secret +# age-keygen -o ~/.config/sops/age/keys.txt # creates the private key +# age-keygen -y ~/.config/sops/age/keys.txt # prints the public key +# +# Paste the printed `age1...` string in place of the placeholder. +# See docs/secrets-management.md for the full walkthrough. + +keys: + # pragma: allowlist secret + # PLACEHOLDER — overwrite with your real age public key. + - &admin age1REPLACEME_run_age_keygen_y_then_paste_the_real_key_here + +creation_rules: + - path_regex: secrets/.*\.yaml$ + key_groups: + - age: + - *admin diff --git a/configuration.nix b/configuration.nix index 8e2d9a5..164455b 100644 --- a/configuration.nix +++ b/configuration.nix @@ -24,6 +24,9 @@ # Option schema (lnbits-sensei.*). ./modules/core.nix + # sops-nix wiring. Inert until secrets/.yaml exists. + ./modules/secrets.nix + # Git remote topology — upstream / fork / extras. ./modules/git/remotes.nix diff --git a/flake.lock b/flake.lock index b1dafb6..23fb283 100644 --- a/flake.lock +++ b/flake.lock @@ -56,7 +56,28 @@ "inputs": { "home-manager": "home-manager", "lnbits-src": "lnbits-src", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777944972, + "narHash": "sha256-VfGRo1qTBKOe3s2gOv8LSoA6Fk19PvBlwQ1ECN0Evn8=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "c591bf665727040c6cc5cb409079acb22dcce33c", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 7a9f981..5d3144f 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,17 @@ url = "github:lnbits/lnbits"; flake = false; }; + + # sops-nix — declarative secrets via age-encrypted YAML files. + # Decryption happens at NixOS activation; values are exposed to + # services as files under /run/secrets/. The host's age + # key lives at ~/.config/sops/age/keys.txt by default; recipients + # are declared in .sops.yaml. See modules/secrets.nix for wiring + # and docs/secrets-management.md for a walkthrough. + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = diff --git a/modules/secrets.nix b/modules/secrets.nix new file mode 100644 index 0000000..9f6fc9c --- /dev/null +++ b/modules/secrets.nix @@ -0,0 +1,42 @@ +# sops-nix per-host wiring. +# pragma: allowlist secret start +# +# Imports the sops-nix NixOS module and points it at this host's +# encrypted file + the consumer's age private key. +# +# - Recipients (which age public keys can decrypt) are declared in +# `.sops.yaml` at the repo root. +# - The encrypted file for this host lives at +# `secrets/${settings.hostName}.yaml`. Create it with: +# sops secrets/${settings.hostName}.yaml +# sops auto-encrypts on save using the recipients from .sops.yaml. +# - The matching private key lives at +# `/home/${settings.user}/.config/sops/age/keys.txt`. Generate it +# one-time with `age-keygen -o ~/.config/sops/age/keys.txt`. +# +# The whole sops block is gated on `builtins.pathExists` so flake +# eval succeeds before the encrypted file exists — useful for the +# scaffold-bootstrap phase. See `docs/secrets-management.md` for a +# walkthrough. +# pragma: allowlist secret end +{ + config, + lib, + pkgs, + inputs, + settings, + ... +}: + +let + sopsFile = ../secrets/${settings.hostName}.yaml; +in +{ + imports = [ inputs.sops-nix.nixosModules.sops ]; + + sops = lib.mkIf (builtins.pathExists sopsFile) { + defaultSopsFile = sopsFile; + defaultSopsFormat = "yaml"; + age.keyFile = "/home/${settings.user}/.config/sops/age/keys.txt"; + }; +} diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..0d675f0 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,39 @@ +# secrets/ + +Encrypted YAML files in this directory are decrypted at NixOS +activation time and exposed under `/run/secrets/` for any +service that declares `sops.secrets.` to consume. + +Recipients are declared in `../.sops.yaml`. The matching age +private key lives at `~/.config/sops/age/keys.txt` on the host +machine (see `modules/secrets.nix`). + +## Workflow + +```sh +# First-time: create + encrypt this host's secrets file +sops secrets/.yaml +# sops auto-encrypts on save using recipients from .sops.yaml + +# Later edits go through sops (auto-decrypts, re-encrypts on save) +sops secrets/.yaml +``` + +See [`../docs/secrets-management.md`](../docs/secrets-management.md) +for the full walkthrough — generating the age key, adding a recipient, +declaring a secret in NixOS, and rotating keys. + +## What goes here + +One YAML file per host, named after the host. Inside each file, a +flat or nested map of secret names → values: + +```yaml +# secrets/.yaml — encrypted in place +lnbits-admin-key: changeme-real-key-goes-here +postgres: + lnbits-password: changeme-real-password-goes-here +``` + +NixOS modules reference these by name via `sops.secrets.` +and read the runtime path via `config.sops.secrets..path`. From fc1d31244ab19f05bdef85dbfb34c17c993ff0fe Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 08:47:19 +0200 Subject: [PATCH 2/2] docs(secrets): add beginner walkthrough for sops-nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/secrets-management.md walks through getting secrets out of .env and into sops-encrypted YAML, assuming zero familiarity with sops or age. Companion to the wiring landed in the prior commit. Sections: - Why bother — the .env failure modes (history leaks, plaintext on disk) and what sops/age fixes. - What's in the box — pointers to the existing scaffold (flake input, modules/secrets.nix, .sops.yaml, .gitignore guards), why it's inert until the first encrypted file lands. - Step-by-step — install tools, generate the age key (with a clear warning about back-ups + no recovery), paste the public key into .sops.yaml, create the first encrypted file via `sops`, declare secrets in NixOS, reference via config.sops.secrets..path, activate. - Common operations — edit / view / rotate / updatekeys. - Multi-host server deployment — per-host age keys at /var/lib/sops-nix/key.txt, path_regex-scoped recipients in .sops.yaml so each host only decrypts its own secrets. - Pitfalls — don't commit unencrypted YAML, don't lose the key, updatekeys ≠ rotation, sops -d outputs are sensitive, the pathExists gate fails-silently-on-delete trap. Linked from README "Further reading" with a one-liner noting the sops-nix wiring already ships with the scaffold. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 + docs/secrets-management.md | 313 +++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 docs/secrets-management.md diff --git a/README.md b/README.md index 43fbc08..76cc987 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,12 @@ top of the human-facing docs, not a replacement for them. Vue/Quasar UMD traps in lnbits page templates: no self-closing tags, CSS specificity vs Quasar's `!important` utilities, cache busting via `?v={server_startup_time}`, dark-mode color discipline. +- [`docs/secrets-management.md`](docs/secrets-management.md) — + beginner-friendly walkthrough for getting secrets out of `.env` + and into sops-encrypted YAML files: generating an age key, adding + recipients, declaring secrets in NixOS, rotating, multi-host + server setups, and common pitfalls. The scaffold ships the sops-nix + wiring already (inert until you create your first encrypted file). ## Contributing to this scaffold diff --git a/docs/secrets-management.md b/docs/secrets-management.md new file mode 100644 index 0000000..b94509e --- /dev/null +++ b/docs/secrets-management.md @@ -0,0 +1,313 @@ +# 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/` 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`. + +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`. + +## 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/.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/.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.` 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 `` 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..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/` on the running host. + +### 7. Activate + +```sh +sudo nixos-rebuild switch --flake .# +``` + +At activation, sops-nix decrypts `secrets/.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/.yaml +# editor opens with plaintext view; sops re-encrypts on save +``` + +**View a secret without editing:** + +```sh +sops -d secrets/.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/.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 pre-commit + hook most consumers use catches obvious cases, but it's not + foolproof. Always verify with `cat secrets/.yaml` that you + see `sops:` metadata + base64 blobs, not plaintext. +- **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..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.` (writes to + `/run/secrets/` with mode-bits you control) instead. +- **`builtins.pathExists` is checked at eval time.** If you delete + `secrets/.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/` file. + Don't delete a host's encrypted file unless you also remove every + `sops.secrets.` referencing it.