Files
Antje Worring dc15c54a47 harden: hedged 256-bit nonce key, DRY CT comparator, honest CT/proof docs
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.
2026-06-21 08:21:58 -07:00

494 lines
18 KiB
Go

package sign
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"errors"
"io"
"math/big"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/primitives"
"github.com/luxfi/corona/utils"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/lattice/v7/utils/structs"
)
// ErrDegenerateSession is returned by SignRound1 when the derived
// session identifier or the fresh hedge salt is all-zero. Both are
// fail-closed preconditions: an all-zero SessionID indicates an
// uninitialised/degenerate (sid, T), and an all-zero salt indicates the
// nonce-randomness source returned no entropy. Signing under either
// would forfeit the per-signature nonce-freshness on which the no-leak
// property rests, so the kernel refuses to proceed.
var ErrDegenerateSession = errors.New("corona/sign: degenerate session (zero SessionID or zero nonce salt); refusing to sign")
// Party struct holds all state and methods for a party in the protocol.
//
// Suite is the hash profile this party uses for every primitives.* call.
// NewParty defaults it to hash.Default() (Corona-SHA3). Operators that need
// to interoperate with old transcripts can override with NewCoronaBLAKE3().
type Party struct {
ID int
Ring *ring.Ring
RingXi *ring.Ring
RingNu *ring.Ring
UniformSampler *ring.UniformSampler
SkShare structs.Vector[ring.Poly]
Seed map[int][][]byte
R structs.Matrix[ring.Poly]
C ring.Poly
H structs.Vector[ring.Poly]
Lambda ring.Poly
D structs.Matrix[ring.Poly]
MACKeys map[int][]byte
MACs map[int][]byte
Suite hash.HashSuite
// Rand is the source of the fresh per-signature nonce hedge salt
// drawn in SignRound1. NewPartyWithSuite defaults it to
// crypto/rand.Reader, which is what every production signer uses.
// KAT/oracle and cross-path byte-equality harnesses pin it to a
// deterministic reader so the signature bytes are reproducible; no
// other code path should override it. This is the single seam
// between hedged (production) and deterministic (test) signing —
// mirroring threshold.GenerateKeys' randSource parameter.
Rand io.Reader
}
// NewParty initializes a new Party instance with the production hash suite
// (Corona-SHA3). To use a different suite, set Party.Suite after construction
// or call NewPartyWithSuite.
func NewParty(id int, r *ring.Ring, r_xi *ring.Ring, r_nu *ring.Ring, sampler *ring.UniformSampler) *Party {
return NewPartyWithSuite(id, r, r_xi, r_nu, sampler, hash.Default())
}
// NewPartyWithSuite initializes a Party with an explicit hash suite. Pass
// nil to resolve to the production default at call time.
func NewPartyWithSuite(id int, r *ring.Ring, r_xi *ring.Ring, r_nu *ring.Ring, sampler *ring.UniformSampler, suite hash.HashSuite) *Party {
return &Party{
ID: id,
Ring: r,
RingXi: r_xi,
RingNu: r_nu,
UniformSampler: sampler,
MACKeys: make(map[int][]byte),
MACs: make(map[int][]byte),
Suite: hash.Resolve(suite),
Rand: rand.Reader,
}
}
// Gen generates the secret shares, seeds, MAC keys, and the public parameter b
func Gen(r *ring.Ring, r_xi *ring.Ring, uniformSampler *ring.UniformSampler, trustedDealerKey []byte, lagrangeCoefficients structs.Vector[ring.Poly]) (structs.Matrix[ring.Poly], map[int]structs.Vector[ring.Poly], map[int][][]byte, map[int]map[int][]byte, structs.Vector[ring.Poly]) {
A := utils.SamplePolyMatrix(r, M, N, uniformSampler, true, true)
precomputeSize := (K * K * KeySize) + (r.N() * N * (K - 1) * len(r.Modulus().Bytes())) + (K * (K - 1) * KeySize)
utils.PrecomputeRandomness(precomputeSize, trustedDealerKey)
prng, _ := sampling.NewKeyedPRNG(trustedDealerKey)
gaussianParams := ring.DiscreteGaussian{Sigma: SigmaE, Bound: BoundE}
gaussianSampler := ring.NewGaussianSampler(prng, r, gaussianParams, false)
s := utils.SamplePolyVector(r, N, gaussianSampler, false, false)
skShares := primitives.ShamirSecretSharing(r, s, Threshold, lagrangeCoefficients)
for _, skShare := range skShares {
utils.ConvertVectorToNTT(r, skShare)
}
utils.ConvertVectorToNTT(r, s)
e := utils.SamplePolyVector(r, M, gaussianSampler, true, true)
b := utils.InitializeVector(r, M)
utils.MatrixVectorMul(r, A, s, b)
utils.VectorAdd(r, b, e, b)
// Round b
utils.ConvertVectorFromNTT(r, b)
bTilde := utils.RoundVector(r, r_xi, b, Xi)
seeds := make(map[int][][]byte)
MACKeys := make(map[int]map[int][]byte)
MACKeys[0] = make(map[int][]byte)
for i := 0; i < K; i++ {
seeds[i] = make([][]byte, K)
for j := 0; j < K; j++ {
seeds[i][j] = utils.GetRandomBytes(KeySize)
if i != j {
if MACKeys[j] == nil {
MACKeys[j] = make(map[int][]byte)
}
if MACKeys[i][j] == nil && MACKeys[j][i] == nil {
MACKeys[i][j] = utils.GetRandomBytes(KeySize)
MACKeys[j][i] = MACKeys[i][j]
}
}
}
}
return A, skShares, seeds, MACKeys, bTilde
}
// SignRound1 performs the first round of signing.
//
// PRECONDITION (HARD, consensus-enforced): the caller MUST supply an sid
// that is unique for this signer's skShare across the lifetime of that
// share. In the Quasar deployment sid is the consensus slot/round and
// slot-uniqueness is a chain invariant (LP-020). This kernel does NOT
// trust that invariant for its no-leak guarantee: the nonce key is
// additionally hedged with fresh per-signature randomness drawn from
// party.Rand (default crypto/rand), so reuse of an sid degrades only the
// defence-in-depth layer, not the security proof. An obviously-degenerate
// session (zero derived SessionID, or a randomness source that yields a
// zero salt) is rejected fail-closed with ErrDegenerateSession.
//
// The error return is the fail-closed signal; on error the returned D
// and MACs are nil and the caller MUST abort the signing session.
func (party *Party) SignRound1(A structs.Matrix[ring.Poly], sid int, PRFKey []byte, T []int) (structs.Matrix[ring.Poly], map[int][]byte, error) {
r := party.Ring
// Nonce-key derivation (replaces the CRIT-1 bare-int keying).
// 1. sessionID: full-width (no 64-bit truncation) deterministic
// binding to the consensus-agreed (sid, T).
// 2. salt: fresh 256-bit per-signature randomness — hedged signing.
// PRNGKeyForRound = PRF(skShare, "CoronaNonceV3" || sessionID || salt).
// Either input being all-zero is a degenerate session: fail closed
// rather than sign with forfeited nonce freshness.
sessionID := primitives.DeriveSessionID(party.Suite, sid, T)
if isZero32(sessionID) {
return nil, nil, ErrDegenerateSession
}
src := party.Rand
if src == nil {
src = rand.Reader
}
var salt [primitives.SessionIDSize]byte
if _, err := io.ReadFull(src, salt[:]); err != nil {
return nil, nil, err
}
if isZero32(salt) {
return nil, nil, ErrDegenerateSession
}
skHash := primitives.PRNGKeyForRound(party.Suite, party.SkShare, sessionID, salt)
prng, _ := sampling.NewKeyedPRNG(skHash)
gaussianParams := ring.DiscreteGaussian{Sigma: SigmaStar, Bound: BoundStar}
gaussianSampler := ring.NewGaussianSampler(prng, r, gaussianParams, false)
r_star := utils.SamplePolyVector(r, N, gaussianSampler, true, true)
e_star := utils.SamplePolyVector(r, M, gaussianSampler, true, true)
// Initialize R_i and E_i
gaussianParams = ring.DiscreteGaussian{Sigma: SigmaE, Bound: BoundE}
gaussianSampler = ring.NewGaussianSampler(prng, r, gaussianParams, false)
R_i := utils.SamplePolyMatrix(r, N, Dbar, gaussianSampler, true, true)
E_i := utils.SamplePolyMatrix(r, M, Dbar, gaussianSampler, true, true)
concatenatedR := utils.InitializeMatrix(r, N, Dbar+1)
for i := range concatenatedR {
concatenatedR[i] = append([]ring.Poly{r_star[i]}, R_i[i]...)
}
party.R = concatenatedR
// Ensure concatenatedE is properly initialized
concatenatedE := utils.InitializeMatrix(r, M, Dbar+1)
for i := range concatenatedE {
concatenatedE[i] = append([]ring.Poly{e_star[i]}, E_i[i]...)
}
D := utils.InitializeMatrix(r, M, Dbar+1)
utils.MatrixMatrixMul(r, A, concatenatedR, D)
utils.MatrixAdd(r, concatenatedE, D, D)
party.D = D
// Generate MACs for each party
MACs := make(map[int][]byte)
for _, j := range T {
if j != party.ID {
MACs[j] = primitives.GenerateMAC(party.Suite, D, party.MACKeys[j], party.ID, sid, T, j, false)
}
}
return D, MACs, nil
}
// nonceSaltDomain domain-separates the deterministic nonce-salt stream
// from every other use of a KeyedPRNG seeded by the same dealer seed.
var nonceSaltDomain = []byte("corona.sign.nonce-salt.v1")
// DeterministicNonceSource returns a deterministic, party-distinct
// io.Reader suitable for Party.Rand in KAT/oracle and cross-path
// byte-equality harnesses. It is keyed by the dealer seed, a fixed
// domain tag, and the party index, so each party draws an independent
// but reproducible hedge salt. Production signers MUST NOT use this —
// they keep the default crypto/rand.Reader. This is the single
// canonical way to pin hedged signing to a reproducible stream.
func DeterministicNonceSource(seed []byte, partyIndex int) io.Reader {
key := make([]byte, 0, len(seed)+len(nonceSaltDomain)+4)
key = append(key, seed...)
key = append(key, nonceSaltDomain...)
key = append(key, byte(partyIndex>>24), byte(partyIndex>>16), byte(partyIndex>>8), byte(partyIndex))
prng, err := sampling.NewKeyedPRNG(key)
if err != nil {
// NewKeyedPRNG only errors on BLAKE2 XOF construction, which
// cannot fail for a non-nil key. Surface loudly if the upstream
// contract ever changes rather than return a nil reader.
panic("corona/sign: DeterministicNonceSource: " + err.Error())
}
return prng
}
// isZero32 reports whether a 32-byte array is all-zero, in constant time.
// Used by the SignRound1 fail-closed guard; the operands (a derived
// SessionID, a freshly drawn salt) are not adversary-chosen, but a
// branch-free check keeps the guard uniform and free of a data-dependent
// early exit.
func isZero32(b [primitives.SessionIDSize]byte) bool {
var zero [primitives.SessionIDSize]byte
return subtle.ConstantTimeCompare(b[:], zero[:]) == 1
}
// SignRound2Preprocess verifies the MACs received in round 1 and performs the minimum eigenvalue check
func (party *Party) SignRound2Preprocess(A structs.Matrix[ring.Poly], b structs.Vector[ring.Poly], D map[int]structs.Matrix[ring.Poly], MACs map[int]map[int][]byte, sid int, T []int) (bool, structs.Matrix[ring.Poly], []byte) {
transcriptHash := primitives.Hash(party.Suite, A, b, D, sid, T)
for _, j := range T {
if j != party.ID {
MAC := MACs[j][party.ID]
expectedMAC := primitives.GenerateMAC(party.Suite, D[j], party.MACKeys[j], party.ID, sid, T, j, true)
if !bytes.Equal(MAC, expectedMAC) {
return false, nil, nil
}
}
}
DSum := utils.InitializeMatrix(party.Ring, M, Dbar+1)
for _, D_j := range D {
utils.MatrixAdd(party.Ring, D_j, DSum, DSum)
}
if !FullRankCheck(DSum, party.Ring) {
return false, nil, nil
}
return true, DSum, transcriptHash
}
// SignRound2 performs the second round of signing.
//
// transcriptHash is the digest returned by SignRound2Preprocess; it
// binds (A, b, D, sid, T) under the active hash suite.
func (party *Party) SignRound2(A structs.Matrix[ring.Poly], bTilde structs.Vector[ring.Poly], DSum structs.Matrix[ring.Poly], sid int, mu string, T []int, PRFKey []byte, transcriptHash []byte) structs.Vector[ring.Poly] {
r := party.Ring
r_nu := party.RingNu
partyID := party.ID
concatR := party.R
seeds := party.Seed
s_i := party.SkShare
lambda := party.Lambda
onePoly := r.NewMonomialXi(0)
r.NTT(onePoly, onePoly)
r.MForm(onePoly, onePoly)
u := structs.Vector[ring.Poly]{}
oneSlice := structs.Vector[ring.Poly]{onePoly}
if Dbar > 0 {
h_u := primitives.GaussianHash(party.Suite, r, transcriptHash, mu, SigmaU, BoundU, Dbar)
u = append(oneSlice, h_u...)
}
h := utils.InitializeVector(r, M)
utils.MatrixVectorMul(r, DSum, u, h)
utils.ConvertVectorFromNTT(r, h)
roundedH := utils.RoundVector(r, r_nu, h, Nu)
party.H = roundedH
c := primitives.LowNormHash(party.Suite, r, A, bTilde, roundedH, mu, Kappa)
party.C = c
seed_i := party.Seed[party.ID]
mask := utils.InitializeVector(r, N)
for _, j := range T {
mask_j := primitives.PRF(party.Suite, r, seed_i[j], PRFKey, mu, transcriptHash, N)
utils.VectorAdd(r, mask, mask_j, mask)
}
maskPrime := utils.InitializeVector(r, N)
for _, j := range T {
mask_j := primitives.PRF(party.Suite, r, seeds[j][partyID], PRFKey, mu, transcriptHash, N)
utils.VectorAdd(r, maskPrime, mask_j, maskPrime)
}
z_i := utils.InitializeVector(r, N)
utils.MatrixVectorMul(r, concatR, u, z_i)
utils.VectorAdd(r, z_i, maskPrime, z_i)
s_c_lambda := utils.InitializeVector(r, N)
utils.VectorPolyMul(r, s_i, lambda, s_c_lambda)
utils.VectorPolyMul(r, s_c_lambda, c, s_c_lambda)
utils.VectorAdd(r, z_i, s_c_lambda, z_i)
utils.VectorSub(r, z_i, mask, z_i)
return z_i
}
// SignFinalize finalizes the signature
func (party *Party) SignFinalize(z map[int]structs.Vector[ring.Poly], A structs.Matrix[ring.Poly], bTilde structs.Vector[ring.Poly]) (ring.Poly, structs.Vector[ring.Poly], structs.Vector[ring.Poly]) {
r := party.Ring
r_xi := party.RingXi
r_nu := party.RingNu
c := party.C
h := party.H
z_sum := utils.InitializeVector(r, N)
for _, z_j := range z {
utils.VectorAdd(r, z_sum, z_j, z_sum)
}
Az_bc := utils.InitializeVector(r, M)
utils.MatrixVectorMul(r, A, z_sum, Az_bc)
bc := utils.InitializeVector(r, M)
b := utils.RestoreVector(r, r_xi, bTilde, Xi)
utils.ConvertVectorToNTT(r, b)
utils.VectorPolyMul(r, b, c, bc)
utils.VectorSub(r, Az_bc, bc, Az_bc)
utils.ConvertVectorFromNTT(r, Az_bc)
roundedAz_bc := utils.RoundVector(r, r_nu, Az_bc, Nu)
Delta := utils.InitializeVector(r_nu, M)
utils.VectorSub(r_nu, h, roundedAz_bc, Delta)
return party.C, z_sum, Delta
}
// Verify verifies the correctness of the signature using the production
// hash suite (Corona-SHA3). For non-default suites use VerifyWithSuite.
// Note: This function does not modify its inputs - it creates copies where needed.
func Verify(r *ring.Ring, r_xi *ring.Ring, r_nu *ring.Ring, z structs.Vector[ring.Poly], A structs.Matrix[ring.Poly], mu string, bTilde structs.Vector[ring.Poly], c ring.Poly, roundedDelta structs.Vector[ring.Poly]) bool {
return VerifyWithSuite(nil, r, r_xi, r_nu, z, A, mu, bTilde, c, roundedDelta)
}
// VerifyWithSuite is the suite-explicit form of Verify. suite=nil resolves
// to the production default (Corona-SHA3).
func VerifyWithSuite(suite hash.HashSuite, r *ring.Ring, r_xi *ring.Ring, r_nu *ring.Ring, z structs.Vector[ring.Poly], A structs.Matrix[ring.Poly], mu string, bTilde structs.Vector[ring.Poly], c ring.Poly, roundedDelta structs.Vector[ring.Poly]) bool {
// Make a copy of z to avoid modifying the input signature
zCopy := make(structs.Vector[ring.Poly], len(z))
for i := range z {
zCopy[i] = *z[i].CopyNew()
}
Az_bc := utils.InitializeVector(r, M)
utils.MatrixVectorMul(r, A, zCopy, Az_bc)
bc := utils.InitializeVector(r, M)
b := utils.RestoreVector(r, r_xi, bTilde, Xi)
utils.ConvertVectorToNTT(r, b)
utils.VectorPolyMul(r, b, c, bc)
utils.VectorSub(r, Az_bc, bc, Az_bc)
utils.ConvertVectorFromNTT(r, Az_bc)
roundedAz_bc := utils.RoundVector(r, r_nu, Az_bc, Nu)
Az_bc_Delta := utils.InitializeVector(r_nu, M)
utils.VectorAdd(r_nu, roundedAz_bc, roundedDelta, Az_bc_Delta)
computedC := primitives.LowNormHash(suite, r, A, bTilde, Az_bc_Delta, mu, Kappa)
if !r.Equal(c, computedC) {
return false
}
Delta := utils.RestoreVector(r, r_nu, roundedDelta, Nu)
utils.ConvertVectorFromNTT(r, zCopy)
return CheckL2Norm(r, Delta, zCopy)
}
// CheckL2Norm checks if the L2 norm of the vector of Delta is less than or equal to Bsquare
func CheckL2Norm(r *ring.Ring, Delta structs.Vector[ring.Poly], z structs.Vector[ring.Poly]) bool {
sumSquares := big.NewInt(0)
qBig := new(big.Int).SetUint64(Q)
halfQ := new(big.Int).Div(qBig, big.NewInt(2))
DeltaCoeffsBigInt := make(structs.Vector[[]*big.Int], r.N())
for i, polyCoeffs := range Delta {
DeltaCoeffsBigInt[i] = make([]*big.Int, r.N())
r.PolyToBigint(polyCoeffs, 1, DeltaCoeffsBigInt[i])
}
for _, polyCoeffs := range DeltaCoeffsBigInt {
for _, coeff := range polyCoeffs {
if coeff.Cmp(halfQ) > 0 {
coeff.Sub(coeff, qBig)
}
coeffSquare := new(big.Int).Mul(coeff, coeff)
sumSquares.Add(sumSquares, coeffSquare)
}
}
zCoeffsBigInt := make(structs.Vector[[]*big.Int], r.N())
for i, polyCoeffs := range z {
zCoeffsBigInt[i] = make([]*big.Int, r.N())
r.PolyToBigint(polyCoeffs, 1, zCoeffsBigInt[i])
}
for _, polyCoeffs := range zCoeffsBigInt {
for _, coeff := range polyCoeffs {
if coeff.Cmp(halfQ) > 0 {
coeff.Sub(coeff, qBig)
}
coeffSquare := new(big.Int).Mul(coeff, coeff)
sumSquares.Add(sumSquares, coeffSquare)
}
}
// Internal norm bound check; intermediate values stay private. Logging
// them at any level (even debug) creates a side-channel for validator
// monitoring stacks that index logs.
BsquareInt, _ := new(big.Int).SetString(Bsquare, 10)
return sumSquares.Cmp(BsquareInt) <= 0
}
// FullRankCheck checks if the given matrix is full-rank, ignoring the first column
func FullRankCheck(D structs.Matrix[ring.Poly], r *ring.Ring) bool {
phi := r.N()
q := r.Modulus()
submatrices := make([][][]*big.Int, phi)
for i := range submatrices {
submatrices[i] = make([][]*big.Int, len(D))
for row := range submatrices[i] {
submatrices[i][row] = make([]*big.Int, len(D[0])-1)
}
}
for row := range D {
for col := 1; col < len(D[row]); col++ {
coeffs := make([]*big.Int, phi)
r.PolyToBigint(D[row][col], 1, coeffs)
for i := 0; i < phi; i++ {
coeff := coeffs[i].Mod(coeffs[i], q)
submatrices[i][row][col-1] = coeff
}
}
}
for i := range submatrices {
if !utils.GaussianEliminationModQ(submatrices[i], q) {
return false
}
}
return true
}