From f0014c4c492877121c7a713b217617fb9fb1e743 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 4 Sep 2026 01:02:56 +0200 Subject: [PATCH] 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 */