frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
#ifndef SECP256K1_FROST_ENROLLMENT_H
|
|
|
|
|
#define SECP256K1_FROST_ENROLLMENT_H
|
|
|
|
|
|
|
|
|
|
#include "secp256k1.h"
|
|
|
|
|
#include "secp256k1_frost.h"
|
|
|
|
|
|
|
|
|
|
#ifdef __cplusplus
|
|
|
|
|
extern "C" {
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
#include <stddef.h>
|
|
|
|
|
#include <stdint.h>
|
|
|
|
|
|
|
|
|
|
/** This module implements FROST enrollment, the protocol of Section 4.1.1 of
|
|
|
|
|
* the FROST paper (https://eprint.iacr.org/2017/1155.pdf). It converts a
|
|
|
|
|
* (t, n) FROST group into a (t, n+1) group without re-running key generation
|
|
|
|
|
* and without any participant revealing its secret share. The same mechanism
|
|
|
|
|
* repairs a lost share: running it with the target identifier of an existing
|
|
|
|
|
* participant reproduces exactly that participant's share.
|
|
|
|
|
*
|
|
|
|
|
* This code is currently a work in progress. It's not secure nor stable.
|
|
|
|
|
* IT IS EXTREMELY DANGEROUS AND RECKLESS TO USE THIS MODULE IN PRODUCTION!
|
|
|
|
|
*
|
|
|
|
|
* Unlike the frost module, this protocol has no BIP. It is specified only by
|
|
|
|
|
* the paper and by the reference proof of concept at
|
|
|
|
|
* https://github.com/siv2r/frost-enrollment. The tagged hash strings and the
|
|
|
|
|
* parameter serialization used here are this module's own, frozen by the
|
|
|
|
|
* regression vectors in src/modules/frost_enrollment/vectors.h.
|
|
|
|
|
*
|
|
|
|
|
* Identifiers follow the frost module: participants are identified by uint32
|
|
|
|
|
* values 0..n-1, and participant id i sits at polynomial x-coordinate i+1.
|
|
|
|
|
* The party receiving a share is identified by `new_id`, which selects the
|
|
|
|
|
* mode:
|
|
|
|
|
*
|
|
|
|
|
* - enrollment: new_id == n_participants. The group grows to n+1
|
|
|
|
|
* participants, so n_participants must be strictly smaller than
|
|
|
|
|
* SECP256K1_FROST_MAX_PARTICIPANTS.
|
|
|
|
|
* - repair: new_id < n_participants. The share of an existing participant
|
|
|
|
|
* is reproduced; n_participants may be SECP256K1_FROST_MAX_PARTICIPANTS.
|
|
|
|
|
*
|
|
|
|
|
* In both modes new_id must not appear in the helper id set.
|
|
|
|
|
*
|
|
|
|
|
* A run involves u helpers (threshold <= u <= n_participants), all of which
|
|
|
|
|
* must be existing participants, plus the target participant:
|
|
|
|
|
*
|
|
|
|
|
* 1. Round 1.1: every helper runs
|
|
|
|
|
* `secp256k1_frost_enrollment_shares_gen`, keeps the output share at
|
|
|
|
|
* its own position and sends each other output share, together with the
|
|
|
|
|
* returned parameters hash, to the helper it is aligned with.
|
|
|
|
|
* 2. Round 1.2: every helper runs
|
|
|
|
|
* `secp256k1_frost_enrollment_share_agg` over the share it kept and the
|
|
|
|
|
* shares it received. The function recomputes the parameters hash and
|
|
|
|
|
* compares every received hash against it, then sums the shares into a
|
|
|
|
|
* single value that is sent to the target participant along with the
|
|
|
|
|
* hash.
|
|
|
|
|
* 3. Round 2: the target participant runs
|
|
|
|
|
* `secp256k1_frost_enrollment_secshare_gen` over the u received values.
|
|
|
|
|
* It recomputes the parameters hash from the group key and parameters it
|
|
|
|
|
* believes, sums the values into its secret share, and verifies the
|
|
|
|
|
* result against the expected public share obtained from
|
|
|
|
|
* `secp256k1_frost_enrollment_pubshare_derive`.
|
|
|
|
|
*
|
|
|
|
|
* Afterwards, in enrollment mode, all participants must consistently update
|
|
|
|
|
* their record of n from n to n+1, and extend their table of public shares
|
|
|
|
|
* with the output of `secp256k1_frost_enrollment_pubshare_derive`.
|
|
|
|
|
*
|
|
|
|
|
* SECURITY: the values exchanged in rounds 1 and 2 are additive shares of
|
|
|
|
|
* real secret shares. They MUST be transmitted over confidential and
|
|
|
|
|
* authenticated channels. Like the chilldkg module, this module handles bytes
|
|
|
|
|
* only; transport is the caller's responsibility. Furthermore, the protocol
|
|
|
|
|
* contains no authorization step: any party that convinces t helpers to run
|
|
|
|
|
* it at a given identifier receives a valid share for that identifier. In
|
|
|
|
|
* repair mode that is an existing participant's actual share. Deciding who
|
|
|
|
|
* may be enrolled is a caller-side precondition.
|
|
|
|
|
*
|
|
|
|
|
* It is recommended to read the documentation in this include file carefully.
|
|
|
|
|
* Further notes on API usage can be found in
|
|
|
|
|
* src/modules/frost_enrollment/frost_enrollment.md.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** Compute the enrollment parameters hash.
|
|
|
|
|
*
|
|
|
|
|
* The hash is
|
|
|
|
|
*
|
|
|
|
|
* out32 = tagged_hash("FROST enrollment/params_hash",
|
|
|
|
|
* cbytes(thresh_pk) || ser32(n_participants) ||
|
|
|
|
|
* ser32(threshold) || ser32(new_id) || ser32(n_ids) ||
|
|
|
|
|
* ser32(sorted_ids[0]) || ... ||
|
|
|
|
|
* ser32(sorted_ids[n_ids-1]))
|
|
|
|
|
*
|
|
|
|
|
* where cbytes is the 33-byte compressed serialization, ser32 is the 32-bit
|
|
|
|
|
* big-endian encoding and sorted_ids is `ids` in ascending order. Sorting
|
|
|
|
|
* makes the hash independent of the order in which a caller lists the helper
|
|
|
|
|
* set; the alignment of every other array in this API follows the caller's
|
|
|
|
|
* own `ids` order.
|
|
|
|
|
*
|
|
|
|
|
* Binding the threshold public key is what makes the hash identify a GROUP
|
|
|
|
|
* rather than a tuple of numbers: two unrelated groups that happen to share
|
|
|
|
|
* (t, n, ids, new_id) produce different hashes.
|
|
|
|
|
*
|
|
|
|
|
* Every party computes this value itself. The hash checks performed by
|
|
|
|
|
* `secp256k1_frost_enrollment_share_agg` and
|
|
|
|
|
* `secp256k1_frost_enrollment_secshare_gen` compare a received hash against a
|
|
|
|
|
* freshly recomputed one; they are never an equality test between two
|
|
|
|
|
* caller-supplied strings.
|
|
|
|
|
*
|
|
|
|
|
* This function operates on public data only.
|
|
|
|
|
*
|
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
|
|
|
* This is also the natural place to pre-validate a parameter tuple: it
|
|
|
|
|
* enforces exactly the same constraints as the four functions below, and
|
|
|
|
|
* nothing else. Note that an unusable `thresh_pk` object is API MISUSE, not
|
|
|
|
|
* an invalid parameter: like every other entry point in the library, this
|
|
|
|
|
* function reports it through the illegal-argument callback rather than by
|
|
|
|
|
* returning 0.
|
|
|
|
|
*
|
|
|
|
|
* Returns: 0 if the parameters are invalid, 1 otherwise
|
frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
* Args: ctx: pointer to a context object
|
|
|
|
|
* Out: out32: pointer to a 32-byte array for the hash. Set to zero
|
|
|
|
|
* if this function returns 0.
|
|
|
|
|
* In: thresh_pk: pointer to the threshold public key of the group
|
|
|
|
|
* ids: array of the u helper identifiers. Every id must be
|
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
|
|
|
* unique, smaller than n_participants and different
|
|
|
|
|
* from new_id; the order is irrelevant.
|
|
|
|
|
* n_ids: number of helpers u. Must be between threshold and
|
|
|
|
|
* n_participants.
|
|
|
|
|
* new_id: identifier of the participant receiving the share.
|
|
|
|
|
* Must equal n_participants (enrollment) or be smaller
|
|
|
|
|
* than it (repair).
|
|
|
|
|
* n_participants: total number of participants n. Must be at most
|
|
|
|
|
* SECP256K1_FROST_MAX_PARTICIPANTS, and strictly
|
|
|
|
|
* smaller in enrollment mode.
|
|
|
|
|
* threshold: threshold t. Must be at least 2 (see
|
|
|
|
|
* frost_enrollment.md) and at most n_participants.
|
frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
*/
|
|
|
|
|
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_enrollment_params_hash(
|
|
|
|
|
const secp256k1_context *ctx,
|
|
|
|
|
unsigned char *out32,
|
|
|
|
|
const secp256k1_pubkey *thresh_pk,
|
|
|
|
|
const uint32_t *ids,
|
|
|
|
|
size_t n_ids,
|
|
|
|
|
uint32_t new_id,
|
|
|
|
|
size_t n_participants,
|
|
|
|
|
uint32_t threshold
|
|
|
|
|
) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4);
|
|
|
|
|
|
|
|
|
|
/** Round 1.1: generate a helper's enrollment shares.
|
|
|
|
|
*
|
|
|
|
|
* Computes v = lambda_my_id(x_new) * secshare, where lambda is the Lagrange
|
|
|
|
|
* basis polynomial of my_id over the helper set evaluated at the target
|
|
|
|
|
* x-coordinate, and splits v into u additive shares that sum to v.
|
|
|
|
|
*
|
|
|
|
|
* `shares32_out` is aligned with `ids`: entry j is destined for the helper
|
|
|
|
|
* with identifier ids[j] and must be sent to it over a confidential,
|
|
|
|
|
* authenticated channel, together with `params_hash32_out`. The entry at
|
|
|
|
|
* my_id's own position is not sent anywhere; it is passed back into
|
|
|
|
|
* `secp256k1_frost_enrollment_share_agg` in round 1.2.
|
|
|
|
|
*
|
|
|
|
|
* The masking shares are derived from `session_secrand32` by tagged hashing,
|
|
|
|
|
* masked with the secret share as defense in depth against a broken random
|
|
|
|
|
* number generator (as `secp256k1_frost_nonce_gen` does). The derivation
|
|
|
|
|
* binds the parameters hash, and therefore the group key and the whole
|
|
|
|
|
* parameter tuple, for DOMAIN SEPARATION: two runs that share a seed but
|
|
|
|
|
* differ in group or parameters must not produce the same shares, because
|
|
|
|
|
* differencing the round-1.2 outputs would then leak share information. This
|
|
|
|
|
* binding cannot detect a parameter disagreement between helpers — these
|
|
|
|
|
* values are per-helper private randomness that nothing cross-checks — which
|
|
|
|
|
* is what the parameters hash comparison in round 1.2 is for.
|
|
|
|
|
*
|
|
|
|
|
* `session_secrand32` must be fresh uniformly random 32 bytes for every run.
|
|
|
|
|
* It is wiped before this function returns. `secshare32` is left unmodified.
|
|
|
|
|
*
|
|
|
|
|
* Returns: 0 if the arguments are invalid, 1 otherwise
|
|
|
|
|
* Args: ctx: pointer to a context object
|
|
|
|
|
* Out: shares32_out: pointer to an array of u*32 bytes for the enrollment
|
frost_enrollment: correct the zeroing contract and harden the CI guard
The two findings from the second review round, plus the coverage gap and
comment error it noted.
Finding 8: the zeroing exception was narrower in the docs than in the
code.
The previous commit's fix leaves shares32_out untouched on EVERY
params_are_valid rejection -- duplicate ids, threshold < 2, new_id
among the helpers, an id >= n, the mode bounds -- but the header
scoped the exception to "when n_ids itself is out of range", and
frost_enrollment.md still said flatly that outputs are zeroed on every
failure path. A caller who inspects the buffer after a failed call
would, on a duplicate-id or t = 1 rejection, read whatever was there
before. In this protocol that can be additive shares of a secret from
an earlier call.
Both now state the actual invariant: not written when the parameter
tuple is rejected, because its extent is only known to be u*32 once
n_ids has been validated against the rest of the tuple; zeroed on
every failure found after that point (an unusable thresh_pk, my_id not
among the helpers, an invalid secret share). The .md adds the sentence
a defensive caller needs: treat a rejected tuple as leaving the buffer
unmodified, not as leaving it zeroed. Documentation only -- the
implementation and the tests already agreed with this reading.
Finding 9: the new CMake CI step never checked that the modules landed.
It configured, built and installed with all five module flags, then
ran `ls -RlAh` -- which prints and does not assert -- and linked
examples/ecdsa.c, which uses core symbols only. Deleting the
add_compile_definitions or PUBLIC_HEADER line for a module during an
upstream sync would keep the step green: no module code means no
undefined references, and the install simply omits the header. The
guard added one commit ago caught a MISORDERED block but not a
MISSING one.
The step now asserts both halves. `ls` of all five module headers
fails the step under bash -e if any is absent, and
examples/frost_enrollment.c is compiled and run against the installed
library, which no header-only check would cover -- it needs the
symbols to actually be in the library.
Verified by running the step's exact command sequence under bash -e:
it exits 0 as written, and against an install configured WITHOUT the
modules the header assertion exits 2 and the example fails to compile
on the missing include.
Coverage: the params-hash attribution path was still only tested where
identifier equals index. The previous commit pinned identifier
semantics for the scalar-overflow path on helper set {0, 2}, but
share_agg's other `*mismatch_id = ids[i]` -- the parameters-hash
disagreement -- was exercised only on {0, 1}. It is now pinned on
{1, 3}, corrupting each slot in turn so the mapping is fixed at both
ends: index 0 must report 1, index 1 must report 3. Verified
load-bearing by changing the assignment to `(uint32_t)i`, which fails
the new case.
Comment error: the {0, 2} overflow case carried the claim that it would
catch an implementation returning array indices. It would not -- it
corrupts slot 0, whose identifier is also 0. The discrimination comes
from the paired own-slot case at slot 1 (identifier 2), and the comment
now says so.
Correcting the record rather than rewriting it: commit 69766dd3's
message says the suite goes "from 12 cases to 16" with "six new tests".
Both counts are wrong. The contract test landed in 266c6a7c, so the
table went 13 -> 17, and that commit added four cases (api, infinity,
no_side_effects, max_size) alongside the x = 0 addition to an existing
test and the two de-confoundings. The commits are unpushed and could be
amended, but the review cites them by hash, so the numbers are corrected
here instead. With this commit the module has 17 tests.
Verification: warning-free build; 17/17 at -i=16, -i=200 and -i=1000;
./tests, ./noverify_tests and ./exhaustive_tests exit 0; `make check`
12/12; the module clean under valgrind (0 errors from 0 contexts);
ctime_tests clean under valgrind; CMake ctest 542/542; vectors.h still
regenerates byte for byte, so nothing here touched a frozen encoding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:35:59 +02:00
|
|
|
* shares, aligned with `ids`. Not written at all when
|
|
|
|
|
* the parameter tuple is rejected, since its extent is
|
|
|
|
|
* only known to be u*32 once n_ids has been validated
|
|
|
|
|
* against the rest of the tuple; zeroed on every
|
|
|
|
|
* failure detected after that point.
|
frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
* params_hash32_out: pointer to a 32-byte array for the parameters hash,
|
|
|
|
|
* identical to what
|
|
|
|
|
* `secp256k1_frost_enrollment_params_hash` returns for
|
|
|
|
|
* the same arguments. Set to zero if this function
|
|
|
|
|
* returns 0.
|
|
|
|
|
* In/Out:
|
|
|
|
|
* session_secrand32: pointer to a 32-byte array of fresh randomness. Must
|
|
|
|
|
* not be reused across runs. Wiped by this function.
|
|
|
|
|
* In: secshare32: pointer to the 32-byte secret share of my_id
|
|
|
|
|
* thresh_pk: pointer to the threshold public key of the group
|
|
|
|
|
* ids: array of the u helper identifiers. Every id must be
|
|
|
|
|
* unique, smaller than n_participants and different
|
|
|
|
|
* from new_id; the order is irrelevant but fixes the
|
|
|
|
|
* alignment of `shares32_out`.
|
|
|
|
|
* n_ids: number of helpers u. Must be between threshold and
|
|
|
|
|
* n_participants.
|
|
|
|
|
* my_id: own identifier. Must appear in `ids`.
|
|
|
|
|
* new_id: identifier of the participant receiving the share.
|
|
|
|
|
* Must equal n_participants (enrollment) or be smaller
|
|
|
|
|
* than it (repair).
|
|
|
|
|
* n_participants: total number of participants n. Must be at most
|
|
|
|
|
* SECP256K1_FROST_MAX_PARTICIPANTS, and strictly
|
|
|
|
|
* smaller in enrollment mode.
|
|
|
|
|
* threshold: threshold t. Must be at least 2 (see
|
|
|
|
|
* frost_enrollment.md) and at most n_participants.
|
|
|
|
|
*/
|
|
|
|
|
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_enrollment_shares_gen(
|
|
|
|
|
const secp256k1_context *ctx,
|
|
|
|
|
unsigned char *shares32_out,
|
|
|
|
|
unsigned char *params_hash32_out,
|
|
|
|
|
unsigned char *session_secrand32,
|
|
|
|
|
const unsigned char *secshare32,
|
|
|
|
|
const secp256k1_pubkey *thresh_pk,
|
|
|
|
|
const uint32_t *ids,
|
|
|
|
|
size_t n_ids,
|
|
|
|
|
uint32_t my_id,
|
|
|
|
|
uint32_t new_id,
|
|
|
|
|
size_t n_participants,
|
|
|
|
|
uint32_t threshold
|
|
|
|
|
) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(5) SECP256K1_ARG_NONNULL(6) SECP256K1_ARG_NONNULL(7);
|
|
|
|
|
|
|
|
|
|
/** Round 1.2: check parameter agreement and aggregate a helper's enrollment
|
|
|
|
|
* shares.
|
|
|
|
|
*
|
|
|
|
|
* The function recomputes its own parameters hash from `thresh_pk` and the
|
|
|
|
|
* parameter tuple it is given, and compares every entry of
|
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
|
|
|
* `received_params_hashes32` against it. It then sums the shares.
|
|
|
|
|
*
|
|
|
|
|
* `mismatch_id` reports fault attribution for BOTH ways a specific helper's
|
|
|
|
|
* contribution can be at fault:
|
|
|
|
|
*
|
|
|
|
|
* - its parameters hash disagrees with the recomputed one, meaning that
|
|
|
|
|
* helper ran round 1.1 on a different parameter tuple or in a different
|
|
|
|
|
* group;
|
|
|
|
|
* - its entry in `all_shares32` is not a valid scalar (it is not smaller
|
|
|
|
|
* than the group order), meaning the value was corrupted in transit or
|
|
|
|
|
* fabricated.
|
|
|
|
|
*
|
|
|
|
|
* In both cases the function returns 0 and, if `mismatch_id` is not NULL,
|
|
|
|
|
* stores the IDENTIFIER of the responsible helper there -- not an array
|
|
|
|
|
* index, which would be ambiguous because identifiers need not be 0..u-1.
|
|
|
|
|
* The two causes are not distinguished, so a caller should not report one of
|
|
|
|
|
* them specifically. Note that the second cause can name the CALLER'S OWN
|
|
|
|
|
* identifier, since the share kept locally is summed along with the rest.
|
|
|
|
|
*
|
|
|
|
|
* `mismatch_id` is set to UINT32_MAX when the failure has neither cause,
|
|
|
|
|
* which covers every invalid-parameter and API-misuse case.
|
frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
*
|
|
|
|
|
* Note the deliberately OPPOSITE own-slot conventions of the two u*32 input
|
|
|
|
|
* buffers, both of which are aligned with `ids`:
|
|
|
|
|
*
|
|
|
|
|
* - `all_shares32`: the entry at my_id's position IS read. It is the share
|
|
|
|
|
* `secp256k1_frost_enrollment_shares_gen` kept locally.
|
|
|
|
|
* - `received_params_hashes32`: the entry at my_id's position is NEVER
|
|
|
|
|
* read, and may be left zero. The own hash is recomputed, never taken
|
|
|
|
|
* from a buffer.
|
|
|
|
|
*
|
|
|
|
|
* The asymmetry is the point: it is what makes this a recomputation check
|
|
|
|
|
* rather than an equality test among caller-supplied strings, so a caller
|
|
|
|
|
* cannot launder a mismatch into a pass by filling its own slot with a
|
|
|
|
|
* received value.
|
|
|
|
|
*
|
|
|
|
|
* `sigma32_out` must be sent to the target participant over a confidential,
|
|
|
|
|
* authenticated channel, together with the parameters hash.
|
|
|
|
|
*
|
|
|
|
|
* Returns: 0 if the arguments are invalid or a parameters hash disagrees,
|
|
|
|
|
* 1 otherwise
|
|
|
|
|
* Args: ctx: pointer to a context object
|
|
|
|
|
* Out: sigma32_out: pointer to a 32-byte array for the aggregated share.
|
|
|
|
|
* Set to zero if this function returns 0.
|
|
|
|
|
* mismatch_id: pointer to an identifier to store the first helper
|
|
|
|
|
* whose parameters hash disagrees, or NULL
|
|
|
|
|
* In: all_shares32: pointer to an array of u*32 bytes, aligned with
|
|
|
|
|
* `ids`: the share kept locally at my_id's position and
|
|
|
|
|
* the shares received from the other helpers at theirs
|
|
|
|
|
* received_params_hashes32: pointer to an array of u*32 bytes, aligned
|
|
|
|
|
* with `ids`, holding the parameters hash received from
|
|
|
|
|
* each other helper. The entry at my_id's position is
|
|
|
|
|
* ignored.
|
|
|
|
|
* thresh_pk: pointer to the threshold public key of the group
|
|
|
|
|
* ids: array of the u helper identifiers, in the same order
|
|
|
|
|
* as in round 1.1
|
|
|
|
|
* n_ids: number of helpers u
|
|
|
|
|
* my_id: own identifier. Must appear in `ids`.
|
|
|
|
|
* new_id: identifier of the participant receiving the share
|
|
|
|
|
* n_participants: total number of participants n
|
|
|
|
|
* threshold: threshold t
|
|
|
|
|
*/
|
|
|
|
|
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_enrollment_share_agg(
|
|
|
|
|
const secp256k1_context *ctx,
|
|
|
|
|
unsigned char *sigma32_out,
|
|
|
|
|
uint32_t *mismatch_id,
|
|
|
|
|
const unsigned char *all_shares32,
|
|
|
|
|
const unsigned char *received_params_hashes32,
|
|
|
|
|
const secp256k1_pubkey *thresh_pk,
|
|
|
|
|
const uint32_t *ids,
|
|
|
|
|
size_t n_ids,
|
|
|
|
|
uint32_t my_id,
|
|
|
|
|
uint32_t new_id,
|
|
|
|
|
size_t n_participants,
|
|
|
|
|
uint32_t threshold
|
|
|
|
|
) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(5) SECP256K1_ARG_NONNULL(6) SECP256K1_ARG_NONNULL(7);
|
|
|
|
|
|
|
|
|
|
/** Derive the public share at the target identifier.
|
|
|
|
|
*
|
|
|
|
|
* Computes sum_i lambda_i(x_new) * pubshares[i], the value of the group's
|
|
|
|
|
* public-share polynomial at the target participant's x-coordinate. This is
|
|
|
|
|
* the public counterpart of what the protocol produces, and it is used both
|
|
|
|
|
* to verify the new secret share in round 2 and to extend the group's table
|
|
|
|
|
* of public shares from n to n+1 entries after an enrollment.
|
|
|
|
|
*
|
|
|
|
|
* This function operates on public data only.
|
|
|
|
|
*
|
|
|
|
|
* Returns: 0 if the arguments are invalid or the result is the point at
|
|
|
|
|
* infinity, 1 otherwise
|
|
|
|
|
* Args: ctx: pointer to a context object
|
|
|
|
|
* Out: new_pubshare_out: pointer to a pubkey object for the derived public
|
|
|
|
|
* share. Set to zero if this function returns 0.
|
|
|
|
|
* In: pubshares: array of u pubkeys, aligned with `ids`, holding the
|
|
|
|
|
* public share of each helper
|
|
|
|
|
* ids: array of the u helper identifiers
|
|
|
|
|
* n_ids: number of helpers u
|
|
|
|
|
* new_id: identifier of the participant receiving the share
|
|
|
|
|
* n_participants: total number of participants n
|
|
|
|
|
* threshold: threshold t
|
|
|
|
|
*/
|
|
|
|
|
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_enrollment_pubshare_derive(
|
|
|
|
|
const secp256k1_context *ctx,
|
|
|
|
|
secp256k1_pubkey *new_pubshare_out,
|
|
|
|
|
const secp256k1_pubkey *pubshares,
|
|
|
|
|
const uint32_t *ids,
|
|
|
|
|
size_t n_ids,
|
|
|
|
|
uint32_t new_id,
|
|
|
|
|
size_t n_participants,
|
|
|
|
|
uint32_t threshold
|
|
|
|
|
) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4);
|
|
|
|
|
|
|
|
|
|
/** Round 2: derive the target participant's secret share.
|
|
|
|
|
*
|
|
|
|
|
* In order:
|
|
|
|
|
*
|
|
|
|
|
* 1. If `expected_params_hash32` is not NULL, the parameters hash is
|
|
|
|
|
* recomputed from `thresh_pk` and the parameter tuple the target
|
|
|
|
|
* participant believes, and compared against it. The round-1.2 check
|
|
|
|
|
* covers helper against helper only; without this check, all helpers
|
|
|
|
|
* could agree on parameters that differ from the ones the target
|
|
|
|
|
* expects, or belong to a different group entirely, and the discrepancy
|
|
|
|
|
* would surface only as an unexplained failure of the public-share
|
|
|
|
|
* check.
|
|
|
|
|
* 2. The u values are summed into `secshare32_out`.
|
|
|
|
|
* 3. If `expected_pubshare` is not NULL, secshare*G is compared against it.
|
|
|
|
|
*
|
|
|
|
|
* `expected_pubshare` is load-bearing: it is the only check that a helper
|
|
|
|
|
* contributed a correct value. Pass NULL only if the resulting share is
|
|
|
|
|
* validated by other means.
|
|
|
|
|
*
|
|
|
|
|
* PRECONDITION, documented but not enforced: `thresh_pk` must come from a
|
|
|
|
|
* source the target participant authenticates independently of the helpers,
|
|
|
|
|
* and `expected_pubshare` must be derived from public shares validated
|
|
|
|
|
* against it with `secp256k1_frost_threshold_info_validate`. Otherwise both
|
|
|
|
|
* checks are circular: t colluding helpers can present a consistent but
|
|
|
|
|
* fabricated polynomial, and every check in this function passes on a
|
|
|
|
|
* worthless share. See frost_enrollment.md and examples/frost_enrollment.c.
|
|
|
|
|
*
|
|
|
|
|
* Returns: 0 if the arguments are invalid or a check fails, 1 otherwise
|
|
|
|
|
* Args: ctx: pointer to a context object
|
|
|
|
|
* Out: secshare32_out: pointer to a 32-byte array for the secret share. Set
|
|
|
|
|
* to zero if this function returns 0.
|
|
|
|
|
* In: sigmas32: pointer to an array of u*32 bytes, aligned with
|
|
|
|
|
* `ids`, holding the value received from each helper
|
|
|
|
|
* thresh_pk: pointer to the INDEPENDENTLY AUTHENTICATED threshold
|
|
|
|
|
* public key of the group
|
|
|
|
|
* ids: array of the u helper identifiers, in the same order
|
|
|
|
|
* as `sigmas32`
|
|
|
|
|
* n_ids: number of helpers u
|
|
|
|
|
* new_id: own identifier, the one the share is being derived
|
|
|
|
|
* for
|
|
|
|
|
* n_participants: total number of participants n
|
|
|
|
|
* threshold: threshold t
|
|
|
|
|
* expected_params_hash32: pointer to the 32-byte parameters hash received
|
|
|
|
|
* from the helpers, or NULL to skip the comparison
|
|
|
|
|
* expected_pubshare: pointer to the expected public share, from
|
|
|
|
|
* `secp256k1_frost_enrollment_pubshare_derive`, or NULL
|
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
|
|
|
* to skip the verification (not recommended). When it
|
|
|
|
|
* is non-NULL, ctx must have been initialized for
|
|
|
|
|
* signing; when it is NULL, no context capability
|
|
|
|
|
* beyond the default is required.
|
frost_enrollment: freeze the API and write the module documentation
First of six commits adding a frost_enrollment module: FROST enrollment,
the protocol of Section 4.1.1 of the FROST paper, which converts a (t, n)
group into a (t, n+1) group without re-running key generation and without
any participant revealing its share. Running it at an existing
participant's identifier repairs that participant's lost share instead.
This commit is the design freeze. It adds no code and touches no build
file; nothing here is compiled yet. The header and the module document
are what the following commits implement against.
Why a separate module rather than part of frost:
- The frost module is deliberately scoped to BIP 445, whose own header
states DKG is out of scope for the same reason. Enrollment has no BIP.
- The repo already puts one protocol per module across the FROST stack:
chilldkg is the DKG, prefractal is the nested FROST+MuSig2 signer, and
both are separate modules layered on frost's key material.
- Enrollment moves share-shaped secrets between participants, has no
authorization mechanism at all, and rests on transport assumptions the
library cannot enforce. Its own --enable-module-frost-enrollment flag
keeps that surface opt-in.
Five functions, named after the round they run:
- params_hash pure, public; every party recomputes it
- shares_gen round 1.1, each helper
- share_agg round 1.2, each helper
- pubshare_derive pure, public; the expected public share at x_new
- secshare_gen round 2, the target participant
Decisions frozen here, in the order they will matter to the
implementation:
Tag strings and encoding. The params hash is
TH("FROST enrollment/params_hash",
cbytes(thresh_pk) || ser32(n) || ser32(t) || ser32(new_id) ||
ser32(u) || ser32(sorted_ids[0]) || ... )
mirroring chilldkg's params_hash (src/modules/chilldkg/util_impl.h:399)
in both its fixed-width u32be discipline and its commitment to key
material rather than to integers alone. Binding thresh_pk is what makes
the hash name a GROUP: two unrelated groups sharing (t, n, ids, new_id)
get different hashes, so the agreement checks prove the parties mean the
same group and not merely the same numbers. Ids are sorted before
hashing so helpers holding the same set in different orders agree; every
other array in the API stays aligned with the caller's own ids order.
The second tag, "FROST enrollment/share_split", is introduced by the
next commit. Both freeze once vectors.h exists.
params_hash returns int, not void. Void-returning public functions in
this library are lifecycle-only (context_destroy, selftest, callback
setters), and ARG_CHECK_VOID (src/secp256k1.c:73) fires the illegal
callback and returns with the output UNWRITTEN. Under a non-aborting
illegal callback -- a supported configuration -- a caller would then
compare a 32-byte buffer that was never computed, silently defeating
both hash gates while every call still appears to succeed.
The two u*32 buffers of share_agg take deliberately opposite own-slot
conventions, and the header says so loudly: all_shares32 READS the slot
at my position (the share shares_gen kept), while
received_params_hashes32 never reads it. The asymmetry is the mechanism
-- the own hash is recomputed from the group key and the parameter
tuple, never taken from a buffer, so a caller cannot copy a received
hash into its own slot and launder a mismatch into a pass.
mismatch_id carries the participant IDENTIFIER, following chilldkg's
fault_index convention (include/secp256k1_chilldkg.h:276), not an array
index: identifiers need not be 0..u-1, so an index would be ambiguous.
threshold >= 2, a deliberate divergence from the frost module, which
accepts threshold >= 1 (keygen_impl.h:231, :321, session_impl.h:541).
The rationale is not that t = 1 is a weak threshold; a lone member of a
1-of-n group can already sign anything. It is that this API permits any
threshold <= n_ids, so t = 1 admits u = 1, and at u = 1 the additive
split degenerates to one share: the lone helper sends the unsplit v_1,
which at t = 1 is the whole group secret. t >= 2 forces u >= 2, which is
what actually makes the split non-degenerate.
Mode-specific bounds. new_id == n_participants means enrollment and
requires n < 128, because the resulting n+1 group must still be one
frost_session_init accepts; new_id < n_participants means repair, which
does not change n and allows n <= 128. The id cap is id <
n_participants; 128 caps n, not id values.
Two deviations from the plan's draft signatures, both to match the frost
module rather than the draft:
- threshold is uint32_t, not size_t. Every frost entry point that takes
a threshold takes uint32_t (trusted_dealer_keygen,
threshold_info_validate, session_init), against size_t for
n_participants and n_signers.
- session_secrand32 sits with the outputs as an in/out parameter rather
than last, which is where secp256k1_frost_nonce_gen puts it
(include/secp256k1_frost.h:365). It is wiped by the call, so grouping
it with the inputs would misdescribe it.
frost_enrollment.md carries the protocol derivation, the two modes and
their bounds, and the four security topics the API cannot enforce on its
own: transport confidentiality for the delta and sigma values, the
missing authorization step, the circularity of the public-share check
when thresh_pk comes from the helpers themselves, and the three separate
roles of parameter binding (helper-to-helper detection, helper-to-target
detection, and seed-reuse domain separation). The verification-flow
walkthrough and the regression-vector caveat land with their code.
The header compiles clean standalone under gcc -std=c89 -pedantic -Wall
-Wextra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:45:13 +02:00
|
|
|
*/
|
|
|
|
|
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_enrollment_secshare_gen(
|
|
|
|
|
const secp256k1_context *ctx,
|
|
|
|
|
unsigned char *secshare32_out,
|
|
|
|
|
const unsigned char *sigmas32,
|
|
|
|
|
const secp256k1_pubkey *thresh_pk,
|
|
|
|
|
const uint32_t *ids,
|
|
|
|
|
size_t n_ids,
|
|
|
|
|
uint32_t new_id,
|
|
|
|
|
size_t n_participants,
|
|
|
|
|
uint32_t threshold,
|
|
|
|
|
const unsigned char *expected_params_hash32,
|
|
|
|
|
const secp256k1_pubkey *expected_pubshare
|
|
|
|
|
) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4) SECP256K1_ARG_NONNULL(5);
|
|
|
|
|
|
|
|
|
|
#ifdef __cplusplus
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
#endif /* SECP256K1_FROST_ENROLLMENT_H */
|