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
opening, an arithmetic intermediate that depends on a secret, or a
verifier digest that is compared against an attacker-supplied value.
**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)** Constant-time gap exists; documented; not exploitable because
the value is public, or the path is verifier-controlled and the timing
oracle does not gain the adversary anything beyond what the result
byte already tells them.
- **(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 is
already byte-blob `subtle.ConstantTimeCompare` or routes through
upstream primitives whose constant-time behavior we cite below. The
remaining (b) entries are public-input verifier paths where the leakage
channel reduces to "is the verifier's output byte 0 or 1" — already
visible to the network observer.
**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. Verifier paths
## 1. Per-signature nonce key (the no-leak-critical path)
| 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. |
| `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). |
| `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. |
| `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]. |
| `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
| `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. DKG2 complaint / verification
## 2. Round-2 masking and the secret-dependent arithmetic
| 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. |
| 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. |
| 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. |
| 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. |
| 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 handling
## 3. Share verification, complaints, and digest comparison
| 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). |
| `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. |
| `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. |
| Lens share derivation (`lens/keyera/keyera.go`) | mirrored into `lens/CONSTANT-TIME-REVIEW.md` | See lens doc. |
| `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. Scalar / ring operations
## 4. Samplers — the residual TCB axiom
| 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`. |
| `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]. |
| `lens/primitives` curve.go scalar ops on three curves | see §5 below | |
[5] https://github.com/tuneinsight/lattigo (we vendor `luxfi/lattice/v7` from this; reduction routines are byte-stable and CT-claimed).
| 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. 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
mini-audit.
### Ed25519 (`lens/primitives/ed25519.go`)
| Operation | Status | Notes |
| Location | 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]. |
| 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). |
| `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]. |
| `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. |
| `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. |
### 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). |
| 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). |
| `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. |
| `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 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 first-diverging slot index. Mitigation: dkg2's `VerifyShareAgainstCommits`
already does CT compare, and the legacy `reshare` path will be
migrated to use the same `constTimePolyEqual` helper at the next
reshare-protocol revision (Mar-31 cutover; tracked in
`~/work/lux/pulsar/LLM.md`).
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.
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.
---
## Reviewers
## Cross-references
- Audit pass: Scientist (Mar-3-2026)
- Cross-check: companion file `~/work/lux/lens/CONSTANT-TIME-REVIEW.md`
(lens-specific section duplicates §5 here under the lens module's
own surface inventory).
- Proof anchor: `proofs/definitions/transcript-binding.tex`
Definitions ref:pulsar-transcript and ref:pulsar-activation-msg.
- TCB axiom inventory: `TRUSTED-COMPUTING-BASE.md`, `AXIOM-INVENTORY.md`.
- Nonce-key construction and the CRIT-1 history: `primitives/hash.go`
(`DeriveSessionID`, `PRNGKeyForRound`) and `sign/sign.go`
(`SignRound1`).
- Constant-time share comparison origin:
`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:
> **Construction-level interchangeability (Class N1).** Every
> signature byte string produced by the Corona threshold combine
> procedure (`threshold.Combine` / `sign.LocalSign` aggregation flow)
> signature byte string produced by the Corona threshold finalize
> 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)`
> satisfying the protocol's well-formedness invariants verifies under
> 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].Seed = seeds
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.Copy(lagrange[i])
r.NTT(lambda, lambda)
@@ -1007,7 +1012,11 @@ func emitSignVerify(outDir string) error {
D := make(map[int]structs.Matrix[ring.Poly])
MACs := make(map[int]map[int][]byte)
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])
+9 -1
View File
@@ -191,6 +191,10 @@ func emitSignVerify(outDir string) error {
parties[i].SkShare = skShares[i]
parties[i].Seed = seeds
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.Copy(lagrange[i])
r.NTT(lambda, lambda)
@@ -208,7 +212,11 @@ func emitSignVerify(outDir string) error {
D := make(map[int]structs.Matrix[ring.Poly])
MACs := make(map[int]map[int][]byte)
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])
+5 -1
View File
@@ -73,7 +73,11 @@ func makeCombineFixture(msg string) (*combineFixture, error) {
r1Data := make(map[int]*threshold.Round1Data)
for _, i := range signers {
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)
for _, i := range signers {
+5 -1
View File
@@ -107,7 +107,11 @@ func signFresh() (*threshold.Signature, error) {
r1Data := make(map[int]*threshold.Round1Data)
for _, i := range signers {
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)
for _, i := range signers {
+6 -45
View File
@@ -87,7 +87,6 @@ package dkg2
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"errors"
"fmt"
@@ -528,10 +527,14 @@ func VerifyShareAgainstCommits(
}
// 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
for ri := 0; ri < sign.M; ri++ {
eq &= constTimePolyEqual(lhs[ri], rhs[ri])
eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri])
}
if eq != 1 {
return false, ErrShareVerification
@@ -539,48 +542,6 @@ func VerifyShareAgainstCommits(
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
// 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))
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
}
+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"))
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)
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)
}
// PRNGKeyForRound generates a per-round PRNG seed by domain-separating
// the secret-share material with the session id. CRIT-1 fix
// (red audit, 2026-05-03): without sid mixing, R/E/D are byte-identical
// across every Sign call of the same Setup — multi-Sign leaks R via
// (z_sum Σ s_i·λ_i·c)·u^{-1} = R.
// SessionIDSize is the width in bytes of a SessionID — a 256-bit
// domain-separated identifier for one signing session.
const SessionIDSize = 32
// 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)).
// Domain tag distinguishes from any other future per-share keying.
// SessionID is the *deterministic, context-binding* half of the nonce
// 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
// 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)
skBuf := new(bytes.Buffer)
_, err := skShare.WriteTo(skBuf)
must("skShare.WriteTo", err)
msg := new(bytes.Buffer)
const tag = "CoronaRoundV2"
const tag = "CoronaNonceV3"
_, err = msg.WriteString(tag)
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)
}
+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) {
skShare := make(structs.Vector[ring.Poly], 3)
for i := range skShare {
skShare[i] = sampler.ReadNew()
}
a := PRNGKeyForRound(sha3, skShare, 42)
b := PRNGKeyForRound(bl3, skShare, 42)
sid := DeriveSessionID(sha3, 42, []int{0, 1, 2})
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) {
t.Fatal("PRNGKeyForRound: SHA3 and BLAKE3 produced identical bytes for same input")
}
// Also assert sid mixing: same suite, two sids → distinct bytes.
c := PRNGKeyForRound(sha3, skShare, 43)
if bytes.Equal(a, c) {
t.Fatal("PRNGKeyForRound: sid mixing broken under SHA3")
// SessionID binding: distinct SessionID, same salt → distinct key.
if bytes.Equal(a, PRNGKeyForRound(sha3, skShare, sid2, salt)) {
t.Fatal("PRNGKeyForRound: SessionID not bound into key")
}
// 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 (
"bytes"
"crypto/subtle"
"encoding/binary"
"errors"
"fmt"
"math/big"
@@ -171,12 +169,13 @@ func VerifyShareAgainstCommits(
// Constant-time compare across all M slots, all coefficient levels.
// Prior implementation short-circuited on first mismatch and leaked
// the diverging slot index via timing. Mirrors dkg2.constTimePolyEqual
// (RED-DKG-REVIEW Findings 5/6 — the same fix applied to the reshare
// path). Always scans every slot regardless of how many differ.
// the diverging slot index via timing (RED-DKG-REVIEW Findings 5/6 —
// the same fix applied to the reshare path). The single canonical
// comparator utils.ConstantTimePolyEqual always scans every slot
// regardless of how many differ.
eq := 1
for ri := 0; ri < sign.M; ri++ {
eq &= constTimePolyEqual(lhs[ri], rhs[ri])
eq &= utils.ConstantTimePolyEqual(lhs[ri], rhs[ri])
}
if eq != 1 {
return fmt.Errorf("%w", ErrCommitMismatch)
@@ -184,43 +183,6 @@ func VerifyShareAgainstCommits(
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
// mod q.
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))
macs := make(map[int]map[int][]byte, len(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
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
8921ef7d1a7170cfe2b5aeb7505a21dab33c480988b011106b98c0cc7e518b57 test/kat/corona_oracle_v2/prng_blake2_xof.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
ac3bca71d1774c3c1da3f44ceb52ed7b85efa0d7d4a31e1879fa5158f3b8671e test/kat/corona_oracle_v2/transcript_hash.json
677bc9a0c999d16a49c82bcea3666d366ed70ae2731cde7b1ef99db27707654c test/kat/reshare_kat.json
+5 -1
View File
@@ -84,7 +84,11 @@ func LocalRun(x int) {
parties[partyID].Seed = seeds
log.Println("Sign Round 1, party", partyID)
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)
}
+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 (
"bytes"
"crypto/rand"
"crypto/subtle"
"errors"
"io"
"math/big"
"github.com/luxfi/corona/hash"
@@ -13,6 +17,15 @@ import (
"github.com/luxfi/lattice/v7/utils/structs"
)
// ErrDegenerateSession is returned by SignRound1 when the derived
// session identifier or the fresh hedge salt is all-zero. Both are
// fail-closed preconditions: an all-zero SessionID indicates an
// uninitialised/degenerate (sid, T), and an all-zero salt indicates the
// nonce-randomness source returned no entropy. Signing under either
// would forfeit the per-signature nonce-freshness on which the no-leak
// property rests, so the kernel refuses to proceed.
var ErrDegenerateSession = errors.New("corona/sign: degenerate session (zero SessionID or zero nonce salt); refusing to sign")
// Party struct holds all state and methods for a party in the protocol.
//
// Suite is the hash profile this party uses for every primitives.* call.
@@ -34,6 +47,16 @@ type Party struct {
MACKeys map[int][]byte
MACs map[int][]byte
Suite hash.HashSuite
// Rand is the source of the fresh per-signature nonce hedge salt
// drawn in SignRound1. NewPartyWithSuite defaults it to
// crypto/rand.Reader, which is what every production signer uses.
// KAT/oracle and cross-path byte-equality harnesses pin it to a
// deterministic reader so the signature bytes are reproducible; no
// other code path should override it. This is the single seam
// between hedged (production) and deterministic (test) signing —
// mirroring threshold.GenerateKeys' randSource parameter.
Rand io.Reader
}
// NewParty initializes a new Party instance with the production hash suite
@@ -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),
MACs: make(map[int][]byte),
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
}
// 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) {
// SignRound1 performs the first round of signing.
//
// PRECONDITION (HARD, consensus-enforced): the caller MUST supply an sid
// that is unique for this signer's skShare across the lifetime of that
// share. In the Quasar deployment sid is the consensus slot/round and
// slot-uniqueness is a chain invariant (LP-020). This kernel does NOT
// trust that invariant for its no-leak guarantee: the nonce key is
// additionally hedged with fresh per-signature randomness drawn from
// party.Rand (default crypto/rand), so reuse of an sid degrades only the
// defence-in-depth layer, not the security proof. An obviously-degenerate
// session (zero derived SessionID, or a randomness source that yields a
// zero salt) is rejected fail-closed with ErrDegenerateSession.
//
// The error return is the fail-closed signal; on error the returned D
// and MACs are nil and the caller MUST abort the signing session.
func (party *Party) SignRound1(A structs.Matrix[ring.Poly], sid int, PRFKey []byte, T []int) (structs.Matrix[ring.Poly], map[int][]byte, error) {
r := party.Ring
// CRIT-1 fix (2026-05-03): mix sid into the per-party PRNG seed.
// Previously this was PRNGKey(SkShare) which produced byte-identical
// R, E, D across every Sign call of the same Setup → multi-Sign leaked
// R. PRNGKeyForRound domain-separates by sid; per-block sid
// monotonicity (LP-020 Quasar = block height) gives uniqueness for
// free. See LP-073 §5.8 (amended) and red audit response.
skHash := primitives.PRNGKeyForRound(party.Suite, party.SkShare, int64(sid))
// Nonce-key derivation (replaces the CRIT-1 bare-int keying).
// 1. sessionID: full-width (no 64-bit truncation) deterministic
// binding to the consensus-agreed (sid, T).
// 2. salt: fresh 256-bit per-signature randomness — hedged signing.
// PRNGKeyForRound = PRF(skShare, "CoronaNonceV3" || sessionID || salt).
// Either input being all-zero is a degenerate session: fail closed
// rather than sign with forfeited nonce freshness.
sessionID := primitives.DeriveSessionID(party.Suite, sid, T)
if isZero32(sessionID) {
return nil, nil, ErrDegenerateSession
}
src := party.Rand
if src == nil {
src = rand.Reader
}
var salt [primitives.SessionIDSize]byte
if _, err := io.ReadFull(src, salt[:]); err != nil {
return nil, nil, err
}
if isZero32(salt) {
return nil, nil, ErrDegenerateSession
}
skHash := primitives.PRNGKeyForRound(party.Suite, party.SkShare, sessionID, salt)
prng, _ := sampling.NewKeyedPRNG(skHash)
gaussianParams := ring.DiscreteGaussian{Sigma: SigmaStar, Bound: BoundStar}
gaussianSampler := ring.NewGaussianSampler(prng, r, gaussianParams, false)
@@ -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
+5 -1
View File
@@ -115,7 +115,11 @@ func signOnly(t *testing.T, suite hash.HashSuite) (
r.MForm(lagrangeCoeffs[id], lagrangeCoeffs[id])
parties[id].Lambda = lagrangeCoeffs[id]
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])
+4 -1
View File
@@ -85,7 +85,10 @@ func benchPulsarSign(b *testing.B, n, thr int, gpuOn bool) {
sid := i + 1
round1 := make(map[int]*Round1Data, n)
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
}
round2 := make(map[int]*Round2Data, n)
+51 -33
View File
@@ -4,8 +4,11 @@
package threshold
import (
"bytes"
"crypto/rand"
"testing"
"github.com/luxfi/corona/sign"
)
// TestE2EThresholdVariants exercises the threshold ceremony at the four
@@ -52,7 +55,11 @@ func TestE2EThresholdVariants(t *testing.T) {
// Round 1.
r1 := make(map[int]*Round1Data, tc.n)
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
}
// Round 2.
@@ -79,24 +86,35 @@ func TestE2EThresholdVariants(t *testing.T) {
}
// TestE2EKATReplayDeterminism asserts that running the same protocol
// inputs twice (same keys, same prfKey, same sid, same message) yields
// the same signature byte-string. The Corona construction is
// rejection-sampled but the rejection randomness derives
// deterministically from (sk_share, sid) per CRIT-1.
// inputs twice captures the post-hedging nonce contract:
//
// - With the DEFAULT crypto/rand nonce source (production), two signings
// 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) {
// Use a deterministic randSource so GenerateKeys is reproducible.
seed := [32]byte{
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
}
type fixedReader struct{ buf [32]byte }
// runSign: deterministic across calls with the same shares.
runSign := func(shares []*KeyShare, gk *GroupKey) *Signature {
const message = "corona KAT replay determinism"
// 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))
for i, share := range shares {
signers[i] = NewSigner(share)
if pinNonce {
signers[i].SetNonceRand(sign.DeterministicNonceSource(seed[:], i))
}
}
signerIDs := make([]int, len(shares))
for i := range signerIDs {
@@ -104,11 +122,13 @@ func TestE2EKATReplayDeterminism(t *testing.T) {
}
const sid = 1
prfKey := seed[:]
message := "corona KAT replay determinism"
r1 := make(map[int]*Round1Data, len(shares))
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
}
r2 := make(map[int]*Round2Data, len(shares))
@@ -123,33 +143,31 @@ func TestE2EKATReplayDeterminism(t *testing.T) {
if err != nil {
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.
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)
shares, _, err := GenerateKeys(2, 3, rand.Reader)
if err != nil {
t.Fatalf("GenerateKeys: %v", err)
}
sig1 := runSign(shares, gk)
sig2 := runSign(shares, gk)
if !Verify(gk, "corona KAT replay determinism", sig1) ||
!Verify(gk, "corona KAT replay determinism", sig2) {
t.Fatal("both signatures must verify")
// Hedged production path: two runs MUST differ (fresh nonces) yet both
// verify. Distinct bytes here is the direct, observable evidence that
// 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)
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.
+4 -1
View File
@@ -37,7 +37,10 @@ func FuzzVerifyParseSignature(f *testing.F) {
message := "fuzz verify seed message"
r1 := make(map[int]*Round1Data)
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
}
r2 := make(map[int]*Round2Data)
+23 -4
View File
@@ -1,7 +1,8 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// 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:
// - 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.
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{
PartyID: s.share.Index,
D: D,
MACs: MACs,
}
}, nil
}
// Round2 performs signing round 2. Returns z share to broadcast.
+10 -1
View File
@@ -10,6 +10,7 @@ import (
"testing"
cgpu "github.com/luxfi/corona/gpu"
"github.com/luxfi/corona/sign"
)
// 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)
for i, sh := range shares {
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)
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
}
+8 -2
View File
@@ -58,7 +58,10 @@ func TestThresholdSigningFlow(t *testing.T) {
// Round 1: All parties compute D + MACs
round1Data := make(map[int]*Round1Data)
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
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
round1Data := make(map[int]*Round1Data)
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
}
+4 -1
View File
@@ -29,7 +29,10 @@ func freshSig(t testing.TB, message string) (*GroupKey, *Signature) {
round1 := make(map[int]*Round1Data)
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
}
round2 := make(map[int]*Round2Data)
+8 -2
View File
@@ -37,8 +37,14 @@ func TestSignatureWireRoundtrip(t *testing.T) {
sigB := NewSigner(shares[1])
const sessionID = 42
r1A := sigA.Round1(sessionID, prfKey, signers)
r1B := sigB.Round1(sessionID, prfKey, signers)
r1A, err := sigA.Round1(sessionID, prfKey, signers)
if err != nil {
t.Fatalf("Round1 A: %v", err)
}
r1B, err := sigB.Round1(sessionID, prfKey, signers)
if err != nil {
t.Fatalf("Round1 B: %v", err)
}
r1Data := map[int]*Round1Data{0: r1A, 1: r1B}
msg := "corona-wire-roundtrip-test"
+45
View File
@@ -1,6 +1,8 @@
package utils
import (
"crypto/subtle"
"encoding/binary"
"fmt"
"log"
"math/big"
@@ -11,6 +13,49 @@ import (
"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.
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