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

425 lines
13 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package keyera is the lifecycle wrapper for a Lens group lineage.
//
// One KeyEra is opened by Bootstrap (a one-time foundation MPC ceremony
// at chain genesis or governance-gated Reanchor). The trust is confined
// to genesis of that key era. Subsequent validator-set rotations call
// Reshare, which preserves the GroupKey (G, H, X) and rotates only the
// share distribution; no trusted dealer is needed for resharing.
// Reanchor opens a new era with a fresh GroupKey for security-event
// response (rare, governance-gated).
//
// The single source of truth for the lifecycle is `lens/DESIGN.md` (in
// concept; see also pulsar/DESIGN.md and lps/LP-103-lens.md).
//
// Invariants (enforced loudly):
//
// BLS lane: each validator has its OWN keypair.
// ML-DSA lane: each validator has its OWN keypair.
// Lens lane: each validator has a SHARE of one group key.
// Pulsar lane: each validator has a SHARE of one group key.
//
// Within a key era:
//
// - The same hidden signing secret s is preserved across epochs.
// - The same group public key X = s·G is preserved.
// - G and H are curve constants; they never change within or across
// eras of the same curve.
// - Only the share distribution of s rotates per epoch.
//
// Across key eras (Reanchor): X is fresh, s is fresh.
package keyera
import (
"crypto/rand"
"errors"
"fmt"
"io"
"github.com/luxfi/lens/hash"
"github.com/luxfi/lens/primitives"
"github.com/luxfi/lens/reshare"
"github.com/luxfi/lens/threshold"
)
// Errors returned by the package.
var (
ErrUninitialized = errors.New("keyera: era is uninitialized")
ErrInvalidThreshold = errors.New("keyera: threshold must satisfy 1 <= t <= n")
ErrEmptyValidators = errors.New("keyera: validator set is empty")
ErrMissingShare = errors.New("keyera: share missing for validator")
)
// LensKeyEraID is a monotonically increasing identifier for a key era.
// Bumped only on Reanchor (rare governance event). All resharings
// within an era keep the same era ID.
type LensKeyEraID uint64
// LensGroupID identifies one Lens group for grouped Quasar setups
// where validator sets are partitioned into smaller groups, each with
// its own GroupKey lineage. For the single-group case it is zero.
type LensGroupID uint64
// KeyEra is one Lens group lineage. The GroupKey (G, H, X) is set at
// Bootstrap and persists across every Reshare within the era. State
// is the current epoch's share distribution; it rotates each Reshare.
//
// HashSuiteID pins the hash profile this era was opened under. It is
// recorded at Bootstrap and remains immutable through every Reshare in
// the era (per pulsar/proofs/hash-suite-separation.tex Remark on
// era-pinning, mirrored on the curve-side sister kernel). Reanchor MAY
// change the suite for the new era. The field is read-only after
// Bootstrap returns; Reshare propagates it without parameterisation.
type KeyEra struct {
EraID LensKeyEraID
GroupID LensGroupID
GroupKey *threshold.GroupKey
GenesisEpoch uint64
HashSuiteID string
State *EpochShareState
}
// EpochShareState is the per-epoch share distribution for a key era.
//
// Three lineage fields, kept distinct (do not collapse — they mean
// different things):
//
// - KeyEraID: Lens group-key lineage. Bumps only at Reanchor (fresh
// GroupKey).
// - Generation: LSS resharing version within this key era. Bumps
// every Refresh / Reshare under the same GroupKey.
// - RollbackFrom: nonzero only when this state descends from a
// Rollback (= the prior Generation that was reverted from). Zero
// on ordinary forward transitions.
type EpochShareState struct {
// Lineage (changes only at Reanchor).
KeyEraID uint64
// HashSuiteID is the pinned hash profile for this share state.
// Mirrored from the parent KeyEra at Bootstrap and propagated
// without modification through every Reshare. Reanchor opens a
// NEW era which MAY pin a different value.
HashSuiteID string
// LSS lifecycle.
Generation uint64
RollbackFrom uint64
// Per-epoch state (rotates every Refresh / Reshare).
Epoch uint64
Validators []string
Threshold int
Shares map[string]*threshold.KeyShare
}
// Bootstrap runs the one-time trusted-dealer ceremony at chain
// genesis or governance-gated Reanchor.
//
// The trust is confined to genesis of the key era: the dealer
// momentarily knows the master secret s while constructing the shares.
// If s is retained, copied, or exfiltrated, the long-lived Lens group
// key is compromised. Foundation MUST coordinate Bootstrap as a
// publicly observable MPC ceremony at chain launch.
//
// After Bootstrap returns, the master secret no longer exists in the
// dealer's memory. The chain only has the public GroupKey and the
// distributed shares.
//
// Use crypto/rand.Reader for the kernel's randomness when no specific
// ceremony source is provided. Tests pass a deterministic source.
//
// Bootstrap pins the production HashSuite (Lens-SHA3). Use
// BootstrapWithSuite to open an era under the legacy Lens-BLAKE3
// profile (for cross-suite KAT replay only — NOT for production).
func Bootstrap(
c primitives.Curve,
t int,
validators []string,
groupID LensGroupID,
eraID LensKeyEraID,
entropy io.Reader,
) (*KeyEra, error) {
return BootstrapWithSuite(c, hash.Default(), t, validators, groupID, eraID, entropy)
}
// BootstrapWithSuite is the canonical entrypoint that explicitly pins
// the hash profile this era will run under. The supplied suite is
// recorded on the returned KeyEra and propagates unchanged through
// every Reshare; Reanchor opens a fresh era and MAY pin a different
// suite. Pass nil to use the production default (Lens-SHA3).
func BootstrapWithSuite(
c primitives.Curve,
suite hash.HashSuite,
t int,
validators []string,
groupID LensGroupID,
eraID LensKeyEraID,
entropy io.Reader,
) (*KeyEra, error) {
if len(validators) == 0 {
return nil, ErrEmptyValidators
}
n := len(validators)
if t < 1 || t > n {
return nil, fmt.Errorf("%w: t=%d n=%d", ErrInvalidThreshold, t, n)
}
if entropy == nil {
entropy = rand.Reader
}
suite = hash.Resolve(suite)
suiteID := suite.ID()
// Sample master secret s and compute X = s·G.
secret, err := c.SampleScalar(entropy)
if err != nil {
return nil, fmt.Errorf("keyera: sample secret: %w", err)
}
X := secret.ActOnBase()
gk := threshold.NewGroupKey(c, X)
// (t, n) Shamir share of secret.
_, shareScalars, err := primitives.Shamir(c, secret, t, n, entropy)
if err != nil {
return nil, fmt.Errorf("keyera: Shamir: %w", err)
}
signerIDs := make([]int, n)
for i := range signerIDs {
signerIDs[i] = i + 1
}
lambda, err := primitives.Lagrange(c, signerIDs)
if err != nil {
return nil, fmt.Errorf("keyera: Lagrange: %w", err)
}
seeds, macKeys := derivePairwiseMaterial(n, entropy)
state := &EpochShareState{
KeyEraID: uint64(eraID),
HashSuiteID: suiteID,
Generation: 0,
RollbackFrom: 0,
Epoch: 0,
Validators: append([]string(nil), validators...),
Threshold: t,
Shares: make(map[string]*threshold.KeyShare, n),
}
for i, v := range validators {
pid := i + 1
ks := &threshold.KeyShare{
Index: i,
PartyID: pid,
SkShare: shareScalars[pid],
VerificationShare: shareScalars[pid].ActOnBase(),
Lambda: lambda[pid],
Seeds: seeds,
MACKeys: macKeys[i],
GroupKey: gk,
}
state.Shares[v] = ks
}
// Erase dealer's master secret.
reshare.EraseScalar(secret)
return &KeyEra{
EraID: eraID,
GroupID: groupID,
GroupKey: gk,
GenesisEpoch: 0,
HashSuiteID: suiteID,
State: state,
}, nil
}
// Reshare evolves the era to a new committee while preserving GroupKey.
//
// The bare Shamir kernel runs in-process; for distributed deployments
// the consensus layer wraps this in the full Verifiable Secret
// Resharing exchange (commits, complaints, activation cert) defined
// in lens/reshare. This kernel exists to (a) drive the cryptographic
// core, (b) be reused as the trusted-collaborator path for
// single-process integration tests, and (c) provide a reference
// against which the distributed protocol can be byte-equality checked.
//
// rand defaults to crypto/rand.Reader.
func (era *KeyEra) Reshare(
newValidators []string,
newThreshold int,
randSource io.Reader,
) (*EpochShareState, error) {
if era == nil || era.GroupKey == nil || era.State == nil {
return nil, ErrUninitialized
}
if len(newValidators) == 0 {
return nil, ErrEmptyValidators
}
K := len(newValidators)
if newThreshold < 1 || newThreshold > K {
return nil, fmt.Errorf("%w: t=%d n=%d", ErrInvalidThreshold, newThreshold, K)
}
if randSource == nil {
randSource = rand.Reader
}
c := era.GroupKey.Curve
// Build oldShares map from current state.
oldShares := make(map[int]primitives.Scalar, len(era.State.Shares))
for _, v := range era.State.Validators {
ks, ok := era.State.Shares[v]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrMissingShare, v)
}
oldShares[ks.PartyID] = c.NewScalar().Set(ks.SkShare)
}
newCommitteeIDs := make([]int, K)
for i := range newValidators {
newCommitteeIDs[i] = i + 1
}
newShareScalars, err := reshare.Reshare(
c,
oldShares,
era.State.Threshold,
newCommitteeIDs,
newThreshold,
randSource,
)
if err != nil {
return nil, fmt.Errorf("keyera: reshare kernel: %w", err)
}
lambda, err := primitives.Lagrange(c, newCommitteeIDs)
if err != nil {
return nil, fmt.Errorf("keyera: Lagrange: %w", err)
}
seeds, macKeys := derivePairwiseMaterial(K, randSource)
nextState := &EpochShareState{
KeyEraID: era.State.KeyEraID,
// HashSuiteID is era-pinned; propagated, NOT a parameter.
// Reshare cannot change the suite — see hash-suite-separation
// theorem (proofs/pulsar/hash-suite-separation.tex; the same
// reasoning applies on the curve-side sister kernel).
HashSuiteID: era.HashSuiteID,
Generation: era.State.Generation + 1,
RollbackFrom: 0,
Epoch: era.State.Epoch + 1,
Validators: append([]string(nil), newValidators...),
Threshold: newThreshold,
Shares: make(map[string]*threshold.KeyShare, K),
}
for i, v := range newValidators {
pid := i + 1
ks := &threshold.KeyShare{
Index: i,
PartyID: pid,
SkShare: newShareScalars[pid],
VerificationShare: newShareScalars[pid].ActOnBase(),
Lambda: lambda[pid],
Seeds: seeds,
MACKeys: macKeys[i],
GroupKey: era.GroupKey,
}
nextState.Shares[v] = ks
}
era.State = nextState
return nextState, nil
}
// Reanchor opens a new key era with a fresh GroupKey. Use ONLY for
// security-event response — long-tail share leakage, suspected
// master-secret compromise, etc. The chain governance MUST authorize
// this; it is not a routine operation.
//
// Reanchor inherits the prior era's HashSuiteID. To migrate to a
// different suite (e.g. moving from legacy Lens-BLAKE3 to production
// Lens-SHA3) call ReanchorWithSuite.
func Reanchor(
prev *KeyEra,
c primitives.Curve,
t int,
validators []string,
groupID LensGroupID,
entropy io.Reader,
) (*KeyEra, error) {
var suite hash.HashSuite
if prev != nil && prev.HashSuiteID == hash.LegacyBLAKE3ID {
suite = hash.NewLensBLAKE3()
} else {
suite = hash.Default()
}
return ReanchorWithSuite(prev, c, suite, t, validators, groupID, entropy)
}
// ReanchorWithSuite opens a new key era with a fresh GroupKey under
// the supplied HashSuite. Reanchor is the ONLY lifecycle entrypoint
// that may pin a hash profile different from the prior era's
// (Reshare cannot — that is enforced by Reshare not accepting a suite
// parameter). nil suite resolves to the production default.
func ReanchorWithSuite(
prev *KeyEra,
c primitives.Curve,
suite hash.HashSuite,
t int,
validators []string,
groupID LensGroupID,
entropy io.Reader,
) (*KeyEra, error) {
var nextEraID LensKeyEraID
var nextEpoch uint64
if prev != nil {
nextEraID = prev.EraID + 1
if prev.State != nil {
nextEpoch = prev.State.Epoch + 1
}
}
next, err := BootstrapWithSuite(c, suite, t, validators, groupID, nextEraID, entropy)
if err != nil {
return nil, err
}
next.GenesisEpoch = nextEpoch
next.State.Epoch = nextEpoch
return next, nil
}
// derivePairwiseMaterial generates per-pair PRF seeds and MAC keys for
// a committee of size K.
//
// seeds[i][j] : 32 bytes, present for every (i, j).
// macKeys[i][j] : 32 bytes, present for i != j; symmetric.
//
// In a single-process simulation the material is freshly drawn from
// randSource. In a distributed deployment the consensus layer
// overrides this with authenticated pairwise KEX from
// lens/reshare/pairwise.go.
func derivePairwiseMaterial(K int, randSource io.Reader) (map[int][][]byte, []map[int][]byte) {
const kSize = 32
seeds := make(map[int][][]byte, K)
macKeys := make([]map[int][]byte, K)
for i := 0; i < K; i++ {
seeds[i] = make([][]byte, K)
macKeys[i] = make(map[int][]byte, K-1)
}
for i := 0; i < K; i++ {
for j := 0; j < K; j++ {
buf := make([]byte, kSize)
_, _ = io.ReadFull(randSource, buf)
seeds[i][j] = buf
}
}
for i := 0; i < K; i++ {
for j := i + 1; j < K; j++ {
buf := make([]byte, kSize)
_, _ = io.ReadFull(randSource, buf)
macKeys[i][j] = buf
macKeys[j][i] = buf
}
}
return seeds, macKeys
}