RPF-WP-0064-T02: isolated original-OpenBao restore adapter (phase A)

Networkless disposable Docker target (--network none, read-only, tmpfs only,
no volumes/ports/kubeconfig), metadata-only preflight before any prompt,
SSH-signed admission in namespace railiance-openbao-restore-attempt, hidden
custody prompts with sealed memfds, throwaway init + forced Raft restore from
stdin, original-share unseal, generate-root readback credential (revoked),
metadata-only readback against committed sources, reaper for parent loss and
hard deadline. Original material refuses until T06 sets the image digest pin.
Fix stale v2.5.3 app version in docs/openbao.md.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4384@bnt-lap001
Assistant-Session: 80d1f3da-7a91-4519-9737-9ba128b4da64
This commit is contained in:
codex 2026-10-23 20:49:24 +02:00
parent 76b45e0e16
commit 0cc3281ab3
6 changed files with 1218 additions and 1 deletions

View file

@ -0,0 +1,159 @@
# Isolated original-OpenBao restore (RPF-WP-0064)
Adapter: `scripts/openbao-isolated-restore/`. Workplan:
`workplans/RPF-WP-0064-isolated-original-openbao-restore.md`.
This restores the original OpenBao Raft snapshot into a disposable,
networkless container on the founder workstation. It unseals the snapshot with
the original Shamir shares and reads back configuration metadata only. It
claims no restore capability for production, no consumer continuity, no live
JWT login, no phase B network and no owner acceptance. Every receipt lists
these under `claims_not_made`.
## Fixed properties
| Property | Value |
| --- | --- |
| Where | Founder workstation Docker only. The launcher refuses `railiance01*` hosts and unsets `KUBECONFIG`, `BAO_*` and `VAULT_*`. |
| Image | `quay.io/openbao/openbao@sha256:<digest>`, run with `--pull=never`. The admission must name an exact digest. Original-material runs also require `PINNED_IMAGE_DIGEST` in `restore_adapter.py`, which T06 sets after rapp-openbao confirms the production pin. Until then they refuse. |
| Container | `--network none`, `--read-only`, `--cap-drop ALL`, `no-new-privileges`, `--user 100:1000`, and tmpfs only for `/openbao/data`, `/openbao/config`, `/openbao/file`, `/openbao/logs` and `/tmp`. The image's VOLUME paths are overlaid with tmpfs, so no Docker volume is created. There are no binds, published ports or kubeconfig. |
| Listener | `127.0.0.1:8200` inside the container namespace only. Unauthenticated generate-root is enabled on this listener alone, because OpenBao 2.x disables it by default and the readback credential needs it. |
| Network proof | `docker container inspect`: `NetworkMode=none`, networks `["none"]`, no ports, mounts or binds, the exact tmpfs set and not privileged. `/proc/net/dev` inside the container lists only `lo`. Two `wget` probes (`192.0.2.1` and `kc.coulomb.social`) must both fail. |
| Deadline | 45 minutes (`max_duration_seconds: 2700`) from the session start, capped by the admission `not_after`. A reaper process enforces it as a backstop, with 60 s of grace. |
| Admission namespace | `railiance-openbao-restore-attempt`. It is an SSH signature over the exact admission bytes. The anchor is `/etc/railiance-openbao-restore/allowed_signers`, overridable with `RAILIANCE_OPENBAO_RESTORE_ANCHOR`. For original material the anchor must not be writable by the running account. |
## Custody path
The prompts are hidden (`getpass` on the controlling TTY). Custody values never
pass through argv, the environment, files or logs.
1. **Backup age identity.** It is checked against the admission's
`age_recipient` with `age-keygen -y`, then held in a sealed memfd. It is
passed to `age -d` only as `/dev/fd/N` and closed straight after decryption.
2. **Snapshot.** The ciphertext size and sha256 must match the admission. The
plaintext sha256 must match the RPF-WP-0015 receipt's `snapshot_sha256`
(admission `plaintext_sha256`). The plaintext is held in a sealed memfd and
streamed to `bao operator raft snapshot restore -force /dev/stdin` over
`docker exec` stdin. It never reaches disk.
3. **Throwaway initialization.** A 1-of-1 init of the empty target. Its share and
credential stay in process memory. The restore invalidates them, and a failed
`lookup-self` after the restore proves this.
4. **Two original unseal shares.** After the restore the target must report
`sealed: true`. The two shares then unseal it, and the restored `cluster_id`
and Raft applied index are compared with the admission.
5. **Readback credential.** Decision for T02 review: generate-root from the two
shares already entered, with the OTP decoded in memory. The operator OIDC
path cannot work without a network. The credential is revoked with
`revoke-self` before teardown, and the revocation is verified.
## Readback
Readback is metadata only. The allow-list in `ALLOWED_READS` contains no
`/data/` path.
| Object | Path | Compared with |
| --- | --- | --- |
| `auth_mount_keycape_services` | `sys/auth` | type `jwt` |
| `auth_mount_netkingdom` | `sys/auth` | type `oidc`/`jwt` |
| `kv_mount_platform` | `sys/mounts` | type `kv`, version 2 |
| `keycape_services_config` | `auth/keycape-services/config` | `openbao/auth/keycape-services-config.json` |
| `secrets_engine_jwt_role` | `auth/keycape-services/role/secrets-engine-openbao` | `openbao/auth/secrets-engine-jwt-role.json` |
| `netkingdom_oidc_config` | `auth/netkingdom/config` | `openbao/auth/netkingdom-oidc-config.json` (except `_note`) |
| `platform_admin_role` | `auth/netkingdom/role/platform-admin` | `openbao/auth/netkingdom-platform-admin-role.json` |
| `policy_secrets_engine_login_self` | `sys/policies/acl/secrets-engine-login-self` | `openbao/policies/secrets-engine-login-self.hcl` |
| `kv_registration_metadata` | `platform/metadata/workloads/keycape/secrets-engine-openbao` | current version present, not deleted, not destroyed |
The comparison projects only the fields the source file declares. Durations
such as `5m` are normalised to seconds, and string lists are sorted. The receipt
records each object as `present`, `source_match` and a typed `observed_sha256`
of the projected fields, never the values. A mismatch means the 2026-10-14
snapshot differs from the current source. That is a finding, not a repair.
## Outcomes
- `restored`: every required object is present and matches its source.
- `refused`: an object is missing (`object_missing`) or differs
(`object_mismatch`). Nothing is repaired.
- `aborted`: any other failed check, a timeout, Ctrl-C/SIGTERM/SIGHUP
(`operator_abort`) or the loss of the parent process (`parent_lost`, written by
the reaper).
Teardown always runs: revoke the credential, close the memfds, wipe the share
buffers, `docker rm -f -v`, then a `docker container inspect` that must fail.
The receipt is written with mode 0600 and `O_EXCL`, then checked by the
independent verifier.
## Receipt v2 and verifier
The schema is `railiance-openbao-restore-receipt/v2`. It extends
`docs/openbao-restore-drill-evidence.example.json`; the shape is in
`docs/openbao-restore-receipt-v2.example.json`. All digests are typed
`sha256:<hex>`, and no key name pairs with a bare hex value.
The verifier `scripts/openbao-isolated-restore/verify-restore-receipt.py`
(`make openbao-verify-restore-receipt`) does not import the adapter. It rejects
unknown or missing fields, duplicate keys and placeholders. It also rejects
credential, share, age, PEM, hex or base64 secret shapes, `data` fields and KV
`/data/` paths. It requires teardown evidence and the exact `claims_not_made`
set, and checks result consistency, the 45-minute deadline and
`--not-after`. Finally it compares `source_sha256` with a clean checkout at
`adapter_commit`. The receipt stays outside Git and is referenced by its
sha256.
## Admission (what T05 signs)
The shape is in `docs/openbao-restore-admission.example.json`, schema
`railiance-openbao-restore-admission/v1`:
- `attempt_id`: `RPF-WP-0064-T07-<id>`
- `phase: "A"`, `phase_b_admitted: false`, `material: "original"`
- `not_before`, `not_after` (at most 24 h), `max_duration_seconds: 2700`,
`abort_operator`
- `image`: the T06-confirmed digest reference
- `adapter_commit` and `source_sha256` for all ten closure files: four adapter
files, four auth JSON files, the login-self policy and the JWT-login contract
- `snapshot`: `ciphertext_sha256`, `ciphertext_bytes`, `plaintext_sha256`,
`age_recipient`, `source_cluster_id`, `source_applied_index`,
`source_receipt_sha256` (taken from the RPF-WP-0015-T03 receipt and the
stored object)
- `unseal: {key_threshold: 2, key_shares: 3}`,
`readback_credential: "generate-root-from-entered-shares"`
Sign it:
```bash
ssh-keygen -Y sign -f ~/.ssh/<founder-key> -n railiance-openbao-restore-attempt admission.json
```
## Attended run (T07)
```bash
scripts/openbao-isolated-restore/run-isolated-restore.sh \
/abs/admission.json /abs/admission.json.sig sha256:<admission-digest> \
/abs/<ciphertext>.age /abs/receipt-dir
make openbao-verify-restore-receipt RECEIPT=/abs/receipt-dir/<file>.json SOURCE_ROOT=<clean checkout>
```
`preflight.py` runs first and is metadata only. It checks the admission bytes
and signature, the window, the source closure at a clean `adapter_commit`, the
pinned image cached locally, the ciphertext hash and age header, `/dev/shm`,
the required tools and that no stale `rpf64-restore-*` container exists.
## Phase B (design stub, not implemented)
Phase B is a separate admission, not part of this adapter. It would attach a
single `docker network create --internal` network holding only this restored
target and the isolated NK49 KeyCape. Inside it `kc.coulomb.social` resolves to
the isolated KeyCape, using its own trusted TLS chain. Egress is shown to be
refused before admission. Custody stays the same. The secrets-engine consumer
run (SECRETS-WP-0015-T04) owns the login attempt.
## Rehearsal (T04)
```bash
make openbao-isolated-restore-test # offline: admission, verifier adversarial fixtures, launcher
make openbao-isolated-restore-image # pull the public image (no custody)
make openbao-isolated-restore-rehearsal # native: real OpenBao, --network none, generated material
```
The workplan T04 note records what ran natively and what was mocked.

