mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user