Skip to content

feat: source the read-accelerator bearer from the session and pin two guards - #1243

Merged
FSM1 merged 3 commits into
mainfrom
feat/session-accelerator-bearer-and-kat-guards
Aug 11, 2026
Merged

feat: source the read-accelerator bearer from the session and pin two guards#1243
FSM1 merged 3 commits into
mainfrom
feat/session-accelerator-bearer-and-kat-guards

Conversation

@FSM1

@FSM1 FSM1 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #1237
Closes #1238
Closes #1033

Three Rust changes over disjoint files: one runtime fix and two regression guards that were raised as review nitpicks and never landed.

The read-accelerator bearer now comes from the session

The read accelerator is CipherBox's own token-authed Kubo trustless gateway, but nothing supplied its bearer. EngineHandle::new took an acceleratorBearer no host could fill: a VITE_-prefixed variable is inlined into the public bundle and a bearer is a per-session credential, so the browser config surface deliberately omitted it. The browser engine therefore reached the accelerator unauthenticated and every read fell through to the public gateway fallbacks — correct fail-open behaviour, but the member acceleration was dead.

The credential is now the session access token the API client already holds in linear memory and never surfaces.

  • SessionBearer (crates/engine/src/content/read.rs) is a shared, zeroizing, Debug-redacted cell. ApiClient writes it on login and on every refresh rotation, and clears it at logout, on a failed refresh, and on an unusable token; Engine::shut_down clears it too, because a parked tick's gateway clone outlives the engine.
  • The accelerator leg reads the cell per request, so a rotation lands without the gateway being rebuilt — a snapshot copied in at login would go stale inside the JWT's lifetime and silently 401 back to the public fallbacks.
  • GatewayConfig now carries base URLs only. A host cannot configure a gateway credential at all, and a public fallback has none by construction rather than by convention.
  • The bearer is bound only to a leg that can keep it — TLS, or a loopback host so the local Kubo in apps/web/.env.example still works. The accelerator URL is host configuration and the token authorizes the whole API, so a stale or mistyped value must cost the member their acceleration, not their session. A denied leg still serves reads, unauthenticated.
  • Teardown is a one-way latch: shut_down seals the cell rather than clearing it, because a refresh parked on the network resumes after the engine is gone and would otherwise re-arm it. The cold-start failure arm clears it too — login has already stored a token by then, and that arm exists to leave no key material resident.
  • A host-supplied acceleratorBearer is refused with an Err, not silently dropped: a bearer reaching that constructor came from a build-time variable in the public bundle, and the host should hear that.

Covered by the_accelerator_presents_whatever_the_session_cell_currently_holds (no session → bare; set → carried; rotated → new value; cleared → bare), into_gateway_binds_the_session_bearer_to_the_accelerator_alone, an_accelerator_that_cannot_keep_a_credential_is_never_handed_one, a_tls_or_loopback_accelerator_is_handed_the_session_bearer, a_sealed_bearer_refuses_a_late_token, login_binds_the_session_token_to_the_accelerator_and_shutdown_drops_it, and the wasm-side a_host_supplied_accelerator_bearer_is_refused_first. The existing "public fallback carries no bearer token" assertion is unchanged.

The credential is the full API session JWT, not a read-scoped token. blueprint/api.md says the gateway is "gated by a CipherBox auth token" without saying which; this makes it the session token, which also authorizes uploads, quota and mailbox writes. That is the only credential the client has without a new API endpoint, and both it and apiBaseUrl come from the same deployment's configuration — but it widens the blast radius of any leak at the gateway tier, and the accelerator now sees which CIDs a member reads and when. Narrowing it to a read-scoped, audience-bound token is #1244.

The prune dead-letter test guards the surviving history

publish_prune computes prune_debt before versions.truncate(...) and before publish_node, precisely so a debt the pass cannot compute leaves the history it was read from standing. a_prune_whose_root_no_source_serves_spends_its_budget_and_dead_letters asserted the dead-letter reason, an unchanged retire-target count, and a zero pending reclaim — every one of which a regression moving prune_debt after the publish would leave passing while the record silently lost a version. It now asserts the history survives.

The KAT suite pins ephemeral-scalar freshness

HPKE ephemeral reuse under one recipient key and one info is a confidentiality break (crates/core/src/seal/owner_local.rs), and several accept families seal every vector to the same recipient under the same info — two of the four owner-local accept vectors carry kind: received-shares. About 35 loops pinned vector names for uniqueness and none pinned the ephemeral.

