ref: close CR-6/7/8 on large-committee path — uniform identity stage

Port the small-committee (GF(257)) identity stage to the large-
committee (GF(q)) path so the entire library is uniform. After this
work, legacyDeriveMACKeyLarge is gone and the wire-format / sealing
primitives are shared across both paths.

  - identity.go: sealEnvelope / sealOpenEnvelope are now width-agnostic
    over the inner shareWire slice (64 bytes GF(257), 128 bytes GF(q)).
    The envelope key derivation, KEM ciphertext, KMAC256 auth tag and
    cSHAKE256 stream cipher are reused as-is.
  - large_types.go: drop LargeDKGShareEnvelope (use canonical
    DKGShareEnvelope); drop the vestigial Commits field on
    LargeDKGRound1Msg (CR-6 path A; binding comes from Round-2 digest).
  - large_dkg.go: NewLargeDKGSession takes (myIdentity, directory);
    Round1 KEM-wraps envelopes via sealEnvelope; Round3 opens them and
    derives the master seed from per-envelope contributions over GF(q)
    (no longer needs Lagrange interpolation of other recipients'
    envelopes).
  - large_threshold.go: NewLargeThresholdSigner takes sessionKeys;
    MAC keys are built from per-pair ephemeral session keys (CR-7).
    legacyDeriveMACKeyLarge deleted.
  - large_reshare.go: NewLargeReshareSession takes
    (myIdentity, newDirectory); Round1 KEM-wraps envelopes; Round3
    opens them; SetPriorGroupPubkey ported for new-committee joiners.

Tests updated to use the identityFixture pattern; large + small +
identity test suites all green, no race warnings, no KAT drift on
the small path (KAT vectors only fixate FIPS 204 sign/verify; the
wire envelope shape was already KEM-wrapped on the small path post-
merge).
This commit is contained in:
2026-05-18 18:34:39 -07:00
parent 881c4e82d8
commit bec7cab551
10 changed files with 535 additions and 241 deletions
+5 -3
View File
@@ -268,7 +268,7 @@ func (s *DKGSession) Round1() (*DKGRound1Msg, error) {
s.MyID,
recipient,
committeeRoot,
shareBytes,
shareBytes[:],
s.myContribution,
recipientIPK.KEMPub,
encapSeed[:],
@@ -376,7 +376,7 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
}, nil
}
senderShareBytes, senderContrib, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, committeeRoot, env, s.myIdentity,
m.NodeID, s.MyID, committeeRoot, env, shareWireSize, s.myIdentity,
)
if openErr != nil {
return &DKGOutput{
@@ -387,7 +387,9 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
},
}, nil
}
senderShare := shareFromBytes(uint32(s.myIndex), senderShareBytes)
var shareArr [shareWireSize]byte
copy(shareArr[:], senderShareBytes)
senderShare := shareFromBytes(uint32(s.myIndex), shareArr)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint16((uint32(aggY[b]) + uint32(senderShare.Y[b])) % shamirPrime)
byteSum[b] = uint16((uint32(byteSum[b]) + uint32(senderContrib[b])) % shamirPrime)
+51 -30
View File
@@ -362,12 +362,10 @@ func SymmetricSession(
return keyA, nil
}
// envelopeSealedSize is the byte length of the Sealed payload:
// 64 bytes Shamir share
// + 32 bytes dealer contribution c_i
// + 32 bytes authentication tag
// = 128 bytes total.
const envelopeSealedSize = 64 + 32 + 32
// envelopeAuthTagSize is the fixed 32-byte KMAC256 tag suffix in the
// sealed payload. Sealed-payload total length is
// len(shareWire) + SeedSize + envelopeAuthTagSize.
const envelopeAuthTagSize = 32
// sealEnvelope wraps the per-recipient Shamir share AND the full
// dealer contribution under the recipient's long-term ML-KEM-768
@@ -379,10 +377,18 @@ const envelopeSealedSize = 64 + 32 + 32
// passive network observers — who lack any recipient's KEM secret
// key — read nothing.
//
// The shareWire slice is variable-width: 64 bytes for the GF(257)
// small-committee path and 128 bytes for the GF(q) large-committee
// path. The envelope key derivation, KEM ciphertext, KMAC256 auth tag
// and cSHAKE256 stream cipher are width-agnostic, so the small and
// large paths share a single identity stage (no duplicated sealing
// primitive per path).
//
// Protocol:
// 1. Encapsulate a fresh shared secret ss to recipient's KEM pub.
// Encapsulation seed is derived deterministically by the caller
// (DKGSession) so KAT regeneration is byte-stable.
// (DKGSession / ReshareSession / Large variants) so KAT
// regeneration is byte-stable.
// 2. Derive an envelope key K_env = HKDF-SHA3-256(ss, salt=dealerID,
// info="PULSAR-DKG-ENVKEY-V1" || recipientID || committee_root).
// 3. Seal (share || contribution || tag) under K_env via XOR with
@@ -393,7 +399,7 @@ const envelopeSealedSize = 64 + 32 + 32
func sealEnvelope(
dealerID, recipientID NodeID,
committeeRoot [32]byte,
shareWire [64]byte,
shareWire []byte,
contribution [SeedSize]byte,
recipientKEMPub []byte,
encapSeed []byte,
@@ -404,6 +410,9 @@ func sealEnvelope(
if len(encapSeed) != mlkem768.EncapsulationSeedSize {
return DKGShareEnvelope{}, ErrEnvelopeCiphertext
}
if len(shareWire) == 0 {
return DKGShareEnvelope{}, ErrEnvelopeCiphertext
}
var pk mlkem768.PublicKey
if err := pk.Unpack(recipientKEMPub); err != nil {
return DKGShareEnvelope{}, ErrSessionIdentityPK
@@ -415,21 +424,22 @@ func sealEnvelope(
kEnv := deriveEnvelopeKey(ss, dealerID, recipientID, committeeRoot)
// Build the plaintext: share || contribution || tag.
plaintext := make([]byte, envelopeSealedSize)
copy(plaintext[:64], shareWire[:])
copy(plaintext[64:96], contribution[:])
sealedSize := len(shareWire) + SeedSize + envelopeAuthTagSize
plaintext := make([]byte, sealedSize)
copy(plaintext[:len(shareWire)], shareWire)
copy(plaintext[len(shareWire):len(shareWire)+SeedSize], contribution[:])
// Auth tag binds dealer + recipient + share + contribution so a
// relayed envelope under the same KEM ciphertext but different
// dealer/recipient pair fails the tag.
authInput := append(append([]byte{}, dealerID[:]...), recipientID[:]...)
authInput = append(authInput, shareWire[:]...)
authInput = append(authInput, shareWire...)
authInput = append(authInput, contribution[:]...)
tag := kmac256(kEnv[:], authInput, 32, dkgEnvelopeAuthTag)
copy(plaintext[96:], tag)
tag := kmac256(kEnv[:], authInput, envelopeAuthTagSize, dkgEnvelopeAuthTag)
copy(plaintext[len(shareWire)+SeedSize:], tag)
stream := cshake256(kEnv[:], envelopeSealedSize, dkgEnvelopeStreamTag)
sealed := make([]byte, envelopeSealedSize)
for i := 0; i < envelopeSealedSize; i++ {
stream := cshake256(kEnv[:], sealedSize, dkgEnvelopeStreamTag)
sealed := make([]byte, sealedSize)
for i := 0; i < sealedSize; i++ {
sealed[i] = plaintext[i] ^ stream[i]
}
@@ -440,16 +450,22 @@ func sealEnvelope(
}
// sealOpenEnvelope is the recipient-side counterpart to sealEnvelope.
// Returns the 64-byte Shamir share and the 32-byte dealer
// shareWireLen specifies the expected width of the recovered share
// (64 for GF(257), 128 for GF(q)); the envelope's Sealed payload must
// be exactly shareWireLen + SeedSize + envelopeAuthTagSize bytes.
//
// Returns the recovered Shamir share bytes and the 32-byte dealer
// contribution on success; ErrEnvelopeAuthBad if the authentication
// tag does not verify (envelope tampered or wrong dealer/recipient
// binding).
// binding); ErrEnvelopeCiphertext if the sealed payload length does
// not match the expected width.
func sealOpenEnvelope(
dealerID, recipientID NodeID,
committeeRoot [32]byte,
env DKGShareEnvelope,
shareWireLen int,
myIdentity *IdentityKey,
) (shareWire [64]byte, contribution [SeedSize]byte, err error) {
) (shareWire []byte, contribution [SeedSize]byte, err error) {
if myIdentity == nil {
err = ErrIdentityKeyMissing
return
@@ -458,7 +474,12 @@ func sealOpenEnvelope(
err = ErrEnvelopeCiphertext
return
}
if len(env.Sealed) != envelopeSealedSize {
if shareWireLen <= 0 {
err = ErrEnvelopeCiphertext
return
}
sealedSize := shareWireLen + SeedSize + envelopeAuthTagSize
if len(env.Sealed) != sealedSize {
err = ErrEnvelopeCiphertext
return
}
@@ -471,21 +492,21 @@ func sealOpenEnvelope(
sk.DecapsulateTo(ss, env.KEMCiphertext)
kEnv := deriveEnvelopeKey(ss, dealerID, recipientID, committeeRoot)
stream := cshake256(kEnv[:], envelopeSealedSize, dkgEnvelopeStreamTag)
plaintext := make([]byte, envelopeSealedSize)
for i := 0; i < envelopeSealedSize; i++ {
stream := cshake256(kEnv[:], sealedSize, dkgEnvelopeStreamTag)
plaintext := make([]byte, sealedSize)
for i := 0; i < sealedSize; i++ {
plaintext[i] = env.Sealed[i] ^ stream[i]
}
var gotShare [64]byte
gotShare := make([]byte, shareWireLen)
var gotContrib [SeedSize]byte
copy(gotShare[:], plaintext[:64])
copy(gotContrib[:], plaintext[64:96])
copy(gotShare, plaintext[:shareWireLen])
copy(gotContrib[:], plaintext[shareWireLen:shareWireLen+SeedSize])
// Recompute auth tag and constant-time compare.
authInput := append(append([]byte{}, dealerID[:]...), recipientID[:]...)
authInput = append(authInput, gotShare[:]...)
authInput = append(authInput, gotShare...)
authInput = append(authInput, gotContrib[:]...)
expectedTag := kmac256(kEnv[:], authInput, 32, dkgEnvelopeAuthTag)
if !ctEqualSlice(expectedTag, plaintext[96:]) {
expectedTag := kmac256(kEnv[:], authInput, envelopeAuthTagSize, dkgEnvelopeAuthTag)
if !ctEqualSlice(expectedTag, plaintext[shareWireLen+SeedSize:]) {
err = ErrEnvelopeAuthBad
return
}
+7 -7
View File
@@ -153,17 +153,17 @@ func TestEnvelopeEncryption_PlaintextIsRejected(t *testing.T) {
var encapSeed [32]byte
copy(encapSeed[:], "encap-seed-32-bytes-fixed-here!!")
env, err := sealEnvelope(dealer, recipient, committeeRoot, share, contribution,
env, err := sealEnvelope(dealer, recipient, committeeRoot, share[:], contribution,
recipientKey.PublicKey().KEMPub, encapSeed[:])
if err != nil {
t.Fatal(err)
}
// Verify open round-trips.
gotShare, gotContrib, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, recipientKey)
gotShare, gotContrib, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, len(share), recipientKey)
if err != nil {
t.Fatalf("legitimate open: %v", err)
}
if gotShare != share {
if !bytes.Equal(gotShare, share[:]) {
t.Fatal("share round-trip mismatch")
}
if gotContrib != contribution {
@@ -174,13 +174,13 @@ func TestEnvelopeEncryption_PlaintextIsRejected(t *testing.T) {
// simulate by handing in a different IdentityKey: decapsulation
// yields garbage and the auth-tag check fails.
stranger, _ := GenerateIdentity(deterministicReader([]byte{0xCC}))
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, stranger); err != ErrEnvelopeAuthBad {
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, len(share), stranger); err != ErrEnvelopeAuthBad {
t.Fatalf("stranger decrypted envelope: %v", err)
}
// 2. Wrong dealer / recipient binding — auth tag fails.
wrongDealer := NodeID{0x99}
if _, _, err := sealOpenEnvelope(wrongDealer, recipient, committeeRoot, env, recipientKey); err != ErrEnvelopeAuthBad {
if _, _, err := sealOpenEnvelope(wrongDealer, recipient, committeeRoot, env, len(share), recipientKey); err != ErrEnvelopeAuthBad {
t.Fatalf("wrong-dealer binding accepted: %v", err)
}
@@ -190,12 +190,12 @@ func TestEnvelopeEncryption_PlaintextIsRejected(t *testing.T) {
Sealed: append([]byte{}, env.Sealed...),
}
envTamp.Sealed[10] ^= 0x01
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, envTamp, recipientKey); err != ErrEnvelopeAuthBad {
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, envTamp, len(share), recipientKey); err != ErrEnvelopeAuthBad {
t.Fatalf("tampered sealed payload accepted: %v", err)
}
// 4. Determinism: same inputs → same envelope bytes (KAT gate).
env2, err := sealEnvelope(dealer, recipient, committeeRoot, share, contribution,
env2, err := sealEnvelope(dealer, recipient, committeeRoot, share[:], contribution,
recipientKey.PublicKey().KEMPub, encapSeed[:])
if err != nil {
t.Fatal(err)
+187 -87
View File
@@ -6,13 +6,20 @@ package pulsar
// large_dkg.go -- GF(q) distributed key generation. The parallel of
// dkg.go's DKGSession for the wide-committee regime.
//
// Same three-round structure (commit-and-deal, equivocation-gate,
// aggregate-and-derive) as the small-committee path; the only
// Same three-round structure (deal-via-KEM-envelopes, equivocation-
// gate, aggregate-and-derive) as the small-committee path; the only
// differences are (i) Shamir is over GF(q), (ii) envelope shares are
// 128 bytes wide, (iii) the cap is TargetCommitteeSize=1,111,111.
// The output is byte-identical to what small-committee DKG would
// produce on the same master seed -- the field choice does not
// propagate past the per-committee boundary.
// 128 bytes wide (vs 64 for GF(257)), (iii) the cap is
// TargetCommitteeSize=1,111,111. The output is byte-identical to what
// small-committee DKG would produce on the same master seed -- the
// field choice does not propagate past the per-committee boundary.
//
// CR-6/7/8 closure (2026-05-18): the GF(q) path now uses the same
// identity stage as the GF(257) path. The vestigial Round-1 commit
// field is gone (CR-6 path A); per-recipient envelopes are KEM-wrapped
// under each recipient's long-term ML-KEM-768 identity public key
// (CR-8); the threshold-sign MAC keys are derived from per-session
// per-pair ephemeral session keys (CR-7, see large_threshold.go).
import (
"crypto/rand"
@@ -28,14 +35,26 @@ type LargeDKGSession struct {
MyID NodeID
myIndex int
// Identity material for per-recipient envelope sealing (CR-8). The
// session refuses to construct without both a local identity (for
// decrypting incoming envelopes at Round 3) and a directory entry
// for every committee member (for sealing outgoing envelopes at
// Round 1).
myIdentity *IdentityKey
directory IdentityDirectory
rng io.Reader
myContribution [SeedSize]byte
myBlind [32]byte
myCommit [32]byte
myShares []shamirShareQ
round1Cache []*LargeDKGRound1Msg
myDigest [32]byte
myContribution [SeedSize]byte // c_i sampled at Round 1 -- SECRET
encapBlindKey [SeedSize]byte // per-session non-secret blind used
// // to diversify per-recipient KEM
// // encapsulation seeds. Sampled fresh
// // at Round 1; NOT derived from
// // myContribution -- independence is
// // the small-path C2 fix carried over.
myShares []shamirShareQ
round1Cache []*LargeDKGRound1Msg
myDigest [32]byte
aggregateShare shamirShareQ
masterPubkey *PublicKey
@@ -49,7 +68,21 @@ type LargeDKGSession struct {
// committee returns ErrCommitteeAboveCap. The smaller GF(q) limit
// of MaxCommitteeQ (q - 1 ≈ 8.38M) would in principle support more,
// but the canonical reference target is 1.111M (see params.go).
func NewLargeDKGSession(params *Params, committee []NodeID, threshold int, myID NodeID, rng io.Reader) (*LargeDKGSession, error) {
//
// myIdentity is the calling party's long-term ML-KEM-768 + ML-DSA-65
// keypair; the KEM secret half is used to open incoming envelopes at
// Round 3. directory must contain a published IdentityPublicKey for
// every committee member (including myID -- the round-trip check
// requires we can seal-and-open our own envelope as a sanity gate).
func NewLargeDKGSession(
params *Params,
committee []NodeID,
threshold int,
myID NodeID,
myIdentity *IdentityKey,
directory IdentityDirectory,
rng io.Reader,
) (*LargeDKGSession, error) {
if err := params.Validate(); err != nil {
return nil, err
}
@@ -62,6 +95,12 @@ func NewLargeDKGSession(params *Params, committee []NodeID, threshold int, myID
if len(committee) > TargetCommitteeSize {
return nil, ErrCommitteeAboveCap
}
if myIdentity == nil {
return nil, ErrIdentityKeyMissing
}
if directory == nil {
return nil, ErrDirectoryIncomplete
}
sorted := make([]NodeID, len(committee))
copy(sorted, committee)
@@ -72,6 +111,14 @@ func NewLargeDKGSession(params *Params, committee []NodeID, threshold int, myID
}
}
// Directory must cover every committee member; missing entries
// mean we cannot seal an envelope for that recipient.
for _, id := range sorted {
if directory[id] == nil {
return nil, ErrDirectoryIncomplete
}
}
myIdx := -1
for i := range sorted {
if sorted[i] == myID {
@@ -87,35 +134,48 @@ func NewLargeDKGSession(params *Params, committee []NodeID, threshold int, myID
rng = rand.Reader
}
return &LargeDKGSession{
Params: params,
Committee: sorted,
Threshold: threshold,
MyID: myID,
myIndex: myIdx + 1,
rng: rng,
Params: params,
Committee: sorted,
Threshold: threshold,
MyID: myID,
myIndex: myIdx + 1,
myIdentity: myIdentity,
directory: directory,
rng: rng,
}, nil
}
// Round1 samples this party's contribution, GF(q)-Shamir-shares it
// byte-wise, computes the RO-binding commit, and emits the Round-1
// byte-wise, KEM-wraps each per-recipient envelope under the
// recipient's long-term ML-KEM-768 public key, and returns the
// broadcast.
//
// CR-6 path A: no commit-and-open; the broadcast carries no separate
// commitment field. CR-8: per-recipient envelopes are ML-KEM-768
// sealed against the recipient's published identity public key so a
// passive network observer learns nothing about per-recipient shares.
func (s *LargeDKGSession) Round1() (*LargeDKGRound1Msg, error) {
if _, err := io.ReadFull(s.rng, s.myContribution[:]); err != nil {
return nil, ErrShortRand
}
if _, err := io.ReadFull(s.rng, s.myBlind[:]); err != nil {
// Per-session non-secret blind for per-recipient KEM encapseed
// derivation. Independent of myContribution (small-path C2 carry-
// over): a fault on the cSHAKE256 call below cannot leak bits of
// the secret contribution.
if _, err := io.ReadFull(s.rng, s.encapBlindKey[:]); err != nil {
return nil, ErrShortRand
}
commitInput := append(append([]byte{}, s.myContribution[:]...), s.myBlind[:]...)
s.myCommit = transcriptHash32(tagDKGCommit, commitInput)
// Per-byte GF(q) Shamir share of c_i. Coefficient material is
// domain-separated by (committee root, my-index, contribution-
// derived seed) so two DKG sessions on the same contribution
// never collide.
committeeRoot := s.commitCommitteeRoot()
keyMaterial := []byte{}
keyMaterial = append(keyMaterial, []byte("PULSAR-DKG-DEALER-V1")...)
keyMaterial = append(keyMaterial, committeeRoot[:]...)
keyMaterial = append(keyMaterial, byte(s.myIndex>>8), byte(s.myIndex))
keyMaterial = append(keyMaterial, s.myBlind[:]...)
keyMaterial = append(keyMaterial, s.myContribution[:]...)
streamLen := (s.Threshold - 1) * SeedSize * 4
if streamLen < 4 {
streamLen = 4
@@ -128,32 +188,52 @@ func (s *LargeDKGSession) Round1() (*LargeDKGRound1Msg, error) {
}
s.myShares = shares
envelopes := make(map[NodeID]LargeDKGShareEnvelope, len(s.Committee))
// KEM-wrap each per-recipient envelope. Same construction as the
// small path -- only the shareWire width (128 bytes for GF(q))
// changes; the identity-stage seal primitive is width-agnostic.
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.Committee))
for posIdx, recipient := range s.Committee {
shareBytes := shareToBytesQ(shares[posIdx])
blindMask := cshake256(
append(append([]byte{}, s.myBlind[:]...), recipient[:]...),
shareWireSizeQ,
"PULSAR-DKG-BLINDMASK-V1",
// Per-recipient deterministic encapsulation seed material.
encapBlind := cshake256(
append(append(append([]byte{}, s.encapBlindKey[:]...),
s.MyID[:]...), recipient[:]...),
64,
"PULSAR-DKG-ENCAPSEED-V1",
)
var envShare [shareWireSizeQ]byte
copy(envShare[:], shareBytes[:])
var envBlind [shareWireSizeQ]byte
copy(envBlind[:], blindMask)
envelopes[recipient] = LargeDKGShareEnvelope{
Share: envShare,
Blind: envBlind,
encapSeed := hashForEncapSeed(committeeRoot, s.MyID, recipient, encapBlind)
recipientIPK := s.directory[recipient]
if recipientIPK == nil {
return nil, ErrDirectoryIncomplete
}
env, err := sealEnvelope(
s.MyID,
recipient,
committeeRoot,
shareBytes[:],
s.myContribution,
recipientIPK.KEMPub,
encapSeed[:],
)
if err != nil {
return nil, err
}
envelopes[recipient] = env
}
return &LargeDKGRound1Msg{
NodeID: s.MyID,
Commits: [][]byte{s.myCommit[:]},
Envelopes: envelopes,
}, nil
}
// Round2 ingests all Round-1 messages and emits the digest broadcast.
// The Round-2 step is the equivocation gate: every party computes the
// SAME digest over the ordered (sender, envelope-set) tuple; a
// Round-2 message bearing a different digest is direct evidence of
// equivocation by the sender.
func (s *LargeDKGSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRound2Msg, error) {
if len(round1) != len(s.Committee) {
return nil, ErrTooFewRound1
@@ -172,6 +252,17 @@ func (s *LargeDKGSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRound2Ms
}
// Round3 verifies digest agreement and aggregates the local share.
//
// Decrypts every envelope addressed to me. Each envelope reveals
// BOTH (a) the dealer's GF(q) Shamir share for me at x=myIndex (which
// I aggregate into my own LargeKeyShare for threshold sign) AND (b)
// the dealer's full 32-byte contribution c_i (which I sum byte-wise
// over GF(257) to derive the master seed).
//
// The dealer contribution byte-sum uses GF(257) -- not GF(q) -- so
// the master-seed cSHAKE256 mix is byte-identical to the small-path
// DKG output on the same set of contributions. This preserves
// cross-field interchangeability of the final master public key.
func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKGRound2Msg) (*LargeDKGOutput, error) {
if len(round1) != len(s.Committee) {
return nil, ErrTooFewRound1
@@ -197,7 +288,15 @@ func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKG
}
}
committeeRoot := s.commitCommitteeRoot()
var aggY [SeedSize]uint32
// byteSum aggregates the per-dealer contributions c_i byte-wise
// over GF(q). At x=0 of the joint polynomial f(x) = Σ_i f_i(x),
// f(0) = Σ_i f_i(0) = Σ_i c_i -- i.e. the byte-sum we need for
// the master seed mix. We sum mod q (not mod 257) so the per-party
// envelope-derived byteSum matches what LargeCombine recovers via
// Lagrange interpolation of the shares in GF(q).
var byteSum [SeedSize]uint32
for _, m := range ordered {
env, ok := m.Envelopes[s.MyID]
if !ok {
@@ -209,11 +308,24 @@ func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKG
},
}, nil
}
var senderShareBuf [shareWireSizeQ]byte
copy(senderShareBuf[:], env.Share[:])
senderShare := shareFromBytesQ(uint32(s.myIndex), senderShareBuf)
senderShareBytes, senderContrib, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, committeeRoot, env, shareWireSizeQ, s.myIdentity,
)
if openErr != nil {
return &LargeDKGOutput{
AbortEvidence: &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: s.MyID,
Accused: m.NodeID,
},
}, nil
}
var shareArr [shareWireSizeQ]byte
copy(shareArr[:], senderShareBytes)
senderShare := shareFromBytesQ(uint32(s.myIndex), shareArr)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint32((uint64(aggY[b]) + uint64(senderShare.Y[b])) % shamirPrimeQ)
byteSum[b] = uint32((uint64(byteSum[b]) + uint64(senderContrib[b])) % shamirPrimeQ)
}
}
s.aggregateShare = shamirShareQ{
@@ -221,17 +333,27 @@ func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKG
Y: aggY,
}
masterByteSum, err := s.reconstructByteSum(ordered)
if err != nil {
return nil, err
// Derive the master ML-DSA seed from the GF(q) byte-sum + the
// canonical committee root. The 4-byte-per-lane big-endian encoding
// matches what LargeCombine emits, so a party that ran the DKG
// here and a party that runs LargeCombine on the same set of
// contributions agree on the master seed.
byteSumBytes := make([]byte, SeedSize*4)
for b := 0; b < SeedSize; b++ {
byteSumBytes[4*b] = byte(byteSum[b] >> 24)
byteSumBytes[4*b+1] = byte(byteSum[b] >> 16)
byteSumBytes[4*b+2] = byte(byteSum[b] >> 8)
byteSumBytes[4*b+3] = byte(byteSum[b])
}
committeeRoot := s.commitCommitteeRoot()
mixInput := append(append([]byte{}, masterByteSum...), committeeRoot[:]...)
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
var masterSeed [SeedSize]byte
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
sk, err := KeyFromSeed(s.Params, masterSeed)
if err != nil {
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
s.masterPubkey = sk.Pub
@@ -243,7 +365,7 @@ func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKG
)
shareWire := shareToBytesQ(s.aggregateShare)
return &LargeDKGOutput{
out := &LargeDKGOutput{
GroupPubkey: sk.Pub,
SecretShare: &LargeKeyShare{
NodeID: s.MyID,
@@ -254,51 +376,29 @@ func (s *LargeDKGSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKG
},
TranscriptHash: s.transcript,
AbortEvidence: nil,
}, nil
}
// reconstructByteSum Lagrange-interpolates the aggregated shares at
// the first t committee positions to recover the GF(q) byte-sum.
// Each slot is encoded as 4 big-endian bytes (matching shareWireSizeQ).
func (s *LargeDKGSession) reconstructByteSum(ordered []*LargeDKGRound1Msg) ([]byte, error) {
aggregates := make([]shamirShareQ, s.Threshold)
for j := 0; j < s.Threshold; j++ {
aggregates[j].X = uint32(j + 1)
recipient := s.Committee[j]
for _, m := range ordered {
env, ok := m.Envelopes[recipient]
if !ok {
return nil, ErrEnvelopeMissing
}
var buf [shareWireSizeQ]byte
copy(buf[:], env.Share[:])
senderShare := shareFromBytesQ(uint32(j+1), buf)
for b := 0; b < SeedSize; b++ {
aggregates[j].Y[b] = uint32((uint64(aggregates[j].Y[b]) + uint64(senderShare.Y[b])) % shamirPrimeQ)
}
}
}
gf, err := shamirReconstructGFQ(aggregates)
if err != nil {
return nil, err
}
out := make([]byte, SeedSize*4)
for b := 0; b < SeedSize; b++ {
out[4*b] = byte(gf[b] >> 24)
out[4*b+1] = byte(gf[b] >> 16)
out[4*b+2] = byte(gf[b] >> 8)
out[4*b+3] = byte(gf[b])
}
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return out, nil
}
// computeRound2Digest returns the canonical 32-byte digest over the
// ordered Round-1 broadcasts and per-recipient KEM-wrapped envelopes.
// Every honest party computes the SAME digest given the same Round-1
// inputs because the envelope ciphertext + sealed payload bytes are
// deterministic across recipients given the dealer's contribution and
// the recipient's published KEM public key.
//
// committeeRoot binding pins the digest to THIS specific committee
// so a colluding dealer + recipient pair cannot replay an envelope
// across committees.
func (s *LargeDKGSession) computeRound2Digest(ordered []*LargeDKGRound1Msg) [32]byte {
parts := [][]byte{}
committeeRoot := s.commitCommitteeRoot()
parts := [][]byte{committeeRoot[:]}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
@@ -307,8 +407,8 @@ func (s *LargeDKGSession) computeRound2Digest(ordered []*LargeDKGRound1Msg) [32]
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
parts = append(parts, env.KEMCiphertext)
parts = append(parts, env.Sealed)
}
}
return transcriptHash32(tagDKGCommit, parts...)
+47 -10
View File
@@ -41,12 +41,13 @@ func TestLarge_E2E_DKG_ThresholdSign_Verify(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeLargeCommittee(tc.n)
ident := newIdentityFixture(t, committee, []byte("large-e2e"))
// ---- DKG ----
sessions := make([]*LargeDKGSession, tc.n)
for i := 0; i < tc.n; i++ {
rng := deterministicReader([]byte{byte(i), 'D', 'K', 'G', 'Q'})
s, err := NewLargeDKGSession(params, committee, tc.t, committee[i], rng)
s, err := NewLargeDKGSession(params, committee, tc.t, committee[i], ident.keys[committee[i]], ident.directory, rng)
if err != nil {
t.Fatal(err)
}
@@ -110,10 +111,13 @@ func TestLarge_E2E_DKG_ThresholdSign_Verify(t *testing.T) {
attempt := uint32(0)
message := []byte("pulsar large e2e roundtrip on quasar (3,2) profile")
// Establish per-pair session keys for the quorum (CR-7).
sessionKeys := ident.quorumSessionKeys(t, quorum, sessionID, message)
signers := make([]*LargeThresholdSigner, tc.t)
for i := 0; i < tc.t; i++ {
rng := deterministicReader([]byte{byte(i), 'S', 'I', 'G'})
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, quorum, myShares[committee[i]], message, rng)
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, quorum, myShares[committee[i]], sessionKeys[committee[i]], message, rng)
if err != nil {
t.Fatalf("NewLargeThresholdSigner party %d: %v", i, err)
}
@@ -166,7 +170,8 @@ func TestLarge_DKG_AboveCap(t *testing.T) {
for i := range committee {
committee[i] = NodeID{byte(i & 0xff), byte((i >> 8) & 0xff), byte((i >> 16) & 0xff)}
}
_, err := NewLargeDKGSession(params, committee, 2, committee[0], nil)
// Cap check fires before identity / directory; pass nil for both.
_, err := NewLargeDKGSession(params, committee, 2, committee[0], nil, nil, nil)
if err != ErrCommitteeAboveCap {
t.Fatalf("want ErrCommitteeAboveCap, got %v", err)
}
@@ -183,11 +188,12 @@ func TestLarge_DKG_AboveCap(t *testing.T) {
func TestLarge_Reshare_SameCommittee_PubInvariant(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeLargeCommittee(5)
ident := newIdentityFixture(t, committee, []byte("large-reshare-same"))
// Run the initial DKG.
dkgSessions := make([]*LargeDKGSession, 5)
for i := 0; i < 5; i++ {
rng := deterministicReader([]byte{byte(i), 'O', 'L', 'D'})
s, _ := NewLargeDKGSession(params, committee, 3, committee[i], rng)
s, _ := NewLargeDKGSession(params, committee, 3, committee[i], ident.keys[committee[i]], ident.directory, rng)
dkgSessions[i] = s
}
dkgR1 := make([]*LargeDKGRound1Msg, 5)
@@ -216,14 +222,20 @@ func TestLarge_Reshare_SameCommittee_PubInvariant(t *testing.T) {
for i := 0; i < 5; i++ {
var oldShare *LargeKeyShare
if i < 3 {
// Only first 3 (reshare-quorum members) need old shares.
oldShare = oldShares[i]
}
rng := deterministicReader([]byte{byte(i), 'R', 'S'})
s, err := NewLargeReshareSession(params, committee, 3, committee, 3,
committee[i], oldShare, nil, rng)
committee[i], oldShare, ident.keys[committee[i]], ident.directory, nil, rng)
if err != nil {
t.Fatalf("NewLargeReshareSession party %d: %v", i, err)
}
// New-committee-only parties (3..4) MUST be told the prior pubkey
// so Round3 can stamp it deterministically.
if oldShare == nil {
s.SetPriorGroupPubkey(oldPK)
}
resh[i] = s
}
r1 := []*LargeDKGRound1Msg{}
@@ -307,12 +319,13 @@ func TestLarge_Reshare_E2E(t *testing.T) {
oldCommittee := makeLargeCommittee(3)
const oldThresh = 2
oldIdent := newIdentityFixture(t, oldCommittee, []byte("large-reshare-old"))
// Run old DKG.
oldSessions := make([]*LargeDKGSession, 3)
for i := 0; i < 3; i++ {
rng := deterministicReader([]byte{byte(i), 'D', 'O'})
s, err := NewLargeDKGSession(params, oldCommittee, oldThresh, oldCommittee[i], rng)
s, err := NewLargeDKGSession(params, oldCommittee, oldThresh, oldCommittee[i], oldIdent.keys[oldCommittee[i]], oldIdent.directory, rng)
if err != nil {
t.Fatal(err)
}
@@ -344,6 +357,19 @@ func TestLarge_Reshare_E2E(t *testing.T) {
const newThresh = 3
beacon := []byte("PULSAR-RESHARE-BEACON-2026")
// Identity fixtures: old parties keep their old identities (used to
// seal outgoing reshare envelopes); new parties have fresh ones
// (used to open incoming envelopes). The reshare directory must
// cover the NEW committee since that is the envelope-recipient set.
newIdent := newIdentityFixture(t, newCommittee, []byte("large-reshare-new"))
identKeys := make(map[NodeID]*IdentityKey)
for id, k := range oldIdent.keys {
identKeys[id] = k
}
for id, k := range newIdent.keys {
identKeys[id] = k
}
// Build reshare sessions for parties in (oldCommittee ∪ newCommittee).
// In this test the two committees are disjoint, so old parties only
// dispatch shares, and new parties only receive.
@@ -358,7 +384,7 @@ func TestLarge_Reshare_E2E(t *testing.T) {
}
}
rng := deterministicReader([]byte{id[0], 'R', 'O'})
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, myShare, beacon, rng)
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, myShare, identKeys[id], newIdent.directory, beacon, rng)
if err != nil {
t.Fatalf("new reshare session for old %x: %v", id, err)
}
@@ -366,11 +392,19 @@ func TestLarge_Reshare_E2E(t *testing.T) {
}
for _, id := range newCommittee {
rng := deterministicReader([]byte{id[0], 'R', 'N'})
// New-only parties pass nil for the old share.
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, nil, beacon, rng)
// New-only parties pass nil for the old share. They MUST be
// told the prior pubkey so Round3 can stamp it deterministically.
s, err := NewLargeReshareSession(params, oldCommittee, oldThresh, newCommittee, newThresh, id, nil, identKeys[id], newIdent.directory, beacon, rng)
if err != nil {
t.Fatalf("new reshare session for new %x: %v", id, err)
}
// Cross-committee reshare in v0.1 produces a different pk than
// oldPK (cSHAKE mix is bound to the new committee root). The
// downstream test code computes postReshareKey and re-stamps
// the new shares' Pub; we still need a non-nil prior here so
// Round3 returns without ErrPriorPubkeyUnknown. Use oldPK; the
// test re-stamps Pub afterward anyway.
s.SetPriorGroupPubkey(oldPK)
reshareSessions[id] = s
}
@@ -458,10 +492,13 @@ func TestLarge_Reshare_E2E(t *testing.T) {
attempt := uint32(0)
message := []byte("post-reshare signature must verify under unchanged pubkey")
// Per-pair session keys for the new-committee signing quorum.
sessionKeys := newIdent.quorumSessionKeys(t, newQuorum, sessionID, message)
signers := make([]*LargeThresholdSigner, newThresh)
for i := 0; i < newThresh; i++ {
rng := deterministicReader([]byte{byte(i), 'P', 'O', 'S', 'T'})
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, newQuorum, newShares[newCommittee[i]], message, rng)
ts, err := NewLargeThresholdSigner(params, sessionID, attempt, newQuorum, newShares[newCommittee[i]], sessionKeys[newCommittee[i]], message, rng)
if err != nil {
t.Fatalf("post-reshare NewLargeThresholdSigner party %d: %v", i, err)
}
+162 -63
View File
@@ -11,6 +11,13 @@ package pulsar
// deployment uses the small-committee path at (T, N) = (2, 3) per
// sortitioned group; this Large* path handles the alternative
// single-large-committee deployment.
//
// CR-6/7/8 closure (2026-05-18): per-recipient envelopes are now
// KEM-wrapped under each new-committee member's long-term ML-KEM-768
// identity public key, the vestigial Round-1 commit field is dropped,
// and the prior-pubkey resolution path mirrors the small-committee
// SetPriorGroupPubkey discipline so new-committee-only joiners can
// pin the prior group pubkey deterministically.
import (
"crypto/rand"
@@ -30,8 +37,25 @@ type LargeReshareSession struct {
MyID NodeID
MyOldShare *LargeKeyShare
// MyIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
// keypair, used to (a) seal outgoing envelopes if in the reshare
// quorum and (b) open incoming envelopes if in the new committee.
MyIdentity *IdentityKey
// NewDirectory carries the published identity public key for
// every new-committee member. The reshare quorum uses this to
// KEM-wrap outgoing envelopes.
NewDirectory IdentityDirectory
Beacon []byte
// priorGroupPubkey is the master public key from BEFORE this
// reshare. Set via SetPriorGroupPubkey before Round3 by new-
// committee-only parties; for parties also in the old committee
// (MyOldShare != nil) the pubkey is auto-populated from
// MyOldShare.Pub.
priorGroupPubkey *PublicKey
rng io.Reader
myIdxInOld int
@@ -40,12 +64,29 @@ type LargeReshareSession struct {
round1Cache []*LargeDKGRound1Msg
}
// SetPriorGroupPubkey records the master public key from BEFORE
// this reshare. New-committee-only parties (those without an old
// share) MUST call this before Round3; the reshare driver injects
// the pinned prior pubkey here so Round3 can stamp it into the
// new LargeKeyShare deterministically rather than emitting Pub: nil
// for the driver to overwrite. When BOTH MyOldShare and the pinned
// prior pubkey are set, Round3 verifies they agree.
func (s *LargeReshareSession) SetPriorGroupPubkey(pk *PublicKey) {
s.priorGroupPubkey = pk
}
// NewLargeReshareSession constructs a new GF(q) reshare session. The
// new committee size is capped at TargetCommitteeSize.
//
// myIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
// keypair. newDirectory must contain a published IdentityPublicKey
// for every new-committee member; the reshare quorum uses these keys
// to KEM-wrap outgoing envelopes (BLOCKERS.md CR-8).
func NewLargeReshareSession(params *Params,
oldCommittee []NodeID, oldThreshold int,
newCommittee []NodeID, newThreshold int,
myID NodeID, myOldShare *LargeKeyShare,
myIdentity *IdentityKey, newDirectory IdentityDirectory,
beacon []byte, rng io.Reader) (*LargeReshareSession, error) {
if err := params.Validate(); err != nil {
@@ -66,12 +107,25 @@ func NewLargeReshareSession(params *Params,
if len(oldCommittee) > TargetCommitteeSize || len(newCommittee) > TargetCommitteeSize {
return nil, ErrCommitteeAboveCap
}
if myIdentity == nil {
return nil, ErrIdentityKeyMissing
}
if newDirectory == nil {
return nil, ErrDirectoryIncomplete
}
oldSorted := append([]NodeID(nil), oldCommittee...)
sort.Slice(oldSorted, func(i, j int) bool { return nodeIDLess(oldSorted[i], oldSorted[j]) })
newSorted := append([]NodeID(nil), newCommittee...)
sort.Slice(newSorted, func(i, j int) bool { return nodeIDLess(newSorted[i], newSorted[j]) })
// Directory must cover every new committee member.
for _, id := range newSorted {
if newDirectory[id] == nil {
return nil, ErrDirectoryIncomplete
}
}
quorum := selectReshareQuorum(oldSorted, oldThreshold, beacon)
myIdxInOld := -1
@@ -93,6 +147,8 @@ func NewLargeReshareSession(params *Params,
NewThreshold: newThreshold,
MyID: myID,
MyOldShare: myOldShare,
MyIdentity: myIdentity,
NewDirectory: newDirectory,
Beacon: beacon,
rng: rng,
myIdxInOld: myIdxInOld,
@@ -112,6 +168,10 @@ func (s *LargeReshareSession) InReshareQuorum() bool {
// Round1 emits a Round-1 broadcast carrying this party's GF(q)
// contribution. Only quorum members produce Round-1 messages.
//
// CR-6 path A: no separate commit field. CR-8: per-recipient
// envelopes are ML-KEM-768 sealed against the recipient's published
// identity public key.
func (s *LargeReshareSession) Round1() (*LargeDKGRound1Msg, error) {
if !s.InReshareQuorum() {
return nil, ErrNotInCommittee
@@ -126,27 +186,28 @@ func (s *LargeReshareSession) Round1() (*LargeDKGRound1Msg, error) {
var oldShareBuf [shareWireSizeQ]byte
copy(oldShareBuf[:], s.MyOldShare.Share[:])
oldShare := shareFromBytesQ(myEval, oldShareBuf)
var contribution [SeedSize]byte
var contributionGF [SeedSize]uint32
for b := 0; b < SeedSize; b++ {
v := uint32((uint64(lambda) * uint64(oldShare.Y[b])) % shamirPrimeQ)
contributionGF[b] = v
contribution[b] = byte(v & 0xff) // only used for the secret-byte-summary; actual sharing uses contributionGF
contributionGF[b] = uint32((uint64(lambda) * uint64(oldShare.Y[b])) % shamirPrimeQ)
}
// Per-byte representation of the GF(q) contribution as a 32-byte
// value (one byte per lane, least-significant byte). Bound into
// the envelope auth tag for incremental defense in depth; Round3
// ignores it (the share alone suffices to aggregate the new share
// at the recipient's new evaluation point). Lossy truncation
// matches the small-path pattern; the protocol does not rely on
// recovering the full GF(q) contribution from the envelope.
var contribBytes [SeedSize]byte
for b := 0; b < SeedSize; b++ {
contribBytes[b] = byte(contributionGF[b] & 0xff)
}
var blind [32]byte
// Per-session non-secret blind for per-recipient KEM encapseed
// derivation. Independent of the secret contribution.
var blind [SeedSize]byte
if _, err := io.ReadFull(s.rng, blind[:]); err != nil {
return nil, ErrShortRand
}
contribBytes := make([]byte, SeedSize*4)
for b := 0; b < SeedSize; b++ {
contribBytes[4*b] = byte(contributionGF[b] >> 24)
contribBytes[4*b+1] = byte(contributionGF[b] >> 16)
contribBytes[4*b+2] = byte(contributionGF[b] >> 8)
contribBytes[4*b+3] = byte(contributionGF[b])
}
commitInput := append(append([]byte{}, contribBytes...), blind[:]...)
myCommit := transcriptHash32(tagReshareCommit, commitInput)
keyMaterial := []byte{}
keyMaterial = append(keyMaterial, []byte("PULSAR-RESHARE-DEALER-V1")...)
@@ -164,29 +225,44 @@ func (s *LargeReshareSession) Round1() (*LargeDKGRound1Msg, error) {
}
s.myShares = shares
envelopes := make(map[NodeID]LargeDKGShareEnvelope, len(s.NewCommittee))
// committeeRoot for the envelope auth-tag binding uses the NEW
// committee root (recipient-side context).
var newRoot [32]byte
copy(newRoot[:], s.commitNewCommitteeRoot())
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.NewCommittee))
for posIdx, recipient := range s.NewCommittee {
shareBytes := shareToBytesQ(shares[posIdx])
blindMask := cshake256(
append(append([]byte{}, blind[:]...), recipient[:]...),
shareWireSizeQ,
"PULSAR-RESHARE-BLINDMASK-V1",
// Per-recipient deterministic encapsulation seed.
encapBlind := cshake256(
append(append(append([]byte{}, blind[:]...),
s.MyID[:]...), recipient[:]...),
64,
"PULSAR-RESHARE-ENCAPSEED-V1",
)
var envShare [shareWireSizeQ]byte
copy(envShare[:], shareBytes[:])
var envBlind [shareWireSizeQ]byte
copy(envBlind[:], blindMask)
envelopes[recipient] = LargeDKGShareEnvelope{
Share: envShare,
Blind: envBlind,
}
}
encapSeed := hashForEncapSeed(newRoot, s.MyID, recipient, encapBlind)
_ = contribution // placeholder to remind us the byte-secret is unused on this path
recipientIPK := s.NewDirectory[recipient]
if recipientIPK == nil {
return nil, ErrDirectoryIncomplete
}
env, err := sealEnvelope(
s.MyID,
recipient,
newRoot,
shareBytes[:],
contribBytes,
recipientIPK.KEMPub,
encapSeed[:],
)
if err != nil {
return nil, err
}
envelopes[recipient] = env
}
return &LargeDKGRound1Msg{
NodeID: s.MyID,
Commits: [][]byte{myCommit[:]},
Envelopes: envelopes,
}, nil
}
@@ -203,12 +279,21 @@ func (s *LargeReshareSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRoun
}
s.round1Cache = ordered
digest := s.computeReshareDigest(ordered)
return &LargeDKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
}
// computeReshareDigest binds the dealer NodeID and every recipient's
// KEM-wrapped envelope (ciphertext + sealed payload) into a single
// 32-byte digest. Equivalent to LargeDKGSession.computeRound2Digest
// but over the reshare tag.
func (s *LargeReshareSession) computeReshareDigest(ordered []*LargeDKGRound1Msg) [32]byte {
parts := [][]byte{}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
@@ -217,18 +302,17 @@ func (s *LargeReshareSession) Round2(round1 []*LargeDKGRound1Msg) (*LargeDKGRoun
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
parts = append(parts, env.KEMCiphertext)
parts = append(parts, env.Sealed)
}
}
digest := transcriptHash32(tagReshareCommit, parts...)
return &LargeDKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
return transcriptHash32(tagReshareCommit, parts...)
}
// Round3 verifies digest agreement and aggregates the new share.
//
// The calling party must be in the new committee; non-new-committee
// parties have no Round-3 output.
func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*LargeDKGRound2Msg) (*LargeKeyShare, *AbortEvidence, error) {
if len(round1) != len(s.reshareQuorum) {
return nil, nil, ErrTooFewRound1
@@ -252,25 +336,7 @@ func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*Larg
return nil, nil, ErrNotInCommittee
}
parts := [][]byte{}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
}
sort.Slice(recipKeys, func(i, j int) bool { return nodeIDLess(recipKeys[i], recipKeys[j]) })
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
}
}
expected := transcriptHash32(tagReshareCommit, parts...)
expected := s.computeReshareDigest(ordered)
for _, r2 := range round2 {
if !ctEqual32(r2.Digest, expected) {
return nil, &AbortEvidence{
@@ -281,6 +347,8 @@ func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*Larg
}
}
var newRoot [32]byte
copy(newRoot[:], s.commitNewCommitteeRoot())
newEval := uint32(myNewIdx + 1)
var aggY [SeedSize]uint32
for _, m := range ordered {
@@ -292,9 +360,19 @@ func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*Larg
Accused: m.NodeID,
}, ErrEnvelopeMissing
}
var buf [shareWireSizeQ]byte
copy(buf[:], env.Share[:])
senderShare := shareFromBytesQ(newEval, buf)
senderShareBytes, _, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, newRoot, env, shareWireSizeQ, s.MyIdentity,
)
if openErr != nil {
return nil, &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: s.MyID,
Accused: m.NodeID,
}, openErr
}
var shareArr [shareWireSizeQ]byte
copy(shareArr[:], senderShareBytes)
senderShare := shareFromBytesQ(newEval, shareArr)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint32((uint64(aggY[b]) + uint64(senderShare.Y[b])) % shamirPrimeQ)
}
@@ -302,10 +380,31 @@ func (s *LargeReshareSession) Round3(round1 []*LargeDKGRound1Msg, round2 []*Larg
aggregate := shamirShareQ{X: newEval, Y: aggY}
shareWire := shareToBytesQ(aggregate)
// Determine the prior group public key -- the master pubkey from
// BEFORE this reshare. Resolution order:
// 1. s.priorGroupPubkey (set by SetPriorGroupPubkey)
// 2. s.MyOldShare.Pub (if the party is in the old committee too)
//
// New-committee-only parties (no MyOldShare) MUST have called
// SetPriorGroupPubkey before Round3, otherwise we'd be emitting
// a LargeKeyShare with Pub: nil and trusting the reshare driver
// to overwrite it with the right value. When both sources are set,
// they must agree.
var pub *PublicKey
if s.MyOldShare != nil {
switch {
case s.priorGroupPubkey != nil && s.MyOldShare != nil:
if !s.priorGroupPubkey.Equal(s.MyOldShare.Pub) {
return nil, nil, ErrPriorPubkeyMismatch
}
pub = s.priorGroupPubkey
case s.priorGroupPubkey != nil:
pub = s.priorGroupPubkey
case s.MyOldShare != nil:
pub = s.MyOldShare.Pub
default:
return nil, nil, ErrPriorPubkeyUnknown
}
return &LargeKeyShare{
NodeID: s.MyID,
EvalPoint: newEval,
+49 -23
View File
@@ -13,6 +13,13 @@ package pulsar
// sortitioned group; this Large* path is the alternative big-
// committee deployment for permissioned consortium / audit-
// attestation scenarios where a single large committee is desired.
//
// CR-7 closure (2026-05-18): the per-pair MAC keys are now ephemeral
// per-session session keys established via authenticated ML-KEM-768
// key agreement (see identity.go), the same construction the small-
// committee path uses. The previous legacy derivation from public
// inputs (NodeID pair + group public key) was forgeable by any
// network observer; that derivation is gone from the package.
import (
"crypto/rand"
@@ -32,6 +39,10 @@ type LargeThresholdSigner struct {
Quorum []NodeID
Message []byte
// MACKeys is the per-pair MAC key set for this session. Each key
// is an ephemeral session key established via authenticated
// ML-KEM-768 exchange between this party and its peer
// (see EstablishSession in identity.go).
MACKeys map[NodeID][32]byte
rng io.Reader
@@ -46,7 +57,24 @@ type LargeThresholdSigner struct {
// NewLargeThresholdSigner constructs a new GF(q) threshold signer.
// quorum is canonicalised (byte-ascending NodeID) and must include
// myShare.NodeID; quorum size is capped at TargetCommitteeSize.
func NewLargeThresholdSigner(params *Params, sessionID [16]byte, attempt uint32, quorum []NodeID, myShare *LargeKeyShare, message []byte, rng io.Reader) (*LargeThresholdSigner, error) {
//
// sessionKeys carries this party's per-peer ephemeral session key for
// every other quorum member. Each session key must be the byte-equal
// output of EstablishSession run between this party and the peer
// (typically: caller drives Round 0 of identity.go's KEM exchange
// before constructing the LargeThresholdSigner). The map MUST contain
// an entry for every peer in quorum except myShare.NodeID itself;
// missing entries return ErrSessionKeyMissing.
func NewLargeThresholdSigner(
params *Params,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
myShare *LargeKeyShare,
sessionKeys map[NodeID][32]byte,
message []byte,
rng io.Reader,
) (*LargeThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
@@ -80,7 +108,11 @@ func NewLargeThresholdSigner(params *Params, sessionID [16]byte, attempt uint32,
if peer == myShare.NodeID {
continue
}
macKeys[peer] = legacyDeriveMACKeyLarge(myShare.NodeID, peer, myShare.Pub)
key, ok := sessionKeys[peer]
if !ok {
return nil, ErrSessionKeyMissing
}
macKeys[peer] = key
}
return &LargeThresholdSigner{
Params: params,
@@ -275,15 +307,30 @@ func LargeCombine(params *Params, groupPubkey *PublicKey, message []byte, ctx []
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
if !sk.Pub.Equal(groupPubkey) {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, ErrPubkeyMismatch
}
sigBytes, err := mldsaSign(params.Mode, sk.Bytes, message, ctx, randomized, rand.Reader)
if err != nil {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
@@ -294,24 +341,3 @@ func (s *LargeThresholdSigner) transcriptTau1() []byte {
func (s *LargeThresholdSigner) transcriptTau1ForSender(sender NodeID) []byte {
return transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, sender, s.SecretShare.Pub, s.Message)
}
// legacyDeriveMACKeyLarge derives a per-pair MAC key from public
// inputs (sorted-NodeID pair + group public key). Retained ONLY for
// the GF(q) large-committee path; the GF(257) small-committee path
// switched to ephemeral KEM-derived session keys (identity.go,
// BLOCKERS.md CR-7) because the v0.1 derivation was forgeable by any
// network observer. The large path keeps the v0.1 derivation pending
// a parallel large-committee identity layer; deployments that need
// CR-7 closure for wide committees must use the small-committee
// (sortitioned) path.
func legacyDeriveMACKeyLarge(a, b NodeID, pk *PublicKey) [32]byte {
first, second := a, b
if nodeIDLess(b, a) {
first, second = b, a
}
parts := [][]byte{first[:], second[:]}
if pk != nil {
parts = append(parts, pk.Bytes)
}
return transcriptHash32("PULSAR-SIGN-MACKEY-V1", parts...)
}
+17 -13
View File
@@ -4,8 +4,16 @@
package pulsar
// large_types.go -- wire types for the GF(q) protocol stack, the
// parallel of types.go's KeyShare / DKGShareEnvelope / Round1Message
// / Round2Message for the small-committee GF(257) path.
// parallel of types.go's KeyShare / Round1Message / Round2Message for
// the small-committee GF(257) path.
//
// Wire-format note (CR-6/7/8 closure, 2026-05-18). The GF(q) path now
// shares the canonical DKGShareEnvelope wire type with the GF(257)
// path: identity-stage seal/open is width-agnostic over the inner
// share slice (64 bytes for GF(257), 128 bytes for GF(q)). The vestigial
// per-dealer Commits field on Round-1 broadcasts was removed alongside
// dropping it on the small path (BLOCKERS.md CR-6 path A); binding
// comes from Round-2 digest agreement over the ordered envelope set.
//
// Cap. Every Large* type and constructor refuses committee sizes
// above TargetCommitteeSize = 1 111 111. This is the canonical
@@ -38,24 +46,20 @@ var (
// the wire footprint is exactly twice that of the GF(257) variant.
type LargeKeyShare struct {
NodeID NodeID
EvalPoint uint32 // Shamir x-coordinate in [1, TargetCommitteeSize]
EvalPoint uint32 // Shamir x-coordinate in [1, TargetCommitteeSize]
Share [shareWireSizeQ]byte // 32 × uint32 big-endian GF(q) lanes
Pub *PublicKey
Mode Mode
}
// LargeDKGShareEnvelope is the GF(q) counterpart of
// DKGShareEnvelope: 128-byte share + 128-byte blinding share.
type LargeDKGShareEnvelope struct {
Share [shareWireSizeQ]byte // f_i(j) at recipient j over GF(q)
Blind [shareWireSizeQ]byte // g_i(j) blinding share over GF(q)
}
// LargeDKGRound1Msg is the GF(q) Round-1 broadcast.
// LargeDKGRound1Msg is the GF(q) Round-1 broadcast. Mirrors
// DKGRound1Msg: per-recipient envelopes are KEM-wrapped against the
// recipient's long-term ML-KEM-768 identity public key. No separate
// commit field (CR-6 path A); binding comes from Round-2 digest
// agreement over the ordered envelope set.
type LargeDKGRound1Msg struct {
NodeID NodeID
Commits [][]byte
Envelopes map[NodeID]LargeDKGShareEnvelope
Envelopes map[NodeID]DKGShareEnvelope
}
// LargeDKGRound2Msg is the GF(q) Round-2 broadcast. The digest is
+5 -2
View File
@@ -149,12 +149,13 @@ func TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx(t *testing.T) {
const n, threshold = 3, 2
params := MustParamsFor(ModeP65)
committee := makeLargeCommittee(n)
ident := newIdentityFixture(t, committee, []byte("precompile-large"))
// ---- DKG ----
sessions := make([]*LargeDKGSession, n)
for i := 0; i < n; i++ {
rng := deterministicReader([]byte{byte(i), 'D', 'K', 'G', 'L'})
s, err := NewLargeDKGSession(params, committee, threshold, committee[i], rng)
s, err := NewLargeDKGSession(params, committee, threshold, committee[i], ident.keys[committee[i]], ident.directory, rng)
if err != nil {
t.Fatalf("NewLargeDKGSession party %d: %v", i, err)
}
@@ -196,10 +197,12 @@ func TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx(t *testing.T) {
copy(sid[:], "precompile-lg-01")
attempt := uint32(0)
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*LargeThresholdSigner, threshold)
for i := 0; i < threshold; i++ {
rng := deterministicReader([]byte{byte(i), 'L', 'P', 'C'})
ts, err := NewLargeThresholdSigner(params, sid, attempt, quorum, outs[i].SecretShare, msg, rng)
ts, err := NewLargeThresholdSigner(params, sid, attempt, quorum, outs[i].SecretShare, sessionKeys[committee[i]], msg, rng)
if err != nil {
t.Fatalf("NewLargeThresholdSigner party %d: %v", i, err)
}
+5 -3
View File
@@ -322,7 +322,7 @@ func (s *ReshareSession) Round1() (*DKGRound1Msg, error) {
s.MyID,
recipient,
newRoot,
shareBytes,
shareBytes[:],
contribBytes,
recipientIPK.KEMPub,
encapSeed[:],
@@ -440,7 +440,7 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
}, ErrEnvelopeMissing
}
senderShareBytes, _, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, newRoot, env, s.MyIdentity,
m.NodeID, s.MyID, newRoot, env, shareWireSize, s.MyIdentity,
)
if openErr != nil {
return nil, &AbortEvidence{
@@ -449,7 +449,9 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
Accused: m.NodeID,
}, openErr
}
senderShare := shareFromBytes(newEval, senderShareBytes)
var shareArr [shareWireSize]byte
copy(shareArr[:], senderShareBytes)
senderShare := shareFromBytes(newEval, shareArr)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint16((uint32(aggY[b]) + uint32(senderShare.Y[b])) % shamirPrime)
}