| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools" ; |
| 6 | |
| 7 | globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data }; |
| 8 | globalThis.tiny = {files: {}}; |
| 9 | vm.runInThisContext(fs.readFileSync("tinyclient/tiny.js" , "utf8" ), { filename: "tiny.js" }); |
| 10 | vm.runInThisContext(fs.readFileSync("tinyclient/file-messages.js" , "utf8" ), { filename: "file-messages.js" }); |
| 11 | const fileMessages = globalThis.tiny.files.messages; |
| 12 | |
| 13 | const alice = generateSecretKey(); |
| 14 | const bob = generateSecretKey(); |
| 15 | const alicePub = getPublicKey(alice); |
| 16 | const bobPub = getPublicKey(bob); |
| 17 | const signer = { |
| 18 | async getPublicKey() { return alicePub; }, |
| 19 | async signEvent(event) { return finalizeEvent(event, alice); }, |
| 20 | async nip44Encrypt(recipient, plaintext) { return nip44.encrypt(plaintext, nip44.getConversationKey(alice, recipient)); }, |
| 21 | }; |
| 22 | const list = (pubkey, relays = ["wss://inbox.example" ]) => finalizeEvent({ kind: 10050, tags: relays.map((relay) => ["relay" , relay]), content: "" , created_at: 100 }, pubkey === alicePub ? alice : bob); |
| 23 | |
| 24 | test("build creates independently decryptable NIP-17 file gift wraps" , async () => { |
| 25 | const result = await fileMessages.build({ fileURL: "https://files.example/abc" , ciphertextHash: "a" .repeat(64), plaintextHash: "b" .repeat(64), mimeType: "image/png" , key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { signer, now: 200 }); |
| 26 | assert.equal(result.rumor.pubkey, alicePub); |
| 27 | assert.equal(result.rumor.id, getEventHash(result.rumor)); |
| 28 | assert.equal(result.rumor.kind, 15); |
| 29 | assert.equal(result.rumor.tags.find((tag) => tag[0] === "x" )[1], "a" .repeat(64)); |
| 30 | assert.equal(result.events.length, 2); |
| 31 | const receiver = result.events[0].event; |
| 32 | assert.equal(receiver.kind, 1059); |
| 33 | assert.deepEqual(receiver.tags, [["p" , bobPub]]); |
| 34 | assert.notEqual(receiver.pubkey, result.events[1].event.pubkey); |
| 35 | const sealJSON = nip44.decrypt(receiver.content, nip44.getConversationKey(bob, receiver.pubkey)); |
| 36 | const seal = JSON.parse(sealJSON); |
| 37 | assert.equal(seal.kind, 13); |
| 38 | assert.deepEqual(seal.tags, []); |
| 39 | assert.equal(seal.pubkey, alicePub); |
| 40 | assert.equal(verifyEvent(seal), true); |
| 41 | const rumor = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(bob, alicePub))); |
| 42 | assert.equal(rumor.pubkey, alicePub); |
| 43 | assert.equal(rumor.content, "https://files.example/abc" ); |
| 44 | assert.equal("" + receiver.content.includes("11" .repeat(32)), "false" ); |
| 45 | }); |
| 46 | |
| 47 | test("share requires valid inbox lists and publishes only to those lists" , async () => { |
| 48 | const published = []; |
| 49 | const result = await fileMessages.share({ fileURL: "https://files.example/a" , ciphertextHash: "c" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { |
| 50 | signer, |
| 51 | queryRelayList: async (pubkey) => list(pubkey, pubkey === bobPub ? ["wss://bob.example" ] : ["ws://127.0.0.1:7777" ]), |
| 52 | publish: async (event, relays) => { published.push({ event, relays }); return true; }, |
| 53 | now: 200, |
| 54 | }); |
| 55 | assert.equal(published.length, 2); |
| 56 | assert.deepEqual(published[0].relays, ["wss://bob.example" ]); |
| 57 | assert.deepEqual(published[1].relays, ["ws://127.0.0.1:7777" ]); |
| 58 | assert.deepEqual(result.relays.recipient, ["wss://bob.example" ]); |
| 59 | }); |
| 60 | |
| 61 | test("share refuses to send when either inbox list is absent" , async () => { |
| 62 | let sends = 0; |
| 63 | await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a" , ciphertextHash: "d" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { |
| 64 | signer, |
| 65 | queryRelayList: async () => null, |
| 66 | publish: async () => { sends++; }, |
| 67 | }), /valid kind 10050/); |
| 68 | assert.equal(sends, 0); |
| 69 | }); |
| 70 | |
| 71 | test("share accepts npub recipients and rejects signer identity mismatches" , async () => { |
| 72 | const published = []; |
| 73 | await fileMessages.share({ fileURL: "https://files.example/a" , ciphertextHash: "e" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: nip19.npubEncode(bobPub) }, { |
| 74 | signer, |
| 75 | queryRelayList: async (pubkey) => list(pubkey), |
| 76 | publish: async (event) => { published.push(event); return true; }, |
| 77 | }); |
| 78 | assert.equal(published.length, 2); |
| 79 | const mismatched = { ...signer, async signEvent(event) { return finalizeEvent(event, bob); } }; |
| 80 | await assert.rejects(() => fileMessages.build({ fileURL: "https://files.example/a" , ciphertextHash: "f" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { signer: mismatched }), /invalid NIP-17 seal/); |
| 81 | }); |
| 82 | |
| 83 | test("share rejects an all-failed publish result" , async () => { |
| 84 | await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a" , ciphertextHash: "1" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { |
| 85 | signer, |
| 86 | queryRelayList: async (pubkey) => list(pubkey), |
| 87 | publish: async () => [false, false], |
| 88 | }), /every inbox relay/); |
| 89 | }); |
| 90 | |
| 91 | test("default transport queries locally and publishes only inbox WebSockets" , async () => { |
| 92 | const queries = []; |
| 93 | const sockets = []; |
| 94 | globalThis.tiny = { |
| 95 | signedFetch: async (path, method, body) => { |
| 96 | queries.push({ path, method, body }); |
| 97 | const author = JSON.parse(body)[0].authors[0]; |
| 98 | return { json: async () => [list(author, [author === bobPub ? "wss://bob.example" : "wss://alice.example" ]) ] }; |
| 99 | }, |
| 100 | }; |
| 101 | globalThis.WebSocket = class { |
| 102 | constructor(url) { this.url = url; sockets.push(this); setTimeout(() => this.onopen?.(), 0); } |
| 103 | send(data) { const event = JSON.parse(data)[1]; setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK" , event.id, true, "" ]) }), 0); } |
| 104 | close() { this.closed = true; } |
| 105 | }; |
| 106 | const result = await fileMessages.share({ fileURL: "https://files.example/a" , ciphertextHash: "2" .repeat(64), key: "11" .repeat(32), nonce: "22" .repeat(12), recipient: bobPub }, { signer, timeoutMs: 1000 }); |
| 107 | assert.equal(queries.length, 2); |
| 108 | assert.deepEqual(sockets.map((socket) => socket.url), ["wss://bob.example" , "wss://alice.example" ]); |
| 109 | assert.deepEqual(result.deliveries, [[true], [true]]); |
| 110 | delete globalThis.tiny; |
| 111 | delete globalThis.WebSocket; |
| 112 | }); |
| 113 | |
| 114 | test("default transport answers one NIP-42 challenge and retries the event" , async () => { |
| 115 | let eventAttempts = 0; |
| 116 | globalThis.WebSocket = class { |
| 117 | constructor() { setTimeout(() => this.onopen?.(), 0); } |
| 118 | send(data) { |
| 119 | const frame = JSON.parse(data); |
| 120 | if (frame[0] === "EVENT" ) { |
| 121 | eventAttempts++; |
| 122 | if (eventAttempts === 1) { |
| 123 | setTimeout(() => this.onmessage?.({ data: JSON.stringify(["AUTH" , "challenge" ]) }), 0); |
| 124 | setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK" , frame[1].id, false, "auth-required" ]) }), 1); |
| 125 | } |
| 126 | else setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK" , frame[1].id, true, "" ]) }), 0); |
| 127 | } else if (frame[0] === "AUTH" ) setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK" , frame[1].id, true, "" ]) }), 0); |
| 128 | } |
| 129 | close() {} |
| 130 | }; |
| 131 | const result = await fileMessages.publishEvent({ id: "1" .repeat(64) }, ["wss://inbox.example" ], { signer, timeoutMs: 1000 }); |
| 132 | assert.deepEqual(result, [true]); |
| 133 | assert.equal(eventAttempts, 2); |
| 134 | delete globalThis.WebSocket; |
| 135 | }); |