Files
zeekay 2c63fd34fc lens: classical curve threshold kernel — sister to Pulsar
Lens is the curve-based threshold signing kernel for Lux. It is the
classical analogue of Pulsar (the lattice kernel at github.com/luxfi/pulsar).
Both kernels share the same lifecycle shape — Bootstrap → Reshare* →
Reanchor — and the same activation circuit-breaker contract. They differ
only in the math field: Pulsar over R_q (Module-LWE), Lens over a
prime-order curve group (discrete log).

Curves shipped: Ed25519, secp256k1, Ristretto255. Each has a
deterministically-derived secondary Pedersen generator H from a
Lens-specific nothing-up-my-sleeve tag.

Sign math: FROST 2-round Schnorr threshold signing per RFC 9591
(Komlo-Goldberg 2020). Verifier checks z·G ?= R + c·X under the
unchanged group public key.

Reshare math: HJKY97 zero-poly Refresh + Desmedt-Jajodia ReshareToNewSet
over the curve scalar field. Master secret s is preserved; only the
share distribution rotates per epoch.

Activation cert message format: QUASAR-LENS-ACTIVATE-v1, byte-equivalent
shape to QUASAR-PULSAR-ACTIVATE-v1.

Hash suite: HashSuite interface with Lens-SHA3 (production, cSHAKE256 /
KMAC256 / TupleHash256) and Lens-BLAKE3 (legacy) profiles.

DKG: distributed FROST DKG with Feldman commitments. No trusted dealer
required at genesis if the chain prefers proper distributed DKG.

KAT oracles: cmd/reshare_oracle and cmd/dkg_oracle emit deterministic
JSON vectors for cross-language byte-equality validation against a
future luxcpp/crypto/lens/ port.

Tests: full unit + integration coverage on every shipped curve. 60
unique tests, 0 failures.
2026-03-03 12:00:00 -08:00

298 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package reshare implements the two proactive secret-sharing
// primitives Lens needs to evolve a key era's share distribution
// without changing the persistent group public key.
//
// 1. Refresh — same-committee zero-polynomial proactive update
// (Herzberg-Jakobsson-Jarecki-Krawczyk-Yung 1997, "PSS"). The
// committee is fixed; every party samples a random degree-(t-1)
// polynomial z_i(X) with z_i(0) = 0 and contributes z_i(α_j) to
// each peer j. Each party updates s'_j = s_j + Σ_i z_i(α_j). The
// master secret is unchanged because Σ_i z_i(0) = 0. Use case:
// periodic share-randomization within a stable validator set, to
// defeat a mobile adversary that compromises < t parties per
// epoch and accumulates shares across epochs.
//
// 2. Reshare — validator-set resharing. The OLD committee O and NEW
// committee N are different (potentially disjoint, potentially
// different threshold). The new committee has no old shares and
// so cannot apply zero-polynomial deltas. Instead, a qualified
// subset Q ⊆ O with |Q| ≥ t_old executes a one-shot Shamir-from-
// Lagrange transformation: each i ∈ Q samples g_i(X) of degree
// t_new-1 with g_i(0) = s_i (its OWN old share as the constant
// term), and privately delivers g_i(β_j) to each new party j ∈ N.
// Each new party j computes
//
// s'_j = Σ_{i ∈ Q} λ^Q_i · g_i(β_j)
//
// where λ^Q_i are the Lagrange coefficients for Q evaluated at 0.
// Define G(X) = Σ_{i ∈ Q} λ^Q_i · g_i(X). Then deg(G) = t_new − 1,
// and G(0) = s by Lagrange interpolation over Q at X = 0.
// Use case: validator-set rotation at Quasar epoch boundaries.
//
// Both primitives leave the public key X = s·G unchanged. The genesis
// values (G, H, X) are persistent for the entire key era. Only the
// share distribution changes. This is the property that lets Quasar
// avoid running a full DKG on every validator-set rotation.
//
// # Security model (kernel)
//
// The kernel APIs Refresh / Reshare implement the arithmetic
// correctness of the two primitives, against a HONEST-BUT-CURIOUS
// adversary that may statically corrupt up to t-1 parties.
//
// # VSR — production deployment
//
// The kernel is embedded in a full Verifiable Secret Resharing
// protocol with the following components, implemented in sibling
// files:
//
// - commit.go — Pedersen commits (g^c · h^r) to f_i (Refresh)
// and g_i (Reshare); recipients verify each share
// against the committed polynomial.
// - transcript.go — Domain-separated transcript binding the entire
// resharing exchange.
// - complaint.go — Complaint format, signed evidence, complaint
// quorum logic, deterministic disqualification.
// - keyshare.go — Wraps reshared scalar shares into complete
// threshold.KeyShare instances by regenerating
// Lambda + Seeds + MACKeys + VerificationShare.
// - pairwise.go — Authenticated pairwise X25519+Ed25519 KEX → KDF
// for Seeds / MACKeys under domain-separated tags.
// - activation.go — Post-reshare activation cert: the new committee
// threshold-signs the resharing transcript hash
// under the unchanged GroupKey. The chain accepts
// the new epoch only when the activation verifies.
package reshare
import (
"crypto/rand"
"errors"
"fmt"
"io"
"sort"
"github.com/luxfi/lens/primitives"
)
// Errors returned by the package.
var (
ErrInvalidThresholdOld = errors.New("reshare: t_old must be >= 1")
ErrInvalidThresholdNew = errors.New("reshare: t_new must be >= 1")
ErrTOldExceedsOldSet = errors.New("reshare: t_old exceeds size of old committee")
ErrTNewExceedsNewSet = errors.New("reshare: t_new exceeds size of new committee")
ErrEmptyOldShares = errors.New("reshare: no old shares supplied")
ErrEmptyNewSet = errors.New("reshare: empty new committee")
ErrZeroPartyID = errors.New("reshare: party IDs must be 1-indexed (no 0)")
ErrDuplicateNewID = errors.New("reshare: duplicate ID in new committee")
ErrTOldShortfall = errors.New("reshare: fewer than t_old shares supplied; cannot reconstruct s")
)
// Reshare runs the proactive secret-resharing protocol described in
// the package doc on the supplied curve. Inputs:
//
// - c — the curve s lives in (must match the GroupKey's curve).
// - oldShares — partyID → secret-share scalar (1-indexed evaluation
// point). The map MUST contain at least t_old entries.
// - tOld — old reconstruction threshold.
// - newSet — new committee (slice of distinct 1-indexed party IDs).
// - tNew — new reconstruction threshold (≤ |newSet|).
// - randSource — randomness source for fresh polynomial coefficients
// (nil → crypto/rand.Reader).
//
// Returns the new share map keyed by new partyID. The output share at
// partyID j satisfies the (t_new, |newSet|)-Shamir relation against the
// SAME master secret s as the input shares.
func Reshare(
c primitives.Curve,
oldShares map[int]primitives.Scalar,
tOld int,
newSet []int,
tNew int,
randSource io.Reader,
) (map[int]primitives.Scalar, error) {
if randSource == nil {
randSource = rand.Reader
}
if tOld < 1 {
return nil, ErrInvalidThresholdOld
}
if tNew < 1 {
return nil, ErrInvalidThresholdNew
}
if len(oldShares) == 0 {
return nil, ErrEmptyOldShares
}
if len(newSet) == 0 {
return nil, ErrEmptyNewSet
}
if tOld > len(oldShares) {
return nil, ErrTOldShortfall
}
if tNew > len(newSet) {
return nil, ErrTNewExceedsNewSet
}
// Validate new committee.
seenNew := make(map[int]bool, len(newSet))
for _, j := range newSet {
if j == 0 {
return nil, ErrZeroPartyID
}
if seenNew[j] {
return nil, ErrDuplicateNewID
}
seenNew[j] = true
}
for id := range oldShares {
if id == 0 {
return nil, ErrZeroPartyID
}
}
// Pick the deterministic smallest-ID t_old quorum.
quorum := selectQuorum(oldShares, tOld)
quorumIDs := sortedKeys(quorum)
// Lagrange λ_i^Q at X = 0 for the quorum.
lambda, err := primitives.Lagrange(c, quorumIDs)
if err != nil {
return nil, fmt.Errorf("reshare: lagrange: %w", err)
}
// For each i ∈ Q, sample its degree-(t_new-1) polynomial f_i with
// f_i(0) = λ_i · s_i (so Σ_i f_i(0) = Σ_i λ_i · s_i = s).
newShares := make(map[int]primitives.Scalar, len(newSet))
for _, j := range newSet {
newShares[j] = c.NewScalar()
}
for _, i := range quorumIDs {
c0 := c.NewScalar().Set(lambda[i]).Mul(quorum[i])
fi, err := primitives.NewPolynomial(c, tNew-1, c0, randSource)
if err != nil {
return nil, fmt.Errorf("reshare: NewPolynomial: %w", err)
}
for _, j := range newSet {
x := c.NewScalar().SetUint64(uint64(j))
contrib := fi.Evaluate(x)
newShares[j].Add(contrib)
}
}
return newShares, nil
}
// Refresh runs the HJKY97 same-committee proactive update on a fixed
// committee. The set of party IDs and the threshold are unchanged:
// only the share values rotate to fresh independent randomness while
// preserving Σ_j λ^T_j · s_j = s for every threshold subset T.
//
// Algorithm (per HJKY97 §3, "zero-polynomial" form):
//
// 1. Each party i samples a random degree-(t-1) polynomial z_i(X)
// over the curve's scalar field with z_i(0) = 0 (i.e. no constant
// term).
//
// 2. Each party i privately delivers z_i(α_j) to every party j in
// the committee, where α_j is the Shamir evaluation point of
// party j (here α_j = j, the 1-indexed party ID).
//
// 3. Each party j updates its share:
//
// s'_j = s_j + Σ_i z_i(α_j) (mod q)
func Refresh(
c primitives.Curve,
shares map[int]primitives.Scalar,
threshold int,
randSource io.Reader,
) (map[int]primitives.Scalar, error) {
if randSource == nil {
randSource = rand.Reader
}
if threshold < 1 {
return nil, ErrInvalidThresholdOld
}
if len(shares) == 0 {
return nil, ErrEmptyOldShares
}
if threshold > len(shares) {
return nil, ErrTOldShortfall
}
for id := range shares {
if id == 0 {
return nil, ErrZeroPartyID
}
}
parties := sortedKeys(shares)
// Initialize new shares as a copy of the old shares.
out := make(map[int]primitives.Scalar, len(shares))
for _, j := range parties {
out[j] = c.NewScalar().Set(shares[j])
}
// threshold = 1 means every share IS the secret; refresh must be a
// no-op (the only zero-poly of degree 0 is identically 0).
if threshold == 1 {
return out, nil
}
// For each party i (canonical order), sample z_i (degree-(t-1),
// constant term 0), evaluate at every α_j, accumulate into out[j].
for range parties {
// z_i has degree (t-1) and constant term 0. Sample t-1 high-
// degree coefficients (degree 1..t-1).
coeffs := make([]primitives.Scalar, threshold)
coeffs[0] = c.NewScalar() // forced zero
for d := 1; d < threshold; d++ {
s, err := c.SampleScalar(randSource)
if err != nil {
return nil, fmt.Errorf("reshare/refresh: sample: %w", err)
}
coeffs[d] = s
}
zPoly := primitives.PolynomialFromCoefficients(c, coeffs)
for _, j := range parties {
x := c.NewScalar().SetUint64(uint64(j))
contrib := zPoly.Evaluate(x)
out[j].Add(contrib)
}
}
return out, nil
}
// Verify is a debugging helper: it Lagrange-interpolates the input
// shares at X=0 (using the smallest-ID t-subset) and returns the
// reconstructed master secret.
//
// IMPORTANT: This recovers s, so it MUST NOT be used in production —
// only in tests and KAT verification. Calling it gives the caller the
// secret.
func Verify(c primitives.Curve, shares map[int]primitives.Scalar, t int) (primitives.Scalar, error) {
return primitives.LagrangeRecover(c, shares, t)
}
// selectQuorum picks a deterministic t-element subset of oldShares (the
// t entries with the smallest party IDs) and returns it.
func selectQuorum(oldShares map[int]primitives.Scalar, t int) map[int]primitives.Scalar {
keys := sortedKeys(oldShares)
out := make(map[int]primitives.Scalar, t)
for i := 0; i < t; i++ {
out[keys[i]] = oldShares[keys[i]]
}
return out
}
// sortedKeys returns ascending integer keys.
func sortedKeys[V any](m map[int]V) []int {
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
return keys
}