Bind wrapped issuance to native metadata and preserve immutable recipient attempts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-12 00:11:49 +02:00
parent 58129de8f1
commit 0db6adef69
8 changed files with 288 additions and 46 deletions

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
@ -400,23 +401,34 @@ class OpenBaoClient:
self._run_ok(["lease", "revoke", lease_id])
def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse:
# Verify native wrapping metadata. Never reconstruct a purported source
# path or TTL from the caller when the backend omitted or changed it.
try:
payload = json.loads(stdout)
except json.JSONDecodeError as exc:
raise BackendError("wrapped response is not JSON") from exc
except (json.JSONDecodeError, TypeError):
raise BackendError("wrapped response is not JSON") from None
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")
if payload.get("data") or payload.get("auth"):
raise BackendError("wrapped response unexpectedly included unwrapped material")
match = re.fullmatch(r"([1-9][0-9]*)([smh])", ttl)
seconds = int(match[1]) * {"s": 1, "m": 60, "h": 3600}[match[2]] if match else 0
if not 0 < seconds <= 900:
raise BackendError("wrap TTL is outside the supported bound")
token, accessor = wrap.get("token"), wrap.get("accessor")
if (not isinstance(token, str) or not token or any(ch.isspace() for ch in token)
or not isinstance(accessor, str) or not accessor
or type(wrap.get("ttl")) is not int or wrap["ttl"] != seconds
or wrap.get("creation_path") != creation_path):
raise BackendError("native wrap identity, TTL or creation path unverified")
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,
accessor_fingerprint=accessor_fingerprint(accessor),
ttl=str(seconds),
creation_path=wrap["creation_path"],
)
def wrap_kv_get(self, mount: str, path: str, *, ttl: str) -> WrappedResponse:
@ -425,7 +437,7 @@ class OpenBaoClient:
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)
return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=f"{mount}/data/{path}")
def wrap_approle_secret_id(self, role_name: str, *, ttl: str) -> WrappedResponse:
"""Wrap a single-use AppRole secret_id. Fail if returned unwrapped."""

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import os
import re
import stat
from dataclasses import dataclass
from pathlib import Path
@ -43,33 +44,48 @@ def normalize_wrap_ttl(value: str) -> str:
def _validate_output_path(path: Path) -> Path:
resolved = path.expanduser().resolve()
worktree = containing_git_worktree(resolved)
# Do not follow a leaf/parent symlink into another recipient's private file.
absolute = Path(os.path.abspath(path.expanduser()))
if absolute.resolve() != absolute or absolute.is_symlink():
raise ProvisioningError("wrap output cannot use redirected paths")
worktree = containing_git_worktree(absolute)
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
raise ProvisioningError("wrap output is inside a Git worktree; keep tokens outside repos")
if os.path.lexists(absolute):
raise ProvisioningError("wrap output already exists; reconcile the prior attempt, never overwrite or remint")
return absolute
def _write_mode_0600(path: Path, value: str) -> None:
def _reserve_mode_0600(path: Path) -> int:
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)
directory_fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = None
fh.write(value)
fh.write("\n")
# Walk using directory descriptors so a concurrent symlink swap cannot
# redirect either an ancestor or the final file creation.
for component in path.parent.parts[1:]:
next_fd = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=directory_fd)
os.close(directory_fd)
directory_fd = next_fd
parent = os.fstat(directory_fd)
if parent.st_uid != os.geteuid() or stat.S_IMODE(parent.st_mode) & 0o077:
raise ProvisioningError("wrap output parent must be owned and private")
# Reserve before the backend call. Failure/crash leaves this private
# marker for reconciliation, never automatic remint.
file_fd = os.open(path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=directory_fd)
try:
os.fchmod(file_fd, 0o600)
os.fsync(file_fd)
os.fsync(directory_fd)
except BaseException:
os.close(file_fd)
raise
return file_fd
except OSError:
raise ProvisioningError("wrap output reservation failed; no backend retry") from None
finally:
if fd is not None:
os.close(fd)
os.close(directory_fd)
def write_wrapped_handoff(
@ -82,20 +98,26 @@ def write_wrapped_handoff(
"""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
if entry.kind != "auth-capability" and not entry.stores_kv_value():
raise ProvisioningError(f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff")
fd = _reserve_mode_0600(out_path)
try:
_write_mode_0600(out_path, token)
if entry.kind == "auth-capability":
wrapped: WrappedResponse = client.wrap_approle_secret_id(entry.role_name, ttl=ttl)
else:
wrapped = client.wrap_kv_get(entry.mount, entry.path, ttl=ttl)
visible, reserved = out_path.lstat(), os.fstat(fd)
if (not stat.S_ISREG(visible.st_mode)
or (visible.st_dev, visible.st_ino) != (reserved.st_dev, reserved.st_ino)):
raise ProvisioningError("wrap output reservation changed; retain and reconcile the mint")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
fd = None
handle.write(wrapped.wrap_token + "\n")
handle.flush()
os.fsync(handle.fileno())
finally:
token = ""
if fd is not None:
os.close(fd)
return WrapHandoffResult(
catalog_id=entry.id,
kind=entry.kind,