diff --git a/README.md b/README.md index dfc21c4a..9ba6cc69 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Added features: * Experimental module for [ChillDKG](src/modules/chilldkg/chilldkg.md), distributed key generation for FROST (bip-frost-dkg draft). * Experimental module for [Iceberg](doc/iceberg.md), a threshold scheme that lets a group of parties stand in for a single MuSig2 (BIP 327) participant. * Experimental module for [Prefractal](doc/prefractal.md), a nested FROST+MuSig2 signer that lets a FROST group occupy one participant slot of an ordinary MuSig2 (BIP 327) session. +* Experimental module for [FROST enrollment](src/modules/frost_enrollment/frost_enrollment.md), which grows a (t, n) FROST group into a (t, n+1) one, and repairs a lost share, without re-running key generation. Experimental features are made available for testing and review by the community. The APIs of these features should not be considered stable. diff --git a/src/modules/frost_enrollment/frost_enrollment.md b/src/modules/frost_enrollment/frost_enrollment.md index 335948b9..20896122 100644 --- a/src/modules/frost_enrollment/frost_enrollment.md +++ b/src/modules/frost_enrollment/frost_enrollment.md @@ -57,6 +57,58 @@ 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. +Who sends what to whom, for u = 3 helpers A, B, C and target N: + +``` +round 1.1 A computes v_A and splits it: d_AA d_BA d_CA + B computes v_B and splits it: d_AB d_BB d_CB + C computes v_C and splits it: d_AC d_BC d_CC + each helper KEEPS its own column entry (d_AA, d_BB, d_CC) + and sends the other two, with its params hash, to their owners + A --d_BA--> B A --d_CA--> C + B --d_AB--> A B --d_CB--> C + C --d_AC--> A C --d_BC--> B + +round 1.2 A: checks the hashes from B and C against its own recomputation, + then sigma_A = d_AA + d_AB + d_AC + B: sigma_B = d_BA + d_BB + d_BC + C: sigma_C = d_CA + d_CB + d_CC + A --sigma_A--> N + B --sigma_B--> N + C --sigma_C--> N + +round 2 N: checks the hash against its own recomputation, then + s_N = sigma_A + sigma_B + sigma_C = f(x_N), + and verifies s_N*G against the expected public share +``` + +Everything on an arrow except the parameters hash is secret. + +The API, in the same order +------------------------- + +| function | who runs it | when | +|----------|-------------|------| +| `secp256k1_frost_enrollment_params_hash` | anyone | any time; pure, public data | +| `secp256k1_frost_enrollment_shares_gen` | each helper | round 1.1 | +| `secp256k1_frost_enrollment_share_agg` | each helper | round 1.2 | +| `secp256k1_frost_enrollment_pubshare_derive` | anyone | before round 2; pure, public data | +| `secp256k1_frost_enrollment_secshare_gen` | the target | round 2 | + +Two conventions run through the whole API and are worth stating once: + +- Arrays of length u — the round 1.1 output, the round 1.2 inputs, the sigma + values, the helper public shares — are aligned with the caller's own `ids` + array, in whatever order the caller wrote it. The parameters hash is the one + thing that does not depend on that order: it sorts a local copy of the + identifiers before hashing, so helpers holding the same set in different + orders still agree. +- The two u*32 buffers of `share_agg` take OPPOSITE conventions at the + caller's own position: `all_shares32` reads it (that is the share + `shares_gen` kept), and `received_params_hashes32` never reads it. The + second half is what makes the check a recomputation rather than a + comparison between two strings the caller supplied. + Enrollment mode and repair mode ------------------------------- @@ -127,7 +179,16 @@ polynomial, and the check passes on a worthless share. The required flow is: 4. Only then run `secp256k1_frost_enrollment_secshare_gen`, passing the same authenticated `thresh_pk`. -`examples/frost_enrollment.c` demonstrates exactly this sequence. +`examples/frost_enrollment.c` demonstrates exactly this sequence, and the +tests exercise the failure it prevents: a flipped bit in one `sigma` makes +`secp256k1_frost_enrollment_secshare_gen` fail and wipe its output, while the +same call with `expected_pubshare = NULL` succeeds and hands back a wrong +share. + +The parameter is therefore load-bearing. Pass NULL only if something else +validates the resulting share — for instance, a subsequent +`secp256k1_frost_threshold_info_validate` over the extended public share +table, which catches the same fault one step later. Parameter agreement and domain separation ----------------------------------------- @@ -197,6 +258,56 @@ 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. +Test vectors +------------ + +`src/modules/frost_enrollment/vectors.h` is generated by +`tools/test_vectors_frost_enrollment_generate.py`. These are REGRESSION +vectors, not cross-validation vectors, and the distinction matters. + +This protocol has no BIP and no published vectors, and the reference proof of +concept draws its randomness from `secrets.randbits`, which is not seedable — +so there is nothing to check the implementation against. The generator instead +re-implements the same math in stdlib-only Python, writing the group +arithmetic out from the secp256k1 parameters rather than borrowing it, and +freezes the result. + +What that pins: the two tag strings, the parameters hash serialization, the +share-splitting derivation and the identifier conventions. Changing any of +them is a vector-breaking change, and that is the point. What it does not +establish is protocol correctness — the algebraic invariants in +`src/modules/frost_enrollment/tests_impl.h` carry that: reconstruction from +every threshold-sized subset, a real BIP340 signature from a set including the +new participant, byte equality in repair mode, and +`secp256k1_frost_threshold_info_validate` accepting the extended public share +table. + +Frozen encodings +---------------- + +Two tagged hashes, both using SHA256 with the BIP340-style tag prefix: + + params_hash = TH("FROST enrollment/params_hash", + cbytes(thresh_pk) || ser32(n) || ser32(t) || + ser32(new_id) || ser32(u) || + ser32(sorted_ids[0]) || ... || ser32(sorted_ids[u-1])) + + rand = TH("FROST enrollment/share_split", session_secrand32) + XOR secshare32 + d_recipient = Scalar.from_bytes_wrapping( + TH("FROST enrollment/share_split", + rand || params_hash || ser32(my_id) || + ser32(recipient_id))) + +where `cbytes` is the 33-byte compressed point serialization, `ser32` is the +32-bit big-endian encoding, and `from_bytes_wrapping` reduces modulo the group +order rather than rejecting (the statistical distance is about 2^-128, and +rejection sampling would add a variable-time loop for nothing). + +The masking share is indexed by the recipient's IDENTIFIER rather than by its +position in the caller's array, so the split does not depend on the order the +helper set was written in. Identifiers are unique, so this is injective. + Constant time -------------