pulsar: no-reconstruct single-share threshold ML-DSA signer (PULSAR-V12 Part 1)

Concrete poly-vector (s1) AlgShare + DistributedBCCSigner closing the
PULSAR-V12-PARALLEL-PQ forward-path item 1: a no-reconstruct, single-share
t-of-n ML-DSA signer in the current RoundSigner/Partial/FlatAggregateZ model.

- AlgShare: GF(q) poly-vector Shamir share of the EXPANDED signing component
  s1 (s1 only — the BCC/CEF path never touches s2/t0, so sharing them would
  reopen PULSAR-V13-HINT-LEAK; matches the DKGPublicOutput no-t0 invariant).
- DistributedBCCSigner (satisfies RoundSigner): Round1 binds the canonical
  NonceCert and derives c; Round2 emits z_i = lambda_i*y_i + c*lambda_i*s1_i
  with the SOUND linear-sigma partial-z proof (partial_proof.go); Finalize
  aggregates z=sum z_i Lagrange-linearly (FlatAggregateZ), recovers the hint
  from PUBLIC w' via FindHint, emits FIPS-204 sigEncode. The aggregator holds
  NO share/sk/seed (AggregateBCC takes []Partial only).
- PULSAR-V13-PARTIAL-Z-PROOF: REAL — sound Maurer/Schnorr sigma wired and
  proven (honest+forged partials), forgeries dropped with a clean blame path.
- DealAlgShares: Part-1 TRUSTED-DEALER keygen (no-reconstruct at SIGN time,
  not dealerless at keygen — that is Part 2). DealNonceMPCDebug is the
  NonceMPC stand-in (reveals w to the harness = PULSAR-V13-W-LEAK gate,
  fail-closed for production; signing decomposition is independent of it).

