Files
corona/sign/nonce_reuse_test.go
Antje Worring dc15c54a47 harden: hedged 256-bit nonce key, DRY CT comparator, honest CT/proof docs
Closes the CRIT-1 residual (D1-2) sid-entropy gap and the CT/doc hygiene
gaps to bring Corona to the no-leak/no-gap bar. EasyCrypt proofs untouched.

PRIORITY 1 — anti-nonce-reuse / no-leak durability:
- The Round-1 nonce-PRF key no longer keys on a bare 64-bit sid. It now
  derives from a 256-bit domain-separated SessionID (primitives.DeriveSessionID:
  TranscriptHash("corona.sign.session-id.v1" || be64(sid) || T)) AND a fresh
  per-signature 256-bit hedge salt drawn inside the kernel from party.Rand
  (default crypto/rand). PRNGKeyForRound = PRF(skShare, "CoronaNonceV3" ||
  sessionID || salt). Hedging restores threshold-Raccoon's fresh-per-signature
  nonce posture: reuse durability no longer rests on the external consensus
  layer never reissuing an sid — even an sid collision yields distinct R with
  prob 2^-256. A deterministic 256-bit SessionID alone is necessary but NOT
  sufficient (it repeats when sid repeats); the salt is what closes the leak.
- SignRound1 is fail-closed: rejects an all-zero derived SessionID or all-zero
  salt with ErrDegenerateSession, and surfaces a short-read error. The
  consensus slot-uniqueness invariant is documented as a HARD precondition at
  SignRound1 and Signer.Round1 (no longer a buried comment).
- KAT/oracle determinism preserved via one seam: sign.DeterministicNonceSource
  (KeyedPRNG over seed || "corona.sign.nonce-salt.v1" || partyIndex), set on
  Party.Rand / Signer.SetNonceRand only by reproducibility harnesses.
- SignRound1 / Signer.Round1 now return an error; all call sites updated.
- KAT REGEN: only sign_verify_e2e.json changes (nonce-key bytes moved);
  transcript_hash.json / MAC / legacy PRNGKey vectors are byte-stable, proving
  the consensus-agreed transcript path was left untouched. Regenerated via
  `bash scripts/regen-kats.sh`; `--verify` confirms byte-determinism (10 files).
- Regression: sign/nonce_reuse_test.go proves same-(skShare,sid) yields distinct
  D (fresh R), pinned-nonce reproduces byte-identically, and the degenerate-
  session guard fires. TestE2EKATReplayDeterminism rewritten to assert both the
  hedged-differs and pinned-reproduces properties (was a defanged no-op).

PRIORITY 2 — constant-time hygiene:
- reshare/commit.go already used a constant-time comparator; the real gap was
  the verbatim duplication of constTimePolyEqual+uint64SliceToBytes across dkg2
  and reshare. Consolidated to one canonical utils.ConstantTimePolyEqual; both
  delegate (no dkg2<->reshare dependency). Orphaned imports removed.
- FullRankCheck and the reshare commit path are now covered in the CT review
  with their public-operand justification.

PRIORITY 3 — doc accuracy:
- CONSTANT-TIME-REVIEW.md rewritten Corona-specific and file:line-accurate:
  drops the stale Pulsar/lens/warp/secp256k1 content; audits the real call
  sites (hedged nonce key, masking PRF, lattigo samplers as the residual TCB
  axiom, utils.ConstantTimePolyEqual, CheckL2Norm/Verify/FullRankCheck big.Int
  variable-time on PUBLIC operands, activation/commit-digest array equality,
  keyera/reshare zeroization).
- PROOF-CLAIMS.md §1: threshold.Combine / sign.LocalSign (nonexistent) -> the
  real sign.Party.SignFinalize exposed as threshold.Signer.Finalize.
- threshold/threshold.go package doc: Ring-LWE -> Module-LWE.
2026-06-21 08:21:58 -07:00

166 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package sign
import (
"bytes"
"io"
"math/big"
"testing"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/primitives"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/lattice/v7/utils/structs"
)
// zeroReader yields an unlimited stream of 0x00 — a degenerate
// randomness source, used to drive SignRound1's fail-closed guard.
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// shortReader yields exactly n zero bytes total then EOFs, modelling a
// randomness source that cannot satisfy the 32-byte salt draw.
type shortReader struct{ n int }
func (s *shortReader) Read(p []byte) (int, error) {
if s.n <= 0 {
return 0, io.EOF
}
k := min(len(p), s.n)
for i := 0; i < k; i++ {
p[i] = 0
}
s.n -= k
return k, nil
}
// newReuseTestParty builds one fully-keyed signing Party (index 0 of a
// K=3 group) via the real Gen ceremony, ready to call SignRound1.
func newReuseTestParty(t *testing.T) (*Party, structs.Matrix[ring.Poly], []int) {
t.Helper()
K = 3
Threshold = K
t.Cleanup(func() { K = 0; Threshold = 0 })
r, err := ring.NewRing(1<<LogN, []uint64{Q})
if err != nil {
t.Fatal(err)
}
rXi, _ := ring.NewRing(1<<LogN, []uint64{QXi})
rNu, _ := ring.NewRing(1<<LogN, []uint64{QNu})
randomKey := make([]byte, KeySize)
prng, _ := sampling.NewKeyedPRNG(randomKey)
uniformSampler := ring.NewUniformSampler(prng, r)
p, _ := sampling.NewKeyedPRNG(randomKey)
us := ring.NewUniformSampler(p, r)
party := NewPartyWithSuite(0, r, rXi, rNu, us, hash.NewCoronaSHA3())
T := make([]int, K)
for i := 0; i < K; i++ {
T[i] = i
}
lagrange := primitives.ComputeLagrangeCoefficients(r, T, big.NewInt(int64(Q)))
A, skShares, seeds, macKeys, _ := Gen(r, rXi, uniformSampler, randomKey, lagrange)
party.SkShare = skShares[0]
party.Seed = seeds
party.MACKeys = macKeys[0]
return party, A, T
}
// dMatrixBytes serializes a D matrix to canonical wire bytes for
// equality comparison. D = A·R + E, so a change in the Round-1 nonce R
// is observable as a change in these bytes.
func dMatrixBytes(t *testing.T, D structs.Matrix[ring.Poly]) []byte {
t.Helper()
var buf bytes.Buffer
if _, err := D.WriteTo(&buf); err != nil {
t.Fatalf("D.WriteTo: %v", err)
}
return buf.Bytes()
}
// TestSignRound1_NonceFreshAcrossReusedSession is the CRIT-1 regression:
// two SignRound1 calls with the SAME skShare and the SAME sid MUST NOT
// produce the same Round-1 commitment D (hence not the same nonce R),
// because the kernel hedges the nonce key with fresh crypto/rand. If they
// ever collide, the R-reuse leak (z_sum Σ s_i·λ_i·c)·u^{-1} = R reopens.
func TestSignRound1_NonceFreshAcrossReusedSession(t *testing.T) {
party, A, T := newReuseTestParty(t)
const sid = 1 // deliberately reuse the SAME session id both times.
d1, _, err := party.SignRound1(A, sid, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1 #1: %v", err)
}
d2, _, err := party.SignRound1(A, sid, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1 #2: %v", err)
}
if bytes.Equal(dMatrixBytes(t, d1), dMatrixBytes(t, d2)) {
t.Fatal("two SignRound1 calls with the same (skShare, sid) produced " +
"byte-identical D: nonce R was reused — CRIT-1 reuse leak reopened")
}
}
// TestSignRound1_DeterministicWhenNoncePinned proves the KAT seam: with a
// pinned deterministic nonce source, two SignRound1 calls with identical
// inputs reproduce the same D byte-for-byte.
func TestSignRound1_DeterministicWhenNoncePinned(t *testing.T) {
seed := bytes.Repeat([]byte{0xA5}, 32)
run := func() []byte {
party, A, T := newReuseTestParty(t)
party.Rand = DeterministicNonceSource(seed, party.ID)
d, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1: %v", err)
}
return dMatrixBytes(t, d)
}
if !bytes.Equal(run(), run()) {
t.Fatal("pinned-nonce SignRound1 not byte-reproducible across runs")
}
}
// TestSignRound1_FailClosedOnDegenerateNonce asserts the fail-closed
// precondition guard: a randomness source that yields an all-zero salt
// (forfeiting nonce freshness) is rejected with ErrDegenerateSession, and
// a source that cannot supply 32 bytes surfaces the read error. The kernel
// MUST refuse to sign rather than proceed with a degraded nonce.
func TestSignRound1_FailClosedOnDegenerateNonce(t *testing.T) {
t.Run("zero-salt rejected", func(t *testing.T) {
party, A, T := newReuseTestParty(t)
party.Rand = zeroReader{}
_, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != ErrDegenerateSession {
t.Fatalf("want ErrDegenerateSession on zero salt, got %v", err)
}
})
t.Run("short randomness surfaces read error", func(t *testing.T) {
party, A, T := newReuseTestParty(t)
party.Rand = &shortReader{n: 4} // < SessionIDSize (32).
_, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err == nil {
t.Fatal("want a read error when the nonce source is short, got nil")
}
if err == ErrDegenerateSession {
t.Fatal("short read should surface io error, not be masked as ErrDegenerateSession")
}
})
}