diff --git a/doc/prefractal.md b/doc/prefractal.md new file mode 100644 index 00000000..2a722750 --- /dev/null +++ b/doc/prefractal.md @@ -0,0 +1,265 @@ +# Prefractal: a nested FROST+MuSig2 signer + +**WARNING: EXPERIMENTAL.** Neither the scheme nor this implementation has been +reviewed by anyone outside the project. Do not use it to protect anything of +value. The construction comes from [frosty-musig][frosty], which is unaudited +research code, and it is built on this repository's `frost` module, which is +itself marked experimental and unstable. + +[frosty]: https://github.com/jesseposner/frosty-musig + +## What it does + +It lets a FROST `t`-of-`n` group occupy **one participant slot** of an ordinary +MuSig2 (BIP 327) session. The group publishes one ordinary MuSig2 public nonce +and one ordinary MuSig2 partial signature. Cosigners need no support for any of +this and cannot tell a group is involved. + +The motivating shape is a 2-of-2 taproot output where one of the two +"participants" is really a threshold group. + +## The signing equation + +Each member `i` of the participating set computes + +``` +s_i = k1_i + b_frost * b_musig * k2_i + e * a * lambda_i * g * gacc * d_i +``` + +- `k1_i`, `k2_i` — the member's two nonce scalars, both negated iff the OUTER + final nonce has odd Y. +- `b_frost` — this module's nonce-binding coefficient (below). +- `b_musig`, `e`, `a`, `g`, `gacc` — all from the OUTER MuSig2 session: the + nonce coefficient, the BIP 340 challenge over the aggregate key, the + key-aggregation coefficient of the group's threshold public key, and the + aggregate key's parity bookkeeping. +- `lambda_i` — the member's Lagrange interpolating value over the participating + set. +- `d_i` — the member's secret share. + +The group's wire nonce is its FROST aggregate nonce with the second component +premultiplied by `b_frost`: + +``` +pubnonce = (R1, b_frost * R2) +``` + +The aggregator sums the members' shares. That is a plain sum, with no +interpolation, because `lambda_i` is already folded into each share. + +## Three deliberate deviations from BIP 445 + +These are the parts a reviewer should look at hardest. Each one is a +considered trade, and each one is enforced or pinned somewhere in the code. + +### 1. `b_frost` does not commit to the message + +BIP 445's nonce coefficient hashes the message. This module's does not: + +``` +b_frost = tagged_hash("Prefractal/noncecoef", + ser32(u) || sorted ser32 ids || aggnonce66 || + cbytes_ext(thresh_pk)) +``` + +**Why.** The protocols this module targets publish the group's wire nonce +*before the message exists*. A lightning channel's funding signer publishes a +verification nonce at commitment number `N` long before the transaction that +nonce will sign has been built. A coefficient that hashed the message could not +be computed in round one and rebuilt identically in round two. + +**Why it is not fatal.** The outer coefficient `b_musig` *does* commit to the +message, via `secp256k1_musig_nonce_process_internal`, and it multiplies +`b_frost` in every term where `b_frost` appears. The product binds the message. +This is the same trade the `iceberg` module makes, for the same reason, with +its own `Iceberg/noncecoef` tag. + +**What is different from BIP 445's preimage.** The message is dropped, and the +threshold public key is hashed in its full 33-byte extended encoding rather +than x-only, because the key is used as a full point everywhere downstream (see +deviation 2) and the binding should cover the point that is actually in play. + +**Pinned by.** `run_prefractal_midstate_test` checks the tagged-hash constant +against a freshly initialised one. Nothing else in the tree would notice a +changed `b_frost`; it would simply produce signatures that do not verify. + +### 2. There is no `g_frost` factor + +Stock FROST negates the secret share when the threshold public key has odd Y: + +```c +/* frost/session_impl.h:664 */ +session_i->g_times_gacc_parity = cache_i->gacc_parity ^ pk_odd; +/* frost/session_impl.h:797-800 */ +if (session_i->g_times_gacc_parity) { + secp256k1_scalar_negate(&d, &d); +} +``` + +It does this because standalone FROST produces a BIP 340 x-only signature, so +the effective secret is normalised to the even-Y representative of the +threshold key. + +**Here that must not happen.** The threshold public key is an *inner +participant* of the outer key aggregation. It enters `secp256k1_musig_pubkey_agg` +as a full 33-byte point, and MuSig2 does no per-participant parity +normalisation: the only key-side flip is at the aggregate level, off the OUTER +keyagg cache. So the group's members must reconstruct `d` with `d*G = thresh_pk` +exactly as dealt, whatever its Y parity. + +**The trap.** It is tempting to say "the FROST tweak cache is the identity, so +the frost key-side factor is 1". That is **false**. The factor is `g * gacc`. +An identity cache gives `gacc = 1`, but `g` is still `-1` for every threshold +key with odd Y — roughly half of all groups. An implementation that reused +`secp256k1_frost_get_session_values`'s key-side handling would produce a signer +that works for even-Y groups and fails for odd-Y ones. + +**Pinned by.** The test suite carries two *fixed* threshold secret keys, one of +each Y parity, and `run_prefractal_odd_y_group_key_test` asserts the parity of +its own fixture so it cannot quietly stop testing what it is named after. This +was verified by mutation: injecting the `pk_odd` negation makes the odd-Y test +fail while the even-Y one still passes. With a randomly seeded fixture that +would have been a coin flip per run. + +### 3. The FROST tweak cache must be the identity + +`tacc == 0` and `gacc_parity == 0`, checked by every entry point that takes a +cache. + +**Why.** The target protocols tweak only the *outer* aggregate key — the BIP +341 key-path tweak is applied to the MuSig2 keyagg cache and handled by the +stock outer session. A frost-level tweak would add an `e * g * tacc` term that +the aggregator would have to fold in, and this module's aggregator is a plain +sum. + +**Where it is checked.** In `secp256k1_prefractal_sign` and +`secp256k1_prefractal_partial_sig_verify`, not only in +`secp256k1_prefractal_partial_sig_agg`. Checking only at aggregation would be +too late and too weak: the signing path would never see the cache, so nothing +would tie the key a member signed under to the cache that was validated. `sign` +and `partial_sig_verify` additionally require `thresh_pk` to equal the cache's +own key, so the two arguments cannot disagree. + +A tweak-aware aggregation variant (folding `e * g_musig * tacc`, as +frosty-musig's `nested_frost_partial_sig_agg` does) is a possible later +extension. It is not implemented. + +## Rules the caller must follow + +### One secnonce, one signature + +The usual FROST rule, and this module cannot enforce it any better than FROST +can. `secp256k1_prefractal_sign` wipes the secnonce, so a second call with the +same one fails — including when the first call failed for some other reason, +which is why a member whose signing attempt was refused must generate a fresh +nonce rather than retry. + +Deployments that derive nonces deterministically from a session label (which is +how a protocol gets a nonce it can publish early and rebuild later) inherit a +sharper version of the rule: **one label signs one message, group-wide**. Two +different messages under one label leak the secret share, and nothing raises an +error. + +### The round-two signer set must equal the round-one set + +Not a subset — the same set. + +`lambda_i` and the aggregate nonce are both defined over the participating set. +If round one aggregates over `C` and only `S ⊂ C` signs, then the nonce terms +of `C \ S` are still in `R` while their key shares are absent from `sum(s_i)`, +and `sum_{i in S} lambda_i^C * d_i != d`. The result is an invalid signature +with no error raised at signing time. + +This is worth stating explicitly because the `iceberg` module in this same +repository *does* tolerate a subset: its `2t-1` / `t` split comes from VSS +interpolation over the contributions, and FROST has no equivalent. Callers +porting between the two must not transpose the rule. + +`run_prefractal_partial_sig_verify_test` covers the detectable half of this: a +share made for one signer set does not verify under another. + +### Nonces at infinity + +A FROST aggregate nonce component may legitimately be the point at infinity +(BIP 445 NonceAgg), but a MuSig2 public nonce has no encoding for one. Both +columns can reach infinity independently — the first is passed through +unscaled, the second only after the `b_frost` multiplication — and +`secp256k1_prefractal_nonce_agg` refuses both. Such a session has to be +restarted with fresh nonces. + +## API + +All four functions are sessionless: every call takes its session parameters +explicitly, so there are no opaque session objects, no new magics and no +`*_SIZE` constants to keep synchronised across bindings. + +| Function | Role | +| --- | --- | +| `secp256k1_prefractal_nonce_agg` | round one: group wire nonce + unscaled aggnonce | +| `secp256k1_prefractal_sign` | round two: one member's partial signature | +| `secp256k1_prefractal_partial_sig_verify` | identifiable abort | +| `secp256k1_prefractal_partial_sig_agg` | sum shares into a MuSig2 partial signature | + +`aggnonce_out` from `nonce_agg` is an internal value, not a wire value: it is +the *unscaled* FROST aggregate, and it must be handed back to `sign` and +`partial_sig_verify` unchanged. The wire value is `pubnonce_out`, an ordinary +66-byte MuSig2 public nonce. + +Members generate their nonces with the stock `secp256k1_frost_nonce_gen`. This +module adds no nonce generation of its own. + +## Relationship to the other modules + +- **`frost`** stays pure, vector-pinned BIP 445. This module deliberately does + not live inside it: the deviations above are not BIP 445, and keeping them + behind their own opt-in flag gives auditors a clean scope boundary. It also + keeps `frost`'s dependency graph honest — `frost` depends only on + `schnorrsig`, and every pure-FROST consumer would otherwise have to build + `musig` too. +- **`musig`** is used unmodified, through its internals. Cosigners run stock + MuSig2 throughout. +- **`iceberg`** solves the same outer problem with a different inner scheme. The + two differ in ways that do not transfer: iceberg's quorum is `2t-1` in round + one and `t` in round two and it tolerates a round-two subset; prefractal uses + `t` in both rounds and requires set equality. Iceberg cannot express 2-of-2 or + 3-of-4; prefractal can. + +## Build + +The module depends on both `frost` and `musig` and forces them on. + +``` +cmake -B build -DSECP256K1_ENABLE_MODULE_PREFRACTAL=ON -DSECP256K1_BUILD_TESTS=ON +cmake --build build && ./build/bin/tests --target=prefractal +``` + +``` +./autogen.sh +./configure --enable-experimental --enable-module-prefractal +make && make check +``` + +Three files order their module blocks differently, and the constraints point in +opposite directions. Anyone adding a module by copying this one should read +this rather than copying `iceberg`'s positions: + +- `src/secp256k1.c` — the include goes **after** `frost` and `musig`, because + the module calls their `static` internals and the whole library is one + translation unit. +- `src/CMakeLists.txt` — the block goes **before** both, because its `set()` + calls are only observed by blocks that run later. +- `configure.ac` — the block likewise goes **before** the `musig` block, *not* + at `iceberg`'s position further down. `configure.ac` orders `musig` and + `frost` ahead of `iceberg`, and iceberg's late `enable_module_musig=yes` is + harmless only because `musig` defaults to yes. `frost` defaults to **no**, so + a late force-enable would leave `-DENABLE_MODULE_FROST=1` unemitted while + `AM_CONDITIONAL` still observed the mutation. + +`frost` is also the first default-OFF module anything depends on, which breaks +the dependency-guard idiom used everywhere else in both build systems. The +existing `DEFINED X AND NOT X` (CMake) and `x$X = xno` (autotools) tests read as +"the user disabled it explicitly" only for default-ON modules, and are true by +default for a default-OFF one. Neither build system can distinguish an explicit +disable from the default once both are in the cache, so enabling `prefractal` +simply implies `frost`; the guard is kept for `musig`, where it still means what +it says. diff --git a/src/ctime_tests.c b/src/ctime_tests.c index 9635ee77..8ce2d8eb 100644 --- a/src/ctime_tests.c +++ b/src/ctime_tests.c @@ -61,6 +61,10 @@ #include "../include/secp256k1_chilldkg.h" #endif +#ifdef ENABLE_MODULE_PREFRACTAL +#include "../include/secp256k1_prefractal.h" +#endif + #ifdef ENABLE_MODULE_ICEBERG #include "../include/secp256k1_iceberg.h" #include "../include/secp256k1_iceberg_dealer.h" @@ -593,6 +597,100 @@ static void run_tests(secp256k1_context *ctx, unsigned char *key) { } #endif +#ifdef ENABLE_MODULE_PREFRACTAL + { + /* A 2-of-2 nested group with one stock musig cosigner, taken as far as + * one signature share. Secret here is the threshold key, the secret + * shares derived from it, and the session randomness the nonces come + * from. Not secret: the identifiers, the public shares, the threshold + * public key, both aggregate nonces, the outer keyagg cache, b_frost, + * and the resulting partial signature. */ + unsigned char thresh_seckey[32]; + unsigned char secshares[2 * 32]; + unsigned char session_secrand[2][32]; + unsigned char cosigner_seckey[32]; + secp256k1_pubkey thresh_pk, pubshares[2], cosigner_pk; + const secp256k1_pubkey *outer_pubkeys[2]; + uint32_t pf_ids[2] = { 0, 1 }; + secp256k1_frost_tweak_cache pf_cache; + secp256k1_frost_secnonce pf_secnonce[2]; + secp256k1_frost_pubnonce pf_pubnonce[2]; + const secp256k1_frost_pubnonce *pf_pubnonce_ptrs[2]; + secp256k1_frost_aggnonce pf_aggnonce; + secp256k1_frost_partial_sig pf_partial_sig; + secp256k1_xonly_pubkey outer_xonly; + secp256k1_musig_keyagg_cache outer_cache; + secp256k1_musig_pubnonce group_pubnonce, cosigner_pubnonce; + const secp256k1_musig_pubnonce *just_cosigner[1]; + secp256k1_musig_secnonce cosigner_secnonce; + secp256k1_musig_aggnonce cosigner_aggnonce; + unsigned char cosigner_secrand[32]; + + pf_pubnonce_ptrs[0] = &pf_pubnonce[0]; + pf_pubnonce_ptrs[1] = &pf_pubnonce[1]; + + SECP256K1_CHECKMEM_DEFINE(key, 32); + memcpy(thresh_seckey, key, sizeof(thresh_seckey)); + thresh_seckey[0] = thresh_seckey[0] + 4; + memcpy(cosigner_seckey, key, sizeof(cosigner_seckey)); + cosigner_seckey[0] = cosigner_seckey[0] + 5; + memcpy(session_secrand[0], key, 32); + session_secrand[0][0] = session_secrand[0][0] + 6; + memcpy(session_secrand[1], key, 32); + session_secrand[1][0] = session_secrand[1][0] + 7; + memcpy(cosigner_secrand, key, 32); + cosigner_secrand[0] = cosigner_secrand[0] + 8; + SECP256K1_CHECKMEM_DEFINE(msg, sizeof(msg)); + + SECP256K1_CHECKMEM_UNDEFINE(thresh_seckey, sizeof(thresh_seckey)); + ret = secp256k1_frost_trusted_dealer_keygen(ctx, secshares, &thresh_pk, pubshares, 2, 2, thresh_seckey); + SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret)); + CHECK(ret == 1); + SECP256K1_CHECKMEM_DEFINE(&thresh_pk, sizeof(thresh_pk)); + SECP256K1_CHECKMEM_DEFINE(pubshares, sizeof(pubshares)); + CHECK(secp256k1_frost_tweak_cache_init(ctx, &pf_cache, &thresh_pk) == 1); + + /* The outer aggregation and the cosigner's round one are entirely + * public as far as this module is concerned. */ + SECP256K1_CHECKMEM_UNDEFINE(cosigner_seckey, sizeof(cosigner_seckey)); + ret = secp256k1_ec_pubkey_create(ctx, &cosigner_pk, cosigner_seckey); + SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret)); + CHECK(ret == 1); + SECP256K1_CHECKMEM_DEFINE(&cosigner_pk, sizeof(cosigner_pk)); + outer_pubkeys[0] = &thresh_pk; + outer_pubkeys[1] = &cosigner_pk; + CHECK(secp256k1_musig_pubkey_agg(ctx, &outer_xonly, &outer_cache, outer_pubkeys, 2) == 1); + + SECP256K1_CHECKMEM_UNDEFINE(cosigner_secrand, sizeof(cosigner_secrand)); + ret = secp256k1_musig_nonce_gen(ctx, &cosigner_secnonce, &cosigner_pubnonce, cosigner_secrand, cosigner_seckey, &cosigner_pk, msg, &outer_cache, NULL); + SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret)); + CHECK(ret == 1); + SECP256K1_CHECKMEM_DEFINE(&cosigner_pubnonce, sizeof(cosigner_pubnonce)); + just_cosigner[0] = &cosigner_pubnonce; + CHECK(secp256k1_musig_nonce_agg(ctx, &cosigner_aggnonce, just_cosigner, 1) == 1); + + /* Group round one. msg is NULL: the wire nonce is published before the + * message is known. */ + for (i = 0; i < 2; i++) { + SECP256K1_CHECKMEM_UNDEFINE(session_secrand[i], 32); + SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32); + ret = secp256k1_frost_nonce_gen(ctx, &pf_secnonce[i], &pf_pubnonce[i], session_secrand[i], &secshares[32 * i], &pubshares[i], NULL, NULL, 0, NULL, 0); + SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret)); + CHECK(ret == 1); + SECP256K1_CHECKMEM_DEFINE(&pf_pubnonce[i], sizeof(pf_pubnonce[i])); + } + /* Aggregation is over published nonces only, so it is public. */ + CHECK(secp256k1_prefractal_nonce_agg(ctx, &group_pubnonce, &pf_aggnonce, pf_pubnonce_ptrs, pf_ids, 2, &thresh_pk) == 1); + + /* The share and the secnonce are secret; the partial signature is the + * public output. */ + ret = secp256k1_prefractal_sign(ctx, &pf_partial_sig, &pf_secnonce[0], &secshares[0], pf_ids[0], pf_ids, pubshares, 2, &pf_aggnonce, &thresh_pk, &pf_cache, &outer_cache, &cosigner_aggnonce, msg); + SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret)); + CHECK(ret == 1); + SECP256K1_CHECKMEM_DEFINE(&pf_partial_sig, sizeof(pf_partial_sig)); + } +#endif + #ifdef ENABLE_MODULE_ICEBERG { /* A 3-of-5 group, dealt from `key` and taken as far as one signature