AtlatestRepositorysigil-crypto

sigil-crypto / tree / test / wasmrun-wasm-test.mjs

1#!/usr/bin/env node
2//
3// sigil-crypto wasm differential test.
4//
5// 1. Builds test/wasm for the host ('host), static musl ('static) and wasm32-wasi with
6// native codegen ('web config), unless SIGIL_PROBE_SKIP_BUILD=1.
7// 2. Runs the host bundle and the wasm module (in Node with a minimal WASI
8// shim) and requires their outputs to match line for line, except lines
9// starting "target:", which must show the Mbed TLS procedures working on
10// the host and refusing with "not available on wasm" on wasm.
11// 3. Checks every line against its expected value, so two identical wrong
12// outputs cannot pass.
13// 4. Drift gate: reads native/crypto.c, requires the wasm stub list to name
14// exactly the Mbed TLS natives the host registers, then calls each one
15// in the wasm module through the eval ABI and requires the refusal.
16//
17// Run from anywhere: node test/wasm/run-wasm-test.mjs
18// Needs: sigil on PATH, guix (for binaryen), node.
19// Exit 0 only on PASS. SETUP-FAILED (exit 2) when something could not be
20// built or found, so nothing was tested.
22import { spawnSync } from "node:child_process";
23import fs from "node:fs";
24import path from "node:path";
25import { fileURLToPath } from "node:url";
27const probeDir = path.dirname(fileURLToPath(import.meta.url));
28const repoRoot = path.resolve(probeDir, "../..");
29const hostBin = path.join(probeDir, "build/host/bin/sigil-crypto-wasm-probe");
30const wasmPath = path.join(probeDir, "build/web/sigil-crypto-wasm-probe.wasm");
31const staticBin = path.join(probeDir, "build/static/bin/sigil-crypto-wasm-probe");
32const cryptoC = path.join(repoRoot, "native/crypto.c");
34let failures = 0;
35function fail(msg) {
36 console.error(`FAIL: ${msg}`);
37 failures += 1;
39function setupFailed(msg) {
40 console.error(`SETUP-FAILED: ${msg}`);
41 process.exit(2);
44// Every non-target line and its expected value. A missing line fails.
45const EXPECTED = {
46 "blake2b-512 abc":
47 "ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923",
48 "blake2b-512 binary.bin": null, // checked against coreutils b2sum below
49 "ed25519 rfc8032 test 2 verifies": "#t",
50 "ed25519 rfc8032 test 3 verifies": "#t",
51 "ed25519 test 2 flipped message": "#f",
52 "ed25519 test 3 flipped signature": "#f",
53 "ed25519 test 3 flipped key": "#f",
54 "ed25519 test 2 public key": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
55 "ed25519 test 2 signature":
56 "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
57 "ed25519 31-byte key": "ed25519-verify: public key must be 32 bytes",
58 "minisign key a id": null, // checked against a.pub's comment line below
59 "minisign hello ED": "sigil-crypto fixture: prehashed",
60 "minisign hello Ed": "sigil-crypto fixture: legacy",
61 "minisign binary ED": "sigil-crypto fixture: binary",
62 "minisign registry root":
63 "registry=pkg.usesigil.org path=/v1/meta/registry.json seq=1 ts=2026-07-30T08:04:54Z",
64 "minisign registry tampered": "bad-signature",
65 "minisign hello tampered": "bad-signature",
66 "minisign key mismatch": "key-id-mismatch",
67 "minisign key b": "#f",
68 "minisign legacy refused": "legacy-refused",
69 "minisign bad key line": "minisign: public key has the wrong base64 length",
70 "timing-safe-equal?": "#t",
71};
72const TARGET_LINES = ["target: sha256", "target: base64url-encode"];
73const WASM_REFUSAL = "not available on wasm";
75function run(cmd, args, opts = {}) {
76 const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
77 return { status: r.status, out: (r.stdout || "") + (r.stderr || ""), error: r.error };
80function build() {
81 // cwd is the probe directory: sigil resolves the project and its
82 // dependencies from cwd, not from the location of any file.
83 const host = run("nice", ["-n", "19", "sigil", "build", "--config", "host"], { cwd: probeDir });
84 if (host.status !== 0) {
85 process.stdout.write(host.out);
86 setupFailed(`host build failed (exit ${host.status})`);
87 }
88 const stat = run("nice", ["-n", "19", "sigil", "build", "--config", "static"], { cwd: probeDir });
89 if (stat.status !== 0) {
90 process.stdout.write(stat.out);
91 setupFailed(`static musl build failed (exit ${stat.status})`);
92 }
93 const web = run(
94 "guix",
95 ["shell", "binaryen", "--", "sh", "-c", "command -v wasm-opt >/dev/null && nice -n 19 sigil build --config web"],
96 { cwd: probeDir },
97 );
98 if (web.status !== 0) {
99 process.stdout.write(web.out);
100 setupFailed(`web build failed (exit ${web.status})`);
101 }
104// The wasm build's objects, not its log (a cached build logs nothing): no
105// Mbed TLS object may exist, and Monocypher's must, which is the positive
106// control that this looks in the right place.
107function checkWasmObjects() {
108 const objDir = path.join(probeDir, "build/web/native");
109 const monocypherObj = path.join(objDir, "vendor/monocypher/src/monocypher.o");
110 if (!fs.existsSync(monocypherObj)) setupFailed(`expected wasm object missing: ${monocypherObj}`);
111 const mbedtlsDir = path.join(objDir, "vendor/mbedtls");
112 const found = fs.existsSync(mbedtlsDir)
113 ? fs.readdirSync(mbedtlsDir, { recursive: true }).filter((f) => String(f).endsWith(".o"))
114 : [];
115 if (found.length > 0) fail(`the wasm build has ${found.length} Mbed TLS objects (e.g. ${found[0]})`);
116 else console.log("wasm objects: Monocypher present, no Mbed TLS objects");
119function describe(file) {
120 if (!fs.existsSync(file)) setupFailed(`missing artifact ${file}`);
121 const st = fs.statSync(file);
122 console.log(`artifact ${file} ${st.size} bytes, mtime ${st.mtime.toISOString()}`);
125// Minimal WASI preview1 shim, after sigil's test-native-facade-reexport.mjs.
126function makeWasi(module, output) {
127 let instance = null;
128 const decoder = new TextDecoder();
129 const view = () => new DataView(instance.exports.memory.buffer);
130 const u8 = () => new Uint8Array(instance.exports.memory.buffer);
131 const known = {
132 fd_write(fd, iovsPtr, iovsLen, nwrittenPtr) {
133 let written = 0;
134 let text = "";
135 for (let i = 0; i < iovsLen; i += 1) {
136 const ptr = view().getUint32(iovsPtr + i * 8, true);
137 const len = view().getUint32(iovsPtr + i * 8 + 4, true);
138 text += decoder.decode(u8().subarray(ptr, ptr + len));
139 written += len;
140 }
141 if (fd === 1 || fd === 2) output.push(text);
142 if (nwrittenPtr) view().setUint32(nwrittenPtr, written, true);
143 return 0;
144 },
145 proc_exit(code) {
146 throw new Error(`WASI proc_exit(${code})`);
147 },
148 args_sizes_get(argcPtr, argvBufSizePtr) {
149 view().setUint32(argcPtr, 0, true);
150 view().setUint32(argvBufSizePtr, 0, true);
151 return 0;
152 },
153 environ_sizes_get(countPtr, bufSizePtr) {
154 view().setUint32(countPtr, 0, true);
155 view().setUint32(bufSizePtr, 0, true);
156 return 0;
157 },
158 clock_time_get(_id, _precision, timePtr) {
159 view().setBigUint64(timePtr, BigInt(Date.now()) * 1000000n, true);
160 return 0;
161 },
162 // Ed25519 verification needs no entropy. The runtime may still ask
163 // (hash seeds); this shim answers from Math.random, which is fine for
164 // a test and would not be for anything that generates keys.
165 random_get(ptr, len) {
166 const bytes = u8();
167 for (let i = 0; i < len; i += 1) bytes[ptr + i] = (Math.random() * 256) | 0;
168 return 0;
169 },
170 fd_fdstat_get(_fd, statPtr) {
171 u8().fill(0, statPtr, statPtr + 24);
172 view().setUint8(statPtr, 2);
173 return 0;
174 },
175 fd_fdstat_set_flags() { return 0; },
176 fd_filestat_get(_fd, statPtr) {
177 u8().fill(0, statPtr, statPtr + 64);
178 view().setUint8(statPtr + 16, 2);
179 return 0;
180 },
181 fd_close() { return 0; },
182 fd_seek() { return 0; },
183 fd_fdstat_set_rights() { return 0; },
184 fd_prestat_get() { return 8; },
185 fd_prestat_dir_name() { return 8; },
186 path_open() { return 44; },
187 };
188 const wasi = {};
189 for (const entry of WebAssembly.Module.imports(module)) {
190 if (entry.module === "wasi_snapshot_preview1") {
191 wasi[entry.name] = known[entry.name] || (() => 0);
192 }
193 }
194 return {
195 imports: { wasi_snapshot_preview1: wasi },
196 setInstance(value) { instance = value; },
197 };
200function parseLines(text) {
201 const map = new Map();
202 const order = [];
203 for (const line of text.split("\n")) {
204 const i = line.indexOf(": ");
205 if (i < 0) continue;
206 const key = line.startsWith("target: ") ? line.slice(0, line.indexOf(": ", 8)) : line.slice(0, i);
207 const value = line.slice(key.length + 2);
208 map.set(key, value);
209 order.push(key);
210 }
211 return { map, order };
214async function runWasm() {
215 const output = [];
216 const module = await WebAssembly.compile(fs.readFileSync(wasmPath));
217 const wasi = makeWasi(module, output);
218 const extra = {};
219 for (const entry of WebAssembly.Module.imports(module)) {
220 if (entry.module === "wasi_snapshot_preview1") continue;
221 extra[entry.module] ??= {};
222 if (entry.kind === "function") extra[entry.module][entry.name] = () => 0;
223 }
224 const instance = await WebAssembly.instantiate(module, { ...wasi.imports, ...extra });
225 wasi.setInstance(instance);
226 const ex = instance.exports;
227 for (const sym of ["sigil_wasm_init", "sigil_wasm_start", "sigil_wasm_eval_string_length", "malloc"]) {
228 if (typeof ex[sym] !== "function") setupFailed(`wasm module does not export ${sym}`);
229 }
230 if (ex.sigil_wasm_init() !== 0) setupFailed("sigil_wasm_init failed");
231 const startRc = ex.sigil_wasm_start();
232 const compiled = output.join("");
233 output.length = 0;
235 const encoder = new TextEncoder();
236 const evalStr = (expr) => {
237 const bytes = encoder.encode(expr);
238 const ptr = ex.malloc(bytes.length + 1);
239 const mem = new Uint8Array(ex.memory.buffer);
240 mem.set(bytes, ptr);
241 mem[ptr + bytes.length] = 0;
242 const rc = ex.sigil_wasm_eval_string_length(ptr);
243 ex.free(ptr);
244 if (rc < 0) return { rc, result: output.splice(0).join("") };
245 const len = ex.sigil_wasm_last_result_length();
246 const dest = ex.malloc(Math.max(len, 1));
247 const n = ex.sigil_wasm_copy_result(dest, len);
248 const text = new TextDecoder().decode(new Uint8Array(ex.memory.buffer, dest, n));
249 ex.free(dest);
250 return { rc, result: text.replace(/^"|"$/g, "") };
251 };
252 return { compiled, startRc, evalStr };
255function checkExpected(label, lines) {
256 for (const [key, want] of Object.entries(EXPECTED)) {
257 if (!lines.map.has(key)) {
258 fail(`${label}: line "${key}" missing`);
259 } else if (want !== null && lines.map.get(key) !== want) {
260 fail(`${label}: "${key}" = ${JSON.stringify(lines.map.get(key))}, want ${JSON.stringify(want)}`);
261 }
262 }
265async function main() {
266 if (process.env.SIGIL_PROBE_SKIP_BUILD !== "1") build();
267 const lock = path.join(probeDir, "sigil.lock");
268 if (fs.existsSync(lock)) {
269 const entries = [...fs.readFileSync(lock, "utf8").matchAll(/name: "([^"]+)"[\s\S]*?sha: "([^"]*)"[\s\S]*?version: "([^"]+)"/g)];
270 for (const [, name, sha, version] of entries) {
271 if (name === "sigil-crypto") continue; // from ../..; its lock sha is stale by design
272 console.log(`input ${name} ${version} ${sha.slice(0, 12)}`);
273 }
274 }
275 {
276 const head = run("git", ["-C", repoRoot, "rev-parse", "HEAD"]).out.trim();
277 const dirty = run("git", ["-C", repoRoot, "status", "--porcelain", "--", ".", ":!test/wasm/sigil.lock"]).out.trim() !== "";
278 console.log(`input sigil-crypto (from ../..) ${head.slice(0, 12)}${dirty ? " +uncommitted changes" : ""}`);
279 }
280 describe(hostBin);
281 describe(wasmPath);
282 describe(staticBin);
283 checkWasmObjects();
285 // Host run. cwd is the probe dir, for the same reason as the build.
286 const host = run(hostBin, [], { cwd: probeDir });
287 if (host.status !== 0 || !host.out.includes("REACHED-END")) {
288 process.stdout.write(host.out);
289 setupFailed(`host probe did not complete (exit ${host.status})`);
290 }
291 // Static musl build: must print exactly what the host prints, target
292 // lines included (Mbed TLS is built there).
293 const stat = run(staticBin, [], { cwd: probeDir });
294 if (stat.status !== 0 || stat.out !== host.out) fail(`static musl output differs from host (exit ${stat.status})`);
295 else console.log(`static musl: output identical to host (${host.out.trim().split("\n").length} lines)`);
296 const wasm = await runWasm();
297 process.stdout.write(`=== wasm (sigil_wasm_start -> ${wasm.startRc}) ===\n${wasm.compiled}`);
298 if (!wasm.compiled.includes("REACHED-END")) fail("wasm probe did not reach the end");
300 const h = parseLines(host.out);
301 const w = parseLines(wasm.compiled);
303 // Independent values for the two lines EXPECTED leaves open.
304 const b2 = run("b2sum", [path.join(repoRoot, "test/fixtures/minisign/binary.bin")]);
305 if (b2.status !== 0) setupFailed("b2sum did not run");
306 EXPECTED["blake2b-512 binary.bin"] = b2.out.split(" ")[0];
307 const pubComment = fs.readFileSync(path.join(repoRoot, "test/fixtures/minisign/a.pub"), "utf8").split("\n")[0];
308 EXPECTED["minisign key a id"] = pubComment.slice(-16);
310 checkExpected("host", h);
311 checkExpected("wasm", w);
313 // Line-for-line agreement outside the target lines.
314 const hOrder = h.order.filter((k) => !k.startsWith("target: "));
315 const wOrder = w.order.filter((k) => !k.startsWith("target: "));
316 if (JSON.stringify(hOrder) !== JSON.stringify(wOrder)) fail("host and wasm print different line sequences");
317 let agreed = 0;
318 for (const key of hOrder) {
319 if (h.map.get(key) === w.map.get(key)) agreed += 1;
320 else fail(`differential: "${key}" host=${JSON.stringify(h.map.get(key))} wasm=${JSON.stringify(w.map.get(key))}`);
321 }
322 console.log(`differential: ${agreed}/${hOrder.length} lines agree`);
324 for (const key of TARGET_LINES) {
325 if (h.map.get(key) !== "no-error") fail(`host ${key}: ${JSON.stringify(h.map.get(key))}, want no-error`);
326 if (!(w.map.get(key) || "").includes(WASM_REFUSAL)) fail(`wasm ${key}: ${JSON.stringify(w.map.get(key))}, want the refusal`);
327 }
329 // Drift gate: the stub list in crypto.c against the host registrations.
330 const src = fs.readFileSync(cryptoC, "utf8");
331 const stubBlock = src.match(/mbedtls_only_natives\[\] = \{([\s\S]*?)NULL/);
332 if (!stubBlock) setupFailed("could not find mbedtls_only_natives in crypto.c");
333 const stubs = [...stubBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
334 const registered = [...src.matchAll(/REGISTER_AND_EXPORT\("([^"]+)"/g)].map((m) => m[1]).sort();
335 if (registered.length < 20) setupFailed(`found only ${registered.length} REGISTER_AND_EXPORT names`);
336 if (JSON.stringify(stubs) !== JSON.stringify(registered)) {
337 fail(`wasm stub list != host Mbed TLS registrations\n stubs only: ${stubs.filter((s) => !registered.includes(s))}\n host only: ${registered.filter((s) => !stubs.includes(s))}`);
338 }
339 let refused = 0;
340 for (const name of registered) {
341 const { rc, result } = wasm.evalStr(
342 `(begin (import (sigil crypto)) (guard (e (#t (if (error-object? e) (error-object-message e) "non-error raise"))) (${name}) "no-error"))`,
343 );
344 if (rc >= 0 && result.includes(WASM_REFUSAL)) refused += 1;
345 else fail(`wasm eval (${name}): rc=${rc} ${JSON.stringify(result)}`);
346 }
347 console.log(`drift gate: ${registered.length} Mbed TLS natives, ${refused} refuse on wasm`);
348 // Positive control for the eval path itself: it must be able to succeed.
349 const control = wasm.evalStr(`(begin (import (sigil crypto)) (number->string (bytevector-length (blake2b-512 "abc"))))`);
350 if (control.rc < 0 || control.result !== "64") fail(`eval positive control: rc=${control.rc} ${JSON.stringify(control.result)}`);
352 if (failures) {
353 console.error(`FAIL: ${failures} check(s) failed`);
354 process.exit(1);
355 }
356 console.log("PASS: sigil-crypto wasm build matches the host; Mbed TLS procedures refuse on wasm");
359await main();