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.
248 lines
6.5 KiB
Go
248 lines
6.5 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package threshold
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// TestSignatureRoundtrip generates a real threshold signature, marshals
|
|
// it, unmarshals it, and verifies the parsed signature against the
|
|
// parsed group key over the same message. Byte-equal serialization is
|
|
// enforced.
|
|
//
|
|
// NOTE: cannot t.Parallel — GenerateKeys mutates sign.K / sign.Threshold
|
|
// package globals (see threshold.go:123-124). The wire codec itself is
|
|
// pure; this is a kernel-side limitation we accept rather than refactor
|
|
// out from under sibling agents who own pulsar/.
|
|
func TestSignatureWireRoundtrip(t *testing.T) {
|
|
const tThr, n = 1, 2
|
|
shares, gk, err := GenerateKeys(tThr, n, rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("GenerateKeys: %v", err)
|
|
}
|
|
|
|
// Two-party signing (smallest committee that exercises the protocol).
|
|
signers := []int{0, 1}
|
|
prfKey := make([]byte, 32)
|
|
if _, err := rand.Read(prfKey); err != nil {
|
|
t.Fatalf("rand.Read prfKey: %v", err)
|
|
}
|
|
|
|
sigA := NewSigner(shares[0])
|
|
sigB := NewSigner(shares[1])
|
|
|
|
const sessionID = 42
|
|
r1A, err := sigA.Round1(sessionID, prfKey, signers)
|
|
if err != nil {
|
|
t.Fatalf("Round1 A: %v", err)
|
|
}
|
|
r1B, err := sigB.Round1(sessionID, prfKey, signers)
|
|
if err != nil {
|
|
t.Fatalf("Round1 B: %v", err)
|
|
}
|
|
r1Data := map[int]*Round1Data{0: r1A, 1: r1B}
|
|
|
|
msg := "corona-wire-roundtrip-test"
|
|
r2A, err := sigA.Round2(sessionID, msg, prfKey, signers, r1Data)
|
|
if err != nil {
|
|
t.Fatalf("Round2 A: %v", err)
|
|
}
|
|
r2B, err := sigB.Round2(sessionID, msg, prfKey, signers, r1Data)
|
|
if err != nil {
|
|
t.Fatalf("Round2 B: %v", err)
|
|
}
|
|
r2Data := map[int]*Round2Data{0: r2A, 1: r2B}
|
|
|
|
sig, err := sigA.Finalize(r2Data)
|
|
if err != nil {
|
|
t.Fatalf("Finalize: %v", err)
|
|
}
|
|
|
|
if !Verify(gk, msg, sig) {
|
|
t.Fatal("baseline Verify failed before serialization")
|
|
}
|
|
|
|
// MarshalBinary + UnmarshalBinary roundtrip for Signature.
|
|
sigBytes, err := sig.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("sig.MarshalBinary: %v", err)
|
|
}
|
|
var sigParsed Signature
|
|
if err := sigParsed.UnmarshalBinary(sigBytes); err != nil {
|
|
t.Fatalf("sig.UnmarshalBinary: %v", err)
|
|
}
|
|
|
|
// Second marshal must be byte-identical to the first.
|
|
sigBytes2, err := sigParsed.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("sigParsed.MarshalBinary: %v", err)
|
|
}
|
|
if !bytes.Equal(sigBytes, sigBytes2) {
|
|
t.Fatalf("Signature re-marshal not byte-equal: first %d bytes, second %d bytes", len(sigBytes), len(sigBytes2))
|
|
}
|
|
|
|
// MarshalBinary + UnmarshalBinary roundtrip for GroupKey.
|
|
gkBytes, err := gk.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("gk.MarshalBinary: %v", err)
|
|
}
|
|
var gkParsed GroupKey
|
|
if err := gkParsed.UnmarshalBinary(gkBytes); err != nil {
|
|
t.Fatalf("gk.UnmarshalBinary: %v", err)
|
|
}
|
|
|
|
gkBytes2, err := gkParsed.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("gkParsed.MarshalBinary: %v", err)
|
|
}
|
|
if !bytes.Equal(gkBytes, gkBytes2) {
|
|
t.Fatalf("GroupKey re-marshal not byte-equal: first %d, second %d", len(gkBytes), len(gkBytes2))
|
|
}
|
|
|
|
// Stateless VerifyBytes path — the surface threshold/pkg/thresholdd
|
|
// uses.
|
|
if !VerifyBytes(gkBytes, msg, sigBytes) {
|
|
t.Fatal("VerifyBytes returned false on a valid signature")
|
|
}
|
|
|
|
// Negative: corrupted signature MUST not verify.
|
|
corrupted := append([]byte(nil), sigBytes...)
|
|
corrupted[len(corrupted)-1] ^= 0x01
|
|
if VerifyBytes(gkBytes, msg, corrupted) {
|
|
t.Fatal("VerifyBytes returned true on a corrupted signature")
|
|
}
|
|
|
|
// Negative: wrong message MUST not verify.
|
|
if VerifyBytes(gkBytes, msg+"-tampered", sigBytes) {
|
|
t.Fatal("VerifyBytes returned true on a tampered message")
|
|
}
|
|
}
|
|
|
|
// TestSignatureWireRejectMalformed ensures the wire codec rejects
|
|
// short, magic-mismatched, and version-mismatched frames before any
|
|
// allocation.
|
|
func TestSignatureWireRejectMalformed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
buf []byte
|
|
want error
|
|
}{
|
|
{"empty", []byte{}, ErrWireFrameTooShort},
|
|
{"too-short", make([]byte, 4), ErrWireFrameTooShort},
|
|
{
|
|
"wrong-magic",
|
|
func() []byte {
|
|
b := make([]byte, 4+2+4*3)
|
|
// magic bytes != wireMagicSig
|
|
b[0] = 0xff
|
|
b[1] = 0xff
|
|
b[2] = 0xff
|
|
b[3] = 0xff
|
|
// version=1
|
|
b[5] = 0x01
|
|
return b
|
|
}(),
|
|
ErrWireMagicMismatch,
|
|
},
|
|
{
|
|
"wrong-version",
|
|
func() []byte {
|
|
b := make([]byte, 4+2+4*3)
|
|
// correct magic 0x434F5253
|
|
b[0] = 0x43
|
|
b[1] = 0x4F
|
|
b[2] = 0x52
|
|
b[3] = 0x53
|
|
// version=999
|
|
b[4] = 0x03
|
|
b[5] = 0xe7
|
|
return b
|
|
}(),
|
|
ErrWireVersionMismatch,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var sig Signature
|
|
err := sig.UnmarshalBinary(tc.buf)
|
|
if err == nil {
|
|
t.Fatalf("expected error %v, got nil", tc.want)
|
|
}
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("expected %v, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGroupKeyWireRejectMalformed mirrors the Signature rejection
|
|
// suite for GroupKey.
|
|
func TestGroupKeyWireRejectMalformed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
buf []byte
|
|
want error
|
|
}{
|
|
{"empty", []byte{}, ErrWireFrameTooShort},
|
|
{
|
|
"sig-magic-into-groupkey",
|
|
func() []byte {
|
|
// Signature magic but feed into GroupKey parser — must
|
|
// be rejected as mismatch, not accepted as anything.
|
|
b := make([]byte, 4+2+4*2)
|
|
b[0] = 0x43
|
|
b[1] = 0x4F
|
|
b[2] = 0x52
|
|
b[3] = 0x53
|
|
b[5] = 0x01
|
|
return b
|
|
}(),
|
|
ErrWireMagicMismatch,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var gk GroupKey
|
|
err := gk.UnmarshalBinary(tc.buf)
|
|
if err == nil {
|
|
t.Fatalf("expected error %v, got nil", tc.want)
|
|
}
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("expected %v, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVerifyBytesRejectsCrossWire ensures VerifyBytes rejects a
|
|
// GroupKey-magic frame in the Signature slot and vice versa — domain
|
|
// separation must be effective.
|
|
func TestVerifyBytesRejectsCrossWire(t *testing.T) {
|
|
shares, gk, err := GenerateKeys(1, 2, rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("GenerateKeys: %v", err)
|
|
}
|
|
_ = shares
|
|
gkBytes, err := gk.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("gk.MarshalBinary: %v", err)
|
|
}
|
|
// Feed gkBytes in the signature slot — VerifyBytes must reject.
|
|
if VerifyBytes(gkBytes, "msg", gkBytes) {
|
|
t.Fatal("VerifyBytes accepted GroupKey magic in signature slot")
|
|
}
|
|
// Feed empty bytes — also reject.
|
|
if VerifyBytes(gkBytes, "msg", nil) {
|
|
t.Fatal("VerifyBytes accepted nil signature")
|
|
}
|
|
if VerifyBytes(nil, "msg", gkBytes) {
|
|
t.Fatal("VerifyBytes accepted nil group key")
|
|
}
|
|
}
|