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:
@@ -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
|
## Nix
|
||||||
|
|
||||||
A `flake.nix` provides a reproducible devShell with the runtime deps (`python311` for stdlib
|
A `flake.nix` provides a reproducible devShell with the runtime deps (`python311` for stdlib
|
||||||
|
|||||||
Executable
+114
@@ -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 <name> # 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 '<value>'}")
|
||||||
|
elif cmd == "get":
|
||||||
|
if len(argv) < 2:
|
||||||
|
sys.exit("usage: secrets.py get <group.key>")
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user