SECRETS-WP-0015-T04: phase B client-secret custody proposal and ISRG trust status
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 4515@bnt-lap001
Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
This commit is contained in:
tegwick 2026-10-27 21:15:25 +01:00
parent 9d3de49000
commit 65a3dbe103

View file

@ -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=<callable>)` 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: `<founder decrypt of the one field> | 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.