From 903da53c062ed4324d2e3c3c50cee2c9c5154ef1 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 4 Sep 2026 00:44:43 +0200 Subject: [PATCH 1/3] prefractal: add the nested FROST+MuSig2 module (API, implementation, wiring) Adds `prefractal`, an experimental module that lets a FROST t-of-n group occupy ONE participant slot of an ordinary MuSig2 (BIP 327) session. Each member computes s_i = k1_i + b_frost*b_musig*k2_i + e*a*lambda_i*g*gacc*d_i and the group publishes one ordinary MuSig2 public nonce and one ordinary MuSig2 partial signature, so cosigners need no support for it and cannot tell a group is involved. Four public functions, all sessionless (every call takes its session parameters explicitly, so there are no new opaque types, magics or *_SIZE constants to keep synchronised): secp256k1_prefractal_nonce_agg group wire nonce + unscaled aggnonce secp256k1_prefractal_sign one member's partial signature secp256k1_prefractal_partial_sig_verify identifiable abort secp256k1_prefractal_partial_sig_agg sum -> musig partial signature Three deliberate deviations from BIP 445, all documented in the public header: 1. b_frost does not commit to the message. The target protocols publish the group's wire nonce before the message exists, so a message-committing coefficient could not be computed in round one and rebuilt later. The outer b_musig does commit to the message and multiplies this one, so the product still binds it. Same trade the iceberg module makes, for the same reason. The preimage is BIP 445's with the message dropped and the group key carried in full rather than x-only, since it is used as a full point downstream. 2. There is NO g_frost factor. Stock FROST normalises its threshold key to even Y (g_times_gacc_parity = gacc_parity ^ pk_odd, frost/session_impl.h :664) because it produces a BIP 340 x-only signature. Here the threshold key is an inner participant of the outer key aggregation and is used as a full point, so all key-side parity normalisation happens once, at the aggregate level, off the OUTER keyagg cache. Note this is NOT implied by the tweak cache being the identity: with an identity cache g_frost is still -1 for every odd-Y group key, i.e. about half of them. Importing frost's key-side parity here would yield a signer that works for even-Y groups and fails for odd-Y ones. 3. The FROST tweak cache must be the identity (tacc == 0, gacc_parity == 0). Checked in sign and partial_sig_verify, not only in partial_sig_agg, so the key a member signs under is tied to the cache that was validated; sign and verify additionally require thresh_pk to equal the cache's own key so the two arguments cannot disagree. The verification equation lives in one helper used both by sign's BIP 445 self-check and by partial_sig_verify, so the two cannot drift apart. Build wiring. Three files order their module blocks differently and the constraints point in opposite directions: - src/secp256k1.c: the include goes AFTER frost and musig, because the module calls their static internals. - 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 - a library whose secp256k1.c never included frost, built alongside frost's own sources. 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. Since neither build system can distinguish an explicit disable from the default once both are in the cache, enabling prefractal simply implies frost; the guard is kept for musig, where it still means what it says. The CMake block additionally lifts both dependencies into the parent scope so the top-level configuration summary reports what was actually built rather than printing "frost OFF" while compiling frost in. Verified on both build systems: cmake -B build -DSECP256K1_ENABLE_MODULE_PREFRACTAL=ON -DSECP256K1_BUILD_TESTS=ON -> musig/frost/prefractal all ON, tests pass, 4 prefractal symbols exported cmake -B build -DSECP256K1_BUILD_TESTS=ON -> prefractal OFF, default build unchanged, tests pass ./configure --enable-experimental --enable-module-prefractal && make && make check -> frost=yes forced on, -DENABLE_MODULE_FROST=1 emitted, 3/3 pass ./configure --enable-module-prefractal -> correctly refused: "Prefractal module is experimental" tests_impl.h is a placeholder here so the module links; the real suite lands next. --- CMakeLists.txt | 2 + Makefile.am | 4 + configure.ac | 31 ++ include/secp256k1_prefractal.h | 235 +++++++++ src/CMakeLists.txt | 26 + src/modules/prefractal/Makefile.am.include | 4 + src/modules/prefractal/main_impl.h | 14 + src/modules/prefractal/session_impl.h | 557 +++++++++++++++++++++ src/modules/prefractal/tests_impl.h | 14 + src/secp256k1.c | 4 + src/tests.c | 7 + 11 files changed, 898 insertions(+) create mode 100644 include/secp256k1_prefractal.h create mode 100644 src/modules/prefractal/Makefile.am.include create mode 100644 src/modules/prefractal/main_impl.h create mode 100644 src/modules/prefractal/session_impl.h create mode 100644 src/modules/prefractal/tests_impl.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f1fbb6e1..4b83facf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ option(SECP256K1_ENABLE_MODULE_MUSIG "Enable musig module." ON) option(SECP256K1_ENABLE_MODULE_FROST "Enable FROST module (experimental)." OFF) option(SECP256K1_ENABLE_MODULE_CHILLDKG "Enable ChillDKG module (experimental)." OFF) option(SECP256K1_ENABLE_MODULE_ICEBERG "Enable Iceberg threshold-MuSig module (experimental)." OFF) +option(SECP256K1_ENABLE_MODULE_PREFRACTAL "Enable Prefractal nested FROST+MuSig2 module (experimental)." OFF) option(SECP256K1_ENABLE_MODULE_ELLSWIFT "Enable ElligatorSwift module." ON) option(SECP256K1_ENABLE_MODULE_GENERATOR "Enable NUMS generator module." ON) @@ -303,6 +304,7 @@ message(" musig ............................... ${SECP256K1_ENABLE_MODULE_MUSIG message(" frost ............................... ${SECP256K1_ENABLE_MODULE_FROST}") message(" chilldkg ............................ ${SECP256K1_ENABLE_MODULE_CHILLDKG}") message(" iceberg ............................. ${SECP256K1_ENABLE_MODULE_ICEBERG}") +message(" prefractal .......................... ${SECP256K1_ENABLE_MODULE_PREFRACTAL}") message(" ElligatorSwift ...................... ${SECP256K1_ENABLE_MODULE_ELLSWIFT}") message(" generator ........................... ${SECP256K1_ENABLE_MODULE_GENERATOR}") message(" rangeproof .......................... ${SECP256K1_ENABLE_MODULE_RANGEPROOF}") diff --git a/Makefile.am b/Makefile.am index 4deb521b..b72f0879 100644 --- a/Makefile.am +++ b/Makefile.am @@ -396,6 +396,10 @@ if ENABLE_MODULE_CHILLDKG include src/modules/chilldkg/Makefile.am.include endif +if ENABLE_MODULE_PREFRACTAL +include src/modules/prefractal/Makefile.am.include +endif + if ENABLE_MODULE_ICEBERG include src/modules/iceberg/Makefile.am.include endif diff --git a/configure.ac b/configure.ac index 6dec492e..683c3e14 100644 --- a/configure.ac +++ b/configure.ac @@ -255,6 +255,11 @@ AC_ARG_ENABLE(module_iceberg, [], [SECP_SET_DEFAULT([enable_module_iceberg], [no], [yes])]) +AC_ARG_ENABLE(module_prefractal, + AS_HELP_STRING([--enable-module-prefractal],[enable Prefractal nested FROST+MuSig2 module (experimental)]), + [], + [SECP_SET_DEFAULT([enable_module_prefractal], [no], [yes])]) + # Test-only override of the (autodetected by the C code) "widemul" setting. # Legal values are: # * int64 (for [u]int64_t), @@ -525,6 +530,27 @@ if test x"$enable_module_ellswift" = x"yes"; then SECP_CONFIG_DEFINES="$SECP_CONFIG_DEFINES -DENABLE_MODULE_ELLSWIFT=1" fi +# This block must stay ahead of the musig and frost blocks below. The +# enable_module_* assignments here are only observed by blocks that run after +# them, and frost defaults to "no": placing this at the iceberg block's +# position (further down) would leave -DENABLE_MODULE_FROST=1 unemitted while +# AM_CONDITIONAL still saw the mutation, which builds frost's sources into a +# library whose secp256k1.c never included them. +if test x"$enable_module_prefractal" = x"yes"; then + # musig defaults to yes, so "no" here really does mean the user disabled it. + if test x"$enable_module_musig" = x"no"; then + AC_MSG_ERROR([Module dependency error: You have disabled the musig module explicitly, but it is required by the prefractal module.]) + fi + # frost defaults to no, so the same test would reject every prefractal build. + # SECP_SET_DEFAULT only runs in AC_ARG_ENABLE's action-if-not-given branch, + # so telling an explicit --disable-module-frost from the default would mean + # changing frost's own declaration; enabling prefractal simply implies frost + # instead. The CMake block does the same, for the same reason. + enable_module_frost=yes + enable_module_musig=yes + SECP_CONFIG_DEFINES="$SECP_CONFIG_DEFINES -DENABLE_MODULE_PREFRACTAL=1" +fi + if test x"$enable_module_musig" = x"yes"; then if test x"$enable_module_schnorrsig" = x"no"; then AC_MSG_ERROR([Module dependency error: You have disabled the schnorrsig module explicitly, but it is required by the musig module.]) @@ -625,6 +651,9 @@ if test x"$enable_experimental" = x"no"; then if test x"$set_asm" = x"arm32"; then AC_MSG_ERROR([ARM32 assembly is experimental. Use --enable-experimental to allow.]) fi + if test x"$enable_module_prefractal" = x"yes"; then + AC_MSG_ERROR([Prefractal module is experimental. Use --enable-experimental to allow.]) + fi if test x"$enable_module_frost" = x"yes"; then AC_MSG_ERROR([FROST module is experimental. Use --enable-experimental to allow.]) fi @@ -674,6 +703,7 @@ AM_CONDITIONAL([ENABLE_MODULE_SCHNORRSIG_HALFAGG], [test x"$enable_module_schnor AM_CONDITIONAL([ENABLE_MODULE_FROST], [test x"$enable_module_frost" = x"yes"]) AM_CONDITIONAL([ENABLE_MODULE_CHILLDKG], [test x"$enable_module_chilldkg" = x"yes"]) AM_CONDITIONAL([ENABLE_MODULE_ICEBERG], [test x"$enable_module_iceberg" = x"yes"]) +AM_CONDITIONAL([ENABLE_MODULE_PREFRACTAL], [test x"$enable_module_prefractal" = x"yes"]) AM_CONDITIONAL([USE_REDUCED_SURJECTION_PROOF_SIZE], [test x"$use_reduced_surjection_proof_size" = x"yes"]) AM_CONDITIONAL([USE_EXTERNAL_ASM], [test x"$enable_external_asm" = x"yes"]) AM_CONDITIONAL([USE_ASM_ARM], [test x"$set_asm" = x"arm32"]) @@ -718,6 +748,7 @@ echo " module schnorrsig-halfagg = $enable_module_schnorrsig_halfagg" echo " module frost = $enable_module_frost" echo " module chilldkg = $enable_module_chilldkg" echo " module iceberg = $enable_module_iceberg" +echo " module prefractal = $enable_module_prefractal" echo echo " asm = $set_asm" echo " ecmult window size = $set_ecmult_window" diff --git a/include/secp256k1_prefractal.h b/include/secp256k1_prefractal.h new file mode 100644 index 00000000..89f21a68 --- /dev/null +++ b/include/secp256k1_prefractal.h @@ -0,0 +1,235 @@ +#ifndef SECP256K1_PREFRACTAL_H +#define SECP256K1_PREFRACTAL_H + +#include "secp256k1_frost.h" +#include "secp256k1_musig.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +/** This module implements a nested FROST+MuSig2 signing scheme ("prefractal"), + * which lets a FROST t-of-n group occupy ONE participant slot of an ordinary + * MuSig2 (BIP 327) session. + * + * WARNING: EXPERIMENTAL. Neither the scheme nor this implementation has been + * reviewed by anyone outside the project, and should not be used to protect + * anything of value. + * + * The construction is the nested signing scheme of frosty-musig + * (https://github.com/jesseposner/frosty-musig). Each group member computes + * + * s_i = k1_i + b_frost*b_musig*k2_i + e*a*lambda_i*g*gacc*d_i + * + * where b_frost is this module's nonce-binding coefficient and b_musig, e, a, + * g and gacc all belong to the OUTER MuSig2 session. The group publishes one + * ordinary MuSig2 public nonce and one ordinary MuSig2 partial signature, so + * cosigners cannot tell a group is involved and need no support for it. + * + * DELIBERATE DEVIATIONS FROM BIP 445 (see doc/prefractal.md): + * + * 1. b_frost does NOT commit to the message. BIP 445's nonce coefficient + * hashes the message, but this module is built for protocols that publish + * nonces before the message exists. The OUTER coefficient b_musig does + * commit to the message and binds b_frost through it, exactly as the + * iceberg module does with its own Iceberg/noncecoef tag. + * + * 2. There is NO g_frost factor. Stock FROST normalizes its threshold public + * key to even Y (see g_times_gacc_parity in the frost module), because it + * produces a BIP 340 x-only signature. Here the threshold public key is an + * inner participant of the outer key aggregation and is used as a FULL + * point, so all key-side parity normalization happens once, at the + * aggregate level, using the OUTER keyagg cache. Applying the frost-level + * factor would break the relation for every group whose key has odd Y. + * + * 3. The FROST tweak cache must be the identity (tacc == 0, gacc_parity == 0). + * The channel protocols this module targets tweak only the outer aggregate + * key. Every entry point below checks this rather than silently ignoring a + * tweaked cache. + * + * NONCE HANDLING: the caller supplies secnonces produced by + * secp256k1_frost_nonce_gen. As always, a secnonce MUST be used for exactly + * one signature; reuse across two different messages leaks the secret share + * and nothing here can detect it. + * + * The round-two signer set must be EXACTLY the round-one set: the Lagrange + * coefficients and the aggregate nonce are both defined over the + * participating set, so a proper subset produces an invalid signature with no + * error raised. Pass the same ids array to every function below. + */ + +/** Aggregate the group members' public nonces and export the group's + * OUTER-wire nonce. + * + * pubnonce_out is an ordinary MuSig2 public nonce, (R1, b_frost*R2), which is + * what the group sends to its cosigners. aggnonce_out is the UNSCALED FROST + * aggregate nonce, which the members need later for partial signing; it is an + * internal value and must be given back to secp256k1_prefractal_sign and + * secp256k1_prefractal_partial_sig_verify unchanged. + * + * b_frost = tagged_hash("Prefractal/noncecoef", + * ser32(u) || sorted ser32 ids || aggnonce66 || + * cbytes_ext(thresh_pk)) + * + * Returns: 1 on success, 0 if a pubnonce could not be loaded, if the ids are + * invalid, or if either output nonce component is the point at + * infinity. A FROST aggregate nonce component may legitimately be + * infinity (BIP 445 NonceAgg), but a MuSig2 public nonce has no + * encoding for it, so such a session must be restarted with fresh + * nonces. + * Args: ctx: pointer to a context object + * Out: pubnonce_out: the group's MuSig2 public nonce + * aggnonce_out: the group's unscaled FROST aggregate nonce + * In: pubnonces: array of pointers to the members' public nonces + * ids: array of the members' identifiers + * n_signers: number of members (must match the array lengths, at + * least 1 and at most SECP256K1_FROST_MAX_PARTICIPANTS) + * thresh_pk: the group's (untweaked) threshold public key + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_prefractal_nonce_agg( + const secp256k1_context *ctx, + secp256k1_musig_pubnonce *pubnonce_out, + secp256k1_frost_aggnonce *aggnonce_out, + const secp256k1_frost_pubnonce *const *pubnonces, + const uint32_t *ids, + size_t n_signers, + const secp256k1_pubkey *thresh_pk +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(5) SECP256K1_ARG_NONNULL(7); + +/** Produce one group member's nested partial signature. + * + * Computes s_i = k1 + b_frost*b_musig*k2 + e*a*lambda_i*g*gacc*d_i, with both + * nonce scalars negated iff the OUTER final nonce has odd Y, and the key-side + * factor g*gacc taken from the OUTER keyagg cache. There is deliberately no + * g_frost factor (see the module notes above). + * + * The secnonce is wiped, so a second call with the same secnonce fails. The + * partial signature is self-verified before it is returned, as BIP 445 + * recommends. + * + * Returns: 1 on success, 0 on failure. Failure cases include: an invalidated + * or malformed secnonce, an invalid secret share, my_id not in ids, + * a secret share that does not match its pubshare, a non-identity + * tweak_cache, a nonce component at infinity, and a failed + * self-verification. + * Args: ctx: pointer to a context object + * Out: partial_sig: the member's partial signature + * In/Out: secnonce: the member's secret nonce, wiped by this call + * In: secshare32: the member's 32-byte secret share + * my_id: the member's identifier + * ids: array of the participating members' identifiers, + * the SAME array given to _nonce_agg + * pubshares: array of the members' public shares, in the order + * of ids, or NULL to skip the share/pubshare check + * (providing them is recommended) + * n_signers: number of members + * aggnonce: the unscaled FROST aggregate nonce from _nonce_agg + * thresh_pk: the group's (untweaked) threshold public key + * tweak_cache: the group's FROST tweak cache, which MUST be the + * identity + * keyagg_cache: the OUTER MuSig2 keyagg cache, already carrying any + * BIP 341 tweak + * cosigner_aggnonce: the aggregate of the NON-group participants' + * MuSig2 public nonces + * msg32: the 32-byte message being signed + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_prefractal_sign( + const secp256k1_context *ctx, + secp256k1_frost_partial_sig *partial_sig, + secp256k1_frost_secnonce *secnonce, + const unsigned char *secshare32, + uint32_t my_id, + const uint32_t *ids, + const secp256k1_pubkey *pubshares, + size_t n_signers, + const secp256k1_frost_aggnonce *aggnonce, + const secp256k1_pubkey *thresh_pk, + const secp256k1_frost_tweak_cache *tweak_cache, + const secp256k1_musig_keyagg_cache *keyagg_cache, + const secp256k1_musig_aggnonce *cosigner_aggnonce, + const unsigned char *msg32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(6) SECP256K1_ARG_NONNULL(9) SECP256K1_ARG_NONNULL(10) SECP256K1_ARG_NONNULL(11) SECP256K1_ARG_NONNULL(12) SECP256K1_ARG_NONNULL(13) SECP256K1_ARG_NONNULL(14); + +/** Verify one group member's nested partial signature. + * + * Checks s_i*G == R1_i + b_frost*b_musig*R2_i + e*a*lambda_i*g*gacc*P_i, with + * the nonce points negated iff the OUTER final nonce has odd Y. The session is + * recomputed from the same parameters secp256k1_prefractal_sign takes, so the + * caller must pass exactly the same ids, aggnonce, keys and message. + * + * This is the identifiable-abort tool: when the aggregate signature fails, + * running this over each member's share names the one at fault. + * + * Returns: 1 if the partial signature is valid, 0 otherwise. + * Args: ctx: pointer to a context object + * In: partial_sig: the partial signature to verify + * pubnonce: the member's public nonce, as given to _nonce_agg + * pubshare: the member's public share + * my_id: the member's identifier + * ids: array of the participating members' identifiers + * n_signers: number of members + * aggnonce: the unscaled FROST aggregate nonce from _nonce_agg + * thresh_pk: the group's (untweaked) threshold public key + * tweak_cache: the group's FROST tweak cache, which MUST be the + * identity + * keyagg_cache: the OUTER MuSig2 keyagg cache + * cosigner_aggnonce: the aggregate of the NON-group participants' nonces + * msg32: the 32-byte message being signed + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_prefractal_partial_sig_verify( + const secp256k1_context *ctx, + const secp256k1_frost_partial_sig *partial_sig, + const secp256k1_frost_pubnonce *pubnonce, + const secp256k1_pubkey *pubshare, + uint32_t my_id, + const uint32_t *ids, + size_t n_signers, + const secp256k1_frost_aggnonce *aggnonce, + const secp256k1_pubkey *thresh_pk, + const secp256k1_frost_tweak_cache *tweak_cache, + const secp256k1_musig_keyagg_cache *keyagg_cache, + const secp256k1_musig_aggnonce *cosigner_aggnonce, + const unsigned char *msg32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(6) SECP256K1_ARG_NONNULL(8) SECP256K1_ARG_NONNULL(9) SECP256K1_ARG_NONNULL(10) SECP256K1_ARG_NONNULL(11) SECP256K1_ARG_NONNULL(12) SECP256K1_ARG_NONNULL(13); + +/** Sum the members' partial signatures into one ordinary MuSig2 partial + * signature. + * + * The result is ready for secp256k1_musig_partial_sig_agg alongside the + * cosigners' partial signatures. The sum is plain because the FROST tweak + * cache is required to be the identity: with a tweak there would be an + * additional e*g*tacc term to fold in, and this module does not support that. + * + * This does not check that the shares are the ones the members would have + * produced; secp256k1_prefractal_partial_sig_verify answers that, one share at + * a time. + * + * Returns: 1 on success, 0 if a partial signature could not be loaded or if + * tweak_cache is not the identity. + * Args: ctx: pointer to a context object + * Out: sig_out: the resulting MuSig2 partial signature + * error_index: if non-NULL and a partial signature fails to load, + * receives its index in partial_sigs + * In: partial_sigs: array of pointers to the members' partial signatures + * n_sigs: number of partial signatures (at least 1 and at most + * SECP256K1_FROST_MAX_PARTICIPANTS) + * tweak_cache: the group's FROST tweak cache, which MUST be the + * identity + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_prefractal_partial_sig_agg( + const secp256k1_context *ctx, + secp256k1_musig_partial_sig *sig_out, + size_t *error_index, + const secp256k1_frost_partial_sig *const *partial_sigs, + size_t n_sigs, + const secp256k1_frost_tweak_cache *tweak_cache +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(6); + +#ifdef __cplusplus +} +#endif + +#endif /* SECP256K1_PREFRACTAL_H */ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f84bb377..25c428b5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -73,6 +73,32 @@ if(SECP256K1_ENABLE_MODULE_ELLSWIFT) set_property(TARGET secp256k1 APPEND PROPERTY PUBLIC_HEADER ${PROJECT_SOURCE_DIR}/include/secp256k1_ellswift.h) endif() +# Must precede the musig and frost blocks below: the set() calls here are what +# force those modules on, and they are only observed by blocks that run after. +if(SECP256K1_ENABLE_MODULE_PREFRACTAL) + # musig defaults to ON, so the guard every other block in this file uses + # reads as "the user turned it off explicitly" and is meaningful here. + if(DEFINED SECP256K1_ENABLE_MODULE_MUSIG AND NOT SECP256K1_ENABLE_MODULE_MUSIG) + message(FATAL_ERROR "Module dependency error: You have disabled the musig module explicitly, but it is required by the prefractal module.") + endif() + # frost is the first default-OFF module anything depends on. The same guard + # cannot be used: option() always leaves the variable DEFINED, so for a + # default-OFF module "DEFINED AND NOT" is true by default and would reject + # every prefractal build. There is no way to tell an explicit -D...=OFF from + # the default once both are in the cache, so enabling prefractal simply + # implies frost. + set(SECP256K1_ENABLE_MODULE_FROST ON) + set(SECP256K1_ENABLE_MODULE_MUSIG ON) + # Also lift them into the parent scope so the top-level configuration + # summary, which runs after add_subdirectory(src), reports what was actually + # built. Without this a prefractal-only configure prints "frost OFF" while + # compiling frost in. + set(SECP256K1_ENABLE_MODULE_FROST ON PARENT_SCOPE) + set(SECP256K1_ENABLE_MODULE_MUSIG ON PARENT_SCOPE) + add_compile_definitions(ENABLE_MODULE_PREFRACTAL=1) + set_property(TARGET secp256k1 APPEND PROPERTY PUBLIC_HEADER ${PROJECT_SOURCE_DIR}/include/secp256k1_prefractal.h) +endif() + if(SECP256K1_ENABLE_MODULE_ICEBERG) if(DEFINED SECP256K1_ENABLE_MODULE_MUSIG AND NOT SECP256K1_ENABLE_MODULE_MUSIG) message(FATAL_ERROR "Module dependency error: You have disabled the musig module explicitly, but it is required by the iceberg module.") diff --git a/src/modules/prefractal/Makefile.am.include b/src/modules/prefractal/Makefile.am.include new file mode 100644 index 00000000..c8b6ec82 --- /dev/null +++ b/src/modules/prefractal/Makefile.am.include @@ -0,0 +1,4 @@ +include_HEADERS += include/secp256k1_prefractal.h +noinst_HEADERS += src/modules/prefractal/main_impl.h +noinst_HEADERS += src/modules/prefractal/session_impl.h +noinst_HEADERS += src/modules/prefractal/tests_impl.h diff --git a/src/modules/prefractal/main_impl.h b/src/modules/prefractal/main_impl.h new file mode 100644 index 00000000..d2e2c665 --- /dev/null +++ b/src/modules/prefractal/main_impl.h @@ -0,0 +1,14 @@ +/*********************************************************************** + * Distributed under the MIT software license, see the accompanying * + * file COPYING or https://www.opensource.org/licenses/mit-license.php.* + ***********************************************************************/ + +#ifndef SECP256K1_MODULE_PREFRACTAL_MAIN_H +#define SECP256K1_MODULE_PREFRACTAL_MAIN_H + +/* One layer only. Everything this module does is a session computation over + * values the frost and musig modules already know how to load and save, so + * there is no keygen, no serialization and no state of its own here. */ +#include "session_impl.h" + +#endif diff --git a/src/modules/prefractal/session_impl.h b/src/modules/prefractal/session_impl.h new file mode 100644 index 00000000..daa295bc --- /dev/null +++ b/src/modules/prefractal/session_impl.h @@ -0,0 +1,557 @@ +/*********************************************************************** + * Distributed under the MIT software license, see the accompanying * + * file COPYING or https://www.opensource.org/licenses/mit-license.php.* + ***********************************************************************/ + +#ifndef SECP256K1_MODULE_PREFRACTAL_SESSION_IMPL_H +#define SECP256K1_MODULE_PREFRACTAL_SESSION_IMPL_H + +#include + +#include "../../../include/secp256k1_prefractal.h" + +/* This module is compiled into the same translation unit as frost and musig + * and is included after both, so it may use their static internals. It adds + * nothing to either: every value it needs is loaded through their existing + * helpers. */ +#include "../frost/keygen.h" +#include "../frost/session.h" +#include "../musig/keyagg.h" +#include "../musig/session.h" + +#include "../../group.h" +#include "../../hash.h" +#include "../../scalar.h" +#include "../../util.h" + +/* Initializes SHA256 with fixed midstate. This midstate was computed by + * applying SHA256 to SHA256("Prefractal/noncecoef")||SHA256("Prefractal/noncecoef"). */ +static void secp256k1_prefractal_noncecoef_sha256_tagged(secp256k1_sha256 *sha) { + static const uint32_t midstate[8] = { + 0x1d1f5957ul, 0x0c41e94ful, 0x0e1ec98ful, 0x70d8f48eul, + 0x8fb8bc46ul, 0x1eece984ul, 0x3b015f4bul, 0x443998c6ul + }; + secp256k1_sha256_initialize_midstate(sha, 64, midstate); +} + +/* b_frost = H_Prefractal/noncecoef(ser32(u) || sorted ser32 ids || aggnonce66 + * || cbytes_ext(thresh_pk)) + * + * The preimage is BIP 445's noncecoef preimage with the message dropped and + * the group key carried in full rather than x-only. Both changes are + * deliberate: + * + * - No message. The protocols this module serves publish the group's wire + * nonce before the transaction being signed exists, so a coefficient that + * hashed the message could not be computed in round one and rebuilt in round + * two. The outer b_musig does hash the message and multiplies this one, so + * the product still binds it. The iceberg module makes the same trade for the + * same reason. + * + * - Full point. The group key is an inner participant of the outer key + * aggregation and is used as a full point everywhere downstream, so the + * binding covers the point that is actually in play, including its Y parity. + * Iceberg's noncecoef hashes its group key the same way. + * + * Computed here and nowhere else: round one publishes the scaled nonce and + * rounds two and three rebuild it, and the three have to agree exactly or every + * signature the group produces is invalid. */ +static void secp256k1_prefractal_noncecoef(const secp256k1_context *ctx, secp256k1_scalar *b_frost, const secp256k1_ge *aggnonce_pts, const uint32_t *ids, size_t n_signers, const secp256k1_ge *thresh_pk) { + const secp256k1_hash_ctx *hash_ctx = secp256k1_get_hash_context(ctx); + uint32_t sorted_ids[SECP256K1_FROST_MAX_PARTICIPANTS]; + secp256k1_ge pts[2], pk = *thresh_pk; + secp256k1_sha256 sha; + unsigned char aggnonce66[66]; + unsigned char buf[33]; + unsigned char out[32]; + size_t i; + + pts[0] = aggnonce_pts[0]; + pts[1] = aggnonce_pts[1]; + + secp256k1_prefractal_noncecoef_sha256_tagged(&sha); + secp256k1_write_be32(buf, (uint32_t)n_signers); + secp256k1_sha256_write(hash_ctx, &sha, buf, 4); + /* Sorting keeps the coefficient independent of the caller's ordering, as + * BIP 445 serialize_ids does. */ + secp256k1_frost_sort_ids(sorted_ids, ids, n_signers); + for (i = 0; i < n_signers; i++) { + secp256k1_write_be32(buf, sorted_ids[i]); + secp256k1_sha256_write(hash_ctx, &sha, buf, 4); + } + /* An aggregate nonce component at infinity is encoded as 33 zero bytes + * (BIP 445 cbytes_ext). It cannot survive to the wire, but it can reach + * this hash: the caller learns it is unusable from the infinity check in + * secp256k1_prefractal_nonce_agg, after this runs. */ + secp256k1_musig_ge_serialize_ext(&aggnonce66[0], &pts[0]); + secp256k1_musig_ge_serialize_ext(&aggnonce66[33], &pts[1]); + secp256k1_sha256_write(hash_ctx, &sha, aggnonce66, sizeof(aggnonce66)); + secp256k1_musig_ge_serialize_ext(buf, &pk); + secp256k1_sha256_write(hash_ctx, &sha, buf, 33); + secp256k1_sha256_finalize(hash_ctx, &sha, out); + secp256k1_scalar_set_b32(b_frost, out, NULL); +} + +/* The nonce pair the group publishes, (R1, b_frost*R2), from its unscaled + * aggregate. Only the second component is scaled. + * + * Returns 0 if either output is the point at infinity. A frost aggregate nonce + * component may legitimately be infinity, but a musig pubnonce has no encoding + * for one, so such a session is unusable and has to be restarted. Both + * components are checked: the first is passed through unscaled and can be + * infinity on its own if the members' first-column contributions cancel. + * + * Round one publishes this and the later rounds rebuild it, so it is written + * once rather than three times. */ +static int secp256k1_prefractal_publish_nonce(secp256k1_ge *out, const secp256k1_ge *pre, const secp256k1_scalar *b_frost) { + secp256k1_gej r2j, scaled; + + out[0] = pre[0]; + secp256k1_gej_set_ge(&r2j, &pre[1]); + secp256k1_ecmult(&scaled, &r2j, b_frost, NULL); + secp256k1_ge_set_gej(&out[1], &scaled); + return !secp256k1_ge_is_infinity(&out[0]) && !secp256k1_ge_is_infinity(&out[1]); +} + +/* The FROST tweak cache must be the identity: gacc == 1 and tacc == 0. + * + * With a tweak there would be an extra e*g*tacc term to fold into the + * aggregation, which this module does not implement. Checking is not a + * formality: a tweaked cache would otherwise be accepted and produce a + * signature that fails only at the very end, against the outer aggregate. + * + * Note this says nothing about g_frost. Stock frost's key-side factor is + * g*gacc where g is -1 for an odd-Y threshold key, so an identity cache does + * NOT imply a factor of 1 there. This module has no g_frost term at all, + * because the threshold key is used as a full point by the outer aggregation + * (see the module notes in the public header). */ +static int secp256k1_prefractal_tweak_cache_is_identity(const secp256k1_context *ctx, secp256k1_ge *thresh_pk_out, const secp256k1_frost_tweak_cache *tweak_cache) { + secp256k1_frost_tweak_cache_internal cache_i; + + if (!secp256k1_frost_tweak_cache_load(ctx, &cache_i, tweak_cache)) { + return 0; + } + if (cache_i.gacc_parity != 0) { + return 0; + } + if (!secp256k1_scalar_is_zero(&cache_i.tacc)) { + return 0; + } + if (thresh_pk_out != NULL) { + *thresh_pk_out = cache_i.thresh_pk; + } + return 1; +} + +/* Everything a member needs to turn its nonce and share into a partial + * signature, and everything a verifier needs to check one. + * + * b0b1 is the product of the outer nonce coefficient and this module's, which + * is the factor on the second nonce component. key_coef is e*a*g*gacc, the + * factor on lambda_i*d_i. fin_parity says whether the outer final nonce came + * out odd, which flips both nonce terms. + * + * The group's own wire nonce is rebuilt here from the aggregate rather than + * taken as an argument, so a member never signs against a nonce a coordinator + * chose for it: b_frost is a hash of exactly that aggregate, and a member that + * accepted three fabricated aggregates under one label would be answering three + * equations in its own secrets. + * + * This is the prefractal counterpart of secp256k1_iceberg_session_values, and + * deliberately NOT of secp256k1_frost_get_session_values: the latter computes a + * standalone FROST session, including the x-only normalization of the threshold + * key that must not happen here. */ +static int secp256k1_prefractal_session_values(const secp256k1_context *ctx, secp256k1_scalar *b0b1, secp256k1_scalar *key_coef, int *fin_parity, const secp256k1_frost_aggnonce *aggnonce, const uint32_t *ids, size_t n_signers, const secp256k1_pubkey *thresh_pk, const secp256k1_musig_keyagg_cache *keyagg_cache, const secp256k1_musig_aggnonce *cosigner_aggnonce, const unsigned char *msg32) { + secp256k1_keyagg_cache_internal cache_i; + secp256k1_ge group_pts[2], cosigner_pts[2], total[2], pk; + secp256k1_scalar b_frost, b_musig, a, e; + secp256k1_gej acc; + unsigned char agg_pk32[32], fin_nonce[32]; + int i; + + if (!secp256k1_keyagg_cache_load(ctx, &cache_i, keyagg_cache)) { + return 0; + } + if (!secp256k1_pubkey_load(ctx, &pk, thresh_pk)) { + return 0; + } + if (!secp256k1_frost_aggnonce_load(ctx, group_pts, aggnonce)) { + return 0; + } + if (!secp256k1_musig_aggnonce_load(ctx, cosigner_pts, cosigner_aggnonce)) { + return 0; + } + + /* Rebuild the group's published nonce with the same function round one + * published it with, then add the cosigners' to it. */ + secp256k1_prefractal_noncecoef(ctx, &b_frost, group_pts, ids, n_signers, &pk); + if (!secp256k1_prefractal_publish_nonce(total, group_pts, &b_frost)) { + return 0; + } + for (i = 0; i < 2; i++) { + secp256k1_gej_set_ge(&acc, &total[i]); + secp256k1_gej_add_ge_var(&acc, &acc, &cosigner_pts[i], NULL); + secp256k1_ge_set_gej(&total[i], &acc); + } + + secp256k1_fe_get_b32(agg_pk32, &cache_i.pk.x); + secp256k1_musig_nonce_process_internal(ctx, fin_parity, fin_nonce, &b_musig, total, agg_pk32, msg32); + secp256k1_schnorrsig_challenge(secp256k1_get_hash_context(ctx), &e, fin_nonce, msg32, 32, agg_pk32); + + secp256k1_scalar_mul(b0b1, &b_musig, &b_frost); + + /* The key coefficient carries the aggregation weight and the parity + * bookkeeping from BIP 340: e*a*g*gacc, where the sign flips if the + * AGGREGATE key is odd exactly once against the accumulated parity. The + * group key's own Y parity is deliberately not consulted: it is an inner + * participant of this aggregation, not the key the signature verifies + * against. */ + secp256k1_musig_keyaggcoef(secp256k1_get_hash_context(ctx), &a, &cache_i, &pk); + secp256k1_scalar_mul(key_coef, &e, &a); + if (secp256k1_fe_is_odd(&cache_i.pk.y) != cache_i.parity_acc) { + secp256k1_scalar_negate(key_coef, key_coef); + } + return 1; +} + +/* The verification equation, shared by the self-check inside + * secp256k1_prefractal_sign and by secp256k1_prefractal_partial_sig_verify so + * the two can never drift apart: + * + * s_i*G == +-(R1_i + b_frost*b_musig*R2_i) + e*a*g*gacc*lambda_i*P_i + * + * rearranged into a single comparison against infinity. The sign on the nonce + * term follows the OUTER final nonce's parity. */ +static int secp256k1_prefractal_verify_partial_sig(const secp256k1_scalar *s, const secp256k1_ge *nonce_pts, const secp256k1_ge *pubshare, const secp256k1_scalar *lambda, const secp256k1_scalar *b0b1, const secp256k1_scalar *key_coef, int fin_parity) { + secp256k1_scalar coef, s_neg; + secp256k1_gej rj, pkj, tmp; + + /* The nonce components of a pubnonce are never the point at infinity. */ + VERIFY_CHECK(!secp256k1_ge_is_infinity(&nonce_pts[0])); + VERIFY_CHECK(!secp256k1_ge_is_infinity(&nonce_pts[1])); + secp256k1_gej_set_ge(&rj, &nonce_pts[1]); + secp256k1_ecmult(&rj, &rj, b0b1, NULL); + secp256k1_gej_add_ge_var(&rj, &rj, &nonce_pts[0], NULL); + if (fin_parity) { + secp256k1_gej_neg(&rj, &rj); + } + + secp256k1_scalar_mul(&coef, key_coef, lambda); + secp256k1_scalar_negate(&s_neg, s); + secp256k1_gej_set_ge(&pkj, pubshare); + secp256k1_ecmult(&tmp, &pkj, &coef, &s_neg); + secp256k1_gej_add_var(&tmp, &tmp, &rj, NULL); + return secp256k1_gej_is_infinity(&tmp); +} + +int secp256k1_prefractal_nonce_agg(const secp256k1_context *ctx, secp256k1_musig_pubnonce *pubnonce_out, secp256k1_frost_aggnonce *aggnonce_out, const secp256k1_frost_pubnonce *const *pubnonces, const uint32_t *ids, size_t n_signers, const secp256k1_pubkey *thresh_pk) { + secp256k1_gej sumj[2]; + secp256k1_ge sum[2], published[2], pk; + secp256k1_scalar b_frost; + size_t i; + int j; + + VERIFY_CHECK(ctx != NULL); + ARG_CHECK(pubnonce_out != NULL); + memset(pubnonce_out, 0, sizeof(*pubnonce_out)); + ARG_CHECK(aggnonce_out != NULL); + memset(aggnonce_out, 0, sizeof(*aggnonce_out)); + ARG_CHECK(pubnonces != NULL); + ARG_CHECK(ids != NULL); + ARG_CHECK(thresh_pk != NULL); + ARG_CHECK(n_signers >= 1 && n_signers <= SECP256K1_FROST_MAX_PARTICIPANTS); + for (i = 0; i < n_signers; i++) { + ARG_CHECK(pubnonces[i] != NULL); + } + /* The ids are what the coefficient commits to and what the Lagrange values + * are computed over, so a duplicate has to be refused here rather than + * producing a nonce nobody can sign against. */ + if (!secp256k1_frost_ids_are_valid(ids, n_signers)) { + return 0; + } + if (!secp256k1_pubkey_load(ctx, &pk, thresh_pk)) { + return 0; + } + + secp256k1_gej_set_infinity(&sumj[0]); + secp256k1_gej_set_infinity(&sumj[1]); + for (i = 0; i < n_signers; i++) { + secp256k1_ge pts[2]; + if (!secp256k1_frost_pubnonce_load(ctx, pts, pubnonces[i])) { + return 0; + } + for (j = 0; j < 2; j++) { + secp256k1_gej_add_ge_var(&sumj[j], &sumj[j], &pts[j], NULL); + } + } + /* Either column sum may be infinity here (BIP 445 NonceAgg); the aggregate + * nonce has an encoding for that, and the check that matters happens after + * scaling, below. */ + secp256k1_ge_set_all_gej_var(sum, sumj, 2); + + secp256k1_prefractal_noncecoef(ctx, &b_frost, sum, ids, n_signers, &pk); + if (!secp256k1_prefractal_publish_nonce(published, sum, &b_frost)) { + return 0; + } + + secp256k1_frost_aggnonce_save(aggnonce_out, sum); + secp256k1_musig_pubnonce_save(pubnonce_out, published); + return 1; +} + +/* The secrets secp256k1_prefractal_sign holds. d and s are not yet meaningful + * on its early error paths; clearing them there writes zeros over whatever the + * stack held, which is what those paths want anyway. */ +static void secp256k1_prefractal_sign_clear(secp256k1_scalar *k, secp256k1_scalar *d, secp256k1_scalar *lambda, secp256k1_scalar *s) { + secp256k1_scalar_clear(&k[0]); + secp256k1_scalar_clear(&k[1]); + secp256k1_scalar_clear(d); + secp256k1_scalar_clear(lambda); + secp256k1_scalar_clear(s); +} + +int secp256k1_prefractal_sign(const secp256k1_context *ctx, secp256k1_frost_partial_sig *partial_sig, secp256k1_frost_secnonce *secnonce, const unsigned char *secshare32, uint32_t my_id, const uint32_t *ids, const secp256k1_pubkey *pubshares, size_t n_signers, const secp256k1_frost_aggnonce *aggnonce, const secp256k1_pubkey *thresh_pk, const secp256k1_frost_tweak_cache *tweak_cache, const secp256k1_musig_keyagg_cache *keyagg_cache, const secp256k1_musig_aggnonce *cosigner_aggnonce, const unsigned char *msg32) { + secp256k1_scalar k[2], d, lambda, s, b0b1, key_coef, tmp; + secp256k1_gej nonce_ptj[2]; + secp256k1_ge nonce_pts[2], cache_pk, pk; + size_t my_index = n_signers; + size_t i; + int fin_parity; + int ret; + + VERIFY_CHECK(ctx != NULL); + ARG_CHECK(secnonce != NULL); + /* Fails if the magic doesn't match or the nonce has been invalidated. */ + ret = secp256k1_frost_secnonce_load(ctx, k, secnonce); + /* Wipe the secnonce to prevent nonce reuse. This will cause subsequent + * calls of this function with the same secnonce to fail. */ + secp256k1_memzero_explicit(secnonce, sizeof(*secnonce)); + if (!ret) { + secp256k1_scalar_clear(&k[0]); + secp256k1_scalar_clear(&k[1]); + return 0; + } + + ARG_CHECK(partial_sig != NULL); + memset(partial_sig, 0, sizeof(*partial_sig)); + ARG_CHECK(secshare32 != NULL); + ARG_CHECK(ids != NULL); + ARG_CHECK(aggnonce != NULL); + ARG_CHECK(thresh_pk != NULL); + ARG_CHECK(tweak_cache != NULL); + ARG_CHECK(keyagg_cache != NULL); + ARG_CHECK(cosigner_aggnonce != NULL); + ARG_CHECK(msg32 != NULL); + ARG_CHECK(secp256k1_ecmult_gen_context_is_built(&ctx->ecmult_gen_ctx)); + + secp256k1_scalar_clear(&d); + secp256k1_scalar_clear(&lambda); + secp256k1_scalar_clear(&s); + + if (n_signers < 1 || n_signers > SECP256K1_FROST_MAX_PARTICIPANTS) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + /* The tweak cache is checked HERE and not only at aggregation, so the key + * the member signs under is tied to the cache that was validated. */ + if (!secp256k1_prefractal_tweak_cache_is_identity(ctx, &cache_pk, tweak_cache)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + if (!secp256k1_pubkey_load(ctx, &pk, thresh_pk)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + /* An identity cache carries the threshold key untouched, so this pins the + * key argument to the cache instead of letting the two disagree. */ + if (!secp256k1_ge_eq_var(&cache_pk, &pk)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + + /* Compute the pubnonce points from the unnegated nonces for the + * self-verification below. k[0] != 0 and k[1] != 0 is guaranteed by + * secnonce_load, so the points are not the point at infinity. */ + secp256k1_ecmult_gen_gej(&ctx->ecmult_gen_ctx, &nonce_ptj[0], &k[0]); + secp256k1_ecmult_gen_gej(&ctx->ecmult_gen_ctx, &nonce_ptj[1], &k[1]); + secp256k1_ge_set_all_gej(nonce_pts, nonce_ptj, 2); + secp256k1_declassify(ctx, &nonce_pts, sizeof(nonce_pts)); + + /* The secret share must be nonzero and less than the curve order. We can + * declassify the result of the check because branching on it only leaks + * whether the provided secret share is a valid secret key, which is not + * secret. */ + { + int share_valid = secp256k1_scalar_set_b32_seckey(&d, secshare32); + secp256k1_declassify(ctx, &share_valid, sizeof(share_valid)); + if (!share_valid) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + } + + for (i = 0; i < n_signers; i++) { + if (ids[i] == my_id) { + my_index = i; + break; + } + } + if (my_index == n_signers) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + /* If the pubshares are known, the secret share must match the signer's + * pubshare (recommended by BIP 445). */ + if (pubshares != NULL) { + secp256k1_ge expected, mine; + secp256k1_ecmult_gen_ge(&ctx->ecmult_gen_ctx, &mine, &d); + secp256k1_declassify(ctx, &mine, sizeof(mine)); + if (!secp256k1_pubkey_load(ctx, &expected, &pubshares[my_index])) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + if (!secp256k1_ge_eq_var(&mine, &expected)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + } + if (!secp256k1_frost_derive_interpolating_value(&lambda, ids, n_signers, my_id)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + if (!secp256k1_prefractal_session_values(ctx, &b0b1, &key_coef, &fin_parity, aggnonce, ids, n_signers, thresh_pk, keyagg_cache, cosigner_aggnonce, msg32)) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + + /* BIP 340: if the OUTER final nonce came out odd, both nonce terms flip. + * + * There is deliberately NO key-side flip for the group key here. See the + * module notes in include/secp256k1_prefractal.h. */ + if (fin_parity) { + secp256k1_scalar_negate(&k[0], &k[0]); + secp256k1_scalar_negate(&k[1], &k[1]); + } + + /* s_i = k1 + b_frost*b_musig*k2 + e*a*g*gacc*lambda_i*d_i */ + secp256k1_scalar_mul(&s, &key_coef, &lambda); + secp256k1_scalar_mul(&s, &s, &d); + secp256k1_scalar_mul(&tmp, &b0b1, &k[1]); + secp256k1_scalar_add(&s, &s, &tmp); + secp256k1_scalar_add(&s, &s, &k[0]); + + /* Self-verify the partial signature, as recommended by BIP 445. This can + * only fail in case of an implementation bug or catastrophic hardware + * failure, so the result of the verification is not secret. The partial + * signature itself is declassified first: it is the public output of this + * function, and the verification below multiplies with it in variable + * time. */ + { + int verified; + secp256k1_ge mine; + secp256k1_declassify(ctx, &s, sizeof(s)); + secp256k1_ecmult_gen_ge(&ctx->ecmult_gen_ctx, &mine, &d); + secp256k1_declassify(ctx, &mine, sizeof(mine)); + verified = secp256k1_prefractal_verify_partial_sig(&s, nonce_pts, &mine, &lambda, &b0b1, &key_coef, fin_parity); + secp256k1_declassify(ctx, &verified, sizeof(verified)); + if (!verified) { + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + return 0; + } + } + + secp256k1_frost_partial_sig_save(partial_sig, &s); + secp256k1_prefractal_sign_clear(k, &d, &lambda, &s); + secp256k1_scalar_clear(&tmp); + return 1; +} + +int secp256k1_prefractal_partial_sig_verify(const secp256k1_context *ctx, const secp256k1_frost_partial_sig *partial_sig, const secp256k1_frost_pubnonce *pubnonce, const secp256k1_pubkey *pubshare, uint32_t my_id, const uint32_t *ids, size_t n_signers, const secp256k1_frost_aggnonce *aggnonce, const secp256k1_pubkey *thresh_pk, const secp256k1_frost_tweak_cache *tweak_cache, const secp256k1_musig_keyagg_cache *keyagg_cache, const secp256k1_musig_aggnonce *cosigner_aggnonce, const unsigned char *msg32) { + secp256k1_scalar b0b1, key_coef, lambda, s; + secp256k1_ge nonce_pts[2], share_pt, cache_pk, pk; + int fin_parity; + + VERIFY_CHECK(ctx != NULL); + ARG_CHECK(partial_sig != NULL); + ARG_CHECK(pubnonce != NULL); + ARG_CHECK(pubshare != NULL); + ARG_CHECK(ids != NULL); + ARG_CHECK(aggnonce != NULL); + ARG_CHECK(thresh_pk != NULL); + ARG_CHECK(tweak_cache != NULL); + ARG_CHECK(keyagg_cache != NULL); + ARG_CHECK(cosigner_aggnonce != NULL); + ARG_CHECK(msg32 != NULL); + + if (n_signers < 1 || n_signers > SECP256K1_FROST_MAX_PARTICIPANTS) { + return 0; + } + /* Recomputed from the same parameters signing used, including this check, + * so a share made under a tweaked cache cannot be validated by a verifier + * that was handed one. */ + if (!secp256k1_prefractal_tweak_cache_is_identity(ctx, &cache_pk, tweak_cache)) { + return 0; + } + if (!secp256k1_pubkey_load(ctx, &pk, thresh_pk)) { + return 0; + } + if (!secp256k1_ge_eq_var(&cache_pk, &pk)) { + return 0; + } + if (!secp256k1_frost_partial_sig_load(ctx, &s, partial_sig)) { + return 0; + } + if (!secp256k1_frost_pubnonce_load(ctx, nonce_pts, pubnonce)) { + return 0; + } + if (!secp256k1_pubkey_load(ctx, &share_pt, pubshare)) { + return 0; + } + if (!secp256k1_frost_derive_interpolating_value(&lambda, ids, n_signers, my_id)) { + return 0; + } + if (!secp256k1_prefractal_session_values(ctx, &b0b1, &key_coef, &fin_parity, aggnonce, ids, n_signers, thresh_pk, keyagg_cache, cosigner_aggnonce, msg32)) { + return 0; + } + return secp256k1_prefractal_verify_partial_sig(&s, nonce_pts, &share_pt, &lambda, &b0b1, &key_coef, fin_parity); +} + +int secp256k1_prefractal_partial_sig_agg(const secp256k1_context *ctx, secp256k1_musig_partial_sig *sig_out, size_t *error_index, const secp256k1_frost_partial_sig *const *partial_sigs, size_t n_sigs, const secp256k1_frost_tweak_cache *tweak_cache) { + secp256k1_scalar s, term; + size_t i; + + VERIFY_CHECK(ctx != NULL); + ARG_CHECK(sig_out != NULL); + memset(sig_out, 0, sizeof(*sig_out)); + ARG_CHECK(partial_sigs != NULL); + ARG_CHECK(tweak_cache != NULL); + ARG_CHECK(n_sigs >= 1 && n_sigs <= SECP256K1_FROST_MAX_PARTICIPANTS); + for (i = 0; i < n_sigs; i++) { + ARG_CHECK(partial_sigs[i] != NULL); + } + + /* The sum below is plain only because there is no accumulated tweak to + * fold in. Checked again here, and not only in sign, because the shares and + * the cache can reach a coordinator from different places. */ + if (!secp256k1_prefractal_tweak_cache_is_identity(ctx, NULL, tweak_cache)) { + return 0; + } + + secp256k1_scalar_set_int(&s, 0); + for (i = 0; i < n_sigs; i++) { + if (!secp256k1_frost_partial_sig_load(ctx, &term, partial_sigs[i])) { + if (error_index != NULL) { + *error_index = i; + } + return 0; + } + secp256k1_scalar_add(&s, &s, &term); + } + /* Saved through the musig helper rather than copied: the two partial + * signature structs are the same size but carry different magics, so a + * struct copy would produce something musig_partial_sig_agg rejects. */ + secp256k1_musig_partial_sig_save(sig_out, &s); + return 1; +} + +#endif /* SECP256K1_MODULE_PREFRACTAL_SESSION_IMPL_H */ diff --git a/src/modules/prefractal/tests_impl.h b/src/modules/prefractal/tests_impl.h new file mode 100644 index 00000000..7a8026d8 --- /dev/null +++ b/src/modules/prefractal/tests_impl.h @@ -0,0 +1,14 @@ +#ifndef SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H +#define SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H + +#include "../../../include/secp256k1_prefractal.h" + +static void run_prefractal_smoke_test(void) { + CHECK(1); +} + +static const struct tf_test_entry tests_prefractal[] = { + CASE1(run_prefractal_smoke_test), +}; + +#endif /* SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H */ diff --git a/src/secp256k1.c b/src/secp256k1.c index d1b2f7c6..54b726e4 100644 --- a/src/secp256k1.c +++ b/src/secp256k1.c @@ -961,6 +961,10 @@ static int secp256k1_ge_parse_ext(secp256k1_ge* ge, const unsigned char *in33) { # include "modules/chilldkg/main_impl.h" #endif +#ifdef ENABLE_MODULE_PREFRACTAL +# include "modules/prefractal/main_impl.h" +#endif + #ifdef ENABLE_MODULE_ICEBERG # include "modules/iceberg/main_impl.h" #endif diff --git a/src/tests.c b/src/tests.c index 112c6f8d..61f94e91 100644 --- a/src/tests.c +++ b/src/tests.c @@ -7928,6 +7928,10 @@ static void run_ecdsa_wycheproof(void) { # include "modules/chilldkg/tests_impl.h" #endif +#ifdef ENABLE_MODULE_PREFRACTAL +# include "modules/prefractal/tests_impl.h" +#endif + #ifdef ENABLE_MODULE_ICEBERG # include "modules/iceberg/tests_impl.h" #endif @@ -8306,6 +8310,9 @@ static const struct tf_test_module registry_modules[] = { #ifdef ENABLE_MODULE_CHILLDKG MAKE_TEST_MODULE(chilldkg), #endif +#ifdef ENABLE_MODULE_PREFRACTAL + MAKE_TEST_MODULE(prefractal), +#endif #ifdef ENABLE_MODULE_ICEBERG MAKE_TEST_MODULE(iceberg), #endif From f0014c4c492877121c7a713b217617fb9fb1e743 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 4 Sep 2026 01:02:56 +0200 Subject: [PATCH 2/3] prefractal: add the module test suite Ten tests covering the round trip, the three deliberate deviations from BIP 445, and the failure modes the module cannot catch for its caller. The centrepiece is a pair of FIXED threshold secret keys, one whose threshold public key has even Y and one whose has odd Y. The nested equation has no g_frost factor, and the reason is NOT that the tweak cache is the identity: stock frost's key-side factor is g*gacc with g = -1 for an odd-Y threshold key (frost/session_impl.h:664, :797-800), so an implementation that imported frost's key-side parity works for even-Y groups and fails for odd-Y ones. With a randomly seeded fixture that is a coin flip per run. This was verified by mutation rather than assumed. Injecting the pk_odd negation into prefractal_session_values and rebuilding: run_prefractal_odd_y_group_key_test FAILED same test with the even-Y fixture PASSED so the fixed odd-Y fixture is what makes the trap detectable, and the parity of the fixture is itself asserted in the test so it cannot quietly stop testing what it is named after. The suite: midstate pins the Prefractal/noncecoef tagged hash against a freshly initialised one. Nothing else in the tree would notice a changed b_frost; it would just produce signatures that do not verify. e2e the round trip over {2-of-2, 3-of-2, 5-of-3, 4-of-3, 7-of-5} x both lexicographic positions of the group key x both group-key Y parities x with and without the outer BIP 341 tweak, against a stock musig cosigner, judged by secp256k1_schnorrsig_verify on the outer aggregate key. Both key orders matter because BIP 327 KeyAgg gives the second distinct key a coefficient of exactly 1. odd_y_group_key the odd-Y case alone, so a regression names its cause instead of surfacing as one iteration of that matrix. partial_sig_verify accepts a good share; rejects a tampered one, the right share against the wrong member, a share made for a different signer set (lambda_i is defined over the participating set), and a different message. identity_cache a tweaked frost cache is refused by sign, by partial_sig_verify and by partial_sig_agg, and the same agg call succeeds with the identity cache, so the refusal is about the tweak and not the arguments. key_cache_mismatch thresh_pk and the tweak cache must describe one key. infinity_nonce both nonce columns can reach infinity independently - the first is passed through unscaled, the second only after the b_frost multiplication - and a musig pubnonce can encode neither, so both are refused; the untouched set still aggregates. nonce_reuse the secnonce is wiped, including on calls that then fail for another reason, so a refused member cannot retry with the same nonce. Reuse takes the illegal-argument path via secnonce_load's ARG_CHECK, as stock frost_sign does, so it is checked with CHECK_ILLEGAL. negative_control wrong key order and a missing outer tweak both produce shares that are individually well formed and only fail at the final BIP 340 verification. These are the mistakes the module cannot catch for the caller, so the tests pin where they do surface. api duplicate identifiers, out-of-range counts, an id outside the signer set, a share not matching its pubshare, and NULL pubshares skipping that check as documented. Also fixes the outer tweak in the identity-cache test, which declared its throwaway output as secp256k1_pubkey where frost_pubkey_xonly_tweak_add wants secp256k1_xonly_pubkey. Both are data[64] so it ran correctly, but it is a type error and -Wincompatible-pointer-types flags it. Verified: cmake ... -DSECP256K1_ENABLE_MODULE_PREFRACTAL=ON -> 10/10 prefractal tests pass, full suite green ./configure --enable-experimental --enable-module-prefractal && make check -> 3/3 pass -Wall -Wextra -Wcast-align -Wshadow -Wundef -std=c89 -pedantic -> no warnings from any prefractal file --- src/modules/prefractal/tests_impl.h | 536 +++++++++++++++++++++++++++- 1 file changed, 533 insertions(+), 3 deletions(-) diff --git a/src/modules/prefractal/tests_impl.h b/src/modules/prefractal/tests_impl.h index 7a8026d8..8fb82b38 100644 --- a/src/modules/prefractal/tests_impl.h +++ b/src/modules/prefractal/tests_impl.h @@ -1,14 +1,544 @@ +/*********************************************************************** + * Distributed under the MIT software license, see the accompanying * + * file COPYING or https://www.opensource.org/licenses/mit-license.php.* + ***********************************************************************/ + #ifndef SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H #define SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H #include "../../../include/secp256k1_prefractal.h" -static void run_prefractal_smoke_test(void) { - CHECK(1); +/* Two FIXED threshold secret keys, one of each Y parity of the resulting + * threshold public key. + * + * The odd one is the point of this pair. The nested equation has no g_frost + * factor, and the reason is NOT that the tweak cache is the identity: stock + * frost's key-side factor is g*gacc with g = -1 for an odd-Y threshold key, so + * an implementation that imported frost's key-side parity would work for + * even-Y groups and fail for odd-Y ones. A randomly seeded fixture makes that + * a coin flip per run, so both parities are pinned here instead. */ +static const unsigned char prefractal_test_seckey_even[32] = { + 0x44, 0xa2, 0x82, 0x5e, 0x46, 0x26, 0xfa, 0x53, + 0xf5, 0x2c, 0x2e, 0x6a, 0x40, 0x7a, 0xfc, 0xb9, + 0xb7, 0xe8, 0x7d, 0x63, 0x30, 0x6b, 0x0d, 0x69, + 0xae, 0x3d, 0x0d, 0x29, 0xeb, 0x6c, 0xa6, 0x08 +}; +static const unsigned char prefractal_test_seckey_odd[32] = { + 0xd3, 0x27, 0x59, 0x3f, 0xe7, 0x53, 0xf6, 0xfd, + 0xe3, 0x8f, 0x29, 0xfd, 0x26, 0x39, 0xd4, 0x4f, + 0x62, 0x05, 0x4b, 0xab, 0xea, 0x21, 0xa3, 0x59, + 0xa4, 0x1d, 0x65, 0x1c, 0x81, 0xf1, 0xe0, 0x1e +}; + +/* Everything one nested session needs, so the tests below can set one up in a + * line and then poke at individual pieces. */ +typedef struct { + unsigned int n, t; + unsigned char secshares[SECP256K1_FROST_MAX_PARTICIPANTS][32]; + secp256k1_pubkey pubshares[SECP256K1_FROST_MAX_PARTICIPANTS]; + secp256k1_pubkey thresh_pk; + secp256k1_frost_tweak_cache tweak_cache; + uint32_t ids[SECP256K1_FROST_MAX_PARTICIPANTS]; + + secp256k1_frost_secnonce secnonces[SECP256K1_FROST_MAX_PARTICIPANTS]; + secp256k1_frost_pubnonce pubnonces[SECP256K1_FROST_MAX_PARTICIPANTS]; + const secp256k1_frost_pubnonce *pubnonce_ptrs[SECP256K1_FROST_MAX_PARTICIPANTS]; + secp256k1_frost_aggnonce aggnonce; + secp256k1_musig_pubnonce group_pubnonce; + + secp256k1_keypair cosigner_keypair; + secp256k1_pubkey cosigner_pk; + secp256k1_xonly_pubkey agg_xonly; + secp256k1_musig_keyagg_cache keyagg_cache; + secp256k1_musig_secnonce cosigner_secnonce; + secp256k1_musig_pubnonce cosigner_pubnonce; + secp256k1_musig_aggnonce cosigner_aggnonce; + secp256k1_musig_aggnonce full_aggnonce; + + unsigned char msg[32]; +} prefractal_test_session; + +/* Deal a group, build the outer 2-key aggregation with one stock musig + * cosigner, and run round one on both sides. + * + * group_first picks which side of the outer aggregation the group key sits on. + * BIP 327 KeyAgg gives the "second" distinct key a coefficient of exactly 1 + * and hashes every other one, so the two orders exercise genuinely different + * arithmetic for the group; both are run everywhere below. + * + * xonly_tweak applies the BIP 341 style tweak to the OUTER cache only, which + * is the arrangement the channel protocols use. */ +static void prefractal_test_setup(prefractal_test_session *s, unsigned int n, unsigned int t, + const unsigned char *thresh_seckey, int group_first, + const unsigned char *xonly_tweak) { + const secp256k1_pubkey *pubkeys[2]; + const secp256k1_musig_pubnonce *just_cosigner[1]; + unsigned char cosigner_seckey[32], secrand[32], thresh_pk32[32]; + secp256k1_xonly_pubkey thresh_xonly; + unsigned int k; + + s->n = n; + s->t = t; + testrand256(s->msg); + testrand256(cosigner_seckey); + + CHECK(secp256k1_frost_trusted_dealer_keygen(CTX, s->secshares[0], &s->thresh_pk, + s->pubshares, n, t, thresh_seckey) == 1); + CHECK(secp256k1_frost_tweak_cache_init(CTX, &s->tweak_cache, &s->thresh_pk) == 1); + for (k = 0; k < n; k++) { + s->ids[k] = k; + } + + /* The group's key enters the outer aggregation as an ordinary public key, + * in full. */ + CHECK(secp256k1_keypair_create(CTX, &s->cosigner_keypair, cosigner_seckey) == 1); + CHECK(secp256k1_keypair_pub(CTX, &s->cosigner_pk, &s->cosigner_keypair) == 1); + pubkeys[group_first ? 0 : 1] = &s->thresh_pk; + pubkeys[group_first ? 1 : 0] = &s->cosigner_pk; + CHECK(secp256k1_musig_pubkey_agg(CTX, &s->agg_xonly, &s->keyagg_cache, pubkeys, 2) == 1); + if (xonly_tweak != NULL) { + secp256k1_pubkey tweaked; + CHECK(secp256k1_musig_pubkey_xonly_tweak_add(CTX, &tweaked, &s->keyagg_cache, xonly_tweak) == 1); + CHECK(secp256k1_xonly_pubkey_from_pubkey(CTX, &s->agg_xonly, NULL, &tweaked) == 1); + } + + /* Round one, cosigner side. */ + testrand256(secrand); + CHECK(secp256k1_musig_nonce_gen(CTX, &s->cosigner_secnonce, &s->cosigner_pubnonce, secrand, + cosigner_seckey, &s->cosigner_pk, s->msg, &s->keyagg_cache, NULL) == 1); + just_cosigner[0] = &s->cosigner_pubnonce; + CHECK(secp256k1_musig_nonce_agg(CTX, &s->cosigner_aggnonce, just_cosigner, 1) == 1); + + /* Round one, group side. The nonces are generated the way the module + * expects them: msg = NULL, because the wire nonce is published before the + * message exists. */ + CHECK(secp256k1_xonly_pubkey_from_pubkey(CTX, &thresh_xonly, NULL, &s->thresh_pk) == 1); + CHECK(secp256k1_xonly_pubkey_serialize(CTX, thresh_pk32, &thresh_xonly) == 1); + for (k = 0; k < t; k++) { + testrand256(secrand); + CHECK(secp256k1_frost_nonce_gen(CTX, &s->secnonces[k], &s->pubnonces[k], secrand, + s->secshares[k], &s->pubshares[k], thresh_pk32, + NULL, 0, NULL, 0) == 1); + s->pubnonce_ptrs[k] = &s->pubnonces[k]; + } + CHECK(secp256k1_prefractal_nonce_agg(CTX, &s->group_pubnonce, &s->aggnonce, + s->pubnonce_ptrs, s->ids, t, &s->thresh_pk) == 1); + + { + const secp256k1_musig_pubnonce *all[2]; + all[group_first ? 0 : 1] = &s->group_pubnonce; + all[group_first ? 1 : 0] = &s->cosigner_pubnonce; + CHECK(secp256k1_musig_nonce_agg(CTX, &s->full_aggnonce, all, 2) == 1); + } +} + +/* Round two on both sides, ending in a BIP 340 signature over the outer + * aggregate key. Returns what secp256k1_schnorrsig_verify says about it. */ +static int prefractal_test_finish(prefractal_test_session *s, int group_first, unsigned char *sig64) { + secp256k1_frost_partial_sig psigs[SECP256K1_FROST_MAX_PARTICIPANTS]; + const secp256k1_frost_partial_sig *psig_ptrs[SECP256K1_FROST_MAX_PARTICIPANTS]; + const secp256k1_musig_partial_sig *musig_psigs[2]; + secp256k1_musig_partial_sig group_psig, cosigner_psig; + secp256k1_musig_session session; + unsigned int k; + + CHECK(secp256k1_musig_nonce_process(CTX, &session, &s->full_aggnonce, s->msg, &s->keyagg_cache, NULL) == 1); + CHECK(secp256k1_musig_partial_sign(CTX, &cosigner_psig, &s->cosigner_secnonce, + &s->cosigner_keypair, &s->keyagg_cache, &session) == 1); + + for (k = 0; k < s->t; k++) { + CHECK(secp256k1_prefractal_sign(CTX, &psigs[k], &s->secnonces[k], s->secshares[k], + s->ids[k], s->ids, s->pubshares, s->t, &s->aggnonce, + &s->thresh_pk, &s->tweak_cache, &s->keyagg_cache, + &s->cosigner_aggnonce, s->msg) == 1); + psig_ptrs[k] = &psigs[k]; + /* Every share verifies against its author's public share. */ + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psigs[k], &s->pubnonces[k], + &s->pubshares[k], s->ids[k], s->ids, s->t, + &s->aggnonce, &s->thresh_pk, &s->tweak_cache, + &s->keyagg_cache, &s->cosigner_aggnonce, + s->msg) == 1); + } + CHECK(secp256k1_prefractal_partial_sig_agg(CTX, &group_psig, NULL, psig_ptrs, s->t, + &s->tweak_cache) == 1); + + musig_psigs[group_first ? 0 : 1] = &group_psig; + musig_psigs[group_first ? 1 : 0] = &cosigner_psig; + CHECK(secp256k1_musig_partial_sig_agg(CTX, sig64, &session, musig_psigs, 2) == 1); + return secp256k1_schnorrsig_verify(CTX, sig64, s->msg, 32, &s->agg_xonly); +} + +/* The round trip, over every t-of-n this module claims to support, both + * lexicographic positions of the group key, both group-key Y parities, and + * with and without the outer BIP 341 tweak. */ +static void run_prefractal_e2e_test(void) { + static const unsigned char configs[][2] = { {2,2}, {3,2}, {5,3}, {4,3}, {7,5} }; + size_t config; + int group_first, odd, tweaked; + + for (config = 0; config < sizeof(configs) / sizeof(configs[0]); config++) { + for (group_first = 0; group_first <= 1; group_first++) { + for (odd = 0; odd <= 1; odd++) { + for (tweaked = 0; tweaked <= 1; tweaked++) { + prefractal_test_session s; + unsigned char sig[64], tweak[32]; + testrand256(tweak); + prefractal_test_setup(&s, configs[config][0], configs[config][1], + odd ? prefractal_test_seckey_odd : prefractal_test_seckey_even, + group_first, tweaked ? tweak : NULL); + CHECK(prefractal_test_finish(&s, group_first, sig) == 1); + } + } + } + } +} + +/* The odd-Y case on its own, so a failure names the cause rather than showing + * up as one iteration of the matrix above. If the g_frost factor were imported + * from stock frost, this test would fail and the even-Y one would pass. */ +static void run_prefractal_odd_y_group_key_test(void) { + prefractal_test_session s; + secp256k1_xonly_pubkey xonly; + unsigned char sig[64], ser[32]; + int parity = -1; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + /* Confirm the fixture really is odd-Y, so the test cannot quietly stop + * testing what it is named after. */ + CHECK(secp256k1_xonly_pubkey_from_pubkey(CTX, &xonly, &parity, &s.thresh_pk) == 1); + CHECK(secp256k1_xonly_pubkey_serialize(CTX, ser, &xonly) == 1); + CHECK(parity == 1); + CHECK(prefractal_test_finish(&s, 1, sig) == 1); +} + +/* partial_sig_verify is the identifiable-abort tool, so it has to say no to + * everything that is not exactly the share it was asked about. */ +static void run_prefractal_partial_sig_verify_test(void) { + prefractal_test_session s; + secp256k1_frost_partial_sig psig, altered; + uint32_t wrong_ids[SECP256K1_FROST_MAX_PARTICIPANTS]; + unsigned char other_msg[32]; + unsigned int k; + + prefractal_test_setup(&s, 5, 3, prefractal_test_seckey_odd, 1, NULL); + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[0], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 1); + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[0], &s.pubshares[0], + s.ids[0], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 1); + + /* A tampered share. */ + altered = psig; + altered.data[10] ^= 0x40; + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &altered, &s.pubnonces[0], &s.pubshares[0], + s.ids[0], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 0); + + /* The right share against the wrong member's public share and nonce. */ + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[1], &s.pubshares[1], + s.ids[1], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 0); + + /* A different signer set. lambda_i is defined over the participating set, + * so a share made for one set does not verify under another even though + * every other argument is unchanged. */ + for (k = 0; k < s.t; k++) { + wrong_ids[k] = s.ids[k]; + } + wrong_ids[s.t - 1] = s.ids[s.t]; + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[0], &s.pubshares[0], + s.ids[0], wrong_ids, s.t, &s.aggnonce, + &s.thresh_pk, &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 0); + + /* A different message gives the same 0 as a bad share, which is why a 0 is + * not by itself evidence about a member. */ + memcpy(other_msg, s.msg, 32); + other_msg[0] ^= 1; + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[0], &s.pubshares[0], + s.ids[0], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, other_msg) == 0); +} + +/* A non-identity frost tweak cache is refused by all three entry points that + * take one. The check is in sign as well as agg so the key a member signs + * under is tied to the cache that was validated. */ +static void run_prefractal_identity_cache_test(void) { + prefractal_test_session s; + secp256k1_frost_tweak_cache tweaked; + secp256k1_frost_partial_sig psig; + const secp256k1_frost_partial_sig *psig_ptrs[1]; + secp256k1_musig_partial_sig out; + secp256k1_xonly_pubkey ignored; + unsigned char tweak[32]; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + testrand256(tweak); + tweaked = s.tweak_cache; + CHECK(secp256k1_frost_pubkey_xonly_tweak_add(CTX, &ignored, &tweaked, tweak) == 1); + + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[0], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &tweaked, &s.keyagg_cache, &s.cosigner_aggnonce, s.msg) == 0); + + /* A good share, then verification and aggregation handed the tweaked + * cache. The secnonce above was consumed even on the failure path, so this + * uses the other member's. */ + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[1], s.secshares[1], s.ids[1], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 1); + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[1], &s.pubshares[1], + s.ids[1], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &tweaked, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 0); + psig_ptrs[0] = &psig; + CHECK(secp256k1_prefractal_partial_sig_agg(CTX, &out, NULL, psig_ptrs, 1, &tweaked) == 0); + /* The identity cache is accepted at the same call, so the refusal above is + * about the tweak and not about the arguments in general. */ + CHECK(secp256k1_prefractal_partial_sig_agg(CTX, &out, NULL, psig_ptrs, 1, &s.tweak_cache) == 1); +} + +/* thresh_pk and the tweak cache have to describe the same key. Passing a cache + * built for some other group is the mistake this catches. */ +static void run_prefractal_key_cache_mismatch_test(void) { + prefractal_test_session s; + secp256k1_frost_tweak_cache other_cache; + secp256k1_pubkey other_pk, other_pubshares[8]; + unsigned char other_secshares[8][32]; + secp256k1_frost_partial_sig psig; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + CHECK(secp256k1_frost_trusted_dealer_keygen(CTX, other_secshares[0], &other_pk, + other_pubshares, 3, 2, + prefractal_test_seckey_even) == 1); + CHECK(secp256k1_frost_tweak_cache_init(CTX, &other_cache, &other_pk) == 1); + + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[0], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &other_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 0); +} + +/* A musig pubnonce cannot encode the point at infinity, so a group whose + * aggregate nonce lands there has to be told rather than handed something + * unusable. Either column can do it independently: the first is passed through + * unscaled, the second is scaled by b_frost first. + * + * Both are reached the same way. A pubnonce and its negation sum to infinity + * in both columns at once, so the first column is tested with that pair, and + * for the second the members' second-column points are made to cancel while + * the first column does not. */ +static void run_prefractal_infinity_nonce_test(void) { + prefractal_test_session s; + secp256k1_frost_pubnonce negated; + const secp256k1_frost_pubnonce *ptrs[2]; + secp256k1_musig_pubnonce pubnonce_out; + secp256k1_frost_aggnonce aggnonce_out; + unsigned char ser[66], neg_ser[66]; + secp256k1_pubkey pt; + uint32_t ids[2]; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + ids[0] = s.ids[0]; + ids[1] = s.ids[1]; + + /* Negate both columns of member 0's nonce: every column then sums to + * infinity. */ + CHECK(secp256k1_frost_pubnonce_serialize(CTX, ser, &s.pubnonces[0]) == 1); + CHECK(secp256k1_ec_pubkey_parse(CTX, &pt, &ser[0], 33) == 1); + CHECK(secp256k1_ec_pubkey_negate(CTX, &pt) == 1); + { + size_t len = 33; + CHECK(secp256k1_ec_pubkey_serialize(CTX, &neg_ser[0], &len, &pt, SECP256K1_EC_COMPRESSED) == 1); + } + CHECK(secp256k1_ec_pubkey_parse(CTX, &pt, &ser[33], 33) == 1); + CHECK(secp256k1_ec_pubkey_negate(CTX, &pt) == 1); + { + size_t len = 33; + CHECK(secp256k1_ec_pubkey_serialize(CTX, &neg_ser[33], &len, &pt, SECP256K1_EC_COMPRESSED) == 1); + } + CHECK(secp256k1_frost_pubnonce_parse(CTX, &negated, neg_ser) == 1); + + ptrs[0] = &s.pubnonces[0]; + ptrs[1] = &negated; + CHECK(secp256k1_prefractal_nonce_agg(CTX, &pubnonce_out, &aggnonce_out, ptrs, ids, 2, + &s.thresh_pk) == 0); + + /* Now cancel only the second column: first column R1 + R1 is not infinity, + * second column R2 + (-R2) is. This is the scaled component, so it reaches + * the guard only after the b_frost multiplication. */ + memcpy(&neg_ser[0], &ser[0], 33); + CHECK(secp256k1_frost_pubnonce_parse(CTX, &negated, neg_ser) == 1); + CHECK(secp256k1_prefractal_nonce_agg(CTX, &pubnonce_out, &aggnonce_out, ptrs, ids, 2, + &s.thresh_pk) == 0); + + /* The untouched set still works, so the refusals above are about the + * infinities and not about this arrangement of arguments. */ + ptrs[1] = &s.pubnonces[1]; + CHECK(secp256k1_prefractal_nonce_agg(CTX, &pubnonce_out, &aggnonce_out, ptrs, ids, 2, + &s.thresh_pk) == 1); +} + +/* A secnonce is single use. sign wipes it, so the second call cannot produce + * the second signature that would expose the share. The wiped secnonce is + * caught by the magic check inside secp256k1_frost_secnonce_load, which is an + * ARG_CHECK and so reaches the illegal-argument callback rather than returning + * 0 - the same way stock secp256k1_frost_sign behaves. */ +static void run_prefractal_nonce_reuse_test(void) { + prefractal_test_session s; + secp256k1_frost_partial_sig psig; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[0], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 1); + CHECK(secp256k1_is_zero_array(s.secnonces[0].data, sizeof(s.secnonces[0].data))); + CHECK_ILLEGAL(CTX, secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], + s.ids[0], s.ids, s.pubshares, s.t, &s.aggnonce, + &s.thresh_pk, &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg)); + + /* Every call that reaches the secnonce load wipes it, including the ones + * that then fail for another reason. A member whose signing attempt was + * refused must not retry with the same nonce. */ + CHECK(secp256k1_is_zero_array(s.secnonces[1].data, sizeof(s.secnonces[1].data)) == 0); + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[1], s.secshares[1], s.ids[2], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 0); + CHECK(secp256k1_is_zero_array(s.secnonces[1].data, sizeof(s.secnonces[1].data))); +} + +/* Negative controls: shares that are individually well formed and only fail at + * the very end, against the outer aggregate. These are the failures the module + * cannot catch for the caller, so the tests pin where they do surface. */ +static void run_prefractal_negative_control_test(void) { + prefractal_test_session s; + unsigned char sig[64], tweak[32]; + + /* Wrong key order: the group signs under a cache that aggregates the two + * keys in the other order, so a_musig is wrong. Every partial signature is + * valid on its own terms and the final signature does not verify. */ + testrand256(tweak); + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, NULL); + { + const secp256k1_pubkey *swapped[2]; + secp256k1_musig_keyagg_cache wrong_cache; + secp256k1_xonly_pubkey ignored; + swapped[0] = &s.cosigner_pk; + swapped[1] = &s.thresh_pk; + CHECK(secp256k1_musig_pubkey_agg(CTX, &ignored, &wrong_cache, swapped, 2) == 1); + s.keyagg_cache = wrong_cache; + /* finish() would trip its own internal partial_sig_verify CHECKs only + * if the shares disagreed with the session; they do not, because the + * session is rebuilt from this same wrong cache. What fails is the + * final BIP 340 verification against the ORIGINAL aggregate key. */ + CHECK(prefractal_test_finish(&s, 1, sig) == 0); + } + + /* Missing tweak: the group signs under the untweaked outer cache while the + * signature is checked against the tweaked aggregate key. */ + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_odd, 1, tweak); + { + const secp256k1_pubkey *pubkeys[2]; + secp256k1_musig_keyagg_cache untweaked; + secp256k1_xonly_pubkey ignored; + pubkeys[0] = &s.thresh_pk; + pubkeys[1] = &s.cosigner_pk; + CHECK(secp256k1_musig_pubkey_agg(CTX, &ignored, &untweaked, pubkeys, 2) == 1); + s.keyagg_cache = untweaked; + CHECK(prefractal_test_finish(&s, 1, sig) == 0); + } +} + +/* Argument checking, and the bounds the module states in its header. */ +static void run_prefractal_api_test(void) { + prefractal_test_session s; + secp256k1_musig_pubnonce pubnonce_out; + secp256k1_frost_aggnonce aggnonce_out; + secp256k1_frost_partial_sig psig; + const secp256k1_frost_partial_sig *psig_ptrs[1]; + secp256k1_musig_partial_sig out; + uint32_t dup_ids[2]; + + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_even, 1, NULL); + + /* Duplicate identifiers make lambda_i undefined, so nonce_agg refuses + * rather than producing a nonce nobody can sign against. */ + dup_ids[0] = s.ids[0]; + dup_ids[1] = s.ids[0]; + CHECK(secp256k1_prefractal_nonce_agg(CTX, &pubnonce_out, &aggnonce_out, s.pubnonce_ptrs, + dup_ids, 2, &s.thresh_pk) == 0); + /* n_signers out of range is a caller bug, not a peer-influenced value, so + * it takes the illegal-argument path rather than returning 0 - the same + * choice the frost module makes for its own counts. */ + CHECK_ILLEGAL(CTX, secp256k1_prefractal_nonce_agg(CTX, &pubnonce_out, &aggnonce_out, + s.pubnonce_ptrs, s.ids, 0, &s.thresh_pk)); + + /* An id that is not in the signer set. */ + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[2], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 0); + + /* A secret share that does not match the pubshare it is claimed for. */ + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[1], s.secshares[0], s.ids[1], + s.ids, s.pubshares, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 0); + + /* Passing NULL pubshares skips that check, which is what the header says it + * does; the signature is still correct. */ + prefractal_test_setup(&s, 3, 2, prefractal_test_seckey_even, 1, NULL); + CHECK(secp256k1_prefractal_sign(CTX, &psig, &s.secnonces[0], s.secshares[0], s.ids[0], + s.ids, NULL, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, &s.cosigner_aggnonce, + s.msg) == 1); + CHECK(secp256k1_prefractal_partial_sig_verify(CTX, &psig, &s.pubnonces[0], &s.pubshares[0], + s.ids[0], s.ids, s.t, &s.aggnonce, &s.thresh_pk, + &s.tweak_cache, &s.keyagg_cache, + &s.cosigner_aggnonce, s.msg) == 1); + + /* n_sigs out of range, likewise. */ + psig_ptrs[0] = &psig; + CHECK_ILLEGAL(CTX, secp256k1_prefractal_partial_sig_agg(CTX, &out, NULL, psig_ptrs, 0, + &s.tweak_cache)); +} + +/* The tagged hash this module defines. Pinning the midstate here is what keeps + * a rewrite of the constant honest: nothing else in the tree would notice a + * changed b_frost, it would just produce signatures that do not verify. */ +static void run_prefractal_midstate_test(void) { + secp256k1_sha256 sha, sha_tagged; + unsigned char tag[] = "Prefractal/noncecoef"; + unsigned char buf[32], buf_tagged[32]; + + secp256k1_sha256_initialize_tagged(secp256k1_get_hash_context(CTX), &sha, tag, sizeof(tag) - 1); + secp256k1_prefractal_noncecoef_sha256_tagged(&sha_tagged); + secp256k1_sha256_finalize(secp256k1_get_hash_context(CTX), &sha, buf); + secp256k1_sha256_finalize(secp256k1_get_hash_context(CTX), &sha_tagged, buf_tagged); + CHECK(secp256k1_memcmp_var(buf, buf_tagged, 32) == 0); } static const struct tf_test_entry tests_prefractal[] = { - CASE1(run_prefractal_smoke_test), + CASE1(run_prefractal_midstate_test), + CASE1(run_prefractal_e2e_test), + CASE1(run_prefractal_odd_y_group_key_test), + CASE1(run_prefractal_partial_sig_verify_test), + CASE1(run_prefractal_identity_cache_test), + CASE1(run_prefractal_key_cache_mismatch_test), + CASE1(run_prefractal_infinity_nonce_test), + CASE1(run_prefractal_nonce_reuse_test), + CASE1(run_prefractal_negative_control_test), + CASE1(run_prefractal_api_test), }; #endif /* SECP256K1_MODULE_PREFRACTAL_TESTS_IMPL_H */ From b66c757b7fd5ae47f62b2a2c2cb37896da60da58 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 4 Sep 2026 01:06:14 +0200 Subject: [PATCH 3/3] prefractal: document the deviations and add the constant-time test 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. --- doc/prefractal.md | 265 ++++++++++++++++++++++++++++++++++++++++++++++ src/ctime_tests.c | 98 +++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 doc/prefractal.md 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