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:
2026-06-27 20:58:24 -07:00
parent 2962fd5c15
commit 789ed0adfc
3 changed files with 644 additions and 18 deletions
+86 -18
View File
@@ -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)
+194
View File
@@ -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
}
+364
View File
@@ -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")
}