Files
zeekay cdfb27641d threshold: Pulsar/Lens kernel adapters + corona KAT + Pulsar-SHA3 wiring
Squashed batch covering:

- protocols/pulsar/: lattice threshold lane (round-based wrapper for
  github.com/luxfi/pulsar kernel); doc.go documents Photon/Lumen/Beam/
  Pulsar/Pulse/Prism/Horizon/Quasar vocabulary stack from LP-105
- protocols/lens/: planned curve threshold sister kernel (design
  intent only; mirrors Pulsar shape; replaces stub LSS-FROST)

- protocols/lss/lss_pulsar.go + tests: LSS-Pulsar adapter
  (DynamicResharePulsar + PulsarSnapshotManager + BuildActivationTranscript).
  Now SHA3-suite-aware: PulsarConfig carries NebulaRoot, HashSuiteID,
  ImplementationVersion as optional transcript-binding fields that
  flow through to the activation message.

  10/10 acceptance tests pass:
    1. GroupKey preservation
    2. KeyEraID preservation
    3. Generation +1
    4. RollbackFrom=0 on forward
    5. t_old != t_new
    6. Disjoint set rotation
    7. Valid signature under unchanged GroupKey
    8. Pairwise material regenerated
    9. Rollback semantics
   10. Error surface
  Plus 1 new test for BuildActivationTranscript Nebula+suite fields.

- protocols/lss/lss_frost.go: marked DEPRECATED (placeholder Sign()/
  Refresh(), single-process simulation, hardcoded ChainKey/RID); will
  be replaced by lss_lens.go.

- corona KAT cross-oracle vs Go reference (N=16 deterministic seeds)
- corona+protocol+harness hardening against parallel-run flakes
- lss/adapters: drop NewXRPL/NewCardano panic; ship real XRPL address
  derivation
- corona SignWithConfig API expansion + test fixes
- v1.6.5 changelog
- go.sum h1 hashes for luxfi/log and 4 deps

Architecture (LP-105 / pulsar/DESIGN.md):
  LSS owns lifecycle (Generation, Rollback, snapshots, dealer/
  coordinator role separation). Pulsar owns lattice math (R_q shares,
  lattice Pedersen commits, Sign1/Sign2/Combine, Pulsar-SHA3 hash
  profile). The lss_pulsar adapter wires them. lss_lens (planned) does
  the same for curve math.

go.mod adds local replace github.com/luxfi/pulsar => ../pulsar.
2026-03-03 12:00:00 -08:00

504 lines
16 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package lss — Lens adapter.
//
// This file is the LSS-Lens adapter: it wires the LSS dynamic-resharing
// lifecycle (Generation tracking, snapshot history, rollback) into the
// Lens curve threshold kernel (curve scalar share representation,
// Pedersen commitments over G/H, FROST-compatible signing material).
//
// Architectural contract:
//
// LSS owns:
// - Generation numbers and version monotonicity
// - GenerationSnapshot history and Rollback semantics
// - Bootstrap Dealer / Signature Coordinator role separation
// - Live resharing orchestration shape
//
// Lens (kernel) owns:
// - Curve scalar share representation and Shamir over the scalar field
// - Pedersen commits (s·G + r·H) over the curve
// - FROST 2-round Sign / Aggregate (RFC 9591)
// - Lambda + pairwise material regeneration
// - Activation cert message bytes (curve Schnorr signature)
//
// This adapter (lss_lens.go) does:
// 1. Validate the LSS lifecycle constraints (consistent KeyEraID
// across old configs, threshold sanity, qualified-old-set size).
// 2. Call into lens/keyera + lens/reshare for the curve math.
// 3. Bump Generation; wire RollbackFrom on rollback paths.
// 4. Persist snapshots through the LSS-style snapshot store.
//
// What this adapter must NOT do:
//
// - Reimplement Lens VSR math (commits, complaints, transcript). That
// lives in github.com/luxfi/lens/reshare and is called via
// lens.KeyEra.Reshare.
// - Inherit ECDSA-specific assumptions from LSS-CMP / LSS-FROST stub
// (auxiliary secrets w, q; multiplicative nonce blinding). FROST's
// signing protocol has additive nonce (d_i, e_i) blinding built into
// Round 1; no w, q needed.
// - Invent its own rollback semantics. Use the snapshot store; on
// activation failure, return an error and let the caller decide.
package lss
import (
"errors"
"fmt"
"io"
"sync"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/lens/hash"
"github.com/luxfi/lens/keyera"
"github.com/luxfi/lens/primitives"
"github.com/luxfi/lens/reshare"
lensThreshold "github.com/luxfi/lens/threshold"
)
// LensConfig is the LSS-Lens adapter's per-party configuration.
// Wraps a lens.EpochShareState so the adapter can apply LSS lifecycle
// semantics uniformly. Generation and RollbackFrom on the embedded
// EpochShareState are the source of truth — the wrapper exists to
// match the FROST/CMP/Pulsar adapter shape.
//
// NebulaRoot, HashSuiteID, and ImplementationVersion are optional
// transcript-binding fields that flow through to the activation
// message. They MAY be left zero / empty at the adapter call site;
// BuildLensActivationTranscript supplies sensible defaults
// ("Lens-SHA3", "lss-lens-test-1.0", zero NebulaRoot) when fields
// are unset.
type LensConfig struct {
// State is the underlying Lens share state. Generation and
// RollbackFrom live inside State (not duplicated on the wrapper).
State *keyera.EpochShareState
// PartyID is the wire-identity of this party.
PartyID party.ID
// NebulaRoot binds the share state to a specific Quasar Nebula
// frontier or epoch root anchor. Zero by default; set when the
// caller wants the activation cert to commit to a specific chain
// state moment.
NebulaRoot [32]byte
// HashSuiteID pins the hash profile this config was produced
// under. Empty defaults to "Lens-SHA3" inside
// BuildLensActivationTranscript.
HashSuiteID string
// ImplementationVersion pins the software-port identity. Empty
// defaults to "lss-lens-test-1.0".
ImplementationVersion string
}
// Generation returns the LSS resharing generation. Pass-through to
// the underlying Lens state; aligns with the lifecycle field LSS reads.
func (lc *LensConfig) Generation() uint64 {
if lc == nil || lc.State == nil {
return 0
}
return lc.State.Generation
}
// RollbackFrom returns the previous generation when this state
// descends from a Rollback; zero on ordinary forward transitions.
func (lc *LensConfig) RollbackFrom() uint64 {
if lc == nil || lc.State == nil {
return 0
}
return lc.State.RollbackFrom
}
// KeyEraID returns the Lens group-key lineage ID. Stable across every
// Reshare within a key era; bumps only at Reanchor.
func (lc *LensConfig) KeyEraID() uint64 {
if lc == nil || lc.State == nil {
return 0
}
return lc.State.KeyEraID
}
// Default values used by BuildLensActivationTranscript when a config
// does not set HashSuiteID / ImplementationVersion explicitly.
const (
defaultLensHashSuiteID = hash.DefaultID // "Lens-SHA3"
defaultLensImplementationVersion = "lss-lens-test-1.0"
)
// BuildLensActivationTranscript returns a fully-populated
// reshare.TranscriptInputs binding (chain_id, network_id, group_id,
// key_era_id, generations, epochs, sets, thresholds, group_pk_hash,
// nebula_root, hash_suite_id, implementation_version, variant, curve)
// for the activation cert the new committee will threshold-sign under
// the unchanged GroupKey.
//
// Inputs:
//
// - oldCfgs — the configs that fed DynamicReshareLens.
// - newCfgs — the configs returned by DynamicReshareLens.
// - chainID, networkID, groupID — wire-level identifiers.
// - groupPubKeyHash — 32-byte digest over the canonical GroupKey
// bytes. Caller computes once at era bootstrap and reuses across
// every reshare in the era.
// - nebulaRoot — Quasar frontier anchor; zero acceptable.
// - hashSuiteID, implVersion — pinned profile and port identifiers;
// empty values resolve to the package defaults.
// - variant — "refresh" or "reshare".
// - suite — HashSuite for the validator-set hashes; nil = default.
func BuildLensActivationTranscript(
oldCfgs map[party.ID]*LensConfig,
newCfgs map[party.ID]*LensConfig,
chainID, networkID, groupID []byte,
groupPubKeyHash [32]byte,
nebulaRoot [32]byte,
hashSuiteID, implVersion, variant string,
suite hash.HashSuite,
) reshare.TranscriptInputs {
if hashSuiteID == "" {
hashSuiteID = defaultLensHashSuiteID
}
if implVersion == "" {
implVersion = defaultLensImplementationVersion
}
var (
eraID uint64
oldGen, newGen uint64
oldT, newT int
oldEpoch, newEpoch uint64
oldKeys, newKeys [][]byte
curveName string
)
for _, c := range oldCfgs {
if c == nil || c.State == nil {
continue
}
eraID = c.State.KeyEraID
oldGen = c.State.Generation
oldT = c.State.Threshold
oldEpoch = c.State.Epoch
oldKeys = make([][]byte, len(c.State.Validators))
for i, v := range c.State.Validators {
oldKeys[i] = []byte(v)
}
// Derive curve name from the first share we find.
for _, ks := range c.State.Shares {
if ks.GroupKey != nil {
curveName = ks.GroupKey.Curve.Name()
}
break
}
break
}
for _, c := range newCfgs {
if c == nil || c.State == nil {
continue
}
newGen = c.State.Generation
newT = c.State.Threshold
newEpoch = c.State.Epoch
newKeys = make([][]byte, len(c.State.Validators))
for i, v := range c.State.Validators {
newKeys[i] = []byte(v)
}
break
}
return reshare.TranscriptInputs{
ChainID: chainID,
NetworkID: networkID,
GroupID: groupID,
KeyEraID: eraID,
OldGeneration: oldGen,
NewGeneration: newGen,
OldEpochID: oldEpoch,
NewEpochID: newEpoch,
OldSetHash: reshare.ValidatorSetHash(oldKeys, suite),
NewSetHash: reshare.ValidatorSetHash(newKeys, suite),
ThresholdOld: uint32(oldT),
ThresholdNew: uint32(newT),
GroupPublicKeyHash: groupPubKeyHash,
NebulaRoot: nebulaRoot,
HashSuiteID: hashSuiteID,
ImplementationVersion: implVersion,
Variant: variant,
CurveName: curveName,
}
}
// Errors returned by the LSS-Lens adapter.
var (
ErrLensNoOldConfigs = errors.New("lss-lens: no old configurations provided")
ErrLensInvalidThreshold = errors.New("lss-lens: invalid new threshold")
ErrLensKeyEraMismatch = errors.New("lss-lens: old configs span multiple key eras")
ErrLensGenerationMismatch = errors.New("lss-lens: old configs span multiple generations")
ErrLensGroupKeyMismatch = errors.New("lss-lens: old configs disagree on GroupKey pointer")
ErrLensInsufficientOldSet = errors.New("lss-lens: insufficient old configs to form qualified set")
ErrLensMissingShare = errors.New("lss-lens: old config missing share")
ErrLensKernelReshareFailed = errors.New("lss-lens: lens kernel reshare failed")
)
// DynamicReshareLens performs LSS dynamic resharing on Lens
// configurations. Implements the LSS Section 4 lifecycle adapted for
// the Lens curve setting:
//
// 1. Validate that all old configs agree on KeyEraID, Generation,
// and GroupKey (they should — they came from the same era at the
// same generation).
// 2. Reconstruct the in-process lens.KeyEra from the agreed-on
// GroupKey + the union of old shares.
// 3. Call lens.KeyEra.Reshare with the new participant set + new
// threshold. The kernel handles all curve math: Reshare polynomial
// sampling, Lagrange interpolation over the old qualified set,
// Pedersen commits over the curve, Lambda recomputation, Seeds +
// MACKeys regeneration.
// 4. Wrap the resulting EpochShareState into LensConfigs, one per
// new party. Generation is bumped by the kernel; KeyEraID is
// preserved; RollbackFrom is zero (forward transition).
//
// Adapter contract guarantees:
//
// Preserved across the call: KeyEraID, GroupKey pointer, master
// secret s (held only as shares; never reconstructed in this
// process), and the persistent group public key X = s·G.
//
// Changed across the call: Generation (incremented by 1),
// participant set, threshold, share values, Lambdas, pairwise PRF
// Seeds, MAC keys, transcript hash.
//
// Failure behavior: returns an error. The caller (LSS RollbackManager
// or Quasar consensus) decides whether to retry, rollback, or
// reanchor. The adapter does not silently fall back.
//
// rand defaults to crypto/rand.Reader. Tests pass a deterministic
// source for KAT replay.
func DynamicReshareLens(
oldConfigs map[party.ID]*LensConfig,
newPartyIDs []party.ID,
newThreshold int,
_ *pool.Pool,
rand io.Reader,
) (map[party.ID]*LensConfig, error) {
if len(oldConfigs) == 0 {
return nil, ErrLensNoOldConfigs
}
if newThreshold < 1 || newThreshold > len(newPartyIDs) {
return nil, fmt.Errorf("%w: t=%d n=%d", ErrLensInvalidThreshold, newThreshold, len(newPartyIDs))
}
// 1. Validate consistency.
var (
refKeyEraID uint64
refGen uint64
refGroupKey *lensThreshold.GroupKey
refValidators []string
refThreshold int
first = true
)
for _, cfg := range oldConfigs {
if cfg == nil || cfg.State == nil {
return nil, ErrLensMissingShare
}
if first {
refKeyEraID = cfg.State.KeyEraID
refGen = cfg.State.Generation
refValidators = cfg.State.Validators
refThreshold = cfg.State.Threshold
first = false
for _, ks := range cfg.State.Shares {
refGroupKey = ks.GroupKey
break
}
continue
}
if cfg.State.KeyEraID != refKeyEraID {
return nil, fmt.Errorf("%w: era %d != %d",
ErrLensKeyEraMismatch, cfg.State.KeyEraID, refKeyEraID)
}
if cfg.State.Generation != refGen {
return nil, fmt.Errorf("%w: gen %d != %d",
ErrLensGenerationMismatch, cfg.State.Generation, refGen)
}
for _, ks := range cfg.State.Shares {
if ks.GroupKey != refGroupKey {
return nil, ErrLensGroupKeyMismatch
}
break
}
}
if refGroupKey == nil {
return nil, ErrLensMissingShare
}
if refThreshold > len(oldConfigs) {
return nil, fmt.Errorf("%w: have %d configs, need %d",
ErrLensInsufficientOldSet, len(oldConfigs), refThreshold)
}
// 3. Reconstruct the lens.KeyEra from the union of old shares.
era := &keyera.KeyEra{
EraID: keyera.LensKeyEraID(refKeyEraID),
GroupKey: refGroupKey,
GenesisEpoch: 0,
State: &keyera.EpochShareState{
KeyEraID: refKeyEraID,
Generation: refGen,
Epoch: 0,
Validators: refValidators,
Threshold: refThreshold,
Shares: make(map[string]*lensThreshold.KeyShare, len(oldConfigs)),
},
}
for _, cfg := range oldConfigs {
for v, ks := range cfg.State.Shares {
era.State.Shares[v] = ks
}
}
// 4. Call into the Lens kernel for all curve math.
newValidators := make([]string, len(newPartyIDs))
for i, id := range newPartyIDs {
newValidators[i] = string(id)
}
nextState, err := era.Reshare(newValidators, newThreshold, rand)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrLensKernelReshareFailed, err)
}
// 5. Wrap each new share into a LensConfig, one per new party.
out := make(map[party.ID]*LensConfig, len(newPartyIDs))
for _, id := range newPartyIDs {
v := string(id)
share, ok := nextState.Shares[v]
if !ok {
return nil, fmt.Errorf("lss-lens: kernel did not return share for %s", v)
}
perPartyState := &keyera.EpochShareState{
KeyEraID: nextState.KeyEraID,
Generation: nextState.Generation,
RollbackFrom: 0,
Epoch: nextState.Epoch,
Validators: nextState.Validators,
Threshold: nextState.Threshold,
Shares: map[string]*lensThreshold.KeyShare{v: share},
}
out[id] = &LensConfig{
State: perPartyState,
PartyID: id,
}
}
return out, nil
}
// LensSnapshotManager is a per-Lens-era snapshot store. Mirrors the
// shape of LSS's RollbackManager but typed to LensConfig.
type LensSnapshotManager struct {
mu sync.RWMutex
history []*LensSnapshot
maxGenerations int
currentGen uint64
}
// LensSnapshot is a point-in-time snapshot of a Lens key era's share
// state, suitable for rollback.
type LensSnapshot struct {
KeyEraID uint64
Generation uint64
State *keyera.EpochShareState
PartyIDs []party.ID
Threshold int
}
// NewLensSnapshotManager creates a snapshot store keeping at most
// maxGenerations entries. Default 10 if maxGenerations < 1.
func NewLensSnapshotManager(maxGenerations int) *LensSnapshotManager {
if maxGenerations < 1 {
maxGenerations = 10
}
return &LensSnapshotManager{
history: make([]*LensSnapshot, 0, maxGenerations),
maxGenerations: maxGenerations,
}
}
// SaveSnapshot persists a per-generation snapshot. Should be called
// by the Signature Coordinator after a successful Reshare/Refresh
// activates.
func (lsm *LensSnapshotManager) SaveSnapshot(state *keyera.EpochShareState, partyIDs []party.ID) error {
if state == nil {
return errors.New("lss-lens: cannot save nil state")
}
lsm.mu.Lock()
defer lsm.mu.Unlock()
snap := &LensSnapshot{
KeyEraID: state.KeyEraID,
Generation: state.Generation,
State: state,
PartyIDs: append([]party.ID(nil), partyIDs...),
Threshold: state.Threshold,
}
lsm.history = append(lsm.history, snap)
if state.Generation > lsm.currentGen {
lsm.currentGen = state.Generation
}
if len(lsm.history) > lsm.maxGenerations {
lsm.history = lsm.history[len(lsm.history)-lsm.maxGenerations:]
}
return nil
}
// Rollback restores the share state at a target generation. Marks the
// restored state with RollbackFrom = currentGen and Generation =
// currentGen + 1.
func (lsm *LensSnapshotManager) Rollback(targetGeneration uint64) (*keyera.EpochShareState, error) {
lsm.mu.Lock()
defer lsm.mu.Unlock()
if targetGeneration >= lsm.currentGen {
return nil, fmt.Errorf("lss-lens: cannot rollback to future generation %d (current: %d)",
targetGeneration, lsm.currentGen)
}
var target *LensSnapshot
for i := len(lsm.history) - 1; i >= 0; i-- {
if lsm.history[i].Generation == targetGeneration {
target = lsm.history[i]
break
}
}
if target == nil {
return nil, fmt.Errorf("lss-lens: generation %d not in history", targetGeneration)
}
restored := *target.State
restored.Generation = lsm.currentGen + 1
restored.RollbackFrom = lsm.currentGen
lsm.currentGen = restored.Generation
return &restored, nil
}
// CurrentGeneration returns the most recently saved generation.
func (lsm *LensSnapshotManager) CurrentGeneration() uint64 {
lsm.mu.RLock()
defer lsm.mu.RUnlock()
return lsm.currentGen
}
// HistorySize returns the number of snapshots currently retained.
func (lsm *LensSnapshotManager) HistorySize() int {
lsm.mu.RLock()
defer lsm.mu.RUnlock()
return len(lsm.history)
}
// CurveForLens is a small helper exposing the default curve to test
// callers — keeps the test file from importing primitives directly.
func CurveForLens() primitives.Curve {
return primitives.NewEd25519()
}