railiance-platform/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md
codex 93ac156adf close first-generation lane and retain rotation residual atomically
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 18:46:21 +02:00

17 KiB
Raw Blame History

id type title domain repo status owner created updated state_hub_workstream_id
RPF-WP-0054 workplan Broker lane keycape-client-secret-generate: generate KeyCape client secrets in custody financials railiance-platform finished claude-code 2026-10-09 2026-10-10 73694adc-12a0-5226-a814-ee096d158f60

Demand: net-kingdom SecurityCaseType oidc-browser-relying-party 1.0 (net-kingdom/security-case-types/oidc-browser-relying-party/type.yaml), follow-through step mint-secret: owner railiance-platform, lane keycape-client-secret-generate, warden_executes: true, attended: false, value_handling: generated_in_custody. It must "generate a client secret inside custody at bao:platform/workloads/keycape/{client_id} using the caller's broker grant for the case's KV path, and never return the value". Requests: ops-mason 789feb39 (2026-10-08) and ops-warden d9f889b1 (2026-10-09); this gates WARDEN-WP-0044-T02 and the Forgejo reference case NK-WP-0048-T06.

Today the broker (scripts/credential.py: request, exec, status, revoke) only leases credentials, and credential-change.py only applies or moves material the caller provides. Nothing generates a value. ops-warden needs a command, a grant shape and a verification that does not read the value.

Meaning of "in custody". OpenBao KV cannot generate values, and sys/tools/random returns bytes to its caller. The value is therefore generated in the broker process (secrets module) and goes only to that process's memory and the bao child's stdin, the same rule as the attended seed/move helpers. It never appears in argv, stdout/stderr, files, receipts, State Hub or the caller's environment. The caller receives a receipt holding the path, version, timestamps and an outcome code.

Scope. Only the first generation for a new client (KV version 1, CAS 0). Rotation needs update on an existing entry plus coordination with the KeyCape restart and ESO refresh. The case type marks it automatable false until a run has been proven, so it is a separate, later task (T07), not part of this lane. Delivery (deliver-secret, ESO) and the per-case read policies remain the case's client-secret-path artifacts and are unchanged here.

T01 — Confirm the value and caller contract with key-cape and ops-warden

id: RPF-WP-0054-T01
status: done
priority: high
state_hub_task_id: "38f92db0-317e-5b2b-8bbd-b4af3272cc21"

Confirm with key-cape: client_id pattern (proposed ^[a-z0-9][a-z0-9-]{2,62}$), field name (CLIENT_SECRET, single field), secret length and alphabet (proposed 48 random bytes, URL-safe base64 without padding, 64 characters), and that KeyCape reads it only through the ESO-delivered Secret. Confirm with ops-warden which identity calls the broker under its caller proxy, and how the accepted case reference (case id + acceptance record digest) is passed. That reference goes into the receipt and into OpenBao custom_metadata (non-secret).

Done when: both replies are recorded here, with no open question about format, field or caller.

2026-10-09 ops-warden (23985370): the caller is the human operator who runs warden case run. Warden runs credential.py generate as a child process in the caller's environment and holds no token or grant, so the caller's own OIDC session is the acting identity. Warden refuses the step under WARDEN_AGENT_ID. The case reference is <case_id>@sha256:<hex64> over the case file's exact bytes (warden case plan prints it). The platform must also take --case-file, re-derive the digest, and check that status is materialized and that parameters.client_id equals --client-id. The client_id pattern is taken from the case type, ^[a-z0-9][a-z0-9-]{1,62}$ (authoritative; it replaces the proposal above). Still open: key-cape confirmation of length and alphabet, asked 2026-10-09.

2026-10-10 key-cape (4e0213b3): format confirmed as proposed, 48 random bytes as URL-safe base64 without padding (64 characters) in the single field CLIENT_SECRET, read only through ESO Secret sso/keycape-{client_id}-client. KeyCape compares secrets as opaque strings in constant time and imposes no length or alphabet limit. It requires only a non-empty clientId; the pattern belongs to the case type. T01 is closed.

T02 — Decide the grant shape

id: RPF-WP-0054-T02
status: done
priority: high
state_hub_task_id: "4183abc4-4acc-5937-9f27-005feb181025"

