/** * Time-lock puzzles — the build-time half of the accessible plain-text path. * * ## Why this exists * * `` marks its encoded block `aria-hidden`, because voicing a decoy is * worse than voicing nothing. That leaves assistive technology with silence, * and silence on its own is not an alternative. The obvious fix — put the * original words at a URL and link to it — shipped in 0.2.0 as * `a11y={{ mode: "text", href }}` and was REMOVED in this release, because a URL * cannot be offered to a screen reader without being offered to everyone else. * The same crawl that reads the decoy reads the link beside it, for one more * line of code. The accessible path was the cheapest path into the plaintext, * which made it the weakest part of the package. * * This module is the replacement. The plain text still ships in the page, but * ENCRYPTED, and the key is not in the page either — it is a number the browser * has to *grind out*, and grinding costs a deliberate, tunable amount of CPU. * * ## The design goal, stated exactly * * The goal is NOT to make the plaintext expensive in absolute terms. It cannot * be: a crawler that wants the words can always render the page and OCR the * pixels, which costs ~5.0 CPU-seconds for a real article page and is * available whether or not this module exists. OCR is the floor on ShieldFont's * protection and no amount of cryptography raises it. * * The goal is that the accessible path is **never the cheapest path**. Set the * puzzle above the OCR cost and the plain-text mode stops being a shortcut: a * crawler gains nothing by using it, so it adds no attack surface, so it can * exist. That is the whole argument, and it also caps the difficulty — pushing * far past OCR buys no protection at all (they would just OCR instead) and only * makes disabled readers wait longer. See {@link DEFAULT_SECONDS}. * * ## The primitive * * A Rivest-Shamir-Wagner time-lock puzzle (1996). Pick primes p and q, let * n = p·q, and define the key as * * K = 2^(2^T) mod n * * A solver who knows only n must square 2 sequentially, T times. There is no * known shortcut, and — the load-bearing property — **it cannot be * parallelised**: squaring number i+1 needs the result of squaring number i. * A crawler with a thousand GPUs still pays T sequential steps per page. Their * only lever is a faster single core, worth maybe 3-4x over a laptop, which is * exactly the narrow margin this design wants. * * Compare a hash chain (the obvious alternative): equally sequential, but the * BUILDER pays the same T as the solver, so a 200-page site costs an hour of * build time. Here the builder holds a trapdoor. Knowing p and q gives φ(n), * and Euler's theorem collapses the tower: * * e = 2^T mod φ(n) then K = 2^e mod n * * Two modular exponentiations, about a millisecond. Measured on this repo's * reference machine: **62 ms to seal a block that costs the reader a 14-second * budget to open.** * p, q and φ(n) are discarded and never leave this function — with them the * puzzle would be trivially bypassable, so nothing may retain them. * * ## Freshness * * Every call generates its own primes. A modulus is never reused across blocks * or across builds, so solving one block teaches an attacker nothing about the * next one, and every redeploy invalidates every solution anyone has already * computed. A shared or persisted modulus would let one grind unlock a whole * site forever, which would defeat the entire mechanism — hence * {@link sealText} taking no seed parameter and offering no way to pin one. * * The same property costs legitimate readers their cached solutions on every * deploy. That is the intended trade, and it is symmetric: it is the very thing * that expires the crawler's cache. */ /** * Squarings per second on the reference device — a mid-range phone, or Safari, * which is what `seconds` in {@link SealOptions} is denominated in. Deliberately * NOT a fast desktop: a progress bar that beats its promise is a good surprise, * one that overruns reads as broken. * * This constant only converts seconds to a step count at build time. It is not * a claim about the reader's device, which may be several times slower (Safari * and Firefox both trail V8 on BigInt, and a phone trails a laptop). The solver * does NOT trust this number — it benchmarks the actual device before it starts * and reports an estimate based on what it measured. See the solver script in * @shieldfont/react. * * IT WAS 250,000, AND THE ARGUMENT FOR MOVING IT WAS ITSELF WRONG. * * That argument said an M1 Max does 259,500 squarings/s, so 250,000 was 96% of * one of the fastest consumer cores in existence while being described as a slow * one. The 259,500 was a bad measurement — an unwarmed loop. Measured again in * the shape the solver actually runs, a warmed Worker in Chrome on the same M1 * Max does about 667,000 squarings/s, median of eleven runs. So 250,000 was * around 37% of a fast core, which is a defensible slow-device figure, and the * stated reason for changing it does not hold. * * 120,000 STAYS ANYWAY, for a reason that survives the correction: it is about * 5.5x slower than that fast core, which is the right order for a mid-range * phone or for Safari, and it is the floor an author should reason from rather * than the ceiling. Nothing about the step count depends on this number being * exactly right — `t` is derived from the ATTACKER's rate (see DEFAULT_SECONDS), * and this one only decides how many steps a given `seconds` buys. * * Recorded rather than quietly fixed, because a wrong measurement used to * justify a change is worse than the change being wrong. */ export declare const REFERENCE_SQUARINGS_PER_SECOND = 120000; /** * Default target, in seconds on the reference device — 14 × 120,000 = 1,680,000 * steps. * * THIS WAS 20 SECONDS AGAINST A REFERENCE OF 250,000, i.e. 5,000,000 steps, and * the reasoning above it was wrong in three places at once. It put OCR at ~3 * CPU-seconds per page, assumed "server cores run 3-4x a laptop", and costed the * puzzle per PAGE while the component seals per BLOCK. Measured 2026-08: * * - Render + OCR is ~5.0 CPU-seconds for a real article page — Tesseract costs * ~2.7 ms per WORD (it tracks text volume, not pixels), a real page carries * ~750 words once nav, sidebar, comments and footer are counted, and a * measured 12% of pages need a retry. * - Server cores are NOT faster than a laptop on bignum work; they are equal * to slightly slower. The attacker's real advantage is software: OpenSSL's * hand-written Montgomery assembly does 1,737,000 squarings/s where V8's * BigInt in a warmed worker does about 667,000. That is a 2.6x gap, and it * is not going away. It does not change `t`, which is derived from the * attacker's rate alone. * - Five blocks on a page meant five puzzles, so the old default billed a * crawler ~14 CPU-seconds against an OCR floor of 5. Ten times over. * * The rule this number obeys: **a block's puzzle should cost a crawler slightly * LESS than its share of render+OCR.** OCR is always available, so anything * dearer is not protection — the crawler takes the cheaper door and the extra * seconds are paid entirely by disabled readers. * * budget/block = 5.0 CPU-s ÷ 5 blocks = 1.00 CPU-s * t = 0.97 × 1.00 × 1,737,000 ≈ 1,680,000 * crawler pays = 1,680,000 ÷ 1,737,000 = 0.97 CPU-s per block * = 97% of the OCR floor ✓ * * ## Why 97% and not the 81% this shipped with first * * 81% left a fifth of the available deterrence unclaimed for no reason. The * crawler picks the CHEAPER door, so the whole game is to sit as close under the * OCR floor as the measurement error allows: * * - below the floor, the puzzle is the cheaper door, so they pay it; * - above the floor, they OCR instead and every extra second is paid purely by * disabled readers, buying nothing. * * So the target is "just under", not "comfortably under". 97% keeps the puzzle * the cheaper door while claiming essentially all of the budget. Deliberately * not 100%: the 5.0 CPU-s OCR figure is a measurement, not a constant, and * overshooting it is the one direction with no upside. * * Reader-side, that is ~6.5 s on V8 and ~14 s on the 120,000 reference. Both are * honest ranges: a real reader may be 2-4x either way, which is why the solver * measures the device rather than trusting this. * * NOTE THE DOLLARS CANCEL. A crawler OCRs and squares on the same rented CPU, so * $/vCPU-second appears on both sides of the comparison and drops out. The rule * is CPU-seconds against CPU-seconds — which also makes it robust to spot-price * swings and to hardware generations, since OCR and squaring speed up together. * * Going higher is still the tempting mistake, for the same reason as before. */ export declare const DEFAULT_SECONDS = 14; /** Options for {@link sealText}. Pass at most one of `seconds` or `steps`. */ export interface SealOptions { /** * Target grind time in seconds on the reference device (see * {@link REFERENCE_SQUARINGS_PER_SECOND}). Default {@link DEFAULT_SECONDS}. * * Read {@link DEFAULT_SECONDS} before raising this. Higher is not safer. */ seconds?: number; /** * The step count directly, bypassing the seconds range check. * * This is an ESCAPE HATCH, not the normal way in. It exists for two callers: * tests, which need the real code path at a step count that returns in * milliseconds rather than seconds; and tooling that has measured its own * audience and wants to calibrate against that instead of against this * file's reference laptop. * * It deliberately skips the 1..30 second guard, so it will happily build a * puzzle that a crawler solves instantly. If you are reaching for it to make * a page feel faster, use `seconds` and read {@link DEFAULT_SECONDS} first. */ steps?: number; } /** * A sealed block: everything the browser needs to grind out the key and * decrypt, and nothing that would let it skip the grinding. * * Serialised into the page as JSON. Field names are short because this ships in * the HTML of every protected block. */ export interface SealedText { /** Modulus n, as a decimal string (`BigInt` does not survive JSON). */ n: string; /** Sequential squarings required. */ t: number; /** AES-GCM initialisation vector, base64. */ iv: string; /** AES-256-GCM ciphertext with its 16-byte auth tag appended, base64. */ ct: string; } /** * Encrypt `plaintext` behind a fresh time-lock puzzle. * * Fast — the trapdoor makes sealing independent of the target time, so a * 1-second puzzle and a 30-second puzzle both cost about 60 ms to build, and * essentially all of that is prime generation. * * Call this at BUILD TIME, in Node, where the plaintext already lives. It is * deliberately Node-only (it needs `node:crypto` for prime generation) and * deliberately not exported from the package index, so bundling * `@shieldfont/core` for a browser never pulls it in. * * @example * import { sealText } from "@shieldfont/core/puzzle"; * const sealed = sealText("The words a screen reader needs.", { seconds: 14 }); */ export declare function sealText(plaintext: string, opts?: SealOptions): SealedText; /** * Solve a sealed block the slow way and return the plaintext. * * This is the SOLVER, in Node, and it exists for tests and for anyone verifying * the claim rather than for production use — it really does perform `t` * sequential squarings, so calling it takes about as long as the puzzle * promises. Nothing in the shipping path calls it; the browser has its own * implementation in @shieldfont/react, and this function is what proves the two * agree. * * @param onProgress Called with a 0..1 fraction, at most once per 1% of work. */ export declare function solveText(sealed: SealedText, onProgress?: (fraction: number) => void): string; //# sourceMappingURL=puzzle.d.ts.map