Files
corona/threshold/verify_batch_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

159 lines
4.1 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package threshold
import (
"errors"
"fmt"
"testing"
)
// freshSig produces one (groupKey, message, sig) triple by running a
// full 2-of-3 ceremony. The verifier in this package is pure, so the
// ceremony is the only realistic way to obtain a Corona signature
// (Sign1/Sign2/Combine are the only public entry points).
func freshSig(t testing.TB, message string) (*GroupKey, *Signature) {
t.Helper()
shares, groupKey, err := GenerateKeys(2, 3, nil)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
}
signers := make([]*Signer, 3)
for i, share := range shares {
signers[i] = NewSigner(share)
}
const sessionID = 1
prfKey := []byte("verify_batch-prf-key-32-bytes!!!")
signerIDs := []int{0, 1, 2}
round1 := make(map[int]*Round1Data)
for _, s := range signers {
d, err := s.Round1(sessionID, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
round1[d.PartyID] = d
}
round2 := make(map[int]*Round2Data)
for _, s := range signers {
d, err := s.Round2(sessionID, message, prfKey, signerIDs, round1)
if err != nil {
t.Fatalf("Round2: %v", err)
}
round2[d.PartyID] = d
}
sig, err := signers[0].Finalize(round2)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
return groupKey, sig
}
// TestVerifyBatch_AllValid asserts a batch of N valid signatures all
// report true and VerifyBatchAll returns true.
func TestVerifyBatch_AllValid(t *testing.T) {
const n = 4
gks := make([]*GroupKey, n)
msgs := make([]string, n)
sigs := make([]*Signature, n)
for i := 0; i < n; i++ {
msg := fmt.Sprintf("corona-batch-msg-%d", i)
gk, sig := freshSig(t, msg)
gks[i] = gk
msgs[i] = msg
sigs[i] = sig
}
results, err := VerifyBatch(gks, msgs, sigs)
if err != nil {
t.Fatalf("VerifyBatch err=%v", err)
}
if len(results) != n {
t.Fatalf("results len=%d, want %d", len(results), n)
}
for i, ok := range results {
if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
all, err := VerifyBatchAll(gks, msgs, sigs)
if err != nil || !all {
t.Errorf("VerifyBatchAll ok=%v err=%v, want (true, nil)", all, err)
}
}
// TestVerifyBatch_OneCorrupt asserts a single message-substitution
// corruption is localised: results[i] = false for the swapped entry,
// true for all the rest, and VerifyBatchAll returns false.
func TestVerifyBatch_OneCorrupt(t *testing.T) {
const n = 3
const badIdx = 1
gks := make([]*GroupKey, n)
msgs := make([]string, n)
sigs := make([]*Signature, n)
for i := 0; i < n; i++ {
msg := fmt.Sprintf("corona-batch-msg-%d", i)
gk, sig := freshSig(t, msg)
gks[i] = gk
msgs[i] = msg
sigs[i] = sig
}
// Verify under the WRONG message for the bad index — the signature
// was made over msgs[badIdx]; we ask the verifier to check against
// a different string, which the FIPS 204 verifier rejects.
msgs[badIdx] = "corona-batch-wrong-message"
results, err := VerifyBatch(gks, msgs, sigs)
if err != nil {
t.Fatalf("VerifyBatch err=%v", err)
}
for i, ok := range results {
if i == badIdx {
if ok {
t.Errorf("result[%d] = true, want false (corrupted entry)", i)
}
} else if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
all, err := VerifyBatchAll(gks, msgs, sigs)
if err != nil {
t.Fatalf("VerifyBatchAll err=%v", err)
}
if all {
t.Error("VerifyBatchAll = true, want false")
}
}
// TestVerifyBatch_StructuralMismatch asserts length-mismatched slices
// surface as ErrBatchSizeMismatch with a nil results slice.
func TestVerifyBatch_StructuralMismatch(t *testing.T) {
gks := []*GroupKey{nil, nil}
msgs := []string{"a", "b", "c"}
sigs := []*Signature{nil, nil}
results, err := VerifyBatch(gks, msgs, sigs)
if !errors.Is(err, ErrBatchSizeMismatch) {
t.Errorf("err = %v, want ErrBatchSizeMismatch", err)
}
if results != nil {
t.Errorf("results = %v, want nil", results)
}
}
// TestVerifyBatch_Empty asserts an empty batch is (nil, nil).
func TestVerifyBatch_Empty(t *testing.T) {
results, err := VerifyBatch(nil, nil, nil)
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if results != nil {
t.Errorf("results = %v, want nil", results)
}
}