Read the secrets-engine-openbao client secret in memory from OpenBao
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:
parent
6941610b8d
commit
b8094f701b
12 changed files with 688 additions and 185 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
236
tests/test_openbao_secret_source.py
Normal file
236
tests/test_openbao_secret_source.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue