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

@zafu/zid

v0.2.4

Published

zafu identity SDK - connect wallet, sign actions, post-quantum e2ee channels and messaging

Downloads

387

Readme

@zafu/zid

The zafu identity SDK for websites. Let a visitor log in with their zafu wallet, sign server challenges, encrypt messages with post-quantum protection, and open end-to-end-encrypted channels - without your site ever touching a private key.

npm install @zafu/zid

Two ways to use it

  • Functional helpers (one-shot, typed errors) - requireWallet() gives you a wallet handle, then sign, zidPubkey, encryptFor, decryptFrom each do one thing and throw a typed ZafuError on failure. Best for login and encrypting a message.
  • Session object - zid.connect() returns a me with me.channel() and me.pickContacts(). Best for a live session: real-time encrypted channels and the wallet's contact picker.

You can mix them. Most sites need only the functional helpers.

1. Detect (decide what to render)

detect() never throws - use it before showing any UI.

import { detect } from '@zafu/zid';

const d = await detect();
if (!d.installed)
  renderInstallLink(); // no zafu wallet
else if (!d.compatible)
  renderUpdatePrompt(); // wallet speaks a protocol this SDK doesn't
else renderLoginButton();

2. Log in with zafu

Server issues a fresh nonce, the wallet signs it, your server verifies the signature. requireWallet() returns a handle or throws ZafuError('unavailable' | 'incompatible'); sign() throws ZafuError('denied' | 'locked' | ...).

import { requireWallet, sign } from '@zafu/zid';

const wallet = await requireWallet();

// challengeHex is a fresh, server-issued nonce (hex). See the security note below.
const { signature, publicKey } = await sign(wallet, challengeHex, 'Sign in to example.com');

// send { signature, publicKey } to your server; it verifies the ed25519 signature
// over the challenge bytes against publicKey, then treats publicKey as the account id.
  • publicKey is site-scoped: the same user gets a stable pubkey on your origin (use it as their account id) and a different one on every other site - so identities are not linkable across sites.
  • signBytes(wallet, bytes, statement?) is the same call if your challenge is raw bytes rather than hex.

Security (important): the wallet signs only the challenge bytes - it does not bind your origin into the signature. So your server MUST issue a fresh random nonce that also commits to your origin/audience (SIWE-style), and never a static string. A signature over challenge C is valid at any site that presents the same C.

3. Post-quantum encryption

encryptFor automatically uses the recipient's hybrid X25519 + ML-KEM-768 key when their wallet advertises one, so recorded ciphertext stays confidential even against a future quantum computer (harvest-now, decrypt-later); it falls back to classical for older wallets.

zidPubkey(wallet) returns your own site-scoped keys - publish them so others can encrypt to you. To encrypt to someone, you need their keys (their zidPubkey output, shared with you out of band).

import { requireWallet, zidPubkey, encryptFor, decryptFrom } from '@zafu/zid';

const wallet = await requireWallet();

// your keys - hand these to peers so they can message you:
const mine = await zidPubkey(wallet); // { pubkey, pq_pubkey?, pq_suite? }

// encrypt TO a peer using THEIR keys (obtained from them):
const sealed = await encryptFor(wallet, theirKeys, new TextEncoder().encode('gm'));
sealed.postQuantum; // true when the PQ path was used

// decrypt a message that was addressed to you:
const plaintext = await decryptFrom(wallet, sealed); // Uint8Array
// `sealed` is { ciphertext, ephemeral_pubkey } - pass both back to decrypt.

sealed.postQuantum reflects what the wallet actually did. To refuse sending anything the post-quantum path didn't cover (recipient has no PQ key, or their wallet is too old), pass { requirePq: true } and encryptFor throws ZafuError('not_available') instead of falling back to classical:

await encryptFor(wallet, theirKeys, bytes, { requirePq: true }); // fail closed

4. Encrypted channels (session)

zid.connect() authorizes a session; me.channel(peerPubkey) opens a hybrid post-quantum Noise IK channel over a relay (ML-KEM-768 mixed into the handshake, ephemeral keys for forward secrecy).

