From e7dc3dc7c42803f6c5f020055a00af5f9d89d8a9 Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Tue, 26 Aug 2025 20:14:08 -0500 Subject: Add improved NIP-17 code This is based on an audited NIP-44 implementation. --- src/lib/nip17.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/lib/nip17.ts (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts new file mode 100644 index 00000000..66f49639 --- /dev/null +++ b/src/lib/nip17.ts @@ -0,0 +1,59 @@ +import { bytesToHex } from '@noble/hashes/utils'; +import { finalizeEvent, getEventHash, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { encrypt, u } from '$lib/nip44'; + +const TWO_DAYS = 2 * 24 * 60 * 60; +const randomTimestamp = () => Math.floor( + Date.now() / 1000 - Math.random() * TWO_DAYS); + +export const createNIP17Message = (text: string, senderSK: Uint8Array, receiverPK: string) => { + const rumour = createRumour(text, senderSK, receiverPK); + const sealed = sealRumour(rumour, senderSK, receiverPK); + const wrapped = giftWrap(sealed, receiverPK); + return wrapped; +} + +const createRumour = (text: string, senderSK: Uint8Array, receiverPK: string) => { + const rumour = { + kind: 14, // rumour + created_at: Math.floor(Date.now() / 1000), + tags: [["p", receiverPK]], + pubkey: getPublicKey(senderSK), + content: text + }; + rumour.id = getEventHash(rumour); + return rumour; +} + +const sealRumour = (rumour: object, senderSK: Uint8Array, receiverPK: string) => { + const senderSKString = bytesToHex(senderSK); + const conversationKey = u.getConversationKey(senderSKString, receiverPK); + const encryptedRumour = encrypt(JSON.stringify(rumour), conversationKey); + + const sealEvent = { + kind: 13, // seal + created_at: randomTimestamp(), + tags: [], + pubkey: getPublicKey(senderSK), + content: encryptedRumour + } + + return finalizeEvent(sealEvent, senderSK); +} + +const giftWrap = (event: object, receiverPK: string) => { + const secretKey = generateSecretKey(); + const SKString = bytesToHex(secretKey); + const conversationKey = u.getConversationKey(secretKey, receiverPK); + const encryptedEvent = encrypt(JSON.stringify(event), conversationKey); + + const giftWrapEvent = { + kind: 1059, // gift wrap + created_at: randomTimestamp(), + tags: [["p", receiverPK]], + pubkey: getPublicKey(secretKey), + content: encryptedEvent + } + + return finalizeEvent(giftWrapEvent, secretKey); +} -- cgit v1.2.3 From 4a2af45b77bb36a81b7f29908b3b320ddb76a769 Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Fri, 5 Sep 2025 16:51:32 -0500 Subject: Seal with NIP-07 if available This makes the system work with any nostr extensions you might be using. --- src/lib/nip17.ts | 23 +++++++++++++++++++++-- src/routes/(app)/dm/[recipient]/+page.svelte | 8 +++++--- 2 files changed, 26 insertions(+), 5 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 66f49639..e0046d47 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -6,9 +6,14 @@ const TWO_DAYS = 2 * 24 * 60 * 60; const randomTimestamp = () => Math.floor( Date.now() / 1000 - Math.random() * TWO_DAYS); -export const createNIP17Message = (text: string, senderSK: Uint8Array, receiverPK: string) => { +export const createNIP17Message = async (text: string, senderSK: Uint8Array, receiverPK: string) => { const rumour = createRumour(text, senderSK, receiverPK); - const sealed = sealRumour(rumour, senderSK, receiverPK); + let sealed; + if (window.nostr) { + sealed = await sealRumourNip07(rumour, receiverPK); + } else { + sealed = sealRumour(rumour, senderSK, receiverPK); + } const wrapped = giftWrap(sealed, receiverPK); return wrapped; } @@ -41,6 +46,20 @@ const sealRumour = (rumour: object, senderSK: Uint8Array, receiverPK: string) => return finalizeEvent(sealEvent, senderSK); } +const sealRumourNip07 = async (rumour: object, receiverPK: string) => { + const encryptedRumour = await window.nostr.nip44.encrypt( + receiverPK, JSON.stringify(rumour)); + + const sealEvent = { + kind: 13, // seal + created_at: randomTimestamp(), + tags: [], + content: encryptedRumour + } + + return window.nostr.signEvent(sealEvent); +} + const giftWrap = (event: object, receiverPK: string) => { const secretKey = generateSecretKey(); const SKString = bytesToHex(secretKey); diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 9e13ab19..33edfcc6 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -8,8 +8,6 @@ let { data } = $props(); const { user, recipient } = data; - // console.log(sign({kind: 13, created_at: 0, tags: [], pubkey: recipient.pubkey, content: "Test"}, recipient)); - let senderSK = $state(""); let overridePK = $state(""); let text = $state(""); @@ -23,11 +21,15 @@ const recipientPK = overridePK || recipient.pubkey; const skBytes = hexToBytes(senderSK); const event = createNIP17Message(text, skBytes, recipientPK); - console.log(JSON.stringify(event)); + event.then(displayEvent); } catch (error) { console.log(error); } } + + const displayEvent = (event: object) => { + console.log(JSON.stringify(event)); + }
-- cgit v1.2.3 From fd098a90952269db708f4d2d21de085c968e835e Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Fri, 5 Sep 2025 19:43:20 -0500 Subject: Remove need for explicit key entry If window.nostr is available, I don't need the sender's SK, I can do everything with its functions. Otherwise, I'll ask for the SK using getPrivateKey(). The override public key was only for testing, but I still don't need it because I can get the users' private keys from the settings tab. --- src/lib/nip17.ts | 30 +++++++++++---------- src/routes/(app)/dm/[recipient]/+page.svelte | 40 +++++++++------------------- 2 files changed, 29 insertions(+), 41 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index e0046d47..6a9888b6 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -6,31 +6,33 @@ const TWO_DAYS = 2 * 24 * 60 * 60; const randomTimestamp = () => Math.floor( Date.now() / 1000 - Math.random() * TWO_DAYS); -export const createNIP17Message = async (text: string, senderSK: Uint8Array, receiverPK: string) => { - const rumour = createRumour(text, senderSK, receiverPK); - let sealed; - if (window.nostr) { - sealed = await sealRumourNip07(rumour, receiverPK); - } else { - sealed = sealRumour(rumour, senderSK, receiverPK); - } - const wrapped = giftWrap(sealed, receiverPK); - return wrapped; +// Create a NIP-17 message using a provided sender secret key. +export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string) => { + const rumour = createRumour(text, getPublicKey(senderSK), receiverPK); + const sealed = sealRumourSK(rumour, senderSK, receiverPK); + return giftWrap(sealed, receiverPK); +} + +// Create a NIP-17 message using NIP-07, requiring no sender secret key. +export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string) => { + const rumour = createRumour(text, senderPK, receiverPK); + const sealed = await sealRumourNip07(rumour, receiverPK); + return giftWrap(sealed, receiverPK); } -const createRumour = (text: string, senderSK: Uint8Array, receiverPK: string) => { +const createRumour = (text: string, senderPK: string, receiverPK: string) => { const rumour = { kind: 14, // rumour created_at: Math.floor(Date.now() / 1000), tags: [["p", receiverPK]], - pubkey: getPublicKey(senderSK), + pubkey: senderPK, content: text }; - rumour.id = getEventHash(rumour); + rumour.id = getEventHash(rumour); // do NOT sign rumours! return rumour; } -const sealRumour = (rumour: object, senderSK: Uint8Array, receiverPK: string) => { +const sealRumourSK = (rumour: object, senderSK: Uint8Array, receiverPK: string) => { const senderSKString = bytesToHex(senderSK); const conversationKey = u.getConversationKey(senderSKString, receiverPK); const encryptedRumour = encrypt(JSON.stringify(rumour), conversationKey); diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 33edfcc6..446a1068 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -2,33 +2,26 @@ import Icon from "$comp/Icon.svelte"; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import * as nip17 from 'nostr-tools/nip17'; - import { createNIP17Message } from '$lib/nip17'; + import { createNIP17MessageSK, createNIP17MessageNIP07 } from '$lib/nip17'; import { sign, getPrivateKey } from '$lib/nostr'; let { data } = $props(); const { user, recipient } = data; - let senderSK = $state(""); - let overridePK = $state(""); let text = $state(""); - getPrivateKey(user) - .then(key => senderSK = bytesToHex(key)) - .catch(console.log); - - const btnCreateMessage = () => { - try { - const recipientPK = overridePK || recipient.pubkey; - const skBytes = hexToBytes(senderSK); - const event = createNIP17Message(text, skBytes, recipientPK); - event.then(displayEvent); - } catch (error) { - console.log(error); + const btnCreateMessage = async () => { + let event; + if (window.nostr) { + event = await createNIP17MessageNIP07( + text, user.pubkey, recipient.pubkey); + } else { + const sk = await getPrivateKey(user); + event = createNIP17MessageSK(text, sk, recipient.pubkey); } - } - const displayEvent = (event: object) => { - console.log(JSON.stringify(event)); + // TODO send event to relays instead of displaying it + text = JSON.stringify(event); } @@ -45,17 +38,10 @@

Direct Messages

-

Testing Message: You are sending nostr messages to {recipient.username}. Their pubkey is {recipient.pubkey}.

- - - - - - +

You are sending nostr messages to {recipient.username}.

- - +
-- cgit v1.2.3 From 3083a8675ee2378513d63954ca1bd5468f738660 Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Sat, 13 Sep 2025 15:11:04 -0500 Subject: Add message decryption Currently, this just takes a message from the text box and returns its decrypted version, but next I will make it decrypt messages it gets from relays. This code works with both NIP-07 (window.nostr) and getPrivateKey(). If both are available, it chooses NIP-07 like the encryption code. --- src/lib/nip17.ts | 11 +++++++++++ src/routes/(app)/dm/[recipient]/+page.svelte | 21 +++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 6a9888b6..84790275 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -78,3 +78,14 @@ const giftWrap = (event: object, receiverPK: string) => { return finalizeEvent(giftWrapEvent, secretKey); } + +export const decryptNIP17MessageNIP07 = async (text: string): string => { + const wrapped = JSON.parse(text); + const sealedText = await window.nostr.nip44.decrypt( + wrapped.pubkey, wrapped.content); + const sealed = JSON.parse(sealedText); + const rumourText = await window.nostr.nip44.decrypt( + sealed.pubkey, sealed.content); + const rumour = JSON.parse(rumourText); + return rumour.content; +} diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 446a1068..8e01b7a8 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -1,8 +1,8 @@
@@ -43,5 +55,6 @@ +
-- cgit v1.2.3 From 2f2348f459e3b6683284975e63df356e8c821bdc Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Wed, 1 Oct 2025 11:13:09 -0500 Subject: Fetch & display received DMs from relays --- src/lib/nip17.ts | 6 ++---- src/routes/(app)/dm/[recipient]/+page.svelte | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 12 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 84790275..495f897e 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -79,13 +79,11 @@ const giftWrap = (event: object, receiverPK: string) => { return finalizeEvent(giftWrapEvent, secretKey); } -export const decryptNIP17MessageNIP07 = async (text: string): string => { - const wrapped = JSON.parse(text); +export const decryptNIP17MessageNIP07 = async (wrapped: object): object => { const sealedText = await window.nostr.nip44.decrypt( wrapped.pubkey, wrapped.content); const sealed = JSON.parse(sealedText); const rumourText = await window.nostr.nip44.decrypt( sealed.pubkey, sealed.content); - const rumour = JSON.parse(rumourText); - return rumour.content; + return JSON.parse(rumourText); } diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 4f02829d..971c0cd5 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -12,8 +12,16 @@ const pool = new SimplePool(); import { PUBLIC_DM_RELAYS } from '$env/static/public'; const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); + const DM_FETCH_LIMIT = 256; let text = $state(""); + let events = $state([]); + + pool.querySync(DM_RELAYS_LIST, + { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT }) + .then(wrapped => + Promise.all(wrapped.map(decryptMessage)) + .then(rumours => events = rumours)); const btnCreateMessage = async () => { let event; @@ -28,15 +36,12 @@ await Promise.any(pool.publish(DM_RELAYS_LIST, event)); } - const btnDecryptMessage = async () => { + const decryptMessage = async (event: object): object => { if (window.nostr) { - text = await libnip17.decryptNIP17MessageNIP07(text); + return await libnip17.decryptNIP17MessageNIP07(event); } else { - // TODO get events from relays instead of the text box - const wrapped = JSON.parse(text); const sk = await getPrivateKey(user); - const rumour = toolsnip17.unwrapEvent(wrapped, sk); - text = rumour.content; + return toolsnip17.unwrapEvent(event, sk); } } @@ -56,9 +61,15 @@ >Direct Messages

You are sending nostr messages to {recipient.username}.

+

Received Messages:

+
    + {#each events as evt} +
  • {new Date(evt.created_at * 1000)}: "{evt.content}"
  • + {/each} +
+ - - + -- cgit v1.2.3 From 8f161a474d7dba438289d5dbe93684325560f85f Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Thu, 2 Oct 2025 10:01:12 -0500 Subject: Send copy of messages to sender --- src/lib/nip17.ts | 18 ++++++++++++------ src/routes/(app)/dm/[recipient]/+page.svelte | 14 ++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 495f897e..6cb8f9e3 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -7,17 +7,23 @@ const randomTimestamp = () => Math.floor( Date.now() / 1000 - Math.random() * TWO_DAYS); // Create a NIP-17 message using a provided sender secret key. -export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string) => { +// If wrapPK is specified and different than receiverPK, +// the message will be gift-wrapped to wrapPK +// but the rumour will be sent to receiverPK. +export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string, wrapPK: string = null) => { const rumour = createRumour(text, getPublicKey(senderSK), receiverPK); - const sealed = sealRumourSK(rumour, senderSK, receiverPK); - return giftWrap(sealed, receiverPK); + const sealed = sealRumourSK(rumour, senderSK, wrapPK || receiverPK); + return giftWrap(sealed, wrapPK || receiverPK); } // Create a NIP-17 message using NIP-07, requiring no sender secret key. -export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string) => { +// If wrapPK is specified and different than receiverPK, +// the message will be gift-wrapped to wrapPK +// but the rumour will be sent to receiverPK. +export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string, wrapPK: string = null) => { const rumour = createRumour(text, senderPK, receiverPK); - const sealed = await sealRumourNip07(rumour, receiverPK); - return giftWrap(sealed, receiverPK); + const sealed = await sealRumourNip07(rumour, wrapPK || receiverPK); + return giftWrap(sealed, wrapPK || receiverPK); } const createRumour = (text: string, senderPK: string, receiverPK: string) => { diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 75e55eea..a4334ff5 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -27,16 +27,22 @@ })); const btnCreateMessage = async () => { - let event; + let event1, event2; if (window.nostr) { - event = await libnip17.createNIP17MessageNIP07( + event1 = await libnip17.createNIP17MessageNIP07( text, user.pubkey, recipient.pubkey); + event2 = await libnip17.createNIP17MessageNIP07( + text, user.pubkey, recipient.pubkey, user.pubkey); } else { const sk = await getPrivateKey(user); - event = libnip17.createNIP17MessageSK(text, sk, recipient.pubkey); + event1 = libnip17.createNIP17MessageSK(text, sk, recipient.pubkey); + event2 = libnip17.createNIP17MessageSK( + text, sk, recipient.pubkey, user.pubkey); } - await Promise.any(pool.publish(DM_RELAYS_LIST, event)); + const p1 = Promise.any(pool.publish(DM_RELAYS_LIST, event1)); + const p2 = Promise.any(pool.publish(DM_RELAYS_LIST, event2)); + await Promise.all([p1, p2]); } const decryptMessage = async (event: object): object => { -- cgit v1.2.3 From 38a691cd6004adde24c913f69b2bbd01b2541fb9 Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Sat, 18 Oct 2025 19:12:28 -0500 Subject: Add DM expiration functionality --- src/lib/nip17.ts | 23 ++++++++++++++++------- src/routes/(app)/dm/[recipient]/+page.svelte | 23 +++++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 6cb8f9e3..9dd59f6f 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -10,20 +10,23 @@ const randomTimestamp = () => Math.floor( // If wrapPK is specified and different than receiverPK, // the message will be gift-wrapped to wrapPK // but the rumour will be sent to receiverPK. -export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string, wrapPK: string = null) => { +export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { const rumour = createRumour(text, getPublicKey(senderSK), receiverPK); const sealed = sealRumourSK(rumour, senderSK, wrapPK || receiverPK); - return giftWrap(sealed, wrapPK || receiverPK); + return giftWrap(sealed, wrapPK || receiverPK, expiryDays); } // Create a NIP-17 message using NIP-07, requiring no sender secret key. // If wrapPK is specified and different than receiverPK, // the message will be gift-wrapped to wrapPK // but the rumour will be sent to receiverPK. -export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string, wrapPK: string = null) => { +// If expiryDays is specified, the message will expire in that many days, +// plus a random amount from 0 to 2 for security. +// (expiration time = random creation time + expiryDays + 2 days) +export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { const rumour = createRumour(text, senderPK, receiverPK); const sealed = await sealRumourNip07(rumour, wrapPK || receiverPK); - return giftWrap(sealed, wrapPK || receiverPK); + return giftWrap(sealed, wrapPK || receiverPK, expiryDays); } const createRumour = (text: string, senderPK: string, receiverPK: string) => { @@ -68,16 +71,22 @@ const sealRumourNip07 = async (rumour: object, receiverPK: string) => { return window.nostr.signEvent(sealEvent); } -const giftWrap = (event: object, receiverPK: string) => { +const giftWrap = (event: object, receiverPK: string, expiryDays: number = null) => { const secretKey = generateSecretKey(); const SKString = bytesToHex(secretKey); const conversationKey = u.getConversationKey(secretKey, receiverPK); const encryptedEvent = encrypt(JSON.stringify(event), conversationKey); + const created = randomTimestamp(); + const tags = [["p", receiverPK]]; + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + tags.push(["expiration", expiration.toString()]); + } const giftWrapEvent = { kind: 1059, // gift wrap - created_at: randomTimestamp(), - tags: [["p", receiverPK]], + created_at: created, + tags: tags, pubkey: getPublicKey(secretKey), content: encryptedEvent } diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index 9bebae92..e0702bc3 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -61,6 +61,15 @@ return relays; } + const expired = (event: object) => { + for (const tag of event.tags) { + if (tag[0] == "expiration") { + return (Date.now() / 1000) >= Number(tag[1]); + } + } + return false; + } + const loadEvents = async (relays: string[]) => { const wrapped = await pool.querySync( relays, { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT } @@ -69,9 +78,11 @@ // intentionally decrypting sequentially to avoid having a bunch of popups let decryptedRumours = new Map(); for (event of wrapped) { - const rumour = await decryptMessage(event); - const created = Math.floor(rumour.created_at / 86400); - appendMultimap(decryptedRumours, created, rumour); + if (!expired(event)) { + const rumour = await decryptMessage(event); + const created = Math.floor(rumour.created_at / 86400); + appendMultimap(decryptedRumours, created, rumour); + } else console.log("relay contains expired event", event.id); } for (const [date, event] of decryptedRumours) { event.sort((ev1, ev2) => ev1.created_at - ev2.created_at); @@ -107,11 +118,15 @@ message, user.pubkey, recipient.pubkey, user.pubkey); } else { const sk = await getPrivateKey(user); - event1 = libnip17.createNIP17MessageSK(message, sk, recipient.pubkey); + event1 = libnip17.createNIP17MessageSK( + message, sk, recipient.pubkey); event2 = libnip17.createNIP17MessageSK( message, sk, recipient.pubkey, user.pubkey); } + console.log(Date.now() / 1000); + console.log(event2.tags); + const recipientRelays = await getPreferredRelays(recipient.pubkey); const p1 = Promise.any(pool.publish(recipientRelays, event1)); const senderRelays = await getPreferredRelays(user.pubkey); -- cgit v1.2.3 From 3e4900606624220e6c112a5438698b03ee3c3943 Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Thu, 13 Nov 2025 11:16:25 -0500 Subject: Add expiration timestamps to kind 13 seals In case these leak, they will expire automatically. Currently, they use the same method as the gift wrap expirations: expiration + 2 days after the creation timestamp. --- src/lib/nip17.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 9dd59f6f..cb9a0573 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -10,9 +10,12 @@ const randomTimestamp = () => Math.floor( // If wrapPK is specified and different than receiverPK, // the message will be gift-wrapped to wrapPK // but the rumour will be sent to receiverPK. +// If expiryDays is specified, the message will expire in that many days, +// plus a random amount from 0 to 2 for security. +// (expiration time = random creation time + expiryDays + 2 days) export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { const rumour = createRumour(text, getPublicKey(senderSK), receiverPK); - const sealed = sealRumourSK(rumour, senderSK, wrapPK || receiverPK); + const sealed = sealRumourSK(rumour, senderSK, wrapPK || receiverPK, expiryDays); return giftWrap(sealed, wrapPK || receiverPK, expiryDays); } @@ -25,7 +28,7 @@ export const createNIP17MessageSK = (text: string, senderSK: Uint8Array, receive // (expiration time = random creation time + expiryDays + 2 days) export const createNIP17MessageNIP07 = async (text: string, senderPK: string, receiverPK: string, wrapPK: string = null, expiryDays: number = null) => { const rumour = createRumour(text, senderPK, receiverPK); - const sealed = await sealRumourNip07(rumour, wrapPK || receiverPK); + const sealed = await sealRumourNip07(rumour, wrapPK || receiverPK, expiryDays); return giftWrap(sealed, wrapPK || receiverPK, expiryDays); } @@ -41,32 +44,42 @@ const createRumour = (text: string, senderPK: string, receiverPK: string) => { return rumour; } -const sealRumourSK = (rumour: object, senderSK: Uint8Array, receiverPK: string) => { +const sealRumourSK = (rumour: object, senderSK: Uint8Array, receiverPK: string, expiryDays: number = null) => { const senderSKString = bytesToHex(senderSK); const conversationKey = u.getConversationKey(senderSKString, receiverPK); const encryptedRumour = encrypt(JSON.stringify(rumour), conversationKey); + const created = randomTimestamp(); const sealEvent = { kind: 13, // seal - created_at: randomTimestamp(), + created_at: created, tags: [], pubkey: getPublicKey(senderSK), content: encryptedRumour } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + sealEvent.tags.push(["expiration", expiration.toString()]); + } return finalizeEvent(sealEvent, senderSK); } -const sealRumourNip07 = async (rumour: object, receiverPK: string) => { +const sealRumourNip07 = async (rumour: object, receiverPK: string, expiryDays: number = null) => { const encryptedRumour = await window.nostr.nip44.encrypt( receiverPK, JSON.stringify(rumour)); + const created = randomTimestamp(); const sealEvent = { kind: 13, // seal - created_at: randomTimestamp(), + created_at: created, tags: [], content: encryptedRumour } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + sealEvent.tags.push(["expiration", expiration.toString()]); + } return window.nostr.signEvent(sealEvent); } @@ -77,19 +90,18 @@ const giftWrap = (event: object, receiverPK: string, expiryDays: number = null) const conversationKey = u.getConversationKey(secretKey, receiverPK); const encryptedEvent = encrypt(JSON.stringify(event), conversationKey); const created = randomTimestamp(); - const tags = [["p", receiverPK]]; - if (expiryDays != null) { - const expiration = created + (expiryDays + 2) * 86400; - tags.push(["expiration", expiration.toString()]); - } const giftWrapEvent = { kind: 1059, // gift wrap created_at: created, - tags: tags, + tags: [["p", receiverPK]], pubkey: getPublicKey(secretKey), content: encryptedEvent } + if (expiryDays != null) { + const expiration = created + (expiryDays + 2) * 86400; + giftWrapEvent.tags.push(["expiration", expiration.toString()]); + } return finalizeEvent(giftWrapEvent, secretKey); } -- cgit v1.2.3 From 4374655730c3762ea56e9c732a7c0b7ab872ae1f Mon Sep 17 00:00:00 2001 From: Adrien Hopkins Date: Fri, 5 Dec 2025 09:51:00 -0500 Subject: Move some DM code to $lib I will be using some of this elsewhere, so it really should be there. --- src/lib/nip11.ts | 15 +++ src/lib/nip17.ts | 105 ++++++++++++++++ src/lib/nip40.ts | 16 +++ src/routes/(app)/dm/[recipient]/+page.svelte | 174 +++++++-------------------- 4 files changed, 177 insertions(+), 133 deletions(-) create mode 100644 src/lib/nip11.ts create mode 100644 src/lib/nip40.ts (limited to 'src/lib/nip17.ts') diff --git a/src/lib/nip11.ts b/src/lib/nip11.ts new file mode 100644 index 00000000..af254b7e --- /dev/null +++ b/src/lib/nip11.ts @@ -0,0 +1,15 @@ +import { fetchRelayInformation } from 'nostr-tools/nip11'; + +// Accepts a list of relays, and returns a list of the relays that support all the provided NIPs. +export const relaysSupporting = async (relays: string[], nips: number[]) => { + let supportingRelays = []; + + for (const relay of relays) { + const relayInfo = await fetchRelayInformation(relay); + if (nips.every(relay => relayInfo.supported_nips.includes(relay))) { + supportingRelays.push(relay); + } + } + + return supportingRelays; +} diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index cb9a0573..1a78eb5a 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -1,10 +1,23 @@ import { bytesToHex } from '@noble/hashes/utils'; + import { finalizeEvent, getEventHash, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import { unwrapEvent } from 'nostr-tools/nip17'; + +import { sign, getPrivateKey } from '$lib/nostr'; +import { relaysSupporting } from '$lib/nip11'; +import { expired, expiration } from '$lib/nip40'; import { encrypt, u } from '$lib/nip44'; const TWO_DAYS = 2 * 24 * 60 * 60; const randomTimestamp = () => Math.floor( Date.now() / 1000 - Math.random() * TWO_DAYS); +const pool = new SimplePool(); +let relayListCache = new Map(); + +import { PUBLIC_DM_RELAYS } from '$env/static/public'; +const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); +const DM_FETCH_LIMIT = 256; // Create a NIP-17 message using a provided sender secret key. // If wrapPK is specified and different than receiverPK, @@ -114,3 +127,95 @@ export const decryptNIP17MessageNIP07 = async (wrapped: object): object => { sealed.pubkey, sealed.content); return JSON.parse(rumourText); } + +// Decrypts the provided message, addressed to the user. +// If NIP-07 is available, it uses that, otherwise it uses getPrivateKey(). +export const decrypt = async (wrapped: object, user: object): object => { + if (await window.nostr.getPublicKey() === user.pubkey) { + return decryptNIP17MessageNIP07(wrapped); + } else { + const sk = await getPrivateKey(user); + return unwrapEvent(wrapped, sk); + } + } + +// Returns the rumours of the messages the provided user has sent or received. +// If the events have an expiration timestamp, +// that timestamp is added to its rumour object as the property `expiration`. +export const getMessageRumours = async (user: object): object[] => { + const relays = await getPreferredRelays(user.pubkey); + const wrapped = await pool.querySync( + relays, { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT } + ); + + let rumours = []; + // intentionally decrypting sequentially to avoid having a bunch of popups + for (event of wrapped) { + if (expired(event)) { + console.log("relay(s) contain expired event", event.id); + continue; + } + + const rumour = await decrypt(event, user); + + const expires = expiration(event); + if (expires) { + rumour.expiration = expires; + } + + rumours.push(rumour); + } + + return rumours; +} + +export const getPreferredRelays = async (pubkey: string): string[] => { + if (relayListCache.has(pubkey)) + return relayListCache.get(pubkey); + + const events = await pool.querySync( + DM_RELAYS_LIST, { kinds: [10050], limit: 1, authors: [pubkey] } + ); + + let relays = []; + for (const event of events) { + for (const tag of event.tags) { + if (tag.length >= 2 && tag[0] == "relay") { + relays.push(tag[1]); + } + } + } + + relayListCache.set(pubkey, relays); + return relays; +} + +const publishToPreferred = async (event: object, pubkey: string, expiryEnabled: boolean): Promise => { + let preferredRelays = await getPreferredRelays(pubkey); + if (expiryEnabled) { + preferredRelays = await relaysSupporting(preferredRelays, [40]); + } + return Promise.any(pool.publish(preferredRelays, event)); +} + +// Sends a NIP-17 message from `user` to `recipient`. +// If expiryDays is set, messages will expire after that many days. +export const send = async (message: string, user: object, recipient: object, expiryDays?: number) => { + let event1, event2; + if (await window.nostr.getPublicKey() === user.pubkey) { + event1 = await createNIP17MessageNIP07( + message, user.pubkey, recipient.pubkey, recipient.pubkey, expiryDays); + event2 = await createNIP17MessageNIP07( + message, user.pubkey, recipient.pubkey, user.pubkey, expiryDays); + } else { + const sk = await getPrivateKey(user); + event1 = createNIP17MessageSK( + message, sk, recipient.pubkey, recipient.pubkey, expiryDays); + event2 = createNIP17MessageSK( + message, sk, recipient.pubkey, user.pubkey, expiryDays); + } + + const p1 = publishToPreferred(event1, recipient.pubkey, expiryDays != null); + const p2 = publishToPreferred(event2, user.pubkey, expiryDays != null); + return Promise.all([p1, p2]); +} diff --git a/src/lib/nip40.ts b/src/lib/nip40.ts new file mode 100644 index 00000000..030fe0df --- /dev/null +++ b/src/lib/nip40.ts @@ -0,0 +1,16 @@ +// Returns true if the message has an expiration in the past, false otherwise. +export const expired = (event: object): boolean => { + const expiryTime = expiration(event); + return expiryTime !== null && Date.now() / 1000 >= expiryTime; +} + +// Returns the provided event's expiration time, as a Unix ms integer. +// If it has none, returns null. +export const expiration = (event: object): number | null => { + for (const tag of event.tags) { + if (tag[0] == "expiration") { + return parseInt(tag[1]); + } + } + return null; +} diff --git a/src/routes/(app)/dm/[recipient]/+page.svelte b/src/routes/(app)/dm/[recipient]/+page.svelte index e11c5c5c..8014cd2a 100644 --- a/src/routes/(app)/dm/[recipient]/+page.svelte +++ b/src/routes/(app)/dm/[recipient]/+page.svelte @@ -2,9 +2,9 @@ import { tick } from "svelte"; import Icon from "$comp/Icon.svelte"; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; - import { fetchRelayInformation } from 'nostr-tools/nip11'; import * as toolsnip17 from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; + import { relaysSupporting } from '$lib/nip11'; import * as libnip17 from '$lib/nip17'; import { sign, getPrivateKey } from '$lib/nostr'; import { t } from "$lib/translations"; @@ -13,11 +13,6 @@ const { user, recipient } = data; const pool = new SimplePool(); - import { PUBLIC_DM_RELAYS } from '$env/static/public'; - const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(','); - const DM_FETCH_LIMIT = 256; - - let relayLists = new Map(); let text = $state(""); let messageRumours = $state([]); @@ -36,89 +31,21 @@ } } - const zeroPad = (num: number): string => { - return num < 10 ? '0' + num.toString() : num.toString(); - } - - const timeString = (date: Date): string => { - const hour = zeroPad(date.getHours()); - const minute = zeroPad(date.getMinutes()); - const second = zeroPad(date.getSeconds()); - return `${hour}:${minute}:${second}`; - } - - const getPreferredRelays = async (pubkey: string): string[] => { - if (relayLists.has(pubkey)) - return relayLists.get(pubkey); - - const events = await pool.querySync( - DM_RELAYS_LIST, { kinds: [10050], limit: 1, authors: [pubkey] } - ); - - let relays = []; + const dateMap = (events: object[]): Map => { + let eventsMap = new Map(); for (const event of events) { - for (const tag of event.tags) { - if (tag.length >= 2 && tag[0] == "relay") { - relays.push(tag[1]); - } - } - } - - relayLists.set(pubkey, relays); - return relays; - } - - // Accepts a list of relays, and returns a list of the relays that support all the provided NIPs. - const relaysSupporting = async (relays: string[], nips: number[]) => { - let supportingRelays = []; - - for (const relay of relays) { - const relayInfo = await fetchRelayInformation(relay); - if (nips.every(relay => relayInfo.supported_nips.includes(relay))) { - supportingRelays.push(relay); - } + const created = Math.floor(event.created_at / 86400); + appendMultimap(eventsMap, created, event); } - - return supportingRelays; - } - - const expired = (event: object) => { - for (const tag of event.tags) { - if (tag[0] == "expiration") { - return (Date.now() / 1000) >= Number(tag[1]); - } + for (const [date, dateEvents] of eventsMap) { + dateEvents.sort((ev1, ev2) => ev1.created_at - ev2.created_at); } - return false; - } - - const expirationClose = (expiration: number) => { - return expiration <= (Date.now() / 1000 + 2 * 86400); + return eventsMap; } - const loadEvents = async (relays: string[]) => { - const wrapped = await pool.querySync( - relays, { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT } - ); - - // intentionally decrypting sequentially to avoid having a bunch of popups - let decryptedRumours = new Map(); - for (event of wrapped) { - if (!expired(event)) { - const rumour = await decryptMessage(event); - const created = Math.floor(rumour.created_at / 86400); - for (const tag of event.tags) { - if (tag[0] == "expiration") { - rumour.expiration = parseInt(tag[1]); - } - } - appendMultimap(decryptedRumours, created, rumour); - } else console.log("relay contains expired event", event.id); - } - for (const [date, event] of decryptedRumours) { - event.sort((ev1, ev2) => ev1.created_at - ev2.created_at); - } - - messageRumours = decryptedRumours; + const loadEvents = async () => { + const rumours = await libnip17.getMessageRumours(user); + messageRumours = dateMap(rumours); dates = Array.from(messageRumours.keys()); dates.sort((d1, d2) => d1 - d2); @@ -128,59 +55,25 @@ messages.scrollTop = messages.scrollHeight; } - getPreferredRelays(user.pubkey).then((relays) => { - if (!relays || relays.length == 0) { - relayWarningShown = true; - return; - } - relayWarningShown = false; - loadEvents(relays); - }); - getPreferredRelays(recipient.pubkey).then((relays) => { - canSend = relays && relays.length > 0; - relaysSupporting(relays, [40]) - .then((supporting) => canSendExpiring = supporting.length > 0); - }); - - const sendMessage = async (message: string) => { - const expiry = expiryEnabled ? expiryDays : null; - let event1, event2; - if (await window.nostr.getPublicKey() === user.pubkey) { - event1 = await libnip17.createNIP17MessageNIP07( - message, user.pubkey, recipient.pubkey, recipient.pubkey, expiry); - event2 = await libnip17.createNIP17MessageNIP07( - message, user.pubkey, recipient.pubkey, user.pubkey, expiry); - } else { - const sk = await getPrivateKey(user); - event1 = libnip17.createNIP17MessageSK( - message, sk, recipient.pubkey, recipient.pubkey, expiry); - event2 = libnip17.createNIP17MessageSK( - message, sk, recipient.pubkey, user.pubkey, expiry); - } - - let recipientRelays = await getPreferredRelays(recipient.pubkey); - if (expiryEnabled) { - recipientRelays = await relaysSupporting(recipientRelays, [40]); - } - const p1 = Promise.any(pool.publish(recipientRelays, event1)); + const zeroPad = (num: number): string => { + return num < 10 ? '0' + num.toString() : num.toString(); + } - let senderRelays = await getPreferredRelays(user.pubkey); - if (expiryEnabled) { - senderRelays = await relaysSupporting(senderRelays, [40]); - } - const p2 = Promise.any(pool.publish(senderRelays, event2)); + const timeString = (date: Date): string => { + const hour = zeroPad(date.getHours()); + const minute = zeroPad(date.getMinutes()); + const second = zeroPad(date.getSeconds()); + return `${hour}:${minute}:${second}`; + } - await Promise.all([p1, p2]); - getPreferredRelays(user.pubkey).then(loadEvents); + const expirationClose = (expiration: number) => { + return expiration <= (Date.now() / 1000 + 2 * 86400); } - const decryptMessage = async (event: object): object => { - if (await window.nostr.getPublicKey() === user.pubkey) { - return libnip17.decryptNIP17MessageNIP07(event); - } else { - const sk = await getPrivateKey(user); - return toolsnip17.unwrapEvent(event, sk); - } + const sendMessage = async (message: string) => { + const expiry = expiryEnabled ? expiryDays : null; + await libnip17.send(message, user, recipient, expiry); + loadEvents(); } const dmName = (pubkey: string): string => { @@ -192,6 +85,21 @@ return pubkey; } } + + libnip17.getPreferredRelays(user.pubkey).then((relays) => { + if (!relays || relays.length == 0) { + relayWarningShown = true; + return; + } + relayWarningShown = false; + loadEvents(); + }); + + libnip17.getPreferredRelays(recipient.pubkey).then((relays) => { + canSend = relays && relays.length > 0; + relaysSupporting(relays, [40]) + .then((supporting) => canSendExpiring = supporting.length > 0); + });