| 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 | |
| 14 | import { sha256, leadingZeroBits } from "./sha256.js"; |
| 15 | |
| 16 | const 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. |
| 25 | export 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. |
| 40 | export 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 | |