| 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, getPublicKey, getEventHash, finalizeEvent, verifyEvent, nip44, decodeNpub: value => nip19.decode(value).data}; |
| 8 | globalThis.tiny = {util: {relayURL: value => value}, files: {messages: {normalizePubkey: value => /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : nip19.decode(value).data}}}; |
| 9 | vm.runInThisContext(fs.readFileSync("tinyclient/direct-messages.js" , "utf8" ), {filename: "direct-messages.js" }); |
| 10 | |
| 11 | const alice = generateSecretKey(); |
| 12 | const bob = generateSecretKey(); |
| 13 | const alicePub = getPublicKey(alice); |
| 14 | const bobPub = getPublicKey(bob); |
| 15 | const makeSigner = secret => ({ |
| 16 | async getPublicKey() { return getPublicKey(secret); }, |
| 17 | async signEvent(unsigned) { return finalizeEvent(unsigned, secret); }, |
| 18 | async nip44Encrypt(peer, value) { return nip44.encrypt(value, nip44.getConversationKey(secret, peer)); }, |
| 19 | async nip44Decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); }, |
| 20 | nip44: { |
| 21 | getConversationKey(peer) { return nip44.getConversationKey(secret, peer); }, |
| 22 | encrypt(first, second) { |
| 23 | if (/^[0-9a-f]{64}$/i.test(first)) return nip44.encrypt(second, nip44.getConversationKey(secret, first)); |
| 24 | return nip44.encrypt(first, second); |
| 25 | }, |
| 26 | decrypt(peer, value) { return nip44.decrypt(value, nip44.getConversationKey(secret, peer)); }, |
| 27 | }, |
| 28 | }); |
| 29 | |
| 30 | // Model a NIP-07 signer: the shared wrappers pass peer and payload to these |
| 31 | // methods, while the signer retains the private key internally. |
| 32 | globalThis.tiny.nip44Encrypt = (peer, value, signer) => signer.nip44.encrypt(peer, value); |
| 33 | globalThis.tiny.nip44Decrypt = (peer, value, signer) => signer.nip44.decrypt(peer, value); |
| 34 | const nip07Signer = secret => { |
| 35 | const base = makeSigner(secret); |
| 36 | return {...base, nip44Encrypt: undefined, nip44Decrypt: undefined}; |
| 37 | }; |
| 38 | |
| 39 | test("build creates independently decryptable recipient and self-copy wraps" , async () => { |
| 40 | const protocol = globalThis.tiny.chat.protocol; |
| 41 | const result = await protocol.build({recipient: bobPub, content: "hello" , reply: {id: "a" .repeat(64), pubkey: alicePub}}, {signer: makeSigner(alice), now: 100}); |
| 42 | assert.equal(result.rumor.kind, 14); |
| 43 | assert.equal(result.rumor.id, getEventHash(result.rumor)); |
| 44 | assert.equal(result.events.length, 2); |
| 45 | assert.deepEqual(result.events.map(item => item.event.tags), [[["p" , bobPub]], [["p" , alicePub]]]); |
| 46 | assert.deepEqual((await protocol.decrypt(result.events[0].event, {signer: makeSigner(bob)})).rumor, result.rumor); |
| 47 | assert.deepEqual((await protocol.decrypt(result.events[1].event, {signer: makeSigner(alice)})).rumor, result.rumor); |
| 48 | }); |
| 49 | |
| 50 | test("decrypt rejects tampering, spoofed recipient and third-party access" , async () => { |
| 51 | const protocol = globalThis.tiny.chat.protocol; |
| 52 | const result = await protocol.build({recipient: bobPub, content: "private" }, {signer: makeSigner(alice), now: 100}); |
| 53 | const tampered = {...result.events[0].event, content: result.events[0].event.content + "x" }; |
| 54 | await assert.rejects(() => protocol.decrypt(tampered, {signer: makeSigner(bob)}), /invalid gift wrap|unable to decrypt/); |
| 55 | await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(alice)}), /not addressed/); |
| 56 | const carol = generateSecretKey(); |
| 57 | await assert.rejects(() => protocol.decrypt(result.events[0].event, {signer: makeSigner(carol)}), /not addressed|unable to decrypt/); |
| 58 | }); |
| 59 | |
| 60 | test("normalizes npub and preserves relay transport wrappers" , () => { |
| 61 | const protocol = globalThis.tiny.chat.protocol; |
| 62 | assert.equal(protocol.normalizePubkey(nip19.npubEncode(alicePub)), alicePub); |
| 63 | assert.equal(typeof protocol.relayList, "function" ); |
| 64 | assert.equal(typeof protocol.queryRelayList, "function" ); |
| 65 | assert.equal(typeof protocol.publishEvent, "function" ); |
| 66 | }); |
| 67 | |
| 68 | test("round trips a kind 15 file rumor through the NIP-07 path" , async () => { |
| 69 | vm.runInThisContext(fs.readFileSync("tinyclient/file-messages.js" , "utf8" ), {filename: "file-messages.js" }); |
| 70 | const file = await globalThis.tiny.files.messages.build({ |
| 71 | recipient: bobPub, fileURL: "https://files.example/photo.jpg" , ciphertextHash: "a" .repeat(64), |
| 72 | mimeType: "image/jpeg" , key: "b" .repeat(64), nonce: "c" .repeat(24) |
| 73 | }, {signer: nip07Signer(alice), now: 100}); |
| 74 | const direct = globalThis.tiny.chat.protocol; |
| 75 | const opened = await direct.decrypt(file.events[0].event, {signer: nip07Signer(bob)}); |
| 76 | assert.equal(opened.rumor.kind, 15); |
| 77 | assert.equal(opened.rumor.content, "https://files.example/photo.jpg" ); |
| 78 | }); |
| 79 | |
| 80 | test("rejects signed rumors, malformed timestamps and optional-tag abuse" , async () => { |
| 81 | const protocol = globalThis.tiny.chat.protocol; |
| 82 | const result = await protocol.build({recipient: bobPub, content: "bounded" }, {signer: nip07Signer(alice), now: 100}); |
| 83 | const opened = await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)}); |
| 84 | const makeWrap = rumor => { |
| 85 | const seal = finalizeEvent({kind: 13, created_at: 1, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(alice, bobPub))}, alice); |
| 86 | const ephemeral = generateSecretKey(); |
| 87 | const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, bobPub)); |
| 88 | return finalizeEvent({kind: 1059, created_at: 1, tags: [["p" , bobPub], ["expiration" , String(Math.floor(Date.now() / 1000) + 3600)]], content: wrapped}, ephemeral); |
| 89 | }; |
| 90 | const signed = {...opened.rumor, sig: "f" .repeat(128)}; |
| 91 | signed.id = getEventHash(signed); |
| 92 | await assert.rejects(() => protocol.decrypt(makeWrap(signed), {signer: nip07Signer(bob)}), /malformed direct message/); |
| 93 | const badTime = {...opened.rumor, created_at: 1.5}; |
| 94 | badTime.id = getEventHash(badTime); |
| 95 | await assert.rejects(() => protocol.decrypt(makeWrap(badTime), {signer: nip07Signer(bob)}), /malformed direct message/); |
| 96 | const duplicateP = {...result.events[0].event, tags: [["p" , bobPub], ["p" , bobPub]]}; |
| 97 | await assert.rejects(() => protocol.decrypt(duplicateP, {signer: nip07Signer(bob)}), /recipient/); |
| 98 | }); |
| 99 | |
| 100 | test("rejects a signer that mutates the requested seal" , async () => { |
| 101 | const protocol = globalThis.tiny.chat.protocol; |
| 102 | const evil = nip07Signer(alice); |
| 103 | evil.signEvent = async unsigned => finalizeEvent({...unsigned, content: unsigned.content + " altered" }, alice); |
| 104 | await assert.rejects(() => protocol.build({recipient: bobPub, content: "do not alter" }, {signer: evil}), /invalid NIP-59 seal/); |
| 105 | }); |
| 106 | |
| 107 | test("round trips an encrypted NIP-25 reaction in both copies" , async () => { |
| 108 | const protocol = globalThis.tiny.chat.protocol; |
| 109 | const target = {id: "d" .repeat(64), pubkey: alicePub, kind: 14}; |
| 110 | const result = await protocol.build({recipient: bobPub, reaction: {...target, content: "+" }}, {signer: nip07Signer(alice), now: 100}); |
| 111 | assert.equal(result.rumor.kind, 7); |
| 112 | assert.deepEqual(result.rumor.tags.slice(1), [["e" , target.id, "" , "root" , target.pubkey], ["p" , target.pubkey], ["k" , "14" ]]); |
| 113 | assert.equal((await protocol.decrypt(result.events[0].event, {signer: nip07Signer(bob)})).rumor.content, "+" ); |
| 114 | assert.equal((await protocol.decrypt(result.events[1].event, {signer: nip07Signer(alice)})).rumor.kind, 7); |
| 115 | }); |
| 116 | |
| 117 | test("rejects reactions with malformed targets" , async () => { |
| 118 | const protocol = globalThis.tiny.chat.protocol; |
| 119 | await assert.rejects(() => protocol.build({recipient: bobPub, reaction: {id: "nope" , pubkey: alicePub, content: "+" }}, {signer: nip07Signer(alice)}), /reaction target/); |
| 120 | }); |
| 121 | |
| 122 | test("accepts an inbound Flotilla reaction with only the target-author p tag" , async () => { |
| 123 | const protocol = globalThis.tiny.chat.protocol; |
| 124 | const target = {id: "e" .repeat(64), pubkey: bobPub, kind: 14}; |
| 125 | const signer = nip07Signer(alice); |
| 126 | const rumor = {pubkey: bobPub, created_at: 100, kind: 7, tags: [["e" , target.id, "" , "root" ], ["p" , bobPub], ["k" , "14" ]], content: "+" }; |
| 127 | rumor.id = getEventHash(rumor); |
| 128 | const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob); |
| 129 | const ephemeral = generateSecretKey(); |
| 130 | const wrapped = nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(ephemeral, alicePub)); |
| 131 | const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p" , alicePub]], content: wrapped}, ephemeral); |
| 132 | const opened = await protocol.decrypt(gift, {signer}); |
| 133 | assert.equal(opened.rumor.pubkey, bobPub); |
| 134 | assert.equal(opened.rumor.kind, 7); |
| 135 | }); |
| 136 | |
| 137 | test("large escaped messages are rejected before asking the signer to encrypt" , async () => { |
| 138 | let calls = 0; |
| 139 | const signer = nip07Signer(alice); |
| 140 | signer.nip44.encrypt = () => { calls++; throw Error("must not reach encryption" ); }; |
| 141 | await assert.rejects(() => tiny.chat.protocol.build({recipient: bobPub, content: "\n" .repeat(24000)}, {signer}), /too long/); |
| 142 | assert.equal(calls, 0); |
| 143 | }); |
| 144 | |
| 145 | test("accepts an encrypted author retraction without a conversation p tag" , async () => { |
| 146 | const rumor = {pubkey: bobPub, created_at: 100, kind: 5, tags: [["e" , "e" .repeat(64)]], content: "" }; |
| 147 | rumor.id = getEventHash(rumor); |
| 148 | const seal = finalizeEvent({kind: 13, created_at: 100, tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(bob, alicePub))}, bob); |
| 149 | const secret = generateSecretKey(); |
| 150 | const gift = finalizeEvent({kind: 1059, created_at: 100, tags: [["p" , alicePub]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(secret, alicePub))}, secret); |
| 151 | assert.deepEqual((await tiny.chat.protocol.decrypt(gift, {signer: nip07Signer(alice)})).rumor, rumor); |
| 152 | }); |
| 153 | |
| 154 | test("decrypts independently generated Flotilla/Welshman gift wraps" , async () => { |
| 155 | const fixture = JSON.parse(fs.readFileSync("scripts/fixtures/flotilla-gift-wraps.json" , "utf8" )); |
| 156 | const signer = nip07Signer(Uint8Array.from(Buffer.from(fixture.recipientSecret, "hex" ))); |
| 157 | const kinds = []; |
| 158 | for (const {gift, rumor} of fixture.vectors) { |
| 159 | assert.equal(verifyEvent(gift), true); |
| 160 | const opened = await tiny.chat.protocol.decrypt(gift, {signer}); |
| 161 | assert.deepEqual(opened.rumor, rumor); |
| 162 | kinds.push(rumor.kind); |
| 163 | } |
| 164 | assert.deepEqual(kinds, [14, 14, 7, 15, 5]); |
| 165 | }); |