Files
zeekayandHanzo Dev 9591affbc7 Resharing changed the key, and had never been run
Every round in this package sat at zero coverage: the tests built a session and
asserted it was non-nil, so no share had ever been produced and the key check
inside round 3 had never executed. Run for the first time it refused, and each
thing it refused for was a separate defect.

The resharing polynomial's constant term was the party's bare share xⱼ, so the
new shares summed to Σxⱼ instead of the secret Σλⱼxⱼ — a different key. It is
now the share weighted by its Lagrange coefficient over the old committee.

The round-1 broadcast carried commitments as curve.Point, an interface CBOR has
no concrete type to decode into, so it failed on every receiver. Points and
scalars travel as bytes now, as keygen's messages already do.

The broadcast declares round 1, so the handler routed it to round1, whose
StoreBroadcastMessage discarded it with a note that round 2 would store it —
and round 2 declared no broadcast. The maps live on round 1, where the message
lands; round 2 reads them through the embed.

A party's own commitments were never in that map, since a broadcast is not
delivered back to its sender, while round 3 added the party's own share
separately — public shares one contribution short of private ones. Seeded.

Round 2 sent a share to itself. Whether that arrives depends on the transport,
so the party's contribution counted once or twice. It is stored directly now
and never sent, and round 3 sums the map once with no special case.

The test network delivered self-addressed messages; production refuses them
(Message.IsFor is false when From is the recipient). It refuses them now, which
is what makes the last fix testable: send-to-self passed under the old network
and fails under this one.

The test runs the reshare, checks every new share against its own public share,
then signs with the new shares and verifies under the ORIGINAL key. Each fix,
reverted alone, fails it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-03 13:34:27 -07:00

165 lines
3.9 KiB
Go

package reshare
import (
"errors"
"github.com/luxfi/threshold/internal/round"
"github.com/luxfi/threshold/pkg/hash"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/lss/config"
)
// round3 finalizes the reshare protocol
type round3 struct {
*round2
}
// Number implements round.Round
func (r *round3) Number() round.Number {
return 3
}
// BroadcastContent implements round.BroadcastRound
func (r *round3) BroadcastContent() round.BroadcastContent {
return nil // No broadcast in round 3
}
// MessageContent implements round.Round
func (r *round3) MessageContent() round.Content {
return nil // No messages in round 3
}
// VerifyMessage implements round.Round
func (r *round3) VerifyMessage(_ round.Message) error {
return nil // No messages to verify
}
// StoreMessage implements round.Round
func (r *round3) StoreMessage(_ round.Message) error {
return nil // No messages to store
}
// Finalize implements round.Round
func (r *round3) Finalize(out chan<- *round.Message) (round.Session, error) {
// Compute our new share
var newShare curve.Scalar
if r.inNewGroup {
// Sum shares from old parties
newShare = r.Group().NewScalar()
for from, share := range r.shares {
// Only include shares from old parties
isOldParty := false
for _, id := range r.oldConfig.PartyIDs() {
if id == from {
isOldParty = true
break
}
}
if isOldParty {
newShare = newShare.Add(share)
}
}
// Our own contribution is already in r.shares: round 2 stores it there
// instead of sending it to ourselves, so the sum above is every old
// party's evaluation at our point, ourselves included, exactly once.
} else {
// We're leaving the group, no new share
return nil, errors.New("party not in new group")
}
// Build new public shares map
publicShares := make(map[party.ID]*config.Public, len(r.newParticipants))
for _, j := range r.newParticipants {
publicPoint := r.Group().NewPoint()
// Sum commitments from old parties
for from, commitments := range r.commitments {
// Only include commitments from old parties
isOldParty := false
for _, id := range r.oldConfig.PartyIDs() {
if id == from {
isOldParty = true
break
}
}
if isOldParty {
if b, ok := commitments[j]; ok {
c := r.Group().NewPoint()
if err := c.UnmarshalBinary(b); err != nil {
return nil, err
}
publicPoint = publicPoint.Add(c)
}
}
}
publicShares[j] = &config.Public{
ECDSA: publicPoint,
}
}
// Compute combined chain key
chainKeyData := make([][]byte, 0, len(r.newParticipants))
for _, id := range r.newParticipants {
if chainKey, ok := r.chainKeys[id]; ok {
chainKeyData = append(chainKeyData, chainKey[:])
}
}
// Hash all chain keys together
h := hash.New()
for _, data := range chainKeyData {
_ = h.WriteAny(data)
}
finalChainKey := h.Sum()
// Create final RID
ridHash := hash.New()
_ = ridHash.WriteAny(r.Hash())
_ = ridHash.WriteAny(finalChainKey)
finalRID := ridHash.Sum()
// Create new config with updated generation
cfg := &config.Config{
ID: r.SelfID(),
Group: r.Group(),
Threshold: r.newThreshold,
Generation: r.oldConfig.Generation + 1,
ECDSA: newShare,
Public: publicShares,
ChainKey: finalChainKey,
RID: finalRID,
}
// Validate the config
if err := cfg.Validate(); err != nil {
return nil, err
}
// Verify public key is preserved (should match old public key)
newPublicKey, err := cfg.PublicPoint()
if err != nil {
return nil, err
}
oldPublicKey, err := r.oldConfig.PublicPoint()
if err != nil {
return nil, err
}
if !newPublicKey.Equal(oldPublicKey) {
return nil, errors.New("public key changed during reshare")
}
return r.ResultRound(cfg), nil
}
// StoreBroadcastMessage implements round.BroadcastRound
func (r *round3) StoreBroadcastMessage(_ round.Message) error {
return nil // No broadcast messages in round 3
}