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-double-ratchet

v0.0.135

Published

Nostr double ratchet library

Readme

nostr-double-ratchet (TypeScript)

TypeScript implementation of end-to-end encrypted Nostr messaging using Double Ratchet, multi-device owner/device identity mapping, and sender-key-based group messaging.

Installation

pnpm add nostr-double-ratchet

Core Components

  • Session: low-level 1:1 ratchet session
  • Invite: handshake/bootstrap primitive
  • SessionManager: multi-device session orchestration and routing
  • SessionGroupRuntime: group transport bound to an existing SessionManager
  • DelegateManager / AppKeysManager: device lifecycle and owner authorization
  • NdrRuntime: high-level runtime that owns AppKeys, delegate/session state, and group transport
  • Group: sender-key group messaging helper (transport-agnostic)
  • SharedChannel: encrypted shared-channel primitive used by higher-level group bootstrap flows

Integration Modes

| Mode | Use it when | What it owns | | --- | --- | --- | | NdrRuntime | You want the default production path for direct messages, linked devices, and groups. | AppKeysManager, DelegateManager, SessionManager, and GroupManager. | | SessionManager | You want multi-device routing, but your app still wants to own more of the runtime wiring. | Session orchestration, routing, storage-backed session state, and emitted pubsub/decrypted-message events. | | Session | You want the smallest 1:1 primitive and already own invite/bootstrap, persistence, and relay transport. Good for negotiated 1:1 channels or app-specific direct links. | Only the ratchet session state itself. |

Supporting pieces around those modes:

  • SessionGroupRuntime: add the same group transport surface that NdrRuntime uses to an existing SessionManager.
  • Invite: bootstrap primitive when you build around plain Session.

If you are unsure, start with NdrRuntime. Drop down to SessionManager only when you want to keep more app-owned runtime structure, and use plain Session only when a small 1:1-only surface is actually the goal.

Minimal Integration Contract

Reference web integrations: iris-client, iris-chat.

They use one NdrRuntime singleton over app-owned transport and persistent storage.

type NostrSubscribe = (
  filter: Filter,
  onEvent: (event: VerifiedEvent) => void,
) => () => void;

type NostrFetch = (filter: Filter) => Promise<VerifiedEvent[]>;

type NostrPublish = (
  event: UnsignedEvent | VerifiedEvent,
) => Promise<VerifiedEvent>;

interface StorageAdapter {
  get<T = unknown>(key: string): Promise<T | undefined>;
  put<T = unknown>(key: string, value: T): Promise<void>;
  del(key: string): Promise<void>;
  list(prefix?: string): Promise<string[]>;
}

If you can provide those four pieces, start with NdrRuntime.

Quick Start (NdrRuntime)

import { NdrRuntime } from "nostr-double-ratchet";

const runtime = new NdrRuntime({
  nostrSubscribe,
  nostrPublish,
  nostrFetch,
  storage,
});

await runtime.initForOwner(ownerPublicKey);
await runtime.ensureCurrentDeviceRegistered(ownerPublicKey);

runtime.onSessionEvent((event, from) => {
  console.log(`${from}: ${event.content}`);
});
runtime.onGroupEvent((event) => {
  console.log(`group ${event.groupId}: ${event.inner.content}`);
});

const groupManager = await runtime.waitForGroupManager(ownerPublicKey);
await runtime.sendMessage(recipientPubkey, "Hello!");

const created = await runtime.createGroup("Friends", [recipientPubkey], {
  fanoutMetadata: false,
});
await runtime.sendGroupMessage(created.group.id, "Hello group!");
console.log(groupManager.managedGroupIds());

NdrRuntime does not own your relay client. It still relies on your nostrSubscribe, nostrFetch, and nostrPublish functions.

initForOwner(...) initializes the runtime for a specific owner/device identity. For owner-key logins that should participate in multi-device fanout, call ensureCurrentDeviceRegistered(...) or registerCurrentDevice(...) before treating private messaging as fully ready.

setupUser(peerPubkey) is optional prewarm, not a hard prerequisite. Web consumers call it before opening a chat when they want subscriptions and bootstrap to start early, but sendEvent(...) already calls it internally and sendMessage(...) queues delivery if sessions are not ready yet.

For groups, initialize the runtime/session path before createGroup(...) or sendGroupEvent(...). If you want metadata fanout immediately instead of waiting for queue flush, prewarm peers with setupUser(...).

Runtime Group API

If you want the high-level path, keep groups on NdrRuntime instead of building a parallel group transport layer:

  • setupUser(...)
  • sendEvent(...), sendMessage(...)
  • sendReceipt(...), sendTyping(...)
  • sendChatSettings(...), setChatSettingsForPeer(...)
  • getGroupManager() / waitForGroupManager(ownerPubkey?)
  • onGroupEvent(...)
  • upsertGroup(...), removeGroup(...), syncGroups(...)
  • createGroup(...)
  • sendGroupEvent(...), sendGroupMessage(...)

GroupManager is still available directly when you want to own more of the app wiring yourself.

Plain Session only encrypts/decrypts unsigned inner events. Use messageBuilders to create text, reaction, receipt, typing, reply, and expiring inner rumors before calling session.sendEvent(...).

Reference Web Pattern

Reference web integrations do this:

  1. Create one long-lived NdrRuntime per active identity with persistent storage.
  2. Wrap nostrSubscribe with DirectMessageSubscriptionTracker + buildDirectMessageBackfillFilter(...) so newly added direct-message authors trigger a short relay backfill immediately.
  3. Call initForOwner(ownerPubkey), then ensureCurrentDeviceRegistered(ownerPubkey) or registerCurrentDevice(...) when owner-key logins should participate in multi-device fanout.
  4. Attach onSessionEvent(...) / onGroupEvent(...) once for app lifetime.
  5. Optionally call setupUser(peerPubkey) before opening a chat or creating a group to prewarm subscriptions and bootstrap.

Mid-Level Setup (SessionGroupRuntime)

If your app already owns SessionManager, SessionGroupRuntime gives you the same group-focused API shape that NdrRuntime uses internally:

import { SessionGroupRuntime } from "nostr-double-ratchet";

const groups = new SessionGroupRuntime({
  sessionManager,
  ourOwnerPubkey: ownerPublicKey,
  ourDevicePubkey: currentDevicePublicKey,
  nostrSubscribe,
  nostrPublish,
  nostrFetch,
});

const created = await groups.createGroup("Friends", [recipientPubkey], {
  fanoutMetadata: false,
});
await groups.sendGroupMessage(created.group.id, "Hello group!");

groups.onGroupEvent((event) => {
  console.log(event.groupId, event.inner.content);
});

Low-Level Setup (SessionManager)

If you want multi-device sessions without the runtime wrapper, the lower-level flow remains:

import { AppKeysManager, DelegateManager } from "nostr-double-ratchet";

const delegate = new DelegateManager({ nostrSubscribe, nostrPublish, storage });
await delegate.init();

const appKeysManager = new AppKeysManager({ nostrPublish, storage });
await appKeysManager.init();
appKeysManager.addDevice(delegate.getRegistrationPayload());
await appKeysManager.publish();

await delegate.activate(ownerPublicKey);
const sessionManager = delegate.createSessionManager();
await sessionManager.init();

Multi-Device Integration Contract

Use the exported helper functions for multi-device policy instead of duplicating the logic in app code.

  • applyAppKeysSnapshot(...): order AppKeys by created_at, ignore stale snapshots, and merge same-second snapshots monotonically.
  • evaluateDeviceRegistrationState(...): decide whether the current device is registered and whether the app should consider private messaging ready.
  • shouldRequireRelayRegistrationConfirmation(...): distinguish first-device bootstrap from “add a new device to an existing owner timeline” before blocking on relay visibility.
  • resolveConversationCandidatePubkeys(...): derive the correct conversation owner/device candidates for self-sync and linked-device routing.
  • resolveInviteOwnerRouting(...): preserve inviter owner/device attribution during invite acceptance, including the link-bootstrap exception and device-identity fallback.
  • DirectMessageSubscriptionTracker + buildDirectMessageBackfillFilter(...): detect newly added direct-message session authors and issue a short replay/backfill right away.
  • resolveSessionPubkeyToOwner(...) and hasExistingSessionWithRecipient(...): normalize owner/device session bookkeeping instead of re-implementing user-record traversal.

Normal app behavior should treat AppKeys as an ordered authorization timeline. Reduced AppKeys sets should only be published for explicit revocation or first-device bootstrap, not on ordinary startup. Imported owner-key or nsec logins on a fresh device should either register the current device or remain explicitly single-device. First-device bootstrap can proceed from locally published AppKeys; public-invite fanout for an additional device should wait until relays echo the updated AppKeys timeline.

Tested References

Treat the README as onboarding and the tests as behavioral source of truth for exact call order and edge cases.

  • tests/NdrRuntime.test.ts: canonical runtime init, registration, direct-message, and group flow
  • tests/SessionGroupRuntime.test.ts: group transport attached to an existing SessionManager
  • tests/SessionManager.acceptInvite.test.ts: invite acceptance and owner/device routing rules
  • tests/directMessageSubscriptions.test.ts: direct-message subscription/backfill helpers

Direct Message Catch-Up

The runtime/session manager decides which session authors should be live-subscribed, but your app still owns relay fetch/backfill. Wrap your nostrSubscribe implementation so newly added direct message authors trigger a short replay immediately:

import {
  buildDirectMessageBackfillFilter,
  DirectMessageSubscriptionTracker,
} from "nostr-double-ratchet";

const tracker = new DirectMessageSubscriptionTracker();

const trackedSubscribe = (filter, onEvent) => {
  const { token, addedAuthors } = tracker.registerFilter(filter);
  if (addedAuthors.length) {
    const backfill = buildDirectMessageBackfillFilter(
      addedAuthors,
      Math.floor(Date.now() / 1000) - 15,
      200,
    );
    // Hand `backfill` to your relay fetch / short-lived subscription path.
  }

  const unsubscribe = nostrSubscribe(filter, onEvent);
  return () => {
    tracker.unregister(token);
    unsubscribe();
  };
};

Security Properties

Confidentiality

  • 1:1 payloads are encrypted with Double Ratchet over NIP-44.
  • Group payloads are encrypted with per-sender sender-key chains.

Forward Secrecy And Post-Compromise Recovery

  • 1:1 sessions get forward secrecy from ratcheting key evolution.
  • Future secrecy recovers after new ratchet steps if a transient compromise ends.

Author And Device Verification

  • Outer events are signature-verified.
  • Sender owner/device attribution comes from authenticated session context and AppKeys mappings.
  • Multi-device owner claims are verified against AppKeys (not accepted blindly).
  • The latest AppKeys set is authoritative for device authorization; removing a device from AppKeys revokes it for future routing and owner-claim validation.
  • Applications must not publish a reduced AppKeys set implicitly during startup/reopen. Publishing fewer devices should only happen for explicit device revocation or first-device bootstrap.
  • Inner rumor pubkey should be treated as untrusted for identity decisions.

Plausible Deniability

  • Inner rumors are unsigned payloads carried in encrypted channels.
  • You get channel authenticity, but not strong transferable non-repudiation of inner content.

Groups

Group is transport-agnostic and implements efficient sender-key messaging:

  • Membership is defined in owner pubkeys.
  • Sender-key distributions are delivered pairwise over authenticated 1:1 sessions.
  • Group messages are published once as one-to-many outer events (per sender device key).
  • Receiver attribution for group payloads is derived from authenticated distribution/session context, not inner rumor pubkey.
  • createGroupData(...) is a pure local constructor. GroupManager.createGroup(...) is the app-level helper that creates local group state and, by default, fans out metadata (kind 40) to members.
  • NdrRuntime now exposes the same high-level group path as Rust/FFI: createGroup(...), sendGroupEvent(...), sendGroupMessage(...), syncGroups(...), and onGroupEvent(...).

Disappearing Messages (Expiration)

Include NIP-40-style ["expiration", "<unix seconds>"] on the inner rumor, or use helpers:

await sessionManager.sendMessage(recipientPubkey, "expires soon", {
  ttlSeconds: 60,
});
await sessionManager.setDefaultExpiration({ ttlSeconds: 60 });
await sessionManager.setExpirationForPeer(recipientPubkey, { ttlSeconds: 120 });
await sessionManager.setExpirationForGroup(groupId, { ttlSeconds: 30 });
await sessionManager.setExpirationForPeer(recipientPubkey, null);
await sessionManager.sendMessage(recipientPubkey, "persist", {
  expiration: null,
});

The library does not purge local storage for you. Clients should enforce retention/UI behavior.

1:1 Chat Settings Signaling

Encrypted settings rumor kind:

  • CHAT_SETTINGS_KIND = 10448
  • Content: { "type": "chat-settings", "v": 1, "messageTtlSeconds": <seconds|null> }
  • Settings events themselves do not expire
await sessionManager.setChatSettingsForPeer(recipientPubkey, 60);
await sessionManager.setChatSettingsForPeer(recipientPubkey, 0);
sessionManager.setAutoAdoptChatSettings(false);

Event Kinds

| Kind | Constant | Purpose | | ----- | ------------------------------------------- | -------------------------------------------- | | 1060 | MESSAGE_EVENT_KIND | Encrypted outer event | | 30078 | INVITE_EVENT_KIND / APP_KEYS_EVENT_KIND | Device invite and AppKeys records | | 1059 | INVITE_RESPONSE_KIND | Encrypted invite response | | 14 | CHAT_MESSAGE_KIND | Inner chat message rumor | | 10448 | CHAT_SETTINGS_KIND | Inner chat-settings rumor | | 40 | GROUP_METADATA_KIND | Group metadata rumor | | 10445 | GROUP_INVITE_RUMOR_KIND | Group bootstrap invite rumor | | 10446 | GROUP_SENDER_KEY_DISTRIBUTION_KIND | Group sender-key distribution rumor | | 10447 | GROUP_SENDER_KEY_MESSAGE_KIND | Group sender-key message rumor kind constant | | 4 | SHARED_CHANNEL_KIND | Shared-channel transport event kind |

Scalability And Tradeoffs

  • Group steady-state publish is O(1) per message (single outer event).
  • Sender-key distribution and metadata fanout are O(members/devices), so membership changes are the expensive path.
  • Multi-device support improves UX but increases session/subscription/state complexity.
  • Relay-level metadata (timing, pubkeys, traffic patterns) remains visible.

Development

pnpm -C ts test:once
pnpm -C ts bench:relay-churn

Multi-Device Test Policy

  • Keep exactly one explicit same-second AppKeys regression test in the library.
  • Ordinary end-to-end tests should wait for a new created_at second before publishing AppKeys mutations so the normal path stays visible and trustworthy.
  • Keep heterogeneous-client interop coverage because same-client tests are not enough.