ITADN
systemslibrarian/crypto-lab-musig-gate
systemslibrarian/crypto-lab-musig-gate · 文件
文件最后提交记录最后更新时间
README.md

MuSig Gate

MuSig2 · BIP-327 — n signers aggregate their public keys into one key and their nonces into one nonce, producing a single Schnorr signature indistinguishable from a lone signer's.

What It Is

An interactive, browser-only lab for MuSig2 as standardized in BIP-327, over secp256k1, producing signatures verifiable by a plain BIP-340 Schnorr verifier.

The problem it solves: an n-of-n multisig wallet normally has to publish n public keys and collect n signatures. That is expensive to store and verify, and it leaks the group's structure to anyone reading the chain. MuSig2 replaces all of it with one key and one signature — while remaining an interactive protocol in which every signer's secret key stays with that signer.

The exact primitives, all hand-rolled in src/musig/ so every intermediate value is inspectable:

  • Key aggregationQ = Σ a_i·P_i, where a_i = H("KeyAgg coefficient", L ‖ P_i) and L = H("KeyAgg list", P_1 ‖ … ‖ P_u). The coefficients are what defeat the rogue-key attack.
  • Two-round nonces — each signer commits two nonces; halves aggregate independently and combine as R = R_1 + b·R_2 with b = H("MuSig/noncecoef", aggnonce ‖ Q ‖ m). The second nonce is what defeats the Wagner/ROS attacks on single-nonce two-round schemes.
  • Partial signaturess_i = k_i1 + b·k_i2 + e·a_i·d_i (mod n), each independently verifiable so a bad contribution is attributable to a signer.
  • AggregationΣ s_i, yielding R.x ‖ s: an ordinary 64-byte BIP-340 signature under the 32-byte aggregate key.

Group arithmetic (point add/multiply, field square root, scalar inversion) comes from @noble/curves — audited, and deliberately not the teaching subject. BIP-327 itself is implemented here.

Two things are deliberately run at reduced scale, each labelled as such in the page, because the honest alternative was to describe them in prose:

  • A drawable group. y² = x³ + 7 over F_127 — the secp256k1 equation, same group law, 127 elements instead of 2^256 — so key aggregation can be plotted as real points and real additions. This is not the smooth textbook curve, which is the equation over the reals and a different object from the finite group cryptography happens in.
  • A truncated challenge, for Wagner's k-list forgery only. That attack needs a birthday search, so the challenge hash is cut to 21–30 bits. The algorithm is unmodified and the forgery genuinely verifies; at the real 256-bit width the same k-tree needs on the order of 2^85 operations.

The ROS forgery reduces nothing at all — full 256-bit challenge, real curve, no search — so if you only look at one attack here, look at that one.

Every finished signature is checked twice: once by a hand-rolled BIP-340 verifier that reports its stage pipeline, and once by @noble/curves' own schnorr.verify. The two must agree, and a disagreement is surfaced as a failure rather than swallowed.

Security model. Honest-but-curious is not the threat model here: two exhibits hand the learner real adversarial capabilities — including two working signature forgeries — and let them use them. Secret keys and nonces are generated per session with WebCrypto, live only in tab memory, and are never persisted or transmitted. There is no backend.

Not production crypto — a teaching demo. The arithmetic is real and matches the specification's own test vectors, but it uses ordinary JavaScript BigInt and is not constant-time. It does nothing to solve the hardest operational problem in MuSig2, which is guaranteeing a secret nonce is used at most once across process restarts. For real signing, use an audited implementation such as libsecp256k1.

How it teaches

The five exhibits are not peers — they are one argument, and the interface now says so.

A guided tour runs ten stops across the tabs, in the order the ideas depend on each other: the promise → predict → run it → break naive key aggregation → repair it → see the repair drawn → break naive nonce aggregation → repair it → back to the promise → prove you can transfer it. Each stop shows its position, says what to do, and its Continue button moves to the next relevant state even when that means changing tabs and putting a panel into a particular state first. The tabs still work exactly as before for free exploration and deep links; the tour is an overlay, not a replacement.

