PageSourceSearch

https://krackpot.io/src/kangaroo/page.js

js krackpot.io collected 2026-10-06 19:58:28 UTC 25,851 bytes, 574 lines download raw bytes

1// Page wiring for the kangaroo demo.
2//
3// The whole page exists to make one claim runnable instead of asserted: a
4// public key from a narrow, known range is recoverable in about the square root
5// of the work a brute-force sweep needs. Everything on screen is generated in
6// front of the visitor so the recovery can be checked rather than believed.
7
8import { expectedWork, medianBruteKeys } from "./kangaroo.js";
9import { scalarMul, compressPubkey } from "../reference/secp.js";
10import { privToAddress } from "../reference/address.js";
11
12const $ = (id) => document.getElementById(id);
13
14// The reference GPU for the brute-force column. Same card and same measured
15// figure the main app's odds ladder uses, so the two pages cannot disagree.
16const REF_GPU = "RTX 3070";
17const REF_RATE = 22_550_000;
18
19// Every range runs on the GPU, both sides.
20//
21// There used to be JavaScript ranges below 2^44. They were dropped because they
22// made the page tell two different stories without explaining the join. A
23// JavaScript brute force manages about 450 keys/sec against 5.99 M on the same
24// laptop's GPU, so the page showed rates differing by four orders of magnitude
25// with no account of why, and the algorithmic saving looked roughly a hundred
26// times larger in JavaScript than it is. One engine means one comparison, and
27// every number on the page is then measured the same way.
28//
29// The cost is that a visitor without WebGPU cannot run anything. They still get
30// the whole argument in prose, and the main app already refuses them, so the
31// page is consistent with the rest of the project rather than uniquely broken.
32//
33// Times are from an M1 Air at 4.8 to 5.5M jumps/sec, which is the slow end. A
34// faster card cuts them roughly in proportion to its search rate.
35const RANGES = [
36  { bits: 44n, label: "2^44", note: "a few seconds" },
37  { bits: 52n, label: "2^52", note: "about 30 seconds" },
38  { bits: 60n, label: "2^60", note: "about 10 minutes" },
39];
40
41const gpuState = { checked: false, device: null, label: "gpu", error: null };
42
43const ensureGpu = async () => {
44  if (gpuState.checked) return gpuState.device;
45  gpuState.checked = true;
46  try {
47    const { initWebGPU, browserName } = await import("../webgpu.js");
48    const { adapter, device } = await initWebGPU();
49    gpuState.device = device;
50    // Name the silicon. Any jump rate quoted from this page is worthless
51    // without knowing what produced it, which is the same reason the main app
52    // shows a GPU ADAPTER tile. Kept in step with it on purpose: this page is about to be
53    // announced, so its readings arrive as community reports carrying the same ambiguities,
54    // and the same two fixes apply.
55    //
56    // info.description first, because Chromium on Windows puts the full card name there
57    // ("NVIDIA GeForce RTX 4090") while vendor and architecture give only the family.
58    // Deduplicated after, because Safari reports both as "apple" and doubling it reads like
59    // a bug. Firefox leaves both empty, which is what the fallback is for.
60    //
61    // The browser is named because it matters as much as the silicon here: an M3 Pro
62    // measured 14.94 MK/s on Chrome and 7.72 on Firefox, and Firefox is the one engine that
63    // leaves adapter.info empty, so the tile used to read just "gpu (wgsl)" on exactly the
64    // browser where knowing mattered most.
65    const info = adapter.info || {};
66    const gpuPart = info.description ||
67      [...new Set([info.vendor, info.architecture].filter(Boolean))].join(" · ");
68    gpuState.label = [gpuPart || "gpu (wgsl)", browserName()].filter(Boolean).join(" · ");
69  } catch (err) {
70    gpuState.error = err;
71    gpuState.device = null;
72  }
73  return gpuState.device;
74};
75
76const SECONDS = { minute: 60, hour: 3600, day: 86400, year: 31556952 };
77
78const prettyDuration = (sec) => {
79  if (!Number.isFinite(sec)) return "longer than anyone has measured";
80  if (sec < 1) return "under a second";
81  if (sec < SECONDS.minute) return `${sec.toFixed(sec < 10 ? 1 : 0)} seconds`;
82  if (sec < SECONDS.hour) return `${(sec / SECONDS.minute).toFixed(1)} minutes`;
83  if (sec < SECONDS.day) return `${(sec / SECONDS.hour).toFixed(1)} hours`;
84  if (sec < SECONDS.year) return `${(sec / SECONDS.day).toFixed(1)} days`;
85  const years = sec / SECONDS.year;
86  // Up to 10,000 rather than 1,000: the 2^60 brute-force figure moved from 810 to 1,123 years when
87  // the median was corrected, and at the old threshold that rendered as "1 thousand years", which
88  // is both vaguer than the number it replaced and disagrees with the social card.
89  if (years < 10000) return `${Math.round(years).toLocaleString("en-US")} years`;
90  if (years < 1e6) return `${(years / 1000).toFixed(0)} thousand years`;
91  if (years < 1e9) return `${(years / 1e6).toFixed(1)} million years`;
92  return `${(years / 1e9).toFixed(1)} billion years`;
93};
94
95// BigInt-aware. width*ln(2) for the larger ranges exceeds 2^53, where Number() would print a
96// fake round tail (...166,000 instead of ...166,001) on a page whose entire argument is that the
97// numbers are checkable. BigInt has its own toLocaleString.
98const fmt = (n) => (typeof n === "bigint" ? n : Number(n)).toLocaleString("en-US");
99
100// One formatter for measured rates, used by the card, the progress line and the
101// result note. Two of these printed the same measurement as "5.99 M" and
102// "6.0 M" on one screen, which invites a reader to wonder which is real on a
103// page whose whole argument is that the numbers are checkable.
104const fmtRate = (r) => (r >= 1e6 ? (r / 1e6).toFixed(2) + " M" : fmt(Math.round(r)));
105const hex = (n, len = 64) => n.toString(16).padStart(len, "0");
106
107// A 64-character key is mostly leading zeros, and the page asks the reader to
108// check the recovered value against the generated one. The eye cannot compare
109// two 64-char strings, so the padding is dimmed and the significant tail left at
110// full strength. Writes markup, so it takes only hex from a BigInt.
111const hexSplit = (n, len = 64) => {
112  if (n === null || n === undefined) return "";
113  const s = hex(n, len);
114  const tail = s.replace(/^0+/, "") || "0";
115  const pad = s.slice(0, s.length - tail.length);
116  return (pad ? `<span class="hex-pad">${pad}</span>` : "") + tail;
117};
118const toHexBytes = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
119
120/** A key drawn with the Web Crypto CSPRNG, never Math.random. */
121const randomKeyInRange = (start, width) => {
122  const bytes = new Uint8Array(40);
123  crypto.getRandomValues(bytes);
124  let v = 0n;
125  for (const b of bytes) v = (v << 8n) | BigInt(b);
126  return start + (v % width);
127};
128
129const state = {
130  // 2^52: about half a minute of kangaroo against roughly three years of brute
131  // force. Long enough to feel real, short enough that people wait for it.
132  range: RANGES[1],
133  key: null,
134  pub: null,
135  running: false,
136  cancelled: false,
137  benching: false,
138  benchFailed: false,
139  // navigator.gpu existing is not the same as getting a device, so this is
140  // only known once something has actually asked for one.
141  noGpu: false,
142};
143
144// --- rendering ------------------------------------------------------------
145
146// Focus goes to whatever now occupies the position of the control that was just
147// pressed, so the viewport never moves. Two earlier attempts moved it instead:
148// the Run button in step 04 threw the page down past two sections, and step 02
149// is tall enough that scrolling all of it into view pins it to the bottom edge
150// and scrolls the hero off. Generate is replaced in place by "Generate a
151// different one", and Run by "Stop", so both are already under the cursor.
152// The news that a key exists is carried by the status announcement instead.
153const PHASE_FOCUS = { ready: "regenerate", running: "cancel-kangaroo" };
154
155const setPhase = (name) => {
156  const prev = document.activeElement;
157  document.body.dataset.phase = name;
158  for (const el of document.querySelectorAll("[data-phase-show]")) {
159    el.hidden = !el.dataset.phaseShow.split(" ").includes(name);
160  }
161  // Two columns can otherwise disagree about whether the page is busy: each
162  // handler guards itself, but silently, so the other control just looked live.
163  $("run-bench").disabled = state.running;
164  for (const el of document.querySelectorAll("#range-choices input")) {
165    el.disabled = !navigator.gpu || state.running || state.benching;
166  }
167  // Every transition on this page is triggered by clicking a button that then
168  // hides itself, which drops focus to <body> and sends a keyboard user back to
169  // the top of the document. Hand focus to whatever replaced it. On `done` that
170  // is the result section, and focusing freshly revealed content is also what
171  // makes a screen reader read the verdict, which a live region written while
172  // its ancestor was hidden would not.
173  if (!prev || prev === document.body || prev.offsetParent !== null) return;
174  const next = name === "done" ? document.querySelector(".kg-result") : $(PHASE_FOCUS[name]);
175  if (next && !next.hidden) next.focus();
176};
177
178/** Return the target block to its empty state. */
179const resetTarget = () => {
180  state.key = null;
181  state.pub = null;
182  for (const id of ["target-privkey", "target-address", "target-pubkey"]) {
183    const el = $(id);
184    el.textContent = "generated when you press the button";
185    el.classList.add("empty");
186  }
187  setPhase("idle");
188};
189
190const renderRangeChoices = () => {
191  const hasGpu = !!navigator.gpu;
192  const wrap = $("range-choices");
193  wrap.innerHTML = "";
194  for (const r of RANGES) {
195    const blocked = !hasGpu;
196    const id = `range-${r.bits}`;
197    const label = document.createElement("label");
198    label.className = "range-choice" + (blocked ? " blocked" : "");
199    label.innerHTML =
200      `<input type="radio" name="range" id="${id}" value="${r.bits}"${r === state.range ? " checked" : ""}${blocked ? " disabled" : ""}>` +
201      `<span class="rc-label">${r.label}</span>` +
202      `<span class="rc-note">${blocked ? "no WebGPU here" : r.note}</span>`;
203    label.querySelector("input").addEventListener("change", () => {
204      // Never mid-flight. Resetting here would hide Stop while the GPU kept
205      // walking, and the completed result would then be thrown away.
206      if (state.running || state.benching) { renderRangeChoices(); return; }
207      state.range = r;
208      // The key belongs to the old interval, and renderProjection is about to
209      // move rangeStart. Keeping it would show a key outside the range printed
210      // directly above it, and Run would then walk an interval it cannot be in.
211      if (state.key !== null) resetTarget();
212      renderProjection();
213    });
214    wrap.appendChild(label);
215  }
216  $("gpu-hint").hidden = hasGpu;
217};
218
219// Measured brute-force rates, cached per engine for this page load.
220//
221// Keyed by engine rather than by range, so switching 2^44 to 2^52 recomputes
222// only the projection. Deliberately not persisted: a rate measured while the
223// machine was busy would be wrong for every later visit, and it is cheap to
224// take again.
225let benchResult = null;
226
227const renderProjection = () => {
228  const width = 2n ** state.range.bits;
229  const { bruteForceKeys, kangarooJumps } = expectedWork(width);
230
231  // A non-zero start, so the range reads as an interval rather than as "small
232  // numbers", which is the thing people wrongly assume makes this work.
233  state.rangeStart = 2n ** (state.range.bits + 8n);
234  $("target-range-lo").textContent = state.rangeStart.toString(16);
235  $("target-range-hi").textContent = (state.rangeStart + width).toString(16);
236
237  $("proj-range").textContent = state.range.label;
238
239  // The herds figure names the range too, so it follows the picker rather than
240  // stating the default while the reader looks at a different interval. SVG has
241  // no <sup>, so the exponent is superscript digits.
242  $("fig-range-label").textContent =
243    "2" + [...state.range.bits.toString()].map((d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[+d]).join("");
244  $("proj-brute-keys").textContent = fmt(bruteForceKeys);
245  $("proj-brute-keys-2").textContent = fmt(bruteForceKeys);
246  $("proj-brute-time").textContent = prettyDuration(Number(bruteForceKeys) / REF_RATE);
247  $("proj-kangaroo-jumps").textContent = fmt(kangarooJumps);
248
249  // Work over work. No hardware in it, so it holds on any machine in any
250  // browser, and it rises monotonically across every range. Deriving it from
251  // measured times instead would swing it by 150x at the JavaScript-to-GPU
252  // boundary and overstate the algorithm's saving in JavaScript by more than a
253  // hundredfold, because JavaScript brute force is anomalously slow rather than
254  // the GPU being anomalously fast.
255  $("proj-ratio").textContent = fmt(bruteForceKeys / (kangarooJumps || 1n));
256
257  renderBench();
258};
259
260/** Show whichever of the four benchmark states currently applies. */
261const renderBench = () => {
262  const bench = benchResult;
263  const measured = !!bench && !state.benchFailed;
264
265  $("bench-start").hidden = measured || state.benching || state.noGpu;
266  $("bench-running").hidden = !state.benching;
267  $("bench-measured").hidden = !measured;
268  $("measured-note").hidden = !measured;
269  $("bench-fallback").hidden = measured;
270  $("fallback-note").hidden = measured;
271  $("bench-invite").hidden = measured || state.noGpu;
272
273  // Both routes to "no device" land here, so this is the one place that has to
274  // reveal the standing note beside the controls. Measuring alone never reaches
275  // the result panel, and without this that route explains nothing at the top.
276  if (state.noGpu) $("gpu-hint").hidden = false;
277
278  if (!measured) return;
279
280  const width = 2n ** state.range.bits;
281  const { bruteForceKeys } = expectedWork(width);
282  const seconds = Number(bruteForceKeys) / bench.rate;
283
284  $("measured-title").textContent = "Your GPU, just measured";
285  $("measured-rate").textContent = fmtRate(bench.rate);
286  $("measured-engine").textContent = bench.label;
287  $("measured-range").textContent = state.range.label;
288  $("measured-time").textContent = prettyDuration(seconds);
289  $("measured-keys").textContent = fmt(bruteForceKeys);
290
291  $("measured-note").textContent =
292    "Same machine, both sides. The kangaroo runs on this GPU too, so the " +
293    "difference you are about to see is the algorithm and nothing else.";
294};
295
296const renderTarget = async () => {
297  for (const id of ["target-privkey", "target-address", "target-pubkey"]) {
298    $(id).classList.remove("empty");
299  }
300  $("target-privkey").innerHTML = hexSplit(state.key);
301  $("target-pubkey").textContent = toHexBytes(compressPubkey(state.pub));
302  $("target-address").textContent = "deriving...";
303  $("target-address").textContent = await privToAddress(state.key);
304};
305
306const renderLive = ({ jumps, dps, elapsedMs }) => {
307  $("live-engine").textContent = gpuState.label;
308  $("live-jumps").textContent = fmt(jumps);
309  $("live-dps").textContent = fmt(dps);
310  $("live-elapsed").textContent = (elapsedMs / 1000).toFixed(1) + "s";
311  const rate = elapsedMs > 0 ? jumps / (elapsedMs / 1000) : 0;
312  $("live-rate").textContent = fmt(Math.round(rate));
313};
314
315/**
316 * Fill the fix block with platform-specific instructions for getting WebGPU
317 * working, reusing the same helper the main page shows in #gpu-info.
318 *
319 * Worth the round trip because the most common real failure is not an incapable
320 * machine, it is Linux plus a Chromium browser with Vulkan switched off, and
321 * that is two flags away from working. Telling someone it does not work without
322 * telling them that is a diagnosis with no remedy.
323 */
324const showGpuFix = async () => {
325  const el = $("result-gpu-fix");
326  try {
327    const { describeWebGPUFailure } = await import("../webgpu.js");
328    const why = (gpuState.error && gpuState.error.message) || "no WebGPU device";
329    // Drop the leading FAILED: line. The verdict directly above already said it,
330    // and repeating it here just pushes the actual steps further down.
331    el.textContent = describeWebGPUFailure(why).replace(/^FAILED:[^\n]*\n+/, "");
332    el.hidden = false;
333  } catch (err) {
334    // The module that explains the failure failing is not worth a second banner.
335    console.warn("could not describe the WebGPU failure:", err);
336    el.hidden = true;
337  }
338};
339
340const renderResult = (res) => {
341  const matched = res.key !== null && res.key === state.key;
342
343  // Reset before branching. Two of the branches below return early, and without
344  // this a failure inherits the previous run's jump count and timing.
345  for (const id of ["result-jumps", "result-elapsed", "result-vs"]) $(id).textContent = "";
346  $("result-brute-dt").hidden = true;
347  $("result-brute").hidden = true;
348  $("result-facts").hidden = false;
349  $("result-gpu-fix").hidden = true;
350  $("result-key").className = res.key === null ? "mono" : "mono accent";
351  if (res.key === null) $("result-key").textContent = "not recovered";
352  else $("result-key").innerHTML = hexSplit(res.key);
353  $("result-original").innerHTML = hexSplit(state.key);
354  const verdict = $("result-verdict");
355  if (res.noGpu) {
356    verdict.textContent =
357      "This demo needs WebGPU and this browser did not provide a device. " +
358      "Everything above still describes what the attack does; it just cannot run here.";
359    verdict.className = "verdict bad";
360    // Nothing ran, so "not recovered", a blank jump count and a blank time are
361    // all noise dressed up as data. The verdict is the entire message.
362    $("result-facts").hidden = true;
363    state.noGpu = true;
364    showGpuFix();
365    renderBench();
366    return;
367  }
368  if (res.gpuError) {
369    verdict.textContent =
370      "The GPU walk failed to start: " + (res.gpuError.message || res.gpuError) +
371      ". The details are in the browser console.";
372    verdict.className = "verdict bad";
373    return;
374  }
375  if (matched) {
376    verdict.textContent = "Recovered. It matches the key generated above.";
377    verdict.className = "verdict ok";
378  } else if (res.stopped) {
379    verdict.textContent = "Stopped before the walk finished.";
380    verdict.className = "verdict";
381  } else {
382    verdict.textContent = "The walk ran out of budget without a collision. Try again or pick a smaller range.";
383    verdict.className = "verdict bad";
384  }
385  $("result-jumps").textContent = fmt(res.jumps);
386  $("result-elapsed").textContent = (res.elapsedMs / 1000).toFixed(2) + "s";
387
388  // If the machine was measured, both figures in this table come from it, which
389  // is the sentence the page has been building toward. If it was not, say whose
390  // hardware the other number belongs to rather than letting it pass as yours.
391  const width = 2n ** state.range.bits;
392  const bench = benchResult;
393  const measured = bench && !state.benchFailed;
394  const bruteSeconds = Number(medianBruteKeys(width)) / (measured ? bench.rate : REF_RATE);
395
396  $("result-brute-dt").hidden = !measured;
397  $("result-brute").hidden = !measured;
398  if (measured) {
399    $("result-brute-dt").textContent = "Brute force, same GPU";
400    $("result-brute").textContent = prettyDuration(bruteSeconds);
401    $("result-vs").textContent =
402      `Both figures come from this machine. A sweep of the same range needs a median ` +
403      `${fmt(medianBruteKeys(width))} keys at your measured ${fmtRate(bench.rate)} keys/sec.`;
404  } else if (!res.stopped) {
405    $("result-vs").textContent =
406      `A brute-force sweep of the same range needs a median ${fmt(medianBruteKeys(width))} keys, ` +
407      `about ${prettyDuration(bruteSeconds)} on an ${REF_GPU}. That is a reference card, ` +
408      `not the machine that just ran the kangaroo.`;
409  }
410};
411
412// --- running --------------------------------------------------------------
413
414/**
415 * Measure brute force on the same GPU that is about to run the kangaroo.
416 *
417 * Both sides on one engine is the whole point. The page used to quote a
418 * reference card against the visitor's own walk and the two numbers disagreed
419 * by four orders of magnitude.
420 */
421const runBench = async () => {
422  if (state.benching || state.running) return;
423  if (benchResult) return;
424
425  state.benching = true;
426  state.benchFailed = false;
427  $("bench-live").textContent = "measuring...";
428  $("bench-detail").textContent = "Calibrating dispatch size, then timing three of them.";
429  renderBench();
430
431  try {
432    const device = await ensureGpu();
433    if (!device) throw new Error("no WebGPU device");
434    const { benchGpu } = await import("./bench.js");
435    benchResult = await benchGpu({
436      label: gpuState.label,
437      onProgress: ({ done, total, rate }) => {
438        $("bench-live").textContent = `${fmtRate(rate)} keys/sec`;
439        $("bench-detail").textContent = `Timed dispatch ${done} of ${total}.`;
440      },
441    });
442  } catch (err) {
443    console.warn("benchmark failed, keeping the reference figure:", err);
444    state.benchFailed = true;
445    // Two very different failures land here. With no device at all the kangaroo
446    // cannot run either, so promising that it still will would contradict the
447    // result panel, which says the demo cannot run here. Retrying is pointless
448    // in that case too, so the invitation goes away rather than baiting a click.
449    state.noGpu = !gpuState.device;
450    $("fallback-note").textContent = state.noGpu
451      ? "Your machine could not be measured, because this browser did not provide a WebGPU " +
452        "device. Neither side of this comparison can run here, so both figures are calculated " +
453        "rather than measured on your hardware."
454      : "Your machine could not be measured, so this is a reference card rather than yours, " +
455        "and it is calculated rather than run. The kangaroo still runs on your hardware, " +
456        "so treat the comparison as indicative.";
457    if (!state.noGpu) $("run-bench").textContent = "Try measuring again";
458  } finally {
459    state.benching = false;
460    renderBench();
461  }
462};
463
464const runOnGpu = async (opts, onProgress, device) => {
465  const { runGpu } = await import("./gpu.js");
466  return runGpu({
467    ...opts,
468    device,
469    onProgress,
470    shouldStop: () => state.cancelled,
471    onSeed: ({ label, done, total }) => {
472      // Not the ELAPSED tile: a label that says ELAPSED should not read
473      // "seeding tame 40%". RUNNING ON is idle at this point and already has
474      // the small mono treatment for strings.
475      $("live-engine").textContent = `seeding ${label} ${Math.round((done / total) * 100)}%`;
476    },
477  });
478};
479
480const startWalk = async () => {
481  // Both would dispatch onto the same device and corrupt each other's timing,
482  // which is precisely the pair of numbers this page exists to compare.
483  if (state.running || state.benching) return;
484  state.running = true;
485  state.cancelled = false;
486  setPhase("running");
487  $("live-status").textContent = "Kangaroo walk started. Progress is shown in the counters in the demonstration panel.";
488  renderLive({ jumps: 0, dps: 0, elapsedMs: 0 });
489
490  const width = 2n ** state.range.bits;
491  $("live-engine").textContent = "detecting...";
492  const device = await ensureGpu();
493
494  // No GPU is a refusal, not a fallback. Every range here needs one, and a
495  // JavaScript walk at 2^44 would run for days.
496  if (!device) {
497    state.running = false;
498    renderResult({ key: null, jumps: 0, dps: 0, elapsedMs: 0, stopped: true, noGpu: true });
499    setPhase("done");
500    return;
501  }
502
503  let res;
504  try {
505    $("live-engine").textContent = gpuState.label;
506    res = await runOnGpu(
507      { pub: state.pub, rangeStart: state.rangeStart, rangeWidth: width },
508      renderLive,
509      device
510    );
511  } catch (err) {
512    // There is nothing to fall back to. Dropping to a JavaScript walk here would
513    // quietly start something that finishes in days, which is worse than saying
514    // the GPU path failed.
515    console.error("GPU walk failed:", err);
516    state.running = false;
517    renderResult({ key: null, jumps: 0, dps: 0, elapsedMs: 0, stopped: true, gpuError: err });
518    setPhase("done");
519    return;
520  }
521
522  state.running = false;
523  $("cancel-kangaroo").removeAttribute("aria-disabled");
524  $("cancel-kangaroo").textContent = "Stop";
525  renderResult(res);
526  $("live-status").textContent = $("result-verdict").textContent;
527  setPhase("done");
528};
529
530const cancelWalk = () => {
531  if (state.cancelled) return;
532  state.cancelled = true;
533  // shouldStop is only read between dispatches, and seeding runs before the
534  // first one, so a press can sit unacknowledged for a while. Say so rather
535  // than looking broken.
536  // aria-disabled, not disabled: disabling the element that currently has focus
537  // drops focus to <body>, which makes setPhase("done") short-circuit and the
538  // result is then neither focused nor announced.
539  const btn = $("cancel-kangaroo");
540  btn.setAttribute("aria-disabled", "true");
541  btn.textContent = "Stopping...";
542  $("live-status").textContent = "Stopping after the current step.";
543};
544
545const generate = async () => {
546  const width = 2n ** state.range.bits;
547  state.key = randomKeyInRange(state.rangeStart, width);
548  state.pub = scalarMul(state.key);
549  await renderTarget();
550  renderProjection();
551  setPhase("ready");
552  $("live-status").textContent =
553    "Key generated. The private key, address and public key are in the demonstration panel.";
554};
555
556// --- init -----------------------------------------------------------------
557
558renderRangeChoices();
559renderProjection();
560setPhase("idle");
561
562$("run-bench").addEventListener("click", () => { runBench().catch((e) => console.error(e)); });
563$("generate").addEventListener("click", () => { generate().catch((e) => console.error(e)); });
564$("run-kangaroo").addEventListener("click", () => { startWalk().catch((e) => console.error(e)); });
565$("cancel-kangaroo").addEventListener("click", cancelWalk);
566$("regenerate").addEventListener("click", () => { generate().catch((e) => console.error(e)); });
567
568// Surfaced for anyone reading the source, which the project invites.
569console.log(
570  "%cKrackpot kangaroo demo",
571  "font-weight:bold",
572  "\nEverything runs locally. Source: /src/kangaroo/kangaroo.js" +
573  "\nIt only ever attacks a key this page generated itself."
574);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.