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

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

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

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

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

View file

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

View file

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

View file

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

View file

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