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