diff --git a/.gitignore b/.gitignore index 8a32b32..c6a6098 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ TODO.md # Demo build output _site/ + +# Local notes and plans +tmp/ diff --git a/README.md b/README.md index 1381e73..7618c2e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ can't predict without running the whole chain, and each collision mixes in an ea digest, so the hasher has to be able to recover earlier digests on demand. ▶️ **Live demo:** [karl.preisner.us/chip](https://karl.preisner.us/chip/) runs the real -library in your browser, showing each salt attempt and collision as it happens. +library in your browser, showing each salt attempt and collision as it happens. Below it, an +interactive explainer walks through how CHIP works, step by step. 📄 **Paper:** [CHIP: Confounding Hash Iteration for Passwords](docs/paper.md), covering how it works, measured costs, and its security limitations. @@ -49,7 +50,7 @@ return salt, SHA256(h) - If a salt needs more than an upper bound of iterations, a new random salt is drawn. With `b = 32, C = 5`, one chain takes about 200,000 iterations on average. The default upper -bound (250,000) rejects about 15% of salts, so a full hash costs about 235,000 iterations +bound (250,000) rejects about 15% of salts, so a full hash costs about 230,000 iterations including retries. ## Quick start @@ -169,7 +170,8 @@ Run `uv run chip --help` for all options. | `task bench` | quick benchmark run | | `task bench-full` | full iteration benchmark; regenerates the `iterations.*` results | | `task bench-memory` | memory benchmark; regenerates the `memory.*` results | -| `task demo` | build the browser demo and serve it at http://localhost:8000 | +| `task bench-distribution` | chain-length distribution at the defaults; regenerates `distribution.csv` | +| `task site` | build the demo and explainer page and serve it at http://localhost:8000 | The same checks run in GitHub Actions on every push and pull request. @@ -184,10 +186,13 @@ tests/ unit tests (pytest) benchmarks/ iterations.py measures iterations for every (b, C) pair memory.py measures memory for a range of (b, C) pairs + distribution.py records every chain's length at the defaults, for the explainer time_memory_tradeoff.py computes CHIP with about 4× less memory results/ committed results: CSV, Markdown tables, charts demo/ - index.html the browser demo (GitHub Pages) + index.html the browser demo and explainer (GitHub Pages) + chain.js the CHIP chain in JavaScript, for the explainer's step-through widgets + explainer.js the explainer's interactive widgets worker.js runs the chip package in Pyodide, off the main thread build.sh assembles the site from the page, src/chip, and benchmark data docs/ diff --git a/Taskfile.yml b/Taskfile.yml index a2f5c74..7b6e3d8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -44,8 +44,14 @@ tasks: cmds: - uv run python benchmarks/memory.py - demo: - desc: Build the browser demo and serve it at http://localhost:8000 + bench-distribution: + desc: Iterations of 1,000 chains at b 32, C 5 into benchmarks/results/ (for the site's histograms) + cmds: + - uv run python benchmarks/distribution.py + + site: + desc: Build the GitHub Pages site (demo and explainer) and serve it at http://localhost:8000 + aliases: [demo] cmds: - sh demo/build.sh _site - uv run python -m http.server 8000 --directory _site diff --git a/benchmarks/distribution.py b/benchmarks/distribution.py new file mode 100644 index 0000000..1742f14 --- /dev/null +++ b/benchmarks/distribution.py @@ -0,0 +1,51 @@ +"""Record the iteration count of every chain at one (b, C), for the demo page's histograms. + +`iterations.py` keeps only the mean and standard deviation. The demo page's explainer also +needs the shape of the distribution: how many salts a given upper bound rejects, and how +unevenly chains finish when guesses run side by side. Runs unbounded chains with random +inputs and writes one row per chain: + +- distribution.csv iterations at each collision (columns c1 ... cC), one row per chain + +Usage: uv run python benchmarks/distribution.py [--bits 32] [--collisions 5] [--runs 1000] +""" + +import argparse +import csv +import os +import statistics +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +from iterations import measure + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--bits", type=int, default=32) + parser.add_argument("--collisions", type=int, default=5) + parser.add_argument("--runs", type=int, default=1000) + parser.add_argument("--workers", type=int, default=os.cpu_count()) + parser.add_argument("--out", type=Path, default=Path(__file__).parent / "results") + args = parser.parse_args() + + with ProcessPoolExecutor(args.workers) as pool: + results = measure(pool, args.bits, args.collisions, args.runs) + + args.out.mkdir(parents=True, exist_ok=True) + path = args.out / "distribution.csv" + with path.open("w", newline="") as f: + writer = csv.writer(f) + writer.writerow([f"c{c}" for c in range(1, args.collisions + 1)]) + writer.writerows(results) + + final = [row[-1] for row in results] + print( + f"b={args.bits} C={args.collisions}: {args.runs:,} chains, " + f"mean {statistics.mean(final):,.0f}, std {statistics.pstdev(final):,.0f}" + ) + print(f"Wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/results/distribution.csv b/benchmarks/results/distribution.csv new file mode 100644 index 0000000..50116a0 --- /dev/null +++ b/benchmarks/results/distribution.csv @@ -0,0 +1,1001 @@ +c1,c2,c3,c4,c5 +46361,104774,116219,136581,141889 +64033,162669,177781,205354,228955 +75968,152545,164202,195914,205988 +116006,139646,174894,192866,201779 +92266,109143,121901,147590,171740 +72112,91437,111412,122857,193268 +11525,90355,105920,173039,216034 +99778,166423,193111,214623,216716 +89354,168425,183777,210826,211963 +61753,141280,193580,208754,220357 +131559,134969,157118,157243,164593 +123119,153983,195102,238304,242971 +91423,205182,207355,241260,246611 +107371,115709,118606,131687,162594 +21029,117329,141714,151605,199605 +7774,74993,115901,192585,208771 +43326,213636,230412,252673,255348 +117266,118773,124772,136057,193520 +169723,178738,230222,230712,264005 +34599,60680,131315,138321,150393 +123469,159434,177272,196121,205219 +93542,124157,130683,132182,204548 +75059,131573,143146,151019,156406 +88988,105533,111017,249641,256996 +84972,90437,113622,138284,146319 +79245,112246,147242,157778,194808 +151808,152215,156968,166774,170426 +37017,98617,105445,112473,167040 +72788,91373,124797,154559,184346 +27341,91874,141361,148039,157033 +35369,48565,63165,68775,190881 +62712,83204,103133,154555,164977 +39937,44155,143036,191556,203594 +117360,212883,246909,268373,291399 +68802,73427,79694,134783,144896 +35258,53001,113870,205511,259727 +125091,144837,161002,170838,207584 +61000,153525,165537,196191,221177 +55120,91926,111719,166282,187986 +78296,91064,92244,165747,201522 +74289,89470,94123,130477,138427 +87488,123316,138611,156155,194201 +121212,140450,164703,213648,248368 +79674,89788,106874,108745,128336 +69160,116926,128095,140920,156548 +99430,128950,135536,170579,223601 +48772,132213,137850,153073,154469 +99163,137533,187755,212669,212727 +99727,187827,226504,236001,247479 +81192,144796,190746,208079,239684 +51951,121605,127573,159739,255439 +78908,89153,134335,137008,203163 +115992,125358,142440,168784,197395 +102540,111015,142701,179759,195463 +62205,117329,127767,138315,141954 +158127,186791,213575,217950,258014 +55156,69968,71928,90078,96340 +90225,156751,170343,223512,235391 +66897,174004,174897,183115,214864 +66782,146042,187409,210098,213186 +41462,114430,124894,203865,235421 +42104,153629,163523,206448,237411 +17065,100576,107207,123637,176045 +78886,111043,117588,129719,200424 +114335,115572,158846,212721,262684 +92694,107544,122372,131775,139557 +142937,150868,184359,189341,221077 +11926,101418,108054,116770,152155 +143091,195012,204784,206870,207719 +77059,105591,144311,186982,209262 +95955,119289,199986,203651,267623 +54600,65329,93219,136581,150036 +125636,207278,209224,225875,236238 +69375,74278,76162,94076,161400 +46170,117081,138611,145808,156448 +49860,168687,183522,207398,269408 +94267,121063,126039,198582,206584 +132253,142885,150564,155411,169779 +59564,91437,108271,127719,137026 +37632,113019,145125,151453,156028 +18671,88663,128021,137578,142213 +48144,138993,157649,196111,199332 +52064,56377,141438,149183,173695 +44219,102464,151745,155029,286538 +41114,103484,103668,158321,198862 +78587,141509,147487,158521,164697 +106950,139645,194779,278439,279344 +109188,128997,156654,159573,169309 +49073,135793,137473,163499,185806 +74591,136619,137971,178350,190088 +161839,210099,241094,255057,256054 +113467,128594,134576,135704,137220 +156095,162012,182954,187803,194422 +30719,100419,119194,155022,162851 +99005,148421,165855,174102,195466 +65770,87506,144913,167043,169608 +44435,116535,140284,142363,145966 +110757,131483,177486,180288,186641 +89883,105261,123662,177283,195942 +51193,88838,109850,130542,152628 +33434,70457,183095,230921,232805 +44571,108922,122586,220237,229784 +95234,96999,108579,145219,160190 +10236,84260,116734,139814,163756 +46462,190161,203332,217671,224578 +232397,247566,260863,268421,268683 +116249,130917,136275,145879,158240 +99043,137598,211136,227192,242929 +109677,112311,210654,234542,238576 +189683,196097,196168,197058,198350 +10362,115091,127824,149675,189653 +40289,151551,174337,188084,192009 +120294,162422,209296,213541,217006 +101215,132874,162299,243959,252166 +90302,95470,101454,149515,169439 +72699,129037,208700,222420,235397 +2924,78792,150428,203222,248705 +70524,79429,154688,155546,228976 +70613,77653,231150,236626,242050 +82439,93378,110136,111044,115077 +64630,110245,110533,184064,247836 +86954,129905,205844,258989,288945 +72760,134864,156536,167348,171334 +70525,79016,95100,97653,113792 +42355,144777,161410,207520,214631 +106387,165188,183689,189250,200797 +130166,200806,214151,230860,231697 +30058,180957,196869,234499,256961 +45856,69342,86192,106081,107979 +53433,122554,148215,177026,185034 +145374,194575,194679,197487,205178 +106105,146910,147067,180358,209076 +105713,144381,180994,239917,273073 +79060,115906,117535,146883,188705 +112387,125425,138309,159139,222520 +79010,128814,159007,293106,318709 +141941,165355,183956,189983,215031 +48375,157309,160224,162516,163511 +41735,51440,84927,137802,138520 +101176,110165,173647,184472,190415 +33991,70761,121653,193420,198267 +103313,108452,117995,159623,178400 +89028,128830,131881,138279,150184 +141801,176348,185343,187097,258250 +21685,73250,94232,106404,125235 +91658,182006,208628,220061,222718 +90871,106035,141783,144931,157615 +52702,101638,128886,150254,175915 +84684,91197,102711,176305,186665 +116207,166953,186670,195472,199515 +32862,65554,190213,214679,225204 +171499,187307,191467,208806,281319 +54182,116980,149757,192446,202202 +117066,122406,154787,214574,226494 +55505,156539,163632,183608,211700 +18879,120700,161509,185335,251450 +111030,132454,151056,170563,184198 +122105,146393,152576,212638,252852 +85832,184368,219940,224369,251521 +59921,175166,183526,197954,198296 +136004,171507,204620,216124,227066 +53148,54250,131116,134441,183115 +198663,201696,252621,267757,268365 +98041,175729,201959,213682,227651 +195502,213860,237577,237583,261520 +118781,150599,158356,163338,182089 +77377,111110,123672,139655,190658 +36176,72763,96210,216542,250608 +93692,109130,181745,186455,191697 +42401,77981,131841,206589,208088 +173117,196766,224335,225394,226539 +85188,123977,142916,168413,188916 +30202,71506,88964,119863,153032 +69708,141477,163211,165423,168043 +101417,171954,204404,225748,250659 +43900,85646,88397,104916,113989 +39481,50452,62712,69331,116252 +199160,202427,296371,302902,303684 +74673,97565,115248,119366,145130 +118415,134806,138542,150947,166598 +36560,63940,112141,153706,184406 +48604,91035,131070,174017,216118 +10500,93377,153421,183956,223103 +64038,65964,78144,116603,121141 +137992,231375,259361,290524,330600 +48026,72823,74751,121867,174444 +154134,165063,185654,204778,225416 +75827,146932,201134,201881,208270 +175781,197403,216584,233812,249612 +96306,117208,145961,167073,209779 +100448,198012,217010,217584,240472 +42905,105914,132701,158327,253647 +128366,177231,189603,227965,264828 +64967,69404,104109,131093,176822 +79690,88162,152531,153100,157958 +77357,116257,140578,172784,203039 +55209,75468,118534,133130,136969 +163585,177887,205345,221915,224112 +80643,94817,154822,167416,204441 +86874,181478,199856,232346,248809 +131908,155643,177996,182082,231226 +85462,116577,139354,192559,195817 +58502,138767,179077,182039,182207 +71315,120680,138239,148041,151922 +78591,129488,130166,130546,133905 +81385,85394,120894,136733,152965 +111182,115788,143376,151413,174885 +52220,126015,150500,173060,177265 +57676,154850,172937,201157,208600 +91499,117912,147407,151781,153960 +81358,94410,129010,130737,138135 +52048,66654,68350,88848,103501 +73288,127097,132678,152621,171948 +51625,92335,127220,133811,185172 +103271,112859,146102,149038,154506 +86989,185015,194993,220752,231748 +88625,136253,258096,270148,338939 +41130,69340,71369,164655,196685 +136792,138524,156143,190614,195581 +98201,125612,179074,181618,209197 +42277,90206,202603,245065,263203 +150029,170215,203140,215700,220559 +54465,90549,115602,139014,139205 +58466,68897,69736,189222,201874 +114424,115922,160407,168375,172442 +41241,81633,104601,222031,225529 +115131,184988,256718,257020,257351 +22025,79395,129035,184487,187870 +146357,147568,155448,156019,200371 +61234,78935,95744,100115,137244 +114882,128057,142136,167872,231901 +119912,155875,192693,199552,230956 +53630,81524,110836,169473,170920 +81344,130092,139708,163633,177591 +51633,80668,95230,114197,114573 +34271,79856,133744,159922,160233 +57697,68294,130857,181397,182970 +36661,88389,93394,132067,148256 +59922,100585,179282,190606,234234 +112368,129196,142927,176728,181789 +67641,77914,135680,150047,150495 +47072,48275,153885,156085,180051 +74993,129094,148985,192063,212249 +154908,156390,164407,178933,187198 +85341,110853,191422,194786,207099 +104596,134694,164781,182645,200862 +76483,199386,233756,259621,266617 +53907,114270,231921,238413,254809 +135986,151582,158120,185386,186896 +32024,191257,251723,263725,272356 +89409,114059,155864,161197,161566 +107167,121651,247782,265878,276462 +57330,134576,155236,163801,163961 +149680,187960,195932,201407,247582 +90901,120728,164673,183965,197223 +86292,113351,174173,175517,206998 +56940,81480,109482,138799,151510 +148126,174117,206294,236027,251991 +19598,97885,169429,171273,190671 +120394,143711,155521,185820,189402 +35885,121498,154920,189576,208271 +58469,102105,129651,181191,184740 +56486,86487,166118,175036,226905 +5541,66910,105916,121498,129326 +133845,253976,266366,278406,309487 +138355,163717,215575,237552,269729 +94281,184914,199607,213550,214068 +77789,116097,148655,195663,197235 +74707,151647,186066,192147,282059 +143653,159748,172841,214324,222230 +44148,117108,138226,152338,164720 +82719,90430,97826,138199,166611 +214935,233383,246978,255367,255821 +49684,77393,110988,134146,204556 +51494,109701,178907,210002,285685 +88710,89405,170957,205434,257344 +66668,106149,115463,128748,137655 +87995,136224,190026,211063,216364 +55983,102534,148247,156653,159112 +30819,79468,124047,143458,155397 +64272,137274,192777,224068,231730 +62360,134136,156130,182998,198448 +130424,190697,194732,201285,224401 +99574,134720,182213,209658,241277 +135247,137275,140089,142878,162525 +37934,193459,201040,214215,222335 +25291,154188,179860,195457,212792 +79799,90291,126503,244127,290989 +95545,126561,168621,211911,212917 +108780,186465,209333,230860,260403 +66091,116719,134981,138665,139028 +19372,89304,107041,214899,217239 +44571,130386,161110,173479,197295 +118218,149799,159269,159758,161613 +131933,233247,274488,276147,290317 +97742,107597,153751,169046,179517 +4212,19120,22647,80075,103025 +16107,31380,68993,81970,117745 +64237,73149,126425,150093,189986 +73644,80372,84317,97112,136695 +76776,101337,145696,192893,238184 +42912,74395,111463,125450,146853 +117616,128611,150656,239583,247579 +17373,74530,82650,155699,207343 +102293,153782,213549,224731,235732 +52223,73355,103229,127887,136478 +32342,138147,171138,193866,195051 +99408,152504,157651,174561,205031 +70531,76879,93731,111187,114681 +92611,100793,102158,161757,197963 +53924,125165,133515,160775,230255 +39227,104905,200516,255891,263592 +29989,99791,121772,180009,189908 +51770,147255,184532,196276,216731 +42769,66895,167632,178859,179846 +88290,127025,148608,158436,181594 +92096,126456,163321,168130,182874 +117453,166607,246765,262121,264201 +95298,97697,129844,216399,223399 +99001,115817,165966,174400,179037 +61776,75440,126527,144812,185652 +46286,84549,96328,131049,157876 +97351,110575,133505,136026,136171 +25229,53228,284498,309831,327900 +109506,148018,166934,168092,183177 +118145,167718,182470,218781,237997 +36210,43464,120754,169160,195469 +73278,122029,149384,171724,198278 +45003,67915,148204,160652,180734 +144312,176734,189725,190505,193422 +92817,164485,166908,182389,196407 +92533,116504,136446,153909,171004 +25074,51079,53066,79847,103527 +109387,198819,204376,236756,244964 +67368,161540,173770,175825,184706 +58661,106302,165463,182307,186187 +126391,130928,136126,154497,161022 +108447,108812,141736,184664,194012 +152105,159123,203024,204312,221694 +91948,150934,157336,229418,234839 +117294,158273,158672,164176,172330 +75524,83572,90603,155677,190630 +144819,146344,160472,177385,178755 +35856,96013,139460,144995,149031 +97867,163074,212588,221658,225789 +49037,117764,148611,156248,161891 +46116,132224,134218,142854,152260 +114912,119683,121164,135923,196507 +28991,134943,178226,180717,182695 +43149,82106,88279,107548,124356 +54004,104812,144261,202838,206434 +72929,78687,100684,105423,116968 +98976,113378,114380,140167,153793 +149297,154297,183009,210670,217319 +114137,136619,149882,157477,175660 +95532,135130,169674,177930,241026 +56517,179407,188209,290506,294274 +108335,120115,134277,155948,157567 +116752,149497,179343,193808,194840 +103360,122899,166399,181690,186104 +58700,116152,121082,121999,131615 +107066,112212,195925,216481,246720 +66476,102350,105592,123265,156386 +162669,164340,193501,198643,230023 +55217,81912,94681,104727,122528 +25620,111387,163201,222953,223293 +90164,123225,151598,176556,269343 +132348,133993,169459,196843,247444 +74955,108258,140467,154757,164291 +15683,26189,53590,62990,71575 +111878,160085,185327,214831,234510 +56603,72445,93899,144850,163740 +27728,55490,112763,151160,186404 +77899,118701,141387,168979,177542 +39642,84364,115076,124779,160297 +88750,90577,161683,175143,216244 +149555,197758,220358,226901,263813 +45901,166278,193931,210196,216270 +93686,112749,126190,196680,221323 +77285,128490,202919,212072,213273 +35949,94361,98382,101159,117107 +20305,58170,66534,112748,124529 +41473,85476,88551,130150,150931 +41181,52069,59897,98625,132361 +37774,75546,93215,104547,138941 +87135,108703,126790,184560,194201 +66323,94568,136520,158660,164688 +103700,118318,207768,239533,283711 +79215,143079,169148,181509,216993 +75747,85573,87190,88671,119044 +63623,65527,144091,145305,157569 +146876,157351,158916,195479,206391 +101993,140768,184787,227343,241170 +81777,117965,124847,141550,144954 +207004,257422,285550,308023,315881 +92559,134674,147228,153206,191115 +41933,55367,115502,124641,178795 +56087,132881,147240,168942,179396 +58031,80478,107455,143337,155852 +50621,86224,90187,93021,93400 +91851,130490,174017,180224,197645 +28785,76150,102193,106307,150662 +88550,90042,144651,180854,189458 +186779,189663,200582,203444,213931 +64214,140411,164365,173852,190569 +14492,102004,174535,176235,211714 +29448,46752,174357,181955,190459 +49985,80756,131002,165129,189359 +105785,138509,143780,260960,277648 +160962,176347,214251,220918,221857 +94613,107010,165006,246755,260109 +21775,93980,104812,181887,186180 +56549,60892,138979,212313,212354 +111710,160423,160597,170519,171507 +62917,95524,115426,115483,134710 +49290,63343,95998,105303,155645 +18011,161379,161972,172880,174074 +18718,30670,30692,113924,199332 +31762,73358,95332,109284,134231 +16678,124600,180448,185785,191752 +76148,97531,106579,119934,153695 +109503,136641,180660,207960,213286 +115876,126236,146114,167194,170312 +44695,81701,93081,113497,145603 +46134,85432,115954,137920,144171 +6867,71521,92805,119772,127078 +29956,106062,171497,178449,264154 +130651,173211,193262,195967,198222 +146197,168974,203309,205371,271066 +95851,181187,183924,244555,244931 +49305,103341,123627,141053,174555 +30489,133545,165621,171672,178789 +25443,54265,211163,219750,224311 +47279,85795,145198,215389,222817 +68071,154300,159359,174311,212105 +124000,174896,176089,183605,204747 +72858,113946,143262,164755,178429 +66669,110653,117541,149740,156662 +104226,153144,199620,218867,219419 +71852,97290,153695,175034,175703 +111752,174285,193049,218161,254580 +18084,95704,155481,170346,176343 +74764,126082,242453,276376,299326 +53569,105557,138069,157819,173470 +19816,156399,173984,242203,287875 +78816,89838,122623,160468,198454 +139900,203063,207739,209284,231642 +124526,142825,188715,238115,249109 +37156,68804,82501,123185,141603 +43703,155550,246667,283937,291284 +32371,68253,127011,189530,209036 +107938,120664,143643,240524,283067 +93779,117057,155689,173900,227050 +34782,109177,144230,144534,151069 +111410,128340,131065,159051,181987 +73602,96109,152866,193175,195072 +36691,127303,129182,156928,164060 +55987,246609,256196,264877,265647 +71239,165373,187810,206934,278836 +42716,102796,114209,126760,144042 +94814,148129,168509,199278,205609 +63321,94387,109885,144810,190140 +44799,152175,175176,218406,237547 +112448,120281,129526,152846,194834 +120896,139428,152716,169711,178010 +48743,114328,118740,143270,155024 +61406,90286,139522,219309,240338 +51762,92176,123079,131889,132853 +52373,61795,73531,123315,215692 +43199,135789,158211,161697,170065 +125670,137535,165170,226254,226724 +31803,55134,80278,168057,189951 +76992,124076,141321,145231,145552 +164135,270891,308318,310885,326134 +112825,123606,132925,148731,171353 +213104,221387,222004,226276,238515 +33194,132691,197396,209352,245542 +31569,36945,98236,180640,224710 +97922,125109,163412,203062,249106 +27776,129898,135978,137967,187941 +33429,157904,158359,286729,313038 +84258,117647,118830,142830,151843 +89939,101721,128913,131922,143700 +16898,90770,98599,137148,159197 +70988,188232,193214,202935,219953 +53922,146849,193344,225986,226537 +12749,23229,50924,63381,116016 +157903,223573,226672,227953,278637 +90943,91468,93181,102171,164502 +84794,105980,130793,138367,177599 +85049,157906,224214,229801,232906 +80475,169166,226707,229869,230541 +43167,95881,131038,153776,180695 +60413,86745,155553,196849,214002 +160533,213273,249131,257363,307852 +93236,113879,119021,119811,138426 +15078,52950,57547,162123,258039 +61986,139017,252025,264246,265885 +96957,132912,138048,163325,165320 +141144,162939,175581,216441,221540 +21840,85346,116939,191963,197473 +118512,134584,219695,222320,226822 +80750,88798,150977,171321,173698 +103789,138595,197864,212080,232181 +156010,161633,175720,189369,193901 +72223,119869,152266,152847,174221 +49018,67901,85352,105420,133706 +168872,180129,183089,183103,199176 +64686,103881,209009,220796,242556 +113368,127922,130003,147952,199203 +148145,178443,181744,195775,237346 +5757,68949,141091,159175,168188 +152479,186446,201173,203935,207728 +5747,69022,77363,143174,147236 +53417,172839,188772,229956,234926 +155549,167621,193342,197139,206904 +45764,150773,157495,182874,193545 +27714,74657,120410,133159,171066 +121812,172752,173867,174426,175892 +86117,98268,127169,186022,191186 +17268,54790,94267,97569,157734 +93442,198024,240293,242806,260877 +68940,106754,163432,223801,240638 +88878,145990,169874,171824,191812 +112221,142413,143842,231501,234468 +54341,106338,150403,155234,167581 +112425,126754,126943,129379,174523 +67882,137966,207951,219907,248803 +27393,88054,89404,103356,126817 +121969,197642,201232,215554,259273 +26715,119581,119659,142661,162917 +18936,153149,182309,189175,189448 +81383,95907,152214,205374,212296 +90840,106189,232189,252746,315839 +93131,97133,113629,149965,157647 +67811,77045,94771,111869,140387 +44431,140758,145153,182847,190159 +35834,72617,97653,118599,146831 +29225,67803,106377,108058,112441 +61577,71595,218991,236764,242081 +90363,125569,153096,155410,171922 +72136,95598,234842,274612,296433 +23274,153600,163406,174865,182441 +133101,158357,175320,186921,215482 +141348,160728,174041,243220,245371 +127461,150167,154359,172625,173754 +167880,259660,268217,275833,316461 +82161,102182,133092,137474,165821 +62726,64156,132739,178827,239142 +102716,117996,129872,159531,162415 +103382,163607,166324,273513,286627 +73261,101677,105449,114984,131196 +11769,82874,130309,163643,164770 +69431,93085,138248,160219,221461 +77920,92182,112118,133557,158328 +90525,119518,170193,183644,194496 +83824,123469,130312,136410,170602 +119081,139690,144567,151972,160034 +64616,77799,90561,92803,200581 +15024,41174,114877,133759,157239 +91331,138738,143544,175756,176098 +14328,92170,154798,176064,188216 +30162,61862,158404,206890,213480 +94005,198076,211938,233900,247628 +58919,64351,107066,117213,127452 +81593,108783,154327,185880,212294 +101442,163212,167670,171901,187372 +67018,107114,129196,150777,151818 +150223,162145,186115,198977,244559 +144340,146627,153201,159790,198298 +76429,78590,99907,101389,109039 +43140,183636,249383,258244,294359 +191349,250757,257775,277236,284086 +12899,136576,223669,258866,271296 +63869,69250,99807,138217,184624 +68092,167099,180539,197481,232464 +53798,58517,68578,143712,160106 +20170,184870,188019,208703,211899 +22029,47624,70169,72277,86106 +67951,71740,145120,189621,230859 +175574,182953,220727,270326,278702 +115570,132803,179645,181018,185488 +60918,81819,150617,167758,212804 +165098,180163,216063,219558,230211 +44680,58601,129751,157941,173446 +80005,170035,194615,204558,208451 +41902,75774,144358,146743,152960 +124987,159561,178112,192693,194305 +107225,182920,204049,247963,251244 +15733,53783,74345,95629,104374 +167228,181916,189369,190140,209770 +129715,164973,189130,196205,227070 +10639,130121,172696,190318,232133 +131350,164437,166837,185284,220625 +31856,159355,179217,183642,191918 +137763,138360,154807,178016,196611 +32045,73109,143683,165206,196105 +41965,47978,69822,97776,102969 +49608,105173,202178,221893,223876 +53656,57368,117839,161141,163645 +62668,107279,125547,128643,140775 +43786,68793,120013,120785,130895 +51482,119062,123327,186169,258230 +59637,100627,145010,149248,156525 +10480,87426,92785,176403,254183 +48149,110181,168455,178006,184023 +111513,125668,230697,253978,287948 +31677,175373,224746,228480,262102 +140662,144257,159170,184802,211770 +70447,80925,178838,184337,187769 +35693,86456,94908,123335,138047 +55093,181926,194909,228861,257387 +70561,80395,178437,179780,181489 +21478,83091,97236,122527,168326 +78744,128115,167969,232000,260830 +102800,152663,159740,162987,174140 +125037,136083,188195,204964,227568 +99340,103481,142746,151100,197061 +36570,42435,54217,99329,129967 +90782,131180,133703,194708,201116 +71907,85769,93412,127093,144095 +82655,100964,170616,175535,179416 +41274,84401,102599,145068,146773 +44125,102339,114195,180853,227460 +36797,65921,98272,108120,118210 +106685,163652,176834,193897,212904 +63474,153249,164006,175625,180367 +42295,86972,136285,155084,174223 +16316,101644,101796,112452,138990 +39618,91012,149153,158789,190358 +68977,85086,187442,231590,261094 +167712,167806,226501,249545,259875 +102626,119893,192122,195956,233641 +120405,179882,189762,195559,222486 +95935,97455,106427,135584,165858 +59229,85782,256127,264832,267817 +108377,117225,135784,194058,197482 +30104,74336,105718,106692,195419 +40518,115920,130935,131279,174550 +102674,135190,157296,178857,180476 +54683,152914,215185,265643,266314 +74361,118778,213986,216580,250117 +59676,84190,90743,119720,176911 +83207,117704,190369,202119,210865 +11423,68883,85818,204596,216219 +44283,157552,167717,197756,241341 +88168,103334,115280,215517,223918 +106656,152482,167266,175193,204352 +73500,84899,90891,91116,102675 +100014,137332,188155,234851,237288 +153575,177086,193762,247143,257534 +58828,62017,169640,171443,172792 +158851,169225,204573,215078,215845 +6902,77336,86049,152329,155356 +169078,191527,200216,205261,237832 +8115,117339,159684,191680,199905 +92189,118817,167160,188639,227491 +33494,37858,102483,149467,168228 +28204,74399,136725,148601,231490 +68062,72086,146619,150881,171798 +121347,122301,124324,153052,181752 +60437,83438,84847,157528,161014 +97431,161647,166736,177162,238754 +22372,88704,163217,228338,229492 +41760,152960,212067,261039,269011 +51657,83107,100646,113311,138267 +67852,76461,78792,123629,171793 +8403,125069,210778,220591,228531 +151165,197641,217031,219083,221282 +51402,69493,165630,180154,202320 +180279,199607,206925,255545,256835 +53882,59523,105457,110661,151736 +183420,231050,271494,303149,317925 +91516,99515,124581,141518,170479 +59965,78690,117560,148589,184307 +158597,220904,226299,227358,230441 +93678,132817,137814,151881,152022 +167839,178224,183909,186315,191223 +147339,200773,201275,207622,217695 +63663,69427,93726,103766,259864 +50662,69192,96962,98101,174096 +92600,143842,165250,182981,199677 +89364,89765,91609,118978,142734 +65276,87320,155712,179744,196126 +86299,121190,172521,198082,215491 +101933,104411,203076,218836,227360 +14067,70805,120476,208633,209930 +129806,156124,201740,205216,214306 +90138,117138,133893,154254,154411 +46354,129901,141222,159603,165785 +68522,92288,97652,131294,143081 +131912,134444,178828,193801,248925 +64166,81509,92638,116384,156626 +122493,140965,168167,222625,247119 +101438,143161,171765,229977,235327 +49142,89143,166058,196674,206243 +97150,103139,143284,159845,254914 +168599,199272,222079,268794,275385 +134073,189789,194350,200889,212941 +84129,91156,117089,174442,190614 +25685,80281,91117,94563,122129 +111789,129626,154844,167436,190036 +70113,74312,106638,112708,120634 +71703,101477,106028,146065,153843 +92488,121550,122625,140527,154604 +115227,115737,141376,169517,170930 +51433,197544,206740,225061,232244 +99048,162562,240840,259365,279903 +63562,63792,187229,196386,209048 +56630,78784,85880,129096,162866 +98662,114174,116667,177332,210574 +93938,182163,198383,213137,228147 +89526,107581,115561,117234,131521 +56571,123489,197166,226568,261719 +107745,118246,141178,222611,241805 +15310,51633,74599,150609,177968 +161912,171268,189191,248185,281967 +71964,89525,89644,147978,178868 +74087,127368,147879,155560,238119 +108022,127389,146034,175319,228003 +149833,157138,189425,198222,231303 +104709,208118,208726,290304,312612 +52215,63539,90550,201868,219758 +18235,21463,62786,68963,115635 +155808,162716,184743,198015,210464 +136468,195355,203478,206038,231918 +206857,212750,231498,248012,250589 +101725,105581,164926,199612,216072 +156587,169251,170254,196198,239791 +116307,210247,219132,221521,226661 +1346,56752,67942,110364,119014 +69353,131584,143636,167841,182484 +87842,105259,113014,141845,153948 +36577,52134,84406,107418,110442 +45813,110740,160944,189368,240833 +44365,78263,128540,150432,151697 +72811,121378,145929,149789,188830 +29113,31116,61242,117055,158089 +32185,40651,50761,101004,133352 +98287,138543,187124,201793,237597 +56976,58518,79479,90917,118736 +74286,119247,130272,160111,168793 +121213,147584,219084,220181,222657 +59336,83209,147622,199388,206037 +74051,120603,122839,129047,157589 +73284,113297,165594,166724,197997 +105996,133094,185691,232468,236612 +108524,111595,132400,165116,189004 +87385,134534,147955,155404,214635 +61024,69644,77683,88792,118526 +116427,127827,134932,181723,190324 +122423,144688,160564,184047,184692 +43506,133586,157119,165604,165806 +77323,119489,156999,159322,186073 +72455,170103,194734,235229,239731 +91318,147912,198435,202842,220152 +97613,103512,155860,156909,176250 +70468,120884,126010,171522,190342 +60123,84997,120357,166738,253679 +69075,104528,179178,233065,238483 +52332,54141,198076,201827,212288 +143731,145213,203329,257487,262596 +100227,132660,141645,221684,236123 +60093,119643,119959,121812,127938 +217021,233823,235039,237219,303169 +27497,61134,71482,117217,136390 +89544,144005,158485,160028,199155 +107010,129096,157481,186434,207999 +63679,79285,108793,167313,255620 +75054,169661,183745,195477,243773 +32677,143822,160303,169948,181185 +59007,90213,94769,110826,133995 +15914,52764,94654,166053,173863 +55621,94516,99310,108984,115778 +67071,71909,106029,136600,143763 +65483,112292,160908,173619,203529 +45818,101547,140058,165662,183242 +52755,141076,226768,227218,245357 +185784,209570,271958,275714,278969 +81891,92202,143471,204428,212333 +45087,135843,143339,165649,171484 +133715,137256,156515,163336,170533 +24923,29556,59651,70357,132253 +41673,66989,68697,106584,215762 +43483,131486,149633,172431,192117 +82118,115662,182683,215773,268381 +24718,101200,159973,167622,189348 +99589,137914,169171,190836,203501 +51566,57692,160597,223385,253146 +92602,209199,225591,233986,270280 +149277,180079,232082,238083,250017 +48801,63201,77161,192469,237385 +95849,174081,186631,195734,232166 +24627,188945,200024,200726,204168 +49503,100181,138424,189431,221710 +107982,154868,189610,190608,192510 +143471,202944,203633,208895,232546 +45181,63370,145396,159008,181487 +66552,71952,88984,131023,180542 +30486,45516,105994,142247,158653 +61213,83994,105098,127639,150928 +40102,50595,71108,95526,97186 +130580,217097,263357,264098,265220 +114094,118863,139195,148378,169556 +65381,160367,167340,220048,230756 +15876,102740,120232,159160,181557 +71044,118905,126697,153337,169659 +21285,35844,124852,127703,131173 +200801,201583,214522,218076,220194 +29881,108935,134284,183443,183509 +34393,151031,158590,182038,191758 +149094,184501,202675,205179,220654 +107122,146773,185873,187226,202048 +51758,67363,77917,83863,139108 +61677,114547,127746,153584,157103 +56701,57581,79358,128578,200505 +43279,107604,136636,166549,235341 +64036,67977,149919,162542,187050 +42597,92309,196955,212147,220315 +67614,75069,134760,159032,233362 +118653,153702,157897,199629,208981 +63323,90914,108519,135453,211621 +65476,86575,152002,166008,177087 +59810,67639,88966,168868,169901 +15654,117866,125388,144412,156526 +128568,168463,173834,184147,211979 +190320,220207,234358,244665,253633 +100974,114141,138868,210665,211525 +128780,168228,188731,270681,296754 +57876,90086,111797,186557,230693 +138138,153328,167526,176732,185325 +137730,154490,197028,199531,208760 +121262,125449,142489,154451,159347 +103239,175544,180426,209480,230704 +48857,50128,112587,124952,161690 +87819,123594,127195,162478,181376 +39470,220381,243868,274541,277838 +47881,58075,108946,120433,129847 +123236,136312,142550,145844,164995 +23411,82654,96901,143020,163658 +67752,122716,253740,273858,287484 +66705,88516,101867,231810,235832 +133290,158449,175817,180846,218942 +83572,138223,144470,155958,185027 +86342,93801,98216,103843,119962 +105095,109610,199946,229397,259726 +126691,131209,190311,218838,224938 +31204,81764,110924,134037,145385 +257043,279074,303684,308153,315104 +64111,113018,152575,169703,198199 +39005,116887,119404,136447,196408 +68432,85789,100904,103304,191207 +147723,159229,164815,167903,171282 +60686,83692,99305,144527,167242 +118081,129442,167559,182023,201301 +123951,140460,143107,152141,171018 +57852,91268,91942,92122,101762 +24178,136948,153002,156510,158957 +63167,98353,128187,131353,179542 +138440,157851,194682,197197,198013 +56524,119600,121385,125054,188625 +45861,151572,184652,216446,235522 +142317,165712,211683,219578,231978 +148828,152369,165181,181880,206294 +81051,119528,183656,194236,202531 +86252,104778,154381,167416,171097 +90339,100004,124650,158038,159792 +157754,195960,199259,233454,237050 +28759,54993,65626,123771,205948 +71553,115017,119385,176662,228899 +41176,72184,108794,148562,149644 +118875,130424,167429,217737,243820 +92610,93818,96528,196003,263748 +185761,188278,224210,252700,262087 +56007,143276,181318,188086,193068 +106158,167628,199374,244160,286089 +143098,150825,155929,216886,217761 +37979,52758,105747,109199,119815 +241892,252192,270405,302306,313424 +18754,53014,75904,120027,143650 +20566,52253,75498,184779,222592 +54623,159986,164156,194261,245022 +83338,157008,219093,225854,242841 +32440,61465,93879,119924,157198 +41174,93850,122880,189094,190877 +78836,93873,126572,227921,245703 +163550,163909,203212,203213,206448 +99224,118579,142900,152789,190830 +104521,138041,143085,147051,182454 +81331,101842,105970,127090,142960 +61825,93344,132667,147382,158936 +185862,233719,234197,234545,240219 +130721,162072,163653,167039,174647 +90910,191158,194818,195243,229175 +207234,221360,246495,259259,263322 +130776,139656,153776,224848,236662 +84137,88062,90174,111344,191959 +111104,113989,150348,169312,209544 +86312,104794,109590,118674,175601 +48365,113847,146193,156736,240843 +66220,80095,92807,95181,208345 +117877,146790,157080,167979,205331 +128316,156905,170817,195769,235882 +69934,85579,159732,159810,170662 +93763,131002,204847,205998,226019 +54003,101916,113692,126241,129064 +52117,68650,150547,191622,200800 +92670,119440,186187,249670,265952 +26678,159082,172663,247241,248857 +143700,205767,251575,281498,297242 +110000,186676,192038,219134,239377 +63329,114685,182784,192541,194079 +142303,226778,239680,270543,284339 +74317,146234,166524,195716,201649 +72222,158311,159057,165127,180638 +136307,138325,150008,207227,216649 +72423,118024,168896,184826,188820 +75105,107131,142488,146172,146181 +108475,119628,188499,193454,299544 +66833,76168,131287,159030,168545 +87196,119797,163054,167103,188455 +46932,49578,73577,112023,163062 +76267,115332,135293,157492,168618 +130268,130552,158429,173741,177742 +63799,113647,130079,137951,156416 +73753,205607,227694,259213,274538 +57796,101584,135242,166766,180822 +79877,97337,142075,150609,181519 +17145,26002,71377,150826,161672 +34582,127538,146910,191990,207792 +19312,92722,154906,175097,187412 +25786,41675,60632,70068,98664 +23569,62938,109973,113382,150125 +153169,163794,166339,198762,219065 +140754,168357,172373,182618,222689 +63637,97436,168563,188143,208780 +62649,82517,141267,223254,265599 +62840,118214,200791,229081,231423 +65891,106998,153128,156225,242816 +80372,99836,113605,207673,208368 +33433,163976,191836,200623,212771 +71296,98397,193908,194856,229006 +129092,137279,156709,157774,158482 +101630,143338,144151,172336,193147 +127814,141007,152723,193060,232621 +105424,153626,185217,226476,242352 +133295,186444,196149,201899,207318 +92093,126857,142088,179430,208924 +57150,62658,71896,139266,148343 +40685,161561,187692,205806,249440 +155098,157109,162427,164064,190613 +40694,134395,147727,214009,217749 +112266,185862,188457,189782,202590 +88509,147988,214520,221060,234770 +131231,135496,233548,239583,246015 +78834,119435,143925,168265,184905 +125037,146085,183381,187569,225821 +77274,78603,79317,104099,109959 +85304,96667,136830,142443,168830 +70267,160366,193602,198876,223069 +109384,122074,210868,233166,239005 +117010,171586,197435,204326,207707 +40669,115559,144473,147417,158968 +54123,64968,88559,88844,144248 +170461,186142,190838,196968,228728 +98876,123519,167412,174042,185984 +54801,71838,97965,122463,171075 +139282,188419,199415,227477,278190 +67145,98354,129558,129971,135329 +80224,90027,109095,141493,153861 +143914,181215,185566,196626,209260 +60766,66571,73443,109529,170164 +116954,124718,137266,188079,198444 +35195,106884,113203,186488,201067 +25810,85137,174969,188222,213917 +107894,109380,160110,183622,215442 +242795,249519,272418,276033,278389 +63430,80139,120840,165522,177180 +59191,70329,101171,108365,197240 +62121,93709,112571,138853,153699 +105841,120285,184225,195878,196113 +44592,96001,158314,161269,174400 +65186,84190,98351,143571,181420 +47824,49047,84854,98825,102205 +122197,122377,131914,169322,180190 +71326,92867,124019,150127,157140 +46179,169788,173071,221579,228093 +57908,59818,112055,133910,194651 +169679,174236,192814,198871,230002 +23902,82778,95272,163367,168865 +57345,76274,121160,147505,172647 +59743,110295,169507,174281,174711 +187689,193858,197925,252195,263946 +54403,57555,102166,109744,168759 +78084,116174,212854,277111,293150 +27082,170329,181341,200309,209087 +37347,114033,178224,185285,222381 +202563,231962,260196,261114,281436 +75062,104903,142847,205244,336382 +11003,129351,150492,236496,268246 diff --git a/demo/build.sh b/demo/build.sh index 8c9ddb1..118bb11 100644 --- a/demo/build.sh +++ b/demo/build.sh @@ -1,11 +1,13 @@ #!/usr/bin/env sh -# Assemble the demo site: the page, the chip package sources, and benchmark data. +# Assemble the site: the demo and explainer page, its scripts, the chip package sources, and +# benchmark data. # Usage: sh demo/build.sh [output-dir] (default: _site) set -eu out="${1:-_site}" rm -rf "$out" mkdir -p "$out/chip" -cp demo/index.html demo/worker.js "$out/" +cp demo/index.html demo/worker.js demo/chain.js demo/explainer.js "$out/" cp src/chip/__init__.py src/chip/core.py src/chip/passwords.py "$out/chip/" -cp benchmarks/results/iterations.csv "$out/" -echo "Built demo in $out/" +cp benchmarks/results/iterations.csv benchmarks/results/distribution.csv \ + benchmarks/results/memory.csv "$out/" +echo "Built site in $out/" diff --git a/demo/chain.js b/demo/chain.js new file mode 100644 index 0000000..e941b3a --- /dev/null +++ b/demo/chain.js @@ -0,0 +1,68 @@ +// CHIP's hash chain in JavaScript, for the explainer's step-through widgets. It mirrors +// Chip.compute in src/chip/core.py with the browser's SHA-256 (WebCrypto), and records every +// iteration so a widget can replay it. tests/test_core.py pins EXAMPLE's result in Python, and +// the page checks this code against it on load (logging to the console only on a mismatch). + +export const EXAMPLE = { + password: "hunter2", + salt: "5c1e4a9b7f0362d8e1a74c2b90f3d65e", + bits: 4, + collisions: 3, + collisionIterations: [9, 11, 12], + digest: "063a918a4bcd790bf964fdaf7d64ad03ec5fef6b85bcbc21fe02ceb12f4e7093", +}; + +export async function sha256(bytes) { + return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); +} + +export const toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +export const fromHex = (hex) => Uint8Array.from(hex.match(/../g) ?? [], (h) => parseInt(h, 16)); +export const utf8 = (text) => new TextEncoder().encode(text); + +export function concat(a, b) { + const out = new Uint8Array(a.length + b.length); + out.set(a); + out.set(b, a.length); + return out; +} + +export const xor = (a, b) => a.map((x, i) => x ^ b[i]); + +/** The low `bits` bits of a digest read as a big-endian integer (`h_int & mask` in core.py). */ +export function lowBits(digest, bits) { + let v = 0n; + for (const byte of digest.slice(-8)) v = (v << 8n) | BigInt(byte); + return Number(v & ((1n << BigInt(bits)) - 1n)); +} + +/** Unpadded standard base64, as in the stored hash string. */ +export const b64 = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/=+$/, ""); + +/** + * Run one chain on `data` (salt + password). Each step records the digest, its low bits, and, + * on a collision, the earlier step it matched. Stops after `collisions` collisions or + * `maxIterations` iterations, like Chip.compute(bounded=True). + */ +export async function chipChain(data, bits, collisions, maxIterations = Infinity) { + const seen = new Map(); // low bits -> the step that first produced them + const steps = []; + const collisionIterations = []; + let h = data; + while (collisionIterations.length < collisions && steps.length < maxIterations) { + const digest = await sha256(h); + const step = { i: steps.length + 1, input: h, digest, key: lowBits(digest, bits), earlier: null }; + const earlier = seen.get(step.key); + if (earlier === undefined) { + seen.set(step.key, step); + h = digest; + } else { + step.earlier = earlier; + collisionIterations.push(step.i); + h = xor(digest, earlier.digest); // perturb with the earlier full digest + } + step.next = h; + steps.push(step); + } + return { steps, collisionIterations, iterations: steps.length, last: h, digest: await sha256(h) }; +} diff --git a/demo/explainer.js b/demo/explainer.js new file mode 100644 index 0000000..da68d8c --- /dev/null +++ b/demo/explainer.js @@ -0,0 +1,939 @@ +// Widgets for the "How CHIP works" explainer below the demo. Each step-through widget is a +// function of the step number (the same pattern as the Sagittal MSM page). Hashing uses the +// browser's SHA-256 through chain.js, so the widgets respond instantly while Pyodide loads. + +import { + EXAMPLE, b64, chipChain, concat, fromHex, lowBits, sha256, toHex, utf8, xor, +} from "./chain.js"; + +const SVG = "http://www.w3.org/2000/svg"; +const $ = (id) => document.getElementById(id); +const fmt = (n) => Math.round(n).toLocaleString("en-US"); +const pct = (x, digits = 1) => `${(100 * x).toFixed(digits)}%`; +const sum = (xs) => xs.reduce((a, b) => a + b, 0); +const mean = (xs) => sum(xs) / xs.length; +const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n)); +const bin = (value, width) => value.toString(2).padStart(width, "0"); +const hl = (x, color = "var(--accent)") => `${x}`; +const hit = (x) => hl(x, "var(--hit)"); +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; + +// --------------------------------------------------------------------------------------------- +// Helpers + +function el(tag, attrs = {}, parent = null, text = null) { + const node = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); + if (text !== null) node.textContent = text; + if (parent) parent.appendChild(node); + return node; +} + +function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } + +function txt(parent, x, y, text, attrs = {}) { + return el("text", { x, y, "font-size": 13, fill: "var(--text)", ...attrs }, parent, text); +} + +/** Reset / Prev / Play / Next controls for a widget. `render(step)` draws the step and returns + * the caption (HTML). `count()` returns the number of steps, so widgets can change length. */ +function stepper(root, count, render, { interval = 900 } = {}) { + const controls = root.querySelector("[data-controls]"); + const caption = root.querySelector("[data-caption]"); + const make = (label, cls = "") => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + if (cls) b.className = cls; + controls.appendChild(b); + return b; + }; + const reset = make("Reset"), prev = make("Prev"), play = make("Play", "primary"), next = make("Next"); + const counter = document.createElement("span"); + counter.className = "step-count"; + controls.appendChild(counter); + + let step = 0, timer = null; + const stop = () => { clearInterval(timer); timer = null; play.textContent = "Play"; }; + const show = () => { + const n = count(); + step = Math.max(0, Math.min(step, n - 1)); + caption.innerHTML = render(step); + counter.textContent = `Step ${step + 1} of ${n}`; + prev.disabled = step === 0; + next.disabled = step === n - 1; + }; + reset.onclick = () => { stop(); step = 0; show(); }; + prev.onclick = () => { stop(); step -= 1; show(); }; + next.onclick = () => { stop(); step += 1; show(); }; + play.onclick = () => { + if (timer) return stop(); + if (step >= count() - 1) step = 0; + play.textContent = "Pause"; + show(); + timer = setInterval(() => { + if (step >= count() - 1) return stop(); + step += 1; + show(); + }, reducedMotion ? interval * 2 : interval); + }; + show(); + return { refresh: show, restart: () => { stop(); step = 0; show(); } }; +} + +async function loadCsv(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(`could not load ${path}`); + const [header, ...rows] = (await response.text()).trim().split("\n"); + return { header: header.split(","), rows: rows.map((r) => r.split(",").map(Number)) }; +} + +/** Pick a random element of `xs`. */ +const pick = (xs) => xs[Math.floor(Math.random() * xs.length)]; + +// --------------------------------------------------------------------------------------------- +// 1. Same password, different salts + +function saltDemo() { + const root = $("w-salt"); + const input = root.querySelector("[data-password]"); + const rows = root.querySelector("[data-view]"); + let salts = [randomBytes(16), randomBytes(16)]; + + async function draw() { + const pw = utf8(input.value); + const plain = toHex(await sha256(pw)); + const salted = await Promise.all(salts.map(async (s) => toHex(await sha256(concat(s, pw))))); + const row = (who, salt, digest, same) => ` +
+
${who}
+
${salt ? `salt ${toHex(salt)}` : `no salt`}
+
${digest}
+
`; + rows.innerHTML = + `
Unsalted: SHA-256(password)
` + + row("Alice", null, plain, true) + row("Bob", null, plain, true) + `
` + + `
Salted: SHA-256(salt ‖ password)
` + + row("Alice", salts[0], salted[0], false) + row("Bob", salts[1], salted[1], false) + `
`; + } + input.addEventListener("input", draw); + root.querySelector("[data-new]").onclick = () => { salts = [randomBytes(16), randomBytes(16)]; draw(); }; + draw(); +} + +// --------------------------------------------------------------------------------------------- +// 2 and 9. Lockstep threads: a warp of 32 guesses that must run together + +/** A warp of 32 guesses. The section 2 copy has no controls and always waits for the slowest + * guess; the section 9 copy adds the attacker's counter-moves. */ +function lockstep(lengths, id) { + const root = $(id); + const svg = root.querySelector("[data-view]"); + const caption = root.querySelector("[data-caption]"); + const mode = root.querySelector("[data-mode]"); + const abandon = root.querySelector("[data-abandon]"); + const dropping = () => abandon?.checked ?? false; + const LANES = 32, BOUND = 250_000, WINDOW = 1_000_000, W = 640, X0 = 30, LANE = 9; + let draws = []; + + const shuffle = () => { draws = Array.from({ length: LANES * 40 }, () => pick(lengths)); }; + + /** Run the lanes for WINDOW iterations of time; draw the bars if `paint`. */ + function simulate(refill, paint) { + const cap = (t) => (dropping() ? Math.min(t, BOUND) : t); + const sx = (t) => X0 + (Math.min(t, WINDOW) / WINDOW) * (W - X0 - 10); + let finished = 0, busy = 0, q = 0; + const bar = (lane, t, len, k, gaveUp) => { + if (!paint) return; + el("rect", { x: sx(t), y: 8 + lane * LANE, width: Math.max(0.5, sx(t + len) - sx(t) - 0.6), + height: LANE - 2, rx: 1.5, + fill: gaveUp ? "var(--bad)" : k % 2 ? "var(--accent-2)" : "var(--accent)", + "fill-opacity": gaveUp ? 0.55 : 0.85 }, svg); + }; + if (refill) { + // Each lane starts its next guess as soon as the last one ends. + for (let lane = 0; lane < LANES; lane++) { + for (let t = 0, k = 0; t < WINDOW; k++) { + const next = draws[q++ % draws.length], len = cap(next); + bar(lane, t, len, k, dropping() && next > BOUND); + busy += Math.min(len, WINDOW - t); + if (t + len <= WINDOW) finished++; + t += len; + } + } + } else { + // A whole warp starts together and ends when its slowest guess ends. + for (let t = 0, k = 0; t < WINDOW; k++) { + const warp = draws.slice(q, q + LANES).map((n) => [n, cap(n)]); + q = (q + LANES) % (draws.length - LANES); + const end = t + Math.max(...warp.map(([, len]) => len)); + warp.forEach(([n, len], lane) => { + bar(lane, t, len, k, dropping() && n > BOUND); + busy += Math.max(0, Math.min(len, WINDOW - t)); + }); + if (end <= WINDOW) finished += LANES; + if (paint && end < WINDOW) el("line", { x1: sx(end), x2: sx(end), y1: 4, y2: 8 + LANES * LANE, + stroke: "var(--text-2)", "stroke-dasharray": "3 3" }, svg); + t = end; + } + } + return { finished, used: busy / (LANES * WINDOW) }; + } + + function draw() { + clear(svg); + for (let lane = 0; lane < LANES; lane++) { + el("rect", { x: X0, y: 8 + lane * LANE, width: W - X0 - 10, height: LANE - 2, fill: "var(--track)", rx: 1.5 }, svg); + } + const refill = mode?.value === "refill"; + const { finished, used } = simulate(refill, true); + txt(svg, X0 - 6, 8 + LANE * LANES / 2, "32 lanes", { "text-anchor": "middle", "font-size": 11, + fill: "var(--text-2)", transform: `rotate(-90 ${X0 - 12} ${8 + LANE * LANES / 2})` }); + const axisY = 8 + LANES * LANE + 14; + txt(svg, X0, axisY, "0", { "font-size": 11, fill: "var(--muted)" }); + txt(svg, W - 10, axisY, `${fmt(WINDOW)} iterations of time`, { "font-size": 11, fill: "var(--muted)", "text-anchor": "end" }); + + const dropped = dropping() ? ` Red guesses passed the upper bound, so they can't be the right password and are cut short.` : ""; + if (refill) { + const waiting = simulate(false, false).finished; + caption.innerHTML = + `Each lane starts a new guess as soon as its last one ends. The lanes hash ${hl(pct(used, 0))} ` + + `of the time and finish ${hl(finished)} guesses, against ${waiting} when every group waits for ` + + `its slowest guess (${(finished / waiting).toFixed(1)}×).` + dropped; + } else { + caption.innerHTML = + `Each group of 32 guesses starts together and ends with its slowest guess (dashed lines). The ` + + `lanes hash only ${hl(pct(used, 0))} of the time; the grey is waiting. ${hl(finished)} guesses ` + + `finished.` + dropped; + } + } + if (mode) mode.onchange = draw; + if (abandon) abandon.onchange = draw; + root.querySelector("[data-new]").onclick = () => { shuffle(); draw(); }; + shuffle(); + draw(); +} + +// --------------------------------------------------------------------------------------------- +// 3. Look at the low bits + +function lowBitsDemo() { + const root = $("w-lowbits"); + const input = root.querySelector("[data-text]"); + const slider = root.querySelector("[data-bits]"); + const out = root.querySelector("[data-bits-out]"); + const view = root.querySelector("[data-view]"); + + async function draw() { + const b = +slider.value; + out.textContent = b; + const digest = await sha256(utf8(input.value)); + const hex = toHex(digest); + const last32 = bin(lowBits(digest, 32), 32); + const bits = [...last32].map((c, i) => (i >= 32 - b ? `${c}` : c)); + const grouped = [0, 1, 2, 3].map((g) => bits.slice(g * 8, g * 8 + 8).join("")).join(" "); + const key = lowBits(digest, b); + view.innerHTML = ` +
SHA-256, 64 hex digits (256 bits) + ${hex.slice(0, -8)}${hex.slice(-8)}
+
Last 32 bits (the highlighted hex digits) in binary + ${grouped}
+
Low ${b} bits as a number + ${fmt(key)} of 2${sup(b)} = ${fmt(2 ** b)} possible values
`; + } + input.addEventListener("input", draw); + slider.addEventListener("input", draw); + draw(); +} + +const sup = (n) => String(n).split("").map((d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]).join(""); + +// --------------------------------------------------------------------------------------------- +// 4. The birthday paradox: random values into 2^b boxes + +function birthday() { + const root = $("w-birthday"); + const svg = root.querySelector("[data-view]"); + const select = root.querySelector("[data-bits]"); + const hist = root.querySelector("[data-hist]"); + const histCaption = root.querySelector("[data-hist-caption]"); + const COLLISIONS = 3; + let draws = []; + + const N = () => 2 ** +select.value; + const newDraws = () => { + draws = []; + const seen = new Set(); + let hits = 0; + while (hits < COLLISIONS) { + const v = Math.floor(Math.random() * N()); + draws.push(v); + if (seen.has(v)) hits++; + seen.add(v); + } + }; + + function render(s) { + clear(svg); + const n = N(), cols = Math.sqrt(n) >= 8 ? 16 : 8, rows = Math.ceil(n / cols); + const C = 640 / 16, size = Math.min(C, 30); + const width = cols * size; + svg.setAttribute("viewBox", `0 0 ${Math.max(width, 200)} ${rows * size + 4}`); + const firstAt = new Map(); + let collisions = 0, lastHit = null; + for (let k = 0; k < s; k++) { + const v = draws[k]; + if (firstAt.has(v)) { collisions++; lastHit = v; } else firstAt.set(v, k + 1); + } + const current = s > 0 ? draws[s - 1] : null; + const isHit = s > 0 && draws.indexOf(current) < s - 1; + for (let v = 0; v < n; v++) { + const x = (v % cols) * size, y = Math.floor(v / cols) * size; + const filled = firstAt.has(v); + el("rect", { x: x + 1.5, y: y + 1.5, width: size - 3, height: size - 3, rx: 3, + fill: v === current && isHit ? "var(--hit)" : filled ? "var(--accent)" : "var(--track)", + "fill-opacity": v === current ? 1 : filled ? 0.45 : 1, + stroke: v === current ? "var(--text)" : "none", "stroke-width": 1.5 }, svg); + } + if (s === 0) { + return `${fmt(n)} boxes, one for each ${select.value}-bit value. Each step draws a random ` + + `value and fills its box. Press Play.`; + } + const where = isHit + ? `${hit("already filled")} at draw ${draws.indexOf(current) + 1}: collision ${collisions}` + : `a new box`; + const done = s === draws.length + ? ` ${hl(COLLISIONS)} collisions after ${hl(s)} draws.` + : ""; + return `Draw ${hl(s)}: value ${current} (${bin(current, +select.value)}) lands in ${where}. ` + + `${firstAt.size} of ${fmt(n)} boxes filled (${pct(firstAt.size / n, 0)}).${done}`; + } + + const step = stepper(root, () => draws.length + 1, render, { interval: 450 }); + root.querySelector("[data-new]").onclick = () => { newDraws(); step.restart(); }; + select.onchange = () => { newDraws(); step.restart(); hist.replaceChildren(); histCaption.textContent = ""; }; + + // Many trials: when does the first collision arrive? + root.querySelector("[data-trials]").onclick = () => { + const n = N(), TRIALS = 1000; + const firsts = Array.from({ length: TRIALS }, () => { + const seen = new Set(); + for (let k = 1; ; k++) { + const v = Math.floor(Math.random() * n); + if (seen.has(v)) return k; + seen.add(v); + } + }); + const W = 640, H = 170, X0 = 30, Y0 = 140; + const top = Math.max(...firsts); + const bins = 30, width = Math.ceil(top / bins); + const counts = Array(bins).fill(0); + for (const f of firsts) counts[Math.min(bins - 1, Math.floor((f - 1) / width))]++; + const peak = Math.max(...counts); + const sx = (k) => X0 + (k / (bins * width)) * (W - X0 - 10); + clear(hist); + hist.setAttribute("viewBox", `0 0 ${W} ${H}`); + counts.forEach((c, i) => el("rect", { x: sx(i * width) + 1, y: Y0 - (c / peak) * 120, + width: sx(width) - X0 - 2, height: (c / peak) * 120, fill: "var(--accent)", "fill-opacity": 0.6 }, hist)); + const theory = Math.sqrt((Math.PI / 2) * n), measured = mean(firsts); + el("line", { x1: sx(theory), x2: sx(theory), y1: 10, y2: Y0, stroke: "var(--hit)", "stroke-width": 2 }, hist); + txt(hist, sx(theory) + 5, 22, `√(π/2 · ${fmt(n)}) ≈ ${theory.toFixed(1)}`, { fill: "var(--hit)", "font-size": 12 }); + el("line", { x1: X0, x2: W - 10, y1: Y0, y2: Y0, stroke: "var(--muted)" }, hist); + txt(hist, X0, Y0 + 18, "1", { "font-size": 11, fill: "var(--muted)" }); + txt(hist, W - 10, Y0 + 18, `${fmt(bins * width)} draws`, { "font-size": 11, fill: "var(--muted)", "text-anchor": "end" }); + histCaption.innerHTML = `${fmt(TRIALS)} trials. The first collision came after ${hl(measured.toFixed(1))} ` + + `draws on average; the formula predicts ${hit(theory.toFixed(1))} (it runs a little low ` + + `when there are only a few boxes). With ${fmt(n)} boxes, that ` + + `is only ${pct(theory / n, 0)} of them filled.`; + }; + + newDraws(); + step.refresh(); +} + +// --------------------------------------------------------------------------------------------- +// 5. The CHIP chain, step by step + +function chainDemo(stats) { + const root = $("w-chain"); + const view = root.querySelector("[data-view]"); + const grid = root.querySelector("[data-grid]"); + const pwInput = root.querySelector("[data-password]"); + const bitsSel = root.querySelector("[data-bits]"); + const cSel = root.querySelector("[data-c]"); + const saltOut = root.querySelector("[data-salt]"); + let salt = fromHex(EXAMPLE.salt); + let chain = null, maxIter = 0, token = 0; + + const short = (bytes, n = 16) => `${toHex(bytes).slice(0, n)}…`; + const D = (i) => `D${i}`; + + function suggestedMax(b, c) { + const s = stats.get(`${b},${c}`); + return s ? Math.ceil((s.mean + s.std) / 10) * 10 : 1000; + } + + async function rebuild() { + const mine = ++token; + const b = +bitsSel.value, c = +cSel.value; + maxIter = suggestedMax(b, c); + saltOut.textContent = toHex(salt); + const result = await chipChain(concat(salt, utf8(pwInput.value)), b, c, maxIter); + if (mine !== token) return; // a newer input arrived while hashing + chain = result; + step.restart(); + } + + function drawGrid(s) { + clear(grid); + const b = +bitsSel.value, n = 2 ** b, cols = 2 ** Math.ceil(b / 2), size = 30; + const rows = n / cols; + grid.setAttribute("viewBox", `0 0 ${cols * size} ${rows * size}`); + const steps = chain.steps.slice(0, Math.min(s, chain.steps.length)); + const firstAt = new Map(); + for (const st of steps) if (!st.earlier) firstAt.set(st.key, st.i); + const cur = s >= 1 && s <= chain.steps.length ? chain.steps[s - 1] : null; + for (let v = 0; v < n; v++) { + const x = (v % cols) * size, y = Math.floor(v / cols) * size; + const filled = firstAt.has(v); + const isCur = cur && cur.key === v; + el("rect", { x: x + 1.5, y: y + 1.5, width: size - 3, height: size - 3, rx: 3, + fill: isCur && cur.earlier ? "var(--hit)" : filled ? "var(--accent)" : "var(--track)", + "fill-opacity": isCur ? 1 : filled ? 0.4 : 1, + stroke: isCur ? "var(--text)" : "none", "stroke-width": 1.5 }, grid); + if (filled) txt(grid, x + size / 2, y + size / 2 + 4, String(firstAt.get(v)), + { "text-anchor": "middle", "font-size": 10.5, fill: "var(--text)" }); + } + } + + function render(s) { + if (!chain) return "Hashing…"; + drawGrid(s); + const b = +bitsSel.value, c = +cSel.value, T = chain.steps.length; + const accepted = chain.collisionIterations.length === c; + const rows = chain.steps.slice(Math.max(0, Math.min(s, T) - 6), Math.min(s, T)).map((st) => ` + + ${st.i}${short(st.digest, 12)} + ${bin(st.key, b)} + ${st.earlier ? `collision with ${D(st.earlier.i)}` : "new: remember it"} + `).join(""); + const table = `${rows}
iDilow ${b} bits
`; + + if (s === 0) { + view.innerHTML = `
h = salt ‖ password + ${toHex(salt)} ‖ ${toHex(utf8(pwInput.value))}
`; + return `Start: the 16-byte salt followed by the password's bytes. The grid has ` + + `one box for each of the ${2 ** b} possible ${b}-bit values. Goal: ${hl(c)} collision${c > 1 ? "s" : ""}, ` + + `within ${fmt(maxIter)} iterations (the suggested upper bound for b = ${b}, C = ${c}).`; + } + if (s <= T) { + const st = chain.steps[s - 1]; + const n = chain.collisionIterations.indexOf(st.i) + 1; + const from = st.i === 1 ? "salt ‖ password" : chain.steps[st.i - 2].earlier ? "the perturbed value" : D(st.i - 1); + let detail = table; + if (st.earlier) { + detail += `
+
${D(st.i)}${short(st.digest, 32)}
+
${D(st.earlier.i)}${short(st.earlier.digest, 32)}
+
XOR${short(st.next, 32)}
`; + } + view.innerHTML = detail; + if (!st.earlier) { + return `Iteration ${hl(st.i)}: ${D(st.i)} = SHA-256(${from}). Its low ${b} bits are ` + + `${hl(bin(st.key, b))} = ${st.key}, not seen before, so CHIP stores the full digest in box ${st.key}.`; + } + return `Iteration ${hit(st.i)}: ${D(st.i)} ends in ${hit(bin(st.key, b))} = ${st.key}, the same as ` + + `${D(st.earlier.i)}. ${hit(`Collision ${n} of ${c}`)}. CHIP looks up the full ${D(st.earlier.i)} ` + + `and sets h = ${D(st.i)} XOR ${D(st.earlier.i)}; the next iteration hashes that instead.`; + } + view.innerHTML = table + `
Final hash = SHA-256(h) + ${toHex(chain.digest)}
` + + (accepted ? `
Stored string` + + `$chip$h=sha256,b=${b},c=${c},min=0,max=${maxIter}$${b64(salt)}$${b64(chain.digest)}
` : ""); + return accepted + ? `${hl(`Done after ${T} iterations`)}, collisions at ${chain.collisionIterations.join(", ")}. CHIP ` + + `hashes once more so the last XOR affects the output. This string is what ${hl("verify")} ` + + `needs: it reruns the same chain from the stored salt.` + : `${hit(`Rejected`)}: only ${chain.collisionIterations.length} of ${c} collisions within ` + + `${fmt(maxIter)} iterations. Real CHIP would draw a new salt and start over. Press New salt.`; + } + + const step = stepper(root, () => (chain ? chain.steps.length + 2 : 1), render, { interval: 700 }); + for (const input of [pwInput, bitsSel, cSel]) input.addEventListener("input", rebuild); + root.querySelector("[data-new]").onclick = () => { salt = randomBytes(16); rebuild(); }; + root.querySelector("[data-example]").onclick = () => { + salt = fromHex(EXAMPLE.salt); + pwInput.value = EXAMPLE.password; + bitsSel.value = EXAMPLE.bits; + cSel.value = EXAMPLE.collisions; + rebuild(); + }; + rebuild(); + + // Check this JavaScript against the Python library's result for the example chain. + chipChain(concat(fromHex(EXAMPLE.salt), utf8(EXAMPLE.password)), EXAMPLE.bits, EXAMPLE.collisions) + .then((r) => { + const ok = toHex(r.digest) === EXAMPLE.digest && + r.collisionIterations.join() === EXAMPLE.collisionIterations.join(); + if (!ok) console.error("chain.js disagrees with the Python library on EXAMPLE"); + }); +} + +// --------------------------------------------------------------------------------------------- +// 6. Pick a perturbation + +function perturbation() { + const root = $("w-perturb"); + const view = root.querySelector("[data-view]"); + const ITER = 200_000; + const VARIANTS = { + none: { + does: "Nothing. The chain is H, H, H, … whatever the collisions are; they only decide where it stops.", + keeps: "Nothing. Compute D1, D2, … and compare SHA-256(Di) with the stored hash after every step. " + + "Stop at the upper bound in the hash string.", + bytes: 0, + }, + flip: { + does: "Flips one fixed bit of Di. The chain now depends on when collisions happen, but not on which earlier digest matched.", + keeps: "The low b bits of every digest, to notice collisions: 4 bytes each at b = 32.", + bytes: 4, + }, + xor: { + does: "XORs Di with the earlier full digest Dj whose low bits matched. The chain depends on Dj's other 224 bits.", + keeps: "Every full digest, so any Dj can be fetched: 32 bytes each.", + bytes: 32, + }, + }; + function draw() { + const choice = root.querySelector("[data-variant]:checked").value; + const v = VARIANTS[choice]; + const bars = Object.entries(VARIANTS).map(([k, x]) => ` +
+ ${{ none: "No perturbation", flip: "Flip one bit", xor: "XOR with Dj (CHIP)" }[k]} + + ${((x.bytes * ITER) / 1e6).toFixed(1)} MB +
`).join(""); + view.innerHTML = ` +
What a collision does${v.does}
+
What an attacker must keep${v.keeps}
+
Memory per guess, about 200,000 iterations (b = 32, C = 5)${bars}
`; + } + root.querySelectorAll("[data-variant]").forEach((r) => (r.onchange = draw)); + draw(); +} + +// --------------------------------------------------------------------------------------------- +// 7. Where to put the upper bound + +function bound(lengths) { + const root = $("w-bound"); + const svg = root.querySelector("[data-view]"); + const slider = root.querySelector("[data-max]"); + const out = root.querySelector("[data-max-out]"); + const stats = root.querySelector("[data-stats]"); + const caption = root.querySelector("[data-caption]"); + const W = 640, H = 210, X0 = 10, Y0 = 180, LO = 50_000, HI = 350_000, BINS = 60; + const width = (HI - LO) / BINS; + const counts = Array(BINS).fill(0); + for (const t of lengths) counts[Math.max(0, Math.min(BINS - 1, Math.floor((t - LO) / width)))]++; + const peak = Math.max(...counts); + const sx = (t) => X0 + ((t - LO) / (HI - LO)) * (W - 2 * X0); + + function draw() { + const max = +slider.value; + out.textContent = fmt(max); + clear(svg); + counts.forEach((c, i) => { + const t = LO + i * width; + el("rect", { x: sx(t) + 0.5, y: Y0 - (c / peak) * 150, width: sx(t + width) - sx(t) - 1, + height: (c / peak) * 150, fill: t + width / 2 > max ? "var(--bad)" : "var(--accent)", + "fill-opacity": 0.65 }, svg); + }); + el("line", { x1: sx(max), x2: sx(max), y1: 14, y2: Y0, stroke: "var(--text)", "stroke-width": 2 }, svg); + txt(svg, sx(max) + (max > 290_000 ? -6 : 6), 26, `max ${fmt(max)}`, + { "font-size": 12, "text-anchor": max > 290_000 ? "end" : "start" }); + el("line", { x1: X0, x2: W - X0, y1: Y0, y2: Y0, stroke: "var(--muted)" }, svg); + for (const t of [100_000, 200_000, 300_000]) { + txt(svg, sx(t), Y0 + 18, fmt(t), { "font-size": 11, fill: "var(--muted)", "text-anchor": "middle" }); + } + const kept = lengths.filter((t) => t <= max); + const p = 1 - kept.length / lengths.length; + const attempts = 1 / (1 - p); + const total = kept.length ? mean(kept) + (attempts - 1) * max : Infinity; + stats.innerHTML = ` +
${pct(p)}
salts rejected
+
${attempts.toFixed(2)}
attempts per hash, on average
+
${fmt(total)}
iterations per hash, including retries
`; + caption.innerHTML = `${fmt(lengths.length)} chains at b = 32, C = 5 (mean ${fmt(mean(lengths))}). ` + + `Every red chain is cut off at ${fmt(max)} and thrown away, so it costs ${fmt(max)} wasted ` + + `iterations. Expected cost = (mean of the kept chains) + (extra attempts) × max = ` + + `${fmt(kept.length ? mean(kept) : 0)} + ${(attempts - 1).toFixed(2)} × ${fmt(max)}.`; + } + slider.addEventListener("input", draw); + draw(); +} + +// --------------------------------------------------------------------------------------------- +// 8. Heatmap of mean iterations for every (b, C) + +function heatmap(stats) { + const root = $("w-heatmap"); + const svg = root.querySelector("[data-view]"); + const caption = root.querySelector("[data-caption]"); + const B = 32, C = 20, X0 = 34, Y0 = 22, CW = 29.5, CH = 13; + const maxLog = Math.log10(stats.get(`${B},${C}`).mean); + + svg.setAttribute("viewBox", `0 0 ${X0 + C * CW + 4} ${Y0 + B * CH + 22}`); + for (let c = 1; c <= C; c++) { + txt(svg, X0 + (c - 0.5) * CW, Y0 - 8, String(c), { "text-anchor": "middle", "font-size": 10, fill: "var(--text-2)" }); + } + txt(svg, X0 + (C * CW) / 2, Y0 + B * CH + 16, "Collisions, C →", { "text-anchor": "middle", "font-size": 11, fill: "var(--text-2)" }); + const cells = new Map(); + for (let b = 1; b <= B; b++) { + if (b % 2 === 0 || b === 1) { + txt(svg, X0 - 6, Y0 + (b - 0.5) * CH + 4, String(b), { "text-anchor": "end", "font-size": 10, fill: "var(--text-2)" }); + } + for (let c = 1; c <= C; c++) { + const s = stats.get(`${b},${c}`); + const t = Math.log10(Math.max(1, s.mean)) / maxLog; + const rect = el("rect", { x: X0 + (c - 1) * CW + 0.5, y: Y0 + (b - 1) * CH + 0.5, width: CW - 1, height: CH - 1, + fill: `color-mix(in oklab, var(--accent) ${Math.round(8 + 92 * t)}%, var(--track))`, + style: "cursor:pointer;outline:none" }, svg); + rect.addEventListener("mouseenter", () => { if (!pinned) select(b, c); }); + rect.addEventListener("click", () => { + // Clicking pins a cell so hovering stops changing it; clicking it again releases it. + pinned = pinned === `${b},${c}` ? null : `${b},${c}`; + select(b, c); + }); + cells.set(`${b},${c}`, rect); + } + } + txt(svg, 4, Y0 - 8, "b ↓", { "font-size": 11, fill: "var(--text-2)" }); + // Drawn just inside the cell's edge, so it doesn't spill over the gaps between cells. + const ring = el("rect", { width: CW - 3, height: CH - 3, fill: "none", "stroke-width": 2, + "pointer-events": "none" }, svg); + let pinned = null; + + function select(b, c) { + const r = cells.get(`${b},${c}`); + ring.setAttribute("x", +r.getAttribute("x") + 1); + ring.setAttribute("y", +r.getAttribute("y") + 1); + ring.setAttribute("stroke", pinned ? "var(--hit)" : "var(--text)"); + const s = stats.get(`${b},${c}`); + const rule = Math.sqrt(2 * c * 2 ** b); + caption.innerHTML = `b = ${hl(b)}, C = ${hl(c)}: ${hl(fmt(s.mean))} iterations on average ` + + `(standard deviation ${fmt(s.std)}). Rule of thumb √(2·C·2${sup(b)}) = ${fmt(rule)} ` + + `(${rule >= s.mean ? "+" : "−"}${pct(Math.abs(rule / s.mean - 1), 0)}). ` + + `Suggested upper bound (mean + 1σ): ${fmt(Math.ceil((s.mean + s.std) / 10) * 10)}.` + + (pinned ? ` Pinned: click the cell again to release it.` : ""); + } + select(32, 5); +} + +// --------------------------------------------------------------------------------------------- +// 8. Line charts: one y-axis, a legend and direct labels, a crosshair tooltip, and a data table + +/** Round up to a tick step of 1, 2, 2.5 or 5 times a power of ten. */ +function niceStep(range, ticks = 5) { + const raw = range / ticks, mag = 10 ** Math.floor(Math.log10(raw)); + return [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw); +} + +/** + * `x` is the shared x value of every point; each series has `name`, `color` (a CSS variable) + * and `y`, one value per x. `title(i)` heads the tooltip and the table row for point i. + */ +function lineChart(root, { x, series, xTicks, xFmt, yFmt, xLabel, yLabel, title, columns }) { + const svg = root.querySelector("[data-view]"); + const W = 640, H = 300, L = 58, R = 120, T = 30, B = 44; + const xMin = Math.min(...x), xMax = Math.max(...x); + const yTop = Math.max(...series.flatMap((s) => s.y)); + const step = niceStep(yTop), yMax = Math.ceil(yTop / step) * step; + const sx = (v) => L + ((v - xMin) / (xMax - xMin)) * (W - L - R); + const sy = (v) => T + (1 - v / yMax) * (H - T - B); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + + for (let v = 0; v <= yMax + 1e-9; v += step) { + el("line", { x1: L, x2: W - R, y1: sy(v), y2: sy(v), stroke: "var(--border)" }, svg); + txt(svg, L - 8, sy(v) + 4, yFmt(v), { "text-anchor": "end", "font-size": 11, fill: "var(--muted)" }); + } + for (const v of xTicks) { + txt(svg, sx(v), H - B + 18, xFmt(v), { "text-anchor": "middle", "font-size": 11, fill: "var(--muted)" }); + } + txt(svg, (L + W - R) / 2, H - 6, xLabel, { "text-anchor": "middle", "font-size": 12, fill: "var(--text-2)" }); + txt(svg, 4, 12, yLabel, { "font-size": 12, fill: "var(--text-2)" }); + + for (const s of series) { + el("path", { d: s.y.map((v, i) => `${i ? "L" : "M"}${sx(x[i])},${sy(v)}`).join(""), fill: "none", + stroke: s.color, "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }, svg); + // Direct label at the line's end, in text ink beside a swatch of the series color. + const yEnd = sy(s.y[s.y.length - 1]); + el("line", { x1: W - R + 8, x2: W - R + 18, y1: yEnd, y2: yEnd, stroke: s.color, "stroke-width": 3, + "stroke-linecap": "round" }, svg); + txt(svg, W - R + 22, yEnd + 4, s.name, { "font-size": 12, fill: "var(--text-2)" }); + } + + const legend = root.querySelector("[data-legend]"); + legend.replaceChildren(...series.map((s) => { + const item = document.createElement("span"); + const swatch = document.createElement("i"); + swatch.style.background = s.color; + item.append(swatch, document.createTextNode(s.name)); + return item; + })); + + // Crosshair: snap to the nearest x and list every series there. + const cross = el("line", { y1: T, y2: H - B, stroke: "var(--text-2)", "stroke-dasharray": "3 3", visibility: "hidden" }, svg); + const dots = series.map((s) => el("circle", { r: 4, fill: s.color, stroke: "var(--surface)", "stroke-width": 2, + visibility: "hidden" }, svg)); + const tip = document.createElement("div"); + tip.className = "tip"; + tip.hidden = true; + root.append(tip); + const hit = el("rect", { x: L, y: T, width: W - L - R, height: H - T - B, fill: "transparent" }, svg); + const show = (event) => { + const box = svg.getBoundingClientRect(); + const px = ((event.clientX - box.left) / box.width) * W; + let i = 0; + x.forEach((v, j) => { if (Math.abs(sx(v) - px) < Math.abs(sx(x[i]) - px)) i = j; }); + cross.setAttribute("x1", sx(x[i])); + cross.setAttribute("x2", sx(x[i])); + cross.setAttribute("visibility", "visible"); + dots.forEach((d, k) => { d.setAttribute("cx", sx(x[i])); d.setAttribute("cy", sy(series[k].y[i])); d.setAttribute("visibility", "visible"); }); + const head = document.createElement("div"); + head.className = "tip-title"; + head.textContent = title(i); + tip.replaceChildren(head, ...series.map((s) => { + const row = document.createElement("div"); + row.className = "tip-row"; + const swatch = document.createElement("i"); + swatch.style.background = s.color; + const value = document.createElement("b"); + value.textContent = yFmt(s.y[i], true); + const name = document.createElement("span"); + name.textContent = s.name; + row.append(swatch, value, name); + return row; + })); + tip.hidden = false; + const scale = box.width / W, left = sx(x[i]) * scale; + const rootBox = root.getBoundingClientRect(); + const offsetX = box.left - rootBox.left, offsetY = box.top - rootBox.top; + tip.style.top = `${offsetY + T * scale}px`; + tip.style.left = left + tip.offsetWidth + 16 > box.width + ? `${offsetX + left - tip.offsetWidth - 12}px` : `${offsetX + left + 12}px`; + }; + const hide = () => { + tip.hidden = true; + cross.setAttribute("visibility", "hidden"); + dots.forEach((d) => d.setAttribute("visibility", "hidden")); + }; + hit.addEventListener("pointermove", show); + hit.addEventListener("pointerdown", show); + hit.addEventListener("pointerleave", hide); + + // The same numbers as a table, for readers who can't or don't want to hover. + const table = document.createElement("table"); + const headRow = table.insertRow(); + for (const name of [...columns, ...series.map((s) => s.name)]) { + const th = document.createElement("th"); + th.textContent = name; + headRow.append(th); + } + x.forEach((_, i) => { + const row = table.insertRow(); + for (const cell of [...title(i, true), ...series.map((s) => yFmt(s.y[i], true))]) row.insertCell().textContent = cell; + }); + root.querySelector("[data-table]").replaceChildren(table); +} + +function iterationsByC(stats) { + const C = Array.from({ length: 20 }, (_, i) => i + 1); + const bits = [32, 30, 28]; + lineChart($("w-by-c"), { + x: C, + series: bits.map((b, k) => ({ name: `b = ${b}`, color: `var(--series-${k + 1})`, + y: C.map((c) => stats.get(`${b},${c}`).mean) })), + xTicks: [1, 5, 10, 15, 20], + xFmt: String, + yFmt: (v, exact) => (exact ? fmt(v) : v === 0 ? "0" : `${fmt(v / 1000)}k`), + xLabel: "Collisions, C", + yLabel: "Mean iterations", + title: (i, row) => (row ? [String(C[i])] : `C = ${C[i]}`), + columns: ["C"], + }); +} + +function memoryChart({ rows }) { + // Columns: bits, collisions, runs, mean_iterations, peak_python_mb, raw_digests_mb, tradeoff_mb, … + const data = [...rows].sort((a, b) => a[3] - b[3]); + lineChart($("w-memory"), { + x: data.map((r) => r[3]), + series: [ + { name: "Python peak", color: "var(--series-1)", y: data.map((r) => r[4]) }, + { name: "Raw digests", color: "var(--series-2)", y: data.map((r) => r[5]) }, + { name: "Tradeoff", color: "var(--series-3)", y: data.map((r) => r[6]) }, + ], + xTicks: [0, 250_000, 500_000, 750_000, 1_000_000].filter((v) => v >= data[0][3]), + xFmt: (v) => (v >= 1e6 ? `${v / 1e6}M` : `${v / 1000}k`), + yFmt: (v, exact) => (exact ? `${v.toFixed(1)} MB` : `${v} MB`), + xLabel: "Mean iterations per chain", + yLabel: "Memory per chain", + title: (i, row) => { + const [b, c, , t] = data[i]; + return row ? [String(b), String(c), fmt(t)] : `b = ${b}, C = ${c}: ${fmt(t)} iterations`; + }, + columns: ["b", "C", "Mean iterations"], + }); +} + +// --------------------------------------------------------------------------------------------- +// 9. Time–memory tradeoff: replaying from a checkpoint + +function replay() { + const root = $("w-replay"); + const svg = root.querySelector("[data-view]"); + const N = 24, K = 6, HIT = 20, J = 9, W = 640, X0 = 10, Y = 40; + const size = (W - 2 * X0) / N; + const x = (i) => X0 + (i - 1) * size; + const STEPS = 7; + + function render(s) { + clear(svg); + const computed = s >= 1 ? HIT : HIT - 1; + const replayed = s >= 3 ? Math.min(J, K + (s - 2)) : null; + for (let i = 1; i <= N; i++) { + const isCheckpoint = i % K === 0 && i <= computed; + const fill = i > computed ? "var(--surface)" + : i === HIT && s >= 1 ? "var(--hit)" + : replayed !== null && i > K && i <= replayed ? "var(--good)" + : isCheckpoint ? "var(--accent)" : "var(--track)"; + el("rect", { x: x(i) + 1, y: Y, width: size - 2, height: size - 2, rx: 3, fill, + "fill-opacity": fill === "var(--track)" || fill === "var(--surface)" ? 1 : 0.8, + stroke: i > computed ? "var(--border)" : "none" }, svg); + txt(svg, x(i) + size / 2, Y + size / 2 + 4, String(i), { "text-anchor": "middle", "font-size": 11 }); + } + txt(svg, X0, Y - 12, "Kept: the iteration index of each low-bit value, plus the full digest at every 6th iteration (blue)", + { "font-size": 12, fill: "var(--text-2)" }); + if (s >= 1) { + const arc = (from, to, color, label) => { + const x1 = x(from) + size / 2, x2 = x(to) + size / 2, yb = Y + size + 2; + el("path", { d: `M${x1},${yb} C${x1},${yb + 46} ${x2},${yb + 46} ${x2},${yb}`, fill: "none", + stroke: color, "stroke-width": 2, "marker-end": "" }, svg); + txt(svg, (x1 + x2) / 2, yb + 50, label, { "text-anchor": "middle", "font-size": 12, fill: color }); + }; + arc(HIT, J, "var(--hit)", "needs D₉"); + if (s >= 2) arc(J, K, "var(--accent)", "nearest checkpoint: D₆"); + } + svg.setAttribute("viewBox", `0 0 ${W} ${Y + size + 70}`); + return [ + `The chain has run to iteration 19. Instead of 19 full digests, only the iteration index of ` + + `each low-bit value is kept, plus full digests at checkpoints 6, 12 and 18.`, + `Iteration ${hit(20)} collides: its low bits match the value first seen at iteration 9. ` + + `CHIP needs the full ${hit("D9")}, which wasn't kept.`, + `Go back to the nearest checkpoint at or before 9: ${hl("D6")}, which was kept.`, + `Re-hash: D7 = SHA-256(D6). ${hl("1 extra hash")}.`, + `D8 = SHA-256(D7). ${hl("2 extra hashes")}.`, + `D9 = SHA-256(D8). ${hl("3 extra hashes")}, and D9 is back.`, + `XOR D20 with D9 and carry on. On average a recovery costs half the checkpoint spacing, and ` + + `there are only C of them per chain.`, + ][s]; + } + stepper(root, () => STEPS, render, { interval: 1100 }); +} + +// --------------------------------------------------------------------------------------------- +// 9. Time–memory tradeoff: checkpoint spacing + +function tradeoff() { + const root = $("w-tradeoff"); + const svg = root.querySelector("[data-view]"); + const preset = root.querySelector("[data-preset]"); + const slider = root.querySelector("[data-spacing]"); + const out = root.querySelector("[data-spacing-out]"); + const stats = root.querySelector("[data-stats]"); + const caption = root.querySelector("[data-caption]"); + // Mean iterations and the measured extra hashing at spacing 1,000, from benchmarks/results/memory.md. + const PRESETS = { + "24,5": [12_908, 0.186], "28,5": [52_113, 0.047], "32,5": [199_770, 0.013], + "32,30": [508_667, 0.03], "34,30": [1_006_702, 0.015], + }; + const spacing = () => Math.round(10 ** +slider.value); + // Memory: a 4-byte key and 4-byte index per stored value, plus a 32-byte digest per checkpoint. + // Extra hashing: each of the C recoveries replays (j mod k) hashes, (k − 1)/2 on average. + const model = (T, C, k) => ({ mem: (T - C) * 8 + Math.floor(T / k) * 32, extra: (C * (k - 1)) / 2 / T }); + + function draw() { + const [b, C] = preset.value.split(",").map(Number); + const [T, measured] = PRESETS[preset.value]; + const k = spacing(); + out.textContent = fmt(k); + const W = 640, H = 230, X0 = 52, X1 = 590, Y0 = 190, Y1 = 22; + const full = (T - C) * 32; + const logs = Array.from({ length: 101 }, (_, i) => i / 20); // log10 of the spacing, 0 to 5 + const pts = logs.map((l) => ({ l, ...model(T, C, Math.round(10 ** l)) })); + const memTop = full * 1.3, extraTop = Math.min(1, Math.max(...pts.map((p) => p.extra))); + const sx = (l) => X0 + (l / 5) * (X1 - X0); + const sm = (m) => Y0 - (Math.min(m, memTop) / memTop) * (Y0 - Y1); + const se = (e) => Y0 - (Math.min(e, extraTop) / extraTop) * (Y0 - Y1); + clear(svg); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + el("line", { x1: X0, x2: X1, y1: Y0, y2: Y0, stroke: "var(--muted)" }, svg); + for (let l = 0; l <= 5; l++) { + txt(svg, sx(l), Y0 + 16, fmt(10 ** l), { "text-anchor": "middle", "font-size": 11, fill: "var(--muted)" }); + } + txt(svg, (X0 + X1) / 2, Y0 + 34, "checkpoint spacing (iterations, log scale)", { "text-anchor": "middle", "font-size": 11, fill: "var(--muted)" }); + // Left axis: memory. Right axis: extra hashing. + txt(svg, X0 - 6, sm(full) + 4, `${(full / 1e6).toFixed(1)} MB`, { "text-anchor": "end", "font-size": 11, fill: "var(--accent)" }); + txt(svg, X0 - 6, Y0, "0", { "text-anchor": "end", "font-size": 11, fill: "var(--muted)" }); + txt(svg, X1 + 6, se(extraTop) + 4, pct(extraTop, 0), { "font-size": 11, fill: "var(--hit)" }); + el("line", { x1: X0, x2: X1, y1: sm(full), y2: sm(full), stroke: "var(--accent)", "stroke-dasharray": "4 4", "stroke-opacity": 0.6 }, svg); + txt(svg, sx(2.5), sm(full) - 6, "storing every digest", { "text-anchor": "middle", "font-size": 11, fill: "var(--accent)" }); + const line = (key, scale, color) => el("path", { fill: "none", stroke: color, "stroke-width": 2.2, + d: pts.map((p, i) => `${i ? "L" : "M"}${sx(p.l)},${scale(p[key])}`).join("") }, svg); + line("mem", sm, "var(--accent)"); + line("extra", se, "var(--hit)"); + txt(svg, sx(0.15), sm(pts[3].mem) - 8, "memory", { "font-size": 12, fill: "var(--accent)", "font-weight": 600 }); + txt(svg, sx(4.85), se(pts[97].extra) - 8, "extra hashing", { "text-anchor": "end", "font-size": 12, fill: "var(--hit)", "font-weight": 600 }); + const cur = model(T, C, k); + el("line", { x1: sx(+slider.value), x2: sx(+slider.value), y1: Y1 - 8, y2: Y0, stroke: "var(--text)", "stroke-width": 1.5 }, svg); + el("circle", { cx: sx(+slider.value), cy: sm(cur.mem), r: 5, fill: "var(--accent)" }, svg); + el("circle", { cx: sx(+slider.value), cy: se(cur.extra), r: 5, fill: "var(--hit)" }, svg); + stats.innerHTML = ` +
${(cur.mem / 1e6).toFixed(2)} MB
memory, vs ${(full / 1e6).toFixed(1)} MB for every digest
+
${pct(cur.extra)}
extra hashing (${fmt(C * (k - 1) / 2)} hashes)
+
${fmt(Math.floor(T / k))}
checkpoints kept
`; + caption.innerHTML = `b = ${b}, C = ${C}: about ${fmt(T)} iterations. Memory never drops below ` + + `${((T - C) * 8 / 1e6).toFixed(2)} MB (the 8-byte key and index per value), a quarter of storing ` + + `every digest. At spacing 1,000 this model gives ${pct(model(T, C, 1000).extra)} extra hashing; ` + + `the benchmark measured ${pct(measured)}.`; + } + preset.onchange = draw; + slider.addEventListener("input", draw); + draw(); +} + +// --------------------------------------------------------------------------------------------- + +saltDemo(); +lowBitsDemo(); +birthday(); +replay(); +tradeoff(); +perturbation(); + +loadCsv("iterations.csv").then(({ rows }) => { + const stats = new Map(rows.map(([b, c, m, s]) => [`${b},${c}`, { mean: m, std: s }])); + chainDemo(stats); + heatmap(stats); + iterationsByC(stats); +}); + +loadCsv("memory.csv").then(memoryChart); + +loadCsv("distribution.csv").then(({ rows }) => { + const lengths = rows.map((r) => r[r.length - 1]); + lockstep(lengths, "w-lockstep"); + lockstep(lengths, "w-lockstep-attack"); + bound(lengths); +}); diff --git a/demo/index.html b/demo/index.html index 903be2c..542cb1a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,6 +22,11 @@ --bad: #c23535; --tick: #0b0b0b; --code-bg: #f0efec; + --hit: #eb6834; + --accent-2: #8db4e6; + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { @@ -39,6 +44,11 @@ --bad: #e66767; --tick: #f0efec; --code-bg: #232322; + --hit: #e0703f; + --accent-2: #2c5c99; + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; } } :root[data-theme="dark"] { @@ -56,6 +66,11 @@ --bad: #e66767; --tick: #f0efec; --code-bg: #232322; + --hit: #e0703f; + --accent-2: #2c5c99; + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; } * { box-sizing: border-box; } body { @@ -203,6 +218,119 @@ details { margin-top: 16px; } summary { cursor: pointer; font-weight: 600; font-size: 14px; } footer { margin-top: 32px; color: var(--muted); font-size: 13px; } + + /* --- Explainer below the demo --------------------------------------------------------- */ + .page-break { + margin: 72px -16px 0; + padding: 56px 16px 40px; + border-top: 1px solid var(--border); + background: linear-gradient(var(--surface), var(--bg)); + text-align: center; + } + .page-break .eyebrow { color: var(--muted); font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.08em; text-transform: uppercase; } + .page-break h2 { margin: 10px 0 8px; font-size: 30px; } + .page-break p { margin: 0 auto; color: var(--text-2); max-width: 60ch; } + .explainer { max-width: 920px; margin: 0 auto; font-size: 16px; line-height: 1.6; } + .explainer h2 { margin: 56px 0 4px; font-size: 22px; scroll-margin-top: 16px; } + .explainer h3 { margin: 26px 0 6px; font-size: 17px; } + .explainer p, .explainer li { max-width: 72ch; } + .explainer .takeaway { + margin: 6px 0 16px; + padding: 10px 14px; + border-left: 4px solid var(--accent); + background: var(--surface); + border-radius: 0 8px 8px 0; + color: var(--text); + max-width: none; + } + .explainer code, .explainer .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; } + .explainer code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; } + .explainer pre { max-height: none; font-size: 14px; margin: 12px 0; } + .explainer pre code { background: none; padding: 0; } + .explainer .card { margin: 16px 0; padding: 16px; } + .explainer .card svg { display: block; width: 100%; height: auto; } + .explainer label { display: inline-flex; gap: 6px; align-items: center; font-weight: 400; margin: 0; } + .toc { + margin-top: 24px; padding: 14px 18px; background: var(--surface); + border: 1px solid var(--border); border-radius: 10px; font-size: 15px; + } + .toc ol { margin: 0; padding-left: 22px; columns: 2; column-gap: 32px; } + @media (max-width: 640px) { .toc ol { columns: 1; } } + .controls { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; } + .controls button, .options button { padding: 6px 14px; font-size: 14px; } + .step-count { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; } + .caption { margin: 10px 0 0; min-height: 3.2em; max-width: none !important; } + .caption .sub { color: var(--text-2); } + .options { display: flex; gap: 10px 18px; flex-wrap: wrap; align-items: center; margin-top: 12px; + font-size: 14px; color: var(--text-2); } + .options label { flex-wrap: wrap; } + .options input[type="range"] { width: 160px; max-width: 100%; } + .options input[type="text"] { width: 12em; padding: 4px 8px; } + .options select { width: auto; padding: 4px 8px; } + .options output { font-variant-numeric: tabular-nums; color: var(--text); min-width: 2ch; } + .explainer table { border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; } + .explainer th, .explainer td { padding: 4px 10px; text-align: left; border-bottom: 1px solid var(--border); } + .explainer th { color: var(--text-2); font-weight: 600; } + .scroll { overflow-x: auto; } + .explainer .stats { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); margin: 14px 0 0; } + .explainer .stat .value { font-variant-numeric: tabular-nums; } + .viz { position: relative; } + .legend { display: flex; gap: 6px 16px; flex-wrap: wrap; font-size: 13px; color: var(--text-2); margin-bottom: 6px; } + .legend span { display: inline-flex; align-items: center; gap: 6px; } + .legend i { width: 14px; height: 3px; border-radius: 2px; display: inline-block; } + .tip { + position: absolute; pointer-events: none; z-index: 2; min-width: 150px; + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + padding: 8px 10px; font-size: 13px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); + } + .tip .tip-title { color: var(--text-2); margin-bottom: 4px; } + .tip .tip-row { display: flex; align-items: center; gap: 8px; } + .tip .tip-row b { font-variant-numeric: tabular-nums; min-width: 5.5em; } + .tip .tip-row span { color: var(--text-2); } + .tip i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } + .viz details { margin-top: 8px; font-size: 14px; } + .viz summary { cursor: pointer; color: var(--text-2); font-weight: 400; } + .viz details table { margin-top: 6px; } + .terms dt { font-weight: 600; margin-top: 14px; scroll-margin-top: 16px; } + .terms dd { margin: 2px 0 0; color: var(--text-2); max-width: 72ch; } + .muted { color: var(--muted); } + .small { font-size: 12px; } + .digest { overflow-wrap: anywhere; word-break: break-all; } + .kv { display: grid; gap: 2px; margin: 10px 0; } + .kv .k { font-size: 13px; color: var(--text-2); } + .hash-group { margin-bottom: 12px; } + .group-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; } + .hash-row { display: grid; grid-template-columns: 52px 1fr; gap: 0 10px; padding: 6px 0; + border-bottom: 1px solid var(--border); font-size: 13px; } + .hash-row .who { grid-row: span 2; font-weight: 600; } + .hash-row .digest.same { color: var(--bad); } + .lowbit, .lowhex { color: var(--accent); font-weight: 700; background: color-mix(in srgb, var(--accent) 14%, transparent); } + .bits { letter-spacing: 0.04em; } + .chain-layout { display: grid; gap: 16px; } + @media (min-width: 760px) { .chain-layout { grid-template-columns: 260px 1fr; align-items: start; } } + .chain-layout svg[data-grid] { max-width: 260px; margin: 0 auto; } + .chain-table { width: 100%; font-size: 13px !important; } + .chain-table tr.is-hit td { color: var(--hit); } + .chain-table tr.is-current td { font-weight: 700; } + .xor { margin-top: 10px; padding: 8px 10px; background: var(--code-bg); border-radius: 6px; font-size: 13px; + overflow-wrap: anywhere; } + .xor .lbl { display: inline-block; width: 3.5em; color: var(--text-2); } + .xor .rule { border-top: 1px solid var(--muted); margin-top: 2px; padding-top: 2px; color: var(--hit); } + .hint.ok { color: var(--good); } + .bar-row { display: grid; grid-template-columns: 150px 1fr 64px; gap: 10px; align-items: center; + font-size: 13px; color: var(--text-2); padding: 3px 0; } + .bar-row.on { color: var(--text); font-weight: 600; } + .bar { height: 12px; background: var(--track); border-radius: 4px; overflow: hidden; } + .bar span { display: block; height: 100%; background: var(--accent); } + .bar-row:not(.on) .bar span { opacity: 0.4; } + .bar-value { text-align: right; font-variant-numeric: tabular-nums; } + .anatomy { display: flex; flex-wrap: wrap; gap: 2px; font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } + .anatomy span { padding: 4px 6px; border-radius: 4px; background: var(--code-bg); } + .anatomy span b { display: block; font: 600 11px/1.3 system-ui, sans-serif; color: var(--text-2); } + .compare td:first-child { font-weight: 600; } + .compare tr.chip td { background: color-mix(in srgb, var(--accent) 8%, transparent); } + .draft { border: 1px dashed var(--hit); border-radius: 8px; padding: 2px 14px; } @@ -219,6 +347,7 @@

CHIP: Confounding Hash Iteration for Passwords

@@ -253,7 +382,7 @@

Parameters

Salt attempts

-

Press Hash to start. Each bar is one salt's chain, each mark a collision.

+

Loading Python… The first hash runs as soon as it's ready. Each bar is one salt's chain, each mark a collision.

+ +
+
Explainer
+

How CHIP works, step by step

+

The demo above runs CHIP. The rest of this page explains it from the ground up: why + password hashes are slow on purpose, the birthday paradox that sets CHIP's cost, what + each step of the chain does, and where the design falls short.

+
+ +
+ + + + + +
+

1. Why password hashes are slow on purpose

+

A stolen password database is attacked offline, so the only defense left is + making each guess expensive.

+

A website never needs your password itself, only a way to check it. So it stores a + hash of the password: a fixed-size fingerprint that is easy to compute + and practically impossible to reverse. At login it hashes what you typed and compares.

+

If the database leaks, the attacker has the hashes. They can't reverse them, but they can + guess: hash "123456", "password", "hunter2", … and look for a match. Nobody + can rate-limit this; it runs on the attacker's own hardware. Each guess costs one run of the + password hash, so the cost of one hash is the cost of one guess.

+

General-purpose hashes such as SHA-256 are designed to be fast: one high-end gaming GPU (an + RTX 4090) computes about 21 billion SHA-256 hashes per second in + hashcat's benchmark. That is good for checking files and bad for passwords. A + password hash deliberately makes each run expensive.

+ +

Salts: the same password, different hashes

+

A salt is a random value stored next to each hash and mixed into it. + Without one, everyone who chose "hunter2" has the same hash, so the attacker cracks them all + at once, or looks the hash up in a precomputed table. With a salt, each user's hash has to + be attacked separately. A salt does not make a single guess any slower. Making each + guess slower is what the rest of this page is about.

+
+
+
+ + +
+
+
+ + +
+

2. What the Password Hashing Competition asked for

+

Make each guess cost time, memory, or an unpredictable amount of time. + CHIP tries all three.

+

From 2013 to 2015 the Password Hashing + Competition (PHC) collected new designs; Argon2 won. The entries + use three levers, often together:

+
    +
  • More iterations. Hash many times in a row. Simple, but an attacker with + a GPU runs thousands of guesses side by side.
  • +
  • More memory. GPUs and custom chips have thousands of cores but little + fast memory per core. If each guess needs megabytes, far fewer guesses fit at once.
  • +
  • Unpredictable run time. A GPU runs threads in + lockstep groups of 32. If guesses take different amounts of time, + the fast ones sit idle until the slowest in their group finishes.
  • +
+

CHIP's run time is set by when random collisions happen (section 4), so it varies from guess + to guess. Below, each bar is one password guess on one GPU lane, with its length taken from + real CHIP chains at the defaults. A group of 32 starts together and can't move on until its + slowest guess is done. Watch the grey: that is time lanes spend waiting.

+
+ +

+
+
+

That waiting is the cost CHIP's variable run time adds for a GPU attacker. Each lever turns + out weaker than it first looks; section 9 shows how an attacker gets + this one back.

+
+ + +
+

3. Digests and low bits

+

A digest is 256 random-looking bits; CHIP looks only at the last b of + them.

+

SHA-256 turns any input into a 32-byte (256-bit) digest, usually + written as 64 hex digits. Change one character of the input and the whole digest changes, + with no visible pattern. So its bits behave like coin flips.

+

CHIP reads only the low b bits: the last b bits of the digest, read as a + number. We'll write this as:

+
low_b_bits(h) = h mod 2^b        # keep only the last b bits
+

There are only 2b possible values, so they repeat far sooner than whole digests + ever would.

+
+
+
+ + +
+
+
+ + +
+

4. The birthday paradox

+

Among 2b possible values, a repeat shows up after about + √(2b) tries, not 2b.

+

In a room of 23 people, there is a better than even chance that two share a birthday, even + though there are 365 days. The reason is pairs: 23 people make 23 × 22 / 2 = 253 pairs, and + each pair has a 1 in 365 chance of matching. The number of pairs grows like the square of the + number of people, so matches arrive much sooner than intuition says.

+

The same happens with b-bit values. Drawing random values into 2b boxes, the + first collision (a value that was already drawn) comes after + about

+
√(π/2 · 2^b) ≈ 1.25 · 2^(b/2) draws
+

At b = 32 that is about 82,000 draws out of 4.3 billion possible values, and the benchmark + measured 81,000.

+
+ +

+
+
+ + + +
+ +

+
+ +

A rule of thumb for C collisions

+

CHIP waits for C collisions, not one. After T draws there are about T²/2 pairs, and each + matches with probability 1/2b, so the expected number of collisions is about + T² / (2 · 2b). Setting that equal to C and solving for T:

+
T ≈ √(2 · C · 2^b)
+

This one formula explains how both parameters behave:

+
    +
  • Each extra bit multiplies the work by √2 ≈ 1.41, because doubling + 2b multiplies its square root by √2.
  • +
  • More collisions add work with diminishing returns, because T grows like + √C: 4 times the collisions only doubles the iterations.
  • +
+

Against the measured means it is within about 4% for C ≥ 5 (b = 32, C = 5: predicted + 207,000, measured 200,000). It overestimates for C = 1 by about 15%, where the exact + √(π/2 · 2b) above applies.

+
+ + +
+

5. The CHIP chain, step by step

+

Hash, check the low bits, remember the digest or perturb with the earlier + one, repeat until C collisions, then hash once more.

+
h = salt ‖ password
+repeat:
+    h = SHA256(h)                       # this is Di
+    if low_b_bits(h) was seen before (as the low bits of Dj):
+        collisions += 1
+        h = h XOR Dj                    # perturb with the earlier full digest
+    else:
+        remember h under its low b bits
+until collisions == C
+return salt, SHA256(h)
+

Names used from here on: Di is the digest produced at iteration i; its low + b bits are Li. A collision is an iteration whose Li equals + the low bits of an earlier, remembered digest Dj. CHIP stores each remembered digest in a + table keyed by its low bits. Step through a real chain, small enough to see every + iteration:

+
+
+ +
+
+

+
+
+ + + + + +
+

Salt

+
+

Each box in the grid is one possible low-bit value, and shows the iteration whose digest it + holds. Two details to notice:

+
    +
  • The first iteration can never collide, because nothing has been remembered yet. That is + why max_iterations must be greater than C.
  • +
  • After the last collision CHIP hashes once more, so the final XOR affects the output.
  • +
+

This is the "confounding" in CHIP's name. The number of iterations depends on exactly when + each collision lands, and the only way to find out is to run the chain. Change one character + of the password or salt and every digest changes, so every collision moves. Press + New salt above to see it.

+
+ + +
+

6. Why the XOR forces memory

+

XOR-ing in the full earlier digest means the hasher must be able to + get Dj back, so it has to keep the chain's digests (or recompute them).

+

Think like an attacker checking one guess against a stolen hash, and ask what they could + skip. Compare three versions of what a collision does:

+
+
+ + + +
+
+
+
    +
  • No perturbation: the chain is just SHA-256 applied over and over; + collisions only decide where it stops. The attacker doesn't care where: they hash, check + the result against the stolen hash, and repeat. No memory at all.
  • +
  • Flip one bit: now the chain changes at each collision, so the attacker + must notice collisions, which means remembering every low-bit value seen. That is b bits per + iteration.
  • +
  • XOR with Dj: the chain depends on the other 224 bits of the earlier + digest too, so the attacker must be able to produce any earlier digest on demand. Keeping + them all is 32 bytes per iteration, eight times the previous row at b = 32.
  • +
+

Whatever is kept belongs to one password and salt, so it can't be shared between guesses. + Keeping every digest is not the only way to get Dj back, though; section + 9 shows a cheaper one.

+
+ + +
+

7. Salts, bounds, and retries

+

Chain lengths vary a lot, so CHIP throws away a salt whose chain runs too + long and tries a new one.

+

At the defaults (b = 32, C = 5), a chain takes about 200,000 iterations on average, with a + standard deviation of about 45,000. Some salts would take far longer. So CHIP sets an + upper bound: if a chain hasn't reached C collisions by then, it draws a new + salt and starts again from scratch. The red bars in the demo above are exactly these rejected + salts. An optional lower bound (off by default) works the same way, so no + password ends up with a suspiciously cheap hash.

+

The demo at the top starts at b = 20, about 3,000 iterations, so it finishes instantly in a + browser. The library's default is b = 32, and the numbers in this section use that.

+

Where to put the bound is a tradeoff. Drag it:

+
+ +
+ +
+
+

+
+

The default, 250,000, is about the mean plus one standard deviation: it rejects about 15% of + salts, so a hash costs about 1.2 chains, or 230,000 iterations, including retries. A bound at + the mean would reject half of all salts.

+ +

The stored string

+

The result records the salt and every parameter, in the style of the + PHC string format, so + verify needs only the password and this string:

+
+ scheme$chip$ + hashh=sha256, + bitsb=32, + collisionsc=5, + boundsmin=0,max=250000 + salt (base64)$SOXeJ7iDuFTAnzpA54YPoA + final digest (base64)$jtbB33Sm… +
+

To verify, CHIP reruns one chain with the stored salt and compares the result. No retries are + needed, because the stored salt is the one that was accepted.

+

That creates a small asymmetry. The defender pays about 1.2 chains per hash + because of retries. An attacker who stole the string knows the winning salt and pays exactly + one chain per guess.

+
+ + +
+

8. Parameters and cost

+

b sets the scale (× 1.41 per bit), C fine-tunes it (diminishing returns), + and memory grows with the iteration count.

+

The benchmark ran 1,000 chains with no upper bound for every b from 1 to 32 and every C from 1 to 20. + Each cell is one mean; darker means more iterations, on a log scale. Hover over a cell:

+
+ +

+
+

The color mostly changes with b, because b sets the scale. C's effect is easier to see one + b at a time:

+
+
+ +

Each extra collision adds a little less than the last, + because iterations grow like √C. Two more bits double the whole curve.

+
Show the data
+
+

At the defaults:

+
+ + + + + + + + +
MeasureValue
Mean iterations per chainabout 200,000 (standard deviation about 45,000)
Salts rejected by the default boundabout 15%
Iterations per hash, including retriesabout 230,000
Raw digests, 32 bytes each (calculated)about 6.4 MB
Peak memory of this Python implementation (measured)about 28 MB
Time per hash, this Python implementationabout 0.2 s
+
+

The Python number is over four times the raw digests because every Python integer and + dictionary entry carries overhead. An attacker writing in C pays only for the raw bytes, so + 6.4 MB is the honest figure.

+
+
+ +

Memory per chain against its length, for b from 24 to + 34 and C from 5 to 30 (100 chains each). The Python peak is measured; raw digests (32 bytes + each) and the tradeoff (section 9) are calculated. All three grow roughly in proportion to + the iteration count: CHIP can only use more memory by also taking longer.

+
Show the data
+
+
+ + +
+

9. Attacks and limitations

+

CHIP makes each guess cost something, but less than it first appears, which + is why Argon2id is the right choice in practice.

+ +

The time–memory tradeoff

+

Look at how the stored digests are used. All 200,000 are written, but a full digest is only + ever read at a collision, and there are just C = 5 collisions per chain. Everything + else only needs the low bits, to notice collisions.

+

So an attacker can keep just the iteration number at which each low-bit value first + appeared, plus a full digest every 1,000 iterations as a checkpoint. + When a collision needs Dj, they jump back to the nearest checkpoint before j and re-hash + forward:

+
+ +

+
+
+

Each recovery costs half the checkpoint spacing on average, and there are only C of them. + benchmarks/time_memory_tradeoff.py runs this and checks that the output is + identical: at the defaults it costs about 1.3% more hashing and needs about 1.6 MB instead of + 6.4 MB. Try other spacings:

+
+ +
+ + +
+
+

+
+

The saving levels off at about four times, because each stored value shrinks from a 32-byte + digest to an 8-byte key and index. For short chains (b = 24) a spacing of 1,000 costs 19% more + hashing, but an attacker would simply choose a smaller spacing. Argon2 + was designed to make tradeoffs like this expensive: it reads earlier memory at every step, + not five times per run.

+ +

Parallelism

+

Separate guesses are independent, so an attacker can still run many at once. The waiting from + section 2 is also easy to avoid, with two counter-moves:

+
    +
  • Refill finished lanes. Every iteration of every guess does the same work + (hash, take the low bits, look them up), so a lane that finishes can start the next guess + right away while its neighbors carry on. Nothing waits.
  • +
  • Drop guesses past the upper bound. The right password always finishes + within the bound stored in the hash string, so a chain that runs longer can't be the right + one and can be abandoned.
  • +
+
+ +

+
+ + + +
+
+ +

Memory is small and tied to time

+

6.4 MB per guess is small next to current guidance: + OWASP + recommends at least 19 MiB for Argon2id, and RFC 9106 + recommends 64 MiB to 2 GiB. CHIP's memory also grows only with its iteration count, so it + can't ask for more memory without also taking longer. At b = 34 and C = 30, about a million + iterations, the digests take about 32 MB.

+ +

Iterations are modest

+

200,000 SHA-256 calls is less hashing than OWASP's recommendation for PBKDF2: 600,000 + iterations of HMAC-SHA256, each costing two SHA-256 compressions, about six times as much.

+ +

Side channels

+

CHIP's run time and memory access pattern depend on the password. Someone sharing the + machine could learn something from timing or cache behavior. Argon2d has the same weakness; + Argon2id was designed to reduce it.

+ +

How it compares

+
+ + + + + + + +
Cost knobsMemoryPassword-dependent memory accessStatus
PBKDF2iterationsnegligiblenostandardized; OWASP: 600,000 iterations
bcryptcost factorfixed, about 4 KByesstill acceptable for existing systems
scryptN (memory and time together), r, ptunableyesacceptable
Argon2idtime, memory, and parallelism, each separatetunablepartly, by designrecommended (RFC 9106, OWASP)
CHIPb and C; memory follows iterationstied to time; about 4× tradeoffyesresearch and teaching only
+
+
+ + +
+

10. Looking back from 2026

+

The ideas make good teaching material; a real design would decouple memory + from time and read memory far more often.

+
+

I first built CHIP in 2018 and revisited it in 2026. What I'd keep:

+
    +
  • Cost from the birthday bound. Two small integers give smooth, + predictable control over the average cost, with a one-line formula behind it.
  • +
  • Building on SHA-256 rather than a new primitive, so the security of each + step rests on a well-studied hash.
  • +
  • Self-describing hash strings, so parameters can change without breaking + old hashes.
  • +
+

What I'd change:

+
    +
  • Read memory more often. CHIP writes every digest but reads a full one only + C times, which is why checkpoints make the memory cheap to avoid. Reading an earlier digest + at every step, chosen by the data, would make each recovery expensive.
  • +
  • Set memory and time separately. Fill a memory region of chosen size first, + then spend a chosen number of steps reading from it. That is essentially what scrypt and + Argon2 do.
  • +
  • Don't count on variable run time. It costs the defender retries and + side-channel exposure, and an attacker refilling lanes barely notices it.
  • +
+

For a real system, I'd use Argon2id.

+
+
+ + +
+

Methodology

+
    +
  • Every CHIP measurement on this page comes from a committed script in + benchmarks/: + iterations.py (1,000 chains for each b from 1 to 32 and C from 1 to 20), + distribution.py (1,000 chains at the defaults, used by the lockstep and bound + widgets), memory.py (100 chains per row), and + time_memory_tradeoff.py (checks that the low-memory method gives identical + hashes).
  • +
  • The step-through widgets hash with your browser's SHA-256 (WebCrypto), so they respond + instantly. The chain widget's example chain is also pinned in the Python tests, so the two must agree. The demo at the top runs the actual Python library through + Pyodide.
  • +
  • The tradeoff widget uses a simple model (half the spacing per recovery, 8 bytes per stored + value); its caption compares it with the measured result.
  • +
  • Not measured here: real GPU attack throughput, a C implementation, or side channels.
  • +
+
+ + +
+

Terms and references

+
+
Hash function, password hash
+
A hash function maps any input to a fixed-size output that is easy to compute and + infeasible to reverse (SHA-256: 32 bytes). A password hash is a hash built to be slow and + costly on purpose.
+
Digest
+
The output of one hash function call. CHIP's chain produces D1, D2, …
+
Salt
+
A random value (16 bytes in CHIP) stored with the hash and mixed into it, so equal + passwords get different hashes and precomputed tables don't help.
+
Collision (in CHIP)
+
An iteration whose low b bits equal those of an earlier remembered digest. The full + digests differ; only their low bits match.
+
Birthday bound
+
Among N equally likely values, the first repeat is expected after about √(πN/2) draws. + Birthday problem.
+
XOR
+
Bitwise exclusive or: each output bit is 1 when exactly one input bit is 1. XOR with a + random value scrambles every bit it touches.
+
Lockstep, warp
+
A GPU runs threads in groups (warps of 32 on NVIDIA hardware) that execute the same + instruction together. A thread with nothing left to do waits for the rest of its warp.
+
Checkpoint, time–memory tradeoff
+
Keeping some intermediate values and recomputing the rest when needed, trading extra + computation for less memory.
+
PBKDF2
+
Iterates HMAC many times; cost comes from iterations alone. + RFC 8018.
+
bcrypt
+
Based on the Blowfish cipher's expensive key setup, with a small fixed memory (about 4 KB). + Provos and Mazières, 1999.
+
scrypt
+
Fills a large memory region, then reads it back at data-dependent positions. + RFC 7914.
+
Argon2 (Argon2d, Argon2i, Argon2id)
+
Winner of the Password Hashing Competition in 2015, with separate time, memory, and + parallelism settings. Argon2id mixes data-independent and data-dependent memory access and + is the recommended variant. RFC 9106.
+
PHC string format
+
A convention for self-describing hash strings: $scheme$params$salt$hash. + Specification.
+
+
+
+