Skip to content

feat: a web client anybody can check against its source - #41

Merged
Sycatle merged 5 commits into
devfrom
feat/verifiable-web
Aug 25, 2026
Merged

feat: a web client anybody can check against its source#41
Sycatle merged 5 commits into
devfrom
feat/verifiable-web

Conversation

@Sycatle

@Sycatle Sycatle commented Aug 25, 2026

Copy link
Copy Markdown
Owner

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 in node:22-bookworm-slim agrees 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_URL is 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_PUBKEY leaves the web bundle and stays on the desktop. pinning.ts already 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-src reduces 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.sh produces it; release.yml attests it with actions/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.sh says 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

  • The banner does not go away, and cannot. A "verified" badge drawn by the page would be forged by exactly the server it is meant to catch. The sentence changes from "nobody can check this" to "here is how"; that is the whole of it.
  • A targeted attack on somebody without the extension. verify-web.sh passes for everybody who runs it and fails only for the person being attacked, who is the person not running it.
  • The re-fetch compromise. Chrome exposes no way to read the bytes a page actually received, so the extension re-requests with 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".
  • Verifiable or calls, not both. VITE_MEDIA_URL still enters the policy, so a deployment configuring calls stops matching the published build — until the media server sits behind the same origin.
  • The extension's own supply chain. It is another artefact from another store.
  • Nothing about the audit. No amount of build verification makes an unaudited protocol audited.

Security impact

  • No security or privacy property is affected.
  • A property is affected. Which one, in which direction, and what compensates:

Two, and both in the direction of more. connect-src narrows 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.ts documents 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.md needs a section for this and does not have one yet — see the last item under What is left.

Invariants

  • crypto-core does not depend on ratchet-lab, directly, transitively, behind a feature flag, or in a test.
  • No behaviour was added that only works in a debug build.

What was run

  • cargo test --release — not run: no Rust changed on this branch
  • cargo clippy --all-targets — same
  • wasm-pack test --node crates/crypto-wasm — no crypto and no WASM changed
  • cargo test -p server --release — the server is untouched
  • pnpm run typecheck, pnpm test (721), pnpm run lint in apps/web
  • node --test extension/*.test.js — 9 passed, now wired into test.yml
  • Verified by hand:
    • two builds with completely different configuration produce identical bytes, all 226 files;
    • a build in node:22-bookworm-slim matches CI's manifest byte for byte;
    • the dev proxy reaches the API, /v1/gateway opens, and connect-src 'self' blocks neither;
    • verify-web.sh against a live instance: 226 matched; alter two files and both are reported ALTERED with exit 1; stop the server and all 226 are MISSING, so the two failures stay distinguishable;
    • release-web.sh refuses a dirty tree — it refused mine;
    • both banners render, and the settings dialog shows them apart.

A rehearsal tag was pushed on purpose, and it found something. Build and hash succeeded, but this development machine disagreed with CI on two of the fourteen generated chunks: node 22.21.0 against 22.23.2. The workflow said node-version: 22 and the Dockerfile node:22-bookworm-slim — two ways of saying "the current 22.x", agreeing that day by luck. apps/web/.nvmrc now 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-provenance refuses 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. The Publish step has therefore never run. The tag was deleted afterwards.

Migrations

  • No SQL migration was added or edited.

What is left

  • docs/THREAT-MODEL.md has 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.
  • The extension is unpacked-only. Publishing it is its own piece of work, and until it is published "install the extension" means "load it from source", which is a smaller audience than the banner now implies.

Checklist

  • Targets dev, not main.
  • Conventional Commits, one logical change per commit.
  • Comments argue the why, in the pattern decision → why → what it does not solve.
  • Documentation updated: docs/DEPLOY.md, .env.example, deploy/.env.example.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PDvUtP9fUQRwHYszVpJtnS

…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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread extension/background.js
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => [
document.location.href.split(/[?#]/)[0],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread extension/background.js
Comment on lines +70 to +73
...performance
.getEntriesByType("resource")
.filter((entry) => entry.initiatorType !== "beacon")
.map((entry) => entry.name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread extension/background.js
Comment on lines +51 to +54
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()) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread scripts/verify-web.sh
echo
echo "$checked file(s) matched, $altered altered, $missing missing"

if (( altered > 0 || missing > 0 )); then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Sycatle
Sycatle merged commit 9e7f5eb into dev Aug 25, 2026
5 checks passed
@Sycatle
Sycatle deleted the feat/verifiable-web branch August 25, 2026 15:29
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