tinyrelayfile
tinyrelay/scripts/direct-messages.test.mjs/

scripts/direct-messages.test.mjs raw
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";
6
7globalThis.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"});
10
11const 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});
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.
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};
38
39test("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
50test("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
60test("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
68test("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
80test("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
100test("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
107test("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
117test("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
122test("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
137test("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
145test("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
154test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs