// 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); } // --- §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 spec = "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; const { fields, body } = proto.parseFrontmatter(spec); eq("fm-subject", fields.Subject, "Re: the thing"); eq("fm-reply", fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); 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 old = proto.identityFromSeed(crypto.randomBytes(32)); const mid = crypto.randomBytes(32); const midPub = crypto.ed25519PublicKey(mid); const fresh = crypto.randomBytes(32); const freshPub = crypto.ed25519PublicKey(fresh); const when = proto.nowSeconds(); const chain = [proto.makeCert(old, mid, when), proto.makeCert(proto.identityFromSeed(mid), fresh, when)]; ok("chain-valid", proto.walkChain(old.publicKey, freshPub, chain)); ok("chain-unchanged", proto.walkChain(old.publicKey, old.publicKey, [])); ok("chain-missing-link", !proto.walkChain(old.publicKey, freshPub, chain.slice(1))); const forged = chain.slice(); forged[1] = proto.makeCert(proto.identityFromSeed(mid), crypto.randomBytes(32), when); ok("chain-broken", !proto.walkChain(old.publicKey, freshPub, forged)); ok("chain-oversize", !proto.walkChain(old.publicKey, freshPub, Array(17).fill(chain[0]))); eq("cert-len", String(chain[0].length), "136"); } console.log(`${passed} checks passed`);