From 65a3dbe103169a907dcb26d05118b85a48c69175 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 27 Oct 2026 21:15:25 +0100 Subject: [PATCH] SECRETS-WP-0015-T04: phase B client-secret custody proposal and ISRG trust status Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 4515@bnt-lap001 Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354 --- ...0015-original-keycape-recovery-selector.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md b/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md index 5641858..0318597 100644 --- a/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md +++ b/workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md @@ -233,3 +233,61 @@ Next owner action: railiance-platform, with rapp-openbao, accepts or declines a OpenBao restore with the secrets-engine JWT role and KV path, as a dependency of this task. The consumer-continuity run (plan claim 3) stays a separately admitted attempt after that. Status stays `wait`. No selector, service_auth.py, checker or founder_admission.py file was edited, so the NK49 pins stand. + +2026-10-27 proposal for NK-WP-0049-T43 / phase B (message 765f6d7b from net-kingdom; proposal only, no live action, +no secret value read, nothing authorized; founder decision 2026-10-27: this workload consumer satisfies T18 scope, +no Authelia in phase B). + +Why the ordinary path cannot be used. `login_service_jwt` takes the client secret from +`OpenBaoOidcClientSecretSource`, an attended OIDC operator login to OpenBao followed by an in-memory KV read. Phase B +has no Authelia/OIDC operator, and the plan forbids injecting the recovered issuer secret into ordinary +`login_service_jwt` or creating a persistent consumer copy. `client_secret_file` is the approval-profile path and +needs a 0600 file; phase B must not use it. So phase B needs a new, separately reviewed secret source. + +Proposed custody: founder-attended pipe into an inherited descriptor, in-memory only. +1. New file, not an edit of any pinned one (`service_auth.py`, selector, checker and `founder_admission.py` stay + byte-identical, so the NK49 pins stand): `tools/phase-b-consumer-run.py` (name suggested) plus tests. It builds a + `KeyCapeServiceAuthProvider(kcfg, secret_source=)` itself; the callable reads fd N once. +2. The founder starts the runner in their own terminal. The secret is produced by the founder's pipeline from the + backup's SOPS material and reaches the runner only as a pipe: ` | runner + --secret-fd 0`-style, or an explicitly opened fd 3 (`3< <(...)`) so stdin stays free for the founder's own + prompts. The value never appears in argv, environment, a file, a tmpfs path or shell history, and the runner + refuses `--secret` / `--secret-file` / `KEYCAPE_*_SECRET` env if present. The agent never holds the terminal, + the pipeline or the output; it only receives the value-free result. +3. Runner reads at most 4 KiB, until EOF, requires one non-empty token without whitespace (same rule as + `read_client_secret_file`), closes the fd, uses the value for exactly one client-credentials exchange, then + zeroes its buffer reference and does not retry or fall back. fd must be a pipe (S_ISFIFO), not a regular file or + tty, so a file copy or an echoed prompt is refused. Core dumps disabled (RLIMIT_CORE 0, PR_SET_DUMPABLE 0), no + swap-visible logging, no `repr` of the value. +4. Runner runs inside the admitted network (member of the internal network, no default route), only after + `check-keycape-recovery-selector.py --admission` passes for the founder-signed attempt for this exact case; the + run is single use inside the signed window. +5. Result is value-free JSON only: exchange accepted, subject/tenant/roles/scope claims match, `openbao:login` + accepted, `openbao:admin` refused, wrong-secret refusal (founder supplies a second unrelated token through the + same fd mechanism), session `revoke-self` attempted and succeeded, secret source name + `founder_attended_pipe_fd`, cleanup facts. +6. The decrypt step is the founder's: SOPS/age key custody is NetKingdom's and the founder's. Which exact backup field + and the decrypt command are for NetKingdom to name; secrets-engine will not name a path to the value and will not + run the decrypt. Not a bypass of the existing "no persistent consumer copy" rule: nothing persists. +7. Alternative considered and not preferred: the issuer's own `service-token` command mints a token in the issuer + tmpfs and hands only that token over private stdin to the runner (plan claim 2). That needs a new NK49 source + closure and review and hands a token, not the client secret, so it does not exercise the client's own secret and + would not prove the secret in the backup is the one the consumer uses. Offered as a fallback only. + +Gaps and owner items: a new reviewed runner file (this task, after founder approval of the design and the +SECRETS-WP-0015 attempt admission for phase B); NK49 names the backup field and decrypt command; the founder signs a +new admission, since the 2026-10-23 window ended 2026-10-24T05:00Z. Status stays `wait`. + +ISRG Root X1 trust of restored OpenBao for /jwks: UNPROVEN in this repository. What the evidence does show: the +KeyCape side served a production letsencrypt-prod chain to ISRG Root X1 (NK49 T18 verifier, receipt 16d72f7c) and +the restored OpenBao's `keycape-services` JWT config and role were present and equal to source (RPF-WP-0064-T07, +receipt 4e048dee), with no data read and no JWKS fetch, because both ran `--network none`. No rehearsal here ever +made OpenBao fetch `/jwks`; the only JWKS fetches in this repo are the consumer-side Python tests +(`tools/exercise_approval_identity.py`, synthetic CA via SSL_CERT_FILE), which say nothing about OpenBao's trust +store. Whether it trusts the chain depends on facts only the OpenBao owner can read: the OpenBao image's system CA +bundle, and whether the restored JWT config sets `jwks_ca_pem` / `oidc_discovery_ca_pem` (a pinned CA would +override or restrict system roots). Requested of railiance-platform in its phase B rehearsal: show those two +settings as present/absent (value-free), and a bounded probe from inside the restored OpenBao network namespace +that OpenBao-side validation of a JWT against `https://kc.coulomb.social/jwks` succeeds, using the production +chain and not LE staging (staging roots are not ISRG Root X1). The first live login attempt is the real proof; it +should be preceded by that probe so a trust failure is not misread as a consumer failure.