Files
secp256k1-zkp/doc/iceberg.md
Kgothatso Ngako 67e9424552 build: gate the experimental modules on SECP256K1_EXPERIMENTAL in CMake
configure.ac refuses --enable-module-frost, --enable-module-chilldkg and
--enable-module-iceberg outright unless --enable-experimental is also
given. The CMake build had no equivalent, so
-DSECP256K1_ENABLE_MODULE_ICEBERG=ON produced a library with no warning
banner and no acknowledgement that anything experimental was requested.

Of the three, iceberg is the one this matters most for: doc/iceberg.md
tells the reader not to put money behind it, and the CMake path let a
build acquire it without the reader ever passing a flag that says so.

Add the three checks to the existing NOT SECP256K1_EXPERIMENTAL block,
next to the ARM32 assembly check and worded the same way. The options are
declared well above it, so the values are set by the time the block runs.
Verified both directions: the configure fails with "Iceberg module is
experimental. Use -DSECP256K1_EXPERIMENTAL=ON to allow." without the
flag, and succeeds with it.

This deliberately leaves the other zkp experimental modules alone. They
are ON by default in CMake and gating them would change every existing
CMake build; the three added here are OFF by default, so nobody is
relying on the ungated path.

While here, correct doc/iceberg.md, which claimed the module "builds by
default here". SECP_SET_DEFAULT(enable_module_iceberg, no, yes) makes it
off by default and on in dev mode, which is now what the paragraph says,
along with the experimental requirement it did not previously mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 23:38:05 +02:00

442 lines
25 KiB
Markdown

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 is off by default and on in dev mode, and
both build systems refuse it outright without `--enable-experimental` or
`-DSECP256K1_EXPERIMENTAL=ON`. 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-1` participants.** 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 **any `t`
points lie on some polynomial of that degree**, including `t` an adversary
chose. So a quorum of `t` does not weaken the check, it empties it. Every
point past `t` is one more constraint a liar has to satisfy, and outnumbering
`t-1` liars takes `t-1` of them:
t + (t-1) = 2t-1
Read as coding theory it is the same statement: detecting `e` errors in a code
of dimension `t` needs `t+e` symbols. The `3t-2` under "Not implemented" is
this formula with correction, `t+2e`, in place of detection. Note that the
`n >= 3t-2` deployment 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-1` too**, and they need not be the ones who took part in
round one. The paper's Table II gives the signing quorum as `2t-1` online
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_sign` does not
insist on finding your own contribution in the set; see "API misuse".
`t` appears in round two only as the interpolation degree; the arithmetic
that turns signature shares back into one signature needs `t` points. Reading
that as "round two needs `t` people" 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
1. **Dealer**: `secp256k1_iceberg_shares_gen`, from
`include/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.
2. **Each participant**: `secp256k1_iceberg_pubshare_gen`, publish the result.
3. **Anyone**: `secp256k1_iceberg_pubkey_agg` over any `2t-1` public shares.
This checks they agree, so a participant that published a wrong one is
caught here rather than at signing time.
4. **Anyone**: `secp256k1_musig_pubkey_agg` with the group's public key and the
cosigners', then optionally `secp256k1_musig_pubkey_xonly_tweak_add` and
`secp256k1_musig_pubkey_ec_tweak_add`.
5. **Each of `2t-1` participants**: `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.
6. **Cosigners**, independently and in either order: `secp256k1_musig_nonce_gen`,
then `secp256k1_musig_nonce_agg` over the cosigners' nonces alone.
7. **Coordinator**: `secp256k1_iceberg_nonce_agg`, which verifies and
interpolates them into one ordinary MuSig2 public nonce.
8. **Coordinator**: `secp256k1_musig_nonce_agg` over that and the cosigners',
then `secp256k1_musig_nonce_process`.
9. **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 all `2t-1` contributions from
step 5 and returns 0 with fewer, and the scheme wants `2t-1` members online
here as in round one; step 10 then interpolates from as few as `t` of 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.
10. **Coordinator**: `secp256k1_iceberg_partial_sig_agg`, giving one MuSig2
partial signature. Hand it more than `t` shares and it degree-checks them,
which above the threshold catches a set that contradicts itself; at exactly
`t` there is nothing to disagree with.
11. **Cosigners**: `secp256k1_musig_partial_sign` as usual.
12. **Coordinator**: `secp256k1_musig_partial_sig_agg`, then
`secp256k1_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_under` in `examples/iceberg.c` does 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_verify` ships, 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 needs `t+2e` points where
detection needed `t+e`, so `3t-2` online at once, against `2t-1` for
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.