Files
corona/keyera/keyera_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

228 lines
7.4 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyera
import (
"io"
"testing"
"github.com/luxfi/corona/threshold"
"github.com/zeebo/blake3"
)
// TestBootstrapBuildsAndSigns confirms that Bootstrap returns a complete
// KeyShare set that can produce a verifying signature under the produced
// GroupKey. The public-BFT-safe Bootstrap routes through Pedersen-DKG
// which requires t < n (strict) for identifiable-abort detection, so
// we exercise the threshold setup (t = n-1, signing committee = full
// validator set).
func TestBootstrapBuildsAndSigns(t *testing.T) {
const tThr, n = 2, 3
validators := []string{"validator-A", "validator-B", "validator-C"}
era, _, err := Bootstrap(tThr, validators, 0, 0, deterministicRand("bootstrap-genesis"))
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
if era.GroupKey == nil {
t.Fatal("Bootstrap returned nil GroupKey")
}
if got := era.State.Threshold; got != tThr {
t.Fatalf("threshold: want %d got %d", tThr, got)
}
if got := len(era.State.Shares); got != n {
t.Fatalf("share count: want %d got %d", n, got)
}
if !signAndVerify(t, era, validators) {
t.Fatal("genesis signature failed to verify under GroupKey")
}
}
// TestReshareSameSetPreservesGroupKey runs Bootstrap then Reshare against
// the same validator set with the same threshold. The GroupKey pointer
// is unchanged after Reshare, and the new shares produce a signature
// that verifies under the unchanged GroupKey.
func TestReshareSameSetPreservesGroupKey(t *testing.T) {
const tThr = 2
validators := []string{"v1", "v2", "v3"}
era, _, err := Bootstrap(tThr, validators, 0, 0, deterministicRand("genesis-A"))
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
gkBefore := era.GroupKey
if _, err := era.Reshare(validators, tThr, deterministicRand("reshare-1")); err != nil {
t.Fatalf("Reshare: %v", err)
}
if era.GroupKey != gkBefore {
t.Fatal("GroupKey pointer changed across Reshare; the era invariant is broken")
}
if got := era.State.Epoch; got != 1 {
t.Fatalf("epoch: want 1 got %d", got)
}
if !signAndVerify(t, era, validators) {
t.Fatal("post-reshare signature failed to verify under unchanged GroupKey")
}
}
// TestReshareNewCommitteePreservesGroupKey rotates onto a different
// validator set with a different threshold. The new committee's shares
// produce a signature that verifies under the unchanged GroupKey.
func TestReshareNewCommitteePreservesGroupKey(t *testing.T) {
const tOld = 2
const tNew = 4
oldSet := []string{"v1", "v2", "v3"}
newSet := []string{"v4", "v5", "v6", "v7", "v8"}
era, _, err := Bootstrap(tOld, oldSet, 0, 0, deterministicRand("genesis-B"))
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
gkBefore := era.GroupKey
if _, err := era.Reshare(newSet, tNew, deterministicRand("reshare-set-rotation")); err != nil {
t.Fatalf("Reshare: %v", err)
}
if era.GroupKey != gkBefore {
t.Fatal("GroupKey pointer changed across Reshare; the era invariant is broken")
}
if got := len(era.State.Validators); got != len(newSet) {
t.Fatalf("validator count after reshare: want %d got %d", len(newSet), got)
}
if got := era.State.Threshold; got != tNew {
t.Fatalf("threshold after reshare: want %d got %d", tNew, got)
}
if !signAndVerify(t, era, newSet) {
t.Fatal("new-committee signature failed to verify under unchanged GroupKey")
}
}
// TestReanchorOpensNewEra verifies Reanchor produces a fresh GroupKey
// while monotonically advancing the epoch and bumping the EraID.
//
// The public-BFT-safe lifecycle (Bootstrap → Reshare → Reanchor) all
// route through Pedersen-DKG which requires t < n (strictly less
// than) for identifiable-abort detection. The legacy t == n behaviour
// remains available via BootstrapTrustedDealer / ReanchorTrustedDealer.
func TestReanchorOpensNewEra(t *testing.T) {
era, _, err := Bootstrap(2, []string{"a", "b", "c"}, 0, 1, deterministicRand("era-1"))
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
if _, err := era.Reshare([]string{"a", "b", "c"}, 2, deterministicRand("reshare-x")); err != nil {
t.Fatalf("Reshare: %v", err)
}
prevEpoch := era.State.Epoch
prevGK := era.GroupKey
prevEraID := era.EraID
era2, transcript, err := Reanchor(era, 2, []string{"d", "e", "f"}, 0, deterministicRand("era-2"))
if err != nil {
t.Fatalf("Reanchor: %v", err)
}
if transcript == nil {
t.Fatal("Reanchor returned nil transcript; public-BFT Reanchor must commit a transcript")
}
if era2.GroupKey == prevGK {
t.Fatal("Reanchor returned the same GroupKey pointer; expected fresh key")
}
if got := era2.GenesisEpoch; got != prevEpoch+1 {
t.Fatalf("genesis epoch: want %d got %d", prevEpoch+1, got)
}
if got := era2.State.Epoch; got != prevEpoch+1 {
t.Fatalf("state epoch: want %d got %d", prevEpoch+1, got)
}
if got := era2.EraID; got != prevEraID+1 {
t.Fatalf("era id: want %d got %d", prevEraID+1, got)
}
}
// TestReshareErrors covers the input-validation surface.
func TestReshareErrors(t *testing.T) {
era, _, err := Bootstrap(2, []string{"a", "b", "c"}, 0, 0, deterministicRand("err"))
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
if _, err := era.Reshare(nil, 2, nil); err == nil {
t.Error("expected error for empty validators")
}
if _, err := era.Reshare([]string{"x", "y"}, 0, nil); err == nil {
t.Error("expected error for threshold < 1")
}
if _, err := era.Reshare([]string{"x", "y"}, 3, nil); err == nil {
t.Error("expected error for threshold > n")
}
var nilEra *KeyEra
if _, err := nilEra.Reshare([]string{"a", "b"}, 1, nil); err == nil {
t.Error("expected error for nil receiver")
}
}
// signAndVerify drives the threshold-signing protocol on the era's
// current state and returns true iff the resulting signature verifies
// under the era's GroupKey.
func signAndVerify(t *testing.T, era *KeyEra, validators []string) bool {
t.Helper()
signersByVal := make(map[string]*threshold.Signer, len(validators))
for _, v := range validators {
ks := era.State.Shares[v]
if ks == nil {
t.Fatalf("missing share for %s", v)
}
signersByVal[v] = threshold.NewSigner(ks)
}
signerIndices := make([]int, 0, len(validators))
for _, v := range validators {
signerIndices = append(signerIndices, era.State.Shares[v].Index)
}
const sessionID = 7
prfKey := []byte("pulsar-keyera-test-prf-key-32-bytes")[:32]
const message = "pulsar-keyera-test-message"
round1Data := make(map[int]*threshold.Round1Data, len(validators))
for _, v := range validators {
r1, err := signersByVal[v].Round1(sessionID, prfKey, signerIndices)
if err != nil {
t.Fatalf("Round1 for %s: %v", v, err)
}
round1Data[era.State.Shares[v].Index] = r1
}
round2Data := make(map[int]*threshold.Round2Data, len(validators))
for _, v := range validators {
r2, err := signersByVal[v].Round2(sessionID, message, prfKey, signerIndices, round1Data)
if err != nil {
t.Fatalf("Round2 for %s: %v", v, err)
}
round2Data[r2.PartyID] = r2
}
finalSig, err := signersByVal[validators[0]].Finalize(round2Data)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
return threshold.Verify(era.GroupKey, message, finalSig)
}
// deterministicRand returns an unbounded byte stream derived from a
// seed string for KAT-replay tests. Backed by BLAKE3-keyed XOF so the
// reshare kernel can pull as many bytes as it needs.
func deterministicRand(seed string) io.Reader {
h := blake3.New()
_, _ = h.Write([]byte("pulsar.keyera.test.rng.v1"))
_, _ = h.Write([]byte(seed))
return h.Digest()
}