import { zid } from '@zafu/zid';

const me = await zid.connect({ appName: 'example.com' });
const ch = await me.channel(peerPubkey);
ch.on('message', bytes => console.log(new TextDecoder().decode(bytes)));
ch.send('hello, privately');

5. Contacts (session)

me.pickContacts() opens the wallet's contact picker and returns app-scoped opaque handles (unlinkable across apps) - your site never sees the user's social graph. Handles are for display and identity, not encryption keys.

const picked = await me.pickContacts({ purpose: 'invite a friend', max: 1 });
// [{ handle, displayName }]

6. Works without the wallet (guest identity)

If no zafu wallet is installed, zid.connect() returns a guest identity instead of failing. It is not a degraded mode: from one in-page seed it derives an ed25519 identity and an X-Wing (X25519 + ML-KEM-768) key, so the same calls work with or without the wallet.

const me = await zid.connect(); // wallet if present, guest otherwise

const keys = me.keys?.(); // { pubkey, pq_pubkey, pq_suite, pq_sig, pq_epoch, origin }
const sealed = await me.sealFor?.(theirKeys, bytes); // post-quantum sealed box
const opened = await me.openSealed?.(sealed); // Uint8Array
const ch = await me.channel(theirPubkey); // hybrid post-quantum channel by default
  • keys() mirrors zidPubkey(), and sealFor / openSealed mirror encryptFor / decryptFrom (same field names, bytes instead of the wallet wire's base64/hex). sealFor/openSealed are present in mode: 'zafu' too and delegate to the wallet, so the call site does not change with the backend.

  • channel(peerPubkey) uses the hybrid post-quantum Noise IK channel. The handshake is the caller's choice via zid.connect({ channel }):

    | channel | behaviour | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 'hybrid' (default) | post-quantum Noise IK only; fails closed, never downgrades | | 'classical' | the legacy X25519 + AES-GCM channel | | 'auto' | try hybrid, fall back to classical only when the handshake failed; a transport failure (relay down, socket error) propagates |

    The channel you get carries kind: 'hybrid' | 'classical', so an 'auto' caller can see and refuse a downgrade instead of being told nothing; isNoiseHandshakeFailure(err) is the same test the fallback uses.

    A peer still on @zafu/[email protected] speaks only the classical handshake. The hybrid handshake fails closed against it (different protocol name, never a downgrade), so a default-mode call to such a peer will not open. Reach it with { channel: 'classical' }, or { channel: 'auto' } to accept the downgrade knowingly. There is no silent fallback in either direction.

  • The post-quantum prekey is authenticated exactly as on the wallet path: a recipient that advertises pq_pubkey must carry a valid pq_sig, or sealFor refuses rather than silently downgrading.

Custody - read this. The guest is burner-grade. By default the seed lives in memory for the page and is gone on reload. zid.connect({ persist: 'local' }) stores it in localStorage so the identity survives reloads - still no backup, no recovery, and readable by any script on the origin. Treat it as a throwaway handle, not the user's keys.

Contact discovery without the wallet

The guest derives its own contact card (suite: 'x25519-v1') and the pairwise root secret locally, then rides the same relay path as the wallet:

const me = await zid.connect({ relayEndpoint: 'https://relay.example/zid' });

me.contactCard?.(); // hand this to a peer
me.deriveRootSecret?.(peerCard); // pairwise secret from a card
me.establishSecret?.(peerPubkey); // or: establish once for a stored contact
const present = await me.discover?.(peers); // DiscoveredContact[]

createHttpRelayTransport({ endpoint, fetch?, headers? }) is the HTTP relay client; its module doc is the exact wire contract a server must implement. The server MUST return the whole bucket for a coordinate and MUST NOT offer per-tag lookup - that invariant is the privacy property.

Composition (services & filters)

zid's seams are also Services, so they compose with @zafu/service the way docs/services-pattern.md describes (Eriksen, "Your Server as a Function"):

import { compose, retry, timeout, trace } from '@zafu/service';
import { walletService, walletStrategy, channelService, sealingFilter } from '@zafu/zid';

// a named, pre-composed stack around the wallet I/O leaf:
const wallet = walletStrategy('patient')(walletService(transport)); // trace + timeout + retry

const send = channelService(channel); // Service<Uint8Array, void>

// an end-to-end encrypted request path over any byte service:
const encrypted = compose(
  trace({ onComplete: log }),
  timeout(5_000),
  sealingFilter(keys), // seals the request, opens the sealed response
  retry({ attempts: 3 }),
)(relayService);

compose is left-to-right with the first-listed filter outermost, and filters never change request/response shapes. Note channelService/callSignalService resolve when a frame is handed to the transport - ZidChannel.send is fire-and-forget, so there is no delivery ack to await.

Errors

Every functional helper throws a typed ZafuError with a .code you can branch on, instead of failing silently:

import { ZafuError } from '@zafu/zid';

try {
  const wallet = await requireWallet();
  await sign(wallet, challengeHex, 'Sign in');
} catch (e) {
  if (e instanceof ZafuError) {
    switch (e.code) {
      case 'unavailable': // no wallet reachable
      case 'incompatible': // wallet speaks a different protocol major
      case 'locked': // ask the user to unlock
      case 'denied': // user declined
      case 'rate_limited':
      case 'not_available': // feature disabled in wallet settings
      case 'transport_error': // could not reach the wallet
      case 'wallet_error': // anything else the wallet reported
    }
  }
}

API

| Function | Purpose | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | detect(zafu?) | Feature-detect + version-negotiate. Never throws; returns { installed, compatible, walletVersion, protocolVersion, protocolVersions }. | | detectZafu() | Return the raw wallet handle (or null), without throwing - the low-level counterpart to requireWallet. | | requireWallet(zafu?) | Resolve a compatible wallet handle, or throw ZafuError('unavailable' \| 'incompatible'). | | sign(wallet, challengeHex, statement?) | Sign a challenge with the site-scoped ZID ed25519 key → { signature, publicKey }. | | signBytes(wallet, bytes, statement?) | As sign, hex-encoding the bytes for you. | | zidPubkey(wallet) | Your site-scoped keys → { pubkey, pq_pubkey?, pq_suite? }. | | encryptFor(wallet, recipient, bytes, opts?) | Seal to a recipient's keys (auto post-quantum) → { ciphertext, ephemeral_pubkey, postQuantum }. opts.requirePq fails closed if the PQ path wasn't used. | | decryptFrom(wallet, sealed) | Open a sealed box addressed to you → Uint8Array. | | zid.connect(opts?) | Authorize a session → me with channel(), pickContacts(), sign(). In mode: 'ephemeral' (no wallet) me also carries keys(), sealFor(), openSealed(), contactCard(), deriveRootSecret(), establishSecret(), discover(). | | createGuestIdentity(opts) | Build the ephemeral (no-wallet) identity directly: same crypto surface, from one in-page seed. | | openChannel(session, peerPubkey, relayUrl, mode?) | Open a channel: mode is 'hybrid' (default) | 'classical' | 'auto'; 'auto' falls back only on a failed handshake and labels the result via channel.kind. | | isNoiseHandshakeFailure(err) | Whether a failure was a hybrid HANDSHAKE failure (the only kind 'auto' downgrades on) rather than a transport failure. | | createHttpRelayTransport({ endpoint, fetch?, headers? }) | RelayTransport over HTTP for contact discovery; the module doc is the server wire contract. | | walletService(transport) / walletStrategy(name) | The wallet as a Service<WalletRequest, WalletResponse>, and a named filter stack ('default' | 'patient') around it. | | channelService(channel) | Sending on a ZidChannel as a Service<Uint8Array, void>. | | sealingFilter(keys) | A Filter that seals the request with @zafu/pq and opens the sealed response - an end-to-end encrypted request path over any byte service. |

Transport

The SDK talks to the extension over a pluggable ZafuTransport. createExtensionTransport is the default; swapping it (a relay, a native host) is how the same API reaches a wallet on another surface.

License

MIT