tinyrelayfile
tinyrelay/tinyclient/direct-messages.js/

tinyclient/direct-messages.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs