feat(admission): founder-signed trust and recovery-attempt admission (WP14 T02, WP15 T05)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Reuse the gated-executor approval pattern: ssh-keygen -Y signatures over exact
bytes, verified against a runtime-unwritable allowed_signers anchor with a fixed
namespace per purpose. load_trust now refuses responder trust without a founder
admission; the KeyCape selector preflight gains --admission for one exact,
bounded recovery attempt. WP14 re-pointed to CUST-WP-0082-T06 and split founder
key admission into human-gated T04; WP15 T04 now requires the signed admission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 809600@bnt-lap001
Assistant-Session: 40ba228f-c8a0-4ec9-bb29-3129951afdc4
This commit is contained in:
tegwick 2026-10-21 00:30:59 +02:00
parent 497040c58c
commit ebd8e49aff
15 changed files with 469 additions and 29 deletions

View file

@ -14,6 +14,9 @@ import subprocess
from typing import Any
from secrets_engine.errors import DecisionError
from secrets_engine.founder_admission import read_signature, verify_admission
TRUST_NAMESPACE = "secrets-engine-pdp-trust"
def _unique_object(pairs):
@ -38,11 +41,13 @@ def strict_json(data):
def load_trust(path: Path | None) -> dict[str, Any]:
"""Read an operator-owned public admission file; missing trust is a refusal.
"""Read a founder-admitted public trust file; missing trust is a refusal.
Admission config is a local authority input like the existing policy pin.
File provenance/approval and the executable's installation are rollout gates,
not established by a successful local signature test.
The exact file bytes must carry a sibling ``<trust>.sig`` made with
``ssh-keygen -Y sign -n secrets-engine-pdp-trust`` by a principal in the
runtime-unwritable admission anchor (``founder_admission``). Rotation is a
new signed file; an unsigned or edited file refuses. The executable's
installation remains a rollout gate.
"""
try:
if path is None or not Path(path).is_absolute() or Path(path).is_symlink():
@ -55,6 +60,7 @@ def load_trust(path: Path | None) -> dict[str, Any]:
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError("trust is oversized")
admission = verify_admission(raw, read_signature(Path(str(path) + ".sig")), namespace=TRUST_NAMESPACE)
trust = strict_json(raw)
if set(trust) != {'schema', 'evaluator', 'mode', 'policy_package', 'policy_version', 'policy_package_digest', 'keys'}:
raise ValueError("invalid trust fields")
@ -69,11 +75,18 @@ def load_trust(path: Path | None) -> dict[str, Any]:
for key in trust["keys"].values():
if len(key) != 43 or len(base64.b64decode(key + "=", altchars=b"-_", validate=True)) != 32:
raise ValueError("invalid Ed25519 public key")
return trust
except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc:
return AdmittedTrust(trust, admission)
except (OSError, ValueError, TypeError, KeyError, RecursionError, DecisionError) as exc:
raise DecisionError("access-engine responder trust is missing or invalid") from exc
class AdmittedTrust(dict):
"""Trust fields plus the non-secret founder admission (principal, key fingerprint)."""
def __init__(self, values, admission):
super().__init__(values)
self.admission = admission
class AuthenticatedEnvelope(dict):
"""Verified typed values with exact producer JSON retained for reauthentication."""
def __init__(self, values, authenticated_json):

View file

@ -0,0 +1,96 @@
"""Founder SSH-signature admission over exact bytes, never runtime self-approval.
Reuses the gated-executor approval pattern (GEXEC-WP-0001, accepted on
railiance01 2026-10-20): ``ssh-keygen -Y verify`` against an ``allowed_signers``
file the running account cannot write, with a fixed namespace per purpose so a
signature for one purpose can never admit another (including a gexec action).
The anchor is ``/etc/secrets-engine/admission/allowed_signers`` unless
``SECRETS_ENGINE_ADMISSION_ANCHOR`` names another directory. Relocating the
anchor does not weaken it: the directory and file must still be owned by root
or another account than the runtime, and not group/world writable. The signer
principal is derived from the signature and must be listed in that file.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
from secrets_engine.errors import DecisionError
DEFAULT_ANCHOR = Path("/etc/secrets-engine/admission")
NAMESPACES = frozenset({"secrets-engine-pdp-trust", "secrets-engine-recovery-attempt"})
SIG_HEADER = b"-----BEGIN SSH SIGNATURE-----"
SIG_LIMIT = 8192
PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$")
FINGERPRINT_RE = re.compile(r"key (SHA256:[A-Za-z0-9+/=]+)")
SSH_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
def anchor_dir() -> Path:
return Path(os.environ.get("SECRETS_ENGINE_ADMISSION_ANCHOR") or DEFAULT_ANCHOR)
def _writable_by_runtime(info: os.stat_result) -> bool:
"""The runtime must not be able to rewrite its own trust anchor."""
euid = os.geteuid()
return bool(info.st_mode & 0o022) or (info.st_uid == euid and euid != 0)
def _read_anchor() -> Path:
directory = anchor_dir()
signers = directory / "allowed_signers"
if not directory.is_absolute() or directory.is_symlink() or signers.is_symlink():
raise ValueError("anchor must be absolute and not a symlink")
for path, kind in ((directory, stat.S_ISDIR), (signers, stat.S_ISREG)):
info = os.stat(path, follow_symlinks=False)
if not kind(info.st_mode) or _writable_by_runtime(info):
raise ValueError("anchor is writable by the runtime")
return signers
def read_signature(path: Path) -> bytes:
if path.is_symlink():
raise DecisionError("founder admission signature is missing or invalid")
try:
with path.open("rb") as stream:
raw = stream.read(SIG_LIMIT + 1)
except OSError as exc:
raise DecisionError("founder admission signature is missing or invalid") from exc
if len(raw) > SIG_LIMIT or not raw.startswith(SIG_HEADER):
raise DecisionError("founder admission signature is missing or invalid")
return raw
def verify_admission(data: bytes, signature: bytes, *, namespace: str) -> dict[str, str]:
"""Verify an exact-byte founder signature; returns principal and key fingerprint."""
try:
if namespace not in NAMESPACES:
raise ValueError("unknown admission namespace")
if len(signature) > SIG_LIMIT or not signature.startswith(SIG_HEADER):
raise ValueError("unsigned admission")
signers = _read_anchor()
with tempfile.TemporaryDirectory(prefix="se-admission-") as tmp:
sig = Path(tmp) / "admission.sig"
sig.write_bytes(signature)
found = subprocess.run(
["ssh-keygen", "-Y", "find-principals", "-f", str(signers), "-n", namespace, "-s", str(sig)],
capture_output=True, env=SSH_ENV, timeout=5, check=False,
)
principals = found.stdout.decode("utf-8", "replace").split()
if found.returncode != 0 or len(principals) != 1 or not PRINCIPAL_RE.fullmatch(principals[0]):
raise ValueError("signer is not exactly one admitted principal")
checked = subprocess.run(
["ssh-keygen", "-Y", "verify", "-f", str(signers), "-I", principals[0], "-n", namespace, "-s", str(sig)],
input=data, capture_output=True, env=SSH_ENV, timeout=5, check=False,
)
match = FINGERPRINT_RE.search(checked.stdout.decode("utf-8", "replace"))
if checked.returncode != 0 or not match:
raise ValueError("signature verification failed")
return {"principal": principals[0], "key_fingerprint": match.group(1), "namespace": namespace}
except (OSError, ValueError, subprocess.SubprocessError) as exc:
raise DecisionError("founder admission is missing or invalid") from exc