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.
This commit is contained in:
Antje Worring
2026-06-21 08:21:58 -07:00
parent 4d9334ee94
commit dc15c54a47
28 changed files with 668 additions and 266 deletions
+73 -94
View File
@@ -1,145 +1,124 @@
# Pulsar — constant-time review (Gate 7) # Corona — constant-time review
**Scope:** every code path that touches a secret share, a private commit **Scope:** every Corona code path that touches a secret share, the
opening, an arithmetic intermediate that depends on a secret, or a per-signature nonce, a private commit opening, an arithmetic intermediate
verifier digest that is compared against an attacker-supplied value. 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:** **Status legend:**
- **(a)** Constant-time by construction; documented citation to the - **(a)** Constant-time by construction; documented citation to the
underlying primitive. underlying primitive.
- **(b)** Constant-time gap exists; documented; not exploitable because - **(b)** Variable-time, but every operand on the path is **public**
the value is public, or the path is verifier-controlled and the timing (publicly derivable from the wire-format signature, the group key, or
oracle does not gain the adversary anything beyond what the result protocol metadata). The timing channel reveals nothing the result byte
byte already tells them. does not already disclose. Documented, not a secret leak.
- **(c)** MUST FIX before production; precise location captured. - **(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 is **Headline:** zero (c) entries. Every secret-dependent comparison in
already byte-blob `subtle.ConstantTimeCompare` or routes through Corona is `crypto/subtle.ConstantTimeCompare` over a byte view, or
upstream primitives whose constant-time behavior we cite below. The fixed-size array equality (constant-time on amd64/arm64). The remaining
remaining (b) entries are public-input verifier paths where the leakage (b) entries are public-input paths. One (TCB) entry — the lattigo
channel reduces to "is the verifier's output byte 0 or 1" — already discrete-Gaussian / uniform / ternary samplers — is the residual
visible to the network observer. assumption on which secret-seeded Round-1 sampling rests; it is asserted,
not mechanically proven here.
--- ---
## 1. Verifier paths ## 1. Per-signature nonce key (the no-leak-critical path)
| Location | Status | Notes | | Location | Status | Notes |
|---|---|---| |---|---|---|
| `pulsar.Verify` (`sign/sign.go:273`) | **(b)** | Operates on a public signature `(c, z, Δ)` against the public group key `(A, b̃)`. The first failure mode is `r.Equal(c, computedC)``lattigo` `Ring.Equal` walks coefficient slices and short-circuits, but every operand here is publicly derivable from the wire-format signature. The second mode (`CheckL2Norm`) sums big.Int squares; `big.Int.Cmp/Mul` are not constant-time, but again every operand is public. No secret-share material crosses this surface. | | `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. |
| `pulsar.Reshare commit verification` (`reshare/commit.go:124` `VerifyShareAgainstCommits`) | **(b)** | This runs on the recipient with a private `(share, blind)` pair against a public commit vector. The early-return `r.Equal(lhs[ri], rhs[ri])` per-coordinate IS a timing channel — but `lhs` is computed from secret `share`/`blind` and `rhs` is public, so a successful equality at coordinate `ri` reveals only "the secret shares at slot `ri` match the public commitment so far," which is information the recipient already has (it is the entire purpose of running the check). The information disclosed by short-circuit on a Pedersen mismatch ("which slot first diverged") leaks a position index `ri` to a network observer measuring the recipient's response time; that index is bounded by `M = 8` slots (Pulsar) and reveals only that the dealer shipped a malformed pair, which the complaint message broadcasts publicly anyway. **Documented gap; fixed in dkg2 path** (see §2). | | `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. |
| `pulsar.VerifyActivation` (`reshare/activation.go:127`) | **(a)** | The hash equality check on `expectedTranscript == localTranscriptHash` is `[32]byte` array-equality — Go compiles fixed-size array equality to a constant-time SIMD compare on amd64/arm64 (see `cmd/compile/internal/walk/compare.go: walkCompareArray`). The threshold-signature `verify` callback is a contract delegated to the caller and verified independently. | | `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. |
| `dkg2 Round 2 verifier` (`dkg2/dkg2.go:482` `VerifyShareAgainstCommits`) | **(a)** | Constant-time by construction: every coordinate of `lhs` and `rhs` is fed to `constTimePolyEqual` (`dkg2.go:560`) which calls `subtle.ConstantTimeCompare` over the little-endian byte view of every `Coeffs[level]`. Cross-slot accumulation is `eq &= …` — no early return. This is the **direct response to Findings 5/6 of `luxcpp/crypto/corona/RED-DKG-REVIEW.md`** [1]. Citation: `crypto/subtle.ConstantTimeCompare` is constant-time over inputs of equal length per `pkg.go.dev/crypto/subtle` [2]. | | `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). |
| `dkg2 commit-digest verifier` (`dkg2/dkg2.go:253` `CommitDigest`) | **(a)** | Returns `[32]byte`. Cohort consistency check at the orchestration layer compares two `[32]byte` arrays — Go fixed-size array equality is constant-time as above. |
| `lens sign verify` (`lens/sign/sign.go:314` `Verify`) | **(a)** | Verifier-public path; no secret material. The `lhs.Equal(rhs)` call routes through each curve's `Point.Equal`: Ed25519 → `filippo.io/edwards25519.Point.Equal` returns `int` constant-time per [3]; Ristretto255 → `gtank/ristretto255.Element.Equal` documented constant-time [4]; secp256k1 → see §5 (decred dcrd path is non-constant-time on Equal but operates on public RHS). |
| `warp.VerifyV2` (`warp/envelope.go:415`) | **(a)** | Pre-flight envelope structure check, hash-suite ID string compare, then delegates to per-lane verifiers. The `env.HashSuiteOrDefault() != opts.HashSuiteID` string compare uses Go's `==` — not constant-time on strings — but the suite ID is **public protocol metadata** (declared in the envelope, transmitted in clear). No secret material. |
| `warp/pulsar.VerifyPulse` (`warp/pulsar/pulsar.go:125`) | **(a)** | Public-input path. Builds the canonical signing transcript (deterministic byte serialization, no secret-dependent branching), deserializes the wire pulse, calls `pulsarKernel.Verify`. The `env.HashSuiteOrDefault() != suiteID` check is on public metadata. The `BuildSigningBytes` byte-stream serializer has zero secret-dependent branches. |
[1] `~/work/lux/luxcpp/crypto/corona/RED-DKG-REVIEW.md` Findings 5/6.
[2] https://pkg.go.dev/crypto/subtle#ConstantTimeCompare
[3] https://pkg.go.dev/filippo.io/edwards25519#Point.Equal
[4] https://pkg.go.dev/github.com/gtank/ristretto255#Element.Equal
--- ---
## 2. DKG2 complaint / verification ## 2. Round-2 masking and the secret-dependent arithmetic
| Location | Status | Notes | | Location | Status | Notes |
|---|---|---| |---|---|---|
| Round 1 commit-share dimension check (`dkg2.go:494`) | **(a)** | `len(commits) != threshold` and `len(v) != sign.M` are integer compares on public structural metadata — branch is structural, not secret-dependent. | | 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. |
| Round 2 share verification (`dkg2.go:539`) | **(a)** | The Pedersen identity `A·NTT(share) + B·NTT(blind) ?= Σ C_{i,k}` is verified slot-by-slot via `constTimePolyEqual` (`dkg2.go:560`). The `eq &= subtle.ConstantTimeCompare(ab, bb)` accumulation pattern across all `M` slots is the canonical constant-time-AND idiom. No early return; the loop runs to completion before the final `if eq != 1` branch. | | `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. |
| Complaint identification (`complaint.go:223` `ComputeDisqualifiedSet`) | **(a)** | Operates on public complaint metadata (sender ID, complainer ID). No secret material; all branches are over public counters. | | `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. |
| Disqualification quorum (`complaint.go:250` `FilterQualifiedQuorum`) | **(a)** | Pure deterministic set filter on public IDs; sorted output via insertion-sort over committee size ≤ 32. No secret-dependent branching. |
--- ---
## 3. Share handling ## 3. Share verification, complaints, and digest comparison
| Location | Status | Notes | | Location | Status | Notes |
|---|---|---| |---|---|---|
| `keyera.BootstrapTrustedDealer` share generation (`keyera/bootstrap_pedersen.go:bootstrapTrustedDealerImpl`) | **(a)** | Shamir over Z_q runs entirely in `math/big`. `big.Int` is **not** constant-time, but the trusted-dealer ceremony is a one-time foundation MPC event; the dealer's machine is the only entity with the master secret in memory at this point. Timing is observable only to the dealer. The dealer's standard-form `s` copy is zeroed in place after sharing. The unqualified `keyera.Bootstrap` defaults to `BootstrapPedersen` since v0.7.5, where no party ever holds `s`; the constant-time analysis there inherits from `dkg2/` (see §2). | | `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`. |
| `keyera.Reshare` share recombination via Lagrange (`keyera/keyera.go:Reshare`) | **(a)** | The Lagrange recombination is a `big.Int` linear combination of secret share values with public Lagrange coefficients. `big.Int.Mul/Mod` are not constant-time per se, but every input value is held by the local party only — there is no remote attacker on the recombination path. Timing leaks only to the local OS scheduler, which is the trust boundary already accepted by the validator deployment posture. | | `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). |
| `EraseShare` after activation (`reshare/keyshare.go:158`) | **(a)** | Plain `for k := range coeffs { coeffs[k] = 0 }`. The Go compiler does not zero behind a pointer reference unless we write the assignment ourselves; this is the documented zeroization technique used by upstream `golang.org/x/crypto/internal/poly1305.MACState.zeroize`. The data is already secret to the local process; the goal is overwrite-on-deactivation, not constant-time-equality. | | `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. |
| Lens share derivation (`lens/keyera/keyera.go`) | mirrored into `lens/CONSTANT-TIME-REVIEW.md` | See lens doc. | | `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. Scalar / ring operations ## 4. Samplers — the residual TCB axiom
| Location | Status | Notes | | Location | Status | Notes |
|---|---|---| |---|---|---|
| `pulsar/primitives/polynomial.go` Lagrange / Shamir (Shamir routines) | **(a)** | Operates on a single party's view of secret coefficients in `big.Int`. The party holds the secret and there is no remote oracle for timing — same boundary as `keyera.Reshare`. | | 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. |
| `pulsar/sign` Montgomery / NTT / discrete-Gaussian sampling | **(a)** | All three primitives come from `github.com/luxfi/lattice/v7/ring` (a fork of `tuneinsight/lattigo/v7`). Lattigo's NTT (`ring/ring_ops.go: NTT`), Montgomery reduction (`ring/ring_field.go`), and the discrete-Gaussian sampler (`ring/sampler_gaussian.go`) all run constant-time over the modulus parameters: NTT and Montgomery do branch-free word-level arithmetic on `uint64` coefficient slices; the Gaussian sampler uses rejection on uniform bytes from `KeyedPRNG`, which is a SHAKE128-based stream — the rejection rate is data-dependent only on the rejected byte, not on the secret coefficient being sampled. **Citation:** `lattigo/v7` README and `ring/sampler_gaussian.go: NewGaussianSampler` documentation [5]. | | `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. |
| `lens/primitives` curve.go scalar ops on three curves | see §5 below | | | `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] https://github.com/tuneinsight/lattigo (we vendor `luxfi/lattice/v7` from this; reduction routines are byte-stable and CT-claimed).
--- ---
## 5. Lens curve operations ## 5. Verifier paths (public-input, variable-time-tolerant)
The lens reshare and sign paths sit on three curves; each gets its own | Location | Status | Notes |
mini-audit.
### Ed25519 (`lens/primitives/ed25519.go`)
| Operation | Status | Notes |
|---|---|---| |---|---|---|
| Scalar mul (`ed25519Scalar.Act`, `ActOnBase`) | **(a)** | Backed by `filippo.io/edwards25519.Point.ScalarMult` and `ScalarBaseMult`. Filippo's `edwards25519` package is the Go standard library's reference Ed25519 implementation (it backs `crypto/ed25519` since Go 1.17), and is documented constant-time per `pkg.go.dev/filippo.io/edwards25519` [3]. | | `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. |
| Scalar Add/Sub/Mul/Negate/Invert | **(a)** | All delegate to `edwards25519.Scalar.Add/Subtract/Multiply/Negate/Invert`. Every operation in the package is constant-time, including `Invert` (Fermat-based, no extended-Euclidean leakage). | | `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). |
| `Equal` on scalars/points | **(a)** | `edwards25519.Scalar.Equal` and `Point.Equal` return `int` (0 or 1) explicitly to enforce constant-time use — see method signatures in [3]. | | `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. |
| `SetBytes` (`SetCanonicalBytes`) | **(a)** | Canonical-encoding rejection short-circuits on malformed input, but malformed input is public (came off the wire); accepted input flows through a constant-time scalar reduction. |
### secp256k1 (`lens/primitives/secp256k1.go`) ---
| Operation | Status | Notes | ## 6. Key-share zeroization
| Location | Status | Notes |
|---|---|---| |---|---|---|
| Scalar mul on points | **(b)** — non-CT, public RHS | `decred/dcrd/dcrec/secp256k1/v4.ScalarMultNonConst` and `ScalarBaseMultNonConst` are explicitly **non-constant-time** (note the `NonConst` suffix). The dcrd authors document this and recommend the constant-time variants for secret-key code paths. **In Lens, the only secp256k1 path that holds a secret scalar is FROST Round 2** (`lens/sign/sign.go:236-243`): `eRho := s.curve.NewScalar().Set(s.eI).Mul(myRho)` and `lamSc := s.curve.NewScalar().Set(s.share.Lambda).Mul(s.share.SkShare).Mul(c)` — these are scalar-on-scalar muls (constant-time, see next row), not scalar-on-point. The point-on-point operations on secp256k1 in Lens are all on **public** points (`commits[id].D`, `commits[id].E`, group key `X`). **Status (b): documented gap; not exploitable because every secp256k1 ScalarMult call site uses public scalar inputs (binding factor `ρ`, challenge `c`, Lagrange coefficient `λ`).** Action: when we ship a secp256k1-backed Lens variant that signs under a single-party path (rather than threshold FROST), reroute to a constant-time scalar-mul implementation (`btcsuite/btcd/btcec/v2.PrivateKey.PubKey()` uses `crypto/ecdsa`'s constant-time mul — that is the upgrade target). | | `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. |
| Scalar Add/Sub/Mul/Negate (`ModNScalar`) | **(a)** | `secp256k1.ModNScalar` operations are **constant-time** per dcrd source (`primitives/secp256k1/scalar.go: Add/Mul/etc.` use bit-twiddling). | | `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. |
| `Invert` | **(b)** | `s.value.InverseNonConst()` — explicitly non-constant-time. **Only invoked from `primitives.Lagrange` over public IDs**; the input is the difference between two committee party IDs (public integers). Not on a secret scalar path. |
| Point `Equal` | **(b)** | `secp256k1Point.Equal` (`secp256k1.go:345`) calls `IsZero/IsOddBit/Equals` which short-circuit on the `IsIdentity` branch then `pa.X.Equals(&oa.X) && pa.Y.Equals(&oa.Y)`. `FieldVal.Equals` is constant-time per dcrd source (XOR-fold over the 10-word representation). The `IsIdentity` branch IS data-dependent but operates on public points. |
### Ristretto255 (`lens/primitives/ristretto255.go`)
| Operation | Status | Notes |
|---|---|---|
| Scalar mul (`ristrettoScalar.Act`, `ActOnBase`) | **(a)** | Backed by `gtank/ristretto255.Element.ScalarMult` / `ScalarBaseMult`. Per `gtank/ristretto255` README and source, every operation is constant-time, deliberately so for use in PAKE / OPAQUE / threshold-Schnorr deployments [4]. |
| Scalar Add/Sub/Mul/Negate/Invert | **(a)** | Delegates to `ristretto255.Scalar.{Add,Subtract,Multiply,Negate,Invert}`, all constant-time per package documentation. |
| `Equal` on scalars/points | **(a)** | `Scalar.Equal` and `Element.Equal` return `int` (0/1) constant-time, by design [4]. |
| Encoding (`Encode`, `SetCanonicalBytes`) | **(a)** | Canonical encoding is documented constant-time per RFC 9496 §4.3.4. |
--- ---
## (c) entries ## (c) entries
**None.** All paths reviewed land in (a) or (b). **None.** All reviewed paths land in (a), (b), or the §4 (TCB) axiom.
The (b) entries break down as: 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.
1. **`reshare/commit.go:124`** — recipient-side Pedersen mismatch leaks The single (TCB) axiom (§4) is the lattigo Gaussian / uniform sampler
the first-diverging slot index. Mitigation: dkg2's `VerifyShareAgainstCommits` constant-time behaviour for **secret-seeded** Round-1 nonce and dealer
already does CT compare, and the legacy `reshare` path will be sampling. It is asserted from the upstream construction (rejection rate
migrated to use the same `constTimePolyEqual` helper at the next depends on rejected bytes, not the secret seed), not mechanically proven
reshare-protocol revision (Mar-31 cutover; tracked in in this tree. Discharging it (e.g. a `dudect` measurement of the sampler
`~/work/lux/pulsar/LLM.md`). under fixed-vs-random secret seeds, or a Jasmin `#ct`-checked sampler) is
tracked as a v0.8.0 high-assurance gate.
2. **`lens/primitives/secp256k1.go`** — `dcrd` non-CT scalar mul. Only
reached with public scalar inputs in current Lens deployments. If we
ever expose secp256k1 to a single-party signing path (e.g. an L2
bridge requiring Bitcoin-key signatures from a single validator),
reroute to a constant-time mul.
Both (b) entries are tracked; neither blocks the Mar-3 Architecture
Freeze.
--- ---
## Reviewers ## Cross-references
- Audit pass: Scientist (Mar-3-2026) - TCB axiom inventory: `TRUSTED-COMPUTING-BASE.md`, `AXIOM-INVENTORY.md`.
- Cross-check: companion file `~/work/lux/lens/CONSTANT-TIME-REVIEW.md` - Nonce-key construction and the CRIT-1 history: `primitives/hash.go`
(lens-specific section duplicates §5 here under the lens module's (`DeriveSessionID`, `PRNGKeyForRound`) and `sign/sign.go`
own surface inventory). (`SignRound1`).
- Proof anchor: `proofs/definitions/transcript-binding.tex` - Constant-time share comparison origin:
Definitions ref:pulsar-transcript and ref:pulsar-activation-msg. `luxcpp/crypto/corona/RED-DKG-REVIEW.md` Findings 5/6.
+3 -2
View File
@@ -12,8 +12,9 @@
The strongest precise statement supported by Corona v0.4.1: The strongest precise statement supported by Corona v0.4.1:
> **Construction-level interchangeability (Class N1).** Every > **Construction-level interchangeability (Class N1).** Every
> signature byte string produced by the Corona threshold combine > signature byte string produced by the Corona threshold finalize
> procedure (`threshold.Combine` / `sign.LocalSign` aggregation flow) > procedure (`sign.Party.SignFinalize`, exposed as
> `threshold.Signer.Finalize`, which aggregates the Round-2 `z` shares)
> on inputs `(group_pk = (A, bTilde), m, ctx, quorum, shares)` > on inputs `(group_pk = (A, bTilde), m, ctx, quorum, shares)`
> satisfying the protocol's well-formedness invariants verifies under > satisfying the protocol's well-formedness invariants verifies under
> the Corona reference verifier `sign.Verify(group_pk, m, σ)` with > the Corona reference verifier `sign.Verify(group_pk, m, σ)` with
+10 -1
View File
@@ -994,6 +994,11 @@ func emitSignVerify(outDir string) error {
parties[i].SkShare = skShares[i] parties[i].SkShare = skShares[i]
parties[i].Seed = seeds parties[i].Seed = seeds
parties[i].MACKeys = macKeys[i] parties[i].MACKeys = macKeys[i]
// Pin the hedged-nonce salt source to a deterministic,
// per-party-distinct KeyedPRNG so the e2e signature bytes
// are reproducible across runs and across the C++ port.
// Production signers leave Party.Rand = crypto/rand.
parties[i].Rand = sign.DeterministicNonceSource(seed, i)
lambda := r.NewPoly() lambda := r.NewPoly()
lambda.Copy(lagrange[i]) lambda.Copy(lagrange[i])
r.NTT(lambda, lambda) r.NTT(lambda, lambda)
@@ -1007,7 +1012,11 @@ func emitSignVerify(outDir string) error {
D := make(map[int]structs.Matrix[ring.Poly]) D := make(map[int]structs.Matrix[ring.Poly])
MACs := make(map[int]map[int][]byte) MACs := make(map[int]map[int][]byte)
for _, pid := range T { for _, pid := range T {
D[pid], MACs[pid] = parties[pid].SignRound1(A, sid, prfKey, T) d, macs, err := parties[pid].SignRound1(A, sid, prfKey, T)
if err != nil {
return fmt.Errorf("sign-e2e: SignRound1 party %d: %w", pid, err)
}
D[pid], MACs[pid] = d, macs
} }
z := make(map[int]structs.Vector[ring.Poly]) z := make(map[int]structs.Vector[ring.Poly])
+9 -1
View File
@@ -191,6 +191,10 @@ func emitSignVerify(outDir string) error {
parties[i].SkShare = skShares[i] parties[i].SkShare = skShares[i]
parties[i].Seed = seeds parties[i].Seed = seeds
parties[i].MACKeys = macKeys[i] parties[i].MACKeys = macKeys[i]
// Pin the hedged-nonce salt to a deterministic, per-party
// source so the KAT signature bytes reproduce across runs
// and the C++ runner. Production keeps crypto/rand.
parties[i].Rand = sign.DeterministicNonceSource(seed, i)
lambda := r.NewPoly() lambda := r.NewPoly()
lambda.Copy(lagrange[i]) lambda.Copy(lagrange[i])
r.NTT(lambda, lambda) r.NTT(lambda, lambda)
@@ -208,7 +212,11 @@ func emitSignVerify(outDir string) error {
D := make(map[int]structs.Matrix[ring.Poly]) D := make(map[int]structs.Matrix[ring.Poly])
MACs := make(map[int]map[int][]byte) MACs := make(map[int]map[int][]byte)
for _, pid := range T { for _, pid := range T {
D[pid], MACs[pid] = parties[pid].SignRound1(A, sid, prfKey, T) d, macs, err := parties[pid].SignRound1(A, sid, prfKey, T)
if err != nil {
return fmt.Errorf("sign-e2e: SignRound1 party %d: %w", pid, err)
}
D[pid], MACs[pid] = d, macs
} }
z := make(map[int]structs.Vector[ring.Poly]) z := make(map[int]structs.Vector[ring.Poly])
+5 -1
View File
@@ -73,7 +73,11 @@ func makeCombineFixture(msg string) (*combineFixture, error) {
r1Data := make(map[int]*threshold.Round1Data) r1Data := make(map[int]*threshold.Round1Data)
for _, i := range signers { for _, i := range signers {
s := threshold.NewSigner(shares[i]) s := threshold.NewSigner(shares[i])
r1Data[i] = s.Round1(sid, prfKey, signers) d, err := s.Round1(sid, prfKey, signers)
if err != nil {
return nil, err
}
r1Data[i] = d
} }
r2Data := make(map[int]*threshold.Round2Data) r2Data := make(map[int]*threshold.Round2Data)
for _, i := range signers { for _, i := range signers {
+5 -1
View File
@@ -107,7 +107,11 @@ func signFresh() (*threshold.Signature, error) {
r1Data := make(map[int]*threshold.Round1Data) r1Data := make(map[int]*threshold.Round1Data)
for _, i := range signers { for _, i := range signers {
s := threshold.NewSigner(shares[i]) s := threshold.NewSigner(shares[i])
r1Data[i] = s.Round1(sid, prfKey, signers) d, err := s.Round1(sid, prfKey, signers)
if err != nil {
return nil, err
}
r1Data[i] = d
} }
r2Data := make(map[int]*threshold.Round2Data) r2Data := make(map[int]*threshold.Round2Data)
for _, i := range signers { for _, i := range signers {
+6 -45
View File
@@ -87,7 +87,6 @@ package dkg2
import ( import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"crypto/subtle"
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
@@ -528,10 +527,14 @@ func VerifyShareAgainstCommits(
} }
// Constant-time compare across all M slots, all coefficient levels. // Constant-time compare across all M slots, all coefficient levels.
// subtle.ConstantTimeCompare returns 1 iff equal; AND across slots. // utils.ConstantTimePolyEqual returns 1 iff equal; AND across slots.
// This is the dkg2 response to Findings 5/6 of
// luxcpp/crypto/corona/RED-DKG-REVIEW.md, which note that the upstream
// dkg/ share comparison short-circuits on the first slot mismatch and
// so leaks the location of any planted divergence to a timing observer.
eq := 1 eq := 1
for ri := 0; ri < sign.M; ri++ { for ri := 0; ri < sign.M; ri++ {
eq &= constTimePolyEqual(lhs[ri], rhs[ri]) eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri])
} }
if eq != 1 { if eq != 1 {
return false, ErrShareVerification return false, ErrShareVerification
@@ -539,48 +542,6 @@ func VerifyShareAgainstCommits(
return true, nil return true, nil
} }
// constTimePolyEqual returns 1 iff a and b have identical coefficient
// arrays at every level, 0 otherwise. The comparison runs in time
// independent of how many coefficients differ — a full scan is always
// performed (no early return).
//
// This is the dkg2 response to Findings 5/6 of
// luxcpp/crypto/corona/RED-DKG-REVIEW.md, which note that the Round 2
// share-comparison loop in the upstream dkg/ package short-circuits on the
// first slot mismatch and so leaks the location of any planted divergence
// to a network observer measuring response timing.
func constTimePolyEqual(a, b ring.Poly) int {
if len(a.Coeffs) != len(b.Coeffs) {
return 0
}
eq := 1
for level := range a.Coeffs {
al := a.Coeffs[level]
bl := b.Coeffs[level]
if len(al) != len(bl) {
eq = 0
continue
}
// Reinterpret each Coeffs[level] as a byte buffer and feed it
// to subtle.ConstantTimeCompare. uint64 little-endian coefficient
// layout is byte-stable on every supported target (amd64, arm64).
ab := uint64SliceToBytes(al)
bb := uint64SliceToBytes(bl)
eq &= subtle.ConstantTimeCompare(ab, bb)
}
return eq
}
// uint64SliceToBytes returns a little-endian byte view of a []uint64. The
// caller must not retain the result past the lifetime of the input.
func uint64SliceToBytes(s []uint64) []byte {
b := make([]byte, 8*len(s))
for i, v := range s {
binary.LittleEndian.PutUint64(b[8*i:8*i+8], v)
}
return b
}
// Round2 verifies received shares (and blinds) against commitments, then // Round2 verifies received shares (and blinds) against commitments, then
// aggregates to (s_j, u_j) plus the Pedersen-shaped group public key. // aggregates to (s_j, u_j) plus the Pedersen-shaped group public key.
// //
+4 -1
View File
@@ -193,7 +193,10 @@ func signAndVerify(t *testing.T, era *KeyEra, validators []string) bool {
round1Data := make(map[int]*threshold.Round1Data, len(validators)) round1Data := make(map[int]*threshold.Round1Data, len(validators))
for _, v := range validators { for _, v := range validators {
r1 := signersByVal[v].Round1(sessionID, prfKey, signerIndices) r1, err := signersByVal[v].Round1(sessionID, prfKey, signerIndices)
if err != nil {
t.Fatalf("Round1 for %s: %v", v, err)
}
round1Data[era.State.Shares[v].Index] = r1 round1Data[era.State.Shares[v].Index] = r1
} }
+5 -1
View File
@@ -155,7 +155,11 @@ func main() {
fmt.Printf("Timestamp before Sign Round 1 compute: %s\n", time.Now().Format("15:04:05.000000")) fmt.Printf("Timestamp before Sign Round 1 compute: %s\n", time.Now().Format("15:04:05.000000"))
start := time.Now() start := time.Now()
D[partyID], MACs[partyID] = party.SignRound1(A, sid, []byte(PRFKey), T) var r1err error
D[partyID], MACs[partyID], r1err = party.SignRound1(A, sid, []byte(PRFKey), T)
if r1err != nil {
log.Fatalf("SignRound1 failed for party %d: %v", partyID, r1err)
}
signRound1Duration = time.Since(start) signRound1Duration = time.Since(start)
log.Println("Completed R1") log.Println("Completed R1")
+59 -10
View File
@@ -49,27 +49,76 @@ func PRNGKey(suite hash.HashSuite, skShare structs.Vector[ring.Poly]) []byte {
return s.PRF(buf.Bytes(), nil, keySize) return s.PRF(buf.Bytes(), nil, keySize)
} }
// PRNGKeyForRound generates a per-round PRNG seed by domain-separating // SessionIDSize is the width in bytes of a SessionID — a 256-bit
// the secret-share material with the session id. CRIT-1 fix // domain-separated identifier for one signing session.
// (red audit, 2026-05-03): without sid mixing, R/E/D are byte-identical const SessionIDSize = 32
// across every Sign call of the same Setup — multi-Sign leaks R via
// (z_sum Σ s_i·λ_i·c)·u^{-1} = R. // sessionIDTag is the nothing-up-my-sleeve domain tag for SessionID
// derivation. Version-pinned; a bump invalidates the nonce-key KATs.
var sessionIDTag = []byte("corona.sign.session-id.v1")
// DeriveSessionID maps the consensus-agreed session inputs to a 256-bit
// session identifier with no truncation. The bare int sid carries at
// most 64 bits; folding it (together with the agreed signer set T)
// through the suite TranscriptHash yields a full-width value, so an
// external collision in the low 64 bits of sid alone cannot collide the
// identifier the nonce key derives from.
// //
// Layout: PRF(key=skShare.WriteTo bytes, msg="CoronaRoundV2" || be64(sid)). // SessionID is the *deterministic, context-binding* half of the nonce
// Domain tag distinguishes from any other future per-share keying. // key. It is NOT, on its own, sufficient to prevent nonce reuse: two
// signatures sharing (sid, T) derive the same SessionID. Reuse
// durability is provided by the fresh-salt argument to PRNGKeyForRound
// (hedged signing); see that function and SignRound1's precondition.
// //
// `suite` selects the hash profile. nil resolves to the production // `suite` selects the hash profile. nil resolves to the production
// default (Corona-SHA3). // default (Corona-SHA3).
func PRNGKeyForRound(suite hash.HashSuite, skShare structs.Vector[ring.Poly], sid int64) []byte { func DeriveSessionID(suite hash.HashSuite, sid int, T []int) [SessionIDSize]byte {
s := hash.Resolve(suite)
buf := new(bytes.Buffer)
must("binary.Write(sid)", binary.Write(buf, binary.BigEndian, int64(sid)))
must("binary.Write(T-len)", binary.Write(buf, binary.BigEndian, int32(len(T))))
for _, t := range T {
must("binary.Write(T-elem)", binary.Write(buf, binary.BigEndian, int32(t)))
}
return s.TranscriptHash(sessionIDTag, buf.Bytes())
}
// PRNGKeyForRound generates a per-signature nonce-PRNG seed by binding
// the secret-share material to a 256-bit SessionID and a fresh 256-bit
// hedge salt.
//
// CRIT-1 (red audit) established that the Round-1 nonce seed must vary
// per signature, or R/E/D are byte-identical across every Sign call of
// the same Setup and multi-Sign leaks R via
// (z_sum Σ s_i·λ_i·c)·u^{-1} = R. The original fix keyed on a bare
// 64-bit sid, which made reuse durability rest entirely on the external
// consensus layer never reissuing an sid for a given share. This form
// closes that residual two ways:
//
// - sessionID is the full-width (no 64-bit truncation) deterministic
// binding to the agreed (sid, T) — see DeriveSessionID.
// - salt is fresh per-signature randomness drawn inside the kernel
// (hedged signing, à la Raccoon's fresh per-signature commitment).
// Even if sessionID collides because the consensus layer reissued
// an sid, distinct salt makes R reuse occur with probability 2^-256.
//
// Layout: PRF(key=skShare.WriteTo bytes,
//
// msg="CoronaNonceV3" || sessionID[32] || salt[32]).
//
// `suite` selects the hash profile. nil resolves to the production
// default (Corona-SHA3).
func PRNGKeyForRound(suite hash.HashSuite, skShare structs.Vector[ring.Poly], sessionID, salt [SessionIDSize]byte) []byte {
s := hash.Resolve(suite) s := hash.Resolve(suite)
skBuf := new(bytes.Buffer) skBuf := new(bytes.Buffer)
_, err := skShare.WriteTo(skBuf) _, err := skShare.WriteTo(skBuf)
must("skShare.WriteTo", err) must("skShare.WriteTo", err)
msg := new(bytes.Buffer) msg := new(bytes.Buffer)
const tag = "CoronaRoundV2" const tag = "CoronaNonceV3"
_, err = msg.WriteString(tag) _, err = msg.WriteString(tag)
must("msg.WriteString(tag)", err) must("msg.WriteString(tag)", err)
must("binary.Write(sid)", binary.Write(msg, binary.BigEndian, sid)) must("msg.Write(sessionID)", binary.Write(msg, binary.BigEndian, sessionID[:]))
must("msg.Write(salt)", binary.Write(msg, binary.BigEndian, salt[:]))
return s.PRF(skBuf.Bytes(), msg.Bytes(), keySize) return s.PRF(skBuf.Bytes(), msg.Bytes(), keySize)
} }
+42 -6
View File
@@ -287,20 +287,56 @@ func TestCoronaSHA3VsBLAKE3_DistinctOutput(t *testing.T) {
} }
}) })
t.Run("DeriveSessionID", func(t *testing.T) {
T := []int{0, 1, 2}
a := DeriveSessionID(sha3, 42, T)
// Distinct suite → distinct SessionID (F22 cross-profile sep).
if a == DeriveSessionID(bl3, 42, T) {
t.Fatal("DeriveSessionID: SHA3 and BLAKE3 produced identical SessionID")
}
// Distinct sid → distinct SessionID.
if a == DeriveSessionID(sha3, 43, T) {
t.Fatal("DeriveSessionID: sid not bound")
}
// Distinct signer set → distinct SessionID.
if a == DeriveSessionID(sha3, 42, []int{0, 1, 3}) {
t.Fatal("DeriveSessionID: signer set T not bound")
}
// Deterministic: same inputs → same SessionID.
if a != DeriveSessionID(sha3, 42, T) {
t.Fatal("DeriveSessionID: not deterministic")
}
})
t.Run("PRNGKeyForRound", func(t *testing.T) { t.Run("PRNGKeyForRound", func(t *testing.T) {
skShare := make(structs.Vector[ring.Poly], 3) skShare := make(structs.Vector[ring.Poly], 3)
for i := range skShare { for i := range skShare {
skShare[i] = sampler.ReadNew() skShare[i] = sampler.ReadNew()
} }
a := PRNGKeyForRound(sha3, skShare, 42) sid := DeriveSessionID(sha3, 42, []int{0, 1, 2})
b := PRNGKeyForRound(bl3, skShare, 42) sid2 := DeriveSessionID(sha3, 43, []int{0, 1, 2})
var salt, salt2 [SessionIDSize]byte
salt[0], salt2[0] = 0x01, 0x02
a := PRNGKeyForRound(sha3, skShare, sid, salt)
b := PRNGKeyForRound(bl3, skShare, sid, salt)
if bytes.Equal(a, b) { if bytes.Equal(a, b) {
t.Fatal("PRNGKeyForRound: SHA3 and BLAKE3 produced identical bytes for same input") t.Fatal("PRNGKeyForRound: SHA3 and BLAKE3 produced identical bytes for same input")
} }
// Also assert sid mixing: same suite, two sids → distinct bytes. // SessionID binding: distinct SessionID, same salt → distinct key.
c := PRNGKeyForRound(sha3, skShare, 43) if bytes.Equal(a, PRNGKeyForRound(sha3, skShare, sid2, salt)) {
if bytes.Equal(a, c) { t.Fatal("PRNGKeyForRound: SessionID not bound into key")
t.Fatal("PRNGKeyForRound: sid mixing broken under SHA3") }
// Hedge binding: same SessionID, distinct salt → distinct key.
// This is the property that closes the sid-reuse leak: even if an
// external sid collision makes SessionID repeat, fresh salt makes
// the nonce key (hence R) differ.
if bytes.Equal(a, PRNGKeyForRound(sha3, skShare, sid, salt2)) {
t.Fatal("PRNGKeyForRound: hedge salt not bound into key")
}
// Deterministic in (sessionID, salt).
if !bytes.Equal(a, PRNGKeyForRound(sha3, skShare, sid, salt)) {
t.Fatal("PRNGKeyForRound: not deterministic in (sessionID, salt)")
} }
}) })
+5 -43
View File
@@ -21,8 +21,6 @@ package reshare
import ( import (
"bytes" "bytes"
"crypto/subtle"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
@@ -171,12 +169,13 @@ func VerifyShareAgainstCommits(
// Constant-time compare across all M slots, all coefficient levels. // Constant-time compare across all M slots, all coefficient levels.
// Prior implementation short-circuited on first mismatch and leaked // Prior implementation short-circuited on first mismatch and leaked
// the diverging slot index via timing. Mirrors dkg2.constTimePolyEqual // the diverging slot index via timing (RED-DKG-REVIEW Findings 5/6 —
// (RED-DKG-REVIEW Findings 5/6 — the same fix applied to the reshare // the same fix applied to the reshare path). The single canonical
// path). Always scans every slot regardless of how many differ. // comparator utils.ConstantTimePolyEqual always scans every slot
// regardless of how many differ.
eq := 1 eq := 1
for ri := 0; ri < sign.M; ri++ { for ri := 0; ri < sign.M; ri++ {
eq &= constTimePolyEqual(lhs[ri], rhs[ri]) eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri])
} }
if eq != 1 { if eq != 1 {
return fmt.Errorf("%w", ErrCommitMismatch) return fmt.Errorf("%w", ErrCommitMismatch)
@@ -184,43 +183,6 @@ func VerifyShareAgainstCommits(
return nil return nil
} }
// constTimePolyEqual returns 1 iff a and b have identical coefficient
// arrays at every level, 0 otherwise. The comparison runs in time
// independent of how many coefficients differ — a full scan is always
// performed (no early return). Same routine as dkg2.constTimePolyEqual,
// kept package-local so the reshare module has no dependency on dkg2's
// internal helpers.
func constTimePolyEqual(a, b ring.Poly) int {
if len(a.Coeffs) != len(b.Coeffs) {
return 0
}
eq := 1
for level := range a.Coeffs {
al := a.Coeffs[level]
bl := b.Coeffs[level]
if len(al) != len(bl) {
eq = 0
continue
}
ab := uint64SliceToBytes(al)
bb := uint64SliceToBytes(bl)
eq &= subtle.ConstantTimeCompare(ab, bb)
}
return eq
}
// uint64SliceToBytes returns a little-endian byte view of a []uint64.
// uint64 little-endian coefficient layout is byte-stable on every
// supported target (amd64, arm64). The caller must not retain the
// result past the lifetime of the input.
func uint64SliceToBytes(s []uint64) []byte {
b := make([]byte, 8*len(s))
for i, v := range s {
binary.LittleEndian.PutUint64(b[8*i:8*i+8], v)
}
return b
}
// polyMulScalarNTTOnly multiplies each NTT coefficient of p by scalar s // polyMulScalarNTTOnly multiplies each NTT coefficient of p by scalar s
// mod q. // mod q.
func polyMulScalarNTTOnly(r *ring.Ring, p ring.Poly, s, q *big.Int) { func polyMulScalarNTTOnly(r *ring.Ring, p ring.Poly, s, q *big.Int) {
+4 -1
View File
@@ -237,7 +237,10 @@ func runRoundsAndVerify(
D := make(map[int]structs.Matrix[ring.Poly], len(parties)) D := make(map[int]structs.Matrix[ring.Poly], len(parties))
macs := make(map[int]map[int][]byte, len(parties)) macs := make(map[int]map[int][]byte, len(parties))
for _, p := range parties { for _, p := range parties {
Di, MAi := p.SignRound1(A, sid, prfKey, signSet) Di, MAi, err := p.SignRound1(A, sid, prfKey, signSet)
if err != nil {
t.Fatalf("SignRound1 party %d (%s): %v", p.ID, label, err)
}
D[p.ID] = Di D[p.ID] = Di
macs[p.ID] = MAi macs[p.ID] = MAi
} }
+1 -1
View File
@@ -4,7 +4,7 @@ b40f6d270d7c52eaa79f9a0925868cd89e28c2133d107589840d284ab51f698b test/kat/activ
3d798070e2debcfbc6dddcb2396f00eab7be3c20879885b2eb6d216cd35e4ae2 test/kat/corona_oracle_v2/montgomery_ntt.json 3d798070e2debcfbc6dddcb2396f00eab7be3c20879885b2eb6d216cd35e4ae2 test/kat/corona_oracle_v2/montgomery_ntt.json
8921ef7d1a7170cfe2b5aeb7505a21dab33c480988b011106b98c0cc7e518b57 test/kat/corona_oracle_v2/prng_blake2_xof.json 8921ef7d1a7170cfe2b5aeb7505a21dab33c480988b011106b98c0cc7e518b57 test/kat/corona_oracle_v2/prng_blake2_xof.json
58e4e9f92434789785a2ed3f61a5d554e8e77ec09010813d804a25eb155ff506 test/kat/corona_oracle_v2/shamir_share.json 58e4e9f92434789785a2ed3f61a5d554e8e77ec09010813d804a25eb155ff506 test/kat/corona_oracle_v2/shamir_share.json
3eb62c4abe3388208bebd6bdd1d9ea82edc5eb3ef83ed646bcc6c2ceaa3b03a1 test/kat/corona_oracle_v2/sign_verify_e2e.json 3d3de6f0f9a879558f907cfff25c3c8f419438d359ec8caf2135a604d45f123f test/kat/corona_oracle_v2/sign_verify_e2e.json
b683814f35aa74694643dcde5b03bdae5d2a7036db8265203162e1e7c13b1fca test/kat/corona_oracle_v2/structs_matrix_wire.json b683814f35aa74694643dcde5b03bdae5d2a7036db8265203162e1e7c13b1fca test/kat/corona_oracle_v2/structs_matrix_wire.json
ac3bca71d1774c3c1da3f44ceb52ed7b85efa0d7d4a31e1879fa5158f3b8671e test/kat/corona_oracle_v2/transcript_hash.json ac3bca71d1774c3c1da3f44ceb52ed7b85efa0d7d4a31e1879fa5158f3b8671e test/kat/corona_oracle_v2/transcript_hash.json
677bc9a0c999d16a49c82bcea3666d366ed70ae2731cde7b1ef99db27707654c test/kat/reshare_kat.json 677bc9a0c999d16a49c82bcea3666d366ed70ae2731cde7b1ef99db27707654c test/kat/reshare_kat.json
+5 -1
View File
@@ -84,7 +84,11 @@ func LocalRun(x int) {
parties[partyID].Seed = seeds parties[partyID].Seed = seeds
log.Println("Sign Round 1, party", partyID) log.Println("Sign Round 1, party", partyID)
start = time.Now() start = time.Now()
D[partyID], MACs[partyID] = parties[partyID].SignRound1(A, sid, []byte(PRFKey), T) var r1err error
D[partyID], MACs[partyID], r1err = parties[partyID].SignRound1(A, sid, []byte(PRFKey), T)
if r1err != nil {
log.Fatalf("SignRound1 failed for party %d: %v", partyID, r1err)
}
signRound1Durations[partyID] = time.Since(start) signRound1Durations[partyID] = time.Since(start)
} }
+165
View File
@@ -0,0 +1,165 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package sign
import (
"bytes"
"io"
"math/big"
"testing"
"github.com/luxfi/corona/hash"
"github.com/luxfi/corona/primitives"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/lattice/v7/utils/structs"
)
// zeroReader yields an unlimited stream of 0x00 — a degenerate
// randomness source, used to drive SignRound1's fail-closed guard.
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// shortReader yields exactly n zero bytes total then EOFs, modelling a
// randomness source that cannot satisfy the 32-byte salt draw.
type shortReader struct{ n int }
func (s *shortReader) Read(p []byte) (int, error) {
if s.n <= 0 {
return 0, io.EOF
}
k := min(len(p), s.n)
for i := 0; i < k; i++ {
p[i] = 0
}
s.n -= k
return k, nil
}
// newReuseTestParty builds one fully-keyed signing Party (index 0 of a
// K=3 group) via the real Gen ceremony, ready to call SignRound1.
func newReuseTestParty(t *testing.T) (*Party, structs.Matrix[ring.Poly], []int) {
t.Helper()
K = 3
Threshold = K
t.Cleanup(func() { K = 0; Threshold = 0 })
r, err := ring.NewRing(1<<LogN, []uint64{Q})
if err != nil {
t.Fatal(err)
}
rXi, _ := ring.NewRing(1<<LogN, []uint64{QXi})
rNu, _ := ring.NewRing(1<<LogN, []uint64{QNu})
randomKey := make([]byte, KeySize)
prng, _ := sampling.NewKeyedPRNG(randomKey)
uniformSampler := ring.NewUniformSampler(prng, r)
p, _ := sampling.NewKeyedPRNG(randomKey)
us := ring.NewUniformSampler(p, r)
party := NewPartyWithSuite(0, r, rXi, rNu, us, hash.NewCoronaSHA3())
T := make([]int, K)
for i := 0; i < K; i++ {
T[i] = i
}
lagrange := primitives.ComputeLagrangeCoefficients(r, T, big.NewInt(int64(Q)))
A, skShares, seeds, macKeys, _ := Gen(r, rXi, uniformSampler, randomKey, lagrange)
party.SkShare = skShares[0]
party.Seed = seeds
party.MACKeys = macKeys[0]
return party, A, T
}
// dMatrixBytes serializes a D matrix to canonical wire bytes for
// equality comparison. D = A·R + E, so a change in the Round-1 nonce R
// is observable as a change in these bytes.
func dMatrixBytes(t *testing.T, D structs.Matrix[ring.Poly]) []byte {
t.Helper()
var buf bytes.Buffer
if _, err := D.WriteTo(&buf); err != nil {
t.Fatalf("D.WriteTo: %v", err)
}
return buf.Bytes()
}
// TestSignRound1_NonceFreshAcrossReusedSession is the CRIT-1 regression:
// two SignRound1 calls with the SAME skShare and the SAME sid MUST NOT
// produce the same Round-1 commitment D (hence not the same nonce R),
// because the kernel hedges the nonce key with fresh crypto/rand. If they
// ever collide, the R-reuse leak (z_sum Σ s_i·λ_i·c)·u^{-1} = R reopens.
func TestSignRound1_NonceFreshAcrossReusedSession(t *testing.T) {
party, A, T := newReuseTestParty(t)
const sid = 1 // deliberately reuse the SAME session id both times.
d1, _, err := party.SignRound1(A, sid, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1 #1: %v", err)
}
d2, _, err := party.SignRound1(A, sid, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1 #2: %v", err)
}
if bytes.Equal(dMatrixBytes(t, d1), dMatrixBytes(t, d2)) {
t.Fatal("two SignRound1 calls with the same (skShare, sid) produced " +
"byte-identical D: nonce R was reused — CRIT-1 reuse leak reopened")
}
}
// TestSignRound1_DeterministicWhenNoncePinned proves the KAT seam: with a
// pinned deterministic nonce source, two SignRound1 calls with identical
// inputs reproduce the same D byte-for-byte.
func TestSignRound1_DeterministicWhenNoncePinned(t *testing.T) {
seed := bytes.Repeat([]byte{0xA5}, 32)
run := func() []byte {
party, A, T := newReuseTestParty(t)
party.Rand = DeterministicNonceSource(seed, party.ID)
d, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != nil {
t.Fatalf("SignRound1: %v", err)
}
return dMatrixBytes(t, d)
}
if !bytes.Equal(run(), run()) {
t.Fatal("pinned-nonce SignRound1 not byte-reproducible across runs")
}
}
// TestSignRound1_FailClosedOnDegenerateNonce asserts the fail-closed
// precondition guard: a randomness source that yields an all-zero salt
// (forfeiting nonce freshness) is rejected with ErrDegenerateSession, and
// a source that cannot supply 32 bytes surfaces the read error. The kernel
// MUST refuse to sign rather than proceed with a degraded nonce.
func TestSignRound1_FailClosedOnDegenerateNonce(t *testing.T) {
t.Run("zero-salt rejected", func(t *testing.T) {
party, A, T := newReuseTestParty(t)
party.Rand = zeroReader{}
_, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err != ErrDegenerateSession {
t.Fatalf("want ErrDegenerateSession on zero salt, got %v", err)
}
})
t.Run("short randomness surfaces read error", func(t *testing.T) {
party, A, T := newReuseTestParty(t)
party.Rand = &shortReader{n: 4} // < SessionIDSize (32).
_, _, err := party.SignRound1(A, 1, []byte("prf-key-32-bytes-aaaaaaaaaaaaaaa"), T)
if err == nil {
t.Fatal("want a read error when the nonce source is short, got nil")
}
if err == ErrDegenerateSession {
t.Fatal("short read should surface io error, not be masked as ErrDegenerateSession")
}
})
}
+100 -10
View File
@@ -2,6 +2,10 @@ package sign
import ( import (
"bytes" "bytes"
"crypto/rand"
"crypto/subtle"
"errors"
"io"
"math/big" "math/big"
"github.com/luxfi/corona/hash" "github.com/luxfi/corona/hash"
@@ -13,6 +17,15 @@ import (
"github.com/luxfi/lattice/v7/utils/structs" "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. // 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. // Suite is the hash profile this party uses for every primitives.* call.
@@ -34,6 +47,16 @@ type Party struct {
MACKeys map[int][]byte MACKeys map[int][]byte
MACs map[int][]byte MACs map[int][]byte
Suite hash.HashSuite 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 // NewParty initializes a new Party instance with the production hash suite
@@ -55,6 +78,7 @@ func NewPartyWithSuite(id int, r *ring.Ring, r_xi *ring.Ring, r_nu *ring.Ring, s
MACKeys: make(map[int][]byte), MACKeys: make(map[int][]byte),
MACs: make(map[int][]byte), MACs: make(map[int][]byte),
Suite: hash.Resolve(suite), Suite: hash.Resolve(suite),
Rand: rand.Reader,
} }
} }
@@ -109,17 +133,47 @@ func Gen(r *ring.Ring, r_xi *ring.Ring, uniformSampler *ring.UniformSampler, tru
return A, skShares, seeds, MACKeys, bTilde return A, skShares, seeds, MACKeys, bTilde
} }
// SignRound1 performs the first round of signing // SignRound1 performs the first round of signing.
func (party *Party) SignRound1(A structs.Matrix[ring.Poly], sid int, PRFKey []byte, T []int) (structs.Matrix[ring.Poly], map[int][]byte) { //
// 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 r := party.Ring
// CRIT-1 fix (2026-05-03): mix sid into the per-party PRNG seed. // Nonce-key derivation (replaces the CRIT-1 bare-int keying).
// Previously this was PRNGKey(SkShare) which produced byte-identical // 1. sessionID: full-width (no 64-bit truncation) deterministic
// R, E, D across every Sign call of the same Setup → multi-Sign leaked // binding to the consensus-agreed (sid, T).
// R. PRNGKeyForRound domain-separates by sid; per-block sid // 2. salt: fresh 256-bit per-signature randomness — hedged signing.
// monotonicity (LP-020 Quasar = block height) gives uniqueness for // PRNGKeyForRound = PRF(skShare, "CoronaNonceV3" || sessionID || salt).
// free. See LP-073 §5.8 (amended) and red audit response. // Either input being all-zero is a degenerate session: fail closed
skHash := primitives.PRNGKeyForRound(party.Suite, party.SkShare, int64(sid)) // 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) prng, _ := sampling.NewKeyedPRNG(skHash)
gaussianParams := ring.DiscreteGaussian{Sigma: SigmaStar, Bound: BoundStar} gaussianParams := ring.DiscreteGaussian{Sigma: SigmaStar, Bound: BoundStar}
gaussianSampler := ring.NewGaussianSampler(prng, r, gaussianParams, false) gaussianSampler := ring.NewGaussianSampler(prng, r, gaussianParams, false)
@@ -159,7 +213,43 @@ func (party *Party) SignRound1(A structs.Matrix[ring.Poly], sid int, PRFKey []by
} }
} }
return D, MACs 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 // SignRound2Preprocess verifies the MACs received in round 1 and performs the minimum eigenvalue check
+5 -1
View File
@@ -115,7 +115,11 @@ func signOnly(t *testing.T, suite hash.HashSuite) (
r.MForm(lagrangeCoeffs[id], lagrangeCoeffs[id]) r.MForm(lagrangeCoeffs[id], lagrangeCoeffs[id])
parties[id].Lambda = lagrangeCoeffs[id] parties[id].Lambda = lagrangeCoeffs[id]
parties[id].Seed = seeds parties[id].Seed = seeds
D[id], MACs[id] = parties[id].SignRound1(A, sid, []byte(PRFKey), T) var err error
D[id], MACs[id], err = parties[id].SignRound1(A, sid, []byte(PRFKey), T)
if err != nil {
t.Fatalf("SignRound1 party %d under suite %s: %v", id, suite.ID(), err)
}
} }
z := make(map[int]structs.Vector[ring.Poly]) z := make(map[int]structs.Vector[ring.Poly])
+4 -1
View File
@@ -85,7 +85,10 @@ func benchPulsarSign(b *testing.B, n, thr int, gpuOn bool) {
sid := i + 1 sid := i + 1
round1 := make(map[int]*Round1Data, n) round1 := make(map[int]*Round1Data, n)
for _, s := range signers { for _, s := range signers {
d := s.Round1(sid, prfKey, signerIDs) d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
b.Fatal(err)
}
round1[d.PartyID] = d round1[d.PartyID] = d
} }
round2 := make(map[int]*Round2Data, n) round2 := make(map[int]*Round2Data, n)
+51 -33
View File
@@ -4,8 +4,11 @@
package threshold package threshold
import ( import (
"bytes"
"crypto/rand" "crypto/rand"
"testing" "testing"
"github.com/luxfi/corona/sign"
) )
// TestE2EThresholdVariants exercises the threshold ceremony at the four // TestE2EThresholdVariants exercises the threshold ceremony at the four
@@ -52,7 +55,11 @@ func TestE2EThresholdVariants(t *testing.T) {
// Round 1. // Round 1.
r1 := make(map[int]*Round1Data, tc.n) r1 := make(map[int]*Round1Data, tc.n)
for _, s := range signers { for _, s := range signers {
d := s.Round1(sid, prfKey, signerIDs) d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("(%d,%d) Round1 party %d: %v",
tc.t, tc.n, s.share.Index, err)
}
r1[d.PartyID] = d r1[d.PartyID] = d
} }
// Round 2. // Round 2.
@@ -79,24 +86,35 @@ func TestE2EThresholdVariants(t *testing.T) {
} }
// TestE2EKATReplayDeterminism asserts that running the same protocol // TestE2EKATReplayDeterminism asserts that running the same protocol
// inputs twice (same keys, same prfKey, same sid, same message) yields // inputs twice captures the post-hedging nonce contract:
// the same signature byte-string. The Corona construction is //
// rejection-sampled but the rejection randomness derives // - With the DEFAULT crypto/rand nonce source (production), two signings
// deterministically from (sk_share, sid) per CRIT-1. // on the same key set, sid, prfKey and message yield DISTINCT
// signature bytes — fresh per-signature nonces, so R is never reused
// even when sid repeats. This is the property that durably closes the
// CRIT-1 nonce-reuse leak.
// - With a PINNED deterministic nonce source (the KAT/oracle seam), two
// signings yield BYTE-IDENTICAL signatures — reproducibility for the
// cross-runtime KAT vectors.
func TestE2EKATReplayDeterminism(t *testing.T) { func TestE2EKATReplayDeterminism(t *testing.T) {
// Use a deterministic randSource so GenerateKeys is reproducible.
seed := [32]byte{ seed := [32]byte{
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
} }
type fixedReader struct{ buf [32]byte } const message = "corona KAT replay determinism"
// runSign: deterministic across calls with the same shares.
runSign := func(shares []*KeyShare, gk *GroupKey) *Signature { // pinNonce: when true, pin each signer to a deterministic, per-party
// salt source so the run is byte-reproducible; otherwise leave the
// default crypto/rand (hedged production path).
runSign := func(shares []*KeyShare, pinNonce bool) []byte {
signers := make([]*Signer, len(shares)) signers := make([]*Signer, len(shares))
for i, share := range shares { for i, share := range shares {
signers[i] = NewSigner(share) signers[i] = NewSigner(share)
if pinNonce {
signers[i].SetNonceRand(sign.DeterministicNonceSource(seed[:], i))
}
} }
signerIDs := make([]int, len(shares)) signerIDs := make([]int, len(shares))
for i := range signerIDs { for i := range signerIDs {
@@ -104,11 +122,13 @@ func TestE2EKATReplayDeterminism(t *testing.T) {
} }
const sid = 1 const sid = 1
prfKey := seed[:] prfKey := seed[:]
message := "corona KAT replay determinism"
r1 := make(map[int]*Round1Data, len(shares)) r1 := make(map[int]*Round1Data, len(shares))
for _, s := range signers { for _, s := range signers {
d := s.Round1(sid, prfKey, signerIDs) d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
r1[d.PartyID] = d r1[d.PartyID] = d
} }
r2 := make(map[int]*Round2Data, len(shares)) r2 := make(map[int]*Round2Data, len(shares))
@@ -123,33 +143,31 @@ func TestE2EKATReplayDeterminism(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Finalize: %v", err) t.Fatalf("Finalize: %v", err)
} }
return sig if !Verify(&GroupKey{A: shares[0].GroupKey.A, BTilde: shares[0].GroupKey.BTilde, Params: shares[0].GroupKey.Params}, message, sig) {
t.Fatal("signature must verify")
}
b, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("MarshalBinary: %v", err)
}
return b
} }
// Two key sets generated with the same seed. shares, _, err := GenerateKeys(2, 3, rand.Reader)
rdr1 := &fixedReader{buf: seed}
rdr2 := &fixedReader{buf: seed}
_ = rdr1
_ = rdr2
// GenerateKeys uses io.ReadFull(randSource, key); we can't easily
// drive it deterministically without modifying the API, so instead
// we verify the WEAKER but operationally-meaningful property:
// signing TWICE on the SAME key set produces the SAME signature.
shares, gk, err := GenerateKeys(2, 3, rand.Reader)
if err != nil { if err != nil {
t.Fatalf("GenerateKeys: %v", err) t.Fatalf("GenerateKeys: %v", err)
} }
sig1 := runSign(shares, gk)
sig2 := runSign(shares, gk)
if !Verify(gk, "corona KAT replay determinism", sig1) || // Hedged production path: two runs MUST differ (fresh nonces) yet both
!Verify(gk, "corona KAT replay determinism", sig2) { // verify. Distinct bytes here is the direct, observable evidence that
t.Fatal("both signatures must verify") // R is not reused across signatures of the same (share, sid).
if bytes.Equal(runSign(shares, false), runSign(shares, false)) {
t.Fatal("hedged signing produced byte-identical signatures across two runs: nonce not fresh (CRIT-1 reuse risk)")
}
// Pinned deterministic path: two runs MUST be byte-identical
// (KAT reproducibility).
if !bytes.Equal(runSign(shares, true), runSign(shares, true)) {
t.Fatal("pinned-nonce signing not byte-reproducible across two runs")
} }
// Per CRIT-1, identical (sk_share, sid, prfKey, message, signerIDs)
// inputs yield identical Round-1 / Round-2 outputs and hence
// identical Finalize bytes. (The seeds in Party.Seed are NOT
// regenerated between calls because we reuse the share-bound
// Party state.)
_ = sig2
} }
+5 -1
View File
@@ -203,7 +203,11 @@ func mustKernelCeremony(tb testing.TB) {
} }
r1 := make(map[int]*Round1Data, 3) r1 := make(map[int]*Round1Data, 3)
for i, p := range parties { for i, p := range parties {
r1[i] = p.Round1(1, prfKey, signers) d, err := p.Round1(1, prfKey, signers)
if err != nil {
tb.Fatalf("Round1: %v", err)
}
r1[i] = d
} }
// Serialize party-0 Round1Data.D (Matrix[Poly]) wire bytes. // Serialize party-0 Round1Data.D (Matrix[Poly]) wire bytes.
+4 -1
View File
@@ -37,7 +37,10 @@ func FuzzVerifyParseSignature(f *testing.F) {
message := "fuzz verify seed message" message := "fuzz verify seed message"
r1 := make(map[int]*Round1Data) r1 := make(map[int]*Round1Data)
for _, s := range signers { for _, s := range signers {
d := s.Round1(sid, prfKey, signerIDs) d, err := s.Round1(sid, prfKey, signerIDs)
if err != nil {
f.Fatal(err)
}
r1[d.PartyID] = d r1[d.PartyID] = d
} }
r2 := make(map[int]*Round2Data) r2 := make(map[int]*Round2Data)
+23 -4
View File
@@ -1,7 +1,8 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved. // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// Package corona provides post-quantum threshold signatures using Ring-LWE. // Package corona provides post-quantum threshold signatures using Module-LWE
// (threshold-Raccoon; module dimensions M=8, N=7 over Z_q[X]/(X^256+1)).
// //
// Signing is a 2-round protocol: // Signing is a 2-round protocol:
// - Round 1: Each party broadcasts D matrix + MACs // - Round 1: Each party broadcasts D matrix + MACs
@@ -206,14 +207,32 @@ func NewSigner(share *KeyShare) *Signer {
} }
} }
// SetNonceRand overrides the source of the fresh per-signature nonce
// hedge salt for this signer. Production signers never call this — they
// inherit crypto/rand.Reader from sign.NewParty. It exists solely so
// KAT/oracle and CPU-vs-GPU byte-equality harnesses can pin signing to a
// deterministic reader and obtain reproducible signature bytes. This is
// the threshold-layer accessor for the single sign.Party.Rand seam.
func (s *Signer) SetNonceRand(r io.Reader) {
s.party.Rand = r
}
// Round1 performs signing round 1. Returns D matrix and MACs to broadcast. // Round1 performs signing round 1. Returns D matrix and MACs to broadcast.
func (s *Signer) Round1(sessionID int, prfKey []byte, signers []int) *Round1Data { //
D, MACs := s.party.SignRound1(s.share.GroupKey.A, sessionID, prfKey, signers) // PRECONDITION: sessionID MUST be unique for this signer's share across
// that share's lifetime (consensus slot-uniqueness; see
// sign.Party.SignRound1). Returns ErrDegenerateSession (via the kernel)
// if the session is degenerate; on error the caller MUST abort signing.
func (s *Signer) Round1(sessionID int, prfKey []byte, signers []int) (*Round1Data, error) {
D, MACs, err := s.party.SignRound1(s.share.GroupKey.A, sessionID, prfKey, signers)
if err != nil {
return nil, err
}
return &Round1Data{ return &Round1Data{
PartyID: s.share.Index, PartyID: s.share.Index,
D: D, D: D,
MACs: MACs, MACs: MACs,
} }, nil
} }
// Round2 performs signing round 2. Returns z share to broadcast. // Round2 performs signing round 2. Returns z share to broadcast.
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"testing" "testing"
cgpu "github.com/luxfi/corona/gpu" cgpu "github.com/luxfi/corona/gpu"
"github.com/luxfi/corona/sign"
) )
// deterministicReader is a SHA-256 keystream over a 32-byte seed, // deterministicReader is a SHA-256 keystream over a 32-byte seed,
@@ -95,11 +96,19 @@ func TestThresholdSign_CPU_vs_GPU_ByteIdentical(t *testing.T) {
signers := make([]*Signer, k) signers := make([]*Signer, k)
for i, sh := range shares { for i, sh := range shares {
signers[i] = NewSigner(sh) signers[i] = NewSigner(sh)
// Pin the hedged-nonce salt to a deterministic, per-party
// source keyed by the shared dealer seed, so both the CPU and
// GPU legs draw identical salts and the resulting signature
// bytes are byte-identical across paths.
signers[i].SetNonceRand(sign.DeterministicNonceSource(seed[:], i))
} }
round1 := make(map[int]*Round1Data) round1 := make(map[int]*Round1Data)
for _, s := range signers { for _, s := range signers {
d := s.Round1(sessionID, prfKey, signerIDs) d, err := s.Round1(sessionID, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1 (gpu=%v): %v", gpuOn, err)
}
round1[d.PartyID] = d round1[d.PartyID] = d
} }
+8 -2
View File
@@ -58,7 +58,10 @@ func TestThresholdSigningFlow(t *testing.T) {
// Round 1: All parties compute D + MACs // Round 1: All parties compute D + MACs
round1Data := make(map[int]*Round1Data) round1Data := make(map[int]*Round1Data)
for _, signer := range signers { for _, signer := range signers {
data := signer.Round1(sessionID, prfKey, signerIDs) data, err := signer.Round1(sessionID, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
round1Data[data.PartyID] = data round1Data[data.PartyID] = data
t.Logf("Party %d: Round1 complete, D size: %d x %d", data.PartyID, len(data.D), len(data.D[0])) t.Logf("Party %d: Round1 complete, D size: %d x %d", data.PartyID, len(data.D), len(data.D[0]))
} }
@@ -108,7 +111,10 @@ func TestThresholdWrongMessage(t *testing.T) {
// Round 1 // Round 1
round1Data := make(map[int]*Round1Data) round1Data := make(map[int]*Round1Data)
for _, signer := range signers { for _, signer := range signers {
data := signer.Round1(sessionID, prfKey, signerIDs) data, err := signer.Round1(sessionID, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
round1Data[data.PartyID] = data round1Data[data.PartyID] = data
} }
+4 -1
View File
@@ -29,7 +29,10 @@ func freshSig(t testing.TB, message string) (*GroupKey, *Signature) {
round1 := make(map[int]*Round1Data) round1 := make(map[int]*Round1Data)
for _, s := range signers { for _, s := range signers {
d := s.Round1(sessionID, prfKey, signerIDs) d, err := s.Round1(sessionID, prfKey, signerIDs)
if err != nil {
t.Fatalf("Round1: %v", err)
}
round1[d.PartyID] = d round1[d.PartyID] = d
} }
round2 := make(map[int]*Round2Data) round2 := make(map[int]*Round2Data)
+8 -2
View File
@@ -37,8 +37,14 @@ func TestSignatureWireRoundtrip(t *testing.T) {
sigB := NewSigner(shares[1]) sigB := NewSigner(shares[1])
const sessionID = 42 const sessionID = 42
r1A := sigA.Round1(sessionID, prfKey, signers) r1A, err := sigA.Round1(sessionID, prfKey, signers)
r1B := sigB.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} r1Data := map[int]*Round1Data{0: r1A, 1: r1B}
msg := "corona-wire-roundtrip-test" msg := "corona-wire-roundtrip-test"
+45
View File
@@ -1,6 +1,8 @@
package utils package utils
import ( import (
"crypto/subtle"
"encoding/binary"
"fmt" "fmt"
"log" "log"
"math/big" "math/big"
@@ -11,6 +13,49 @@ import (
"github.com/zeebo/blake3" "github.com/zeebo/blake3"
) )
// ConstantTimePolyEqual reports whether two ring polynomials have
// identical coefficient arrays at every level, returning 1 iff equal and
// 0 otherwise. The comparison runs in time independent of how many
// coefficients differ — every level is always scanned (no early return),
// and the per-level compare is crypto/subtle.ConstantTimeCompare over the
// little-endian byte view of each level's []uint64.
//
// This is the single canonical constant-time poly comparator. The dkg2
// and reshare commit-verification paths both delegate here so there is
// exactly one implementation. It exists because the upstream dkg/ share
// comparison short-circuited on the first slot mismatch, leaking the
// location of a planted divergence to a timing observer
// (luxcpp/crypto/corona/RED-DKG-REVIEW.md Findings 5/6).
func ConstantTimePolyEqual(a, b ring.Poly) int {
if len(a.Coeffs) != len(b.Coeffs) {
return 0
}
eq := 1
for level := range a.Coeffs {
al := a.Coeffs[level]
bl := b.Coeffs[level]
if len(al) != len(bl) {
eq = 0
continue
}
ab := uint64SliceToBytes(al)
bb := uint64SliceToBytes(bl)
eq &= subtle.ConstantTimeCompare(ab, bb)
}
return eq
}
// uint64SliceToBytes returns a little-endian byte view of a []uint64.
// The uint64 little-endian coefficient layout is byte-stable on every
// supported target (amd64, arm64).
func uint64SliceToBytes(s []uint64) []byte {
b := make([]byte, 8*len(s))
for i, v := range s {
binary.LittleEndian.PutUint64(b[8*i:8*i+8], v)
}
return b
}
// MatrixVectorMul performs matrix-vector multiplication. // MatrixVectorMul performs matrix-vector multiplication.
func MatrixVectorMulNTT(r *ring.Ring, M structs.Matrix[ring.Poly], vec structs.Vector[ring.Poly], result structs.Vector[ring.Poly]) { func MatrixVectorMulNTT(r *ring.Ring, M structs.Matrix[ring.Poly], vec structs.Vector[ring.Poly], result structs.Vector[ring.Poly]) {
// Convert all elements of the matrix and the vector to the NTT domain // Convert all elements of the matrix and the vector to the NTT domain