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:
zeekay
2026-06-28 05:47:34 -07:00
3 changed files with 1914 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
# Mithril hyperball parameters for MLDSA65 (onesided BCC, noreconstruct)
This document derives the hyperball rejectionsampling parameters for the
**noreconstruct** threshold signing path on the dealerless RSS MLDSA65 key
(`mithril_rss.go`). It is the parameter companion to `mithril_rss_hyperball.go`.
The reference scheme is **Mithril** (Celidel PinoEspitauNiotPrest,
*Efficient Threshold MLDSA from Short Secret Sharing*, USENIX Security 2026,
ePrint 2026/013), whose reference implementation
(`github.com/lattice-safe/threshold-ml-dsa`) is parameterised for **MLDSA44**.
We derive the corresponding **MLDSA65** parameters and adapt the geometry to
pulsar's **onesided BoundaryCleared / CarryElimination (BCC)** signer.
---
## 1. Where the hyperball sits in the protocol
The noreconstruct signature is the standard FIPS204 MLDSA 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
balancedpartition 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 lowbits/`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` **leakfree** with respect to the secret share `s1_(j)`,
and the Krepetition machinery is what keeps the joint acceptance probability
across `T` parties from collapsing. This separation is what makes the
noreconstruct path provably stockverifiable independent of the parameter
derivation below.
---
## 2. Why a ball, not a box (the leakage problem)
If each party did the standard FIPS204 **L∞ (hypercube)** rejection on `z_j`
independently, the perparty 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 percoordinate constraints with a
**single L2 (hyperball) constraint** turns the decay from `(·)^(L·N)` into the
concentration of one chilike statistic, restoring feasibility. This is the core
Mithril observation (ePrint 2026/013, §2.7).
The ball also makes the leakfreeness argument a clean **boundedrejection**
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)`.
### Onesided adaptation
The Mithril reference is **twosided**: 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` (s1side) coordinates by `1/ν²` and the last `K·N` (s2side)
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 **onesided**: `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 FIPS204's `w`, no `e`);
revealing it is sound because, given the toberevealed `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
ModuleSIS (the same assumption MLDSA's unforgeability already rests on).
We keep `ν` as a configurable field for fidelity to the reference, defaulting to
`ν = 1` (plain ball) in the onesided path.
---
## 3. The leakfree 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 uniformonaset, the
Rényi divergence of every order `α` between the accepted distribution `P_sh` and
the ideal shiftindependent `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`**.
### Highdimensional 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 `∝ (1u²)^((n3)/2)`, so
```
p_cap ≈ (1 (Δ/δ)²)^(n/2).
```
Setting `p_cap = 2^(−κ)` and using `(Δ/δ)² ≪ 1`:
```
┌─────────────────────────────┐
Δ / δ = │ √( 2·κ·ln2 / n ) │ (leakfree gap ratio)
└─────────────────────────────┘
```
independent of `r` (hence of the norm budget). This is the loadbearing
formula. We use the order`∞` (volumeratio) bound, which is the **conservative**
choice — it overestimates `Δ` relative to a tighter finite`α` Rényi analysis,
buying *more* leak protection at the cost of more rejection.
### Validation against the MLDSA44 reference table
For the **twosided** reference, `n = (K+L)·N = 2048`, and the singlesubset
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, singlesubset shares):
| (T,N) | r | r1 | r1r |
|-------|---------|---------|------|
| (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 offdiagonal reference entries scale as
`Δ ≈ 56·√(maxSubsetsPerParty)`, confirming the quadrature scaling below.
---
## 4. MLDSA65 parameters (onesided, `n = L·N = 1280`)
FIPS204 MLDSA65 constants: `N=256, K=6, L=5, η=4, τ=49, ω=55, q=8380417,
d=13, γ1=2^19=524288, γ2=261888, β=τ·η=196, γ1−β=524092`.
**Singlesubset shift L2 norm.** `s1^(S)` has `L·N = 1280` coefficients drawn
uniformly from `[−η,η] = [4,4]` (FIPS204 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.
```
**Leakfree gap ratio** (`n = 1280`, `κ = 64`):
```
Δ/δ = √(2·64·ln2 / 1280) = 0.2633 .
```
**Base gap** (single subset, `T = N`): `Δ_base = ⌈0.2633·720⌉ = 190`.
**Percommittee 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` percoordinate ≈ `N(0, r1²/n)` and `T` independent
parties, the summed percoordinate 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` percoordinate std at a constant ≈ 81 800 (independent
of `T`), worstcoordinate ≈ 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 perparty ballacceptance; the joint perslot success is
`p_party^T · p_boundary` with the central `BoundaryClear` yield
`p_boundary ≈ 0.09` for MLDSA65. Krepetition (and protocol reruns) amortise
this; e.g. `(8,8)`: perslot ≈ `0.791^8·0.09 ≈ 0.013`, so ≈ 350 slotattempts
clear 99 %, spread over `K` parallel slots and `maxRounds` reruns.
### 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 reruns 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 roundtrip test across `n=8,t=8` and all `N≤6` committees.
* **Leakfreeness** (perparty `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 noleak**: 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**: ModuleLWE / ModuleSIS over `R_q = Z_q[X]/(X^256+1)` (the same
assumption MLDSA65 / FIPS204 already rests on) — the onesided commitment
`w_j = A·y_j` hides `y_j` under ModuleSIS. EUFCMA of the produced signature
is exactly FIPS204's.
## 6. References
* del Pino, Celi, Espitau, Niot, Prest. *Mithril: Efficient Threshold MLDSA
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` 3round protocol).
* FIPS 204, *ModuleLatticeBased Digital Signature Standard* (MLDSA).
* Bai, Lepoint, RouxLanglois, Sakzad, Stehlé, Steinfeld. *Improved Security
Proofs in LatticeBased Cryptography: Using the Rényi Divergence.* J.
Cryptology 2018 (the Rényidivergence probabilitypreservation 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 T1 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}, // T1 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 z1z2 = (c1c2)·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)
}
}
}