From 5155ee03f75b51760f1df5763bd94f02f12cd8b0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 4 Sep 2026 04:30:48 +0200 Subject: [PATCH] frost_enrollment: add the example program Fifth of six commits. examples/frost_enrollment.c runs a 2-of-3 group through an enrollment to 2-of-4, signs with the new participant, and then repairs a lost share -- all roles in one process, following examples/frost.c's structure. The example exists mostly to demonstrate two things the API cannot enforce and that a reader would otherwise have to reconstruct from the documentation. First, the verification flow, in the order that makes it non-circular: 1. obtain thresh_pk from a source authenticated INDEPENDENTLY of the helpers (here, the dealer step, commented as the stand-in); 2. validate the helpers' public shares against it with secp256k1_frost_threshold_info_validate; 3. derive the expected public share from those validated shares; 4. only then run round 2, passing the same authenticated thresh_pk. Skip step 1 or 2 and every check in round 2 still passes -- on a share from whatever polynomial t colluding helpers chose to present. The example says so at the point where it would be tempting to skip them. Second, the authorization gap. There is no authorization step in the protocol: anyone who convinces t helpers to run it receives a valid share, and in repair mode that is an existing participant's actual share. The precondition sits in the comment on enroll(), where a reader copying the function will see it, and again at the repair call site, which is where it bites hardest. Beyond that the example is a working reference for the parts that are fiddly to get right from the header alone: the transposition between round 1.1's output buffers and round 1.2's input buffer (helper j collects entry j of every helper's buffer), the opposite own-slot conventions of the two round-1.2 buffers, the n -> n+1 bookkeeping with threshold_info_validate over the extended table, and the fact that the resulting signature verifies against the group's ORIGINAL threshold public key, since enrollment changes neither the polynomial nor any existing share. The repair half asserts byte equality with the original secret share and the original public share, so a regression there fails the example rather than passing quietly. Wired into both build systems next to the iceberg example: Makefile.am (noinst_PROGRAMS and TESTS under ENABLE_MODULE_FROST_ENROLLMENT) and examples/CMakeLists.txt. The .gitignore entry landed with the Phase 1 scaffolding. Verification. Autotools: builds warning-free and `make check` reports 12/12 PASS including frost_enrollment_example; five consecutive runs exit 0 (the key material is freshly random each time, so this exercises both threshold-key parities in practice). CMake: with -DSECP256K1_BUILD_EXAMPLES=ON, ctest runs all nine examples and secp256k1.example.frost_enrollment passes; the full ctest suite is 523/523 with frost, chilldkg, iceberg, prefractal and frost-enrollment all enabled. The source is clean under gcc -std=c89 -pedantic -Wall -Wextra. Note for anyone reproducing this: examples are OFF by default in both build systems (--enable-examples for autotools, -DSECP256K1_BUILD_EXAMPLES=ON for CMake), so a plain build will not compile this file at all. Co-Authored-By: Claude Opus 5 --- Makefile.am | 11 + examples/CMakeLists.txt | 4 + examples/frost_enrollment.c | 423 ++++++++++++++++++++++++++++++++++++ 3 files changed, 438 insertions(+) create mode 100644 examples/frost_enrollment.c diff --git a/Makefile.am b/Makefile.am index 15bc7644..4a0c4f18 100644 --- a/Makefile.am +++ b/Makefile.am @@ -251,6 +251,17 @@ iceberg_example_LDFLAGS += -lbcrypt endif TESTS += iceberg_example endif +if ENABLE_MODULE_FROST_ENROLLMENT +noinst_PROGRAMS += frost_enrollment_example +frost_enrollment_example_SOURCES = examples/frost_enrollment.c +frost_enrollment_example_CPPFLAGS = -I$(top_srcdir)/include -DSECP256K1_STATIC +frost_enrollment_example_LDADD = libsecp256k1.la +frost_enrollment_example_LDFLAGS = -static +if BUILD_WINDOWS +frost_enrollment_example_LDFLAGS += -lbcrypt +endif +TESTS += frost_enrollment_example +endif endif ### Precomputed tables diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index d04e6645..648bda1d 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -43,3 +43,7 @@ endif() if(SECP256K1_ENABLE_MODULE_ICEBERG) add_example(iceberg) endif() + +if(SECP256K1_ENABLE_MODULE_FROST_ENROLLMENT) + add_example(frost_enrollment) +endif() diff --git a/examples/frost_enrollment.c b/examples/frost_enrollment.c new file mode 100644 index 00000000..7edac885 --- /dev/null +++ b/examples/frost_enrollment.c @@ -0,0 +1,423 @@ +/************************************************************************* + * To the extent possible under law, the author(s) have dedicated all * + * copyright and related and neighboring rights to the software in this * + * file to the public domain worldwide. This software is distributed * + * without any warranty. For the CC0 Public Domain Dedication, see * + * EXAMPLES_COPYING or https://creativecommons.org/publicdomain/zero/1.0 * + *************************************************************************/ + +/** This file demonstrates how to use the FROST enrollment module: first to + * grow a 2-of-3 group into a 2-of-4 one without re-running key generation, + * then to repair a participant's lost share. Additionally, see the + * documentation in include/secp256k1_frost_enrollment.h and + * src/modules/frost_enrollment/frost_enrollment.md. + * + * The example runs all roles (trusted dealer, helpers, the party receiving + * the share, coordinator) in a single process. In a real deployment these + * roles are performed by different parties, and the enrollment shares and + * the aggregated values they produce MUST travel over confidential and + * authenticated channels: they are additive shares of real secret shares. + * + * Two things this example demonstrates that are easy to get wrong: + * + * 1. The verification flow is only non-circular if the threshold public key + * comes from somewhere the helpers do not control. Here the enrollee gets + * it straight from the dealer step, which stands in for real + * authentication, and validates the helpers' public shares against it + * before deriving anything from them. + * 2. Nothing in the protocol decides WHO may be enrolled. That is a + * caller-side precondition; see the note before enroll() below. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "examples_util.h" + +/* Number of participants n before the enrollment */ +#define N_PARTICIPANTS 3 +/* Number of participants after the enrollment */ +#define N_PARTICIPANTS_AFTER (N_PARTICIPANTS + 1) +/* Threshold t */ +#define THRESHOLD 2 +/* Number of helpers u taking part in the enrollment run. Any u with + * THRESHOLD <= u <= N_PARTICIPANTS works; the result does not depend on the + * choice. */ +#define N_HELPERS 2 +/* The identifier the new participant will hold. In enrollment mode this must + * be exactly the current participant count. */ +#define NEW_ID N_PARTICIPANTS +/* The signers of the demonstration signing session: participant 2 and the + * newly enrolled participant. */ +#define N_SIGNERS 2 + +/* What one helper holds during a run. */ +struct helper { + uint32_t id; + /* The helper's own secret share of the group key. Never leaves the + * helper. */ + unsigned char secshare[32]; + /* Round 1.1 output, aligned with the helper id array: entry j goes to the + * helper at position j. The entry at this helper's own position is kept + * locally. These are secret. */ + unsigned char shares[N_HELPERS * 32]; + /* The parameters hash this helper computed, sent alongside every share it + * distributes and to the enrollee. Public. */ + unsigned char params_hash[32]; + /* Round 1.2 output, sent to the enrollee. Secret. */ + unsigned char sigma[32]; +}; + +/* Run the trusted dealer key generation for the initial 2-of-3 group. + * + * WARNING: The trusted dealer knows the threshold secret key and all secret + * shares, and must erase them after distributing the shares. A distributed key + * generation protocol avoids a trusted dealer entirely; see the chilldkg + * module. */ +static int trusted_dealer_keygen(const secp256k1_context* ctx, unsigned char *threshold_seckey, unsigned char *secshares, secp256k1_pubkey *thresh_pk, secp256k1_pubkey *pubshares) { + if (!fill_random(threshold_seckey, 32)) { + printf("Failed to generate randomness\n"); + return 0; + } + if (!secp256k1_frost_trusted_dealer_keygen(ctx, secshares, thresh_pk, pubshares, N_PARTICIPANTS, THRESHOLD, threshold_seckey)) { + return 0; + } + return secp256k1_frost_threshold_info_validate(ctx, thresh_pk, pubshares, N_PARTICIPANTS, THRESHOLD); +} + +/* Run one enrollment or repair, all three rounds. + * + * `helper_pubshares` and `helpers` are aligned with `ids`. On success the new + * secret share is written to `new_secshare` and its public counterpart to + * `new_pubshare`. + * + * PRECONDITION THE LIBRARY CANNOT ENFORCE: the helpers must already have + * agreed, out of band, that this party is entitled to a share at `new_id`. + * The protocol has no authorization step of its own: anyone who convinces t + * helpers to run it walks away with a valid share, and in repair mode that is + * an existing participant's actual share. Authenticated channels establish who + * is speaking, not that the group approved the request. */ +static int enroll(const secp256k1_context* ctx, unsigned char *new_secshare, secp256k1_pubkey *new_pubshare, struct helper *helpers, const uint32_t *ids, const secp256k1_pubkey *helper_pubshares, const secp256k1_pubkey *thresh_pk, uint32_t new_id) { + unsigned char sigmas[N_HELPERS * 32]; + unsigned char enrollee_params_hash[32]; + int i, j; + + /* --- Round 1.1 ------------------------------------------------------ + * Every helper splits its Lagrange-weighted share into one additive share + * per helper, keeps its own and sends the rest out. */ + for (i = 0; i < N_HELPERS; i++) { + unsigned char session_secrand[32]; + + /* Fresh randomness for every run. Reusing it across runs leaks share + * information. shares_gen wipes it before returning. */ + if (!fill_random(session_secrand, sizeof(session_secrand))) { + printf("Failed to generate randomness\n"); + return 0; + } + if (!secp256k1_frost_enrollment_shares_gen(ctx, helpers[i].shares, helpers[i].params_hash, session_secrand, helpers[i].secshare, thresh_pk, ids, N_HELPERS, helpers[i].id, new_id, N_PARTICIPANTS, THRESHOLD)) { + return 0; + } + secure_erase(session_secrand, sizeof(session_secrand)); + } + + /* --- Round 1.2 ------------------------------------------------------ + * Helper j receives entry j of every other helper's buffer, over a + * confidential and authenticated channel, together with that helper's + * parameters hash. It recomputes the hash itself and compares. */ + for (j = 0; j < N_HELPERS; j++) { + unsigned char all_shares[N_HELPERS * 32]; + unsigned char received_hashes[N_HELPERS * 32]; + uint32_t mismatch_id; + + /* The two buffers take deliberately opposite conventions at this + * helper's own position: the share kept locally IS read, while the + * hash slot is never read and stays zero. The own hash is + * recomputed, never taken from a buffer -- which is what makes this + * a recomputation check rather than a comparison between two strings + * the caller supplied. */ + memset(received_hashes, 0, sizeof(received_hashes)); + for (i = 0; i < N_HELPERS; i++) { + memcpy(&all_shares[32 * i], &helpers[i].shares[32 * j], 32); + if (i != j) { + memcpy(&received_hashes[32 * i], helpers[i].params_hash, 32); + } + } + if (!secp256k1_frost_enrollment_share_agg(ctx, helpers[j].sigma, &mismatch_id, all_shares, received_hashes, thresh_pk, ids, N_HELPERS, helpers[j].id, new_id, N_PARTICIPANTS, THRESHOLD)) { + if (mismatch_id != UINT32_MAX) { + printf("\nHelper %u disagrees about the enrollment parameters\n", mismatch_id); + } + return 0; + } + secure_erase(all_shares, sizeof(all_shares)); + } + + /* --- Round 2 -------------------------------------------------------- + * The enrollee receives one value from each helper, again confidentially + * and authenticated, plus the parameters hash. + * + * The expected public share is derived from the helpers' public shares -- + * which the caller has already validated against an independently + * authenticated threshold public key (see main). Without that step both + * checks below would be circular: t colluding helpers could present a + * consistent but fabricated polynomial and every check would pass on a + * worthless share. */ + for (i = 0; i < N_HELPERS; i++) { + memcpy(&sigmas[32 * i], helpers[i].sigma, 32); + } + memcpy(enrollee_params_hash, helpers[0].params_hash, 32); + + if (!secp256k1_frost_enrollment_pubshare_derive(ctx, new_pubshare, helper_pubshares, ids, N_HELPERS, new_id, N_PARTICIPANTS, THRESHOLD)) { + return 0; + } + if (!secp256k1_frost_enrollment_secshare_gen(ctx, new_secshare, sigmas, thresh_pk, ids, N_HELPERS, new_id, N_PARTICIPANTS, THRESHOLD, enrollee_params_hash, new_pubshare)) { + return 0; + } + secure_erase(sigmas, sizeof(sigmas)); + for (i = 0; i < N_HELPERS; i++) { + secure_erase(helpers[i].shares, sizeof(helpers[i].shares)); + secure_erase(helpers[i].sigma, sizeof(helpers[i].sigma)); + } + return 1; +} + +/* Produce a BIP340 signature with the given signer set and verify it against + * the (untweaked) threshold public key. `secshares` and `pubshares` are + * aligned with `ids`. `n_participants` is the group size AFTER the enrollment, + * which every participant must have updated consistently. */ +static int sign_and_verify(const secp256k1_context* ctx, const uint32_t *ids, unsigned char (*secshares)[32], const secp256k1_pubkey *pubshares, const secp256k1_pubkey *thresh_pk, size_t n_participants, const unsigned char *msg, size_t msglen) { + secp256k1_frost_tweak_cache cache; + secp256k1_frost_secnonce secnonces[N_SIGNERS]; + secp256k1_frost_pubnonce pubnonces[N_SIGNERS]; + const secp256k1_frost_pubnonce *pubnonce_ptrs[N_SIGNERS]; + secp256k1_frost_partial_sig partial_sigs[N_SIGNERS]; + const secp256k1_frost_partial_sig *partial_sig_ptrs[N_SIGNERS]; + secp256k1_frost_aggnonce aggnonce; + secp256k1_frost_session session; + secp256k1_xonly_pubkey tweaked_pk; + unsigned char tweaked_pk32[32]; + unsigned char sig[64]; + int i; + int ret = 0; + + if (!secp256k1_frost_tweak_cache_init(ctx, &cache, thresh_pk)) { + return 0; + } + /* No tweaks are applied here, so the "tweaked" key is the threshold public + * key itself in its x-only encoding. */ + if (!secp256k1_frost_tweaked_pubkey_get(ctx, &tweaked_pk, &cache) + || !secp256k1_xonly_pubkey_serialize(ctx, tweaked_pk32, &tweaked_pk)) { + return 0; + } + + for (i = 0; i < N_SIGNERS; i++) { + unsigned char session_secrand[32]; + if (!fill_random(session_secrand, sizeof(session_secrand))) { + printf("Failed to generate randomness\n"); + return 0; + } + if (!secp256k1_frost_nonce_gen(ctx, &secnonces[i], &pubnonces[i], session_secrand, secshares[i], &pubshares[i], tweaked_pk32, msg, msglen, NULL, 0)) { + return 0; + } + secure_erase(session_secrand, sizeof(session_secrand)); + pubnonce_ptrs[i] = &pubnonces[i]; + partial_sig_ptrs[i] = &partial_sigs[i]; + } + if (!secp256k1_frost_nonce_agg(ctx, &aggnonce, NULL, pubnonce_ptrs, N_SIGNERS)) { + return 0; + } + if (!secp256k1_frost_session_init(ctx, &session, &aggnonce, ids, pubshares, N_SIGNERS, n_participants, THRESHOLD, &cache, msg, msglen)) { + return 0; + } + for (i = 0; i < N_SIGNERS; i++) { + if (!secp256k1_frost_sign(ctx, &partial_sigs[i], &secnonces[i], secshares[i], &session, ids, pubshares, N_SIGNERS, ids[i])) { + goto cleanup; + } + if (!secp256k1_frost_partial_sig_verify(ctx, &partial_sigs[i], &pubnonces[i], &pubshares[i], &session, ids, N_SIGNERS, (size_t)i)) { + goto cleanup; + } + } + if (!secp256k1_frost_partial_sig_agg(ctx, sig, NULL, &session, partial_sig_ptrs, N_SIGNERS)) { + goto cleanup; + } + ret = secp256k1_schnorrsig_verify(ctx, sig, msg, msglen, &tweaked_pk); + +cleanup: + for (i = 0; i < N_SIGNERS; i++) { + secure_erase(&secnonces[i], sizeof(secnonces[i])); + } + return ret; +} + +int main(void) { + secp256k1_context* ctx; + /* Key material of the initial 2-of-3 group. The dealer erases all of it + * at the end of this function. */ + unsigned char threshold_seckey[32]; + unsigned char secshares[N_PARTICIPANTS * 32]; + secp256k1_pubkey thresh_pk; + secp256k1_pubkey pubshares[N_PARTICIPANTS_AFTER]; + + /* The enrollment run for the new participant 3. */ + struct helper helpers[N_HELPERS]; + uint32_t helper_ids[N_HELPERS] = { 0, 1 }; + secp256k1_pubkey helper_pubshares[N_HELPERS]; + unsigned char new_secshare[32]; + secp256k1_pubkey new_pubshare; + + /* The repair run for participant 1. */ + struct helper repair_helpers[N_HELPERS]; + uint32_t repair_ids[N_HELPERS] = { 0, 2 }; + secp256k1_pubkey repair_pubshares[N_HELPERS]; + unsigned char repaired_secshare[32]; + secp256k1_pubkey repaired_pubshare; + + /* The signing session that proves the enrolled participant works. */ + uint32_t signer_ids[N_SIGNERS] = { 2, NEW_ID }; + unsigned char signer_secshares[N_SIGNERS][32]; + secp256k1_pubkey signer_pubshares[N_SIGNERS]; + unsigned char msg[32] = "this_could_be_the_hash_of_a_msg"; + + unsigned char buf[33]; + size_t outputlen; + int i; + + ctx = secp256k1_context_create(SECP256K1_CONTEXT_NONE); + + printf("Generating threshold key material for a %d-of-%d group...", THRESHOLD, N_PARTICIPANTS); + fflush(stdout); + if (!trusted_dealer_keygen(ctx, threshold_seckey, secshares, &thresh_pk, pubshares)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + outputlen = sizeof(buf); + if (!secp256k1_ec_pubkey_serialize(ctx, buf, &outputlen, &thresh_pk, SECP256K1_EC_COMPRESSED)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("Threshold public key: "); + print_hex(buf, outputlen); + fflush(stdout); + + /* The new participant obtains the threshold public key from a source it + * authenticates INDEPENDENTLY OF THE HELPERS. In this single-process + * example that is the dealer step above; in a real deployment it might be + * a signed group descriptor, an on-chain commitment, or whatever the group + * already trusts to say what it is. + * + * It then validates the helpers' public shares against that key. This is + * the step that makes the round 2 checks worth anything: without it, the + * enrollee would be checking the helpers' arithmetic against numbers the + * helpers also chose, and t colluding helpers could hand it a consistent + * fake polynomial. */ + printf("Validating the group's public shares against the authenticated key..."); + fflush(stdout); + if (!secp256k1_frost_threshold_info_validate(ctx, &thresh_pk, pubshares, N_PARTICIPANTS, THRESHOLD)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + + /* Enrollment: helpers 0 and 1 give participant 3 a share. */ + for (i = 0; i < N_HELPERS; i++) { + helpers[i].id = helper_ids[i]; + memcpy(helpers[i].secshare, &secshares[32 * helper_ids[i]], 32); + helper_pubshares[i] = pubshares[helper_ids[i]]; + } + printf("Enrolling participant %d with %d helpers...", NEW_ID, N_HELPERS); + fflush(stdout); + if (!enroll(ctx, new_secshare, &new_pubshare, helpers, helper_ids, helper_pubshares, &thresh_pk, NEW_ID)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + + /* n -> n+1 bookkeeping. Every participant must extend its table of public + * shares and update its record of n; participants that disagree about n + * compute different Lagrange coefficients and cannot sign together. */ + pubshares[NEW_ID] = new_pubshare; + printf("Validating the extended %d-participant key material...", N_PARTICIPANTS_AFTER); + fflush(stdout); + if (!secp256k1_frost_threshold_info_validate(ctx, &thresh_pk, pubshares, N_PARTICIPANTS_AFTER, THRESHOLD)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + outputlen = sizeof(buf); + if (!secp256k1_ec_pubkey_serialize(ctx, buf, &outputlen, &new_pubshare, SECP256K1_EC_COMPRESSED)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("New participant's public share: "); + print_hex(buf, outputlen); + fflush(stdout); + + /* Sign with participant 2 and the newly enrolled participant 3. The + * signature verifies against the group's original threshold public key: + * enrollment does not change the group key or any existing share. */ + memcpy(signer_secshares[0], &secshares[32 * signer_ids[0]], 32); + memcpy(signer_secshares[1], new_secshare, 32); + signer_pubshares[0] = pubshares[signer_ids[0]]; + signer_pubshares[1] = pubshares[signer_ids[1]]; + printf("Signing with participants %u and %u...", signer_ids[0], signer_ids[1]); + fflush(stdout); + if (!sign_and_verify(ctx, signer_ids, signer_secshares, signer_pubshares, &thresh_pk, N_PARTICIPANTS_AFTER, msg, sizeof(msg))) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + + /* Repair: participant 1 lost its share. The same three rounds at + * new_id = 1 reproduce it exactly -- the share is a fixed value, f(x_1), + * not a fresh random one. Note that n does NOT change here, and that this + * is the mode where the missing authorization step bites hardest: whoever + * convinces the helpers to run it receives participant 1's actual share. + */ + for (i = 0; i < N_HELPERS; i++) { + repair_helpers[i].id = repair_ids[i]; + memcpy(repair_helpers[i].secshare, &secshares[32 * repair_ids[i]], 32); + repair_pubshares[i] = pubshares[repair_ids[i]]; + } + printf("Repairing participant 1's lost share..."); + fflush(stdout); + if (!enroll(ctx, repaired_secshare, &repaired_pubshare, repair_helpers, repair_ids, repair_pubshares, &thresh_pk, 1)) { + printf("FAILED\n"); + return EXIT_FAILURE; + } + if (memcmp(repaired_secshare, &secshares[32], 32) != 0) { + printf("FAILED (the repaired share differs from the original)\n"); + return EXIT_FAILURE; + } + if (memcmp(&repaired_pubshare, &pubshares[1], sizeof(repaired_pubshare)) != 0) { + printf("FAILED (the repaired public share differs from the original)\n"); + return EXIT_FAILURE; + } + printf("ok\n"); + + /* It's best practice to try to clear secrets from memory after using them. + * This is done because some bugs can allow an attacker to leak memory, for + * example through "out of bounds" array access (see Heartbleed), or the OS + * swapping them to disk. Hence, we overwrite secret key material with + * zeros. + * + * The session randomness was already wiped by shares_gen, and the + * secnonces by frost_sign. */ + secure_erase(threshold_seckey, sizeof(threshold_seckey)); + secure_erase(secshares, sizeof(secshares)); + secure_erase(new_secshare, sizeof(new_secshare)); + secure_erase(repaired_secshare, sizeof(repaired_secshare)); + secure_erase(signer_secshares, sizeof(signer_secshares)); + for (i = 0; i < N_HELPERS; i++) { + secure_erase(&helpers[i], sizeof(helpers[i])); + secure_erase(&repair_helpers[i], sizeof(repair_helpers[i])); + } + secp256k1_context_destroy(ctx); + return EXIT_SUCCESS; +}