From 7605efe6242e20b2d4bff6b43a27d2e399f8a405 Mon Sep 17 00:00:00 2001 From: notplants-bot Date: Sat, 1 Aug 2026 16:51:36 +0000 Subject: [PATCH] feat(secrets): one sops+age store for the host, documented for every project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 43 ++++++++++++++++++++ secrets.py | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100755 secrets.py diff --git a/README.md b/README.md index 93eb760..ba7ce81 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/secrets.py b/secrets.py new file mode 100755 index 0000000..25d8c93 --- /dev/null +++ b/secrets.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""The one place secrets live on an orchestrator host: a sops+age encrypted store. + +WHY: credentials used to sit in plaintext all over the box — passwords baked into git +remote URLs (`https://user:pass@host/...`, which `git remote -v` happily prints), API keys +in .env files, a private key at mode 0644. Anything in a repo is one `git add -A` away from +a push. So: ONE encrypted file, OUTSIDE every git tree, and a helper every project uses. + + store: /srv/secrets/store.yaml sops+age ciphertext, mode 0600 + age key: ~/.config/sops/age/keys.txt the ONLY plaintext secret on disk, 0600 + outside git by construction — /srv/secrets is not a repo and has no remote. + +USAGE (library): + from secrets import get, get_group + cookie = get("tangled.cookie") + env = get_group("cc_ci_testenv") # dict, e.g. to build an env + +USAGE (CLI): + python3 engine/secrets.py list # group/key names only, never values + python3 engine/secrets.py get tangled.cookie # value to stdout (careful in logs) + python3 engine/secrets.py materialize # write a runtime file a consumer needs + +MATERIALIZED FILES: some consumers read a fixed path and cannot be taught otherwise (a +systemd EnvironmentFile, an ssh IdentityFile, `nix`'s authKeyFile). Those files still exist +on disk at 0600, but the STORE IS THE SOURCE OF TRUTH — `materialize` rewrites them from it. +Never edit a materialized file by hand; edit the store (`sops /srv/secrets/store.yaml`) and +re-materialize, or the two silently drift. + +ADDING A SECRET: sops /srv/secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save) +""" +import json, os, subprocess, sys, pathlib + +STORE = os.environ.get("AO_SECRETS_STORE", "/srv/secrets/store.yaml") +AGE_KEY = os.environ.get("SOPS_AGE_KEY_FILE", os.path.expanduser("~/.config/sops/age/keys.txt")) + +# name -> (path, mode). Files a consumer reads from a fixed location; see MATERIALIZED FILES. +MATERIALIZE = { + "tangled-session": ("engine/.tangled-session", 0o600), # relative to the project dir + "cc-ci-testenv": ("/srv/cc-ci/.testenv", 0o600), +} + + +def _load(): + """Decrypt the store. Fails loudly: a silent empty dict would look like 'no secrets'.""" + if not pathlib.Path(STORE).exists(): + sys.exit(f"no secret store at {STORE} — see engine/README.md (Secrets)") + env = {**os.environ, "SOPS_AGE_KEY_FILE": AGE_KEY} + r = subprocess.run(["sops", "-d", "--output-type", "json", STORE], + capture_output=True, text=True, env=env) + if r.returncode != 0: + sys.exit(f"cannot decrypt {STORE} (age key at {AGE_KEY}?): {r.stderr.strip()[:300]}") + return json.loads(r.stdout) + + +def get(dotted, default=None): + """get('group.key') -> value. Missing key returns default (None) rather than raising.""" + cur = _load() + for part in dotted.split("."): + if not isinstance(cur, dict) or part not in cur: + return default + cur = cur[part] + return cur + + +def get_group(name): + """get_group('cc_ci_testenv') -> dict of that group (empty dict if absent).""" + return _load().get(name, {}) + + +def materialize(name, project_dir="."): + """Write a runtime file from the store. Returns the path written.""" + if name == "tangled-session": + cookie = get("tangled.cookie") + if not cookie: + sys.exit("store has no tangled.cookie") + p = pathlib.Path(project_dir) / MATERIALIZE[name][0] + body = f"TANGLED_COOKIE={cookie}\n" + elif name == "cc-ci-testenv": + p = pathlib.Path(MATERIALIZE[name][0]) + body = "".join(f"{k}={v}\n" for k, v in get_group("cc_ci_testenv").items()) + else: + sys.exit(f"unknown materialize target {name!r} — known: {', '.join(MATERIALIZE)}") + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + p.chmod(MATERIALIZE[name][1]) + return str(p) + + +def main(): + argv = sys.argv[1:] + if not argv or argv[0] in ("-h", "--help"): + print(__doc__) + return + cmd = argv[0] + if cmd == "list": + for g, v in _load().items(): + print(f"{g}: {', '.join(v) if isinstance(v, dict) else ''}") + elif cmd == "get": + if len(argv) < 2: + sys.exit("usage: secrets.py get ") + v = get(argv[1]) + if v is None: + sys.exit(f"no such key: {argv[1]}") + print(v if isinstance(v, str) else json.dumps(v)) + elif cmd == "materialize": + if len(argv) < 2: + sys.exit(f"usage: secrets.py materialize <{'|'.join(MATERIALIZE)}>") + print("wrote", materialize(argv[1], argv[2] if len(argv) > 2 else ".")) + else: + sys.exit(f"unknown command {cmd!r} — list | get | materialize") + + +if __name__ == "__main__": + main()