railiance-platform/scripts/openbao-reboot-snapshot.py
codex 048362e3ef RPF-WP-0015-T03: snapshot lane accepts the escrow key in age identity-file format
First attended snapshot run failed closed at verify: the stored AGE_PRIVATE_KEY
carries the standard '# created'/'# public key' comment lines. Nothing uploaded.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 680842@bnt-lap001
Assistant-Session: 54eb9e8e-7d6b-4f0c-b7a6-a6db62075883
2026-10-14 20:04:25 +02:00

455 lines
18 KiB
Python
Executable file

#!/usr/bin/env python3
"""Attended OpenBao Raft snapshot lane for the railiance01 reboot hold point.
RPF-WP-0015-T03, docs/railiance01-coordinated-reboot.md step 6. Runs only as
the single, silent child of the contained ops-warden login::
python3 scripts/openbao-attended-exec.py -- scripts/openbao-reboot-snapshot.py \
--scenario-id <ID> --receipt-file /abs/new/file --private-dir /abs/0700/dir
Order: read-only ``bao status`` -> ``bao operator raft snapshot save`` into a
fresh 0700 temp dir -> ``bao status`` again (same cluster, index not behind) ->
age-encrypt to the declared offsite-lane recipient -> decrypt-stream verify
against the plaintext sha256 with the lane's escrow key (held only in memory)
-> upload ciphertext with ``If-None-Match: *`` to the governed Nextcloud lane
-> shred the temp dir (always) -> self-validate and write the receipt.
The process prints nothing: Warden fails closed on any child output. The only
output is the 0600 receipt file (or a value-safe failure record) at
``--receipt-file``. Only booleans, hashes, ids and status codes are recorded.
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import importlib.util
import json
import os
import re
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
RECIPIENT_SOURCE = ROOT / "lib" / "railiance-backup-common.sh"
LANE_PATH = "platform/workloads/railiance/backup/offsite-lane"
# Established offsite-lane folder used by every existing filesdrop upload.
UPLOAD_PREFIX = "openbao" # founder-created railiance-backups/openbao/ (2026-10-14)
SOURCE = {"source_cluster": "railiance01", "source_namespace": "openbao", "source_pod": "openbao-0"}
OPERATOR = "railiance-platform"
SCENARIO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{2,95}$")
AGE_RECIPIENT_RE = re.compile(r"^age1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
AGE_HEADER = b"age-encryption.org/v1\n"
GZIP_MAGIC = b"\x1f\x8b"
CHUNK = 1024 * 1024
def _load(name: str, filename: str) -> Any:
spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename)
if not spec or not spec.loader:
raise RuntimeError(f"cannot load {filename}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Reuse, do not copy: contained login env + safe_run from the lease harness,
# the reviewed receipt validator, and the governed Nextcloud request helper.
LEASE = _load("audit_core_database_lease_recovery", "audit-core-database-lease-recovery.py")
PREFLIGHT = LEASE.PREFLIGHT
import migrate_nextcloud_backup_account as NEXTCLOUD # noqa: E402
class SnapshotError(RuntimeError):
pass
def fail(message: str) -> None:
raise SnapshotError(message)
def stamp(value: datetime) -> str:
return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def compact(value: datetime) -> str:
return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
def utc_now() -> datetime:
return datetime.now(UTC)
def declared_recipient(path: Path = RECIPIENT_SOURCE) -> str:
"""The offsite lane's public recipient, as declared in the backup helper."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
fail("offsite recipient declaration unavailable")
found = re.findall(r'RAILIANCE_BACKUP_AGE_PUBLIC_KEY:=(age1[0-9a-z]+)\}', text)
if len(found) != 1 or not AGE_RECIPIENT_RE.fullmatch(found[0]):
fail("offsite recipient declaration is not exactly one age recipient")
return found[0]
def inside_git_tree(path: Path) -> bool:
for candidate in (path, *path.parents):
if (candidate / ".git").exists():
return True
return False
def private_dir(value: str | None, environ: dict[str, str]) -> Path:
raw = value or environ.get("HOME", "")
path = Path(raw)
if not raw or not path.is_absolute():
fail("private dir must be an absolute path")
try:
info = path.lstat()
except OSError:
fail("private dir is missing")
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
fail("private dir must be an owner-only (0700) directory")
if inside_git_tree(path.resolve()):
fail("private dir must not be inside a Git tree")
return path
def open_receipt(value: str) -> int:
path = Path(value)
if not path.is_absolute() or inside_git_tree(path.parent.resolve()):
raise SnapshotError("receipt file must be an absolute path outside Git")
return os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(CHUNK), b""):
digest.update(chunk)
return digest.hexdigest()
def shred(path: Path) -> None:
"""Overwrite then unlink every regular file below ``path``; remove the tree."""
if not path.exists():
return
for item in sorted(path.rglob("*"), reverse=True):
try:
if item.is_file() and not item.is_symlink():
size = item.stat().st_size
with item.open("r+b") as handle:
remaining = size
while remaining > 0:
step = min(CHUNK, remaining)
handle.write(b"\0" * step)
remaining -= step
handle.flush()
os.fsync(handle.fileno())
item.unlink()
elif item.is_dir() and not item.is_symlink():
item.rmdir()
else:
item.unlink()
except OSError:
pass
shutil.rmtree(path, ignore_errors=True)
class Lane:
"""All side effects of one attended run; tests replace the boundaries."""
def __init__(self, env: dict[str, str]) -> None:
self.env = env
self.tool_env = {"PATH": env.get("PATH", "/usr/bin:/bin"), "HOME": env.get("HOME", "/")}
def bao(self, args: list[str], *, label: str) -> str:
return LEASE.safe_run(["bao", *args], label=label, env=self.env).stdout
def status(self) -> dict[str, Any]:
try:
status = json.loads(self.bao(["status", "-format=json"], label="read OpenBao status"))
return {
"initialized": status["initialized"] is True,
"unsealed": status["sealed"] is False,
"storage_type": status.get("storage_type"),
"cluster_id": str(status["cluster_id"]),
"raft_applied_index": int(status["raft_applied_index"]),
}
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
fail("OpenBao status is incomplete")
def snapshot(self, target: Path) -> None:
self.bao(["operator", "raft", "snapshot", "save", str(target)], label="save OpenBao Raft snapshot")
def lane_identity(self) -> str:
"""The escrow key may be stored as a standard age identity file
(``# created``/``# public key`` comments plus one key line); keep only that line."""
raw = self.bao(["kv", "get", "-field=AGE_PRIVATE_KEY", LANE_PATH], label="read offsite lane AGE_PRIVATE_KEY")
keys = []
for line in raw.replace("\r\n", "\n").split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
keys.append(line)
del raw
if len(keys) != 1 or not keys[0].startswith("AGE-SECRET-KEY-1"):
fail("offsite lane AGE_PRIVATE_KEY is unusable")
return keys[0]
def lane_field(self, field: str) -> str:
value = self.bao(["kv", "get", f"-field={field}", LANE_PATH], label=f"read offsite lane {field}")
value = value.strip()
if not value or "\n" in value or "\r" in value:
fail(f"offsite lane {field} is unusable")
return value
def tool(self, argv: list[str], *, label: str, payload: bytes = b"") -> bytes:
try:
result = subprocess.run(argv, input=payload, capture_output=True, env=self.tool_env, timeout=1800)
except (OSError, subprocess.TimeoutExpired):
fail(f"{label} unavailable")
if result.returncode != 0:
fail(f"{label} failed (exit {result.returncode})")
return result.stdout
def encrypt(self, recipients: list[str], source: Path, target: Path) -> None:
argv = ["age"]
for recipient in recipients:
argv += ["-r", recipient]
self.tool([*argv, "-o", str(target), str(source)], label="age encrypt")
def recipient_of(self, identity: bytes) -> str:
return self.tool(["age-keygen", "-y", "/dev/stdin"], label="derive escrow recipient", payload=identity).decode().strip()
def decrypt_digest(self, identity: bytes, ciphertext: Path) -> str:
"""Stream-decrypt into a hash; plaintext never touches disk again."""
digest = hashlib.sha256()
try:
proc = subprocess.Popen(
["age", "-d", "-i", "/dev/stdin", str(ciphertext)],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=self.tool_env,
)
except OSError:
fail("age decrypt unavailable")
try:
assert proc.stdin and proc.stdout
proc.stdin.write(identity)
proc.stdin.close()
for chunk in iter(lambda: proc.stdout.read(CHUNK), b""):
digest.update(chunk)
code = proc.wait(timeout=1800)
except (OSError, subprocess.TimeoutExpired):
proc.kill()
proc.wait()
fail("age decrypt failed")
finally:
if proc.stdout:
proc.stdout.close()
if code != 0:
fail(f"age decrypt failed (exit {code})")
return digest.hexdigest()
def upload(self, url: str, token: str, body: bytes) -> int:
status, _ = NEXTCLOUD.request(
url, "PUT", body, (token, ""),
{"Content-Type": "application/octet-stream", "If-None-Match": "*",
"Content-Length": str(len(body))},
)
return status
def run(
args: argparse.Namespace, lane: Lane, receipt: dict[str, Any], now=utc_now
) -> tuple[dict[str, Any], dict[str, Any]]:
recipient = declared_recipient()
base = private_dir(args.private_dir, lane.env)
stage = receipt.setdefault("stages", [])
stage.append("status_before")
before = lane.status()
if not (before["initialized"] and before["unsealed"] and before["storage_type"] == "raft"):
fail("OpenBao source is not an initialized, unsealed Raft node")
if before["raft_applied_index"] <= 0:
fail("OpenBao Raft applied index is not positive")
created = now()
work = Path(tempfile.mkdtemp(prefix="openbao-reboot-snapshot-", dir=base))
try:
os.chmod(work, 0o700)
plain = work / "openbao.snap"
cipher = work / "openbao.snap.age"
stage.append("snapshot_save")
lane.snapshot(plain)
if not plain.is_file() or plain.stat().st_size == 0:
fail("snapshot file was not written")
os.chmod(plain, 0o600)
with plain.open("rb") as handle:
if handle.read(2) != GZIP_MAGIC:
fail("snapshot is not a Raft snapshot archive")
stage.append("status_after")
after = lane.status()
if after["cluster_id"] != before["cluster_id"] or after["raft_applied_index"] < before["raft_applied_index"]:
fail("OpenBao identity or Raft index changed incoherently during the snapshot")
if not (after["initialized"] and after["unsealed"]):
fail("OpenBao source sealed during the snapshot")
plain_sha = sha256_file(plain)
stage.append("encrypt")
recipients = [recipient, *([args.custodian_recipient] if args.custodian_recipient else [])]
lane.encrypt(recipients, plain, cipher)
os.chmod(cipher, 0o600)
with cipher.open("rb") as handle:
if handle.read(len(AGE_HEADER)) != AGE_HEADER:
fail("ciphertext is not an age file")
cipher_sha = sha256_file(cipher)
if cipher_sha == plain_sha:
fail("ciphertext equals plaintext")
stage.append("verify")
url = lane.lane_field("NC_WEBDAV_URL")
token = lane.lane_field("NC_WEBDAV_TOKEN")
identity = bytearray((lane.lane_identity() + "\n").encode())
try:
escrow_recipient = lane.recipient_of(bytes(identity))
if not hmac.compare_digest(escrow_recipient, recipient):
fail("escrow key does not match the declared offsite recipient")
decrypted_sha = lane.decrypt_digest(bytes(identity), cipher)
finally:
identity[:] = b"\0" * len(identity)
if not hmac.compare_digest(decrypted_sha, plain_sha):
fail("decrypted snapshot hash does not match the plaintext")
stage.append("upload")
if url not in (
f"{NEXTCLOUD.HOST}/public.php/dav/filesdrop/{token}",
f"{NEXTCLOUD.HOST}/public.php/dav/files/{token}",
):
fail("offsite lane URL does not match the governed provider contract")
uploaded = now()
name = f"openbao-raft-snapshot-{args.scenario_id}-{compact(uploaded)}.snap.age"
status = lane.upload(f"{url}/{UPLOAD_PREFIX}/{name}", token, cipher.read_bytes())
if status != 201:
fail("offsite upload did not create a new object")
# Only a 201 on a no-overwrite PUT names a location; the drop cannot read back.
receipt["upload_http_status"] = status
receipt["encrypted_location_ref"] = f"offhost-custody:{args.scenario_id}:{compact(uploaded)}"
finally:
stage.append("shred")
shred(work)
receipt["temp_dir_removed"] = not work.exists()
if not receipt["temp_dir_removed"]:
fail("private temp dir could not be removed")
return {
"receipt_version": 1,
"receipt_id": f"{args.scenario_id}-SNAPSHOT-{compact(created)}",
"created_at": stamp(created),
"operator": OPERATOR,
**SOURCE,
"cluster_id": before["cluster_id"],
# Lower bound: the snapshot contains at least the pre-save index.
"raft_applied_index": before["raft_applied_index"],
"snapshot_created": True,
"source_initialized": True,
"source_unsealed": True,
"snapshot_sha256": f"sha256:{plain_sha}",
"snapshot_encrypted": True,
"encrypted_snapshot_sha256": f"sha256:{cipher_sha}",
"encrypted_location_ref": f"offhost-custody:{args.scenario_id}:{compact(uploaded)}",
"encrypted_copy_off_host": True,
"off_host_evidence": "upload acknowledged (HTTP 201, If-None-Match: *); remote hash not read back",
"upload_folder": UPLOAD_PREFIX,
"recipients": [recipient, *([args.custodian_recipient] if args.custodian_recipient else [])],
"custodian_escrow_ref": args.custodian_escrow_ref,
"escrow_key_read_decision": "reviews/RPF-WP-0015-T03-founder-go-20261014.json#escrow_key_read",
"encryption_verified": True,
"hash_verified": True,
"no_secret_material_recorded": True,
}, after
class QuietParser(argparse.ArgumentParser):
def error(self, message: str) -> None: # never print usage
raise SnapshotError("invalid arguments")
def parser() -> argparse.ArgumentParser:
p = QuietParser(description=__doc__, add_help=False)
p.add_argument("--scenario-id", required=True)
p.add_argument("--receipt-file", required=True)
p.add_argument("--private-dir")
p.add_argument("--remote", default="railiance01", choices=["railiance01"])
p.add_argument("--custodian-recipient", help="extra age recipient whose key is held outside OpenBao")
p.add_argument("--custodian-escrow-ref", required=True,
help="value-safe reference to where a decryption key is held outside OpenBao")
return p
def main(argv: list[str] | None = None, *, lane_factory=Lane, now=utc_now) -> int:
try:
args = parser().parse_args(argv)
if not SCENARIO_RE.fullmatch(args.scenario_id):
return 2
# Always required: where a decryption key is held outside OpenBao (alone, when the
# offline key is the offsite recipient; with --custodian-recipient otherwise).
if not (args.custodian_escrow_ref or "").strip() or (
args.custodian_recipient and not AGE_RECIPIENT_RE.fullmatch(args.custodian_recipient)
):
return 2
fd = open_receipt(args.receipt_file)
except (SnapshotError, OSError):
return 2
record: dict[str, Any] = {"status": "failed", "scenario_id": args.scenario_id, "error": "internal_error"}
result: dict[str, Any] | None = None
def _abort(signum, _frame): # warden timeout/abort: still run every finally/shred
raise SnapshotError(f"interrupted by signal {signum}")
signal.signal(signal.SIGTERM, _abort)
signal.signal(signal.SIGHUP, _abort)
try:
os.umask(0o077)
env = LEASE.contained_bao_env()
receipt, live = run(args, lane_factory(env), record, now=now)
# Self-check with the exact preflight validator before claiming success.
check = Path(tempfile.mkdtemp(prefix="openbao-receipt-check-", dir=private_dir(args.private_dir, env)))
try:
probe = check / "receipt.json"
probe.write_text(json.dumps(receipt), encoding="utf-8")
PREFLIGHT.validate_snapshot_receipt(probe, live_openbao=live, now=now(), max_age_hours=1.0)
finally:
shutil.rmtree(check, ignore_errors=True)
result = receipt
except (SnapshotError, LEASE.ProcedureError, PREFLIGHT.PreflightError) as exc:
record["error"] = str(exc)
except Exception: # noqa: BLE001 - value-safe: never record exception text
record["error"] = "internal_error"
finally:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(result if result is not None else record, handle, indent=2, sort_keys=False)
handle.write("\n")
return 0 if result is not None else 1
def silence() -> None:
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 1)
os.dup2(devnull, 2)
os.close(devnull)
if __name__ == "__main__":
silence()
raise SystemExit(main())