Two options, both create-only (ACL create without update, so an existing client's secret can never be overwritten through this lane):

  • A — one templated grant (recommended). Grant keycape/client-secret-generate holds policy keycape-client-secret-generate: create on platform/data/workloads/keycape/+, read on platform/metadata/workloads/keycape/+ (absence proof and version verification, no value), and explicit deny on existing reserved entries (today keycape/secrets-engine-openbao). The broker additionally refuses a client_id with no accepted case reference and refuses if metadata already exists. Each case needs no new policy or CCR; the reach is "create any new KeyCape client path", and the caller cannot read values.
  • B — one exact grant per case. Each case's client-secret-path artifact adds a create-only stanza for its own path, applied through a CCR before mint-secret. This keeps the tightest ACL, but every case needs an attended policy apply, which conflicts with attended: false for this step.

Recommendation: A. Its widest misuse is creating an unused entry for a client that does not exist. That cannot leak or overwrite a value, and metadata audit plus the case-reference check make it visible.

Done when: the founder records the choice here (and on a State Hub decision if one is opened).

Decision 2026-10-09 (founder): option A, one templated create-only grant keycape/client-secret-generate with the broker-side case-ref and absence checks above. T04 implements it; per-case exact grants (B) are not built.

T03 — Implement the broker generate operation

id: RPF-WP-0054-T03
status: done
priority: high
depends_on: [RPF-WP-0054-T01, RPF-WP-0054-T02]
state_hub_task_id: "3e159e92-f5ee-5a92-b887-e06daf7dfbdb"

Add credential.py generate --grant keycape/client-secret-generate --client-id <id> --case-ref <ref> [--receipt <file>] [--dry-run]:

  1. Validate the grant, the client_id pattern, the reserved-entry denylist and the case-ref shape. Refuse wildcard, parent or extra segments.
  2. Obtain the grant's short-lived token through the existing lease path. The caller's identity is used, and the token is never printed.
  3. Prove absence: metadata read returns 404. Anything else refuses (exit 3 if present).
  4. Generate in process and write {"options":{"cas":0},"data":{"CLIENT_SECRET":...}} through stdin.
  5. Verify by metadata only: current_version 1, not deleted. Never read the data path. The case-ref goes in the receipt and the hub metadata, not in OpenBao custom_metadata: writing that needs metadata update, which would also let the token change delete_version_after/max_versions of other clients' entries (decided while doing T04, 2026-10-09).
  6. Revoke the lease. Write the receipt (0600, O_EXCL, versions, booleans and outcome only). stdout/stderr stay silent apart from the fixed outcome code.

Tests with a fake bao: success, already-present refusal, CAS-0 race loser, reserved path, bad client_id, missing case-ref, write failure, verify mismatch, silence contract, and an assertion that no data-path read occurs. No secret text appears in any receipt.

2026-10-09 progress: scripts/keycape_client_secret_generate.py, reached as credential.py generate --grant keycape/client-secret-generate --client-id <id> --case-file <case.yaml> --case-ref <ref> [--receipt f] [--dry-run] [--record-state-hub]. It refuses agents, root, and callers without the issuer policy. It verifies the case file as ops-warden specified, mints the 5m grant token, and makes every KV call with that token only. It proves absence, writes once with CAS 0, verifies by metadata (version 1), revokes the token, and prints nothing. There are 8 offline tests. Remaining before done: key-cape's format confirmation (SECRET_BYTES is the proposed 48 bytes, 64 URL-safe characters). 2026-10-10: key-cape confirmed the format; SECRET_BYTES stays as is. T03 is done. The live probe passed under T05. The first real run (Forgejo) needs the case net-kingdom/security-cases/forgejo-netkingdom-oidc.yaml to reach materialized (today accepted; ops-mason's step).

T04 — Grant, policy and token role source plus CCR

id: RPF-WP-0054-T04
status: done
priority: high
depends_on: [RPF-WP-0054-T02]
state_hub_task_id: "74eb1309-df16-5163-9840-ffb88072d09e"

Add the grant to credential-grants/catalog.yaml (class per T02, TTL 5m, exec delivery only, no response-wrap). Add the policy under openbao/policies/, the issuing token role, and a CCR for the attended apply. Agent boundary: coding-agent identities keep deny on the keycape data paths. Check whether the existing agent-high-risk-boundary lane entries need a keycape/+ deny and run make agent-high-risk-boundary-check. make credential-grants-validate and the token-grant dry-runs must pass.

