Skip to content

feat(desktop): give the shell a frontend that can log in - #1277

Open
claude[bot] wants to merge 6 commits into
mainfrom
claude/cipher-box-1261-desktop-frontend
Open

feat(desktop): give the shell a frontend that can log in#1277
claude[bot] wants to merge 6 commits into
mainfrom
claude/cipher-box-1261-desktop-frontend

Conversation

@claude

@claude claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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_start is 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 the process / Buffer / global shims Web3Auth reads off the global scope (apps/web/src/polyfills.ts is the reference). tauri.conf.json now has beforeBuildCommand, beforeDevCommand, devUrl and frontendDist: ../dist, so tauri build builds the app.

The sequencing is the shared package's. src/main.ts calls createLoginFlow with a LoginHost and 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'] is never and the collector has no wallet member, so collectedMethods never 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's commands/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

Vite/TS frontend, tauri build wiring real
createLoginFlow sequencing, collector, front door real
Identity exchange against the API real (@cipherbox/login)
Web3Auth Core Kit login and secret export real
Native Google loopback collection real
session_start / session_logout stubbed — see below
Core Kit session persistence across restarts not implemented — in-memory store

The engine is not linked. src-tauri does not depend on it. LoginFacade is { start, logout }, and behind them src-tauri/src/session.rs accepts the login secret, checks it is the 32-byte scalar crates/engine/src/session.rs requires, 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-seams already holds the eight seam implementations the native host will inject, so the remaining work is the engine construction and the !Send single-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 CredentialStore seam, so a restart is a fresh sign-in rather than a scalar on disk. polyfills.ts replaces localStorage, sessionStorage and indexedDB with 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.

  • Pre-registered ports 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.
  • The redirect names the IP literal, http://127.0.0.1:{port}/callback, built from Ipv4Addr::LOCALHOST so the host name cannot creep back in. This is RFC 8252 §8.3, and it is load-bearing: localhost resolves to both ::1 and 127.0.0.1 with RFC 6724 preferring ::1, and binding [::1]:14200 does not conflict with a 127.0.0.1:14200 bind. 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 an IpAddr.
  • Squatter detection. The chosen port is also bound on [::1]; AddrInUse there refuses the exchange. Only that error is fatal — a host with no IPv6 stack answers EAFNOSUPPORT, and refusing on every bind error would make sign-in impossible there.
  • Binds loopback only, so nothing off this machine can connect, and requests carrying any other Host are answered 400 — closing the DNS-rebinding path that would otherwise let a rebound page read the callback document.
  • The ID token's nonce claim 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 the nonce was 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.
  • Page nonce, served once. The callback document carries a 128-bit nonce and must repeat it on the POST. A second GET /callback in one exchange is answered 410 and no nonce, so a local peer cannot read it out of the page and then kill the sign-in with a wrong-state POST. A wrong page nonce is still Ignored rather than fatal — that is what stops a stray cross-origin no-cors POST from ending the flow.
  • state binds 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.
  • Deadlines and concurrency. 120 s absolute on the exchange, counted from before the window opens; connections are served in spawned tasks with the verdict returned over a channel, so silent peers cannot starve the real callback of the budget. Requests are capped at 16 KiB.
  • Provider host allowlist. The only URL the shell will open a window on is 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).
  • The listener is dropped when the command returns, and the ID token is never logged, never echoed into any served page, and never written to disk by the shell.

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:

default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self';
img-src 'self' data:; worker-src 'self' blob:;
connect-src 'self' ipc: http://ipc.localhost <API origin> https://*.web3auth.io https://*.tor.us;
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'none'
  • 'wasm-unsafe-eval' and worker-src blob: — the Core Kit's threshold signing instantiates a WebAssembly module in a blob worker.
  • The two connect-src host families are the only remote hosts the built bundle names; *.torusnode.com, *.auth.network and *.authnetwork.dev appear nowhere in it and are not allowed. A wss: upgrade to the same hosts is covered by CSP's scheme matching.
  • Google is deliberately not in the policy. Its consent screen is opened natively in a separate window, so the shell's own document never reaches it.
  • No 'unsafe-inline' and no 'unsafe-eval': the built index.html carries no inline script or style, and a test asserts the policy contains neither.

The API origin is a deployment variable the committed tauri.conf.json cannot name, and the Tauri CLI reads its config before beforeBuildCommand runs, so no build step could supply it. scripts/tauri.mjs therefore computes the policy from the same environment the bundle is built with and passes it to the CLI as --config; package.json's tauri script points at the wrapper, so pnpm tauri build and the existing CI invocation both go through it. The committed base is default-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's postMessage fallback, which a CSP violation triggers, JSON-stringifies an ArrayBuffer into a number array, and the login secret must never cross as one. src/csp.test.ts holds 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.md law 1: the new TS suites run under the existing recursive Typecheck / Test / Build gates (apps/** is in the ts paths filter), and the new Rust suite gets a cargo test -p cipherbox-desktop step in the Desktop Build gate — the workspace-wide cargo test excludes 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:

  1. A packaged build signs in with Google — not only tauri dev. This is the distinction that hid the bug in v1.
  2. http://127.0.0.1:14200/callback, :14201 and :14202 are registered as authorized redirect URIs on the desktop OAuth client, and that client ID is what VITE_GOOGLE_CLIENT_ID carries for desktop builds. The IP literal is required, not cosmetic — see the loopback section above. Sign-in fails until these are registered.
  3. The CSP does not refuse anything the Core Kit needs at runtime. The host list was derived from the strings the built bundle contains; a host it constructs at runtime from a shape I did not find would be refused. Watch the webview console during a real login.
  4. 'wasm-unsafe-eval' and worker-src blob: are sufficient for the DKLS WASM on WebKitGTK, WKWebView and WebView2 — they were not exercised.
  5. Email code sign-in end to end against a real API.
  6. The consent window opens and closes correctly on Windows WebView2 and macOS WKWebView.
  7. The desktop OAuth client's audience matches the API's 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

  • Adds a Vite/TypeScript frontend to the desktop Tauri app with a login UI that renders Google and email sign-in methods based on available config, driven by the shared @cipherbox/login package.
  • Implements a native loopback OAuth callback server in Rust (oauth.rs) that opens a Google consent window, validates the returned ID token, and returns it to the webview via IPC.
  • Adds a Session struct (session.rs) with IPC commands session_start and session_logout; session_start enforces a single active session and requires a 32-byte secret buffer.
  • Integrates Web3Auth MPC Core Kit via an in-memory store (coreKit.ts) that explicitly blocks localStorage, sessionStorage, and indexedDB to prevent at-rest persistence.
  • Computes the Tauri CSP dynamically at CLI invocation time from build-env API and Web3Auth origins (csp.mjs), with the committed tauri.conf.json CSP restricted to self and Tauri IPC only.
  • Risk: the main window is now visible: true by default in tauri.conf.json, reversing prior behavior of hiding it on start.

Macroscope summarized 180b3f4.

claude added 6 commits August 14, 2026 08:57
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
FSM1 marked this pull request as ready for review August 14, 2026 12:19
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