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>
This commit is contained in:
207
src/modules/frost_enrollment/frost_enrollment.md
Normal file
207
src/modules/frost_enrollment/frost_enrollment.md
Normal file
@@ -0,0 +1,207 @@
|
||||
Notes on the frost_enrollment module API
|
||||
========================================
|
||||
|
||||
This module implements FROST enrollment, the protocol of Section 4.1.1 of the
|
||||
FROST paper (https://eprint.iacr.org/2017/1155.pdf). It turns a (t, n) FROST
|
||||
group into a (t, n+1) group without re-running key generation and without any
|
||||
participant revealing its secret share, and it repairs a lost share by running
|
||||
the same protocol at the identifier of an existing participant.
|
||||
|
||||
Unlike the frost module, which tracks BIP 445, this protocol has no BIP. It is
|
||||
specified 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.
|
||||
|
||||
The following sections contain additional notes on the API of the
|
||||
frost_enrollment module (`include/secp256k1_frost_enrollment.h`). A usage
|
||||
example can be found in `examples/frost_enrollment.c`.
|
||||
|
||||
**This module is experimental.** Do not use it in production. The API should
|
||||
not be considered stable.
|
||||
|
||||
The protocol
|
||||
------------
|
||||
|
||||
Write `f` for the group's secret sharing polynomial of degree t-1, `s_i =
|
||||
f(x_i)` for the secret share of participant `i` at x-coordinate `x_i = i + 1`,
|
||||
and `L_i(x)` for the Lagrange basis polynomial of participant `i` over the set
|
||||
of the u participating helpers. For any u >= t helpers,
|
||||
|
||||
f(x_new) = sum_i L_i(x_new) * s_i
|
||||
|
||||
so the target share is a fixed public linear combination of the helpers'
|
||||
shares. The protocol computes that sum without any helper learning another's
|
||||
term.
|
||||
|
||||
1. **Round 1.1** (`secp256k1_frost_enrollment_shares_gen`, each helper). Helper
|
||||
`i` computes `v_i = L_i(x_new) * s_i` and splits it into u additive shares
|
||||
`d_{j,i}` with `sum_j d_{j,i} = v_i`, one per helper. It keeps `d_{i,i}` and
|
||||
sends `d_{j,i}` to helper `j`.
|
||||
|
||||
The random split is the security step. Without it, helper `i` would send
|
||||
`v_i` itself to the target, who could multiply by `L_i(x_new)^-1` and
|
||||
recover `s_i`.
|
||||
|
||||
2. **Round 1.2** (`secp256k1_frost_enrollment_share_agg`, each helper). Helper
|
||||
`j` sums the share it kept and the u-1 shares it received:
|
||||
`sigma_j = sum_i d_{j,i}`, and sends `sigma_j` to the target.
|
||||
|
||||
3. **Round 2** (`secp256k1_frost_enrollment_secshare_gen`, target). The target
|
||||
sums the u values it received:
|
||||
|
||||
sum_j sigma_j = sum_j sum_i d_{j,i} = sum_i v_i = f(x_new)
|
||||
|
||||
which is a valid share at `x_new`.
|
||||
|
||||
No individual helper ever sees more than one additive share of any `v_i`, and
|
||||
the target sees only `u` sums, each of which is masked by every helper's
|
||||
randomness.
|
||||
|
||||
Enrollment mode and repair mode
|
||||
-------------------------------
|
||||
|
||||
`new_id` selects the mode, and the bounds differ:
|
||||
|
||||
| mode | condition | bound on n |
|
||||
|------------|----------------------------|-----------------------------------------------|
|
||||
| enrollment | `new_id == n_participants` | `n < SECP256K1_FROST_MAX_PARTICIPANTS` |
|
||||
| repair | `new_id < n_participants` | `n <= SECP256K1_FROST_MAX_PARTICIPANTS` |
|
||||
|
||||
Enrollment needs the stricter bound because the resulting group has n+1
|
||||
participants, and a 129-participant group is rejected by the frost module
|
||||
everywhere. Repair does not change n, so the full range is allowed.
|
||||
|
||||
In both modes `new_id` must not be one of the helper identifiers: a helper
|
||||
cannot be the target of its own run.
|
||||
|
||||
After a successful enrollment, every participant must update its record of n to
|
||||
n+1 and append the output of `secp256k1_frost_enrollment_pubshare_derive` to
|
||||
its table of public shares. Nothing in the library enforces this bookkeeping;
|
||||
participants that disagree about n will compute different Lagrange
|
||||
coefficients in later signing sessions.
|
||||
|
||||
Repair mode reproduces the lost share exactly, byte for byte, because the
|
||||
target x-coordinate determines the value: the share is `f(x_j)`, not a fresh
|
||||
random value. A repaired participant therefore keeps its old public share.
|
||||
|
||||
Transport
|
||||
---------
|
||||
|
||||
The `d` values of round 1.1 and the `sigma` values of round 1.2 are additive
|
||||
shares of real secret shares. They MUST be transmitted over confidential and
|
||||
authenticated channels, both helper to helper and helper to target. Like the
|
||||
chilldkg module, this module handles bytes only; the caller owns transport.
|
||||
|
||||
Authorization
|
||||
-------------
|
||||
|
||||
The protocol has no authorization step. Any party that convinces t helpers to
|
||||
run it at `x_new` walks away with a valid share at `x_new`. In repair mode this
|
||||
is acute: an attacker who impersonates participant `j` and reaches t helpers
|
||||
obtains participant `j`'s actual share.
|
||||
|
||||
Authenticated channels are necessary but not sufficient — they establish who is
|
||||
speaking, not that the helpers agreed this party is entitled to a share. The
|
||||
library cannot enforce the missing step; it is a caller-side precondition, to
|
||||
be met by the group's existing approval policy out of band. This is the same
|
||||
gap that motivated ChillDKG's certification round.
|
||||
|
||||
Verification, and what it is worth
|
||||
----------------------------------
|
||||
|
||||
`secp256k1_frost_enrollment_secshare_gen` checks the derived share against the
|
||||
expected public share `sum_i L_i(x_new) * P_i`, where `P_i` is helper `i`'s
|
||||
public share. This catches any helper that contributed a wrong value, using
|
||||
public data only.
|
||||
|
||||
The check is only as strong as the provenance of its inputs. If the target
|
||||
obtains both the helpers' public shares and the threshold public key from those
|
||||
same helpers, then t colluding helpers can hand it a consistent fabricated
|
||||
polynomial, and the check passes on a worthless share. The required flow is:
|
||||
|
||||
1. Obtain `thresh_pk` from a source authenticated independently of the helpers.
|
||||
2. Validate the helpers' public shares against it with
|
||||
`secp256k1_frost_threshold_info_validate`.
|
||||
3. Derive `expected_pubshare` from the validated public shares with
|
||||
`secp256k1_frost_enrollment_pubshare_derive`.
|
||||
4. Only then run `secp256k1_frost_enrollment_secshare_gen`, passing the same
|
||||
authenticated `thresh_pk`.
|
||||
|
||||
`examples/frost_enrollment.c` demonstrates exactly this sequence.
|
||||
|
||||
Parameter agreement and domain separation
|
||||
-----------------------------------------
|
||||
|
||||
Three distinct mechanisms, which should not be conflated:
|
||||
|
||||
1. *Helper against helper.* All u helpers must use byte-identical
|
||||
`(thresh_pk, ids, new_id, n_participants, threshold)`. The `d` values cannot
|
||||
cross-check this because they are per-helper private randomness, so
|
||||
`secp256k1_frost_enrollment_share_agg` recomputes its own parameters hash
|
||||
from the group key and the tuple, and compares every received hash against
|
||||
the recomputed value. It never compares two caller-supplied strings, and it
|
||||
never reads a hash out of its own slot — otherwise a caller could copy a
|
||||
received hash into that slot and launder a mismatch into a pass. A
|
||||
disagreement aborts round 1 and reports the disagreeing helper's identifier.
|
||||
|
||||
2. *Helper against target.* The round-1.2 check says nothing about the target's
|
||||
view. `secp256k1_frost_enrollment_secshare_gen` therefore recomputes the
|
||||
hash from the authenticated `thresh_pk` and the tuple the target believes,
|
||||
and compares it against the helpers' hash. This catches "every helper agreed
|
||||
on new_id = 7 while the target expected 5", and it catches a target being
|
||||
fed a run from an entirely different group, before any secret arithmetic
|
||||
happens.
|
||||
|
||||
3. *Domain separation.* The share-splitting randomness binds the parameters
|
||||
hash, and therefore the group key and the whole tuple. Two runs that share a
|
||||
`session_secrand32` but differ in group or parameters would otherwise produce
|
||||
identical `d` sets, and differencing the `sigma` values would leak share
|
||||
information.
|
||||
|
||||
Because the hash commits to `thresh_pk` and not only to the integers, agreement
|
||||
covers group identity: two unrelated groups with the same `(t, n, ids, new_id)`
|
||||
produce different hashes.
|
||||
|
||||
What none of this covers: once the parameter gates pass, a *corrupted* `sigma`
|
||||
value is still only detectable, by the public-share check in round 2, and not
|
||||
attributable to a particular helper.
|
||||
|
||||
Threshold must be at least 2
|
||||
----------------------------
|
||||
|
||||
The frost module accepts `threshold >= 1`. This module refuses `threshold < 2`.
|
||||
|
||||
The reason 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 would permit u = 1, and at u = 1 the additive
|
||||
split degenerates to a single share: the lone helper sends the unsplit `v_1`,
|
||||
which at t = 1 is the entire group secret. The protocol's security property
|
||||
would be vacuous rather than merely weak. Requiring `threshold >= 2` forces
|
||||
`n_ids >= 2`, which is what actually guarantees the split is non-degenerate.
|
||||
|
||||
Randomness
|
||||
----------
|
||||
|
||||
`secp256k1_frost_enrollment_shares_gen` requires fresh uniform randomness for
|
||||
every run. Reusing `session_secrand32` across runs leaks share information; the
|
||||
domain-separation binding above mitigates this for runs that differ in their
|
||||
parameters, but does not excuse it. The function wipes the seed before
|
||||
returning and masks the derived randomness with the secret share, so that a
|
||||
weak random number generator alone does not reveal the split.
|
||||
|
||||
No key refresh
|
||||
--------------
|
||||
|
||||
Enrollment does not change the polynomial and does not change any existing
|
||||
share. The group's long-term security assumptions are unchanged: an adversary
|
||||
who had collected shares before an enrollment still holds valid shares
|
||||
afterwards. This protocol adds a participant; it is not a proactive refresh.
|
||||
|
||||
Constant time
|
||||
-------------
|
||||
|
||||
All arithmetic on secret data uses the constant-time `secp256k1_scalar`
|
||||
operations. Every branch driven by identifiers, counts or hash comparisons
|
||||
operates on public values. Secret intermediates are cleansed with
|
||||
`secp256k1_memclear_explicit` before the functions return, and outputs are
|
||||
zeroed on every failure path.
|
||||
Reference in New Issue
Block a user