chore: flatten repository history
This commit is contained in:
commit
3a03fab876
30 changed files with 7223 additions and 0 deletions
457
test/client.mjs
Normal file
457
test/client.mjs
Normal 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
162
test/e2e.py
Normal 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
203
test/gen_vectors.py
Normal 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
236
test/vectors.json
Normal 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
246
test/vectors.test.mjs
Normal 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`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue