1/* NIP-17 direct messages: kind 14 rumors, NIP-59 seals and gift wraps. */2(() => {3 "use strict";45 // 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));4142 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 }9192 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 }119120 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})();