railiance-platform/docs/openbao-isolated-restore.md
codex 0cc3281ab3 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
2026-10-23 20:49:24 +02:00

9.1 KiB

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:

ssh-keygen -Y sign -f ~/.ssh/<founder-key> -n railiance-openbao-restore-attempt admission.json

Attended run (T07)

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)

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.