mirror of
https://github.com/luxfi/corona.git
synced 2026-07-27 02:50:34 +00:00
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.
228 lines
7.4 KiB
Go
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()
|
|
}
|