tinyrelayfile
tinyrelay/scripts/chat-files.test.mjs/

scripts/chat-files.test.mjs raw
1import assert from "node:assert/strict";
2import {webcrypto} from "node:crypto";
3import {readFile} from "node:fs/promises";
4import test from "node:test";
5import vm from "node:vm";
6
7const source = await readFile(new URL("../tinyclient/chat-files.js", import.meta.url), "utf8");
8const hex = value => Buffer.from(value).toString("hex");
9let DirectFile;
10const revoked = [];
11const sandbox = {
12 crypto: webcrypto, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, URL, Blob,
13 location: {origin: "https://relay.test"}, fetch: async () => { throw Error("unexpected fetch"); },
14 HTMLElement: class {}, customElements: {get: () => undefined, define(_name, ctor) { DirectFile = ctor; }},
15 document: {createElement: tag => ({localName: tag, append() {}, replaceChildren() {}, addEventListener() {}})}
16};
17sandbox.globalThis = sandbox;
18sandbox.tiny = {util: {
19 bytes: value => value instanceof Uint8Array ? value : new Uint8Array(value),
20 hex, sha256: async value => new Uint8Array(await webcrypto.subtle.digest("SHA-256", value))
21}};
22vm.runInNewContext(source, sandbox);
23const api = sandbox.tiny.chat.files;
24
25const makeFile = async (plain = new TextEncoder().encode("hello")) => {
26 const key = webcrypto.getRandomValues(new Uint8Array(32));
27 const nonce = webcrypto.getRandomValues(new Uint8Array(12));
28 const cryptoKey = await webcrypto.subtle.importKey("raw", key, {name: "AES-GCM"}, false, ["encrypt"]);
29 const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({name: "AES-GCM", iv: nonce}, cryptoKey, plain));
30 const digest = value => webcrypto.subtle.digest("SHA-256", value).then(hex);
31 return {plain, encrypted, key, nonce, hash: await digest(encrypted), plaintextHash: await digest(plain)};
32};
33
34test("parses interoperable NIP-17 kind 15 tags without exposing key in the node", async () => {
35 const file = await makeFile();
36 const rumor = {kind: 15, content: "https://cdn.example/file.mp4", tags: [
37 ["p", "a".repeat(64)], ["file-type", "video/mp4"], ["encryption-algorithm", "aes-gcm"],
38 ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash], ["size", String(file.encrypted.length)]
39 ]};
40 const parsed = api.parse(rumor);
41 assert.equal(parsed.mime, "video/mp4");
42 assert.equal(parsed.hash, file.hash);
43 assert.deepEqual(await api.decrypt(parsed, file.encrypted), file.plain);
44 const node = api.render(rumor);
45 assert.equal(node.rumor, rumor);
46});
47
48test("rejects credentialed, non-HTTP and malformed attachment URLs", () => {
49 const base = {kind: 15, tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", "a".repeat(64)], ["decryption-nonce", "b".repeat(24)], ["x", "c".repeat(64)]]};
50 for (const content of ["data:text/plain,secret", "javascript:alert(1)", "https://user:pass@example.test/a"]) {
51 assert.throws(() => api.parse({...base, content}), /URL/);
52 }
53 assert.throws(() => api.parse({...base, content: "https://example.test/a", tags: base.tags.map(tag => tag[0] === "x" ? ["x", "bad"] : tag)}), /hash/);
54});
55
56test("checks ciphertext and plaintext hashes and authenticated decryption", async () => {
57 const file = await makeFile();
58 const rumor = {kind: 15, content: "https://cdn.example/file.txt", tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash]]};
59 const parsed = api.parse(rumor);
60 const altered = new Uint8Array(file.encrypted); altered[0] ^= 1;
61 await assert.rejects(api.decrypt(parsed, altered), /hash/);
62 const wrongKey = {...parsed, key: new Uint8Array(32).fill(7)};
63 await assert.rejects(api.decrypt(wrongKey, file.encrypted), /decryption failed|plaintext hash mismatch/);
64});
65
66test("revokes attachment object URLs when its direct-file element disconnects", () => {
67 const oldRevoke = sandbox.URL.revokeObjectURL;
68 sandbox.URL.revokeObjectURL = value => revoked.push(value);
69 const node = new DirectFile();
70 node._attachmentURLs = new Set(["blob:attachment-1", "blob:attachment-2"]);
71 node.disconnectedCallback();
72 assert.deepEqual(revoked, ["blob:attachment-1", "blob:attachment-2"]);
73 sandbox.URL.revokeObjectURL = oldRevoke;
74});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs