Files
secp256k1-zkp/doc/prefractal.md
T

265 lines
12 KiB
Markdown
Raw Normal View History

# 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.