tinyrelayfile
tinyrelay/tinyclient/file-messages.js/

tinyclient/file-messages.js raw
1/* NIP-17 file messages. The module builds encrypted rumors and gift-wrap
2 events, discovers inbox relays and sends bounded deliveries. */
3(() => {
4 "use strict";
5 const {relayURL} = globalThis.tiny.util;
6 const {signer: activeSigner, nip44Encrypt} = globalThis.tiny;
7
8 const MAX_RELAYS = 8;
9 const DAY = 86400;
10
11 function api() {
12 const signer = globalThis.NostrSigner;
13 if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {
14 throw Error("NIP-44 support is unavailable");
15 }
16 return signer;
17 }
18
19 function relayList(event, pubkey) {
20 if (Array.isArray(event)) {
21 event = event
22 .filter(candidate => candidate?.kind === 10050 && candidate.pubkey === pubkey)
23 .sort((a, b) => b.created_at - a.created_at)[0];
24 }
25 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {
26 throw Error("recipient has no valid kind 10050 relay list");
27 }
28 const signer = api();
29 if (!signer.verifyEvent(event)) throw Error("invalid kind 10050 relay list signature");
30 const relays = [
31 ...new Set(
32 event.tags
33 .filter(tag => tag[0] === "relay")
34 .map(tag => relayURL(tag[1]))
35 .filter(Boolean)
36 )
37 ];
38 if (!relays.length) throw Error("kind 10050 relay list is empty");
39 return relays.slice(0, MAX_RELAYS);
40 }
41
42 function normalizePubkey(value) {
43 const text = String(value || "").trim();
44 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();
45 const decoded = api().decodeNpub?.(text);
46 if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();
47 throw Error("public key must be a 64 character hex key or npub");
48 }
49
50 async function bounded(value, timeout, label) {
51 const ms = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;
52 let timer;
53 try {
54 return await Promise.race([
55 Promise.resolve(value),
56 new Promise((_, reject) => {
57 timer = setTimeout(() => reject(Error(label + " timed out")), ms);
58 })
59 ]);
60 } finally {
61 clearTimeout(timer);
62 }
63 }
64
65 function sent(result) {
66 if (result === false || result == null) return false;
67 if (Array.isArray(result)) return result.some(sent);
68 if (typeof result === "object" && "ok" in result) return result.ok === true;
69 return true;
70 }
71
72 function past(now) {
73 return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));
74 }
75
76 async function publicKey(signer, getPublicKey) {
77 const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();
78 if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw Error("invalid signer public key");
79 return key.toLowerCase();
80 }
81
82 async function queryRelayList(pubkey, options) {
83 if (!globalThis.tiny?.signedFetch) throw Error("signed local query is unavailable");
84 const response = await globalThis.tiny.signedFetch(
85 "/query",
86 "POST",
87 JSON.stringify([{kinds: [10050], authors: [pubkey], limit: 1}]),
88 {contentType: "application/json"}
89 );
90 return response.json();
91 }
92
93 function publishEvent(event, relays, options = {}) {
94 const signer = options.signer || activeSigner();
95 const WebSocketImpl = options.WebSocket || globalThis.WebSocket;
96 if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");
97 const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;
98 return Promise.all(
99 relays.map(
100 url =>
101 new Promise(resolve => {
102 let socket;
103 let timer;
104 let settled = false;
105 let authID = "";
106 let authAttempts = 0;
107 let eventSent = false;
108 const finish = ok => {
109 if (settled) return;
110 settled = true;
111 clearTimeout(timer);
112 try {
113 socket?.close();
114 } catch {}
115 resolve(ok);
116 };
117 const sendEvent = () => {
118 if (!settled && !eventSent) {
119 eventSent = true;
120 socket.send(JSON.stringify(["EVENT", event]));
121 }
122 };
123 try {
124 socket = new WebSocketImpl(url);
125 timer = setTimeout(() => finish(false), timeout);
126 socket.onopen = sendEvent;
127 socket.onmessage = async message => {
128 let frame;
129 try {
130 frame = JSON.parse(typeof message.data === "string" ? message.data : "");
131 } catch {
132 return;
133 }
134 if (settled || !Array.isArray(frame)) return;
135 if (frame[0] === "OK" && frame[1] === authID) {
136 if (frame[2] === true) {
137 eventSent = false;
138 sendEvent();
139 } else finish(false);
140 return;
141 }
142 if (frame[0] === "OK" && frame[1] === event.id) {
143 if (
144 frame[2] !== true &&
145 String(frame[3] || "")
146 .toLowerCase()
147 .startsWith("auth-required")
148 ) {
149 eventSent = false;
150 return;
151 }
152 finish(frame[2] === true);
153 return;
154 }
155 if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;
156 if (typeof frame[1] !== "string" || frame[1].length > 4096) {
157 finish(false);
158 return;
159 }
160 if (authAttempts++ >= 1) {
161 finish(false);
162 return;
163 }
164 try {
165 const auth = await signer.signEvent({
166 kind: 22242,
167 created_at: Math.floor(Date.now() / 1000),
168 tags: [
169 ["relay", url],
170 ["challenge", frame[1]],
171 ["method", "AUTH"]
172 ],
173 content: ""
174 });
175 if (
176 auth &&
177 !settled &&
178 auth.kind === 22242 &&
179 auth.content === "" &&
180 auth.tags.length === 3 &&
181 auth.tags[0][0] === "relay" &&
182 auth.tags[0][1] === url &&
183 auth.tags[1][0] === "challenge" &&
184 auth.tags[1][1] === frame[1] &&
185 auth.tags[2][0] === "method" &&
186 auth.tags[2][1] === "AUTH" &&
187 api().verifyEvent(auth) &&
188 auth.pubkey === (await publicKey(signer))
189 ) {
190 authID = auth.id;
191 socket.send(JSON.stringify(["AUTH", auth]));
192 } else finish(false);
193 } catch {
194 finish(false);
195 }
196 };
197 socket.onerror = () => finish(false);
198 socket.onclose = () => finish(false);
199 } catch {
200 finish(false);
201 }
202 })
203 )
204 );
205 }
206
207 function unsignedRumor(input, sender, now) {
208 if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw Error("fileURL must be an HTTP(S) URL");
209 if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw Error("ciphertextHash must be SHA-256 hex");
210 if (!input.key || !input.nonce) throw Error("encrypted file messages require a decryption key and nonce");
211 const tags = [["p", input.recipient]];
212 if (input.mimeType) tags.push(["file-type", input.mimeType]);
213 tags.push(["encryption-algorithm", "aes-gcm"]);
214 if (input.key) tags.push(["decryption-key", input.key]);
215 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);
216 tags.push(["x", input.ciphertextHash.toLowerCase()]);
217 if (input.plaintextHash) {
218 if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw Error("plaintextHash must be SHA-256 hex");
219 tags.push(["ox", input.plaintextHash.toLowerCase()]);
220 }
221 if (input.size != null) tags.push(["size", String(input.size)]);
222 const rumor = {
223 pubkey: sender,
224 created_at: Math.floor(now),
225 kind: 15,
226 tags,
227 content: input.fileURL
228 };
229 rumor.id = api().getEventHash(rumor);
230 return rumor;
231 }
232
233 const encryptFor = (signer, recipient, plaintext) => nip44Encrypt(recipient, plaintext, signer);
234
235 async function seal(rumor, recipient, signer, now) {
236 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));
237 const unsigned = {
238 kind: 13,
239 tags: [],
240 content,
241 created_at: past(now),
242 pubkey: rumor.pubkey
243 };
244 const event = await signer.signEvent(unsigned);
245 if (
246 !event ||
247 event.kind !== 13 ||
248 event.pubkey !== rumor.pubkey ||
249 event.tags.length ||
250 event.content !== unsigned.content ||
251 event.created_at !== unsigned.created_at
252 )
253 throw Error("signer returned an invalid NIP-17 seal");
254 if (!api().verifyEvent(event)) throw Error("signer returned an invalid seal signature");
255 return event;
256 }
257
258 function wrap(sealEvent, recipient, now) {
259 const signer = api();
260 const secret = signer.generateSecretKey();
261 const pubkey = signer.getPublicKey(secret);
262 const key = signer.nip44.getConversationKey(secret, recipient);
263 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);
264 const event = signer.finalizeEvent({kind: 1059, tags: [["p", recipient]], content, created_at: past(now)}, secret);
265 if (!signer.verifyEvent(event)) throw Error("failed to verify gift wrap");
266 return {event, secret, pubkey};
267 }
268
269 async function build(input, options = {}) {
270 const signer = options.signer || activeSigner();
271 if (
272 !signer ||
273 typeof signer.signEvent !== "function" ||
274 (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)
275 )
276 throw Error("a NIP-44 signer is required");
277 const recipient = normalizePubkey(input.recipient);
278 const sender = await publicKey(signer, options.getPublicKey);
279 const now = options.now || Date.now() / 1000;
280 const rumor = unsignedRumor({...input, recipient}, sender, now);
281 const receiverSeal = await seal(rumor, recipient, signer, now);
282 const senderSeal = await seal(rumor, sender, signer, now);
283 const receiver = wrap(receiverSeal, recipient, now).event;
284 const own = wrap(senderSeal, sender, now).event;
285 return {
286 rumor,
287 events: [
288 {recipient, event: receiver},
289 {recipient: sender, event: own}
290 ]
291 };
292 }
293
294 async function share(input, options = {}) {
295 const signer = options.signer || activeSigner();
296 const query = options.queryRelayList || (pubkey => queryRelayList(pubkey, options));
297 const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));
298 if (typeof query !== "function" || typeof publish !== "function")
299 throw Error("private NIP-17 delivery is unavailable");
300 const sender = await publicKey(signer, options.getPublicKey);
301 const recipient = normalizePubkey(input.recipient);
302 const timeout = options.timeoutMs;
303 const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);
304 const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);
305 const built = await build(input, options);
306 if (built.rumor.pubkey !== sender) throw Error("signer changed during file sharing; try again");
307 const deliveries = [];
308 for (const target of built.events) {
309 const relays = target.recipient === recipient ? receiverList : senderList;
310 const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");
311 if (!sent(delivery)) throw Error("NIP-17 delivery failed on every inbox relay");
312 deliveries.push(delivery);
313 }
314 return {
315 ...built,
316 relays: {recipient: receiverList, sender: senderList},
317 deliveries
318 };
319 }
320
321 const apiObject = Object.freeze({
322 build,
323 share,
324 normalizePubkey,
325 relayList,
326 queryRelayList,
327 publishEvent
328 });
329 globalThis.tiny = globalThis.tiny || {};
330 globalThis.tiny.files = {...globalThis.tiny.files, messages: apiObject};
331})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs