Merge branch 'review-fixes'

This commit is contained in:
Kgothatso Ngako
2026-09-04 02:56:23 +02:00
11 changed files with 406 additions and 13 deletions

View File

@@ -130,6 +130,15 @@ if(NOT SECP256K1_EXPERIMENTAL)
if(SECP256K1_ASM STREQUAL "arm32")
message(FATAL_ERROR "ARM32 assembly is experimental. Use -DSECP256K1_EXPERIMENTAL=ON to allow.")
endif()
if(SECP256K1_ENABLE_MODULE_FROST)
message(FATAL_ERROR "FROST module is experimental. Use -DSECP256K1_EXPERIMENTAL=ON to allow.")
endif()
if(SECP256K1_ENABLE_MODULE_CHILLDKG)
message(FATAL_ERROR "ChillDKG module is experimental. Use -DSECP256K1_EXPERIMENTAL=ON to allow.")
endif()
if(SECP256K1_ENABLE_MODULE_ICEBERG)
message(FATAL_ERROR "Iceberg module is experimental. Use -DSECP256K1_EXPERIMENTAL=ON to allow.")
endif()
endif()
set(SECP256K1_VALGRIND "AUTO" CACHE STRING "Build with extra checks for running inside Valgrind. [default=AUTO]")

View File

@@ -318,7 +318,8 @@ maintainer-clean-local: clean-testvectors
### Additional files to distribute
EXTRA_DIST = autogen.sh CHANGELOG.md SECURITY.md
EXTRA_DIST += doc/release-process.md doc/safegcd_implementation.md
EXTRA_DIST += doc/ellswift.md doc/musig.md
EXTRA_DIST += doc/ellswift.md doc/musig.md doc/iceberg.md
EXTRA_DIST += src/modules/frost/frost.md src/modules/chilldkg/chilldkg.md
EXTRA_DIST += examples/EXAMPLES_COPYING
EXTRA_DIST += sage/gen_exhaustive_groups.sage
EXTRA_DIST += sage/gen_split_lambda_constants.sage

View File

@@ -5,15 +5,15 @@ 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.
**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

View File

@@ -520,6 +520,25 @@ SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_frost_sign(
* through NonceAgg as a pubnonce contribution, and a pubnonce's components
* are never the point at infinity); if it does, this function fails.
*
* WARNING: the derivation above is the whole of what the nonce depends on. It
* does NOT commit to the pubshares, to the untweaked threshold public key, or
* to which tweaks the cache accumulated -- only to the x-only encoding of the
* _tweaked_ threshold public key (this is BIP 445's det_nonce_hash, not a
* deviation). Two tweak caches can therefore agree on that x-only key and
* still disagree on the sign g*gacc that multiplies the secret share, because
* Q and -Q have the same x-coordinate: a cache initialized from the threshold
* public key and one initialized from its negation are the simplest example.
* Two calls that differ only in that way emit the SAME pubnonce and two
* partial signatures that differ only in the sign of the secret-share term,
* which is two equations in the nonce and the secret share -- the secret
* share falls out of the pair.
*
* The caller must therefore treat the tweak cache and the pubshares as fixed
* key material belonging to the group, established once at key generation,
* and never as per-session parameters accepted from a coordinator or any
* other peer. Given that, repeating a call reproduces a byte-identical result
* and is harmless, which is the point of a deterministic nonce.
*
* Returns: 0 if the arguments are invalid or signing fails, 1 otherwise
* Args: ctx: pointer to a context object
* Out: partial_sig: pointer to a partial_sig object

View File

