feat(secrets): one sops+age store for the host, documented for every project

Credentials were scattered in plaintext: a gitea password baked into six git remote
URLs (`git remote -v` prints those), API keys in .env files, an incus client key at
0644. Anything living in a repo is one `git add -A` from being pushed.

So: ONE encrypted file outside every git tree, and a helper each project uses.

  /srv/secrets/store.yaml       sops+age ciphertext, 0600, not a repo, no remote
  ~/.config/sops/age/keys.txt   the only plaintext secret on disk, 0600

secrets.py is stdlib + the sops binary: get("group.key"), get_group("group"), and
materialize() for consumers that must read a fixed path (systemd EnvironmentFile,
ssh IdentityFile, nix authKeyFile) — those keep their file, but the store is the
source of truth, so a materialized file is never hand-edited.

`list` prints names only, never values, so it is safe in a transcript.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:51:36 +00:00
co-authored by Claude
parent 26d93c1eda
commit 7605efe624
2 changed files with 157 additions and 0 deletions
+43
View File
@@ -357,6 +357,49 @@ Run it by hand with `engine/agents.py up --config agents.toml`.
---
## Secrets — one encrypted store, never in git
**Every credential on an orchestrator host lives in one sops+age encrypted file. Do not put a
secret anywhere else** — not in a git remote URL, not in a project `.env`, not in a prompt.
```
/srv/secrets/store.yaml the store: sops+age ciphertext, mode 0600
~/.config/sops/age/keys.txt the age private key — the ONE plaintext secret, mode 0600
```
`/srv/secrets/` is deliberately **not a git repo and has no remote**, so there is no path by
which a `git add`/`git push` can leak it; the store is ciphertext at rest anyway.
Read it with `engine/secrets.py` (stdlib + the `sops` binary, no Python deps):
```python
from secrets import get, get_group
cookie = get("tangled.cookie") # a single value
env = get_group("cc_ci_testenv") # a whole group as a dict
```
```sh
python3 engine/secrets.py list # group/key NAMES only — never prints values
python3 engine/secrets.py get tangled.cookie # one value on stdout
python3 engine/secrets.py materialize tangled-session # write a runtime file from the store
sops /srv/secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
```
**Materialized files.** Some consumers read a fixed path and can't be taught otherwise (a systemd
`EnvironmentFile`, an ssh `IdentityFile`, nix's `authKeyFile`). Those files still exist at 0600,
but **the store is the source of truth**`materialize` rewrites them from it. Never hand-edit a
materialized file: edit the store and re-materialize, or the two silently drift.
**Rules of thumb**
- Prefer ssh remotes over `https://user:pass@host/...`. A password in a remote URL is printed by
`git remote -v`, copied into every clone, and survives in `.git/config` where nobody looks.
- A private key is `chmod 600`. Check with
`find . -name '*.key' -o -name 'id_*' ! -name '*.pub' -perm /044`.
- Anything a project must keep on disk goes in `.gitignore` **and** gets its real home in the store.
---
## Nix
A `flake.nix` provides a reproducible devShell with the runtime deps (`python311` for stdlib