fix: pin the login challenge shape and give the bearer header one home - #1099
Conversation
WalkthroughThe PR centralizes bearer-token validation, enforces canonical identity challenge formats, handles cancelled refresh leaders, and disables redirect following across Rust and TypeScript HTTP transports. ChangesAuthentication and redirect hardening
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
crates/desktop-seams/src/http.rs (1)
37-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueState the credential-scope rationale in the redirect comments.
The current comments describe configuration, expected output, or test mechanics. State why redirect refusal is required: a redirect can send a bearer-bearing request to a different target.
crates/desktop-seams/src/http.rs#L37-L37: replace the contract reference with the redirect and credential-scope rationale.packages/client/src/seams/http.ts#L28-L29: replace the Fetch-mode description with the same rationale.crates/desktop-seams/tests/conformance.rs#L340-L342: remove the CI-mechanics statement and retain the rationale for the live-server test.As per coding guidelines: comments should explain why rather than what and avoid duplicated happy-path narration.
🤖 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/desktop-seams/src/http.rs` at line 37, Update the redirect-related comments to state the credential-scope rationale: refusing redirects prevents bearer-bearing requests from being sent to a different target. In crates/desktop-seams/src/http.rs lines 37-37 and packages/client/src/seams/http.ts lines 28-29, replace the existing configuration or contract descriptions with this rationale; in crates/desktop-seams/tests/conformance.rs lines 340-342, remove the CI-mechanics explanation and retain the same rationale for the live-server test.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/engine/src/api/client.rs`:
- Around line 547-551: Update send_with_token to detect an unusable access
token, release the state.borrow() scope, then asynchronously call clear_session
before returning the decode error. Preserve valid-token header construction, and
ensure the token is removed so the next authenticated request can proceed
unauthenticated and trigger the existing 401 refresh flow.
In `@packages/client/src/seams/http.test.ts`:
- Around line 98-107: Update the FetchHttp redirect test and its recordingFetch
stub so requests with redirect: 'error' reject at runtime instead of always
resolving a 200 response. Assert that both send() and sendCapped() reject, while
retaining coverage that each request uses redirect: 'error'.
---
Nitpick comments:
In `@crates/desktop-seams/src/http.rs`:
- Line 37: Update the redirect-related comments to state the credential-scope
rationale: refusing redirects prevents bearer-bearing requests from being sent
to a different target. In crates/desktop-seams/src/http.rs lines 37-37 and
packages/client/src/seams/http.ts lines 28-29, replace the existing
configuration or contract descriptions with this rationale; in
crates/desktop-seams/tests/conformance.rs lines 340-342, remove the CI-mechanics
explanation and retain the same rationale for the live-server test.
🪄 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: e158934f-707e-4e05-bf8d-a0155e04fe2a
📒 Files selected for processing (15)
apps/api/src/auth/services/challenge.service.test.tscrates/desktop-seams/src/http.rscrates/desktop-seams/tests/conformance.rscrates/desktop-seams/tests/mock_http/mod.rscrates/engine/src/api/client.rscrates/engine/src/content/provider.rscrates/engine/src/content/read.rscrates/engine/src/facade.rscrates/engine/src/seams/http.rscrates/engine/src/seams/mod.rscrates/load/src/runner.rscrates/load/src/seams.rspackages/client/src/seams/http.test.tspackages/client/src/seams/http.tspackages/client/test/browser/mockAuth.ts
Four adjacent auth/transport defects on the engine's Http seam. engine: `login_identity` signed whatever string `/auth/challenge` answered, with no shape check. The signer hands `sha256(utf8(challenge))` to the secp256k1 identity key via the same `sign_detcbor` primitive that signs det-CBOR structures, so anything answering at `apiBaseUrl` was a signing oracle for an arbitrary UTF-8 preimage. The challenge is now pinned to the whole shape the API issues -- the `cipherbox-login:v2:` domain tag plus exactly 32 bytes of lowercase hex -- not merely the prefix, which would have left the rest of the preimage steerable. engine: a single-flight refresh leader dropped while parked on the network left `refresh_waiters` occupied forever, so every later caller enqueued behind a rotation that would never finish. An RAII guard hands leadership back however the leader leaves, and a waiter woken by a cancelled leader is told it was an availability failure rather than a dead session. engine: the `Authorization` header name and the rule that makes a bearer safe to send now live once, on the seam, as `bearer_header`. All three splice sites use it -- the BYO config token, the access token decoded out of an `/auth/*` body, and a gateway source's bearer -- so the rule no longer depends on whichever caller remembered to run a config gate first. client + desktop-seams: both transports refuse redirects, as the record transport already does. Every target on this seam is directly addressed and gated on the URL the engine supplied, so a hop the engine did not choose can only escape that gate. Closes #1034 Closes #713 Closes #933 Closes #1086 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2
…chable splice plumbing Folds the /simplify, /security-review and /crypto-privacy-review passes over this branch's own diff back into the code. crypto-privacy, LOW, a real regression this branch introduced: routing `validate_byo_config` through `bearer_header` made a pure byte scan build and drop a non-zeroized `"Bearer " + token` on every settings encode and decode -- inside the module that declares itself the token's terminal zeroizing owner. `check_bearer` now owns the rule and `bearer_header` calls it, so a caller that only asks the question never materializes a second copy of the credential. simplify: the fallibility threaded through `headers`, `kubo_block_put`, `pin_by_cid` and `probe_request` was unreachable -- both public entries run `validate_byo_config` on the line above, and that gate is now the same predicate. Reverted, with the shared rule kept at the gate and the shared header name kept at the splice, plus a test pinning that the two agree on every token. The genuinely ungated splices, in the API client and the gateway read path, keep their check. simplify: comment and test bloat. The refresh invariant was stated three times, the redirect rationale re-derived at each impl rather than cited from the trait, and the challenge doc argued against a design that is not in the code while hardcoding a byte count that would rot. `finish` and `Drop` both released leadership; `waiters` now only takes. crypto-privacy: the hostile-challenge test proved the flow never *sent*, not that the key never *signed*. It now runs against a signer that panics if invoked, and covers the bare tag, mixed case, a wholly attacker-chosen tail, both bytes-vs-chars boundaries, and version confusion -- plus an accept-side test so a tightening that would break a real login fails here rather than in staging. The API side gains the matching shape assertion, so the cross-language contract is pinned on both ends. altitude: the no-redirect obligation was documented but unchecked on the Rust host. A live 302 route in the desktop mock proves the seam surfaces the hop rather than taking it, and never reaches its target. Also carries the fix into `crates/load`, the fourth bearer splice site and the one `with_client` caller, which was still hand-splicing an unvalidated bearer and still following up to ten hops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2
An access token the seam refuses as a header value stayed in memory, so every later authenticated call repeated the same refusal and the session could not recover. Drop it instead: the next call goes out unauthenticated and its 401 buys one rotation. Only the in-memory access token is dropped — the refresh credential is a separate secret, so a malformed response cannot end the session. Also exercise the Http seam's redirect refusal at runtime rather than asserting the RequestInit field alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2
4bc51d8 to
bdb8309
Compare
Four adjacent defects on one surface: the engine's
Httpseam and the auth flow that rides it.Closes #1034
Closes #713
Closes #933
Closes #1086
#1034 — the login challenge is a signing oracle
Verified at
3d087d673:login_identitytookbody.challengestraight out of the/auth/challengeresponse and handed it tosigner.sign_challenge. Percrates/engine/src/api/signer.rs, that isEcdsaSigner::sign_detcbor(challenge.as_bytes())— RFC 6979 deterministic secp256k1 oversha256(utf8(challenge)), with the member's identity key, and the primitive is the same one that signs det-CBOR structures. Nothing checked the string. Anything answering atapiBaseUrlgot an arbitrary UTF-8 preimage signed.A prefix check is not sufficient — and that is the substance of this fix
The issue asks for "reject a challenge that does not carry the prefix, and cap its length". That would have been a weak fix:
cipherbox-login:v2:followed by 64 bytes of attacker-chosen text still passes a prefix-plus-cap check.So the whole shape is pinned instead. The API issues exactly
IDENTITY_CHALLENGE_PREFIX + randomBytes(32).toString('hex')(apps/api/src/auth/services/challenge.service.ts:42), so the client can require exactly that: the domain tag plus 64 lowercase-hex characters. That leaves a hostile responder no steerable bytes outside[0-9a-f]— it cannot smuggle det-CBOR framing, an EIP-191/4361 message, or any other protocol's domain tag into the preimage, because none of those are expressible in a hex alphabet after a fixed 19-byte ASCII tag.Two things this deliberately does not try to do, because they are not the client's to do:
ChallengeService.consumecompares the boundpublicKey).expiresAtis not checked. The client has no injectedClockseam, and reading a wall clock in engine logic is against repo law. The server enforces the TTL and single-use consumption.Over-tightening is guarded by the live contract suite, which runs the real client against a real API on every PR — if the API's entropy width ever drifts, that goes red immediately rather than silently. The
v2:in the tag is the change-control mechanism for a deliberate format change.Covered by
a_challenge_the_api_could_not_have_issued_is_never_signed: nine hostile shapes (no tag, another protocol's tag, right tag with the responder's own text, wrong width both ways, uppercase hex, tag as a suffix, leading whitespace, empty). Each assertsErrand that only/auth/challengewas sent — no POST to/auth/login, and the key signed nothing.#713 — a cancelled refresh leader wedged auth
Confirmed. The leader set
refresh_waiters = Some(vec![]), awaiteddo_refresh(), and only took the vec back on completion. Drop the leader's future mid-awaitand the slot stays occupied forever: every later caller pushes aoneshot::Senderinto a vec nothing will ever drain, and awaits a receiver whose sender is parked in theRefCell— so not evenCanceledfires.The RAII guard the issue suggests is the right shape, with one correction.
RefreshLeadreleases leadership however the leader leaves; dropping the stored senders wakes every waiter withCanceled. But the existingCanceledmapping wasApiError::Unauthorized, which was dead code before this change and becomes reachable after it — and telling a waiter "your session is dead, re-login" because someone else's future was dropped is wrong. It is remapped toApiError::Transport: availability, not a bypass, which is what a cancelled rotation actually is.Two tests: the next caller leads a fresh rotation (request count goes 1 → 2), and a waiter already queued behind a cancelled leader is woken with a transport error rather than parking.
I did not make the waiter re-lead by recursing into
refresh(). It works, but it adds an allocation and a re-entrancy path to buy one transparent retry; the caller's next request refreshes cleanly anyway.#933 — one home for the
Authorizationheader valueAUTHORIZATIONwas declared three times (api/client.rs:37,content/read.rs:25,content/provider.rs:38) and theBearer {}splice written out four.crates/engine/src/seams/http.rsnow owns both the name and the rule asbearer_header, beside the request type that carries the value.Enforced at each splice, not just at a gate two frames up:
api/client.rs— the access token is decoded out of an/auth/*JSON body, so it is the API's bytes. This was genuinely ungated: a hostile API returning anaccessTokencontaining CRLF got it spliced into a header value. Now fails closed.content/read.rs—GatewaySource.beareris host config, also ungated. A source whose token cannot be a header value is skipped rather than contacted bare; rotation drops to the next source.content/provider.rs— the BYO token is the one that was already gated, at config entry.validate_byo_confignow delegates its rule to the sharedcheck_bearer(the localis_bearer_byteis deleted) and the splice imports the shared header name. A test pins that the gate and the header the splice builds agree on every token, so the two cannot drift.The rule is split in two:
check_bearerasks the question,bearer_headerbuilds the value. That is not cosmetic — see the review section below.Corrections to the issue body
content/provider.rs"holds the BYO token to visible ASCII" at the splice. It did not —is_bearer_bytewas applied at config entry invalidate_byo_config(:344-357), and both public entry points (place_block:118,test_connection:328) call it first. The splice itself was unguarded. That is now fixed in both places.packages/client/src/worker/engineHost.tsforwards the URLs.crates/load/src/runner.rs:91. It is now fixed too — see the scope note below.#1086 — refuse redirects on the
HttpseamReqwestRecordTransportandFetchRecordTransportalready refuse them;ReqwestHttpfollowed up to 10 hops (guarding only an https→http downgrade) andFetchHttpinherited the browser defaultfollow. Both now match their siblings.The issue says the substance is the verification, not the two-line change. Done — a full sweep of every configured API/accelerator/gateway URL, CI workflow, compose file, and proxy config:
client.rs:102-109,read.rs:270,provider.rsprobe paths), so no bare-host or trailing-slash 301 is reachable.docker/Caddyfile:39-41(:80→ https, 301). Every in-repo consumer addresses the API overhttps://api-staging.cipherbox.ccor loopback, so it is never traversed. No nginx/Traefik/ingress, no HSTS, no www/apex redirect.vars.VITE_PUBLIC_GATEWAYS, and unset yields an empty list that fails closed.crates/contract's ownreqwestclient, not this seam, so it is unaffected.One thing to eyeball before merge: the values of
vars.STAGING_API_URL,vars.VITE_READ_ACCELERATOR_URL, andvars.VITE_PUBLIC_GATEWAYSare not in the repo. Each should behttps://and path-free. A plaintextSTAGING_API_URLis the one configuration that would turn this into a hard failure — it would hit the Caddy:80301. Nothing in the build gate checks the scheme today.The seam's own trait doc already stated this obligation; both transports were out of compliance with it. The doc is updated to say what it now means: no hop at all, because every target here is directly addressed and gated on the URL the engine supplied, so a hop the engine did not choose can only escape that gate.
Fixture changes
Pinning the challenge shape means test fixtures must mint what the API mints.
crates/engine/src/facade.rsandpackages/client/test/browser/mockAuth.tsused short suffixes (…:v2:abc, and a 16-char slice). Both now produce a 64-char lowercase-hex tail. The issue's note that "no harness change is needed" inmockAuth.tsheld only for a bare prefix check.What the three review gates changed
All three ran against this branch's own diff, with an independent reviewer pass on each. Findings folded into the second commit.
/security-review: no findings met the bar. It confirmedstrip_prefixon an ASCII prefix is bytewise so no Unicode subtlety is reachable; thatlogin_identityis the only caller of the only productionChallengeSigner; that noRefCelldouble-borrow is reachable throughfinish+Drop(it compiled and ran the exact shape); and that refusing redirects fails closed everywhere, since every consumer already treats 3xx as non-success./crypto-privacy-review: one real regression, which this branch introduced. Routingvalidate_byo_configthroughbearer_headerturned a pure byte scan into one that built and dropped a non-zeroized"Bearer " + token— on every settings encode and decode, inside the module that declares itself the token's terminal zeroizing owner. Fixed by splittingcheck_bearer(the predicate) frombearer_header(the formatter). A caller that only asks the question never materializes a second copy of the credential.It also confirmed domain separation is genuinely complete, which is the load-bearing claim behind #1034: all four
sign_detcbordomains sign a det-CBOR container, so every preimage starts with a major-type-4/5 header (0xa2,0xa3,0x85) — never0x63('c'). A challenge string can never be one of those preimages, nor they it. Worth recording that the pre-existing bug was worse than an internal forgery risk:sign_detcboris single-SHA256 over caller bytes and the identity scalar is the raw Web3Auth secp256k1 export, so a hostile responder needed only to servesha256(tx)— a Bitcoin sighash issha256(sha256(tx))— with the sole constraint that the 32 bytes be valid UTF-8. That was plausibly a transaction-signing oracle on a funded key. Pinning the shape closes it: no chosen digest can be placed in the preimage at all./simplify: the first commit was over-built in two places.headers/kubo_block_put/pin_by_cid/probe_requestwas unreachable by construction — both public entries runvalidate_byo_configon the line above, and after this PR that gate is the same predicate. Four changed signatures and an uncoverable error arm that would read as a live path forever. Reverted; the shared rule stays at the gate, the shared name at the splice, and a new test pins that the two agree on every token. The genuinely ungated splices keep their check.is_identity_challenge's doc argued against a design not in the code while hardcoding a byte count that would rot.finishandDropboth released leadership;waitersnow only takes.Tests strengthened by the reviews. The hostile-challenge test proved the flow never sent; it now runs against a signer that panics if invoked, so it proves the key never signed. Cases added for the bare tag, mixed case, a wholly attacker-chosen tail, both bytes-vs-chars boundaries, and version confusion, plus an accept-side boundary test. The API side gains the matching
/^cipherbox-login:v2:[0-9a-f]{64}$/assertion, so narrowingrandomBytes(32)or switching to base64url fails there rather than silently breaking every login. And the no-redirect obligation, previously documented but unchecked on the Rust host, now has a live 302 route in the desktop mock proving the seam surfaces the hop rather than taking it.Scope note:
crates/loadThree reviewers independently flagged that
crates/loadundercut the fix —runner.rs:91still hand-spliced an unvalidated bearer, andseams.rsis the oneReqwestHttp::with_clientcaller, so it built a client with no redirect policy and followed up to ten hops. Its own module doc says the harness exists to exercise "the same byte mover the shipping client uses", which it had stopped doing. Both are fixed here. It is two additive lines in aworkflow_dispatch-only crate that no sibling branch owns, and leaving it would have meant "one home" still had two.Deliberately not done
Zeroizingowner the moment it becomes aHttpRequest.headersString. Changing that type ripples throughnet/,sync/,crates/wasm,desktop-seams, andcrates/load. Both reviewers agreed it belongs in its own PR, and that this one is the right precondition — the eventual fix becomes a one-line signature change now that the splice has a single home. Worth a follow-up issue with a dependency edge to this PR.apiBaseUrlcan relay a genuine challenge, harvest the signature, and complete the login itself. Shape pinning cannot close that — only channel binding can, and it needs an API-side change. Pre-existing, not widened here, and an attacker who controlsapiBaseUrlalready owns the session.crates/engine/src/api/signer.rsandsession.rsfixtures still use…:v2:deadbeef.sign_challengedoes not validate, so nothing fails; left alone to keep the diff off files this PR has no other reason to touch.One residual worth knowing
A leader dropped after the server rotates but before
store_tokenspersists leaves the stored refresh token spent. With this fix the next caller leads a fresh rotation and replays it, and the API hard-deletes the whole token family on reuse — so every device is logged out, where previously the slot simply wedged. Not attacker-triggerable, and there is no production cancellation point today (noselect!, race, or task abort anywhere incrates/engine/src,crates/wasm/src, orcrates/desktop-seams/src); both outcomes end the session. Recorded rather than coded around.Gates
cargo fmt --all --check,cargo clippy --workspace --all-targets,cargo test -p cipherbox-engine,cargo check -p cipherbox-wasm --target wasm32-unknown-unknown,pnpm typecheck,pnpm lint,pnpm lint:tracker-refs,pnpm test— all green, on a clean tree before and after.Note
Pin login challenge format and centralize bearer header construction across all HTTP transports
is_identity_challengeinapi/client.rsto validate that server-issued challenges matchcipherbox-login:v2:[0-9a-f]{64}exactly; the client refuses to sign non-conforming challenges.Authorizationheader construction inseams/http.rsviacheck_bearerandbearer_header, replacing duplicated ad-hoc logic inapi/client.rs,content/read.rs,content/provider.rs, andload/runner.rs.request_authedare now dropped and surface a decode error rather than being sent; the next call proceeds unauthenticated and can re-authenticate via 401.redirect: 'error'onFetchHttpandPolicy::none()on all reqwest clients so 3xx responses are surfaced rather than followed, preventing silentAuthorizationheader replay to redirect targets.Macroscope summarized bdb8309.
Summary by CodeRabbit
Security
Reliability