chore: flatten repository history

This commit is contained in:
randogoth 2026-10-09 13:47:42 +03:00
commit 3a03fab876
30 changed files with 7223 additions and 0 deletions

457
test/client.mjs Normal file
View file

@ -0,0 +1,457 @@
// Headless driver for the end-to-end test: the same web/js modules the browser
// uses, over a TCP socket (or, with GSMOL_TRANSPORT=ws, through the bridge's
// WebSocket relay — the exact byte path the browser takes).
//
// node test/client.mjs --state bob.json <command> [args]
//
// Commands mirror the reference smolmail.py: keygen, trust, register, resolve,
// import, accept, block, send, fetch, list, read, rotate. State lives in a
// JSON file; mail is stored sealed, like the browser store does.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import net from "node:net";
import { hex, timingSafeEqual, unhex, utf8Bytes } from "../web/js/crypto.js";
import * as proto from "../web/js/proto.js";
const { b32decode, b32encode, fingerprint } = proto;
const args = process.argv.slice(2);
const stateIndex = args.indexOf("--state");
const statePath = stateIndex >= 0 ? args[stateIndex + 1] : "client-state.json";
const command = args.find((a, i) => i !== stateIndex && i !== stateIndex + 1 && !a.startsWith("--"));
const rest = args.slice(args.indexOf(command) + 1);
const load = () => existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {};
const save = (state) => writeFileSync(statePath, JSON.stringify(state));
function parseOption(name) {
const i = rest.indexOf(name);
return i >= 0 ? rest[i + 1] : null;
}
// --- transports -------------------------------------------------------------
function connectTcp(host, port) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port });
socket.on("error", reject);
socket.on("connect", () => {
const stream = {
send: (bytes) => socket.write(bytes),
close: () => socket.end(),
onData: null,
onClose: null,
};
socket.on("data", (chunk) => stream.onData?.(new Uint8Array(chunk)));
socket.on("close", () => stream.onClose?.());
resolve(stream);
});
});
}
function connectWs(host, port) {
const bridge = process.env.GSMOL_BRIDGE || "ws://127.0.0.1:8096";
return new Promise((resolve, reject) => {
const socket = new WebSocket(`${bridge}/tcp/${host}/${port}`);
socket.binaryType = "arraybuffer";
const stream = {
send: (bytes) => socket.send(bytes),
close: () => socket.close(),
onData: null,
onClose: null,
};
socket.onerror = () => reject(new Error(`cannot reach the bridge at ${bridge}`));
socket.onopen = () => resolve(stream);
socket.onmessage = (event) => stream.onData?.(new Uint8Array(event.data));
socket.onclose = () => stream.onClose?.();
});
}
const connectStream = process.env.GSMOL_TRANSPORT === "ws" ? connectWs : connectTcp;
// --- session ------------------------------------------------------------------
async function open(host, port, { requirePin }) {
const state = load();
const pinned = state.servers?.[host] ? b32decode(state.servers[host]) : null;
if (requirePin && !pinned)
throw new proto.SmolError(`no pinned key for ${host}`);
const stream = await connectStream(host, port);
const opened = await proto.openSession(stream, host, pinned);
if (!pinned)
console.error(`warning: ${host} is not pinned; its key is ${b32encode(opened.serverStatic)}`);
return opened;
}
// §2: the identity at the state's current rotation index, plus every earlier
// one — mail sealed to a superseded key is readable with nothing else.
function currentIdentity(state) {
return proto.identityFromSeed(proto.identitySeed(unhex(state.master), state.rotations ?? 0));
}
function identities(state) {
const master = unhex(state.master);
const out = [];
for (let n = state.rotations ?? 0; n >= 0; n--) out.push(proto.identityFromSeed(proto.identitySeed(master, n)));
return out;
}
function cursor(state) {
return [state.afterTime ?? 0, state.afterId ? unhex(state.afterId) : new Uint8Array(proto.ID_LEN)];
}
// §4/§5.8: the accept tokens to push with AUTH, and whether to push at all —
// a client that cannot vouch for its own set (freshly restored) must not
// replace the server's with an incomplete one.
function tokenSet(state) {
if (state.syncOk === false) return { sync: 0, tokens: [] };
const accepted = Object.entries(state.accepted || {}).filter(([, a]) => a.active)
.sort(([a], [b]) => a.localeCompare(b));
const master = unhex(state.master);
return { sync: 1, tokens: accepted.map(([, a]) => proto.tokenFor(master, b32decode(a.identity))) };
}
function addressOfSigner(state, sender, replyToField) {
const known = Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(sender))?.[0];
if (known) return known;
if (!replyToField) return null;
try {
const parsed = proto.parseAddress(replyToField);
if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
} catch { /* malformed claim */ }
return null;
}
// --- commands -------------------------------------------------------------------
const commands = {
keygen() {
const state = load();
state.master = hex(cryptoRandom(32));
state.rotations = 0;
state.syncOk = true;
save(state);
const me = currentIdentity(state);
console.log(`public key: ${b32encode(me.publicKey)}`);
console.log(`fingerprint: ${fingerprint(me.publicKey)}`);
},
trust() {
const host = rest[0], key = b32decode(rest[1]);
if (key.length !== 32) throw new proto.SmolError(`server key is ${key.length} bytes, expected 32`);
const state = load();
state.servers = { ...(state.servers || {}), [host]: b32encode(key) };
save(state);
console.log(`pinned ${host} ${b32encode(key)}`);
},
async register() {
const state = load();
const me = currentIdentity(state);
const addr = proto.parseAddress(rest[0]);
const opened = await open(addr.host, addr.port, { requirePin: true });
try {
await proto.registerOp(opened.session, opened.serverStatic, {
username: addr.user, identity: me, token: parseOption("--token") || "",
});
} finally {
opened.session.stream.close();
}
state.account = { user: addr.user, host: addr.host, port: addr.port };
save(state);
console.log(`registered ${addr.short}`);
console.log(`share: ${addr.uri(me.publicKey)}`);
},
async resolve() {
const state = load();
const addr = proto.parseAddress(rest[0]);
const opened = await open(addr.host, addr.port, { requirePin: false });
let current, chain;
try {
({ identity: current, chain } = await proto.resolveOp(opened.session, addr.user));
} finally {
opened.session.stream.close();
}
const known = state.contacts?.[addr.short];
if (!known) {
state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(current), verified: false } };
save(state);
console.log(`${addr.short} ${b32encode(current)}`);
console.log(` pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
return;
}
if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`);
if (proto.walkChain(addr.user, b32decode(known.key), current, chain)) {
state.contacts[addr.short] = { key: b32encode(current), verified: known.verified };
save(state);
console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`);
return;
}
throw new proto.SmolError(`${addr.short} presents a different key with no valid rotation chain`);
},
import() {
const state = load();
const addr = proto.parseAddress(rest[0]);
if (!addr.identity) throw new proto.SmolError("import needs a smol:// address carrying a key");
state.contacts = {
...(state.contacts || {}),
[addr.short]: { key: b32encode(addr.identity), verified: true },
};
save(state);
console.log(`imported ${addr.short} ${b32encode(addr.identity)} (verified)`);
},
contacts() {
const state = load();
for (const [address, c] of Object.entries(state.contacts || {})) {
const accepted = state.accepted?.[address];
const tag = accepted ? (accepted.active ? "accepted" : "blocked") : "";
console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"} ${tag}`.trimEnd());
}
},
// §5.8: admit a contact to the main tier; their token travels in our next
// message to them. Pushed to the server right away.
async accept() {
const state = load();
const address = rest[0];
const contact = state.contacts?.[address];
if (!contact) throw new proto.SmolError(`no key for ${address} yet`);
state.accepted = { ...(state.accepted || {}),
[address]: { identity: state.accepted?.[address]?.identity ?? contact.key, active: true } };
state.syncOk = true;
save(state);
const held = await pushTokens(state);
console.log(`accepted ${address}; server now holds ${held} accept token(s)`);
},
// §5.8: withdraw a contact's accept token; their mail lands in requests
// from their next message on.
async block() {
const state = load();
const address = rest[0];
if (!state.accepted?.[address]) throw new proto.SmolError(`${address} was never accepted`);
state.accepted[address] = { ...state.accepted[address], active: false };
save(state);
const held = await pushTokens(state);
console.log(`blocked ${address}; server now holds ${held} accept token(s)`);
},
async send() {
const state = load();
const me = currentIdentity(state);
const master = unhex(state.master);
const addr = proto.parseAddress(rest[0]);
let recipient;
if (addr.identity) {
recipient = addr.identity;
state.contacts = {
...(state.contacts || {}),
[addr.short]: { key: b32encode(recipient), verified: true },
};
} else if (state.contacts?.[addr.short]) {
recipient = b32decode(state.contacts[addr.short].key);
} else {
const opened = await open(addr.host, addr.port, { requirePin: false });
try {
({ identity: recipient } = await proto.resolveOp(opened.session, addr.user));
} finally {
opened.session.stream.close();
}
state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(recipient), verified: false } };
console.error(`warning: ${addr.short} pinned (trust on first use)`);
}
save(state);
const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]]
.filter(([, v]) => v);
if (state.account && !rest.includes("--anonymous"))
fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]);
// §5.8: hand an accepted correspondent the token for our own mailbox.
const accepted = state.accepted?.[addr.short];
if (accepted?.active) fields.push(["Accept", b32encode(proto.tokenFor(master, b32decode(accepted.identity)))]);
const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n"));
const envelope = proto.seal(me, recipient, body);
// §5.8: our token for their mailbox, if they have given us one.
const held = state.tokens?.[addr.short];
const mac = held ? proto.acceptMac(b32decode(held.token), proto.messageId(envelope)) : null;
const opened = await open(addr.host, addr.port, { requirePin: false });
try {
await proto.sendOp(opened.session, envelope, mac);
} finally {
opened.session.stream.close();
}
state.sent = [...(state.sent || []), {
id: hex(proto.messageId(envelope)), recipient: addr.short,
envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(),
}];
save(state);
console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 16)} to ${addr.short}` +
(mac ? " (accepted)" : ""));
},
async fetch() {
const state = load();
const me = currentIdentity(state);
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
const ids = identities(state);
const keep = rest.includes("--keep");
if (rest.includes("--reset")) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
let [afterTime, afterId] = keep ? cursor(state) : [0, new Uint8Array(proto.ID_LEN)];
const opened = await open(account.host, account.port, { requirePin: true });
let total = 0, stored = 0, rejected = 0;
try {
const { sync, tokens } = tokenSet(state);
await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
for (;;) {
const records = await proto.fetchOp(opened.session, afterTime, afterId);
if (!records.length) break;
const acked = [];
for (const record of records) {
total++;
afterTime = record.receivedAt;
afterId = record.id;
let unsealed;
try {
if (!timingSafeEqual(proto.messageId(record.envelope), record.id))
throw new proto.SmolError("id does not match the envelope");
unsealed = proto.unseal(ids, record.envelope);
} catch (err) {
rejected++;
console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`);
continue;
}
if (!state.inbox) state.inbox = [];
if (!state.inbox.some(m => m.id === hex(record.id))) {
state.inbox.push({
id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt,
isRequest: record.isRequest, keptOnServer: keep,
});
stored++;
const { fields } = proto.parseFrontmatter(new TextDecoder().decode(unsealed.body));
const raw = fields.accept;
if (raw) {
try {
const token = b32decode(raw);
if (token.length === proto.TOKEN_LEN) {
const address = addressOfSigner(state, unsealed.sender, fields["reply-to"]);
if (address) state.tokens = { ...(state.tokens || {}), [address]: { token: b32encode(token) } };
}
} catch { /* malformed token: ignore */ }
}
}
acked.push(record.id);
}
if (keep) {
state.afterTime = afterTime;
state.afterId = hex(afterId);
} else if (acked.length) {
await proto.deleteOp(opened.session, acked);
}
}
} finally {
opened.session.stream.close();
}
if (!keep) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
save(state);
console.log(`${total} message(s): ${stored} new, ${rejected} rejected`);
},
list() {
const state = load();
const wantRequests = rest.includes("--requests");
for (const m of state.inbox || []) {
if (Boolean(m.isRequest) === wantRequests) describe(state, m, "inbox");
}
if (rest.includes("--sent")) for (const m of state.sent || []) describe(state, m, "sent");
},
read() {
const state = load();
const wanted = rest[0].toLowerCase();
const match = [...(state.inbox || []).map(m => [m, "inbox"]), ...(state.sent || []).map(m => [m, "sent"])]
.find(([m]) => m.id.startsWith(wanted));
if (!match) throw new proto.SmolError(`no message matching ${wanted}`);
describe(state, match[0], match[1], true);
},
async rotate() {
const state = load();
const old = currentIdentity(state);
const master = unhex(state.master);
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
const nextIndex = (state.rotations ?? 0) + 1;
const freshSeed = proto.identitySeed(master, nextIndex);
const fresh = proto.identityFromSeed(freshSeed);
const cert = proto.makeCert(account.user, old, freshSeed);
const opened = await open(account.host, account.port, { requirePin: true });
try {
await proto.registerOp(opened.session, opened.serverStatic, { username: account.user, identity: fresh, cert });
} finally {
opened.session.stream.close();
}
state.rotations = nextIndex;
save(state);
console.log(`rotated ${account.short}`);
console.log(`new key: ${b32encode(fresh.publicKey)}`);
console.log(`fingerprint: ${fingerprint(fresh.publicKey)}`);
},
};
async function pushTokens(state) {
if (!state.account) {
console.error("warning: not registered; the set will be pushed with your first fetch");
return 0;
}
const me = currentIdentity(state);
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
const opened = await open(account.host, account.port, { requirePin: true });
try {
const { sync, tokens } = tokenSet(state);
return await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
} finally {
opened.session.stream.close();
}
}
function describe(state, row, box, verbose = false) {
const id = row.id;
try {
const opened = proto.unseal(identities(state), unhex(row.envelope));
const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body));
const who = box === "sent" ? row.recipient
: Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(opened.sender))?.[0]
?? `<${b32encode(opened.sender).slice(0, 20)}…>`;
const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " ");
if (!verbose) return console.log(
`${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.subject || "(no subject)"}`);
console.log(`id: ${id}`);
console.log(`${box === "sent" ? "to" : "from"}: ${who}`);
console.log(`key: ${b32encode(opened.sender)}`);
console.log(`date: ${new Date(opened.time * 1000).toISOString()}`);
for (const [k, v] of Object.entries(fields)) if (k !== "accept") console.log(`${k}: ${v}`);
console.log("signature verified\n");
console.log(body);
} catch (err) {
if (verbose) throw err;
console.log(`${id.slice(0, 8)} <unreadable: ${err.message}>`);
}
}
function cryptoRandom(n) {
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
}
if (!commands[command]) {
console.error(`unknown command: ${command}`);
process.exit(2);
}
await commands[command]();

162
test/e2e.py Normal file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""End-to-end test: the browser JS stack (web/js, driven headless by
test/client.mjs) against the reference server and client in ../smolmail.
Two transports are covered: the driver connects directly over TCP, and once
more through bridge.py's WebSocket relay — the exact byte path the browser
takes. Alice is always the reference smolmail.py client, so every exchange
crosses implementations.
"""
from __future__ import annotations
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
SMOL = Path(__file__).resolve().parent.parent.parent / "smolmail"
NODE = "node"
failures: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
print(("ok " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else ""))
if not condition:
failures.append(name)
def wait_port(port: int, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return
except OSError:
time.sleep(0.2)
raise TimeoutError(f"port {port} never opened")
def run(command: list[str], expect: int = 0, env: dict | None = None) -> str:
result = subprocess.run(command, capture_output=True, text=True, env=env)
if result.returncode != expect:
raise RuntimeError(f"command failed ({result.returncode}): {command}\n{result.stdout}{result.stderr}")
return result.stdout + result.stderr
def main() -> int:
work = Path(tempfile.mkdtemp(prefix="gsmol-e2e-"))
server_port, bridge_port = 11961, 18096
smolmaild = ["uv", "run", str(SMOL / "smolmaild.py")]
smolmail = ["uv", "run", str(SMOL / "smolmail.py")]
alice = smolmail + ["--key", str(work / "alice.key"), "--db", str(work / "alice.db")]
bob = [NODE, "test/client.mjs", "--state", str(work / "bob.json")]
server_key = re.search(r"public key: (\S+)", run(
smolmaild + ["keygen", "--key", str(work / "server.key")])).group(1)
server = subprocess.Popen(
smolmaild + ["serve", "--key", str(work / "server.key"), "--db", str(work / "mail.db"),
"--host", "127.0.0.1", "--port", str(server_port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
bridge = subprocess.Popen(
["uv", "run", "bridge.py", "--port", str(bridge_port), "--allow-port", str(server_port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
wait_port(server_port)
wait_port(bridge_port)
host = f"127.0.0.1:{server_port}"
# --- both sides register on the reference server
run(alice + ["keygen"])
run(alice + ["trust", "127.0.0.1", server_key])
run(alice + ["register", f"alice@{host}"])
run(bob + ["keygen"])
run(bob + ["trust", "127.0.0.1", server_key])
run(bob + ["register", f"bob@{host}"])
check("bob registers on the reference server", True)
# bob needs alice as a known contact before he can accept her (§5.8).
run(bob + ["resolve", f"alice@{host}"])
# --- reference client sends to the JS client, unsolicited: bob has not
# accepted alice yet, so it lands in his requests tier, not the main one
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
print(run(bob + ["fetch"]).strip())
bob_requests = run(bob + ["list", "--requests"])
check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests,
bob_requests)
check("bob's main tier is still empty", "greetings" not in run(bob + ["list"]))
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)])
alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1)
check("bob reads the message", "hello bob" in bob_read, bob_read)
check("bob sees alice's key", alice_pub in bob_read)
check("signature verifies", "signature verified" in bob_read)
# the reference client now emits its signed reply address by default
check("reference client emits the signed reply address",
"reply-to: smol://alice@" in bob_read, bob_read)
# --- bob accepts alice (§5.8): pushed to the server right away, so her
# future mail reaches his main tier
accepted = run(bob + ["accept", f"alice@{host}"])
check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted,
accepted)
# --- JS client replies, through the bridge's WebSocket relay. Bob has
# accepted alice, so this carries the token issued to her — but it is
# still unsolicited from alice's side, landing in her requests tier.
ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}")
reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}",
"--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1)
check("bob sends through the bridge", bool(reply_id))
run(alice + ["fetch"])
alice_requests = run(alice + ["list", "--requests"])
check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests,
alice_requests)
alice_read = run(alice + ["read", reply_id[:8]])
check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
check("reference client verifies the JS signature", "signature verified" in alice_read)
# bob's reply carries his signed smol:// address; a receiver that does
# not know the field must still preserve and display it (SPEC.md §5.5)
check("reference client preserves the signed reply address",
bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read)
# --- having now learned bob's accept token from that reply's Accept
# field, alice's next message to him carries a matching MAC and
# reaches his main tier even after he rotates his key
bob_rotated = run(bob + ["rotate"])
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
resolved = run(alice + ["resolve", f"bob@{host}"])
check("chain confirms the rotation", "a signed chain confirms it" in resolved, resolved)
run(alice + ["send", f"bob@{host}", "--subject", "post-rotation", "--body", "still here",
"--anonymous"])
print(run(bob + ["fetch"]).strip())
bob_list = run(bob + ["list"])
check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list,
bob_list)
# --anonymous omits the reply address; the field list must show none
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
bob_list).group(1)])
check("--anonymous omits the reply address", "reply-to:" not in post_read, post_read)
print(f"\n{len(failures)} failure(s)" + (f": {failures}" if failures else ""))
return 1 if failures else 0
finally:
for process in (bridge, server):
process.send_signal(signal.SIGTERM)
process.wait(timeout=10)
print(f"workdir left at {work}" if failures else f"cleaning {work}")
if __name__ == "__main__":
os.chdir(Path(__file__).resolve().parent.parent)
sys.exit(main())

203
test/gen_vectors.py Normal file
View file

@ -0,0 +1,203 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["noiseprotocol>=0.3.1", "pynacl>=1.5"]
# ///
"""Generate test/vectors.json for the JS implementation.
The JS crypto in web/js must agree byte for byte with the reference stack
(hashlib/hmac, PyNaCl, noiseprotocol), so the ground truth is generated here
with fixed inputs and consumed by test/vectors.test.mjs. Everything is
derived from SHA-256 of fixed labels, so the file is reproducible.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import struct
from pathlib import Path
import nacl.bindings as sodium
from noise.connection import Keypair, NoiseConnection
LABEL_SEAL, LABEL_MSG, LABEL_ID = b"smolmail/1 seal", b"smolmail/1 msg", b"smolmail/1 id"
LABEL_IDENTITY, LABEL_ACCEPT, LABEL_MAC = b"smolmail/1 identity", b"smolmail/1 accept", b"smolmail/1 mac"
TIME = 1730000000 # fixed so the envelope vector is reproducible
def seed(label: str) -> bytes:
return hashlib.sha256(label.encode()).digest()
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
out, block, counter = b"", b"", 1
while len(out) < length:
block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
out, counter = out + block, counter + 1
return out[:length]
def main() -> int:
out: dict = {}
# --- hashes over empty, one-block and multi-block inputs
out["sha256"] = [
{"in": m.hex(), "out": hashlib.sha256(m).hexdigest()}
for m in (b"", b"abc", bytes(range(64)), b"smolmail" * 40)
]
out["sha512"] = [
{"in": m.hex(), "out": hashlib.sha512(m).hexdigest()}
for m in (b"", b"abc", bytes(range(64)))
]
# --- HMAC/HKDF, including RFC 5869 test case 1
out["hkdf"] = []
for ikm, salt, info, length, name in (
(bytes.fromhex("0b" * 22), bytes.fromhex("000102030405060708090a0b0c"),
bytes.fromhex("f0f1f2f3f4f5f6f7f8f9"), 42, "rfc5869-1"),
(b"agreement shared", b"epk" + b"recipient", b"smolmail/1 seal", 32, "seal-shaped"),
):
out["hkdf"].append({"name": name, "ikm": ikm.hex(), "salt": salt.hex(),
"info": info.hex(), "len": length,
"out": hkdf_sha256(ikm, salt, info, length).hex()})
# --- ChaCha20-Poly1305 (RFC 8439 IETF AEAD) over varied shapes
key, nonce = seed("aead key"), seed("aead nonce")[:12]
aad = bytes.fromhex("50515253c0c1c2c3c4c5c6c7")
out["aead"] = []
for plaintext, use_aad, name in (
(b"", False, "empty"), (b"hello", True, "short"),
(b"Ladies and Gentlemen of the class of '99: if I could offer you "
b"only one tip for the future, sunscreen would be it.", True, "multiline"),
):
sealed = sodium.crypto_aead_chacha20poly1305_ietf_encrypt(
plaintext, aad if use_aad else b"", nonce, key)
out["aead"].append({"name": name, "key": key.hex(), "nonce": nonce.hex(),
"aad": (aad if use_aad else b"").hex(), "plaintext": plaintext.hex(),
"sealed": sealed.hex()})
# --- X25519: base multiplication and agreement, including the §2 checks
out["x25519"] = []
for name, priv in (("alice", seed("x25519 alice")), ("bob", seed("x25519 bob"))):
pub = sodium.crypto_scalarmult_base(priv)
out["x25519"].append({"name": name, "priv": priv.hex(), "pub": pub.hex()})
out["x25519"].append({"name": "agree",
"shared": sodium.crypto_scalarmult(
seed("x25519 alice"),
sodium.crypto_scalarmult_base(seed("x25519 bob"))).hex()})
for bad in (b"\x00" * 32, bytes.fromhex("0100" + "00" * 30)):
try:
sodium.crypto_scalarmult(seed("x25519 alice"), bad)
raised = False
except Exception:
raised = True
out["x25519"].append({"name": f"low-order-{bad[0]}", "peer": bad.hex(),
"peer_rejected": True, "raised": raised})
assert raised, "PyNaCl must reject this low-order point for the vector to mean anything"
# --- Ed25519: keypair, signature, verification, and a tampered case
out["ed25519"] = []
for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))):
pub, _ = sodium.crypto_sign_seed_keypair(s)
for message in (b"", b"smolmail/1 auth" + bytes(32), bytes(range(64))):
sig = sodium.crypto_sign(message, s + pub)[:64]
out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(),
"message": message.hex(), "signature": sig.hex(),
"valid": True})
bad = bytes([sig[0] ^ 1]) + sig[1:]
out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(),
"message": message.hex(), "signature": bad.hex(),
"valid": False})
# --- §2 conversions between the identity key and X25519
out["ed_to_x25519"] = []
for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))):
pub, sk64 = sodium.crypto_sign_seed_keypair(s)
out["ed_to_x25519"].append({
"name": name, "seed": s.hex(),
"x_priv": sodium.crypto_sign_ed25519_sk_to_curve25519(sk64).hex(),
"x_pub": sodium.crypto_sign_ed25519_pk_to_curve25519(pub).hex()})
# --- §5 envelope sealed with a fixed ephemeral, mirroring smolmail.py seal()
sender_seed, recipient_seed, esk = seed("envelope sender"), seed("envelope recipient"), seed("envelope esk")
sender_pub, _ = sodium.crypto_sign_seed_keypair(sender_seed)
recipient_pub, _ = sodium.crypto_sign_seed_keypair(recipient_seed)
epk = sodium.crypto_scalarmult_base(esk)
shared = sodium.crypto_scalarmult(esk, sodium.crypto_sign_ed25519_pk_to_curve25519(recipient_pub))
key = hkdf_sha256(shared, epk + recipient_pub, LABEL_SEAL, 32)
body = b"---\nSubject: vector\n---\nhello bob\n"
sig = sodium.crypto_sign(LABEL_MSG + recipient_pub + epk
+ bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body)) + body,
sender_seed + sender_pub)[:64]
plaintext = (bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body))
+ body + sig)
aad = b"SMOL" + bytes([1]) + recipient_pub + epk
envelope = aad + sodium.crypto_aead_chacha20poly1305_ietf_encrypt(plaintext, aad, bytes(12), key)
out["envelope"] = {
"sender_seed": sender_seed.hex(), "recipient_seed": recipient_seed.hex(),
"esk": esk.hex(), "body": body.hex(), "time": TIME,
"envelope": envelope.hex(),
"id": hashlib.sha256(LABEL_ID + envelope).digest().hex(),
"unpadded_plaintext_len": len(plaintext)}
# --- §2/§5.8: master-derived rotation seeds and accept tokens
master = seed("master")
correspondent = seed("correspondent identity")
accept_key = hkdf_sha256(master, b"", LABEL_ACCEPT, 32)
token = hmac.new(accept_key, correspondent, hashlib.sha256).digest()
mid = seed("token message id")
out["tokens"] = {
"master": master.hex(),
"identity_seed_0": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 0), 32).hex(),
"identity_seed_1": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 1), 32).hex(),
"accept_key": accept_key.hex(),
"correspondent": correspondent.hex(),
"token": token.hex(),
"message_id": mid.hex(),
"accept_mac": hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest().hex(),
}
# --- Noise NX transcript with every key fixed
i_e, r_e, s_priv = seed("nx initiator eph"), seed("nx responder eph"), seed("nx server static")
s_pub = sodium.crypto_scalarmult_base(s_priv)
n1 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256")
n1.set_as_initiator()
n1.set_prologue(b"smolmail/1")
n1.set_keypair_from_private_bytes(Keypair.EPHEMERAL, i_e)
n1.start_handshake()
m1 = n1.write_message()
n2 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256")
n2.set_as_responder()
n2.set_prologue(b"smolmail/1")
n2.set_keypair_from_private_bytes(Keypair.STATIC, s_priv)
n2.set_keypair_from_private_bytes(Keypair.EPHEMERAL, r_e)
n2.start_handshake()
n2.read_message(m1)
m2 = n2.write_message()
n1.read_message(m2)
assert n1.handshake_finished and n2.handshake_finished
out["noise"] = {
"initiator_eph_priv": i_e.hex(), "responder_eph_priv": r_e.hex(),
"server_static_priv": s_priv.hex(), "server_static_pub": s_pub.hex(),
"message1": m1.hex(), "message2": m2.hex(),
"handshake_hash": n1.get_handshake_hash().hex(),
"assert_hash_equal": n1.get_handshake_hash() == n2.get_handshake_hash(),
"initiator_frames": [{"plaintext": p.hex(), "sealed": n1.encrypt(p).hex()}
for p in (b"ping", b"second frame to test the counter")],
"responder_frames": [{"plaintext": p.hex(), "sealed": n2.encrypt(p).hex()}
for p in (b"pong", b"x" * 300)],
}
path = Path(__file__).parent / "vectors.json"
path.write_text(json.dumps(out, indent=2) + "\n")
print(f"wrote {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

236
test/vectors.json Normal file
View file

@ -0,0 +1,236 @@
{
"sha256": [
{
"in": "",
"out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"in": "616263",
"out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108"
},
{
"in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c",
"out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462"
}
],
"sha512": [
{
"in": "",
"out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
},
{
"in": "616263",
"out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949"
}
],
"hkdf": [
{
"name": "rfc5869-1",
"ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"salt": "000102030405060708090a0b0c",
"info": "f0f1f2f3f4f5f6f7f8f9",
"len": 42,
"out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
},
{
"name": "seal-shaped",
"ikm": "61677265656d656e7420736861726564",
"salt": "65706b726563697069656e74",
"info": "736d6f6c6d61696c2f31207365616c",
"len": 32,
"out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c"
}
],
"aead": [
{
"name": "empty",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "",
"plaintext": "",
"sealed": "941b286ea0ee86bb66236fc3c16b2e48"
},
{
"name": "short",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "68656c6c6f",
"sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29"
},
{
"name": "multiline",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e",
"sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24"
}
],
"x25519": [
{
"name": "alice",
"priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33",
"pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664"
},
{
"name": "bob",
"priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7",
"pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252"
},
{
"name": "agree",
"shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13"
},
{
"name": "low-order-0",
"peer": "0000000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
},
{
"name": "low-order-1",
"peer": "0100000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
}
],
"ed25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "",
"signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": false
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "",
"signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": false
}
],
"ed_to_x25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68",
"x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910"
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963",
"x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d"
}
],
"envelope": {
"sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc",
"recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf",
"esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48",
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
"time": 1730000000,
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
"id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
"unpadded_plaintext_len": 143
},
"tokens": {
"master": "fc613b4dfd6736a7bd268c8a0e74ed0d1c04a959f59dd74ef2874983fd443fc9",
"identity_seed_0": "35c2f058e7568f68e99e4fba7a730bd2d3cf7e1f6a2a266a2c5f5ca150d0074c",
"identity_seed_1": "fef4bd7f73b6459031a79dd589011a120c2cbc19b693b918f4aa9a8afe9dba7b",
"accept_key": "4fbaf4883885875100dc157864977e9a533866b15adc9daed35eedcc5f62fb20",
"correspondent": "fbaa80b2e87dc300cd33a2497dfe672c47ca8c8af099db8d45644054dff85297",
"token": "fe3085aeb2952b2bdd5abdcf8908ffd00054e871f47dfed2aa541cdb0e2006d6",
"message_id": "7c65ce5d23376fb508b4dda83a271095fd41e0ce47604213153520b0a0db50ac",
"accept_mac": "1d56673df4952dedcc1e40dfce2ad7820864090809a381e225e0f2fafa7cbfc7"
},
"noise": {
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
"server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e",
"server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32",
"message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e",
"message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9",
"handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72",
"assert_hash_equal": true,
"initiator_frames": [
{
"plaintext": "70696e67",
"sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0"
},
{
"plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572",
"sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c"
}
],
"responder_frames": [
{
"plaintext": "706f6e67",
"sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b"
},
{
"plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",
"sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62"
}
]
}
}

246
test/vectors.test.mjs Normal file
View file

@ -0,0 +1,246 @@
// 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`);