From dc15c54a4740b40a8f63315e70d23d074d71d85f Mon Sep 17 00:00:00 2001 From: Antje Worring Date: Sun, 21 Jun 2026 08:21:58 -0700 Subject: [PATCH] harden: hedged 256-bit nonce key, DRY CT comparator, honest CT/proof docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONSTANT-TIME-REVIEW.md | 167 ++++++++++------------- PROOF-CLAIMS.md | 5 +- cmd/corona_oracle_v2/main.go | 11 +- cmd/sign_oracle/main.go | 10 +- ct/dudect/combine_ct.go | 6 +- ct/dudect/verify_ct.go | 6 +- dkg2/dkg2.go | 51 +------ keyera/keyera_test.go | 5 +- main.go | 6 +- primitives/hash.go | 69 ++++++++-- primitives/hash_test.go | 48 ++++++- reshare/commit.go | 48 +------ reshare/integration_test.go | 5 +- scripts/regen-kats.manifest.sha256 | 2 +- sign/local.go | 6 +- sign/nonce_reuse_test.go | 165 ++++++++++++++++++++++ sign/sign.go | 110 +++++++++++++-- sign/sign_roundtrip_test.go | 6 +- threshold/bench_gpu_test.go | 5 +- threshold/e2e_threshold_variants_test.go | 84 +++++++----- threshold/fuzz_round_test.go | 6 +- threshold/fuzz_verify_test.go | 5 +- threshold/threshold.go | 27 +++- threshold/threshold_gpu_test.go | 11 +- threshold/threshold_test.go | 10 +- threshold/verify_batch_test.go | 5 +- threshold/wire_test.go | 10 +- utils/utils.go | 45 ++++++ 28 files changed, 668 insertions(+), 266 deletions(-) create mode 100644 sign/nonce_reuse_test.go diff --git a/CONSTANT-TIME-REVIEW.md b/CONSTANT-TIME-REVIEW.md index a032d29..7855f23 100644 --- a/CONSTANT-TIME-REVIEW.md +++ b/CONSTANT-TIME-REVIEW.md @@ -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. diff --git a/PROOF-CLAIMS.md b/PROOF-CLAIMS.md index 8c86d16..d8c048c 100644 --- a/PROOF-CLAIMS.md +++ b/PROOF-CLAIMS.md @@ -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 diff --git a/cmd/corona_oracle_v2/main.go b/cmd/corona_oracle_v2/main.go index e8f61dc..86cf563 100644 --- a/cmd/corona_oracle_v2/main.go +++ b/cmd/corona_oracle_v2/main.go @@ -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]) diff --git a/cmd/sign_oracle/main.go b/cmd/sign_oracle/main.go index f24cd86..5b3bcfe 100644 --- a/cmd/sign_oracle/main.go +++ b/cmd/sign_oracle/main.go @@ -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]) diff --git a/ct/dudect/combine_ct.go b/ct/dudect/combine_ct.go index 9413736..b8d684f 100644 --- a/ct/dudect/combine_ct.go +++ b/ct/dudect/combine_ct.go @@ -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 { diff --git a/ct/dudect/verify_ct.go b/ct/dudect/verify_ct.go index 017281e..060a954 100644 --- a/ct/dudect/verify_ct.go +++ b/ct/dudect/verify_ct.go @@ -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 { diff --git a/dkg2/dkg2.go b/dkg2/dkg2.go index ca1b52f..ae379fc 100644 --- a/dkg2/dkg2.go +++ b/dkg2/dkg2.go @@ -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. // diff --git a/keyera/keyera_test.go b/keyera/keyera_test.go index e1dcbef..487fafc 100644 --- a/keyera/keyera_test.go +++ b/keyera/keyera_test.go @@ -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 } diff --git a/main.go b/main.go index 998da4b..bfe214c 100644 --- a/main.go +++ b/main.go @@ -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") diff --git a/primitives/hash.go b/primitives/hash.go index f97d743..a489a0d 100644 --- a/primitives/hash.go +++ b/primitives/hash.go @@ -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) } diff --git a/primitives/hash_test.go b/primitives/hash_test.go index f3b6995..9b8a86a 100644 --- a/primitives/hash_test.go +++ b/primitives/hash_test.go @@ -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)") } }) diff --git a/reshare/commit.go b/reshare/commit.go index 5ee31a0..d9bd94e 100644 --- a/reshare/commit.go +++ b/reshare/commit.go @@ -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) { diff --git a/reshare/integration_test.go b/reshare/integration_test.go index 84d0788..44b763b 100644 --- a/reshare/integration_test.go +++ b/reshare/integration_test.go @@ -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 } diff --git a/scripts/regen-kats.manifest.sha256 b/scripts/regen-kats.manifest.sha256 index 12be2a0..cbe99b6 100644 --- a/scripts/regen-kats.manifest.sha256 +++ b/scripts/regen-kats.manifest.sha256 @@ -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 diff --git a/sign/local.go b/sign/local.go index e5992af..31ead86 100644 --- a/sign/local.go +++ b/sign/local.go @@ -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) } diff --git a/sign/nonce_reuse_test.go b/sign/nonce_reuse_test.go new file mode 100644 index 0000000..3c7a9bd --- /dev/null +++ b/sign/nonce_reuse_test.go @@ -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<>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 diff --git a/sign/sign_roundtrip_test.go b/sign/sign_roundtrip_test.go index 63422f1..3a7d009 100644 --- a/sign/sign_roundtrip_test.go +++ b/sign/sign_roundtrip_test.go @@ -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]) diff --git a/threshold/bench_gpu_test.go b/threshold/bench_gpu_test.go index e9404e5..8fd9b0f 100644 --- a/threshold/bench_gpu_test.go +++ b/threshold/bench_gpu_test.go @@ -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) diff --git a/threshold/e2e_threshold_variants_test.go b/threshold/e2e_threshold_variants_test.go index 8bc022e..b1d8020 100644 --- a/threshold/e2e_threshold_variants_test.go +++ b/threshold/e2e_threshold_variants_test.go @@ -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 } diff --git a/threshold/fuzz_round_test.go b/threshold/fuzz_round_test.go index a137267..1a315d8 100644 --- a/threshold/fuzz_round_test.go +++ b/threshold/fuzz_round_test.go @@ -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. diff --git a/threshold/fuzz_verify_test.go b/threshold/fuzz_verify_test.go index 279f6a1..b4335aa 100644 --- a/threshold/fuzz_verify_test.go +++ b/threshold/fuzz_verify_test.go @@ -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) diff --git a/threshold/threshold.go b/threshold/threshold.go index 50c6bfb..0963a8a 100644 --- a/threshold/threshold.go +++ b/threshold/threshold.go @@ -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. diff --git a/threshold/threshold_gpu_test.go b/threshold/threshold_gpu_test.go index 7f39ee4..91be0ab 100644 --- a/threshold/threshold_gpu_test.go +++ b/threshold/threshold_gpu_test.go @@ -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 } diff --git a/threshold/threshold_test.go b/threshold/threshold_test.go index bb6370c..5fe0b8a 100644 --- a/threshold/threshold_test.go +++ b/threshold/threshold_test.go @@ -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 } diff --git a/threshold/verify_batch_test.go b/threshold/verify_batch_test.go index d325b56..74c74fb 100644 --- a/threshold/verify_batch_test.go +++ b/threshold/verify_batch_test.go @@ -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) diff --git a/threshold/wire_test.go b/threshold/wire_test.go index 0141812..284de15 100644 --- a/threshold/wire_test.go +++ b/threshold/wire_test.go @@ -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" diff --git a/utils/utils.go b/utils/utils.go index 00767da..507b025 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -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