Files
2026-08-21 12:27:47 -07:00

1275 lines
41 KiB
Go

// Copyright (C) 2025, Lux Industries Inc All rights reserved.
// Epoch-based Pulsar share management for quantum-safe consensus.
//
// The cosmic vocabulary stack (see pulsar/DESIGN.md): a Pulsar group key
// is the persistent body that survives every validator-set rotation;
// only the share distribution rotates. Each Pulsar certificate emitted
// over a Quasar bundle is the "pulse" — one threshold signature under
// the unchanged GroupKey.
//
// Concretely: the master signing secret s and the public matrix A are
// preserved across every routine epoch (Refresh / Reshare). They change
// only at Reanchor — a rare governance event that opens a fresh
// KeyEra.
//
// This file owns the EpochManager — the consensus-layer driver that
// (a) bootstraps the first key era at chain genesis, (b) drives every
// subsequent rotation through the LSS-Pulsar adapter, and (c) wires
// the activation circuit-breaker so a chain never accepts a new
// committee whose shares fail to threshold-sign under the unchanged
// GroupKey.
//
// Rate limiting and validator-set change detection stay unchanged from
// the original RotateEpoch implementation — those are pure consensus
// policy and orthogonal to the share-rotation mechanics.
package quasar
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math/big"
"sync"
"time"
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
"github.com/luxfi/threshold/protocols/corona/keyera"
"github.com/luxfi/threshold/protocols/corona/primitives"
pulsarReshare "github.com/luxfi/threshold/protocols/corona/reshare"
pulsarSign "github.com/luxfi/threshold/protocols/corona/sign"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/lss"
)
// signQ is the Pulsar lattice prime, re-exported here so the local
// Lambda recomputation does not require pulsar/sign to add a public
// accessor. Mirrors sign.Q at compile time.
var signQ = pulsarSign.Q
const maxBundleSigningAttempts = 3
var errBundleSignatureRejected = errors.New("bundle signature did not self-verify")
// pulsarComputeLagrange wraps primitives.ComputeLagrangeCoefficients so
// callers do not need to import pulsar/primitives directly. Returns
// Lagrange coefficients keyed by position in the input slice.
func pulsarComputeLagrange(r *ring.Ring, T []int, modulus *big.Int) []ring.Poly {
return primitives.ComputeLagrangeCoefficients(r, T, modulus)
}
const (
// MinEpochDuration is the minimum time between key rotations.
// Changing keys frequently makes quantum attacks harder since any
// progress toward breaking keys is invalidated when keys change.
// At 10 minutes per epoch, uint64 supports 351 trillion years of epochs.
MinEpochDuration = 10 * time.Minute
// MaxEpochDuration is the maximum time keys can be used.
// Forces rotation even if validator set hasn't changed.
// With 10-minute epochs, force rotation after 1 hour (6 epochs).
MaxEpochDuration = 1 * time.Hour
// DefaultHistoryLimit is the default number of old epochs to keep.
// With 10-minute epochs, 6 epochs = 1 hour of history for verification.
DefaultHistoryLimit = 6
// QuantumCheckpointInterval is how often we create quantum-safe signatures.
// Every 3 seconds provides frequent quantum-safe anchors while keys rotate
// every 10 minutes. With <100 validators, 3-second signing is achievable.
QuantumCheckpointInterval = 3 * time.Second
)
var (
ErrEpochRateLimited = errors.New("epoch keygen rate limited: must wait at least 10 minutes between rotations")
ErrNoValidatorChange = errors.New("validator set unchanged, no keygen needed")
ErrEpochNotFound = errors.New("epoch not found")
ErrInvalidValidatorSet = errors.New("invalid validator set configuration")
)
// EpochManager drives the Pulsar share lifecycle for the validator set.
//
// At chain genesis the manager runs Bootstrap (one-time trusted-dealer
// ceremony confined to era 0). Every subsequent validator-set rotation
// is a SHARE rotation, not a key rotation: lss.DynamicResharePulsar
// preserves the GroupKey (A, bTilde) and the master secret s, and only
// rotates the share distribution. The activation circuit-breaker
// (pulsar/reshare.VerifyActivation) gates committing the new committee.
//
// Rate-limiting policy is unchanged from the original epoch design.
// History retention exists to support cross-epoch verification while
// the chain transitions; signatures from old epochs continue to
// verify under the unchanged GroupKey, so history is operationally a
// per-validator-set log rather than a per-key log.
type EpochManager struct {
mu sync.RWMutex
// Current epoch state
currentEpoch uint64
currentKeys *EpochKeys
lastKeygenTime time.Time
// currentEra is the persistent Pulsar key era. Bootstrap creates it
// once at genesis; every subsequent Reshare mutates currentEra.State
// in place (preserving currentEra.GroupKey). On Reanchor we discard
// the era and open a fresh one — currently unused; reanchor flows
// through governance and is wired via a separate API.
currentEra *keyera.KeyEra
// Historical epochs for signature verification
// We need to verify signatures from recent epochs during transitions
epochHistory map[uint64]*EpochKeys
historyLimit int // How many old epochs to keep
// Validator set tracking
currentValidators []string
threshold int
// Chain identity bound into every resharing transcript. Defaults to
// "lux-quasar" if unset by the caller; production deployments should
// inject the chain-specific binding via SetChainID.
chainID []byte
groupID []byte
}
// EpochKeys holds the per-epoch Pulsar share state for a specific epoch.
//
// GroupKey is preserved across every Reshare within a key era. Shares
// rotate. Signers wrap each share for the 2-round Pulsar (Corona)
// signing protocol.
//
// Naming note: this type was historically called EpochKeys when each
// rotation generated a fresh keypair. With Pulsar resharing the type
// holds *shares* of one persistent key, but the public name is kept
// for byte-stable callers (BundleSigner, GroupedEpochManager,
// adversarial_test.go, etc.). Conceptually it is the per-epoch
// EpochShareState defined in pulsar/DESIGN.md.
type EpochKeys struct {
Epoch uint64
CreatedAt time.Time
ExpiresAt time.Time
ValidatorSet []string
Threshold int
TotalParties int
GroupKey *coronaThreshold.GroupKey
Shares map[string]*coronaThreshold.KeyShare
Signers map[string]*coronaThreshold.Signer
// LSS lifecycle fields. Generation increments by one on every
// successful Reshare under the same KeyEraID. RollbackFrom is
// nonzero only when this state descends from a Rollback. KeyEraID
// bumps only on Reanchor (rare governance event).
KeyEraID uint64
Generation uint64
RollbackFrom uint64
}
// NewEpochManager creates a new epoch manager.
func NewEpochManager(threshold int, historyLimit int) *EpochManager {
if historyLimit < 1 {
historyLimit = DefaultHistoryLimit // Keep enough epochs for cross-epoch verification
}
return &EpochManager{
epochHistory: make(map[uint64]*EpochKeys),
historyLimit: historyLimit,
threshold: threshold,
chainID: []byte("lux-quasar"),
groupID: []byte{0},
}
}
// SetChainID binds the resharing transcript to a specific chain identity
// and Pulsar group. Production deployments should call this before
// InitializeEpoch so activation certs cannot be replayed across chains.
func (em *EpochManager) SetChainID(chainID, groupID []byte) {
em.mu.Lock()
defer em.mu.Unlock()
if len(chainID) > 0 {
em.chainID = append([]byte(nil), chainID...)
}
if len(groupID) > 0 {
em.groupID = append([]byte(nil), groupID...)
}
}
// InitializeEpoch creates the first epoch with the initial validator set.
//
// At chain genesis this runs the Pulsar Bootstrap ceremony — the one-time
// trusted-dealer step that forms the persistent GroupKey (A, bTilde) and
// distributes the initial shares. The dealer state is erased before
// returning. Subsequent rotations call RotateEpoch, which preserves
// GroupKey and only rotates the share distribution via the LSS-Pulsar
// adapter.
//
// Bootstrap is the only step where a master secret s exists in process
// memory. After this returns the node holds only public values and its
// own share.
func (em *EpochManager) InitializeEpoch(validators []string) (*EpochKeys, error) {
em.mu.Lock()
defer em.mu.Unlock()
if len(validators) < 2 {
return nil, fmt.Errorf("%w: need at least 2 validators", ErrInvalidValidatorSet)
}
if em.threshold >= len(validators) {
return nil, fmt.Errorf("%w: threshold %d must be less than validator count %d",
ErrInvalidValidatorSet, em.threshold, len(validators))
}
era, _, err := keyera.Bootstrap(
em.threshold,
validators,
keyera.CoronaGroupID(0),
keyera.CoronaKeyEraID(1), // EraID 0 is reserved as "unset"
nil, // crypto/rand.Reader
)
if err != nil {
return nil, fmt.Errorf("pulsar bootstrap: %w", err)
}
keys := em.epochKeysFromState(0, validators, em.threshold, era.State, era.GroupKey)
em.currentEpoch = 0
em.currentKeys = keys
em.currentEra = era
em.currentValidators = validators
em.lastKeygenTime = time.Now()
em.epochHistory[0] = keys
return keys, nil
}
// RotateEpoch transitions the chain to a new validator set by RESHARING
// the existing GroupKey to the new committee — never by generating a
// fresh keypair. The master secret s, the public matrix A, and the
// rounded public key bTilde are all preserved across this call.
//
// Two distinct flows live here:
//
// 1. First call (currentEra == nil): treated as a delayed Bootstrap
// for callers that did not invoke InitializeEpoch. Equivalent to
// InitializeEpoch but rate-limit-aware.
//
// 2. Subsequent calls: build a map[party.ID]*lss.PulsarConfig from the
// era's current shares, hand it to lss.DynamicResharePulsar, and
// gate acceptance on the activation circuit-breaker. The new
// committee threshold-signs the canonical activation message under
// the unchanged GroupKey; only when the signature verifies does
// this method commit the new state and return.
//
// Returns ErrEpochRateLimited if called within MinEpochDuration of the
// last rotation. Returns ErrNoValidatorChange if the validator set
// hasn't changed (unless force=true). Returns a wrapped activation
// error (pulsarReshare.ErrActivationFailed) if the new committee
// cannot collectively sign under the unchanged GroupKey — in which
// case the chain stays at the old epoch and the old committee
// continues signing.
func (em *EpochManager) RotateEpoch(validators []string, force bool) (*EpochKeys, error) {
em.mu.Lock()
defer em.mu.Unlock()
now := time.Now()
// Rate limiting: at most 1 rotation per 10 minutes
if !em.lastKeygenTime.IsZero() {
elapsed := now.Sub(em.lastKeygenTime)
if elapsed < MinEpochDuration {
remaining := MinEpochDuration - elapsed
return nil, fmt.Errorf("%w: %v remaining", ErrEpochRateLimited, remaining.Round(time.Second))
}
}
// Check if validator set actually changed
if !force && em.validatorSetUnchanged(validators) {
return nil, ErrNoValidatorChange
}
// Validate new set
if len(validators) < 2 {
return nil, fmt.Errorf("%w: need at least 2 validators", ErrInvalidValidatorSet)
}
effectiveThreshold := em.threshold
if effectiveThreshold >= len(validators) {
effectiveThreshold = len(validators) - 1
}
newEpoch := em.currentEpoch + 1
keys, err := em.reshareEpochKeys(newEpoch, validators, effectiveThreshold)
if err != nil {
return nil, err
}
// Store old epoch in history
if em.currentKeys != nil {
em.epochHistory[em.currentEpoch] = em.currentKeys
}
// Prune old epochs
em.pruneHistory()
// Update current state
em.currentEpoch = newEpoch
em.currentKeys = keys
em.currentValidators = validators
em.lastKeygenTime = now
em.epochHistory[newEpoch] = keys
return keys, nil
}
// ForceRotateIfExpired rotates keys if MaxEpochDuration has passed.
// This ensures keys don't stay valid indefinitely even if validator set is stable.
func (em *EpochManager) ForceRotateIfExpired() (*EpochKeys, bool, error) {
em.mu.RLock()
if em.currentKeys == nil {
em.mu.RUnlock()
return nil, false, nil
}
expired := time.Now().After(em.currentKeys.ExpiresAt)
validators := em.currentValidators
em.mu.RUnlock()
if !expired {
return nil, false, nil
}
keys, err := em.RotateEpoch(validators, true)
if err != nil {
return nil, false, err
}
return keys, true, nil
}
// GetCurrentKeys returns the current epoch's keys.
func (em *EpochManager) GetCurrentKeys() *EpochKeys {
em.mu.RLock()
defer em.mu.RUnlock()
return em.currentKeys
}
// GetEpochKeys returns keys for a specific epoch (current or historical).
func (em *EpochManager) GetEpochKeys(epoch uint64) (*EpochKeys, error) {
em.mu.RLock()
defer em.mu.RUnlock()
if keys, exists := em.epochHistory[epoch]; exists {
return keys, nil
}
return nil, fmt.Errorf("%w: epoch %d", ErrEpochNotFound, epoch)
}
// GetCurrentEpoch returns the current epoch number.
func (em *EpochManager) GetCurrentEpoch() uint64 {
em.mu.RLock()
defer em.mu.RUnlock()
return em.currentEpoch
}
// GetSigner returns the Corona signer for a validator in the current epoch.
func (em *EpochManager) GetSigner(validatorID string) (*coronaThreshold.Signer, error) {
em.mu.RLock()
defer em.mu.RUnlock()
if em.currentKeys == nil {
return nil, errors.New("no current epoch keys")
}
signer, exists := em.currentKeys.Signers[validatorID]
if !exists {
return nil, fmt.Errorf("validator %s not in current epoch", validatorID)
}
return signer, nil
}
// GetSignerForEpoch returns the signer for a validator in a specific epoch.
func (em *EpochManager) GetSignerForEpoch(validatorID string, epoch uint64) (*coronaThreshold.Signer, error) {
em.mu.RLock()
defer em.mu.RUnlock()
keys, exists := em.epochHistory[epoch]
if !exists {
return nil, fmt.Errorf("%w: epoch %d", ErrEpochNotFound, epoch)
}
signer, exists := keys.Signers[validatorID]
if !exists {
return nil, fmt.Errorf("validator %s not in epoch %d", validatorID, epoch)
}
return signer, nil
}
// VerifySignatureForEpoch verifies a Corona signature using the epoch's keys.
func (em *EpochManager) VerifySignatureForEpoch(message string, sig *coronaThreshold.Signature, epoch uint64) bool {
em.mu.RLock()
keys, exists := em.epochHistory[epoch]
em.mu.RUnlock()
if !exists || keys.GroupKey == nil || sig == nil {
return false
}
return coronaThreshold.Verify(keys.GroupKey, message, sig)
}
// TimeUntilNextRotation returns how long until the next rotation is allowed.
func (em *EpochManager) TimeUntilNextRotation() time.Duration {
em.mu.RLock()
defer em.mu.RUnlock()
if em.lastKeygenTime.IsZero() {
return 0
}
elapsed := time.Since(em.lastKeygenTime)
if elapsed >= MinEpochDuration {
return 0
}
return MinEpochDuration - elapsed
}
// Stats returns current epoch statistics.
func (em *EpochManager) Stats() EpochStats {
em.mu.RLock()
defer em.mu.RUnlock()
var validatorCount int
var epochAge time.Duration
if em.currentKeys != nil {
validatorCount = len(em.currentKeys.ValidatorSet)
epochAge = time.Since(em.currentKeys.CreatedAt)
}
return EpochStats{
CurrentEpoch: em.currentEpoch,
EpochAge: epochAge,
ValidatorCount: validatorCount,
Threshold: em.threshold,
HistorySize: len(em.epochHistory),
LastKeygenTime: em.lastKeygenTime,
TimeUntilRotation: em.timeUntilNextRotationLocked(),
}
}
// EpochStats provides statistics about the epoch manager.
type EpochStats struct {
CurrentEpoch uint64
EpochAge time.Duration
ValidatorCount int
Threshold int
HistorySize int
LastKeygenTime time.Time
TimeUntilRotation time.Duration
}
// Internal helpers
// reshareEpochKeys drives the share rotation for a new validator set.
//
// First call (no era yet): runs Bootstrap and returns its initial state.
// This branch is the caller-friendly path for code that skipped
// InitializeEpoch — it keeps the rate-limit guard inline.
//
// Subsequent calls: builds the per-party PulsarConfig map from the
// current era's shares, calls lss.DynamicResharePulsar, then drives
// the new committee to threshold-sign the canonical activation message
// under the unchanged GroupKey. The activation cert is verified via
// pulsarReshare.VerifyActivation. The new state is committed only on
// successful verification.
func (em *EpochManager) reshareEpochKeys(epoch uint64, validators []string, threshold int) (*EpochKeys, error) {
if em.currentEra == nil {
era, _, err := keyera.Bootstrap(
threshold,
validators,
keyera.CoronaGroupID(0),
keyera.CoronaKeyEraID(1),
nil,
)
if err != nil {
return nil, fmt.Errorf("pulsar bootstrap (delayed): %w", err)
}
em.currentEra = era
return em.epochKeysFromState(epoch, validators, threshold, era.State, era.GroupKey), nil
}
era := em.currentEra
gkBefore := era.GroupKey
// Build per-party LSS-Pulsar configs from the current era state.
oldConfigs := make(map[party.ID]*lss.PulsarConfig, len(era.State.Validators))
for _, v := range era.State.Validators {
share, ok := era.State.Shares[v]
if !ok {
return nil, fmt.Errorf("pulsar reshare: missing share for old validator %s", v)
}
perParty := &keyera.EpochShareState{
KeyEraID: era.State.KeyEraID,
Generation: era.State.Generation,
RollbackFrom: era.State.RollbackFrom,
Epoch: era.State.Epoch,
Validators: era.State.Validators,
Threshold: era.State.Threshold,
Shares: map[string]*coronaThreshold.KeyShare{v: share},
}
oldConfigs[party.ID(v)] = &lss.PulsarConfig{State: perParty, PartyID: party.ID(v)}
}
newPartyIDs := make([]party.ID, len(validators))
for i, v := range validators {
newPartyIDs[i] = party.ID(v)
}
newConfigs, err := lss.DynamicResharePulsar(oldConfigs, newPartyIDs, threshold, nil, nil)
if err != nil {
return nil, fmt.Errorf("pulsar reshare (lss adapter): %w", err)
}
// Reassemble a single in-process state for the new committee. Each
// PulsarConfig holds only its own party's share; combine them into
// the era state so this node — which carries every share in the
// trusted-collaborator orchestration path — can drive activation
// signing on behalf of the committee.
var (
nextKeyEraID uint64
nextGeneration uint64
nextRollbackFrom uint64
nextEpoch uint64
nextThreshold int
)
combinedShares := make(map[string]*coronaThreshold.KeyShare, len(newPartyIDs))
for _, id := range newPartyIDs {
cfg := newConfigs[id]
if cfg == nil || cfg.State == nil {
return nil, fmt.Errorf("pulsar reshare: lss adapter returned no state for %s", id)
}
share, ok := cfg.State.Shares[string(id)]
if !ok {
return nil, fmt.Errorf("pulsar reshare: lss adapter returned no share for %s", id)
}
combinedShares[string(id)] = share
nextKeyEraID = cfg.State.KeyEraID
nextGeneration = cfg.State.Generation
nextRollbackFrom = cfg.State.RollbackFrom
nextEpoch = cfg.State.Epoch
nextThreshold = cfg.State.Threshold
}
nextState := &keyera.EpochShareState{
KeyEraID: nextKeyEraID,
Generation: nextGeneration,
RollbackFrom: nextRollbackFrom,
Epoch: nextEpoch,
Validators: append([]string(nil), validators...),
Threshold: nextThreshold,
Shares: combinedShares,
}
// Activation circuit-breaker. The new committee threshold-signs the
// canonical activation message under the UNCHANGED GroupKey. If the
// signature verifies, the chain commits; otherwise it stays at the
// old epoch (the caller treats the error as ErrActivationFailed and
// the old committee continues signing on the next round).
if err := em.verifyActivationLocked(gkBefore, era.State, nextState); err != nil {
return nil, fmt.Errorf("pulsar reshare activation: %w", err)
}
// Commit the new state to the era. Within a key era, era.GroupKey
// pointer is preserved.
era.State = nextState
return em.epochKeysFromState(epoch, validators, threshold, nextState, era.GroupKey), nil
}
// epochKeysFromState assembles an EpochKeys snapshot from a Pulsar
// EpochShareState plus the era's persistent GroupKey. The Signers map
// wraps each share for the 2-round Pulsar signing protocol, byte-stable
// with the historical EpochKeys layout.
func (em *EpochManager) epochKeysFromState(epoch uint64, validators []string, threshold int, state *keyera.EpochShareState, gk *coronaThreshold.GroupKey) *EpochKeys {
now := time.Now()
keys := &EpochKeys{
Epoch: epoch,
CreatedAt: now,
ExpiresAt: now.Add(MaxEpochDuration),
ValidatorSet: append([]string(nil), validators...),
Threshold: threshold,
TotalParties: len(validators),
GroupKey: gk,
Shares: make(map[string]*coronaThreshold.KeyShare, len(validators)),
Signers: make(map[string]*coronaThreshold.Signer, len(validators)),
KeyEraID: state.KeyEraID,
Generation: state.Generation,
RollbackFrom: state.RollbackFrom,
}
for _, v := range validators {
share := state.Shares[v]
keys.Shares[v] = share
keys.Signers[v] = coronaThreshold.NewSigner(share)
}
return keys
}
// verifyActivationLocked drives the activation circuit-breaker for a
// resharing transition. The new committee threshold-signs the canonical
// activation message under the UNCHANGED GroupKey using their freshly-
// derived shares; the chain accepts the new state only when the
// signature verifies via pulsarReshare.VerifyActivation.
//
// In the trusted-collaborator orchestration path (this in-process
// EpochManager), every validator's share lives on this node, so the
// node can drive every Round1/Round2/Finalize step locally. In a
// distributed deployment, the round-based protocol at
// threshold/protocols/pulsar replaces this with per-party computation;
// the consensus mempool collects the partial signatures and the
// VerifyActivation check happens in the same shape.
//
// transcript and exchange hashes are bound to the chain identity
// (chainID, groupID), the era lineage (KeyEraID), and the
// generation/epoch tuple so that a malicious proposer cannot replay an
// activation cert across chains, eras, or generations.
func (em *EpochManager) verifyActivationLocked(gk *coronaThreshold.GroupKey, oldState, newState *keyera.EpochShareState) error {
// Threshold-sign the activation message under the new committee.
signers := make([]*coronaThreshold.Signer, 0, len(newState.Validators))
signerIndices := make([]int, 0, len(newState.Validators))
for _, v := range newState.Validators {
share, ok := newState.Shares[v]
if !ok {
return fmt.Errorf("activation: missing share for new validator %s", v)
}
signers = append(signers, coronaThreshold.NewSigner(share))
signerIndices = append(signerIndices, share.Index)
}
// Sign the exact bytes the chain will verify. We don't yet have a
// distributed exchange transcript (no commits/complaints in the
// in-process path); the empty ReshareTranscript hashes deterministically
// to a fixed value the verifier reproduces.
tx := pulsarReshare.TranscriptInputs{
ChainID: em.chainID,
GroupID: em.groupID,
OldEpochID: oldState.Epoch,
NewEpochID: newState.Epoch,
OldSetHash: hashValidatorSetForActivation(oldState.Validators),
NewSetHash: hashValidatorSetForActivation(newState.Validators),
ThresholdOld: uint32(oldState.Threshold),
ThresholdNew: uint32(newState.Threshold),
GroupPublicKeyHash: hashGroupKey(gk),
Variant: "reshare",
}
exchange := pulsarReshare.ReshareTranscript{}
activation := pulsarReshare.ActivationMessage{
Transcript: tx,
ReshareTranscript: exchange,
}
// nil suite resolves to the production default Pulsar HashSuite.
msg := activation.SignableBytes(nil)
const sessionID = 0
prfKey := derivePRFKey(em.chainID, em.groupID, newState.KeyEraID, newState.Generation, newState.Epoch)
round1 := make(map[int]*coronaThreshold.Round1Data, len(signers))
for _, s := range signers {
// Round1 sets PartyID from the underlying share index. corona v0.7.9
// re-added the error return on SignRound1 (kernel-boundary hardening),
// so propagate it rather than assume infallibility.
r1, err := s.Round1(sessionID, prfKey, signerIndices)
if err != nil {
return fmt.Errorf("activation: round1: %w", err)
}
round1[r1.PartyID] = r1
}
round2 := make(map[int]*coronaThreshold.Round2Data, len(signers))
for _, s := range signers {
r2, err := s.Round2(sessionID, string(msg), prfKey, signerIndices, round1)
if err != nil {
return fmt.Errorf("activation: round2: %w", err)
}
round2[r2.PartyID] = r2
}
sig, err := signers[0].Finalize(round2)
if err != nil {
return fmt.Errorf("activation: finalize: %w", err)
}
// VerifyActivation runs the chain-level circuit-breaker check.
// It re-derives both transcript hashes from cert.Message and
// confirms they match the chain's local view, then runs the
// supplied verifier callback against the unchanged GroupKey.
cert := &pulsarReshare.ActivationCert{
Message: activation,
Signature: nil, // opaque bytes; the verifier closure ignores Signature serialization here
}
localTranscriptHash := tx.Hash(nil)
localExchangeHash := exchange.Hash(nil)
verifier := func(message, _ []byte) bool {
// We hold the structured Pulsar Signature (sig) directly; the
// closure receives the same canonical message we just signed.
// The Signature reference is captured here; ActivationCert's
// opaque bytes are unused in the in-process path.
_ = message
return coronaThreshold.Verify(gk, string(msg), sig)
}
if err := pulsarReshare.VerifyActivation(cert, localTranscriptHash, localExchangeHash, nil, verifier); err != nil {
return err
}
return nil
}
// hashValidatorSetForActivation returns the canonical 32-byte hash of a validator set.
// Sorted-by-string ordering matches the OldSetHash/NewSetHash binding
// the activation transcript expects.
func hashValidatorSetForActivation(validators []string) [32]byte {
sorted := append([]string(nil), validators...)
// Insertion sort — committee size is bounded.
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && sorted[j-1] > sorted[j]; j-- {
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
}
}
h := sha256.New()
h.Write([]byte("quasar.validator-set.v1"))
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(sorted)))
h.Write(lenBuf[:])
for _, v := range sorted {
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(v)))
h.Write(lenBuf[:])
h.Write([]byte(v))
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// hashGroupKey returns a stable 32-byte digest of a Pulsar GroupKey for
// transcript binding. The current public Bytes() method returns a
// short-form summary; we wrap it in SHA-256 for fixed width.
func hashGroupKey(gk *coronaThreshold.GroupKey) [32]byte {
h := sha256.New()
h.Write([]byte("quasar.group-key.v1"))
if gk != nil {
h.Write(gk.Bytes())
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// derivePRFKey produces a 32-byte session-binding PRF key for the
// activation signing. Distinct from any block-level prfKey so an
// activation signature cannot be replayed for block-level signing.
func derivePRFKey(chainID, groupID []byte, keyEraID, generation, epoch uint64) []byte {
h := sha256.New()
h.Write([]byte("quasar.activation.prf.v1"))
h.Write(chainID)
h.Write(groupID)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], keyEraID)
h.Write(buf[:])
binary.BigEndian.PutUint64(buf[:], generation)
h.Write(buf[:])
binary.BigEndian.PutUint64(buf[:], epoch)
h.Write(buf[:])
return h.Sum(nil)
}
func (em *EpochManager) validatorSetUnchanged(newValidators []string) bool {
if len(newValidators) != len(em.currentValidators) {
return false
}
// Create set of current validators
current := make(map[string]bool, len(em.currentValidators))
for _, v := range em.currentValidators {
current[v] = true
}
// Check if all new validators exist in current set
for _, v := range newValidators {
if !current[v] {
return false
}
}
return true
}
func (em *EpochManager) pruneHistory() {
if len(em.epochHistory) <= em.historyLimit {
return
}
// Find the minimum epoch to keep
minEpochToKeep := em.currentEpoch
if uint64(em.historyLimit) < minEpochToKeep {
minEpochToKeep = em.currentEpoch - uint64(em.historyLimit) + 1
} else {
minEpochToKeep = 0
}
// Remove old epochs
for epoch := range em.epochHistory {
if epoch < minEpochToKeep {
delete(em.epochHistory, epoch)
}
}
}
func (em *EpochManager) timeUntilNextRotationLocked() time.Duration {
if em.lastKeygenTime.IsZero() {
return 0
}
elapsed := time.Since(em.lastKeygenTime)
if elapsed >= MinEpochDuration {
return 0
}
return MinEpochDuration - elapsed
}
// ============================================================================
// Quantum Block - Bundles BLS blocks into quantum-safe anchors
// ============================================================================
//
// Architecture (parallel execution):
// BLS Layer: [B1]--[B2]--[B3]--[B4]--[B5]--[B6]--[B7]--[B8]--[B9]--...
// | 500ms finality per block |
// |_____________________________________|
// |
// Quantum Layer: [QB1: Merkle(B1-B6)]--------[QB2: Merkle(B7-B12)]
// | 3-second interval, async Corona signing
//
// NTT Corona benchmarks (IEEE S&P 2025):
// - 0.6s online signing phase (2-round protocol)
// - 2.5s total including offline prep across 5 continents
// - Our 3-second interval provides comfortable margin
// QuantumBundle bundles multiple BLS-signed blocks into a quantum-safe anchor.
// BLS blocks continue at 500ms pace; quantum bundles form every 3 seconds
// containing a Merkle root of ~6 BLS block hashes.
// Note: This is distinct from core.go's QuantumBlock which represents per-block finality.
type QuantumBundle struct {
Epoch uint64 // Current key epoch
Sequence uint64 // Bundle sequence within epoch
StartHeight uint64 // First BLS block in this bundle
EndHeight uint64 // Last BLS block in this bundle
BlockCount int // Number of BLS blocks bundled
MerkleRoot [32]byte // Merkle root of BLS block hashes
BlockHashes [][32]byte // Individual block hashes (for Merkle proof)
PreviousHash [32]byte // Previous bundle hash (chain linkage)
Timestamp int64 // Unix timestamp
// Corona threshold signature (post-quantum secure)
Signature *coronaThreshold.Signature
}
// Hash returns the hash of this bundle for chain linkage.
func (qb *QuantumBundle) Hash() [32]byte {
h := sha256.New()
buf := make([]byte, 8)
// Epoch + sequence
binary.BigEndian.PutUint64(buf, qb.Epoch)
h.Write(buf)
binary.BigEndian.PutUint64(buf, qb.Sequence)
h.Write(buf)
// Block range
binary.BigEndian.PutUint64(buf, qb.StartHeight)
h.Write(buf)
binary.BigEndian.PutUint64(buf, qb.EndHeight)
h.Write(buf)
// Merkle root of bundled BLS blocks
h.Write(qb.MerkleRoot[:])
// Previous bundle hash (chain linkage)
h.Write(qb.PreviousHash[:])
// Timestamp
binary.BigEndian.PutUint64(buf, uint64(qb.Timestamp))
h.Write(buf)
var result [32]byte
copy(result[:], h.Sum(nil))
return result
}
// SignableMessage returns the message for Corona signing.
func (qb *QuantumBundle) SignableMessage() string {
hash := qb.Hash()
return fmt.Sprintf("QUASAR-QB-v1:%x", hash)
}
// BundleSigner handles creating and verifying 3-second quantum bundles.
// Bundles accumulate multiple BLS blocks and sign them with Corona.
type BundleSigner struct {
em *EpochManager
lastBundle *QuantumBundle
sequence uint64
// Pending BLS blocks waiting to be bundled
pendingBlocks [][32]byte
pendingStart uint64
pendingEnd uint64
mu sync.Mutex
}
// NewBundleSigner creates a bundle signer for the epoch manager.
func NewBundleSigner(em *EpochManager) *BundleSigner {
return &BundleSigner{em: em}
}
// AddBLSBlock adds a finalized BLS block hash to the pending bundle.
// Called whenever a BLS block achieves finality (~500ms).
func (bs *BundleSigner) AddBLSBlock(height uint64, hash [32]byte) {
bs.mu.Lock()
defer bs.mu.Unlock()
if len(bs.pendingBlocks) == 0 {
bs.pendingStart = height
}
bs.pendingBlocks = append(bs.pendingBlocks, hash)
bs.pendingEnd = height
}
// PendingCount returns the number of pending BLS blocks.
func (bs *BundleSigner) PendingCount() int {
bs.mu.Lock()
defer bs.mu.Unlock()
return len(bs.pendingBlocks)
}
// CreateBundle bundles pending BLS blocks into a quantum bundle.
// Call this every 3 seconds (QuantumCheckpointInterval).
// Returns nil if no pending blocks.
func (bs *BundleSigner) CreateBundle() *QuantumBundle {
bs.mu.Lock()
defer bs.mu.Unlock()
if len(bs.pendingBlocks) == 0 {
return nil
}
// Compute Merkle root of bundled BLS blocks
merkleRoot := ComputeMerkleRoot(bs.pendingBlocks)
// Get previous bundle hash for chain linkage
var prevHash [32]byte
if bs.lastBundle != nil {
prevHash = bs.lastBundle.Hash()
}
epoch := bs.em.GetCurrentEpoch()
// Reset sequence on new epoch
if bs.lastBundle != nil && bs.lastBundle.Epoch != epoch {
bs.sequence = 0
}
qb := &QuantumBundle{
Epoch: epoch,
Sequence: bs.sequence,
StartHeight: bs.pendingStart,
EndHeight: bs.pendingEnd,
BlockCount: len(bs.pendingBlocks),
MerkleRoot: merkleRoot,
BlockHashes: bs.pendingBlocks,
PreviousHash: prevHash,
Timestamp: time.Now().Unix(),
}
// Clear pending and advance sequence
bs.pendingBlocks = nil
bs.sequence++
bs.lastBundle = qb
return qb
}
// LastBundle returns the most recent bundle.
func (bs *BundleSigner) LastBundle() *QuantumBundle {
bs.mu.Lock()
defer bs.mu.Unlock()
return bs.lastBundle
}
// SignBundle performs the 2-round Pulsar (Corona) signing for a
// bundle. Runs the full threshold signing protocol with the
// participating validators.
//
// Each share's Lambda is recomputed for the participating subset
// before signing. Pulsar's keyera bakes a Lambda for the FULL
// committee at Bootstrap; when fewer parties sign, the Lagrange
// weights must be re-derived for the actual signing subset so the
// reconstructed signature evaluates the secret-sharing polynomial at
// 0 correctly. This is identical to how distributed Pulsar deployments
// recompute Lambda when a coordinator picks a quorum smaller than the
// full set.
func (bs *BundleSigner) SignBundle(
bundle *QuantumBundle,
sessionID int,
prfKey []byte,
participatingValidators []string,
) error {
if bundle == nil {
return errors.New("nil bundle")
}
bs.em.mu.RLock()
keys := bs.em.currentKeys
bs.em.mu.RUnlock()
if keys == nil {
return errors.New("no current epoch keys")
}
// Build signer indices
signerIndices := make([]int, 0, len(participatingValidators))
seenSignerIndices := make(map[int]struct{}, len(participatingValidators))
for _, v := range participatingValidators {
if share, ok := keys.Shares[v]; ok {
if _, duplicated := seenSignerIndices[share.Index]; duplicated {
return fmt.Errorf("duplicate signer: %s", v)
}
seenSignerIndices[share.Index] = struct{}{}
signerIndices = append(signerIndices, share.Index)
}
}
if len(signerIndices) < keys.Threshold {
return fmt.Errorf("insufficient signers: %d < threshold %d", len(signerIndices), keys.Threshold)
}
message := bundle.SignableMessage()
bundleHash := bundle.Hash()
var lastErr error
for attempt := 0; attempt < maxBundleSigningAttempts; attempt++ {
subsetSigners, err := newSubsetSigners(keys, participatingValidators, signerIndices)
if err != nil {
return err
}
attemptSessionID := bundleSigningSessionID(bundleHash, sessionID, attempt)
sig, err := signBundleAttempt(
keys,
subsetSigners,
participatingValidators,
signerIndices,
attemptSessionID,
message,
prfKey,
)
if err != nil {
lastErr = err
continue
}
if !coronaThreshold.Verify(keys.GroupKey, message, sig) {
lastErr = errBundleSignatureRejected
continue
}
bundle.Signature = sig
return nil
}
bundle.Signature = nil
return fmt.Errorf("bundle signing failed after %d attempts: %w", maxBundleSigningAttempts, lastErr)
}
// bundleSigningSessionID gives every retry a fresh consensus-session domain.
// The first attempt preserves the caller's session ID. Later attempts bind the
// logical session, bundle hash and attempt number into a 64-bit ID, avoiding
// nonce-domain reuse without creating a counter range that can collide with a
// future bundle after enough uptime.
func bundleSigningSessionID(bundleHash [32]byte, sessionID, attempt int) int {
if attempt == 0 {
return sessionID
}
h := sha256.New()
h.Write([]byte("QUASAR-BUNDLE-SIGNING-ATTEMPT-v1"))
var encoded [8]byte
binary.BigEndian.PutUint64(encoded[:], uint64(int64(sessionID)))
h.Write(encoded[:])
binary.BigEndian.PutUint64(encoded[:], uint64(attempt))
h.Write(encoded[:])
h.Write(bundleHash[:])
digest := h.Sum(nil)
retryID := int(int64(binary.BigEndian.Uint64(digest[:8])))
if retryID == sessionID {
retryID = ^retryID
}
return retryID
}
func signBundleAttempt(
keys *EpochKeys,
subsetSigners map[string]*coronaThreshold.Signer,
participatingValidators []string,
signerIndices []int,
sessionID int,
message string,
prfKey []byte,
) (*coronaThreshold.Signature, error) {
// Round 1: Collect commitments
round1Data := make(map[int]*coronaThreshold.Round1Data)
for _, v := range participatingValidators {
signer, ok := subsetSigners[v]
if !ok {
continue
}
r1, err := signer.Round1(sessionID, prfKey, signerIndices)
if err != nil {
return nil, fmt.Errorf("round1: validator %s: %w", v, err)
}
round1Data[keys.Shares[v].Index] = r1
}
// Round 2: Generate signature shares
round2Data := make(map[int]*coronaThreshold.Round2Data)
for _, v := range participatingValidators {
signer, ok := subsetSigners[v]
if !ok {
continue
}
r2, err := signer.Round2(sessionID, message, prfKey, signerIndices, round1Data)
if err != nil {
return nil, fmt.Errorf("round2 failed for %s: %w", v, err)
}
round2Data[r2.PartyID] = r2
}
// Finalize
var finalSigner *coronaThreshold.Signer
for _, validator := range participatingValidators {
if signer, ok := subsetSigners[validator]; ok {
finalSigner = signer
break
}
}
if finalSigner == nil {
return nil, errors.New("no participating signer")
}
sig, err := finalSigner.Finalize(round2Data)
if err != nil {
return nil, fmt.Errorf("finalize failed: %w", err)
}
return sig, nil
}
// newSubsetSigners produces a fresh set of Signers whose underlying
// shares carry Lambda recomputed for the participating subset.
//
// The per-share KeyShare has an Index field that the original keygen
// converted into an evaluation point (Index+1). When a strict subset
// signs, the Lagrange coefficient for each signer at the polynomial's
// constant term must be derived from the SUBSET evaluation points,
// not the full-committee evaluation points. We compute these on the
// fly and clone each KeyShare with the corrected Lambda.
//
// Side effects: this allocates one KeyShare clone and one Signer per
// participating validator. The original epoch state is not mutated.
func newSubsetSigners(keys *EpochKeys, participating []string, signerIndices []int) (map[string]*coronaThreshold.Signer, error) {
if len(participating) == 0 {
return nil, errors.New("no participating validators")
}
if keys == nil || keys.GroupKey == nil {
return nil, errors.New("missing group key")
}
r := keys.GroupKey.Params.R
q := big.NewInt(int64(signQ))
subsetLambda := pulsarComputeLagrange(r, signerIndices, q)
indexToSlot := make(map[int]int, len(signerIndices))
for slot, idx := range signerIndices {
indexToSlot[idx] = slot
}
out := make(map[string]*coronaThreshold.Signer, len(participating))
for _, v := range participating {
share, ok := keys.Shares[v]
if !ok {
continue
}
slot, ok := indexToSlot[share.Index]
if !ok {
continue
}
newLambda := r.NewPoly()
newLambda.Copy(subsetLambda[slot])
r.NTT(newLambda, newLambda)
r.MForm(newLambda, newLambda)
cloned := *share
cloned.Lambda = newLambda
out[v] = coronaThreshold.NewSigner(&cloned)
}
return out, nil
}
// VerifyBundle verifies a quantum bundle's Corona signature.
func (bs *BundleSigner) VerifyBundle(bundle *QuantumBundle) bool {
if bundle == nil || bundle.Signature == nil {
return false
}
// Verify Merkle root matches block hashes
expectedRoot := ComputeMerkleRoot(bundle.BlockHashes)
if expectedRoot != bundle.MerkleRoot {
return false
}
// Get keys for the bundle's epoch
keys, err := bs.em.GetEpochKeys(bundle.Epoch)
if err != nil {
return false
}
message := bundle.SignableMessage()
return coronaThreshold.Verify(keys.GroupKey, message, bundle.Signature)
}
// ComputeMerkleRoot computes a Merkle root over block hashes.
func ComputeMerkleRoot(hashes [][32]byte) [32]byte {
if len(hashes) == 0 {
return [32]byte{}
}
if len(hashes) == 1 {
return hashes[0]
}
// Pad to even length
level := make([][32]byte, len(hashes))
copy(level, hashes)
if len(level)%2 != 0 {
level = append(level, level[len(level)-1])
}
// Build tree
for len(level) > 1 {
nextLevel := make([][32]byte, len(level)/2)
for i := 0; i < len(level); i += 2 {
combined := append(level[i][:], level[i+1][:]...)
nextLevel[i/2] = sha256.Sum256(combined)
}
level = nextLevel
if len(level) > 1 && len(level)%2 != 0 {
level = append(level, level[len(level)-1])
}
}
return level[0]
}