Files
corona/threshold/e2e_threshold_variants_test.go
T
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

174 lines
5.1 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package threshold
import (
"bytes"
"crypto/rand"
"testing"
"github.com/luxfi/corona/sign"
)
// TestE2EThresholdVariants exercises the threshold ceremony at the four
// committee sizes called out in the v0.7.0 work plan: (3,2), (5,3),
// (7,4), (10,7). All four must produce a valid signature.
//
// This is the canonical e2e suite mirroring Pulsar's reshare_test variants.
func TestE2EThresholdVariants(t *testing.T) {
cases := []struct {
t, n int
}{
{2, 3},
{3, 5},
{4, 7},
{7, 10},
}
for _, tc := range cases {
tc := tc
t.Run("", func(t *testing.T) {
shares, gk, err := GenerateKeys(tc.t, tc.n, rand.Reader)
if err != nil {
t.Fatalf("(%d,%d) GenerateKeys: %v", tc.t, tc.n, err)
}
if len(shares) != tc.n {
t.Fatalf("(%d,%d) wanted %d shares, got %d",
tc.t, tc.n, tc.n, len(shares))
}
signers := make([]*Signer, tc.n)
for i, share := range shares {
signers[i] = NewSigner(share)
}
signerIDs := make([]int, tc.n)
for i := range signerIDs {
signerIDs[i] = i
}
const sid = 1
prfKey := make([]byte, 32)
if _, err := rand.Read(prfKey); err != nil {
t.Fatal(err)
}
message := "corona e2e variants test"
// Round 1.
r1 := make(map[int]*Round1Data, tc.n)
for _, s := range signers {
d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("(%d,%d) Round1 party %d: %v",
tc.t, tc.n, s.share.Index, err)
}
r1[d.PartyID] = d
}
// Round 2.
r2 := make(map[int]*Round2Data, tc.n)
for _, s := range signers {
d, err := s.Round2(sid, message, prfKey, signerIDs, r1)
if err != nil {
t.Fatalf("(%d,%d) Round2 party %d: %v",
tc.t, tc.n, s.share.Index, err)
}
r2[d.PartyID] = d
}
// Finalize + Verify.
sig, err := signers[0].Finalize(r2)
if err != nil {
t.Fatalf("(%d,%d) Finalize: %v", tc.t, tc.n, err)
}
if !Verify(gk, message, sig) {
t.Fatalf("(%d,%d) Verify rejected an honest signature",
tc.t, tc.n)
}
})
}
}
// TestE2EKATReplayDeterminism asserts that running the same protocol
// inputs twice captures the post-hedging nonce contract:
//
// - With the DEFAULT crypto/rand nonce source (production), two signings
// on the same key set, sid, prfKey and message yield DISTINCT
// signature bytes — fresh per-signature nonces, so R is never reused
// even when sid repeats. This is the property that durably closes the
// CRIT-1 nonce-reuse leak.
// - With a PINNED deterministic nonce source (the KAT/oracle seam), two
// signings yield BYTE-IDENTICAL signatures — reproducibility for the
// cross-runtime KAT vectors.
func TestE2EKATReplayDeterminism(t *testing.T) {
seed := [32]byte{
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
}
const message = "corona KAT replay determinism"
// pinNonce: when true, pin each signer to a deterministic, per-party
// salt source so the run is byte-reproducible; otherwise leave the
// default crypto/rand (hedged production path).
runSign := func(shares []*KeyShare, pinNonce bool) []byte {
signers := make([]*Signer, len(shares))
for i, share := range shares {
signers[i] = NewSigner(share)
if pinNonce {
signers[i].SetNonceRand(sign.DeterministicNonceSource(seed[:], i))
}
}
signerIDs := make([]int, len(shares))
for i := range signerIDs {
signerIDs[i] = i
}
const sid = 1
prfKey := seed[:]
r1 := make(map[int]*Round1Data, len(shares))
for _, s := range signers {
d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
r1[d.PartyID] = d
}
r2 := make(map[int]*Round2Data, len(shares))
for _, s := range signers {
d, err := s.Round2(sid, message, prfKey, signerIDs, r1)
if err != nil {
t.Fatalf("Round2: %v", err)
}
r2[d.PartyID] = d
}
sig, err := signers[0].Finalize(r2)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if !Verify(&GroupKey{A: shares[0].GroupKey.A, BTilde: shares[0].GroupKey.BTilde, Params: shares[0].GroupKey.Params}, message, sig) {
t.Fatal("signature must verify")
}
b, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("MarshalBinary: %v", err)
}
return b
}
shares, _, err := GenerateKeys(2, 3, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
}
// Hedged production path: two runs MUST differ (fresh nonces) yet both
// verify. Distinct bytes here is the direct, observable evidence that
// R is not reused across signatures of the same (share, sid).
if bytes.Equal(runSign(shares, false), runSign(shares, false)) {
t.Fatal("hedged signing produced byte-identical signatures across two runs: nonce not fresh (CRIT-1 reuse risk)")
}
// Pinned deterministic path: two runs MUST be byte-identical
// (KAT reproducibility).
if !bytes.Equal(runSign(shares, true), runSign(shares, true)) {
t.Fatal("pinned-nonce signing not byte-reproducible across two runs")
}
}