Rather than adding a set to each of the ten HPKE-sealing loops, one test reads every embedded fixture for ephemeralScalar and asserts uniqueness within each file, scalars folded to lowercase so a hex-case change cannot hide a byte-level repeat.

Per-file is the right scope because a vector file is a superset of each (recipient, info) group inside it — owner_local_accept alone spans four info values and pins two vectors under one — so it forbids every real repeat plus some harmless ones. It is deliberately not corpus-wide: content_key_accept and settings_record_accept share a recipient key and both ephemeral scalars today, separated only by their HPKE info, so a global assertion would fail on main. HPKE_EPHEMERAL_FAMILIES is the anti-vacuity anchor as a named (file, count) list, matching this file's CODEC_DECODE_REACHABLE_CHECKS / ALL_STRUCT_TAGS convention — a bare total would let one family going dark be masked by another gaining a vector.

The vectors themselves are untouched — cargo run -p cipherbox-core --example kat_gen leaves crates/core/kat/ clean.

Gates

Gate Result
cargo fmt --all -- --check 0
cargo clippy --workspace --all-targets -- -D warnings 0
cargo test --workspace 0
cargo check -p cipherbox-wasm --target wasm32-unknown-unknown 0
pnpm lint:tracker-refs 0
kat_gen + git status --porcelain crates/core/kat/ empty

True negatives

Each guard was verified to fail when the thing it guards is broken.

Break Caught by
prune_debt moved after publish_node in crates/engine/src/sync/drain.rs a_prune_whose_root_no_source_serves_spends_its_budget_and_dead_letters — the new assertion reports left: 1, right: 2 once the harness's attempt-budget assert is stood down; with the harness intact the same reorder fails the test at the budget instead, so CI is red either way
Ephemeral of empty-body duplicated onto received-shares-body in owner_local_accept.json, in uppercase hex — both kind: received-shares, so one recipient key and one info ephemeral_scalars_are_fresh_within_each_vector_file — "vector received-shares-body repeats another vector's ephemeral scalar", so the case fold holds
ephemeralScalar renamed in mailbox_accept.json, i.e. one family silently leaving the walk same test — "hpke ephemeral family coverage drift", naming the missing file

All breaks were reverted; crates/core/kat/ and crates/engine/src/sync/drain.rs are byte-identical to main.

Review passes

/simplify, /security-review and /crypto-privacy-review were run over the diff and their findings folded back in a second commit — the transport gate on the bearer binding (both security passes ranked it highest), the seal-on-teardown latch, clearing on cold-start failure, dropping PartialEq/Eq from the gateway types (they made a variable-time comparison of a credential reachable and existed only for fixtures), narrowing the SessionBearer mutation surface, collapsing the API client's one-field State, and GatewaySource::public for the six no-auth sites.

The crypto pass also caught a factual error in my own comment: it claimed distinct vector files seal under distinct recipients, which the corpus contradicts. That is corrected above and in the code.

Not done

EngineHandle::new keeps its acceleratorBearer parameter position rather than dropping it. packages/client/src/worker/engineWasm.ts and engineHost.ts pass it positionally as undefined, and EngineWasm is a hand-written interface, so removing the parameter would shift publicGateways into the headroom slot without a TypeScript error — a silent runtime regression. Those files are outside this change's ownership, so the four have to move together: #1245.

