diff --git a/README.md b/README.md index 10ad7b3..a9566e2 100644 --- a/README.md +++ b/README.md @@ -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 ``` -**No second copies.** A secret must never be written to a second file "so something can read -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**: +**One home per secret — two shapes.** -```sh -# a group as environment variables — nothing touches the disk -python3 engine/secrets.py exec-env cc_ci_testenv -- some-command +*Values our code reads* live **in the store**; import this module and ask for them. Nothing is +written to disk (`engine/.tangled-session` is gone — the tangled tools read `tangled.cookie`). -# a consumer that insists on a path: 0600 file in a private tmpdir, deleted when the command exits -python3 engine/secrets.py with-file ssh_keys.tangled-ed25519 -- ssh -i {} host +*Secrets a third party reads from a fixed path* (ssh keys, a systemd `EnvironmentFile`, nix's +`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 -effect, no plaintext at rest. The genuine exceptions are OS-level paths that are read before any -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 -sources of truth again. +The consumer is unchanged and unaware; the file exists once, in one directory, at 0600. Do **not** +also copy such a secret into `store.yaml` — that is two sources of truth again. + +For a one-off where neither shape fits, inject at run time and leave nothing behind: + +```sh +python3 engine/secrets.py exec-env -- some-command # group as env vars +python3 engine/secrets.py with-file -- 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** diff --git a/secrets.py b/secrets.py index 4601944..65bf5c3 100755 --- a/secrets.py +++ b/secrets.py @@ -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 # 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 -- some-command # group as env vars, no file + secrets.py with-file -- 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) """