Files
corona/keyera/cutover_gate_test.go
T
zeekay 259a1a2cae feat(keyera): drive the dealerless DKG through luxfi/dkg vss
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.
2026-06-27 21:55:51 -07:00

441 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyera
// cutover_gate_test.go — the Phase-3a cutover gate for replacing corona/dkg2's
// hand-rolled Pedersen-VSS DKG with the shared github.com/luxfi/dkg library
// (vss + ring), parameterized by ring.Ringtail().
//
// Before any rewire, this gate proves — byte-for-byte — that luxfi/dkg's
// Ringtail binding reproduces corona/dkg2's KAT-pinned arithmetic:
//
// (A) the public Pedersen matrices A, B are byte-identical;
// (B) vss's REAL Round1 produces byte-identical Pedersen commits for the same
// per-party sampling seed (same χ sampler, same NTT/Mont convention);
// (C) the secret share f_i(j) (Horner eval) is byte-identical;
// (D) the group-key NTT convention: dkg2's Round2 b_ped uses IMForm+INTT
// (R^{-1}-scaled), vss's group commit T uses INTT-only (TRUE A·s1+B·u);
// the two differ by EXACTLY the Montgomery factor R. corona's PRODUCTION
// group key (keyera Path-(a) noise flooding) is the TRUE A·s+e'' — it does
// NOT use dkg2's b_ped — so it is convention-aligned with vss's TRUE T.
//
// (A)(C) are the share-dealing core: if they hold, a vss-backed Bootstrap that
// keeps corona's own Path-(a) finalize preserves the group key + shares
// byte-for-byte. (D) pins PIN 4 of HANDOFF-PHASE3.md against ground truth.
import (
"bytes"
"crypto/rand"
"io"
"testing"
"github.com/luxfi/corona/dkg2"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/sign"
dkgchannel "github.com/luxfi/dkg/channel"
dkgring "github.com/luxfi/dkg/ring"
dkgvss "github.com/luxfi/dkg/vss"
lring "github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/structs"
)
// polysEqual reports byte-equality of two single-modulus polynomials over all
// coefficients (Coeffs[0]); the corona ring is single-level so this is total.
func polysEqual(a, b lring.Poly) bool {
if len(a.Coeffs) != len(b.Coeffs) {
return false
}
for lvl := range a.Coeffs {
if len(a.Coeffs[lvl]) != len(b.Coeffs[lvl]) {
return false
}
for i := range a.Coeffs[lvl] {
if a.Coeffs[lvl][i] != b.Coeffs[lvl][i] {
return false
}
}
}
return true
}
func vecsEqual(a, b structs.Vector[lring.Poly]) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !polysEqual(a[i], b[i]) {
return false
}
}
return true
}
// TestCutover_RingtailMatricesByteIdentical — gate (A). luxfi/dkg's
// ring.Ringtail() must derive the SAME public Pedersen matrices A, B as
// corona/dkg2.DeriveA/DeriveB (both BLAKE3("corona.dkg2.{A,B}.v1") → KeyedPRNG
// → uniform → NTT-Mont). This is the cutover gate: matrix divergence would make
// every commit, share, and group key diverge.
func TestCutover_RingtailMatricesByteIdentical(t *testing.T) {
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
coronaA := dkg2.DeriveA(dkgParams.R)
coronaB := dkg2.DeriveB(dkgParams.R)
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
if len(prof.A) != len(coronaA) || len(prof.B) != len(coronaB) {
t.Fatalf("matrix row mismatch: A %d/%d B %d/%d",
len(prof.A), len(coronaA), len(prof.B), len(coronaB))
}
for i := range coronaA {
for j := range coronaA[i] {
if !polysEqual(prof.A[i][j], coronaA[i][j]) {
t.Fatalf("A[%d][%d] byte-divergent between Ringtail() and dkg2.DeriveA", i, j)
}
if !polysEqual(prof.B[i][j], coronaB[i][j]) {
t.Fatalf("B[%d][%d] byte-divergent between Ringtail() and dkg2.DeriveB", i, j)
}
}
}
t.Logf("Ringtail() A,B byte-identical to dkg2.DeriveA/DeriveB over all %dx%d slots", len(coronaA), len(coronaA[0]))
}
// seededGateRng returns a reader that yields seed first (the 32 bytes vss
// Party.Round1 consumes as its sampling seed) then a deterministic tail (KEM
// encapsulation randomness — does NOT affect the commits, which are computed
// before any sealing). This makes vss's χ draws identical to a corona/dkg2
// Round1WithSeed(seed) run.
func seededGateRng(seed []byte) io.Reader {
tail := make([]byte, 1<<16)
// deterministic, distinct from seed; KEM randomness only.
for i := range tail {
tail[i] = byte(0x5A ^ (i & 0xFF))
}
return io.MultiReader(bytes.NewReader(seed), bytes.NewReader(tail))
}
// buildVSSParty0 constructs party 0 of an n-party vss committee with a directory
// of n fresh identities (identities affect only the sealed envelopes, never the
// public commits). Returns the party ready for Round1.
func buildVSSParty0(t *testing.T, prof *dkgring.Profile, n, thr int) *dkgvss.Party {
return buildVSSPartyAt(t, prof, 0, n, thr)
}
// buildVSSPartyAt constructs party idx of an n-party vss committee with a fresh
// identity directory. Identities affect only sealed envelopes; gate E drives
// Round3 with DealerInputs built directly from dkg2's (gate-B/C-proven identical)
// cleartext contributions, so envelope decryption is not exercised — only vss's
// real verifyPedersen + aggregateShare.
func buildVSSPartyAt(t *testing.T, prof *dkgring.Profile, idx, n, thr int) *dkgvss.Party {
t.Helper()
ids := make([]*dkgchannel.IdentityKey, n)
nodes := make([]dkgchannel.NodeID, n)
entries := make(map[dkgchannel.NodeID]*dkgchannel.IdentityPublicKey, n)
for i := 0; i < n; i++ {
id, err := dkgchannel.GenerateIdentity(rand.Reader)
if err != nil {
t.Fatalf("GenerateIdentity[%d]: %v", i, err)
}
ids[i] = id
nodes[i] = dkgchannel.NodeID{byte(i + 1), 0xC0}
entries[nodes[i]] = id.PublicKey()
}
dir, err := dkgchannel.NewIdentityDirectory(entries)
if err != nil {
t.Fatalf("NewIdentityDirectory: %v", err)
}
p, err := dkgvss.NewParty(prof, nodes[idx], ids[idx], idx, n, thr, nodes, dir, [32]byte{0xCA, 0xFE})
if err != nil {
t.Fatalf("NewParty: %v", err)
}
return p
}
// TestCutover_Round1CommitsByteIdentical — gate (B). vss's REAL Party.Round1
// (which internally samples χ, builds Pedersen commits C_k = A·NTT(c_k)+B·NTT(r_k))
// must produce commits byte-identical to corona/dkg2.Round1WithSeed for the same
// 32-byte sampling seed. This exercises vss's actual computeCommits path, not a
// reimplementation.
func TestCutover_Round1CommitsByteIdentical(t *testing.T) {
const n, thr = 5, 3
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x11*i + 7)
}
// corona/dkg2 reference.
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sess, err := dkg2.NewDKGSession(dkgParams, 0, n, thr, hash.NewCoronaSHA3())
if err != nil {
t.Fatalf("NewDKGSession: %v", err)
}
coronaOut, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed: %v", err)
}
// vss real Round1 with the same sampling seed.
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
party := buildVSSParty0(t, prof, n, thr)
vssOut, err := party.Round1(seededGateRng(seed))
if err != nil {
t.Fatalf("vss Party.Round1: %v", err)
}
if len(vssOut.Commits) != len(coronaOut.Commits) {
t.Fatalf("commit count: vss=%d corona=%d", len(vssOut.Commits), len(coronaOut.Commits))
}
for k := range coronaOut.Commits {
if !vecsEqual(vssOut.Commits[k], coronaOut.Commits[k]) {
t.Fatalf("commit[%d] byte-divergent between vss Round1 and dkg2 Round1WithSeed", k)
}
}
t.Logf("vss Party.Round1 commits byte-identical to dkg2.Round1WithSeed over all %d Pedersen commits", len(coronaOut.Commits))
}
// TestCutover_HornerShareByteIdentical — gate (C). The secret share f_i(j) =
// Σ_k c_{i,k}·(j+1)^k must be byte-identical. corona/dkg2 computes it with a
// big.Int Horner (polyMulScalar/polyAddCoeffwise); vss computes it with the
// lattice ring's Barrett-reduced ScalarMulVec + VecAdd. Both yield the canonical
// representative in [0,q). This replicates vss's hornerEval (3 exported-op lines)
// because vss seals the share into an envelope rather than exposing it; the
// arithmetic under test is vss's own ring.ScalarMulVec / ring.VecAdd.
func TestCutover_HornerShareByteIdentical(t *testing.T) {
const n, thr = 5, 3
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x09*i + 3)
}
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sess, err := dkg2.NewDKGSession(dkgParams, 0, n, thr, hash.NewCoronaSHA3())
if err != nil {
t.Fatalf("NewDKGSession: %v", err)
}
coronaOut, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed: %v", err)
}
// Re-sample the SAME c_{i,k} via luxfi/dkg's Ringtail χ sampler (identical
// PRNG, σ, bound, order) and Horner-evaluate with vss's exported ring ops.
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
r := prof.Ring
prng, err := dkgring.NewKeyedPRNG(seed)
if err != nil {
t.Fatalf("NewKeyedPRNG: %v", err)
}
cCoeffs := make([]dkgring.Vector, thr)
for k := 0; k < thr; k++ {
cCoeffs[k] = prof.SampleSecretVec(prng)
}
// (skip the r_{i,k} draws — not needed for the share value, but keep the
// PRNG honest for any future blind comparison.)
rCoeffs := make([]dkgring.Vector, thr)
for k := 0; k < thr; k++ {
rCoeffs[k] = prof.SampleSecretVec(prng)
}
_ = rCoeffs
// hornerEval at every recipient point j+1, exactly as vss/party.go does.
for j := 0; j < n; j++ {
x := uint64(j + 1)
share := dkgring.NewVec(r, prof.L)
for k := thr - 1; k >= 0; k-- {
if k < thr-1 {
dkgring.ScalarMulVec(r, share, x, share)
}
dkgring.VecAdd(r, share, cCoeffs[k], share)
}
if !vecsEqual(share, coronaOut.Shares[j]) {
t.Fatalf("share to recipient %d byte-divergent (vss Horner vs dkg2 Horner)", j)
}
}
t.Logf("vss-style Horner shares byte-identical to dkg2 shares for all %d recipients", n)
}
// TestCutover_GroupKeyConvention — gate (D), PIN 4. Pins the exact NTT-domain
// relationship between the two group-commit conventions against ground truth:
//
// - dkg2.Round2 b_ped = RoundXi( ConvertVectorFromNTT(ΣC) ) [IMForm + INTT]
// - vss group commit T = RoundXi( ConvertVecFromNTT(ΣC) ) [INTT only]
//
// Because dkg2's commits are plain-NTT (Montgomery cancels in the commit
// multiply), the extra IMForm injects exactly one R^{-1}. So dkg2's pre-round
// vector is vss's TRUE A·s1+B·u scaled by R^{-1}; equivalently MForm(dkg2) == vss.
// This asserts that identity coefficient-wise — proving the two conventions
// differ by EXACTLY the Montgomery factor R, not by anything message-dependent.
//
// The corona PRODUCTION group key is NEITHER of these: keyera Path-(a) noise
// flooding multiplies NTT-Mont operands (A · NTT-Mont(λ_j·s_j)), so the surviving
// R is correctly removed by ConvertVectorFromNTT, yielding the TRUE A·s+e”
// (convention-aligned with vss's TRUE T). dkg2's R^{-1}-scaled b_ped is an
// internal Round2 value the production path discards.
func TestCutover_GroupKeyConvention(t *testing.T) {
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
r := prof.Ring
// Deterministic plain-NTT "aggregated commit" ΣC ∈ R_q^K: derive it as a
// real commit A·NTT(c) + B·NTT(r) so it lives in the exact commit domain.
seed := make([]byte, sign.KeySize)
for i := range seed {
seed[i] = byte(0x33*i + 1)
}
prng, err := dkgring.NewKeyedPRNG(seed)
if err != nil {
t.Fatalf("NewKeyedPRNG: %v", err)
}
c := prof.SampleSecretVec(prng)
rr := prof.SampleSecretVec(prng)
cN := dkgring.CopyVec(c)
rN := dkgring.CopyVec(rr)
dkgring.NTTVec(r, cN)
dkgring.NTTVec(r, rN)
ac := dkgring.NewVec(r, prof.K)
br := dkgring.NewVec(r, prof.K)
dkgring.MatVecMul(r, prof.A, cN, ac)
dkgring.MatVecMul(r, prof.B, rN, br)
sumC := dkgring.NewVec(r, prof.K) // plain-NTT aggregated commit
dkgring.VecAdd(r, ac, br, sumC)
// vss convention: INTT only → TRUE A·s1+B·u (standard coeff form).
vssT := dkgring.CopyVec(sumC)
dkgring.ConvertVecFromNTT(r, vssT) // INTT only
// dkg2 convention: IMForm + INTT → R^{-1}-scaled.
base := prof.Ring.Base()
dkg2T := dkgring.CopyVec(sumC)
for i := range dkg2T {
base.IMForm(dkg2T[i], dkg2T[i])
base.INTT(dkg2T[i], dkg2T[i])
}
// They must DIFFER (the conventions genuinely diverge)...
if vecsEqual(vssT, dkg2T) {
t.Fatal("expected the IMForm+INTT and INTT-only conventions to differ; they did not")
}
// ...and differ by EXACTLY the Montgomery factor R: MForm(dkg2T) == vssT.
reMont := dkgring.CopyVec(dkg2T)
for i := range reMont {
base.MForm(reMont[i], reMont[i])
}
if !vecsEqual(reMont, vssT) {
t.Fatal("MForm(dkg2 b_ped pre-round) != vss T: the conventions do not differ by exactly R")
}
t.Log("PIN 4 confirmed: dkg2 b_ped (IMForm+INTT) = vss T (INTT-only) scaled by R^{-1}; " +
"production keyera Path-(a) key is the TRUE A·s+e'' (R-cancelled), convention-aligned with vss T")
}
// TestCutover_Round3AggregationByteIdentical — gate (E), the LAST byte-equality
// the production swap rests on. Feeding dkg2's (gate-B/C-proven byte-identical)
// per-dealer commits/shares/blinds into vss's REAL Round3 must:
//
// (1) pass vss's verifyPedersen for EVERY dealer — proving vss's verify accepts
// dkg2's contributions cross-implementation (despite dkg2's "NTT-Montgomery"
// vs vss's "plain-NTT" commit-domain comments, the bytes and the Pedersen
// identity agree); and
// (2) produce the aggregated secret share s_j = Σ_i f_i(j) BYTE-IDENTICAL to
// dkg2's Round2Identify.
//
// gate C proved each per-dealer share f_i(j) is identical; this pins the
// AGGREGATION + VERIFY step (vss.aggregateShare / vss.verifyPedersen ==
// dkg2.Round2Identify). With gates AE green, BootstrapPedersen can drive vss
// (Round1 → OpenDealerShare → Round3.ShareResult.Share) for the share-dealing DKG
// and keep corona's own Path-(a) noise-flooding finalize, preserving the group
// key + per-party shares byte-for-byte — i.e. deployed corona signatures survive.
func TestCutover_Round3AggregationByteIdentical(t *testing.T) {
const n, thr = 5, 3
suite := hash.NewCoronaSHA3()
// dkg2 reference: full Round1 for all n parties.
dkgParams, err := dkg2.NewParams()
if err != nil {
t.Fatalf("dkg2.NewParams: %v", err)
}
sessions := make([]*dkg2.DKGSession, n)
round1 := make([]*dkg2.Round1Output, n)
for i := 0; i < n; i++ {
sess, err := dkg2.NewDKGSession(dkgParams, i, n, thr, suite)
if err != nil {
t.Fatalf("NewDKGSession[%d]: %v", i, err)
}
sessions[i] = sess
seed := make([]byte, sign.KeySize)
for k := range seed {
seed[k] = byte(0x07*i + 0x13*k + 1)
}
out, err := sess.Round1WithSeed(seed)
if err != nil {
t.Fatalf("Round1WithSeed[%d]: %v", i, err)
}
round1[i] = out
}
prof, err := dkgring.Ringtail()
if err != nil {
t.Fatalf("ring.Ringtail: %v", err)
}
for j := 0; j < n; j++ {
// dkg2 aggregated, verified share s_j (the production Round 2 path).
shares := map[int]structs.Vector[lring.Poly]{}
blinds := map[int]structs.Vector[lring.Poly]{}
commits := map[int][]structs.Vector[lring.Poly]{}
for i := 0; i < n; i++ {
shares[i] = round1[i].Shares[j]
blinds[i] = round1[i].Blinds[j]
commits[i] = round1[i].Commits
}
dkg2Sj, _, _, badID, err := sessions[j].Round2Identify(shares, blinds, commits)
if err != nil {
t.Fatalf("dkg2 Round2Identify[%d] (badID=%d): %v", j, badID, err)
}
// vss real Round3 over the SAME (commits, share, blind) inputs.
party := buildVSSPartyAt(t, prof, j, n, thr)
inputs := make(map[int]*dkgvss.DealerInput, n)
for i := 0; i < n; i++ {
inputs[i] = &dkgvss.DealerInput{
Commits: round1[i].Commits,
Share: round1[i].Shares[j],
Blind: round1[i].Blinds[j],
}
}
res, fault, err := party.Round3(inputs)
if err != nil {
if fault != nil {
t.Fatalf("vss Round3[%d]: verifyPedersen REJECTED dkg2 dealer %d — commit-domain conventions diverge (blocker)", j, fault.DealerIndex)
}
t.Fatalf("vss Round3[%d]: %v", j, err)
}
if !vecsEqual(res.Share, dkg2Sj) {
t.Fatalf("aggregated s_%d byte-divergent: vss Round3 aggregateShare != dkg2 Round2Identify", j)
}
}
t.Logf("vss Round3 (verifyPedersen + aggregateShare) byte-identical to dkg2 Round2Identify for all %d recipients", n)
}