2026-10-09 done: CCR-2026-0040 (request type custody-generate-grant, scripts/broker_grant_lane.py) writes the grant policy keycape-client-secret-generate (create on keycape/+ data, read on keycape/+ metadata, deny on keycape/secrets-engine-openbao, revoke-self), the issuer policy credential-broker-keycape-client-secret-generate-issuer and the token role (5m, orphan, non-renewable, no default policy), then reads all three back. The catalog grant is pilot/approval-required, exec-env only. The apply binds the issuer policy to no identity, so it is inert until T01 names the caller. Agent boundary: no coding-agent policy grants any keycape/ path, and the create-only grant is never attached to an agent identity. No new boundary deny is needed.

T05 — Attended apply and disposable probe

id: RPF-WP-0054-T05
status: done
priority: high
depends_on: [RPF-WP-0054-T03, RPF-WP-0054-T04]
state_hub_task_id: "a21d1915-fee9-5c31-89e7-a4560c645950"

The CCR apply can run any time; it is inert until the issuer binding exists:

cd ~/railiance-platform
export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200
python3 scripts/credential-change.py approve CCR-2026-0040 --reviewer "platform-owner, key-cape-owner (founder Bernd Worsch)" --comment "Approve create-only broker grant keycape/client-secret-generate (RPF-WP-0054-T02 option A)." \
 && warden access openbao-platform-admin-login --exec -- python3 scripts/credential-change.py applier-apply CCR-2026-0040 --confirm "DELEGATED APPLY CCR-2026-0040" --actor founder --record-state-hub --quiet

2026-10-09: CCR-2026-0040 applied by the founder at 17:19Z; readback matched for both policies and the token role. The run then printed a traceback from the State Hub evidence step (KeyError on openbao.auth for a prefix lane), so warden revoked the session. That was fixed and the hub event was posted afterwards. The OpenBao objects were complete before the crash.

2026-10-10: the founder chose net-kingdom-admins + aal2 for the caller. CCR-2026-0043 (request type broker-issuer-binding, scripts/issuer_binding_lane.py) adds OIDC role credential-broker-keycape-client-secret-generate on auth/netkingdom. It carries only the issuer policy and has loopback callbacks, a 15m TTL, 8 uses and no default policy. The apply refuses unless the live issuer policy matches source.

