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:
@@ -385,23 +385,37 @@ python3 engine/secrets.py materialize tangled-session # write a runtime file f
|
|||||||
sops /secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
|
sops /secrets/store.yaml # add/edit: decrypts to $EDITOR, re-encrypts on save
|
||||||
```
|
```
|
||||||
|
|
||||||
**No second copies.** A secret must never be written to a second file "so something can read
|
**One home per secret — two shapes.**
|
||||||
it" — copies drift from the store, get committed, and widen what a stray `grep` or an attacker
|
|
||||||
finds. Our own code imports this module. Anything else gets the value at **run time**:
|
|
||||||
|
|
||||||
```sh
|
*Values our code reads* live **in the store**; import this module and ask for them. Nothing is
|
||||||
# a group as environment variables — nothing touches the disk
|
written to disk (`engine/.tangled-session` is gone — the tangled tools read `tangled.cookie`).
|
||||||
python3 engine/secrets.py exec-env cc_ci_testenv -- some-command
|
|
||||||
|
|
||||||
# a consumer that insists on a path: 0600 file in a private tmpdir, deleted when the command exits
|
*Secrets a third party reads from a fixed path* (ssh keys, a systemd `EnvironmentFile`, nix's
|
||||||
python3 engine/secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host
|
`authKeyFile`, a TLS keypair) live as **real files in `/secrets/files/`, symlinked from the path
|
||||||
|
the consumer expects**:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.ssh/tangled-ed25519 -> /secrets/files/tangled-ed25519
|
||||||
|
/etc/ts-auth-key -> /secrets/files/ts-auth-key
|
||||||
|
/srv/cc-ci/.testenv -> /secrets/files/cc-ci.testenv
|
||||||
```
|
```
|
||||||
|
|
||||||
For **systemd**, wrap `ExecStart` in `exec-env` rather than using an `EnvironmentFile`: same
|
The consumer is unchanged and unaware; the file exists once, in one directory, at 0600. Do **not**
|
||||||
effect, no plaintext at rest. The genuine exceptions are OS-level paths that are read before any
|
also copy such a secret into `store.yaml` — that is two sources of truth again.
|
||||||
of this exists — nix's `authKeyFile`, sshd host keys, and ssh client keys used by bare `git push`.
|
|
||||||
Those stay where the OS expects them; do not also copy them into the store, or you have two
|
For a one-off where neither shape fits, inject at run time and leave nothing behind:
|
||||||
sources of truth again.
|
|
||||||
|
```sh
|
||||||
|
python3 engine/secrets.py exec-env <group> -- some-command # group as env vars
|
||||||
|
python3 engine/secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private
|
||||||
|
# tmpdir, deleted on exit
|
||||||
|
```
|
||||||
|
|
||||||
|
**The symlink exception: apps that rewrite their own credential file.** An app that refreshes an
|
||||||
|
OAuth token by writing `auth.json` atomically (write-temp + rename) **replaces the symlink with a
|
||||||
|
regular file**, silently splitting the home again. `~/.local/share/opencode/auth.json` is such a
|
||||||
|
file, so it stays where it is and is deliberately *not* centralised. Before symlinking a secret,
|
||||||
|
ask whether its owner ever writes it back.
|
||||||
|
|
||||||
**Rules of thumb**
|
**Rules of thumb**
|
||||||
|
|
||||||
|
|||||||
+19
-14
@@ -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
|
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.
|
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
|
/secrets/store.yaml sops+age ciphertext (0600) — values our code reads
|
||||||
age key: ~/.config/sops/age/keys.txt the ONLY plaintext secret on disk, 0600
|
/secrets/files/ real files (0600) SYMLINKED from the fixed path a third
|
||||||
outside git by construction — /secrets is not a repo and has no remote.
|
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):
|
USAGE (library):
|
||||||
from secrets import get, get_group
|
from secrets import get, get_group
|
||||||
@@ -18,20 +24,19 @@ USAGE (library):
|
|||||||
USAGE (CLI):
|
USAGE (CLI):
|
||||||
python3 engine/secrets.py list # group/key names only, never values
|
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 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" —
|
NO SECOND COPIES. A secret is never 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
|
copies drift, get committed, and widen what a stray `grep` or an attacker finds. A consumer
|
||||||
`grep`) can find. Consumers read the store: our own code imports this module; anything else
|
that insists on a path gets a SYMLINK into /secrets/files (see above), so the file still
|
||||||
gets the value injected at RUN TIME and nothing is left at rest.
|
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 exec-env <group> -- some-command # group as env vars, no file
|
||||||
secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host # 0600 file in a private
|
secrets.py with-file <group.key> -- cmd -i {} # 0600 file in a private tmpdir,
|
||||||
# tmpdir, deleted when the command exits
|
# deleted when the command exits
|
||||||
|
|
||||||
For systemd, wrap ExecStart in `exec-env` instead of using an EnvironmentFile — same effect,
|
Careful with symlinks: an app that rewrites its own credential file (an OAuth refresh writing
|
||||||
no plaintext on disk. The few OS-level paths that genuinely cannot be taught this (nix's
|
auth.json via write-temp+rename) REPLACES the symlink with a regular file and silently splits
|
||||||
`authKeyFile`, sshd host keys) are the exception, and are noted in engine/README.md.
|
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)
|
ADDING A SECRET: sops /secrets/store.yaml (opens decrypted in $EDITOR, re-encrypts on save)
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user