feat(admission): founder-signed trust and recovery-attempt admission (WP14 T02, WP15 T05)
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:
parent
497040c58c
commit
ebd8e49aff
15 changed files with 469 additions and 29 deletions
|
|
@ -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):
|
||||
|
|
|
|||
96
src/secrets_engine/founder_admission.py
Normal file
96
src/secrets_engine/founder_admission.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue