mirror of
https://github.com/luxfi/pulsar.git
synced 2026-07-27 06:55:04 +00:00
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:
@@ -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_A−c_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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user