The accelerator gate is transport-only. Requiring the accelerator host to match the API host was considered and rejected: staging runs them on sibling subdomains, and a same-registrable-domain test needs a public-suffix list. Narrowing the credential itself (#1244) is the better lever.

Note

Source the read-accelerator bearer from the session and pin HPKE ephemeral guards

  • Introduces SessionBearer in read.rs, a shared, zeroizing, sealable Rc<RefCell<BearerCell>> that holds the API access token and is readable by the read-accelerator leg at request time.
  • Wires ApiClient to accept a SessionBearer via with_session_bearer; token rotations and clears now propagate to all consumers sharing the cell.
  • GatewayConfig::into_gateway now accepts a SessionBearer and attaches it to the accelerator source only when the URL is TLS or loopback (carries_credentials_safely); public fallbacks never receive a credential.
  • Engine holds the shared bearer, passes a clone to the gateway at construction, sets it on the API client at login, clears it on cold-start failure, and seals it during shutdown to prevent late re-arming.
  • EngineHandle::new (WASM host) now rejects a caller-supplied acceleratorBearer with an explicit error, as credentials are session-scoped.
  • Adds KAT tests asserting HPKE ephemeral scalars are unique within each vector file and that the expected files pin the expected counts.
  • Risk: GatewayConfig no longer accepts credentials at construction; callers that previously embedded a bearer directly must migrate to into_gateway(SessionBearer); WASM callers passing acceleratorBearer now receive a hard error.

Macroscope summarized f126425.

Summary by CodeRabbit

  • Security

    • Improved session-token handling for gateway and API access, including secure clearing during shutdown and safer credential redaction.
    • Accelerator credentials are restricted to approved connections, while unsupported credentials are rejected early.
  • Configuration

    • Gateway accelerators and public fallbacks can be configured directly with URL values.
  • Bug Fixes

    • Preserved histories when their roots cannot be fetched during pruning.
    • Added validation to ensure embedded HPKE test vectors remain complete and non-duplicated.

… guards

The read accelerator is CipherBox's own token-authed gateway, but nothing
supplied its bearer: `EngineHandle::new` took an `acceleratorBearer` no host
could fill, because a `VITE_`-prefixed variable is inlined into the public
bundle and a bearer is a per-session credential. The browser engine therefore
reached the accelerator unauthenticated and every read fell through to the
public fallbacks.

The credential is now the session access token the API client already holds in
linear memory. `SessionBearer` is a shared, zeroizing, `Debug`-redacted cell:
the client writes it on login and on every refresh rotation and clears it at
logout and at engine shutdown, and the accelerator leg reads it per request, so
a rotation lands without the gateway being rebuilt. `GatewayConfig` carries
base URLs only, so a host cannot configure a gateway credential at all, and a
public fallback has none by construction. A host-supplied `acceleratorBearer`
is refused rather than silently dropped.

Alongside it, two regression guards that were raised as review nitpicks and
never landed:

- The prune dead-letter test now asserts the unpruned history survives the
  failed expansion. `publish_prune` computes `prune_debt` before the truncate
  and the publish precisely so a debt it cannot compute leaves the history
  standing; moving it back left every existing assertion passing while the
  record silently lost a version.
- The KAT suite pins ephemeral-scalar freshness within each vector file. HPKE
  ephemeral reuse under one recipient key and `info` is a confidentiality
  break, and several accept families seal every vector to the same recipient
  under the same `info` — only a regenerated corpus could introduce a repeat,
  which is exactly when it would slip in unnoticed. The walk covers every
  embedded fixture rather than a registered list, so a family added later is
  guarded without being enrolled.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds shared session bearer storage for gateway credentials, connects the bearer to the engine and API client session lifecycle, updates gateway configuration APIs, adds WASM rejection checks, strengthens gateway tests, and validates HPKE ephemeral-scalar uniqueness and family coverage.

Changes

Session-bound gateway credentials

Layer / File(s) Summary
Gateway bearer and URL contract
crates/engine/src/content/read.rs, crates/engine/src/content/mod.rs, crates/engine/src/lib.rs
Adds public SessionBearer storage with zeroization, clearing, sealing, redacted debugging, and per-request token access. Gateway configuration stores URL strings and attaches credentials only to safe accelerator URLs.
Engine session lifecycle
crates/engine/src/facade.rs
The engine shares one bearer with the API client and gateway, clears it after cold-start failure, and seals it during shutdown. Regression coverage verifies token propagation and cleanup.
API client session integration
crates/engine/src/api/client.rs
ApiClient stores access tokens through SessionBearer and separates refresh waiters from session state. Token validation, refresh coordination, storage, and clearing use the new representation.
Gateway API migrations and validation
crates/engine/src/net/*, crates/engine/src/settings.rs, crates/engine/src/testkit/*, crates/engine/tests/*, crates/fuse/tests/*, crates/wasm/src/host.rs
Fixtures and integrations use URL-based gateway configuration. WASM rejects supplied acceleratorBearer values before URL validation. Gateway tests cover URL safety, sealing, rotation, and credential binding. The prune test checks that failed expansion preserves history.

HPKE KAT manifest validation

Layer / File(s) Summary
HPKE ephemeral uniqueness validation
crates/core/tests/kat_manifest.rs
Pins HPKE family counts, extracts normalized ephemeralScalar values, rejects duplicates within files, and verifies family representation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation satisfies the session-bearer requirements and adds the requested prune-history and KAT uniqueness guards [#1033, #1237, #1238].
Out of Scope Changes check ✅ Passed All production, API, fixture, and test changes support the linked objectives; no unrelated code changes are identified.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main session-bearer change and the two added regression guards.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/session-accelerator-bearer-and-kat-guards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ionale

Review findings from the self-review passes, folded back in.

The accelerator base URL is host configuration and the token now bound to it
authorizes the whole API, so a stale or mistyped value turned a "reads are
slow" misconfiguration into session-credential disclosure. The bearer is now
bound only to a leg that can keep it — TLS, or a loopback host so the local
Kubo in `apps/web/.env.example` still works. A denied leg still serves reads,
unauthenticated.

Teardown is a one-way latch. `shut_down` sealed the cell rather than clearing
it, because a refresh parked on the network resumes after the engine is gone
and would otherwise write a fresh token into a cell nothing will clear again.
The cold-start failure arm clears it too: login has already stored a token by
then, and that arm's whole job is leaving no key material resident.

The freshness test's stated rationale was false. It claimed distinct vector
files seal under distinct recipients; `content_key_accept` and
`settings_record_accept` in fact share a recipient key *and* both ephemeral
scalars, and are separated only by their HPKE `info`. The comment now states
what is actually true and checkable, and the anti-vacuity anchor is a named
`(file, count)` family list rather than a bare total, so a family that stops
emitting `ephemeralScalar` names itself instead of being masked by another
family gaining a vector. Scalars fold to lowercase before the uniqueness check
so a hex-case change cannot hide a byte-level repeat.

Also: drop `PartialEq`/`Eq` from the gateway types, which existed only so
fixtures could compare and made a variable-time comparison of a credential
reachable; narrow the `SessionBearer` mutation surface to the crate and its
fixture constructor to tests; collapse the API client's one-field `State`; add
`GatewaySource::public` for the six no-auth construction sites; and prove the
wasm refusal really does precede the `apiBaseUrl` check instead of only
claiming it.
@FSM1
FSM1 marked this pull request as ready for review August 10, 2026 18:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/core/tests/kat_manifest.rs (1)

1478-1482: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten the rationale comments.

Keep the invariant and scope. Remove the extended explanation of excluded corpus-wide behavior and current fixture examples. The test name and assertions already show the implementation detail.

As per coding guidelines, “Comments should explain short, non-obvious rationale” and must not describe absent paths.

Also applies to: 1514-1524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/core/tests/kat_manifest.rs` around lines 1478 - 1482, Shorten the
rationale comments around the HPKE ephemeral vector-file anchors and the related
assertions near the freshness check. Preserve the invariant and scope, but
remove discussion of corpus-wide exclusions, absent emission paths, and fixture
examples; retain only a concise explanation of the non-obvious rationale.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/core/tests/kat_manifest.rs`:
- Around line 1499-1511: The ephemeral_scalars helper must return decoded binary
scalars rather than lowercase hex strings. Update its return type and parsing
logic to decode each vector’s ephemeralScalar as strict hex, require exactly 32
bytes, and fail with the vector name and path for missing, non-string,
malformed, or incorrectly sized values instead of silently filtering them out;
preserve the existing JSON parse context.

In `@crates/engine/tests/write_plane.rs`:
- Around line 7969-7973: Update the test around plant_record to capture both
expected CoreVersion values before they are consumed, then compare
published_versions(&world.record_store, &blocks, file) directly with that
expected vector. Replace the length-only assertion while preserving the existing
verification that the unexpanded history entries, including their CIDs and
metadata, remain unchanged.

---

Nitpick comments:
In `@crates/core/tests/kat_manifest.rs`:
- Around line 1478-1482: Shorten the rationale comments around the HPKE
ephemeral vector-file anchors and the related assertions near the freshness
check. Preserve the invariant and scope, but remove discussion of corpus-wide
exclusions, absent emission paths, and fixture examples; retain only a concise
explanation of the non-obvious rationale.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c756a180-a536-45f1-88cf-56afe0f9173a

📥 Commits

Reviewing files that changed from the base of the PR and between 4128ccc and 8c9459f.

📒 Files selected for processing (14)
  • crates/core/tests/kat_manifest.rs
  • crates/engine/src/api/client.rs
  • crates/engine/src/content/mod.rs
  • crates/engine/src/content/read.rs
  • crates/engine/src/facade.rs
  • crates/engine/src/lib.rs
  • crates/engine/src/net/adopter.rs
  • crates/engine/src/net/rotation.rs
  • crates/engine/src/settings.rs
  • crates/engine/src/testkit/content.rs
  • crates/engine/tests/vault_settings.rs
  • crates/engine/tests/write_plane.rs
  • crates/fuse/tests/fuse_op_core.rs
  • crates/wasm/src/host.rs

Comment thread crates/core/tests/kat_manifest.rs Outdated
Comment thread crates/engine/tests/write_plane.rs
@FSM1
FSM1 marked this pull request as draft August 10, 2026 18:37
ephemeral_scalars kept each scalar as hex text, so the freshness check
compared spellings and a malformed or wrong-length field passed through
unread. It now decodes through unhex32, which already enforces the
lowercase-hex contract and the 32-byte length and names the vector that
broke it. A vector carrying no ephemeralScalar is still skipped: that
absence is how a file with no HPKE ephemerals is recognised, and the
per-file counts pinned above catch a family that goes dark.

The prune test asserted its surviving history by length alone, so a pass
that replaced an entry or changed its CID still satisfied a claim about
leaving the history standing. It compares the versions themselves now.

Comment rationale trimmed to what is not visible from the code: the
fixture inventories named which files share a scalar today, which the
corpus regeneration this test exists to catch would falsify.

FSM1 commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Review disposition — CodeRabbit on 8c9459f

2 actionable and 1 nitpick. The nitpick creates no thread, so it is dispositioned here. Fixed in f126425.

Actionable (2 of 2)

Finding Disposition
ephemeral_scalars keeps hex text instead of decoding Taken, with one departure. Decoded through this file's existing unhex32 (lowercase-hex contract + 32-byte length + names the vector), so the freshness check compares bytes and the .to_ascii_lowercase() workaround is gone. Not taken: failing on a missing field. That helper scans every fixture in the corpus, and continues on files that yield none — absence is how a file with no HPKE ephemerals is recognised, so panicking there would fire on every non-HPKE vector. Field absent → skip; field present → must be valid 32-byte lowercase hex.
prune test asserts the surviving history by length only Taken. len() == 2 satisfied any pass that swapped an entry or changed a CID, while claiming the history was left standing. The planted versions are captured and compared entry for entry.

The "silently omits a vector" premise was checked and is already covered: HPKE_EPHEMERAL_FAMILIES pins per-file counts and the test ends in assert_eq!(pinned, expected, "hpke ephemeral family coverage drift"), so a vector losing its scalar fails there naming the file. Verified against the corpus — all ten files carry a 64-hex-char scalar on every vector.

Nitpick (1) — partly taken

Nitpick Disposition
Shorten the rationale comments (1478-1482, 1514-1524) Split. Removed the fixture inventories — "owner_local_accept alone spans four info values", "content_key_accept and settings_record_accept share both their recipient and both their scalars today". Those are claims about corpus state at a moment, and the corpus regeneration this test exists to catch is exactly what falsifies them. Kept the scope rationale for why uniqueness is per-file and not corpus-wide: that is not visible from the code, and without it the obvious "improvement" is to tighten the check corpus-wide, which fails immediately. Net 11 lines → 8, and 5 → 4 on the constant.

Verification

Both fixes were verified as discrimination checks, not re-runs:

  • swapping a scalar for invalid hex now fails with vector empty-info-empty-aad: bad hex: Invalid character 'z' at position 0; the old String path accepted it
  • a same-length history whose second entry carries a different root CID now fails with a left == right diff; len() == 2 passed it

Gates on f126425

cargo fmt --all --check 0 · cargo clippy --workspace --all-targets -- -D warnings 0 · cargo test 1841 passed · cargo check -p cipherbox-wasm --target wasm32-unknown-unknown 0 · pnpm lint:tracker-refs 0 · kat_gen leaves crates/core/kat/ byte-identical.


Generated by Claude Code

@FSM1
FSM1 marked this pull request as ready for review August 10, 2026 23:00
@FSM1
FSM1 merged commit 892ca99 into main Aug 11, 2026
36 checks passed
@FSM1
FSM1 deleted the feat/session-accelerator-bearer-and-kat-guards branch August 11, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant