mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
pulsar: the single-use guard keys on the secret share, not the public label
The nonce ledger keyed dedup on the packed w1, a public label. A swapped cert carries a different w1, so it reserved a fresh entry and the reuse guard never fired — the ledger recorded two first-uses of two labels while one secret was spent twice. Two fresh signers sharing the same y-share then released two z-partials under two challenges, and z_i^A − z_i^B = (c_A − c_B)·λ_i·s1_i recovered the share, the master key, and a forged FIPS 204 signature on a message no signer saw. A guard on the label cannot protect the secret the label refers to. The reservation now keys on y_i, the secret nonce share the response actually spends. It is constant across the two swapped certs, because they share the share, and distinct across legitimately different nonces — so the second spend of one share is refused where the w1 key could not see it. The committee-dedup property the w1 key protected is preserved: the ledger is already per-validator-share, so the same joint nonce reused across two committees that share the victim still collapses to one key. Reverting the reservation to the w1 key reopens the recovery and fails the test.
This commit is contained in:
@@ -679,6 +679,17 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
|
||||
if err := ReserveNonceTicket(ledger, ticket); err != nil {
|
||||
return Partial{}, err
|
||||
}
|
||||
// The ticket keys on the PUBLIC w1 — a label a caller can swap. The quantity
|
||||
// this response actually spends is the SECRET share y_i: a cert minted over a
|
||||
// different joint nonce carries a different w1, reserves a fresh w1 entry, and
|
||||
// would let the SAME y_i be spent again under a fresh challenge, at which point
|
||||
// z_i^A − z_i^B = (c_A − c_B)·λ_i·s1_i hands out the share and then the master
|
||||
// key. Reserve the share itself in the same per-share ledger so one y_i yields
|
||||
// one response whatever cert it is shown; the w1 entry still deduplicates a
|
||||
// relabeled or cross-committee reuse of one joint nonce (nonceMaterialKey).
|
||||
if err := ledger.Reserve(nonceShareKey(d.yShare), ticket.Binding); err != nil {
|
||||
return Partial{}, err
|
||||
}
|
||||
|
||||
// z_i = partialLinearMap(λ_i, ĉ, y_i, s1_i) — byte-identical to the image
|
||||
// the proof certifies, so the partial and its proof are consistent.
|
||||
|
||||
@@ -29,16 +29,25 @@ package pulsar
|
||||
// A NonceLedger records, per VALIDATOR KEY-SHARE, the nonce material that has
|
||||
// already been consumed. Before a signer emits its secret z-partial it
|
||||
// RESERVES the nonce; a second reservation of the same material is rejected
|
||||
// FAIL-CLOSED (ErrNonceReused) and NO partial is produced. The dedup key is
|
||||
// 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:
|
||||
// FAIL-CLOSED (ErrNonceReused) and NO partial is produced. Two keys are
|
||||
// reserved, one per way the same secret can be spent twice:
|
||||
//
|
||||
// key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ w1)
|
||||
// w1 key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ w1)
|
||||
// share key = SHAKE256("PULSAR/nonce-share-single-use/v1" ‖ y_i)
|
||||
//
|
||||
// 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).
|
||||
// The w1 key is the nonce COMMITMENT (not the nonceID label), so relabeling the
|
||||
// same joint nonce under a fresh nonceID does not bypass the guard. It 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).
|
||||
//
|
||||
// The share key is the SECRET nonce share y_i the response actually spends. w1
|
||||
// is a public label a caller can swap: a cert minted over a different joint
|
||||
// nonce carries a different w1 and reserves a fresh w1 entry, but the response
|
||||
// z_i = λ_i·y_i + c·λ_i·s1_i is over the SAME y_i under a fresh challenge, and
|
||||
// z_i^A − z_i^B = (c_A − c_B)·λ_i·s1_i reveals the share. The share key refuses
|
||||
// that second spend where the w1 key cannot see it (see nonceShareKey).
|
||||
//
|
||||
// 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
|
||||
@@ -399,6 +408,38 @@ func nonceMaterialKey(w1Packed []byte) [32]byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// nonceShareKey derives the single-use dedup key from the SECRET per-party nonce
|
||||
// share y_i a signer holds. It is the companion to nonceMaterialKey: w1 is the
|
||||
// public LABEL a response is issued under, y_i is the secret the response SPENDS
|
||||
// (z_i = λ_i·y_i + c·λ_i·s1_i). Two certs minted over different joint nonces carry
|
||||
// different w1 and reserve different w1 keys, yet a caller can present both to a
|
||||
// signer holding ONE y_i; the two responses over that one share under two
|
||||
// challenges give z_i^A − z_i^B = (c_A − c_B)·λ_i·s1_i and the share falls out —
|
||||
// then the master key. Keying a reservation on y_i itself refuses the second
|
||||
// spend: the share is identical across the swapped certs (they share it) and
|
||||
// distinct across legitimately different nonces (each is dealt its own share).
|
||||
//
|
||||
// Committee-INDEPENDENT for the same reason as nonceMaterialKey: the ledger is
|
||||
// already per-share, so a bare share hash keeps one share to ONE key across
|
||||
// committees. The two keys divide the labour — nonceMaterialKey dedups a
|
||||
// relabeled or cross-committee reuse of one joint nonce (same w1); nonceShareKey
|
||||
// dedups a swapped cert over one share (same y_i). The one-way SHAKE256 digest
|
||||
// leaks nothing about y_i.
|
||||
func nonceShareKey(yShare polyVec) [32]byte {
|
||||
h := sha3.NewShake256()
|
||||
_, _ = h.Write([]byte("PULSAR/nonce-share-single-use/v1"))
|
||||
var u4 [4]byte
|
||||
for l := range yShare {
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
binary.BigEndian.PutUint32(u4[:], yShare[l][j])
|
||||
_, _ = h.Write(u4[:])
|
||||
}
|
||||
}
|
||||
var out [32]byte
|
||||
_, _ = h.Read(out[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// deriveCommitteeID is the canonical 32-byte identity of a (group key, sorted
|
||||
// quorum) pair. quorum MUST be the sorted, distinct committee (the constructor
|
||||
// enforces this). It binds a nonce reservation to THIS committee + key.
|
||||
|
||||
Reference in New Issue
Block a user