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

nostr-contact-card

v0.4.0

Published

One QR or link that makes a stranger a contact, names their box, and starts a bond. Build, sign, read and refresh contact cards; verify the Link address card inside.

Downloads

876

Readme

nostr-contact-card

CI npm licence: MIT GitHub Sponsors

One QR or link that makes a stranger a contact, names their box, and starts a bond.

A contact card is a signed Nostr event of kind 21641, in the ephemeral range and never posted to a relay, carried after # in a link or as the whole of a QR code. The kind is ephemeral so that a relay which meets a card by mistake does not store it: a card carries a rendezvous key, a fresh ephemeral and a bond nonce, and a leaked one should not sit on a relay for thirty days. The event's pubkey is the person's key, its created_at is when the card was issued, its expiration tag is when it lapses, and its content is the card, so the signature is the one every signer already makes: an extension, a bunker or a key held locally all make the same card. It holds a person's key and name, their public relays, their box (a Link address card, carried opaquely, that the person's signature endorses), a rendezvous key and a fresh ephemeral for deriving private rendezvous material, an optional attestation pointer, and a bond handshake so an in-person ceremony can start from the card.

Reading a card needs nothing from the network: five checks, in order, and the result names the step that failed so the client can say why. What comes back is rebuilt from the fields the draft names, each checked; an extra key on the wire never reaches the caller under a verified signature. Dialling the box afterwards is on the person's own word; a later fresh address from the box is accepted only under the node id they endorsed, which the client pins from the first read (the Link card inside a contact card lasts seven days, the card itself thirty, so a client keeps the node id it read).

import { buildCard, buildCardWith, cardLink, readCard, refreshBox } from 'nostr-contact-card'

const card = buildCard({ identityPrivateKey, rz, ephemeralPrivateKey, name: 'Ada', relays, boxes: [box], bond })
// or, through a signer that holds the key (NIP-07, NIP-46):
const viaSigner = await buildCardWith(pubkey, (unsigned) => signer.signEvent(unsigned), { rz, ephemeralPrivateKey, name: 'Ada', relays, boxes: [box] })
const link = cardLink('https://your.app/join', card)     // the server never sees the fragment

const r = readCard(scannedText, now())
if (!r.ok) show(r.step, r.reason)
else { addContact(r.card.p, r.card.name); for (const b of r.boxes) dial(b.link, b.box.p) }

const fresh = refreshBox(pinnedNodeId, freshLinkCardBytes, now(), highestSerialSeen)

Vectors in vectors/contact-card.json are the draft's known-answer file: thirty-two cards failing at each step, wrong kind and version, extra tag, extra keys on the event and inside the signed content, tampered fields and times, a foreign key, format characters, surrogates, byte-order marks, empty fields and expiry windows, and six refresh cases including a small-order node id and a nonce point carrying torsion. A second implementation, written from the draft alone, must agree on every one; the vectors carry the expected ok and step, and a verifier that disagrees on either is wrong.

What a card carries

More than two keys: the person's key p, their rendezvous key rz, a fresh ephemeral, each box's key and Link node id (and so its onion address), a display name, and a bond nonce that is a bearer secret for the card's life: whoever holds the link can start the ceremony. The fragment never reaches the server, but it does sit in browser history, clipboards, chat logs and photographs of QR codes. Hand a card to the person it is for.

Whoever holds it cannot derive rendezvous material from it, because the derivation (in nostr-deaddrop) needs the reader's own secret or the ephemeral's private half, and a card carries neither. It is never identity: the box still decides admission, and a bond still needs the ceremony.

Security notes

  • readCard returns only the named fields, every hex field lower-cased (bond and persona keys included), bond canonicalised. Names, display names and persona labels pass isGoodName: 1 to 100 code points, at least one visible, no control, surrogate, unassigned or private-use character, no separator but an ordinary space and none at the ends, no invisible or direction-changing format character, no run of five combining marks; the joiners and tag characters emoji need are allowed only beside a pictographic character. Empty strings are refused, because an empty name or attest would hash the same as none.
  • The wire is decoded as strict UTF-8: a byte sequence that is not UTF-8, or a leading byte-order mark, is not a card. Nothing decodes to U+FFFD.
  • Link cards are verified as libsodium and ed25519-dalek's verify_strict do: canonical encodings only (no ZIP-215), a node id or nonce point of small order refused, S below the group order, and the cofactorless equation [S]B = R + [k]A. noble's own verify multiplies by the cofactor and would accept a node id or nonce carrying torsion that a strict verifier refuses, so the check is done by hand (verifyStrict).
  • Relay hints and card relays pass isRelayUrl: wss://, a URL with a DNS name or IP literal as host, no credentials, no fragment, no comma, no unprintable character, no byte-order mark. Onion hints are a 56-character base32 host with a non-zero port. An ephemeral hint (kind 0x04) has a compressed-point prefix. Kind 0x02 and unknown kinds come back in hints exactly as the box wrote them; only relays and onions are checked views. A serial above 2^53 is refused under rule 3, before the signature.
  • now must be a finite number of seconds and highestSerial an integer or absent. Anything else fails closed; it never switches a check off.
  • The signature is the event's, over its NIP-01 id, and the content is signed byte for byte as carried. A key the draft does not name never reaches the caller, whether it sits on the event outside the signature or inside the signed content. The event carries exactly one tag, expiration; a second tag is refused before the signature is looked at. buildCardWith refuses a signer that returns anything but the event it was asked to sign.
  • Hex case in the id and signature is normalised and base64url padding tolerated, so one card has several wire forms. Anything that caches or deduplicates on the encoded string must key on the card's fields, not its bytes.
  • The size cap applies to the card, not the link it rides on. A contact card's issued may be up to 300 seconds ahead of now, and expires must be after issued.
  • Replay protection for a box's fresh Link cards is rule 8, and rule 8 needs the highest serial this client accepted for that node id. The library pins nothing: persist link.serial per node id and pass it to refreshBox; without it any unexpired old card of the same node is accepted.

What remains

In the profile's own words, from its table of what remains: a card is trusted on first use. An impostor who hands you a card is an impostor you have a contact for; what narrows that is not in this library but in how many independent channels confirm the key, shown as a count and never as a score, and a bond ceremony spoken live. Whoever holds a card holds its bond nonce, a bearer secret for the card's life.

Licence

MIT. ForgeSworn.