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.
13 KiB
Corona — constant-time review
Scope: every Corona code path that touches a secret share, the
per-signature nonce, a private commit opening, an arithmetic intermediate
that depends on a secret, or a digest compared against an
attacker-supplied value. Corona is a pure-Go threshold-Raccoon
(Module-LWE) signature; there are no curve operations and no lens/warp
surfaces — those belong to other repositories and are out of scope here.
Status legend:
- (a) Constant-time by construction; documented citation to the underlying primitive.
- (b) Variable-time, but every operand on the path is public (publicly derivable from the wire-format signature, the group key, or protocol metadata). The timing channel reveals nothing the result byte does not already disclose. Documented, not a secret leak.
- (c) MUST FIX before production; precise location captured.
- (TCB) Constant-time behaviour is assumed of an upstream dependency, not proven in this tree. Listed explicitly so the trust assumption is visible.
Headline: zero (c) entries. Every secret-dependent comparison in
Corona is crypto/subtle.ConstantTimeCompare over a byte view, or
fixed-size array equality (constant-time on amd64/arm64). The remaining
(b) entries are public-input paths. One (TCB) entry — the lattigo
discrete-Gaussian / uniform / ternary samplers — is the residual
assumption on which secret-seeded Round-1 sampling rests; it is asserted,
not mechanically proven here.
1. Per-signature nonce key (the no-leak-critical path)
| Location | Status | Notes |
|---|---|---|
primitives.DeriveSessionID (primitives/hash.go:75) |
(a) | Deterministic 256-bit session identifier TranscriptHash("corona.sign.session-id.v1" || be64(sid) || T). Inputs (sid, T) are public consensus metadata; the function is a hash with no secret-dependent branching. Replaces the prior bare-64-bit-sid nonce keying. |
primitives.PRNGKeyForRound (primitives/hash.go:111) |
(a) | PRF(skShare, "CoronaNonceV3" || sessionID[32] || salt[32]). skShare is secret but is only fed as the PRF key to the suite PRF (KMAC256 under Corona-SHA3 / keyed BLAKE3 under the legacy suite) — keyed-hash absorption is constant-time in the key per the sponge/compression construction; there is no secret-dependent control flow. Output is the Round-1 nonce-PRNG seed. |
sign.Party.SignRound1 nonce guard (sign/sign.go:151) |
(a) | Draws a fresh 32-byte hedge salt from party.Rand (default crypto/rand.Reader), so the nonce key — hence R — is fresh per signature even if the consensus layer reissues an sid. The fail-closed guard rejects an all-zero derived SessionID (sign/sign.go:162) or an all-zero salt (sign/sign.go:173) with ErrDegenerateSession, and surfaces a short-read error from io.ReadFull. The zero-checks use isZero32 (sign/sign.go:250), a branch-free subtle.ConstantTimeCompare against a zero array; the guarded values are not adversary-chosen, but the check is kept uniform. This is the durable fix for the CRIT-1 nonce-reuse leak (z_sum − Σ s_i·λ_i·c)·u^{-1} = R; reuse durability no longer rests on an external slot-uniqueness invariant. |
sign.DeterministicNonceSource (sign/sign.go) |
(a) | The KAT/oracle seam: a KeyedPRNG over seed || "corona.sign.nonce-salt.v1" || partyIndex. Used only by reproducibility harnesses to pin the hedge salt; production never calls it. No secret material (the seed is a test fixture). |
2. Round-2 masking and the secret-dependent arithmetic
| Location | Status | Notes |
|---|---|---|
Additive masks (sign/sign.go:317, :323) |
(a) | Each z_i is masked by mask = Σ_j PRF(seed_i[j], …) (outgoing row) minus maskPrime = Σ_j PRF(seed_j[i], …) (incoming column), both sums of uniform R_q PRF outputs over pairwise seeds (primitives.PRF, primitives/hash.go:173). The two double-sums telescope to zero when the quorum's z_i are summed in SignFinalize, so no per-party c·s is ever broadcast in the clear (the structural no-leak property). The masking arithmetic (utils.VectorAdd/VectorSub/VectorPolyMul) is coefficient-wise uint64 ring arithmetic from lattigo — branch-free over the modulus. |
s_i·λ_i·c term (sign/sign.go:335–:340) |
(a) | VectorPolyMul is NTT-domain Montgomery multiplication on uint64 coefficient slices (lattigo MulCoeffsMontgomery*); no secret-dependent branch. The result is immediately additively masked before it leaves the function. |
primitives.PRF seed expansion (primitives/hash.go:173) |
(a) / (TCB) | The PRF key derivation (suite PRF) is constant-time in its inputs; the subsequent NewUniformSampler.ReadNew over the derived stream is the lattigo uniform sampler — see the (TCB) note in §4. |
3. Share verification, complaints, and digest comparison
| Location | Status | Notes |
|---|---|---|
utils.ConstantTimePolyEqual (utils/utils.go:29) |
(a) | The single canonical constant-time poly comparator: scans every level, no early return, per-level subtle.ConstantTimeCompare over the little-endian byte view of each Coeffs[level]. Both the dkg2 and reshare verification paths delegate here (one implementation, no dkg2↔reshare dependency). Citation: crypto/subtle.ConstantTimeCompare is constant-time over equal-length inputs per pkg.go.dev/crypto/subtle. |
dkg2.VerifyShareAgainstCommits (dkg2/dkg2.go:473) |
(a) | Verifies the Pedersen identity A·NTT(share) + B·NTT(blind) ?= Σ C_{i,k} slot-by-slot via utils.ConstantTimePolyEqual (dkg2/dkg2.go:537) with eq &= … accumulation. share/blind are secret, commits public; the comparator does not leak which slot first diverged. Structural pre-checks len(commits) != threshold (:485) and len(v) != sign.M (:489) branch on public metadata only. This is the direct response to Findings 5/6 of luxcpp/crypto/corona/RED-DKG-REVIEW.md (the upstream dkg/ package short-circuited on first mismatch). |
reshare.VerifyShareAgainstCommits (reshare/commit.go:126) |
(a) | Recipient-side reshare commit check, identical posture to dkg2: eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri]) (reshare/commit.go:178) across all M slots, no early return. Previously this path short-circuited; it now uses the canonical constant-time comparator. lhs derives from the secret (share, blind), rhs is the public commit vector. |
dkg2.CommitDigest / reshare.CommitDigest (reshare/commit.go:203) |
(a) | Return [32]byte. Cohort-consistency checks compare two [32]byte arrays — Go compiles fixed-size array equality to a constant-time SIMD compare on amd64/arm64 (cmd/compile/internal/walk/compare.go: walkCompareArray). |
reshare.VerifyActivation (reshare/activation.go:127) |
(a) | expectedTranscript != localTranscriptHash (reshare/activation.go:138) is [32]byte array equality — constant-time as above. The activation cert is fail-closed: a transcript mismatch returns ErrTranscriptMismatch and the share is never activated. |
dkg2 complaint identification / disqualification (dkg2/complaint.go) |
(a) | Operates on public complaint metadata (sender/complainer IDs, public counters). No secret material; all branches are over public values. |
4. Samplers — the residual TCB axiom
| Location | Status | Notes |
|---|---|---|
Round-1 discrete-Gaussian sampling r_star, e_star, R_i, E_i (sign/sign.go:179, :185) |
(a) / (TCB) | Seeded by the secret-derived skHash (the nonce-PRNG seed from §1). The samplers are github.com/luxfi/lattice/v7/ring.NewGaussianSampler (a fork of tuneinsight/lattigo/v7). Lattigo's discrete-Gaussian sampler uses rejection over a SHAKE/BLAKE2-XOF keystream; the rejection rate depends on the rejected uniform byte, not on the secret seed value, so timing does not correlate with the secret coefficient being produced. This constant-time property is asserted of the upstream dependency, not mechanically verified in this tree — it is the principal Corona TCB axiom (mirrored in TRUSTED-COMPUTING-BASE.md). Any secret-seeded sampling (Round-1 nonces, dealer key generation) inherits this assumption. |
primitives.GaussianHash sampler (primitives/hash.go:154, :164) |
(a) / (TCB) | Same lattigo Gaussian sampler, seeded from the public transcript digest + mu. Public seed, so even if the sampler leaked timing on its seed it would reveal nothing secret; the (TCB) note applies only to the sampler's internal byte-level behaviour. |
primitives.PRF uniform sampler (primitives/hash.go:183) |
(a) / (TCB) | NewUniformSampler over the PRF-derived stream. Uniform sampling is rejection over the modulus; rate depends on rejected bytes, not on the (secret) seed. Public-vs-secret seed varies by caller; the (TCB) note is on the sampler internals. |
primitives.LowNormHash ternary sampler (primitives/hash.go:239) |
(a) | NewTernarySampler seeded from the public (A, b̃, h, mu) transcript. The sampled challenge c is public (it is recomputed by every verifier). No secret on this path. |
5. Verifier paths (public-input, variable-time-tolerant)
| Location | Status | Notes |
|---|---|---|
sign.Verify / sign.VerifyWithSuite (sign/sign.go:381, :387) |
(b) | Operates on a public signature (c, z, Δ) against the public group key (A, b̃). The first failure mode r.Equal(c, computedC) (sign/sign.go:411) walks coefficient slices and short-circuits — but every operand is publicly derivable from the wire signature. No secret-share material crosses this surface. |
sign.CheckL2Norm (sign/sign.go:422) |
(b) | Sums big.Int squares of the centered (Δ, z) coefficients and compares to Bsquare. big.Int.Cmp/Mul/Sub are not constant-time, and the coeff.Cmp(halfQ) > 0 centering branch is data-dependent — but (z, Δ) are the published signature components, fully public. The norm bound is a property of public data; leaking timing about it reveals only what the signature bytes already do. The function deliberately logs no intermediates (a log would create a side-channel for log-indexing monitors). |
sign.FullRankCheck (sign/sign.go:467) |
(b) | Gaussian elimination mod q over the per-coefficient submatrices of DSum to reject a rank-deficient aggregate commitment. DSum = Σ_j D_j is the public sum of all parties' broadcast Round-1 D_j matrices (each D_j is broadcast and MAC-bound in Round 1), so every operand is public. utils.GaussianEliminationModQ uses big.Int and a data-dependent pivot search — variable-time, but only on public aggregate data. Not a secret leak. |
6. Key-share zeroization
| Location | Status | Notes |
|---|---|---|
reshare.EraseShare (reshare/keyshare.go:158) |
(a) | for k := range coeffs { coeffs[k] = 0 } (reshare/keyshare.go:166) overwrites the SkShare coefficient backing arrays in place after activation. The goal is overwrite-on-deactivation, not constant-time equality; the data is already secret to the local process. |
keyera standard-form secret zeroization (keyera/keyera.go:255, :504) |
(a) | After Shamir sharing / resharing, the dealer's or party's standard-form secret copy is zeroed coefficient-by-coefficient. keyera.Reshare (keyera/keyera.go:282) recombines via a big.Int Lagrange linear combination of secret share values with public coefficients — big.Int is not constant-time, but every value is held by the local party only; there is no remote oracle on the recombination path. BootstrapPedersen (keyera/bootstrap_pedersen.go:144) is the default since no party ever holds the full master secret; its per-party arithmetic inherits the dkg2 §3 analysis. The legacy trusted-dealer path (bootstrapTrustedDealerImpl, keyera/keyera.go:195) runs the master secret in big.Int on a single dealer machine during a one-time ceremony — timing observable only to the dealer. |
(c) entries
None. All reviewed paths land in (a), (b), or the §4 (TCB) axiom.
The (b) entries (sign.Verify, CheckL2Norm, FullRankCheck) are all
public-input verifier paths: big.Int and r.Equal variable-time
behaviour operates exclusively on the published signature, the public
group key, or the broadcast aggregate DSum. Promoting them to
constant-time would add cost for no confidentiality gain.
The single (TCB) axiom (§4) is the lattigo Gaussian / uniform sampler
constant-time behaviour for secret-seeded Round-1 nonce and dealer
sampling. It is asserted from the upstream construction (rejection rate
depends on rejected bytes, not the secret seed), not mechanically proven
in this tree. Discharging it (e.g. a dudect measurement of the sampler
under fixed-vs-random secret seeds, or a Jasmin #ct-checked sampler) is
tracked as a v0.8.0 high-assurance gate.
Cross-references
- TCB axiom inventory:
TRUSTED-COMPUTING-BASE.md,AXIOM-INVENTORY.md. - Nonce-key construction and the CRIT-1 history:
primitives/hash.go(DeriveSessionID,PRNGKeyForRound) andsign/sign.go(SignRound1). - Constant-time share comparison origin:
luxcpp/crypto/corona/RED-DKG-REVIEW.mdFindings 5/6.