carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1// The solver itself, separate from the Worker that hosts it.
2//
3// It is its own module so that the browser test can import and run exactly
4// this code. A test that re-implements the loop proves the two
5// implementations agree with each other and says nothing about the one that
6// ships.
7//
8// The search: find a counter where SHA-256(nonce ":" email ":" counter) has
9// at least `difficulty` leading zero bits. The address is inside the hash on
10// purpose — an unbound challenge is solved once and replayed against every
11// address in a list, so binding it is what makes each address cost its own
12// work, which is the axis a bulk signup attack actually scales along.
13
14import { sha256, leadingZeroBits } from "./sha256.js";
15
16const encoder = new TextEncoder();
17
18// asciiLower mirrors Go's NormalizeEmail exactly: A-Z and nothing else.
19//
20// String.prototype.toLowerCase is the obvious choice and it is wrong. It
21// folds parts of Unicode differently from Go's strings.ToLower, and a
22// one-byte disagreement in the preimage produces a solution the server
23// rejects as too short — a signup that fails for a real person, with no
24// error anybody can read and nothing in the logs to explain it.
25export function asciiLower(s) {
26 let out = "";
27 for (let i = 0; i < s.length; i++) {
28 const c = s.charCodeAt(i);
29 out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : s[i];
30 }
31 return out;
32}
33
34// solve returns the counter as a string, the same shape the form posts.
35//
36// onProgress, when given, is called every 20,000 attempts. Solve time is
37// geometric: the p99 is roughly 4.6x the average, so a visitor on old
38// hardware can legitimately wait several times what a laptop measures, and a
39// button with no sign of life is a button people give up on.
40export function solve(nonce, email, difficulty, onProgress) {
41 // The prefix never changes, so it is encoded once and the counter's digits
42 // are written after it. Re-encoding the whole string every attempt is most
43 // of the cost otherwise.
44 const prefix = encoder.encode(nonce + ":" + asciiLower(email.trim()) + ":");
45 const buf = new Uint8Array(prefix.length + 16);
46 buf.set(prefix);
47
48 let counter = 0;
49 let sinceReport = 0;
50
51 for (;;) {
52 let len = prefix.length;
53 if (counter === 0) {
54 buf[len++] = 48; // "0"
55 } else {
56 let n = counter;
57 let digits = 0;
58 while (n > 0) {
59 n = (n / 10) | 0;
60 digits++;
61 }
62 n = counter;
63 for (let i = digits - 1; i >= 0; i--) {
64 buf[len + i] = 48 + (n % 10);
65 n = (n / 10) | 0;
66 }
67 len += digits;
68 }
69
70 if (leadingZeroBits(sha256(buf, len)) >= difficulty) {
71 return String(counter);
72 }
73
74 counter++;
75 if (onProgress && ++sinceReport >= 20000) {
76 sinceReport = 0;
77 onProgress(counter);
78 }
79 }
80}
81