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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue