doc/prefractal.md writes down the three deliberate deviations from BIP 445 where a reviewer will find them, since none of them is visible from the API and two of them are actively counterintuitive: 1. b_frost does not commit to the message, because the target protocols publish the group's wire nonce before the message exists. The outer b_musig does commit to it and multiplies b_frost everywhere it appears. 2. There is no g_frost factor, and the reason is NOT that the tweak cache is the identity. The frost key-side factor is g*gacc; an identity cache gives gacc = 1, but g is still -1 for every odd-Y threshold key. The doc spells this out because "identity cache, therefore no key term" is the plausible wrong reason, and acting on it yields a signer that works for even-Y groups and fails for odd-Y ones. 3. The frost tweak cache must be the identity, checked at signing and verification and not only at aggregation, so the key a member signs under is tied to the cache that was validated. It also records the two caller obligations the module cannot enforce - one secnonce per signature, and round-two signers EQUAL to round-one contributors - and notes that iceberg tolerates a round-two subset where this module must not, since callers moving between the two would otherwise transpose the rule. The build section documents the three-way ordering constraint rather than leaving the next person to copy iceberg's positions, which are wrong for configure.ac. The ctime test adds a 2-of-2 nested group with a stock musig cosigner, taken as far as one signature share, marking the threshold key, the secret shares and the session randomness as secret and everything else as public. Nonce generation passes msg = NULL, which is how the module is actually driven. Verified: valgrind -q ./build/bin/ctime_tests exits 0, so nothing in the prefractal signing path branches on secret data. Full test suite green. The example program from the plan's optional list is not included; the test suite covers the same ground and the doc carries the usage rules.
12 KiB
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, which is unaudited
research code, and it is built on this repository's frost module, which is
itself marked experimental and unstable.
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:
/* 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
froststays 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 keepsfrost's dependency graph honest —frostdepends only onschnorrsig, and every pure-FROST consumer would otherwise have to buildmusigtoo.musigis used unmodified, through its internals. Cosigners run stock MuSig2 throughout.icebergsolves the same outer problem with a different inner scheme. The two differ in ways that do not transfer: iceberg's quorum is2t-1in round one andtin round two and it tolerates a round-two subset; prefractal usestin 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 afterfrostandmusig, because the module calls theirstaticinternals and the whole library is one translation unit.src/CMakeLists.txt— the block goes before both, because itsset()calls are only observed by blocks that run later.configure.ac— the block likewise goes before themusigblock, not aticeberg's position further down.configure.acordersmusigandfrostahead oficeberg, and iceberg's lateenable_module_musig=yesis harmless only becausemusigdefaults to yes.frostdefaults to no, so a late force-enable would leave-DENABLE_MODULE_FROST=1unemitted whileAM_CONDITIONALstill 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.