314 lines
12 KiB
Python
314 lines
12 KiB
Python
|
|
#!/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())
|