carlos / movement Public

Task 5: the solver, and a test that proves both sides agree

SHA-256 is hand-written because crypto.subtle is async per call and this
runs a few hundred thousand times to solve one challenge. A promise per
attempt is not an inner loop.

The solve loop lives in powcore.js rather than inside the Worker, because
a Worker cannot be imported and a test that re-implements the loop proves
the two copies agree with each other while saying nothing about the one
that ships. The Worker is now four lines around it.

The crossing test runs that exact module in Chromium and hands its answer
to the Go verifier. Changing one separator character in the preimage makes
it fail with "the two sides are building different preimages" — which is
the failure it exists for, and the one a Go-only test cannot see: every
honest visitor rejected as pow_short, no error they can read, nothing in
the logs.

asciiLower is deliberately dumb on both sides. toLowerCase folds parts of
Unicode differently from Go's strings.ToLower.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell b1c4bb00ecc94a4e9c466a2c263e3c8f5be6f12d parent d40fd32
5 files changed, +461 −0
  • internal/movement/static/pow-worker.js +20 −0
  • internal/movement/static/pow.js +81 −0
  • internal/movement/static/powcore.js +80 −0
  • internal/movement/static/sha256.js +124 −0
  • internal/movementtest/browser_pow_test.go +156 −0
diff --git a/internal/movement/static/pow-worker.js b/internal/movement/static/pow-worker.js
new file mode 100644
index 0000000..b1a275c
--- /dev/null
+++ b/internal/movement/static/pow-worker.js
@@ -0,0 +1,20 @@
+// The Worker that hosts the solver.
+//
+// Thin on purpose: everything that could disagree with the Go verifier lives
+// in powcore.js, which the browser test imports and runs directly. A worker
+// cannot be imported, so any logic left in here would be untested by the one
+// gate that matters.
+//
+// It runs off the main thread because a few hundred thousand hashes on it
+// freezes a phone for seconds — the page stops scrolling and stops answering
+// taps at exactly the moment somebody is deciding whether to trust us.
+
+import { solve } from "./powcore.js";
+
+self.onmessage = (e) => {
+ const { nonce, email, difficulty } = e.data;
+ const counter = solve(nonce, email, difficulty, (tried) => {
+ self.postMessage({ done: false, tried });
+ });
+ self.postMessage({ done: true, counter });
+};
diff --git a/internal/movement/static/pow.js b/internal/movement/static/pow.js
new file mode 100644
index 0000000..a3beeb5
--- /dev/null
+++ b/internal/movement/static/pow.js
@@ -0,0 +1,81 @@
+// Wires the signup form to the solver.
+//
+// The submit button is rendered disabled, with an explanation inside
+// <noscript>, and this module enables it once it has loaded. That ordering is
+// the only one that fails safe: JavaScript cannot enable a control that lives
+// inside <noscript>, and a module that throws on an old browser leaves the
+// honest disabled state rather than a form that looks live and silently does
+// nothing.
+//
+// Signing up genuinely requires JavaScript here — the proof of work is
+// mandatory. A visitor who cannot run it deserves to learn that immediately,
+// and from us.
+
+// This module does no hashing itself — the worker imports sha256.js.
+
+const form = document.querySelector("[data-pow-form]");
+
+if (form) {
+ const submit = form.querySelector("[data-pow-submit]");
+ const email = form.querySelector("[data-pow-email]");
+ const counter = form.querySelector("[data-pow-counter]");
+ const status = form.querySelector("[data-pow-status]");
+ const workerURL = form.dataset.powWorker;
+ const difficulty = parseInt(form.dataset.powDifficulty, 10);
+ const nonce = form.dataset.powNonce;
+
+ const idleLabel = submit.dataset.label || submit.textContent;
+ const workingLabel = submit.dataset.workingLabel || idleLabel;
+
+ // Everything above resolved, so the form can be used.
+ submit.disabled = false;
+
+ let solved = false;
+
+ form.addEventListener("submit", (event) => {
+ if (solved) return; // second pass: the counter is in, let it through.
+
+ event.preventDefault();
+ if (!email.value.trim()) {
+ email.reportValidity();
+ return;
+ }
+
+ submit.disabled = true;
+ submit.textContent = workingLabel;
+ if (status) status.hidden = false;
+
+ // The worker is addressed by a fingerprinted URL the template supplies,
+ // because static assets are content-hashed and this module cannot guess
+ // the name. Module type, so it can import sha256.js the same way.
+ const worker = new Worker(workerURL, { type: "module" });
+
+ worker.onmessage = (e) => {
+ if (!e.data.done) {
+ if (status) status.textContent = String(e.data.tried);
+ return;
+ }
+ worker.terminate();
+ counter.value = e.data.counter;
+ solved = true;
+ form.requestSubmit ? form.requestSubmit() : form.submit();
+ };
+
+ worker.onerror = () => {
+ worker.terminate();
+ // Nothing clever to do: without a solution the server will refuse the
+ // submission anyway, so say so here rather than posting something we
+ // know will be rejected.
+ submit.disabled = false;
+ submit.textContent = idleLabel;
+ if (status) status.textContent = "";
+ form.dispatchEvent(new CustomEvent("pow:failed"));
+ };
+
+ worker.postMessage({
+ nonce,
+ email: email.value,
+ difficulty,
+ });
+ });
+}
diff --git a/internal/movement/static/powcore.js b/internal/movement/static/powcore.js
new file mode 100644
index 0000000..fcfceac
--- /dev/null
+++ b/internal/movement/static/powcore.js
@@ -0,0 +1,80 @@
+// The solver itself, separate from the Worker that hosts it.
+//
+// It is its own module so that the browser test can import and run exactly
+// this code. A test that re-implements the loop proves the two
+// implementations agree with each other and says nothing about the one that
+// ships.
+//
+// The search: find a counter where SHA-256(nonce ":" email ":" counter) has
+// at least `difficulty` leading zero bits. The address is inside the hash on
+// purpose — an unbound challenge is solved once and replayed against every
+// address in a list, so binding it is what makes each address cost its own
+// work, which is the axis a bulk signup attack actually scales along.
+
+import { sha256, leadingZeroBits } from "./sha256.js";
+
+const encoder = new TextEncoder();
+
+// asciiLower mirrors Go's NormalizeEmail exactly: A-Z and nothing else.
+//
+// String.prototype.toLowerCase is the obvious choice and it is wrong. It
+// folds parts of Unicode differently from Go's strings.ToLower, and a
+// one-byte disagreement in the preimage produces a solution the server
+// rejects as too short — a signup that fails for a real person, with no
+// error anybody can read and nothing in the logs to explain it.
+export function asciiLower(s) {
+ let out = "";
+ for (let i = 0; i < s.length; i++) {
+ const c = s.charCodeAt(i);
+ out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : s[i];
+ }
+ return out;
+}
+
+// solve returns the counter as a string, the same shape the form posts.
+//
+// onProgress, when given, is called every 20,000 attempts. Solve time is
+// geometric: the p99 is roughly 4.6x the average, so a visitor on old
+// hardware can legitimately wait several times what a laptop measures, and a
+// button with no sign of life is a button people give up on.
+export function solve(nonce, email, difficulty, onProgress) {
+ // The prefix never changes, so it is encoded once and the counter's digits
+ // are written after it. Re-encoding the whole string every attempt is most
+ // of the cost otherwise.
+ const prefix = encoder.encode(nonce + ":" + asciiLower(email.trim()) + ":");
+ const buf = new Uint8Array(prefix.length + 16);
+ buf.set(prefix);
+
+ let counter = 0;
+ let sinceReport = 0;
+
+ for (;;) {
+ let len = prefix.length;
+ if (counter === 0) {
+ buf[len++] = 48; // "0"
+ } else {
+ let n = counter;
+ let digits = 0;
+ while (n > 0) {
+ n = (n / 10) | 0;
+ digits++;
+ }
+ n = counter;
+ for (let i = digits - 1; i >= 0; i--) {
+ buf[len + i] = 48 + (n % 10);
+ n = (n / 10) | 0;
+ }
+ len += digits;
+ }
+
+ if (leadingZeroBits(sha256(buf, len)) >= difficulty) {
+ return String(counter);
+ }
+
+ counter++;
+ if (onProgress && ++sinceReport >= 20000) {
+ sinceReport = 0;
+ onProgress(counter);
+ }
+ }
+}
diff --git a/internal/movement/static/sha256.js b/internal/movement/static/sha256.js
new file mode 100644
index 0000000..42df302
--- /dev/null
+++ b/internal/movement/static/sha256.js
@@ -0,0 +1,124 @@
+// SHA-256, synchronously, over a Uint8Array.
+//
+// The browser already has SHA-256 in crypto.subtle, and it is not usable
+// here: subtle.digest is asynchronous, returning a promise per call, and this
+// runs a few hundred thousand times to solve one challenge. A promise per
+// attempt is not an inner loop.
+//
+// So it is transcribed rather than invented — FIPS 180-4, the ordinary
+// reference implementation. Its test is comparison against Go's
+// crypto/sha256 over a corpus, not cleverness. Nothing in here should ever
+// be optimised in a way that makes it disagree with the Go verifier by even
+// one byte: a disagreement is a signup that silently fails for a real person
+// with no error anyone can read.
+
+const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
+// Reused across calls. The solver runs this hundreds of thousands of times
+// in a tight loop, and allocating two arrays per attempt is most of the cost.
+const W = new Uint32Array(64);
+const H = new Uint32Array(8);
+const OUT = new Uint8Array(32);
+
+function rotr(x, n) {
+ return (x >>> n) | (x << (32 - n));
+}
+
+// digest hashes len bytes of buf and writes 32 bytes into OUT, which it
+// returns. The result is only valid until the next call — the caller reads it
+// immediately, which is all this solver ever does.
+export function sha256(buf, len) {
+ H[0] = 0x6a09e667; H[1] = 0xbb67ae85; H[2] = 0x3c6ef372; H[3] = 0xa54ff53a;
+ H[4] = 0x510e527f; H[5] = 0x9b05688c; H[6] = 0x1f83d9ab; H[7] = 0x5be0cd19;
+
+ // One padded copy: the message, a 0x80 byte, zeroes, then the length in
+ // bits as a big-endian 64-bit integer.
+ const withLen = len + 9;
+ const total = withLen + ((64 - (withLen % 64)) % 64);
+ const m = new Uint8Array(total);
+ m.set(buf.subarray(0, len));
+ m[len] = 0x80;
+
+ const bits = len * 8;
+ // Lengths here are far below 2^32 bits, so the high word is always zero.
+ m[total - 4] = (bits >>> 24) & 0xff;
+ m[total - 3] = (bits >>> 16) & 0xff;
+ m[total - 2] = (bits >>> 8) & 0xff;
+ m[total - 1] = bits & 0xff;
+
+ for (let off = 0; off < total; off += 64) {
+ for (let i = 0; i < 16; i++) {
+ const j = off + i * 4;
+ W[i] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | m[j + 3];
+ }
+ for (let i = 16; i < 64; i++) {
+ const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ (W[i - 15] >>> 3);
+ const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ (W[i - 2] >>> 10);
+ W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
+ }
+
+ let a = H[0], b = H[1], c = H[2], d = H[3];
+ let e = H[4], f = H[5], g = H[6], h = H[7];
+
+ for (let i = 0; i < 64; i++) {
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
+ const ch = (e & f) ^ (~e & g);
+ const t1 = (h + S1 + ch + K[i] + W[i]) | 0;
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
+ const maj = (a & b) ^ (a & c) ^ (b & c);
+ const t2 = (S0 + maj) | 0;
+
+ h = g; g = f; f = e; e = (d + t1) | 0;
+ d = c; c = b; b = a; a = (t1 + t2) | 0;
+ }
+
+ H[0] = (H[0] + a) | 0; H[1] = (H[1] + b) | 0;
+ H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
+ H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0;
+ H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
+ }
+
+ for (let i = 0; i < 8; i++) {
+ OUT[i * 4] = (H[i] >>> 24) & 0xff;
+ OUT[i * 4 + 1] = (H[i] >>> 16) & 0xff;
+ OUT[i * 4 + 2] = (H[i] >>> 8) & 0xff;
+ OUT[i * 4 + 3] = H[i] & 0xff;
+ }
+ return OUT;
+}
+
+// leadingZeroBits counts zero bits from the most significant end, the same
+// way the Go verifier does.
+export function leadingZeroBits(sum) {
+ let n = 0;
+ for (let i = 0; i < sum.length; i++) {
+ const b = sum[i];
+ if (b === 0) {
+ n += 8;
+ continue;
+ }
+ return n + Math.clz32(b) - 24;
+ }
+ return n;
+}
+
+// hex is used only by tests, to compare a digest against Go's.
+export function hex(bytes) {
+ let s = "";
+ for (let i = 0; i < bytes.length; i++) {
+ s += bytes[i].toString(16).padStart(2, "0");
+ }
+ return s;
+}
diff --git a/internal/movementtest/browser_pow_test.go b/internal/movementtest/browser_pow_test.go
new file mode 100644
index 0000000..1f27aed
--- /dev/null
+++ b/internal/movementtest/browser_pow_test.go
@@ -0,0 +1,156 @@
+//go:build browser
+
+package movementtest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ movement "amadan.net/carlos/movement/internal/movement"
+)
+
+// awaitPromise makes Evaluate wait for the expression's promise instead of
+// handing back a pending object. Every script here uses dynamic import, which
+// is asynchronous by nature. chromedp exports no such option of its own.
+func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+ return p.WithAwaitPromise(true)
+}
+
+func goSHA256(b []byte) []byte {
+ sum := sha256.Sum256(b)
+ return sum[:]
+}
+
+// This is the gate the whole proof-of-work scheme rests on.
+//
+// A Go-only test proves the verifier and nothing else. The failure it cannot
+// see is the one that actually happens: the browser and the server building
+// a different preimage from the same inputs — a stray Unicode case fold, a
+// different separator, a counter formatted another way — so every honest
+// visitor's solution is rejected as too short, with no error they can read
+// and nothing in the logs to explain it. The only way to know the two agree
+// is to run the shipped module in a real browser and check its answer here.
+//
+// It imports powcore.js, which is exactly what the Worker runs.
+
+func powRig(t *testing.T) *harness.Rig {
+ t.Helper()
+ return harness.New(t, func(origin string) http.Handler {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ movement.SetLogger(logger)
+ env := testEnv()
+ env.Origin = origin
+ mux, err := movement.App(d, env)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ return mux
+ })
+}
+
+// browserSolve runs the shipped solver in the page and returns its counter.
+const browserSolve = `(async () => {
+ const m = await import("/static/powcore.js");
+ return m.solve(%q, %q, %d, null);
+})()`
+
+func TestBrowserSolutionSatisfiesGoVerifier(t *testing.T) {
+ rig := powRig(t)
+ rig.Run(chromedp.Navigate(rig.Origin + "/"))
+
+ // Low difficulty: the property under test is agreement, not cost. A
+ // realistic 18 bits would make this test take as long as a real signup
+ // for no extra information.
+ const difficulty = 12
+ const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
+ const email = "Alice@Example.COM"
+
+ var counter string
+ rig.Run(chromedp.Evaluate(
+ fmt.Sprintf(browserSolve, nonce, email, difficulty),
+ &counter,
+ awaitPromise,
+ ))
+
+ if counter == "" {
+ t.Fatal("the browser returned no counter")
+ }
+ if !movement.VerifyPow(nonce, email, counter, difficulty) {
+ t.Fatalf("the browser solved %q for %q but Go rejects it: the two sides "+
+ "are building different preimages", counter, email)
+ }
+}
+
+// The address is inside the hash, and this is what that buys. If the binding
+// were ever dropped — or built differently on the two sides — one solve would
+// buy every address in a list.
+func TestBrowserSolutionDoesNotTransfer(t *testing.T) {
+ rig := powRig(t)
+ rig.Run(chromedp.Navigate(rig.Origin + "/"))
+
+ const difficulty = 12
+ const nonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"
+
+ var counter string
+ rig.Run(chromedp.Evaluate(
+ fmt.Sprintf(browserSolve, nonce, "alice@example.com", difficulty),
+ &counter,
+ awaitPromise,
+ ))
+
+ if movement.VerifyPow(nonce, "bob@example.com", counter, difficulty) {
+ t.Fatal("a solution the browser found for one address verified for another")
+ }
+}
+
+// The two implementations of SHA-256 must agree byte for byte, not merely
+// agree about which counters pass. This catches a divergence directly rather
+// than waiting for one to show up as a rejected signup.
+func TestBrowserSHA256MatchesGo(t *testing.T) {
+ rig := powRig(t)
+ rig.Run(chromedp.Navigate(rig.Origin + "/"))
+
+ inputs := []string{
+ "",
+ "a",
+ "abc",
+ "the quick brown fox jumps over the lazy dog",
+ // 55, 56 and 64 bytes: the padding boundaries, where a transcribed
+ // SHA-256 goes wrong if it is going to.
+ "5555555555555555555555555555555555555555555555555555555",
+ "66666666666666666666666666666666666666666666666666666666",
+ "6666666666666666666666666666666666666666666666666666666666666666",
+ }
+
+ const digestJS = `(async () => {
+ const m = await import("/static/sha256.js");
+ const b = new TextEncoder().encode(%q);
+ return m.hex(m.sha256(b, b.length));
+ })()`
+
+ for _, in := range inputs {
+ var got string
+ rig.Run(chromedp.Evaluate(fmt.Sprintf(digestJS, in), &got, awaitPromise))
+
+ want := hex.EncodeToString(goSHA256([]byte(in)))
+ if got != want {
+ t.Errorf("sha256(%q):\n browser %s\n go %s", in, got, want)
+ }
+ }
+}