Read the secrets-engine-openbao client secret in memory from OpenBao
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Founder direction 2026-10-06 and ops-mason plan
keycape-secrets-engine-openbao-client-custody (658cdbc): no workstation copy.
service-jwt now gets CLIENT_SECRET from platform/workloads/keycape/
secrets-engine-openbao through one attended OIDC login on
keycape-secrets-engine-openbao-operator-workload-kv-read (-no-store, token from
a pipe, header-only GET, unconditional revoke-self). The consumer file is
refused for this profile and kept for the approval client.
service-auth probe-openbao prints value-free first-use evidence.

SECRETS-WP-0012: T02 cancelled (D1-D4 superseded), T04 done, T03 is the
founder-run first live use. Synthetic tests only; 524 passed, 1 skipped.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 132360@bnt-lap001
Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
This commit is contained in:
tegwick 2026-10-06 14:13:41 +02:00
parent 6941610b8d
commit b8094f701b
12 changed files with 688 additions and 185 deletions

View file

@ -43,7 +43,7 @@ secrets-engine --version
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | _(unset)_ | explicit approval bearer file; conflicts with approval client-secret provider |
| `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | _(unset)_ | KeyCape token endpoint for `service-jwt` |
| `SECRETS_ENGINE_KEYCAPE_ISSUER` | _(unset)_ | KeyCape issuer; must match the JWT login contract |
| `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | mode-0600 out-of-repo client secret |
| `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | **refused** since 2026-10-06: the secrets-engine-openbao secret is read in memory from OpenBao per command (see keycape-openbao-consumer-custody.md) |
| `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` | _(unset)_ | platform JWT mount/role contract YAML |
## Commands

View file

@ -1,154 +1,114 @@
# KeyCape `secrets-engine-openbao` consumer custody packet
# KeyCape `secrets-engine-openbao` client secret: consumer custody
Prepared 2026-10-06 by secrets-engine for KEY-WP-0037-T02 and
RPF-WP-0035-T02 (proposed CCR-2026-0033 custody child). Tracked in
SECRETS-WP-0012. **Source and documentation only.** No value was generated,
fetched or written; no host, cluster, OpenBao or KeyCape state was changed.
The founder's custody-boundary decision `632a8893-0d6a-4591-a50b-40bbdb798f32`
approves the two-copy boundary, not generation, fetch, delivery, registration
or deployment. Each live step below still needs its own approval.
Rewritten 2026-10-06 by secrets-engine. Tracked in SECRETS-WP-0012.
**Source and documentation only.** No value was generated, fetched or written;
no host, cluster, OpenBao or KeyCape state was changed.
This packet names the **consumer copy only**: the secrets-engine-owned
mode-0600 file. The authoritative copy (platform KV
`platform/services/keycape/secrets-engine-openbao`, field `CLIENT_SECRET`) and
the issuer-side ESO copy (`sso` Secret `keycape-secrets-engine-openbao-client`,
key `client-secret`) are platform's and KeyCape's. Issuer ESO does not supply
this copy, and nothing here adds a client-side OpenBao reader.
## Direction
## What the code supports (determined from source)
Founder direction 2026-10-06: the `secrets-engine-openbao` KeyCape client
secret lives **only in OpenBao**. There is no persistent workstation copy.
ops-mason decided placement and the read lane in
`ops-mason/plans/keycape-secrets-engine-openbao-client-custody.md` (commit
`658cdbc`, workplan MASON-WP-0009; status *reviewed*, founder approvals A1–A3
pending). That direction and plan **supersede the earlier consumer-file packet
and its decisions D1–D4** (host, OS user, persistent file path, rotation
cadence). The consumer-file custody child in CCR-2026-0033 is out of direction
and is being revised by railiance-platform.
- secrets-engine is a CLI, not a daemon. The OpenBao JWT login runs inside
whichever process an operator starts with `--auth service-jwt` (or `auto`
once `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` is set); it logs in for one command
and self-revokes (`engine_auth.login_service_jwt`). There is no service
account, unit file or container for the engine.
- The provider reads the secret from the path in
`SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (`config.py`), rereads it before
every exchange and sends it only as HTTP Basic (`service_auth.py`).
- As of this packet the read goes through `read_client_secret_file`, which
refuses, without exposing the value: a relative path; a missing file; any
symlink or non-canonical path component; a non-regular file; a file or
parent directory not owned by the effective user; any group/other mode bit on
the file (must be 0600) or its parent (must be 0700); a path inside a Git
worktree; empty content; content that is not exactly one token. Before this
change only the file mode, worktree and empty checks existed. The approval
profile shares the same provider and therefore the same checks.
- `secrets-engine service-auth check-secret-file [--path P] [--json]` runs those
checks and prints check names and pass/fail only — never the value, its
length or any hash. Exit 0 = accepted, 1 = refused. This is the bounded
validation interface RPF-WP-0035-T02 asked for.
## Coordinates (non-secret)
## Consumer coordinates
| Item | Value |
|---|---|
| Authoritative entry | KV v2 mount `platform`, path `workloads/keycape/secrets-engine-openbao` (API `platform/data/workloads/keycape/secrets-engine-openbao`), field `CLIENT_SECRET` |
| Issuer copy | `sso` Secret `keycape-secrets-engine-openbao-client` key `client-secret`, via ESO on Kubernetes auth (platform/KeyCape; unchanged) |
| Consumer copy | **none** — the value exists only in process memory for one command |
| Consumer read lane | OIDC mount `auth/netkingdom`, role `keycape-secrets-engine-openbao-operator-workload-kv-read`, policy `workload-kv-read-keycape-secrets-engine-openbao-operator` (read on the data path, `update` on `auth/token/revoke-self`, nothing else), 5m TTL, 4 uses, bound `groups=net-kingdom-admins` |
| Companion config | `BAO_ADDR=https://bao.coulomb.social`, `SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token`, `SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social`, `SECRETS_ENGINE_OPENBAO_JWT_LOGIN=<platform-published contract file>` |
| Coordinate | Value | Basis |
| --- | --- | --- |
| Host | Operator workstation (the WSL2 Linux host where every documented live secrets-engine OpenBao operation runs against `BAO_ADDR=https://bao.coulomb.social`) | Documented practice; **founder decision D1** |
| OS user / owner | `worsch`, file and parent `worsch:worsch` | The account that runs the CLI; **founder decision D2** |
| File | `/home/worsch/.secrets-engine/keycape/secrets-engine-openbao.client-secret` | Follows existing `~/.secrets-engine/bootstrap/` convention |
| File mode | `0600`, regular file, single token, no trailing data other than one newline | Enforced by code |
| Parent | `/home/worsch/.secrets-engine/keycape`, `0700`, owned by `worsch` | Enforced by code |
| Grandparent | `/home/worsch/.secrets-engine` exists today as `0755` with only `0700` children; not changed by this packet | Observed metadata only |
| Git | Outside every worktree (`/home/worsch` and `~/.secrets-engine` are not worktrees) | Enforced by code |
| Storage | Persistent ext4 home, not tmpfs | **Founder decision D3** |
| Config reference | `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE=<file above>` | `config.py` |
| Companion config | `SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token`, `SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social`, `SECRETS_ENGINE_OPENBAO_JWT_LOGIN=/home/worsch/.secrets-engine/keycape/openbao-jwt-login.yaml` (non-secret `mount`/`role: secrets-engine-openbao`/`bound_issuer`, content published by platform) | `engine_auth.py`, platform design |
The mount, path, field and role are pinned in
`service_auth.OpenBaoOidcClientSecretSource`; any other value is refused.
One host and one file. A second host or user would be a third copy and needs
its own approval; the engine has no mechanism that distributes the file.
## What the code does (per command that selects `service-jwt`)
## Delivery (platform custody → this file)
1. Runs `bao login -method=oidc -path=netkingdom -no-store -format=json
role=keycape-secrets-engine-openbao-operator-workload-kv-read`. The
browser/MFA prompt goes to the operator's terminal (stderr); the login
response is captured from a **stdout pipe into memory**. `BAO_TOKEN` and
`VAULT_TOKEN` are removed from the child environment, and `-no-store` keeps
the token out of the token helper. For non-token methods the CLI does not
send a helper-stored token. The token never appears in argv, env, a file,
a log or evidence.
2. One in-process HTTPS `GET /v1/platform/data/workloads/keycape/secrets-engine-openbao`
with the token only in the `X-Vault-Token` header; extracts `CLIENT_SECRET`
(must be exactly one non-empty token).
3. `POST /v1/auth/token/revoke-self` with the same token — always, also when
the read fails.
4. The value goes in memory to the KeyCape `/token` `client_credentials`
exchange (HTTP Basic only), then the existing OpenBao JWT login on the
platform role and its self-revoke at command end. The value is dropped
when the exchange request is built.
Attended operator path, executed on the host above as `worsch`, inside the
platform-owned custody child of CCR-2026-0033:
Refusals: `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` set → refused for this
profile; a `KeyCapeServiceAuthConfig` with a file → refused; a provider for this
profile without the in-memory source → refused. `service-jwt` still never falls
back to `--bootstrap-token-file`, `BAO_TOKEN` or AppRole. The approval client
(`secrets-engine-approval`) keeps its own file source and
`service-auth check-secret-file` for it.
1. Preconditions: founder approval of the exact custody child; platform's
operator OpenBao session for the KV write; `umask 077`; shell tracing and
credential-bearing debug logs off.
2. The custody child generates one random value **in process memory** and
compare-before-writes it to the authoritative KV field. If that write fails,
no consumer file is written.
3. In the **same process**, it writes the consumer copy: create the parent
with `0700` if absent (refuse if it exists with other owner/mode); open a
new temp file in that directory with `O_CREAT|O_EXCL|O_WRONLY`, mode `0600`;
write, `fsync`, `rename` onto the final path; `fsync` the directory. The
value never appears in argv, environment, stdout/stderr, logs, Git or the hub.
4. It then runs `secrets-engine service-auth check-secret-file --json` and
records only that result plus non-secret IDs in the native receipt.
Evidence is value-free: `secret_source=openbao-oidc-in-memory`, the operator
and engine accessor fingerprints (12 hex chars of SHA-256), and both
revoke-self results. Tests with synthetic values:
`tests/test_openbao_secret_source.py`.
No client-side read of OpenBao is used or required: the consumer copy is
written from the same in-memory value that creates the authoritative version.
If the founder rejects same-process delivery, the only alternative is a
separately admitted, single-path client-side reader (as RPF-WP-0035-T06 does
for the approval client); this packet does not request one.
## First live use (founder-run, attended)
Ownership: platform owns generation, the authoritative write and the delivery
code path; secrets-engine owns these destination coordinates, the acceptance
check and the file's lifecycle on the host.
Run only after **all** of: founder approval of the ops-mason plan (A1), the
revised CCR-2026-0033 applied by railiance-platform (A2/A3: KV entry written,
operator OIDC role and policy, ESO reader, JWT role), the KeyCape
`secrets-engine-openbao` registration live and KeyCape restarted after the KV
write (KEY-WP-0037), and the platform JWT login contract file published. This
is the evidence step for KEY-WP-0037-T04 / RPF-WP-0035-T02 and plan
verification item 6.
## Detecting a failed delivery
```bash
cd /home/worsch/secrets-engine && \
env -u BAO_TOKEN -u VAULT_TOKEN -u SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE \
BAO_ADDR=https://bao.coulomb.social \
SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token \
SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social \
SECRETS_ENGINE_OPENBAO_JWT_LOGIN=/home/worsch/.secrets-engine/keycape/openbao-jwt-login.yaml \
uv run secrets-engine service-auth probe-openbao --json
```
- `check-secret-file` exits 1 and names the failing check (missing, mode,
owner, parent, symlink, worktree, empty). The receipt must carry `ok: true`.
- At use time the same checks refuse the exchange with `ProvisioningError`
(exit 6); `service-jwt` never falls back to bootstrap, `BAO_TOKEN` or AppRole.
- A value that is well-formed but does not match the issuer is detectable only
by the first live exchange (KEY-WP-0037-T04): KeyCape answers HTTP 401 and
the engine reports `KeyCape token exchange failed with HTTP 401`. No hash or
fingerprint of the value is recorded to compare copies; that is deliberate.
- Crash between the KV write and the rename: the KV version exists and the
consumer file does not (or a stale temp file remains in the 0700 directory).
Recovery is a fresh rotation, not a KV read; the operator removes the exact
temp file after inspecting its name and metadata only.
A browser opens for the KeyCape login with MFA (an existing session is
reused); stderr shows the `bao` OIDC prompt. Expected stdout, exit 0 — one
line, only these keys, `<fp>` being 12 hex characters:
## Rotation and revocation
```json
{"auth_provider": "service-jwt", "established": true, "ok": true, "operator_revocation_attempted": true, "operator_revocation_succeeded": true, "operator_session_handle": "<fp>", "revocation_attempted": true, "revocation_succeeded": true, "secret_source": "openbao-oidc-in-memory", "session_handle": "<fp>"}
```
| Event | Owner | Action |
| --- | --- | --- |
| Generation / authoritative version | railiance-platform | New KV version via the custody child |
| Issuer pickup | KeyCape / NetKingdom | ESO refresh plus KeyCape restart (secrets load at startup) |
| Consumer copy replacement | secrets-engine (file), executed in platform's attended child | Atomic rename as above, then `check-secret-file` |
| Disable on compromise | KeyCape / NetKingdom | Remove or disable the registration first |
| Retire versions | railiance-platform | Destroy/retire per CCR |
| Remove consumer copy | secrets-engine operator | See rollback below |
Anything else is a failure to record, not to retry blindly: exit 1 with
`error: ...` on stderr names the failed step (OIDC login exit code, KV HTTP
status, KeyCape HTTP status, JWT login) and, for a source failure, both
operator revoke flags. `ok: false` means a revoke-self did not succeed; the
token stays TTL/use-bound (5m/4 uses, engine token per platform role). Record
the JSON line only; never a value, token or full accessor.
KeyCape holds one secret per client, so rotation has a cutover window: hold
the new value in the attended process, replace the consumer file only after
the issuer reload is confirmed, and accept that exchanges fail closed (401) in
between. Already-issued JWTs live at most 15 minutes; OpenBao tokens from this
login are self-revoked per command and bounded by platform's TTL/use limits.
No instant JWT recall is claimed.
## Rotation, revocation, rollback
Rotation cadence is **founder decision D4**.
## Rollback and cleanup
1. Unset `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (and the JWT login
contract variable) in the operator environment.
2. Remove the exact file path (no recursive removal); `rmdir` the parent only
if it is empty.
3. Verify with `check-secret-file --path <file>`: it must exit 1 on `exists`.
4. Record cleanup status only. Unlink on ext4 is not secure erasure and not
revocation; revocation is the KeyCape and platform steps above.
## Founder / owner decisions (with recommendation)
- **D1 Host.** Recommended: the operator workstation only. Alternative:
railiance01, which has no documented secrets-engine OpenBao operator use and
would need its own user and path decision.
- **D2 OS user.** Recommended: the operator account `worsch`, since the engine
runs only as that user's CLI. A dedicated system user only makes sense once
an unattended engine runtime exists, and no source supports one today.
- **D3 Persistence.** Recommended: persistent home storage. tmpfs would
require re-delivery after every reboot, and every re-delivery would need either
a rotation or an unadmitted client-side KV read. Open owner question: whether
any workstation backup or sync tool covers `~/.secrets-engine`. If one does,
exclude it there before delivery.
- **D4 Rotation cadence.** Recommended: event-driven (compromise, host or user
change, operator departure) plus a 90-day scheduled rotation run through the
same attended child. Platform's CCR sets the binding value.
Platform-owned (plan §3.5): roll-forward KV writes with `cas=<current>`,
event-driven plus 90 days, then KeyCape restart. The consumer needs no action:
the next command reads the latest version. Exchanges fail closed (401) between
the KV write and the KeyCape restart. On compromise KeyCape/NetKingdom disable
the registration first. secrets-engine can neither write nor rotate this entry
and never reads an old version.
## Not established
No registration, issued JWT, KV version, ESO Secret or consumer file exists
by implication of this packet. Code tests use synthetic values only and prove
file acceptance/refusal, not live delivery, issuer match or OpenBao login.
No live OIDC login, KV read, revoke-self, KeyCape exchange or JWT login has
run. The operator role, policy, KV entry and JWT contract file do not exist
yet as far as this repository knows. `bao login -no-store -format=json`
output shape for OIDC is taken from the CLI contract and is first exercised by
the founder run above.

View file

@ -12,17 +12,21 @@ the non-cryptographic contract before any future OpenBao login:
and renewal when no more than three minutes remain;
- no ID token, refresh token, implicit retry provider, or fallback identity.
The confidential-client secret must be an explicitly selected mode-0600 file
outside every Git worktree. It is sent with HTTP Basic authentication and never
placed in a request body, command argument, evidence record, or object
representation.
Since 2026-10-06 the file must also be a canonical regular file (no symlink
component) owned by the running user, in a parent directory owned by that
user with mode 0700, and hold exactly one token.
`secrets-engine service-auth check-secret-file [--path P] [--json]` reports
those checks without the value. Consumer host, path, delivery and lifecycle
coordinates: [keycape-openbao-consumer-custody.md](keycape-openbao-consumer-custody.md).
Since 2026-10-06 (founder direction; ops-mason plan
`keycape-secrets-engine-openbao-client-custody`) the `secrets-engine-openbao`
client secret has **no consumer file**. Each command that selects
`service-jwt` reads it in memory from OpenBao (`platform/workloads/keycape/secrets-engine-openbao`,
field `CLIENT_SECRET`) through one attended `bao login -method=oidc -no-store`
on role `keycape-secrets-engine-openbao-operator-workload-kv-read`, with the
token captured from a pipe, used only in a request header, and revoked with
`revoke-self` right after the read. Ambient `BAO_TOKEN`/`VAULT_TOKEN` and the
token helper are never used, and `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE`
is refused for this profile. The value is sent only with HTTP Basic
authentication and never placed in a request body, command argument, env,
file, evidence record, or object representation.
`secrets-engine service-auth probe-openbao [--json]` runs the whole chain and
prints value-free evidence. Coordinates, first-use command and lifecycle:
[keycape-openbao-consumer-custody.md](keycape-openbao-consumer-custody.md).
JWT payload parsing does not prove a signature. OpenBao must verify RS256
against the configured issuer before issuing a token. railiance-platform owns
@ -42,7 +46,8 @@ Canonical provider contract:
## Approval identity is separate
[Approval service authentication](approval-service-auth.md) uses its own
client-secret input, resource audience, exact tenant:platform and per-request
client-secret file (`SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE`, checked by
`service-auth check-secret-file`), resource audience, exact tenant:platform and per-request
read/consume scopes. Never put that credential into the OpenBao provider.
Both providers now validate KeyCape’s actual `level`/`methods` assurance shape;
the old `aal`/`method` fixture was incompatible with the issuer.

View file

@ -41,7 +41,7 @@ from secrets_engine.approval_consume import (
from secrets_engine.catalog import get_entry, load_catalog
from secrets_engine.config import Config, repo_root
from secrets_engine.decisions import require_approved, resolve_decision
from secrets_engine.errors import DecisionError, SecretsEngineError
from secrets_engine.errors import BackendError, DecisionError, SecretsEngineError
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
from secrets_engine.engine_auth import login_service_jwt, select_engine_auth
from secrets_engine.lane_state import (
@ -172,6 +172,8 @@ def _open_backend(cfg: Config, args, evidence: PrivilegedActionEvidence | None =
try:
if evidence is not None:
evidence.detail["auth_session_handle"] = session.accessor_fingerprint
for key, value in session.source_evidence.items():
evidence.detail[f"auth_{key}"] = value
yield session.client
finally:
try:
@ -957,18 +959,46 @@ def cmd_evidence_classify(cfg: Config, args) -> int:
# -- parser ----------------------------------------------------------------
def cmd_service_auth_probe_openbao(cfg: Config, args) -> int:
"""First-use proof for the secrets-engine-openbao profile. Value-free."""
import json as _json
try:
session = login_service_jwt(cfg)
except BackendError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
try:
session.close()
except BackendError:
pass
payload = {"auth_provider": "service-jwt", **session.evidence()}
ok = bool(
payload.get("operator_revocation_succeeded") and payload.get("revocation_succeeded")
)
payload["ok"] = ok
if args.json:
print(_json.dumps(payload, sort_keys=True))
else:
for key in sorted(payload):
print(f"{key}: {payload[key]}")
return 0 if ok else 1
def cmd_service_auth_check_secret_file(cfg: Config, args) -> int:
"""Value-free acceptance check of the KeyCape consumer secret file."""
import json as _json
from secrets_engine.service_auth import check_client_secret_file
# The secrets-engine-openbao profile has no consumer file (founder
# direction 2026-10-06); this check serves the approval-client file.
raw = args.path or (
str(cfg.keycape_client_secret_file) if cfg.keycape_client_secret_file else ""
str(cfg.approval_client_secret_file) if cfg.approval_client_secret_file else ""
)
if not raw:
print(
"error: no path; pass --path or set SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE",
"error: no path; pass --path or set SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE",
file=sys.stderr,
)
return 2
@ -1218,6 +1248,14 @@ def build_parser() -> argparse.ArgumentParser:
sacheck.add_argument("--json", action="store_true")
sacheck.set_defaults(func=cmd_service_auth_check_secret_file)
saprobe = sasub.add_parser(
"probe-openbao",
help="attended in-memory secret read, KeyCape exchange and OpenBao JWT "
"login, then self-revoke; prints value-free evidence only",
)
saprobe.add_argument("--json", action="store_true")
saprobe.set_defaults(func=cmd_service_auth_probe_openbao)
return p

View file

@ -16,7 +16,11 @@ import yaml
from secrets_engine.errors import BackendError
from secrets_engine.openbao import OpenBaoClient, ScopedTokenSession
from secrets_engine.service_auth import KeyCapeServiceAuthConfig, KeyCapeServiceAuthProvider
from secrets_engine.service_auth import (
KeyCapeServiceAuthConfig,
KeyCapeServiceAuthProvider,
OpenBaoOidcClientSecretSource,
)
_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
PROVIDERS = ("auto", "service-jwt", "bootstrap", "env")
@ -79,15 +83,22 @@ def load_jwt_login_contract(cfg: Any) -> JwtLoginContract:
def keycape_config(cfg: Any) -> KeyCapeServiceAuthConfig:
token_url = str(getattr(cfg, "keycape_token_url", "") or "")
issuer = str(getattr(cfg, "keycape_issuer", "") or "")
secret = _optional_path(getattr(cfg, "keycape_client_secret_file", None))
if not token_url or not issuer or secret is None:
if _optional_path(getattr(cfg, "keycape_client_secret_file", None)) is not None:
raise BackendError(
"service-jwt requires KeyCape token URL, issuer, and client-secret file"
"SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE is refused for the "
"secrets-engine-openbao profile; the secret is read in memory from OpenBao"
)
return KeyCapeServiceAuthConfig(
token_url=token_url,
issuer=issuer,
client_secret_file=secret,
if not token_url or not issuer:
raise BackendError("service-jwt requires KeyCape token URL and issuer")
return KeyCapeServiceAuthConfig(token_url=token_url, issuer=issuer)
def _source_note(source: OpenBaoOidcClientSecretSource) -> str:
ev = source.evidence()
return (
f"secret_source={ev['secret_source']} "
f"operator_revocation_attempted={ev['operator_revocation_attempted']} "
f"operator_revocation_succeeded={ev['operator_revocation_succeeded']}"
)
@ -129,11 +140,19 @@ def login_service_jwt(cfg: Any) -> ScopedTokenSession:
kcfg = keycape_config(cfg)
if contract.bound_issuer != kcfg.issuer:
raise BackendError("OpenBao JWT login issuer does not match KeyCape issuer")
service_jwt = KeyCapeServiceAuthProvider(kcfg).exchange()
source = OpenBaoOidcClientSecretSource(
bao_addr=cfg.bao_addr, bao_bin=shutil.which("bao") or ""
)
try:
service_jwt = KeyCapeServiceAuthProvider(kcfg, secret_source=source).exchange()
except BackendError as exc:
raise BackendError(f"{exc} [{_source_note(source)}]") from exc
bao_bin = shutil.which("bao") or shutil.which("vault") or ""
anon = OpenBaoClient(addr=cfg.bao_addr, token="", bao_bin=bao_bin)
try:
return anon.login_jwt(contract.mount, contract.role, service_jwt.token)
session = anon.login_jwt(contract.mount, contract.role, service_jwt.token)
session.source_evidence = source.evidence()
return session
finally:
# Drop the KeyCape JWT from this frame; OpenBao verifies the signature.
del service_jwt

View file

@ -88,10 +88,13 @@ class ScopedTokenSession:
closed: bool = False
revocation_attempted: bool = False
revocation_succeeded: bool = False
# Value-free evidence of the in-memory client-secret read, if any.
source_evidence: dict[str, object] = field(default_factory=dict)
def evidence(self) -> dict[str, object]:
"""Return non-secret lifecycle evidence for this issued session."""
return {
**self.source_evidence,
"session_handle": self.accessor_fingerprint,
"established": True,
"revocation_attempted": self.revocation_attempted,

View file

@ -10,14 +10,17 @@ from __future__ import annotations
import base64
import binascii
import json
import hashlib
import ipaddress
import os
import stat
import subprocess
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from urllib.request import Request, urlopen
from secrets_engine.errors import BackendError, ProvisioningError
@ -37,7 +40,10 @@ RENEW_WINDOW_SECONDS = 3 * 60
class KeyCapeServiceAuthConfig:
token_url: str
issuer: str
client_secret_file: Path
# The OpenBao profile takes no file: its secret is read in memory from
# OpenBao per command (OpenBaoOidcClientSecretSource). The approval
# profile subclass still uses a consumer file.
client_secret_file: Path | None = None
client_id: str = CLIENT_ID
subject: str = SUBJECT
audience: str = CLIENT_ID
@ -63,6 +69,11 @@ class KeyCapeServiceAuthConfig:
raise BackendError("KeyCape service identity claims must match accepted contract")
if self.timeout_seconds <= 0:
raise BackendError("KeyCape timeout must be positive")
if type(self) is KeyCapeServiceAuthConfig and self.client_secret_file is not None:
raise BackendError(
"secrets-engine-openbao client secret file source is refused; "
"the secret is read in memory from OpenBao per command"
)
@dataclass(frozen=True)
@ -238,13 +249,25 @@ def read_client_secret_file(path: Path, *, purpose: str = "KeyCape client secret
class KeyCapeServiceAuthProvider:
config: KeyCapeServiceAuthConfig
transport: Transport = field(default=urlopen, repr=False, compare=False)
# In-memory secret source. Required for the OpenBao profile; the approval
# profile reads its consumer file when this is None.
secret_source: Callable[[], str] | None = field(default=None, repr=False, compare=False)
def exchange(self, *, now: datetime | None = None) -> ServiceJWT:
"""Perform one client-credentials exchange; never retry or fall back."""
secret = read_client_secret_file(self.config.client_secret_file)
if self.secret_source is not None:
secret = self.secret_source()
elif type(self.config) is KeyCapeServiceAuthConfig or self.config.client_secret_file is None:
raise BackendError(
"secrets-engine-openbao requires the in-memory OpenBao secret source; "
"no file fallback"
)
else:
secret = read_client_secret_file(self.config.client_secret_file)
basic = base64.b64encode(
f"{self.config.client_id}:{secret}".encode("utf-8")
).decode("ascii")
del secret
body = urlencode(
{"grant_type": "client_credentials", "scope": self.config.scope}
).encode("ascii")
@ -292,3 +315,182 @@ class KeyCapeServiceAuthProvider:
):
raise BackendError("KeyCape token response lifetime is outside contract")
return preflight_service_jwt(token, self.config, now=now)
# -- in-memory OpenBao source for the secrets-engine-openbao profile ---------
#
# ops-mason plan keycape-secrets-engine-openbao-client-custody (658cdbc),
# founder direction 2026-10-06: the value lives only in OpenBao. Per command an
# attended OIDC login on one narrow role yields a 5m/4-use operator token that
# is captured from a pipe, used once in a request header for the KV GET, and
# revoked with revoke-self. Nothing is written to file, env, argv or cache.
OIDC_MOUNT = "netkingdom"
OIDC_ROLE = "keycape-secrets-engine-openbao-operator-workload-kv-read"
KV_MOUNT = "platform"
KV_PATH = "workloads/keycape/secrets-engine-openbao"
KV_FIELD = "CLIENT_SECRET"
SOURCE_NAME = "openbao-oidc-in-memory"
_AMBIENT_TOKEN_ENV = ("BAO_TOKEN", "VAULT_TOKEN")
_MAX_KV_RESPONSE = 65536
Runner = Callable[[list[str], dict[str, str]], "tuple[int, bytes]"]
def _run_login(argv: list[str], env: dict[str, str]) -> tuple[int, bytes]:
"""Run the attended login. stdout is a pipe into memory; stderr (the
browser prompt, no token) stays on the operator's terminal."""
proc = subprocess.run( # noqa: S603 - fixed argv, no shell
argv,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=None,
check=False,
)
return proc.returncode, proc.stdout
def _require_bao_address(addr: str) -> str:
try:
parts = urlsplit(addr)
host = parts.hostname or ""
port_ok = parts.port != 0
except ValueError:
raise BackendError("OpenBao address is invalid for the in-memory secret read") from None
if not host or parts.username or parts.password or not port_ok:
raise BackendError("OpenBao address is invalid for the in-memory secret read")
try:
loopback = ipaddress.ip_address(host).is_loopback
except ValueError:
loopback = host == "localhost"
if parts.scheme == "https" or (parts.scheme == "http" and loopback):
return addr.rstrip("/")
raise BackendError("OpenBao address must be HTTPS or a literal loopback forward")
@dataclass
class OpenBaoOidcClientSecretSource:
"""Read CLIENT_SECRET in memory through one attended OIDC operator login.
Evidence is value-free: source name, operator accessor fingerprint, and
whether revoke-self was attempted and succeeded.
"""
bao_addr: str
bao_bin: str = "bao"
oidc_mount: str = OIDC_MOUNT
oidc_role: str = OIDC_ROLE
kv_mount: str = KV_MOUNT
kv_path: str = KV_PATH
kv_field: str = KV_FIELD
timeout_seconds: float = 10.0
runner: Runner = field(default=_run_login, repr=False, compare=False)
transport: Transport = field(default=urlopen, repr=False, compare=False)
accessor_fingerprint: str = ""
revocation_attempted: bool = False
revocation_succeeded: bool = False
def __post_init__(self) -> None:
pinned = (OIDC_MOUNT, OIDC_ROLE, KV_MOUNT, KV_PATH, KV_FIELD)
if (self.oidc_mount, self.oidc_role, self.kv_mount, self.kv_path, self.kv_field) != pinned:
raise BackendError("OpenBao secret source coordinates do not match the reviewed plan")
self.bao_addr = _require_bao_address(self.bao_addr)
if not self.bao_bin:
raise BackendError("bao CLI is required for the attended OIDC login")
def evidence(self) -> dict[str, object]:
return {
"secret_source": SOURCE_NAME,
"operator_session_handle": self.accessor_fingerprint,
"operator_revocation_attempted": self.revocation_attempted,
"operator_revocation_succeeded": self.revocation_succeeded,
}
def argv(self) -> list[str]:
# Token never appears here; -no-store keeps it out of the token helper.
return [
self.bao_bin,
"login",
"-method=oidc",
f"-path={self.oidc_mount}",
"-no-store",
"-format=json",
f"role={self.oidc_role}",
]
def _login_env(self) -> dict[str, str]:
env = {k: v for k, v in os.environ.items() if k not in _AMBIENT_TOKEN_ENV}
env["BAO_ADDR"] = self.bao_addr
env.pop("VAULT_ADDR", None)
return env
def _login(self) -> str:
code, out = self.runner(self.argv(), self._login_env())
try:
if code != 0:
raise BackendError(f"attended OpenBao OIDC login failed (exit {code})")
try:
auth = json.loads(out)["auth"]
token = str(auth["client_token"])
accessor = str(auth.get("accessor", ""))
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as e:
raise BackendError("malformed OpenBao OIDC login response") from e
finally:
del out
if not token or any(ch.isspace() for ch in token):
raise BackendError("OpenBao OIDC login returned no usable token")
self.accessor_fingerprint = (
hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] if accessor else ""
)
return token
def _call(self, method: str, path: str, token: str) -> tuple[int, bytes]:
request = Request(
f"{self.bao_addr}/v1/{path}",
data=b"" if method == "POST" else None,
method=method,
headers={"X-Vault-Token": token, "Accept": "application/json"},
)
try:
response = self.transport(request, timeout=self.timeout_seconds)
with response:
status = getattr(response, "status", 200)
raw = response.read(_MAX_KV_RESPONSE + 1)
except HTTPError as e:
return e.code, b""
except (URLError, TimeoutError, OSError) as e:
raise BackendError(f"OpenBao request failed: {method} {path}") from e
if len(raw) > _MAX_KV_RESPONSE:
raise BackendError("OpenBao response exceeds size bound")
return status, raw
def _read_field(self, token: str) -> str:
status, raw = self._call("GET", f"{self.kv_mount}/data/{self.kv_path}", token)
if status != 200:
raise BackendError(f"OpenBao KV read of the client secret failed with HTTP {status}")
try:
value = json.loads(raw)["data"]["data"][self.kv_field]
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as e:
raise BackendError("OpenBao KV response has no client-secret field") from e
finally:
del raw
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise BackendError("OpenBao client-secret field must hold exactly one token")
return value
def _revoke_self(self, token: str) -> None:
self.revocation_attempted = True
try:
status, _ = self._call("POST", "auth/token/revoke-self", token)
except BackendError:
return
self.revocation_succeeded = status in (200, 204)
def __call__(self) -> str:
token = self._login()
try:
return self._read_field(token)
finally:
self._revoke_self(token)
del token

View file

@ -108,6 +108,6 @@ def test_cli_check_reports_without_value(tmp_path, capsys):
def test_cli_check_uses_configured_env_path(tmp_path, capsys, monkeypatch):
f = _private_file(tmp_path)
monkeypatch.setenv("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", str(f))
monkeypatch.setenv("SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", str(f))
assert cli.main(["service-auth", "check-secret-file"]) == 0
assert "accepted" in capsys.readouterr().out

View file

@ -80,15 +80,11 @@ def test_explicit_service_jwt_fails_closed_without_contract(tmp_path, monkeypatc
def test_jwt_login_failure_does_not_fall_back_to_env(tmp_path, monkeypatch):
monkeypatch.setenv("BAO_TOKEN", "must-not-be-used")
secret = tmp_path / "client.secret"
secret.write_text("client-secret-value", encoding="utf-8")
secret.chmod(0o600)
cfg = _cfg(
tmp_path,
openbao_jwt_login_file=_jwt_contract(tmp_path),
keycape_token_url="https://keycape.example.test/token",
keycape_issuer="https://keycape.example.test",
keycape_client_secret_file=secret,
)
monkeypatch.setattr(
"secrets_engine.engine_auth.KeyCapeServiceAuthProvider.exchange",

View file

@ -0,0 +1,236 @@
"""In-memory OpenBao source for the secrets-engine-openbao client secret.
Synthetic values only. Proves the operator token and the client secret never
reach argv, env, stdout/stderr or files, that revoke-self always runs, and that
the file source and ambient tokens are refused for this profile.
"""
import functools
import json
import os
from types import SimpleNamespace
from urllib.error import HTTPError
import pytest
from secrets_engine import cli, engine_auth
from secrets_engine.errors import BackendError
from secrets_engine.openbao import OpenBaoClient, ScopedTokenSession
from secrets_engine.service_auth import (
KeyCapeServiceAuthProvider,
OpenBaoOidcClientSecretSource,
)
from tests.test_engine_auth import _cfg, _jwt_contract
from tests.test_service_auth import _jwt
SECRET = "synthetic-client-secret-0123456789"
OP_TOKEN = "synthetic-operator-token-abcdef"
OP_ACCESSOR = "synthetic-operator-accessor"
AMBIENT = "ambient-token-must-not-be-used"
ADDR = "https://bao.example.test"
class _Resp:
def __init__(self, body: bytes, status: int = 200):
self.status, self._body = status, body
def read(self, _n=-1):
return self._body
def __enter__(self):
return self
def __exit__(self, *_):
return False
class FakeBao:
def __init__(self, kv_status=200, revoke_status=204):
self.kv_status, self.revoke_status = kv_status, revoke_status
self.argv, self.env, self.requests = [], [], []
def runner(self, argv, env):
self.argv.append(list(argv))
self.env.append(dict(env))
out = {"auth": {"client_token": OP_TOKEN, "accessor": OP_ACCESSOR}}
return 0, json.dumps(out).encode()
def transport(self, request, *, timeout):
self.requests.append(
(request.get_method(), request.full_url, dict(request.header_items()))
)
if request.full_url.endswith("/v1/platform/data/workloads/keycape/secrets-engine-openbao"):
if self.kv_status != 200:
raise HTTPError(request.full_url, self.kv_status, "denied", {}, None)
return _Resp(json.dumps({"data": {"data": {"CLIENT_SECRET": SECRET}}}).encode())
if request.full_url.endswith("/v1/auth/token/revoke-self"):
return _Resp(b"", self.revoke_status)
pytest.fail(f"unexpected request {request.full_url}")
def _source(fake, **kw):
return OpenBaoOidcClientSecretSource(
bao_addr=ADDR, runner=fake.runner, transport=fake.transport, **kw
)
def _assert_no_leak(fake, *texts):
for value in (SECRET, OP_TOKEN, OP_ACCESSOR):
for argv in fake.argv:
assert value not in " ".join(argv)
for env in fake.env:
assert value not in " ".join(f"{k}={v}" for k, v in env.items())
assert value not in " ".join(f"{k}={v}" for k, v in os.environ.items())
for text in texts:
assert value not in text
def _assert_no_files(root):
for dirpath, _dirs, files in os.walk(root):
for name in files:
data = open(os.path.join(dirpath, name), "rb").read()
assert SECRET.encode() not in data and OP_TOKEN.encode() not in data
def test_reads_in_memory_with_header_only_and_revokes(monkeypatch, tmp_path, capsys):
monkeypatch.setenv("BAO_TOKEN", AMBIENT)
monkeypatch.setenv("VAULT_TOKEN", AMBIENT)
monkeypatch.setenv("HOME", str(tmp_path))
fake = FakeBao()
src = _source(fake)
assert src() == SECRET
assert fake.argv == [[
"bao", "login", "-method=oidc", "-path=netkingdom", "-no-store",
"-format=json", "role=keycape-secrets-engine-openbao-operator-workload-kv-read",
]]
env = fake.env[0]
assert "BAO_TOKEN" not in env and "VAULT_TOKEN" not in env
assert env["BAO_ADDR"] == ADDR
(get, kv_url, kv_headers), (post, rv_url, rv_headers) = fake.requests
assert (get, post) == ("GET", "POST")
assert kv_url == ADDR + "/v1/platform/data/workloads/keycape/secrets-engine-openbao"
assert rv_url == ADDR + "/v1/auth/token/revoke-self"
for headers in (kv_headers, rv_headers):
assert headers["X-vault-token"] == OP_TOKEN
assert AMBIENT not in json.dumps(headers)
ev = src.evidence()
assert ev == {
"secret_source": "openbao-oidc-in-memory",
"operator_session_handle": ev["operator_session_handle"],
"operator_revocation_attempted": True,
"operator_revocation_succeeded": True,
}
assert len(ev["operator_session_handle"]) == 12
out, err = capsys.readouterr()
_assert_no_leak(fake, out, err, repr(src), json.dumps(ev))
_assert_no_files(tmp_path)
def test_revoke_self_runs_when_kv_read_is_denied(capsys):
fake = FakeBao(kv_status=403)
src = _source(fake)
with pytest.raises(BackendError, match="HTTP 403") as error:
src()
assert [r[0] for r in fake.requests] == ["GET", "POST"]
assert src.revocation_attempted and src.revocation_succeeded
_assert_no_leak(fake, str(error.value), *capsys.readouterr())
def test_failed_login_makes_no_request():
fake = FakeBao()
src = _source(fake)
src.runner = lambda argv, env: (2, b"")
with pytest.raises(BackendError, match="exit 2"):
src()
assert fake.requests == []
@pytest.mark.parametrize(
"override",
[
{"oidc_role": "platform-admin"},
{"oidc_mount": "oidc"},
{"kv_path": "workloads/keycape/other"},
{"kv_field": "OTHER"},
],
)
def test_coordinates_are_pinned(override):
with pytest.raises(BackendError, match="reviewed plan"):
_source(FakeBao(), **override)
@pytest.mark.parametrize("addr", ["http://bao.example.test", "ftp://x", "", "https://u:p@bao"])
def test_address_must_be_https_or_loopback(addr):
with pytest.raises(BackendError):
OpenBaoOidcClientSecretSource(bao_addr=addr)
OpenBaoOidcClientSecretSource(bao_addr="http://127.0.0.1:8200")
def test_engine_refuses_consumer_file_for_openbao_profile(tmp_path):
secret = tmp_path / "client.secret"
secret.write_text(SECRET)
secret.chmod(0o600)
cfg = _cfg(
tmp_path,
openbao_jwt_login_file=_jwt_contract(tmp_path),
keycape_token_url="https://keycape.example.test/token",
keycape_issuer="https://keycape.example.test",
keycape_client_secret_file=secret,
)
with pytest.raises(BackendError, match="refused for the secrets-engine-openbao") as error:
engine_auth.login_service_jwt(cfg)
assert SECRET not in str(error.value)
def test_probe_end_to_end_is_value_free(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("BAO_TOKEN", AMBIENT)
monkeypatch.setenv("HOME", str(tmp_path))
fake = FakeBao()
keycape_auth = []
def keycape_transport(request, *, timeout):
keycape_auth.append(request.get_header("Authorization"))
return _Resp(json.dumps(
{"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900}
).encode())
monkeypatch.setattr(
engine_auth, "OpenBaoOidcClientSecretSource",
functools.partial(
OpenBaoOidcClientSecretSource, runner=fake.runner, transport=fake.transport
),
)
monkeypatch.setattr(
engine_auth, "KeyCapeServiceAuthProvider",
functools.partial(KeyCapeServiceAuthProvider, transport=keycape_transport),
)
monkeypatch.setattr("secrets_engine.service_auth.preflight_service_jwt",
lambda token, config, now=None: SimpleNamespace(token=token))
jwt_logins = []
def fake_login_jwt(self, mount, role, jwt):
jwt_logins.append((mount, role))
client = OpenBaoClient(addr=self.addr, token="engine-token", bao_bin="bao")
monkeypatch.setattr(client, "_run_ok", lambda args, **_k: "")
return ScopedTokenSession(client=client, accessor_fingerprint="0123456789ab")
monkeypatch.setattr(OpenBaoClient, "login_jwt", fake_login_jwt)
cfg = _cfg(
tmp_path,
bao_addr=ADDR,
openbao_jwt_login_file=_jwt_contract(tmp_path),
keycape_token_url="https://keycape.example.test/token",
keycape_issuer="https://keycape.example.test",
)
monkeypatch.setattr(cli.Config, "load", classmethod(lambda _cls: cfg))
rc = cli.main(["service-auth", "probe-openbao", "--json"])
out, err = capsys.readouterr()
assert rc == 0, err
payload = json.loads(out)
assert payload["ok"] is True
assert payload["secret_source"] == "openbao-oidc-in-memory"
assert payload["operator_revocation_succeeded"] is True
assert payload["revocation_succeeded"] is True
assert len(jwt_logins) == 1
assert len(keycape_auth) == 1 and keycape_auth[0].startswith("Basic ")
_assert_no_leak(fake, out, err)
_assert_no_files(tmp_path)

View file

@ -45,11 +45,10 @@ def _jwt(**overrides):
return f"{_segment({'alg': 'RS256', 'typ': 'JWT'})}.{_segment(claims)}.signature"
def _config(secret_file=Path("/tmp/keycape-client-secret")):
def _config(_unused=None):
return KeyCapeServiceAuthConfig(
token_url="https://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=secret_file,
)
@ -114,7 +113,9 @@ def test_exchange_uses_basic_auth_and_never_places_secret_in_body(tmp_path):
}
)
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
provider = KeyCapeServiceAuthProvider(
_config(), transport=transport, secret_source=lambda: "client-secret-value"
)
result = provider.exchange(now=NOW)
assert result.expires_at == int(NOW.timestamp()) + 900
assert captured["authorization"].startswith("Basic ")
@ -138,7 +139,9 @@ def test_exchange_rejects_refresh_or_id_tokens_without_exposing_secret(tmp_path)
}
)
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
provider = KeyCapeServiceAuthProvider(
_config(), transport=transport, secret_source=lambda: secret
)
with pytest.raises(BackendError, match="forbidden extra token") as error:
provider.exchange(now=NOW)
assert secret not in str(error.value)
@ -149,12 +152,24 @@ def test_provider_config_is_exact_and_has_no_bootstrap_fallback():
KeyCapeServiceAuthConfig(
token_url="http://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=Path("/tmp/unused"),
)
with pytest.raises(BackendError, match="accepted contract"):
KeyCapeServiceAuthConfig(
token_url="https://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=Path("/tmp/unused"),
client_id="operator",
)
def test_openbao_profile_refuses_file_source(tmp_path):
with pytest.raises(BackendError, match="file source is refused"):
KeyCapeServiceAuthConfig(
token_url="https://keycape.example.test/token",
issuer="https://keycape.example.test",
client_secret_file=tmp_path / "client.secret",
)
provider = KeyCapeServiceAuthProvider(
_config(), transport=lambda *_a, **_k: pytest.fail("no exchange")
)
with pytest.raises(BackendError, match="no file fallback"):
provider.exchange(now=NOW)

View file

@ -25,6 +25,12 @@ approved the two-copy custody boundary (decision
`632a8893-0d6a-4591-a50b-40bbdb798f32`), not generation, fetch, delivery,
registration or deployment. Live service-JWT adoption stays SECRETS-WP-0008-T06.
Redirected 2026-10-06: founder direction keeps the secret only in OpenBao, and
the ops-mason plan `keycape-secrets-engine-openbao-client-custody` (658cdbc)
replaces the consumer file with a per-command in-memory read (T02 cancelled,
T04 implements it, T03 is the first live use). T01's file checks remain for
the approval client only.
Packet: [docs/keycape-openbao-consumer-custody.md](../docs/keycape-openbao-consumer-custody.md).
## Consumer packet and strict file acceptance
@ -48,28 +54,51 @@ suite: 510 passed, 1 skipped (root-only owner test).
```task
id: SECRETS-WP-0012-T02
status: wait
needs_human: true
status: cancel
priority: high
blocked_on: founder
blocking_reason: "Host (workstation), OS user (worsch), persistent storage and rotation cadence are recommendations awaiting founder acceptance; the custody child cannot be finalized without them."
state_hub_task_id: "b95da704-ce13-5bf5-be3b-126e894a669d"
```
## Attended consumer-copy delivery
Cancelled 2026-10-06: superseded by founder direction 2026-10-06 (the client
secret lives only in OpenBao, no workstation copy) and the ops-mason plan
`keycape-secrets-engine-openbao-client-custody` (658cdbc, MASON-WP-0009).
D1-D4 (host, OS user, persistent file, rotation cadence) no longer apply.
## In-memory OpenBao secret source
```task
id: SECRETS-WP-0012-T04
status: done
priority: high
```
Done 2026-10-06. `service_auth.OpenBaoOidcClientSecretSource`: attended
`bao login -method=oidc -path=netkingdom -no-store -format=json` on role
`keycape-secrets-engine-openbao-operator-workload-kv-read`, token from a
stdout pipe, ambient `BAO_TOKEN`/`VAULT_TOKEN` stripped, header-only GET of
`platform/data/workloads/keycape/secrets-engine-openbao` field
`CLIENT_SECRET`, unconditional `revoke-self`, then the KeyCape exchange and
OpenBao JWT login. The file source is refused for this profile (kept for the
approval client). `secrets-engine service-auth probe-openbao` prints
value-free evidence. Synthetic tests: `tests/test_openbao_secret_source.py`.
Docs: `docs/keycape-openbao-consumer-custody.md` (rewritten),
`docs/service-auth.md`, `docs/cli.md`.
## First live attended use
```task
id: SECRETS-WP-0012-T03
status: wait
needs_human: true
depends_on: [SECRETS-WP-0012-T02]
priority: high
blocked_on: message-from:railiance-platform
blocking_reason: "Requires the founder-approved CCR-2026-0033 custody child, executed attended on the named host. Platform generates and writes both copies in one process. secrets-engine records only the check-secret-file result. No client-side OpenBao read."
blocked_on: founder
blocking_reason: "Founder runs the attended probe-openbao command in docs/keycape-openbao-consumer-custody.md after plan approval A1-A3, the revised CCR-2026-0033 objects applied by railiance-platform, the KeyCape registration live (KEY-WP-0037) and the JWT login contract published."
state_hub_task_id: "1c3afd34-4aa3-5484-a9c7-2db577225269"
```
Acceptance: receipt carries `check-secret-file --json` with `ok: true` and
no value, length or hash. A failed write leaves no consumer file and is
recovered by rotation, not by a KV read. First live exchange evidence belongs
to KEY-WP-0037-T04, and OpenBao login proof belongs to SECRETS-WP-0008-T06.
Acceptance: the one-line JSON from `service-auth probe-openbao --json` with
`ok: true`, `secret_source: openbao-oidc-in-memory`, both accessor
fingerprints and both revoke-self results true, recorded without any value,
token or full accessor. This is the evidence for KEY-WP-0037-T04,
RPF-WP-0035-T02 and plan verification item 6; OpenBao login proof also
closes into SECRETS-WP-0008-T06.