diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b83facf..294f948f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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]") diff --git a/Makefile.am b/Makefile.am index b72f0879..d7a6c125 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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 diff --git a/doc/iceberg.md b/doc/iceberg.md index ae7fed54..140fc99b 100644 --- a/doc/iceberg.md +++ b/doc/iceberg.md @@ -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 diff --git a/include/secp256k1_frost.h b/include/secp256k1_frost.h index c4c7c18a..5f05f713 100644 --- a/include/secp256k1_frost.h +++ b/include/secp256k1_frost.h @@ -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 diff --git a/src/modules/chilldkg/util_impl.h b/src/modules/chilldkg/util_impl.h index f10512da..63bfcc4e 100644 --- a/src/modules/chilldkg/util_impl.h +++ b/src/modules/chilldkg/util_impl.h @@ -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); diff --git a/src/modules/frost/frost.md b/src/modules/frost/frost.md index e634c573..7fc31085 100644 --- a/src/modules/frost/frost.md +++ b/src/modules/frost/frost.md @@ -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. diff --git a/src/modules/frost/keygen_impl.h b/src/modules/frost/keygen_impl.h index 65954dd1..f83a8ebc 100644 --- a/src/modules/frost/keygen_impl.h +++ b/src/modules/frost/keygen_impl.h @@ -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); diff --git a/src/modules/frost/tests_impl.h b/src/modules/frost/tests_impl.h index 16a7beb2..776f96b9 100644 --- a/src/modules/frost/tests_impl.h +++ b/src/modules/frost/tests_impl.h @@ -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); diff --git a/tools/test_vectors_chilldkg_generate.py b/tools/test_vectors_chilldkg_generate.py old mode 100644 new mode 100755 diff --git a/tools/test_vectors_frost_generate.py b/tools/test_vectors_frost_generate.py old mode 100644 new mode 100755 diff --git a/tools/test_vectors_iceberg_generate.py b/tools/test_vectors_iceberg_generate.py new file mode 100755 index 00000000..e04703d2 --- /dev/null +++ b/tools/test_vectors_iceberg_generate.py @@ -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 + +#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())