View file

@ -0,0 +1,40 @@
{
"schema": "railiance-openbao-restore-admission/v1",
"namespace": "railiance-openbao-restore-attempt",
"attempt_id": "RPF-WP-0064-T07-YYYYMMDD-01",
"phase": "A",
"phase_b_admitted": false,
"material": "original",
"not_before": "YYYY-MM-DDTHH:MM:SSZ",
"not_after": "YYYY-MM-DDTHH:MM:SSZ",
"max_duration_seconds": 2700,
"abort_operator": "founder",
"image": "quay.io/openbao/openbao@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"adapter_commit": "replace-with-40-hex-adapter-commit",
"snapshot": {
"ciphertext_sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"ciphertext_bytes": 0,
"plaintext_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"age_recipient": "replace-with-railiance-backup-age-recipient",
"source_cluster_id": "replace-with-source-cluster-id",
"source_applied_index": 0,
"source_receipt_sha256": "sha256:3333333333333333333333333333333333333333333333333333333333333333"
},
"unseal": {
"key_threshold": 2,
"key_shares": 3
},
"readback_credential": "generate-root-from-entered-shares",
"source_sha256": {
"docs/contracts/secrets-engine-openbao-jwt-login.yaml": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"openbao/auth/keycape-services-config.json": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"openbao/auth/netkingdom-oidc-config.json": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"openbao/auth/netkingdom-platform-admin-role.json": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"openbao/auth/secrets-engine-jwt-role.json": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"openbao/policies/secrets-engine-login-self.hcl": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"scripts/openbao-isolated-restore/preflight.py": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"scripts/openbao-isolated-restore/restore_adapter.py": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"scripts/openbao-isolated-restore/run-isolated-restore.sh": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"scripts/openbao-isolated-restore/verify-restore-receipt.py": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
}

View file

@ -2,7 +2,7 @@
**Chart:** `openbao/openbao`
**Chart version:** `0.28.2`
**App version:** `v2.5.3`
**App version:** `v2.5.4` (image tag in `helm/openbao-values.yaml`)
**Namespace:** `openbao`
**Managed by:** `railiance-platform` (S3)
**Workplan:** `RAIL-PL-WP-0002`

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Metadata-only preflight for the isolated original-OpenBao restore (RPF-WP-0064-T02).
Runs before any custody prompt. Never asks for, opens or decrypts custody
material. Checks: signed admission (exact bytes, sha256, fixed SSH namespace,
protected allowed_signers anchor, window), exact committed source closure,
pinned image digest present locally (``--pull=never``), ciphertext size and
sha256, required tools, ``/dev/shm`` tmpfs and that no prior container of this
adapter exists. Prints only OK/ERROR, never values.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import shutil
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("restore_adapter", HERE / "restore_adapter.py")
adapter = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(adapter)
require, Refused = adapter.require, adapter.Refused
def check_local(adm: dict, ciphertext: Path, docker: str = "docker") -> None:
for tool in ("age", "age-keygen", "ssh-keygen", "git", docker):
require(shutil.which(tool), "preflight_refused")
mounts = Path("/proc/mounts").read_text().splitlines()
require(any(line.split()[1:3] == ["/dev/shm", "tmpfs"] for line in mounts), "preflight_refused")
inspect = subprocess.run([docker, "image", "inspect", adm["image"], "--format", "{{json .RepoDigests}}"],
capture_output=True, timeout=30)
require(inspect.returncode == 0 and adm["image"] in json.loads(inspect.stdout or b"[]"), "preflight_refused")
stale = subprocess.run([docker, "ps", "-a", "-q", "--filter", "name=rpf64-restore-"],
capture_output=True, timeout=30)
require(stale.returncode == 0 and not stale.stdout.strip(), "preflight_refused")
raw = adapter.read_owner_file(ciphertext, 512 * 1024 * 1024)
snap = adm["snapshot"]
require(len(raw) == snap["ciphertext_bytes"] and adapter.sha256_bytes(raw) == snap["ciphertext_sha256"],
"ciphertext_mismatch")
require(raw.startswith(b"age-encryption.org/v1\n"), "ciphertext_mismatch")
def main(argv: list[str] | None = None) -> int:
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)
opts = parser.parse_args(argv)
try:
adm, _raw, _signer, _protected = adapter.load_admission(
Path(opts.admission), Path(opts.admission_sig), opts.admission_sha256, adapter.anchor_path())
require(adm["material"] == "original", "admission_refused")
adapter.check_window(adm)
adapter.check_source_closure(adm, adapter.ROOT)
check_local(adm, Path(opts.ciphertext))
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: preflight refused ({exc.reason}); no custody input requested.")
return 1
except Exception:
print("ERROR: preflight refused (internal_error); no custody input requested.")
return 1
print("OK: signed admission, source closure, pinned image, ciphertext and local target verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,916 @@
#!/usr/bin/env python3
"""Isolated original-OpenBao restore adapter (RPF-WP-0064-T02, phase A only).
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.
"""
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 writes the owner-confirmed production digest here. Until then
# original-material runs refuse; synthetic rehearsals use the admission digest.
PINNED_IMAGE_DIGEST: str | None = None
MAX_DURATION_SECONDS = 2700
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"})
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", "cluster_mismatch",
"applied_index_behind", "readback_credential_failed", "readback_failed", "deadline_exceeded",
"operator_abort", "parent_lost", "internal_error",
})
SERVER_CONFIG = json.dumps({
"storage": {"raft": {"path": "/openbao/data", "node_id": "isolated-restore"}},
# 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",
}
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}$")
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) -> dict[str, Any]:
"""Shape-only validation of the signed admission (no I/O)."""
require(isinstance(adm, dict) and set(adm) == ADMISSION_FIELDS, "admission_refused")
require(adm["schema"] == ADMISSION_SCHEMA and adm["namespace"] == NAMESPACE, "admission_refused")
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"] == "generate-root-from-entered-shares", "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) -> 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))
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 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):
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},
"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),
}
# ---- 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 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("unseal_refused")
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:
for _ in range(120):
leader = self.read("sys/leader", reason=reason) or {}
if leader.get("is_self") and isinstance(leader.get("raft_applied_index"), int) \
and leader["raft_applied_index"] > 0:
return leader
time.sleep(0.5)
self.remaining()
raise Refused(reason)
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
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()
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)
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())
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)
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") and verify.returncode == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Attended launcher: isolated original-OpenBao restore, phase A (RPF-WP-0064).
# Founder workstation only. Never railiance01, never a kubeconfig, no network.
set +x
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd -- "$ROOT"
umask 077
unset KUBECONFIG BAO_TOKEN VAULT_TOKEN BAO_ADDR VAULT_ADDR
usage='Usage: run-isolated-restore.sh ADMISSION ADMISSION_SIG ADMISSION_SHA256 CIPHERTEXT RECEIPT_DIR'
[[ $# == 5 ]] || { printf '%s\n' "$usage"; exit 1; }
[[ "$3" =~ ^sha256:[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact sha256:<hex> admission digest required.'; exit 1; }
for index in 1 2 4; do
[[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; }
done
[[ -d "$5" && ! -L "$5" && "$5" == /* ]] || { printf '%s\n' 'ERROR: RECEIPT_DIR must be an existing absolute directory.'; exit 1; }
[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; }
[[ "$(hostname -s)" != railiance01* ]] || { printf '%s\n' 'ERROR: never on railiance01.'; exit 1; }
for tool in python3 age age-keygen docker ssh-keygen git; do
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
done
RECEIPT="$5/rpf64-restore-$(date -u +%Y%m%dT%H%M%SZ).json"
python3 scripts/openbao-isolated-restore/preflight.py --admission "$1" --admission-sig "$2" \
--admission-sha256 "$3" --ciphertext "$4" --receipt "$RECEIPT"
printf '%s\n' 'Phase A only: networkless restore and metadata readback. No consumer continuity,'
printf '%s\n' 'live JWT login, phase B network or owner acceptance is claimed.'
printf '%s\n' 'You will be asked for the backup age identity, then two original unseal shares.'
printf '%s\n' 'Press Ctrl-C at any time to abort; the container is removed and an aborted receipt written.'
python3 scripts/openbao-isolated-restore/restore_adapter.py --admission "$1" --admission-sig "$2" \
--admission-sha256 "$3" --ciphertext "$4" --receipt "$RECEIPT"
printf 'Safe receipt: %s\n' "$RECEIPT"