feat: a web client anybody can check against its source - #41
Conversation
…t reach The client tells its user that the server "could deliver a version that exfiltrates your keys. No browser API fixes that." Nothing removes that sentence, but something answers it: a list of hashes published somewhere the delivering server does not control, so that a substitution stops being invisible. `scripts/release-web.sh` produces the list. It refuses a dirty tree for the reason `release.sh` does — a manifest matching no commit is one nobody can rebuild to compare — and exports `SOURCE_DATE_EPOCH` from the commit, which changes nothing measurable today and means the day a dependency starts embedding a timestamp, it embeds the same one everywhere. `.github/workflows/release.yml` is where the mechanism actually acquires its meaning, and it is not the script. A manifest built anywhere says "somebody hashed some files"; `attest-build-provenance` binds it to this commit and this workflow through Sigstore, and nobody outside Actions — the maintainer included — can produce that binding. The repository's own Ed25519 release key would not do: `verify-release.sh` already says it lives in the repository, so whoever controls the repository can replace it, and a manifest meant to be independent of the party serving the code cannot rest on a key that party holds. It is the first workflow here to declare `permissions:` — no other one does — because `id-token: write` is what lets the runner prove which workflow it is. `scripts/verify-web.sh` is the other end, and it states its own ceiling on success rather than in a header nobody reads: it establishes that a server serves the build a manifest describes, not that the server is honest. One willing to serve one build to the world and another to one person passes this for everybody who runs it and fails only for the person it is attacking — who is the person not running it. That is what the extension is for, and why this script is the version a human can run today rather than the answer. The manifest describes every deployment at once, which is only true because of the commit before this one: the bundle no longer carries any deployment's configuration, so two instances of a commit serve identical bytes. Without that, this would have been a service to the official deployment and to nobody else.
The rehearsal tag found this, which is what a rehearsal is for. `Build and hash` succeeded in CI, and its manifest agrees byte for byte with one built in `node:22-bookworm-slim` — the image the deployment uses. Three of the five sampled chunks also matched this development machine. Two did not: `index-*.js` and `PdfViewer-*.js` differ between node 22.21.0 and node 22.23.2, while the CSS and the remaining chunks are identical. So reproducibility holds across machines, and holds on the version of node. `node-version: 22` in the workflow and `node:22-bookworm-slim` in the Dockerfile both resolve to "whatever 22.x is current", and they happened to agree on the day this was written. That is not a property to rest a manifest on: the day they drift, a deployment stops matching its own published hashes and the mismatch looks exactly like the substitution this mechanism exists to make visible. `apps/web/.nvmrc` is now the one place that says which node, read by the workflow through `node-version-file` and repeated by hand in the Dockerfile — whose comment says to change both in the same commit. The same discipline `rust-toolchain.toml` already applies to the compiler, and for the same reason it gives: "recent" is not a version. Not fixed here, and deliberately: `test.yml` still says `node-version: 22`. It builds nothing anybody verifies, so pinning it would be a change with no argument behind it beyond symmetry.
…annot The manifest published on a tag is only worth what somebody does with it. `scripts/verify-web.sh` is the version a human runs, and it has a ceiling it states itself: a server willing to serve one build to the world and another to one person passes it for everybody who runs it and fails only for the person it is attacking — who is the person not running it. This is the half that runs for that person. **The verdict is in the toolbar icon and never in the page.** That is the reason an extension exists rather than a badge in the application: everything a page displays is drawn by the server being checked, so a "verified" mark there would be forged by exactly the server it is meant to catch. **The manifest is fetched from GitHub, never from the inspected origin.** A server handing over both the code and the hashes of that code has certified itself, which is the defect the whole mechanism removes — the same one the threat model records about the transparency log being signed by the server it watches. `verify.js` holds the comparison and takes every input as an argument, so it runs under `node --test` without a browser. Nine tests, and the four that matter are the failures: an altered byte, a script at a path the manifest never described, a resource pulled from another origin, and one that cannot be re-fetched. Each is a way past a hash check that a verifier walking only the manifest would report as clean. Checking nothing answers `unknown` rather than `ok`, which is the single most dangerous thing it could display. The compromise it cannot avoid, written at the top of `background.js` rather than discovered: Chrome exposes no way to read the bytes a page actually received, so resources are re-requested with `cache: "force-cache"`. A server answering differently to a second request defeats that, and nothing here detects it. What it raises is the cost of an attack from "serve anything" to "serve one thing consistently and hope nobody compares". Host access is requested at a click and declared nowhere. An extension able to read every site from the moment it is installed is a worse thing than the problem it solves. The banner splits, which was worth doing on its own. It used to make two unrelated claims in one paragraph — that this code arrives from a server on every load, and that the project is unaudited — so the desktop build, where the first is false, silently dropped the second as well. Somebody who installed the signed binary was told nothing about the audit, which is the half that still applies to them. The delivery half now names what to check with; the audit half is untouched by any of this and shows everywhere.
`connect-src` matched on `media.replace(/^http/, "ws")`, which assumes the deployment wrote `http://`. Given `ws://` — what `.env.example` recommends for a local media server, and how a LiveKit URL is written everywhere — the replacement matched nothing and returned its input. The policy then listed the same origin twice and **omitted the HTTP one**. That form is not decoration: the comment three lines above says the SDK asks the media server over HTTP why a connection failed, so a broken call reported a vaguer reason than the browser had. Exactly the class of omission the file exists to catch. `bothSchemes` normalises to HTTP first and returns the pair, so all four spellings — `ws`, `wss`, `http`, `https` — produce the same two sources. Found by configuring calls locally and reading the policy the dev server served: `connect-src 'self' ws://127.0.0.1:7880 ws://127.0.0.1:7880`. The existing test agreed with the bug because it only ever passed `https://`; the new one passes the other spelling and fails against the old code with "ws://127.0.0.1:7880 does not allow http://127.0.0.1:7880".
…oes not §2.3 lists "serve hostile JavaScript, on every load, to one person" among a malicious server's powers, with no answer beside it but the desktop binary. There is one now, and it is worth less than it first reads — which is exactly why it needs a section rather than a line. §4quinquies says what was built: a manifest of the bundle's hashes published per release and attested by GitHub Actions through Sigstore, binding it to a commit and to the workflow. It says why that binding is the part that matters, and why the repository's own Ed25519 key would not have done — `verify-release.sh` already records that the key lives in the repository, so whoever controls the repository can replace it, and a manifest whose job is independence from the party serving the code cannot rest on that party's key. The rest of the section is what it does not establish, ordered by how easily each is over-read: - the banner does not go away and no version of this removes it, because a badge drawn by the page would be forged by the server being checked; - a targeted attack on somebody who does not check is untouched — the manual script passes for everybody who runs it and fails only for the person being attacked, who is the person not running it; - the extension re-requests rather than reads, so a server answering differently the second time defeats it; - verifiable or calls, not both, while `VITE_MEDIA_URL` still enters the policy; - the extension is its own supply chain, and unpublished; - none of it concerns the audit. Two existing entries were false after the change and are corrected. §2.3 gains the pointer. The limitations table said the web build pins the log key through `VITE_LOG_PUBKEY`; it does not any more, and the row now says why the removal was deliberate rather than a regression — the server shipped the pin alongside the code it constrained, and compiling it in was what stopped one manifest from describing every deployment.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a72fe9ecc3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const [result] = await chrome.scripting.executeScript({ | ||
| target: { tabId }, | ||
| func: () => [ | ||
| document.location.href.split(/[?#]/)[0], |
There was a problem hiding this comment.
Normalize SPA document URLs to index.html
On the normal deployment URL /, this records the document as the origin followed by /; pathOf() consequently produces an empty path, while the generated manifest lists the same bytes as index.html. The same problem occurs for client-side routes such as /settings, which Caddy serves via its try_files {path} /index.html fallback. An unchanged deployment therefore receives an UNLISTED finding and can never verify successfully unless the user explicitly visits /index.html.
Useful? React with 👍 / 👎.
| ...performance | ||
| .getEntriesByType("resource") | ||
| .filter((entry) => entry.initiatorType !== "beacon") | ||
| .map((entry) => entry.name), |
There was a problem hiding this comment.
Exclude application API traffic from bundle verification
After the page makes any fetch or XHR request, its resource timing entry is included here because only beacons are filtered. This application performs numerous same-origin requests under /v1/ (apps/web/src/lib/api.ts), but those dynamic API endpoints cannot appear in the static build manifest, so verifyResources() marks them UNLISTED and paints the badge red even when every shipped file is genuine. Filter non-build traffic, at minimum the same-origin API namespace, before comparing resource URLs with the manifest.
Useful? React with 👍 / 👎.
| const manifest = await fetch(asset.browser_download_url); | ||
| if (!manifest.ok) throw new Error(`manifest unreachable (${manifest.status})`); | ||
|
|
||
| return { tag: body.tag_name, entries: parseManifest(await manifest.text()) }; |
There was a problem hiding this comment.
Verify provenance before trusting the release manifest
The extension downloads the mutable release asset and immediately treats its contents as authoritative, but never verifies the build-provenance attestation created by release.yml. Anyone able to replace or upload release assets can therefore publish hashes for a substituted deployment and make the extension show ok without those hashes having been produced by the attested commit and workflow—the trust property this change says is essential. The extension must authenticate the attestation, or consume an equivalently immutable authenticated mapping, before accepting these entries.
Useful? React with 👍 / 👎.
| echo | ||
| echo "$checked file(s) matched, $altered altered, $missing missing" | ||
|
|
||
| if (( altered > 0 || missing > 0 )); then |
There was a problem hiding this comment.
Reject manifests that contain no file entries
If the supplied manifest is empty, truncated to comments, or otherwise contains no readable entries, the loop leaves all three counters at zero and this condition falls through to the success message and exit status 0. That reports “Every file ... is served byte for byte” despite checking nothing; require at least one valid manifest entry (and preferably validate its digest format) before allowing success.
Useful? React with 👍 / 👎.
The web client tells its user that the server "could deliver a version that exfiltrates your keys. No browser API fixes that." Asking to stop showing that is asking to make it false. This makes it checkable instead.
What this changes, and why this rather than the obvious alternative
The obvious alternative was to soften the banner. It is a true sentence about every web application, and the only honest way to answer it is to let somebody compare the delivered code against something the delivering server does not control.
Phase 0 — measured before anything was built. Two consecutive builds of
apps/web: 226 files, byte-identical. Then the same thing across machines, which is the one that mattered — a build innode:22-bookworm-slimagrees with CI byte for byte.Phase 1 — the bundle stopped belonging to a deployment. Three variables were substituted into the bytes at build time, so two instances of one commit produced different files and a manifest could only ever have described one of them. Measured: four files differed between two configurations; now none do.
VITE_API_URLis gone — the client asks the origin that served it, and development gets the same code path through a Vite proxy rather than a second one.VITE_LOG_PUBKEYleaves the web bundle and stays on the desktop.pinning.tsalready argued the pin is "not a defence against the party that builds the bundle" there, since the server ships the pin along with the code it constrains.connect-srcreduces to'self', which is strictly tighter than naming an origin. It covers the WebSocket too, and that was the doubtful part, so it was checked in a browser rather than read in a specification.Phase 2 — the manifest, published where the server cannot reach.
scripts/release-web.shproduces it;release.ymlattests it withactions/attest-build-provenance, which binds it to the commit and the workflow through Sigstore. The repository's own Ed25519 key would not do:verify-release.shsays it lives in the repository, so whoever controls the repository can replace it.Phase 3 — the extension. The verdict lives in its toolbar icon and never in the page, because everything a page displays is drawn by the server being checked.
Phase 4 — the banner splits. It used to make two unrelated claims in one paragraph, so the desktop build — where the delivery claim is false — silently dropped the audit claim too. Somebody who installed the signed binary was told nothing about the audit, which is the half that still applies to them.
What it does not solve
verify-web.shpasses for everybody who runs it and fails only for the person being attacked, who is the person not running it.cache: "force-cache". A server answering differently to a second request defeats this and nothing detects it. It raises the cost of an attack from "serve anything" to "serve one thing consistently".VITE_MEDIA_URLstill enters the policy, so a deployment configuring calls stops matching the published build — until the media server sits behind the same origin.Security impact
Two, and both in the direction of more.
connect-srcnarrows from naming an origin to'self'. And the delivered bundle becomes checkable against a manifest attested to a commit, which is a property the project did not have at all.One is deliberately given up: the web client no longer pins the transparency log's key.
pinning.tsdocuments at length that on the web the pin was never a defence — the server ships it alongside the code it constrains — and what it did buy, a substitution that breaks every client at once instead of silently, is what a verifiable build provides and provides better. The desktop keeps it, where the artefact is signed and the argument holds.docs/THREAT-MODEL.mdneeds a section for this and does not have one yet — see the last item under What is left.Invariants
crypto-coredoes not depend onratchet-lab, directly, transitively, behind a feature flag, or in a test.What was run
cargo test --release— not run: no Rust changed on this branchcargo clippy --all-targets— samewasm-pack test --node crates/crypto-wasm— no crypto and no WASM changedcargo test -p server --release— the server is untouchedpnpm run typecheck,pnpm test(721),pnpm run lintinapps/webnode --test extension/*.test.js— 9 passed, now wired intotest.ymlnode:22-bookworm-slimmatches CI's manifest byte for byte;/v1/gatewayopens, andconnect-src 'self'blocks neither;verify-web.shagainst a live instance: 226 matched; alter two files and both are reportedALTEREDwith exit 1; stop the server and all 226 areMISSING, so the two failures stay distinguishable;release-web.shrefuses a dirty tree — it refused mine;A rehearsal tag was pushed on purpose, and it found something.
Build and hashsucceeded, but this development machine disagreed with CI on two of the fourteen generated chunks: node 22.21.0 against 22.23.2. The workflow saidnode-version: 22and the Dockerfilenode:22-bookworm-slim— two ways of saying "the current 22.x", agreeing that day by luck.apps/web/.nvmrcnow pins the patch and both read it. Without that, a deployment would eventually stop matching its own published hashes, and the mismatch would look exactly like an attack.The tag also confirmed what it was expected to:
attest-build-provenancerefuses a user-owned private repository — "To enable this feature, please make this repository public." Nothing to fix; it resolves when the repository goes public, which is the plan. ThePublishstep has therefore never run. The tag was deleted afterwards.Migrations
What is left
docs/THREAT-MODEL.mdhas no section on build verification, and it should: what the manifest establishes, the re-fetch compromise, and the pin that moved. Worth its own commit rather than being squeezed in here.Checklist
dev, notmain.docs/DEPLOY.md,.env.example,deploy/.env.example.🤖 Generated with Claude Code
https://claude.ai/code/session_01PDvUtP9fUQRwHYszVpJtnS