feat(secrets): path-bound secrets are symlinks into /secrets/files

A secret a third party reads from a fixed path (ssh key, systemd EnvironmentFile, nix
authKeyFile, TLS keypair) now lives ONCE as a real file in /secrets/files and is symlinked
from where the consumer expects it. The consumer is unchanged and unaware; the file exists
in one directory, at 0600, outside /srv and outside every git tree.

That makes the store and the file directory alternatives, not layers: a secret is a value in
store.yaml OR a file in /secrets/files, never both. The copies of the ssh keys, tailscale
auth key, incus keypair, LE cert and cc-ci testenv have been dropped from store.yaml now that
each has a single home.

Documented exception: an app that rewrites its own credential file (OAuth refresh via
write-temp+rename) replaces the symlink with a regular file and silently re-splits the home.
opencode's auth.json is one, so it stays put and is deliberately not centralised.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-01 17:06:18 +00:00
co-authored by Claude
parent 300e69d3b3
commit ef85d40a63
2 changed files with 46 additions and 27 deletions
+19 -14
View File
@@ -6,9 +6,15 @@ remote URLs (`https://user:pass@host/...`, which `git remote -v` happily prints)
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: /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 — /secrets is not a repo and has no remote.
/secrets/store.yaml sops+age ciphertext (0600) — values our code reads
/secrets/files/ real files (0600) SYMLINKED from the fixed path a third
party insists on: ~/.ssh keys, a systemd EnvironmentFile,
nix authKeyFile, a TLS keypair
~/.config/sops/age/keys.txt the age private key, 0600
One home per secret: a value is in the store OR a file in /secrets/files, never both.
/secrets is outside every git tree — not a repo, no remote — and outside /srv, which agents
grep and walk constantly.
USAGE (library):
from secrets import get, get_group
@@ -18,20 +24,19 @@ USAGE (library):
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
NO SECOND COPIES. A secret must not be written to a second file "so something can read it"
copies drift from the store, get committed, and multiply what an attacker (or a careless
`grep`) can find. Consumers read the store: our own code imports this module; anything else
gets the value injected at RUN TIME and nothing is left at rest.
NO SECOND COPIES. A secret is never written to a second file "so something can read it"
copies drift, get committed, and widen what a stray `grep` or an attacker finds. A consumer
that insists on a path gets a SYMLINK into /secrets/files (see above), so the file still
exists exactly once. For a one-off, inject at run time and leave nothing behind:
secrets.py exec-env cc_ci_testenv -- some-command # group as env vars, no file
secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host # 0600 file in a private
# tmpdir, deleted when the command exits
secrets.py exec-env <group> -- some-command # group as env vars, no file
secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private tmpdir,
# deleted when the command exits
For systemd, wrap ExecStart in `exec-env` instead of using an EnvironmentFile — same effect,
no plaintext on disk. The few OS-level paths that genuinely cannot be taught this (nix's
`authKeyFile`, sshd host keys) are the exception, and are noted in engine/README.md.
Careful with symlinks: an app that rewrites its own credential file (an OAuth refresh writing
auth.json via write-temp+rename) REPLACES the symlink with a regular file and silently splits
the home again. Before symlinking, ask whether the owner ever writes it back.
ADDING A SECRET: sops /secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
"""