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

hugin-crypto

v1.0.0

Published

Cryptographic functions used in hugin.chat — noble (pure-JS) ML-KEM + XSalsa20-Poly1305 over CryptoNote (kryptokrona) key derivation. Runs in Node, Bare, Hermes/React Native.

Readme

hugin-crypto

tests

Cryptographic layer used by hugin.chat. Pure-JS, runs on Node, Bare, and Hermes/React Native — no native modules.

Under the hood:

  • ML-KEM-512 (FIPS-203, @noble/post-quantum) for the post-quantum key exchange.
  • XSalsa20-Poly1305 (@noble/ciphers) for symmetric authenticated encryption — wire-compatible with libsodium crypto_secretbox_easy.
  • CryptoNote ECDH (kryptokrona-utils) for the outer per-message key derivation from the recipient's xkr view key.
  • Ed25519 signatures (via kryptokrona-utils) so every plaintext friend request is authenticated by the sender's spend key.

Protocol

Every private message is an ephemeral, signed friend-request-shaped box — a fresh one-time key per message gives an unlinkable view tag on the wire. Three states layer on top:

| State | What goes out | Encryption | |-|-|-| | Initial (no shared secret yet) | kem_pub in the outer plaintext | Outer only (ECDH-derived key) | | First reply (peer's kem_pub seen) | kem_ct + inner-encrypted body | Outer and inner (ML-KEM-derived key) | | Established | Inner-encrypted body only | Outer and inner (double-encrypt) |

The kem_pub and kem_ct are carried inside the outer secretbox, never on the wire itself — a passive observer sees only {box, t, txKey, vt}.

Handshake

Alice (initiator)                                 Bob (responder)
─────────────────────────────────────────────────────────────────
createFriendRequest({ myKemPub: alicePub })  ──── wire ────►
                                                  openFriendRequest()
                                                    → handshake.peerKemPub
                                                    → encapsulate(alicePub)
                                                      = { ciphertext, sharedSecret }
                                                    save sharedSecret as messageKey
                                                  ◄──── reply ────
                                                  createFriendRequest({
                                                    messageKey,
                                                    kemCiphertext: ciphertext,
                                                  })
openFriendRequest()
  → handshake.sharedSecret (already decapsulated)
  → outer decrypt → inner decrypt with messageKey

Both sides now hold the same 32-byte messageKey.
Every subsequent message is inner-encrypted with it before the outer wrap.

Once established, an attacker has to break both the per-conversation ephemeral ECDH wrap and the long-lived ML-KEM-derived messageKey to recover plaintext.

Wire shape

encodeExtra(wire)  →  hex( JSON.stringify(wire) )

wire = {
  box:   hex(secretboxEncrypt(outerPlaintext, boxKey, nonceFromTimestamp(t))),
  t:     unix-ms,
  txKey: 32-byte ephemeral CryptoNote public key (hex),
  vt:    2-byte view tag,           // cheap "is this for me" scan
}

boxKey = cn_fast_hash('box' || derivation);
vt = cn_fast_hash(derivation)[0:2] — domain-separated so the tag does not leak key bytes.

API

import {
  createFriendRequest, openFriendRequest,
  encryptMessage, decryptMessage,          // aliases of the above
  encodeExtra, decodeExtra,
  generateKemKeypair, encapsulate, decapsulate,
  messageHash,
  KEM_PUB_BYTES, KEM_CIPHERTEXT_BYTES, KEM_SHARED_BYTES,
} from 'hugin-crypto';

Long-lived identity

Each device holds one long-lived ML-KEM keypair. Persist both halves; expose the public key to the friend-request path and the secret to the decrypt path.

const identity = generateKemKeypair();
// { publicKey: 800 bytes, secretKey: 1632 bytes }

Sending: Alice → Bob (initial)

const wire = await createFriendRequest({
  message: 'hi',
  sender: { address: aliceAddress, privateSpendKey: alicePrivSpend },
  toAddress: bobAddress,
  name: 'alice',
  myKemPub: identity.publicKey,   // include on every send until we hold messageKey
});

const extra = encodeExtra(wire); // hex(JSON) — ready for the wire

Receiving

const wire = decodeExtra(extraHex);
const result = await openFriendRequest(
  wire,
  {
    privateViewKey: bobPrivView,
    getMessageKey: async (fromAddress) => {
      // Return the 32-byte shared secret for this contact, or null.
      const contact = await loadContact(fromAddress);
      return contact?.messageKey ?? null;
    },
  },
  identity.secretKey,             // for auto-decapsulation of kem_ct
);

if (!result) return;              // view-tag miss, or auth/decrypt failure
// result: {
//   type: 'friend-request' | 'message',
//   from, msg, name, t, verified,
//   handshake?: { peerKemPub?, sharedSecret? }
// }

// Persist handshake progress BEFORE saving anything else:
if (result.handshake?.peerKemPub) {
  // Peer's initial → we encapsulate against it.
  const { ciphertext, sharedSecret } = encapsulate(result.handshake.peerKemPub);
  await saveContactKemState(result.from, {
    messageKey: sharedSecret,
    pendingKemCapsule: ciphertext,   // ship on our next outgoing send
    peerKemPubHex: uintToHex(result.handshake.peerKemPub),
  });
} else if (result.handshake?.sharedSecret) {
  // Peer's first reply — hugin-crypto already decapsulated for us.
  await saveContactKemState(result.from, { messageKey: result.handshake.sharedSecret });
}

Sending: Alice → Bob (established)

const state = await loadContactKemState(bobAddress);

const wire = await createFriendRequest({
  message: 'second',
  sender: { address: aliceAddress, privateSpendKey: alicePrivSpend },
  toAddress: bobAddress,
  messageKey: state.messageKey,               // triggers inner encryption
  kemCiphertext: state.pendingKemCapsule,     // included on first reply only
});

Clear pendingKemCapsule after a successful send.

Detecting a peer that restored / rotated

onReceivedPeerKemPub (or your equivalent) should compare the incoming kem_pub to the one you cached on the contact row:

  • No stored pub → first handshake, encapsulate and persist.
  • Stored pub matches incoming → in-flight duplicate (peer re-broadcasts their pub on every message until they hold a messageKey) — keep the existing messageKey; do NOT re-encapsulate or you will silently desync.
  • Stored pub differs from incoming → peer restored their account and has a fresh ML-KEM keypair. Discard the old messageKey, re-encapsulate against the new pub, ship the new kem_ct on your next send.

Content-addressed message id

messageHash(extraHex)   // SHA-256 of the wire bytes → 64 hex chars

Both sides derive the same id from the same wire — no random id needs to ride along, dedup is hash-keyed everywhere.

Constants

| | Bytes | Hex chars | |-|-|-| | ML-KEM public key | 800 | 1600 | | ML-KEM secret key | 1632 | 3264 | | ML-KEM ciphertext | 768 | 1536 | | ML-KEM shared secret | 32 | 64 | | secretbox key | 32 | 64 | | secretbox nonce | 24 | 48 | | secretbox MAC | 16 | 32 |

Build / test

npm install
npm run build   # tsc -> dist/
npm test        # node --test

No native modules — no Python, no node-gyp, no platform-specific build step.

License

GPL-3.0-or-later