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
|
|
@ -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