From 087a2df3a38cebacaa0096e9a4b45e0de50c723e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:38:00 +0000 Subject: [PATCH] Add NIP-17 DM docs and fix test imports Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- NIP.md | 22 +++++++++++++++++-- docs/apps.md | 32 +++++++++++++++++++++++++++- src/hooks/useDirectMessages.test.tsx | 4 ++-- src/lib/dm.test.ts | 9 ++++---- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/NIP.md b/NIP.md index b52b94d..4f09bff 100644 --- a/NIP.md +++ b/NIP.md @@ -18,6 +18,23 @@ protocols: NIP-86 authorization (with the NIP-86-required `payload` tag) and for Blossom uploads. - [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) — read at connect time to show relay identity in Relay Admin. +- [NIP-17: Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md) + (draft, optional) — the Messages app (`src/apps/messages/`, protocol layer in + `src/lib/dm.ts`). Chat messages are unsigned kind `14` rumors, sealed by the sender + into kind `13` with [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) + and gift-wrapped (kind `1059`, [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) + with a throwaway key — one wrap for the recipient and one sender copy, so history is + recoverable on any client holding the keys. Seal/wrap timestamps are randomized up to + two days into the past. The recipient copy is published to the relays from the + recipient's kind `10050` inbox list; a sender without that list gets the message on + their own relays plus a warning that the recipient may not be reachable. The + seal↔rumor pubkey check mandated by the NIP is enforced on unwrap, and rumors whose + room (author + `p` tags) does not include the viewer are dropped. +- [NIP-04: Encrypted Direct Messages](https://github.com/nostr-protocol/nips/blob/master/04.md) + (deprecated) — kind `4` legacy DMs are read for backwards compatibility and used for + sending only when the signer offers no NIP-44 encryption; the UI labels that fallback + visibly (weaker crypto, public sender/recipient metadata) and never downgrades a + NIP-44-capable signer to it. ## Adopted third-party kinds @@ -25,5 +42,6 @@ protocols: [Grimoire](https://github.com/purrgrammer/grimoire) client, adopted as-is for interop. See `docs/apps.md` ("Spells are a third-party kind") and `src/hooks/useSpells.ts`. -Anything else (kinds 0, 1, 3, 5, 6, 16, 9802, 10002, 10003, 22242, 30023, 30311, 31337, -39701, …) follows the official NIPs as implemented in `src/hooks/` and `src/lib/`. +Anything else (kinds 0, 1, 3, 4, 5, 6, 13, 14, 16, 9802, 10002, 10003, 10050, 22242, +30023, 30311, 31337, 39701, …) follows the official NIPs as implemented in `src/hooks/` +and `src/lib/`. diff --git a/docs/apps.md b/docs/apps.md index 7d554e2..aedeedf 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) { } ``` -## The eleven apps +## The twelve apps | App | `id` | Params | Notes | |---|---|---|---| @@ -100,6 +100,7 @@ export default function ExampleApp({ setTitle }: AppProps) { | Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles | | Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL | | Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat | +| Messages | `messages` | `peer?` | NIP-17 gift-wrapped DMs (kinds 14/13/1059), legacy NIP-04 fallback | | Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP | | Relays | `relays` | — | Connection state, subscription count, measured latency | | Relay Admin | `relay-admin` | `relay?` | NIP-86 management console for relays you operate | @@ -168,6 +169,35 @@ whether the relay offers a reverse operation. Every operation is written to a pe audit log (method, target, result, operator-safe error) that never contains the authorization header or any key material; signing secrets stay inside the user's signer. +### Messages keep plaintext and metadata off the wire — and out of storage + +The Messages app implements NIP-17 (`src/lib/dm.ts` + `src/hooks/useDirectMessages.ts`): +plaintext only ever exists inside an unsigned kind 14 rumor, which is NIP-44-sealed by +the sender and gift-wrapped with a throwaway key. One wrap goes to the recipient's kind +10050 inbox relays, one sender copy to the sender's own relays — the sender copy is what +makes history recoverable on another device, so it is always written. Decryption happens +on demand and plaintexts live in memory only; nothing message-shaped is persisted to +localStorage. The conversation list shows no received plaintext either ("New message", +not a snippet), because a lock-screen-glance at the window title bar or an over-the-shoulder +view should not leak content. + +Two compatibility decisions are deliberately visible rather than silent: + +- **Legacy NIP-04 (kind 4)** is read for old messages and used for sending only when the + signer has no NIP-44 (some extensions). A banner and a "legacy" label on the affected + bubbles say exactly what that costs: sender/recipient metadata is public. A + NIP-44-capable signer is never downgraded. +- **No kind 10050 inbox list** means NIP-17 cannot say where the recipient receives DMs. + The message is still stored (on the sender's relays, so the sender keeps their + history) but the send reports a `DmRecipientUnreachableError` with recovery guidance + instead of claiming delivery. Bubble states are honest for the same reason: + pending → sent means "a relay accepted the gift wrap", never "the recipient read it". + +Hiding a conversation publishes a NIP-09 kind 5 deletion request for the sender's own +copies (relays may refuse; nothing recalls a message the recipient already fetched) and +hides the peer locally. Received gift wraps are signed by throwaway keys and cannot be +deletion-requested by design. + ### Relay latency is a real round trip A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE` diff --git a/src/hooks/useDirectMessages.test.tsx b/src/hooks/useDirectMessages.test.tsx index cd53c6a..5e1943e 100644 --- a/src/hooks/useDirectMessages.test.tsx +++ b/src/hooks/useDirectMessages.test.tsx @@ -5,9 +5,9 @@ import { NSecSigner, type NostrEvent } from '@nostrify/nostrify'; import { useNostr } from '@nostrify/react'; import { TestApp } from '@/test/TestApp'; -import { buildGiftWraps, DM_GIFT_WRAP_KIND, LEGACY_DM_KIND } from '@/lib/dm'; +import { buildGiftWraps, DM_GIFT_WRAP_KIND, LEGACY_DM_KIND, type DmMessage } from '@/lib/dm'; import { useLoginActions } from './useLoginActions'; -import { groupIntoConversations, useDmConversations, type DmMessage } from './useDirectMessages'; +import { groupIntoConversations, useDmConversations } from './useDirectMessages'; const aliceSecret = generateSecretKey(); const alice = getPublicKey(aliceSecret); diff --git a/src/lib/dm.test.ts b/src/lib/dm.test.ts index 5f52a6a..2ea304b 100644 --- a/src/lib/dm.test.ts +++ b/src/lib/dm.test.ts @@ -81,10 +81,11 @@ describe('parseRecipient', () => { }); it('rejects note/nevent/naddr identifiers — they are not people', () => { - const note = nip19.noteEncode('a'.repeat(64)); - expect(parseRecipient(note).type).toBe('invalid'); - const nevent = nip19.neventEncode({ id: 'a'.repeat(64) }); - expect(parseRecipient(nevent.type ? nevent : '').type).toBe('invalid'); + expect(parseRecipient(nip19.noteEncode('a'.repeat(64))).type).toBe('invalid'); + expect(parseRecipient(nip19.neventEncode({ id: 'a'.repeat(64) })).type).toBe('invalid'); + expect( + parseRecipient(nip19.naddrEncode({ pubkey: alice, kind: 30023, identifier: 'x' })).type, + ).toBe('invalid'); }); it('rejects an nsec — a secret must never be typed into a recipient field', () => {