Open source · TypeScript · MIT · v0.4.4
Signed data, checked in the browser.
Have the page check that downloaded data was signed by you and not changed, then keep it for offline use. @gainratio/browser verifies a signed bundle in a Web Worker before your app sees a byte.
- Ed25519 signatures
- SHA-256 content addresses
- SQLite on OPFS
- Typed refusals
- 0.4.4
AlmaMesh uses it to download its chart engine.
The page trusts whatever the network sends.
Many web apps download data files and work with them inside the page: a product catalog, a search index, a price list, a model. Usually the page trusts whatever the server or CDN sends back. If a mirror serves an old copy, or a file is changed or cut short on the way, the app uses it anyway and nobody notices.
A checksum is not a signature
A plain hash tells you a file changed. It does not tell you who released it. Anyone who can replace the file can replace the hash that came with it. HTTPS authenticates the connection, not the release your page meant to receive.
This package moves the check into the page. You sign the data once when you publish it. The page pins your public key, checks your signature and every piece, and refuses anything that does not match.
- The publisher signs one small pointer, not every download.
- Each piece is named by the SHA-256 of its contents.
- A sequence number can only move forward.
- Any failure is a typed error, and the last good version stays.
- Checked pieces are kept for the next visit and for offline use.
A signed bundle, checked piece by piece.
Publish a folder of static files. Your page hands the bundle URL and the public-key URL to a Worker. The Worker verifies the signed pointer, fetches only the pieces it is missing, and promotes the release only when every check passes.
The pointer is authenticated
The Worker fetches your public key or keyring with no HTTP-cache reuse, capped at 64 KiB, and checks the pointer's Ed25519 signature over canonical JSON bytes.
Every piece proves itself
Each chunk is named by the SHA-256 of its contents. The Worker decompresses within a size bound, re-hashes, and deletes a bad row instead of serving it.
A sequence number can only go up
A pointer whose sequence went backwards, or forked at the same sequence, is refused. The highest accepted sequence stays in storage as the floor.
The next visit downloads only what changed
Checked chunks live in one SQLite database on OPFS. If OPFS is refused, an in-memory database keeps the Worker alive for the session and re-downloads next time.
Refused means refused
A bad signature, a tampered chunk, a rolled-back pointer, or an expired network pointer throws a typed error. Nothing unchecked is returned with a warning.
Count the Worker's requests
A monitor inside the Worker reports every request it made over a BroadcastChannel, so “no backend calls” can be counted instead of promised.
Quickstart
From zero to a checked file in about a minute.
You need Node 22.13 or newer, and uv to run the Python tool that signs the data. The commands below are the real quickstart, not a sketch.
Step 1 Create the project
The package and Vite are the page-side installs.
mkdir signed-data-demo && cd signed-data-demo
npm init -y
npm install @gainratio/browser vite
Step 2 Sign a folder of data
The publisher is the Python edge-proc CLI, run through uvx. Keep the private key off the web server; copy only the public key into public/.
mkdir data
echo '{"sku": "A-100", "name": "Blue mug", "price": 12.5}' > data/prices.json
uvx --from 'edge-proc[bundles]' edgeproc keygen --out keys
uvx --from 'edge-proc[bundles]' edgeproc publish --src data --origin-dir public/bundle \
--key keys/private.key --bundle-id prices --version v1 --sequence 1
cp keys/public.key public/public.key
--sequence, and raise it every time you publish. The browser refuses a pointer without one and uses the number to refuse rollbacks. public/bundle now holds the signed pointer (latest), a file list (manifest/), and the data in pieces (chunk/).Step 3 Add the Worker
Keep the Worker entry in your own source so Vite owns its URL.
import "@gainratio/browser/worker";
Step 4 Sync and read the checked bytes
The client asks the Worker to sync. Only after the signature and every piece pass does readFile return data.
import { EngineClient } from "@gainratio/browser";
import EdgeProcWorker from "./edgeproc.worker.js?worker";
const out = document.querySelector("#out");
const client = new EngineClient(new EdgeProcWorker());
try {
const result = await client.sync("/bundle", "/public.key");
const bytes = await client.readFile("prices.json");
out.textContent = `verified ${result.version} (${result.chunksFetched} fetched, ${result.chunksReused} reused)\n` +
new TextDecoder().decode(bytes);
} catch (error) {
out.textContent = `refused (${error.code}): ${error.message}`;
}
<!doctype html>
<h1>Signed data check</h1>
<pre id="out">checking...</pre>
<script type="module" src="/main.js"></script>
Step 5 Run it
npx vite
Open http://localhost:5173. The page downloads the bundle, checks it, and shows the file:
verified v1 (1 fetched, 0 reused)
{"sku": "A-100", "name": "Blue mug", "price": 12.5}
Reload the page. It now says verified v1 (0 fetched, 1 reused): the checked copy is already in the browser, so nothing was downloaded again. In a new private window, change one byte of a stored data piece and the page refuses it with refused (integrity): chunk ... failed content-address check. The tamper command is in the examples.
Copyable real example
The same check in Node, with the transport injected.
The checking core also runs in Node. This is the README's check.mjs: the “server” reads local files, and the second run flips one byte in every piece it serves.
import { readFile } from "node:fs/promises";
import { MemoryCacheStore, materializeFile, syncIndex, verifyEd25519 } from "@gainratio/browser";
const key = new Uint8Array(await readFile("public/public.key"));
const verify = (message, signature) => verifyEd25519(key, message, signature);
// Stands in for a web server: "/bundle/latest" is read from public/bundle/latest.
const server = async (url) => new Uint8Array(await readFile(`public${url}`));
// A bad mirror: flips one byte in every data chunk it serves.
const tampered = async (url) => {
const bytes = await server(url);
if (url.includes("/chunk/")) bytes[9] ^= 1;
return bytes;
};
const store = new MemoryCacheStore();
const ok = await syncIndex({ baseUrl: "/bundle", store, fetchBytes: server, verify });
const manifest = JSON.parse(new TextDecoder().decode(await store.getManifest(ok.manifestHash)));
const bytes = await materializeFile(store, manifest, "prices.json");
console.log(`accepted ${ok.version}: ${new TextDecoder().decode(bytes).trim()}`);
await syncIndex({ baseUrl: "/bundle", store: new MemoryCacheStore(), fetchBytes: tampered, verify })
.catch((error) => console.log(`refused: ${error.name}: ${error.message}`));
accepted v1: {"sku": "A-100", "name": "Blue mug", "price": 12.5}
refused: IntegrityError: chunk 34fa454858ceada20cfa0cd5236eb70f90e1021ed3bd03a6c9d5332597b3cc12 failed content-address check
The repository's pnpm demo runs a real committed 783-chunk fixture with no network and proves the same fail-closed behaviour.
What it does not do.
It is not a publisher
It checks bundles; it does not sign or publish them. Today the publisher is the Python edgeproc publish command from edge-proc.
Data flows one way
Publisher to browser. It does not sync user edits back to a server, and it has no backend of its own.
It does not know your data
It hands your app checked bytes. What they mean, how to index them, and what to show the user are your decisions.
It is not confidentiality
Signing proves integrity and signer identity, not secrecy. The bundle is plaintext bytes. The opt-in /seal export encrypts files separately with a passphrase.
It cannot protect a compromised page
A malicious browser extension, or an attacker who controls your public-key URL, is out of reach. Serve the key over HTTPS from your own site, separately from mutable bundle content.
It is still pre-1.0
0.4.4 is the current release. Chromium gets the full browser suite; Firefox and WebKit get a cross-browser test. Playwright's WebKit refuses OPFS, and real Safari is not tested.
Data handling
What stays in the browser.
The public key is public. The private signing key never belongs in the browser. A sync's network traffic goes to the bundle URL and the public-key URL you configure; checked bytes are not sent back anywhere.
The cache is one SQLite database on OPFS, or an in-memory database when OPFS is refused. Every cached chunk is re-hashed on read, and a bad row is deleted rather than served. The stored active pointer is also the anti-rollback floor.
The package has no analytics and loads no code from a CDN. This site has no external fonts, scripts, trackers, or images.
- Serve the public key or keyring from a URL you control, over HTTPS.
- Host it apart from mutable bundle content.
- Keep the private signing key on the publishing machine.
- Pin the expected bundle and channel when the publisher signs them.
- Treat Worker error messages as untrusted text, not HTML.
- Do not put secrets in bundle paths or URLs.
Questions
Do I need to change my server?
No application server is required. The publisher writes a folder of static files (latest, manifest/, chunk/) and any static web server or CDN can serve it. Your page also needs the public key available over HTTPS.
What happens if a CDN serves an old copy?
The signed pointer carries a sequence number. A lower sequence, or a different pointer at the same sequence, is refused as a rollback, and the last good version stays. A changed chunk fails its content-address check. Neither condition is served with a warning.
Does this replace HTTPS?
No. HTTPS protects the connection; this package lets the page check the release itself. Use both. The public-key URL is a trust root, so serve it from somewhere you control, not from the same mutable mirror as the data.
Does it replace Subresource Integrity or a service worker?
SRI covers a few files whose hashes you put in the HTML at build time. A service-worker cache makes your app shell work offline. This package is for data bundles that change without redeploying the page, and want the page to refuse anything you did not sign.
Can it run without a Web Worker?
The supported browser path is a Worker. syncIndex is the same checking logic without the Worker, with the transport injected, so it also runs in Node for tests and build scripts.
Does it encrypt the data?
No. Signing proves integrity and signer identity, not confidentiality. If you need passphrase-encrypted files, the opt-in @gainratio/browser/seal export writes standard age files. It is separate from bundle verification.
Which browsers work?
Chromium runs the full browser suite. Firefox opens OPFS in CI. Playwright's WebKit refuses OPFS, so the cross-browser test checks the typed opfs-unavailable fallback there; real Safari is not tested. The browser support notes cover the rest.
Is it production-ready?
It is an early release. Only the latest published version gets fixes. @gainratio/browser releases publish from CI with npm provenance, and the exact-Git-commit build checks that a clean rebuild of dist/ matches. See supported versions.
How do I report a security issue?
Use GitHub's private vulnerability reporting for the repository, or email with SECURITY in the subject. Do not open a public issue. Details are on the security page.