Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions service/dist_key_gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,7 @@
return nil, err
}

existing, err := s.getOrRebuildFromRoundDKG(codeCommitmentHex, fromRound, isResharing)
if err != nil {
return nil, err
}

share, err := existing.DistKeyShare()
share, err := s.loadFromRoundShare(codeCommitmentHex, fromRound, isResharing)
if err != nil {
return nil, err
}
Expand All @@ -308,6 +303,44 @@
})
}

// loadFromRoundShare returns this node's fromRound DistKeyShare, preferring the
// share sealed at finalization (cache -> sealed store -> live recompute fallback).
// Dealing from the sealed share keeps the dealt share on the same polynomial as the
// on-chain coeffs; a live recompute can land on a different one (kyber interpolates
// the QUAL at call time), which makes every verifier complain.
func (s *DKGServer) loadFromRoundShare(
codeCommitmentHex string,
fromRound uint32,
isResharing bool,
) (*dkg.DistKeyShare, error) {
if share, ok := s.DistKeyShareCache.Get(fromRound); ok {
return share, nil
}

share, err := s.DKGStore.LoadDistKeyShare(codeCommitmentHex, fromRound)
if err == nil {
s.DistKeyShareCache.Set(fromRound, share)
log.WithField("from_round", fromRound).Info("loaded from-round share from the sealed store")

return share, nil
}

// Fallback for a wiped disk or a pre-fix round: recompute from the live fromRound
// instance. Only this path still needs the fromRound DKG instance, and the recomputed
// share may diverge from the on-chain coeffs if the QUAL changed since finalization.
log.WithFields(log.Fields{
"from_round": fromRound,
"error": err,
}).Warn("sealed finalize-time share missing; recomputing live share (polynomial divergence risk)")

existing, err := s.getOrRebuildFromRoundDKG(codeCommitmentHex, fromRound, isResharing)
if err != nil {
return nil, err
}

Check warning on line 339 in service/dist_key_gen.go

View check run for this annotation

Jinn Agent / Jinn PR Review

service/dist_key_gen.go#L339

The fallback result from `existing.DistKeyShare()` is not stored in `DistKeyShareCache`. Any subsequent call to `loadFromRoundShare` for the same `fromRound` (e.g. a second `toRound` resharing from the same `fromRound`, or a retry) will miss the cache, miss the sealed store again, and call `DistKeyShare()` a second time on the live instance. If concurrent message processing has since changed the QUAL between calls, the two invocations return shares on different polynomials — exactly the divergence this PR was designed to prevent, but now in the fallback scenario. Cache the result before returning: ```go share, err := existing.DistKeyShare() if err != nil { return nil, err } s.DistKeyShareCache.Set(fromRound, share) return share, nil ```

return existing.DistKeyShare()
}

// getOrRebuildFromRoundDKG returns the DKG instance that produced the fromRound
// share, rebuilding it from persisted state on a cache miss. isResharing tells
// whether fromRound itself was a resharing round: an initial round lives in
Expand Down Expand Up @@ -386,12 +419,7 @@
return nil, err
}

existing, err := s.getOrRebuildFromRoundDKG(codeCommitmentHex, fromRound, isResharing)
if err != nil {
return nil, err
}

share, err := existing.DistKeyShare()
share, err := s.loadFromRoundShare(codeCommitmentHex, fromRound, isResharing)
if err != nil {
return nil, err
}
Expand Down
87 changes: 87 additions & 0 deletions service/dist_key_gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"go.dedis.ch/kyber/v4"
"go.dedis.ch/kyber/v4/group/edwards25519"
"go.dedis.ch/kyber/v4/share"
dkg "go.dedis.ch/kyber/v4/share/dkg/pedersen"

"github.com/piplabs/story-kernel/store"
Expand Down Expand Up @@ -195,6 +196,7 @@ func TestGetResharingPrevDKG_FirstReshareAfterInitialDKG_RebuildPath(t *testing.
InitDKGCache: store.NewDKGCache(),
ResharingPrevCache: store.NewResharingDKGCache(),
ResharingNextCache: store.NewDKGCache(),
DistKeyShareCache: store.NewDistKeyShareCache(),
}
if tc.warmCache {
// The initial round lives in InitDKGCache (never in
Expand Down Expand Up @@ -270,9 +272,12 @@ func TestGetResharingPrevDKG_FirstReshareAfterInitialDKG_BuildPath(t *testing.T)
InitDKGCache: store.NewDKGCache(),
ResharingPrevCache: store.NewResharingDKGCache(),
ResharingNextCache: store.NewDKGCache(),
DistKeyShareCache: store.NewDistKeyShareCache(),
}
s.InitDKGCache.Set(fromRound, c.gens[self])

// No sealed fromRound share exists here, so loadFromRoundShare must fall back to
// recomputing the share from the live fromRound instance and still build a dealer.
dkgInst, err := s.GetResharingPrevDKG(cc, toRound, threshold, nextPubs, latest)
require.NoError(t, err)
require.NotNil(t, dkgInst)
Expand All @@ -287,6 +292,88 @@ func TestGetResharingPrevDKG_FirstReshareAfterInitialDKG_BuildPath(t *testing.T)
require.True(t, hasNext, "build path must persist the toRound state")
}

// TestGetResharingPrevDKG_PrefersSealedFinalizeShare is the regression test for
// issue #86: the resharing dealer must deal from the share sealed at fromRound's
// finalization, not a share recomputed live. kyber's DistKeyShare() interpolates
// the first oldT QUAL members at call time, so a live recomputation on a QUAL that
// filled up after finalization yields a share on a different polynomial than the
// on-chain coeffs, and every verifier complains. We seal a KNOWN share whose secret
// differs from the live one and assert the dealer's polynomial secret (coeff[0])
// equals the sealed value, proving the sealed share wins.
func TestGetResharingPrevDKG_PrefersSealedFinalizeShare(t *testing.T) {
suite := edwards25519.NewBlakeSHA256Ed25519()

const (
cc = "cc-sealed-share"
fromRound = uint32(3)
toRound = uint32(4)
threshold = 2
)

c := runCertifiedInitialDKG(t, 3, threshold)
self := 0

st := newInitialReshareStore(t, c, self, cc, fromRound, threshold)

liveShare, err := c.gens[self].DistKeyShare()
require.NoError(t, err)
coeffsBz, err := MarshalPoints(liveShare.Commitments())
require.NoError(t, err)

// Seal a share for fromRound whose secret differs from the live one. Reusing the
// live Commits keeps the blob well-formed; only Share.V drives the dealer secret.
knownV := suite.Scalar().SetInt64(987654321)
require.False(t, knownV.Equal(liveShare.PriShare().V), "sanity: known secret must differ from live")
sealed := &dkg.DistKeyShare{
Commits: liveShare.Commitments(),
Share: &share.PriShare{I: liveShare.PriShare().I, V: knownV},
}
require.NoError(t, st.SealAndStoreDistKeyShare(sealed, cc, fromRound))

regs := make([]*pb.DKGRegistration, len(c.pubs))
for i, p := range c.pubs {
bz, err := p.MarshalBinary()
require.NoError(t, err)
regs[i] = &pb.DKGRegistration{Index: uint32(i + 1), Round: fromRound, DkgPubKey: bz}
}

point := func(seed int64) kyber.Point {
return suite.Point().Mul(suite.Scalar().SetInt64(seed), nil)
}
nextPubs := []kyber.Point{point(61), point(62), point(63)}

latest := &pb.DKGNetwork{Round: fromRound, Threshold: threshold, IsResharing: false, PublicCoeffs: coeffsBz}

s := &DKGServer{
QueryClient: &upgradeStubQC{latest: latest, regs: regs},
Suite: suite,
DKGStore: st,
InitDKGCache: store.NewDKGCache(),
ResharingPrevCache: store.NewResharingDKGCache(),
ResharingNextCache: store.NewDKGCache(),
DistKeyShareCache: store.NewDistKeyShareCache(),
}
s.InitDKGCache.Set(fromRound, c.gens[self])

dkgInst, err := s.GetResharingPrevDKG(cc, toRound, threshold, nextPubs, latest)
require.NoError(t, err)
require.NotNil(t, dkgInst)

// The dealer polynomial's free coefficient is the secret it deals; kyber sets it
// to Config.Share.Share.V. It must equal the SEALED secret, not the live one.
polyCoeffs, err := extractDealerPolyCoeffs(dkgInst, suite)
require.NoError(t, err)
require.NotEmpty(t, polyCoeffs)

knownVBz, err := knownV.MarshalBinary()
require.NoError(t, err)
require.Equal(t, knownVBz, polyCoeffs[0], "dealer must deal from the sealed finalize-time secret")

liveVBz, err := liveShare.PriShare().V.MarshalBinary()
require.NoError(t, err)
require.NotEqual(t, liveVBz, polyCoeffs[0], "dealer must NOT deal from a live-recomputed secret")
}

// TestGetOrRebuildFromRoundDKG_ResharingRouting pins the isResharing=true
// branch: a resharing fromRound on a cold cache must be recovered through
// rebuildResharingNextDKG and come back as a receiver (no deals), never
Expand Down
44 changes: 24 additions & 20 deletions service/dkg_finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,30 @@ func (s *DKGServer) FinalizeDKG(_ context.Context, req *pb.FinalizeDKGRequest) (
}
}

// Calculate participants root from the post-invalidation participant set. The chain
// flips the round to the finalization stage, invalidates dealers that submitted no deal,
// and emits BeginDKGFinalization in the same block, so we must wait until the light
// client has observed that block before reading registrations. Otherwise a lagging
// trusted height may still report an invalidated dealer as VERIFIED, producing a
// participants root that disagrees with the set the chain agreed on.
//
// Snapshotting the share only after this wait also lets more of the response feed drain,
// so DistKeyShare() interpolates over a fuller QUAL; that same share is sealed and later
// used for dealing (loadFromRoundShare), keeping deals and on-chain coeffs consistent.
registrations, err := s.waitForFinalizationRegistrations(codeCommitmentHex, req.GetRound())
if err != nil {
log.Errorf("failed to get finalization DKG registrations: %v", err)

return nil, status.Errorf(codes.Internal, "failed to get finalization DKG registrations")
}

participantsRoot, err := calculateParticipantsRoot(registrations)
if err != nil {
log.Errorf("failed to calculate participants root: %v", err)

return nil, status.Errorf(codes.Internal, "failed to calculate participants root")
}

// Enable the timeout so DealCertified tolerates up to n-t absent verifier responses,
// restoring the configured threshold's fault tolerance; without it a single absent
// verifier fails the whole round. SetTimeout propagates to every verifier's aggregator.
Expand Down Expand Up @@ -135,26 +159,6 @@ func (s *DKGServer) FinalizeDKG(_ context.Context, req *pb.FinalizeDKGRequest) (
return nil, status.Errorf(codes.Internal, "failed to marshal public coeffs")
}

// Calculate participants root from the post-invalidation participant set. The chain
// flips the round to the finalization stage, invalidates dealers that submitted no deal,
// and emits BeginDKGFinalization in the same block, so we must wait until the light
// client has observed that block before reading registrations. Otherwise a lagging
// trusted height may still report an invalidated dealer as VERIFIED, producing a
// participants root that disagrees with the set the chain agreed on.
registrations, err := s.waitForFinalizationRegistrations(codeCommitmentHex, req.GetRound())
if err != nil {
log.Errorf("failed to get finalization DKG registrations: %v", err)

return nil, status.Errorf(codes.Internal, "failed to get finalization DKG registrations")
}

participantsRoot, err := calculateParticipantsRoot(registrations)
if err != nil {
log.Errorf("failed to calculate participants root: %v", err)

return nil, status.Errorf(codes.Internal, "failed to calculate participants root")
}

// Hash response message
respHash, err := hashFinalizeDKGResponse(req.GetCodeCommitment(), req.GetRound(), participantsRoot, globalPubKey, publicCoeffsBz, pubKeyShare)
if err != nil {
Expand Down
Loading