Security

Integrity, with explicit boundaries.

This package deliberately performs network and browser-storage I/O. The useful review is not “is it encrypted?” but “what exactly does a successful sync prove, what is refused, and what is still the operator's job?”

What a successful sync checks

The security boundary is a signed, monotonic pointer. The Worker fetches a pinned trust root (a public key or keyring) without HTTP-cache reuse, verifies the pointer, content-addresses the manifest, bounds compressed and expanded bytes, verifies every chunk and reassembled file, then promotes last.

Invalid bytes never become a degraded result. Any failure throws a typed error and the previous good version stays. A network outage is the only condition that may serve cache, and it has its own type (NetworkError) for exactly that reason.

What is refused, never used with a warning

The documented refusal table is in the documentation. The security-relevant categories are:

ConditionResult
Signature does not verifySignatureError; nothing is promoted.
Signer is revoked or not in the keyringKeyRevokedError or UnknownKeyError; no fallback to other keys.
Trust root is malformedKeyringError; it verifies nothing.
Network pointer is past its signed expiryPointerExpiredError.
Chunk or file content address does not matchIntegrityError; the bad cached row is deleted.
Response exceeds its byte capResponseTooLargeError, an IntegrityError on purpose, so sync never falls back to cache for it.
Sequence went backwards, or a different pointer appeared at the same sequenceRollbackError.
Bundle exceeds a structural capSyncCapError.

Chunk transport failures classified as NetworkError get six bounded retries with exponential jitter. Integrity, signature, storage, and rollback failures are verdicts and are never retried.

What it cannot protect

Not protectedWhy
A compromised page or browser extensionIt runs with your page's rights. The Worker cannot defend code that can read or rewrite what the page shows.
An attacker who controls the public-key URLWhoever controls the trust root controls trust. Serve it over HTTPS from a location you control, separately from mutable bundle content.
A stolen signing key before you revoke itUntil the key is revoked and a higher sequence is published, a signature from that key is a valid signature.
What your app does with checked dataThe package returns bytes. Parsing, rendering, authorization, and business logic are yours.
A compromised device or browserThe threat model does not include an attacker with control of the device or the browser's code.
Secrecy of the dataSigning is not encryption. The bundle is plaintext. The opt-in /seal export handles passphrase-encrypted files separately.
A secure clockExpiry uses the device clock unless a now() is injected. A slow clock can accept a pointer past its deadline; a fast one refuses early. Expiry bounds replay windows; it is not a secure time source.

Trust root, rotation, revocation, and expiry

The configured URL serves either exactly 32 bytes (a raw Ed25519 public key, treated as a keyring of one) or a strict edgeproc.keyring/v1 JSON keyring. A keyring is size-capped at 64 KiB before decoding; it rejects unknown fields, duplicate ids, malformed hex, and any key_id that is not the first 16 hex characters of SHA-256 of its key. It must leave at least one key unrevoked. A malformed trust root verifies nothing.

Rotation

Publish a keyring listing old key A and new key B, then sign new pointers with B and a key_id naming B at a higher sequence. Clients keep verifying A-signed caches while A is listed and unrevoked. Extending an expires_at or switching signer for the same release also needs a new sequence: the persistent stores refuse to promote a pointer that differs from the active one in any signed field at the same sequence.

Revocation

Listing a key id in revoked makes every signature by that key fail. A pointer naming it fails with KeyRevokedError; a pointer without key_id is accepted only under unrevoked keys. A cached bundle whose pointer was signed by a now-revoked key is refused for offline serving, yet that pointer remains the anti-rollback floor. Revoking a key never lets an older release back in.

Unknown signer

A pointer whose key_id is not in the keyring fails with UnknownKeyError. There is no fallback to other keys.

Expiry as freeze/replay defense

expires_at is signed Unix seconds. A network-fetched pointer is refused with PointerExpiredError once now >= expires_at, checked after its signature verifies. A publisher using expiry must re-sign, with a higher sequence, before the deadline, or every online client stops updating.

Offline-expired policy

When the origin is unreachable, sync serves the already-verified cached bundle even if its pointer has expired, and marks the result expired: true. Refusing would brick offline apps that hold intact, authentic bytes; the flag lets the application tell the user the data may be stale. Applications that must never show expired data should treat expired: true as a failure.

The floor survives key changes

The stored active pointer is the floor even when the currently pinned key cannot verify it, after a key rotation or a swapped key. A key change never resets the floor: an older release re-signed by a new key is refused as a rollback. The floor only refuses; the cached bundle is served offline only under a signature the current trust root verifies.

Single-verifier callers. Code that calls syncIndex with its own verify function keeps its behavior. key_id is covered by the signature but cannot select a key on that path; expires_at is still enforced.

Local storage is untrusted durable state

The SQLite chunk database on OPFS holds chunks, manifests, the active pointer, and the rollback floor. It is treated as untrusted durable state, not as a trusted cache:

Cache names and legacy (0.2.x) IndexedDB layouts are local identifiers, not secrets. Layout input is bounded and cannot select arbitrary code or callbacks. The layout is read once, to migrate a 0.2.x cache; the library never writes to IndexedDB.

Integration rules

  • Pin the expected bundle and channel when the publisher signs those identities. undefined deliberately skips a pin; null requires a legacy absent/null field exactly.
  • Treat the configured public-key URL as a trust root. Serve it over HTTPS and control it separately from mutable bundle content.
  • Keep the private signing key on the publishing machine, never in the browser or on the web server.
  • Do not put secrets in bundle paths or URLs.
  • Worker error messages can include URLs or producer-controlled identifiers. Do not render them as HTML.
  • The network sentinel is evidence about requests, not an access-control mechanism. Same-origin channel messages are shape-checked but are not treated as authenticated actors.
  • Applications that must never show expired data should treat expired: true as a failure.
  • Use cacheFallback: "none" if the app must not run without persistent storage.

Supply chain and release checking

@gainratio/browser releases publish from CI with npm provenance, which npm audit signatures checks. 0.1.0, published under the old name @edgeproc/browser, was a hand-published bootstrap and carries no npm provenance; that name is deprecated. For an exact-Git-commit install, the build refuses if a clean rebuild of dist/ differs from the committed output (pnpm verify:dist).

Runtime dependencies are @noble/ed25519, @hpcc-js/wasm-zstd, and, for the opt-in /seal export only, age-encryption. The chunk store and the optional SQLite subpaths share pinned local WASM assets and do not load code from a CDN.

Supported versions

This project is pre-1.0. Only the latest published version gets fixes. Version 0.4.4 is current. Once 1.0 ships, the latest minor of the current major will be supported as well.

Report a vulnerability

Please do not open a public issue for a security problem. Report it privately in one of two ways:

  1. Preferred: use GitHub's private reporting. Open a draft advisory in the repository's Security tab. This keeps the discussion in the repo and lets the maintainers issue a CVE if one is warranted.
  2. Email harish.seshadri@gmail.com with SECURITY in the subject.

Please include what you can: the version, a description of the problem, and the smallest input that reproduces it. Use non-sensitive data in the reproduction; do not include private keys, production payloads, or other secrets.

The project aims to acknowledge a report within 5 working days and to ship a fix, or an explanation of why it is not a vulnerability, within 30 days.

Read next

Documentation · Examples · SECURITY.md · Architecture