mirror of
https://github.com/luxfi/pulsar.git
synced 2026-07-27 06:55:04 +00:00
merge Pulsar trustless signing: dealerless RSS + general partition + overflow-fix + HYPERBALL no-reconstruct (gate 5)
Unites main's mlwe-dedup line with the trustless-signing line: general Algorithm-6 balanced partition (n=8,t=7 + n=16,t=14 sign), the per-add mod-q overflow fix (C>=512), and the Mithril 3-round hyperball no-reconstruct threshold signing — no party/coordinator forms the full ML-DSA key at sign time. Gate 5 closed + verified, stock-circl-verifiable.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# Mithril hyperball parameters for ML‑DSA‑65 (one‑sided BCC, no‑reconstruct)
|
||||
|
||||
This document derives the hyperball rejection‑sampling parameters for the
|
||||
**no‑reconstruct** threshold signing path on the dealerless RSS ML‑DSA‑65 key
|
||||
(`mithril_rss.go`). It is the parameter companion to `mithril_rss_hyperball.go`.
|
||||
|
||||
The reference scheme is **Mithril** (Celi–del Pino–Espitau–Niot–Prest,
|
||||
*Efficient Threshold ML‑DSA from Short Secret Sharing*, USENIX Security 2026,
|
||||
ePrint 2026/013), whose reference implementation
|
||||
(`github.com/lattice-safe/threshold-ml-dsa`) is parameterised for **ML‑DSA‑44**.
|
||||
We derive the corresponding **ML‑DSA‑65** parameters and adapt the geometry to
|
||||
pulsar's **one‑sided Boundary‑Cleared / Carry‑Elimination (BCC)** signer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the hyperball sits in the protocol
|
||||
|
||||
The no‑reconstruct signature is the standard FIPS‑204 ML‑DSA signature
|
||||
|
||||
```
|
||||
z = y + c·s1 , h = hint recovered from public w' = A·z − c·t1·2^d
|
||||
```
|
||||
|
||||
formed **additively** across the active signers: party `j` holds only its
|
||||
balanced‑partition share `s1_(j)` of `s1` (`Σ_j s1_(j) = s1`, all reconstruction
|
||||
coefficients 0/1 — the RSS property), samples its own mask `y_j`, and emits only
|
||||
|
||||
```
|
||||
z_j = y_j + c·s1_(j) (Round 3)
|
||||
```
|
||||
|
||||
so that `z = Σ_j z_j = y + c·s1` forms with **no party ever holding `y`, `s1`,
|
||||
the full key, or any low‑bits/`w0` quantity**. The hint comes from the public
|
||||
`w'` exactly as in `bcc_sign.go` (`BoundaryClear` + `FindHint`), so **`s2` is
|
||||
never touched during signing**.
|
||||
|
||||
Crucially, **verifiability does not depend on the hyperball parameters at all.**
|
||||
The signature verifies under unmodified `circl mldsa65.Verify` iff the *summed*
|
||||
`z` clears the central BCC checks:
|
||||
|
||||
* `BoundaryClear(w)` on the aggregated commitment `w = Σ_j A·y_j` (public),
|
||||
* `‖z‖∞ < γ1 − β` on the aggregated `z`,
|
||||
* `FindHint(w', w1)` succeeds with weight `≤ ω`.
|
||||
|
||||
The hyperball is **not** a correctness device. It is the device that makes each
|
||||
*revealed* partial `z_j` **leak‑free** with respect to the secret share `s1_(j)`,
|
||||
and the K‑repetition machinery is what keeps the joint acceptance probability
|
||||
across `T` parties from collapsing. This separation is what makes the
|
||||
no‑reconstruct path provably stock‑verifiable independent of the parameter
|
||||
derivation below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why a ball, not a box (the leakage problem)
|
||||
|
||||
If each party did the standard FIPS‑204 **L∞ (hypercube)** rejection on `z_j`
|
||||
independently, the per‑party accept probability would be
|
||||
`((γ1−β)/γ1)^(L·N)` and the joint probability over `T` parties would be
|
||||
|
||||
```
|
||||
p_cube = ((γ1−β)/γ1)^(T·L·N)
|
||||
```
|
||||
|
||||
which decays **exponentially in `T·L·N`** — infeasible already at `T = 2`,
|
||||
`L·N = 1280`. Replacing the `L·N` independent per‑coordinate constraints with a
|
||||
**single L2 (hyperball) constraint** turns the decay from `(·)^(L·N)` into the
|
||||
concentration of one chi‑like statistic, restoring feasibility. This is the core
|
||||
Mithril observation (ePrint 2026/013, §2.7).
|
||||
|
||||
The ball also makes the leak‑freeness argument a clean **bounded‑rejection**
|
||||
statement: a nonce drawn uniformly from a ball, accepted iff the shifted
|
||||
response stays in a slightly smaller ball, yields an accepted response whose
|
||||
distribution is (almost) independent of the shift `c·s1_(j)`.
|
||||
|
||||
### One‑sided adaptation
|
||||
|
||||
The Mithril reference is **two‑sided**: it commits `w = A·y + e`, responds with
|
||||
`(z1, z2) = (c·s1 + y, c·s2 + e)`, and rejects on an **ellipsoid** that weights
|
||||
the first `L·N` (s1‑side) coordinates by `1/ν²` and the last `K·N` (s2‑side)
|
||||
coordinates by `1` (`Excess: Σ_{i<L·N} z_i²/ν² + Σ_{i≥L·N} z_i² > r²`, ν = 3).
|
||||
The `ν` balances the differing magnitudes of the `s1` and `s2` responses.
|
||||
|
||||
Pulsar's BCC signer is **one‑sided**: `s2` is never used in signing (the hint is
|
||||
recovered from the public `w'`), so there is no `s2`‑side, no `e`, and the
|
||||
ellipsoid degenerates to a **plain L2 ball over the `L·N` mask coordinates**
|
||||
(`ν = 1`). The commitment is `w_j = A·y_j` (exactly FIPS‑204's `w`, no `e`);
|
||||
revealing it is sound because, given the to‑be‑revealed `z_j` and the public
|
||||
`A·s1_(j)`, `w_j = A·z_j − c·A·s1_(j)` carries no extra information, and for
|
||||
*rejected* slots only `w_j = A·y_j` is on the wire, which hides `y_j` under
|
||||
Module‑SIS (the same assumption ML‑DSA's unforgeability already rests on).
|
||||
|
||||
We keep `ν` as a configurable field for fidelity to the reference, defaulting to
|
||||
`ν = 1` (plain ball) in the one‑sided path.
|
||||
|
||||
---
|
||||
|
||||
## 3. The leak‑free radius gap `Δ = r1 − r`
|
||||
|
||||
Sample the nonce `y_j` uniformly on the ball `B(0, r1) ⊂ R^n`, `n = L·N`.
|
||||
Accept the response `z_j = y_j + sh`, `sh = c·s1_(j)`, iff `z_j ∈ B(0, r)` with
|
||||
`r < r1`. Conditioned on acceptance, `z_j` is uniform on the **lens**
|
||||
`B(0, r) ∩ B(sh, r1)`. Because both distributions are uniform‑on‑a‑set, the
|
||||
Rényi divergence of every order `α` between the accepted distribution `P_sh` and
|
||||
the ideal shift‑independent `U = Uniform(B(0,r))` is the **volume ratio**
|
||||
|
||||
```
|
||||
R_α(P_sh ‖ U) = Vol(B(0,r)) / Vol(B(0,r) ∩ B(sh,r1)) = 1 / (1 − p_cap)
|
||||
```
|
||||
|
||||
where `p_cap` is the fraction of `B(0,r)` farther than `r1` from `sh` (the
|
||||
missing spherical cap). Over `Q` signatures the divergence multiplies,
|
||||
`R_α(P^Q ‖ U^Q) = (1 − p_cap)^(−Q)`, and Rényi probability preservation gives no
|
||||
security loss as long as
|
||||
|
||||
```
|
||||
(1 − p_cap)^(−Q) ≤ 2 ⟺ p_cap ≤ ln2 / Q ≈ 2^(−64.5) for Q = 2^64.
|
||||
```
|
||||
|
||||
so we target **`p_cap ≤ 2^(−κ)` with `κ = 64`**.
|
||||
|
||||
### High‑dimensional cap concentration
|
||||
|
||||
Most mass of `B(0,r)` is near the boundary sphere. For a point on the sphere of
|
||||
radius `r`, with `sh` along an axis and `‖sh‖ = δ`, `‖z − sh‖ > r1` becomes
|
||||
`z_1 < t` for `t = (r² + δ² − r1²)/(2δ)`. For `r ≫ δ` (always true here) and
|
||||
`Δ = r1 − r`, `t ≈ −r·Δ/δ`, i.e. the cap is `{z_1/r < −Δ/δ}`. The sphere
|
||||
marginal of `z_1/r` in dimension `n` has density `∝ (1−u²)^((n−3)/2)`, so
|
||||
|
||||
```
|
||||
p_cap ≈ (1 − (Δ/δ)²)^(n/2).
|
||||
```
|
||||
|
||||
Setting `p_cap = 2^(−κ)` and using `(Δ/δ)² ≪ 1`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
Δ / δ = │ √( 2·κ·ln2 / n ) │ (leak‑free gap ratio)
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
independent of `r` (hence of the norm budget). This is the load‑bearing
|
||||
formula. We use the order‑`∞` (volume‑ratio) bound, which is the **conservative**
|
||||
choice — it over‑estimates `Δ` relative to a tighter finite‑`α` Rényi analysis,
|
||||
buying *more* leak protection at the cost of more rejection.
|
||||
|
||||
### Validation against the ML‑DSA‑44 reference table
|
||||
|
||||
For the **two‑sided** reference, `n = (K+L)·N = 2048`, and the single‑subset
|
||||
shift (one χ_η=2 secret, τ=39) has `δ ≈ 400` (mean) over the 2048 coords. The
|
||||
formula predicts `Δ/δ = √(2·64·ln2/2048) = 0.208`, i.e. `Δ ≈ 0.208·400 ≈ 56` —
|
||||
matching the reference table's diagonal (T=N, single‑subset shares):
|
||||
|
||||
| (T,N) | r | r1 | r1−r |
|
||||
|-------|---------|---------|------|
|
||||
| (2,2) | 252778 | 252833 | 55 |
|
||||
| (3,3) | 246490 | 246546 | 56 |
|
||||
| (4,4) | 243463 | 243519 | 56 |
|
||||
| (5,5) | 239924 | 239981 | 57 |
|
||||
| (6,6) | 219245 | 219301 | 56 |
|
||||
|
||||
The reference's effective `κ` is slightly smaller (≈ 29, a tighter finite‑`α`
|
||||
bound); our `κ = 64` `α=∞` choice yields `Δ` larger by ≈ 1.5×, i.e. **strictly
|
||||
more** leak protection. The off‑diagonal reference entries scale as
|
||||
`Δ ≈ 56·√(maxSubsetsPerParty)`, confirming the quadrature scaling below.
|
||||
|
||||
---
|
||||
|
||||
## 4. ML‑DSA‑65 parameters (one‑sided, `n = L·N = 1280`)
|
||||
|
||||
FIPS‑204 ML‑DSA‑65 constants: `N=256, K=6, L=5, η=4, τ=49, ω=55, q=8380417,
|
||||
d=13, γ1=2^19=524288, γ2=261888, β=τ·η=196, γ1−β=524092`.
|
||||
|
||||
**Single‑subset shift L2 norm.** `s1^(S)` has `L·N = 1280` coefficients drawn
|
||||
uniformly from `[−η,η] = [−4,4]` (FIPS‑204 RejBoundedPoly), variance
|
||||
`((2η+1)²−1)/12 = 80/12 = 6.667`. The shift coefficient
|
||||
`(c·s1^(S))_i` is a sum of `τ = 49` signed copies, variance `τ·6.667 = 326.7`,
|
||||
so `E‖sh‖² = 1280·326.7 = 418133`, `E‖sh‖ = 646.6`, and a 6σ upper bound is
|
||||
|
||||
```
|
||||
δ_single ≈ √(418133 + 6·√(2·1280)·326.7) ≈ √517307 ≈ 720.
|
||||
```
|
||||
|
||||
**Leak‑free gap ratio** (`n = 1280`, `κ = 64`):
|
||||
|
||||
```
|
||||
Δ/δ = √(2·64·ln2 / 1280) = 0.2633 .
|
||||
```
|
||||
|
||||
**Base gap** (single subset, `T = N`): `Δ_base = ⌈0.2633·720⌉ = 190`.
|
||||
|
||||
**Per‑committee gap.** A party's share `s1_(j)` is the sum of
|
||||
`m = maxSubsetsPerParty(T,N)` independent χ_η secrets, so `‖sh_(j)‖ ≈ √m·δ_single`
|
||||
(quadrature). Hence
|
||||
|
||||
```
|
||||
Δ(T,N) = Δ_base · √( maxSubsetsPerParty(T,N) ) (= 190·√m)
|
||||
```
|
||||
|
||||
where `maxSubsetsPerParty` is the largest block of the balanced partition
|
||||
`rss.RSSRecover` (`= 1` for `T = N`).
|
||||
|
||||
**Nonce radius.** `r1` is set from the L∞ norm budget so the *summed* `z` stays
|
||||
under `γ1 − β`. With `y_j` per‑coordinate ≈ `N(0, r1²/n)` and `T` independent
|
||||
parties, the summed per‑coordinate std is `r1·√(T/n)`; a safety tail of `6.4`
|
||||
keeps the worst of `L·N` coordinates ≈ `1.7×` under budget:
|
||||
|
||||
```
|
||||
r1(T,N) = (γ1−β)·√(L·N) / (6.4·√T) = 2,929,687 / √T , r = r1 − Δ(T,N).
|
||||
```
|
||||
|
||||
This holds the summed‑`z` per‑coordinate std at a constant ≈ 81 800 (independent
|
||||
of `T`), worst‑coordinate ≈ 309 000 `< γ1−β = 524 092`.
|
||||
|
||||
### Worked values (`Δ_base = 190`, `ν = 1`)
|
||||
|
||||
| (T,N) | m | Δ=190√m | r1=2929687/√T | r=r1−Δ | p_party=(r/r1)^1280 |
|
||||
|-------|---|---------|---------------|--------|---------------------|
|
||||
| (2,2) | 1 | 190 | 2 071 600 | 2 071 410 | 0.886 |
|
||||
| (3,3) | 1 | 190 | 1 691 460 | 1 691 270 | 0.861 |
|
||||
| (4,4) | 1 | 190 | 1 464 840 | 1 464 650 | 0.848 |
|
||||
| (5,5) | 1 | 190 | 1 310 020 | 1 309 830 | 0.832 |
|
||||
| (6,6) | 1 | 190 | 1 196 000 | 1 195 810 | 0.817 |
|
||||
| (8,8) | 1 | 190 | 1 035 800 | 1 035 610 | 0.791 |
|
||||
| (4,6) | 5 | 425 | 1 196 000 | 1 195 575 | 0.634 |
|
||||
| (3,5) | 4 | 380 | 1 310 020 | 1 309 640 | 0.690 |
|
||||
|
||||
`p_party` is the per‑party ball‑acceptance; the joint per‑slot success is
|
||||
`p_party^T · p_boundary` with the central `BoundaryClear` yield
|
||||
`p_boundary ≈ 0.09` for ML‑DSA‑65. K‑repetition (and protocol re‑runs) amortise
|
||||
this; e.g. `(8,8)`: per‑slot ≈ `0.791^8·0.09 ≈ 0.013`, so ≈ 350 slot‑attempts
|
||||
clear 99 %, spread over `K` parallel slots and `maxRounds` re‑runs.
|
||||
|
||||
### Recommended K (parallel slots per round)
|
||||
|
||||
```
|
||||
K(T,N) = clamp( ⌈ 3 / (p_party^T · p_boundary) ⌉ , 8 , 256 )
|
||||
```
|
||||
|
||||
a slot budget giving `≈ 95 %` success per round; the driver re‑runs the 3 rounds
|
||||
with fresh nonces up to `maxRounds` if a round produces no usable slot. Sample:
|
||||
`(2,2)→8`, `(3,3)→8`, `(6,6)→18`, `(8,8)→34`, `(4,6)→200`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security summary
|
||||
|
||||
* **Verifiability** (stock `circl mldsa65.Verify`): independent of all
|
||||
hyperball parameters; guaranteed by the central BCC checks on the *summed*
|
||||
`z`, `w`. Proven by round‑trip test across `n=8,t=8` and all `N≤6` committees.
|
||||
* **Leak‑freeness** (per‑party `z_j`): bounded by Rényi divergence
|
||||
`R_α ≤ 1/(1 − 2^(−64))` per signature, `≤ 2` over `Q = 2^64` signatures, by
|
||||
the `Δ/δ = √(2κln2/n)` gap. The α=∞ choice is conservative.
|
||||
* **Structural no‑leak**: no party forms `s1, s2, y, w, w0, sk`; the coordinator
|
||||
forms only the *public* aggregates `w = Σ A·y_j`, `w1 = HighBits(w)`,
|
||||
`w' = A·z − c·t1·2^d`, and the hint — never `w0 = LowBits(w)`, never any share.
|
||||
* **Assumption**: Module‑LWE / Module‑SIS over `R_q = Z_q[X]/(X^256+1)` (the same
|
||||
assumption ML‑DSA‑65 / FIPS‑204 already rests on) — the one‑sided commitment
|
||||
`w_j = A·y_j` hides `y_j` under Module‑SIS. EUF‑CMA of the produced signature
|
||||
is exactly FIPS‑204's.
|
||||
|
||||
## 6. References
|
||||
|
||||
* del Pino, Celi, Espitau, Niot, Prest. *Mithril: Efficient Threshold ML‑DSA
|
||||
from Short Secret Sharing.* USENIX Security 2026, ePrint 2026/013.
|
||||
* Reference implementation: `github.com/lattice-safe/threshold-ml-dsa`
|
||||
(`src/fvec.rs` SampleHyperball + Excess, `src/params.rs` Figure 8/9 table,
|
||||
`src/sign.rs` 3‑round protocol).
|
||||
* FIPS 204, *Module‑Lattice‑Based Digital Signature Standard* (ML‑DSA).
|
||||
* Bai, Lepoint, Roux‑Langlois, Sakzad, Stehlé, Steinfeld. *Improved Security
|
||||
Proofs in Lattice‑Based Cryptography: Using the Rényi Divergence.* J.
|
||||
Cryptology 2018 (the Rényi‑divergence probability‑preservation lemma).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
"github.com/luxfi/dkg/rss"
|
||||
)
|
||||
|
||||
// mithril_rss_hyperball_test.go — GATE 5 (no reconstruction) and GATE 6
|
||||
// (fail-closed) for the Mithril 3-round hyperball no-reconstruct signer.
|
||||
//
|
||||
// GATE 5: a hyperball signature verifies byte-for-byte under unmodified
|
||||
// cloudflare/circl mldsa65.Verify at n=8,t=8 and across the N≤6 committees, AND
|
||||
// the signing path forms NO full s1/s2/y/w0/sk in any party (structural + source
|
||||
// + transcript scans), and never calls ReconstructKeyMaterial.
|
||||
//
|
||||
// GATE 6: sub-threshold signing fails closed; a malformed/biased partial fails
|
||||
// closed and is blamed; nonce reuse is structurally prevented (and demonstrably
|
||||
// catastrophic if violated); the FindHint + self-verify release gate rejects any
|
||||
// biased z_i.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATE 5 (a): stock circl round-trip, no reconstruction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHyperballStockCirclVerify is the headline no-reconstruct proof: the
|
||||
// dealerless RSS ML-DSA-65 key signs via the 3-round hyperball protocol (no key
|
||||
// reconstruction) and the signature verifies under unmodified circl
|
||||
// mldsa65.Verify, for n=8,t=8 and every N≤6 committee. Tamper / wrong-message /
|
||||
// wrong-context are rejected (verifier non-vacuous).
|
||||
func TestHyperballStockCirclVerify(t *testing.T) {
|
||||
committees := [][2]int{
|
||||
{2, 2}, {2, 3}, {3, 3}, {2, 4}, {3, 4}, {4, 4},
|
||||
{2, 5}, {3, 5}, {4, 5}, {5, 5},
|
||||
{2, 6}, {3, 6}, {4, 6}, {5, 6}, {6, 6},
|
||||
{8, 8}, // T=N singleton beyond the partition table
|
||||
}
|
||||
msg := []byte("Pulsar Mithril hyperball — no-reconstruct ML-DSA-65 under stock circl")
|
||||
ctx := []byte("quasar-pulsar-leg")
|
||||
for _, tn := range committees {
|
||||
tt, n := tn[0], tn[1]
|
||||
t.Run(fmt.Sprintf("T%d_N%d", tt, n), func(t *testing.T) {
|
||||
mk, err := MithrilRSSKeygen(ModeP65, tt, n, mithrilSeeds(n))
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
active := canonicalActive(tt)
|
||||
rng := newBCCDeterministicRNG(fmt.Sprintf("PULSAR/HYPERBALL/%d-%d", tt, n))
|
||||
sig, tr, err := mk.SignHyperball(active, msg, ctx, rng, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("(T=%d,N=%d) SignHyperball: %v", tt, n, err)
|
||||
}
|
||||
|
||||
var pkC mldsa65.PublicKey
|
||||
if err := pkC.UnmarshalBinary(mk.pub); err != nil {
|
||||
t.Fatalf("circl unmarshal pk: %v", err)
|
||||
}
|
||||
if !mldsa65.Verify(&pkC, msg, ctx, sig.Bytes) {
|
||||
t.Fatalf("(T=%d,N=%d): STOCK circl mldsa65.Verify REJECTED the no-reconstruct hyperball signature", tt, n)
|
||||
}
|
||||
// Non-vacuous: tamper, wrong message, wrong context all rejected.
|
||||
tampered := append([]byte(nil), sig.Bytes...)
|
||||
tampered[len(tampered)/2] ^= 0x01
|
||||
if mldsa65.Verify(&pkC, msg, ctx, tampered) {
|
||||
t.Fatal("circl accepted a tampered signature — verifier vacuous")
|
||||
}
|
||||
if mldsa65.Verify(&pkC, []byte("a different message"), ctx, sig.Bytes) {
|
||||
t.Fatal("circl accepted signature under wrong message — binding broken")
|
||||
}
|
||||
if mldsa65.Verify(&pkC, msg, []byte("wrong-ctx"), sig.Bytes) {
|
||||
t.Fatal("circl accepted signature under wrong context — ctx binding broken")
|
||||
}
|
||||
hp, _ := deriveHyperballParams(ModeP65, tt, n)
|
||||
t.Logf("(T=%d,N=%d) m=%d K=%d rounds=%d slot=%d r1=%.0f Δ=%.0f: stock-circl verified",
|
||||
tt, n, maxSubsetsPerParty(tt, n), hp.kReps, tr.Rounds, tr.WinningSlot, hp.r1, hp.r1-hp.r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATE 5 (b): structural / source / transcript no-reconstruct scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHyperballNoReconstructStructural proves the no-reconstruct property three
|
||||
// ways: (1) the hyperball source never calls ReconstructKeyMaterial; (2) the
|
||||
// round-message types carry no secret material — the public transcript contains
|
||||
// none of any party's share s1_(j), nor the full reconstructed s1/s2; (3) the
|
||||
// per-party object holds only its own share (any T−1 parties' shares miss at
|
||||
// least one subset, so no party set below T even *could* reconstruct).
|
||||
func TestHyperballNoReconstructStructural(t *testing.T) {
|
||||
// (1) AST scan: the hyperball signing path must contain NO call expression
|
||||
// invoking ReconstructKeyMaterial. (Parsing the AST ignores the doc comment
|
||||
// that names it for contrast — only real CallExprs count.)
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "mithril_rss_hyperball.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse source: %v", err)
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "ReconstructKeyMaterial" {
|
||||
t.Fatal("mithril_rss_hyperball.go CALLS ReconstructKeyMaterial — NOT no-reconstruct")
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "ReconstructKeyMaterial" {
|
||||
t.Fatal("mithril_rss_hyperball.go CALLS ReconstructKeyMaterial — NOT no-reconstruct")
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Defensive byte scan: the source must not pack a party's secret share or
|
||||
// mask onto any wire buffer. The only packPolyVec arguments in the round path
|
||||
// are w, z, w1, tjS1 (all public); s1Share / yInt must never be serialized.
|
||||
src, err := os.ReadFile("mithril_rss_hyperball.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read source: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"packPolyVec(p.s1Share", "packPolyVec(s1Share", "packPolyVec(p.yInt", "packPolyVec(yInt"} {
|
||||
if bytes.Contains(src, []byte(forbidden)) {
|
||||
t.Fatalf("source serializes secret material: %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Runtime transcript oracle: sign, then assert the PUBLIC transcript
|
||||
// bytes contain neither any party's share nor the full reconstructed secret.
|
||||
tt, n := 8, 8
|
||||
mk, err := MithrilRSSKeygen(ModeP65, tt, n, mithrilSeeds(n))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active := canonicalActive(tt)
|
||||
msg := []byte("no-reconstruct transcript oracle")
|
||||
rng := newBCCDeterministicRNG("PULSAR/HYPERBALL/no-leak")
|
||||
sig, tr, err := mk.SignHyperball(active, msg, nil, rng, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := tr.publicBytes()
|
||||
|
||||
// Each active party's share s1_(j) (packed) must NOT appear in the transcript.
|
||||
part, _ := rss.RSSRecover(active, tt, n)
|
||||
for j, id := range active {
|
||||
share, err := mk.partyShareS1(id, part[j])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Normalize for a faithful byte comparison against what a leak would emit.
|
||||
ns := make(polyVec, len(share))
|
||||
for i := range share {
|
||||
ns[i] = share[i]
|
||||
ns[i].reduceLe2Q()
|
||||
ns[i].normalize()
|
||||
}
|
||||
if bytes.Contains(pub, packPolyVec(ns)) {
|
||||
t.Fatalf("party %d share s1_(j) bytes appear in the public transcript — LEAK", id)
|
||||
}
|
||||
}
|
||||
// The full reconstructed s1 and s2 must NOT appear either.
|
||||
km, _ := mk.ReconstructKeyMaterial(active)
|
||||
for _, secret := range []polyVec{km.s1, km.s2} {
|
||||
ns := make(polyVec, len(secret))
|
||||
for i := range secret {
|
||||
ns[i] = secret[i]
|
||||
ns[i].normalize()
|
||||
}
|
||||
if bytes.Contains(pub, packPolyVec(ns)) {
|
||||
t.Fatal("full reconstructed s1/s2 bytes appear in the public transcript — LEAK")
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the signature is real (stock circl accepts it) — the scan above is
|
||||
// not vacuously passing on a degenerate transcript.
|
||||
var pkC mldsa65.PublicKey
|
||||
_ = pkC.UnmarshalBinary(mk.pub)
|
||||
if !mldsa65.Verify(&pkC, msg, nil, sig.Bytes) {
|
||||
t.Fatal("transcript-oracle signature does not verify — scan would be vacuous")
|
||||
}
|
||||
|
||||
// (3) Below-threshold parties cannot even cover all subsets (proven for the
|
||||
// full viability range in TestMithrilRSSDealerless; re-assert for this set).
|
||||
full := len(rss.EnumerateSubsets(tt, n))
|
||||
for _, coalition := range combos(n, tt-1) {
|
||||
covered := map[uint64]bool{}
|
||||
for _, id := range coalition {
|
||||
for m := range mk.holdings[id] {
|
||||
covered[m] = true
|
||||
}
|
||||
}
|
||||
if len(covered) == full {
|
||||
t.Fatalf("T-1 coalition %v covers all subsets", coalition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATE 6: fail-closed behaviours (sub-threshold, malformed/biased partial +
|
||||
// blame, equivocation, nonce reuse).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// hbSession is a manual in-process harness exposing the individual round
|
||||
// functions so the fault tests can inject misbehaviour a malicious party could.
|
||||
type hbSession struct {
|
||||
mk *MithrilKey
|
||||
hp *hyperballParams
|
||||
parties []*hyperballParty
|
||||
co *hyperballCoordinator
|
||||
sid [32]byte
|
||||
mu [64]byte
|
||||
entropy []byte
|
||||
}
|
||||
|
||||
func setupHBSession(t *testing.T, tt, n int, msg, ctx []byte, label string) *hbSession {
|
||||
t.Helper()
|
||||
mk, err := MithrilRSSKeygen(ModeP65, tt, n, mithrilSeeds(n))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hp, err := deriveHyperballParams(ModeP65, tt, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active := canonicalActive(tt)
|
||||
var mu [64]byte
|
||||
deriveMuCtx(mk.tr, ctx, msg, mu[:])
|
||||
part, err := rss.RSSRecover(active, tt, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parties := make([]*hyperballParty, len(active))
|
||||
for j, id := range active {
|
||||
s1Share, err := mk.partyShareS1(id, part[j])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parties[j] = newHyperballParty(ModeP65, hp, mk.a, id, s1Share)
|
||||
}
|
||||
sid := hyperballSessionID(mk.pub, active, mu[:])
|
||||
co := newHyperballCoordinator(mk, hp, active, mu[:])
|
||||
co.bindMessage(msg, ctx)
|
||||
ent := make([]byte, 32)
|
||||
h := newBCCDeterministicRNG("PULSAR/HYPERBALL/manual/" + label)
|
||||
_, _ = h.Read(ent)
|
||||
return &hbSession{mk: mk, hp: hp, parties: parties, co: co, sid: sid, mu: mu, entropy: ent}
|
||||
}
|
||||
|
||||
func (s *hbSession) round1() []HyperballRound1 {
|
||||
r1s := make([]HyperballRound1, len(s.parties))
|
||||
for j, p := range s.parties {
|
||||
r1s[j] = p.round1(s.entropy, s.sid, s.mu[:])
|
||||
}
|
||||
return r1s
|
||||
}
|
||||
func (s *hbSession) round2() []HyperballRound2 {
|
||||
r2s := make([]HyperballRound2, len(s.parties))
|
||||
for j, p := range s.parties {
|
||||
r2s[j] = p.round2()
|
||||
}
|
||||
return r2s
|
||||
}
|
||||
func (s *hbSession) round3() []HyperballRound3 {
|
||||
ch := s.co.challengesForRound3()
|
||||
r3s := make([]HyperballRound3, len(s.parties))
|
||||
for j, p := range s.parties {
|
||||
r3s[j] = p.round3(ch)
|
||||
}
|
||||
return r3s
|
||||
}
|
||||
func (s *hbSession) liveSlots() []int {
|
||||
var out []int
|
||||
for i, c := range s.co.c {
|
||||
if c != nil {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func r3byID(r3s []HyperballRound3) map[int]HyperballRound3 {
|
||||
m := map[int]HyperballRound3{}
|
||||
for _, r := range r3s {
|
||||
m[r.PartyID] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestHyperballSubThresholdFailsClosed proves a below-threshold or malformed
|
||||
// active set is rejected before any signing — never a partial-quorum signature.
|
||||
func TestHyperballSubThresholdFailsClosed(t *testing.T) {
|
||||
mk, err := MithrilRSSKeygen(ModeP65, 4, 6, mithrilSeeds(6))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := []byte("sub-threshold")
|
||||
cases := map[string][]int{
|
||||
"too_few": {0, 1, 2}, // T−1 signers
|
||||
"too_many": {0, 1, 2, 3, 4}, // T+1 signers
|
||||
"unsorted": {0, 2, 1, 3}, // not sorted
|
||||
"duplicate": {0, 1, 1, 3}, // duplicate id
|
||||
"out_of_range": {0, 1, 2, 9}, // id ≥ N
|
||||
}
|
||||
for name, active := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rng := newBCCDeterministicRNG("sub/" + name)
|
||||
sig, _, err := mk.SignHyperball(active, msg, nil, rng, 8)
|
||||
if err == nil {
|
||||
t.Fatalf("active=%v produced a signature — sub-threshold not fail-closed", active)
|
||||
}
|
||||
if sig != nil {
|
||||
t.Fatal("non-nil signature returned with an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperballBiasedPartialCaughtAndBlamed proves the FindHint + self-verify
|
||||
// release gate rejects a biased partial (no bad signature is ever emitted) and
|
||||
// blameSlot pinpoints the culprit without leaking s2 (it stops at the culprit,
|
||||
// never forming the full active sum Σ_j T_j = t − s2).
|
||||
func TestHyperballBiasedPartialCaughtAndBlamed(t *testing.T) {
|
||||
msg := []byte("biased partial")
|
||||
ctx := []byte("gate6")
|
||||
s := setupHBSession(t, 3, 5, msg, ctx, "biased")
|
||||
params, _ := ParamsFor(ModeP65)
|
||||
|
||||
r1s := s.round1()
|
||||
r2s := s.round2()
|
||||
if err := s.co.aggregateCommitments(s.sid, r1s, r2s); err != nil {
|
||||
t.Fatalf("aggregate: %v", err)
|
||||
}
|
||||
r3s := s.round3()
|
||||
|
||||
// Baseline: the honest round produces a valid signature (so the fault below
|
||||
// is what breaks it, not an already-dead round).
|
||||
if _, _, err := s.co.finalize(params, r3s); err != nil {
|
||||
t.Fatalf("honest round did not finalize: %v", err)
|
||||
}
|
||||
|
||||
// A blame slot must be one EVERY party accepted (so an honest party is not
|
||||
// mistaken for a liveness fault). The honest finalize above guarantees one.
|
||||
blameAt := -1
|
||||
for sl := 0; sl < s.hp.kReps; sl++ {
|
||||
if s.co.c[sl] == nil {
|
||||
continue
|
||||
}
|
||||
allAcc := true
|
||||
for _, r := range r3s {
|
||||
if !r.Accepted[sl] || r.Z[sl] == nil {
|
||||
allAcc = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allAcc {
|
||||
blameAt = sl
|
||||
break
|
||||
}
|
||||
}
|
||||
if blameAt < 0 {
|
||||
t.Skip("no fully-accepted slot in this deterministic round (raise K)")
|
||||
}
|
||||
|
||||
// Malicious party at active index 1 biases its partial on EVERY accepted
|
||||
// slot (keeps Accepted=true to look live). The committed T_1 is honest.
|
||||
bad := 1
|
||||
badID := s.co.active[bad]
|
||||
for sl := range r3s[bad].Z {
|
||||
if r3s[bad].Accepted[sl] && r3s[bad].Z[sl] != nil {
|
||||
r3s[bad].Z[sl][0][0] = (r3s[bad].Z[sl][0][0] + 12345) % mldsaQ
|
||||
}
|
||||
}
|
||||
|
||||
// Release gate: no slot yields a verifiable signature → fail-closed.
|
||||
if sig, _, err := s.co.finalize(params, r3s); err == nil {
|
||||
t.Fatalf("finalize emitted a signature despite a biased partial: %v", sig != nil)
|
||||
}
|
||||
|
||||
// Blame: reveal T_j up to the culprit only (party 0, then party 1 = culprit).
|
||||
revealed := map[int]polyVec{
|
||||
s.co.active[0]: s.parties[0].tjS1,
|
||||
s.co.active[1]: s.parties[1].tjS1,
|
||||
}
|
||||
culprit, ok := s.co.blameSlot(blameAt, r3byID(r3s), revealed)
|
||||
if !ok || culprit != badID {
|
||||
t.Fatalf("blame returned (%d, %v); want culprit %d", culprit, ok, badID)
|
||||
}
|
||||
// Leak-free discipline holds: party 2's T was never revealed, so the full
|
||||
// active sum Σ_j T_j (= t − s2) was never formed.
|
||||
if _, leaked := revealed[s.co.active[2]]; leaked {
|
||||
t.Fatal("blame revealed the final party's T — would leak s2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperballEquivocationCaught proves a rushing party that changes its
|
||||
// commitment w between Round 1 and Round 2 is rejected by the binding check.
|
||||
func TestHyperballEquivocationCaught(t *testing.T) {
|
||||
s := setupHBSession(t, 3, 3, []byte("equiv"), nil, "equiv")
|
||||
r1s := s.round1()
|
||||
r2s := s.round2()
|
||||
// Party 1 reveals a DIFFERENT w for slot 0 than it committed in Round 1.
|
||||
r2s[1].W[0][0][0] = (r2s[1].W[0][0][0] + 777) % mldsaQ
|
||||
if err := s.co.aggregateCommitments(s.sid, r1s, r2s); err == nil {
|
||||
t.Fatal("aggregateCommitments accepted an equivocated w — binding broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperballNonceReuseFatal demonstrates WHY the protocol must never reuse a
|
||||
// mask across two challenges (it leaks the share), and asserts the protocol's
|
||||
// per-round nonce derivation prevents it (fresh entropy ⇒ fresh masks; the
|
||||
// Round-1 commitment binds the mask before the challenge is known).
|
||||
func TestHyperballNonceReuseFatal(t *testing.T) {
|
||||
mk, err := MithrilRSSKeygen(ModeP65, 3, 5, mithrilSeeds(5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, L, _ := modeShape(ModeP65)
|
||||
part, _ := rss.RSSRecover(canonicalActive(3), 3, 5)
|
||||
s1Share, _ := mk.partyShareS1(0, part[0])
|
||||
s1Hat := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
s1Hat[l] = s1Share[l]
|
||||
s1Hat[l].reduceLe2Q()
|
||||
s1Hat[l].ntt()
|
||||
}
|
||||
|
||||
// Two distinct challenges, ONE reused mask y.
|
||||
var c1, c2 poly
|
||||
polyDeriveUniformBall(&c1, []byte("reuse-challenge-1"), 49)
|
||||
polyDeriveUniformBall(&c2, []byte("reuse-challenge-2"), 49)
|
||||
seed := hyperballNonceSeed([]byte("fixed-round-entropy-32-bytes!!!!"), [32]byte{}, 0, 0)
|
||||
yf := sampleHyperballInBall(seed, L*mldsaN, 1.0e6)
|
||||
y := floatToMaskPolyVec(yf, L)
|
||||
|
||||
z1 := addCMulShare(y, &c1, s1Hat, L)
|
||||
z2 := addCMulShare(y, &c2, s1Hat, L)
|
||||
|
||||
// The mask cancels: z1 − z2 = (c1 − c2)·s1_(j). An attacker can CONFIRM a
|
||||
// guess g of the share by checking c1·g − c2·g == z1 − z2.
|
||||
diffZ := subPolyVec(z1, z2, L)
|
||||
if !samePolyVec(diffZ, shareDiffWitness(&c1, &c2, s1Hat, L), L) {
|
||||
t.Fatal("reuse identity z1−z2 = (c1−c2)·s1 does not hold — test setup wrong")
|
||||
}
|
||||
// A WRONG share guess fails the confirmation — so the check is information-
|
||||
// bearing and the true share is recoverable from reused-nonce signatures.
|
||||
wrong := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
wrong[l] = s1Share[l]
|
||||
}
|
||||
wrong[0][0] = (wrong[0][0] + 1) % mldsaQ
|
||||
wrongHat := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
wrongHat[l] = wrong[l]
|
||||
wrongHat[l].reduceLe2Q()
|
||||
wrongHat[l].ntt()
|
||||
}
|
||||
if samePolyVec(diffZ, shareDiffWitness(&c1, &c2, wrongHat, L), L) {
|
||||
t.Fatal("a wrong share also satisfied the reuse identity — leak demo vacuous")
|
||||
}
|
||||
|
||||
// Protection: the per-round nonce seed is fresh across rounds (distinct
|
||||
// entropy ⇒ distinct mask) and deterministic within a round (so the
|
||||
// Round-1 commitment binds the mask before the challenge is revealed).
|
||||
e1 := []byte("round-entropy-AAAAAAAAAAAAAAAAAA")
|
||||
e2 := []byte("round-entropy-BBBBBBBBBBBBBBBBBB")
|
||||
var sid [32]byte
|
||||
if *hyperballNonceSeed(e1, sid, 0, 0) == *hyperballNonceSeed(e2, sid, 0, 0) {
|
||||
t.Fatal("distinct round entropy produced the same nonce seed — reuse risk")
|
||||
}
|
||||
if *hyperballNonceSeed(e1, sid, 0, 0) != *hyperballNonceSeed(e1, sid, 0, 0) {
|
||||
t.Fatal("nonce seed is non-deterministic within a round — commitment would not bind")
|
||||
}
|
||||
}
|
||||
|
||||
// addCMulShare returns y + c·s1 (the partial-response computation), matching the
|
||||
// party's round3 arithmetic.
|
||||
func addCMulShare(y polyVec, c *poly, s1Hat polyVec, L int) polyVec {
|
||||
cHat := *c
|
||||
cHat.ntt()
|
||||
out := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
var cs1 poly
|
||||
cs1.mulHat(&cHat, &s1Hat[l])
|
||||
cs1.reduceLe2Q()
|
||||
cs1.invNTT()
|
||||
cs1.normalize()
|
||||
out[l].add(&y[l], &cs1)
|
||||
out[l].normalize()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shareDiffWitness returns c1·s1 − c2·s1 (what z1 − z2 must equal under reuse).
|
||||
func shareDiffWitness(c1, c2 *poly, s1Hat polyVec, L int) polyVec {
|
||||
zero := make(polyVec, L)
|
||||
a := addCMulShare(zero, c1, s1Hat, L)
|
||||
b := addCMulShare(zero, c2, s1Hat, L)
|
||||
return subPolyVec(a, b, L)
|
||||
}
|
||||
|
||||
func subPolyVec(a, b polyVec, L int) polyVec {
|
||||
out := make(polyVec, L)
|
||||
for l := 0; l < L; l++ {
|
||||
out[l].sub(&a[l], &b[l])
|
||||
out[l].normalize()
|
||||
}
|
||||
return out
|
||||
}
|
||||
func samePolyVec(a, b polyVec, L int) bool {
|
||||
for l := 0; l < L; l++ {
|
||||
if a[l] != b[l] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestHyperballMaskNeverOnWire proves the secret mask y_j is never serialised:
|
||||
// the wire carries only w_{j,k} = A·y_{j,k} (Module-SIS hides y) and the public
|
||||
// response z_{j,k}. Using the manual harness (where the exact yInt is readable)
|
||||
// it asserts no party's mask bytes appear in the Round-2 / Round-3 wire data.
|
||||
func TestHyperballMaskNeverOnWire(t *testing.T) {
|
||||
s := setupHBSession(t, 4, 6, []byte("mask never on wire"), nil, "mask")
|
||||
r1s := s.round1()
|
||||
r2s := s.round2()
|
||||
if err := s.co.aggregateCommitments(s.sid, r1s, r2s); err != nil {
|
||||
t.Fatalf("aggregate: %v", err)
|
||||
}
|
||||
r3s := s.round3()
|
||||
|
||||
wire := make([]byte, 0, 1<<16)
|
||||
for _, m := range r2s {
|
||||
for _, w := range m.W {
|
||||
wire = append(wire, packPolyVec(w)...)
|
||||
}
|
||||
}
|
||||
for _, m := range r3s {
|
||||
for _, z := range m.Z {
|
||||
if z != nil {
|
||||
wire = append(wire, packPolyVec(z)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for j, p := range s.parties {
|
||||
for k := range p.yInt {
|
||||
ny := make(polyVec, len(p.yInt[k]))
|
||||
for i := range p.yInt[k] {
|
||||
ny[i] = p.yInt[k][i]
|
||||
ny[i].normalize()
|
||||
}
|
||||
if bytes.Contains(wire, packPolyVec(ny)) {
|
||||
t.Fatalf("party %d slot %d mask y bytes appear on the wire — LEAK", j, k)
|
||||
}
|
||||
}
|
||||
// And the share, for good measure.
|
||||
ns := make(polyVec, len(p.s1Share))
|
||||
for i := range p.s1Share {
|
||||
ns[i] = p.s1Share[i]
|
||||
ns[i].reduceLe2Q()
|
||||
ns[i].normalize()
|
||||
}
|
||||
if bytes.Contains(wire, packPolyVec(ns)) {
|
||||
t.Fatalf("party %d share s1_(j) bytes appear on the wire — LEAK", j)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user