The demo has three parts. First, a gallery of generated distorted-text images: drag the distortion slider and notice how your success rate falls as the image gets harder, which is the human side of the trade. Second, a proof-of-work puzzle: your browser searches for a nonce whose SHA-256 hash starts with a chosen number of zero bits, and you see the attempts, the time, and the expected 2k work. Third, a cost table compares one human visit with a million bot visits.
<h3 class="h">1. Distorted-text CAPTCHAs</h3>
<p class="hint">Type the five characters. Raise the distortion and see how your own success rate changes. Resistance to machines is described, not measured here.</p>
<canvas id="cv" width="240" height="80" aria-label="Distorted text image"></canvas>
<label class="row">Distortion <b id="dval">3</b>
<input id="dist" type="range" min="0" max="10" value="3" aria-label="Distortion">
</label>
<div class="row">
<input id="ans" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="8" placeholder="Type the text" aria-label="Type the text">
<button id="chk" type="button">Check</button>
<button id="nw" type="button" class="ghost">New image</button>
</div>
<div id="msg" class="status"></div>
<div id="resist" class="note"></div>
<table class="tbl" id="stats"></table>
<h3 class="h">2. Proof of work with SHA-256</h3>
<p class="hint">Find a nonce so that SHA-256(prefix + nonce) starts with k zero bits. Each extra bit doubles the expected work, 2^k hashes.</p>
<label class="row">Difficulty k (zero bits) <b id="bval">14</b>
<input id="bits" type="range" min="4" max="22" value="14" aria-label="Difficulty k (zero bits)">
</label>
<div class="row">
<button id="solve" type="button">Solve the puzzle</button>
<button id="stop" type="button" class="ghost" disabled>Stop</button>
</div>
<div class="bar"><div id="fill"></div></div>
<div id="powout" class="mono"></div>
<div id="powsum" class="status"></div>
<h3 class="h">3. Cost of one visit versus a flood</h3>
<label class="row">Attacker speed-up over this browser:
<select id="spd" aria-label="Attacker speed-up over this browser:">
<option value="1">1x</option>
<option value="50">50x</option>
<option value="1000">1000x</option>
</select>
</label>
<table class="tbl" id="cost"></table>
<p class="hint">Times use the hash rate measured in your browser. The cost is trivial once, and large only when multiplied by a flood. Native code or GPUs are faster than JavaScript, which is why the speed-up factor is a parameter.</p>
:root { --fg:#1f2933; --bg:#ffffff; --muted:#52606d; --line:#cbd2d9; --accent:#1d4e89; --accent-fg:#ffffff; --ok:#0a7d33; --bad:#b42318; --soft:#eef2f6; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e4e7eb; --bg:#14181d; --muted:#9aa5b1; --line:#3e4c59; --accent:#6aa5e0; --accent-fg:#0b1118; --ok:#4cc17a; --bad:#ff7b72; --soft:#1f262e; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; font-size: 14px; }
.h { font-size: 1rem; margin: 1rem 0 .3rem; }
.h:first-child { margin-top: 0; }
.hint { color: var(--muted); line-height: 1.45; margin: 0 0 .6rem; }
.note { color: var(--muted); font-size: .85rem; margin: .3rem 0; min-height: 2.4em; line-height: 1.4; }
canvas { display: block; width: 100%; max-width: 240px; height: auto; border: 1px solid var(--line); border-radius: 8px; background: #f4f1e8; }
.row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: .5rem 0; }
input[type=range] { flex: 1 1 120px; min-width: 100px; }
input[type=text] { flex: 1 1 100px; min-width: 90px; font: 16px ui-monospace, monospace; padding: .4rem .5rem; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); }
select { font: inherit; padding: .3rem; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--accent); background: var(--accent); color: var(--accent-fg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button:disabled { opacity: .45; cursor: default; }
.status { font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.tbl { border-collapse: collapse; width: 100%; font-size: .85rem; margin: .4rem 0; }
.tbl th, .tbl td { border: 1px solid var(--line); padding: .3rem .4rem; text-align: left; }
.tbl th { background: var(--soft); }
.bar { height: 8px; background: var(--soft); border-radius: 4px; overflow: hidden; margin: .4rem 0; }
#fill { height: 100%; width: 0; background: var(--accent); }
.mono { font: 12px ui-monospace, monospace; word-break: break-all; color: var(--muted); min-height: 2.6em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const $ = (id) => document.getElementById(id);
const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
const FONTS = ['serif', 'sans-serif', 'monospace'];
let answer = '';
const stats = { low: [0, 0], mid: [0, 0], high: [0, 0] };
const bucket = (d) => (d <= 3 ? 'low' : d <= 7 ? 'mid' : 'high');
const rnd = (a, b) => a + Math.random() * (b - a);
function newText() {
let s = '';
for (let i = 0; i < 5; i++) s += ALPHA[Math.floor(Math.random() * ALPHA.length)];
return s;
}
function draw(text, d) {
const cv = $('cv');
const W = cv.width, H = cv.height;
const off = document.createElement('canvas');
off.width = W; off.height = H;
const g = off.getContext('2d');
g.fillStyle = '#f4f1e8';
g.fillRect(0, 0, W, H);
const step = 42 - d * 1.9;
const x0 = (W - step * (text.length - 1)) / 2;
g.textAlign = 'center';
g.textBaseline = 'middle';
for (let i = 0; i < text.length; i++) {
g.save();
g.translate(x0 + i * step + rnd(-d * 0.6, d * 0.6), H / 2 + rnd(-d * 1.4, d * 1.4));
g.rotate(rnd(-1, 1) * d * 0.06);
const sc = 1 + rnd(-1, 1) * d * 0.03;
g.scale(sc, sc);
g.font = 'bold ' + Math.round(rnd(34, 40)) + 'px ' + FONTS[Math.floor(Math.random() * 3)];
g.fillStyle = 'rgb(' + Math.floor(rnd(20, 90)) + ',' + Math.floor(rnd(20, 90)) + ',' + Math.floor(rnd(40, 110)) + ')';
g.fillText(text[i], 0, 0);
g.restore();
}
const ctx = cv.getContext('2d');
ctx.fillStyle = '#f4f1e8';
ctx.fillRect(0, 0, W, H);
const amp = d * 0.55, freq = rnd(0.05, 0.11), ph = rnd(0, 6.28);
for (let x = 0; x < W; x++) {
ctx.drawImage(off, x, 0, 1, H, x, Math.round(amp * Math.sin(x * freq + ph)), 1, H);
}
ctx.lineCap = 'round';
for (let i = 0; i < d * 1.2; i++) {
ctx.strokeStyle = 'rgba(' + Math.floor(rnd(30, 120)) + ',' + Math.floor(rnd(30, 120)) + ',' + Math.floor(rnd(30, 120)) + ',0.75)';
ctx.lineWidth = rnd(1, 1 + d * 0.2);
ctx.beginPath();
ctx.moveTo(rnd(0, W), rnd(0, H));
ctx.bezierCurveTo(rnd(0, W), rnd(0, H), rnd(0, W), rnd(0, H), rnd(0, W), rnd(0, H));
ctx.stroke();
}
for (let i = 0; i < d * 25; i++) {
ctx.fillStyle = 'rgba(60,60,60,0.5)';
ctx.fillRect(rnd(0, W), rnd(0, H), 1.5, 1.5);
}
}
function renderStats() {
const rows = [['low', "Low (0-3)"], ['mid', "Medium (4-7)"], ['high', "High (8-10)"]];
let h = '<tr><th>Distortion</th><th>Your tries</th><th>Solved</th></tr>';
for (const [k, name] of rows) {
const [n, ok] = stats[k];
h += '<tr><td>' + name + '</td><td>' + n + '</td><td>' + (n ? Math.round((100 * ok) / n) + '%' : '-') + '</td></tr>';
}
$('stats').innerHTML = h;
}
function renderResist() {
const d = +$('dist').value;
const k = bucket(d);
$('resist').textContent = k === 'low' ? "Stated: plain OCR reads this easily, so it stops almost nothing." : k === 'mid' ? "Stated: this is the regime of early-2000s CAPTCHAs; naive character segmentation starts to fail." : "Stated: hard for classical pipelines, but deep networks trained on such images have read comparable text (Goodfellow et al., 2014), and people also fail more often.";
}
function newChallenge() {
answer = newText();
draw(answer, +$('dist').value);
$('ans').value = '';
$('msg').textContent = '';
$('msg').className = 'status';
}
function check() {
const v = $('ans').value.trim().toLowerCase();
if (!v) return;
const b = stats[bucket(+$('dist').value)];
b[0]++;
if (v === answer) {
b[1]++;
$('msg').textContent = "Correct.";
$('msg').className = 'status ok';
} else {
$('msg').textContent = fmt("Not quite. It was: %a%", { a: answer });
$('msg').className = 'status bad';
}
renderStats();
draw(answer, +$('dist').value);
}
$('dist').addEventListener('input', () => {
$('dval').textContent = $('dist').value;
renderResist();
draw(answer, +$('dist').value);
});
$('chk').addEventListener('click', check);
$('nw').addEventListener('click', newChallenge);
$('ans').addEventListener('keydown', (e) => {
if (e.key === 'Enter') check();
});
const KK = new Uint32Array(64);
const HH = new Uint32Array(8);
(function initConst() {
const primes = [];
for (let n = 2; primes.length < 64; n++) {
let ok = true;
for (const p of primes) if (p * p <= n && n % p === 0) { ok = false; break; }
if (ok) primes.push(n);
}
for (let i = 0; i < 64; i++) KK[i] = Math.floor((Math.cbrt(primes[i]) % 1) * 4294967296);
for (let i = 0; i < 8; i++) HH[i] = Math.floor((Math.sqrt(primes[i]) % 1) * 4294967296);
})();
const BLK = new Uint8Array(64);
const W32 = new Uint32Array(64);
const OUT = new Uint32Array(8);
function sha256Short(str) {
const len = str.length;
BLK.fill(0);
for (let i = 0; i < len; i++) BLK[i] = str.charCodeAt(i);
BLK[len] = 0x80;
const bits = len * 8;
BLK[62] = (bits >>> 8) & 255;
BLK[63] = bits & 255;
for (let i = 0; i < 16; i++) {
W32[i] = (BLK[4 * i] << 24) | (BLK[4 * i + 1] << 16) | (BLK[4 * i + 2] << 8) | BLK[4 * i + 3];
}
for (let i = 16; i < 64; i++) {
const a = W32[i - 15], b = W32[i - 2];
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
W32[i] = (W32[i - 16] + s0 + W32[i - 7] + s1) | 0;
}
let a = HH[0], b = HH[1], c = HH[2], d = HH[3], e = HH[4], f = HH[5], g = HH[6], h = HH[7];
for (let i = 0; i < 64; i++) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + KK[i] + W32[i]) | 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const mj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + mj) | 0;
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
OUT[0] = (HH[0] + a) | 0; OUT[1] = (HH[1] + b) | 0; OUT[2] = (HH[2] + c) | 0; OUT[3] = (HH[3] + d) | 0;
OUT[4] = (HH[4] + e) | 0; OUT[5] = (HH[5] + f) | 0; OUT[6] = (HH[6] + g) | 0; OUT[7] = (HH[7] + h) | 0;
return OUT;
}
function hex(o) {
let s = '';
for (let i = 0; i < 8; i++) s += ('00000000' + (o[i] >>> 0).toString(16)).slice(-8);
return s;
}
function zeroBits(o) {
return o[0] !== 0 ? Math.clz32(o[0]) : 32 + Math.clz32(o[1]);
}
let rate = 0;
function calibrate() {
const t0 = performance.now();
let n = 0;
while (performance.now() - t0 < 60) {
for (let i = 0; i < 2000; i++) sha256Short('calibrate-' + (n + i));
n += 2000;
}
rate = n / ((performance.now() - t0) / 1000);
}
function fmtTime(sec) {
const t = (v, u) => fmt('%v% %u%', { v: v >= 100 ? Math.round(v).toLocaleString() : v.toPrecision(3), u: u });
if (sec < 1) return t(sec * 1000, "ms");
if (sec < 60) return t(sec, "s");
if (sec < 3600) return t(sec / 60, "min");
if (sec < 86400) return t(sec / 3600, "h");
if (sec < 31557600) return t(sec / 86400, "days");
return t(sec / 31557600, "years");
}
function renderCost() {
const k = +$('bits').value;
const spd = +$('spd').value;
const perVisit = Math.pow(2, k) / rate;
const rows = [
["One human visitor", 1, perVisit],
["Bot, 1,000 visits", 1000, perVisit / spd],
["Bot, 1,000,000 visits", 1000000, perVisit / spd]
];
let h = '<tr><th>Who</th><th>Visits</th><th>CPU time</th></tr>';
for (const [name, n, per] of rows) {
h += '<tr><td>' + name + '</td><td>' + n.toLocaleString() + '</td><td>' + fmtTime(per * n) + '</td></tr>';
}
$('cost').innerHTML = h;
}
let timer = null;
function stopSolve() {
if (timer) clearTimeout(timer);
timer = null;
$('solve').disabled = false;
$('stop').disabled = true;
}
function solve() {
stopSolve();
const k = +$('bits').value;
const prefix = 'kipu:' + Math.floor(Math.random() * 1e9).toString(36) + ':';
let nonce = 0;
const t0 = performance.now();
const expected = Math.pow(2, k);
$('solve').disabled = true;
$('stop').disabled = false;
$('powsum').textContent = '';
$('powsum').className = 'status';
$('powout').textContent = fmt("Prefix %p% - looking for %k% leading zero bits...", { p: prefix, k: k });
const step = () => {
const tEnd = performance.now() + 25;
while (performance.now() < tEnd) {
for (let i = 0; i < 500; i++) {
const o = sha256Short(prefix + nonce);
if (zeroBits(o) >= k) {
const secs = (performance.now() - t0) / 1000;
$('fill').style.width = '100%';
$('powout').textContent = fmt("Input: %s% - SHA-256: %h%", { s: prefix + nonce, h: hex(o) });
$('powsum').textContent = fmt("Solved after %n% attempts in %t%. Expected about %e% attempts.", { n: (nonce + 1).toLocaleString(), t: fmtTime(secs), e: expected.toLocaleString() });
$('powsum').className = 'status ok';
rate = (nonce + 1) / Math.max(secs, 0.001);
renderCost();
stopSolve();
return;
}
nonce++;
}
}
$('fill').style.width = Math.min(99, (100 * nonce) / expected) + '%';
$('powsum').textContent = fmt("Tried %n% nonces...", { n: nonce.toLocaleString() });
timer = setTimeout(step, 0);
};
step();
}
$('bits').addEventListener('input', () => {
$('bval').textContent = $('bits').value;
$('fill').style.width = '0';
renderCost();
});
$('spd').addEventListener('change', renderCost);
$('solve').addEventListener('click', solve);
$('stop').addEventListener('click', () => {
stopSolve();
$('powsum').textContent = "Stopped.";
});
calibrate();
renderStats();
renderResist();
renderCost();
newChallenge();
<h3 class="h">1. Distorted-text CAPTCHAs</h3>
<p class="hint">Type the five characters. Raise the distortion and see how your own success rate changes. Resistance to machines is described, not measured here.</p>
<canvas id="cv" width="240" height="80" aria-label="Distorted text image"></canvas>
<label class="row">Distortion <b id="dval">3</b>
<input id="dist" type="range" min="0" max="10" value="3" aria-label="Distortion">
</label>
<div class="row">
<input id="ans" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="8" placeholder="Type the text" aria-label="Type the text">
<button id="chk" type="button">Check</button>
<button id="nw" type="button" class="ghost">New image</button>
</div>
<div id="msg" class="status"></div>
<div id="resist" class="note"></div>
<table class="tbl" id="stats"></table>
<h3 class="h">2. Proof of work with SHA-256</h3>
<p class="hint">Find a nonce so that SHA-256(prefix + nonce) starts with k zero bits. Each extra bit doubles the expected work, 2^k hashes.</p>
<label class="row">Difficulty k (zero bits) <b id="bval">14</b>
<input id="bits" type="range" min="4" max="22" value="14" aria-label="Difficulty k (zero bits)">
</label>
<div class="row">
<button id="solve" type="button">Solve the puzzle</button>
<button id="stop" type="button" class="ghost" disabled>Stop</button>
</div>
<div class="bar"><div id="fill"></div></div>
<div id="powout" class="mono"></div>
<div id="powsum" class="status"></div>
<h3 class="h">3. Cost of one visit versus a flood</h3>
<label class="row">Attacker speed-up over this browser:
<select id="spd" aria-label="Attacker speed-up over this browser:">
<option value="1">1x</option>
<option value="50">50x</option>
<option value="1000">1000x</option>
</select>
</label>
<table class="tbl" id="cost"></table>
<p class="hint">Times use the hash rate measured in your browser. The cost is trivial once, and large only when multiplied by a flood. Native code or GPUs are faster than JavaScript, which is why the speed-up factor is a parameter.</p>
:root { --fg:#1f2933; --bg:#ffffff; --muted:#52606d; --line:#cbd2d9; --accent:#1d4e89; --accent-fg:#ffffff; --ok:#0a7d33; --bad:#b42318; --soft:#eef2f6; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e4e7eb; --bg:#14181d; --muted:#9aa5b1; --line:#3e4c59; --accent:#6aa5e0; --accent-fg:#0b1118; --ok:#4cc17a; --bad:#ff7b72; --soft:#1f262e; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; font-size: 14px; }
.h { font-size: 1rem; margin: 1rem 0 .3rem; }
.h:first-child { margin-top: 0; }
.hint { color: var(--muted); line-height: 1.45; margin: 0 0 .6rem; }
.note { color: var(--muted); font-size: .85rem; margin: .3rem 0; min-height: 2.4em; line-height: 1.4; }
canvas { display: block; width: 100%; max-width: 240px; height: auto; border: 1px solid var(--line); border-radius: 8px; background: #f4f1e8; }
.row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: .5rem 0; }
input[type=range] { flex: 1 1 120px; min-width: 100px; }
input[type=text] { flex: 1 1 100px; min-width: 90px; font: 16px ui-monospace, monospace; padding: .4rem .5rem; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); }
select { font: inherit; padding: .3rem; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid var(--accent); background: var(--accent); color: var(--accent-fg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button:disabled { opacity: .45; cursor: default; }
.status { font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.tbl { border-collapse: collapse; width: 100%; font-size: .85rem; margin: .4rem 0; }
.tbl th, .tbl td { border: 1px solid var(--line); padding: .3rem .4rem; text-align: left; }
.tbl th { background: var(--soft); }
.bar { height: 8px; background: var(--soft); border-radius: 4px; overflow: hidden; margin: .4rem 0; }
#fill { height: 100%; width: 0; background: var(--accent); }
.mono { font: 12px ui-monospace, monospace; word-break: break-all; color: var(--muted); min-height: 2.6em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const $ = (id) => document.getElementById(id);
const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
const FONTS = ['serif', 'sans-serif', 'monospace'];
let answer = '';
const stats = { low: [0, 0], mid: [0, 0], high: [0, 0] };
const bucket = (d) => (d <= 3 ? 'low' : d <= 7 ? 'mid' : 'high');
const rnd = (a, b) => a + Math.random() * (b - a);
function newText() {
let s = '';
for (let i = 0; i < 5; i++) s += ALPHA[Math.floor(Math.random() * ALPHA.length)];
return s;
}
function draw(text, d) {
const cv = $('cv');
const W = cv.width, H = cv.height;
const off = document.createElement('canvas');
off.width = W; off.height = H;
const g = off.getContext('2d');
g.fillStyle = '#f4f1e8';
g.fillRect(0, 0, W, H);
const step = 42 - d * 1.9;
const x0 = (W - step * (text.length - 1)) / 2;
g.textAlign = 'center';
g.textBaseline = 'middle';
for (let i = 0; i < text.length; i++) {
g.save();
g.translate(x0 + i * step + rnd(-d * 0.6, d * 0.6), H / 2 + rnd(-d * 1.4, d * 1.4));
g.rotate(rnd(-1, 1) * d * 0.06);
const sc = 1 + rnd(-1, 1) * d * 0.03;
g.scale(sc, sc);
g.font = 'bold ' + Math.round(rnd(34, 40)) + 'px ' + FONTS[Math.floor(Math.random() * 3)];
g.fillStyle = 'rgb(' + Math.floor(rnd(20, 90)) + ',' + Math.floor(rnd(20, 90)) + ',' + Math.floor(rnd(40, 110)) + ')';
g.fillText(text[i], 0, 0);
g.restore();
}
const ctx = cv.getContext('2d');
ctx.fillStyle = '#f4f1e8';
ctx.fillRect(0, 0, W, H);
const amp = d * 0.55, freq = rnd(0.05, 0.11), ph = rnd(0, 6.28);
for (let x = 0; x < W; x++) {
ctx.drawImage(off, x, 0, 1, H, x, Math.round(amp * Math.sin(x * freq + ph)), 1, H);
}
ctx.lineCap = 'round';
for (let i = 0; i < d * 1.2; i++) {
ctx.strokeStyle = 'rgba(' + Math.floor(rnd(30, 120)) + ',' + Math.floor(rnd(30, 120)) + ',' + Math.floor(rnd(30, 120)) + ',0.75)';
ctx.lineWidth = rnd(1, 1 + d * 0.2);
ctx.beginPath();
ctx.moveTo(rnd(0, W), rnd(0, H));
ctx.bezierCurveTo(rnd(0, W), rnd(0, H), rnd(0, W), rnd(0, H), rnd(0, W), rnd(0, H));
ctx.stroke();
}
for (let i = 0; i < d * 25; i++) {
ctx.fillStyle = 'rgba(60,60,60,0.5)';
ctx.fillRect(rnd(0, W), rnd(0, H), 1.5, 1.5);
}
}
function renderStats() {
const rows = [['low', "Low (0-3)"], ['mid', "Medium (4-7)"], ['high', "High (8-10)"]];
let h = '<tr><th>Distortion</th><th>Your tries</th><th>Solved</th></tr>';
for (const [k, name] of rows) {
const [n, ok] = stats[k];
h += '<tr><td>' + name + '</td><td>' + n + '</td><td>' + (n ? Math.round((100 * ok) / n) + '%' : '-') + '</td></tr>';
}
$('stats').innerHTML = h;
}
function renderResist() {
const d = +$('dist').value;
const k = bucket(d);
$('resist').textContent = k === 'low' ? "Stated: plain OCR reads this easily, so it stops almost nothing." : k === 'mid' ? "Stated: this is the regime of early-2000s CAPTCHAs; naive character segmentation starts to fail." : "Stated: hard for classical pipelines, but deep networks trained on such images have read comparable text (Goodfellow et al., 2014), and people also fail more often.";
}
function newChallenge() {
answer = newText();
draw(answer, +$('dist').value);
$('ans').value = '';
$('msg').textContent = '';
$('msg').className = 'status';
}
function check() {
const v = $('ans').value.trim().toLowerCase();
if (!v) return;
const b = stats[bucket(+$('dist').value)];
b[0]++;
if (v === answer) {
b[1]++;
$('msg').textContent = "Correct.";
$('msg').className = 'status ok';
} else {
$('msg').textContent = fmt("Not quite. It was: %a%", { a: answer });
$('msg').className = 'status bad';
}
renderStats();
draw(answer, +$('dist').value);
}
$('dist').addEventListener('input', () => {
$('dval').textContent = $('dist').value;
renderResist();
draw(answer, +$('dist').value);
});
$('chk').addEventListener('click', check);
$('nw').addEventListener('click', newChallenge);
$('ans').addEventListener('keydown', (e) => {
if (e.key === 'Enter') check();
});
const KK = new Uint32Array(64);
const HH = new Uint32Array(8);
(function initConst() {
const primes = [];
for (let n = 2; primes.length < 64; n++) {
let ok = true;
for (const p of primes) if (p * p <= n && n % p === 0) { ok = false; break; }
if (ok) primes.push(n);
}
for (let i = 0; i < 64; i++) KK[i] = Math.floor((Math.cbrt(primes[i]) % 1) * 4294967296);
for (let i = 0; i < 8; i++) HH[i] = Math.floor((Math.sqrt(primes[i]) % 1) * 4294967296);
})();
const BLK = new Uint8Array(64);
const W32 = new Uint32Array(64);
const OUT = new Uint32Array(8);
function sha256Short(str) {
const len = str.length;
BLK.fill(0);
for (let i = 0; i < len; i++) BLK[i] = str.charCodeAt(i);
BLK[len] = 0x80;
const bits = len * 8;
BLK[62] = (bits >>> 8) & 255;
BLK[63] = bits & 255;
for (let i = 0; i < 16; i++) {
W32[i] = (BLK[4 * i] << 24) | (BLK[4 * i + 1] << 16) | (BLK[4 * i + 2] << 8) | BLK[4 * i + 3];
}
for (let i = 16; i < 64; i++) {
const a = W32[i - 15], b = W32[i - 2];
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
W32[i] = (W32[i - 16] + s0 + W32[i - 7] + s1) | 0;
}
let a = HH[0], b = HH[1], c = HH[2], d = HH[3], e = HH[4], f = HH[5], g = HH[6], h = HH[7];
for (let i = 0; i < 64; i++) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + KK[i] + W32[i]) | 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const mj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + mj) | 0;
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
OUT[0] = (HH[0] + a) | 0; OUT[1] = (HH[1] + b) | 0; OUT[2] = (HH[2] + c) | 0; OUT[3] = (HH[3] + d) | 0;
OUT[4] = (HH[4] + e) | 0; OUT[5] = (HH[5] + f) | 0; OUT[6] = (HH[6] + g) | 0; OUT[7] = (HH[7] + h) | 0;
return OUT;
}
function hex(o) {
let s = '';
for (let i = 0; i < 8; i++) s += ('00000000' + (o[i] >>> 0).toString(16)).slice(-8);
return s;
}
function zeroBits(o) {
return o[0] !== 0 ? Math.clz32(o[0]) : 32 + Math.clz32(o[1]);
}
let rate = 0;
function calibrate() {
const t0 = performance.now();
let n = 0;
while (performance.now() - t0 < 60) {
for (let i = 0; i < 2000; i++) sha256Short('calibrate-' + (n + i));
n += 2000;
}
rate = n / ((performance.now() - t0) / 1000);
}
function fmtTime(sec) {
const t = (v, u) => fmt('%v% %u%', { v: v >= 100 ? Math.round(v).toLocaleString() : v.toPrecision(3), u: u });
if (sec < 1) return t(sec * 1000, "ms");
if (sec < 60) return t(sec, "s");
if (sec < 3600) return t(sec / 60, "min");
if (sec < 86400) return t(sec / 3600, "h");
if (sec < 31557600) return t(sec / 86400, "days");
return t(sec / 31557600, "years");
}
function renderCost() {
const k = +$('bits').value;
const spd = +$('spd').value;
const perVisit = Math.pow(2, k) / rate;
const rows = [
["One human visitor", 1, perVisit],
["Bot, 1,000 visits", 1000, perVisit / spd],
["Bot, 1,000,000 visits", 1000000, perVisit / spd]
];
let h = '<tr><th>Who</th><th>Visits</th><th>CPU time</th></tr>';
for (const [name, n, per] of rows) {
h += '<tr><td>' + name + '</td><td>' + n.toLocaleString() + '</td><td>' + fmtTime(per * n) + '</td></tr>';
}
$('cost').innerHTML = h;
}
let timer = null;
function stopSolve() {
if (timer) clearTimeout(timer);
timer = null;
$('solve').disabled = false;
$('stop').disabled = true;
}
function solve() {
stopSolve();
const k = +$('bits').value;
const prefix = 'kipu:' + Math.floor(Math.random() * 1e9).toString(36) + ':';
let nonce = 0;
const t0 = performance.now();
const expected = Math.pow(2, k);
$('solve').disabled = true;
$('stop').disabled = false;
$('powsum').textContent = '';
$('powsum').className = 'status';
$('powout').textContent = fmt("Prefix %p% - looking for %k% leading zero bits...", { p: prefix, k: k });
const step = () => {
const tEnd = performance.now() + 25;
while (performance.now() < tEnd) {
for (let i = 0; i < 500; i++) {
const o = sha256Short(prefix + nonce);
if (zeroBits(o) >= k) {
const secs = (performance.now() - t0) / 1000;
$('fill').style.width = '100%';
$('powout').textContent = fmt("Input: %s% - SHA-256: %h%", { s: prefix + nonce, h: hex(o) });
$('powsum').textContent = fmt("Solved after %n% attempts in %t%. Expected about %e% attempts.", { n: (nonce + 1).toLocaleString(), t: fmtTime(secs), e: expected.toLocaleString() });
$('powsum').className = 'status ok';
rate = (nonce + 1) / Math.max(secs, 0.001);
renderCost();
stopSolve();
return;
}
nonce++;
}
}
$('fill').style.width = Math.min(99, (100 * nonce) / expected) + '%';
$('powsum').textContent = fmt("Tried %n% nonces...", { n: nonce.toLocaleString() });
timer = setTimeout(step, 0);
};
step();
}
$('bits').addEventListener('input', () => {
$('bval').textContent = $('bits').value;
$('fill').style.width = '0';
renderCost();
});
$('spd').addEventListener('change', renderCost);
$('solve').addEventListener('click', solve);
$('stop').addEventListener('click', () => {
stopSolve();
$('powsum').textContent = "Stopped.";
});
calibrate();
renderStats();
renderResist();
renderCost();
newChallenge();
Two things to notice. In part one the trade-off is uncomfortable: the same distortion that confuses a program also confuses you. In part two the puzzle is not hard for one visitor, but its cost is the same for every visit, so a flood of visits multiplies it. That is the key shift in the history that follows: from "prove you are human" to "pay a small price per request".
Comments
Loading comments...