Files
secp256k1-zkp/src/modules/frost_enrollment/tests_impl.h

1149 lines
57 KiB
C
Raw Normal View History

build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
/***********************************************************************
* Distributed under the MIT software license, see the accompanying *
* file COPYING or https://www.opensource.org/licenses/mit-license.php.*
***********************************************************************/
#ifndef SECP256K1_MODULE_FROST_ENROLLMENT_TESTS_IMPL_H
#define SECP256K1_MODULE_FROST_ENROLLMENT_TESTS_IMPL_H
#include "../../../include/secp256k1_frost_enrollment.h"
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
#include "../../../include/secp256k1_schnorrsig.h"
#include "vectors.h"
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
/* Everything one enrollment run needs, so a test can set one up in a line and
* then poke at individual pieces. */
typedef struct {
size_t n, t, u;
uint32_t new_id;
unsigned char thresh_sk[32];
unsigned char secshares[SECP256K1_FROST_MAX_PARTICIPANTS][32];
secp256k1_pubkey pubshares[SECP256K1_FROST_MAX_PARTICIPANTS];
secp256k1_pubkey thresh_pk;
uint32_t ids[SECP256K1_FROST_MAX_PARTICIPANTS];
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* shares[i] is helper ids[i]'s round 1.1 output buffer, aligned with ids:
* entry j is what helper ids[i] produced for helper ids[j]. */
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
unsigned char shares[SECP256K1_FROST_MAX_PARTICIPANTS][SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char params_hashes[SECP256K1_FROST_MAX_PARTICIPANTS][32];
unsigned char sigmas[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
secp256k1_pubkey new_pubshare;
unsigned char new_secshare[32];
} frost_enrollment_test_run;
/* Deals a fresh (t, n) group and fills in the helper set: the first u
* identifiers that are not new_id, in ascending order. */
static void frost_enrollment_test_deal(frost_enrollment_test_run *r, size_t n, size_t t, size_t u, uint32_t new_id) {
size_t i, k;
r->n = n;
r->t = t;
r->u = u;
r->new_id = new_id;
testrand256(r->thresh_sk);
CHECK(secp256k1_frost_trusted_dealer_keygen(CTX, r->secshares[0], &r->thresh_pk, r->pubshares, n, (uint32_t)t, r->thresh_sk) == 1);
k = 0;
for (i = 0; i < n && k < u; i++) {
if ((uint32_t)i == new_id) {
continue;
}
r->ids[k] = (uint32_t)i;
k++;
}
CHECK(k == u);
}
/* Runs round 1.1 for every helper. */
static void frost_enrollment_test_round1_gen(frost_enrollment_test_run *r) {
size_t i;
for (i = 0; i < r->u; i++) {
unsigned char secrand[32];
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, r->shares[i], r->params_hashes[i], secrand, r->secshares[r->ids[i]], &r->thresh_pk, r->ids, r->u, r->ids[i], r->new_id, r->n, (uint32_t)r->t) == 1);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* The seed is consumed by the call. */
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
}
}
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* Assembles helper j's round 1.2 inputs out of the round 1.1 outputs: the
* share kept at its own position, the shares received at the others', and the
* received parameters hashes with its own slot left zero. */
static void frost_enrollment_test_collect(const frost_enrollment_test_run *r, size_t j, unsigned char *all_shares, unsigned char *received) {
size_t i;
memset(received, 0, r->u * 32);
for (i = 0; i < r->u; i++) {
memcpy(&all_shares[32 * i], &r->shares[i][32 * j], 32);
if (i != j) {
memcpy(&received[32 * i], r->params_hashes[i], 32);
}
}
}
/* Runs round 1.2 for every helper. */
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
static void frost_enrollment_test_round1_agg(frost_enrollment_test_run *r) {
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
size_t j;
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
for (j = 0; j < r->u; j++) {
unsigned char all_shares[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char received[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
uint32_t mismatch_id = 0;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
frost_enrollment_test_collect(r, j, all_shares, received);
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
CHECK(secp256k1_frost_enrollment_share_agg(CTX, &r->sigmas[32 * j], &mismatch_id, all_shares, received, &r->thresh_pk, r->ids, r->u, r->ids[j], r->new_id, r->n, (uint32_t)r->t) == 1);
CHECK(mismatch_id == UINT32_MAX);
}
}
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* Re-points an already-dealt run at a different helper set, so that two runs
* can be compared over identical key material without copying the (large)
* run struct or hand-editing its fields. */
static void frost_enrollment_test_use_helpers(frost_enrollment_test_run *r, const uint32_t *ids, size_t u) {
CHECK(u > 0 && u <= SECP256K1_FROST_MAX_PARTICIPANTS);
memcpy(r->ids, ids, u * sizeof(*ids));
r->u = u;
}
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* Gathers the helpers' public shares into an array aligned with ids. The
* run's own table is indexed by participant id, which only coincides with the
* ids alignment when the helper set happens to be 0..u-1 -- exactly the
* confusion the API documentation warns about. */
static void frost_enrollment_test_helper_pubshares(const frost_enrollment_test_run *r, secp256k1_pubkey *out) {
size_t i;
for (i = 0; i < r->u; i++) {
out[i] = r->pubshares[r->ids[i]];
}
}
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
/* Runs round 2, with both optional checks enabled. */
static void frost_enrollment_test_round2(frost_enrollment_test_run *r) {
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
secp256k1_pubkey helper_pubshares[SECP256K1_FROST_MAX_PARTICIPANTS];
frost_enrollment_test_helper_pubshares(r, helper_pubshares);
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &r->new_pubshare, helper_pubshares, r->ids, r->u, r->new_id, r->n, (uint32_t)r->t) == 1);
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, r->new_secshare, r->sigmas, &r->thresh_pk, r->ids, r->u, r->new_id, r->n, (uint32_t)r->t, r->params_hashes[0], &r->new_pubshare) == 1);
}
static void frost_enrollment_test_full_run(frost_enrollment_test_run *r, size_t n, size_t t, size_t u, uint32_t new_id) {
frost_enrollment_test_deal(r, n, t, u, new_id);
frost_enrollment_test_round1_gen(r);
frost_enrollment_test_round1_agg(r);
frost_enrollment_test_round2(r);
}
/* Reconstructs the threshold secret from the shares of the given identifiers
* and checks it against the threshold public key. shares[k] must be the share
* of ids[k]. */
static void frost_enrollment_test_check_reconstruction(const uint32_t *ids, const unsigned char *const *shares, size_t n_ids, const secp256k1_pubkey *thresh_pk) {
secp256k1_scalar secret, share, lambda;
secp256k1_ge pk, expected;
secp256k1_gej pkj;
size_t i;
secp256k1_scalar_set_int(&secret, 0);
for (i = 0; i < n_ids; i++) {
CHECK(secp256k1_frost_derive_interpolating_value(&lambda, ids, n_ids, ids[i]) == 1);
CHECK(secp256k1_scalar_set_b32_seckey(&share, shares[i]) == 1);
secp256k1_scalar_mul(&share, &share, &lambda);
secp256k1_scalar_add(&secret, &secret, &share);
}
CHECK(!secp256k1_scalar_is_zero(&secret));
secp256k1_ecmult_gen_gej(&CTX->ecmult_gen_ctx, &pkj, &secret);
secp256k1_ge_set_gej(&pk, &pkj);
CHECK(secp256k1_pubkey_load(CTX, &expected, thresh_pk) == 1);
CHECK(secp256k1_ge_eq_var(&pk, &expected) == 1);
}
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* Produces and verifies a BIP340 signature with the given signer set. shares
* and pubshares must be aligned with ids. */
static void frost_enrollment_test_sign(const uint32_t *ids, const unsigned char *const *shares, const secp256k1_pubkey *pubshares, size_t n_signers, size_t n_participants, size_t threshold, const secp256k1_pubkey *thresh_pk) {
secp256k1_frost_tweak_cache cache;
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_partial_sig partial_sigs[SECP256K1_FROST_MAX_PARTICIPANTS];
const secp256k1_frost_partial_sig *partial_sig_ptrs[SECP256K1_FROST_MAX_PARTICIPANTS];
secp256k1_frost_aggnonce aggnonce;
secp256k1_frost_session session;
secp256k1_xonly_pubkey tweaked_pk;
unsigned char tweaked_pk32[32];
unsigned char msg[32];
unsigned char sig64[64];
size_t i;
testrand256(msg);
CHECK(secp256k1_frost_tweak_cache_init(CTX, &cache, thresh_pk) == 1);
CHECK(secp256k1_frost_tweaked_pubkey_get(CTX, &tweaked_pk, &cache) == 1);
CHECK(secp256k1_xonly_pubkey_serialize(CTX, tweaked_pk32, &tweaked_pk) == 1);
for (i = 0; i < n_signers; i++) {
unsigned char secrand[32];
testrand256(secrand);
CHECK(secp256k1_frost_nonce_gen(CTX, &secnonces[i], &pubnonces[i], secrand, shares[i], &pubshares[i], tweaked_pk32, msg, sizeof(msg), NULL, 0) == 1);
pubnonce_ptrs[i] = &pubnonces[i];
}
CHECK(secp256k1_frost_nonce_agg(CTX, &aggnonce, NULL, pubnonce_ptrs, n_signers) == 1);
CHECK(secp256k1_frost_session_init(CTX, &session, &aggnonce, ids, pubshares, n_signers, n_participants, (uint32_t)threshold, &cache, msg, sizeof(msg)) == 1);
for (i = 0; i < n_signers; i++) {
CHECK(secp256k1_frost_sign(CTX, &partial_sigs[i], &secnonces[i], shares[i], &session, ids, pubshares, n_signers, ids[i]) == 1);
CHECK(secp256k1_frost_partial_sig_verify(CTX, &partial_sigs[i], &pubnonces[i], &pubshares[i], &session, ids, n_signers, i) == 1);
partial_sig_ptrs[i] = &partial_sigs[i];
}
CHECK(secp256k1_frost_partial_sig_agg(CTX, sig64, NULL, &session, partial_sig_ptrs, n_signers) == 1);
CHECK(secp256k1_schnorrsig_verify(CTX, sig64, msg, sizeof(msg), &tweaked_pk) == 1);
}
/* PoC test_generate_frost_share: a 2-of-3 group grows to 2-of-4, and the new
* share sits on the same polynomial as the old ones. Every threshold-sized
* subset containing the new participant reconstructs the original threshold
* secret, and so does the untouched original pair. */
static void run_frost_enrollment_reconstruction_test(void) {
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
frost_enrollment_test_run r;
const unsigned char *shares[2];
uint32_t ids[2];
size_t i;
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
for (i = 0; i < 3; i++) {
ids[0] = (uint32_t)i;
ids[1] = 3;
shares[0] = r.secshares[i];
shares[1] = r.new_secshare;
frost_enrollment_test_check_reconstruction(ids, shares, 2, &r.thresh_pk);
}
ids[0] = 0;
ids[1] = 1;
shares[0] = r.secshares[0];
shares[1] = r.secshares[1];
frost_enrollment_test_check_reconstruction(ids, shares, 2, &r.thresh_pk);
}
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* PoC test_sign: a real BIP340 signature from a signer set that includes the
* enrolled participant, over the unchanged threshold public key. Also the
* n -> n+1 bookkeeping: the extended public share table must still satisfy
* secp256k1_frost_threshold_info_validate at n+1. */
static void run_frost_enrollment_signing_test(void) {
frost_enrollment_test_run r;
secp256k1_pubkey pubshares[4];
const unsigned char *shares[2];
uint32_t ids[2];
size_t i;
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
/* The extended table: the three original public shares plus the derived
* one at the new identifier. */
for (i = 0; i < 3; i++) {
pubshares[i] = r.pubshares[i];
}
pubshares[3] = r.new_pubshare;
CHECK(secp256k1_frost_threshold_info_validate(CTX, &r.thresh_pk, pubshares, 4, 2) == 1);
/* Signer set {2, 3}: one original participant and the new one. */
ids[0] = 2;
ids[1] = 3;
shares[0] = r.secshares[2];
shares[1] = r.new_secshare;
{
secp256k1_pubkey signer_pubshares[2];
signer_pubshares[0] = pubshares[2];
signer_pubshares[1] = pubshares[3];
frost_enrollment_test_sign(ids, shares, signer_pubshares, 2, 4, 2, &r.thresh_pk);
}
}
/* Repair mode: participant 1 "loses" its share and the same protocol run at
* new_id = 1 reproduces it, byte for byte. The share is f(x_1), a fixed value,
* not a fresh random one, so anything short of exact equality is a bug. */
static void run_frost_enrollment_repair_test(void) {
frost_enrollment_test_run r;
frost_enrollment_test_full_run(&r, 3, 2, 2, 1);
/* The helper set is {0, 2}: deal() skips the target identifier. */
CHECK(r.ids[0] == 0);
CHECK(r.ids[1] == 2);
CHECK(secp256k1_memcmp_var(r.new_secshare, r.secshares[1], 32) == 0);
/* And the repaired participant keeps its old public share. */
CHECK(secp256k1_memcmp_var(&r.new_pubshare, &r.pubshares[1], sizeof(r.new_pubshare)) == 0);
}
/* An oversized helper set produces the same share: Lagrange interpolation at
* the target is exact for any u >= t points on a degree-(t-1) polynomial. */
static void run_frost_enrollment_oversized_set_test(void) {
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
frost_enrollment_test_run r;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
unsigned char share_u2[32];
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
secp256k1_pubkey pubshare_u2;
static const uint32_t helpers_u2[2] = { 0, 1 };
static const uint32_t helpers_u3[3] = { 0, 1, 2 };
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* Enroll id 3 into a 2-of-3 group with two helpers, then with all three,
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
* over the same dealt key material. */
frost_enrollment_test_deal(&r, 3, 2, 2, 3);
frost_enrollment_test_use_helpers(&r, helpers_u2, 2);
frost_enrollment_test_round1_gen(&r);
frost_enrollment_test_round1_agg(&r);
frost_enrollment_test_round2(&r);
memcpy(share_u2, r.new_secshare, 32);
pubshare_u2 = r.new_pubshare;
frost_enrollment_test_use_helpers(&r, helpers_u3, 3);
frost_enrollment_test_round1_gen(&r);
frost_enrollment_test_round1_agg(&r);
frost_enrollment_test_round2(&r);
CHECK(secp256k1_memcmp_var(share_u2, r.new_secshare, 32) == 0);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* The derived public share does not depend on the helper set either. */
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_memcmp_var(&pubshare_u2, &r.new_pubshare, sizeof(pubshare_u2)) == 0);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
}
/* A corrupted sigma value must be caught by the public-share check, and the
* output must be wiped rather than left holding a wrong share. Tampered
* pubshares are caught earlier, by the validation step the recommended flow
* runs before the protocol starts. */
static void run_frost_enrollment_fault_injection_test(void) {
frost_enrollment_test_run r;
unsigned char sigmas[2 * 32];
unsigned char out[32];
secp256k1_pubkey tampered[3];
size_t i;
/* A clean run first, so that r.new_secshare holds the share the corrupted
* runs below must fail to reproduce. */
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
for (i = 0; i < 2; i++) {
memcpy(sigmas, r.sigmas, sizeof(sigmas));
sigmas[32 * i] ^= 1;
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, r.params_hashes[0], &r.new_pubshare) == 0);
CHECK(secp256k1_is_zero_array(out, sizeof(out)));
/* Without the public-share check nothing notices: the sum is a
* perfectly well-formed scalar, just the wrong one. This is what
* makes expected_pubshare load-bearing rather than optional. */
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, r.params_hashes[0], NULL) == 1);
CHECK(secp256k1_memcmp_var(out, r.new_secshare, 32) != 0);
}
/* The recommended flow, not just the module: a tampered public share is
* rejected by secp256k1_frost_threshold_info_validate against the
* independently authenticated threshold public key, before enrollment
* begins. */
for (i = 0; i < 3; i++) {
unsigned char ser[33];
size_t len = sizeof(ser);
memcpy(tampered, r.pubshares, sizeof(tampered));
CHECK(secp256k1_ec_pubkey_serialize(CTX, ser, &len, &tampered[i], SECP256K1_EC_COMPRESSED) == 1);
/* Flip to the other point of the same x-coordinate: still a valid
* pubkey, but no longer on the group's polynomial. */
ser[0] ^= 1;
CHECK(secp256k1_ec_pubkey_parse(CTX, &tampered[i], ser, len) == 1);
CHECK(secp256k1_frost_threshold_info_validate(CTX, &r.thresh_pk, tampered, 3, 2) == 0);
}
}
/* Parameter and group agreement, from four angles. */
static void run_frost_enrollment_mismatch_test(void) {
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* Static rather than automatic: this struct is ~540 KB and this test
* needs several of them live at once. */
static frost_enrollment_test_run r, other;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
unsigned char all_shares[2 * 32];
unsigned char received[2 * 32];
unsigned char sigma[32];
unsigned char out[32];
unsigned char good_hash[32];
unsigned char secrand[32];
uint32_t mismatch_id;
size_t j;
frost_enrollment_test_deal(&r, 4, 2, 2, 4);
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* (a) Helper 0 runs round 1.1 believing the target is 3 while helper 1
* believes it is 4 -- the disagreement enters where it would in reality,
* at the round 1.1 call, rather than being patched in afterwards.
* Helper 1's round 1.2 must abort and name helper 0 by IDENTIFIER. */
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CHECK(secp256k1_frost_enrollment_params_hash(CTX, good_hash, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t) == 1);
testrand256(secrand);
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, r.shares[0], r.params_hashes[0], secrand, r.secshares[r.ids[0]], &r.thresh_pk, r.ids, r.u, r.ids[0], 3, r.n, (uint32_t)r.t) == 1);
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, r.shares[1], r.params_hashes[1], secrand, r.secshares[r.ids[1]], &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 1);
CHECK(secp256k1_memcmp_var(r.params_hashes[0], good_hash, 32) != 0);
CHECK(secp256k1_memcmp_var(r.params_hashes[1], good_hash, 32) == 0);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
frost_enrollment_test_collect(&r, 1, all_shares, received);
mismatch_id = 0;
memset(sigma, 0xff, sizeof(sigma));
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma, &mismatch_id, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 0);
CHECK(mismatch_id == r.ids[0]);
CHECK(secp256k1_is_zero_array(sigma, sizeof(sigma)));
/* (b) A caller that ignores the abort and finishes round 1.2 anyway still
* does not end up with a usable share: the public-share check catches the
* inconsistent sum. Defence in depth, rather than a test of the test's
* own control flow. */
for (j = 0; j < r.u; j++) {
size_t k;
frost_enrollment_test_collect(&r, j, all_shares, received);
/* Simulate the gate having passed: every helper is handed the hash it
* expects, while helper 0's mismatched delta values stay in place. */
for (k = 0; k < r.u; k++) {
if (k != j) {
memcpy(&received[32 * k], good_hash, 32);
}
}
CHECK(secp256k1_frost_enrollment_share_agg(CTX, &r.sigmas[32 * j], NULL, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[j], r.new_id, r.n, (uint32_t)r.t) == 1);
}
{
secp256k1_pubkey helper_pubshares[SECP256K1_FROST_MAX_PARTICIPANTS];
frost_enrollment_test_helper_pubshares(&r, helper_pubshares);
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &r.new_pubshare, helper_pubshares, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t) == 1);
}
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, good_hash, &r.new_pubshare) == 0);
CHECK(secp256k1_is_zero_array(out, sizeof(out)));
/* (c) The helpers agree with each other but not with the target: a clean
* run for new_id = 3, handed to a target that believes it is 4. Round 1.2
* passed everywhere; round 2's own recomputation is what catches it. */
frost_enrollment_test_full_run(&other, 4, 2, 2, 3);
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, other.sigmas, &other.thresh_pk, other.ids, other.u, 4, other.n, (uint32_t)other.t, other.params_hashes[0], NULL) == 0);
CHECK(secp256k1_is_zero_array(out, sizeof(out)));
/* (d) Group binding. Two groups with identical (t, n, ids, new_id) get
* different parameters hashes, because the hash commits to the threshold
* public key -- and a hash from one group fails round 1.2 in the other. */
{
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
static frost_enrollment_test_run a, b;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
unsigned char hash_a[32], hash_b[32];
frost_enrollment_test_deal(&a, 3, 2, 2, 3);
frost_enrollment_test_deal(&b, 3, 2, 2, 3);
CHECK(secp256k1_memcmp_var(&a.thresh_pk, &b.thresh_pk, sizeof(a.thresh_pk)) != 0);
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash_a, &a.thresh_pk, a.ids, a.u, a.new_id, a.n, (uint32_t)a.t) == 1);
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash_b, &b.thresh_pk, b.ids, b.u, b.new_id, b.n, (uint32_t)b.t) == 1);
CHECK(secp256k1_memcmp_var(hash_a, hash_b, 32) != 0);
frost_enrollment_test_round1_gen(&a);
frost_enrollment_test_collect(&a, 1, all_shares, received);
memcpy(&received[0], hash_b, 32);
mismatch_id = 0;
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma, &mismatch_id, all_shares, received, &a.thresh_pk, a.ids, a.u, a.ids[1], a.new_id, a.n, (uint32_t)a.t) == 0);
CHECK(mismatch_id == a.ids[0]);
}
}
/* The own slot of received_params_hashes32 is never read, so a caller cannot
* fill it with a received hash and launder a mismatch into a pass. */
static void run_frost_enrollment_own_slot_test(void) {
frost_enrollment_test_run r;
unsigned char all_shares[2 * 32];
unsigned char received[2 * 32];
unsigned char sigma_zero[32], sigma_garbage[32];
frost_enrollment_test_deal(&r, 3, 2, 2, 3);
frost_enrollment_test_round1_gen(&r);
/* Helper 1 aggregates with its own slot zero, as documented. */
frost_enrollment_test_collect(&r, 1, all_shares, received);
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma_zero, NULL, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 1);
/* And again with garbage in that slot. Same result: it is not read. */
memset(&received[32], 0xa5, 32);
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma_garbage, NULL, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 1);
CHECK(secp256k1_memcmp_var(sigma_zero, sigma_garbage, 32) == 0);
/* But a wrong hash in a slot that IS read still aborts, even if the same
* wrong hash sits in the own slot -- the own hash is recomputed, so there
* is nothing to agree with. */
memset(&received[0], 0xa5, 32);
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma_garbage, NULL, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 0);
}
/* Invalid parameter tuples, including the two deliberate divergences from the
* frost module (threshold >= 2, and enrollment refused at n = 128). */
static void run_frost_enrollment_invalid_params_test(void) {
frost_enrollment_test_run r;
unsigned char hash32[32];
unsigned char shares[4 * 32];
unsigned char secrand[32];
uint32_t ids[4];
frost_enrollment_test_deal(&r, 4, 2, 3, 4);
/* The valid baseline. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, 4, 2) == 1);
/* Duplicate ids. */
memcpy(ids, r.ids, 3 * sizeof(ids[0]));
ids[2] = ids[0];
memset(hash32, 0xff, sizeof(hash32));
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, ids, 3, 4, 4, 2) == 0);
CHECK(secp256k1_is_zero_array(hash32, sizeof(hash32)));
/* new_id among the helpers. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, r.ids[1], 4, 2) == 0);
/* new_id past the end: neither enrollment (== n) nor repair (< n). */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 5, 4, 2) == 0);
/* Too few helpers, and more helpers than participants. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 1, 4, 4, 2) == 0);
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, 2, 2) == 0);
/* threshold = 1 is refused, unlike in the frost module. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, 4, 1) == 0);
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, 4, 0) == 0);
/* threshold above the participant count. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, 4, 5) == 0);
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* A helper id outside 0..n-1, and nothing else wrong: new_id = 4 is a
* valid enrollment target for n = 4, and 5 is neither a valid id nor
* equal to new_id, so the id range check is the only condition that
* fails. */
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
memcpy(ids, r.ids, 3 * sizeof(ids[0]));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
ids[2] = 5;
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, ids, 3, 4, 4, 2) == 0);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* n above the maximum, and n_ids above the maximum. Both must be caught
* in production builds; neither may ride on the VERIFY_CHECK inside
* secp256k1_frost_sort_ids. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, 3, 4, SECP256K1_FROST_MAX_PARTICIPANTS + 1, 2) == 0);
memset(hash32, 0xff, sizeof(hash32));
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, r.ids, SECP256K1_FROST_MAX_PARTICIPANTS + 1, 4, 4, 2) == 0);
CHECK(secp256k1_is_zero_array(hash32, sizeof(hash32)));
/* Mode-specific bounds at the maximum: enrollment would produce a
* 129-participant group and is refused; repair leaves n alone and is
* accepted. Both use a helper set of exactly t, so no oversized array is
* involved either way. */
{
uint32_t big_ids[2];
big_ids[0] = 0;
big_ids[1] = 1;
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, big_ids, 2, SECP256K1_FROST_MAX_PARTICIPANTS, SECP256K1_FROST_MAX_PARTICIPANTS, 2) == 0);
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, big_ids, 2, SECP256K1_FROST_MAX_PARTICIPANTS - 1, SECP256K1_FROST_MAX_PARTICIPANTS, 2) == 1);
/* One below the maximum, enrollment is fine again. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, big_ids, 2, SECP256K1_FROST_MAX_PARTICIPANTS - 1, SECP256K1_FROST_MAX_PARTICIPANTS - 1, 2) == 1);
}
/* An uninitialized threshold public key: secp256k1_pubkey_load treats
* that as an API misuse and fires the illegal-argument callback, as it
* does everywhere else in the library. */
{
secp256k1_pubkey zero_pk;
memset(&zero_pk, 0, sizeof(zero_pk));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_params_hash(CTX, hash32, &zero_pk, r.ids, 3, 4, 4, 2));
CHECK(secp256k1_is_zero_array(hash32, sizeof(hash32)));
}
/* The other entry points reject the same tuples. */
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, 3, r.ids[0], 4, 4, 1) == 0);
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
/* my_id must be one of the helpers. */
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, 3, 3, 4, 4, 2) == 0);
{
secp256k1_pubkey helper_pubshares[SECP256K1_FROST_MAX_PARTICIPANTS];
frost_enrollment_test_helper_pubshares(&r, helper_pubshares);
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &r.new_pubshare, helper_pubshares, r.ids, 3, 4, 4, 1) == 0);
}
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, shares, r.sigmas, &r.thresh_pk, r.ids, 3, 4, 4, 1, NULL, NULL) == 0);
}
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
/* Every entry point must reject an empty helper set and leave its output
frost_enrollment: implement the three rounds Third of six commits. Replaces the Phase 1 stubs with the real arithmetic, adds a smoke test that a 2-of-3 group really does grow into a working 2-of-4 one, and wires the entry points into ctime_tests. The Lagrange machinery is frost's, called in place. pubshare_derive is a skin over secp256k1_frost_derive_pubshare_at (src/modules/frost/keygen_impl.h:150) evaluated at identifier new_id, and the id canonicalization is secp256k1_frost_sort_ids, reached through the declaration the previous commit added. The one piece frost could not supply is the scalar Lagrange coefficient at an arbitrary point. frost's secp256k1_frost_derive_interpolating_value evaluates at x-coordinate 0, which is what reconstructing the group secret needs; enrollment needs the basis polynomial at the TARGET x-coordinate. secp256k1_frost_enrollment_lagrange_at is that, and it is deliberately the same product derive_pubshare_at applies to each pubshare, in the same identifier space -- so the scalar path and the point path agree by construction rather than by coincidence. Working in identifier space is what makes the id-to-x-coordinate +1 cancel: an x-coordinate difference x_j - x_i is the identifier difference id_j - id_i. Round 1.1 computes v = lambda * secshare and splits it. Every share but the one kept locally is masking randomness derived as Scalar.from_bytes_wrapping( TH("FROST enrollment/share_split", rand32 || params_hash32 || ser32(my_id) || ser32(recipient_id))) with rand32 = TH(same tag, session_secrand32) XOR secshare32; the kept share absorbs the remainder so the set sums to v. Three details: - The reduction wraps rather than rejects, chilldkg's from_bytes_wrapping (src/modules/chilldkg/util_impl.h:394). A 256-bit hash mod the group order is about 2^-128 from uniform; rejection sampling would buy that back in exchange for a variable-time loop. - Masking with the secret share is the secp256k1_frost_nonce_gen pattern (session_impl.h:340), so a broken RNG alone does not reveal the split. - The derivation is indexed by the recipient's IDENTIFIER, not by its position in the caller's ids array. The plan called for a counter; identifiers are unique, so they are one, and using them makes the split independent of the order a caller lists the helper set in. What the binding buys is DOMAIN SEPARATION only: params_hash32 carries the group key and the whole parameter tuple, so two runs sharing a seed but differing in either cannot produce the same deltas. It cannot detect a disagreement between helpers, because nothing cross-checks per-helper private randomness. That is the params hash's job. session_secrand32 is wiped whether the call succeeds or fails, so a caller cannot retry a failed run on the same randomness. Round 1.2 recomputes its own params hash from the group key and the tuple, compares every received hash against it, then sums. The slot at the caller's own position in received_params_hashes32 is skipped, while the same position in all_shares32 is read -- the asymmetry the header documents, and the thing that makes this a recomputation rather than a string comparison. The mode and bounds are re-validated here rather than trusted from the round 1.1 call site, since the full tuple is present. An out-of-range share is reported through mismatch_id the way secp256k1_frost_partial_sig_agg reports an unparseable partial signature. Round 2 compares the params hash against its own recomputation over the authenticated group key, sums, rejects a zero share, and checks secshare*G against the expected public share. Three deviations from the plan, all to match what the tree already does: - Value ranges return 0; only pointers get ARG_CHECK. The plan called for an ARG_CHECK on the n_ids bound, but the frost module's split is the one used here (secp256k1_frost_trusted_dealer_keygen, keygen_impl.h:227), and the header already documents these as return-0 conditions. The bound is still enforced in production builds -- params_are_valid requires 2 <= threshold <= n_ids <= n_participants <= 128 -- so it does not ride on the VERIFY_CHECK inside secp256k1_frost_sort_ids, which is what the plan was guarding against. - The public-share check declassifies the derived point and compares with secp256k1_ge_eq_var, rather than comparing 33 serialized bytes in constant time. There is no constant-time memcmp in this tree, and secshare*G is a public key: secp256k1_frost_sign declassifies exactly this quantity before exactly this comparison (src/modules/frost/session_impl.h:770, :789). Inventing a primitive to avoid following that precedent would be the worse trade. - params_hash's public entry point delegates to the same internal routine every gate uses, so the encoding has exactly one implementation to keep in step with the vectors. One real bug found by the tooling rather than by reading. Accumulators were initialized with secp256k1_scalar_clear, and secp256k1_memclear_explicit marks its target UNDEFINED in VERIFY builds (src/util.h:295) precisely so that reading cleared memory is caught. It was: valgrind reported 143752 errors in share_agg's summation loop. Accumulators now start at secp256k1_scalar_set_int(x, 0); scalar_clear is used only where it means "done with this secret". Worth stating plainly because the failure mode is invisible in a production build, where memclear_explicit only zeroes. ctime_tests gains a 2-of-3-enrolls-a-fourth block covering all three rounds, following prefractal's b66c757b. The threshold key, the secret shares, the session randomness and every delta and sigma on the wire are marked secret; the identifiers, public shares, group key, parameters hashes and derived public share are not. Under valgrind: 0 errors from 0 contexts, so no branch or memory access in the new code depends on secret data. Verification: ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the frost_enrollment module runs clean under valgrind (0 errors); a separate CPPFLAGS='-DVERIFY' build compiles without warnings and passes; the module builds warning-free alongside frost, chilldkg, iceberg and prefractal. The smoke test is the substantive check: after a 2-of-3 group enrolls participant 3, every pair {i, 3} for i in 0..2 reconstructs the original threshold secret and matches the threshold public key, and the untouched pair {0, 1} still does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:06:59 +02:00
* zeroed. This also checks that all five symbols are reachable from the test
* binary. */
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
static void run_frost_enrollment_rejects_empty_set_test(void) {
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
frost_enrollment_test_run r;
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
secp256k1_pubkey pubshare;
unsigned char buf32[32];
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
unsigned char secshare32[32];
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
unsigned char secrand32[32];
unsigned char hash32[32];
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* (n = 2, t = 2, new_id = 2) is a valid enrollment tuple, so n_ids = 0 is
* the ONLY failing condition here. The earlier version used (n = 1,
* t = 2), which fails on threshold > n_participants whatever n_ids is,
* and would have stayed green with the n_ids handling removed. */
frost_enrollment_test_deal(&r, 2, 2, 2, 2);
memcpy(secshare32, r.secshares[0], 32);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
memset(&pubshare, 0xff, sizeof(pubshare));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
testrand256(secrand32);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
memset(buf32, 0xff, sizeof(buf32));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_params_hash(CTX, buf32, &r.thresh_pk, r.ids, 0, 2, 2, 2) == 0);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CHECK(secp256k1_is_zero_array(buf32, sizeof(buf32)));
memset(buf32, 0xff, sizeof(buf32));
memset(hash32, 0xff, sizeof(hash32));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, buf32, hash32, secrand32, secshare32, &r.thresh_pk, r.ids, 0, 0, 2, 2, 2) == 0);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CHECK(secp256k1_is_zero_array(hash32, sizeof(hash32)));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_is_zero_array(secrand32, sizeof(secrand32)));
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
memset(buf32, 0xff, sizeof(buf32));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_share_agg(CTX, buf32, NULL, r.sigmas, hash32, &r.thresh_pk, r.ids, 0, 0, 2, 2, 2) == 0);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CHECK(secp256k1_is_zero_array(buf32, sizeof(buf32)));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &pubshare, r.pubshares, r.ids, 0, 2, 2, 2) == 0);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CHECK(secp256k1_is_zero_array((unsigned char *)&pubshare, sizeof(pubshare)));
memset(buf32, 0xff, sizeof(buf32));
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, buf32, r.sigmas, &r.thresh_pk, r.ids, 0, 2, 2, 2, NULL, NULL) == 0);
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CHECK(secp256k1_is_zero_array(buf32, sizeof(buf32)));
}
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
/* pubshare_derive is a wrapper over frost's derive_pubshare_at. This pins its
* argument plumbing: at an existing participant's identifier it must return
* exactly that participant's public share, and it must agree with a direct
* call to the function it wraps. */
static void run_frost_enrollment_pubshare_derive_test(void) {
frost_enrollment_test_run r;
secp256k1_pubkey derived, aligned[3];
secp256k1_ge points[3], expected, got;
secp256k1_gej resultj;
secp256k1_scalar x;
uint32_t ids[3];
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
size_t i, j, k;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
frost_enrollment_test_deal(&r, 4, 3, 3, 4);
/* Repair mode at every existing identifier: the derived public share is
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
* the one the dealer produced. The helper set is the other three. */
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
for (i = 0; i < 4; i++) {
k = 0;
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
for (j = 0; j < 4; j++) {
if (j != i) {
ids[k] = (uint32_t)j;
aligned[k] = r.pubshares[j];
k++;
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
}
}
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CHECK(k == 3);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &derived, aligned, ids, 3, (uint32_t)i, 4, 3) == 1);
CHECK(secp256k1_pubkey_load(CTX, &expected, &r.pubshares[i]) == 1);
CHECK(secp256k1_pubkey_load(CTX, &got, &derived) == 1);
CHECK(secp256k1_ge_eq_var(&expected, &got) == 1);
}
/* And the wrapper passes the target identifier through unchanged. */
for (k = 0; k < 3; k++) {
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
ids[k] = (uint32_t)k;
aligned[k] = r.pubshares[k];
CHECK(secp256k1_pubkey_load(CTX, &points[k], &r.pubshares[k]) == 1);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
}
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &derived, aligned, ids, 3, 4, 4, 3) == 1);
secp256k1_scalar_set_int(&x, 4);
CHECK(secp256k1_frost_derive_pubshare_at(&resultj, ids, points, 3, &x) == 1);
secp256k1_ge_set_gej_var(&expected, &resultj);
CHECK(secp256k1_pubkey_load(CTX, &got, &derived) == 1);
CHECK(secp256k1_ge_eq_var(&expected, &got) == 1);
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* The other end of the polynomial. The public API cannot ask for
* x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t
* bounded by n_participants -- so the identifier convention there is
* pinned by evaluating frost's own derive_thresh_pubkey over the same
* points and requiring it to reproduce the group key. Together with the
* check above, this fixes both ends of the interpolation this module
* relies on. */
CHECK(secp256k1_frost_derive_thresh_pubkey(&resultj, ids, points, 3) == 1);
secp256k1_ge_set_gej_var(&got, &resultj);
CHECK(secp256k1_pubkey_load(CTX, &expected, &r.thresh_pk) == 1);
CHECK(secp256k1_ge_eq_var(&expected, &got) == 1);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
}
/* One random (t, n, u) round trip. Each helper is given the identifier set in
* its own random order, which must not change the parameters hash -- while the
* delta buffers stay aligned with whatever order that helper used. */
static void frost_enrollment_random_iteration(void) {
frost_enrollment_test_run r;
uint32_t perm[SECP256K1_FROST_MAX_PARTICIPANTS][SECP256K1_FROST_MAX_PARTICIPANTS];
unsigned char hash32[32];
size_t n, t, u, i, j;
uint32_t new_id;
/* 2 <= t <= u <= n <= 7 */
t = 2 + testrand_int(3);
u = t + testrand_int(4);
n = u + testrand_int(8 - (unsigned int)u);
if (n > 7) {
n = 7;
}
if (u > n) {
u = n;
}
/* Enrollment half the time, repair the other half. */
new_id = testrand_bits(1) ? (uint32_t)n : (uint32_t)testrand_int((unsigned int)n);
if ((size_t)new_id < n && u > n - 1) {
u = n - 1;
}
if (u < t) {
return;
}
frost_enrollment_test_deal(&r, n, t, u, new_id);
/* Give every helper its own shuffled view of the identifier set. */
for (i = 0; i < u; i++) {
memcpy(perm[i], r.ids, u * sizeof(r.ids[0]));
for (j = u; j > 1; j--) {
size_t k = testrand_int((unsigned int)j);
uint32_t tmp = perm[i][j - 1];
perm[i][j - 1] = perm[i][k];
perm[i][k] = tmp;
}
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, perm[i], u, new_id, n, (uint32_t)t) == 1);
}
/* Round 1.1 in each helper's own order. */
for (i = 0; i < u; i++) {
unsigned char secrand[32];
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, r.shares[i], r.params_hashes[i], secrand, r.secshares[r.ids[i]], &r.thresh_pk, perm[i], u, r.ids[i], new_id, n, (uint32_t)t) == 1);
/* Order-independent: same digest as the canonical order. */
CHECK(secp256k1_memcmp_var(r.params_hashes[i], hash32, 32) == 0);
}
/* Round 1.2, translating each helper's alignment into the canonical one.
* all_shares[k] must be what helper ids[k] produced for helper ids[j],
* which sits at helper k's own position for ids[j]. */
for (j = 0; j < u; j++) {
unsigned char all_shares[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char received[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
memset(received, 0, u * 32);
for (i = 0; i < u; i++) {
size_t pos;
for (pos = 0; pos < u; pos++) {
if (perm[i][pos] == r.ids[j]) {
break;
}
}
CHECK(pos < u);
memcpy(&all_shares[32 * i], &r.shares[i][32 * pos], 32);
if (i != j) {
memcpy(&received[32 * i], r.params_hashes[i], 32);
}
}
CHECK(secp256k1_frost_enrollment_share_agg(CTX, &r.sigmas[32 * j], NULL, all_shares, received, &r.thresh_pk, r.ids, u, r.ids[j], new_id, n, (uint32_t)t) == 1);
}
{
secp256k1_pubkey helper_pubshares[SECP256K1_FROST_MAX_PARTICIPANTS];
frost_enrollment_test_helper_pubshares(&r, helper_pubshares);
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &r.new_pubshare, helper_pubshares, r.ids, u, new_id, n, (uint32_t)t) == 1);
}
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, r.new_secshare, r.sigmas, &r.thresh_pk, r.ids, u, new_id, n, (uint32_t)t, hash32, &r.new_pubshare) == 1);
if ((size_t)new_id < n) {
/* Repair reproduces the lost share exactly. */
CHECK(secp256k1_memcmp_var(r.new_secshare, r.secshares[new_id], 32) == 0);
} else {
/* Enrollment: every t-subset of the extended group that contains the
* new participant reconstructs the same threshold secret, and the
* extended public share table still validates at n+1. */
secp256k1_pubkey extended[SECP256K1_FROST_MAX_PARTICIPANTS];
uint32_t sub_ids[SECP256K1_FROST_MAX_PARTICIPANTS];
const unsigned char *sub_shares[SECP256K1_FROST_MAX_PARTICIPANTS];
for (i = 0; i < n; i++) {
extended[i] = r.pubshares[i];
}
extended[n] = r.new_pubshare;
CHECK(secp256k1_frost_threshold_info_validate(CTX, &r.thresh_pk, extended, n + 1, (uint32_t)t) == 1);
for (i = 0; i + t <= n + 1; i++) {
for (j = 0; j + 1 < t; j++) {
sub_ids[j] = (uint32_t)(i + j);
sub_shares[j] = r.secshares[i + j];
}
sub_ids[t - 1] = new_id;
sub_shares[t - 1] = r.new_secshare;
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* The loop bound gives sub_ids[t-2] = i+t-2 <= n-1 < n = new_id,
* so the subset identifiers are always distinct and ascending. */
CHECK(sub_ids[t - 2] < new_id);
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
frost_enrollment_test_check_reconstruction(sub_ids, sub_shares, t, &r.thresh_pk);
}
}
}
/* The frozen regression vectors. They pin the two tag strings, the parameters
* hash encoding and the share-splitting derivation: any change to those is a
* vector-breaking change, and this is where it shows up. */
static void run_frost_enrollment_vectors_test(void) {
size_t c;
for (c = 0; c < sizeof(frost_enrollment_vec_cases) / sizeof(frost_enrollment_vec_cases[0]); c++) {
const struct frost_enrollment_vec_case *v = &frost_enrollment_vec_cases[c];
secp256k1_pubkey thresh_pk, pubshares[SECP256K1_FROST_MAX_PARTICIPANTS], new_pubshare;
unsigned char hash32[32];
unsigned char shares[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char all_shares[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char received[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char sigmas[SECP256K1_FROST_MAX_PARTICIPANTS * 32];
unsigned char secshare[32];
unsigned char secrand[32];
unsigned char ser[33];
size_t len;
size_t i, j;
CHECK(secp256k1_ec_pubkey_parse(CTX, &thresh_pk, v->thresh_pk33, 33) == 1);
for (i = 0; i < v->n_ids; i++) {
CHECK(secp256k1_ec_pubkey_parse(CTX, &pubshares[i], v->pubshares33[i], 33) == 1);
}
/* The parameters hash. */
CHECK(secp256k1_frost_enrollment_params_hash(CTX, hash32, &thresh_pk, v->ids, v->n_ids, v->new_id, v->n_participants, v->threshold) == 1);
CHECK(secp256k1_memcmp_var(hash32, v->params_hash32, 32) == 0);
/* Round 1.1 for every helper, from the frozen seeds. */
for (i = 0; i < v->n_ids; i++) {
memcpy(secrand, v->session_secrand32[i], 32);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, v->secshares32[i], &thresh_pk, v->ids, v->n_ids, v->ids[i], v->new_id, v->n_participants, v->threshold) == 1);
CHECK(secp256k1_memcmp_var(hash32, v->params_hash32, 32) == 0);
CHECK(secp256k1_memcmp_var(shares, v->shares32[i], v->n_ids * 32) == 0);
}
/* Round 1.2 for every helper. */
for (j = 0; j < v->n_ids; j++) {
memset(received, 0, v->n_ids * 32);
for (i = 0; i < v->n_ids; i++) {
memcpy(&all_shares[32 * i], &v->shares32[i][32 * j], 32);
if (i != j) {
memcpy(&received[32 * i], v->params_hash32, 32);
}
}
CHECK(secp256k1_frost_enrollment_share_agg(CTX, &sigmas[32 * j], NULL, all_shares, received, &thresh_pk, v->ids, v->n_ids, v->ids[j], v->new_id, v->n_participants, v->threshold) == 1);
}
CHECK(secp256k1_memcmp_var(sigmas, v->sigmas32, v->n_ids * 32) == 0);
/* The derived public share and round 2. */
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &new_pubshare, pubshares, v->ids, v->n_ids, v->new_id, v->n_participants, v->threshold) == 1);
len = sizeof(ser);
CHECK(secp256k1_ec_pubkey_serialize(CTX, ser, &len, &new_pubshare, SECP256K1_EC_COMPRESSED) == 1);
CHECK(len == 33);
CHECK(secp256k1_memcmp_var(ser, v->new_pubshare33, 33) == 0);
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, secshare, sigmas, &thresh_pk, v->ids, v->n_ids, v->new_id, v->n_participants, v->threshold, v->params_hash32, &new_pubshare) == 1);
CHECK(secp256k1_memcmp_var(secshare, v->new_secshare32, 32) == 0);
}
}
/* COUNT iterations of the above, so that -i scales the fuzzing the way it does
* for the iceberg module's randomized loops. */
static void run_frost_enrollment_random_test(void) {
int i;
for (i = 0; i < COUNT; i++) {
frost_enrollment_random_iteration();
}
}
frost_enrollment: fix API contract issues found in review Five review findings, all non-blocking, all in the contract between the module and its callers rather than in the cryptography. Each fix comes with a regression test that fails without it. 1. shares_gen zeroed shares32_out before validating n_ids. shares32_out is the only output in this module whose size is caller-supplied. A caller that takes the helper count from a negotiated protocol message, passes a fixed buffer, and relies on this API's "invalid ranges return 0" convention would have memory past that buffer zeroed before the call reported failure -- turning a recoverable length-confusion bug into memory corruption. The frost module validates counts first for exactly this reason (trusted_dealer_keygen, keygen_impl.h:228). Validation now happens before the memset. The early return still wipes session_secrand32, because "a failed call cannot be retried on the same randomness" is a security property and an exception to it would be worse than the tidier control flow. The header's zeroing promise is scoped accordingly: the buffer is zeroed on failure except when n_ids itself is out of range, where it is not written at all. 2. mismatch_id had an undocumented second cause. The header said mismatch_id names the helper whose PARAMETERS HASH disagrees and is UINT32_MAX "when the failure has another cause", but share_agg also sets it when a helper's share is not a valid scalar. The example baked the wrong reading in, printing "Helper %u disagrees about the enrollment parameters" for what may be a corrupted transmission. Documented rather than removed: the attribution is genuinely useful for both causes, and this is API- and vector-compatible. The header now names both, says they are not distinguished so a caller must not report one specifically, and calls out that the second can name the CALLER'S OWN identifier, since the kept share is summed with the rest. The example's message is corrected in a following commit. 3. params_hash's doc claimed it returns 0 on an "unparseable thresh_pk". It does not, and cannot: secp256k1_pubkey_load (secp256k1.c:280) only ARG_CHECKs that x is nonzero, so a zeroed pubkey fires the illegal-argument callback and any other 64-byte content is accepted without curve validation. A caller writing input screening around the documented return 0 would abort on the first malformed input. The doc now states that an unusable pubkey object is API misuse, matching the pointer/value split the impl already follows. 4. params_hash's doc listed three of its ten validity conditions. It is the natural pre-validation entry point -- it enforces exactly what the other four enforce -- but the doc mentioned only duplicate ids and the two n_ids bounds, so the threshold >= 2 divergence and the mode-specific n bounds were discoverable only from the .md or the source. The parameter list now carries the same constraint lines as shares_gen. 5. secshare_gen required a signing context even when it would not sign. The ecmult_gen check was unconditional, but ecmult_gen is used only inside the expected_pubshare != NULL branch. A caller on a verification-only context passing NULL -- explicitly permitted -- hit the illegal-argument callback for a generator multiplication that would never happen. The check is now conditional on expected_pubshare being non-NULL, and stays at the top of the function rather than moving into the branch: ARG_CHECK returns directly, and from inside the branch that would skip the cleanup that wipes secshare and term. Documented in the header. Also in this commit, three comment/dead-code fixes the review noted: the redundant set_int of `term` in both aggregation loops (always written by set_b32 before it is read), the Lagrange denominator comment crediting new_id for something only id distinctness provides, and the comment that described the memset-before-validation ordering rather than justifying it -- now moot. The new run_frost_enrollment_contract_test also closes review coverage gaps 1, 2 and 8, which overlap these findings: malformed wire scalars into share_agg and secshare_gen, sigmas summing to zero mod the order, an invalid secshare32 into shares_gen (the only path exercising its declassify branch), mismatch_id asserted on a NON-CONTIGUOUS helper set {0, 2} so an implementation returning the array index would now be caught, mismatch_id at the caller's own slot, and successful runs with each optional secshare_gen check skipped and with both skipped. Both fixes were verified to be load-bearing by reverting them individually: the F1 test fails on `guarded[i] == 0xa5` and the F5 test fires the illegal-argument callback. ./tests, ./noverify_tests and ctime_tests pass; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:15:26 +02:00
/* Contract details that are easy to regress and that a caller can reasonably
* depend on. */
static void run_frost_enrollment_contract_test(void) {
frost_enrollment_test_run r;
unsigned char guarded[4 * 32];
unsigned char hash32[32];
unsigned char secrand[32];
unsigned char sigma[32];
unsigned char out[32];
unsigned char all_shares[2 * 32];
unsigned char received[2 * 32];
uint32_t mismatch_id;
size_t i;
/* An out-of-range n_ids must be rejected WITHOUT writing shares32_out,
* whose size is only u*32 by contract: a caller passing a fixed buffer
* and a bad count would otherwise have memory past it zeroed. The seed is
* still consumed, so a failed call cannot be retried on it. */
frost_enrollment_test_deal(&r, 3, 2, 2, 3);
memset(guarded, 0xa5, sizeof(guarded));
testrand256(secrand);
memset(hash32, 0xff, sizeof(hash32));
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, guarded, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, SECP256K1_FROST_MAX_PARTICIPANTS + 1, r.ids[0], r.new_id, r.n, (uint32_t)r.t) == 0);
for (i = 0; i < sizeof(guarded); i++) {
CHECK(guarded[i] == 0xa5);
}
CHECK(secp256k1_is_zero_array(hash32, sizeof(hash32)));
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
/* The same holds for an n_ids that is merely inconsistent with the rest
* of the tuple rather than out of the absolute range. */
memset(guarded, 0xa5, sizeof(guarded));
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, guarded, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, 1, r.ids[0], r.new_id, r.n, (uint32_t)r.t) == 0);
for (i = 0; i < sizeof(guarded); i++) {
CHECK(guarded[i] == 0xa5);
}
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
/* A valid call does zero the buffer it is allowed to write, and only
* that part of it. */
memset(guarded, 0xa5, sizeof(guarded));
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, guarded, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t) == 1);
for (i = r.u * 32; i < sizeof(guarded); i++) {
CHECK(guarded[i] == 0xa5);
}
/* secshare_gen needs a signing-capable context only when it is going to
* check the public share. With expected_pubshare == NULL it must work on
* the static context; with a public share it is API misuse there. */
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(STATIC_CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, r.params_hashes[0], NULL) == 1);
CHECK(secp256k1_memcmp_var(out, r.new_secshare, 32) == 0);
CHECK_ILLEGAL(STATIC_CTX, secp256k1_frost_enrollment_secshare_gen(STATIC_CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, r.params_hashes[0], &r.new_pubshare));
/* Both optional checks are genuinely optional: skipping either, or both,
* still produces the same share on an honest run. */
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, &r.new_pubshare) == 1);
CHECK(secp256k1_memcmp_var(out, r.new_secshare, 32) == 0);
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL) == 1);
CHECK(secp256k1_memcmp_var(out, r.new_secshare, 32) == 0);
/* share_agg reports a share that is not a valid scalar the same way it
* reports a parameters disagreement: by naming the responsible helper.
* The helper set here is {0, 2}, so an implementation returning the array
* index rather than the identifier would be caught. */
frost_enrollment_test_deal(&r, 3, 2, 2, 1);
CHECK(r.ids[0] == 0 && r.ids[1] == 2);
frost_enrollment_test_round1_gen(&r);
frost_enrollment_test_collect(&r, 1, all_shares, received);
/* All-ones is larger than the group order. */
memset(&all_shares[0], 0xff, 32);
mismatch_id = 0;
memset(sigma, 0xff, sizeof(sigma));
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma, &mismatch_id, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 0);
CHECK(mismatch_id == r.ids[0]);
CHECK(mismatch_id == 0);
CHECK(secp256k1_is_zero_array(sigma, sizeof(sigma)));
/* And at the caller's OWN slot, which the header calls out: the kept
* share is summed along with the rest, so the caller can be named. */
frost_enrollment_test_collect(&r, 1, all_shares, received);
memset(&all_shares[32], 0xff, 32);
mismatch_id = 0;
CHECK(secp256k1_frost_enrollment_share_agg(CTX, sigma, &mismatch_id, all_shares, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t) == 0);
CHECK(mismatch_id == r.ids[1]);
CHECK(mismatch_id == 2);
/* An out-of-range sigma is rejected by secshare_gen too, which has no
* attribution to offer. */
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
{
unsigned char sigmas[2 * 32];
memcpy(sigmas, r.sigmas, sizeof(sigmas));
memset(&sigmas[32], 0xff, 32);
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, r.params_hashes[0], &r.new_pubshare) == 0);
CHECK(secp256k1_is_zero_array(out, sizeof(out)));
}
/* Sigmas summing to zero mod the group order are rejected: a zero share
* is not a usable secret key. secshare = 1 + (order - 1). */
{
unsigned char sigmas[2 * 32];
static const unsigned char order_minus_one[32] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B,
0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x40
};
memset(sigmas, 0, sizeof(sigmas));
sigmas[31] = 1;
memcpy(&sigmas[32], order_minus_one, 32);
memset(out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_secshare_gen(CTX, out, sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL) == 0);
CHECK(secp256k1_is_zero_array(out, sizeof(out)));
}
/* shares_gen rejects a secret share that is not a valid secret key. */
{
unsigned char bad_secshare[32];
memset(bad_secshare, 0, sizeof(bad_secshare));
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, guarded, hash32, secrand, bad_secshare, &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t) == 0);
CHECK(secp256k1_is_zero_array(guarded, r.u * 32));
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
memset(bad_secshare, 0xff, sizeof(bad_secshare));
testrand256(secrand);
CHECK(secp256k1_frost_enrollment_shares_gen(CTX, guarded, hash32, secrand, bad_secshare, &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t) == 0);
CHECK(secp256k1_is_zero_array(secrand, sizeof(secrand)));
}
}
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
/* NULL-argument handling for all five entry points: every pointer the header
* marks ARG_NONNULL must fire the illegal-argument callback, and outputs must
* still be left unusable. This mirrors the frost module's api test. */
static void run_frost_enrollment_api_test(void) {
frost_enrollment_test_run r;
secp256k1_pubkey pubshare;
secp256k1_pubkey helper_pubshares[2];
unsigned char hash32[32];
unsigned char shares[2 * 32];
unsigned char received[2 * 32];
unsigned char secrand[32];
unsigned char out[32];
frost_enrollment_test_full_run(&r, 3, 2, 2, 3);
frost_enrollment_test_helper_pubshares(&r, helper_pubshares);
memset(received, 0, sizeof(received));
memcpy(received, r.params_hashes[1], 32);
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_params_hash(CTX, NULL, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_params_hash(CTX, hash32, NULL, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_params_hash(CTX, hash32, &r.thresh_pk, NULL, r.u, r.new_id, r.n, (uint32_t)r.t));
testrand256(secrand);
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, NULL, hash32, secrand, r.secshares[0], &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, NULL, secrand, r.secshares[0], &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, NULL, r.secshares[0], &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, NULL, &r.thresh_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, r.secshares[0], NULL, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, r.secshares[0], &r.thresh_pk, NULL, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
/* None of those consumed the seed: they never reached the body. */
CHECK(!secp256k1_is_zero_array(secrand, sizeof(secrand)));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, NULL, NULL, r.shares[0], received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, out, NULL, NULL, received, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, out, NULL, r.shares[0], NULL, &r.thresh_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, out, NULL, r.shares[0], received, NULL, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, out, NULL, r.shares[0], received, &r.thresh_pk, NULL, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_pubshare_derive(CTX, NULL, helper_pubshares, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_pubshare_derive(CTX, &pubshare, NULL, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_pubshare_derive(CTX, &pubshare, helper_pubshares, NULL, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, NULL, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, out, NULL, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, NULL, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &r.thresh_pk, NULL, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL));
/* An unusable pubkey object is API misuse everywhere it is accepted, not
* just in params_hash. */
{
secp256k1_pubkey zero_pk;
memset(&zero_pk, 0, sizeof(zero_pk));
testrand256(secrand);
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_shares_gen(CTX, shares, hash32, secrand, r.secshares[0], &zero_pk, r.ids, r.u, r.ids[0], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_share_agg(CTX, out, NULL, r.shares[0], received, &zero_pk, r.ids, r.u, r.ids[1], r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_pubshare_derive(CTX, &pubshare, &zero_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &zero_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, NULL));
CHECK_ILLEGAL(CTX, secp256k1_frost_enrollment_secshare_gen(CTX, out, r.sigmas, &r.thresh_pk, r.ids, r.u, r.new_id, r.n, (uint32_t)r.t, NULL, &zero_pk));
}
}
/* pubshare_derive must reject an input set whose interpolation lands on the
* point at infinity. With u = 2 and new_id = 2 the coefficients are -1 and 2,
* so P_0 = 2*P_1 makes the sum vanish. */
static void run_frost_enrollment_infinity_test(void) {
secp256k1_pubkey pubshares[2], out;
unsigned char sk1[32], sk0[32];
uint32_t ids[2] = { 0, 1 };
do {
testrand256(sk1);
} while (!secp256k1_ec_seckey_verify(CTX, sk1));
memcpy(sk0, sk1, 32);
/* sk0 = 2 * sk1 */
CHECK(secp256k1_ec_seckey_tweak_add(CTX, sk0, sk1) == 1);
CHECK(secp256k1_ec_pubkey_create(CTX, &pubshares[0], sk0) == 1);
CHECK(secp256k1_ec_pubkey_create(CTX, &pubshares[1], sk1) == 1);
memset(&out, 0xff, sizeof(out));
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &out, pubshares, ids, 2, 2, 3, 2) == 0);
CHECK(secp256k1_is_zero_array((unsigned char *)&out, sizeof(out)));
/* The same points at a different target do not vanish, so the rejection
* above is the infinity check and not a parameter problem. */
CHECK(secp256k1_frost_enrollment_pubshare_derive(CTX, &out, pubshares, ids, 2, 3, 4, 2) == 1);
}
/* Enrollment leaves every existing participant's key material untouched --
* the C analogue of the reference implementation's test_participant_not_in_dkg,
* which checks that the new participant holds only its aggregate share and the
* group key. */
static void run_frost_enrollment_no_side_effects_test(void) {
frost_enrollment_test_run r;
unsigned char secshares_before[3][32];
secp256k1_pubkey pubshares_before[3];
secp256k1_pubkey thresh_pk_before;
size_t i;
frost_enrollment_test_deal(&r, 3, 2, 2, 3);
memcpy(secshares_before, r.secshares, sizeof(secshares_before));
memcpy(pubshares_before, r.pubshares, sizeof(pubshares_before));
thresh_pk_before = r.thresh_pk;
frost_enrollment_test_round1_gen(&r);
frost_enrollment_test_round1_agg(&r);
frost_enrollment_test_round2(&r);
for (i = 0; i < 3; i++) {
CHECK(secp256k1_memcmp_var(r.secshares[i], secshares_before[i], 32) == 0);
CHECK(secp256k1_memcmp_var(&r.pubshares[i], &pubshares_before[i], sizeof(r.pubshares[i])) == 0);
}
CHECK(secp256k1_memcmp_var(&r.thresh_pk, &thresh_pk_before, sizeof(r.thresh_pk)) == 0);
/* And the new share is genuinely new. */
for (i = 0; i < 3; i++) {
CHECK(secp256k1_memcmp_var(r.new_secshare, r.secshares[i], 32) != 0);
}
}
/* Full protocol runs at the largest sizes the API admits. u is capped at 127
* in both modes -- enrollment needs n < 128 and repair excludes the target
* from the helper set -- so these exercise the fixed-size arrays
* (points[128] in pubshare_derive, sorted_ids[128] in the hash) one entry
* below their bound, which is as far as a valid tuple reaches. */
static void run_frost_enrollment_max_size_test(void) {
/* ~540 KB per run; static so that this does not sit on the stack. */
static frost_enrollment_test_run r;
const size_t max = SECP256K1_FROST_MAX_PARTICIPANTS;
/* Enrollment at the largest group that can still grow. */
frost_enrollment_test_full_run(&r, max - 1, 2, max - 1, (uint32_t)(max - 1));
{
static secp256k1_pubkey extended[SECP256K1_FROST_MAX_PARTICIPANTS];
size_t i;
for (i = 0; i < max - 1; i++) {
extended[i] = r.pubshares[i];
}
extended[max - 1] = r.new_pubshare;
CHECK(secp256k1_frost_threshold_info_validate(CTX, &r.thresh_pk, extended, max, 2) == 1);
}
/* Repair in a full group, which enrollment mode refuses. */
frost_enrollment_test_full_run(&r, max, 2, max - 1, 0);
CHECK(secp256k1_memcmp_var(r.new_secshare, r.secshares[0], 32) == 0);
}
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
static const struct tf_test_entry tests_frost_enrollment[] = {
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CASE1(run_frost_enrollment_vectors_test),
CASE1(run_frost_enrollment_reconstruction_test),
CASE1(run_frost_enrollment_signing_test),
CASE1(run_frost_enrollment_repair_test),
CASE1(run_frost_enrollment_oversized_set_test),
CASE1(run_frost_enrollment_fault_injection_test),
CASE1(run_frost_enrollment_mismatch_test),
CASE1(run_frost_enrollment_own_slot_test),
frost_enrollment: fix API contract issues found in review Five review findings, all non-blocking, all in the contract between the module and its callers rather than in the cryptography. Each fix comes with a regression test that fails without it. 1. shares_gen zeroed shares32_out before validating n_ids. shares32_out is the only output in this module whose size is caller-supplied. A caller that takes the helper count from a negotiated protocol message, passes a fixed buffer, and relies on this API's "invalid ranges return 0" convention would have memory past that buffer zeroed before the call reported failure -- turning a recoverable length-confusion bug into memory corruption. The frost module validates counts first for exactly this reason (trusted_dealer_keygen, keygen_impl.h:228). Validation now happens before the memset. The early return still wipes session_secrand32, because "a failed call cannot be retried on the same randomness" is a security property and an exception to it would be worse than the tidier control flow. The header's zeroing promise is scoped accordingly: the buffer is zeroed on failure except when n_ids itself is out of range, where it is not written at all. 2. mismatch_id had an undocumented second cause. The header said mismatch_id names the helper whose PARAMETERS HASH disagrees and is UINT32_MAX "when the failure has another cause", but share_agg also sets it when a helper's share is not a valid scalar. The example baked the wrong reading in, printing "Helper %u disagrees about the enrollment parameters" for what may be a corrupted transmission. Documented rather than removed: the attribution is genuinely useful for both causes, and this is API- and vector-compatible. The header now names both, says they are not distinguished so a caller must not report one specifically, and calls out that the second can name the CALLER'S OWN identifier, since the kept share is summed with the rest. The example's message is corrected in a following commit. 3. params_hash's doc claimed it returns 0 on an "unparseable thresh_pk". It does not, and cannot: secp256k1_pubkey_load (secp256k1.c:280) only ARG_CHECKs that x is nonzero, so a zeroed pubkey fires the illegal-argument callback and any other 64-byte content is accepted without curve validation. A caller writing input screening around the documented return 0 would abort on the first malformed input. The doc now states that an unusable pubkey object is API misuse, matching the pointer/value split the impl already follows. 4. params_hash's doc listed three of its ten validity conditions. It is the natural pre-validation entry point -- it enforces exactly what the other four enforce -- but the doc mentioned only duplicate ids and the two n_ids bounds, so the threshold >= 2 divergence and the mode-specific n bounds were discoverable only from the .md or the source. The parameter list now carries the same constraint lines as shares_gen. 5. secshare_gen required a signing context even when it would not sign. The ecmult_gen check was unconditional, but ecmult_gen is used only inside the expected_pubshare != NULL branch. A caller on a verification-only context passing NULL -- explicitly permitted -- hit the illegal-argument callback for a generator multiplication that would never happen. The check is now conditional on expected_pubshare being non-NULL, and stays at the top of the function rather than moving into the branch: ARG_CHECK returns directly, and from inside the branch that would skip the cleanup that wipes secshare and term. Documented in the header. Also in this commit, three comment/dead-code fixes the review noted: the redundant set_int of `term` in both aggregation loops (always written by set_b32 before it is read), the Lagrange denominator comment crediting new_id for something only id distinctness provides, and the comment that described the memset-before-validation ordering rather than justifying it -- now moot. The new run_frost_enrollment_contract_test also closes review coverage gaps 1, 2 and 8, which overlap these findings: malformed wire scalars into share_agg and secshare_gen, sigmas summing to zero mod the order, an invalid secshare32 into shares_gen (the only path exercising its declassify branch), mismatch_id asserted on a NON-CONTIGUOUS helper set {0, 2} so an implementation returning the array index would now be caught, mismatch_id at the caller's own slot, and successful runs with each optional secshare_gen check skipped and with both skipped. Both fixes were verified to be load-bearing by reverting them individually: the F1 test fails on `guarded[i] == 0xa5` and the F5 test fires the illegal-argument callback. ./tests, ./noverify_tests and ctime_tests pass; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:15:26 +02:00
CASE1(run_frost_enrollment_contract_test),
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CASE1(run_frost_enrollment_invalid_params_test),
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
CASE1(run_frost_enrollment_rejects_empty_set_test),
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CASE1(run_frost_enrollment_pubshare_derive_test),
frost_enrollment: close the review's test coverage gaps Six new tests and repairs to two that were confounded. The suite goes from 12 cases to 16. Two existing tests would have stayed green with the checks they target deleted, which is the worst kind of passing test: - The "helper id out of range" case also passed new_id = 5 > n = 4, which params_are_valid rejects several lines earlier. It now uses a valid enrollment target (new_id = 4) and an id of 5, so the id range check is the sole failing condition. - Every call in the empty-set test used (n = 1, t = 2), which fails on threshold > n_participants regardless of n_ids. It now uses (n = 2, t = 2, new_id = 2) -- a valid tuple in every respect except n_ids = 0. New coverage, in the order the review ranked it: - run_frost_enrollment_api_test: NULL for every ARG_NONNULL pointer on all five entry points, plus an unusable pubkey object on the four that take one (previously only params_hash was covered). Also asserts that a shares_gen call rejected at an ARG_CHECK does NOT consume the seed, since it never reaches the body -- the complement of the "a failed call always consumes it" property the previous commit pinned. - run_frost_enrollment_infinity_test: pubshare_derive's infinity rejection, which nothing reached before. With u = 2 and new_id = 2 the Lagrange coefficients are exactly -1 and 2, so P_0 = 2*P_1 makes the interpolation vanish; the same two points at a different target succeed, which is what distinguishes the infinity check from a parameter rejection. - run_frost_enrollment_max_size_test: full protocol runs at the largest sizes the API admits -- enrollment at n = 127 with u = 127, and repair in a full n = 128 group with u = 127. u cannot reach 128 in either mode (enrollment needs n < 128, repair excludes the target from the helper set), so these reach one entry below the fixed-size arrays' bound, which is as far as a valid tuple goes. Everything before this capped at n <= 7. Runs in 51 ms. - run_frost_enrollment_no_side_effects_test: the C analogue of the reference implementation's test_participant_not_in_dkg, which plan §1.2 listed and the Phase 3 list dropped. Every existing participant's secret share, public share and the group key are byte-identical before and after an enrollment, and the new share differs from all of them. - The pubshare_derive test now also pins the OTHER end of the polynomial. The public API cannot ask for x-coordinate 0 -- that is identifier -1, and new_id is a uint32_t bounded by n_participants -- so the convention there is checked by running frost's own derive_thresh_pubkey over the same loaded points and requiring it to reproduce the group key. With the existing check at x = new_id, both ends of the interpolation this module depends on are now fixed. Test-structure repairs the review called out: - The mismatch test's disagreement now enters where it would in reality, at helper 0's round 1.1 call (which is made with new_id = 3 while helper 1 uses 4), rather than by running a clean round and overwriting the outputs afterwards. - The oversized-helper-set test re-points a single dealt run at {0,1} and then {0,1,2} through a named helper, instead of struct-copying a ~540 KB run and hand-editing u and ids[2] -- which would have broken silently if deal()'s helper-selection rule changed. - frost_enrollment_test_run instances in the mismatch test are now static. That test needs four live at once, which was over 2 MB of stack. - The randomized test's `if (sub_ids[t-2] >= new_id) continue;` was unreachable: the loop bound gives sub_ids[t-2] <= n-1 and new_id == n in that branch. It is now the CHECK that states the invariant. - The pubshare_derive test's `k` was reset and reused as both helper counter and aligned-array index inside the same loop body. Verification: 16/16 pass at -i=16, -i=200 and -i=1000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0; the module is clean under valgrind (0 errors from 0 contexts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:21:13 +02:00
CASE1(run_frost_enrollment_infinity_test),
CASE1(run_frost_enrollment_no_side_effects_test),
CASE1(run_frost_enrollment_max_size_test),
CASE1(run_frost_enrollment_api_test),
frost_enrollment: add the test suite and the regression vectors Fourth of six commits. Twelve tests replacing the Phase 2 smoke test, plus a vector generator and the frozen vectors it produces. The regression vectors are the one part of this worth being precise about, because they are easy to over-claim. FROST enrollment has no BIP and no published vectors, and the reference proof of concept draws its randomness from secrets.randbits, which is not seedable -- so there is nothing to cross-validate against. tools/test_vectors_frost_enrollment_generate.py therefore re-implements the math independently in stdlib-only Python, including the group arithmetic written from the secp256k1 parameters rather than borrowed, and freezes the output. What that buys: the two tag strings, the params hash serialization, the share-splitting derivation and the identifier conventions are now pinned, and changing any of them is a loud vector-breaking change. What it does not buy is evidence of protocol correctness. The generator header comment and the generated file both say so, as does frost_enrollment.md. The vectors passed on the first run against the C code, which is worth recording: two independent implementations agree byte for byte on the params hash, every delta, every sigma, the derived public share and the final share, across four cases (2-of-3 minimal, 2-of-3 oversized at u = 3 > t = 2, a 3-of-5 repair with a deliberately UNSORTED helper set, and a 4-of-6 enrollment), covering both threshold-key Y parities. The algebraic invariants are what actually carry correctness: - Reconstruction (PoC test_generate_frost_share): after a 2-of-3 group enrolls id 3, every pair {i, 3} reconstructs the original threshold secret, and so does the untouched pair {0, 1}. - Signing (PoC test_sign): a real BIP340 signature from {2, 3} verifying against the unchanged threshold public key, with every partial signature individually verified, plus the n -> n+1 bookkeeping -- secp256k1_frost_threshold_info_validate must accept the public share table extended with pubshare_derive's output at n+1. - Repair: byte-for-byte equality with the lost share, and the repaired participant keeps its old public share. - Oversized helper set: u = 3 and u = 2 over the same key material produce the same share and the same derived public share. - Randomized: COUNT iterations over 2 <= t <= u <= n <= 7, half enrollment and half repair, with EVERY HELPER GIVEN THE IDENTIFIER SET IN ITS OWN SHUFFLED ORDER. The params hash must come out identical while the delta buffers stay aligned per helper -- which is the whole point of canonicalizing ids inside the hash and nowhere else. Each iteration then checks every t-subset containing the new participant. The negative tests are organized around what each gate is actually for: - Fault injection flips a bit in one sigma. secshare_gen fails and wipes its output; the same call with expected_pubshare = NULL SUCCEEDS and returns a wrong share. That second assertion is the point -- it is the evidence that the parameter is load-bearing rather than decorative. Tampered public shares are caught earlier, by secp256k1_frost_threshold_info_validate, so the test exercises the recommended flow and not just the module. - Parameter mismatch, four angles: (a) one helper runs round 1.1 for a different target and every other helper's share_agg aborts naming it by identifier; (b) a caller that IGNORES that abort and finishes round 1.2 anyway still cannot produce a usable share, because the public-share check catches the inconsistent sum -- defence in depth, not a test of the test's own control flow; (c) the helpers agree with each other on new_id = 3 while the target expects 4, which round 1.2 cannot see and round 2's own recomputation does; (d) two groups with identical (t, n, ids, new_id) get different hashes, and a hash from one fails share_agg in the other. - Own-slot semantics: filling the caller's own slot of received_params_hashes32 with garbage changes nothing, because it is never read -- but the same garbage in a slot that IS read still aborts. That pair is what makes "recomputation, not string comparison" testable rather than merely asserted. - Invalid parameters, including both deliberate divergences: t = 1 refused, enrollment refused at n = 128 while repair at n = 128 is accepted, n_ids > 128 returning 0 with the output zeroed in a production build. Three bugs found while writing these, all in the tests, all worth naming: - pubshare_derive takes public shares ALIGNED WITH ids, and the test helper was handing it the participant-indexed table. Those coincide exactly when the helper set is 0..u-1, which every test until the repair case used, so the first non-contiguous helper set {0, 2} was what exposed it. There is now one helper that does the gather, with a comment saying which confusion it exists to prevent. - The fault-injection test compared against r.new_secshare without ever running round 2, and the mismatch test compared against r.params_hashes[0] one line before round 1.1 filled it. Both were reads of uninitialized memory that happened to pass; valgrind found both. The randomized test loops COUNT times so -i scales it, following the iceberg module (tests_impl.h:1322) rather than prefractal's run-once convention -- a fuzzing loop that ignores the iteration count is not much of one. Verification: all twelve tests pass at the default iteration count, at -i=200 and at -i=2000; ./tests, ./noverify_tests and ./exhaustive_tests exit 0 with all five FROST-stack modules enabled; the module runs clean under valgrind (0 errors from 0 contexts); ctime_tests is clean under valgrind; regenerating vectors.h reproduces it byte for byte. One note for anyone running these locally: ctime_tests must not be run against a CPPFLAGS='-DVERIFY' build. secp256k1_scalar_verify branches on scalar values, which ctime_tests deliberately marks secret, so every scalar operation in the library reports a finding -- 75997 of them, none in this module. The CI matrix already pairs -DVERIFY with CTIMETESTS: 'no' (.github/workflows/ci.yml:119, :596) for this reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:24:21 +02:00
CASE1(run_frost_enrollment_random_test),
build: wire the frost_enrollment module into both build systems Second of six commits adding the frost_enrollment module. This one is scaffolding only: the five entry points are stubs that validate their pointer arguments, zero their outputs and return 0. What is being verified here is that the module configures, compiles, links, exports its symbols and registers its test module in both build systems -- so that the next commit changes nothing but arithmetic. Ordering is the one thing in this commit that can go silently wrong, and it goes wrong in opposite directions in the two build systems: - configure.ac executes its `if` blocks in file order, and enable_module_frost defaults to no (configure.ac:243). A block placed after the frost block at :601 that sets enable_module_frost=yes flips the variable too late: AM_CONDITIONAL goes true, so the header is installed and the Makefile fragment is pulled in, but -DENABLE_MODULE_FROST=1 is never appended, so src/secp256k1.c never includes frost's implementation and every secp256k1_frost_* symbol fails to link. The new block therefore goes ahead of both the frost block and prefractal's, which documents the same trap. - src/CMakeLists.txt processes dependents FIRST, so the same block goes above the FROST block there, beside prefractal's. Verified rather than assumed: configuring with ONLY --enable-module-frost-enrollment emits -DENABLE_MODULE_FROST=1 alongside -DENABLE_MODULE_FROST_ENROLLMENT=1, and the CMake summary prints "frost ON" for the same configuration -- the latter is what the PARENT_SCOPE lift buys, since the summary runs after add_subdirectory(src) and would otherwise report a module it is compiling in as OFF. The dependency guard is prefractal's implies-frost idiom, copied verbatim along with its reasoning. frost is default-OFF, so the `test x"$enable_module_frost" = x"no"` / `DEFINED X AND NOT X` guard every other module uses -- which reads as "the user disabled it explicitly" for a default-ON dependency -- is true by default here and cannot tell an explicit --disable-module-frost from the default once both are in the cache. Enabling frost-enrollment simply implies frost, with no error. The one frost-module change in the whole series is in this commit: src/modules/frost/session.h gains a declaration for secp256k1_frost_sort_ids, which is defined at session_impl.h:517 and declared nowhere. The params hash needs it to canonicalize identifier order. Prefractal reaches frost's statics through translation-unit ordering alone; rather than inherit reuse-by-link-order, this declares the function where keygen.h:48 already declares derive_pubshare_at, so the reuse goes through an interface. No behavior change: it is a declaration for an existing static definition in the same TU. CI wiring is two files, and skipping either half fails quietly: - ci/ci.sh gets FROST_ENROLLMENT in the reproduction header's variable list and --enable-module-frost-enrollment="$FROST_ENROLLMENT" after the prefractal line. - .github/workflows/ci.yml gets FROST_ENROLLMENT at every PREFRACTAL site: the global default, 11 inline matrix entries and 10 job-level env blocks. Without the default, ci.sh runs under set -eux with an empty $FROST_ENROLLMENT, passes --enable-module-frost-enrollment="", `test x"" = x"yes"` is false, and the module is off in all of CI while ci.sh visibly has the plumbing. Verified programmatically over the parsed workflow: across the 106 effective job contexts, PREFRACTAL and FROST_ENROLLMENT now agree in every single one (45 set to yes, no mismatches), no context sets FROST_ENROLLMENT without FROST or without EXPERIMENTAL, and no context leaves it undefined. ci.sh passes sh -n. The stub test is not a placeholder that has to be deleted later: every entry point must reject an empty helper set and leave its output zeroed, which is true of the stubs and stays true of the finished implementation, so it doubles as the check that all five symbols are reachable from the test binary. Verification. Autotools: ./autogen.sh, then a frost-enrollment-only configure and a full configure with frost, chilldkg, iceberg, prefractal and frost-enrollment all on -- both build with zero warnings under the project's -Werror-grade flag set, ./tests and ./exhaustive_tests exit 0, and `./tests -l` lists the frost_enrollment module. CMake: configure with -DSECP256K1_EXPERIMENTAL=ON -DSECP256K1_ENABLE_MODULE_FROST_ENROLLMENT=ON builds clean and ctest passes 391 tests. nm shows the five new symbols exported from libsecp256k1.so; tools/symbol-check.py could not be run here because python3-lief is not installed in this environment, but all five carry the required secp256k1_ prefix. make dist succeeds and the tarball carries src/modules/frost_enrollment/frost_enrollment.md alongside the other module documents. One unrelated observation from this build: a stale src/ctime_tests-ctime_tests.o left over from an earlier configure with a different module set will fail to link, because automake does not track CPPFLAGS changes across reconfigures. make clean between configurations with different module sets, not a fault in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:53:03 +02:00
};
#endif /* SECP256K1_MODULE_FROST_ENROLLMENT_TESTS_IMPL_H */