[feature] OpenBao transit engine + broker + KV-sourced master key #27

Closed
opened 2026-08-12 08:12:19 +00:00 by james.oates · 5 comments
Owner

Execution tracker for the OpenBao integration. Design + plan live in Obsidian (source of truth):

  • Spec: edge-router/specs/2026-08-11-openbao-transit-broker-design.md
  • Plan: edge-router/plans/2026-08-11-openbao-transit-broker.md

Branch: feat/openbao-transit-broker. Supersedes the transit portion of #20; unblocked by #18.

What: opt-in OpenBao as the at-rest encryption engine (transit) and as a source for the master API key (KV), with all OpenBao credentials obtained from a local broker over a Unix socket — key material never enters the ER process. Local AES-GCM stays default; two independent toggles share one broker + one OpenBao.

Tasks (TDD, each independently testable):

  • 1. Broker + transit client (broker.rs) — token over UDS, 90%-TTL cache, 403-refresh (mock-UDS-broker tests)
  • 2. Async CryptoEngine enum (Local | Transit); prefix-routed decrypt; .await refactor of call sites
  • 3. Config + OpenBao env + file>inline resolution flip + highest-security-wins-and-rejects
  • 4. Master key from OpenBao KV (kv_read + v1/v2 field extraction)
  • 5. One-shot AES→transit migration (injectable re-encrypt seam; idempotent/resumable)
  • 6. Startup wiring + fail-closed (health-check fail-fast, migrate, inject)
  • 7. Live integration test (gated on BAO_ADDR, real transit round-trip) + docs

Pre-flight: dev homelab runs a pre-per-service broker (one shared socket /run/broker/broker.sock, broker itself registered) → no ER enrollment needed; Task 7 verifies against it. Confirm the actual transit key name + KV path/field at Task 7.

Notes: migration is one-way (rollback needs a pre-migration DB snapshot); ENCRYPTION_KEY resolution flips to file>inline (one non-additive behavior change). Verify the Alpine/musl Docker build after adding hyperlocal.

Execution tracker for the OpenBao integration. Design + plan live in Obsidian (source of truth): - Spec: `edge-router/specs/2026-08-11-openbao-transit-broker-design.md` - Plan: `edge-router/plans/2026-08-11-openbao-transit-broker.md` Branch: `feat/openbao-transit-broker`. Supersedes the transit portion of #20; unblocked by #18. **What:** opt-in OpenBao as the at-rest encryption engine (transit) and as a source for the master API key (KV), with all OpenBao credentials obtained from a local broker over a Unix socket — key material never enters the ER process. Local AES-GCM stays default; two independent toggles share one broker + one OpenBao. **Tasks (TDD, each independently testable):** - [ ] 1. Broker + transit client (`broker.rs`) — token over UDS, 90%-TTL cache, 403-refresh (mock-UDS-broker tests) - [ ] 2. Async `CryptoEngine` enum (Local | Transit); prefix-routed decrypt; `.await` refactor of call sites - [ ] 3. Config + OpenBao env + `file>inline` resolution flip + highest-security-wins-and-rejects - [ ] 4. Master key from OpenBao KV (`kv_read` + v1/v2 field extraction) - [ ] 5. One-shot AES→transit migration (injectable re-encrypt seam; idempotent/resumable) - [ ] 6. Startup wiring + fail-closed (health-check fail-fast, migrate, inject) - [ ] 7. Live integration test (gated on `BAO_ADDR`, real transit round-trip) + docs **Pre-flight:** dev homelab runs a pre-per-service broker (one shared socket `/run/broker/broker.sock`, broker itself registered) → **no ER enrollment needed**; Task 7 verifies against it. Confirm the actual transit key name + KV path/field at Task 7. **Notes:** migration is one-way (rollback needs a pre-migration DB snapshot); `ENCRYPTION_KEY` resolution flips to file>inline (one non-additive behavior change). Verify the Alpine/musl Docker build after adding `hyperlocal`.
Author
Owner

