Adds --mode break-glass-drill with its own signed admission (drill: "break-glass", attempt id RPF-WP-0064-T10-*, snapshot_label). After the phase A restore and unseal it checks the held root token with lookup-self only, runs a fresh generate-root ceremony with re-entered shares, does one read-only sys/auth action, revokes the generated token (204 then 403) and drills a cancelled attempt. Phase A is unchanged. The verifier types the receipt extension and rejects token, accessor, OTP, nonce, encoded-token and share shapes. Offline 34 and native 16 pass. Founder runbooks for the drill and the read-only production SSO-down walk are in the T10 section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 4515@bnt-lap001 Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
1165 lines
62 KiB
Python
1165 lines
62 KiB
Python
#!/usr/bin/env python3
|
|
"""Isolated original-OpenBao restore adapter (RPF-WP-0064-T02 phase A; T10 break-glass drill).
|
|
|
|
Restores one age-encrypted Raft snapshot into a disposable, networkless Docker
|
|
container on the founder workstation, unseals it with the original Shamir
|
|
shares, reads back configuration metadata, and tears everything down. It never
|
|
runs on railiance01, never uses a kubeconfig, never reads KV ``data`` and never
|
|
prints or records a secret value.
|
|
|
|
Custody inputs (founder-attended, hidden prompts only, never argv/env/files):
|
|
the backup age identity (held in a sealed memfd for ``age -d``) and two
|
|
original unseal shares (process memory only). The decrypted snapshot lives in
|
|
a sealed memfd and is streamed to the container's stdin; the container's Raft
|
|
directory is tmpfs. A throwaway 1-of-1 init makes the empty target writable;
|
|
the restore invalidates it. Readback uses a root credential generated in the
|
|
restored target from the same two shares, revoked before teardown.
|
|
|
|
A reaper process watches the session: on parent loss or the hard deadline it
|
|
kills the session, removes the container and writes an ``aborted`` receipt.
|
|
|
|
Outcomes: ``restored`` | ``refused`` (required object missing or not matching
|
|
its source; never repaired) | ``aborted`` (any other failed check, timeout,
|
|
operator abort). Only the launcher ``run-isolated-restore.sh`` is the attended
|
|
entry point; ``Session`` is importable for the synthetic rehearsal tests.
|
|
|
|
``--mode break-glass-drill`` (RPF-WP-0064-T10, NK-WP-0055-T13) needs its own
|
|
signed admission (``drill: "break-glass"``, attempt id ``RPF-WP-0064-T10-*``).
|
|
It runs the same restore and unseal, forgets the unseal shares, then: checks a
|
|
held root token with ``lookup-self`` only (never revoked or otherwise used);
|
|
runs a fresh generate-root ceremony with a server OTP and two re-entered
|
|
shares; does one read-only action (``sys/auth``); revokes the generated token
|
|
(204) and confirms ``lookup-self`` is refused (403); starts and cancels one
|
|
more attempt. Outcomes: ``drilled`` | ``aborted``. No object readback.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import fcntl
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import select
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HERE = "scripts/openbao-isolated-restore/"
|
|
NAMESPACE = "railiance-openbao-restore-attempt"
|
|
ADMISSION_SCHEMA = "railiance-openbao-restore-admission/v1"
|
|
RECEIPT_SCHEMA = "railiance-openbao-restore-receipt/v2"
|
|
IMAGE_REPOSITORY = "quay.io/openbao/openbao"
|
|
# RPF-WP-0064-T06: production digest, observed 2026-10-24 as the imageID of the live
|
|
# openbao-0 container (spec image quay.io/openbao/openbao:2.5.4); equal to the T04
|
|
# rehearsal pull. Original-material runs refuse any other image.
|
|
PINNED_IMAGE_DIGEST: str | None = "sha256:436eaf9778cad75507ff70ea26ace30dcbe15606e619ac3823495663d7f7c115"
|
|
MAX_DURATION_SECONDS = 2700
|
|
LEADER_WAIT_SECONDS = 180
|
|
LEADER_FIELDS = {"ha_enabled": bool, "is_self": bool, "leader_address": str, "leader_cluster_address": str,
|
|
"raft_committed_index": int, "raft_applied_index": int, "active_time": str}
|
|
SEAL_FIELDS = {"sealed": bool, "initialized": bool, "storage_type": str, "version": str}
|
|
_SECRETISH = re.compile(r"\b(?:hvs|hvb|hvr|s|b|r)\.[A-Za-z0-9_-]{8,}|[A-Za-z0-9+/=_-]{32,}")
|
|
|
|
|
|
def typed(value, kind):
|
|
"""Keep a status value only if it has the expected type; bound strings and redact token-like text."""
|
|
if kind is int:
|
|
return value if type(value) is int else None
|
|
if kind is bool:
|
|
return value if type(value) is bool else None
|
|
if isinstance(value, str):
|
|
return _SECRETISH.sub("[redacted]", value)[:200]
|
|
return None
|
|
|
|
|
|
def sanitize_status_line(raw: bytes) -> str | None:
|
|
"""First non-empty stderr line, bounded, with token-like text redacted."""
|
|
for line in raw.decode("utf-8", "replace").splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
return _SECRETISH.sub("[redacted]", line)[:200]
|
|
return None
|
|
REAPER_GRACE_SECONDS = 60
|
|
DEFAULT_ANCHOR = Path("/etc/railiance-openbao-restore/allowed_signers")
|
|
ANCHOR_ENV = "RAILIANCE_OPENBAO_RESTORE_ANCHOR"
|
|
SSH_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
|
|
CLAIMS_NOT_MADE = (
|
|
"consumer_continuity", "live_jwt_login", "phase_b_network",
|
|
"owner_acceptance", "recurring_restore_assurance", "production_redirect",
|
|
)
|
|
ADAPTER_PATHS = tuple(HERE + n for n in (
|
|
"restore_adapter.py", "preflight.py", "run-isolated-restore.sh", "verify-restore-receipt.py"))
|
|
OBJECT_SOURCE_PATHS = (
|
|
"openbao/auth/keycape-services-config.json",
|
|
"openbao/auth/secrets-engine-jwt-role.json",
|
|
"openbao/auth/netkingdom-oidc-config.json",
|
|
"openbao/auth/netkingdom-platform-admin-role.json",
|
|
"openbao/policies/secrets-engine-login-self.hcl",
|
|
"docs/contracts/secrets-engine-openbao-jwt-login.yaml",
|
|
)
|
|
SOURCE_PATHS = tuple(sorted(ADAPTER_PATHS + OBJECT_SOURCE_PATHS))
|
|
KV_METADATA_PATH = "platform/metadata/workloads/keycape/secrets-engine-openbao"
|
|
|
|
# Required objects. ``source`` objects compare the declared fields of the
|
|
# source file (minus ``ignore``) with the restored readback after normalising.
|
|
OBJECTS: tuple[dict[str, Any], ...] = (
|
|
{"object": "auth_mount_keycape_services", "kind": "mount", "path": "sys/auth",
|
|
"mount": "keycape-services/", "types": ("jwt",)},
|
|
{"object": "auth_mount_netkingdom", "kind": "mount", "path": "sys/auth",
|
|
"mount": "netkingdom/", "types": ("oidc", "jwt")},
|
|
{"object": "kv_mount_platform", "kind": "mount", "path": "sys/mounts",
|
|
"mount": "platform/", "types": ("kv",), "kv_version": "2"},
|
|
{"object": "keycape_services_config", "kind": "source", "path": "auth/keycape-services/config",
|
|
"source": "openbao/auth/keycape-services-config.json", "ignore": ()},
|
|
{"object": "secrets_engine_jwt_role", "kind": "source",
|
|
"path": "auth/keycape-services/role/secrets-engine-openbao",
|
|
"source": "openbao/auth/secrets-engine-jwt-role.json", "ignore": ()},
|
|
{"object": "netkingdom_oidc_config", "kind": "source", "path": "auth/netkingdom/config",
|
|
"source": "openbao/auth/netkingdom-oidc-config.json", "ignore": ("_note",)},
|
|
{"object": "platform_admin_role", "kind": "source", "path": "auth/netkingdom/role/platform-admin",
|
|
"source": "openbao/auth/netkingdom-platform-admin-role.json", "ignore": ()},
|
|
{"object": "policy_secrets_engine_login_self", "kind": "policy",
|
|
"path": "sys/policies/acl/secrets-engine-login-self",
|
|
"source": "openbao/policies/secrets-engine-login-self.hcl"},
|
|
{"object": "kv_registration_metadata", "kind": "kv_metadata", "path": KV_METADATA_PATH},
|
|
)
|
|
ALLOWED_READS = frozenset({"sys/seal-status", "sys/leader", "auth/token/lookup-self",
|
|
*(o["path"] for o in OBJECTS)})
|
|
ALLOWED_WRITES = frozenset({"sys/unseal", "sys/generate-root/attempt", "sys/generate-root/update",
|
|
"auth/token/revoke-self"})
|
|
# --------------------------------------------------------------------------- T10 break-glass drill
|
|
PHASE_A, DRILL_MODE = "phase-a", "break-glass-drill"
|
|
DRILL = "break-glass"
|
|
GENERATE_ROOT_ATTEMPT = "sys/generate-root/attempt"
|
|
MINIMAL_ACTION_PATH = "sys/auth"
|
|
# T10 extensions: generate-root status (GET) and cancel (DELETE); sys/auth and
|
|
# auth/token/lookup-self are already allowed reads, sys/generate-root/update an allowed write.
|
|
ALLOWED_READS = ALLOWED_READS | {GENERATE_ROOT_ATTEMPT}
|
|
ALLOWED_DELETES = frozenset({GENERATE_ROOT_ATTEMPT})
|
|
DRILL_READBACK = "generate-root-from-reentered-shares"
|
|
DRILL_CLAIMS_NOT_MADE = ("production_root_token_validity", "production_generate_root_availability",
|
|
"sso_down_access_path")
|
|
DRILL_ABORT_REASONS = frozenset({"held_token_refused", "generate_root_failed", "minimal_action_failed",
|
|
"revocation_failed", "cancel_drill_failed"})
|
|
ABORT_REASONS = frozenset({
|
|
"admission_refused", "preflight_refused", "custody_identity_refused", "ciphertext_mismatch",
|
|
"decrypt_failed", "plaintext_mismatch", "target_start_failed", "network_containment_failed",
|
|
"throwaway_init_failed", "restore_failed", "unseal_refused", "leader_not_elected", "cluster_mismatch",
|
|
"applied_index_behind", "readback_credential_failed", "readback_failed", "deadline_exceeded",
|
|
"operator_abort", "parent_lost", "internal_error",
|
|
}) | DRILL_ABORT_REASONS
|
|
# Production runs the chart with setNodeId, so the snapshot's voter is the pod
|
|
# name. The target takes the same id; a native rehearsal showed a different id
|
|
# still elects a leader after a forced restore, so this is alignment, not the
|
|
# T07 attempt -01 cause.
|
|
RESTORE_NODE_ID = "openbao-0"
|
|
SERVER_CONFIG = json.dumps({
|
|
"storage": {"raft": {"path": "/openbao/data", "node_id": RESTORE_NODE_ID}},
|
|
# OpenBao 2.x disables unauthenticated generate-root by default; the
|
|
# readback credential needs it, on a loopback-only listener without a network.
|
|
"listener": {"tcp": {"address": "127.0.0.1:8200", "tls_disable": True,
|
|
"disable_unauthed_generate_root_endpoints": False}},
|
|
"api_addr": "http://127.0.0.1:8200", "cluster_addr": "http://127.0.0.1:8201",
|
|
"disable_mlock": True, "ui": False,
|
|
}, separators=(",", ":"))
|
|
TMPFS = {
|
|
"/openbao/data": "rw,nosuid,nodev,noexec,size=536870912,uid=100,gid=1000,mode=0700",
|
|
"/openbao/config": "rw,nosuid,nodev,noexec,size=1048576,uid=100,gid=1000,mode=0700",
|
|
"/tmp": "rw,nosuid,nodev,noexec,size=16777216,uid=100,gid=1000,mode=0700",
|
|
# The image declares VOLUMEs here; tmpfs overlays stop anonymous volumes.
|
|
"/openbao/file": "rw,nosuid,nodev,noexec,size=1048576,uid=100,gid=1000,mode=0700",
|
|
"/openbao/logs": "rw,nosuid,nodev,noexec,size=1048576,uid=100,gid=1000,mode=0700",
|
|
# Production declares a file audit device at /openbao/audit/openbao-audit.log
|
|
# (rapp-openbao values). The restored audit table re-enables it on unseal; if
|
|
# the path is unwritable OpenBao seals itself again (T07 attempts -01/-02).
|
|
# The log holds HMAC'd entries only and dies with the container.
|
|
"/openbao/audit": "rw,nosuid,nodev,noexec,size=16777216,uid=100,gid=1000,mode=0700",
|
|
}
|
|
DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
IMAGE_RE = re.compile(r"^" + re.escape(IMAGE_REPOSITORY) + r"@(sha256:[a-f0-9]{64})$")
|
|
AGE_IDENTITY_RE = re.compile(r"^AGE-SECRET-KEY-1[0-9A-Z]{58}$")
|
|
AGE_RECIPIENT_RE = re.compile(r"^age1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
|
SHARE_RE = re.compile(r"^(?:[A-Za-z0-9+/]{40,100}={0,2}|[a-f0-9]{60,130})$")
|
|
ATTEMPT_RE = re.compile(r"^RPF-WP-0064-(?:T07|T04)-[A-Za-z0-9-]{3,64}$")
|
|
DRILL_ATTEMPT_RE = re.compile(r"^RPF-WP-0064-T10-[A-Za-z0-9-]{3,64}$")
|
|
# Snapshot name for the claim limit, e.g. openbao-raft-20261014T1532Z.snap.age.
|
|
SNAPSHOT_LABEL_RE = re.compile(r"^(?=.*20\d{6})[a-z0-9][a-z0-9._-]{2,95}$")
|
|
HELD_TOKEN_RE = re.compile(r"^[A-Za-z0-9._-]{16,256}$")
|
|
POLICY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
|
HTTP_CODE_RE = re.compile(rb"Code: (\d{3})\.")
|
|
COMMIT_RE = re.compile(r"^[a-f0-9]{40}$")
|
|
DURATION_RE = re.compile(r"^(\d+)([smh]?)$")
|
|
|
|
|
|
class Refused(Exception):
|
|
"""A fail-closed check; ``reason`` is an ABORT_REASONS code or ``object_*``."""
|
|
|
|
def __init__(self, reason: str):
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
|
|
|
|
def require(condition: Any, reason: str) -> None:
|
|
if not condition:
|
|
raise Refused(reason)
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
return datetime.now(UTC).replace(microsecond=0)
|
|
|
|
|
|
def iso(value: datetime) -> str:
|
|
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def parse_iso(value: Any) -> datetime:
|
|
require(isinstance(value, str) and re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", value), "admission_refused")
|
|
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def canonical(value: Any) -> bytes:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
|
|
|
|
def strict_json(raw: bytes) -> Any:
|
|
def unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
out: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
require(key not in out, "admission_refused")
|
|
out[key] = value
|
|
return out
|
|
return json.loads(raw, object_pairs_hook=unique)
|
|
|
|
|
|
def placeholder_digest(value: str) -> bool:
|
|
hexpart = value.split(":", 1)[-1]
|
|
return len(set(hexpart)) <= 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- admission
|
|
|
|
ADMISSION_FIELDS = {"schema", "namespace", "attempt_id", "phase", "phase_b_admitted", "material",
|
|
"not_before", "not_after", "max_duration_seconds", "abort_operator", "image",
|
|
"adapter_commit", "snapshot", "unseal", "readback_credential", "source_sha256"}
|
|
SNAPSHOT_FIELDS = {"ciphertext_sha256", "ciphertext_bytes", "plaintext_sha256", "age_recipient",
|
|
"source_cluster_id", "source_applied_index", "source_receipt_sha256"}
|
|
|
|
|
|
def validate_admission(adm: Any, mode: str = PHASE_A) -> dict[str, Any]:
|
|
"""Shape-only validation of the signed admission (no I/O); phase-A and drill admissions are disjoint."""
|
|
require(mode in (PHASE_A, DRILL_MODE), "admission_refused")
|
|
drill = mode == DRILL_MODE
|
|
fields = ADMISSION_FIELDS | {"drill", "snapshot_label"} if drill else ADMISSION_FIELDS
|
|
require(isinstance(adm, dict) and set(adm) == fields, "admission_refused")
|
|
require(adm["schema"] == ADMISSION_SCHEMA and adm["namespace"] == NAMESPACE, "admission_refused")
|
|
attempt_re = DRILL_ATTEMPT_RE if drill else ATTEMPT_RE
|
|
require(isinstance(adm["attempt_id"], str) and attempt_re.fullmatch(adm["attempt_id"]), "admission_refused")
|
|
require(adm["phase"] == "A" and adm["phase_b_admitted"] is False, "admission_refused")
|
|
require(adm["material"] in ("original", "synthetic"), "admission_refused")
|
|
require(adm["readback_credential"] == (DRILL_READBACK if drill else "generate-root-from-entered-shares"),
|
|
"admission_refused")
|
|
if drill:
|
|
require(adm["drill"] == DRILL, "admission_refused")
|
|
require(isinstance(adm["snapshot_label"], str) and SNAPSHOT_LABEL_RE.fullmatch(adm["snapshot_label"]),
|
|
"admission_refused")
|
|
nb, na = parse_iso(adm["not_before"]), parse_iso(adm["not_after"])
|
|
require(nb < na and (na - nb).total_seconds() <= 24 * 3600, "admission_refused")
|
|
dur = adm["max_duration_seconds"]
|
|
require(type(dur) is int and 60 <= dur <= MAX_DURATION_SECONDS, "admission_refused")
|
|
if adm["material"] == "original":
|
|
require(dur == MAX_DURATION_SECONDS, "admission_refused")
|
|
require(isinstance(adm["abort_operator"], str) and re.fullmatch(r"[a-z][a-z0-9-]{1,63}", adm["abort_operator"]),
|
|
"admission_refused")
|
|
match = IMAGE_RE.fullmatch(adm["image"]) if isinstance(adm["image"], str) else None
|
|
require(match and not placeholder_digest(match.group(1)), "admission_refused")
|
|
require(isinstance(adm["adapter_commit"], str) and COMMIT_RE.fullmatch(adm["adapter_commit"]), "admission_refused")
|
|
snap = adm["snapshot"]
|
|
require(isinstance(snap, dict) and set(snap) == SNAPSHOT_FIELDS, "admission_refused")
|
|
for field in ("ciphertext_sha256", "plaintext_sha256", "source_receipt_sha256"):
|
|
require(isinstance(snap[field], str) and DIGEST_RE.fullmatch(snap[field])
|
|
and not placeholder_digest(snap[field]), "admission_refused")
|
|
require(type(snap["ciphertext_bytes"]) is int and 0 < snap["ciphertext_bytes"] <= 512 * 1024 * 1024,
|
|
"admission_refused")
|
|
require(isinstance(snap["age_recipient"], str) and AGE_RECIPIENT_RE.fullmatch(snap["age_recipient"]),
|
|
"admission_refused")
|
|
require(isinstance(snap["source_cluster_id"], str)
|
|
and re.fullmatch(r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", snap["source_cluster_id"]),
|
|
"admission_refused")
|
|
require(type(snap["source_applied_index"]) is int and snap["source_applied_index"] > 0, "admission_refused")
|
|
require(adm["unseal"] == {"key_threshold": 2, "key_shares": 3}, "admission_refused")
|
|
sources = adm["source_sha256"]
|
|
require(isinstance(sources, dict) and set(sources) == set(SOURCE_PATHS), "admission_refused")
|
|
for value in sources.values():
|
|
require(isinstance(value, str) and DIGEST_RE.fullmatch(value), "admission_refused")
|
|
return adm
|
|
|
|
|
|
def source_digests(root: Path) -> dict[str, str]:
|
|
out = {}
|
|
for name in SOURCE_PATHS:
|
|
path = root / name
|
|
require(not any(p.is_symlink() for p in (path, *path.parents)) and path.is_file(), "preflight_refused")
|
|
out[name] = sha256_bytes(path.read_bytes())
|
|
return out
|
|
|
|
|
|
def check_source_closure(adm: dict[str, Any], root: Path) -> None:
|
|
"""Admission names the exact committed adapter and source files on disk."""
|
|
require(source_digests(root) == adm["source_sha256"], "preflight_refused")
|
|
head = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, timeout=10)
|
|
require(head.returncode == 0 and head.stdout.decode().strip() == adm["adapter_commit"], "preflight_refused")
|
|
dirty = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--", *SOURCE_PATHS],
|
|
capture_output=True, timeout=10)
|
|
require(dirty.returncode == 0 and not dirty.stdout.strip(), "preflight_refused")
|
|
|
|
|
|
def anchor_path() -> Path:
|
|
return Path(os.environ.get(ANCHOR_ENV) or DEFAULT_ANCHOR)
|
|
|
|
|
|
def anchor_protected(signers: Path) -> bool:
|
|
"""True if the running account cannot rewrite the trust anchor."""
|
|
euid = os.geteuid()
|
|
for path in (signers.parent, signers):
|
|
info = os.stat(path, follow_symlinks=False)
|
|
if info.st_mode & 0o022 or (info.st_uid == euid and euid != 0):
|
|
return False
|
|
return True
|
|
|
|
|
|
def verify_signature(data: bytes, signature: bytes, signers: Path) -> dict[str, str]:
|
|
"""``ssh-keygen -Y`` over exact admission bytes with the fixed namespace."""
|
|
import tempfile
|
|
require(signers.is_absolute() and not signers.is_symlink() and not signers.parent.is_symlink()
|
|
and signers.is_file(), "admission_refused")
|
|
require(signature.startswith(b"-----BEGIN SSH SIGNATURE-----") and len(signature) <= 8192, "admission_refused")
|
|
with tempfile.TemporaryDirectory(prefix="rpf64-adm-") 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=10)
|
|
principals = found.stdout.decode("utf-8", "replace").split()
|
|
require(found.returncode == 0 and len(principals) == 1
|
|
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._@-]{0,127}", principals[0]), "admission_refused")
|
|
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=10)
|
|
match = re.search(r"key (SHA256:[A-Za-z0-9+/=]+)", checked.stdout.decode("utf-8", "replace"))
|
|
require(checked.returncode == 0 and match, "admission_refused")
|
|
return {"principal": principals[0], "fingerprint": match.group(1)}
|
|
|
|
|
|
def read_owner_file(path: Path, limit: int) -> bytes:
|
|
"""Regular, owner-only, single-link file read without following symlinks."""
|
|
path = Path(os.path.abspath(path))
|
|
require(not any(p.is_symlink() for p in (path, *path.parents)), "preflight_refused")
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(fd, "rb") as handle:
|
|
info = os.fstat(handle.fileno())
|
|
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and info.st_nlink == 1
|
|
and not info.st_mode & 0o077 and 0 < info.st_size <= limit, "preflight_refused")
|
|
raw = handle.read(limit + 1)
|
|
require(0 < len(raw) <= limit, "preflight_refused")
|
|
return raw
|
|
|
|
|
|
def load_admission(admission: Path, signature: Path, admission_sha256: str, signers: Path,
|
|
mode: str = PHASE_A) -> tuple[dict[str, Any], bytes, dict[str, str], bool]:
|
|
raw = read_owner_file(admission, 64 * 1024)
|
|
require(DIGEST_RE.fullmatch(admission_sha256) and sha256_bytes(raw) == admission_sha256, "admission_refused")
|
|
adm = validate_admission(strict_json(raw), mode)
|
|
signer = verify_signature(raw, read_owner_file(signature, 8192), signers)
|
|
protected = anchor_protected(signers)
|
|
if adm["material"] == "original":
|
|
require(protected, "admission_refused")
|
|
require(PINNED_IMAGE_DIGEST and adm["image"] == f"{IMAGE_REPOSITORY}@{PINNED_IMAGE_DIGEST}",
|
|
"admission_refused")
|
|
return adm, raw, signer, protected
|
|
|
|
|
|
def check_window(adm: dict[str, Any], at: datetime | None = None) -> None:
|
|
at = at or now_utc()
|
|
require(parse_iso(adm["not_before"]) <= at < parse_iso(adm["not_after"]), "admission_refused")
|
|
|
|
|
|
# --------------------------------------------------------------------------- normalisation
|
|
|
|
def _seconds(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
match = DURATION_RE.fullmatch(value)
|
|
if match:
|
|
return int(match.group(1)) * {"": 1, "s": 1, "m": 60, "h": 3600}[match.group(2)]
|
|
return value
|
|
|
|
|
|
def normalise(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {k: normalise(v) for k, v in sorted(value.items())}
|
|
if isinstance(value, list):
|
|
items = [normalise(v) for v in value]
|
|
return sorted(items) if all(isinstance(v, str) for v in items) else items
|
|
return _seconds(value)
|
|
|
|
|
|
def project(source: dict[str, Any], observed: dict[str, Any], ignore: tuple[str, ...]) -> tuple[bool, dict]:
|
|
fields = sorted(k for k in source if k not in ignore)
|
|
projected = {k: normalise(observed.get(k)) for k in fields}
|
|
expected = {k: normalise(source[k]) for k in fields}
|
|
return projected == expected, projected
|
|
|
|
|
|
# --------------------------------------------------------------------------- memfd custody
|
|
|
|
def sealed_memfd(name: str, data: bytes | bytearray) -> int:
|
|
fd = os.memfd_create(name, os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING)
|
|
view = memoryview(data)
|
|
while view:
|
|
view = view[os.write(fd, view):]
|
|
fcntl.fcntl(fd, fcntl.F_ADD_SEALS, fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_GROW | fcntl.F_SEAL_WRITE | fcntl.F_SEAL_SEAL)
|
|
os.lseek(fd, 0, os.SEEK_SET)
|
|
return fd
|
|
|
|
|
|
def wipe(buf: bytearray | None) -> None:
|
|
if buf is not None:
|
|
for i in range(len(buf)):
|
|
buf[i] = 0
|
|
|
|
|
|
def http_code(stderr: bytes) -> int | None:
|
|
"""HTTP status from the bao CLI error text (``Code: 403.``); None if absent."""
|
|
match = HTTP_CODE_RE.search(stderr)
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def policy_names(value: Any) -> list[str]:
|
|
"""Policy names only, sorted; anything not shaped like a policy name is dropped."""
|
|
return sorted(p for p in (value if isinstance(value, list) else [])
|
|
if isinstance(p, str) and POLICY_NAME_RE.fullmatch(p))[:32]
|
|
|
|
|
|
def tty_prompt(label: str) -> bytearray:
|
|
"""Hidden prompt on the controlling terminal; the value never touches argv/env/disk."""
|
|
require(sys.stdin.isatty(), "preflight_refused")
|
|
return bytearray(getpass.getpass(f"{label} (hidden): ").strip().encode())
|
|
|
|
|
|
# --------------------------------------------------------------------------- session
|
|
|
|
class Session:
|
|
"""One phase-A restore attempt. Inject ``prompt``/``docker``/``say`` in tests only."""
|
|
|
|
def __init__(self, *, admission: dict[str, Any], admission_sha256: str, signer: dict[str, str],
|
|
anchor_is_protected: bool, ciphertext: Path, receipt_path: Path, root: Path = ROOT,
|
|
prompt: Callable[[str], bytearray] = tty_prompt, docker: str = "docker",
|
|
say: Callable[[str], None] = lambda m: print(m, flush=True), start_reaper: bool = True,
|
|
mode: str = PHASE_A):
|
|
require(mode in (PHASE_A, DRILL_MODE) and (mode == DRILL_MODE) == ("drill" in admission), "admission_refused")
|
|
self.mode = mode
|
|
self.adm, self.root, self.prompt, self.docker, self.say = admission, root, prompt, docker, say
|
|
self.ciphertext, self.receipt_path, self.start_reaper = ciphertext, receipt_path, start_reaper
|
|
self.name = "rpf64-restore-" + secrets.token_hex(6)
|
|
self.deadline = time.monotonic() + admission["max_duration_seconds"]
|
|
self.not_after = parse_iso(admission["not_after"])
|
|
self.started = now_utc()
|
|
self.container_started = False
|
|
self.identity_fd: int | None = None
|
|
self.plaintext_fd: int | None = None
|
|
self.shares: list[bytearray] = []
|
|
self.credential: str | None = None
|
|
self.reaper: subprocess.Popen | None = None
|
|
snap = admission["snapshot"]
|
|
self.receipt: dict[str, Any] = {
|
|
"schema": RECEIPT_SCHEMA,
|
|
"extends": "docs/openbao-restore-drill-evidence.example.json",
|
|
"attempt_id": admission["attempt_id"], "material": admission["material"], "phase": "A",
|
|
"admission_sha256": admission_sha256, "admission_namespace": NAMESPACE,
|
|
"signer_principal": signer["principal"], "signer_fingerprint": signer["fingerprint"],
|
|
"anchor_protected": anchor_is_protected, "abort_operator": admission["abort_operator"],
|
|
"adapter_commit": admission["adapter_commit"], "source_sha256": dict(admission["source_sha256"]),
|
|
"started_at": iso(self.started), "finished_at": None, "not_after": admission["not_after"],
|
|
"deadline_seconds": admission["max_duration_seconds"], "elapsed_seconds": None,
|
|
"image": admission["image"], "image_id": None,
|
|
"snapshot": {"ciphertext_sha256": snap["ciphertext_sha256"], "ciphertext_bytes": snap["ciphertext_bytes"],
|
|
"plaintext_sha256": snap["plaintext_sha256"], "source_cluster_id": snap["source_cluster_id"],
|
|
"source_applied_index": snap["source_applied_index"],
|
|
"source_receipt_sha256": snap["source_receipt_sha256"],
|
|
"ciphertext_verified": False, "plaintext_verified": False},
|
|
"restored": {"cluster_id": None, "applied_index": None, "sealed_after_restore": None,
|
|
"unsealed_with_original_shares": False, "cluster_id_match": False,
|
|
"applied_index_not_behind": False, "throwaway_init_invalidated": False,
|
|
"leader_wait": None},
|
|
"network": {"network_mode": None, "docker_networks": [], "interfaces": [], "published_ports": [],
|
|
"mounts": [], "egress_refused": False},
|
|
"objects": [], "result": None, "reason": None,
|
|
"teardown": {"container_removed": False, "container_absent_verified": False,
|
|
"plaintext_memfd_closed": True, "custody_memfd_closed": True,
|
|
"readback_credential_revoked": None, "docker_volumes_created": 0, "performed_by": "session"},
|
|
"kv_data_read": False, "no_secret_material_recorded": True,
|
|
"claims_not_made": list(CLAIMS_NOT_MADE),
|
|
}
|
|
if mode == DRILL_MODE:
|
|
self.receipt["claims_not_made"] += list(DRILL_CLAIMS_NOT_MADE)
|
|
self.receipt.update({
|
|
"drill": DRILL,
|
|
"held_root_token": {"valid": None, "policies": [], "ttl_zero": None, "expire_time_null": None,
|
|
"orphan": None},
|
|
"generate_root": {"started": False, "shares_entered": 0, "complete": False, "otp_decode_ok": False,
|
|
"policies": [], "minimal_action_path": MINIMAL_ACTION_PATH,
|
|
"minimal_action_ok": False},
|
|
"revocation": {"revoke_status": None, "post_revoke_lookup": None},
|
|
"cancel_drill": {"started_then_cancelled": False},
|
|
# The held-token result is valid only as of this snapshot, never for production now.
|
|
"claim_limits": {"production_root_token_validity": {
|
|
"as_of_snapshot": admission["snapshot_label"],
|
|
"snapshot_plaintext_sha256": snap["plaintext_sha256"]}},
|
|
})
|
|
|
|
# ---- infrastructure
|
|
def remaining(self) -> float:
|
|
left = min(self.deadline - time.monotonic(), (self.not_after - now_utc()).total_seconds())
|
|
require(left > 0, "deadline_exceeded")
|
|
return left
|
|
|
|
def run(self, argv: list[str], *, data: bytes = b"", limit: float = 60, check: bool = True,
|
|
reason: str = "internal_error") -> subprocess.CompletedProcess:
|
|
try:
|
|
done = subprocess.run(argv, input=data, capture_output=True, timeout=min(limit, self.remaining()))
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise Refused("deadline_exceeded" if self.remaining_or_zero() <= 1 else reason) from exc
|
|
require(not check or done.returncode == 0, reason)
|
|
return done
|
|
|
|
def remaining_or_zero(self) -> float:
|
|
return max(0.0, min(self.deadline - time.monotonic(), (self.not_after - now_utc()).total_seconds()))
|
|
|
|
def bao(self, args: list[str], *, credential: str | None = None, body: bytes = b"", limit: float = 30,
|
|
check: bool = True, reason: str = "internal_error") -> subprocess.CompletedProcess:
|
|
script = 'IFS= read -r t; BAO_TOKEN="$t"; export BAO_TOKEN; exec "$@"' if credential else 'exec "$@"'
|
|
data = (credential.encode() + b"\n" + body) if credential else body
|
|
return self.run([self.docker, "exec", "-i", self.name, "sh", "-c", script, "sh", "bao", *args],
|
|
data=data, limit=limit, check=check, reason=reason)
|
|
|
|
def read(self, path: str, credential: str | None = None, reason: str = "readback_failed") -> dict | None:
|
|
require(path in ALLOWED_READS and "/data/" not in path, "internal_error")
|
|
done = self.bao(["read", "-format=json", path], credential=credential, check=False, reason=reason)
|
|
if done.returncode != 0:
|
|
if b"No value found" in done.stderr or done.returncode == 2:
|
|
return None
|
|
raise Refused(reason)
|
|
body = json.loads(done.stdout)
|
|
return body.get("data", body) if isinstance(body, dict) else None
|
|
|
|
def write(self, path: str, payload: dict, credential: str | None = None, reason: str = "internal_error") -> dict:
|
|
require(path in ALLOWED_WRITES, "internal_error")
|
|
done = self.bao(["write", "-format=json", path, "-"], credential=credential, body=json.dumps(payload).encode(),
|
|
reason=reason)
|
|
body = json.loads(done.stdout or b"{}")
|
|
return body.get("data", body) if isinstance(body, dict) and isinstance(body.get("data"), dict) else body
|
|
|
|
def delete(self, path: str, reason: str = "internal_error") -> None:
|
|
require(path in ALLOWED_DELETES, "internal_error")
|
|
self.bao(["delete", path], reason=reason)
|
|
|
|
def tell_reaper(self, line: str) -> None:
|
|
if self.reaper and self.reaper.stdin:
|
|
try:
|
|
self.reaper.stdin.write((line + "\n").encode())
|
|
self.reaper.stdin.flush()
|
|
except OSError:
|
|
pass
|
|
|
|
def spawn_reaper(self) -> None:
|
|
deadline_epoch = time.time() + self.remaining_or_zero() + REAPER_GRACE_SECONDS
|
|
header = {"parent_pid": os.getpid(), "deadline_epoch": deadline_epoch, "docker": self.docker,
|
|
"receipt_path": str(self.receipt_path), "base_receipt": self.receipt}
|
|
self.reaper = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--reaper"],
|
|
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
start_new_session=True, close_fds=True)
|
|
self.tell_reaper(json.dumps(header))
|
|
|
|
# ---- steps
|
|
def custody_identity(self) -> None:
|
|
self.say("Step 1/7: custody identity (hidden prompt).")
|
|
identity = self.prompt("Backup age identity")
|
|
try:
|
|
require(AGE_IDENTITY_RE.fullmatch(identity.decode("ascii", "replace")), "custody_identity_refused")
|
|
self.identity_fd = sealed_memfd("rpf64-age-identity", identity + b"\n")
|
|
self.receipt["teardown"]["custody_memfd_closed"] = False
|
|
finally:
|
|
wipe(identity)
|
|
done = subprocess.run(["age-keygen", "-y", f"/dev/fd/{self.identity_fd}"], pass_fds=(self.identity_fd,),
|
|
capture_output=True, timeout=min(10, self.remaining()))
|
|
require(done.returncode == 0 and done.stdout.decode().strip() == self.adm["snapshot"]["age_recipient"],
|
|
"custody_identity_refused")
|
|
|
|
def decrypt(self) -> None:
|
|
self.say("Step 2/7: verify ciphertext and decrypt into a sealed memfd.")
|
|
snap = self.adm["snapshot"]
|
|
raw = self.ciphertext.read_bytes()
|
|
require(len(raw) == snap["ciphertext_bytes"] and sha256_bytes(raw) == snap["ciphertext_sha256"],
|
|
"ciphertext_mismatch")
|
|
self.receipt["snapshot"]["ciphertext_verified"] = True
|
|
os.lseek(self.identity_fd, 0, os.SEEK_SET)
|
|
try:
|
|
done = subprocess.run(["age", "-d", "-i", f"/dev/fd/{self.identity_fd}"], input=raw,
|
|
pass_fds=(self.identity_fd,), capture_output=True,
|
|
timeout=min(300, self.remaining()))
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise Refused("deadline_exceeded") from exc
|
|
require(done.returncode == 0 and done.stdout, "decrypt_failed")
|
|
plain = bytearray(done.stdout)
|
|
del done
|
|
try:
|
|
require(sha256_bytes(bytes(plain)) == snap["plaintext_sha256"], "plaintext_mismatch")
|
|
self.plaintext_fd = sealed_memfd("rpf64-snapshot", plain)
|
|
self.receipt["teardown"]["plaintext_memfd_closed"] = False
|
|
finally:
|
|
wipe(plain)
|
|
self.receipt["snapshot"]["plaintext_verified"] = True
|
|
self.close_identity()
|
|
|
|
def start_target(self) -> None:
|
|
self.say("Step 3/7: start networkless disposable target.")
|
|
image = self.adm["image"]
|
|
inspect = self.run([self.docker, "image", "inspect", image, "--format", "{{json .RepoDigests}}|{{.Id}}"],
|
|
reason="target_start_failed")
|
|
digests, image_id = inspect.stdout.decode().strip().split("|", 1)
|
|
require(image in json.loads(digests) and DIGEST_RE.fullmatch(image_id), "target_start_failed")
|
|
self.receipt["image_id"] = image_id
|
|
argv = [self.docker, "run", "--detach", "--pull=never", "--name", self.name, "--network", "none",
|
|
"--user", "100:1000", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
"--pids-limit", "256", "--memory", "1g", "--label", "railiance.restore.attempt=" + self.adm["attempt_id"]]
|
|
for target, options in TMPFS.items():
|
|
argv += ["--tmpfs", f"{target}:{options}"]
|
|
argv += ["-e", "BAO_LOCAL_CONFIG=" + SERVER_CONFIG, "-e", "BAO_ADDR=http://127.0.0.1:8200",
|
|
"-e", "SKIP_CHOWN=1", image, "server"]
|
|
self.tell_reaper("C " + self.name)
|
|
self.container_started = True
|
|
self.run(argv, reason="target_start_failed")
|
|
self.check_network()
|
|
for _ in range(60):
|
|
status = self.read("sys/seal-status", reason="target_start_failed")
|
|
if status and status.get("initialized") is False:
|
|
return
|
|
time.sleep(0.5)
|
|
self.remaining()
|
|
raise Refused("target_start_failed")
|
|
|
|
def check_network(self) -> None:
|
|
done = self.run([self.docker, "container", "inspect", self.name], reason="network_containment_failed")
|
|
info = json.loads(done.stdout)[0]
|
|
host, net = info["HostConfig"], info["NetworkSettings"]
|
|
record = self.receipt["network"]
|
|
record["network_mode"] = host.get("NetworkMode")
|
|
record["docker_networks"] = sorted((net.get("Networks") or {}).keys())
|
|
record["published_ports"] = sorted((net.get("Ports") or {}).keys())
|
|
record["mounts"] = sorted(m.get("Type", "?") for m in info.get("Mounts") or [])
|
|
require(record["network_mode"] == "none" and record["docker_networks"] == ["none"]
|
|
and not record["published_ports"] and not record["mounts"] and not host.get("PortBindings")
|
|
and not host.get("Binds") and host.get("Privileged") is False and host.get("ReadonlyRootfs") is True
|
|
and sorted((host.get("Tmpfs") or {}).keys()) == sorted(TMPFS), "network_containment_failed")
|
|
dev = self.run([self.docker, "exec", self.name, "cat", "/proc/net/dev"], reason="network_containment_failed")
|
|
names = sorted(line.split(":", 1)[0].strip() for line in dev.stdout.decode().splitlines()[2:] if ":" in line)
|
|
record["interfaces"] = names
|
|
require(names == ["lo"], "network_containment_failed")
|
|
probe = self.run([self.docker, "exec", self.name, "sh", "-c",
|
|
"wget -q -T 3 -O /dev/null http://192.0.2.1/ || exit 7; "
|
|
"wget -q -T 3 -O /dev/null https://kc.coulomb.social/ || exit 7; exit 0"],
|
|
check=False, limit=20, reason="network_containment_failed")
|
|
record["egress_refused"] = probe.returncode == 7
|
|
require(record["egress_refused"], "network_containment_failed")
|
|
|
|
def restore(self) -> None:
|
|
self.say("Step 4/7: throwaway init, then forced Raft restore from the sealed memfd.")
|
|
init = self.bao(["operator", "init", "-key-shares=1", "-key-threshold=1", "-format=json"],
|
|
reason="throwaway_init_failed")
|
|
throwaway = json.loads(init.stdout)
|
|
del init
|
|
unseal = self.write("sys/unseal", {"key": throwaway["unseal_keys_b64"][0]}, reason="throwaway_init_failed")
|
|
require(unseal.get("sealed") is False, "throwaway_init_failed")
|
|
self.wait_active("throwaway_init_failed")
|
|
throwaway_credential = throwaway["root_token"]
|
|
throwaway.clear()
|
|
script = 'IFS= read -r t; BAO_TOKEN="$t"; export BAO_TOKEN; exec bao operator raft snapshot restore -force /dev/stdin'
|
|
proc = subprocess.Popen([self.docker, "exec", "-i", self.name, "sh", "-c", script], stdin=subprocess.PIPE,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
proc.stdin.write(throwaway_credential.encode() + b"\n")
|
|
os.lseek(self.plaintext_fd, 0, os.SEEK_SET)
|
|
while chunk := os.read(self.plaintext_fd, 1 << 20):
|
|
proc.stdin.write(chunk)
|
|
proc.stdin.close()
|
|
code = proc.wait(timeout=min(600, self.remaining()))
|
|
except subprocess.TimeoutExpired as exc:
|
|
proc.kill()
|
|
raise Refused("deadline_exceeded" if self.remaining_or_zero() <= 1 else "restore_failed") from exc
|
|
except BrokenPipeError as exc:
|
|
raise Refused("restore_failed") from exc
|
|
require(code == 0, "restore_failed")
|
|
self.close_plaintext()
|
|
self.throwaway_credential = throwaway_credential
|
|
|
|
def unseal_original(self) -> None:
|
|
self.say("Step 5/7: unseal with two original shares (hidden prompts).")
|
|
sealed = None
|
|
for _ in range(60):
|
|
status = self.read("sys/seal-status", reason="unseal_refused")
|
|
sealed = bool(status and status.get("sealed"))
|
|
if sealed:
|
|
break
|
|
time.sleep(0.5)
|
|
self.remaining()
|
|
self.receipt["restored"]["sealed_after_restore"] = sealed
|
|
require(sealed, "unseal_refused")
|
|
for index in (1, 2):
|
|
share = self.prompt(f"Original unseal share {index} of 2")
|
|
if not SHARE_RE.fullmatch(share.decode("ascii", "replace")):
|
|
wipe(share)
|
|
raise Refused("unseal_refused")
|
|
self.shares.append(share)
|
|
result = self.write("sys/unseal", {"key": share.decode()}, reason="unseal_refused")
|
|
require(result.get("sealed") is False, "unseal_refused")
|
|
self.receipt["restored"]["unsealed_with_original_shares"] = True
|
|
leader = self.wait_active("leader_not_elected")
|
|
status = self.read("sys/seal-status", reason="unseal_refused") or {}
|
|
snap, restored = self.adm["snapshot"], self.receipt["restored"]
|
|
restored["cluster_id"] = status.get("cluster_id")
|
|
restored["applied_index"] = leader.get("raft_applied_index")
|
|
restored["cluster_id_match"] = restored["cluster_id"] == snap["source_cluster_id"]
|
|
restored["applied_index_not_behind"] = (isinstance(restored["applied_index"], int)
|
|
and restored["applied_index"] >= snap["source_applied_index"])
|
|
lookup = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=self.throwaway_credential,
|
|
check=False)
|
|
restored["throwaway_init_invalidated"] = lookup.returncode != 0
|
|
self.throwaway_credential = None
|
|
require(restored["throwaway_init_invalidated"], "restore_failed")
|
|
require(restored["cluster_id_match"], "cluster_mismatch")
|
|
require(restored["applied_index_not_behind"], "applied_index_behind")
|
|
|
|
def wait_active(self, reason: str) -> dict:
|
|
# Bounded, non-secret diagnostics: T07 attempt -01 timed out here with no
|
|
# trace. Only typed status fields and a sanitized first error line are kept.
|
|
started, polls, leader, seal, error = time.monotonic(), 0, {}, {}, None
|
|
wait = self.receipt["restored"]
|
|
try:
|
|
while time.monotonic() - started < LEADER_WAIT_SECONDS:
|
|
polls += 1
|
|
done = self.bao(["read", "-format=json", "sys/leader"], check=False, reason=reason)
|
|
if done.returncode == 0:
|
|
body = json.loads(done.stdout)
|
|
leader = body.get("data", body) if isinstance(body, dict) else {}
|
|
error = None
|
|
if leader.get("is_self") and isinstance(leader.get("raft_applied_index"), int) \
|
|
and leader["raft_applied_index"] > 0:
|
|
return leader
|
|
else:
|
|
error = sanitize_status_line(done.stderr)
|
|
time.sleep(1)
|
|
self.remaining()
|
|
status = self.bao(["read", "-format=json", "sys/seal-status"], check=False, reason=reason)
|
|
if status.returncode == 0:
|
|
body = json.loads(status.stdout)
|
|
seal = body.get("data", body) if isinstance(body, dict) else {}
|
|
raise Refused(reason)
|
|
finally:
|
|
wait["leader_wait"] = {
|
|
"waited_seconds": int(time.monotonic() - started), "polls": polls,
|
|
"elected": bool(leader.get("is_self")) and isinstance(leader.get("raft_applied_index"), int)
|
|
and leader.get("raft_applied_index", 0) > 0,
|
|
"last_leader": {k: typed(leader.get(k), t) for k, t in LEADER_FIELDS.items()},
|
|
"last_seal": {k: typed(seal.get(k), t) for k, t in SEAL_FIELDS.items()},
|
|
"last_error": error,
|
|
}
|
|
|
|
def generate_credential(self) -> None:
|
|
self.say("Step 6/7: generate an in-memory readback credential from the entered shares.")
|
|
attempt = self.write("sys/generate-root/attempt", {}, reason="readback_credential_failed")
|
|
otp, nonce, encoded = attempt.get("otp"), attempt.get("nonce"), None
|
|
require(otp and nonce, "readback_credential_failed")
|
|
for share in self.shares:
|
|
update = self.write("sys/generate-root/update", {"key": share.decode(), "nonce": nonce},
|
|
reason="readback_credential_failed")
|
|
encoded = update.get("encoded_token") or update.get("encoded_root_token") or encoded
|
|
require(encoded, "readback_credential_failed")
|
|
raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
require(len(raw) == len(otp), "readback_credential_failed")
|
|
self.credential = bytes(a ^ b for a, b in zip(raw, otp.encode())).decode()
|
|
for share in self.shares:
|
|
wipe(share)
|
|
self.shares.clear()
|
|
|
|
def readback(self) -> list[dict[str, Any]]:
|
|
self.say("Step 7/7: metadata-only readback against the committed sources.")
|
|
rows = []
|
|
cache: dict[str, Any] = {}
|
|
for spec in OBJECTS:
|
|
row = {"object": spec["object"], "path": spec["path"], "present": False, "source_match": False,
|
|
"source_path": spec.get("source"), "observed_sha256": None}
|
|
if spec["kind"] == "mount":
|
|
if spec["path"] not in cache:
|
|
cache[spec["path"]] = self.read(spec["path"], self.credential) or {}
|
|
mount = cache[spec["path"]].get(spec["mount"])
|
|
if isinstance(mount, dict):
|
|
row["present"] = True
|
|
observed = {"type": mount.get("type"),
|
|
"kv_version": (mount.get("options") or {}).get("version")}
|
|
row["source_match"] = observed["type"] in spec["types"] and \
|
|
("kv_version" not in spec or observed["kv_version"] == spec["kv_version"])
|
|
row["observed_sha256"] = sha256_bytes(canonical(observed))
|
|
elif spec["kind"] == "source":
|
|
observed = self.read(spec["path"], self.credential)
|
|
if isinstance(observed, dict):
|
|
row["present"] = True
|
|
source = json.loads((self.root / spec["source"]).read_bytes())
|
|
row["source_match"], projected = project(source, observed, spec["ignore"])
|
|
row["observed_sha256"] = sha256_bytes(canonical(projected))
|
|
elif spec["kind"] == "policy":
|
|
observed = self.read(spec["path"], self.credential)
|
|
text = (observed or {}).get("policy")
|
|
if isinstance(text, str):
|
|
row["present"] = True
|
|
row["source_match"] = text.strip() == (self.root / spec["source"]).read_text().strip()
|
|
row["observed_sha256"] = sha256_bytes(text.strip().encode())
|
|
elif spec["kind"] == "kv_metadata":
|
|
meta = self.read(spec["path"], self.credential)
|
|
if isinstance(meta, dict):
|
|
current = meta.get("current_version")
|
|
version = (meta.get("versions") or {}).get(str(current)) or {}
|
|
observed = {"current_version": current, "versions": len(meta.get("versions") or {}),
|
|
"created_time": meta.get("created_time"), "updated_time": meta.get("updated_time"),
|
|
"current_deleted": bool(version.get("deletion_time")),
|
|
"current_destroyed": bool(version.get("destroyed"))}
|
|
row["present"] = isinstance(current, int) and current > 0
|
|
row["source_match"] = row["present"] and not observed["current_deleted"] \
|
|
and not observed["current_destroyed"]
|
|
row["observed_sha256"] = sha256_bytes(canonical(observed))
|
|
rows.append(row)
|
|
self.receipt["objects"] = rows
|
|
return rows
|
|
|
|
# ---- T10 break-glass drill steps (after restore + unseal)
|
|
def forget_unseal_shares(self) -> None:
|
|
"""The drill re-enters shares for generate-root; the unseal entries are not reused."""
|
|
for share in self.shares:
|
|
wipe(share)
|
|
self.shares.clear()
|
|
|
|
def drill_held_token(self) -> None:
|
|
self.say("Drill 1/5: held root-token check (hidden prompt; lookup-self only, never revoked).")
|
|
held = self.prompt("Held initial root token")
|
|
try:
|
|
require(HELD_TOKEN_RE.fullmatch(held.decode("ascii", "replace")), "held_token_refused")
|
|
done = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=held.decode(),
|
|
check=False, reason="held_token_refused")
|
|
finally:
|
|
wipe(held)
|
|
record = self.receipt["held_root_token"]
|
|
if done.returncode == 0:
|
|
body = json.loads(done.stdout)
|
|
data = body.get("data") if isinstance(body, dict) and isinstance(body.get("data"), dict) else {}
|
|
record.update(valid=True, policies=policy_names(data.get("policies")),
|
|
ttl_zero=type(data.get("ttl")) is int and data["ttl"] == 0,
|
|
expire_time_null="expire_time" in data and data["expire_time"] is None,
|
|
orphan=data["orphan"] if type(data.get("orphan")) is bool else None)
|
|
body.clear()
|
|
data = {}
|
|
elif http_code(done.stderr) == 403:
|
|
record.update(valid=False)
|
|
else:
|
|
raise Refused("held_token_refused")
|
|
|
|
def drill_generate_root(self) -> None:
|
|
self.say("Drill 2/5: fresh generate-root ceremony; re-enter two shares (hidden prompts).")
|
|
record = self.receipt["generate_root"]
|
|
status = self.read(GENERATE_ROOT_ATTEMPT, reason="generate_root_failed") or {}
|
|
require(status.get("started") is False, "generate_root_failed")
|
|
attempt = self.write(GENERATE_ROOT_ATTEMPT, {}, reason="generate_root_failed")
|
|
otp, nonce, encoded, update = attempt.get("otp"), attempt.get("nonce"), None, {}
|
|
attempt.clear()
|
|
require(isinstance(otp, str) and otp and isinstance(nonce, str) and nonce, "generate_root_failed")
|
|
record["started"] = True
|
|
for index in (1, 2):
|
|
share = self.prompt(f"Re-enter unseal share {index} of 2 for generate-root")
|
|
try:
|
|
require(SHARE_RE.fullmatch(share.decode("ascii", "replace")), "generate_root_failed")
|
|
update = self.write("sys/generate-root/update", {"key": share.decode(), "nonce": nonce},
|
|
reason="generate_root_failed")
|
|
finally:
|
|
wipe(share)
|
|
record["shares_entered"] = index
|
|
encoded = update.get("encoded_token") or update.get("encoded_root_token") or encoded
|
|
record["complete"] = update.get("complete") is True and bool(encoded)
|
|
require(record["complete"], "generate_root_failed")
|
|
try:
|
|
raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
require(len(raw) == len(otp), "generate_root_failed")
|
|
self.credential = bytes(a ^ b for a, b in zip(raw, otp.encode())).decode("ascii")
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
raise Refused("generate_root_failed") from exc
|
|
finally:
|
|
otp = nonce = encoded = None
|
|
update.clear()
|
|
record["otp_decode_ok"] = True
|
|
lookup = self.read("auth/token/lookup-self", self.credential, reason="generate_root_failed") or {}
|
|
record["policies"] = policy_names(lookup.get("policies"))
|
|
lookup.clear()
|
|
require(record["policies"] == ["root"], "generate_root_failed")
|
|
|
|
def drill_minimal_action(self) -> None:
|
|
self.say("Drill 3/5: one read-only action (sys/auth metadata).")
|
|
mounts = self.read(MINIMAL_ACTION_PATH, self.credential, reason="minimal_action_failed")
|
|
ok = isinstance(mounts, dict) and isinstance(mounts.get("token/"), dict)
|
|
self.receipt["generate_root"]["minimal_action_ok"] = ok
|
|
require(ok, "minimal_action_failed")
|
|
|
|
def drill_revoke(self) -> None:
|
|
self.say("Drill 4/5: revoke-self the generated token, then confirm lookup-self is refused.")
|
|
record = self.receipt["revocation"]
|
|
require("auth/token/revoke-self" in ALLOWED_WRITES, "internal_error")
|
|
done = self.bao(["write", "-format=json", "auth/token/revoke-self", "-"], credential=self.credential,
|
|
body=b"{}", check=False, reason="revocation_failed")
|
|
# The CLI prints nothing for 204 No Content; any response body means another status.
|
|
record["revoke_status"] = (204 if not done.stdout.strip() else 200) if done.returncode == 0 \
|
|
else http_code(done.stderr)
|
|
check = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=self.credential,
|
|
check=False, reason="revocation_failed")
|
|
record["post_revoke_lookup"] = 200 if check.returncode == 0 else http_code(check.stderr)
|
|
revoked = record["revoke_status"] == 204 and record["post_revoke_lookup"] == 403
|
|
self.receipt["teardown"]["readback_credential_revoked"] = revoked
|
|
require(revoked, "revocation_failed")
|
|
self.credential = None
|
|
|
|
def drill_cancel(self) -> None:
|
|
self.say("Drill 5/5: start one more generate-root attempt and cancel it.")
|
|
attempt = self.write(GENERATE_ROOT_ATTEMPT, {}, reason="cancel_drill_failed")
|
|
started = attempt.get("started") is True
|
|
attempt.clear()
|
|
self.delete(GENERATE_ROOT_ATTEMPT, reason="cancel_drill_failed")
|
|
status = self.read(GENERATE_ROOT_ATTEMPT, reason="cancel_drill_failed") or {}
|
|
ok = started and status.get("started") is False and status.get("progress") == 0
|
|
self.receipt["cancel_drill"]["started_then_cancelled"] = ok
|
|
require(ok, "cancel_drill_failed")
|
|
|
|
def revoke(self) -> None:
|
|
if not self.credential or not self.container_started:
|
|
return
|
|
try:
|
|
self.write("auth/token/revoke-self", {}, credential=self.credential)
|
|
check = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=self.credential,
|
|
check=False)
|
|
self.receipt["teardown"]["readback_credential_revoked"] = check.returncode != 0
|
|
except Exception:
|
|
self.receipt["teardown"]["readback_credential_revoked"] = False
|
|
finally:
|
|
self.credential = None
|
|
|
|
# ---- teardown
|
|
def close_identity(self) -> None:
|
|
if self.identity_fd is not None:
|
|
os.close(self.identity_fd)
|
|
self.identity_fd = None
|
|
self.receipt["teardown"]["custody_memfd_closed"] = True
|
|
|
|
def close_plaintext(self) -> None:
|
|
if self.plaintext_fd is not None:
|
|
os.close(self.plaintext_fd)
|
|
self.plaintext_fd = None
|
|
self.receipt["teardown"]["plaintext_memfd_closed"] = True
|
|
|
|
def teardown(self) -> None:
|
|
self.revoke()
|
|
self.close_identity()
|
|
self.close_plaintext()
|
|
for share in self.shares:
|
|
wipe(share)
|
|
self.shares.clear()
|
|
self.throwaway_credential = None
|
|
td = self.receipt["teardown"]
|
|
subprocess.run([self.docker, "rm", "-f", "-v", self.name], capture_output=True, timeout=60)
|
|
absent = subprocess.run([self.docker, "container", "inspect", self.name], capture_output=True, timeout=30)
|
|
td["container_removed"] = td["container_absent_verified"] = absent.returncode != 0
|
|
|
|
def write_receipt(self) -> None:
|
|
finished = now_utc()
|
|
self.receipt["finished_at"] = iso(finished)
|
|
self.receipt["elapsed_seconds"] = int((finished - self.started).total_seconds())
|
|
fd = os.open(self.receipt_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, "w") as out:
|
|
json.dump(self.receipt, out, indent=2, sort_keys=True)
|
|
out.write("\n")
|
|
|
|
def execute(self) -> dict[str, Any]:
|
|
self.throwaway_credential = None
|
|
handlers = {}
|
|
|
|
def interrupted(signum, _frame):
|
|
raise Refused("operator_abort")
|
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
handlers[sig] = signal.signal(sig, interrupted)
|
|
result, reason = "aborted", "internal_error"
|
|
try:
|
|
check_window(self.adm)
|
|
if self.start_reaper:
|
|
self.spawn_reaper()
|
|
self.custody_identity()
|
|
self.decrypt()
|
|
self.start_target()
|
|
self.restore()
|
|
self.unseal_original()
|
|
if self.mode == DRILL_MODE:
|
|
self.forget_unseal_shares()
|
|
self.drill_held_token()
|
|
self.drill_generate_root()
|
|
self.drill_minimal_action()
|
|
self.drill_revoke()
|
|
self.drill_cancel()
|
|
result, reason = "drilled", "break_glass_drill_complete"
|
|
else:
|
|
self.generate_credential()
|
|
rows = self.readback()
|
|
missing = [r for r in rows if not r["present"]]
|
|
mismatch = [r for r in rows if r["present"] and not r["source_match"]]
|
|
if missing:
|
|
result, reason = "refused", "object_missing"
|
|
elif mismatch:
|
|
result, reason = "refused", "object_mismatch"
|
|
else:
|
|
result, reason = "restored", "all_required_objects_match"
|
|
except Refused as exc:
|
|
result, reason = "aborted", exc.reason if exc.reason in ABORT_REASONS else "internal_error"
|
|
except Exception:
|
|
result, reason = "aborted", "internal_error"
|
|
finally:
|
|
try:
|
|
self.teardown()
|
|
finally:
|
|
for sig, handler in handlers.items():
|
|
signal.signal(sig, handler)
|
|
self.receipt["result"], self.receipt["reason"] = result, reason
|
|
self.write_receipt()
|
|
self.tell_reaper("DONE")
|
|
if self.reaper:
|
|
try:
|
|
self.reaper.stdin.close()
|
|
self.reaper.wait(timeout=10)
|
|
except Exception:
|
|
pass
|
|
self.say(f"Result: {result} ({reason}). Receipt written; verify it independently.")
|
|
return self.receipt
|
|
|
|
|
|
# --------------------------------------------------------------------------- reaper
|
|
|
|
def reaper_main() -> int:
|
|
"""Backstop: on parent loss or hard deadline remove containers, write ``aborted``."""
|
|
stream = sys.stdin.buffer
|
|
buffer = b""
|
|
while b"\n" not in buffer:
|
|
chunk = os.read(stream.fileno(), 65536)
|
|
if not chunk:
|
|
return 1
|
|
buffer += chunk
|
|
first, buffer = buffer.split(b"\n", 1)
|
|
header = json.loads(first)
|
|
parent, deadline = header["parent_pid"], header["deadline_epoch"]
|
|
try:
|
|
parent_fd = os.pidfd_open(parent)
|
|
except OSError:
|
|
parent_fd = None
|
|
names: list[str] = []
|
|
reason = "parent_lost"
|
|
while True:
|
|
while b"\n" in buffer:
|
|
line, buffer = buffer.split(b"\n", 1)
|
|
text = line.decode()
|
|
if text == "DONE":
|
|
return 0
|
|
if text.startswith("C ") and re.fullmatch(r"rpf64-restore-[a-f0-9]{12}", text[2:]):
|
|
names.append(text[2:])
|
|
wait = deadline - time.time()
|
|
if wait <= 0:
|
|
reason = "deadline_exceeded"
|
|
break
|
|
ready, _, _ = select.select([stream], [], [], min(wait, 5))
|
|
if not ready:
|
|
continue
|
|
chunk = os.read(stream.fileno(), 4096)
|
|
if not chunk:
|
|
break
|
|
buffer += chunk
|
|
if parent_fd is not None:
|
|
try:
|
|
signal.pidfd_send_signal(parent_fd, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
select.select([parent_fd], [], [], 10)
|
|
os.close(parent_fd)
|
|
removed = True
|
|
for name in names:
|
|
subprocess.run([header["docker"], "rm", "-f", "-v", name], capture_output=True, timeout=60)
|
|
absent = subprocess.run([header["docker"], "container", "inspect", name], capture_output=True, timeout=30)
|
|
removed = removed and absent.returncode != 0
|
|
receipt = header["base_receipt"]
|
|
finished = now_utc()
|
|
receipt.update({"result": "aborted", "reason": reason, "finished_at": iso(finished),
|
|
"elapsed_seconds": int((finished - parse_iso(receipt["started_at"])).total_seconds())})
|
|
receipt["teardown"].update({"container_removed": removed, "container_absent_verified": removed,
|
|
"plaintext_memfd_closed": True, "custody_memfd_closed": True,
|
|
"readback_credential_revoked": None, "performed_by": "reaper"})
|
|
try:
|
|
fd = os.open(header["receipt_path"], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
except FileExistsError:
|
|
return 0
|
|
with os.fdopen(fd, "w") as out:
|
|
json.dump(receipt, out, indent=2, sort_keys=True)
|
|
out.write("\n")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
import argparse
|
|
args = argv if argv is not None else sys.argv[1:]
|
|
if args == ["--reaper"]:
|
|
return reaper_main()
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
for name in ("admission", "admission-sig", "admission-sha256", "ciphertext", "receipt"):
|
|
parser.add_argument("--" + name, required=True)
|
|
parser.add_argument("--mode", choices=(PHASE_A, DRILL_MODE), default=PHASE_A)
|
|
opts = parser.parse_args(args)
|
|
os.umask(0o077)
|
|
try:
|
|
adm, _raw, signer, protected = load_admission(Path(opts.admission), Path(opts.admission_sig),
|
|
opts.admission_sha256, anchor_path(), opts.mode)
|
|
require(adm["material"] == "original", "admission_refused")
|
|
check_source_closure(adm, ROOT)
|
|
receipt = Path(opts.receipt)
|
|
require(receipt.is_absolute() and not receipt.exists() and receipt.parent.is_dir(), "preflight_refused")
|
|
except Refused as exc:
|
|
print(f"ERROR: {exc.reason}; no custody input requested.", file=sys.stderr)
|
|
return 1
|
|
session = Session(admission=adm, admission_sha256=opts.admission_sha256, signer=signer,
|
|
anchor_is_protected=protected, ciphertext=Path(opts.ciphertext), receipt_path=receipt,
|
|
mode=opts.mode)
|
|
result = session.execute()
|
|
verify = subprocess.run([sys.executable, str(ROOT / HERE / "verify-restore-receipt.py"), "--receipt",
|
|
str(receipt), "--source-root", str(ROOT)], capture_output=True, timeout=60)
|
|
print("Independent verifier: " + ("PASS" if verify.returncode == 0 else "FAIL"))
|
|
return 0 if result["result"] in ("restored", "refused", "drilled") and verify.returncode == 0 else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|