Files
corona/threshold/no_reconstruct_sign_test.go
zeekay 69a2da1c44 test(threshold): pin Corona no-reconstruct signing (gate 5, trustless-by-default)
Structural + behavioural proof that the Corona threshold SIGNING path never
forms the full secret s, even transiently at sign time:

  GATE A (structural, go/ast): every signing/verify function body is scanned;
    none calls an across-party reconstruction primitive (reconstruct /
    interpolate / combineShares / recover* / ShamirSecretSharing /
    lagrangeAtZero / selectQuorum) and none invokes the trusted-dealer keygen
    (Gen / GenerateKeysTrustedDealer). Per-party Lagrange (party.Lambda on the
    party's OWN share) is the legitimate no-reconstruct mechanism and is NOT
    forbidden. Negative-control verified: injecting a reconstruct call into
    SignFinalize trips GATE A.

  GATE B (behavioural, reflect): Round1Data/Round2Data/Signature carry no
    share/sk/seed/lambda; Finalize ingests only map[int]*Round2Data (masked z
    partials) — the aggregator never holds a second party's secret.

  GATE C (independent verifier): a real (3,5) no-reconstruct ceremony, each
    Signer from exactly ONE share, verifies under the bare Corona verifier
    (public data only) and the stateless wire verifier; tamper rejected.

Verdict: Corona is a STRICT trustless lane — dealerless keygen (keyera.Bootstrap)
+ no-reconstruct signing. Sub-quorum-cannot-forge is pinned separately by
TestThresholdSubQuorumCannotForge.
2026-06-28 00:53:54 -07:00

378 lines
16 KiB
Go
Raw Permalink 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 threshold
// no_reconstruct_sign_test.go — the headline STRUCTURAL + BEHAVIOURAL proof
// that the Corona threshold SIGNING path is no-reconstruct: no party and no
// aggregator ever forms the full secret key s (or reconstructs it from shares)
// during signing, not even transiently. This is gate 5 of the trustless-by-
// default law (a reveal-and-aggregate / reconstruct-at-coordinator signing
// path is NOT a strict trustless lane).
//
// It mirrors the established pattern:
// - luxfi/dkg vss/noreconstruct_test.go (go/ast structural scan of the path)
// - luxfi/pulsar no_reconstruct_committee_test.go (combiner-sees-no-secret +
// independent-verifier gates)
//
// THE CRYPTOGRAPHIC DISTINCTION (why this scan is NOT vss's scan verbatim).
// Corona is threshold-Raccoon / Module-LWE. Its no-reconstruct signing works by
// each party computing, LOCALLY and from its OWN share only,
//
// z_i = R_i·u + maskPrime_i + (λ_i · s_i · c) − mask_i (sign.SignRound2)
//
// and the aggregator summing the MASKED partials
//
// z = Σ_j z_j = Σ_j R_j·u + c·Σ_j λ_j·s_j = R·u + c·s (sign.SignFinalize)
//
// The secret s = Σ_j λ_j·s_j appears ONLY as the coefficient of the public
// challenge c inside the aggregated, nonce-masked z; it is NEVER materialised.
// Crucially, the per-party Lagrange coefficient λ_i (party.Lambda) applied to
// the party's OWN share s_i IS the no-reconstruct mechanism — so, unlike vss
// (whose DKG output path derives the group key from PUBLIC commits and touches
// no Lagrange at all), this scan deliberately does NOT forbid per-party
// Lagrange. What it forbids is the ACROSS-PARTY recombination of shares into s
// (Lagrange-interpolation-at-0 / Shamir recombination). party.Lambda is read as
// a struct field, never as a call, so the call-target scan leaves it untouched.
//
// CLAIM DISCIPLINE. What this proves: "Corona threshold signing is
// no-reconstruct — the signing functions form no full secret and never invoke
// the trusted-dealer keygen; the only artifacts exchanged between parties carry
// no share; and the no-reconstruct ceremony's output verifies under the
// independent Corona verifier." Keygen is a SEPARATE concern: the production
// chain uses the dealerless keyera.Bootstrap (Pedersen VSS, no party ever holds
// s); sign.Gen / GenerateKeysTrustedDealer are the opt-in trusted-dealer KEYGEN
// footgun (loudly documented), and this proof's second arm is precisely that
// the signing path never reaches them.
import (
"bytes"
"crypto/rand"
"go/ast"
"go/parser"
"go/token"
"reflect"
"strings"
"testing"
"github.com/luxfi/corona/sign"
)
// ─────────────────────────────────────────────────────────────────────────
// GATE A (structural) — the signing functions reconstruct no secret.
// ─────────────────────────────────────────────────────────────────────────
// signPathFiles are the non-test source files that implement the Corona
// threshold signing + verification path. Paths are relative to this package
// dir (corona/threshold); the sign-kernel files live one directory up.
var signPathFiles = []string{
"../sign/sign.go",
"../sign/config.go",
"threshold.go",
"wire.go",
"verify_batch.go",
}
// signingFuncs is the EXACT set of load-bearing signing + verification
// functions whose bodies must be proven reconstruct-free. Verification helpers
// are included for completeness (they consume only public data). KEYGEN
// functions (Gen, GenerateKeysTrustedDealer) are deliberately ABSENT — they
// legitimately form s and are carved out below.
var signingFuncs = map[string]bool{
// sign kernel — the 2-round protocol + finalize.
"SignRound1": true,
"SignRound2Preprocess": true,
"SignRound2": true,
"SignFinalize": true,
// sign kernel — public verifier.
"Verify": true,
"VerifyWithSuite": true,
"CheckL2Norm": true,
// threshold orchestration — single-party signer + aggregate + verify.
"NewSigner": true,
"Round1": true,
"Round2": true,
"Finalize": true,
"VerifyBytes": true,
"VerifyBatch": true,
"VerifyBatchAll": true,
}
// mustSeeSigningFuncs are the signing functions that MUST be found and
// inspected for the scan to be non-vacuous. If any is renamed/removed the test
// fails loudly rather than silently passing on an empty scan.
var mustSeeSigningFuncs = []string{
"SignRound1", "SignRound2Preprocess", "SignRound2", "SignFinalize",
"NewSigner", "Round1", "Round2", "Finalize",
}
// keygenFuncs FORM or Shamir-share the master secret s. They are the ONLY
// functions permitted to touch the full secret, they are KEYGEN not signing,
// and the production chain replaces them with the dealerless keyera.Bootstrap.
// The structural proof's second arm: NO signing function calls one of these.
var keygenFuncs = map[string]bool{
"Gen": true,
"GenerateKeysTrustedDealer": true,
}
// forbiddenReconstruct are call-target identifier substrings that denote
// forming the FULL secret s = Σ_j λ_j·s_j across parties (Lagrange
// interpolation at 0 / Shamir recombination / share-combining / reshare master
// recovery). Per-party Lagrange (party.Lambda applied to the party's OWN share)
// is the legitimate no-reconstruct mechanism and is intentionally NOT here — it
// is a field read, not a call, and denotes no across-party recombination.
var forbiddenReconstruct = []string{
"reconstruct",
"interpolate",
"combineshare",
"recoversecret",
"recovershamir",
"recoverpoly",
"shamirsecretsharing",
"lagrangeatzero",
"selectquorum",
}
// calleeName extracts the called function's identifier from a call expression:
// the bare name for f(...) and the selector tail for pkg.F(...) / x.M(...).
func calleeName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
return fn.Sel.Name
}
return ""
}
// TestNoReconstruct_Sign_SourceStructural parses every signing/verification
// function in the Corona sign path with go/ast (comments excluded, so prose
// never trips the scan) and asserts that no function body (a) calls an
// across-party secret-reconstruction primitive, or (b) invokes the
// trusted-dealer keygen that forms s. Together these prove the signing path
// never reconstructs the secret.
func TestNoReconstruct_Sign_SourceStructural(t *testing.T) {
fset := token.NewFileSet()
seen := map[string]bool{}
inspectedFuncs := 0
for _, path := range signPathFiles {
file, err := parser.ParseFile(fset, path, nil, 0) // 0 = drop comments
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
name := fn.Name.Name
if !signingFuncs[name] {
continue // not a signing/verify function (e.g. Gen keygen, helpers)
}
seen[name] = true
inspectedFuncs++
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
callee := calleeName(call)
low := strings.ToLower(callee)
for _, bad := range forbiddenReconstruct {
if strings.Contains(low, bad) {
t.Errorf("%s: signing function %q CALLS %q (matches forbidden reconstruction primitive %q) — the signing path must never recombine shares into s",
path, name, callee, bad)
}
}
if keygenFuncs[callee] {
t.Errorf("%s: signing function %q CALLS keygen %q which forms the full secret s — signing must not reach the trusted-dealer keygen (use the dealerless keyera.Bootstrap shares)",
path, name, callee)
}
return true
})
}
}
if inspectedFuncs == 0 {
t.Fatal("no signing functions inspected — scan is vacuous (paths or names drifted)")
}
for _, want := range mustSeeSigningFuncs {
if !seen[want] {
t.Errorf("expected signing function %q was not found in the scanned sign path — the scan is incomplete (rename?), refusing to certify no-reconstruct", want)
}
}
t.Logf("GATE A (structural) PASS: %d signing/verify functions inspected; none recombines shares into s nor invokes the trusted-dealer keygen", inspectedFuncs)
}
// ─────────────────────────────────────────────────────────────────────────
// GATE B (behavioural, type) — the artifacts exchanged between parties, and
// the aggregator's inputs, carry NO secret. The aggregator therefore cannot
// reconstruct s even in principle: it never receives a second party's share.
// ─────────────────────────────────────────────────────────────────────────
// secretFieldNameTokens are field-name substrings that denote secret key
// material. None may appear on an inter-party message or on the final
// signature. (MAC tags — field "MACs" — are NOT secrets: they are
// authentication codes broadcast to bind the D matrix, and "macs" matches no
// token below.)
var secretFieldNameTokens = []string{
"skshare", "share", "secret", "seed", "lambda",
"mackey", "privatekey", "master", "prfkey",
}
// secretFieldTypeTokens are type-string substrings that must never ride an
// inter-party message: the per-party KeyShare struct above all.
var secretFieldTypeTokens = []string{"KeyShare"}
func assertNoSecretFields(t *testing.T, what string, typ reflect.Type) {
t.Helper()
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
ln := strings.ToLower(f.Name)
for _, tok := range secretFieldNameTokens {
if strings.Contains(ln, tok) {
t.Errorf("%s.%s — secret-bearing field name (matches %q) on a no-secret artifact", what, f.Name, tok)
}
}
ts := f.Type.String()
for _, tok := range secretFieldTypeTokens {
if strings.Contains(ts, tok) {
t.Errorf("%s.%s has secret-bearing type %s (matches %q)", what, f.Name, ts, tok)
}
}
}
}
// TestNoReconstruct_Sign_CombinerSeesNoSecret reflects over every artifact that
// crosses between parties (Round1Data, Round2Data) and the final Signature, and
// asserts none carries a share / sk / seed / Lagrange coefficient. The
// aggregator's Finalize ingests only map[int]*Round2Data — masked z partials —
// so it structurally cannot recombine shares: it never holds a second party's
// secret.
func TestNoReconstruct_Sign_CombinerSeesNoSecret(t *testing.T) {
assertNoSecretFields(t, "Round1Data", reflect.TypeOf(Round1Data{}))
assertNoSecretFields(t, "Round2Data", reflect.TypeOf(Round2Data{}))
assertNoSecretFields(t, "Signature", reflect.TypeOf(Signature{}))
// Finalize's only per-signer input is the value type of its map parameter.
// Pin that it is *Round2Data (the masked response), NOT *KeyShare.
finalize, ok := reflect.TypeOf(&Signer{}).MethodByName("Finalize")
if !ok {
t.Fatal("Signer has no Finalize method — signing surface drifted")
}
// method type: func(*Signer, map[int]*Round2Data) (*Signature, error)
ft := finalize.Func.Type()
if ft.NumIn() != 2 {
t.Fatalf("Finalize arity changed: got %d params (want receiver + 1)", ft.NumIn())
}
in := ft.In(1)
if in.Kind() != reflect.Map || in.Elem() != reflect.TypeOf(&Round2Data{}) {
t.Fatalf("Finalize ingests %s — the aggregator's input must be map[int]*Round2Data (masked partials), never a share type", in)
}
t.Logf("GATE B (behavioural) PASS: Round1Data/Round2Data/Signature carry no secret; aggregator ingests only masked z partials")
}
// ─────────────────────────────────────────────────────────────────────────
// GATE C (independent verifier) — a real no-reconstruct ceremony, where every
// Signer is constructed from exactly ONE share and parties exchange only the
// public Round1Data/Round2Data, produces a signature that the independent
// Corona verifier (sign.Verify, public data only) accepts; tamper is rejected.
// ─────────────────────────────────────────────────────────────────────────
func TestNoReconstruct_Sign_VerifiesUnderIndependentVerifier(t *testing.T) {
const tt, n = 3, 5
shares, gk, err := GenerateKeysTrustedDealer(tt, n, rand.Reader)
if err != nil {
t.Fatalf("keygen (%d,%d): %v", tt, n, err)
}
// Custody: each signer holds exactly ONE distinct share. No signer is ever
// handed another party's KeyShare — the no-reconstruct custody invariant.
signers := make([]*Signer, n)
seenIdx := map[int]bool{}
for i, sh := range shares {
if seenIdx[sh.Index] {
t.Fatalf("share index %d duplicated — single-share custody violated", sh.Index)
}
seenIdx[sh.Index] = true
signers[i] = NewSigner(sh)
}
signerIDs := make([]int, n)
for i := range signerIDs {
signerIDs[i] = i
}
const sid = 1
prfKey := make([]byte, 32)
if _, err := rand.Read(prfKey); err != nil {
t.Fatal(err)
}
msg := "corona no-reconstruct ceremony: independent verify"
// Round 1 — each party broadcasts only D + MACs (Round1Data: no share).
r1 := make(map[int]*Round1Data, n)
for _, s := range signers {
d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1 party %d: %v", s.share.Index, err)
}
r1[d.PartyID] = d
}
// Round 2 — each party emits only its masked z partial (Round2Data).
r2 := make(map[int]*Round2Data, n)
for _, s := range signers {
d, err := s.Round2(sid, msg, prfKey, signerIDs, r1)
if err != nil {
t.Fatalf("Round2 party %d: %v", s.share.Index, err)
}
r2[d.PartyID] = d
}
// Finalize — aggregator (party 0) sums masked partials; forms no secret.
sig, err := signers[0].Finalize(r2)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
// Independent verifier: the bare Corona verifier over PUBLIC data only
// (A, BTilde, z, c, Delta) — no signing/keygen state in the loop.
if !sign.Verify(
gk.Params.R, gk.Params.RXi, gk.Params.RNu,
sig.Z, gk.A, msg, gk.BTilde, sig.C, sig.Delta,
) {
t.Fatal("GATE C FAILED: independent Corona verifier REJECTED the no-reconstruct signature")
}
// Negative control: a one-character message tamper must be rejected.
bad := msg + "!"
if sign.Verify(
gk.Params.R, gk.Params.RXi, gk.Params.RNu,
sig.Z, gk.A, bad, gk.BTilde, sig.C, sig.Delta,
) {
t.Fatal("GATE C FAILED: independent verifier accepted a tampered message — binding broken")
}
// And via the stateless wire verifier (bytes-in, bool-out) for parity.
gkBytes, err := gk.MarshalBinary()
if err != nil {
t.Fatalf("group key marshal: %v", err)
}
sigBytes, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("signature marshal: %v", err)
}
if !VerifyBytes(gkBytes, msg, sigBytes) {
t.Fatal("GATE C FAILED: stateless wire verifier rejected the no-reconstruct signature")
}
if VerifyBytes(gkBytes, bad, sigBytes) {
t.Fatal("GATE C FAILED: stateless wire verifier accepted a tampered message")
}
if bytes.Equal(gkBytes, sigBytes) {
t.Fatal("group key and signature serialised identically — impossible, codec broken")
}
t.Logf("GATE C PASS: (%d,%d) no-reconstruct ceremony verifies under the independent Corona verifier; tamper rejected. Sub-quorum-cannot-forge is pinned separately by TestThresholdSubQuorumCannotForge.", tt, n)
}