Main development is on decentralized git:
htree://npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/nostr-double-ratchet
End-to-end encrypted messaging primitives for Nostr, implemented in TypeScript and Rust.
Reference integrations:
iris-client,
iris-chat,
iris-chat-flutter.
Used by chat.iris.to. For command-line chat tooling,
use the iris CLI from the
iris-chat crate.
- 1:1 messaging via Double Ratchet over Nostr events
- Multi-device identity model (owner key + device keys) with AppKeys
- Invite and link flows for session bootstrapping
- Group messaging with sender keys and one-to-many outer events
- High-level
NdrRuntimepath that owns both session and group transport - Cross-language TS/Rust interoperability tests
- Breaking changes are still possible while APIs settle
| Mode | Use it when | What it owns |
|---|---|---|
NdrRuntime |
You want the default production path with one app-facing surface for direct messages, linked devices, and groups. | AppKeysManager, DelegateManager, SessionManager, and GroupManager in TypeScript. |
SessionManager |
You want multi-device routing and storage, 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 simplest 1:1 primitive and you already own invite/bootstrap, persistence, and transport. Good for negotiated 1:1 channels or other app-specific direct links. | Only the ratchet session state itself. |
Add-ons around those layers:
SessionGroupRuntime: attach the same group transport surface thatNdrRuntimeuses to an existingSessionManager.GroupManager: direct group transport helper if you want to wire group state yourself.Invite: handshake/bootstrap primitive when you build around plainSession.
Use NdrRuntime when you want one concrete app-facing surface for:
setupUser(...)sendEvent(...),sendMessage(...)sendReceipt(...),sendTyping(...)sendChatSettings(...),setChatSettingsForPeer(...)waitForSessionManager(...)andonSessionEvent(...)getGroupManager()/waitForGroupManager(...)onGroupEvent(...)upsertGroup(...),removeGroup(...),syncGroups(...)createGroup(...)sendGroupEvent(...),sendGroupMessage(...)
Use SessionManager when you want to keep your own app runtime, but you do not want to rebuild
multi-device routing, device authorization, or session persistence yourself.
Use plain Session when you want the smallest possible surface for 1:1 messaging and you do not
need owner/device fanout, AppKeys-driven authorization, or runtime-managed group transport.
Build inner rumors with messageBuilders first; Session only encrypts/decrypts unsigned inner
events and does not own chat features such as reactions, typing, receipts, or expiration policy.
- 1:1 payloads are encrypted end-to-end (NIP-44 + Double Ratchet).
- Group payloads are encrypted with per-sender sender-key chains.
- Relays can still observe outer-event metadata (timing, pubkeys, kind), not plaintext.
- Double Ratchet gives forward secrecy for 1:1 sessions.
- After compromise of a current chain key, future secrecy recovers after fresh ratchet steps (assuming attacker no longer controls endpoints).
- Group sender keys rotate and are redistributed to handle membership and key changes.
- Outer Nostr events are signature-verified.
- Session/identity attribution is bound to authenticated session context and owner/device mappings.
ownerPubkeyclaims are verified against AppKeys for multi-device identities.- 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
pubkeyis not trusted for sender identity decisions. - Shared-channel group invite bootstrap requires signed inner payloads and owner/device consistency checks.
- Inner rumors are unsigned payloads transported inside encrypted channels.
- Recipients can verify a message came through an established secure session, but there is no strong non-repudiation proof for inner message authorship.
- No protection against a compromised endpoint/device.
- No global availability guarantee; delivery depends on relay reachability.
- No perfect metadata privacy (Nostr relays still see network-level and outer-event metadata).
New clients and tools should use the shared multi-device helpers in this repo instead of re-implementing policy ad hoc.
- AppKeys are an ordered authorization timeline, not just a set.
- Order AppKeys snapshots by Nostr
created_at. - Ignore stale AppKeys snapshots.
- If two AppKeys snapshots land in the same second, merge monotonically instead of letting arrival order shrink the authorized device set.
- Publishing a reduced AppKeys set is only valid for explicit device revocation or first-device bootstrap, not normal startup/reopen.
- Imported owner-key or
nseclogin on a fresh device must either register the current device or remain explicitly single-device. - First-device bootstrap can proceed from locally published AppKeys. Adding a new device to an existing owner timeline should wait for relay-visible AppKeys before relying on public-invite fanout.
- After device registration or revocation, clients should refresh bootstrap state, subscriptions, and session routing.
- When a new direct-message session author appears in a
session-current-*orsession-next-*subscription, clients should do an immediate short replay/backfill for that author instead of waiting for the next periodic sweep. - Self-DM routing must consider owner pubkey, sender/session pubkey, rumor author pubkey,
ptags, and known own-device AppKeys/session state together. Inner rumorpubkeyalone is not enough. - Invite acceptance must preserve inviter owner/device attribution. Until AppKeys verifies a claimed owner, clients may need to fall back to device-identity routing rather than inventing ownership.
- Prefer the shared helpers over local policy forks:
- TypeScript:
applyAppKeysSnapshot,evaluateDeviceRegistrationState,shouldRequireRelayRegistrationConfirmation,resolveConversationCandidatePubkeys,resolveInviteOwnerRouting,DirectMessageSubscriptionTracker,buildDirectMessageBackfillFilter,resolveSessionPubkeyToOwner,hasExistingSessionWithRecipient - Rust:
apply_app_keys_snapshot,select_latest_app_keys_from_events,evaluate_device_registration_state,should_require_relay_registration_confirmation,resolve_invite_owner_routing,resolve_conversation_candidate_pubkeys,DirectMessageSubscriptionTracker,build_direct_message_backfill_filter,resolve_rumor_peer_pubkey
NdrRuntime and SessionManager own session state and emit pubsub/decrypted-message events, but
they do not own relay history fetch. Consumers should treat new direct-message subscription authors
as a transport catch-up signal and run a short replay/backfill with the shared helpers above.
Groups use a hybrid model:
- Group metadata and sender-key distributions are sent over authenticated 1:1 ratchet sessions.
- Each sending device has a per-group sender-event keypair and sender-key chain.
- A group message is published once as a one-to-many encrypted outer event.
- Members decrypt using sender-key state learned from pairwise distributions.
- Shared-channel events are used only for signed bootstrap invites when members do not yet have a direct 1:1 session.
- Per-group message publish is O(1) (single outer event), which scales better than per-member ciphertext fanout.
- Sender-key distribution and metadata updates are O(number of members/devices), so membership churn is more expensive than steady-state messaging.
- Multi-device support improves usability but increases session count, subscriptions, and state management complexity.
- Delivery is eventually consistent; implementations use persistent queues/retries but cannot force relay delivery.
| Language | Directory | Package |
|---|---|---|
| TypeScript | ts/ | npm |
| Rust | rust/ | crates.io |
For iOS/Android integration (for example Flutter/native apps), use:
- rust/crates/ndr-ffi - UniFFI bindings crate
- scripts/mobile/build-ios.sh
- scripts/mobile/build-android.sh
ts/: TypeScript libraryrust/crates/nostr-double-ratchet/: Rust core library
# TypeScript tests
pnpm -C ts test:once
# Local relay churn/latency benchmark
pnpm -C ts bench:relay-churn
# Rust library tests
cargo test -p nostr-double-ratchet --manifest-path rust/Cargo.tomlFor exact integration behavior, treat the README as onboarding and the runtime/invite tests as the behavioral source of truth.
- Keep one explicit same-second AppKeys regression in library tests.
- Normal end-to-end and interop tests should avoid same-second AppKeys publishes unless the test is explicitly about that edge case.
- Keep heterogeneous-client coverage in the matrix.
iris-chat,iris-client, andiris-chat-fluttershould not each trust only their own same-client tests. - When possible, assert both self-sync and peer fanout across owner and linked devices.
The formal/ directory contains small TLA+ models for the rules that are easiest to
get subtly wrong in multi-device and invite handling.
formal/session_manager_fanout: AppKeys replay ordering, monotonic same-second merges, revocation, and eventual fanout recovery.formal/invite_handshake: invite replay handling, unauthorized owner-claim rejection, and single-device fallback.formal/device_registration_policy: the split policy for imported-device registration: first-device bootstrap may trust locally published AppKeys, but an additional device on an existing owner timeline must wait for relay-visible AppKeys before public-invite fanout trusts it.
The main TLA+ learning from the latest multi-device work is that there is no single global registration rule that fits both bootstrap and additional-device flows. Always trusting local AppKeys is too weak for additional devices; always requiring relay visibility is too strict for bootstrap and recovery.
For language-specific usage, see: