266 lines
12 KiB
Markdown
266 lines
12 KiB
Markdown
|
|
# 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.
|