| 1 | /* NIP-17 direct messages: kind 14 rumors, NIP-59 seals and gift wraps. */ |
| 2 | (() => { |
| 3 | "use strict" ; |
| 4 | |
| 5 | // Leave room for the rumor JSON envelope under NIP-44's 65535-byte limit. |
| 6 | const MAX_CONTENT = 60 * 1024; |
| 7 | const MAX_TAGS = 32; |
| 8 | const contentBytes = value => typeof TextEncoder === "function" ? new TextEncoder().encode(value).length : unescape(encodeURIComponent(value)).length; |
| 9 | const hex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/i.test(value); |
| 10 | const signerAPI = () => { |
| 11 | const value = globalThis.NostrSigner; |
| 12 | if (!value?.finalizeEvent || !value.generateSecretKey || !value.verifyEvent || !value.getEventHash) |
| 13 | throw Error("Nostr signing support is unavailable" ); |
| 14 | return value; |
| 15 | }; |
| 16 | const signerPublicKey = async signer => { |
| 17 | const key = await signer.getPublicKey(); |
| 18 | if (!hex64(key)) throw Error("invalid signer public key" ); |
| 19 | return key.toLowerCase(); |
| 20 | }; |
| 21 | const normalizePubkey = value => { |
| 22 | if (globalThis.tiny?.files?.messages?.normalizePubkey) |
| 23 | return globalThis.tiny.files.messages.normalizePubkey(value); |
| 24 | if (hex64(value)) return value.toLowerCase(); |
| 25 | const decoded = signerAPI().decodeNpub?.(String(value || "" )); |
| 26 | if (hex64(decoded)) return decoded.toLowerCase(); |
| 27 | throw Error("public key must be a 64 character hex key or npub" ); |
| 28 | }; |
| 29 | const nip44Encrypt = async (signer, pubkey, text) => { |
| 30 | if (typeof globalThis.tiny?.nip44Encrypt === "function" ) return globalThis.tiny.nip44Encrypt(pubkey, text, signer); |
| 31 | if (typeof signer.nip44Encrypt === "function" ) return signer.nip44Encrypt(pubkey, text); |
| 32 | throw Error("NIP-44 encryption is unavailable" ); |
| 33 | }; |
| 34 | const nip44Decrypt = async (signer, pubkey, text) => { |
| 35 | if (typeof globalThis.tiny?.nip44Decrypt === "function" ) return globalThis.tiny.nip44Decrypt(pubkey, text, signer); |
| 36 | if (typeof signer.nip44Decrypt === "function" ) return signer.nip44Decrypt(pubkey, text); |
| 37 | throw Error("NIP-44 decryption is unavailable" ); |
| 38 | }; |
| 39 | const eventHash = (api, event) => api.getEventHash(event); |
| 40 | const past = now => Math.max(0, Math.floor(now) - Math.floor(Math.random() * 172801)); |
| 41 | |
| 42 | async function build(input = {}, options = {}) { |
| 43 | const signer = options.signer || globalThis.tiny?.signer?.(); |
| 44 | if (!signer?.signEvent || !signer.getPublicKey) throw Error("a NIP-44 signer is required" ); |
| 45 | const recipient = normalizePubkey(input.recipient); |
| 46 | const sender = await signerPublicKey(signer); |
| 47 | const content = String(input.content ?? "" ); |
| 48 | if (contentBytes(content) > MAX_CONTENT) throw Error("message content is empty or too large" ); |
| 49 | const tags = [["p" , recipient]]; |
| 50 | if (input.reply) { |
| 51 | if (!hex64(input.reply.id) || !hex64(input.reply.pubkey)) throw Error("reply must include valid id and pubkey" ); |
| 52 | tags.push(["e" , input.reply.id.toLowerCase(), input.reply.relay || "" , "reply" , input.reply.pubkey.toLowerCase()]); |
| 53 | } |
| 54 | const now = options.now ?? Date.now() / 1000; |
| 55 | let kind = 14; |
| 56 | if (input.reaction) { |
| 57 | const target = input.reaction; |
| 58 | if (!hex64(target.id) || !hex64(target.pubkey) || (target.kind != null && (!Number.isSafeInteger(target.kind) || target.kind < 0))) |
| 59 | throw Error("reaction target must include valid id and pubkey" ); |
| 60 | kind = 7; |
| 61 | tags.push(["e" , target.id.toLowerCase(), target.relay || "" , "root" , target.pubkey.toLowerCase()]); |
| 62 | tags.push(["p" , target.pubkey.toLowerCase()]); |
| 63 | if (target.kind != null) tags.push(["k" , String(target.kind)]); |
| 64 | } |
| 65 | const rumorContent = input.reaction ? String(input.reaction.content ?? content) : content; |
| 66 | if (!rumorContent || contentBytes(rumorContent) > MAX_CONTENT) throw Error("message content is empty or too large" ); |
| 67 | const rumor = {pubkey: sender, created_at: Math.floor(now), kind, tags, content: rumorContent}; |
| 68 | rumor.id = eventHash(signerAPI(), rumor); |
| 69 | // Keep both encryption layers within the original NIP-44 payload limit. |
| 70 | if (contentBytes(JSON.stringify(rumor)) > 40 * 1024) throw Error("Message is too long to send. Split it into shorter messages." ); |
| 71 | const sealFor = async target => { |
| 72 | const encrypted = await nip44Encrypt(signer, target, JSON.stringify(rumor)); |
| 73 | const sealInput = {kind: 13, created_at: past(now), tags: [], content: encrypted}; |
| 74 | const seal = await signer.signEvent(sealInput); |
| 75 | if (!seal || seal.kind !== sealInput.kind || seal.tags.length || seal.content !== encrypted || seal.created_at !== sealInput.created_at || seal.pubkey !== sender || !signerAPI().verifyEvent(seal)) throw Error("invalid NIP-59 seal" ); |
| 76 | return seal; |
| 77 | }; |
| 78 | const wrap = async (target, seal) => { |
| 79 | const api = signerAPI(); |
| 80 | const secret = api.generateSecretKey(); |
| 81 | const ephemeral = api.getPublicKey(secret); |
| 82 | const key = api.nip44.getConversationKey(secret, target); |
| 83 | const encrypted = api.nip44.encrypt(JSON.stringify(seal), key); |
| 84 | const event = api.finalizeEvent({kind: 1059, created_at: past(now), tags: [["p" , target]], content: encrypted}, secret); |
| 85 | if (!api.verifyEvent(event)) throw Error("invalid NIP-59 gift wrap" ); |
| 86 | return {recipient: target, event}; |
| 87 | }; |
| 88 | const [receiverSeal, senderSeal] = await Promise.all([sealFor(recipient), sealFor(sender)]); |
| 89 | return {rumor, events: [await wrap(recipient, receiverSeal), await wrap(sender, senderSeal)]}; |
| 90 | } |
| 91 | |
| 92 | async function decrypt(wrap, options = {}) { |
| 93 | const signer = options.signer || globalThis.tiny?.signer?.(); |
| 94 | if (!wrap || wrap.kind !== 1059 || !hex64(wrap.pubkey) || !Array.isArray(wrap.tags) || wrap.tags.some(tag => !Array.isArray(tag) || tag.some(value => typeof value !== "string" ))) |
| 95 | throw Error("malformed gift wrap" ); |
| 96 | const actor = await signerPublicKey(signer); |
| 97 | const recipients = wrap.tags.filter(tag => tag[0] === "p" ); |
| 98 | if (recipients.length !== 1 || !hex64(recipients[0][1])) throw Error("malformed gift wrap recipient" ); |
| 99 | const recipient = normalizePubkey(recipients[0][1]); |
| 100 | if (recipient !== actor || (options.actor && normalizePubkey(options.actor) !== actor)) throw Error("gift wrap is not addressed to this signer" ); |
| 101 | if (typeof wrap.content !== "string" || wrap.content.length > 256 * 1024) throw Error("gift wrap is too large" ); |
| 102 | if (!signerAPI().verifyEvent(wrap) || wrap.id !== eventHash(signerAPI(), {...wrap, id: undefined})) throw Error("invalid gift wrap signature" ); |
| 103 | let seal; |
| 104 | try { seal = JSON.parse(await nip44Decrypt(signer, wrap.pubkey, wrap.content)); } catch { throw Error("unable to decrypt gift wrap" ); } |
| 105 | if (!seal || seal.kind !== 13 || !hex64(seal.pubkey) || !Array.isArray(seal.tags) || seal.tags.length > MAX_TAGS || seal.tags.some(tag => !Array.isArray(tag) || tag.length < 1 || tag.length > 32 || tag.some(value => typeof value !== "string" )) || typeof seal.content !== "string" || seal.content.length > 256 * 1024 || !signerAPI().verifyEvent(seal)) throw Error("malformed seal" ); |
| 106 | const expirations = seal.tags.filter(tag => tag[0] === "expiration" ); |
| 107 | if (expirations.some(tag => tag.length !== 2 || !/^\d+$/.test(tag[1]) || Number(tag[1]) < Math.floor(Date.now() / 1000))) throw Error("gift wrap has expired" ); |
| 108 | let rumor; |
| 109 | try { rumor = JSON.parse(await nip44Decrypt(signer, seal.pubkey, seal.content)); } catch { throw Error("unable to decrypt seal" ); } |
| 110 | if (!rumor || ![5, 7, 14, 15].includes(rumor.kind) || !hex64(rumor.pubkey) || Object.prototype.hasOwnProperty.call(rumor, "sig" ) || !Number.isSafeInteger(rumor.created_at) || rumor.created_at < 0 || rumor.created_at > 8640000000000 || typeof rumor.content !== "string" || contentBytes(rumor.content) > MAX_CONTENT || !Array.isArray(rumor.tags) || rumor.tags.length > MAX_TAGS || rumor.tags.reduce((total, tag) => total + JSON.stringify(tag).length, 0) > 16 * 1024 || rumor.tags.some(tag => !Array.isArray(tag) || tag.length < 1 || tag.length > 32 || tag.some(value => typeof value !== "string" )) || rumor.id !== eventHash(signerAPI(), {...rumor, id: undefined}) || seal.pubkey !== rumor.pubkey) |
| 111 | throw Error("malformed direct message rumor" ); |
| 112 | const addressed = rumor.tags.filter(tag => Array.isArray(tag) && tag[0] === "p" && hex64(tag[1])).map(tag => tag[1].toLowerCase()); |
| 113 | const selfCopy = rumor.pubkey === actor; |
| 114 | const interactionTarget = [5, 7].includes(rumor.kind) && rumor.tags.some(tag => (tag[0] === "e" || tag[0] === "q" ) && hex64(tag[1])); |
| 115 | if (![5, 7].includes(rumor.kind) && (!addressed.includes(actor) && !selfCopy || options.actor && !addressed.includes(normalizePubkey(options.actor)) && !selfCopy)) throw Error("rumor recipient mismatch" ); |
| 116 | if ([5, 7].includes(rumor.kind) && !interactionTarget) throw Error("interaction target is missing" ); |
| 117 | return {rumor, seal, wrap, sender: rumor.pubkey}; |
| 118 | } |
| 119 | |
| 120 | const files = () => globalThis.tiny?.files?.messages; |
| 121 | const protocol = Object.freeze({ |
| 122 | normalizePubkey, |
| 123 | build, |
| 124 | decrypt, |
| 125 | relayList: (...args) => files()?.relayList(...args), |
| 126 | queryRelayList: (...args) => files()?.queryRelayList(...args), |
| 127 | publishEvent: (...args) => files()?.publishEvent(...args) |
| 128 | }); |
| 129 | globalThis.tiny = globalThis.tiny || {}; |
| 130 | globalThis.tiny.chat = {...globalThis.tiny.chat, protocol}; |
| 131 | })(); |