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.

What changes
  • 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.

PublishA signed pointer, a file list, and zstd-compressed pieces.
FetchPointer, manifest, and only the chunks not already stored.
CheckEd25519 signature, then SHA-256 for every chunk and file.
PromoteOne SQLite transaction replaces the active release.
Signature first

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.

Content-addressed

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.

Rollback floor

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.

Offline

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.

Fail closed

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.

Measurable

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
Always pass --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.

edgeproc.worker.jsWeb Worker entry
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.

main.jsmain thread
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}`;
}
index.htmlthe page
<!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.

check.mjsrun with node check.mjs
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}`));
outputexpected
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.

Trust boundaries
  • 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.

Read next

Documentation

Install, API, errors, limits

The Worker setup, the checking core, keyrings, SQL, vectors, seal, browser support, and the refusal table.

Open the docs

Examples

Copyable, runnable snippets

Vite sync, Node verification, key rotation, cache fallback, SQL, vectors, seal, and tamper detection.

Browse examples

Security

Threat model and reporting

What is checked, what is refused, what is out of reach, key lifecycle, and how to report a vulnerability.

Read security