Commit Graph

2 Commits

Author SHA1 Message Date
Kgothatso Ngako
266c6a7c4f 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
Kgothatso Ngako
b6791ba867 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