diff --git a/docs/cli.md b/docs/cli.md index e4a7c07..a9c76c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/keycape-openbao-consumer-custody.md b/docs/keycape-openbao-consumer-custody.md index 89ea17b..5affaad 100644 --- a/docs/keycape-openbao-consumer-custody.md +++ b/docs/keycape-openbao-consumer-custody.md @@ -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=` | -| 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=` | `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, `` 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": "", "revocation_attempted": true, "revocation_succeeded": true, "secret_source": "openbao-oidc-in-memory", "session_handle": ""} +``` -| 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 `: 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=`, +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. diff --git a/docs/service-auth.md b/docs/service-auth.md index f730d57..599a1c8 100644 --- a/docs/service-auth.md +++ b/docs/service-auth.md @@ -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. diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index d491d5e..e46cee2 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -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 diff --git a/src/secrets_engine/engine_auth.py b/src/secrets_engine/engine_auth.py index 77cc034..7011457 100644 --- a/src/secrets_engine/engine_auth.py +++ b/src/secrets_engine/engine_auth.py @@ -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 diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 75e8555..3759300 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -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, diff --git a/src/secrets_engine/service_auth.py b/src/secrets_engine/service_auth.py index 7306026..29225e8 100644 --- a/src/secrets_engine/service_auth.py +++ b/src/secrets_engine/service_auth.py @@ -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 diff --git a/tests/test_consumer_secret_file.py b/tests/test_consumer_secret_file.py index 62bc8cd..da692ac 100644 --- a/tests/test_consumer_secret_file.py +++ b/tests/test_consumer_secret_file.py @@ -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 diff --git a/tests/test_engine_auth.py b/tests/test_engine_auth.py index a59977d..71f2535 100644 --- a/tests/test_engine_auth.py +++ b/tests/test_engine_auth.py @@ -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", diff --git a/tests/test_openbao_secret_source.py b/tests/test_openbao_secret_source.py new file mode 100644 index 0000000..d0f9342 --- /dev/null +++ b/tests/test_openbao_secret_source.py @@ -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) diff --git a/tests/test_service_auth.py b/tests/test_service_auth.py index 6c0b271..beb8578 100644 --- a/tests/test_service_auth.py +++ b/tests/test_service_auth.py @@ -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) diff --git a/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md b/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md index 90c9baf..f331d7f 100644 --- a/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md +++ b/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md @@ -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.