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
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.
- Backup age identity. It is checked against the admission's
age_recipientwithage-keygen -y, then held in a sealed memfd. It is passed toage -donly as/dev/fd/Nand closed straight after decryption. - Snapshot. The ciphertext size and sha256 must match the admission. The
plaintext sha256 must match the RPF-WP-0015 receipt's
snapshot_sha256(admissionplaintext_sha256). The plaintext is held in a sealed memfd and streamed tobao operator raft snapshot restore -force /dev/stdinoverdocker execstdin. It never reaches disk. - 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-selfafter the restore proves this. - Two original unseal shares. After the restore the target must report
sealed: true. The two shares then unseal it, and the restoredcluster_idand Raft applied index are compared with the admission. - 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-selfbefore 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_operatorimage: the T06-confirmed digest referenceadapter_commitandsource_sha256for all ten closure files: four adapter files, four auth JSON files, the login-self policy and the JWT-login contractsnapshot: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.