Founder session (probe uses a synthetic, founder-written case file; the real Forgejo run waits for key-cape's format confirmation):

cd ~/railiance-platform
export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200
# 1. binding
python3 scripts/credential-change.py approve CCR-2026-0043 --reviewer "platform-owner, key-cape-owner (founder Bernd Worsch)" --comment "Approve issuer binding for keycape-client-secret-generate: net-kingdom-admins + aal2 (RPF-WP-0054)." \
 && python3 scripts/credential-change.py confirm-binding CCR-2026-0043 --reviewer founder --comment "groups=[net-kingdom-admins] and /assurance/level=aal2, same binding as CCR-2026-0034." \
 && warden access openbao-platform-admin-login --exec -- python3 scripts/credential-change.py applier-apply CCR-2026-0043 --confirm "DELEGATED APPLY CCR-2026-0043" --actor founder --record-state-hub --quiet
# 2. probe case (synthetic, materialized, never registered in KeyCape)
P=probe-secret-generate-20261010
cat > ~/$P-case.yaml <<YAML
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
  id: $P
  type: oidc-browser-relying-party@1.0
  status: materialized
  parameters:
    client_id: $P
    client_type: confidential
YAML
REF="$P@sha256:$(sha256sum ~/$P-case.yaml | cut -d' ' -f1)"
# 3. log in as the bound operator role (token goes to the bao token helper)
bao login -no-print -method=oidc -path=netkingdom role=credential-broker-keycape-client-secret-generate
G="python3 scripts/credential.py generate --grant keycape/client-secret-generate --client-id $P --case-file $HOME/$P-case.yaml --case-ref $REF"
$G --dry-run --receipt ~/$P-dry.json
$G --receipt ~/$P-1.json            # expect generated, kv_version 1
$G --receipt ~/$P-2.json            # expect exit 3, already_present
bao token revoke -self
cat ~/$P-dry.json ~/$P-1.json ~/$P-2.json
# 4. remove the probe entry
warden access openbao-platform-admin-login --exec -- sh -c "bao delete platform/metadata/workloads/keycape/$P >/dev/null 2>&1"

2026-10-10 first probe: CCR-2026-0043 applied cleanly and the OIDC login succeeded, but all three generate runs exited 4 (grant_token_create_failed) and the operator's revoke-self returned 403. Nothing was written. Causes: (1) the role set token_num_uses: 8, and OpenBao refuses child-token creation from a limited-use token. The role now uses 0, which the lane validator enforces. (2) The issuer policy lacked auth/token/revoke-self; it is now added. Re-apply CCR-2026-0040 (issuer policy) before CCR-2026-0043 (role), because the binding apply refuses unless the live issuer policy matches source.

2026-10-10 second probe passed after re-applying both CCRs (09:15Z). The caller token held only the issuer policy with num_uses 0. Results: dry-run dry_run_would_generate; generate generated, kv_version 1; second run exit 3 already_present. The grant token was revoked every time, operator revoke-self succeeded, and no value was read. The probe entry was then metadata-deleted. Receipts: docs/evidence/2026-10-10-keycape-client-secret-generate-probe.json.

The policy denials (data read, update, delete, other paths) were already proven by the CCR-2026-0040 readback and its policy tests. The probe proves the live create-once behaviour.

Record the receipts under docs/evidence/.

T06 — Hand the lane to ops-warden and net-kingdom

id: RPF-WP-0054-T06
status: done
priority: medium
depends_on: [RPF-WP-0054-T05]
state_hub_task_id: "d7015584-3429-5f32-b4bc-0a9b64017c15"

Reply to ops-warden d9f889b1 and ops-mason 789feb39 with the command, the grant shape and the verification (metadata version plus capabilities, no value read). ops-warden catalogs the lane (pointer-only entry); net-kingdom can then plan NK-WP-0048-T06. Consider declaring the c3.runtime-secrets capability so the case type can compose mint-secret instead of treating it as a follow-through step (net-kingdom case README "Known limits").

2026-10-10: ops-warden catalogued the lane as draft (ops-warden 0ae8a0e, playbook wiki/playbooks/keycape-client-secret-generate.md) and activates it when platform confirms. Probe receipt sent. Activation is held until key-cape confirms the secret format (T01/T03).

2026-10-10: ops-warden activated the lane (ops-warden 6414252). The playbook leads with the issuer-role login, and warden case check-type reports oidc-browser-relying-party 1.0 as plannable. WARDEN-WP-0044-T03 now waits only on the Forgejo case reaching materialized. The c3.runtime-secrets capability is left for a later declaration; mint-secret works as a follow-through step. T06 is done.

T07 — Rotation lane (separate, later)

id: RPF-WP-0054-T07
status: cancel
priority: low
depends_on: [RPF-WP-0054-T05]
state_hub_task_id: "d5f0beb6-54dc-502b-9c13-0cb6892ab87d"

Design keycape-client-secret-rotate: update with CAS = current version, an attended window coordinated with ESO refresh and the KeyCape restart, and rollback to the previous version. Only after one proven first-generation run. Not part of the mint-secret gate.

Closure — 2026-10-10

The first-generation lane is complete: T01–T06 record owner contract acceptance, source implementation, CCR-2026-0040/0043 attended apply, the disposable CAS-0 probe and self-revocation/cleanup receipts, and ops-warden activation at 6414252. The live proof is docs/evidence/2026-10-10-keycape-client-secret-generate-probe.json. This closes the lane itself; it does not claim the Forgejo reference case is live or replace its independent MASON-WP-0010 / WARDEN-WP-0044 acceptance.

T07 was explicitly outside this lane's scope (separate, later rotation). It is cancelled here as transferred, not implemented. The actionable follow-up is RPF-WP-0055, flavor: residual, origin_ref: RPF-WP-0054; no demand or risk promotion is claimed. Rotation requires its own reviewed CAS/update authority, ESO and KeyCape coordination, verification and rollback.