Three questions are asked before the experiment that answers them, not after — which is the difference between testing whether you read something and testing whether you understood it. A prediction is recorded and deliberately not graded at that moment; the experiment runs, and a debrief elsewhere on the page reports whether you were right and why.

The default path ends at the one-nonce/two-nonce comparison, which is all that is needed to understand why MuSig2 commits two nonces. The Wagner and ROS forgeries are research-grade evidence and sit behind an Advanced disclosure, led by a table comparing the two routes — present for anyone who wants them, not an obstacle for everyone else.

The sixth stop exists because the tour previously showed coefficients defeating the rogue-key attack — the attacker missing round after round — without ever showing what a coefficient does to a key. That is the difference between "it works" and "I know why", and the drawable F_127 group is the one place on the page where the answer is a picture rather than more hex. It was also the one teaching exhibit no stop reached, which showed up as a hole in the lesson map below.

Any stop can be linked to directly: #step-6 opens the lesson on stop 6, the address bar follows along as you go — via replaceState, so ten Continues do not become ten back-button entries — and the bar carries a Link to this step button, because being able to send someone "read step 6" is most of what makes a guided lesson usable by a third party. The alternative instruction, "press Continue five times", is the kind that quietly does not get followed.

While the tour runs, the tab strip becomes the lesson map: exhibits whose stops are behind you are marked done — in a word as well as a colour, so the state survives greyscale and reaches a screen reader — and the marks clear when you leave, because outside the lesson there is no order to be ahead or behind of. The BIP-327 Vectors tab sits behind a divider rather than in the run of chapters: no tour stop lands there, because it is implementation evidence and not the fifth idea.

The glossary stays collapsed, but each of the six session stages now shows the one term it is the first to require beside itself — Coefficient at the stage that introduces a_i, Nonce at the stage that commits two of them — so a newcomer never has to scroll back and search a ten-entry list to finish the sentence they are reading. Both come from the same array, so a definition cannot drift from the one shown inline.

Every intermediate value is still on the page, but hex is abbreviated by default and one control beside the tabs switches the whole page to full bytes — flipped once rather than expanded value by value, and remembered for the rest of the visit. Hovering any value shows it in full without touching the switch, and the two values you would plausibly paste into another tool — the aggregate key and the finished signature — carry a copy button. Nothing is ever actually hidden; the page just does not open with forty lines of hex in front of the idea it is teaching.

Each panel closes by saying what it established and what question that creates, and the lab ends with an exit check: two scenarios not seen anywhere else on the page, plus a threat-to-defence matching exercise whose four rows include "one of the n signers disappears → nothing in MuSig2 defends against this". That row matters as much as the wins — the most dangerous misconception this lab could leave behind is that MuSig2 is generic threshold multisig.

Exhibits

  1. Signing Session — one real MuSig2 session, stepped through six stages. A collapse diagram shows n public keys becoming 1, n nonce pairs becoming 1 nonce, and n partial signatures becoming 1 signature, with every coefficient, challenge and scalar shown at the stage that computes it. Choose 2–5 signers, type the message, toggle BIP-327 KeySort and watch the aggregate key move. Each signer's partial is checked in the group as well as the scalar field — s_i·G against (R_i1 + b·R_i2)^± + e·a_i·g′·P_i, compared byte-for-byte — and the secret scalars can be revealed so the equation is checkable by hand. The final stage hands the 64 bytes to a plain BIP-340 verifier. Break-it controls corrupt a single bit of one partial signature (the aggregator names the culprit) and attempt to sign with one signer absent (MuSig2 is n-of-n). A collapsed glossary introduces every term the page uses.

    The tab closes with "One of these was signed by a group. Which one?" — the headline claim put as a question rather than a statement. Two signatures over the same message sit side by side: one is this session's aggregate, the other comes from @noble/curves' ordinary single-signer schnorr.sign, and which slot is which is a WebCrypto coin flip. Guess, then reveal, then compare the two on every observable property — all of which read identical. It is equally clear about the limit: this hides the group from whoever reads the finished signature, not from a participant or a network observer.

  2. Key Aggregation — the coefficients in the foreground. L, the second-key shortcut, every a_i, and an independently recomputed Σ a_i·P_i compared byte-for-byte against Q. Reverse the key order, apply KeySort, or make every key identical to exercise the all-keys-equal sentinel, and see the naive Σ P_i aggregate side by side with the BIP-327 one.

    The tab closes by drawing it: all 126 affine points of y² = x³ + 7 over F_127, with each input key marked, an arrow to where its coefficient sent it, and a path stepping through the running totals to Q — alongside the naive Σ P_i path landing somewhere else entirely. Real points, real additions, and a text alternative carrying the same facts.

  3. Rogue Key Attack — the break that killed naive multisig, run for real. Under naive aggregation the attacker publishes P_rogue = t·G − ΣP_honest, signs alone, and the genuine BIP-340 verifier accepts — shown as an alarm, not a success. The identical attack against BIP-327 runs the attacker's fixed-point search round by round and misses every time. A third control lets you supply your own rogue key and target secret and submit them to either rule.

  4. Why Two Nonces — pick a target aggregate nonce. Against one nonce per signer, the attacker hits it exactly, first try, with one subtraction — and therefore chooses the challenge. Against BIP-327's two nonces, the same move misses every round, with the real b derived from the bytes the attacker just published.

    Then it goes further and actually forges signatures — twice, by two different routes:

    • Wagner's k-list attack. Four concurrent sessions, a generalised-birthday search over four lists, and out comes a valid signature on a message the honest signer never saw. Only the challenge width is reduced (~420 ms at 27 bits).
    • The ROS attack, at the full 256-bit width with nothing reduced. 256 concurrent sessions, two candidate challenges computed offline per session, and the bit pattern falls out of a single modular subtraction — no search anywhere. 256 sessions in, 257 signatures out, in about half a second. This is what the attack actually costs in reality.

    Both use real secp256k1 with hash-derived key-aggregation coefficients, so the key setup is provably not the flaw, and a signing oracle that throws on nonce reuse, so a nonce-reuse key leak cannot be masquerading as the attack. Running each against two nonces shows exactly why it dies: Wagner's k-tree needs a fixed target, ROS needs a constant right-hand side, and with two nonces both are functions of the very values being solved for.

  5. BIP-327 Vectors — all 56 official BIP-327 known-answer cases from all seven vector files, executed in the browser on load, each expandable to expected-vs-actual. Includes the specification's malformed-input cases, which must be rejected for the right reason.

    Fifty-six green rows are evidence, not a lesson, so three cases are pulled to the front — the ones that change how you would write an implementation rather than merely confirming arithmetic: a public key that is not a curve point, where rejecting is not enough because the report has to name the signer; a partial signature at or above the group order, where the obvious shortcut of reducing mod n quietly costs you that identifiable abort; and a nonce half that cancels to the point at infinity, where the correct behaviour is not to reject but to serialize 33 zero bytes and carry on — and any signer moving last can force exactly that. They are looked up by vector group and index, so a reworded vector file surfaces as a visible missing case rather than silently dropping one.

When to Use It

Use MuSig2 when:

  • Every listed signer must sign, and you want the on-chain result to look like a single signer — smaller, cheaper, and private about the group's structure.
  • You are building Bitcoin Taproot spends (BIP-340/341) and want a key-path spend for a group.
  • Signers can run two communication rounds and each keep custody of their own key.

Do NOT use MuSig2 when:

  • You need a t-of-n quorum. MuSig2 is n-of-n; every signer is mandatory. Losing one key means losing the funds. Use FROST — see crypto-lab-frost-threshold.
  • You need a public audit trail of who signed. The whole point is that the signer set is unrecoverable from the signature. Record it elsewhere, out of band.
  • You cannot guarantee single use of a secret nonce. A signer that replays a nonce across two different messages leaks its private key by elementary algebra. If a signer's storage can roll back, use the deterministic-signing variant or don't use a two-round scheme.
  • You cannot bound how many signing sessions run concurrently. Concurrency is what both forgeries in exhibit 4 feed on. MuSig2's two nonces are what make concurrent sessions safe; a single-nonce scheme with unbounded concurrency is broken outright.
  • You need non-interactive aggregation across parties who never talk. That is a different primitive — see crypto-lab-pairing-gate for BLS.

Live Demo

https://systemslibrarian.github.io/crypto-lab-musig-gate/

In the browser you can: step a real 2-to-5-signer session from fresh keys to one 64-byte signature; watch the key, nonce and signature collapses happen one stage at a time; verify the aggregate signature with a plain BIP-340 verifier and an independent library verifier; try to pick the group's signature out of a blind pair against a lone signer's and discover you cannot; corrupt one partial signature and see the culprit named; attempt to sign with a signer missing; mount a rogue-key attack that a real verifier accepts, then watch the same attack fail against BIP-327; steer a single-nonce aggregate onto a chosen target and watch the two-nonce version refuse; and run all 56 BIP-327 vectors live.

Deep links: #session, #keyagg, #rogue, #nonce, #vectors.

What Can Go Wrong

  • Rogue keys. Aggregating by plain summation lets the last signer to publish own the group key outright. Demonstrated end to end in exhibit 3; prevented by the key-aggregation coefficients.
  • A single nonce per signer. Makes the aggregate nonce, and therefore the challenge, a value the last signer chooses — and that is enough to forge. Exhibit 4 runs both known routes for real: Wagner's generalised-birthday algorithm (Drijvers et al., IEEE S&P 2019) over four sessions at reduced challenge width, and the polynomial-time ROS attack (Benhamouda et al., 2020) over 256 sessions at the real 256-bit width with no reduction whatsoever. Each is then re-run against two nonces and fails.
  • Nonce reuse. Signing two different messages with the same secret nonce reveals the private key. sign() here zeroes the secret nonce as it consumes it, so a second call fails loudly instead of leaking. The key-recovery algebra itself is demonstrated in crypto-lab-schnorr-forge.
  • Key-list order. KeyAgg is order-dependent — the same keys in a different order give a different aggregate key, and a group that disagrees on order derives different keys and cannot sign. BIP-327 defines KeySort for exactly this; the toggle in exhibit 1 makes the difference visible.
  • Malformed contributions. Keys off the curve, keys with x ≥ p, nonces with a bad prefix byte, partial signatures ≥ n, aggregate nonces that are not points. All fail closed, and all name the offending party — the spec insists a bad contribution be attributable, not merely fatal.
  • Legitimate degenerate cases. An aggregate nonce half can cancel to the point at infinity (serialized as 33 zero bytes), and R_1 + b·R_2 can itself be infinity, which the spec handles with a defined R = G fallback. Both are implemented and covered by vectors rather than crashed on.
  • A missing signer. n-of-n means the absent signer's e·a_i·d_i term is simply not in Σ s_i, so verification fails. This is a property, not a bug — but it is a real operational risk if you wanted a quorum.

Real-World Usage

  • Bitcoin Taproot (BIP-340/341/342). MuSig2 is the standard way for a group to produce a key-path Taproot spend that is indistinguishable from a single-signer spend. BIP-327 exists to make independent wallet implementations interoperate.
  • Wallet and custody software. Implemented in libsecp256k1's musig module, and in wallets and coordination libraries built on it.
  • Lightning and channel constructions, where reducing multi-party signatures to one signature reduces on-chain footprint.
  • Cross-organisation signing where the participants specifically do not want the number of approvers visible on a public ledger.

BIP-327 supersedes the original MuSig2 paper's parameterisation for Bitcoin use and pins the exact hash tags, encodings and tweaking rules — which is why this lab tests against its vectors rather than against a paper.

How to Run Locally

npm ci
npm run dev        # http://localhost:5173/crypto-lab-musig-gate/
npm test           # 261 unit tests, including the 56 BIP-327 spec KATs
npm run bench      # the protocol with no DOM, for the performance table
npm run perf       # the browser figures, against the production build
npm run build      # tsc --noEmit && vite build
npm run preview    # serve the production build (the a11y gate serves it on port 4276)
npm run test:a11y  # what CI gates on: axe (both themes) + flows on Chromium desktop & mobile
npm run test:axe   # just the axe accessibility gate
npm run test:e2e   # just the Chromium flows
npm run test:e2e:all  # every engine: Chromium, mobile viewport, Firefox, WebKit

Requires Node 22+. npm run test:a11y needs the Playwright Chromium browser once (npx playwright install chromium); npm run test:e2e:all additionally needs firefox webkit.

  • crypto-lab-frost-threshold — MuSig2 is n-of-n; the quorum case IS frost-threshold. If you need t-of-n, you need FROST, not this.
  • crypto-lab-schnorr-forge — plain BIP-340 Schnorr keygen, signing and the nonce-reuse key-recovery attack. This lab builds on it and does not re-teach it.
  • crypto-lab-dkg-gate — distributed key generation. Here each signer generates its own key independently; no DKG is involved.
  • crypto-lab-pairing-gate — BLS signature aggregation. A different primitive over pairing-friendly curves, with different trade-offs.
  • crypto-lab-bitcoin-script — Taproot transaction assembly and spending paths. Key tweaking is implemented here for spec-vector coverage but is not an exhibit.

Build & Verify

261 unit tests (Vitest), including 56 official BIP-327 known-answer cases — 30 that must be accepted, 26 that must be rejected — plus 274 end-to-end tests (Playwright) across four browser engines. All pass (six clipboard checks run on Chromium only, which is the engine Playwright grants clipboard permission for here, and are skipped elsewhere rather than silently asserted).

Spec vectors, verbatim from bitcoin/bips · bip-0327/vectors:

FileCasesCovers
src/musig/vectors/key_agg_vectors.json9key aggregation, order dependence, all-identical keys, malformed keys, bad tweaks
src/musig/vectors/nonce_gen_vectors.json4the two-nonce derivation, with/without secret-key hardening, absent vs. empty message
src/musig/vectors/nonce_agg_vectors.json5independent half summation, infinity encoding, malformed nonces
src/musig/vectors/sign_verify_vectors.json18partial signatures byte-for-byte, wrong signer, negated signature, out-of-range scalar
src/musig/vectors/tweak_vectors.json6plain and x-only tweaks applied to the aggregate key
src/musig/vectors/det_sign_vectors.json9deterministic signing for the last signer
src/musig/vectors/sig_agg_vectors.json5partial signatures → one 64-byte signature that a plain BIP-340 verifier accepts

The same runners (src/musig/vectors.ts) drive both the Vitest suite and exhibit 5's live table, so a green table in the browser and a green CI run are the same claim.

End-to-end (Playwright): 68 functional flows across four engines — Chromium desktop, a Pixel 5 viewport, Firefox and WebKit — assert what the unit suite structurally cannot — that each exhibit renders its result rather than throwing, that alarm-versus-pass semantics actually reach the DOM (a successful forgery must render as .verdict-alarm, never .verdict-pass), that stepping reveals more of the same session rather than resampling it, that the blind pair really is indistinguishable, that a corrupted partial marks exactly one signer, that a malformed rogue key is refused before signing, that the theme toggle persists across a reload, that there is exactly one <h1> and one banner landmark, that arrow keys move between tabs, that the scripture line appears verbatim exactly once, and that nothing overflows horizontally at 320px. The Wagner forgery has its own flows: it must render as .verdict-alarm, Σ e_j must match e* exactly, and the two-nonce attempt must produce a distinct target for every probe. The teaching layer is tested too: the tour must walk all ten stops, reach every teaching exhibit and never select the evidence tab, survive a reload mid-lesson, and reach a stop whose anchor only exists once a panel is in a particular state; a prediction must be recorded without being graded, with the verdict arriving only in the debrief; the advanced forgeries must be collapsed by default; and the exit check must reject MuSig2-as-shown for a 2-of-3 requirement. The byte-display switch has its own flows: every hex value must start abbreviated with its full bytes still in the DOM, one click must expand all of them, the choice must survive a reload, and a panel rendered after the switch must come up full — which is the part a subscribe-on-render implementation gets wrong. The three featured vector cases are asserted by their actual expected values, so a reworded vector file fails the suite rather than quietly dropping a case. The scaffolding is tested for drift: each stage's inline term must be one the glossary actually defines. Deep links are tested for the ordering bug they invite — the tour rewrites the hash whenever it renders, and it renders while mounting, so an incoming #step-9 must survive a lesson resumed from storage at a different stop — plus clamping, nonsense hashes, and that replaceState keeps the back button clean. Failure is tested as a first-class case: with crypto.getRandomValues made to throw — a real condition on an insecure origin, where every panel generates keys the moment it renders — the exhibit must explain itself rather than going blank, and the failure must stay contained to that one tab. And the lesson map must mark a finished exhibit, say so in the accessible name rather than in colour alone, and clear itself on exit. Every flow also asserts zero uncaught page errors and zero console errors.

