Skip to content

Bitkit sign-in hardening: hand-off confirmation, grant cleanup, lapsed claims, rate limits - #112

Merged
BitcoinErrorLog merged 17 commits into
release/shop-v0.6.8from
cursor/bitkit-grant-backlog-240a
Sep 24, 2026
Merged

BitcoinErrorLog merged 17 commits into
release/shop-v0.6.8from
cursor/bitkit-grant-backlog-240a

Conversation

@BitcoinErrorLog

@BitcoinErrorLog BitcoinErrorLog commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes the review backlog of the Bitkit grant sign-in (Shop v0.6.24/25): six Kimi P3s from the PR 2/PR 3 audit, the R3.9a P2 from the code review, and the #s= hand-off item from the sign-in QR incident audit. Every item is either fixed with a test that fails on revert, or decided with the reason recorded below. One Kimi finding (cancel after key rotation) was re-scoped after tracing the code; the reasoning is in its row.

Base: release/shop-v0.6.8 at a26333e2e (includes #110 and #111). Head: b656309aa7be7adbc4f00aceab8fb30e9aadf65f.

Backlog

Item Source Disposition Commit
A failed saveGrantSession leaves the approved grant live and a partial record plus the flow key in IndexedDB Kimi PR 2 P3-1 Fixed. The failure signs the grant out, then removes every grant key under the finalization lock (sign-out order: the sign-out needs the key), and the save error reaches the caller. 0d53eca0a
The grant restore skips discardFragmentSessionExport Kimi PR 2 P3-2 Fixed. The grant leg's finally discards the cached #s= export, the same bound as the cookie leg. 2b3a7d0d7
The auth URL (relay channel secret) sits in data-auth-url Kimi PR 2 P3-3 Fixed. The attribute is gone; the URL is drawn into the QR only, so a CSS attribute selector cannot read it. #110 already reads the URL through the QR copy button in launch-critical.mjs. 7d8e29aeb
A crash between acquireCliClaim and complete/abandon strands the flow in claiming Kimi PR 3 P3-1 Fixed. A poll abandons a claim whose lease lapsed (database clock, lease_until <= now()) and answers 422 fresh_approval_required. A live lease still answers 409 claim_in_progress. 65f6708df
Cancel after a state-key rotation is not terminalized Kimi PR 3 P3-2 Fixed, with a correction. Flow authentication (hashBoundCookie) and the context seal use the same key epoch. So when the epoch leaves the config, cancel fails at the cookie check before it ever opens the context. Such a flow cannot be claimed either, and the cleanup job expires it. Terminalizing it without the cookie would let anyone holding a state id end another user's flow, so that case is left as is and pinned by a test. What did leave a flow un-terminalized is a context that fails to open for another reason (result_denied). Cancel now terminalizes first, then opens the context. 15af2e878
A third party can burn a pubky's challenge-creation bucket Kimi PR 3 P3-3 Superseded by the revision below: the per-pubky bucket is removed. 6a60b15fc, 9390b585b
R3.9a: Bitkit step-up re-approval is not built Code review P2 Decided not needed, and the dead refusal path is removed (next section). A controller-level guard is kept (revision below). a3833566f
A #s= hand-off can restore a session without confirmation Sign-in QR incident Kimi backlog Fixed with a confirmation. A nonce binding needs the board to echo a Shop-issued value, and the board opens the Shop without one. The fragment leg now asks "Continue as this account?" with the pubky named. Not me, Escape, or a logout declines the hand-off and suppresses the bridge leg for the tab. With no confirmer passed, the hand-off is declined. a5a2109a6

ed0e90ced adds the changelog fragment. 255157ee3 is formatting and import order only. b656309aa gives #111's side-by-side Bitkit test fixture the full Shop grant, which the full-grant check now requires.

R3.9a decision

A Bitkit sign-in requests exactly CAPABILITIES. This PR makes that a guarantee rather than an assumption: AuthApplication.assertFullGrantSession signs out and refuses an approved grant session narrower than the Shop grant. A stored grant that restores narrower is signed out and its record removed. So every live grant session can write /priv/pubky.app/, and capability-based needs_reauth cannot occur for one.

The other trigger for needs_reauth is a 401/403 on the private document. For a full-grant grant session, that means the grant itself is refused (revoked in Bitkit, or expired). A step-up widens scope; it cannot repair a refused grant. So a grant session's refusal now reports error for watchlist sync (the outbox job stays pending) and unavailable for receipts, and both retry on the next load. MarketplaceReauthDialog renders only in needs_reauth, so its grant branch was unreachable and is removed; AuthController.getStepUpAuthUrl still refuses a grant session (revision below). docs/ecommerce/step-up-approval.md records this, and ADR 0029 records the #s= confirmation.

Contract rows (§6d)

Input or state Deployed client sends / current behaviour New behaviour UI copy or state Test name
Grant approval whose info.capabilities ≠ CAPABILITIES Accepted and saved Signed out, refused, never saved Toast "Authorization failed. Try again." (existing useAuthUrl copy) an approval narrower than the Shop grant is signed out and never saved
Full-grant approval from the deployed Bitkit path (Node SDK 0.11 approveAuthRequest) Signs in Signs in (unchanged) Signed in staging Chromium a real full-grant approval passes the full-grant check and signs in
Stored grant record restoring narrower than CAPABILITIES Restored Signed out, record removed Signed out a stored grant narrower than the Shop grant is signed out and its record removed
BrowserSessionStore.save rejects Session left live, record/key orphaned, generic reset Grant signed out, then all grant keys removed (retried until confirmed, see revision), save error rethrown Toast "Sign in failed. Try again." (existing copy); QR reloads a failed save signs the grant out, removes its keys and surfaces the failure
Grant restore with a #s= captured on the same load Export survives Export discarded — grant restore drops a #s= export captured on the same load
QR slot DOM data-auth-url holds the full URL No attribute; the URL exists only in the QR QR unchanged keeps the relay secret out of the DOM markup; renders QRCodeSVG and ring logo when url is available
Poll of a claiming flow whose lease lapsed 409 claim_in_progress until cleanup Flow abandoned; 422 fresh_approval_required "Start a fresh approval" copy (existing reason code) a claim whose lease lapsed ends the flow and asks for a fresh approval; real Postgres lapsed-claim-sql.log
Poll of a claiming flow with a live lease 409 claim_in_progress Unchanged Unchanged a claim with a live lease stays in progress
Cancel of a flow whose context cannot be opened 403, flow stays awaiting Flow cancelled, then 403 result_denied Client ignores cancel errors (unchanged) cancel ends the flow even when its context cannot be opened
Cancel/poll of a flow whose key epoch rotated out 422 fresh_approval_required Unchanged (not reachable past authentication; cannot be claimed) Fresh approval a flow whose key epoch rotated out can be neither cancelled nor claimed
Challenge creation naming pubky P after a peer on the same IP created 5 for P 429 retry_later (per-pubky bucket) Created (no per-pubky bucket) — browser-bff and cli-bff challenges a NAT peer creates for someone's pubky never lock the owner out
Challenge creation with a rotating client-written x-vercel-forwarded-for hop Each request in a fresh bucket Keyed by the platform-written (rightmost) hop; 429 once the per-IP bucket is spent 429 retry_later rotating a client-written forwarded-for hop does not escape the per-IP bucket
Step-up requested while signed in with a grant session Ring step-up QR Refused before any Ring flow Dialog error state a grant session is refused a step-up and no Ring flow starts
Private document 401/403 for a grant session needs_reauth (dialog with refusal copy) Watchlist error, receipts unavailable No re-auth card; retries next load a grant session refused with 403 fails the round…; …reports unavailable, not a step-up
Private document 401/403 for a cookie session needs_reauth Unchanged Re-auth card flips to needs_reauth when the actual write is refused with 403
#s=<export> that restores, user answers Continue Silently signed in Signed in after the answer Dialog "Continue as this account?" with pubky + 8…8 a #s= hand-off waits for the prompt and only a yes passes through; staging Chromium
#s= user answers Not me / Escape Silently signed in Signed out; bridge leg suppressed for the tab Dialog closes; guest shell a declined #s= hand-off restores nothing and turns the bridge off for the tab; Not me declines…; dismissing the dialog declines…
#s= with no confirmer (Application default) Restored Declined — a #s= hand-off is declined when no one can confirm it
Logout while the prompt is open n/a Prompt declined, logout proceeds — logout declines an unanswered #s= prompt

Identity predicates: the hand-off prompt names the pubky of the session the export restores (Identity.z32FromSession). Accepting it runs the existing restore finalization, which compares against the persisted currentUserPubky and cleans up on a different identity (unchanged). No new connected/owner/can* predicate is added. isCurrentSessionGrant reads the live store session, the same source as canCurrentSessionWrite.

Implementer self-check

  1. Full gate on the pre-review, pre-rebase head: PREPUSH OK dc3b11bcf1998c9175d082ea3cf693ed22a6f79f 1472 (the seconds include waiting on the shared heavy-job lock). Prettier (27 files), eslint (24 files), typecheck, and vitest related (424 files, 6,547 tests) passed; Linux VRT on 58 specs passed in the pinned container. Log: /Volumes/vibedrive/vibes-dev/.evidence/bitkit-grant-backlog-240a/prepush-2.log. A full unit-project run on the pre-format tree was 15,161 passed, 2 skipped.
  2. Staging Chromium proof, real pubky-marketplace-staging deployment dpl_AgZ4CANJAuSKULsb68deiMo7QtnN built from the pre-rebase commit 2afd59d4a with NEXT_PUBLIC_VIBE_SESSION_BRIDGE_ORIGIN=https://pubky.app: 17/17 PASS, covering the #s= prompt, Not me, reload, Continue, sign-out with DELETE /session 200, #s= replay after sign-out, the Bitkit full-grant sign-in, reload, and sign-out. Log: /Volumes/vibedrive/vibes-dev/.evidence/bitkit-grant-backlog-240a/chromium-proof.log. Screenshots: chromium/02-handoff-prompt.png, chromium/04-handoff-accepted-signed-in.png, chromium/08-bitkit-signed-in.png. Calibration: the same script (sha256 d3f9072a…) fails on the unfixed release head 5e7fc0e64, built the same way and deployed as dpl_GmB5Q8A4VTfh8HLyAjWu54Ec6CrQ. It reports data-auth-url=true secretInHtml=true on the QR, and the #s= link signs the tab in silently as the seat with no prompt (chromium-proof-base-fail.log, chromium-base-fail/02-handoff-no-prompt.png). It then passes 17/17 again on the branch deployment. The settled prompt frame is chromium/diag-handoff.png. The review revision and the rebase onto fix(marketplace): settle the inventory grant in launch-e2e #110/Sign-in: Ring and Bitkit QRs side by side, profile prefill, clear quota error #111 are not re-proven on staging; the targeted checks in the revision section and the release deploy cover them.
  3. Telemetry: new Logger calls use static messages. Arguments are only caught error objects (logoutError, error), the existing pattern; no id, pubky, query value, or URL.
  4. Identity: no new connected/owner/can* predicate (see above).
  5. Gates shown failing: mutations.log shows 15/15 mutations caught, and the revision adds 11/11 (mutations-r2.log, mutations-r3.log). Each fix reverted fails its named test, and the source is restored byte-for-byte with the test passing again. lapsed-claim-sql.log shows the abandon SQL against real Postgres 17 with the repo migrations: lapsed abandoned, live left, second call a no-op, table CHECKs hold.
  6. Contract table: every row above names its passing test.

Staging

The staging alias was on dpl_GXBWJBkmvoZF4EREUu41mLKTbuhV before this work and is promoted back to it after the proof (staging/target-confirmation.md, staging/promote-back.log). No project env var was changed; consumer mode was a build-only override.

Revision after the Sol review (REVISE) and Kimi audit (SHIP)

Finding Change Commit Test that fails on revert
Sol P1: a failed save whose key removal also fails leaves the keys with no pointer and no retry A durable marker (pubky-grant-key-cleanup-pending-v1) is set before every grant save and cleared only when the save lands or BrowserSessionStore reads back empty. It is retried on the next load (RouteGuard, after hydration), at the next Bitkit sign-in (before its new flow key exists), and on every sign-out. A signed-in grant session in any tab defers the sweep to its own sign-out. 2bc65d16a a failed save whose key removal also fails keeps the cleanup, and the next Bitkit sign-in empties the store first; the next load removes grant keys a failed cleanup left behind; a pending cleanup leaves a signed-in grant session alone until its sign-out removes every key; a successful grant save drops only the marker it set; RouteGuard retries a pending grant-key cleanup once the auth store has hydrated
Sol P2: a peer behind the same NAT can spend the owner's pubky bucket The per-pubky challenge bucket is gone on both routes. Creation is unauthenticated and may name any pubky, so any pubky-keyed bucket is spendable by someone else. The per-IP bucket alone bounds creation. Clients behind one NAT or VPN exit share it; that is documented at createCliChallenge and pinned by a test. SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE is no longer read (not set on staging). 9390b585b browser and CLI challenges a NAT peer creates for someone's pubky never lock the owner out; documentation test the per-IP bucket is shared behind one NAT (accepted limit of unauthenticated creation)
Kimi P2-1: rate-limit IP spoofable through forwarded-for hops On Vercel the key uses the rightmost x-vercel-forwarded-for hop (the one the platform writes; Vercel overwrites the header, per its request-headers docs). It never uses a leftmost hop. Off Vercel it was already the hop the configured trusted proxies appended, and with zero trusted proxies every client shares one bucket. 0645f3cbb rotating a client-written forwarded-for hop does not escape the per-IP bucket
Kimi P2-2: keep a grant guard on step-up AuthController.getStepUpAuthUrl refuses a grant session before any Ring flow starts; the dialog shows the error. step-up-approval.md records it. a2a2e07ac a grant session is refused a step-up and no Ring flow starts

Kimi's P3 that a failed save wipes another tab's healthy grant is covered by the Sol P1 change: that cleanup now goes through the same sweep, which leaves the store alone while any tab's signed-in grant pointer is persisted.

Rebase onto release/shop-v0.6.8 at a26333e2e (#110, #111): the old launch-critical harness commit is dropped, and #110's launch-critical.mjs is kept unchanged. getGrantAuthUrl keeps #111's two-flow arbitration. settlePendingGrantKeyCleanup() runs before the new flow key is created. assertFullGrantSession(session) runs after #111's first-winner check and before grantEpochAtStart.set(...).

Checks for this revision and the rebase are targeted, not the full gate (targeted-rebase.log at b656309aa): typecheck, eslint and prettier on every changed file, and 34 test files / 520 tests across the auth controller and application, the single-approval side-by-side suite, RouteGuard, marketplace-grant, commerce watchlist/receipts, QrCodeSlot, SignIn, Scan, DialogSessionHandoff, MarketplaceReauthDialog, useAuthUrl, and vibe-session.

Follow-ups (not in this PR)

  • Kimi P2-3: the launch-e2e seller-board canary, restored by fix(marketplace): settle the inventory grant in launch-e2e #110 (merged).
  • Kimi P2-4: the lease-lapse SQL is unit-tested through a mock. The real-Postgres run is evidence only (lapsed-claim-sql.log); add a DB-boundary test.
  • Kimi P3: the hand-off prompt has no timeout if DialogSessionHandoff fails to mount.
  • Kimi P3: logout's own restore passes a live confirmer, so a #s= captured on that load can prompt on /logout.
  • Kimi P3: HomeserverService.isCurrentSessionGrant reads the auth store from the service layer (same pattern as canCurrentSessionWrite).
  • Kimi P3: client IPs at rest in plaintext in cli_rate_buckets.bucket_key (TTL-pruned; pubkys no longer appear there since the per-pubky bucket is gone).
  • Kimi P3: the CLI claim path has no lazy lapsed-claim abandon; the cleanup job is the backstop.

…be saved

A failed BrowserSessionStore save left the approved grant live on the homeserver and a partial
record plus the flow key in IndexedDB. The session is now signed out, then every grant key is
removed under the finalization lock, and the save error reaches the caller.
The grant leg returned before the discard that bounds the cookie leg, so a hand-off captured on
the same load could survive into a later restore after the grant signed out.
The URL carries the relay channel secret. It is drawn into the QR only; the data-auth-url
attribute is gone, so a CSS attribute selector cannot read it. The launch-critical suite reads
the URL through the copy button instead.
A claimer that died between acquire and complete left the flow in claiming, and every poll
answered claim_in_progress until the cleanup job ran. A poll now abandons a claim whose lease
lapsed (database clock) and asks for a fresh approval.
…ng its context

A context that could not be opened left the cancelled flow awaiting. The flow is now terminal
first, so no later poll can claim it.
Challenge creation is unauthenticated and names any pubky, so a bucket keyed by pubky alone let
a third party spend the owner's bucket. Browser and CLI challenges now key it by IP and pubky.
…ts step-up refusal

A grant approval or stored grant narrower than CAPABILITIES is signed out and refused, so a
grant session never reaches the capability-based needs_reauth state. A 401/403 on the private
document of a grant session no longer reports needs_reauth: a step-up widens scope and cannot
repair a refused grant. MarketplaceReauthDialog loses its unreachable grant branch; R3.9a is not
built, and step-up-approval.md records why.
Any page can link to the Shop with #s=<export> for a session whose cookie the browser holds, and
nothing binds the link to this device. The fragment leg now asks the user to confirm the named
pubky. Not me, Escape, or a logout declines it and suppresses the bridge leg for the tab; with no
confirmer the hand-off is declined.
… empty

A save failure whose key removal also failed dropped the obligation: no record
pointer, no marker, so the partial record and delegated key stayed at rest. A
durable marker is now set before every grant save and cleared only when the
save lands or BrowserSessionStore reads back empty. The next load, the next
Bitkit sign-in (before its new flow key exists) and every sign-out retry it.
A signed-in grant session in any tab defers the sweep to its own sign-out.
Challenge creation is unauthenticated and may name any pubky, so any bucket
keyed by pubky (alone or with the client IP) can be spent by someone else:
a peer behind the same NAT still locked the owner out. The per-IP bucket alone
bounds creation; clients behind one egress share it, which is documented and
pinned by a test. SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE is no longer
read.
On Vercel the challenge buckets read the leftmost x-vercel-forwarded-for hop.
Vercel writes that header itself, but a proxy that appends leaves client-written
hops on the left, so the rightmost hop is the one used now. A rotating
client-written hop no longer escapes the per-IP bucket.
Grant sessions are held to the full Shop grant at sign-in and restore, so the
re-auth dialog has no state that opens it for one. getStepUpAuthUrl now also
refuses a grant session before any Ring flow starts, so a future grant path
that skips those checks cannot swap the grant session for a cookie session.
Grant approvals narrower than CAPABILITIES are now refused, as a real Bitkit
approval never is.
@BitcoinErrorLog
BitcoinErrorLog force-pushed the cursor/bitkit-grant-backlog-240a branch from bd227fe to b656309 Compare September 24, 2026 16:26
@BitcoinErrorLog
BitcoinErrorLog merged commit a51cccf into release/shop-v0.6.8 Sep 24, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant