mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
pulsar: nonce single-use anti-replay guard closes RED nonce-reuse key-recovery (HIGH) + GATE A
THE HIGH (RED TestRED_NonceReuse_RecoversS1): same joint nonce + 2 messages => public (z_A - z_B) = (c_A - c_B)*s1 solves for the WHOLE expanded key. UNGUARDED on v0.4.0. FIX (nonce_ledger.go + Round2 wiring): - NonceLedger single-use store keyed on the nonce COMMITMENT H(committeeID || w1) -- NOT the nonceID label, so a relabeled joint nonce cannot bypass. Reserve() is fail-closed: ErrNonceReused on any second use, never rolled back (an aborted attempt leaves no reusable nonce state). - DistributedBCCSigner reserves the nonce in Round2 BEFORE touching the secret. Per-validator ledger injectable via SetNonceLedger (one ledger per share across signer instances catches cross-instance reuse); domain binding via SetNonceBinding (epoch/policy/message_kind/digest, audit + tripwire). GATE A (nonce_reuse_gate_test.go): (1) actually RECOVERS the full 5-poly s1 by solving (c_A-c_B)*s1 = z_A-z_B over Z_q (negacyclic Gauss-Jordan) == dealer secret bit-for-bit -- the vuln is real; (2) the real signer API refuses the second partial (ErrNonceReused), incl. the relabel bypass -- the system can never be assembled via honest signers. GATE-1/GATE-2 still green; full suite green (7.2s). Residual (flagged): in-memory ledger does not survive restart -> production must inject a PERSISTENT ledger; dealer-side NonceMPC 'Consumed' marking is complementary defense-in-depth (leak-free NonceMPC residual).
This commit is contained in:
@@ -352,14 +352,28 @@ type DistributedBCCSigner struct {
|
||||
ctx, msg []byte
|
||||
rng io.Reader
|
||||
|
||||
// committeeID binds nonce reservations to THIS (group key, sorted quorum).
|
||||
committeeID [32]byte
|
||||
// ledger is the per-VALIDATOR nonce single-use guard (RED nonce-reuse fix).
|
||||
// Defaults to a fresh in-memory ledger; a validator that runs more than one
|
||||
// signer instance over the SAME share MUST inject ONE shared ledger via
|
||||
// SetNonceLedger so cross-instance reuse is also caught.
|
||||
ledger NonceLedger
|
||||
// optional domain-binding context recorded with each reservation (audit /
|
||||
// domain separation). Zero by default; set via SetNonceBinding.
|
||||
epoch uint64
|
||||
policy [32]byte
|
||||
messageKind uint32
|
||||
|
||||
// per-nonce state.
|
||||
nonceID [32]byte
|
||||
yShare polyVec // this node's Shamir share of the joint nonce ȳ
|
||||
haveY bool
|
||||
w1 polyVec // public HighBits(w), unpacked from the NonceCert
|
||||
c poly // challenge = SampleInBall(H(μ, w1)); set by Round1
|
||||
cHat poly // c in NTT/Montgomery form
|
||||
haveC bool
|
||||
nonceID [32]byte
|
||||
yShare polyVec // this node's Shamir share of the joint nonce ȳ
|
||||
haveY bool
|
||||
w1 polyVec // public HighBits(w), unpacked from the NonceCert
|
||||
w1Packed []byte // packed HighBits(w) as carried on the cert — nonce material
|
||||
c poly // challenge = SampleInBall(H(μ, w1)); set by Round1
|
||||
cHat poly // c in NTT/Montgomery form
|
||||
haveC bool
|
||||
}
|
||||
|
||||
// compile-time witness: the concrete signer is a Quasar RoundSigner.
|
||||
@@ -415,20 +429,48 @@ func NewDistributedBCCSigner(params *Params, setup *AlgSetup, share *AlgShare, q
|
||||
ctxCopy = append([]byte{}, ctx...)
|
||||
}
|
||||
return &DistributedBCCSigner{
|
||||
params: params,
|
||||
setup: setup,
|
||||
share: share,
|
||||
quorum: append([]NodeID{}, quorum...),
|
||||
evalPoints: append([]uint32{}, evalPoints...),
|
||||
partyIdx: uint32(myIdx),
|
||||
lambda: LagrangeAtZeroQ(share.EvalPoint, evalPoints),
|
||||
sid: sid,
|
||||
ctx: ctxCopy,
|
||||
msg: append([]byte{}, msg...),
|
||||
rng: rng,
|
||||
params: params,
|
||||
setup: setup,
|
||||
share: share,
|
||||
quorum: append([]NodeID{}, quorum...),
|
||||
evalPoints: append([]uint32{}, evalPoints...),
|
||||
partyIdx: uint32(myIdx),
|
||||
lambda: LagrangeAtZeroQ(share.EvalPoint, evalPoints),
|
||||
sid: sid,
|
||||
ctx: ctxCopy,
|
||||
msg: append([]byte{}, msg...),
|
||||
rng: rng,
|
||||
committeeID: deriveCommitteeID(pkID(setup.Pub), quorum),
|
||||
ledger: NewInMemoryNonceLedger(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNonceLedger injects a shared/persistent nonce single-use ledger. A
|
||||
// validator that runs more than one DistributedBCCSigner over the SAME key
|
||||
// share (e.g. one signer instance per signing request) MUST share ONE ledger
|
||||
// across all of them, so a second signing on the same nonce is caught even when
|
||||
// it crosses signer instances. Returns the signer for chaining. A nil ledger is
|
||||
// ignored (the default in-memory ledger is retained — fail-closed, never
|
||||
// fail-open). Production injects a PERSISTENT ledger here.
|
||||
func (d *DistributedBCCSigner) SetNonceLedger(l NonceLedger) *DistributedBCCSigner {
|
||||
if l != nil {
|
||||
d.ledger = l
|
||||
}
|
||||
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
|
||||
// auditable "what was this nonce spent on" record. Returns the signer for
|
||||
// chaining.
|
||||
func (d *DistributedBCCSigner) SetNonceBinding(epoch uint64, policy [32]byte, messageKind uint32) *DistributedBCCSigner {
|
||||
d.epoch = epoch
|
||||
d.policy = policy
|
||||
d.messageKind = messageKind
|
||||
return d
|
||||
}
|
||||
|
||||
// Profile reports the Quasar cert profile this signer serves.
|
||||
func (d *DistributedBCCSigner) Profile() CertProfile { return ProfilePulsar }
|
||||
|
||||
@@ -486,6 +528,9 @@ func (d *DistributedBCCSigner) Round1(sessionID, nonceID [32]byte, cert NonceCer
|
||||
return SignRound1{}, err
|
||||
}
|
||||
d.w1 = w1
|
||||
// Capture the canonical packed nonce commitment as the single-use material
|
||||
// (keyed on w1, not the nonceID label, so a relabeled nonce cannot bypass).
|
||||
d.w1Packed = append([]byte(nil), cert.W1...)
|
||||
|
||||
// c̃ = H(μ, packW1(w1)); c = SampleInBall(c̃). Same deriveCTilde the
|
||||
// aggregator uses, so every party's c and the final signature's c̃ agree.
|
||||
@@ -516,6 +561,29 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
|
||||
if r1.NonceID != d.nonceID {
|
||||
return Partial{}, ErrAlgNonceMismatch
|
||||
}
|
||||
if d.ledger == nil {
|
||||
return Partial{}, ErrNonceLedgerNil
|
||||
}
|
||||
|
||||
// NONCE SINGLE-USE GUARD (RED nonce-reuse, HIGH). Reserve the nonce
|
||||
// commitment BEFORE the secret is touched. A second use of the same joint
|
||||
// nonce — even relabeled under a fresh nonceID — is refused FAIL-CLOSED, so
|
||||
// the attacker can never obtain a second z-partial on the same nonce and the
|
||||
// (c_A − c_B)·s1 key-recovery system can never be assembled. The reservation
|
||||
// is never rolled back: if proof generation below fails, the nonce stays
|
||||
// burned (an aborted attempt leaves no reusable nonce state).
|
||||
key := nonceMaterialKey(d.committeeID, d.w1Packed)
|
||||
binding := NonceBinding{
|
||||
Epoch: d.epoch,
|
||||
CommitteeID: d.committeeID,
|
||||
Policy: d.policy,
|
||||
MessageKind: d.messageKind,
|
||||
Digest: nonceBindingDigest(d.params.Mode, d.setup.tr, d.ctx, d.msg),
|
||||
}
|
||||
if err := d.ledger.Reserve(key, 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.
|
||||
z := partialLinearMap(d.lambda, &d.cHat, d.yShare, d.share.S1Share)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
// nonce_ledger.go — the nonce single-use / anti-replay guard.
|
||||
//
|
||||
// THE ATTACK THIS CLOSES (RED finding, HIGH — nonce reuse ⇒ full key recovery).
|
||||
//
|
||||
// The committee response is z = ȳ + c·s1 where c = SampleInBall(H(μ, w1))
|
||||
// depends on the message μ but ȳ (hence w1) is the joint nonce. If the SAME
|
||||
// joint nonce is consumed for TWO messages A, B, the two public aggregates
|
||||
//
|
||||
// z_A = ȳ + c_A·s1 , z_B = ȳ + c_B·s1
|
||||
//
|
||||
// subtract to the PUBLIC linear relation
|
||||
//
|
||||
// z_A − z_B = (c_A − c_B)·s1 (over R_q^L)
|
||||
//
|
||||
// in which EVERYTHING but s1 is public. (c_A − c_B) is invertible in R_q with
|
||||
// overwhelming probability, so any observer solves for the WHOLE expanded key
|
||||
// s1 and forges at will. This is the textbook Schnorr/FROST nonce-reuse
|
||||
// catastrophe carried to the lattice setting — it is intrinsic to the LINEAR
|
||||
// response and cannot be patched in the algebra. The defence is the same as
|
||||
// FROST's: an HONEST signer MUST consume each nonce AT MOST ONCE.
|
||||
//
|
||||
// THE GUARD (this file + Round2 wiring).
|
||||
//
|
||||
// A NonceLedger records, per signer/validator, 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:
|
||||
//
|
||||
// key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ committeeID ‖ w1)
|
||||
//
|
||||
// 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
|
||||
// (c_A − c_B)·s1 system can never be assembled. The guard is per-VALIDATOR
|
||||
// (per-share), shared across every signer instance that uses the same share —
|
||||
// see SetNonceLedger.
|
||||
//
|
||||
// FAIL-CLOSED LIFECYCLE. Reservation happens BEFORE the secret is touched and
|
||||
// is NEVER rolled back: if proof generation later fails, the nonce stays
|
||||
// burned. An aborted attempt therefore cannot leave a reusable nonce behind
|
||||
// (the "abort doesn't leak reusable nonce state" property). Re-transmitting a
|
||||
// dropped partial is the CALLER's job (cache the Round2 output and re-send it);
|
||||
// re-INVOKING Round2 for the same nonce is correctly refused.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNonceReused is returned when a signer is asked to consume a nonce
|
||||
// whose commitment material has already produced a partial. This is the
|
||||
// load-bearing anti-replay refusal (RED nonce-reuse finding).
|
||||
ErrNonceReused = errors.New("pulsar: nonce already consumed — single-use guard refuses reuse (key-recovery vector)")
|
||||
|
||||
// ErrNonceBindingMismatch is returned by CheckBinding when a reserved
|
||||
// nonce is re-presented under a DIFFERENT domain binding than it was
|
||||
// minted for (epoch / committee / policy / message-kind / digest). It is a
|
||||
// domain-separation tripwire; the single-use guard itself fires first on
|
||||
// any second use.
|
||||
ErrNonceBindingMismatch = errors.New("pulsar: nonce binding mismatch — reserved for a different (epoch,committee,policy,kind,digest)")
|
||||
|
||||
// ErrNonceLedgerNil guards a signer constructed without a ledger.
|
||||
ErrNonceLedgerNil = errors.New("pulsar: nonce ledger is nil")
|
||||
)
|
||||
|
||||
// NonceBinding is the domain context a nonce is reserved for. It is recorded
|
||||
// with the reservation for audit and domain-separation. The single-use guard
|
||||
// keys on the nonce COMMITMENT (committeeID‖w1), not on this binding; the
|
||||
// binding is the auditable "what was this nonce spent on" record and the
|
||||
// tripwire CheckBinding uses.
|
||||
type NonceBinding struct {
|
||||
Epoch uint64
|
||||
CommitteeID [32]byte
|
||||
Policy [32]byte // policy / domain tag (e.g. block-cert vs warp-msg domain)
|
||||
MessageKind uint32 // caller-defined message-kind discriminator
|
||||
Digest [32]byte // digest of the (ctx,msg) this nonce is reserved for
|
||||
}
|
||||
|
||||
func (a NonceBinding) equal(b NonceBinding) bool {
|
||||
return a.Epoch == b.Epoch &&
|
||||
a.CommitteeID == b.CommitteeID &&
|
||||
a.Policy == b.Policy &&
|
||||
a.MessageKind == b.MessageKind &&
|
||||
a.Digest == b.Digest
|
||||
}
|
||||
|
||||
// NonceLedger is the single-use store. Implementations MUST be safe for
|
||||
// concurrent use and MUST be FAIL-CLOSED: Reserve returns ErrNonceReused for
|
||||
// any key it has already seen, and never forgets a key for the lifetime of the
|
||||
// share it protects. A production deployment backs this with PERSISTENT storage
|
||||
// so a crash-and-restart cannot reuse a nonce (the in-memory default does not
|
||||
// survive restart — flagged residual).
|
||||
type NonceLedger interface {
|
||||
// Reserve records the first use of key with its binding and returns nil.
|
||||
// A second Reserve of the same key returns ErrNonceReused — no exceptions,
|
||||
// regardless of the binding presented.
|
||||
Reserve(key [32]byte, binding NonceBinding) error
|
||||
|
||||
// CheckBinding reports whether key is reserved and, if so, whether it was
|
||||
// reserved for the given binding. Returns (true, nil) on a binding match,
|
||||
// (true, ErrNonceBindingMismatch) on a mismatch, and (false, nil) if the
|
||||
// key is unknown. It never mutates the ledger.
|
||||
CheckBinding(key [32]byte, binding NonceBinding) (reserved bool, err error)
|
||||
}
|
||||
|
||||
// InMemoryNonceLedger is the default mutex-guarded in-process ledger. It is
|
||||
// correct for a single long-lived validator process; it does NOT persist
|
||||
// across restarts (production must inject a persistent ledger — see residuals).
|
||||
type InMemoryNonceLedger struct {
|
||||
mu sync.Mutex
|
||||
seen map[[32]byte]NonceBinding
|
||||
}
|
||||
|
||||
// NewInMemoryNonceLedger returns an empty in-memory single-use ledger.
|
||||
func NewInMemoryNonceLedger() *InMemoryNonceLedger {
|
||||
return &InMemoryNonceLedger{seen: make(map[[32]byte]NonceBinding)}
|
||||
}
|
||||
|
||||
// Reserve implements NonceLedger.
|
||||
func (l *InMemoryNonceLedger) Reserve(key [32]byte, binding NonceBinding) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.seen[key]; ok {
|
||||
return ErrNonceReused
|
||||
}
|
||||
l.seen[key] = binding
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckBinding implements NonceLedger.
|
||||
func (l *InMemoryNonceLedger) CheckBinding(key [32]byte, binding NonceBinding) (bool, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
got, ok := l.seen[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if !got.equal(binding) {
|
||||
return true, ErrNonceBindingMismatch
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
h := sha3.NewShake256()
|
||||
_, _ = h.Write([]byte("PULSAR/nonce-single-use/v1"))
|
||||
_, _ = h.Write(committeeID[:])
|
||||
_, _ = h.Write(w1Packed)
|
||||
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.
|
||||
func deriveCommitteeID(jointPKID [32]byte, quorum []NodeID) [32]byte {
|
||||
h := sha3.NewShake256()
|
||||
_, _ = h.Write([]byte("PULSAR/committee-id/v1"))
|
||||
_, _ = h.Write(jointPKID[:])
|
||||
for i := range quorum {
|
||||
_, _ = h.Write(quorum[i][:])
|
||||
}
|
||||
var out [32]byte
|
||||
_, _ = h.Read(out[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// nonceBindingDigest derives the 32-byte (ctx,msg) digest recorded in a
|
||||
// nonce reservation. It binds the reservation to exactly what is being signed.
|
||||
func nonceBindingDigest(mode Mode, tr [64]byte, ctx, msg []byte) [32]byte {
|
||||
var mu [64]byte
|
||||
deriveMuCtx(tr, ctx, msg, mu[:])
|
||||
h := sha3.NewShake256()
|
||||
_, _ = h.Write([]byte("PULSAR/nonce-binding-digest/v1"))
|
||||
_, _ = h.Write([]byte{byte(mode)})
|
||||
_, _ = h.Write(mu[:])
|
||||
var out [32]byte
|
||||
_, _ = h.Read(out[:])
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
// nonce_reuse_gate_test.go — GATE A (RED finding, HIGH: nonce reuse ⇒ full key
|
||||
// recovery). Ported from RED's TestRED_NonceReuse_RecoversS1.
|
||||
//
|
||||
// The gate has two halves:
|
||||
//
|
||||
// (1) THE VULNERABILITY IS REAL. Reusing one joint nonce for two messages
|
||||
// yields the PUBLIC linear system z_A − z_B = (c_A − c_B)·s1 in which
|
||||
// everything but s1 is public. We assert the identity holds (via the
|
||||
// package's own arithmetic) AND actually RECOVER the whole expanded key
|
||||
// s1 from it by solving the negacyclic system over Z_q — matching the
|
||||
// dealer's secret bit-for-bit. This leak is intrinsic to the LINEAR
|
||||
// response; it cannot be patched in the algebra.
|
||||
//
|
||||
// (2) THE GUARD BLOCKS THE ATTACK. Driving the REAL signer API, an honest
|
||||
// validator that has emitted a z-partial for a nonce REFUSES to emit a
|
||||
// second partial for the same nonce — even relabeled under a fresh
|
||||
// nonceID — so the attacker can never assemble (z_A, z_B) on one nonce.
|
||||
// PRE-FIX this Round2 succeeded (key recovered); POST-FIX it returns
|
||||
// ErrNonceReused. The gate is the second partial FAILING to be produced.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// negacyclic linear algebra over Z_q (test-only attacker math)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func powModQ(a uint32, e uint32) uint32 {
|
||||
r := uint64(1)
|
||||
b := uint64(a) % mldsaQ
|
||||
for e > 0 {
|
||||
if e&1 == 1 {
|
||||
r = r * b % mldsaQ
|
||||
}
|
||||
b = b * b % mldsaQ
|
||||
e >>= 1
|
||||
}
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
// invModQ returns a^(q-2) mod q (Fermat inverse; q prime).
|
||||
func invModQ(a uint32) uint32 { return powModQ(a, mldsaQ-2) }
|
||||
|
||||
// negacyclicMatrix builds the n×n matrix M_d over Z_q with (d·s)[k] = Σ_j M[k][j]·s[j]
|
||||
// for the ring R_q = Z_q[X]/(X^n+1): M[k][j] = d[k-j] for k≥j, else −d[k-j+n].
|
||||
func negacyclicMatrix(d *poly) [][]uint32 {
|
||||
n := mldsaN
|
||||
M := make([][]uint32, n)
|
||||
for k := 0; k < n; k++ {
|
||||
M[k] = make([]uint32, n)
|
||||
for j := 0; j < n; j++ {
|
||||
if k >= j {
|
||||
M[k][j] = d[k-j] % mldsaQ
|
||||
} else {
|
||||
M[k][j] = (mldsaQ - d[k-j+n]%mldsaQ) % mldsaQ
|
||||
}
|
||||
}
|
||||
}
|
||||
return M
|
||||
}
|
||||
|
||||
// solveModQ solves M·x = b over Z_q by Gauss–Jordan elimination. Returns
|
||||
// ok=false iff M is singular (d not invertible in R_q).
|
||||
func solveModQ(M [][]uint32, b []uint32) ([]uint32, bool) {
|
||||
n := len(b)
|
||||
A := make([][]uint64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
A[i] = make([]uint64, n+1)
|
||||
for j := 0; j < n; j++ {
|
||||
A[i][j] = uint64(M[i][j])
|
||||
}
|
||||
A[i][n] = uint64(b[i])
|
||||
}
|
||||
for col := 0; col < n; col++ {
|
||||
piv := -1
|
||||
for r := col; r < n; r++ {
|
||||
if A[r][col]%mldsaQ != 0 {
|
||||
piv = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if piv == -1 {
|
||||
return nil, false
|
||||
}
|
||||
A[col], A[piv] = A[piv], A[col]
|
||||
inv := uint64(invModQ(uint32(A[col][col] % mldsaQ)))
|
||||
for j := col; j <= n; j++ {
|
||||
A[col][j] = A[col][j] % mldsaQ * inv % mldsaQ
|
||||
}
|
||||
for r := 0; r < n; r++ {
|
||||
if r == col {
|
||||
continue
|
||||
}
|
||||
f := A[r][col] % mldsaQ
|
||||
if f == 0 {
|
||||
continue
|
||||
}
|
||||
for j := col; j <= n; j++ {
|
||||
A[r][j] = (A[r][j] + (mldsaQ-A[col][j]*f%mldsaQ)) % mldsaQ
|
||||
}
|
||||
}
|
||||
}
|
||||
x := make([]uint32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = uint32(A[i][n] % mldsaQ)
|
||||
}
|
||||
return x, true
|
||||
}
|
||||
|
||||
// challengeFor derives the challenge poly c = SampleInBall(H(μ, w1)) for a
|
||||
// message under a FIXED nonce commitment w1 — the per-message challenge a
|
||||
// reused nonce produces.
|
||||
func challengeFor(mode Mode, tr [64]byte, ctx, msg []byte, w1 polyVec, gamma2 uint32, K, tau int) poly {
|
||||
cTilde := deriveCTilde(mode, tr, ctx, msg, w1, gamma2, K)
|
||||
var c poly
|
||||
polyDeriveUniformBall(&c, cTilde, tau)
|
||||
return c
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GATE A
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRED_NonceReuse_RecoversS1(t *testing.T) {
|
||||
const n, threshold = 5, 3
|
||||
mode := ModeP65
|
||||
params := MustParamsFor(mode)
|
||||
gamma2, _, _, ok := bccParams(mode)
|
||||
if !ok {
|
||||
t.Fatalf("bccParams(%v) not in scope", mode)
|
||||
}
|
||||
K, L, _ := modeShape(mode)
|
||||
tau, _, _, _ := modeTauOmega(mode)
|
||||
|
||||
// Deal a committee. We KEEP the seed so we can derive the reference s1 the
|
||||
// attack must recover (the dealer wipes its own copy).
|
||||
committee := make([]NodeID, n)
|
||||
for i := range committee {
|
||||
if _, err := rand.Read(committee[i][:]); err != nil {
|
||||
t.Fatalf("committee entropy: %v", err)
|
||||
}
|
||||
}
|
||||
var seed [SeedSize]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
t.Fatalf("seed entropy: %v", err)
|
||||
}
|
||||
setup, shares, err := DealAlgShares(params, committee, threshold, seed, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("DealAlgShares: %v", err)
|
||||
}
|
||||
sort.Slice(shares, func(i, j int) bool { return nodeIDLess(shares[i].NodeID, shares[j].NodeID) })
|
||||
|
||||
// Reference secret the attack must recover: s1 normalized to [0,q).
|
||||
refKM, err := deriveKeyMaterial(mode, &seed)
|
||||
if err != nil {
|
||||
t.Fatalf("deriveKeyMaterial(ref): %v", err)
|
||||
}
|
||||
refS1 := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
refS1[l] = refKM.s1[l]
|
||||
refS1[l].normalize()
|
||||
}
|
||||
|
||||
// The signing quorum (threshold members) + their eval points / Lagrange λ.
|
||||
quorum := make([]NodeID, threshold)
|
||||
evalPoints := make([]uint32, threshold)
|
||||
qshares := make([]*AlgShare, threshold)
|
||||
for i := 0; i < threshold; i++ {
|
||||
quorum[i] = shares[i].NodeID
|
||||
evalPoints[i] = shares[i].EvalPoint
|
||||
qshares[i] = shares[i]
|
||||
}
|
||||
|
||||
// ONE joint nonce, shared across the quorum (the reused nonce).
|
||||
var nonceID [32]byte
|
||||
nonceID[0] = 0xA1
|
||||
deal, err := DealNonceMPCDebug(setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("DealNonceMPCDebug: %v", err)
|
||||
}
|
||||
w1vec, err := unpackW1Vec(deal.Cert.W1, gamma2, K)
|
||||
if err != nil {
|
||||
t.Fatalf("unpackW1Vec: %v", err)
|
||||
}
|
||||
|
||||
// ── Part 1: the vulnerability is real — recover the WHOLE s1. ──
|
||||
//
|
||||
// Two messages, SAME nonce ⇒ two challenges; per-party partials
|
||||
// z_i = λ_i(y_i + c·s1_i) aggregate to z = ȳ + c·s1. We try candidate
|
||||
// second messages until (c_A − c_B) is invertible in R_q, then solve.
|
||||
msgA := []byte("transfer 1 LUX to alice — first use of the nonce")
|
||||
candidatesB := [][]byte{
|
||||
[]byte("transfer 1000000 LUX to mallory — REUSED nonce, message 2"),
|
||||
[]byte("mallory variant 2"),
|
||||
[]byte("mallory variant 3"),
|
||||
[]byte("mallory variant 4"),
|
||||
}
|
||||
|
||||
cA := challengeFor(mode, setup.tr, nil, msgA, w1vec, gamma2, K, tau)
|
||||
cAHat := cA
|
||||
cAHat.ntt()
|
||||
|
||||
aggregateFor := func(cHat *poly) polyVec {
|
||||
parts := make([]polyVec, threshold)
|
||||
for i := 0; i < threshold; i++ {
|
||||
lambda := LagrangeAtZeroQ(evalPoints[i], evalPoints)
|
||||
y := deal.YShares[quorum[i]]
|
||||
parts[i] = partialLinearMap(lambda, cHat, y, qshares[i].S1Share)
|
||||
}
|
||||
return FlatAggregateZ(parts, L)
|
||||
}
|
||||
zA := aggregateFor(&cAHat)
|
||||
|
||||
recovered := false
|
||||
for _, msgB := range candidatesB {
|
||||
cB := challengeFor(mode, setup.tr, nil, msgB, w1vec, gamma2, K, tau)
|
||||
cBHat := cB
|
||||
cBHat.ntt()
|
||||
zB := aggregateFor(&cBHat)
|
||||
|
||||
// d = c_A − c_B (normal domain).
|
||||
var d poly
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
d[j] = (cA[j] + (mldsaQ - cB[j]%mldsaQ)) % mldsaQ
|
||||
}
|
||||
|
||||
// (a) Assert the leak identity z_A − z_B == d·s1 via the package's
|
||||
// own NTT/Montgomery arithmetic — the public solvable system.
|
||||
dHat := d
|
||||
dHat.ntt()
|
||||
for l := 0; l < L; l++ {
|
||||
var diff poly
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
diff[j] = (zA[l][j] + (mldsaQ - zB[l][j]%mldsaQ)) % mldsaQ
|
||||
}
|
||||
s1Hat := refS1[l]
|
||||
s1Hat.reduceLe2Q()
|
||||
s1Hat.ntt()
|
||||
var ds poly
|
||||
ds.mulHat(&dHat, &s1Hat)
|
||||
ds.reduceLe2Q()
|
||||
ds.invNTT()
|
||||
ds.normalize()
|
||||
if ds != diff {
|
||||
t.Fatalf("leak identity (z_A−z_B == (c_A−c_B)·s1) failed at l=%d — math model wrong", l)
|
||||
}
|
||||
}
|
||||
|
||||
// (b) Actually RECOVER s1 from the public system (needs d invertible).
|
||||
M := negacyclicMatrix(&d)
|
||||
probe, okSolve := solveModQ(M, func() []uint32 {
|
||||
b := make([]uint32, mldsaN)
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
b[j] = (zA[0][j] + (mldsaQ - zB[0][j]%mldsaQ)) % mldsaQ
|
||||
}
|
||||
return b
|
||||
}())
|
||||
if !okSolve {
|
||||
continue // (c_A − c_B) singular — pick another second message
|
||||
}
|
||||
// verify coordinate 0 then solve the rest.
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
if probe[j] != refS1[0][j] {
|
||||
t.Fatalf("recovered s1[0] mismatch at coeff %d: got %d want %d", j, probe[j], refS1[0][j])
|
||||
}
|
||||
}
|
||||
for l := 1; l < L; l++ {
|
||||
b := make([]uint32, mldsaN)
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
b[j] = (zA[l][j] + (mldsaQ - zB[l][j]%mldsaQ)) % mldsaQ
|
||||
}
|
||||
x, ok2 := solveModQ(M, b)
|
||||
if !ok2 {
|
||||
t.Fatalf("solve l=%d unexpectedly singular", l)
|
||||
}
|
||||
for j := 0; j < mldsaN; j++ {
|
||||
if x[j] != refS1[l][j] {
|
||||
t.Fatalf("recovered s1[%d] mismatch at coeff %d", l, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
recovered = true
|
||||
t.Logf("VULN CONFIRMED: nonce reuse over 2 messages recovered the FULL %d-poly s1 (the master signing key) by solving (c_A−c_B)·s1 = z_A−z_B over Z_q", L)
|
||||
break
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatalf("could not find an invertible (c_A−c_B) across %d candidates (vanishingly unlikely)", len(candidatesB))
|
||||
}
|
||||
|
||||
// ── Part 2: the guard blocks the attack via the REAL signer API. ──
|
||||
//
|
||||
// Model ONE honest validator (share[0]) handed two signing requests on the
|
||||
// SAME nonce. It uses ONE shared ledger across both signer instances (a
|
||||
// validator's per-share ledger). The first emits its partial; the second
|
||||
// MUST be refused, so the attacker never gets the second partial.
|
||||
ledger := NewInMemoryNonceLedger()
|
||||
var sidA, sidB [32]byte
|
||||
copy(sidA[:], []byte("session-A-first-use"))
|
||||
copy(sidB[:], []byte("session-B-replay-attempt"))
|
||||
|
||||
signerA, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidA, nil, msgA, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("signerA: %v", err)
|
||||
}
|
||||
signerA.SetNonceLedger(ledger)
|
||||
if err := signerA.SetNonceShare(nonceID, deal.YShares[quorum[0]]); err != nil {
|
||||
t.Fatalf("signerA SetNonceShare: %v", err)
|
||||
}
|
||||
r1A, err := signerA.Round1(sidA, nonceID, deal.Cert)
|
||||
if err != nil {
|
||||
t.Fatalf("signerA Round1: %v", err)
|
||||
}
|
||||
if _, err := signerA.Round2(r1A, PartialInput{}); err != nil {
|
||||
t.Fatalf("signerA Round2 (first, honest use) must succeed: %v", err)
|
||||
}
|
||||
|
||||
// Second signing request on the SAME nonce, different message — the attack.
|
||||
signerB, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidB, nil, candidatesB[0], rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("signerB: %v", err)
|
||||
}
|
||||
signerB.SetNonceLedger(ledger) // SAME validator, SAME share, SAME ledger
|
||||
if err := signerB.SetNonceShare(nonceID, deal.YShares[quorum[0]]); err != nil {
|
||||
t.Fatalf("signerB SetNonceShare: %v", err)
|
||||
}
|
||||
r1B, err := signerB.Round1(sidB, nonceID, deal.Cert)
|
||||
if err != nil {
|
||||
t.Fatalf("signerB Round1: %v", err)
|
||||
}
|
||||
if _, err := signerB.Round2(r1B, PartialInput{}); err != ErrNonceReused {
|
||||
t.Fatalf("GATE A FAILED: nonce-reuse Round2 returned %v, want ErrNonceReused — the second partial was produced, key recovery is possible", err)
|
||||
}
|
||||
|
||||
// ── Part 3: relabel bypass is closed — same ȳ/w1 under a NEW nonceID. ──
|
||||
var nonceID2 [32]byte
|
||||
nonceID2[0] = 0xB2 // different label …
|
||||
relabeled := deal.Cert
|
||||
relabeled.NonceID = nonceID2 // … same W1 (same joint nonce material)
|
||||
signerC, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidB, nil, candidatesB[1], rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("signerC: %v", err)
|
||||
}
|
||||
signerC.SetNonceLedger(ledger)
|
||||
if err := signerC.SetNonceShare(nonceID2, deal.YShares[quorum[0]]); err != nil {
|
||||
t.Fatalf("signerC SetNonceShare: %v", err)
|
||||
}
|
||||
r1C, err := signerC.Round1(sidB, nonceID2, relabeled)
|
||||
if err != nil {
|
||||
t.Fatalf("signerC Round1: %v", err)
|
||||
}
|
||||
if _, err := signerC.Round2(r1C, PartialInput{}); err != ErrNonceReused {
|
||||
t.Fatalf("GATE A FAILED (relabel): reusing the same w1 under a new nonceID returned %v, want ErrNonceReused", err)
|
||||
}
|
||||
|
||||
t.Logf("GATE A PASS: nonce reuse mathematically recovers the full key, but the single-use guard refuses the second partial (ErrNonceReused) — incl. the relabel bypass — so the (c_A−c_B)·s1 system can never be assembled via honest signers")
|
||||
}
|
||||
Reference in New Issue
Block a user