Port the experimental Iceberg module from the benchmark-iceberg tree
(github.com/furszy/benchmark-iceberg, sources/secp256k1-kmp/native/
secp256k1) into this repo.
Iceberg is a threshold scheme that lets a group of parties stand in
for a single MuSig2 (BIP 327) participant: the group produces one
ordinary MuSig2 public nonce and one ordinary MuSig2 partial
signature, so cosigners cannot tell a group is involved and need no
changes. Nonces are derived from a caller-chosen per-session label
(sid32) rather than stored, so no signer holds a secret nonce between
rounds; labels are public but must never be reused. A quorum of 2t-1
members (of whom up to t-1 may be corrupt) is needed in each round,
so the threshold is at most half the group rounded up; combined with
the scheme's other constraints the smallest usable group is 2-of-4.
See doc/iceberg.md and the module header for the full usage notes.
Module layout (src/modules/iceberg/, layered bottom-up, each layer
may only use the ones above it -- that ordering is also the
constant-time story):
- scalar_poly.{h,_impl.h}: secret-carrying polynomial arithmetic,
keeping secrets away from inversions (documented in the header).
- rss.{h,_impl.h}: replicated secret sharing evaluation.
- vpss.{h,_impl.h}: verifiable public shares; variable-time by
design, sees only participant indices and published points.
- keygen_impl.h: distributed key generation producing one share per
member.
- session_impl.h: nonce_gen/nonce_agg and partial_sign/
partial_sig_agg producing plain MuSig2 objects.
- tests_impl.h: 28 tests including the shipped vectors.h vector
suite and dealer known-answer tests.
- bench_impl.h: benchmark definitions (wired in a follow-up commit).
Public headers: include/secp256k1_iceberg.h (installed) and
include/secp256k1_iceberg_dealer.h (in-tree only: a trusted dealer is
not part of the shipped API, but tests, benchmarks and the example
need to deal shares).
Content adaptations relative to the source tree (the only changes to
the ported code): three secp256k1_musig_nonce_process call sites in
tests_impl.h gained a NULL adaptor argument, because this repo's
musig is the zkp variant whose public nonce_process takes an optional
adaptor point. All musig internals the module uses (ge_parse_ext,
ge_serialize_ext, keyaggcoef, aggnonce_load, pubnonce_save,
partial_sig_save, nonce_process_internal) are identical in both
trees, as are all core headers the module touches; nothing else
needed adaptation.
Build wiring mirrors the chilldkg module:
- configure.ac: --enable-module-iceberg (default no, experimental
gate), hard dependency on the musig module with a configure error
if musig is explicitly disabled (musig itself pulls in schnorrsig),
AM_CONDITIONAL(ENABLE_MODULE_ICEBERG), summary line.
- Makefile.am: include src/modules/iceberg/Makefile.am.include under
the conditional.
- src/secp256k1.c: guarded include of modules/iceberg/main_impl.h
after the chilldkg block (musig is included earlier, so its
internals are in scope).
- src/tests.c: module test registration via MAKE_TEST_MODULE(iceberg).
- CMakeLists.txt / src/CMakeLists.txt: SECP256K1_ENABLE_MODULE_ICEBERG
option (OFF) with a dependency check on SECP256K1_ENABLE_MODULE_MUSIG
(placed before the musig block so the force-enable takes effect),
ENABLE_MODULE_ICEBERG=1 compile definition, public header export,
summary line.
Verified: ./configure --enable-experimental --enable-module-iceberg
&& make check passes; ./tests --target=iceberg runs the full module
suite (28/28); CMake build + ctest pass; the musig dependency error
fires correctly in both build systems.
25 KiB
Notes on the iceberg module API
The following sections contain additional notes on the API of the iceberg
module (include/secp256k1_iceberg.h). A usage example can be found in
examples/iceberg.c, which runs the whole flow and narrates it.
This module is experimental. It builds by default here, which is a development convenience rather than a statement that it is ready. Iceberg has a security proof, by reduction to NestedMuSig2's unforgeability, but it is in an anonymous conference submission that is still a working draft, and at the two nonces BIP-327 fixes that reduction holds in the algebraic group model rather than the plain random oracle model. The proof also assumes a property no library can provide (that a session label is used once, group-wide) and the known ways to lose a key all live in exactly that assumption. Do not put money behind this module.
Iceberg lets a t-of-n group act as a single MuSig2 participant. From outside, the result is an ordinary BIP-340 signature: nothing in it records that a group was involved, or how large the group was.
The objects
Two APIs are in play and about a dozen nouns between them. MuSig2's, which this module does not replace:
| name | type | made by | secret? | size |
|---|---|---|---|---|
| key aggregation cache | musig_keyagg_cache |
anyone, from the signers' public keys | no | opaque |
| secret nonce | musig_secnonce |
a signer, once per session | yes, and it must survive between the rounds | never serialized |
| public nonce | musig_pubnonce |
a signer | no | 66 B |
| aggregate nonce | musig_aggnonce |
anyone | no | 66 B |
| partial signature | musig_partial_sig |
a signer | no | 32 B |
And Iceberg's, which exist entirely inside the group:
| name | type | made by | secret? | size |
|---|---|---|---|---|
| share | iceberg_share |
the dealer, once | yes, and it is the only secret anyone stores | 4 + 32*C(n-1, t-1) B |
| share cache | iceberg_share_cache |
a participant | no: Lagrange weights, which depend only on which participant you are | opaque |
| public share | iceberg_pubshare |
a participant | no | 34 B |
| nonce contribution | iceberg_pubnonce |
a participant, per session | no | 67 B |
| group nonce | iceberg_aggnonce |
nobody has to: no call takes one as input, so pass NULL unless you want it for logging |
no | 66 B |
| signature share | iceberg_partial_sig |
a participant, per session | no | 33 B |
Two names are close and the objects are not. The group produces a MuSig2 partial signature, built
out of Iceberg signature shares, one per participant. Likewise a participant
makes a nonce contribution, and the group turns 2t-1 of them into one
ordinary MuSig2 public nonce.
Those two conversions are the seam. iceberg_nonce_agg emits a
musig_pubnonce and iceberg_partial_sig_agg emits a musig_partial_sig;
everything above them is ordinary MuSig2 that knows nothing about a group, and
everything below is this module.
Three roles
Three roles appear here, and they run different code:
| role | runs | trusted? |
|---|---|---|
| participant | iceberg_* calls. Holds a share, never the key. There are n of them |
with its own share only |
| coordinator | moves messages, calls the _agg functions |
no. Every check in the module assumes it is hostile |
| cosigner | plain musig_* calls, and knows nothing about the group |
as any MuSig2 signer |
A participant may also act as the coordinator; nothing changes if it does, because the coordinator has no privileges to abuse.
How many people, and when
Two different counts:
-
Round one needs
2t-1participants. Not because the secret needs that many, but because the group verifies its own nonce contributions, and that check is error detection.A contribution is a point on a polynomial of degree
t-1, and anytpoints lie on some polynomial of that degree, includingtan adversary chose. So a quorum oftdoes not weaken the check, it empties it. Every point pasttis one more constraint a liar has to satisfy, and outnumberingt-1liars takest-1of them:t + (t-1) = 2t-1Read as coding theory it is the same statement: detecting
eerrors in a code of dimensiontneedst+esymbols. The3t-2under "Not implemented" is this formula with correction,t+2e, in place of detection. Note that then >= 3t-2deployment bound below is a different result that happens to be the same number: one is Reed-Solomon correction, the other is Byzantine agreement, and neither implies the other. -
Round two needs
2t-1too, and they need not be the ones who took part in round one. The paper's Table II gives the signing quorum as2t-1online members for every threshold, and the honest majority that number represents does not stop applying halfway through a session.A member that was offline for round one can still take part: it holds the share that determines what its contribution would have been, so it can verify the set it is handed and sign against it. That is the property the deterministic nonces exist to buy, and it is why
partial_signdoes not insist on finding your own contribution in the set; see "API misuse".tappears in round two only as the interpolation degree; the arithmetic that turns signature shares back into one signature needstpoints. Reading that as "round two needstpeople" is the mistake to avoid.
Since 2t-1 participants must exist, 2t-1 <= n, so:
t <= (n+1)/2
This is a hard structural limit, not a performance note. 2-of-2, 3-of-3,
3-of-4, 4-of-5, 4-of-6 and 6-of-10 cannot be expressed at all.
secp256k1_iceberg_shares_gen refuses them at setup rather than mishandling
them later; see the failure table under "API misuse" for what refusing means.
Sizes, since a share is a bundle of seeds and grows quickly. Both rounds want
2t-1 members online; the third column is the smaller number the arithmetic of
step 10 needs, which is a degree and not a quorum.
These are expressible configurations, not recommended ones. A separate bound
applies to deployment: agreeing on the live state with up to t-1 faulty members
is Byzantine agreement, which needs n >= 3(t-1)+1 = 3t-2. That gives 2-of-4,
3-of-7, 4-of-10, 5-of-13 as the smallest deployable groups, and five of the
seven rows below fall short of it. The signature scheme is correct at all of
them, since the arithmetic does not know how many faults the surrounding
agreement survives, but a group sized from this table alone will be too small for the
consensus the scheme assumes. See the deployment constraints in
include/secp256k1_iceberg.h.
Note where the two bounds meet. 5-of-13 needs more participants than
SECP256K1_ICEBERG_MAX_PARTICIPANTS allows, so at the maximum of 10 the
largest threshold that is both expressible and deployable is 4-of-10. The
table below goes past that line, and so do the benchmark and the tests: 5-of-9
and 5-of-10 are there to show the cost curve, not because a group should be
sized that way.
SECP256K1_ICEBERG_MAX_PARTICIPANTS is 10. It sizes two of the opaque types and
the largest stack frames in the module, all of which grow as C(n-1, t-1), so it
is part of the ABI and not something one member of a group changes on its own. A
build that will only ever run small groups can lower it, and gets a 200-byte share
instead of a 4040-byte one at five participants; src/modules/iceberg/rss.h
beside the #error says which three derived values have to be lowered with it,
and run_iceberg_binom_test recomputes all three and names the one that is wrong.
Raising it is refused at compile time. rss.h also carries the command that
measures the stack frames, rather than a number, because the number moves with the
compiler. None of that is in the public header: a caller of the installed library
has no rss.h, no test suite and no src/ to run it against.
| config | quorum 2t-1 |
shares that interpolate | seeds in the group | seeds per participant | serialized share |
|---|---|---|---|---|---|
| 2-of-3 | 3 | 2 | 3 | 2 | 68 B |
| 2-of-4 | 3 | 2 | 4 | 3 | 100 B |
| 3-of-5 | 5 | 3 | 10 | 6 | 196 B |
| 3-of-7 | 5 | 3 | 21 | 15 | 484 B |
| 4-of-7 | 7 | 4 | 35 | 20 | 644 B |
| 5-of-9 | 9 | 5 | 126 | 70 | 2244 B |
| 5-of-10 | 9 | 5 | 210 | 126 | 4036 B |
Everything that crosses the network serializes: a share to 4 + 32*C(n-1, t-1)
bytes, a public share to 34, a nonce contribution to 67 and a signature share to
33. The group's aggregate nonce serializes to 66 as well, but it is not on that
list: nothing receives one. Those bytes are the start of the b1 preimage, and
they are there for an implementation checking its arithmetic against this one.
The public share, the nonce contribution and the signature share each carry the participant index they belong to, which is the extra byte in each. The group's aggregate nonce does not, since it belongs to the group rather than to a member, which is exactly why it is 66 bytes and a contribution is 67.
The group's seed count is C(n, t-1) and each participant holds
C(n-1, t-1) of them. The bounds derived from
SECP256K1_ICEBERG_MAX_PARTICIPANTS (SECP256K1_ICEBERG_MAX_SEEDS here, and
MAX_T and MAX_SUBSETS in rss.h) are written out by hand because C89
cannot evaluate a binomial at preprocessing time. The test suite recomputes each
of them and fails on a wrong line.
The flow
participant k coordinator cosigner
(one of n) (untrusted) (plain musig2)
------------- ----------- --------------
SETUP, once. A dealer runs shares_gen and is then not needed again.
iceberg_shares_gen -> one share each [_dealer.h, not installed]
iceberg_pubshare_gen -> iceberg_pubkey_agg -> the group's public key
KEY AGGREGATION. The group is now one public key among several.
musig_pubkey_agg([group_pk, cosigner_pk, ...]) -> keyagg_cache
optionally musig_pubkey_xonly_tweak_add / _ec_tweak_add
=== ROUND ONE ================================ needs 2t-1 participants ===
Neither side waits on the other. iceberg_nonce_gen needs only the share
and the label; musig_nonce_gen is an ordinary MuSig2 call and takes
whatever it usually takes. Drawn side by side for that reason.
iceberg_nonce_gen(share, sid32) musig_nonce_gen
--- pubnonce (67 B) -->
<-- pubnonce ------
iceberg_nonce_agg
verifies 2t-1 of them, interpolates
-> one ordinary musig pubnonce
musig_nonce_agg([group, cosigners])
--- aggnonce -->
musig_nonce_process
------------------------------------------------------------------------
THE GAP. No secret nonce survives this line. A participant's nonces
are a function of (share, sid), and it is handed the sid again, so a
crash here costs it nothing and there is no secret to lose, leak or
duplicate. That is narrower than "stores nothing": it must still
remember which labels it has answered under, and restoring a backup
from before that record is exactly the exploitable case.
------------------------------------------------------------------------
=== ROUND TWO ============================ needs 2t-1 again, any of n ===
The message appears here for the first time, which is the point: the
group commits to a nonce before knowing what it will sign.
--- msg, the round-one pubnonces -->
iceberg_partial_sign(share, sid32, msg, the contributions, ...)
derives the group's aggregate from the contributions rather than
accepting one, checks they are a single sharing of degree t-1, and
compares their value at its own index against the contribution it
derives for itself. So it signs against the aggregate the group
really formed. A member that sat round one out can still sign.
--- partial sig (33 B) -->
iceberg_partial_sig_agg
-> one ordinary musig partial sig
musig_partial_sign
<-- partial sig ---
musig_partial_sig_agg -> 64-byte signature
schnorrsig_verify accepts it under the (possibly tweaked) aggregate key.
Signing, as a list
- Dealer:
secp256k1_iceberg_shares_gen, frominclude/secp256k1_iceberg_dealer.h, then hand each participant its share and forget the seed. That header is separate and is not installed, because a trusted dealer is not something the library offers for deployment; see "API misuse" below. A distributed key generation replaces this step and nothing downstream changes. - Each participant:
secp256k1_iceberg_pubshare_gen, publish the result. - Anyone:
secp256k1_iceberg_pubkey_aggover any2t-1public shares. This checks they agree, so a participant that published a wrong one is caught here rather than at signing time. - Anyone:
secp256k1_musig_pubkey_aggwith the group's public key and the cosigners', then optionallysecp256k1_musig_pubkey_xonly_tweak_addandsecp256k1_musig_pubkey_ec_tweak_add. - Each of
2t-1participants:secp256k1_iceberg_nonce_gen, over the session label alone. Publish the result. Nothing here depends on the message or on the cosigners, so this can run before either exists. - Cosigners, independently and in either order:
secp256k1_musig_nonce_gen, thensecp256k1_musig_nonce_aggover the cosigners' nonces alone. - Coordinator:
secp256k1_iceberg_nonce_agg, which verifies and interpolates them into one ordinary MuSig2 public nonce. - Coordinator:
secp256k1_musig_nonce_aggover that and the cosigners', thensecp256k1_musig_nonce_process. - Participants, not necessarily the ones from step 5:
secp256k1_iceberg_partial_sign, given the group's own round-one contributions, not an aggregate of them and not the cosigners', the same label, and now the message. This is where the message enters and the only place it appears in the API. Each call needs all2t-1contributions from step 5 and returns 0 with fewer, and the scheme wants2t-1members online here as in round one; step 10 then interpolates from as few astof the resulting shares. Before calling it, each signer checks its own record that it has not answered under this label already; the library cannot do that for it, because it holds nothing between calls. - Coordinator:
secp256k1_iceberg_partial_sig_agg, giving one MuSig2 partial signature. Hand it more thantshares and it degree-checks them, which above the threshold catches a set that contradicts itself; at exactlytthere is nothing to disagree with. - Cosigners:
secp256k1_musig_partial_signas usual. - Coordinator:
secp256k1_musig_partial_sig_agg, thensecp256k1_schnorrsig_verify.
Steps 1 to 4 happen once per group, and secp256k1_iceberg_keyagg_check belongs
with step 4: it confirms the outer cache aggregates the key list you think it
does, which is a fact about the channel rather than about this attempt. Steps 5
to 12 are one signing session.
API misuse
The musig module's three rules apply here too: unique nonces, never copy or
serialize a secp256k1_musig_secnonce, and never read or write an opaque struct
directly. Iceberg adds its own, and the reasons are specific.
How a call refuses, before anything else. A function returns 0 when the
values it was handed do not work together: an inconsistent set of shares, a
malformed encoding, a contribution derived under some other label. The illegal
callback, which aborts the process unless the caller has installed its own with
secp256k1_context_set_illegal_callback, is reserved for a bug in the calling
code: a null pointer, an uninitialized opaque struct, a group shape the scheme
cannot express.
How many contributions turned up is neither. It is a fact about the group, influenced by whichever peers answered, so every call that takes a count returns 0 rather than aborting:
| call | a bad count does what |
|---|---|
iceberg_shares_gen |
aborts: n outside 1..10, or t outside 1..(n+1)/2. These are the group's shape, not a count |
iceberg_pubkey_agg |
returns 0: fewer than 2t-1 public shares, or more than n |
iceberg_nonce_agg |
returns 0: fewer than 2t-1 contributions, or more than n |
iceberg_partial_sig_agg |
returns 0: fewer than t shares or more than n. A share that never came from partial_sign still aborts, that one being an uninitialized struct |
iceberg_partial_sign |
returns 0: it checks the same 2t-1 bound while deriving the aggregate |
iceberg_partial_sig_verify |
returns 0: same reason, and it derives the same aggregate |
A member that waits for a deadline and aggregates whatever arrived is therefore doing something the API supports, rather than something that kills its process the first time a peer is asleep.
Where n and t come from. iceberg_partial_sign reads them off the caller's
own share and never asks. The four calls that take them as arguments should be
given the same values; a member that keeps only a serialized share can read them
from it, since the encoding is version | n | t | index | seeds. It matters most
for t: understate it and the degree check still runs, against a lower degree,
and proves less. Nothing cross-checks the four calls against each other.
You supply the session label, and the rule about it is yours to enforce. Every participant's secret nonces are a
deterministic function of its seeds and the label, and the seeds never change,
so a label reused under two different messages produces two answers in which
k1, k2 and d are identical while b0 and e have moved. Three such
answers are three linear equations in those three unknowns, and solving them
recovers a key share.
Concretely, the caller must guarantee both halves:
- one answer per member per label. A member can enforce this alone, but not
from anything the library holds; it keeps nothing between calls. It needs
durable storage of its own: one 32-byte field per participant holding the last
label it signed under, and a rule that a new label must be strictly greater.
may_sign_underinexamples/iceberg.cdoes exactly that. Restore it with the share; a record rolled back to an old backup is a member that will answer twice. - one message per label across the whole group: the members must agree on what they are signing before any of them answers.
The library sees one call at a time and holds nothing between them, so it can enforce neither. Neither can any individual participant: a coordinator can show three different members three different messages under one label, and each one signs exactly once, refuses nothing, and detects nothing, because nothing in the protocol tells a member that somebody else saw this label too. Per-participant discipline is therefore not a substitute for group agreement, which is why the scheme's security model assumes a consensus its deployment already runs.
Why the label cannot simply be derived from the message, which would close all of this: round one has to run before the message exists. In Lightning the nonce is exchanged a full round-trip before the commitment transaction is assembled, so a label binding the message could not be computed when it is needed. The intended label is the channel's commitment number, plus a counter for retries under it. A label bound to the message is possible where the message is known early, but it costs the message-independent first round, which is a different setting from the one this module assumes. The module takes any 32 bytes and offers no derivation, because the right label depends on the deployment, and the wrong one costs a key share.
secp256k1_iceberg_partial_sign takes the group's own round-one
contributions, not an aggregate of them. This is not an optimisation
opportunity. The nesting coefficient is a hash of the group's aggregate nonce,
so a coordinator free to invent that aggregate gets a coefficient it can vary at
will: three invented aggregates under one correctly-bound label again give
three equations in the same three unknowns. The aggregate is therefore derived
from contributions that must pass the degree check together, and then the
interpolated polynomial is evaluated at the signer's own index to get the nonce
shares it signs with. It signs against the aggregate the group actually formed,
whether or not it was one of the members who helped form it.
It does not require the signer's own round-one contribution to be present and
unaltered in the set. That would be strictly stronger against an unauthenticated
transport, and it would lock out a member who was offline during round one --
the exact failure the deterministic nonces exist to survive. With the
authenticated transport the scheme assumes anyway it is unnecessary: among
2t-1 contributions with at most t-1 corruptions, at least t are honest,
and t points already pin a degree t-1 polynomial.
A tweak belongs to the outer session and must not be applied twice. The
module is correct here and needs nothing from the caller. No Iceberg call takes
a tweak because secp256k1_musig_nonce_process sets
the tweak term aside and secp256k1_musig_partial_sig_agg adds it in once, at
the top. The group's shares carry the key coefficient and not the tweak.
secp256k1_iceberg_shares_gen is a trusted dealer, and lives outside the
installed API for that reason. For the duration of that one call, one machine
holds enough to reconstruct the group's private key, which is the situation a
threshold scheme exists to avoid. It is fine for testing, and fine where one
party is already trusted with the whole key. It is not fine otherwise, and this
module does not provide a distributed key generation.
It is declared in include/secp256k1_iceberg_dealer.h, which is in the tree so
the tests, the benchmarks and the example have shares to work with, and is not
installed. Including secp256k1_iceberg.h does not offer you a dealer. Nothing
in the signing API cares how a share was produced: one arrives through
secp256k1_iceberg_share_parse, so a share from a DKG or from another
implementation is used identically.
What is stored, and for how long
| object | who holds it | lifetime | secret? |
|---|---|---|---|
iceberg_share |
one participant | forever | yes |
iceberg_share_cache |
one participant | optional, derived from the share | no |
iceberg_pubshare |
published | forever | no |
session label (sid32) |
anyone | one session, and a record that it was used, for as long as the group lives | no |
iceberg_pubnonce |
published | one session | no |
iceberg_aggnonce |
coordinator | one session | no |
iceberg_partial_sig |
published | one session | no |
Nothing marked secret above needs to survive a reboot except the share,
which is why examples/iceberg.c wipes every participant between the two rounds
and rebuilds them from storage. That is the property Iceberg exists for: a FROST
signer must keep a secret nonce alive across the same gap, and losing it,
restoring an old copy over it, or running two instances of the signer are all
catastrophic.
It does not follow that a participant is stateless, and the table does not say so. A member must also remember which labels it has already answered under. That record is not secret and so is not listed above, but it is not optional: restoring a backup taken before it was written is the one restore that is dangerous. A signer with no memory can also be talked into signing a superseded channel state, which in Lightning costs it the channel.
The share cache holds no secrets despite being derived from a share: it is
Lagrange weights, which depend only on which participant it is. It has no
serialized form and does not need one; passing NULL wherever a cache is
accepted rebuilds it. bench_iceberg prints what that costs, along with the
rest of the module; the figure moves with the machine.
Not implemented
- Naming the liar.
secp256k1_iceberg_partial_sig_verifyships, so a share can be checked against the commitments it claims to come from, and a failed signature need not be a mystery. What it does not do is assign blame: a 0 means that share does not satisfy the equation, not that its author cheated, because MuSig2 partial signatures are forgeable. Naming the liar is error correction rather than detection, and correction needst+2epoints where detection neededt+e, so3t-2online at once, against2t-1for signing. That is usually more people than the group has, which is why the function is documented as detection and stops there. - Distributed key generation. See above.