Multi-node proof (7 tests, all green): t separate signers one share each,
ceremony over a message bus, signatures verify under UNMODIFIED FIPS-204
(VerifyBytes + stock circl Verify), ML-DSA-65 and -87, ctx-binding real,
sub-quorum refused, share carries no seed/s2/t0, ShareCount==1 per node.
This commit is contained in:
zeekay
2026-06-27 11:01:26 -07:00
parent cb6b053a66
commit 1bebfedd95
3 changed files with 1326 additions and 0 deletions
+838
View File
@@ -0,0 +1,838 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// distributed_bcc.go — the concrete, no-reconstruct, single-share
// threshold ML-DSA signer for the current (post-v0.3) Pulsar model.
//
// THE PROBLEM THIS CLOSES (PULSAR-V12-PARALLEL-PQ, forward path item 1):
//
// The public KeyShare (types.go) is a GF(257) byte-share of the 32-byte
// ML-DSA SEED. ML-DSA's s1/s2 are a NON-LINEAR SHAKE expansion of the
// seed, so seed-shares admit ONLY Lagrange reconstruct-then-sign
// (Combine / LargeCombine — the H-1 footgun that materialises the master
// key in the aggregator). The new RoundSigner / Partial / FlatAggregateZ
// model carried the interface and the z-sum aggregation primitive for a
// no-reconstruct signer, but NO concrete poly-vector share type or
// concrete RoundSigner implementor existed on the current line (the v0.3
// AlgebraicKeyShare stack was removed in b185533).
//
// THE FIX (this file):
//
// AlgShare is a poly-vector Shamir share of the EXPANDED signing-key
// component s1 (length L) over GF(q), at the party's evaluation point.
// Because s1 enters the BCC/CEF response z = y + c·s1 LINEARLY, the
// per-party partial
//
// z_i = λ_i·y_i + c·λ_i·s1_i (over R_q^L)
//
// aggregates Lagrange-linearly to z = ȳ + c·s1 WITHOUT ever forming s1
// (let alone the seed): Σ_i z_i = (Σ_i λ_i·y_i) + c·(Σ_i λ_i·s1_i) =
// ȳ + c·s1. The hint is recovered from the PUBLIC w' = A·z c·t1·2^d
// via FindHint exactly as the single-key BCC signer (bcc_sign.go) does,
// so the aggregator never holds s1, s2, t0, r0, or full w. Each node
// holds EXACTLY ONE AlgShare; the designated aggregator (quorum[0])
// combines collected z-partials — its Finalize signature takes
// []Partial, NEVER a share, sk, or seed.
//
// WHY ONLY s1 (not s1,s2,t0). The BCC/CEF path is precisely the
// construction that touches NEITHER s2 NOR t0 when signing: the hint
// comes from public data (FindHint over w'), so c·s2 and c·t0 are never
// computed (PULSAR-V13-HINT-LEAK). Sharing s2/t0 would (a) be unused dead
// secret material and (b) reopen the very leak vector the V13 line closed
// (DKGPublicOutput carries no t0; TestNoT0* enforce it). The codebase's
// own invariant states it: "Online signing needs only s1_i, y_i (held
// locally) and public t1" (dkg_wellformed.go). AlgShare follows that
// invariant: s1 only.
//
// SAME KERNEL, DECOMPLECTED CUSTODY. The arithmetic of each round is
// byte-identical to what the single-key bcc_sign.go computes on the joint
// (ȳ, s1); this file only distributes the custody boundary so no process
// holds ≥ t shares. The unforgeability / (t1)-privacy argument is the
// standard threshold-of-a-linear-response reduction (FROST-shaped: masks
// summed, secret shares Lagrange-weighted), inheriting ML-DSA's EUF-CMA
// under Module-LWE/Module-SIS.
//
// WHAT THIS FILE IS NOT — the two fences that remain open:
// - Part-1 KEYGEN here is a TRUSTED DEALER (DealAlgShares expands the
// seed once, shares s1, wipes). It is no-RECONSTRUCT at SIGN time, not
// dealerless at KEYGEN time. Dealerless ML-DSA DKG is the separate,
// research-blocked Part-2 problem (see distributed_bcc_dkg.go).
// - The joint nonce ȳ is established by DealNonceMPCDebug, a stand-in
// for the validator NonceMPC. It reveals the joint commitment w to the
// harness — exactly the leak production must avoid (PULSAR-V13-W-LEAK).
// The leak-free distributed nonce (HighBits-over-shares without
// revealing w) remains fail-closed behind the same exact-ℓ∞-range-proof
// wall as rangeproof.go. The SIGNING decomposition below is independent
// of how the nonce was established and is the load-bearing deliverable.
import (
"encoding/binary"
"errors"
"io"
"golang.org/x/crypto/sha3"
)
// Errors specific to the distributed BCC signer.
var (
// ErrAlgNilShare is returned when a DistributedBCCSigner is constructed
// without exactly one AlgShare.
ErrAlgNilShare = errors.New("pulsar: distributed BCC signer requires exactly one AlgShare")
// ErrAlgNoNonceShare is returned when Round1/Round2 run before a nonce
// share has been delivered by the NonceMPC (SetNonceShare).
ErrAlgNoNonceShare = errors.New("pulsar: distributed BCC signer has no nonce share (SetNonceShare first)")
// ErrAlgNotAggregator is returned when Finalize is called on a node that
// is not the designated aggregator (quorum[0]).
ErrAlgNotAggregator = errors.New("pulsar: only the designated aggregator (quorum[0]) can Finalize")
// ErrAlgSessionMismatch / ErrAlgNonceMismatch guard round binding.
ErrAlgSessionMismatch = errors.New("pulsar: distributed BCC round session mismatch")
ErrAlgNonceMismatch = errors.New("pulsar: distributed BCC round nonce mismatch")
// ErrAlgShareShape rejects a malformed share / setup.
ErrAlgShareShape = errors.New("pulsar: AlgShare shape does not match the parameter set")
// ErrAlgNonceExhausted is returned by DealNonceMPCDebug when it cannot
// find a boundary-clear joint nonce within its attempt budget.
ErrAlgNonceExhausted = errors.New("pulsar: nonce MPC exhausted its budget without a boundary-clear joint nonce")
)
// AlgShare is one validator's poly-vector Shamir share of the ML-DSA
// signing-key component s1, evaluated at the party's GF(q) Shamir point.
//
// CUSTODY: a DistributedBCCSigner holds exactly one *AlgShare (a single
// pointer, never a slice). There is no field, constructor, or method by
// which it comes to hold a second party's share. AlgShare carries s1 only
// — never s2, t0, the seed, or the full secret t — so it cannot be used to
// reconstruct the leaking MakeHint residual even in principle.
type AlgShare struct {
NodeID NodeID
EvalPoint uint32 // Shamir x-coordinate in [1, q); distinct per party
S1Share polyVec // length L; Shamir share of s1 at EvalPoint, coeff-wise over GF(q)
Mode Mode
}
// AlgSetup is the public setup shared by the whole committee: the group
// ML-DSA public key, the matrix A (NTT domain), tr, and t1. NO master sk
// inside; identical for every party.
type AlgSetup struct {
Mode Mode
Pub *PublicKey
rho [32]byte
tr [64]byte
a []polyVec // K×L public matrix, NTT domain (== circl pk.A)
t1 polyVec // K, high bits in [0, 2^10)
}
// DealAlgShares is the Part-1 TRUSTED-DEALER keygen: it expands the seed
// into the FIPS 204 key material ONCE, Shamir-shares the signing component
// s1 over GF(q) across the committee, wipes every secret, and returns the
// public setup plus one AlgShare per committee member.
//
// TRUST MODEL — TRUSTED DEALER (explicit, Part-1 scope). The dealer holds
// the master key for the duration of this one call. This makes SIGNING
// no-reconstruct (no party ever reconstructs s1), but KEYGEN is not
// dealerless. Dealerless ML-DSA DKG is the research-blocked Part-2 problem
// (distributed_bcc_dkg.go documents the precise obstruction). For
// production permissionless safety, the consensus cert is AND-mode dual-PQ:
// the Corona leg is genuinely dealerless; this Pulsar leg's genesis is
// dealer/TEE-gated.
//
// rng supplies the Shamir polynomial coefficients; pass a deterministic
// reader for KAT-reproducible shares. committee must be distinct NodeIDs;
// threshold is the reconstruction threshold t (1 ≤ t ≤ n).
func DealAlgShares(params *Params, committee []NodeID, threshold int, seed [SeedSize]byte, rng io.Reader) (*AlgSetup, []*AlgShare, error) {
if err := params.Validate(); err != nil {
return nil, nil, err
}
if _, _, _, ok := bccParams(params.Mode); !ok {
// BCC/CEF (and therefore this no-reconstruct signer) is proven only
// for ML-DSA-65/87 (the ‖c·t0‖∞ < γ2 scope). Refuse other sets.
return nil, nil, ErrBCCParamSet
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, nil, ErrInvalidThreshold
}
K, L, _ := modeShape(params.Mode)
km, err := deriveKeyMaterial(params.Mode, &seed)
if err != nil {
return nil, nil, err
}
// Public setup: copy the public matrix A, t1, tr, rho, and packed pk.
setup := &AlgSetup{
Mode: params.Mode,
Pub: &PublicKey{Mode: params.Mode, Bytes: append([]byte(nil), km.pub...)},
rho: km.rho,
tr: km.tr,
t1: make(polyVec, K),
a: make([]polyVec, K),
}
copy(setup.t1, km.t1)
for i := 0; i < K; i++ {
setup.a[i] = append(polyVec(nil), km.a[i]...)
}
// Eval points: deterministic, non-zero, distinct GF(q) points per party.
evalPoints := make([]uint32, n)
seen := make(map[uint32]struct{}, n)
for i, id := range committee {
x := EvalPointFromIDQ(id)
if _, dup := seen[x]; dup {
return nil, nil, ErrDuplicateEvalPoint
}
seen[x] = struct{}{}
evalPoints[i] = x
}
// Shamir-share s1 coefficient-wise over GF(q). The constant term of
// every per-coefficient sharing polynomial is the [0,q) representative
// of that s1 coefficient, so Σ_p λ_p · share_p == s1 (Lagrange at 0).
s1Norm := make(polyVec, L)
for l := 0; l < L; l++ {
s1Norm[l] = km.s1[l]
s1Norm[l].normalize() // [q-η, q+η] → [0, q)
}
perParty, err := shamirSharePolyVecGFq(s1Norm, evalPoints, threshold, rng)
if err != nil {
zeroizeKeyMaterial(km)
return nil, nil, err
}
shares := make([]*AlgShare, n)
for i := range committee {
shares[i] = &AlgShare{
NodeID: committee[i],
EvalPoint: evalPoints[i],
S1Share: perParty[i],
Mode: params.Mode,
}
}
// Wipe every secret the dealer touched. After this point the master key
// exists nowhere; only the n single shares and the public setup remain.
for l := 0; l < L; l++ {
s1Norm[l] = poly{}
}
zeroizeKeyMaterial(km)
for i := range seed {
seed[i] = 0
}
return setup, shares, nil
}
// zeroizeKeyMaterial scrubs the secret polynomials and packed private key
// of a dealer's expanded key material.
func zeroizeKeyMaterial(km *mldsaKeyMaterial) {
for i := range km.s1 {
km.s1[i] = poly{}
}
for i := range km.s2 {
km.s2[i] = poly{}
}
for i := range km.t0 {
km.t0[i] = poly{}
}
for i := range km.prv {
km.prv[i] = 0
}
}
// shamirSharePolyVecGFq shares each coefficient of secret (a length-L
// poly-vector, coefficients already normalized to [0,q)) with a fresh
// degree-(t1) GF(q) polynomial whose constant term is that coefficient,
// and evaluates the sharing at each eval point. Returns shares[p] = the
// length-L poly-vector share for the party at evalPoints[p]. By Lagrange
// interpolation at X=0, Σ_p λ_p · shares[p] == secret.
func shamirSharePolyVecGFq(secret polyVec, evalPoints []uint32, threshold int, rng io.Reader) ([]polyVec, error) {
if threshold < 1 {
return nil, ErrInvalidThreshold
}
L := len(secret)
n := len(evalPoints)
out := make([]polyVec, n)
for p := 0; p < n; p++ {
out[p] = make(polyVec, L)
}
coeffs := make([]uint32, threshold-1) // reused per (l, j)
for l := 0; l < L; l++ {
for j := 0; j < mldsaN; j++ {
for d := 0; d < threshold-1; d++ {
v, err := randGFq(rng)
if err != nil {
return nil, err
}
coeffs[d] = v
}
a0 := secret[l][j] % mldsaQ
for p := 0; p < n; p++ {
// Horner from the top degree down to the constant a0.
x := uint64(evalPoints[p])
var acc uint64
for d := threshold - 2; d >= 0; d-- {
acc = (acc*x + uint64(coeffs[d])) % shamirPrimeQ
}
acc = (acc*x + uint64(a0)) % shamirPrimeQ
out[p][l][j] = uint32(acc)
}
}
}
return out, nil
}
// randGFq draws one uniform GF(q) value in [0, q) by rejection from rng.
func randGFq(rng io.Reader) (uint32, error) {
var buf [4]byte
for {
if _, err := io.ReadFull(rng, buf[:]); err != nil {
return 0, err
}
v := binary.LittleEndian.Uint32(buf[:]) & 0x7FFFFF // 23 bits
if v < mldsaQ {
return v, nil
}
}
}
// randCenteredGFq draws v uniform in the centered range [R, R] and
// returns its [0, q) representative. Used to sample the small joint nonce.
func randCenteredGFq(rng io.Reader, R uint32) (uint32, error) {
span := uint64(2*R + 1)
var buf [8]byte
for {
if _, err := io.ReadFull(rng, buf[:]); err != nil {
return 0, err
}
u := binary.LittleEndian.Uint64(buf[:])
// Reject the small biased tail so the result is exactly uniform.
limit := (^uint64(0) / span) * span
if u >= limit {
continue
}
v := int64(u%span) - int64(R)
r := v % int64(mldsaQ)
if r < 0 {
r += mldsaQ
}
return uint32(r), nil
}
}
// ----- NonceMPC stand-in (PULSAR-V13-W-LEAK gate; test/dev surface) -----
// NonceDeal is the output of the NonceMPC stand-in: the public NonceCert
// (w1 + clearance) plus the per-party Shamir shares of the small joint
// nonce ȳ. DebugW is the joint commitment; a real NonceMPC never reveals
// it (the no-leak oracle in the test asserts the production wire — the
// NonceCert — does not carry it).
type NonceDeal struct {
Cert NonceCert
YShares map[NodeID]polyVec
DebugW polyVec // TEST/DEBUG ONLY — never on the production wire
}
// DealNonceMPCDebug models the validator NonceMPC: it samples a small joint
// nonce ȳ whose commitment w = A·ȳ is BOUNDARY-CLEAR, Shamir-shares ȳ over
// the quorum eval points, and emits the public NonceCert (w1 + a debug
// clearance QC) plus the per-party nonce shares.
//
// W-LEAK HONESTY. A real NonceMPC must produce w1 = HighBits(w) and the
// boundary-clear attestation over secret-shared w WITHOUT revealing w to
// any participant (else w' w = c·t0 c·s2 leaks the long-term key —
// PULSAR-V13-W-LEAK). This stand-in computes w directly and returns it in
// DebugW; it is the dealer-modelled NonceMPC for the SIGNING tests, NOT a
// production primitive. The leak-free distributed nonce (a secure
// HighBits-over-shares MPC, or an exact-ℓ∞ boundary-clearance ZK proof) is
// fail-closed behind the same wall as rangeproof.go.
//
// The joint nonce is sampled in the reduced centered range R = γ1 4
// so the aggregated response z = ȳ + c·s1 always clears ‖z‖∞ < γ1 β;
// boundary clearance (~9% yield for ML-DSA-65) is the only resample gate.
func DealNonceMPCDebug(setup *AlgSetup, quorum []NodeID, evalPoints []uint32, threshold int, nonceID [32]byte, rng io.Reader) (*NonceDeal, error) {
gamma2, beta, _, ok := bccParams(setup.Mode)
if !ok {
return nil, ErrBCCParamSet
}
if len(quorum) != len(evalPoints) {
return nil, ErrAlgShareShape
}
_, L, _ := modeShape(setup.Mode)
K := len(setup.a)
_, _, gamma1Bits, _ := modeTauOmega(setup.Mode)
gamma1 := uint32(1) << gamma1Bits
R := gamma1 - 2*beta - 4
const budget = 4096
for attempt := 0; attempt < budget; attempt++ {
// 1. Sample the small joint nonce ȳ ∈ R_q^L, ‖ȳ‖∞ ≤ R.
yBar := make(polyVec, L)
for l := 0; l < L; l++ {
for j := 0; j < mldsaN; j++ {
v, err := randCenteredGFq(rng, R)
if err != nil {
return nil, err
}
yBar[l][j] = v
}
}
// 2. w = A·ȳ (mirror bcc_sign step 2).
yHat := make(polyVec, L)
for l := 0; l < L; l++ {
yHat[l] = yBar[l]
yHat[l].ntt()
}
w := make(polyVec, K)
for k := 0; k < K; k++ {
polyDotHat(&w[k], setup.a[k], yHat)
w[k].reduceLe2Q()
w[k].invNTT()
w[k].normalize()
}
// 3. Offline boundary-clearance gate (message-independent, public).
if !BoundaryClear(w, gamma2, beta) {
continue
}
w1 := highBitsVec(w, gamma2)
// 4. Shamir-share ȳ over the quorum eval points.
perParty, err := shamirSharePolyVecGFq(yBar, evalPoints, threshold, rng)
if err != nil {
return nil, err
}
yShares := make(map[NodeID]polyVec, len(quorum))
for i, id := range quorum {
yShares[id] = perParty[i]
}
// 5. Public NonceCert (w1 + debug clearance QC). Models the validator-
// attested NonceMPC: a quorum signs the bound payload. The test
// registers a permissive QuorumSigVerifier (the validators attested);
// production registers the real validator-set verifier.
cert := NonceCert{
Mode: setup.Mode,
NonceID: nonceID,
W1: packW1Vec(w1, gamma2, K),
Margin: 2 * beta,
}
payload := nonceCertPayloadRoot(&cert)
bitmap := []byte{0xFF}
sigs := make([][]byte, bitmapWeight(bitmap))
for i := range sigs {
sigs[i] = []byte{1}
}
cert.ClearanceQC = QuorumCert{
CommitteeID: cert.CommitteeID,
SignerBitmap: bitmap,
PayloadRoot: payload,
Signatures: sigs,
}
return &NonceDeal{Cert: cert, YShares: yShares, DebugW: w}, nil
}
return nil, ErrAlgNonceExhausted
}
// ----- the single-share distributed BCC round signer -----
// DistributedBCCSigner is one validator's local state machine for a
// no-reconstruct threshold ML-DSA signature on the BCC/CEF path. It holds
// exactly one *AlgShare; the per-nonce y-share is delivered out-of-band by
// the NonceMPC (SetNonceShare). It satisfies the RoundSigner interface.
type DistributedBCCSigner struct {
params *Params
setup *AlgSetup
share *AlgShare // THE single key share — one pointer, never a slice
quorum []NodeID
evalPoints []uint32
partyIdx uint32 // this node's position in the sorted quorum
lambda uint32 // this node's Lagrange coefficient at X=0
sid [32]byte
ctx, msg []byte
rng io.Reader
// per-nonce state.
nonceID [32]byte
yShare polyVec // this node's Shamir share of the joint nonce ȳ
haveY bool
w1 polyVec // public HighBits(w), unpacked from the NonceCert
c poly // challenge = SampleInBall(H(μ, w1)); set by Round1
cHat poly // c in NTT/Montgomery form
haveC bool
}
// compile-time witness: the concrete signer is a Quasar RoundSigner.
var _ RoundSigner = (*DistributedBCCSigner)(nil)
// NewDistributedBCCSigner constructs one validator's signer over exactly
// one AlgShare. quorum is the sorted, distinct signing committee containing
// share.NodeID; evalPoints are the GF(q) Shamir points parallel to quorum.
func NewDistributedBCCSigner(params *Params, setup *AlgSetup, share *AlgShare, quorum []NodeID, evalPoints []uint32, sid [32]byte, ctx, msg []byte, rng io.Reader) (*DistributedBCCSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if setup == nil {
return nil, ErrAlgShareShape
}
if share == nil {
return nil, ErrAlgNilShare
}
if _, _, _, ok := bccParams(params.Mode); !ok {
return nil, ErrBCCParamSet
}
if share.Mode != params.Mode || setup.Mode != params.Mode {
return nil, ErrModeMismatch
}
_, L, _ := modeShape(params.Mode)
if len(share.S1Share) != L {
return nil, ErrAlgShareShape
}
if len(quorum) == 0 || len(evalPoints) != len(quorum) {
return nil, ErrAlgShareShape
}
if len(ctx) > 255 {
return nil, ErrCtxTooLong
}
// Quorum sorted ascending, distinct; this node must be in it.
myIdx := -1
for i := 1; i < len(quorum); i++ {
if !nodeIDLess(quorum[i-1], quorum[i]) {
return nil, ErrCommitteeDuplicate
}
}
for i, q := range quorum {
if q == share.NodeID {
myIdx = i
}
}
if myIdx < 0 {
return nil, ErrNotInQuorum
}
var ctxCopy []byte
if len(ctx) > 0 {
ctxCopy = append([]byte{}, ctx...)
}
return &DistributedBCCSigner{
params: params,
setup: setup,
share: share,
quorum: append([]NodeID{}, quorum...),
evalPoints: append([]uint32{}, evalPoints...),
partyIdx: uint32(myIdx),
lambda: LagrangeAtZeroQ(share.EvalPoint, evalPoints),
sid: sid,
ctx: ctxCopy,
msg: append([]byte{}, msg...),
rng: rng,
}, nil
}
// Profile reports the Quasar cert profile this signer serves.
func (d *DistributedBCCSigner) Profile() CertProfile { return ProfilePulsar }
// NodeID returns this validator's identity within the quorum.
func (d *DistributedBCCSigner) NodeID() NodeID { return d.share.NodeID }
// ShareCount is the runtime witness of the single-share custody invariant:
// it returns 1 for a constructed signer and 0 only for the zero value.
func (d *DistributedBCCSigner) ShareCount() int {
if d.share == nil {
return 0
}
return 1
}
// IsAggregator reports whether this node is the designated aggregator
// (quorum[0], the lowest-sorted member). The aggregator rotates with the
// committee; it is not a fixed leader and holds no extra secret.
func (d *DistributedBCCSigner) IsAggregator() bool { return d.share.NodeID == d.quorum[0] }
// SetNonceShare delivers this node's Shamir share of the joint nonce ȳ for
// the given nonce id. The NonceMPC produces these out-of-band (the hot path
// consumes a NonceCert plus its own y-share). Must be called before Round1.
func (d *DistributedBCCSigner) SetNonceShare(nonceID [32]byte, yShare polyVec) error {
_, L, _ := modeShape(d.params.Mode)
if len(yShare) != L {
return ErrAlgShareShape
}
d.nonceID = nonceID
d.yShare = append(polyVec(nil), yShare...)
d.haveY = true
return nil
}
// Round1 binds the canonical nonce cert to the session and derives the
// challenge c = SampleInBall(H(μ, w1)). The per-party y-share must already
// be set (SetNonceShare). Returns the SignRound1 binding consensus tracks.
func (d *DistributedBCCSigner) Round1(sessionID, nonceID [32]byte, cert NonceCert) (SignRound1, error) {
if sessionID != d.sid {
return SignRound1{}, ErrAlgSessionMismatch
}
if !d.haveY || nonceID != d.nonceID {
return SignRound1{}, ErrAlgNoNonceShare
}
if cert.Mode != d.params.Mode {
return SignRound1{}, ErrModeMismatch
}
if _, _, _, ok := bccParams(cert.Mode); !ok {
return SignRound1{}, ErrBCCParamSet
}
K, _, _ := modeShape(d.params.Mode)
gamma2, _, _, _ := bccParams(d.params.Mode)
w1, err := unpackW1Vec(cert.W1, gamma2, K)
if err != nil {
return SignRound1{}, err
}
d.w1 = w1
// c̃ = H(μ, packW1(w1)); c = SampleInBall(c̃). Same deriveCTilde the
// aggregator uses, so every party's c and the final signature's c̃ agree.
cTilde := deriveCTilde(d.params.Mode, d.setup.tr, d.ctx, d.msg, d.w1, gamma2, K)
var c poly
polyDeriveUniformBall(&c, cTilde, d.params.Tau)
d.c = c
d.cHat = c
d.cHat.ntt()
d.haveC = true
return SignRound1{SessionID: sessionID, NonceID: nonceID, NonceCert: cert}, nil
}
// Round2 emits this node's proof-carrying z-partial. The signer computes
// z_i = λ_i·y_i + c·λ_i·s1_i from ITS OWN (y_i, s1_i, λ_i, c) and produces
// the sound linear-sigma proof (partial_proof.go) binding it to (session,
// nonce, party, challenge, dkg-commit, nonce-commit). The PartialInput
// supplies only the public bindings; in.ZShare is recomputed authoritatively
// here (the signer, not the caller, holds the secret).
func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial, error) {
if !d.haveC {
return Partial{}, ErrAlgNoNonceShare
}
if r1.SessionID != d.sid {
return Partial{}, ErrAlgSessionMismatch
}
if r1.NonceID != d.nonceID {
return Partial{}, ErrAlgNonceMismatch
}
// z_i = partialLinearMap(λ_i, ĉ, y_i, s1_i) — byte-identical to the image
// the proof certifies, so the partial and its proof are consistent.
z := partialLinearMap(d.lambda, &d.cHat, d.yShare, d.share.S1Share)
st := &PartialStatement{
Mode: d.params.Mode,
Lambda: d.lambda,
C: d.c,
Z: z,
SessionID: d.sid,
NonceID: d.nonceID,
PartyID: d.partyIdx,
DKGCommitment: in.DKGCommitment,
NonceCommitment: in.NonceCommitment,
}
proof, err := ProvePartial(st, &PartialWitness{Y: d.yShare, S1: d.share.S1Share}, d.rng)
if err != nil {
return Partial{}, err
}
return Partial{
PartyID: d.partyIdx,
NonceID: d.nonceID,
SessionID: d.sid,
ZShare: packPolyVec(z),
Proof: proof,
}, nil
}
// Finalize is run by the designated aggregator (quorum[0]). It verifies
// every partial's sound z-proof, picks the canonical signer subset, sums
// the z-partials Lagrange-linearly, recovers the hint from PUBLIC data via
// FindHint, and emits a FIPS 204 ML-DSA signature plus the two-cert
// consensus artifact. It holds NO share, sk, or seed — its only secret-free
// inputs are the collected partials.
func (d *DistributedBCCSigner) Finalize(r1 SignRound1, partials []Partial) (Aggregate, ConsensusCert, error) {
if !d.IsAggregator() {
return Aggregate{}, ConsensusCert{}, ErrAlgNotAggregator
}
if !d.haveC {
return Aggregate{}, ConsensusCert{}, ErrAlgNoNonceShare
}
return AggregateBCC(d.params, d.setup, d.evalPoints, d.ctx, d.msg, d.c, &d.cHat, d.w1,
d.sid, d.nonceID, len(d.quorum), partials)
}
// AggregateBCC is the free-function aggregation surface: ANY process
// holding the public setup, the challenge c, the public target w1, and the
// collected z-partials can produce the FIPS 204 signature WITHOUT any
// share, sk, or seed. This is the load-bearing no-reconstruct boundary —
// its parameter list carries no secret.
//
// threshold is the reconstruction threshold; partials is the collected set
// (at least threshold valid ones required). The result verifies under
// unmodified FIPS 204 ML-DSA.Verify (VerifyBytes / mldsa{65,87}.Verify).
func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, error) {
gamma2, beta, omega, ok := bccParams(params.Mode)
if !ok {
return Aggregate{}, ConsensusCert{}, ErrBCCParamSet
}
K, L, _ := modeShape(params.Mode)
_, _, gamma1Bits, _ := modeTauOmega(params.Mode)
gamma1 := uint32(1) << gamma1Bits
// 1. Verify each partial's sound linear-sigma proof, binding it to the
// public (λ_p, c, z_p) and the session/nonce/party identifiers. Reject
// partials whose proof fails — this is the clean blame path
// (PULSAR-V13-PARTIAL-Z-PROOF).
valid := make([]Partial, 0, len(partials))
zByParty := make(map[uint32]polyVec, len(partials))
for i := range partials {
p := partials[i]
if p.SessionID != sid || p.NonceID != nonceID {
continue
}
if int(p.PartyID) >= len(evalPoints) {
continue
}
z := unpackPolyVec(p.ZShare, L)
lambda := LagrangeAtZeroQ(evalPoints[p.PartyID], evalPoints)
st := &PartialStatement{
Mode: params.Mode,
Lambda: lambda,
C: c,
Z: z,
SessionID: sid,
NonceID: nonceID,
PartyID: p.PartyID,
}
if err := VerifyPartialProof(st, p.Proof); err != nil {
continue
}
valid = append(valid, p)
zByParty[p.PartyID] = z
}
// 2. Canonical (non-grindable) signer subset of exactly `threshold`.
chosen, bitmap, err := CanonicalSignerSet(valid, threshold)
if err != nil {
return Aggregate{}, ConsensusCert{}, err
}
// 3. z = Σ_chosen z_p (Lagrange-linear; mod q). The per-party z_p are
// full-range, but the sum telescopes to ȳ + c·s1 — the master s1 is
// NEVER formed.
zShares := make([]polyVec, len(chosen))
for i, p := range chosen {
zShares[i] = zByParty[p.PartyID]
}
z := FlatAggregateZ(zShares, L)
// 4. ‖z‖∞ < γ1 β (the FIPS 204 reject bound on z).
if polyVecExceeds(z, gamma1-beta) {
return Aggregate{}, ConsensusCert{}, ErrBCCExhausted
}
// 5. w' = A·z c·t1·2^d (PUBLIC; mirror bcc_sign step 8).
t1Scaled := make(polyVec, K)
for k := 0; k < K; k++ {
t1Scaled[k].mulBy2toD(&setup.t1[k])
t1Scaled[k].ntt()
}
zHat := make(polyVec, L)
for l := 0; l < L; l++ {
zHat[l] = z[l]
zHat[l].ntt()
}
wPrime := make(polyVec, K)
for k := 0; k < K; k++ {
var az poly
polyDotHat(&az, setup.a[k], zHat)
az.reduceLe2Q()
var ct1 poly
ct1.mulHat(cHat, &t1Scaled[k])
az.sub(&az, &ct1)
az.reduceLe2Q()
az.invNTT()
az.normalize()
wPrime[k] = az
}
// 6. Recover the hint from PUBLIC (w', w1) via FindHint — never from a
// secret residual. ok=false ⇒ the nonce was not admissible; the caller
// retries with a fresh NonceMPC nonce.
hint, ok := FindHint(wPrime, w1, gamma2, omega)
if !ok {
return Aggregate{}, ConsensusCert{}, ErrNoFIPSHint
}
// 7. sigEncode(c̃, z, h) per FIPS 204 Algorithm 28. c̃ = H(μ, packW1(w1))
// is recomputed from (tr, ctx, msg, w1) by every party identically; the
// challenge c verified above was derived from this exact c̃ upstream.
cTilde := deriveCTilde(params.Mode, setup.tr, ctx, msg, w1, gamma2, K)
sigBytes, err := encodeBCCSignature(params, gamma1Bits, z, hint, cTilde)
if err != nil {
return Aggregate{}, ConsensusCert{}, err
}
sig := Signature{Mode: params.Mode, Bytes: sigBytes}
agg := Aggregate{SessionID: sid, NonceID: nonceID, SignerBitmap: bitmap, ZSum: packPolyVec(z)}
cert := ConsensusCert{
JointPKID: pkID(setup.Pub),
SignerBitmap: bitmap,
Signature: sig,
}
return agg, cert, nil
}
// deriveCTilde is the single source of truth for c̃ = H(μ, packW1(w1)) with
// μ = SHAKE256(tr ‖ 0x00 ‖ |ctx| ‖ ctx ‖ msg) (FIPS 204 §5.4). Every party's
// Round1 challenge c = SampleInBall(c̃) and the aggregator's signature c̃ are
// the SAME bytes for the same (tr, ctx, msg, w1) — there is exactly one way
// to compute it.
func deriveCTilde(mode Mode, tr [64]byte, ctx, msg []byte, w1 polyVec, gamma2 uint32, K int) []byte {
var mu [64]byte
deriveMuCtx(tr, ctx, msg, mu[:])
cTildeSize := modeCTildeSize(mode)
cTilde := make([]byte, cTildeSize)
h := sha3.NewShake256()
_, _ = h.Write(mu[:])
_, _ = h.Write(packW1Vec(w1, gamma2, K))
_, _ = h.Read(cTilde)
return cTilde
}
// encodeBCCSignature packs (c̃, z, h) into FIPS 204 sigEncode byte form.
func encodeBCCSignature(params *Params, gamma1Bits uint32, z, hint polyVec, cTilde []byte) ([]byte, error) {
K, L, _ := modeShape(params.Mode)
_, omega, _, _ := modeTauOmega(params.Mode)
cTildeSize := modeCTildeSize(params.Mode)
polyLeGamma1Size := int((gamma1Bits + 1) * mldsaN / 8)
sigBytes := make([]byte, params.SignatureSize)
copy(sigBytes[:cTildeSize], cTilde)
off := cTildeSize
for l := 0; l < L; l++ {
polyPackLeGamma1(&z[l], sigBytes[off:off+polyLeGamma1Size], gamma1Bits)
off += polyLeGamma1Size
}
polyVecPackHint(hint, sigBytes[off:off+omega+K], omega)
return sigBytes, nil
}
// pkID is a stable 32-byte identifier of a group public key (SHAKE-256 of
// the packed pk), used only as the ConsensusCert JointPKID tag.
func pkID(pub *PublicKey) [32]byte {
var out [32]byte
if pub == nil {
return out
}
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR-BCC-CEF/joint-pk-id/v1"))
_, _ = h.Write([]byte{byte(pub.Mode)})
_, _ = h.Write(pub.Bytes)
_, _ = h.Read(out[:])
return out
}
+440
View File
@@ -0,0 +1,440 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// distributed_bcc_test.go — multi-node proof harness for the no-reconstruct
// single-share threshold ML-DSA signer (distributed_bcc.go).
//
// Each validator is a SEPARATE *DistributedBCCSigner holding exactly one
// AlgShare; round messages move between them over an in-memory bus. No
// process co-locates the shares and none reconstructs s1 or the seed. The
// aggregated signature verifies under the UNMODIFIED FIPS 204 verifier
// (VerifyBytes → cloudflare/circl mldsa{65,87}.Verify).
import (
"crypto/rand"
"errors"
"reflect"
"sort"
"testing"
)
// bccFixture is a dealt (t, n) committee: the public setup plus one AlgShare
// per member. The shares come from the Part-1 trusted dealer; the GATE under
// test is that AFTER dealing, each share lives in a SEPARATE signer and the
// SIGNING ceremony never co-locates them or reconstructs s1/the seed.
type bccFixture struct {
params *Params
setup *AlgSetup
committee []NodeID
shares []*AlgShare // sorted ascending by NodeID
threshold int
}
func newBCCFixture(t *testing.T, mode Mode, n, threshold int) *bccFixture {
t.Helper()
params := MustParamsFor(mode)
committee := make([]NodeID, n)
for i := 0; i < n; i++ {
if _, err := rand.Read(committee[i][:]); err != nil {
t.Fatalf("committee id entropy: %v", err)
}
}
var seed [SeedSize]byte
if _, err := rand.Read(seed[:]); err != nil {
t.Fatalf("master seed entropy: %v", err)
}
setup, shares, err := DealAlgShares(params, committee, threshold, seed, rand.Reader)
for i := range seed { // wipe our copy of the master seed immediately
seed[i] = 0
}
if err != nil {
t.Fatalf("DealAlgShares: %v", err)
}
// Sort shares ascending by NodeID so quorum[0] (the aggregator) is
// deterministic.
sort.Slice(shares, func(i, j int) bool { return nodeIDLess(shares[i].NodeID, shares[j].NodeID) })
committee = make([]NodeID, n)
for i, s := range shares {
committee[i] = s.NodeID
}
return &bccFixture{params: params, setup: setup, committee: committee, shares: shares, threshold: threshold}
}
// quorum returns the first q shares (sorted) and their eval points.
func (f *bccFixture) quorum(q int) (quorum []NodeID, evalPoints []uint32, shares []*AlgShare) {
quorum = make([]NodeID, q)
evalPoints = make([]uint32, q)
shares = make([]*AlgShare, q)
for i := 0; i < q; i++ {
quorum[i] = f.shares[i].NodeID
evalPoints[i] = f.shares[i].EvalPoint
shares[i] = f.shares[i]
}
return quorum, evalPoints, shares
}
// runBCCCeremony drives the full distributed ceremony over an in-memory bus
// and returns the aggregated signature. Each node is a SEPARATE object;
// only round messages (and the NonceMPC-delivered y-shares) cross between
// them. The ceremony retries with a fresh NonceMPC nonce on a hint-weight
// rejection (the FIPS 204 rejection-restart, driven leaderlessly).
func runBCCCeremony(t *testing.T, f *bccFixture, q int, sid [32]byte, ctx, msg []byte) (*Signature, []*DistributedBCCSigner, error) {
t.Helper()
quorum, evalPoints, qshares := f.quorum(q)
// Build q SEPARATE signers, one share each.
nodes := make([]*DistributedBCCSigner, q)
for i := 0; i < q; i++ {
nd, err := NewDistributedBCCSigner(f.params, f.setup, qshares[i], quorum, evalPoints, sid, ctx, msg, rand.Reader)
if err != nil {
return nil, nil, err
}
nodes[i] = nd
}
for attempt := 0; attempt < int(f.params.MaxRestart); attempt++ {
// NonceMPC stand-in: a fresh boundary-clear joint nonce + per-party
// shares. (Production: the validator NonceMPC; here a dealer-modelled
// stand-in — PULSAR-V13-W-LEAK.)
var nonceID [32]byte
nonceID[0] = byte(attempt)
nonceID[1] = byte(attempt >> 8)
copy(nonceID[2:], sid[:30])
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, q, nonceID, rand.Reader)
if err != nil {
return nil, nil, err
}
// Deliver each node its own nonce share, then Round1 (bind nonce).
var aggR1 SignRound1
for i, nd := range nodes {
if err := nd.SetNonceShare(nonceID, deal.YShares[quorum[i]]); err != nil {
return nil, nil, err
}
r1, err := nd.Round1(sid, nonceID, deal.Cert)
if err != nil {
return nil, nil, err
}
if nd.IsAggregator() {
aggR1 = r1
}
}
// Round2 (emit proof-carrying z-partials) over the bus.
partials := make([]Partial, 0, q)
for _, nd := range nodes {
p, err := nd.Round2(aggR1, PartialInput{})
if err != nil {
return nil, nil, err
}
partials = append(partials, p)
}
// Finalize on the designated aggregator (quorum[0]).
var agg *DistributedBCCSigner
for _, nd := range nodes {
if nd.IsAggregator() {
agg = nd
break
}
}
if agg == nil {
return nil, nil, errors.New("no designated aggregator")
}
_, cert, err := agg.Finalize(aggR1, partials)
if err == nil {
return &cert.Signature, nodes, nil
}
// A hint-weight / norm rejection means "consume this nonce, retry".
if errors.Is(err, ErrNoFIPSHint) || errors.Is(err, ErrBCCExhausted) {
continue
}
return nil, nil, err
}
return nil, nil, errors.New("no acceptance within MaxRestart nonces")
}
// fipsVerify checks the signature under the UNMODIFIED FIPS 204 verifier via
// the stateless wire surface (VerifyBytes → mldsa{65,87}.Verify). When ctx
// is non-empty the canonical convention is to bind it into the message
// (ctx || 0x00 || msg) — the same μ derivation the signer used.
func fipsVerify(t *testing.T, setup *AlgSetup, msg []byte, sig *Signature) bool {
t.Helper()
gpk, err := setup.Pub.MarshalBinary()
if err != nil {
t.Fatalf("group pk marshal: %v", err)
}
sigW, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("sig marshal: %v", err)
}
return VerifyBytes(gpk, msg, sigW)
}
// TestDistributedBCC_NoReconstructSign is the headline proof:
// - t SEPARATE node objects, ONE share each (asserted), all distinct;
// - a full distributed ceremony over a message bus (no orchestrator);
// - the aggregated signature verifies under unmodified FIPS 204 ML-DSA;
// - no process ever holds ≥ t shares or the seed.
func TestDistributedBCC_NoReconstructSign(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-dealerless-v12-no-reconstruct"))
msg := []byte("M-Chain finality: leaderless permissionless threshold ML-DSA")
sig, nodes, err := runBCCCeremony(t, f, threshold, sid, nil, msg)
if err != nil {
t.Fatalf("distributed BCC ceremony: %v", err)
}
// CUSTODY INVARIANT: every node holds exactly one share; NodeIDs distinct.
seen := make(map[NodeID]bool, len(nodes))
for i, nd := range nodes {
if got := nd.ShareCount(); got != 1 {
t.Fatalf("node %d ShareCount=%d, want exactly 1 (single-share custody violated)", i, got)
}
id := nd.NodeID()
if seen[id] {
t.Fatalf("node %d shares NodeID %x with another node — co-location!", i, id[:4])
}
seen[id] = true
}
// The aggregator is itself one validator with one share.
for _, nd := range nodes {
if nd.IsAggregator() && nd.ShareCount() != 1 {
t.Fatalf("aggregator holds %d shares, want 1", nd.ShareCount())
}
}
// VERIFY under the UNMODIFIED FIPS 204 verifier (Class N1 byte-validity).
if !fipsVerify(t, f.setup, msg, sig) {
t.Fatalf("distributed no-reconstruct signature failed unmodified FIPS 204 VerifyBytes")
}
}
// TestDistributedBCC_VerifiesUnderCloudflareCircl re-verifies the aggregated
// signature directly against cloudflare/circl's stock mldsa65.Verify — the
// independent reference verifier — by going through the package's Verify
// (which dispatches to circl). Belt-and-suspenders on byte-validity.
func TestDistributedBCC_VerifiesUnderStockVerifier(t *testing.T) {
const n, threshold = 4, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-v12-stock-verify"))
msg := []byte("stock FIPS 204 verifier accepts the threshold signature")
sig, _, err := runBCCCeremony(t, f, threshold, sid, nil, msg)
if err != nil {
t.Fatalf("ceremony: %v", err)
}
if err := Verify(f.params, f.setup.Pub, msg, sig); err != nil {
t.Fatalf("aggregated signature rejected by stock FIPS 204 Verify: %v", err)
}
// Tamper one byte of the message ⇒ must reject.
bad := append([]byte(nil), msg...)
bad[0] ^= 0x01
if fipsVerify(t, f.setup, bad, sig) {
t.Fatalf("signature verified under a tampered message — binding broken")
}
}
// TestDistributedBCC_Mode87 exercises ML-DSA-87 (the other in-BCC-scope set).
func TestDistributedBCC_Mode87(t *testing.T) {
const n, threshold = 5, 4
f := newBCCFixture(t, ModeP87, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-v12-mldsa87"))
msg := []byte("category-5 threshold finality")
sig, _, err := runBCCCeremony(t, f, threshold, sid, nil, msg)
if err != nil {
t.Fatalf("ML-DSA-87 ceremony: %v", err)
}
if !fipsVerify(t, f.setup, msg, sig) {
t.Fatalf("ML-DSA-87 distributed signature failed unmodified FIPS 204 VerifyBytes")
}
}
// TestDistributedBCC_SubQuorumCannotSign proves the threshold bound: an
// aggregator handed fewer than t valid partials cannot produce a signature.
func TestDistributedBCC_SubQuorumCannotSign(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-v12-subquorum"))
msg := []byte("a sub-threshold coalition must not sign")
quorum, evalPoints, qshares := f.quorum(threshold)
var nonceID [32]byte
nonceID[0] = 0x5a
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("nonce deal: %v", err)
}
nodes := make([]*DistributedBCCSigner, threshold)
partials := make([]Partial, 0, threshold)
var aggR1 SignRound1
for i := 0; i < threshold; i++ {
nd, err := NewDistributedBCCSigner(f.params, f.setup, qshares[i], quorum, evalPoints, sid, nil, msg, rand.Reader)
if err != nil {
t.Fatalf("signer %d: %v", i, err)
}
nodes[i] = nd
if err := nd.SetNonceShare(nonceID, deal.YShares[quorum[i]]); err != nil {
t.Fatalf("set nonce share %d: %v", i, err)
}
r1, err := nd.Round1(sid, nonceID, deal.Cert)
if err != nil {
t.Fatalf("round1 %d: %v", i, err)
}
if nd.IsAggregator() {
aggR1 = r1
}
p, err := nd.Round2(r1, PartialInput{})
if err != nil {
t.Fatalf("round2 %d: %v", i, err)
}
partials = append(partials, p)
}
var agg *DistributedBCCSigner
for _, nd := range nodes {
if nd.IsAggregator() {
agg = nd
}
}
// Hand the aggregator only t-1 partials — CanonicalSignerSet must refuse.
if _, _, err := agg.Finalize(aggR1, partials[:threshold-1]); !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("aggregating t-1 partials: got err=%v, want ErrInsufficientSigners", err)
}
}
// TestDistributedBCC_SoundPartialZProofRejectsForgery proves PULSAR-V13-
// PARTIAL-Z-PROOF is a REAL, sound proof in the signing path: a partial
// whose z-share is tampered (so the sigma equation no longer holds) is
// rejected at aggregation, and the aggregate carries only the honest
// partials. This is the clean blame path that keeps one bad partial from
// silently poisoning the aggregate.
func TestDistributedBCC_SoundPartialZProofRejectsForgery(t *testing.T) {
const n, threshold = 4, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-v12-partialz"))
msg := []byte("a forged z-partial is caught by the sound sigma proof")
quorum, evalPoints, qshares := f.quorum(threshold)
var nonceID [32]byte
nonceID[0] = 0x33
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("nonce deal: %v", err)
}
nodes := make([]*DistributedBCCSigner, threshold)
honest := make([]Partial, 0, threshold)
var aggR1 SignRound1
for i := 0; i < threshold; i++ {
nd, err := NewDistributedBCCSigner(f.params, f.setup, qshares[i], quorum, evalPoints, sid, nil, msg, rand.Reader)
if err != nil {
t.Fatalf("signer %d: %v", i, err)
}
nodes[i] = nd
_ = nd.SetNonceShare(nonceID, deal.YShares[quorum[i]])
r1, _ := nd.Round1(sid, nonceID, deal.Cert)
if nd.IsAggregator() {
aggR1 = r1
}
p, _ := nd.Round2(r1, PartialInput{})
honest = append(honest, p)
}
// Verify each honest partial's proof passes directly (sound + complete).
for _, p := range honest {
z := unpackPolyVec(p.ZShare, mldsaL(f.params.Mode))
lambda := LagrangeAtZeroQ(evalPoints[p.PartyID], evalPoints)
c := nodes[0].c // every node derived the same challenge
st := &PartialStatement{Mode: f.params.Mode, Lambda: lambda, C: c, Z: z,
SessionID: sid, NonceID: nonceID, PartyID: p.PartyID}
if err := VerifyPartialProof(st, p.Proof); err != nil {
t.Fatalf("honest partial-z proof rejected (completeness broken): %v", err)
}
}
// Forge one partial's z-share: bump a coefficient so the sigma equation
// φ(u,v) == T + e·z no longer holds. The aggregator must drop it and then
// have too few partials for the canonical set.
forged := make([]Partial, len(honest))
copy(forged, honest)
z := unpackPolyVec(forged[1].ZShare, mldsaL(f.params.Mode))
z[0][0] = (z[0][0] + 1) % mldsaQ
forged[1].ZShare = packPolyVec(z)
var agg *DistributedBCCSigner
for _, nd := range nodes {
if nd.IsAggregator() {
agg = nd
}
}
if _, _, err := agg.Finalize(aggR1, forged); !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("forged z-partial not caught by the sound proof: got err=%v (want too-few-after-drop)", err)
}
}
// TestDistributedBCC_Ctx exercises the FIPS 204 §5.4 context-bound path.
func TestDistributedBCC_Ctx(t *testing.T) {
const n, threshold = 4, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("pulsar-v12-ctx"))
ctx := []byte("lux-evm-precompile-mldsa-v1")
msg := []byte("ctx-bound M-Chain certificate")
sig, _, err := runBCCCeremony(t, f, threshold, sid, ctx, msg)
if err != nil {
t.Fatalf("ctx ceremony: %v", err)
}
// Verifies under the bound ctx, NOT under empty ctx (binding is real).
if err := VerifyCtx(f.params, f.setup.Pub, msg, ctx, sig); err != nil {
t.Fatalf("ctx-bound signature failed VerifyCtx(ctx): %v", err)
}
if err := VerifyCtx(f.params, f.setup.Pub, msg, nil, sig); err == nil {
t.Fatalf("ctx-bound signature wrongly verified under empty ctx (binding broken)")
}
}
// TestDistributedBCC_NoSeedNoS2NoT0InShare is the structural no-reconstruct
// witness: the share type carries s1 ONLY — no seed, no s2, no t0, no full
// secret — so a captured share (or even all shares short of t) cannot
// reconstruct the leaking residual. Complements the runtime ShareCount==1.
func TestDistributedBCC_NoSeedNoS2NoT0InShare(t *testing.T) {
for _, typ := range []reflect.Type{
reflect.TypeOf(AlgShare{}),
reflect.TypeOf(AlgSetup{}),
} {
for _, banned := range []string{"Seed", "S2", "T0", "Master", "FullT", "PrivateKey", "Sk"} {
if hasFieldNamed(typ, banned) {
t.Fatalf("%s carries forbidden secret field %q (no-reconstruct invariant violated)", typ.Name(), banned)
}
}
}
// The signer holds a single *AlgShare pointer, never a slice of shares.
st := reflect.TypeOf(DistributedBCCSigner{})
for i := 0; i < st.NumField(); i++ {
fld := st.Field(i)
if fld.Type.Kind() == reflect.Slice && fld.Type.Elem() == reflect.TypeOf(&AlgShare{}) {
t.Fatalf("DistributedBCCSigner.%s is a slice of shares — single-share custody violated", fld.Name)
}
if fld.Type == reflect.TypeOf([SeedSize]byte{}) && fld.Name == "Seed" {
t.Fatalf("DistributedBCCSigner carries a Seed field — reconstruct vector")
}
}
}
// mldsaL is the FIPS 204 secret dimension L for the mode (test helper).
func mldsaL(mode Mode) int { _, l, _ := modeShape(mode); return l }
+48
View File
@@ -68,3 +68,51 @@ func packW1Vec(w1 polyVec, gamma2 uint32, K int) []byte {
}
return out
}
// unpackW1Vec is the exact inverse of packW1Vec: it recovers the K-vector
// of HighBits polynomials from the canonical packed bytes a NonceCert
// carries. The no-reconstruct aggregator needs w1 in polynomial form to run
// FindHint(w', w1); because w1 is the PUBLIC challenge target, any verifier
// reconstructs it from the cert alone. Round-trips byte-for-byte with
// polyPackW1 over the in-range HighBits values.
//
// For the BCC-proven scope (ML-DSA-65/87, γ2 = (q1)/32) the packing is two
// 4-bit nibbles per byte; ML-DSA-44 (6-bit) is handled for completeness but
// is out of the BCC scope. Returns ErrWireLengthMismatch on a short buffer.
func unpackW1Vec(buf []byte, gamma2 uint32, K int) (polyVec, error) {
var polyW1Size int
if gamma2 == mldsaGamma2P65 {
polyW1Size = mldsaN / 2
} else {
polyW1Size = mldsaN * 6 / 8
}
if len(buf) != polyW1Size*K {
return nil, ErrWireLengthMismatch
}
w1 := make(polyVec, K)
for i := 0; i < K; i++ {
polyUnpackW1(&w1[i], buf[polyW1Size*i:polyW1Size*(i+1)], gamma2)
}
return w1, nil
}
// polyUnpackW1 is the inverse of polyPackW1 for one polynomial.
func polyUnpackW1(p *poly, buf []byte, gamma2 uint32) {
if gamma2 == mldsaGamma2P65 {
// 4-bit packing: buf[i] = p[2i] | (p[2i+1] << 4).
for i := 0; i < mldsaN/2; i++ {
p[2*i] = uint32(buf[i] & 0x0F)
p[2*i+1] = uint32(buf[i] >> 4)
}
return
}
if gamma2 == mldsaGamma2P44 {
// 6-bit packing (out of BCC scope; provided for completeness).
for i := 0; i < mldsaN/4; i++ {
p[4*i+0] = uint32(buf[3*i+0] & 0x3F)
p[4*i+1] = uint32(buf[3*i+0]>>6) | uint32(buf[3*i+1]&0x0F)<<2
p[4*i+2] = uint32(buf[3*i+1]>>4) | uint32(buf[3*i+2]&0x03)<<4
p[4*i+3] = uint32(buf[3*i+2] >> 2)
}
}
}