pulsar: dealerless RSS FIPS-leg Sign API (fail-closed, quorum-gated)

MithrilKey.Sign reconstructs key material from any T-quorum's holdings, BCC-signs
(hint from public w', no c·s2/c·t0 leak), and FAIL-CLOSED self-verifies against
FIPS-204 before emitting. Output verifies under stock circl mldsa65.Verify.
Proven for multiple qualifying quorums under one group key. Full pulsar suite
green (65s).
This commit is contained in:
2026-06-27 23:05:15 -07:00
parent 33f1bb97fb
commit afd9de23a0
2 changed files with 64 additions and 0 deletions
+36
View File
@@ -31,6 +31,7 @@ package pulsar
import (
"fmt"
"io"
"github.com/luxfi/dkg/rss"
"golang.org/x/crypto/sha3"
@@ -257,3 +258,38 @@ func (mk *MithrilKey) ReconstructKeyMaterial(active []int) (*mldsaKeyMaterial, e
km.a = mk.a
return km, nil
}
// Sign produces a byte-stock-FIPS-204 ML-DSA signature on (message, ctx) under
// the dealerless RSS group key, from any T active parties. It reconstructs the
// key material from the quorum's holdings (balanced partition), runs the BCC
// signer — whose hint is recovered from the PUBLIC w' = A·z − c·t1·2^d, so it
// emits NONE of c·s2 / c·t0 / r0 — and FAIL-CLOSED self-verifies the result
// against the FIPS-204 verifier before returning. The returned *Signature
// verifies under unmodified cloudflare/circl mldsa{65,87}.Verify.
//
// This is the threshold-gated SIGNING act: it requires a T-quorum and rebuilds
// the secret at the signing coordinator. The dealerless KEYGEN never does this —
// no party holds the full key at keygen. (A signing-time no-reconstruct variant
// is the Mithril 3-round hyperball protocol, where each party emits only its
// partial z_i = y_i + c·s_{1,I}; that is the signing-side hardening follow-up.)
func (mk *MithrilKey) Sign(active []int, message, ctx []byte, rng io.Reader, maxAttempts int) (*Signature, error) {
km, err := mk.ReconstructKeyMaterial(active)
if err != nil {
return nil, err
}
sigBytes, _, err := bccSign(km, mk.Mode, message, ctx, rng, maxAttempts)
if err != nil {
return nil, fmt.Errorf("pulsar: mithril RSS sign: %w", err)
}
sig := &Signature{Mode: mk.Mode, Bytes: sigBytes}
// Fail-closed: never emit a signature the FIPS-204 verifier rejects.
params, err := ParamsFor(mk.Mode)
if err != nil {
return nil, err
}
pk := &PublicKey{Mode: mk.Mode, Bytes: mk.pub}
if err := VerifyCtx(params, pk, message, ctx, sig); err != nil {
return nil, fmt.Errorf("pulsar: mithril RSS sign self-verify failed (refusing to emit): %w", err)
}
return sig, nil
}
+28
View File
@@ -146,6 +146,34 @@ func TestMithrilRSSAnyQuorumSameKey(t *testing.T) {
}
}
// TestMithrilRSSSignAPI exercises the production-facing dealerless FIPS-leg
// signer: MithrilKey.Sign reconstructs from a quorum, BCC-signs, fail-closed
// self-verifies, and the result verifies under stock circl mldsa65.Verify.
func TestMithrilRSSSignAPI(t *testing.T) {
mk, err := MithrilRSSKeygen(ModeP65, 3, 5, mithrilSeeds(5))
if err != nil {
t.Fatal(err)
}
msg := []byte("dealerless RSS FIPS leg — Sign API")
ctx := []byte("quasar-pulsar-leg")
// Two different qualifying quorums both sign valid signatures under the
// same public key.
for _, active := range [][]int{{0, 1, 2}, {2, 3, 4}, {0, 2, 4}} {
rng := newBCCDeterministicRNG(fmt.Sprintf("sign/%v", active))
sig, err := mk.Sign(active, msg, ctx, rng, 1<<20)
if err != nil {
t.Fatalf("quorum %v Sign: %v", active, err)
}
var pkC mldsa65.PublicKey
if err := pkC.UnmarshalBinary(mk.pub); err != nil {
t.Fatal(err)
}
if !mldsa65.Verify(&pkC, msg, ctx, sig.Bytes) {
t.Fatalf("quorum %v: stock circl rejected the Sign output", active)
}
}
}
func TestMithrilRSSRejectsBadCommittee(t *testing.T) {
for _, tn := range [][2]int{{1, 2}, {3, 2}, {2, 7}} {
if _, err := MithrilRSSKeygen(ModeP65, tn[0], tn[1], mithrilSeeds(maxI(tn[1], 7))); err == nil {