mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
pulsar: GATE 6 — fail-closed behaviours for the hyperball signer
TestHyperballSubThresholdFailsClosed: |active|≠T / unsorted / dup / OOR all rejected before signing (no partial-quorum signature). TestHyperballBiasedPartialCaughtAndBlamed: a biased partial makes every slot miss FindHint/self-verify → fail-closed (no signature); blameSlot pinpoints the culprit revealing T_j only up to it (Σ_j T_j = t−s2 never formed). TestHyperballEquivocationCaught: a party changing w between R1 and R2 is rejected by the binding check. TestHyperballNonceReuseFatal: demonstrates reuse leaks the share (z1−z2=(c1−c2)·s1, confirmable; wrong guess fails) and asserts the per-round nonce derivation is fresh-across-rounds + deterministic-within-round.
This commit is contained in:
@@ -202,3 +202,322 @@ func TestHyperballNoReconstructStructural(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user