@@ -104,7 +104,14 @@ static int secp256k1_chilldkg_xonly_load(secp256k1_ge *p, const unsigned char *i
static void secp256k1_chilldkg_pad33(unsigned char *out33, const char *str) {
size_t len = strlen(str);
VERIFY_CHECK(len <= 33);
/* Every call site passes a string literal of the module, so this cannot
* trigger. The clamp must not sit inside VERIFY_CHECK, which is compiled
* out in noverify builds: an over-long tag would overflow out33 and make
* the memset length below underflow to a huge value. */
if (len > 33) {
VERIFY_CHECK(0);
len = 33;
}
memcpy(out33, str, len);
memset(out33 + len, 0, 33 - len);
}
@@ -116,7 +123,20 @@ static void secp256k1_chilldkg_schnorrsig_sha256_tagged(const secp256k1_hash_ctx
size_t prefix_len = strlen(tag_prefix);
size_t subtag_len = strlen(subtag);
VERIFY_CHECK(prefix_len + subtag_len <= sizeof(tag));
/* The longest tag the module builds is "BIP DKG/pop message" ||
* "/challenge", 29 bytes. As in secp256k1_chilldkg_pad33, the bound is
* enforced outside VERIFY_CHECK so that a future over-long tag cannot
* overflow tag[] in a noverify build. Clamping rather than returning
* early keeps sha initialized for the caller; a truncated tag changes
* every hash the module computes, so the test vectors fail loudly. */
if (prefix_len > sizeof(tag)) {
VERIFY_CHECK(0);
prefix_len = sizeof(tag);
}
if (subtag_len > sizeof(tag) - prefix_len) {
VERIFY_CHECK(0);
subtag_len = sizeof(tag) - prefix_len;
}
memcpy(tag, tag_prefix, prefix_len);
memcpy(tag + prefix_len, subtag, subtag_len);
secp256k1_sha256_initialize_tagged(hash_ctx, sha, tag, prefix_len + subtag_len);

View File

@@ -96,6 +96,29 @@ Security notes
unique for every call to `secp256k1_frost_nonce_gen`. Passing the secret
share to `nonce_gen` is recommended as defense-in-depth against bad
randomness.
- `secp256k1_frost_deterministic_sign` has no `session_secrand32` to keep
fresh; its safety rests instead on what the nonce derivation commits to. Per
BIP 445's `det_nonce_hash` that is the secret share, `my_id`, `u`, the sorted
ids, the aggothernonce, the **x-only** tweaked threshold public key, and the
message — and nothing else. In particular it does not commit to the
pubshares, to the untweaked threshold public key, or to the accumulated
tweaks. Since `Q` and `-Q` share an x-coordinate, a tweak cache initialized
from the threshold public key and one initialized from its negation present
the same x-only key to the derivation while disagreeing on the sign `g*gacc`
that multiplies the secret share. Two calls differing only in that produce
the same pubnonce and partial signatures `s = k + e*lambda*d` and
`s' = k - e*lambda*d`, where `k` is the identical `k1 + b*k2`; subtracting
them yields `d` directly. The same holds for any two caches that agree on
the tweaked x-only key but not on `g*gacc`.
This is a property of the specified derivation, not of this implementation,
and it is not detectable from inside a single call: the self-verification in
`Sign` passes in both cases, because each signature is individually valid
under its own cache. The caller carries the obligation. Treat the tweak
cache and the pubshares as fixed key material established once at key
generation, and never accept either as a per-session parameter from the
coordinator or another peer. Under that discipline a repeated call is
byte-identical and harmless, which is what the deterministic nonce is for.
- Final signatures produced by `secp256k1_frost_partial_sig_agg` are ordinary
BIP340 signatures; they are verified with `secp256k1_schnorrsig_verify`
against the (tweaked) x-only threshold public key.

View File

@@ -293,6 +293,14 @@ int secp256k1_frost_trusted_dealer_keygen(const secp256k1_context *ctx, unsigned
ret = 1;
cleanup:
if (!ret) {
/* The loop above may have written real secret shares for the first
* few participants before failing. Zero the outputs again so that a
* failed call leaves nothing usable behind, as promised above. */
secp256k1_memzero_explicit(secshares32, n_participants * 32);
memset(thresh_pk, 0, sizeof(*thresh_pk));
memset(pubshares, 0, n_participants * sizeof(*pubshares));
}
secp256k1_scalar_clear(&secret);
secp256k1_scalar_clear(&share);
secp256k1_scalar_clear(&x);

View File

@@ -530,7 +530,7 @@ static void frost_nonce_test_internal(void) {
msglen = testrand_int(sizeof(msg) + 1);
testrand256(msg);
extra_in_len = testrand_int(sizeof(extra_in) + 1);
testrand256(extra_in);
testrand_bytes_test(extra_in, sizeof(extra_in));
if (testrand_bits(1)) {
secp256k1_pubkey pubshare_tmp;
CHECK(secp256k1_ec_pubkey_create(CTX, &pubshare_tmp, secshare) == 1);

0
tools/test_vectors_chilldkg_generate.py Normal file → Executable file
View File

0
tools/test_vectors_frost_generate.py Normal file → Executable file
View File

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Generate src/modules/iceberg/vectors.h from the Python reference.
git clone https://github.com/nkohen/Iceberg.git
git -C Iceberg checkout 7b55ef6dc0dd6e11d1c14cb3bc0a675ff3487cce
./tools/test_vectors_iceberg_generate.py Iceberg > src/modules/iceberg/vectors.h
That commit is "Fixed nonce generation security", Nadav Kohen, 12 January 2026.
It is named here because vectors.h is the only thing in this repository that
cannot be rebuilt from what the repository contains, and because a dozen comments
in the module assert agreement with "the reference" as a correctness property. The
reference is not vendored: it is not ours, and pinning a copy would hide it
drifting.
There are no published test vectors for Iceberg, so these are produced by
running the reference implementation and recording what it does. That makes
them a cross-implementation check and not an authority: if both implementations
are wrong in the same way, these vectors agree with the mistake. What they do
catch is the case that matters most here, where one of the two drifts.
Two things about the reference have to be worked around, and both are recorded
in its own notes:
* eval_lagrange removes an element from the set it is passed, which is the
caller's set. Anything calling it twice with the same set gets a different
answer the second time. Every call below hands it a copy.
* all_lagrange_coefficients does polynomial division in numpy float64. It is
correct up to a quorum of 18 and silently wrong from 19, because the
constant term of the product is the quorum's factorial and 19! is past
2^53. It is replaced below with exact integer arithmetic.
The seeds are derived from a fixed string rather than taken from the reference's
own key_gen, which uses the system random source. That is what makes the vectors
reproducible; it also means the seeds have to be laid out in the same order the
C module expects, which is lexicographic order of the (t-1)-subsets.
"""
import hashlib
import itertools
import sys
import textwrap
def combinations_in_rank_order(n, size):
"""The subsets, in the order secp256k1_rss_subset_unrank produces them.
That function walks the members upward and takes a binomial-sized block at
each step, which is plain lexicographic order on the ascending member list,
the same order itertools produces. The whole seed layout depends on it.
"""
return [set(c) for c in itertools.combinations(range(1, n + 1), size)]
def exact_lagrange_coefficients(indices, modulus):
"""all_lagrange_coefficients, over the integers instead of float64.
For each j, the basis polynomial that is 1 at j and 0 at every other index,
as coefficients from the constant term upward.
A guard rather than a fix: the largest configuration below has a quorum of
9, where the reference's float64 path is still exact, so this changes no
emitted byte today. It is what keeps that true if a larger one is added.
"""
def poly_mul(a, b):
out = [0] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
for j, y in enumerate(b):
out[i + j] = (out[i + j] + x * y) % modulus
return out
def poly_eval(coeffs, x):
total = 0
for c in reversed(coeffs):
total = (total * x + c) % modulus
return total
result = {}
for j in sorted(indices):
basis = [1]
for index in sorted(indices):
if index != j:
basis = poly_mul(basis, [(-index) % modulus, 1])
inverse = pow(poly_eval(basis, j), -1, modulus)
result[j] = [(c * inverse) % modulus for c in basis]
return result
def hexbytes(data, indent):
"""A C initializer for a byte array, wrapped to fit."""
body = ", ".join("0x%02X" % b for b in data)
return textwrap.fill(body, width=78 - indent,
initial_indent=" " * indent,
subsequent_indent=" " * indent)
class Session:
"""One configuration, dealt and signed once, with everything recorded."""
def __init__(self, iceberg, vpss, musig, label, n_parties, t):
self.label = label
self.n = n_parties
self.t = t
self.mu = 2 * t - 1
self.iceberg = iceberg
self.vpss = vpss
self.musig = musig
self._deal()
self._aggregate()
self._round_one()
self._round_two()
def _seed(self, rank):
material = "iceberg test vectors|%s|%d" % (self.label, rank)
return hashlib.sha256(material.encode()).digest()
def _deal(self):
"""One seed per (t-1)-subset, in rank order, then the shares.
Only the seeds go into the header. Each is held by n-(t-1)
participants, so writing out every share would repeat most of them;
the C side rebuilds each share from the same rank order.
"""
subsets = combinations_in_rank_order(self.n, self.t - 1)
self.seeds = [self._seed(rank) for rank in range(len(subsets))]
self.shares = {}
for k in range(1, self.n + 1):
mine = [(seed, subset) for seed, subset in zip(self.seeds, subsets)
if k not in subset]
self.shares[k] = self.vpss.RSSShare(
[self.vpss.RSSSummand(seed, subset) for seed, subset in mine])
def _aggregate(self):
self.pubshares = {}
for k in range(1, self.n + 1):
_, pk_k = self.iceberg.key_gen(k, self.shares[k])
self.pubshares[k] = pk_k
quorum = [self.vpss.VPSSCommitment(k, self.pubshares[k])
for k in range(1, self.mu + 1)]
self.group_pk = self.iceberg.pk_agg(self.t, self.mu, quorum)
def _session_label(self):
"""A session label for this vector.
The module takes any 32 bytes here, so this is an arbitrary but
reproducible choice, not a derivation the scheme requires. Changing the
rule changes every nonce and signature below it.
"""
keyagg = self.musig.key_agg([self.group_pk, self.cosigner_pk])
agg_pk32 = self.musig.bytes_from_int(self.musig.x(keyagg.Q))
preimage = agg_pk32 + self.group_pk + b"".join(
self.cosigner_pubnonce[33 * i:33 * (i + 1)] for i in (0, 1))
return self.musig.tagged_hash("Iceberg/sid", self.msg + preimage)
def _round_one(self):
"""A cosigner whose nonce and key are fixed, so this is reproducible."""
self.msg = hashlib.sha256(("message|" + self.label).encode()).digest()
cosigner_sk = hashlib.sha256(("cosigner|" + self.label).encode()).digest()
self.cosigner_pk = self.musig.individual_pk(cosigner_sk)
halves = [hashlib.sha256(("cosigner nonce %d|%s" % (i, self.label)).encode()).digest()
for i in (1, 2)]
self.cosigner_pubnonce = b"".join(
self.musig.cbytes(self.musig.point_mul(
self.musig.G, self.musig.int_from_bytes(h) % self.musig.n))
for h in halves)
self.sid = self._session_label()
self.pubnonces = {}
for k in range(1, self.n + 1):
_, pubnonce_k = self.iceberg.nonce_gen(k, self.shares[k], self.sid)
self.pubnonces[k] = pubnonce_k
quorum = [(k, self.pubnonces[k]) for k in range(1, self.mu + 1)]
self.group_aggnonce = self.iceberg.nonce_agg(self.t, self.mu, quorum)
self.group_pubnonce = self.iceberg.nonce_agg_ext(self.group_pk,
self.group_aggnonce)
def _round_two(self):
pubkeys = [self.group_pk, self.cosigner_pk]
aggnonce = self.musig.nonce_agg([self.group_pubnonce, self.cosigner_pubnonce])
upper = self.musig.SessionContext(aggnonce, pubkeys, [], [], self.msg)
self.psigs = {}
for k in range(1, self.t + 1):
self.psigs[k] = self.iceberg.sign(k, self.shares[k], self.group_pk,
self.sid, self.group_aggnonce, upper)
self.group_psig = self.iceberg.sign_agg(
[(k, self.psigs[k]) for k in range(1, self.t + 1)])
def emit(self, out):
out.append(" {")
out.append(' "%s", %d, %d, %d, %d,'
% (self.label, self.n, self.t, self.mu, len(self.seeds)))
out.append(" { /* one seed per (t-1)-subset, in rank order */")
for seed in self.seeds:
out.append(" {")
out.append(hexbytes(seed, 16))
out.append(" },")
out.append(" },")
for name, blob in (("group_pk", self.group_pk),
("msg", self.msg),
("sid", self.sid),
("cosigner_pk", self.cosigner_pk),
("cosigner_pubnonce", self.cosigner_pubnonce),
("group_aggnonce", self.group_aggnonce),
("group_pubnonce", self.group_pubnonce),
("group_psig", self.group_psig)):
out.append(" { /* %s */" % name)
out.append(hexbytes(blob, 12))
out.append(" },")
out.append(" { /* pubshares, participant 1 first */")
for k in range(1, self.n + 1):
out.append(" {")
out.append(hexbytes(self.pubshares[k], 16))
out.append(" },")
out.append(" },")
out.append(" { /* pubnonces, the mu participants of round one */")
for k in range(1, self.mu + 1):
out.append(" {")
out.append(hexbytes(self.pubnonces[k], 16))
out.append(" },")
out.append(" },")
out.append(" { /* signature shares, the first t participants */")
for k in range(1, self.t + 1):
out.append(" {")
out.append(hexbytes(self.psigs[k], 16))
out.append(" },")
out.append(" },")
out.append(" },")
HEADER = """/**
* Note: this file was autogenerated using test_vectors_iceberg_generate.py.
* Do not edit. To regenerate:
*
* git clone https://github.com/nkohen/Iceberg.git
* git -C Iceberg checkout 7b55ef6dc0dd6e11d1c14cb3bc0a675ff3487cce
* ./tools/test_vectors_iceberg_generate.py Iceberg > src/modules/iceberg/vectors.h
*
* There are no published Iceberg test vectors. These record what the reference
* does, so they catch the two implementations drifting apart, not a mistake
* they might both make. That makes this the one file here that cannot be rebuilt
* from what this repository contains, which is why the commit is named.
*/
#include <stddef.h>
#define ICEBERG_VECTOR_MAX_PARTICIPANTS %d
#define ICEBERG_VECTOR_MAX_SEEDS %d
struct iceberg_session_vector {
const char *label;
unsigned int n;
unsigned int t;
unsigned int mu;
size_t n_seeds;
unsigned char seeds[ICEBERG_VECTOR_MAX_SEEDS][32];
unsigned char group_pk[33];
unsigned char msg[32];
unsigned char sid[32];
unsigned char cosigner_pk[33];
unsigned char cosigner_pubnonce[66];
unsigned char group_aggnonce[66];
unsigned char group_pubnonce[66];
unsigned char group_psig[32];
unsigned char pubshares[ICEBERG_VECTOR_MAX_PARTICIPANTS][33];
unsigned char pubnonces[ICEBERG_VECTOR_MAX_PARTICIPANTS][66];
unsigned char psigs[ICEBERG_VECTOR_MAX_PARTICIPANTS][32];
};
static const struct iceberg_session_vector iceberg_session_vectors[] = {"""
def main():
if len(sys.argv) != 2:
print(__doc__)
return 1
sys.path.insert(0, sys.argv[1])
import musig
import vpss
import iceberg
# Both workarounds described at the top of this file. They are installed
# before anything runs, so nothing below can accidentally use the originals.
original_eval = vpss.eval_lagrange
vpss.eval_lagrange = lambda indices, excluding, eval_at, modulus=musig.n: \
original_eval(set(indices), excluding, eval_at, modulus)
iceberg.eval_lagrange = vpss.eval_lagrange
vpss.all_lagrange_coefficients = lambda indices, modulus=musig.n: \
exact_lagrange_coefficients(indices, modulus)
configs = [("2of3", 3, 2), ("3of5", 5, 3), ("3of7", 7, 3),
("4of7", 7, 4), ("5of9", 9, 5)]
sessions = [Session(iceberg, vpss, musig, *config) for config in configs]
widest = max(s.n for s in sessions)
most_seeds = max(len(s.seeds) for s in sessions)
out = [HEADER % (widest, most_seeds)]
for session in sessions:
session.emit(out)
out.append("};")
out.append("")
print("\n".join(out))
return 0
if __name__ == "__main__":
sys.exit(main())