| 1 | import assert from "node:assert/strict" ; |
| 2 | import {webcrypto} from "node:crypto" ; |
| 3 | import {readFile} from "node:fs/promises" ; |
| 4 | import test from "node:test" ; |
| 5 | import vm from "node:vm" ; |
| 6 | |
| 7 | const source = await readFile(new URL("../tinyclient/chat-files.js" , import.meta.url), "utf8" ); |
| 8 | const hex = value => Buffer.from(value).toString("hex" ); |
| 9 | let DirectFile; |
| 10 | const revoked = []; |
| 11 | const 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 | }; |
| 17 | sandbox.globalThis = sandbox; |
| 18 | sandbox.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 | }}; |
| 22 | vm.runInNewContext(source, sandbox); |
| 23 | const api = sandbox.tiny.chat.files; |
| 24 | |
| 25 | const 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 | |
| 34 | test("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 | |
| 48 | test("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 | |
| 56 | test("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 | |
| 66 | test("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 | }); |