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
159 lines
9.1 KiB
Markdown
159 lines
9.1 KiB
Markdown
# 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.
|