pulsar: nonce dedup keys on w1 ALONE — close RED LOW cross-committee gap

The dedup key was H(committeeID‖w1). The same joint nonce (same w1) used in two
committees that share a victim validator produced DISTINCT keys, so the victim's
ledger did not dedup across committees -> victim emits a partial in each -> the
attacker collects two partials on one nonce.

Fix: key on w1 alone. This is sound precisely BECAUSE the ledger is now
per-validator-share (resolved by shareIdentityKey): within the victim's one
ledger the same w1 collapses to ONE key regardless of committee. committeeID
stays on the NonceTicket/binding for audit and on TicketID; only the MaterialKey
drops it.

GATE: TestRED_LOW_CrossCommittee_SameNonce_Deduped — two distinct committees
sharing a victim, same w1 -> equal MaterialKey -> second reserve ErrNonceReused.
This commit is contained in:
zeekay
2026-06-27 22:11:53 -07:00
parent 1016afb88b
commit 583fd91715
2 changed files with 67 additions and 12 deletions
+26 -12
View File
@@ -33,7 +33,12 @@ package pulsar
// derived from the nonce COMMITMENT w1 (not the nonceID label), so relabeling
// the same joint nonce under a fresh nonceID does not bypass the guard:
//
// key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ committeeID ‖ w1)
// key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ w1)
//
// The key is w1 ALONE — NOT committeeID (RED LOW): the ledger is already
// per-validator-share, so the SAME joint nonce reused across two committees
// that share the victim collapses to ONE key and is deduped (a committeeID in
// the key would split it and let the second use through).
//
// One nonce ⇒ one signature, ever. Even ONE honest member refusing the
// second use starves the attacker of that member's z_B partial, so the
@@ -243,9 +248,10 @@ func shareIdentityKey(share *AlgShare) [32]byte {
// NonceTicket is the public, auditable handle for one nonce's single use. The
// TicketID is a UNIQUE label (it folds in the binding, so two tickets minted
// for the same nonce material but different messages have DIFFERENT TicketIDs);
// the SINGLE-USE dedup, however, keys on the nonce MATERIAL (committeeID‖W1) via
// MaterialKey — NOT on TicketID — so a relabeled or different-message reuse of
// the SAME joint nonce is still rejected. This separation is deliberate:
// the SINGLE-USE dedup, however, keys on the nonce MATERIAL (W1 alone) via
// MaterialKey — NOT on TicketID, NOT on committeeID — so a relabeled,
// different-message, or different-committee reuse of the SAME joint nonce is
// still rejected by the victim's per-share ledger. This separation is deliberate:
// TicketID is for logs/correlation, MaterialKey is for security.
type NonceTicket struct {
TicketID [32]byte
@@ -283,9 +289,10 @@ func NewNonceTicket(committeeID [32]byte, w1 []byte, binding NonceBinding) Nonce
return NonceTicket{TicketID: id, CommitteeID: committeeID, W1: append([]byte(nil), w1...), Binding: binding}
}
// MaterialKey is the single-use dedup key for the ticket's nonce material.
// MaterialKey is the single-use dedup key for the ticket's nonce material
// (w1 alone; committee-independent — see nonceMaterialKey).
func (tk NonceTicket) MaterialKey() [32]byte {
return nonceMaterialKey(tk.CommitteeID, tk.W1)
return nonceMaterialKey(tk.W1)
}
// ReserveNonceTicket is the ONE way a nonce is consumed: it reserves the
@@ -299,14 +306,21 @@ func ReserveNonceTicket(l NonceLedger, tk NonceTicket) error {
return l.Reserve(tk.MaterialKey(), tk.Binding)
}
// nonceMaterialKey derives the single-use dedup key from the committee identity
// and the nonce commitment w1 (the packed HighBits(w) carried on the public
// NonceCert). Keying on w1 — NOT on the nonceID label — defeats the relabel
// bypass: the same joint nonce ȳ has the same w1 under any nonceID.
func nonceMaterialKey(committeeID [32]byte, w1Packed []byte) [32]byte {
// nonceMaterialKey derives the single-use dedup key from the nonce commitment
// w1 ALONE (the packed HighBits(w) carried on the public NonceCert).
//
// w1 ONLY — NOT the nonceID label, NOT committeeID:
// - dropping the nonceID label defeats the relabel bypass: the same joint
// nonce ȳ has the same w1 under any nonceID.
// - dropping committeeID closes the RED LOW cross-committee gap. The ledger is
// already per-VALIDATOR-share (resolved by shareIdentityKey), so within a
// victim's one ledger the SAME joint nonce reused across two committees
// collapses to ONE key and is deduped. With committeeID in the key, the same
// w1 in two committees sharing the victim split into two keys and slipped
// through, handing the attacker two partials on one nonce.
func nonceMaterialKey(w1Packed []byte) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/nonce-single-use/v1"))
_, _ = h.Write(committeeID[:])
_, _ = h.Write(w1Packed)
var out [32]byte
_, _ = h.Read(out[:])
@@ -437,3 +437,44 @@ func TestRED_PoC_DefaultLedger_NonceReuse_Refused(t *testing.T) {
t.Logf("RED PoC FLIPPED: on the BARE default path (no SetNonceLedger), two signer instances over the same share share ONE registry ledger; the second partial on the reused nonce is refused (ErrNonceReused), incl. relabel — the (c_Ac_B)·s1 system is never assembled, s1 is NOT recoverable")
}
// TestRED_LOW_CrossCommittee_SameNonce_Deduped closes the RED LOW: the dedup
// key must NOT include committeeID. The SAME joint nonce (same w1) used in two
// DIFFERENT committees that share a victim validator must collapse to ONE key in
// the victim's per-share ledger — else the victim emits a partial in each
// committee and the attacker collects two partials on one nonce. Keying on w1
// alone (within the per-share ledger) refuses the second use regardless of
// committee.
func TestRED_LOW_CrossCommittee_SameNonce_Deduped(t *testing.T) {
// Two DIFFERENT committees (different joint-PK ids AND quorums) that share a
// victim validator v — distinct committeeIDs.
var pkA, pkB [32]byte
pkA[0], pkB[0] = 0x01, 0x02
var v, a, b NodeID
v[0], a[0], b[0] = 0x01, 0x02, 0x03 // v < a, v < b ⇒ {v,a} and {v,b} sorted
cid1 := deriveCommitteeID(pkA, []NodeID{v, a})
cid2 := deriveCommitteeID(pkB, []NodeID{v, b})
if cid1 == cid2 {
t.Fatal("committee ids must differ for this test to be meaningful")
}
// SAME joint nonce material (same w1) presented under both committees.
w1 := []byte("identical-joint-nonce-commitment-w1-AAAA")
bind := NonceBinding{Digest: sha3Sum32([]byte("victim signs in both committees"))}
tk1 := NewNonceTicket(cid1, w1, bind)
tk2 := NewNonceTicket(cid2, w1, bind)
// (1) the dedup keys must be EQUAL despite different committees (w1-keyed).
if tk1.MaterialKey() != tk2.MaterialKey() {
t.Fatalf("RED LOW FAILED: MaterialKey depends on committeeID — the same nonce across committees is NOT deduped (two-partials-on-one-nonce key recovery)")
}
// (2) one per-share ledger refuses the second committee's reuse of the nonce.
led := NewInMemoryNonceLedger()
if err := ReserveNonceTicket(led, tk1); err != nil {
t.Fatalf("first committee reserve must succeed: %v", err)
}
if err := ReserveNonceTicket(led, tk2); err != ErrNonceReused {
t.Fatalf("RED LOW FAILED: the same joint nonce in a second committee returned %v, want ErrNonceReused", err)
}
t.Logf("RED LOW PASS: nonce dedup keys on w1 ALONE; the same joint nonce reused across two committees sharing the victim is refused (ErrNonceReused)")
}