npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chiljs/crypto

v0.4.0

Published

Optional end-to-end encryption for CHIL. ECDH P-256 + HKDF + AES-GCM, recipient key held non-extractable in IndexedDB.

Readme

@chiljs/crypto

Optional end-to-end encryption for CHIL. The sender seals the upload to a key the server never sees; only the device that issued the handoff can open it.

// Requester — key generated once, kept in IndexedDB.
const recipient = await createRecipient({ scope: room });
useHandoffSession({ mint, transport, recipient }); // public key goes in the URL fragment

// Sender — reads the fragment, seals inside send().
const key = keyFromFragment();
useUploadSession({
  token,
  transport,
  seal: key ? await createSealer(key) : undefined,
  requireSeal: true, // a stripped fragment must fail, not downgrade to plaintext
});

// Dashboard.
const file = await recipient.decrypt(downloaded);

ECDH P-256 → HKDF-SHA256 → AES-256-GCM. The sealed blob is self-describing, so a queue can hold sealed and unsealed items at once and decrypt passes plaintext straight through — you can roll it out, and roll it back, without stranding anything.

Why the fragment

A browser never sends #… to the server. The key reaches the sender in the URL itself, so the server that stores the ciphertext never learns it. Put the same value in the query string and the encryption is decorative.

What this protects against, and what it does not

Does: storage compromise, backups, an untrusted object store or host, a subpoena on data at rest.

Does not: a compromised application server. The server that stores the ciphertext also ships the JavaScript that encrypts it. Swap the bundle, exfiltrate the key. This is the standing limit of browser-delivered E2E and no library fixes it.

So it pays when storage is a different trust domain from the application. When they are the same host, it mostly buys ceremony — and costs you the checks below.

What it costs

A format allowlist has to go — swap it for sealedOnly(). Ciphertext has no image magic bytes, so checking for JPEG rejects every upload; content validation and E2E are contradictory by definition. But a sealed blob does carry this library's own magic, so inspect: sealedOnly() still buys you something better than nothing: your server's guarantee changes from "this is a JPEG" to "this is sealed and under N bytes", and a plaintext upload from a page that lost its key is refused rather than stored.

Understand what that check is worth. It reads a magic signature the uploader supplied, so it is trivially forgeable: anyone holding a valid token can prepend those bytes to arbitrary data and pass. It catches the accident — a page whose key was stripped in transit — not an attacker. The guarantee is asserted by the uploader, not verified by the server, that is beyond the scope of this library.

Raise maxLabelLength to 512. A sealed label carries an 85-byte header and a 16-byte tag before base64url, so a 12-character name arrives as ~150 characters. Leave the default of 120 and the server truncates it, which surfaces much later as corrupt.

<img src> stops working. Every image becomes fetch → decrypt → object URL, and Cache-Control no longer helps. useDecrypted in @chiljs/react makes it a one-liner, but it is still a change to your components.

How long the key survives — read before shipping

The private key is generated non-extractable and stored in IndexedDB as a CryptoKey handle, so it never exists as bytes in JavaScript. Script on the page can use it while the page is open and cannot export it: an XSS becomes a session-long problem rather than a permanent key compromise. That is also why there is deliberately no backup, export or escrow — all three need an extractable key.

The consequence is that key loss is real and silent. A device whose key is gone keeps working — a fresh keypair is generated on demand and new uploads seal to it — but anything already queued under the old key is unreadable for good.

Survives: reload, tab close, browser restart, device reboot, and any amount of server-side redeployment. None of this lives on the server.

Lost to:

| Cause | Notes | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Clearing site data | The realistic one, and the worst — it can happen mid-shift with items queued. | | Replaced, reset or reimaged device; another browser profile | Nothing recovers this. | | Private / incognito browsing | The store dies with the window. | | Safari ITP eviction | All script-writable storage is erased after seven days without user interaction with the site. A tracking countermeasure, not a storage policy; Chrome and Firefox do not do it. | | Storage pressure | Browsers evict whole origins when the disk fills. requestPersistence() asks for exemption and is usually granted to a site with real interaction history. It does not exempt you from ITP. |

The ITP case sounds the most alarming and costs the least: the clock is reset by someone opening the page, so a kiosk in daily use never approaches it, and a device idle for seven days has an empty queue anyway if your retention is shorter than that.

Loss cannot be prevented from here, so the design makes it legible instead: decrypt reports no-key, wrong-key, corrupt and unsupported-version as distinct reasons, and canDecrypt answers without attempting, so a list view can say "sealed to a key this device no longer has" rather than showing a broken image.

Scope

One key per tenant or room — createRecipient({ scope: room }). Two devices sharing one set of credentials will not be able to read each other's uploads; the device becomes part of the identity.

Nothing expires a key. A device that has served ten rooms holds ten private keys, each still able to open what was queued under it — on shared, rented or resold hardware those are keys to other people's uploads. Purge them when the scope changes, when it drops, and on sign-out:

const store = indexedDbKeyStore();
for (const scope of await store.scopes()) {
  if (scope !== room) await store.remove(scope);
}

Which scopes are stale is yours to decide, not this library's: a device legitimately serving two rooms at once must not lose one. Where the current scope is unknown — a device sitting in stock — StoredKey.createdAt is the only handle left, and dropping everything past some age is the honest policy.

Deleting is as final as losing: purge a scope with items still queued under it and they are unreadable for good.

Full documentation: the repository README.

MIT.