mirror of
https://github.com/luxfi/corona.git
synced 2026-07-27 02:50:34 +00:00
merge Corona no-reconstruct-signing audit (v0.10.0): STRICT trustless lane certified — sign path proven no-reconstruct
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Corona -- Agent Knowledge Base
|
||||
|
||||
**Repository**: github.com/luxfi/corona
|
||||
**Latest Tag**: v0.8.0 (dealerless Pedersen DKG is the production keygen default; signing never reconstructs the master secret)
|
||||
**Latest Tag**: v0.10.0 (Corona certified a STRICT trustless lane: dealerless Pedersen DKG keygen + no-reconstruct signing — the no-reconstruct signing property is now PINNED by a code-level structural+behavioural gate, `threshold/no_reconstruct_sign_test.go`)
|
||||
**Status**: Production (consensus path); NIST MPTC submission package included. Sibling submission `luxfi/pulsar` is the M-LWE byte-equal FIPS 204 path.
|
||||
|
||||
## Purpose (one-liner)
|
||||
@@ -52,6 +52,8 @@ Pulsar does; Corona's Module-LWE construction has no FIPS standard target to ref
|
||||
|
||||
| SHA | Tag | Impact |
|
||||
|-----|-----|--------|
|
||||
| `500d319` | v0.10.0 | threshold: pin Corona NO-RECONSTRUCT signing (gate 5, trustless-by-default law). `threshold/no_reconstruct_sign_test.go` — GATE A go/ast scan (no signing function recombines shares into s nor invokes the trusted-dealer keygen; per-party `party.Lambda` allowed; negative-control verified), GATE B reflect (Round1Data/Round2Data/Signature carry no share), GATE C independent-verifier ceremony. Complements the Lean-4 `secret_aggregate_no_reconstruct` proof at the code level. Audit verdict: Corona is a STRICT trustless lane for STRICT_DUAL_PQ / POLARIS_MAX |
|
||||
| `7996ad7` | v0.9.0 | merge Phase-3a: corona keyera DKG cutover onto `luxfi/dkg` v0.2.0 (shared dealerless no-reconstruct VSS); `dkg2` package deleted |
|
||||
| `f08e2b5` | v0.8.0 | threshold: trusted-dealer keygen made explicit/footgun-only; dead `ReconstructSecret` dropped; sub-quorum soundness pinned — signing sums partials, master secret never formed |
|
||||
| `7c102d2` | v0.7.9 | threshold: reject duplicate PartyID in Round2/Finalize combine (kernel-boundary uniq guard) |
|
||||
| `6b4d5d5` | v0.7.8 | docs: correct stale 'no EC toolchain' claims; combine bridge discharged to lemma + jasminc available |
|
||||
|
||||
@@ -51,6 +51,23 @@ single-party Boschini signature — a STANDARD PQ assumption, **not** an
|
||||
implementation reconstruct. Model 2's EC side is **written, machine-recheck
|
||||
pending EasyCrypt**; its Lean core is machine-checked now.
|
||||
|
||||
*Code-level conformance gate (added v0.10.0).* The same no-reconstruct
|
||||
property is pinned at the IMPLEMENTATION level by
|
||||
`threshold/no_reconstruct_sign_test.go`: (GATE A) a `go/ast` scan of every
|
||||
signing/verify function proving none calls an across-party reconstruction
|
||||
primitive (`reconstruct`/`interpolate`/`combineShares`/`recover*`/
|
||||
`ShamirSecretSharing`/`lagrangeAtZero`) nor the trusted-dealer keygen
|
||||
(`Gen`/`GenerateKeysTrustedDealer`) — per-party `party.Lambda` on the party's
|
||||
OWN share is the legitimate no-reconstruct mechanism and is deliberately NOT
|
||||
forbidden; the gate's teeth are negative-control-verified (injecting a
|
||||
reconstruct call into `SignFinalize` trips it); (GATE B) a `reflect` gate
|
||||
proving the inter-party messages `Round1Data`/`Round2Data` and the final
|
||||
`Signature` carry no share/sk/seed/λ, so the aggregator never holds a second
|
||||
party's secret; (GATE C) a real (3,5) no-reconstruct ceremony verifying under
|
||||
the independent Corona verifier. This is a **test gate** (assurance tag:
|
||||
code-review/interop-tested at the construction level), NOT a proof-assistant
|
||||
artifact — it complements, and does not replace, the Lean core.
|
||||
|
||||
**Disclosure 2 — the no-leak property is NOT independently interop-tested.**
|
||||
Corona's KAT cross-validation is **Go↔C++ of the SAME construction**
|
||||
(`luxcpp/crypto/corona`), not against an independent verifier. There is no
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user