// Unit tests: web/js must reproduce test/vectors.json byte for byte, plus // protocol-level checks for frontmatter, addresses and rotation chains. // Run: node test/vectors.test.mjs import { readFileSync } from "node:fs"; import * as crypto from "../web/js/crypto.js"; import * as noise from "../web/js/noise.js"; import * as proto from "../web/js/proto.js"; const vectors = JSON.parse(readFileSync(new URL("./vectors.json", import.meta.url), "utf8")); const unhex = crypto.unhex; const hex = crypto.hex; let passed = 0; function ok(name, condition, detail = "") { if (!condition) throw new Error(`FAIL ${name} ${detail}`); passed++; } const eq = (name, got, want) => ok(name, got === want, `got ${got}, want ${want}`); // --- hashes, HMAC/HKDF, AEAD -------------------------------------------------- for (const v of vectors.sha256) eq(`sha256[${v.in.slice(0, 12)}]`, hex(crypto.sha256(unhex(v.in))), v.out); for (const v of vectors.sha512) eq(`sha512[${v.in.slice(0, 12)}]`, hex(crypto.sha512(unhex(v.in))), v.out); for (const v of vectors.hkdf) eq(`hkdf[${v.name}]`, hex(crypto.hkdfSha256(unhex(v.ikm), unhex(v.salt), unhex(v.info), v.len)), v.out); for (const v of vectors.aead) { const sealed = crypto.aeadEncrypt(unhex(v.key), unhex(v.nonce), unhex(v.plaintext), unhex(v.aad)); eq(`aead-seal[${v.name}]`, hex(sealed), v.sealed); eq(`aead-open[${v.name}]`, hex(crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed), unhex(v.aad))), v.plaintext); let threw = false; try { crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed).slice(0, -1), unhex(v.aad)); } catch { threw = true; } ok(`aead-tamper[${v.name}]`, threw); } // --- X25519 ------------------------------------------------------------------- const x = Object.fromEntries(vectors.x25519.map(v => [v.name, v])); for (const name of ["alice", "bob"]) eq(`x25519-base[${name}]`, hex(crypto.x25519Base(unhex(x[name].priv))), x[name].pub); eq("x25519-agree", hex(crypto.x25519(unhex(x.alice.priv), unhex(x.bob.pub))), x.agree.shared); for (const v of vectors.x25519.filter(v => v.name.startsWith("low-order"))) { let threw = false; try { crypto.x25519(unhex(x.alice.priv), unhex(v.peer)); } catch { threw = true; } ok(`x25519-rejects[${v.name}]`, threw); } // --- Ed25519 ------------------------------------------------------------------ for (const v of vectors.ed25519) { const seed = unhex(v.seed); eq(`ed25519-pub[${v.name}]`, hex(crypto.ed25519PublicKey(seed)), v.pub); const sig = crypto.ed25519Sign(seed, unhex(v.message)); if (v.valid) { eq(`ed25519-sign[${v.name}]`, hex(sig), v.signature); ok(`ed25519-verify[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), sig)); ok(`ed25519-verify-pynacl[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); } else { ok(`ed25519-reject[${v.name}]`, !crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); } } // --- §2 conversions ----------------------------------------------------------- for (const v of vectors.ed_to_x25519) { eq(`x_priv[${v.name}]`, hex(crypto.ed25519SeedToX25519(unhex(v.seed))), v.x_priv); eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub); } // --- §2/§5.8: master-derived rotation seeds and accept tokens ----------------- { const t = vectors.tokens; const master = unhex(t.master); eq("identity-seed-0", hex(proto.identitySeed(master, 0)), t.identity_seed_0); eq("identity-seed-1", hex(proto.identitySeed(master, 1)), t.identity_seed_1); eq("accept-key", hex(proto.acceptKeyFor(master)), t.accept_key); eq("token-for", hex(proto.tokenFor(master, unhex(t.correspondent))), t.token); eq("accept-mac", hex(proto.acceptMac(unhex(t.token), unhex(t.message_id))), t.accept_mac); } // --- §5 envelope -------------------------------------------------------------- { const v = vectors.envelope; const sender = proto.identityFromSeed(unhex(v.sender_seed)); const recipient = proto.identityFromSeed(unhex(v.recipient_seed)); const seal = (opts) => proto.seal(sender, recipient.publicKey, unhex(v.body), v.time, { esk: unhex(v.esk), ...opts }); eq("envelope", hex(seal({ pad: false })), v.envelope); eq("envelope-id", hex(proto.messageId(unhex(v.envelope))), v.id); const opened = proto.unseal([recipient], unhex(v.envelope)); eq("envelope-unseal-sender", hex(opened.sender), hex(sender.publicKey)); eq("envelope-unseal-time", String(opened.time), String(v.time)); eq("envelope-unseal-body", hex(opened.body), v.body); let threw = false; try { proto.unseal([proto.identityFromSeed(unhex(v.esk))], unhex(v.envelope)); } catch { threw = true; } ok("envelope-wrong-recipient", threw); // padding round-trips and is ignored by the receiver (§5.3) const padded = seal({}); // the padded envelope is 69 header + 16 tag + a multiple of 1 KiB of plaintext ok("envelope-padded", (padded.length - proto.ENVELOPE_HEADER - 16) % proto.PAD_TO === 0 && padded.length > v.envelope.length / 2); eq("envelope-padded-body", hex(proto.unseal([recipient], padded).body), v.body); } // --- Noise NX transcript ------------------------------------------------------ { const v = vectors.noise; ok("noise-transcript-selfcheck", v.assert_hash_equal); const nx = new noise.NxInitiator(); eq("noise-m1", hex(nx.writeMessage1(unhex(v.initiator_eph_priv))), v.message1); const ciphers = nx.readMessage2(unhex(v.message2)); eq("noise-server-static", hex(nx.serverStatic), v.server_static_pub); eq("noise-handshake-hash", hex(nx.handshakeHash), v.handshake_hash); for (const [i, frame] of v.initiator_frames.entries()) { eq(`noise-frame-i${i}`, hex(ciphers.send.encrypt(unhex(frame.plaintext))), frame.sealed); } for (const [i, frame] of v.responder_frames.entries()) { eq(`noise-frame-r${i}`, hex(ciphers.recv.decrypt(unhex(frame.sealed))), frame.plaintext); } // the responder direction must also produce identical ciphertexts (AEAD is // deterministic), so the recv cipher can be checked in both directions const mirrored = new noise.NxInitiator(); mirrored.writeMessage1(unhex(v.initiator_eph_priv)); const mirrorCiphers = mirrored.readMessage2(unhex(v.message2)); eq("noise-frame-r0-mirror", hex(mirrorCiphers.recv.encrypt(unhex(v.responder_frames[0].plaintext))), v.responder_frames[0].sealed); } // --- frontmatter (§5.5) ------------------------------------------------------- { const rid = "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; // §5.4: 64 hex chars const spec = `---\nSubject: Re: the thing\nIn-Reply-To: ${rid}\nX-Mood: cautiously optimistic\n---\nBody text starts here.`; const { fields, body } = proto.parseFrontmatter(spec); // §5.5: keys are compared case-insensitively, so they come back lowercased. eq("fm-subject", fields.subject, "Re: the thing"); eq("fm-reply", fields["in-reply-to"], rid); eq("fm-case-insensitive", proto.parseFrontmatter("---\nSUBJECT: hi\n---\nx").fields.subject, "hi"); eq("fm-body", body, "Body text starts here."); // a malformed line invalidates the whole block, which fails closed toward display eq("fm-malformed", proto.parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest"); eq("fm-unterminated", proto.parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end"); eq("fm-first-wins", proto.parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields.a, "1"); eq("fm-escape", proto.buildFrontmatter([], "---\nactual body"), "---\n---\n---\nactual body"); eq("fm-build", proto.buildFrontmatter([["Subject", "hi"]], "there"), "---\nSubject: hi\n---\nthere"); eq("fm-no-block", proto.buildFrontmatter([], "plain"), "plain"); eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none"); } // --- addresses (§3) ----------------------------------------------------------- { const a = proto.parseAddress("Alice@Example.ORG:1961"); eq("addr-user", a.user, "alice"); eq("addr-port", String(a.port), "1961"); eq("addr-short", a.short, "alice@example.org"); // a default port is dropped, as in the reference eq("addr-default-port", String(proto.parseAddress("bob@host").port), "1961"); const key = unhex(vectors.ed25519[0].pub); const parsed = proto.parseAddress(proto.parseAddress("bob@h").uri(key)); ok("addr-uri-roundtrip", key.every((b, i) => b === parsed.identity[i]) && parsed.user === "bob"); let threw = false; try { proto.parseAddress("-bob@h"); } catch { threw = true; } ok("addr-separator-rejected", threw); // §3: fingerprints are the first 20 base32 characters in groups of four const b32 = proto.b32encode(key); eq("fingerprint", proto.fingerprint(key), [b32.slice(0, 4), b32.slice(4, 8), b32.slice(8, 12), b32.slice(12, 16), b32.slice(16, 20)].join(" ")); for (const n of [1, 2, 5, 32, 52, 64]) { const raw = crypto.randomBytes(n); eq(`b32[${n}]`, proto.b32encode(proto.b32decode(proto.b32encode(raw))), proto.b32encode(raw)); } } // --- rotation chains (§7) ------------------------------------------------------ { const user = "alice"; const old = proto.identityFromSeed(crypto.randomBytes(32)); const mid = crypto.randomBytes(32); const fresh = crypto.randomBytes(32); const freshPub = crypto.ed25519PublicKey(fresh); const when = proto.nowSeconds(); const chain = [proto.makeCert(user, old, mid, when), proto.makeCert(user, proto.identityFromSeed(mid), fresh, when)]; ok("chain-valid", proto.walkChain(user, old.publicKey, freshPub, chain)); ok("chain-unchanged", proto.walkChain(user, old.publicKey, old.publicKey, [])); ok("chain-missing-link", !proto.walkChain(user, old.publicKey, freshPub, chain.slice(1))); ok("chain-wrong-username", !proto.walkChain("bob", old.publicKey, freshPub, chain)); const forged = chain.slice(); forged[1] = proto.makeCert(user, proto.identityFromSeed(mid), crypto.randomBytes(32), when); ok("chain-broken", !proto.walkChain(user, old.publicKey, freshPub, forged)); ok("chain-oversize", !proto.walkChain(user, old.publicKey, freshPub, Array(17).fill(chain[0]))); eq("cert-len", String(chain[0].length), "200"); } // --- response framing guards (§6.1) ------------------------------------------- // A Session over a scripted server. The cipher states are the identity, so the // bytes handed in are exactly what call() parses; readNoise wants at least 16, // and trailing bytes past the frame length are ignored by design. function scriptedSession(frame) { const message = crypto.concat(frame, new Uint8Array(Math.max(0, 16 - frame.length))); const wire = { queue: crypto.concat(proto.u16BE(message.length), message), pos: 0, async readExact(n) { if (this.pos + n > this.queue.length) throw new Error("script exhausted"); this.pos += n; return this.queue.slice(this.pos - n, this.pos); }, }; const passthrough = { encrypt: bytes => bytes, decrypt: bytes => bytes }; return new proto.Session(wire, { send() {}, close() {} }, passthrough, passthrough); } async function refuses(name, frame, op) { let threw = false; try { await scriptedSession(frame).call(op); } catch { threw = true; } ok(name, threw, "call resolved instead of throwing"); } // The shortest legal response is a type byte and a status byte. await refuses("frame-too-short", crypto.concat(proto.u32BE(1), Uint8Array.of(proto.OP.FETCH)), proto.OP.FETCH); // A response reuses the request's type byte; a mismatch means the session // desynchronised, which must not be read as a status. await refuses("frame-op-mismatch", crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.RESOLVE, 0)), proto.OP.FETCH); // The same frame with the right echo still passes, so the guard is not // simply rejecting everything. { const session = scriptedSession(crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.FETCH, 0))); eq("frame-op-echo-ok", (await session.call(proto.OP.FETCH)).status, 0); } console.log(`${passed} checks passed`);