feat(desktop): give the shell a frontend that can log in - #1277
Open
claude[bot] wants to merge 6 commits into
Open
feat(desktop): give the shell a frontend that can log in#1277claude[bot] wants to merge 6 commits into
claude[bot] wants to merge 6 commits into
Conversation
The shell's frontend was a 43-line static page with no script, no bundler and no build step, so it could host nothing. Replace it with a Vite + TypeScript app carrying the process/Buffer/global shims Web3Auth reads off the global scope, and wire beforeBuildCommand/frontendDist so tauri build builds it. The Content-Security-Policy comes with it. default-src 'self' refuses the login the shell exists to run, and the API origin it must allow is a deployment variable the committed config cannot name, so the policy is computed from the build environment and merged into the Tauri config by a CLI wrapper — the CLI reads its config before beforeBuildCommand runs, so nothing later could supply it. It widens to exactly the Core Kit's two host families, Tauri's IPC endpoint, and the configured API origin.
Google Identity Services does not run in this webview, and the OAuth2 flow it falls back to needs an http(s) redirect_uri that a packaged Tauri origin cannot supply — tauri://localhost is refused as a non-http(s) scheme, which is why this breaks in packaged builds and not in dev. So the shell serves the callback itself, from a loopback listener on ports pre-registered with the provider: a random port could not be registered, so an exhausted list fails fast rather than falling back to one. Harvested from the v1 listener, with its security properties kept: the listener binds 127.0.0.1 only, the callback page carries a nonce the POST that delivers the token must repeat, state binds the reply to this attempt, an allowlist bounds what the shell will open a window on, and the exchange, each connection and the request size are all capped. The consent screen is opened from Rust, since window.open() is unreliable on Windows WebView2. session_start and session_logout stand in for the LoginFacade. The engine is not linked here yet, so session_start takes the login secret, checks it is the scalar the engine will require, and zeroizes it. There is no vault behind it and the shell's window says so.
The sequencing is createLoginFlow's, unchanged: the shell supplies only what differs per host — its credential collector, its Core Kit instance, the facade over Tauri IPC, and where progress and the account are rendered. Wallet is absent rather than offered and unable to complete. The collector has no wallet member, its collected type is never, and the front door renders one affordance per method the flow reports, so no wallet control exists to disable. The Core Kit store is in memory for now: what it holds opens the Web3Auth record, and nothing on this host may keep that at rest until the shell has its keychain-backed CredentialStore seam.
The workspace-wide cargo test excludes cipherbox-desktop because compiling Tauri needs webkit2gtk system deps, so the loopback callback's suite would otherwise run in no gate at all.
The redirect named `http://localhost:{port}/callback` while the listener
bound `127.0.0.1` only. `localhost` also resolves to `::1`, RFC 6724
destination ordering prefers it, and binding `[::1]:{port}` does not
conflict with the IPv4 bind — so the IPv6 loopback on every pre-registered
port was permanently unclaimed. Any unprivileged same-user process could
hold it, receive the consent redirect, and read the `id_token` straight out
of the fragment; `state` and the page nonce do not help, because the
squatter is the listener the webview reached rather than a poster to the
real one. That token carries CipherBox's `aud` and the member's `sub`, so
it replays through the identity exchange for their TSS key and vault.
The redirect now names the IP literal (RFC 8252 section 8.3), and the
chosen port's IPv6 loopback is bound and held for the exchange, so a
process already sitting there is refused rather than silently preferred. A
host with no IPv6 loopback has nothing to squat and still signs in.
The authorized redirect URIs registered with the provider must therefore be
the `http://127.0.0.1:{port}/callback` forms, for ports 14200-14202.
Also from the review pass: fold the one-function googleOAuth module into
its only caller, keep the "engine is not linked yet" statement at its home
in session.rs alone, collapse the single-entry host allowlist to a host
comparison, and find the header boundary in read() once on the byte slice
instead of re-decoding the whole buffer per chunk — which also stops a
lossy-decoded char length being compared against a declared byte count.
…ener The OAuth exchange minted an `oidc_nonce`, sent it to the provider, and never looked at what came back. This is the implicit flow, where the nonce is the only replay defence there is (OIDC Core §3.2.2.11), and the API cannot compensate because it never saw the nonce. `verdict()` now decodes the ID token payload — not its signature, which stays the API's job — and refuses a token whose `nonce` claim is not this exchange's. The loopback listener gains three bounds it was missing: - a `Host` check, so a name rebound to 127.0.0.1 is not same-origin with the callback and cannot read the page nonce out of it - the callback page is served once per exchange, so a local reader can no longer take that nonce and kill the sign-in with a wrong `state` - a task per connection, so a peer that opens and says nothing spends its own deadline instead of the exchange's The committed `tauri.conf.json` CSP now names the Tauri IPC endpoint. Only the build wrapper did, and blocking it drops `invoke` to its `postMessage` fallback, which JSON-stringifies the login secret into a number array no frame can scrub. `localStorage`, `sessionStorage` and `indexedDB` become throwing stubs. Nothing may hold Core Kit material at rest before a keychain-backed CredentialStore seam exists, and a Tauri webview persists all three to the app data dir; a dependency reaching for one should fail loudly. Also covers `session.rs`, which had no tests, and routes the listener on a parsed path rather than a substring of the request line.
FSM1
marked this pull request as ready for review
August 14, 2026 12:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gives the desktop shell a frontend that can log in (ADR 0008 D3). The shell's frontend was a 43-line static page with no script and no build step; it is now a Vite + TypeScript app that drives
@cipherbox/login's sequencing, with the two genuinely native steps — Google collection and the facade — behind Tauri IPC.Part of #1261. Part of #1253.
#1261 stays open deliberately. Three of its four acceptance criteria are met — wallet is absent rather than present-and-failing, the sequencing is the shared package's, and the only desktop-specific login code is credential collection. The fourth, "a member signs in on desktop with Google and reaches the same vault as on web", is not: the engine is not linked into the shell, so
session_startis where the login stops rather than where it hands over. That slice keeps the issue open, and is tracked as #1278.What was built
A real frontend app.
apps/desktop/index.html+src/built by Vite, typechecked in the workspace, with theprocess/Buffer/globalshims Web3Auth reads off the global scope (apps/web/src/polyfills.tsis the reference).tauri.conf.jsonnow hasbeforeBuildCommand,beforeDevCommand,devUrlandfrontendDist: ../dist, sotauri buildbuilds the app.The sequencing is the shared package's.
src/main.tscallscreateLoginFlowwith aLoginHostand supplies only the host's own parts: the Core Kit instance (src/auth/coreKit.ts), the collector (src/auth/collector.ts), the facade (src/auth/facade.ts), and where progress and the account are rendered. No step of the sequence is reimplemented.Wallet is absent, not present-and-failing.
DesktopCollected['wallet']isneverand the collector has nowalletmember, socollectedMethodsnever reports it. The front door renders one affordance per method the flow reports, from a renderer table with no wallet entry — there is no wallet control to disable. Covered by tests in both files.Native Google collection.
src-tauri/src/oauth.rs, harvested from v1'scommands/oauth.rs. The shell opens the consent screen from Rust (window.open()is unreliable on Windows WebView2) and serves the callback itself from a loopback listener.Wired to real behaviour vs. stubbed
tauri buildwiringcreateLoginFlowsequencing, collector, front door@cipherbox/login)session_start/session_logoutThe engine is not linked.
src-tauridoes not depend on it.LoginFacadeis{ start, logout }, and behind themsrc-tauri/src/session.rsaccepts the login secret, checks it is the 32-byte scalarcrates/engine/src/session.rsrequires, and zeroizes it. There is no vault behind it, and the signed-in panel says so in as many words rather than rendering one.crates/desktop-seamsalready holds the eight seam implementations the native host will inject, so the remaining work is the engine construction and the!Sendsingle-writer hosting around it — #1278.The Core Kit store is in memory for the process lifetime. What the SDK keeps there is a scalar that both addresses and decrypts the Web3Auth record holding the login secret; nothing on this host may hold that at rest until the shell has its keychain-backed
CredentialStoreseam, so a restart is a fresh sign-in rather than a scalar on disk.polyfills.tsreplaceslocalStorage,sessionStorageandindexedDBwith throwing stubs, so that invariant is enforced rather than asserted — a future SDK version reaching for one fails loudly instead of silently writing a factor share to disk.The loopback listener
Three review gates ran over this file and the list below reflects what they changed, not only what it started as.
14200, 14201, 14202, tried in order. An exhausted list fails fast with a message naming them — a random port cannot be registered as an authorized redirect URI with the provider, so falling back to one would only move the failure.http://127.0.0.1:{port}/callback, built fromIpv4Addr::LOCALHOSTso the host name cannot creep back in. This is RFC 8252 §8.3, and it is load-bearing:localhostresolves to both::1and127.0.0.1with RFC 6724 preferring::1, and binding[::1]:14200does not conflict with a127.0.0.1:14200bind. A hostname redirect therefore left IPv6 loopback on all three ports permanently squattable by any same-user process, with no race and no visible failure — and the squatter would receive Google's redirect and read the ID token out of the fragment. A test parses the redirect and asserts its host is anIpAddr.[::1];AddrInUsethere refuses the exchange. Only that error is fatal — a host with no IPv6 stack answersEAFNOSUPPORT, and refusing on every bind error would make sign-in impossible there.Hostare answered400— closing the DNS-rebinding path that would otherwise let a rebound page read the callback document.nonceclaim is checked against the one this exchange sent. The shell base64url-decodes the payload only; the API remains the signature verifier, so no crypto moves into the shell. Without this thenoncewas minted, sent, and validated by nobody — and under the implicit flow OIDC Core §3.2.2.11 makes that check a MUST, because it is the flow's only replay defence. A malformed token refuses rather than panicking.GET /callbackin one exchange is answered410and no nonce, so a local peer cannot read it out of the page and then kill the sign-in with a wrong-statePOST. A wrong page nonce is stillIgnoredrather than fatal — that is what stops a stray cross-origin no-cors POST from ending the flow.statebinds the reply to this attempt; a mismatch ends the exchange. It is 128 bits that reach the machine only through the provider's redirect, so it is the gate that holds even when the page nonce is known.https://accounts.google.com; the authorize URL is built by appending query pairs to a fixed base, so no value carried through it can move the request off that host (tested with a hostile client ID).Known residual: the ID token arrives in a URL fragment inside a webview whose profile persists to disk, so it may rest in webview history. Authorization code + PKCE (RFC 8252 §8.2) would keep it out of any URL and is the change to make if that residual is not acceptable; it is not in this PR.
Why the CSP was widened exactly this far
default-src 'self'refuses the login the shell exists to run. The policy the release binary carries is:'wasm-unsafe-eval'andworker-src blob:— the Core Kit's threshold signing instantiates a WebAssembly module in a blob worker.connect-srchost families are the only remote hosts the built bundle names;*.torusnode.com,*.auth.networkand*.authnetwork.devappear nowhere in it and are not allowed. Awss:upgrade to the same hosts is covered by CSP's scheme matching.'unsafe-inline'and no'unsafe-eval': the builtindex.htmlcarries no inline script or style, and a test asserts the policy contains neither.The API origin is a deployment variable the committed
tauri.conf.jsoncannot name, and the Tauri CLI reads its config beforebeforeBuildCommandruns, so no build step could supply it.scripts/tauri.mjstherefore computes the policy from the same environment the bundle is built with and passes it to the CLI as--config;package.json'stauriscript points at the wrapper, sopnpm tauri buildand the existing CI invocation both go through it. The committed base isdefault-src 'self'; connect-src 'self' ipc: http://ipc.localhost, so a build that bypasses the wrapper still fails closed for every remote host — it admits only Tauri's own IPC endpoint. That entry is pinned in the committed config on purpose: Tauri'spostMessagefallback, which a CSP violation triggers, JSON-stringifies anArrayBufferinto a number array, and the login secret must never cross as one.src/csp.test.tsholds the wrapper's default API URL and the app's own together, so the two cannot drift.Verified here
cargo fmt -p cipherbox-desktop --check,cargo clippy -p cipherbox-desktop --all-targets -- -D warnings,cargo test -p cipherbox-desktop(24 tests) — pass.pnpm --filter @cipherbox/desktop typecheck/build/test(24 tests) — pass.pnpm typecheck,pnpm test,pnpm lint,pnpm lint:md,pnpm lint:tracker-refs— pass across the workspace.pnpm --filter @cipherbox/desktop tauri build --no-bundle— a full release build succeeds through the wrapper, and the computed CSP is present in the resulting binary.blueprint/testing.mdlaw 1: the new TS suites run under the existing recursiveTypecheck/Test/Buildgates (apps/**is in thetspaths filter), and the new Rust suite gets acargo test -p cipherbox-desktopstep in theDesktop Buildgate — the workspace-widecargo testexcludes this crate, so it would otherwise have run in no gate at all.Needs human verification
None of the following can be proved in this container: it has no display, no real Google OAuth client, and no configured Web3Auth verifier. No end-to-end signed-in session was exercised. On a real machine with real credentials, please confirm:
tauri dev. This is the distinction that hid the bug in v1.http://127.0.0.1:14200/callback,:14201and:14202are registered as authorized redirect URIs on the desktop OAuth client, and that client ID is whatVITE_GOOGLE_CLIENT_IDcarries for desktop builds. The IP literal is required, not cosmetic — see the loopback section above. Sign-in fails until these are registered.'wasm-unsafe-eval'andworker-src blob:are sufficient for the DKLS WASM on WebKitGTK, WKWebView and WebView2 — they were not exercised.GOOGLE_CLIENT_ID, or desktop sign-in fails closed at the API's verifier.A member cannot yet "reach the same vault as on web" on desktop — that needs the engine, which is #1278.
Note
Add a login frontend to the desktop shell with native Google OAuth and email flows
@cipherbox/loginpackage.Sessionstruct (session.rs) with IPC commandssession_startandsession_logout;session_startenforces a single active session and requires a 32-byte secret buffer.localStorage,sessionStorage, andindexedDBto prevent at-rest persistence.tauri.conf.jsonCSP restricted toselfand Tauri IPC only.visible: trueby default in tauri.conf.json, reversing prior behavior of hiding it on start.Macroscope summarized 180b3f4.