Two workflows, for one reason: deploy.yml must stay byte-for-byte identical across the fleet and it installs only Chromium, so Firefox and WebKit cannot run inside it. .github/workflows/e2e.yml installs all three engines and runs the full matrix on every push and pull request, while deploy.yml's gate step runs axe plus the Chromium flows. The Chromium projects therefore run in both places, deliberately.

The unit suite also gates cost: cost.test.ts counts every scalar multiplication a session performs and asserts the exact total, plus the shape of its growth — equal successive differences per signer, which is what rules out an O(u²) loop reappearing. It would have caught both of the inefficiencies described under Performance on the day they landed.

Beyond the KATs, the unit suite covers: full 2-, 3-, 4- and 5-signer round trips verified by two independent verifiers; the algebraic identity s_i = k_i1 + b·k_i2 + e·a_i·d_i checked per signer; Σ s_i equal to the signature's s; secnonce consumption (a second sign() throws); refusal to sign with a mismatched key, an out-of-range key, or for a key list the signer is not in; rejection of a negated partial, a wrong-signer partial, and an out-of-range partial; single-bit tamper detection with correct attribution; the n-of-n boundary; the infinity-fallback and empty-message edge cases; the naive rogue-key attack succeeding; the BIP-327 rogue-key attack failing; single-nonce target-hitting succeeding; and two-nonce target-hitting failing. The two forgery modules are tested on the unusual requirement that an attack must succeed: the forged signature verifies, the honest signer provably never saw the forged message, no nonce was reused (its oracle throws on reuse, so a nonce-reuse key leak cannot be masquerading as the k-sum), and Σ e_j = e* over the integers rather than merely modulo 2^bits — plus soundness of the k-tree itself, which must return null rather than an inexact answer. ROS is held to the same bar at full width, and additionally to costing exactly one scalar multiplication per session plus one — evidence there is no hidden search term. The drawable group is tested on the group axioms (closure, associativity, inverses, identity), its stated order of 127, and that its generator enumerates all 127 elements — if any of those failed, the picture would be a lie. The lone-signer comparison is tested both ways: indistinguishable in shape for 2–5 signers, and not interchangeable in substance (neither signature verifies under the other's key), with the coin flip shown to reach both slots.

Accessibility gate: @axe-core/playwright scans the production build for WCAG 2.1 A/AA violations in both themes, driving all five exhibits into their post-interaction states first — every step revealed, both attacks run in both modes, the malformed-input rejection path, the tamper and missing-signer failures, the byte-display switch flipped to full width so the wider layout is scanned too, the tour restarted and advanced far enough that the tab strip's completed-exhibit state is in the DOM, and every disclosure and learner check opened. Zero violations required.

Two things a scanner cannot catch are covered by their own tests. Pressing Continue changes the selected tab, scrolls elsewhere and sometimes puts a panel into a new state — visually obvious, silent to a screen reader — so each stop is announced through a live region that stays in the DOM permanently, since one inserted at the moment its text changes is not reliably read. And @media (prefers-reduced-motion) sets scroll-behavior: auto, but a behavior argument to scrollIntoView overrides the stylesheet: measured, all three engines animated straight through the preference until the JS asked matchMedia itself. That regression test asserts the scroll position is already final on the first animation frame, with a paired test proving the animated case still animates — a wall-clock budget passed against the unfixed code and was worthless.

Colour choices are checked rather than eyeballed: every text/background pair in src/style.css was contrast-computed against AA, verdict tints are explicit colours rather than color-mix() so their ratios are verifiable, muted text lowers lightness instead of using opacity, and state is always icon + word + colour so it survives greyscale and deuteranopia.

.github/workflows/deploy.yml runs unit tests → build (typecheck included) → the full Playwright suite (axe gate and functional flows), and only then deploys, so a broken build, a functional regression, or an accessibility regression never ships.

Performance

Everything runs client-side with JavaScript BigInt arithmetic, so scalar multiplication dominates everything else. Both halves of this table are reproducible — npm run perf for the browser figures and npm run bench for the no-DOM ones — because a published performance number is worth what its methodology is worth.

Measured on an Apple M5 (10 cores), Chromium, against the production build served by vite preview: median of 11 runs on a fresh page each time, two discarded as warm-up, timing the synchronous work between the click and the result. npm run perf refuses to report if the machine is under load, which is not pedantry — a first attempt at these numbers, taken while the test suites were running, spread a single row across 121–245 ms.

OperationTimeSpread over runs
Full 2-signer session + complete panel render~18 ms17.9–18.9 ms
Full 3-signer session + complete panel render~23 ms23.2–23.9 ms
Full 5-signer session + complete panel render~34 ms33.3–34.1 ms
All 56 BIP-327 vectors + table render~182 ms181–184 ms
Rogue-key attack: forge, then 6 fixed-point rounds~2.3 ms2.3–2.5 ms
The drawable group: 126 points + aggregation + plot~0.4 ms0.4–0.5 ms
Wagner forgery, 24-bit challenge~194 ms187–196 ms
Wagner forgery, 27-bit challenge~419 ms400–482 ms
Wagner forgery, 30-bit challenge~944 ms925–975 ms
ROS forgery, full 256-bit challenge, 256 sessions~187 ms185–189 ms

The four forgery rows are the ones you need trust for least: the page times itself and prints the figure, and the harness simply reads what a visitor would see. Their spread is wider because Wagner runs a birthday search — a stochastic algorithm genuinely takes a different length of time each run, and a row whose min and max sit close together would be the suspicious one.

The same protocol with no DOM at all (npm run bench, Vitest, mean of 10+): 34 ms for 2 signers, 47 ms for 3, 77 ms for 5. Higher than the browser column, which is measured on Chromium's V8 rather than Node's.

A session costs 17 scalar multiplications per signer plus 4 — 89 for five signers — counted exactly, not estimated: src/musig/cost.test.ts mocks the one primitive every module reaches the curve through and asserts the total. That test is the real performance guard. A wall-clock ceiling is the obvious choice and the wrong one, because CI runners vary by more than the regression you are trying to catch; an operation count is identical on every machine and strictly more sensitive.

Sub-quarter-second is well inside "feels instant" for a click, which is why exhibit 5 runs the entire vector suite on every page load rather than shipping a cached result.

Measuring found two real problems, which is the argument for measuring at all.

The first was dead work: every partial signature had both sides of its group equation computed twice — once when the record was built, then again after the tamper path could have replaced the scalar — and the second result overwrote the first every time.

The second was worse and older, and only showed up once the work was being counted rather than timed. A session's derived values (Q, b, R, e) were re-derived inside every per-signer call, and deriving them re-runs the whole key aggregation — one scalar multiplication per signer. So signing and verifying u partials cost O(u²): 4u² + 22u + 5, or 215 multiplications for five signers where 89 suffice. The session is now derived once and passed down; the public, spec-shaped partialSigVerify still derives its own context exactly as BIP-327 describes, for callers who have only the wire data.

Together: a 5-signer session went from 117 ms to 34 ms in the browser and 319 ms to 77 ms with no DOM, and the whole 56-case vector suite from 215 ms to 182 ms. Nothing about what is computed changed — only how many times.

None of this arithmetic is constant-time and no timing property should be inferred from these numbers — they describe interactive responsiveness, not side-channel resistance.


Part of the Crypto Lab suite.

"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31