mirror of
https://github.com/luxfi/corona.git
synced 2026-09-24 00:25:54 +00:00
Rewire BootstrapPedersen's no-reconstruct share-dealing onto the shared
github.com/luxfi/dkg library (vss + blame + ring.Ringtail()), dropping the
in-keyera dependence on corona/dkg2. corona keeps ONLY its scheme-specific
finalize: Path (a) noise flooding + Round_Xi, which turns the per-party secret
shares s_j into the LWE-shaped Ringtail public key bTilde = Round_Xi(A·s+e'').
The vss Pedersen aggregate T = A·s1+B·u is NOT corona's group key (its B·u term
is not a small LWE error), so vss is used purely for the no-reconstruct share
distribution + identifiable blame.
Mechanism:
- BootstrapPedersen draws per-party sampling seeds from the ceremony entropy
(same sequencing as before) and threads each into vss.Party.Round1, so the
χ draws — hence the Pedersen commits and the secret shares — are byte-
identical to the prior dkg2 path (cutover gates B/C/E).
- DealPedersen runs Round 1 + opens every sealed envelope; FinishBootstrapPedersen
runs Round 3 (Pedersen verify + aggregate) then Path (a) + KeyShare assembly.
The two-phase split is the test seam for identifiable abort (tamper an opened
share, observe ErrBootstrapPedersenAbort + re-checkable blame).
- Commit digests are recomputed under the corona HashSuite over the lattigo
wire bytes of vss's commits — byte-identical to dkg2's CommitDigest — so the
noise seed and transcript hash are preserved.
- AbortEvidence now carries luxfi/dkg blame.Complaint (accused-by-NodeID,
third-party re-checkable via vss.RecheckBadDelivery); the in-process channel
identities are derived deterministically and affect only the (immediately-
opened) envelope ciphertext, never any byte-stable output.
Proof: the frozen golden KAT (bTilde / transcript / shares digests for 5/3 and
3/2) PASSES against the vss path — the group key, transcript, and shares are
preserved byte-for-byte. Determinism, Bootstrap→sign→verify, dishonest-dealer
abort (+ RecheckBadDelivery), reanchor round-trip/abort, and the no-master-secret
structural checks all green. Public API (Bootstrap/KeyEra/EpochShareState/
BootstrapTranscript/GroupKey/KeyShare) unchanged.
307 lines
11 KiB
Go
307 lines
11 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package keyera
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/luxfi/corona/hash"
|
|
"github.com/luxfi/corona/sign"
|
|
|
|
dkgblame "github.com/luxfi/dkg/blame"
|
|
dkgvss "github.com/luxfi/dkg/vss"
|
|
)
|
|
|
|
// TestReanchorPedersen_RoundTrip — open a Pedersen-DKG era, rotate to a
|
|
// new committee with fresh keys via ReanchorPedersen, then drive the
|
|
// 2-round threshold-sign protocol under the new era's GroupKey. The
|
|
// resulting signature must verify under threshold.Verify.
|
|
//
|
|
// Acceptance criteria:
|
|
// - The new era has a fresh GroupKey distinct from the prior era's.
|
|
// - EraID monotonically increments.
|
|
// - GenesisEpoch + State.Epoch advance to prev.State.Epoch + 1.
|
|
// - The transcript is non-nil with a non-zero TranscriptHash.
|
|
// - Threshold signing under the new shares verifies under the new
|
|
// GroupKey.
|
|
// - HashSuiteID is inherited from the prior era when unspecified.
|
|
func TestReanchorPedersen_RoundTrip(t *testing.T) {
|
|
const thr, n = 3, 5
|
|
v1 := []string{"v1", "v2", "v3", "v4", "v5"}
|
|
|
|
era1, _, err := BootstrapPedersen(
|
|
hash.NewCoronaSHA3(),
|
|
thr, v1, 0, 1,
|
|
deterministicRand("reanchor-pedersen-rt-era1"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BootstrapPedersen era1: %v", err)
|
|
}
|
|
prevEpoch := era1.State.Epoch
|
|
prevGK := era1.GroupKey
|
|
prevEraID := era1.EraID
|
|
prevSuiteID := era1.HashSuiteID
|
|
|
|
v2 := []string{"v6", "v7", "v8", "v9", "v10"}
|
|
era2, transcript, err := ReanchorPedersen(era1, thr, v2, 0,
|
|
deterministicRand("reanchor-pedersen-rt-era2"))
|
|
if err != nil {
|
|
t.Fatalf("ReanchorPedersen: %v", err)
|
|
}
|
|
if era2 == nil || transcript == nil {
|
|
t.Fatal("ReanchorPedersen returned nil era or transcript")
|
|
}
|
|
if era2.GroupKey == prevGK {
|
|
t.Fatal("ReanchorPedersen returned same GroupKey pointer")
|
|
}
|
|
if era2.EraID != prevEraID+1 {
|
|
t.Fatalf("EraID: want %d got %d", prevEraID+1, era2.EraID)
|
|
}
|
|
if era2.GenesisEpoch != prevEpoch+1 {
|
|
t.Fatalf("GenesisEpoch: want %d got %d", prevEpoch+1, era2.GenesisEpoch)
|
|
}
|
|
if era2.State.Epoch != prevEpoch+1 {
|
|
t.Fatalf("State.Epoch: want %d got %d", prevEpoch+1, era2.State.Epoch)
|
|
}
|
|
if era2.HashSuiteID != prevSuiteID {
|
|
t.Fatalf("HashSuiteID inheritance: want %q got %q", prevSuiteID, era2.HashSuiteID)
|
|
}
|
|
if transcript.TranscriptHash == [32]byte{} {
|
|
t.Fatal("transcript hash is zero")
|
|
}
|
|
if len(transcript.BetaSerialized) != n {
|
|
t.Fatalf("transcript beta count: want %d got %d", n, len(transcript.BetaSerialized))
|
|
}
|
|
|
|
// Every new share is a real share (right dimension + GroupKey link).
|
|
for vName, ks := range era2.State.Shares {
|
|
if len(ks.SkShare) != sign.N {
|
|
t.Fatalf("validator %s: SkShare dim %d, want %d", vName, len(ks.SkShare), sign.N)
|
|
}
|
|
if ks.GroupKey != era2.GroupKey {
|
|
t.Fatalf("validator %s: GroupKey pointer mismatch", vName)
|
|
}
|
|
}
|
|
|
|
// Sign + verify under the NEW era. This binds the contract: the
|
|
// Pedersen-reanchored GroupKey accepts threshold-signed messages
|
|
// under the new shares without any code change in the sign path.
|
|
if !signAndVerify(t, era2, v2) {
|
|
t.Fatal("post-ReanchorPedersen signature failed to verify under new GroupKey")
|
|
}
|
|
}
|
|
|
|
// TestReanchorPedersen_NoMasterSecret — defense-in-depth: no party
|
|
// holds the new era's master secret s at any point during the
|
|
// rotation. The same structural argument that grounds
|
|
// BootstrapPedersen carries over because ReanchorPedersen routes
|
|
// through the identical dkg2/ + Path (a) path on the new era.
|
|
//
|
|
// Concrete checks:
|
|
// - dkg2.DKGSession exposes no field named "masterSecret" /
|
|
// "fullSecret" (lower-cased substring check).
|
|
// - Every distinct pair of new-committee SkShares disagrees on at
|
|
// least one coefficient (an orchestrator that wrote s into every
|
|
// share slot would collide; we catch it here).
|
|
// - Every new-committee Lambda is distinct (Lagrange weights cannot
|
|
// collide for n ≥ 2 at distinct evaluation points).
|
|
//
|
|
// This is structural, not runtime — the Go runtime cannot prove
|
|
// absence of a value across all paths. The public-BFT-safe property
|
|
// is established by review + this structural check + the Pedersen-DKG
|
|
// security proof under MLWE on [A|B] (LP-073 §07).
|
|
func TestReanchorPedersen_NoMasterSecret(t *testing.T) {
|
|
const thr, n = 3, 5
|
|
v1 := []string{"a", "b", "c", "d", "e"}
|
|
era1, _, err := BootstrapPedersen(
|
|
hash.NewCoronaSHA3(),
|
|
thr, v1, 0, 0,
|
|
deterministicRand("reanchor-pedersen-noms-era1"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BootstrapPedersen era1: %v", err)
|
|
}
|
|
|
|
v2 := []string{"f", "g", "h", "i", "j"}
|
|
era2, _, err := ReanchorPedersen(era1, thr, v2, 0,
|
|
deterministicRand("reanchor-pedersen-noms-era2"))
|
|
if err != nil {
|
|
t.Fatalf("ReanchorPedersen: %v", err)
|
|
}
|
|
|
|
// Structural assertion 1: the vss.Party exposes no master-secret
|
|
// accessor field. This is shared with BootstrapPedersen but worth
|
|
// re-asserting on the Reanchor path so a future regression on
|
|
// either is caught.
|
|
dkgType := reflect.TypeOf((*dkgvss.Party)(nil)).Elem()
|
|
for i := 0; i < dkgType.NumField(); i++ {
|
|
f := dkgType.Field(i)
|
|
lower := lowercase(f.Name)
|
|
if contains(lower, "mastersecret") || contains(lower, "fullsecret") {
|
|
t.Fatalf("dkgvss.Party has a forbidden field %q — master-secret state leaks", f.Name)
|
|
}
|
|
}
|
|
|
|
// Structural assertion 2: every new-committee pair of SkShares is
|
|
// distinct. If an orchestrator regression wrote s into every share
|
|
// slot, every pair would be byte-equal and we would catch it.
|
|
for nameA, ksA := range era2.State.Shares {
|
|
for nameB, ksB := range era2.State.Shares {
|
|
if nameA >= nameB {
|
|
continue
|
|
}
|
|
if sharesEqual(ksA, ksB) {
|
|
t.Fatalf("validators %s and %s share identical secret bytes — orchestrator leaked master secret on Reanchor",
|
|
nameA, nameB)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Structural assertion 3: every new-committee Lambda is distinct.
|
|
lambdaSeen := make(map[uint64]string)
|
|
for vName, ks := range era2.State.Shares {
|
|
key := ks.Lambda.Coeffs[0][0]
|
|
if prev, ok := lambdaSeen[key]; ok && prev != vName {
|
|
t.Fatalf("validators %s and %s share identical Lambda[0][0] — Lagrange weights degenerate on Reanchor",
|
|
prev, vName)
|
|
}
|
|
lambdaSeen[key] = vName
|
|
}
|
|
}
|
|
|
|
// TestReanchorPedersen_DishonestParty — one party broadcasts an
|
|
// inconsistent contribution during the Pedersen-DKG rounds. The
|
|
// Reanchor orchestrator must return ErrBootstrapPedersenAbort with an
|
|
// AbortEvidence naming the offending sender; the era and transcript
|
|
// are nil — the chain stays at the previous era.
|
|
//
|
|
// We drive the abort path through FinishBootstrapPedersen because the
|
|
// kernel ReanchorPedersen call always produces honest contributions.
|
|
// FinishBootstrapPedersen is the supported tampering entry; the
|
|
// behaviour is byte-identical to ReanchorPedersen on the abort path
|
|
// because ReanchorPedersen just delegates to BootstrapPedersen with
|
|
// era-id-and-epoch adjustments after the orchestrator returns.
|
|
func TestReanchorPedersen_DishonestParty(t *testing.T) {
|
|
const thr, n = 3, 5
|
|
validators := []string{"v1", "v2", "v3", "v4", "v5"}
|
|
suite := hash.NewCoronaSHA3()
|
|
|
|
rng := deterministicRand("reanchor-pedersen-dishonest")
|
|
seeds := make([][]byte, n)
|
|
for i := 0; i < n; i++ {
|
|
seeds[i] = make([]byte, sign.KeySize)
|
|
if _, err := rng.Read(seeds[i]); err != nil {
|
|
t.Fatalf("rng.Read[%d]: %v", i, err)
|
|
}
|
|
}
|
|
contribs, err := DealPedersen(suite, thr, validators, seeds)
|
|
if err != nil {
|
|
t.Fatalf("DealPedersen: %v", err)
|
|
}
|
|
|
|
// Tamper sender 4's share as recipient 1 opened it. Round 3 from
|
|
// recipient 1's perspective must name sender 4 as the bad actor.
|
|
contribs.inputs[1][4].Share[0].Coeffs[0][0] ^= 0x42
|
|
|
|
// The Reanchor abort path shares its semantics with the Bootstrap
|
|
// abort path: FinishBootstrapPedersen is the kernel entrypoint and
|
|
// the wrapping for era-id / epoch adjustments in ReanchorPedersen
|
|
// occurs strictly AFTER a successful return.
|
|
era, transcript, err := FinishBootstrapPedersen(suite, thr, validators, 0, 1, contribs)
|
|
if era != nil || transcript != nil {
|
|
t.Fatal("expected (nil, nil) on Reanchor abort")
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected ErrBootstrapPedersenAbort, got nil")
|
|
}
|
|
if !errors.Is(err, ErrBootstrapPedersenAbort) {
|
|
t.Fatalf("expected ErrBootstrapPedersenAbort wrapping, got %v", err)
|
|
}
|
|
ev := ExtractAbortEvidence(err)
|
|
if ev == nil {
|
|
t.Fatal("AbortEvidence nil after ExtractAbortEvidence")
|
|
}
|
|
if _, ok := ev.Disqualified[4]; !ok {
|
|
t.Fatalf("expected sender 4 disqualified, disqualified=%v", ev.Disqualified)
|
|
}
|
|
if len(ev.Complaints) == 0 {
|
|
t.Fatal("expected at least one Complaint in evidence")
|
|
}
|
|
if ev.Complaints[0].Accused != pedersenNodeID(4) {
|
|
t.Fatalf("complaint.Accused: want NodeID of dealer 4, got %x", ev.Complaints[0].Accused)
|
|
}
|
|
if ev.Complaints[0].Reason != dkgblame.ReasonBadDelivery {
|
|
t.Fatalf("complaint.Reason: want bad-delivery got %v", ev.Complaints[0].Reason)
|
|
}
|
|
if ev.TranscriptHash == [32]byte{} {
|
|
t.Fatal("AbortEvidence.TranscriptHash is zero")
|
|
}
|
|
}
|
|
|
|
// TestReanchorTrustedDealer_LegacyAlias — confirm the legacy single-
|
|
// dealer alias still works (retained for HSM / TEE / publicly-
|
|
// observable foundation ceremonies). It is byte-equivalent to the
|
|
// pre-v0.7.4 Reanchor behaviour: same EraID / GenesisEpoch /
|
|
// State.Epoch progression and same shape KeyEra output.
|
|
//
|
|
// We assert byte-equivalence on bTilde across two calls with the same
|
|
// entropy stream — both runs consume identical bytes and traverse the
|
|
// identical Shamir kernel, so bTilde MUST agree.
|
|
func TestReanchorTrustedDealer_LegacyAlias(t *testing.T) {
|
|
const thr, n = 3, 3
|
|
// era1 uses BootstrapTrustedDealer because we exercise the t == n
|
|
// trusted-dealer ceremony path; the public-BFT Bootstrap (Pedersen-
|
|
// DKG) structurally requires t < n.
|
|
era1, err := BootstrapTrustedDealer(thr, []string{"a", "b", "c"}, 0, 1,
|
|
deterministicRand("reanchor-td-era1"))
|
|
if err != nil {
|
|
t.Fatalf("BootstrapTrustedDealer: %v", err)
|
|
}
|
|
prevEpoch := era1.State.Epoch
|
|
prevGK := era1.GroupKey
|
|
prevEraID := era1.EraID
|
|
|
|
era2A, err := ReanchorTrustedDealer(era1, thr, []string{"x", "y", "z"}, 0,
|
|
deterministicRand("reanchor-td-era2"))
|
|
if err != nil {
|
|
t.Fatalf("ReanchorTrustedDealer: %v", err)
|
|
}
|
|
era2B, err := ReanchorTrustedDealerWithSuite(era1, hash.NewCoronaSHA3(), thr,
|
|
[]string{"x", "y", "z"}, 0, deterministicRand("reanchor-td-era2"))
|
|
if err != nil {
|
|
t.Fatalf("ReanchorTrustedDealerWithSuite: %v", err)
|
|
}
|
|
|
|
if era2A.GroupKey == prevGK {
|
|
t.Fatal("ReanchorTrustedDealer returned same GroupKey pointer")
|
|
}
|
|
if era2A.EraID != prevEraID+1 {
|
|
t.Fatalf("EraID: want %d got %d", prevEraID+1, era2A.EraID)
|
|
}
|
|
if era2A.GenesisEpoch != prevEpoch+1 {
|
|
t.Fatalf("GenesisEpoch: want %d got %d", prevEpoch+1, era2A.GenesisEpoch)
|
|
}
|
|
if era2A.State.Epoch != prevEpoch+1 {
|
|
t.Fatalf("State.Epoch: want %d got %d", prevEpoch+1, era2A.State.Epoch)
|
|
}
|
|
|
|
// Byte-equivalence: ReanchorTrustedDealer (default suite inherited
|
|
// from Corona-SHA3) and ReanchorTrustedDealerWithSuite(Corona-SHA3)
|
|
// MUST produce identical bTilde — both go through BootstrapWithSuite
|
|
// on the same entropy stream.
|
|
if !reflect.DeepEqual(era2A.GroupKey.BTilde, era2B.GroupKey.BTilde) {
|
|
t.Fatal("ReanchorTrustedDealer diverges from ReanchorTrustedDealerWithSuite on bTilde — alias broken")
|
|
}
|
|
if !reflect.DeepEqual(era2A.State.Validators, era2B.State.Validators) {
|
|
t.Fatal("ReanchorTrustedDealer diverges from ReanchorTrustedDealerWithSuite on validators")
|
|
}
|
|
|
|
// The legacy alias must still sign + verify under the new GroupKey.
|
|
if !signAndVerify(t, era2A, []string{"x", "y", "z"}) {
|
|
t.Fatal("ReanchorTrustedDealer GroupKey did not accept threshold-signed message")
|
|
}
|
|
}
|