clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / scripts / direct-messages.test.mjs
direct-messages.test.mjs 10.1K
1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";67globalThis.NostrSigner = {generateSecretKey, getPublicKey, getEventHash, finalizeEvent, verifyEvent, nip44, decodeNpub: value => nip19.decode(value).data};8globalThis.tiny = {util: {relayURL: value => value}, files: {messages: {normalizePubkey: value => /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : nip19.decode(value).data}}};9vm.runInThisContext(fs.readFileSync("tinyclient/direct-messages.js", "utf8"), {filename: "direct-messages.js"});1011const alice = generateSecretKey();12const bob = generateSecretKey();13const alicePub = getPublicKey(alice);14const bobPub = getPublicKey(bob);15const 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});2930// Model a NIP-07 signer: the shared wrappers pass peer and payload to these31// methods, while the signer retains the private key internally.32globalThis.tiny.nip44Encrypt = (peer, value, signer) => signer.nip44.encrypt(peer, value);33globalThis.tiny.nip44Decrypt = (peer, value, signer) => signer.nip44.decrypt(peer, value);34const nip07Signer = secret => {35 const base = makeSigner(secret);36 return {...base, nip44Encrypt: undefined, nip44Decrypt: undefined};37};3839test("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});4950test("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});5960test("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});6768test("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});7980test("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});99100test("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});106107test("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});116117test("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});121122test("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});136137test("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});144145test("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});153154test("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});