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())