Add response-wrapped operator handoff
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:
parent
2278cefbb3
commit
afd1c8e593
12 changed files with 387 additions and 8 deletions
|
|
@ -9,6 +9,7 @@ Command surface (FR7):
|
|||
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
|
||||
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
||||
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||
wrap <catalog-id> --out F [--ttl 15m]
|
||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
||||
route <catalog-id> [--json]
|
||||
revoke <catalog-id>
|
||||
|
|
@ -435,6 +436,37 @@ def cmd_handoff(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_wrap(cfg: Config, args) -> int:
|
||||
from secrets_engine.wrap import write_wrapped_handoff
|
||||
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
with _privileged_evidence(
|
||||
cfg, entry, "wrap", detail={"ttl": args.ttl, "out_file": args.out}
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "wrap", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
result = write_wrapped_handoff(
|
||||
client, entry, out_file=Path(args.out), ttl=args.ttl
|
||||
)
|
||||
print(
|
||||
f"wrote wrap token for lane '{result.catalog_id}' — token not displayed"
|
||||
)
|
||||
print(f" out: {result.out_file}")
|
||||
print(f" ttl: {result.ttl}")
|
||||
print(f" handle: {result.wrap_handle or '-'}")
|
||||
evidence.finish(
|
||||
"wrap-token-written",
|
||||
detail={
|
||||
"out_file": result.out_file,
|
||||
"ttl": result.ttl,
|
||||
"wrap_handle": result.wrap_handle,
|
||||
"creation_path": result.creation_path,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_exec(cfg: Config, args) -> int:
|
||||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||
|
|
@ -795,6 +827,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
add_token_arg(ha)
|
||||
ha.set_defaults(func=cmd_handoff)
|
||||
|
||||
wr = sub.add_parser(
|
||||
"wrap",
|
||||
help="write a single-use OpenBao wrap token to a file (never printed)",
|
||||
)
|
||||
wr.add_argument("catalog_id")
|
||||
wr.add_argument("--out", required=True, help="mode-0600 wrap-token file outside Git")
|
||||
wr.add_argument("--ttl", default="15m", help="wrap TTL, max 15m (default 15m)")
|
||||
add_token_arg(wr)
|
||||
wr.set_defaults(func=cmd_wrap)
|
||||
|
||||
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
|
||||
ex.add_argument("--catalog", required=True)
|
||||
ex.add_argument("--field", default=None)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ SHIPPED_RULES = (
|
|||
"lifecycle-deactivate",
|
||||
"provision",
|
||||
"session-revoke",
|
||||
"wrap",
|
||||
),
|
||||
"stages": ("prod",),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
106
src/secrets_engine/wrap.py
Normal file
106
src/secrets_engine/wrap.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Response-wrapped operator handoff.
|
||||
|
||||
Writes a single-use wrap token to a mode-0600 file outside Git. The wrap token
|
||||
is secret material and is never printed or recorded in evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
from secrets_engine.openbao import OpenBaoClient, WrappedResponse
|
||||
from secrets_engine.safe_paths import containing_git_worktree
|
||||
|
||||
_TTL_RE = re.compile(r"^([1-9][0-9]*)([smh])$")
|
||||
MAX_WRAP_SECONDS = 15 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WrapHandoffResult:
|
||||
catalog_id: str
|
||||
kind: str
|
||||
out_file: str
|
||||
ttl: str
|
||||
wrap_handle: str
|
||||
creation_path: str
|
||||
|
||||
|
||||
def normalize_wrap_ttl(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
match = _TTL_RE.fullmatch(text)
|
||||
if not match:
|
||||
raise ProvisioningError("wrap ttl must be like 60s or 15m")
|
||||
amount = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
seconds = amount * {"s": 1, "m": 60, "h": 3600}[unit]
|
||||
if seconds > MAX_WRAP_SECONDS:
|
||||
raise ProvisioningError("wrap ttl must not exceed 15m")
|
||||
return text
|
||||
|
||||
|
||||
def _validate_output_path(path: Path) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
worktree = containing_git_worktree(resolved)
|
||||
if worktree is not None:
|
||||
raise ProvisioningError(
|
||||
f"wrap file {resolved} is inside a Git worktree ({worktree}); "
|
||||
"keep wrap tokens outside repos"
|
||||
)
|
||||
if resolved.exists() and resolved.stat().st_mode & 0o077:
|
||||
raise ProvisioningError(
|
||||
f"wrap file {resolved} is group/other-accessible "
|
||||
f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _write_mode_0600(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fd = None
|
||||
fh.write(value)
|
||||
fh.write("\n")
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def write_wrapped_handoff(
|
||||
client: OpenBaoClient,
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
out_file: Path,
|
||||
ttl: str,
|
||||
) -> WrapHandoffResult:
|
||||
"""Mint a wrap token and write it without printing it."""
|
||||
ttl = normalize_wrap_ttl(ttl)
|
||||
out_path = _validate_output_path(out_file)
|
||||
wrapped: WrappedResponse
|
||||
if entry.kind == "auth-capability":
|
||||
wrapped = client.wrap_approle_secret_id(entry.role_name, ttl=ttl)
|
||||
elif entry.stores_kv_value():
|
||||
wrapped = client.wrap_kv_get(entry.mount, entry.path, ttl=ttl)
|
||||
else:
|
||||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff"
|
||||
)
|
||||
token = wrapped.wrap_token
|
||||
try:
|
||||
_write_mode_0600(out_path, token)
|
||||
finally:
|
||||
token = ""
|
||||
return WrapHandoffResult(
|
||||
catalog_id=entry.id,
|
||||
kind=entry.kind,
|
||||
out_file=str(out_path),
|
||||
ttl=ttl,
|
||||
wrap_handle=wrapped.accessor_fingerprint,
|
||||
creation_path=wrapped.creation_path,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue