pulsar: authenticate partial->PartyID; gate blame on signature validity (RED MEDIUM)

AggregateBCCWithBlame consumed raw []Partial; PartyID was unauthenticated, so an
attacker could stamp a victim's slot on a forged partial and (via first-per-
PartyID + duplicate rules) get the honest victim BLAMED and front-run-EXCLUDED.
No forge/leak (capstone holds), but blame misattributed.

Fix: bind each partial to its producer's long-term identity key.
- Partial gains Author + AuthSig; Round2 signs (author ‖ slot ‖ H(PartyID,
  session,nonce,ZShare,Proof)) via an injected IdentitySigner (SetIdentity).
- AggregateBCCWithBlame gains (quorum, epoch, AbortSignatureVerifier). When a
  verifier is supplied it AUTHENTICATES each partial BEFORE attribution: accept
  only if AuthSig verifies AND Author == quorum[PartyID]; drop forged/wrong-slot/
  unsigned partials with NO blame against the slot's honest owner, BEFORE the
  first-per-PartyID/duplicate logic (so a forgery cannot evict the victim).
- Blame is emitted ONLY for authenticated partials and NEVER off a raw PartyID
  (auth==nil => no blame produced — attribution fails closed). Wraps
  protocol_auth.SignedProtocolMessage (one identity layer). Networked transport
  auth stays a flagged residual.

GATE B updated to authenticated partials (a deviator SIGNS its bad partial =>
attributable; a transport-tampered one breaks its sig => dropped, not framed).
New TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim: forged victim-slot
partial is dropped (attacker can't sign as victim) -> victim NOT blamed, NOT
excluded, round still signs; contrast shows the verifier is load-bearing.
This commit is contained in:
2026-06-27 22:28:34 -07:00
parent bc18129216
commit e0d31539fc
5 changed files with 351 additions and 34 deletions
+97 -9
View File
@@ -42,6 +42,10 @@ func bccRound(t *testing.T, f *bccFixture, msg []byte, sid [32]byte) (partials [
if err != nil {
t.Fatalf("signer %d: %v", i, err)
}
// Wire the identity layer so partials are authenticated and the aggregator
// can attribute blame soundly (RED MEDIUM). Producer = this node's key;
// verifier = the shared per-validator set.
nd.SetIdentity(f.idset.signerFor(quorum[i]), f.idset)
nodes[i] = nd
if err := nd.SetNonceShare(nonceID, deal.YShares[quorum[i]]); err != nil {
t.Fatalf("set nonce share %d: %v", i, err)
@@ -111,7 +115,9 @@ func TestGATE_B_DuplicatePartyID(t *testing.T) {
// (b) duplicates of ONE party can NOT reach threshold: t copies of party 0
// => 1 distinct signer => ErrInsufficientSigners + every extra blamed.
onlyDups := []Partial{partials[0], partials[0], partials[0]}
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup, []uint32{f.shares[0].EvalPoint, f.shares[1].EvalPoint, f.shares[2].EvalPoint},
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup,
[]uint32{f.shares[0].EvalPoint, f.shares[1].EvalPoint, f.shares[2].EvalPoint},
[]NodeID{f.shares[0].NodeID, f.shares[1].NodeID, f.shares[2].NodeID}, 0, f.idset,
nil, []byte("dup test"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, onlyDups)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("GATE B FAILED: %d duplicates of one party produced err=%v, want ErrInsufficientSigners (duplicates inflated the threshold!)", len(onlyDups), err)
@@ -133,10 +139,14 @@ func TestGATE_B_InvalidProofAttributed(t *testing.T) {
const badParty = 1
// Tamper party 1's z WITHOUT updating its proof => sigma equation breaks.
// The malicious validator SIGNS the bad partial (authenticated misbehavior),
// so it passes origin-auth and is attributed BlameProofInvalid — vs a
// transport-tampered partial (stale sig) which would be dropped unattributed.
tampered := append([]Partial(nil), partials...)
z := unpackPolyVec(tampered[badParty].ZShare, mldsaL(f.params.Mode))
z[0][0] = (z[0][0] + 1) % mldsaQ
tampered[badParty].ZShare = packPolyVec(z)
f.idset.signPartial(&tampered[badParty], quorum[badParty], 0)
_, _, ev, err := agg.FinalizeWithBlame(aggR1, tampered)
if !errors.Is(err, ErrInsufficientSigners) {
@@ -159,8 +169,8 @@ func TestGATE_B_InvalidProofAttributed(t *testing.T) {
t.Fatalf("attributed AbortEvidence malformed: %v", vErr)
}
// Confirm the raw reason is proof-invalid.
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, nil, []byte("invalid proof test"),
agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, tampered)
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, []byte("invalid proof test"), agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, tampered)
if b, ok := blameFor(blames, badParty); !ok || b.Reason != BlameProofInvalid {
t.Fatalf("GATE B FAILED: party %d not blamed BlameProofInvalid (got %+v)", badParty, blames)
}
@@ -174,14 +184,17 @@ func TestGATE_B_MalformedShareNoPanic(t *testing.T) {
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate-b-malformed"))
partials, agg, _, _, _ := bccRound(t, f, []byte("malformed test"), sid)
partials, agg, _, quorum, _ := bccRound(t, f, []byte("malformed test"), sid)
const badParty = 2
mangled := append([]Partial(nil), partials...)
mangled[badParty].ZShare = []byte{0x00, 0x01} // truncated — would panic unpackPolyVec
// Malicious validator SIGNS the malformed partial so it passes origin-auth
// and is attributed BlameMalformed (not dropped as transport corruption).
f.idset.signPartial(&mangled[badParty], quorum[badParty], 0)
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, nil, []byte("malformed test"),
agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, mangled)
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, []byte("malformed test"), agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, mangled)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("malformed share should drop to t-1 => ErrInsufficientSigners, got %v", err)
}
@@ -201,7 +214,7 @@ func TestGATE_B_ValidSigmaWrongZ_ResidualNoForgery(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("gate-b-wrongz-residual"))
msg := []byte("valid-sigma wrong-z residual")
partials, agg, aggR1, _, evalPoints := bccRound(t, f, msg, sid)
partials, agg, aggR1, quorum, evalPoints := bccRound(t, f, msg, sid)
const badParty = 1
_, L, _ := modeShape(f.params.Mode)
@@ -222,6 +235,10 @@ func TestGATE_B_ValidSigmaWrongZ_ResidualNoForgery(t *testing.T) {
}
wrong := append([]Partial(nil), partials...)
wrong[badParty] = Partial{PartyID: badParty, NonceID: partials[badParty].NonceID, SessionID: sid, ZShare: packPolyVec(zeros), Proof: proof}
// The malicious validator SIGNS its valid-sigma wrong-z partial (authenticated),
// so origin-auth passes and the partial reaches the sigma check — which it
// passes (valid statement for a wrong z). This is the residual: not attributed.
f.idset.signPartial(&wrong[badParty], quorum[badParty], 0)
agg2, cert, ev, ferr := agg.FinalizeWithBlame(aggR1, wrong)
_ = agg2
@@ -238,8 +255,8 @@ func TestGATE_B_ValidSigmaWrongZ_ResidualNoForgery(t *testing.T) {
for _, e := range ev {
_ = e
}
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, nil, msg,
agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, wrong)
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, msg, agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, wrong)
return blameFor(blames, badParty)
}(); attributed {
t.Fatalf("unexpected: valid-sigma wrong-z was attributed — update the residual claim (now SOUND)")
@@ -249,3 +266,74 @@ func TestGATE_B_ValidSigmaWrongZ_ResidualNoForgery(t *testing.T) {
}
t.Logf("GATE B.4 PASS (honest residual): valid-sigma wrong-z yields NO FIPS-valid signature (no forgery/leak) but is NOT yet attributed — BDLOP share-commitment residual (ErrIdentifiableAbortResidual)")
}
// TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim is RED's MEDIUM PoCs
// (UnauthenticatedPartyID_BlameShift / MalformedFrontRun_ExcludesAndBlamesHonest)
// FLIPPED. An attacker front-runs an honest victim's slot with a forged,
// malformed partial STAMPED with the victim's PartyID. Pre-fix this got the
// victim BLAMED (BlameMalformed/Duplicate) and EXCLUDED off a raw unauthenticated
// PartyID. Post-fix:
// - origin-auth drops the forgery (the attacker cannot sign as the victim), so
// the victim's real partial stands -> NOT excluded, the round still signs;
// - blame is gated on signature validity, so the victim is NEVER blamed.
func TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("red-medium-frame"))
msg := []byte("attacker tries to frame an honest victim")
partials, agg, aggR1, quorum, _ := bccRound(t, f, msg, sid)
const victim = 1 // a non-aggregator slot
// The attacker is NOT in the quorum; it holds its OWN identity key, never the
// victim's. So it cannot produce the victim's signature.
var attacker NodeID
attacker[0] = 0xEE
attackerSet := newTestIdentitySet(attacker)
// (1) Forged partial: victim's PartyID, malformed content, signed with the
// ATTACKER's key (author = attacker). Front-run: placed BEFORE the real one.
forged := Partial{PartyID: victim, SessionID: sid, NonceID: partials[victim].NonceID, ZShare: []byte{0xde, 0xad}}
attackerSet.signPartial(&forged, attacker, 0)
input := []Partial{partials[0], forged, partials[1], partials[2]}
_, cert, ev, err := agg.FinalizeWithBlame(aggR1, input)
if err != nil {
t.Fatalf("RED MEDIUM: honest victim EXCLUDED — round failed with the victim's real partial present: %v", err)
}
if !fipsVerify(t, f.setup, msg, &cert.Signature) {
t.Fatalf("RED MEDIUM: round did not produce a FIPS-valid signature with the victim included")
}
if blamedNode(ev, quorum[victim]) {
t.Fatalf("RED MEDIUM: honest victim %x was BLAMED off an unauthenticated forged PartyID", quorum[victim][:4])
}
// (2) Impersonation variant: author CLAIMS to be the victim but signs with the
// wrong key (attacker lacks the victim's key) -> signature invalid -> dropped.
forged2 := Partial{PartyID: victim, SessionID: sid, NonceID: partials[victim].NonceID, ZShare: []byte{0xba, 0xad}}
forged2.Author = quorum[victim]
forged2.AuthSig = idMAC(attackerSet.keys[attacker], partialAuthTBS(forged2, 0)) // wrong key
input2 := []Partial{partials[0], forged2, partials[1], partials[2]}
_, cert2, ev2, err2 := agg.FinalizeWithBlame(aggR1, input2)
if err2 != nil || !fipsVerify(t, f.setup, msg, &cert2.Signature) {
t.Fatalf("RED MEDIUM (impersonation): victim excluded (err=%v)", err2)
}
if blamedNode(ev2, quorum[victim]) {
t.Fatalf("RED MEDIUM (impersonation): honest victim blamed off a forged victim-authored partial")
}
// (3) CONTRAST — the verifier is load-bearing. With NO identity verifier the
// same front-run EXCLUDES the victim (the forged malformed partial occupies
// the slot; the real one is dropped as a duplicate) -> t-1 valid ->
// ErrInsufficientSigners. No blame is emitted either way (blame is gated on
// auth), but exclusion-resistance REQUIRES the verifier.
_, _, blamesNoAuth, errNoAuth := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, nil,
nil, msg, agg.c, &agg.cHat, agg.w1, sid, partials[victim].NonceID, threshold, input)
if !errors.Is(errNoAuth, ErrInsufficientSigners) {
t.Fatalf("contrast: expected the un-authenticated path to drop to t-1 (front-run exclusion), got %v", errNoAuth)
}
if len(blamesNoAuth) != 0 {
t.Fatalf("contrast: blame MUST NOT be emitted off raw unauthenticated PartyIDs, got %+v", blamesNoAuth)
}
t.Logf("RED MEDIUM FLIPPED: origin-auth drops the forged victim-slot partial (attacker cannot sign as the victim) -> victim NOT excluded, round still signs, victim NEVER blamed; the un-authenticated path neither blames nor (alone) resists exclusion — the verifier is load-bearing")
}
+13
View File
@@ -41,6 +41,17 @@ type NonceCert struct {
}
// Partial is a proof-carrying z-share. No CS2/CT0/r0/LowBits/hint fields.
//
// AUTHENTICATED ORIGIN (RED MEDIUM). PartyID is an UNauthenticated routing
// field; on its own an attacker can stamp a victim's slot on a forged partial to
// get the victim blamed/excluded. Author + AuthSig bind the partial to the
// validator's long-term identity key: AuthSig is that key's signature over the
// canonical partial-auth TBS (partialAuthTBS — author ‖ slot ‖ H(PartyID,
// session, nonce, ZShare, Proof)). The aggregator accepts a partial for a slot
// ONLY if AuthSig verifies AND Author == quorum[PartyID]; blame is emitted only
// for content faults on such authenticated partials, never off a raw PartyID.
// (Producer: Round2 when an IdentitySigner is set. Verifier: AggregateBCCWith
// Blame when an AbortSignatureVerifier is supplied.)
type Partial struct {
PartyID uint32
NonceID [32]byte
@@ -48,6 +59,8 @@ type Partial struct {
ZShare []byte // packed z_i
Proof []byte // partial-correctness proof (PROTOTYPE)
MAC []byte
Author NodeID // authenticated producer identity (== quorum[PartyID])
AuthSig []byte // identity-key signature over partialAuthTBS (origin auth)
}
// ConsensusCert is the two-certificate consensus artifact: the ML-DSA
+155 -7
View File
@@ -375,6 +375,16 @@ type DistributedBCCSigner struct {
policy [32]byte
messageKind uint32
// identity layer (RED MEDIUM, authenticated PartyID). idSigner signs THIS
// node's z-partials with its long-term identity key (producer side); idVerify
// authenticates OTHER nodes' partials when this node aggregates (verifier
// side). Both nil by default: Round2 then emits an unsigned partial and
// FinalizeWithBlame aggregates sigma-checked partials WITHOUT emitting blame
// (no blame is ever produced off an unauthenticated PartyID). Wire both via
// SetIdentity to enable authenticated blame + front-run-exclusion resistance.
idSigner IdentitySigner
idVerify AbortSignatureVerifier
// per-nonce state.
nonceID [32]byte
yShare polyVec // this node's Shamir share of the joint nonce ȳ
@@ -469,6 +479,29 @@ func (d *DistributedBCCSigner) SetNonceLedger(l NonceLedger) *DistributedBCCSign
return d
}
// IdentitySigner is the PRODUCER side of the protocol-message identity layer
// (the verifier side is AbortSignatureVerifier — one identity layer, two faces).
// A validator signs the canonical to-be-signed bytes of a protocol message with
// its long-term identity key (Ed25519 / hybrid PQ / BLS — opaque here). It is
// the same key whose public half a verifier looks up in the validator set.
type IdentitySigner interface {
// SignProtocolMessage returns the identity-key signature over tbs.
SignProtocolMessage(tbs []byte) []byte
}
// SetIdentity wires this node's identity layer (RED MEDIUM, authenticated
// PartyID). signer signs THIS node's z-partials so the aggregator can bind each
// partial to its true producer; verifier authenticates OTHER nodes' partials
// when this node aggregates, so a forged partial stamped with a victim's PartyID
// is dropped (never blamed/excluded). Both must be set for authenticated blame;
// production wires the validator's identity key + the validator-set verifier
// here. Returns the signer for chaining.
func (d *DistributedBCCSigner) SetIdentity(signer IdentitySigner, verifier AbortSignatureVerifier) *DistributedBCCSigner {
d.idSigner = signer
d.idVerify = verifier
return d
}
// SetNonceBinding sets the optional domain-binding context (epoch, policy,
// message-kind) recorded with each nonce reservation for audit / domain
// separation. The single-use guard is independent of this; the binding is the
@@ -626,13 +659,24 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
if err != nil {
return Partial{}, err
}
return Partial{
p := Partial{
PartyID: d.partyIdx,
NonceID: d.nonceID,
SessionID: d.sid,
ZShare: packPolyVec(z),
Proof: proof,
}, nil
}
// AUTHENTICATE ORIGIN (RED MEDIUM). Sign the partial with this validator's
// long-term identity key so the aggregator can bind it to its true producer
// (Author == quorum[PartyID]) and refuse a forged partial stamped with this
// slot. The signature binds the slot AND the content (partialAuthTBS), so it
// is non-malleable. With no identity wired the partial is unsigned and the
// aggregator will not emit blame off it (fail-closed on attribution).
if d.idSigner != nil {
p.Author = d.NodeID()
p.AuthSig = d.idSigner.SignProtocolMessage(partialAuthTBS(p, d.epoch))
}
return p, nil
}
// Finalize is run by the designated aggregator (quorum[0]). It verifies
@@ -661,8 +705,8 @@ func (d *DistributedBCCSigner) FinalizeWithBlame(r1 SignRound1, partials []Parti
if !d.haveC {
return Aggregate{}, ConsensusCert{}, nil, ErrAlgNoNonceShare
}
agg, cert, blames, err := AggregateBCCWithBlame(d.params, d.setup, d.evalPoints, d.ctx, d.msg, d.c, &d.cHat, d.w1,
d.sid, d.nonceID, len(d.quorum), partials)
agg, cert, blames, err := AggregateBCCWithBlame(d.params, d.setup, d.evalPoints, d.quorum, d.epoch, d.idVerify,
d.ctx, d.msg, d.c, &d.cHat, d.w1, d.sid, d.nonceID, len(d.quorum), partials)
return agg, cert, d.blamesToEvidence(blames), err
}
@@ -687,6 +731,68 @@ func (d *DistributedBCCSigner) blamesToEvidence(blames []PartialBlame) []AbortEv
return out
}
// ── partial origin authentication (RED MEDIUM) ────────────────────────────
// partialAuthDigest is the 32-byte payload digest an authenticated z-partial's
// identity signature binds. It commits to the partial's SLOT (PartyID) AND its
// CONTENT (ZShare, Proof) under the round (session, nonce), so the signature is
// non-malleable: changing the claimed PartyID or any byte invalidates it. (MAC,
// Author, AuthSig are excluded — they are the envelope, not the signed payload.)
func partialAuthDigest(p Partial) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/partial-auth-digest/v1"))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], p.PartyID)
_, _ = h.Write(u4[:])
_, _ = h.Write(p.SessionID[:])
_, _ = h.Write(p.NonceID[:])
_, _ = h.Write(p.ZShare)
_, _ = h.Write(p.Proof)
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// partialAuthTBS is the canonical to-be-signed bytes for an authenticated
// z-partial: the producer's identity key signs (author ‖ slot ‖ payload-digest)
// via the shared protocol-message framing (ProtocolMessageTBS, round = Partial).
// One encoding, reused by the verifier — there is exactly one way to compute it.
func partialAuthTBS(p Partial, epoch uint64) []byte {
return ProtocolMessageTBS(p.Author, ProtocolContext{
Epoch: epoch,
SessionID: p.SessionID,
NonceID: p.NonceID,
Round: ProtocolRoundPartial,
}, partialAuthDigest(p))
}
// authenticatePartial reports whether p is a genuine partial from the validator
// expected at p's slot. FAIL CLOSED on every doubt: nil verifier, a partial for
// the wrong round, an author that is not the slot's validator (impersonation),
// or a bad/absent signature all return false. This is the gate that stops an
// attacker stamping a victim's PartyID on a forged partial to get the victim
// blamed or front-run-excluded — the attacker cannot produce the victim's
// identity signature, so the forgery is dropped and the victim's real partial
// stands.
func authenticatePartial(p Partial, expectedAuthor NodeID, epoch uint64, sid, nonceID [32]byte, v AbortSignatureVerifier) bool {
if v == nil {
return false
}
if p.Author != expectedAuthor {
return false // slot impersonation: author is not the slot's validator
}
if p.SessionID != sid || p.NonceID != nonceID {
return false // not a partial for this round
}
m := SignedProtocolMessage{
Author: p.Author,
Context: ProtocolContext{Epoch: epoch, SessionID: sid, NonceID: nonceID, Round: ProtocolRoundPartial},
PayloadDigest: partialAuthDigest(p),
Signature: p.AuthSig,
}
return VerifySignedProtocolMessage(&m, v)
}
// AggregateBCC is the free-function aggregation surface: ANY process
// holding the public setup, the challenge c, the public target w1, and the
// collected z-partials can produce the FIPS 204 signature WITHOUT any
@@ -697,8 +803,8 @@ func (d *DistributedBCCSigner) blamesToEvidence(blames []PartialBlame) []AbortEv
// threshold is the reconstruction threshold; partials is the collected set
// (at least threshold valid ones required). The result verifies under
// unmodified FIPS 204 ML-DSA.Verify (VerifyBytes / mldsa{65,87}.Verify).
func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, error) {
agg, cert, _, err := AggregateBCCWithBlame(params, setup, evalPoints, ctx, msg, c, cHat, w1, sid, nonceID, threshold, partials)
func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, quorum []NodeID, epoch uint64, auth AbortSignatureVerifier, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, error) {
agg, cert, _, err := AggregateBCCWithBlame(params, setup, evalPoints, quorum, epoch, auth, ctx, msg, c, cHat, w1, sid, nonceID, threshold, partials)
return agg, cert, err
}
@@ -711,7 +817,17 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
// does not dedupe, so a duplicate could otherwise satisfy the threshold with
// fewer than t DISTINCT signers. Malformed ZShares are rejected without
// panicking (unpackPolyVecChecked). It holds NO share, sk, or seed.
func AggregateBCCWithBlame(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, []PartialBlame, error) {
//
// ORIGIN AUTHENTICATION (RED MEDIUM). quorum maps PartyID → expected validator
// NodeID; auth is the identity-layer verifier; epoch binds the round. When auth
// is non-nil, a partial is ACCEPTED only if it carries a valid identity
// signature whose author IS quorum[PartyID] — a forged partial stamped with a
// victim's slot is dropped, so an attacker can neither blame nor front-run-
// exclude an honest victim. Blame is emitted ONLY for authenticated partials;
// it is NEVER produced off a raw unauthenticated PartyID (with auth == nil no
// blame is produced at all — attribution fails closed). The networked transport
// that authenticates DELIVERY remains a flagged residual.
func AggregateBCCWithBlame(params *Params, setup *AlgSetup, evalPoints []uint32, quorum []NodeID, epoch uint64, auth AbortSignatureVerifier, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, []PartialBlame, error) {
gamma2, beta, omega, ok := bccParams(params.Mode)
if !ok {
return Aggregate{}, ConsensusCert{}, nil, ErrBCCParamSet
@@ -720,6 +836,31 @@ func AggregateBCCWithBlame(params *Params, setup *AlgSetup, evalPoints []uint32,
_, _, gamma1Bits, _ := modeTauOmega(params.Mode)
gamma1 := uint32(1) << gamma1Bits
// 0. AUTHENTICATE ORIGIN before any attribution (RED MEDIUM). PartyID is an
// unauthenticated wire field; an attacker can stamp a victim's slot on a
// forged partial to get the victim blamed or front-run-excluded. When an
// identity verifier is supplied, ACCEPT a partial only if it carries a valid
// identity signature whose author IS the validator at that slot — drop any
// unauthenticated / wrong-slot / forged partial HERE, with NO blame against
// the slot's honest owner, BEFORE the first-per-PartyID and duplicate logic
// (so a forgery cannot occupy the victim's slot or evict the victim's real
// partial). With auth == nil the identity layer is not wired: the round still
// aggregates sigma-checked partials but produces NO blame (see below).
if auth != nil {
authed := make([]Partial, 0, len(partials))
for i := range partials {
p := partials[i]
if int(p.PartyID) >= len(quorum) {
continue // unknown slot — nobody to frame; drop
}
if !authenticatePartial(p, quorum[p.PartyID], epoch, sid, nonceID, auth) {
continue // forged / wrong-slot / unsigned — drop, never blame the victim
}
authed = append(authed, p)
}
partials = authed
}
// 1. Verify + ATTRIBUTE. A deviating partial is attributed to its PartyID
// (PartialBlame), never silently dropped. The FIRST partial per PartyID is
// authoritative; any later one carrying the same PartyID is rejected as a
@@ -729,6 +870,13 @@ func AggregateBCCWithBlame(params *Params, setup *AlgSetup, evalPoints []uint32,
seen := make(map[uint32]bool, len(partials))
var blames []PartialBlame
blame := func(party uint32, reason BlameReason) {
// Never emit blame off an unauthenticated PartyID (RED MEDIUM): with no
// identity verifier wired, attribution is not sound, so produce none.
// With auth != nil only authenticated partials reach here, so the PartyID
// is the genuine slot owner and the blame is sound.
if auth == nil {
return
}
blames = append(blames, PartialBlame{PartyID: party, Reason: reason, SessionID: sid, NonceID: nonceID})
}
for i := range partials {
+70 -6
View File
@@ -13,23 +13,84 @@ package pulsar
// (VerifyBytes → cloudflare/circl mldsa{65,87}.Verify).
import (
"bytes"
"crypto/rand"
"errors"
"reflect"
"sort"
"testing"
"golang.org/x/crypto/sha3"
)
// ── per-validator identity layer for authenticated-partial tests (RED MEDIUM) ──
//
// testIdentitySet models an identity layer with PER-VALIDATOR keys: each NodeID
// owns a random secret, so an attacker lacking a victim's key cannot forge the
// victim's signature. It is BOTH faces of the one identity layer — the verifier
// (VerifyAbortSignature) and, via signerFor, the producer (IdentitySigner).
// MAC = SHAKE256(key ‖ tbs)[:64] stands in for a real Ed25519/PQ identity key.
type testIdentitySet struct{ keys map[NodeID][]byte }
func newTestIdentitySet(ids ...NodeID) *testIdentitySet {
s := &testIdentitySet{keys: make(map[NodeID][]byte, len(ids))}
for _, id := range ids {
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
panic(err)
}
s.keys[id] = k
}
return s
}
func idMAC(key, tbs []byte) []byte {
h := sha3.NewShake256()
_, _ = h.Write(key)
_, _ = h.Write(tbs)
out := make([]byte, 64)
_, _ = h.Read(out)
return out
}
// VerifyAbortSignature is the verifier face (AbortSignatureVerifier).
func (s *testIdentitySet) VerifyAbortSignature(author NodeID, transcript, signature []byte) bool {
k, ok := s.keys[author]
if !ok || len(signature) == 0 {
return false
}
return bytes.Equal(signature, idMAC(k, transcript))
}
// signerFor returns an IdentitySigner holding ONLY id's key (producer face).
func (s *testIdentitySet) signerFor(id NodeID) IdentitySigner {
return keyedSigner{key: append([]byte(nil), s.keys[id]...)}
}
type keyedSigner struct{ key []byte }
func (k keyedSigner) SignProtocolMessage(tbs []byte) []byte { return idMAC(k.key, tbs) }
// signPartial stamps p with author + a valid identity signature over its
// (possibly tampered) content — models a validator that SIGNS a bad partial
// (authenticated misbehavior, attributable) as opposed to a transport-tampered
// partial (signature breaks, dropped, not attributed). Requires keys[author].
func (s *testIdentitySet) signPartial(p *Partial, author NodeID, epoch uint64) {
p.Author = author
p.AuthSig = idMAC(s.keys[author], partialAuthTBS(*p, epoch))
}
// bccFixture is a dealt (t, n) committee: the public setup plus one AlgShare
// per member. The shares come from the Part-1 trusted dealer; the GATE under
// test is that AFTER dealing, each share lives in a SEPARATE signer and the
// SIGNING ceremony never co-locates them or reconstructs s1/the seed.
type bccFixture struct {
params *Params
setup *AlgSetup
committee []NodeID
shares []*AlgShare // sorted ascending by NodeID
threshold int
params *Params
setup *AlgSetup
committee []NodeID
shares []*AlgShare // sorted ascending by NodeID
threshold int
idset *testIdentitySet // per-validator identity layer (authenticated partials)
}
func newBCCFixture(t *testing.T, mode Mode, n, threshold int) *bccFixture {
@@ -62,7 +123,10 @@ func newBCCFixture(t *testing.T, mode Mode, n, threshold int) *bccFixture {
for i, s := range shares {
committee[i] = s.NodeID
}
return &bccFixture{params: params, setup: setup, committee: committee, shares: shares, threshold: threshold}
return &bccFixture{
params: params, setup: setup, committee: committee, shares: shares, threshold: threshold,
idset: newTestIdentitySet(committee...), // one identity key per committee member
}
}
// quorum returns the first q shares (sorted) and their eval points.
+16 -12
View File
@@ -81,6 +81,7 @@ func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
z := unpackPolyVec(bad[1].ZShare, L)
z[0][0] = (z[0][0] + 1) % mldsaQ
bad[1].ZShare = packPolyVec(z)
f.idset.signPartial(&bad[1], quorum[1], 0) // bad validator SIGNS its bad partial
_, cert, ev, err := agg.FinalizeWithBlame(aggR1, bad)
noForgery(t, f, []byte("invalid"), cert, err)
if !errors.Is(err, ErrInsufficientSigners) {
@@ -95,10 +96,10 @@ func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
t.Run("duplicate-cannot-inflate", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-dup"))
partials, agg, _, _, evalPoints := bccRound(t, f, []byte("dup"), sid)
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("dup"), sid)
dups := []Partial{partials[0], partials[0], partials[0]}
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, nil, []byte("dup"),
agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, dups)
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("dup"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, dups)
noForgery(t, f, []byte("dup"), cert, err)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("duplicates inflated the threshold: %v", err)
@@ -112,11 +113,12 @@ func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
t.Run("malformed-no-panic", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-malformed"))
partials, agg, _, _, evalPoints := bccRound(t, f, []byte("malformed"), sid)
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("malformed"), sid)
bad := append([]Partial(nil), partials...)
bad[2].ZShare = []byte{0xde, 0xad}
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, nil, []byte("malformed"),
agg.c, &agg.cHat, agg.w1, sid, partials[2].NonceID, threshold, bad)
f.idset.signPartial(&bad[2], quorum[2], 0) // bad validator SIGNS its malformed partial
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("malformed"), agg.c, &agg.cHat, agg.w1, sid, partials[2].NonceID, threshold, bad)
noForgery(t, f, []byte("malformed"), cert, err)
if b, ok := blameFor(blames, 2); !ok || b.Reason != BlameMalformed {
t.Fatalf("malformed not blamed")
@@ -128,7 +130,7 @@ func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-wrongz"))
msg := []byte("wrongz")
partials, agg, aggR1, _, evalPoints := bccRound(t, f, msg, sid)
partials, agg, aggR1, quorum, evalPoints := bccRound(t, f, msg, sid)
zeros := make(polyVec, L)
lambda := LagrangeAtZeroQ(evalPoints[1], evalPoints)
st := &PartialStatement{Mode: f.params.Mode, Lambda: lambda, C: agg.c, Z: zeros,
@@ -139,6 +141,7 @@ func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
}
bad := append([]Partial(nil), partials...)
bad[1] = Partial{PartyID: 1, NonceID: partials[1].NonceID, SessionID: sid, ZShare: packPolyVec(zeros), Proof: proof}
f.idset.signPartial(&bad[1], quorum[1], 0) // bad validator SIGNS its valid-sigma wrong-z partial
_, cert, _, ferr := agg.FinalizeWithBlame(aggR1, bad)
noForgery(t, f, msg, cert, ferr) // the load-bearing property: NEVER a forgery
})
@@ -174,6 +177,7 @@ func TestMalicious_SelectiveAbort_ReformProducesValidSig(t *testing.T) {
z := unpackPolyVec(bad[1].ZShare, L)
z[0][0] = (z[0][0] + 1) % mldsaQ
bad[1].ZShare = packPolyVec(z)
f.idset.signPartial(&bad[1], quorum[1], 0) // deviator SIGNS its bad partial (attributable)
_, _, ev, err := agg.FinalizeWithBlame(aggR1, bad)
if !errors.Is(err, ErrInsufficientSigners) || !blamedNode(ev, quorum[1]) {
t.Fatalf("round 1 should abort+blame party 1: err=%v", err)
@@ -205,17 +209,17 @@ func TestMalicious_AggregationOrderIndependent(t *testing.T) {
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("order-independence"))
partials, agg, _, _, evalPoints := bccRound(t, f, []byte("order"), sid)
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("order"), sid)
forward, certF, _, errF := AggregateBCCWithBlame(f.params, f.setup, evalPoints, nil, []byte("order"),
agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, partials)
forward, certF, _, errF := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("order"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, partials)
_ = forward
if errF != nil {
t.Fatalf("forward aggregate: %v", errF)
}
reversed := []Partial{partials[2], partials[1], partials[0]}
_, certR, _, errR := AggregateBCCWithBlame(f.params, f.setup, evalPoints, nil, []byte("order"),
agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, reversed)
_, certR, _, errR := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("order"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, reversed)
if errR != nil {
t.Fatalf("reversed aggregate: %v", errR)
}