| 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 | })(); |