Task 1 done — e6ad026 on feat/openbao-transit-broker.

broker.rs: TransitClient (token over UDS, ~90%-TTL cache, 403-refresh, HTTPS-only, Debug-redacted token) + is_transit_ciphertext. Offline tests via a mock UDS broker (cache-vs-refetch, https-only rejection, fail-closed-without-broker); real transit round-trip deferred to the gated live test (task 7). Deps added: hyperlocal, bytes. Full suite 202 green, clippy + fmt clean.

Tracked debt: mod broker is #[allow(dead_code)] until CryptoEngine (task 2) + startup wiring (task 6) construct a TransitClient. Remove the allow then.

Tasks 2–7 remain. Task 2 is the broad async refactor of every encrypt/decrypt call site (crypto.rs, health.rs, db.rs, middleware.rs, proxy.rs) — the security-sensitive core.

**Task 1 done** — `e6ad026` on `feat/openbao-transit-broker`. `broker.rs`: `TransitClient` (token over UDS, ~90%-TTL cache, 403-refresh, HTTPS-only, `Debug`-redacted token) + `is_transit_ciphertext`. Offline tests via a mock UDS broker (cache-vs-refetch, https-only rejection, fail-closed-without-broker); real transit round-trip deferred to the gated live test (task 7). Deps added: `hyperlocal`, `bytes`. Full suite 202 green, clippy + fmt clean. **Tracked debt:** `mod broker` is `#[allow(dead_code)]` until `CryptoEngine` (task 2) + startup wiring (task 6) construct a `TransitClient`. Remove the allow then. Tasks 2–7 remain. Task 2 is the broad async refactor of every `encrypt`/`decrypt` call site (`crypto.rs`, `health.rs`, `db.rs`, `middleware.rs`, `proxy.rs`) — the security-sensitive core.
Author
Owner

All 7 tasks implemented on feat/openbao-transit-broker (commits e6ad026, 9da37d5, e4590ae, a5bc56a, d76a025):

  • ✅ 1. Broker + transit client (broker.rs) — token/UDS/cache/403-refresh, mock-broker tests
  • ✅ 2. Async CryptoEngine (Local|Transit) + .await refactor of every call site
  • ✅ 3. Config + OpenBao env + file>inline flip + highest-security-wins-and-rejects (pure, race-free tests)
  • ✅ 4. Master key from OpenBao KV (kv_read + v1/v2 field extraction)
  • ✅ 5. One-shot AES→transit migration (injected re-encrypt seam; selective + idempotent tests)
  • ✅ 6. Startup wiring: one shared client, fail-fast health check, engine selection, migrate — all WIP dead-code allows removed
  • ✅ 7. Gated live integration test + docs (README/example.toml/CLAUDE.md)

Full suite 213 green (live test skips without BAO_ADDR), clippy + fmt clean, zero dead-code allows. Alpine/musl Docker build (validates hyperlocal) in progress. Then: final review + merge to main, and a live run against the dev broker to prove the real transit round-trip.

**All 7 tasks implemented** on `feat/openbao-transit-broker` (commits `e6ad026`, `9da37d5`, `e4590ae`, `a5bc56a`, `d76a025`): - ✅ 1. Broker + transit client (`broker.rs`) — token/UDS/cache/403-refresh, mock-broker tests - ✅ 2. Async `CryptoEngine` (Local|Transit) + `.await` refactor of every call site - ✅ 3. Config + OpenBao env + `file>inline` flip + highest-security-wins-and-rejects (pure, race-free tests) - ✅ 4. Master key from OpenBao KV (`kv_read` + v1/v2 field extraction) - ✅ 5. One-shot AES→transit migration (injected re-encrypt seam; selective + idempotent tests) - ✅ 6. Startup wiring: one shared client, fail-fast health check, engine selection, migrate — all WIP dead-code allows removed - ✅ 7. Gated live integration test + docs (README/example.toml/CLAUDE.md) Full suite **213 green** (live test skips without `BAO_ADDR`), clippy + fmt clean, zero dead-code allows. Alpine/musl Docker build (validates `hyperlocal`) in progress. Then: final review + merge to `main`, and a live run against the dev broker to prove the real transit round-trip.
Author
Owner

Status: code-complete, blocked on live verification — NOT merged.

All offline gates pass on feat/openbao-transit-broker: 213 unit tests, Alpine/musl Docker build (confirms hyperlocal compiles), clippy + fmt clean, zero dead-code allows.

But the feature has never run against real OpenBao — every passing test uses a mock UDS broker (canned token) or an injected vault:v1:{pt} fake. Per our rule (verify external services against the real contract, don't assume documented behaviour), this must be proven before merge. Only a live run catches: the real transit response shape (.data.ciphertext/.data.plaintext + base64), the actual vault:/bao: prefix (which migration idempotency + decrypt-routing depend on), and the broker's real GET /token JSON.

Can't run it from the build host: playground-broker-1's socket is not mounted on the host (/run/broker absent), and devinfra-openbao-1 is only on 127.0.0.1:8200 with an in-network cert the HTTPS-only client correctly rejects. The live test needs to run inside the dev environment where the broker socket is mounted and bao's cert is trusted:

BAO_ADDR=https://bao.dev.oates.ws \
BAO_BROKER_SOCKET=/run/broker/broker.sock \
BAO_TRANSIT_KEY=<key the shared broker's policy grants> \
BAO_MASTER_KEY_PATH=<optional kv path> \
cargo test openbao_live -- --nocapture

Prereqs to confirm there: the shared broker's policy grants a transit key ER can use (encrypt/decrypt), and (if testing KV) read on the master-key path. Merge to main is gated on this coming back green.

**Status: code-complete, blocked on live verification — NOT merged.** All offline gates pass on `feat/openbao-transit-broker`: 213 unit tests, Alpine/musl Docker build (confirms `hyperlocal` compiles), clippy + fmt clean, zero dead-code allows. **But the feature has never run against real OpenBao** — every passing test uses a mock UDS broker (canned token) or an injected `vault:v1:{pt}` fake. Per our rule (verify external services against the real contract, don't assume documented behaviour), this must be proven before merge. Only a live run catches: the real transit response shape (`.data.ciphertext`/`.data.plaintext` + base64), the actual `vault:`/`bao:` prefix (which migration idempotency + decrypt-routing depend on), and the broker's real `GET /token` JSON. **Can't run it from the build host:** `playground-broker-1`'s socket is not mounted on the host (`/run/broker` absent), and `devinfra-openbao-1` is only on `127.0.0.1:8200` with an in-network cert the HTTPS-only client correctly rejects. The live test needs to run **inside the dev environment** where the broker socket is mounted and bao's cert is trusted: ``` BAO_ADDR=https://bao.dev.oates.ws \ BAO_BROKER_SOCKET=/run/broker/broker.sock \ BAO_TRANSIT_KEY=<key the shared broker's policy grants> \ BAO_MASTER_KEY_PATH=<optional kv path> \ cargo test openbao_live -- --nocapture ``` Prereqs to confirm there: the shared broker's policy grants a transit key ER can use (encrypt/decrypt), and (if testing KV) read on the master-key path. **Merge to `main` is gated on this coming back green.**
Author
Owner

Closing — merged to main as 2a187f5 on 2026-08-21. The checkboxes above were never ticked, so this looked unfinished; verified against the code on main today:

Task Evidence on main
1. Broker + transit client src/broker.rs (556 lines) — token over UDS, TTL cache, 403-refresh
2. Async CryptoEngine (Local | Transit) src/crypto.rs (359 lines), prefix-routed decrypt
3. Config + env + file>inline flip BAO_* resolution in src/config.rs
4. Master key from OpenBao KV BAO_MASTER_KEY_PATH / BAO_MASTER_KEY_FIELD wired through config.rs → main.rs
5. One-shot AES→transit migration db.rs:956 migrate_secrets_to_transit, injected re-encrypt seam
6. Startup wiring + fail-closed main.rs, no dead-code allows remain
7. Gated live test + docs broker.rs:512 openbao_live_round_trip, skips cleanly without BAO_ADDR; README documents the setup

One thing this close does not assert. The comment above gated merge on a live run against real OpenBao. The merge happened nine days later, so that gate presumably passed, but no result was recorded here — I'm inferring it from the merge, not from evidence. Separately, the live run covered the transit round-trip; KV master-key sourcing has never been exercised against a real instance, only against a mock broker and injected fakes.

That last gap is the only genuinely unproven part, so it gets its own issue rather than holding this one open.

Closing — merged to `main` as `2a187f5` on 2026-08-21. The checkboxes above were never ticked, so this looked unfinished; verified against the code on `main` today: | Task | Evidence on `main` | |---|---| | 1. Broker + transit client | `src/broker.rs` (556 lines) — token over UDS, TTL cache, 403-refresh | | 2. Async `CryptoEngine` (Local \| Transit) | `src/crypto.rs` (359 lines), prefix-routed decrypt | | 3. Config + env + `file>inline` flip | `BAO_*` resolution in `src/config.rs` | | 4. Master key from OpenBao KV | `BAO_MASTER_KEY_PATH` / `BAO_MASTER_KEY_FIELD` wired through `config.rs` → `main.rs` | | 5. One-shot AES→transit migration | `db.rs:956` `migrate_secrets_to_transit`, injected re-encrypt seam | | 6. Startup wiring + fail-closed | `main.rs`, no dead-code allows remain | | 7. Gated live test + docs | `broker.rs:512` `openbao_live_round_trip`, skips cleanly without `BAO_ADDR`; README documents the setup | **One thing this close does not assert.** The comment above gated merge on a live run against real OpenBao. The merge happened nine days later, so that gate presumably passed, but no result was recorded here — I'm inferring it from the merge, not from evidence. Separately, the live run covered the transit round-trip; **KV master-key sourcing has never been exercised against a real instance**, only against a mock broker and injected fakes. That last gap is the only genuinely unproven part, so it gets its own issue rather than holding this one open.
Author
Owner

Correction to my previous comment: the live verification was recorded, just not here. From my notes of 2026-08-24 —

broker::tests::openbao_live_round_trip passed against the real broker + OpenBao in devinfra, and the running devinfra edge-router booted clean on the merged code, logging OpenBao broker reachable — credential path verified. ER was registered via devinfra/scripts/provision-broker.sh edge-router, giving transit key edge-router and policy edge-router-transit on socket /run/broker/consumers/edge-router.sock.

So the merge gate was genuinely satisfied — I was wrong to call it an inference.

That same note also explains precisely why KV was never exercised, which is better than "nobody tried": the provisioned policy grants encrypt/decrypt only, no KV read. It also hard-conflicts with the existing MASTER_API_KEY env under highest-security-source-wins. So #33 needs a policy extension and dropping MASTER_API_KEY, not just a test run — I've added that there.

Correction to my previous comment: the live verification **was** recorded, just not here. From my notes of 2026-08-24 — `broker::tests::openbao_live_round_trip` passed against the real broker + OpenBao in devinfra, and the running devinfra `edge-router` booted clean on the merged code, logging `OpenBao broker reachable — credential path verified`. ER was registered via `devinfra/scripts/provision-broker.sh edge-router`, giving transit key `edge-router` and policy `edge-router-transit` on socket `/run/broker/consumers/edge-router.sock`. So the merge gate was genuinely satisfied — I was wrong to call it an inference. That same note also explains precisely why KV was never exercised, which is better than "nobody tried": **the provisioned policy grants encrypt/decrypt only, no KV read.** It also hard-conflicts with the existing `MASTER_API_KEY` env under highest-security-source-wins. So #33 needs a policy extension and dropping `MASTER_API_KEY`, not just a test run — I've added that there.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#27
No description provided.