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