Add response-wrapped operator handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

secrets-engine wrap writes a single-use OpenBao wrap token to a mode-0600
out-of-repo file and never prints it. KV reads and AppRole secret_ids are
wrapped with a 15m TTL cap. Unwrapped secret payloads fail closed.
Production wrap remains fail-closed.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
tegwick 2026-09-02 08:12:42 +02:00
parent 2278cefbb3
commit afd1c8e593
12 changed files with 387 additions and 8 deletions

View file

@ -21,7 +21,7 @@ import stat
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from secrets_engine.errors import BackendError, ProvisioningError
@ -61,6 +61,24 @@ def accessor_fingerprint(accessor: str) -> str:
return hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12]
@dataclass(frozen=True)
class WrappedResponse:
"""One OpenBao response-wrapped payload. The wrap token is secret."""
wrap_token: str = field(repr=False)
accessor_fingerprint: str
ttl: str
creation_path: str
def evidence(self) -> dict[str, object]:
payload: dict[str, object] = {
"wrap_handle": self.accessor_fingerprint,
"wrap_ttl": self.ttl,
"creation_path": self.creation_path,
}
return payload
@dataclass
class ScopedTokenSession:
"""One AppRole login token that revokes itself on close."""
@ -335,6 +353,44 @@ class OpenBaoClient:
raise BackendError("token accessor is missing or invalid")
self._run_ok(["token", "revoke", "-accessor", accessor])
def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse:
try:
payload = json.loads(stdout)
except json.JSONDecodeError as exc:
raise BackendError("wrapped response is not JSON") from exc
if not isinstance(payload, dict):
raise BackendError("wrapped response is invalid")
wrap = payload.get("wrap_info")
if not isinstance(wrap, dict) or not wrap.get("token"):
raise BackendError("response wrapping was not applied")
token = str(wrap["token"])
accessor = str(wrap.get("accessor") or "")
wrap_ttl = wrap.get("ttl")
return WrappedResponse(
wrap_token=token,
accessor_fingerprint=accessor_fingerprint(accessor) if accessor else "",
ttl=str(wrap_ttl if wrap_ttl is not None else ttl),
creation_path=creation_path,
)
def wrap_kv_get(self, mount: str, path: str, *, ttl: str) -> WrappedResponse:
"""Wrap a KV read. Fail if OpenBao returns the secret unwrapped."""
target = f"{mount}/{path}"
proc = self._run(["kv", "get", f"-wrap-ttl={ttl}", "-format=json", target])
if proc.returncode != 0:
raise BackendError("wrapped KV read failed")
return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=target)
def wrap_approle_secret_id(self, role_name: str, *, ttl: str) -> WrappedResponse:
"""Wrap a single-use AppRole secret_id. Fail if returned unwrapped."""
target = f"auth/approle/role/{role_name}/secret-id"
proc = self._run(
["write", f"-wrap-ttl={ttl}", "-format=json", "-force", target]
)
if proc.returncode != 0:
raise BackendError("wrapped AppRole secret-id mint failed")
return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=target)
# -- KV v2 -------------------------------------------------------------
def kv_mount_exists(self, mount: str) -> bool: