Files
secp256k1-zkp/src/ctime_tests.c

894 lines
40 KiB
C
Raw Normal View History

2020-12-17 08:33:49 +02:00
/***********************************************************************
* Copyright (c) 2020 Gregory Maxwell *
* Distributed under the MIT software license, see the accompanying *
* file COPYING or https://www.opensource.org/licenses/mit-license.php.*
***********************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../include/secp256k1.h"
#include "assumptions.h"
#include "checkmem.h"
#if !SECP256K1_CHECKMEM_ENABLED
# error "This tool cannot be compiled without memory-checking interface (valgrind or msan)"
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_ECDH
# include "../include/secp256k1_ecdh.h"
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_RECOVERY
# include "../include/secp256k1_recovery.h"
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_EXTRAKEYS
# include "../include/secp256k1_extrakeys.h"
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_SCHNORRSIG
#include "../include/secp256k1_schnorrsig.h"
#endif
#ifdef ENABLE_MODULE_MUSIG
#include "../include/secp256k1_musig.h"
#endif
2022-11-04 15:52:12 -04:00
#ifdef ENABLE_MODULE_ELLSWIFT
#include "../include/secp256k1_ellswift.h"
#endif
2020-12-21 20:27:14 +00:00
#ifdef ENABLE_MODULE_ECDSA_S2C
#include "../include/secp256k1_ecdsa_s2c.h"
2020-12-21 20:27:14 +00:00
#endif
#ifdef ENABLE_MODULE_ECDSA_ADAPTOR
#include "../include/secp256k1_ecdsa_adaptor.h"
#endif
#if defined(__GNUC__)
# pragma GCC diagnostic push
# pragma GCC diagnostic warning "-Wunused-function"
#endif
2026-08-31 00:05:16 +02:00
#ifdef ENABLE_MODULE_FROST
#include "../include/secp256k1_frost.h"
#endif
chilldkg: CI wiring, ctime_tests coverage, declassify fixes CI: - ci/ci.sh: new CHILLDKG environment variable, passed to configure as --enable-module-chilldkg (mirroring FROST). - .github/workflows/ci.yml: default CHILLDKG: 'no' and CHILLDKG: 'yes' in every job that enables FROST, except the x86_64 matrix entry that deliberately builds without the ecdh module (chilldkg requires schnorrsig + ecdh; the configure-time dependency error would fire there). YAML validity and per-job dependency presence checked programmatically. ctime_tests: - src/ctime_tests.c: run a full ChillDKG session (n = 2, t = 2) through the public API under the memory checker: hostpubkey_gen, params_hash, participant_step1, coordinator_step1, participant_step2, coordinator_finalize, participant_finalize, participant_recover and recovery_ack_sign. Host secret keys, session randomness, aux randomness and the resulting secret shares are undefined (secret); all protocol messages, the certificate, threshold public key, public shares, recovery data, ack signature and the secret-free state1 objects are defined (public). state2 stays secret (contains the secret share). Constant-time fixes found by running the new block under MemorySanitizer (valgrind unavailable locally; MSan build via clang + CMake). All are missing declassifications of secret-derived but public (or public-outcome) values, following the frost module's secp256k1_declassify pattern with justification comments; no real constant-time bugs were found: - hostpubkey_gen: declassify the computed host public key before serialization (public output). - participant_step1: declassify the zero-randomness check result (only reveals "the RNG returned 32 zero bytes", which aborts the session). - encpedpop participant_step1: declassify the pubnonce point before serialization (public, part of pmsg1). - chilldkg_schnorrsig_sign: declassify the signer public key before normalization/parity branch, and declassify the return value (a failure only reveals a zero derived nonce, negligible probability). - vss_commit: declassify the VSS commitments before serialization (public, part of pmsg1). - vss_verify_secshare: declassify secshare*G before the infinity/eq checks (equals the public pubshare in honest runs; the discrete log is not revealed). - simplpedpop_participant_investigate (proactive audit; not reached by ctime_tests): declassify the secshare-sum comparison result (the public fault code reveals it anyway). Verified: MSan ctime_tests exits 0; autotools make check 10/10 (the local tree is configured without --enable-ctime-tests because neither valgrind nor an MSan-instrumented gcc build is available; CI runs ctime_tests under valgrind as before); CMake ctest 428/428; ./tests --target=chilldkg and ./chilldkg_example pass.
2026-08-31 10:25:14 +02:00
#ifdef ENABLE_MODULE_CHILLDKG
#include "../include/secp256k1_chilldkg.h"
#endif
prefractal: document the deviations and add the constant-time test doc/prefractal.md writes down the three deliberate deviations from BIP 445 where a reviewer will find them, since none of them is visible from the API and two of them are actively counterintuitive: 1. b_frost does not commit to the message, because the target protocols publish the group's wire nonce before the message exists. The outer b_musig does commit to it and multiplies b_frost everywhere it appears. 2. There is no g_frost factor, and the reason is NOT that the tweak cache is the identity. The frost key-side factor is g*gacc; an identity cache gives gacc = 1, but g is still -1 for every odd-Y threshold key. The doc spells this out because "identity cache, therefore no key term" is the plausible wrong reason, and acting on it yields a signer that works for even-Y groups and fails for odd-Y ones. 3. The frost tweak cache must be the identity, checked at signing and verification and not only at aggregation, so the key a member signs under is tied to the cache that was validated. It also records the two caller obligations the module cannot enforce - one secnonce per signature, and round-two signers EQUAL to round-one contributors - and notes that iceberg tolerates a round-two subset where this module must not, since callers moving between the two would otherwise transpose the rule. The build section documents the three-way ordering constraint rather than leaving the next person to copy iceberg's positions, which are wrong for configure.ac. The ctime test adds a 2-of-2 nested group with a stock musig cosigner, taken as far as one signature share, marking the threshold key, the secret shares and the session randomness as secret and everything else as public. Nonce generation passes msg = NULL, which is how the module is actually driven. Verified: valgrind -q ./build/bin/ctime_tests exits 0, so nothing in the prefractal signing path branches on secret data. Full test suite green. The example program from the plan's optional list is not included; the test suite covers the same ground and the doc carries the usage rules.
2026-09-04 01:06:14 +02:00
#ifdef ENABLE_MODULE_PREFRACTAL
#include "../include/secp256k1_prefractal.h"
#endif
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
#ifdef ENABLE_MODULE_FROST_ENROLLMENT
#include "../include/secp256k1_frost_enrollment.h"
#endif
#ifdef ENABLE_MODULE_ICEBERG
#include "../include/secp256k1_iceberg.h"
#include "../include/secp256k1_iceberg_dealer.h"
#endif
static void run_tests(secp256k1_context *ctx, unsigned char *key);
int main(void) {
secp256k1_context* ctx;
unsigned char key[32];
int ret, i;
if (!SECP256K1_CHECKMEM_RUNNING()) {
fprintf(stderr, "This test can only usefully be run inside valgrind because it was not compiled under msan.\n");
fprintf(stderr, "Usage: valgrind ./ctime_tests (or with Autotools: libtool --mode=execute valgrind ./ctime_tests)\n");
return EXIT_FAILURE;
}
ctx = secp256k1_context_create(SECP256K1_CONTEXT_DECLASSIFY);
/** In theory, testing with a single secret input should be sufficient:
* If control flow depended on secrets the tool would generate an error.
*/
for (i = 0; i < 32; i++) {
key[i] = i + 65;
}
run_tests(ctx, key);
/* Test context randomisation. Do this last because it leaves the context
* tainted. */
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_context_randomize(ctx, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret);
secp256k1_context_destroy(ctx);
return EXIT_SUCCESS;
}
static void run_tests(secp256k1_context *ctx, unsigned char *key) {
secp256k1_ecdsa_signature signature;
secp256k1_pubkey pubkey;
size_t siglen = 74;
size_t outputlen = 33;
int i;
int ret;
unsigned char msg[32];
unsigned char sig[74];
unsigned char spubkey[33];
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_RECOVERY
secp256k1_ecdsa_recoverable_signature recoverable_signature;
int recid;
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_EXTRAKEYS
secp256k1_keypair keypair;
#endif
2022-11-04 15:52:12 -04:00
#ifdef ENABLE_MODULE_ELLSWIFT
unsigned char ellswift[64];
static const unsigned char prefix[64] = {'t', 'e', 's', 't'};
#endif
for (i = 0; i < 32; i++) {
msg[i] = i + 1;
}
/* Test keygen. */
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ec_pubkey_create(ctx, &pubkey, key);
SECP256K1_CHECKMEM_DEFINE(&pubkey, sizeof(secp256k1_pubkey));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret);
CHECK(secp256k1_ec_pubkey_serialize(ctx, spubkey, &outputlen, &pubkey, SECP256K1_EC_COMPRESSED) == 1);
/* Test signing. */
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ecdsa_sign(ctx, &signature, msg, key, NULL, NULL);
SECP256K1_CHECKMEM_DEFINE(&signature, sizeof(secp256k1_ecdsa_signature));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret);
CHECK(secp256k1_ecdsa_signature_serialize_der(ctx, sig, &siglen, &signature));
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_ECDH
/* Test ECDH. */
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ecdh(ctx, msg, &pubkey, key, NULL, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_RECOVERY
/* Test signing a recoverable signature. */
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ecdsa_sign_recoverable(ctx, &recoverable_signature, msg, key, NULL, NULL);
SECP256K1_CHECKMEM_DEFINE(&recoverable_signature, sizeof(recoverable_signature));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret);
CHECK(secp256k1_ecdsa_recoverable_signature_serialize_compact(ctx, sig, &recid, &recoverable_signature));
CHECK(recid >= 0 && recid <= 3);
#endif
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ec_seckey_verify(ctx, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ec_seckey_negate(ctx, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(msg, 32);
ret = secp256k1_ec_seckey_tweak_add(ctx, key, msg);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(msg, 32);
ret = secp256k1_ec_seckey_tweak_mul(ctx, key, msg);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
2020-07-22 09:09:34 +00:00
/* Test keypair_create and keypair_xonly_tweak_add. */
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_EXTRAKEYS
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_keypair_create(ctx, &keypair, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
2020-07-22 09:09:34 +00:00
/* The tweak is not treated as a secret in keypair_tweak_add */
SECP256K1_CHECKMEM_DEFINE(msg, 32);
2020-07-22 09:09:34 +00:00
ret = secp256k1_keypair_xonly_tweak_add(ctx, &keypair, msg);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2020-07-22 09:09:34 +00:00
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(&keypair, sizeof(keypair));
ret = secp256k1_keypair_sec(ctx, key, &keypair);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
#endif
2020-09-18 13:36:07 +02:00
#ifdef ENABLE_MODULE_SCHNORRSIG
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_keypair_create(ctx, &keypair, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_schnorrsig_sign32(ctx, sig, msg, &keypair, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
#endif
2022-11-04 15:52:12 -04:00
#ifdef ENABLE_MODULE_MUSIG
{
secp256k1_pubkey pk;
const secp256k1_pubkey *pk_ptr[1];
secp256k1_xonly_pubkey agg_pk;
unsigned char session_secrand[32];
uint64_t nonrepeating_cnt = 0;
secp256k1_musig_secnonce secnonce;
secp256k1_musig_pubnonce pubnonce;
const secp256k1_musig_pubnonce *pubnonce_ptr[1];
secp256k1_musig_aggnonce aggnonce;
secp256k1_musig_keyagg_cache cache;
secp256k1_musig_session session;
secp256k1_musig_partial_sig partial_sig;
const secp256k1_musig_partial_sig *partial_sig_ptr[1];
unsigned char extra_input[32];
unsigned char sec_adaptor[32];
secp256k1_pubkey adaptor;
unsigned char pre_sig[64];
int nonce_parity;
pk_ptr[0] = &pk;
pubnonce_ptr[0] = &pubnonce;
SECP256K1_CHECKMEM_DEFINE(key, 32);
memcpy(session_secrand, key, sizeof(session_secrand));
session_secrand[0] = session_secrand[0] + 1;
memcpy(extra_input, key, sizeof(extra_input));
extra_input[0] = extra_input[0] + 2;
memcpy(sec_adaptor, key, sizeof(sec_adaptor));
sec_adaptor[0] = extra_input[0] + 3;
partial_sig_ptr[0] = &partial_sig;
CHECK(secp256k1_keypair_create(ctx, &keypair, key));
CHECK(secp256k1_keypair_pub(ctx, &pk, &keypair));
CHECK(secp256k1_musig_pubkey_agg(ctx, &agg_pk, &cache, pk_ptr, 1));
CHECK(secp256k1_ec_pubkey_create(ctx, &adaptor, sec_adaptor));
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(session_secrand, sizeof(session_secrand));
SECP256K1_CHECKMEM_UNDEFINE(extra_input, sizeof(extra_input));
SECP256K1_CHECKMEM_UNDEFINE(sec_adaptor, sizeof(sec_adaptor));
ret = secp256k1_musig_nonce_gen(ctx, &secnonce, &pubnonce, session_secrand, key, &pk, msg, &cache, extra_input);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_musig_nonce_gen_counter(ctx, &secnonce, &pubnonce, nonrepeating_cnt, &keypair, msg, &cache, extra_input);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
CHECK(secp256k1_musig_nonce_agg(ctx, &aggnonce, pubnonce_ptr, 1));
/* Make sure that previous tests don't undefine msg. It's not used as a secret here. */
SECP256K1_CHECKMEM_DEFINE(msg, sizeof(msg));
CHECK(secp256k1_musig_nonce_process(ctx, &session, &aggnonce, msg, &cache, &adaptor) == 1);
ret = secp256k1_keypair_create(ctx, &keypair, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_musig_partial_sign(ctx, &partial_sig, &secnonce, &keypair, &cache, &session);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&partial_sig, sizeof(partial_sig));
CHECK(secp256k1_musig_partial_sig_agg(ctx, pre_sig, &session, partial_sig_ptr, 1));
SECP256K1_CHECKMEM_DEFINE(pre_sig, sizeof(pre_sig));
CHECK(secp256k1_musig_nonce_parity(ctx, &nonce_parity, &session));
ret = secp256k1_musig_adapt(ctx, sig, pre_sig, sec_adaptor, nonce_parity);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_musig_extract_adaptor(ctx, sec_adaptor, sig, pre_sig, nonce_parity);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
#endif
2022-11-04 15:52:12 -04:00
#ifdef ENABLE_MODULE_ELLSWIFT
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
2022-11-04 15:52:12 -04:00
ret = secp256k1_ellswift_create(ctx, ellswift, key, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2022-11-04 15:52:12 -04:00
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
2022-11-04 15:52:12 -04:00
ret = secp256k1_ellswift_create(ctx, ellswift, key, ellswift);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2022-11-04 15:52:12 -04:00
CHECK(ret == 1);
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_DEFINE(&ellswift, sizeof(ellswift));
2022-11-04 15:52:12 -04:00
ret = secp256k1_ellswift_xdh(ctx, msg, ellswift, ellswift, key, i, secp256k1_ellswift_xdh_hash_function_bip324, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2022-11-04 15:52:12 -04:00
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_DEFINE(&ellswift, sizeof(ellswift));
2022-11-04 15:52:12 -04:00
ret = secp256k1_ellswift_xdh(ctx, msg, ellswift, ellswift, key, i, secp256k1_ellswift_xdh_hash_function_prefix, (void *)prefix);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2022-11-04 15:52:12 -04:00
CHECK(ret == 1);
}
#endif
2022-11-04 15:52:12 -04:00
2020-12-21 20:27:14 +00:00
#ifdef ENABLE_MODULE_ECDSA_S2C
{
unsigned char s2c_data[32] = {0};
unsigned char s2c_data_comm[32] = {0};
secp256k1_ecdsa_s2c_opening s2c_opening;
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(s2c_data, 32);
2020-12-21 20:27:14 +00:00
ret = secp256k1_ecdsa_s2c_sign(ctx, &signature, &s2c_opening, msg, key, s2c_data);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2020-12-21 20:27:14 +00:00
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(s2c_data, 32);
ret = secp256k1_ecdsa_anti_exfil_host_commit(ctx, s2c_data_comm, s2c_data);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2020-12-21 20:27:14 +00:00
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
SECP256K1_CHECKMEM_UNDEFINE(s2c_data, 32);
ret = secp256k1_ecdsa_anti_exfil_signer_commit(ctx, &s2c_opening, msg, key, s2c_data);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
2020-12-21 20:27:14 +00:00
CHECK(ret == 1);
}
#endif
#ifdef ENABLE_MODULE_ECDSA_ADAPTOR
{
unsigned char adaptor_sig[162];
unsigned char deckey[32];
unsigned char expected_deckey[32];
secp256k1_pubkey enckey;
for (i = 0; i < 32; i++) {
deckey[i] = i + 2;
}
ret = secp256k1_ec_pubkey_create(ctx, &enckey, deckey);
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_ecdsa_adaptor_encrypt(ctx, adaptor_sig, key, &enckey, msg, NULL, NULL);
SECP256K1_CHECKMEM_DEFINE(adaptor_sig, sizeof(adaptor_sig));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(deckey, 32);
ret = secp256k1_ecdsa_adaptor_decrypt(ctx, &signature, deckey, adaptor_sig);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_UNDEFINE(&signature, 32);
ret = secp256k1_ecdsa_adaptor_recover(ctx, expected_deckey, &signature, adaptor_sig, &enckey);
SECP256K1_CHECKMEM_DEFINE(expected_deckey, sizeof(expected_deckey));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(deckey, sizeof(deckey));
ret = secp256k1_memcmp_var(deckey, expected_deckey, sizeof(expected_deckey));
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 0);
}
2022-11-04 15:52:12 -04:00
#endif
2026-08-31 00:05:16 +02:00
#ifdef ENABLE_MODULE_FROST
{
unsigned char thresh_seckey[32];
unsigned char secshares[2 * 32];
secp256k1_pubkey thresh_pk;
secp256k1_pubkey pubshares[2];
uint32_t frost_ids[2] = { 0, 1 };
secp256k1_frost_tweak_cache cache;
secp256k1_frost_secnonce secnonce[2];
secp256k1_frost_pubnonce pubnonce[2];
const secp256k1_frost_pubnonce *pubnonce_ptrs[2];
secp256k1_frost_aggnonce aggnonce;
secp256k1_frost_session session;
secp256k1_frost_partial_sig partial_sig[2];
const secp256k1_frost_partial_sig *partial_sig_ptrs[2];
unsigned char session_secrand[2][32];
pubnonce_ptrs[0] = &pubnonce[0];
pubnonce_ptrs[1] = &pubnonce[1];
partial_sig_ptrs[0] = &partial_sig[0];
partial_sig_ptrs[1] = &partial_sig[1];
/* All public inputs are derived from defined memory. key is reused as
* the base of the (secret) threshold key and session randomness. */
SECP256K1_CHECKMEM_DEFINE(key, 32);
memcpy(thresh_seckey, key, sizeof(thresh_seckey));
thresh_seckey[0] = thresh_seckey[0] + 1;
memcpy(session_secrand[0], key, 32);
session_secrand[0][0] = session_secrand[0][0] + 2;
memcpy(session_secrand[1], key, 32);
session_secrand[1][0] = session_secrand[1][0] + 3;
/* Test frost_trusted_dealer_keygen. The threshold secret key and the
* resulting secret shares are secret; the threshold public key and
* the public shares are public. */
SECP256K1_CHECKMEM_UNDEFINE(thresh_seckey, sizeof(thresh_seckey));
ret = secp256k1_frost_trusted_dealer_keygen(ctx, secshares, &thresh_pk, pubshares, 2, 2, thresh_seckey);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&thresh_pk, sizeof(thresh_pk));
SECP256K1_CHECKMEM_DEFINE(pubshares, sizeof(pubshares));
/* The session setup uses only public inputs. */
CHECK(secp256k1_frost_tweak_cache_init(ctx, &cache, &thresh_pk) == 1);
/* Make sure that previous tests don't undefine msg. It's not used as a secret here. */
SECP256K1_CHECKMEM_DEFINE(msg, sizeof(msg));
/* Test frost_nonce_gen. The session randomness and the secret share
* are secret; the pubnonce is public. The secnonce stays secret. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(session_secrand[i], 32);
SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32);
ret = secp256k1_frost_nonce_gen(ctx, &secnonce[i], &pubnonce[i], session_secrand[i], &secshares[32 * i], &pubshares[i], NULL, msg, 32, NULL, 0);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&pubnonce[i], sizeof(pubnonce[i]));
}
CHECK(secp256k1_frost_nonce_agg(ctx, &aggnonce, NULL, pubnonce_ptrs, 2) == 1);
CHECK(secp256k1_frost_session_init(ctx, &session, &aggnonce, frost_ids, pubshares, 2, 2, 2, &cache, msg, 32) == 1);
/* Test frost_sign. The secret share is secret (the secnonce is
* tainted through the tainted session randomness; its magic bytes
* must remain defined for the validity check). The partial signature
* is public. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32);
ret = secp256k1_frost_sign(ctx, &partial_sig[i], &secnonce[i], &secshares[32 * i], &session, frost_ids, pubshares, 2, frost_ids[i]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&partial_sig[i], sizeof(partial_sig[i]));
}
CHECK(secp256k1_frost_partial_sig_agg(ctx, sig, NULL, &session, partial_sig_ptrs, 2) == 1);
frost: fix constant-time violations and C90 conformance The module's BIP 445 logic itself is unchanged and was independently validated against the pinned spec commit bb5396f (BIP v0.10.0), both via the checked-in test vectors and via differential testing against the Python reference over 360 randomised configurations (n up to 128, shuffled non-contiguous signer ids, mixed xonly/plain tweak chains, variable-length messages, pubshares present and absent). Every change below is structural: the differential harness produces byte-identical pubnonces, aggnonces, partial signatures and final signatures before and after. Two classes of problem prevented the module from passing CI. 1. Constant-time violations (ctime_tests) ----------------------------------------- The CI matrix enables FROST in rows that also run "valgrind --error-exitcode=42 ./ctime_tests" -- WITH_VALGRIND and CTIMETESTS both default to 'yes'. With the module enabled that job reported 639 "conditional jump depends on uninitialised value" errors, all originating from two sites: - secp256k1_frost_derive_coefficient returned !overflow && !secp256k1_scalar_is_zero(out) where the short-circuiting && branches on `overflow`, which is derived from the threshold secret key. The caller declassifies the return value, but the branch has already happened inside the callee. Replaced with a bitwise &, matching the existing idiom in secp256k1_scalar_set_b32_seckey (src/scalar_impl.h). - secp256k1_frost_sign_internal performs the self-verification recommended by BIP 445, which runs the *variable-time* secp256k1_ecmult over the partial signature s. nonce_pts and pk were already declassified ahead of that call; s was not. Since s is the public output of the function, declassifying it before the self-verification is both correct and sufficient. secp256k1_frost_deterministic_sign carried three more instances of the same class, invisible until now because ctime_tests did not exercise that path at all: - the `if (!valid)` check on secp256k1_scalar_set_b32_seckey lacked the declassify that the identical checks in secp256k1_frost_nonce_gen and secp256k1_frost_sign_internal already have; - secp256k1_frost_det_nonce_function used the same short-circuiting &&, here over the secret nonces; - the branch on that function's result was not declassified. The && in det_nonce_function is rewritten via two int locals rather than a bare bitwise &: clang's -Wbitwise-instead-of-logical fires when both operands are `!f(...)` expressions, which would break the -Werror clang builds. ctime_tests now also covers secp256k1_frost_deterministic_sign, so that path stays checked from here on. None of these leak anything of value in practice -- they reveal only negligible-probability events (a hash overflowing the curve order, a zero nonce) or whether a secret share is a valid secret key -- but they violate the project's declassification discipline and fail the ctime test. 2. C90 conformance (-Werror -pedantic-errors) --------------------------------------------- The project targets C90 (CMAKE_C_STANDARD 90, -std=c89 -pedantic) and CI passes WERROR_CFLAGS='-Werror -pedantic-errors'. Compiling src/tests.c with those flags produced 62 errors in three groups: - 40x "ISO C forbids empty initializer braces before C2X" in the generated vectors.h; empty {} initializers are C23-only. Fixed in tools/test_vectors_frost_generate.py so it survives regeneration: hexstr_to_intarray now emits "0" for an empty byte string (all six of its call sites wrap the result in braces), and init_group's `counted` helper emits "{ 0 }" for an empty group. In every affected slot the paired count/length field is 0, so the padding element is never read. - 1x "comma at end of enumerator list" (C99+), also in the generator. - 21x "initializer element is not computable at load time" across 11 lines of tests_impl.h. C90 requires constant expressions in initializers for automatic aggregates, so const secp256k1_frost_pubnonce *ptrs[2] = { &a, &b }; is invalid. Rewritten as a declaration plus assignments, the style the musig tests already use, which is why the pre-existing tree was green. vectors.h is regenerated from the spec's JSON vectors. Its hex payload is byte-identical (verified by hashing every 0xNN token) and the file still reproduces exactly from tools/test_vectors_frost_generate.py. Verification ------------ - gcc and clang, -std=c89 -pedantic-errors -Werror, with and without -DVERIFY: clean (was 62 errors) - ctime_tests under MemorySanitizer: 0 reports (was 639); exits 0 with halt_on_error=1 - tests, noverify_tests and frost_example: pass - vectors.h regenerates identically from the pinned spec vectors - 240 signing + 120 deterministic-signing differential cases against the BIP 445 Python reference: byte-identical to the pre-fix build Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:57:43 +02:00
/* Test frost_deterministic_sign. The secret share is secret; the
* pubnonce and the partial signature are public. */
for (i = 0; i < 2; i++) {
secp256k1_frost_pubnonce det_pubnonce;
secp256k1_frost_partial_sig det_partial_sig;
SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32);
ret = secp256k1_frost_deterministic_sign(ctx, &det_partial_sig, &det_pubnonce, &secshares[32 * i], frost_ids[i], &aggnonce, frost_ids, pubshares, 2, 2, 2, &cache, msg, 32, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&det_pubnonce, sizeof(det_pubnonce));
SECP256K1_CHECKMEM_DEFINE(&det_partial_sig, sizeof(det_partial_sig));
}
2026-08-31 00:05:16 +02:00
}
#endif
chilldkg: CI wiring, ctime_tests coverage, declassify fixes CI: - ci/ci.sh: new CHILLDKG environment variable, passed to configure as --enable-module-chilldkg (mirroring FROST). - .github/workflows/ci.yml: default CHILLDKG: 'no' and CHILLDKG: 'yes' in every job that enables FROST, except the x86_64 matrix entry that deliberately builds without the ecdh module (chilldkg requires schnorrsig + ecdh; the configure-time dependency error would fire there). YAML validity and per-job dependency presence checked programmatically. ctime_tests: - src/ctime_tests.c: run a full ChillDKG session (n = 2, t = 2) through the public API under the memory checker: hostpubkey_gen, params_hash, participant_step1, coordinator_step1, participant_step2, coordinator_finalize, participant_finalize, participant_recover and recovery_ack_sign. Host secret keys, session randomness, aux randomness and the resulting secret shares are undefined (secret); all protocol messages, the certificate, threshold public key, public shares, recovery data, ack signature and the secret-free state1 objects are defined (public). state2 stays secret (contains the secret share). Constant-time fixes found by running the new block under MemorySanitizer (valgrind unavailable locally; MSan build via clang + CMake). All are missing declassifications of secret-derived but public (or public-outcome) values, following the frost module's secp256k1_declassify pattern with justification comments; no real constant-time bugs were found: - hostpubkey_gen: declassify the computed host public key before serialization (public output). - participant_step1: declassify the zero-randomness check result (only reveals "the RNG returned 32 zero bytes", which aborts the session). - encpedpop participant_step1: declassify the pubnonce point before serialization (public, part of pmsg1). - chilldkg_schnorrsig_sign: declassify the signer public key before normalization/parity branch, and declassify the return value (a failure only reveals a zero derived nonce, negligible probability). - vss_commit: declassify the VSS commitments before serialization (public, part of pmsg1). - vss_verify_secshare: declassify secshare*G before the infinity/eq checks (equals the public pubshare in honest runs; the discrete log is not revealed). - simplpedpop_participant_investigate (proactive audit; not reached by ctime_tests): declassify the secshare-sum comparison result (the public fault code reveals it anyway). Verified: MSan ctime_tests exits 0; autotools make check 10/10 (the local tree is configured without --enable-ctime-tests because neither valgrind nor an MSan-instrumented gcc build is available; CI runs ctime_tests under valgrind as before); CMake ctest 428/428; ./tests --target=chilldkg and ./chilldkg_example pass.
2026-08-31 10:25:14 +02:00
#ifdef ENABLE_MODULE_CHILLDKG
{
/* Full ChillDKG session with n = 2, t = 2 (pmsg1: 227 bytes, cmsg1:
* 357 bytes, cert: 128 bytes, recovery data: 394 bytes). */
unsigned char hostseckeys[2][32];
unsigned char hostpubkeys[2 * 33];
unsigned char dkg_random[2][32];
unsigned char aux_rands[2][32];
secp256k1_chilldkg_participant_state1 state1[2];
secp256k1_chilldkg_participant_state2 state2[2];
secp256k1_chilldkg_coordinator_state cstate;
unsigned char pmsgs1[2][227];
const unsigned char *pmsgs1_ptrs[2];
unsigned char cmsg1[357];
unsigned char pmsgs2[2][64];
const unsigned char *pmsgs2_ptrs[2];
unsigned char cmsg2[128];
unsigned char secshare32[32];
unsigned char thresh_pk33[33];
unsigned char pubshares33[2 * 33];
unsigned char recovery[394];
unsigned char rec_secshare32[32];
unsigned char rec_thresh_pk33[33];
unsigned char rec_pubshares33[33 * SECP256K1_CHILLDKG_MAX_PARTICIPANTS];
unsigned char rec_hostpubkeys33[33 * SECP256K1_CHILLDKG_MAX_PARTICIPANTS];
unsigned char ack_sig[64];
size_t rec_n;
uint32_t rec_t;
uint32_t fault_index;
secp256k1_chilldkg_fault fault;
pmsgs1_ptrs[0] = pmsgs1[0];
pmsgs1_ptrs[1] = pmsgs1[1];
pmsgs2_ptrs[0] = pmsgs2[0];
pmsgs2_ptrs[1] = pmsgs2[1];
/* All public inputs are derived from defined memory. key is reused as
* the base of the (secret) host keys, session randomness and
* auxiliary randomness. */
SECP256K1_CHECKMEM_DEFINE(key, 32);
for (i = 0; i < 2; i++) {
memcpy(hostseckeys[i], key, 32);
hostseckeys[i][0] = hostseckeys[i][0] + 4 + i;
memcpy(dkg_random[i], key, 32);
dkg_random[i][0] = dkg_random[i][0] + 6 + i;
memcpy(aux_rands[i], key, 32);
aux_rands[i][0] = aux_rands[i][0] + 8 + i;
}
/* Test chilldkg_hostpubkey_gen. The host secret keys are secret; the
* host public keys are public. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(hostseckeys[i], 32);
ret = secp256k1_chilldkg_hostpubkey_gen(ctx, &hostpubkeys[33 * i], hostseckeys[i]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&hostpubkeys[33 * i], 33);
}
/* Test chilldkg_participant_step1. The host secret key and the
* session randomness are secret; pmsg1 and the state1 object (which
* contains no secrets) are public. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(hostseckeys[i], 32);
SECP256K1_CHECKMEM_UNDEFINE(dkg_random[i], 32);
ret = secp256k1_chilldkg_participant_step1(ctx, &state1[i], pmsgs1[i], hostseckeys[i], hostpubkeys, 2, 2, dkg_random[i]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(pmsgs1[i], sizeof(pmsgs1[i]));
SECP256K1_CHECKMEM_DEFINE(&state1[i], sizeof(state1[i]));
}
/* The coordinator's steps use only public inputs. */
fault = secp256k1_chilldkg_coordinator_step1(ctx, &cstate, cmsg1, &fault_index, pmsgs1_ptrs, hostpubkeys, 2, 2);
SECP256K1_CHECKMEM_DEFINE(&fault, sizeof(fault));
CHECK(fault == SECP256K1_CHILLDKG_OK);
/* Test chilldkg_participant_step2. The host secret key and the aux
* randomness are secret; the CertEq signature is public. The state2
* object contains the secret share and stays secret. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(hostseckeys[i], 32);
SECP256K1_CHECKMEM_UNDEFINE(aux_rands[i], 32);
fault = secp256k1_chilldkg_participant_step2(ctx, &state2[i], pmsgs2[i], &fault_index, NULL, &state1[i], hostseckeys[i], cmsg1, aux_rands[i]);
SECP256K1_CHECKMEM_DEFINE(&fault, sizeof(fault));
CHECK(fault == SECP256K1_CHILLDKG_OK);
SECP256K1_CHECKMEM_DEFINE(pmsgs2[i], sizeof(pmsgs2[i]));
}
fault = secp256k1_chilldkg_coordinator_finalize(ctx, cmsg2, thresh_pk33, pubshares33, recovery, &fault_index, &cstate, pmsgs2_ptrs);
SECP256K1_CHECKMEM_DEFINE(&fault, sizeof(fault));
CHECK(fault == SECP256K1_CHILLDKG_OK);
/* Test chilldkg_participant_finalize. The state2 input and the
* secshare output are secret; the threshold public key, the public
* shares and the recovery data are public. */
fault = secp256k1_chilldkg_participant_finalize(ctx, secshare32, thresh_pk33, pubshares33, recovery, &fault_index, &state2[0], cmsg2);
SECP256K1_CHECKMEM_DEFINE(&fault, sizeof(fault));
CHECK(fault == SECP256K1_CHILLDKG_OK);
SECP256K1_CHECKMEM_DEFINE(thresh_pk33, sizeof(thresh_pk33));
SECP256K1_CHECKMEM_DEFINE(pubshares33, sizeof(pubshares33));
SECP256K1_CHECKMEM_DEFINE(recovery, sizeof(recovery));
/* Test chilldkg_participant_recover. The host secret key and the
* recovered secret share are secret; the recovery data and the
* remaining outputs are public. */
SECP256K1_CHECKMEM_UNDEFINE(hostseckeys[0], 32);
fault = secp256k1_chilldkg_participant_recover(ctx, rec_secshare32, rec_thresh_pk33, rec_pubshares33, rec_hostpubkeys33, &rec_n, &rec_t, &fault_index, hostseckeys[0], recovery, sizeof(recovery));
SECP256K1_CHECKMEM_DEFINE(&fault, sizeof(fault));
CHECK(fault == SECP256K1_CHILLDKG_OK);
SECP256K1_CHECKMEM_DEFINE(rec_thresh_pk33, sizeof(rec_thresh_pk33));
SECP256K1_CHECKMEM_DEFINE(rec_pubshares33, 2 * 33);
SECP256K1_CHECKMEM_DEFINE(rec_hostpubkeys33, 2 * 33);
SECP256K1_CHECKMEM_DEFINE(&rec_n, sizeof(rec_n));
SECP256K1_CHECKMEM_DEFINE(&rec_t, sizeof(rec_t));
CHECK(rec_n == 2 && rec_t == 2);
/* Test chilldkg_recovery_ack_sign. The host secret key and the aux
* randomness are secret; the acknowledgment signature is public. */
SECP256K1_CHECKMEM_UNDEFINE(hostseckeys[0], 32);
SECP256K1_CHECKMEM_UNDEFINE(aux_rands[0], 32);
ret = secp256k1_chilldkg_recovery_ack_sign(ctx, ack_sig, hostseckeys[0], hostpubkeys, 2, 2, recovery, sizeof(recovery), aux_rands[0]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(ack_sig, sizeof(ack_sig));
}
#endif
prefractal: document the deviations and add the constant-time test doc/prefractal.md writes down the three deliberate deviations from BIP 445 where a reviewer will find them, since none of them is visible from the API and two of them are actively counterintuitive: 1. b_frost does not commit to the message, because the target protocols publish the group's wire nonce before the message exists. The outer b_musig does commit to it and multiplies b_frost everywhere it appears. 2. There is no g_frost factor, and the reason is NOT that the tweak cache is the identity. The frost key-side factor is g*gacc; an identity cache gives gacc = 1, but g is still -1 for every odd-Y threshold key. The doc spells this out because "identity cache, therefore no key term" is the plausible wrong reason, and acting on it yields a signer that works for even-Y groups and fails for odd-Y ones. 3. The frost tweak cache must be the identity, checked at signing and verification and not only at aggregation, so the key a member signs under is tied to the cache that was validated. It also records the two caller obligations the module cannot enforce - one secnonce per signature, and round-two signers EQUAL to round-one contributors - and notes that iceberg tolerates a round-two subset where this module must not, since callers moving between the two would otherwise transpose the rule. The build section documents the three-way ordering constraint rather than leaving the next person to copy iceberg's positions, which are wrong for configure.ac. The ctime test adds a 2-of-2 nested group with a stock musig cosigner, taken as far as one signature share, marking the threshold key, the secret shares and the session randomness as secret and everything else as public. Nonce generation passes msg = NULL, which is how the module is actually driven. Verified: valgrind -q ./build/bin/ctime_tests exits 0, so nothing in the prefractal signing path branches on secret data. Full test suite green. The example program from the plan's optional list is not included; the test suite covers the same ground and the doc carries the usage rules.
2026-09-04 01:06:14 +02:00
#ifdef ENABLE_MODULE_PREFRACTAL
{
/* A 2-of-2 nested group with one stock musig cosigner, taken as far as
* one signature share. Secret here is the threshold key, the secret
* shares derived from it, and the session randomness the nonces come
* from. Not secret: the identifiers, the public shares, the threshold
* public key, both aggregate nonces, the outer keyagg cache, b_frost,
* and the resulting partial signature. */
unsigned char thresh_seckey[32];
unsigned char secshares[2 * 32];
unsigned char session_secrand[2][32];
unsigned char cosigner_seckey[32];
secp256k1_pubkey thresh_pk, pubshares[2], cosigner_pk;
const secp256k1_pubkey *outer_pubkeys[2];
uint32_t pf_ids[2] = { 0, 1 };
secp256k1_frost_tweak_cache pf_cache;
secp256k1_frost_secnonce pf_secnonce[2];
secp256k1_frost_pubnonce pf_pubnonce[2];
const secp256k1_frost_pubnonce *pf_pubnonce_ptrs[2];
secp256k1_frost_aggnonce pf_aggnonce;
secp256k1_frost_partial_sig pf_partial_sig;
secp256k1_xonly_pubkey outer_xonly;
secp256k1_musig_keyagg_cache outer_cache;
secp256k1_musig_pubnonce group_pubnonce, cosigner_pubnonce;
const secp256k1_musig_pubnonce *just_cosigner[1];
secp256k1_musig_secnonce cosigner_secnonce;
secp256k1_musig_aggnonce cosigner_aggnonce;
unsigned char cosigner_secrand[32];
pf_pubnonce_ptrs[0] = &pf_pubnonce[0];
pf_pubnonce_ptrs[1] = &pf_pubnonce[1];
SECP256K1_CHECKMEM_DEFINE(key, 32);
memcpy(thresh_seckey, key, sizeof(thresh_seckey));
thresh_seckey[0] = thresh_seckey[0] + 4;
memcpy(cosigner_seckey, key, sizeof(cosigner_seckey));
cosigner_seckey[0] = cosigner_seckey[0] + 5;
memcpy(session_secrand[0], key, 32);
session_secrand[0][0] = session_secrand[0][0] + 6;
memcpy(session_secrand[1], key, 32);
session_secrand[1][0] = session_secrand[1][0] + 7;
memcpy(cosigner_secrand, key, 32);
cosigner_secrand[0] = cosigner_secrand[0] + 8;
SECP256K1_CHECKMEM_DEFINE(msg, sizeof(msg));
SECP256K1_CHECKMEM_UNDEFINE(thresh_seckey, sizeof(thresh_seckey));
ret = secp256k1_frost_trusted_dealer_keygen(ctx, secshares, &thresh_pk, pubshares, 2, 2, thresh_seckey);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&thresh_pk, sizeof(thresh_pk));
SECP256K1_CHECKMEM_DEFINE(pubshares, sizeof(pubshares));
CHECK(secp256k1_frost_tweak_cache_init(ctx, &pf_cache, &thresh_pk) == 1);
/* The outer aggregation and the cosigner's round one are entirely
* public as far as this module is concerned. */
SECP256K1_CHECKMEM_UNDEFINE(cosigner_seckey, sizeof(cosigner_seckey));
ret = secp256k1_ec_pubkey_create(ctx, &cosigner_pk, cosigner_seckey);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&cosigner_pk, sizeof(cosigner_pk));
outer_pubkeys[0] = &thresh_pk;
outer_pubkeys[1] = &cosigner_pk;
CHECK(secp256k1_musig_pubkey_agg(ctx, &outer_xonly, &outer_cache, outer_pubkeys, 2) == 1);
SECP256K1_CHECKMEM_UNDEFINE(cosigner_secrand, sizeof(cosigner_secrand));
ret = secp256k1_musig_nonce_gen(ctx, &cosigner_secnonce, &cosigner_pubnonce, cosigner_secrand, cosigner_seckey, &cosigner_pk, msg, &outer_cache, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&cosigner_pubnonce, sizeof(cosigner_pubnonce));
just_cosigner[0] = &cosigner_pubnonce;
CHECK(secp256k1_musig_nonce_agg(ctx, &cosigner_aggnonce, just_cosigner, 1) == 1);
/* Group round one. msg is NULL: the wire nonce is published before the
* message is known. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(session_secrand[i], 32);
SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32);
ret = secp256k1_frost_nonce_gen(ctx, &pf_secnonce[i], &pf_pubnonce[i], session_secrand[i], &secshares[32 * i], &pubshares[i], NULL, NULL, 0, NULL, 0);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&pf_pubnonce[i], sizeof(pf_pubnonce[i]));
}
/* Aggregation is over published nonces only, so it is public. */
CHECK(secp256k1_prefractal_nonce_agg(ctx, &group_pubnonce, &pf_aggnonce, pf_pubnonce_ptrs, pf_ids, 2, &thresh_pk) == 1);
/* The share and the secnonce are secret; the partial signature is the
* public output. */
ret = secp256k1_prefractal_sign(ctx, &pf_partial_sig, &pf_secnonce[0], &secshares[0], pf_ids[0], pf_ids, pubshares, 2, &pf_aggnonce, &thresh_pk, &pf_cache, &outer_cache, &cosigner_aggnonce, msg);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&pf_partial_sig, sizeof(pf_partial_sig));
}
#endif
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
#ifdef ENABLE_MODULE_FROST_ENROLLMENT
{
/* A 2-of-3 group enrolling a fourth participant, taken through all
* three rounds. Secret here is the threshold key, the secret shares
* derived from it, the session randomness the split comes from, and
* every delta and sigma value on the wire -- those are additive shares
* of real secret shares. Not secret: the identifiers, the public
* shares, the threshold public key, the parameters hashes, and the
* public share derived for the new participant. */
unsigned char thresh_seckey[32];
unsigned char secshares[3 * 32];
unsigned char session_secrand[2][32];
unsigned char shares[2][2 * 32];
unsigned char all_shares[2 * 32];
unsigned char received_hashes[2 * 32];
unsigned char params_hashes[2][32];
unsigned char sigmas[2 * 32];
unsigned char new_secshare[32];
unsigned char direct_hash[32];
secp256k1_pubkey thresh_pk, pubshares[3], new_pubshare;
uint32_t fe_ids[2] = { 0, 1 };
uint32_t mismatch_id;
int j;
SECP256K1_CHECKMEM_DEFINE(key, 32);
memcpy(thresh_seckey, key, sizeof(thresh_seckey));
thresh_seckey[0] = thresh_seckey[0] + 9;
memcpy(session_secrand[0], key, 32);
session_secrand[0][0] = session_secrand[0][0] + 10;
memcpy(session_secrand[1], key, 32);
session_secrand[1][0] = session_secrand[1][0] + 11;
SECP256K1_CHECKMEM_UNDEFINE(thresh_seckey, sizeof(thresh_seckey));
ret = secp256k1_frost_trusted_dealer_keygen(ctx, secshares, &thresh_pk, pubshares, 3, 2, thresh_seckey);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&thresh_pk, sizeof(thresh_pk));
SECP256K1_CHECKMEM_DEFINE(pubshares, sizeof(pubshares));
/* The parameters hash and the public share at the target identifier
* are functions of public data alone. */
CHECK(secp256k1_frost_enrollment_params_hash(ctx, direct_hash, &thresh_pk, fe_ids, 2, 3, 3, 2) == 1);
CHECK(secp256k1_frost_enrollment_pubshare_derive(ctx, &new_pubshare, pubshares, fe_ids, 2, 3, 3, 2) == 1);
/* Round 1.1. The seed and the secret share are secret; the parameters
* hash is public, the delta values are not. */
for (i = 0; i < 2; i++) {
SECP256K1_CHECKMEM_UNDEFINE(session_secrand[i], 32);
SECP256K1_CHECKMEM_UNDEFINE(&secshares[32 * i], 32);
ret = secp256k1_frost_enrollment_shares_gen(ctx, shares[i], params_hashes[i], session_secrand[i], &secshares[32 * i], &thresh_pk, fe_ids, 2, fe_ids[i], 3, 3, 2);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(params_hashes[i], 32);
}
/* Round 1.2, for both helpers. The delta values stay secret through
* the sum; only the return value is examined. */
for (j = 0; j < 2; j++) {
memset(received_hashes, 0, sizeof(received_hashes));
for (i = 0; i < 2; i++) {
memcpy(&all_shares[32 * i], &shares[i][32 * j], 32);
if (i != j) {
memcpy(&received_hashes[32 * i], params_hashes[i], 32);
}
}
ret = secp256k1_frost_enrollment_share_agg(ctx, &sigmas[32 * j], &mismatch_id, all_shares, received_hashes, &thresh_pk, fe_ids, 2, fe_ids[j], 3, 3, 2);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
/* Round 2, with both optional checks on. The resulting share is
* secret; the verification against the expected public share is the
* one place a secret-derived point is deliberately declassified. */
ret = secp256k1_frost_enrollment_secshare_gen(ctx, new_secshare, sigmas, &thresh_pk, fe_ids, 2, 3, 3, 2, params_hashes[0], &new_pubshare);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
#endif
#ifdef ENABLE_MODULE_ICEBERG
{
/* A 3-of-5 group, dealt from `key` and taken as far as one signature
* share. Secret here is the dealer's root seed and everything the
* module derives from it: the per-subset seeds, the key share, and the
* two nonce shares. Not secret: participant indices, the group and
* threshold, every Lagrange weight, the commitments, the session label,
* and both nonce coefficients. */
enum { ICEBERG_N = 5, ICEBERG_T = 3, ICEBERG_MU = 2 * ICEBERG_T - 1 };
secp256k1_iceberg_share shares[ICEBERG_N];
secp256k1_iceberg_share *share_ptr[ICEBERG_N];
secp256k1_iceberg_share_cache share_cache;
secp256k1_iceberg_pubshare pubshares[ICEBERG_N];
const secp256k1_iceberg_pubshare *pubshare_ptr[ICEBERG_N];
secp256k1_iceberg_pubnonce nonces[ICEBERG_N];
const secp256k1_iceberg_pubnonce *nonce_ptr[ICEBERG_N];
secp256k1_iceberg_aggnonce iceberg_aggnonce;
secp256k1_iceberg_partial_sig iceberg_psig;
secp256k1_musig_pubnonce group_pubnonce, cosigner_pubnonce;
const secp256k1_musig_pubnonce *cosigner_ptr[1];
secp256k1_musig_secnonce cosigner_secnonce;
secp256k1_musig_aggnonce cosigner_aggnonce;
secp256k1_musig_keyagg_cache iceberg_cache;
secp256k1_pubkey group_pk, cosigner_pk;
const secp256k1_pubkey *iceberg_pk_ptr[2];
unsigned char share_bytes[SECP256K1_ICEBERG_SHARE_MAX_LEN];
unsigned char sid[32], cosigner_secrand[32];
size_t share_len;
int party;
for (party = 0; party < ICEBERG_N; party++) {
share_ptr[party] = &shares[party];
pubshare_ptr[party] = &pubshares[party];
nonce_ptr[party] = &nonces[party];
}
SECP256K1_CHECKMEM_DEFINE(key, 32);
/* The cosigner needs randomness distinct from the dealer's root seed;
* any perturbation of `key` will do. */
memcpy(cosigner_secrand, key, sizeof(cosigner_secrand));
cosigner_secrand[0] = cosigner_secrand[0] + 3;
CHECK(secp256k1_keypair_create(ctx, &keypair, key));
CHECK(secp256k1_keypair_pub(ctx, &cosigner_pk, &keypair));
SECP256K1_CHECKMEM_UNDEFINE(key, 32);
ret = secp256k1_iceberg_shares_gen(ctx, share_ptr, ICEBERG_N, ICEBERG_T, key);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
/* Storing and restoring a share moves seed material through a buffer,
* which is where a length or an offset computed from it would show. */
share_len = sizeof(share_bytes);
ret = secp256k1_iceberg_share_serialize(ctx, share_bytes, &share_len, &shares[0]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_iceberg_share_parse(ctx, &shares[0], share_bytes, share_len);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
ret = secp256k1_iceberg_share_cache_create(ctx, &share_cache, &shares[0]);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
/* The header says the cache holds no secret, and this is the line that
* makes that a result rather than a claim. Every byte of it must be
* defined: the weights come from the group size, the threshold and the
* participant index, all of which are public, and none of them from a
* seed. Declassifying is not the same test: it would say the value may be
* published, where this says nothing secret reached it. */
SECP256K1_CHECKMEM_CHECK(&share_cache, sizeof(share_cache));
for (party = 0; party < ICEBERG_N; party++) {
ret = secp256k1_iceberg_pubshare_gen(ctx, &pubshares[party], &shares[party],
party == 0 ? &share_cache : NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
/* A commitment is public, and so is everything built from one. */
SECP256K1_CHECKMEM_DEFINE(pubshares, sizeof(pubshares));
CHECK(secp256k1_iceberg_pubkey_agg(ctx, &group_pk, pubshare_ptr, ICEBERG_MU, ICEBERG_N, ICEBERG_T) == 1);
iceberg_pk_ptr[0] = &group_pk;
iceberg_pk_ptr[1] = &cosigner_pk;
CHECK(secp256k1_musig_pubkey_agg(ctx, NULL, &iceberg_cache, iceberg_pk_ptr, 2));
SECP256K1_CHECKMEM_DEFINE(msg, sizeof(msg));
SECP256K1_CHECKMEM_UNDEFINE(cosigner_secrand, sizeof(cosigner_secrand));
ret = secp256k1_musig_nonce_gen(ctx, &cosigner_secnonce, &cosigner_pubnonce,
cosigner_secrand, NULL, &cosigner_pk, msg,
&iceberg_cache, NULL);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
SECP256K1_CHECKMEM_DEFINE(&cosigner_pubnonce, sizeof(cosigner_pubnonce));
cosigner_ptr[0] = &cosigner_pubnonce;
CHECK(secp256k1_musig_nonce_agg(ctx, &cosigner_aggnonce, cosigner_ptr, 1));
memset(sid, 0x7e, sizeof(sid)); /* public: the label is the caller's to choose */
for (party = 0; party < ICEBERG_MU; party++) {
ret = secp256k1_iceberg_nonce_gen(ctx, &nonces[party], &shares[party],
party == 0 ? &share_cache : NULL, sid);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
SECP256K1_CHECKMEM_DEFINE(nonces, sizeof(nonces));
CHECK(secp256k1_iceberg_nonce_agg(ctx, &group_pubnonce, &iceberg_aggnonce,
nonce_ptr, ICEBERG_MU, ICEBERG_N, ICEBERG_T, &group_pk) == 1);
ret = secp256k1_iceberg_partial_sign(ctx, &iceberg_psig, &shares[0], &share_cache,
sid, nonce_ptr, ICEBERG_MU, &group_pk,
&iceberg_cache, msg, &cosigner_aggnonce);
SECP256K1_CHECKMEM_DEFINE(&ret, sizeof(ret));
CHECK(ret == 1);
}
#endif
}
#if defined(__GNUC__)
# pragma GCC diagnostic pop
#endif