gsmol/test/vectors.test.mjs

247 lines
12 KiB
JavaScript
Raw Normal View History

2026-10-09 13:47:42 +03:00
// 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`);