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.
- The trust root is fetched with
no-store, capped at 64 KiB before decoding, and parsed strictly. - The pointer's Ed25519 signature verifies over canonical JSON bytes under the configured key or keyring.
- The pointer's
sequenceis non-negative and has not gone backwards or forked. - The manifest matches the content hash the pointer named.
- Every chunk matches its SHA-256 content address and its signed decompressed size, after a size-bounded decompression.
- Every reassembled file matches its signed length and SHA-256.
- A revoked or unlisted signer, and a network pointer past its signed
expires_at, are refused. - Promotion is one transaction. A pointer can never name a missing chunk.
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:
| Condition | Result |
|---|---|
| Signature does not verify | SignatureError; nothing is promoted. |
| Signer is revoked or not in the keyring | KeyRevokedError or UnknownKeyError; no fallback to other keys. |
| Trust root is malformed | KeyringError; it verifies nothing. |
| Network pointer is past its signed expiry | PointerExpiredError. |
| Chunk or file content address does not match | IntegrityError; the bad cached row is deleted. |
| Response exceeds its byte cap | ResponseTooLargeError, an IntegrityError on purpose, so sync never falls back to cache for it. |
| Sequence went backwards, or a different pointer appeared at the same sequence | RollbackError. |
| Bundle exceeds a structural cap | SyncCapError. |
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 protected | Why |
|---|---|
| A compromised page or browser extension | It 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 URL | Whoever 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 it | Until 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 data | The package returns bytes. Parsing, rendering, authorization, and business logic are yours. |
| A compromised device or browser | The threat model does not include an attacker with control of the device or the browser's code. |
| Secrecy of the data | Signing is not encryption. The bundle is plaintext. The opt-in /seal export handles passphrase-encrypted files separately. |
| A secure clock | Expiry 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.
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:
- Every chunk is re-hashed on read. A mismatch throws
IntegrityErrorand deletes the bad row, so the next sync fetches it again. - A trigger refuses any update that lowers the floor (
rollback floor may not decrease). Equal-sequence disagreement fails closed. - Promotion is one transaction. Chunks are written in bounded batches, then
promote()checks every needed chunk is present and the floor does not go backwards before writing the pointer. - Migrated 0.2.x content is checked by content address before it is copied, and the migrated floor is the highest legacy sequence, never lower.
- When OPFS is refused, the cache is an in-memory SQLite database and the rollback floor lasts only for that Worker's life. Sync results report
cacheBackend: "sqlite-memory".
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.
undefineddeliberately skips a pin;nullrequires 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: trueas 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:
- 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.
- Email harish.seshadri@gmail.com with
SECURITYin 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.