merge Phase-3b: pulsar onto luxfi/dkg — no-reconstruct vss DKG + malicious CSCP + hardening suite

Reconciled both Phase-3b lines: luxfi/dkg v0.2.0 consumption (vss no-reconstruct DKG
primitive talus_dkg_vss.go, pin-4 KAT, CSCP malicious lift, mechanized pick-2 obstruction)
+ in-house malicious hardening (nonce anti-replay, identifiable-abort sign path, GATE A-C,
nonce-ticket lifecycle, claim hygiene). HONEST RESIDUAL: stock-FIPS-204 signing leg keeps
trusted-dealer DealAlgShares (the {no-reconstruct,dealerless,stock-FIPS}=pick-2 wall);
corona carries the dealerless permissionless guarantee. Dealer-free leg = Mithril RSS (next).
This commit is contained in:
2026-06-27 22:31:11 -07:00
26 changed files with 3789 additions and 66 deletions
+32
View File
@@ -10,6 +10,38 @@
> `docs/proof-axiom-inventory.md` (deep dives). This root file is the one
> the high-assurance gate reads (`claims-vs-reality.sh`).
## §0 FINAL SCOPED CLAIM (canonical, user-approved)
> **Dealerless committee key/nonce gen + CEF/CSCP no-reconstruct signing +
> standard-ML-DSA-verifier output, semi-honest / no-leak today; malicious-CSCP +
> networked-MPC are gated residuals.**
This is the single sentence the project stands behind. Its honest decomposition:
- **No-reconstruct signing (CEF/CSCP)** — production v0.4 path; no process forms
`s1`/seed/`sk`/`c·s2`/`c·t0`/`r0`/full `w`. **interop-tested** byte-equal under
CIRCL + pq-crystals; Lean-machine-checked no-leak aggregate core.
- **Standard-ML-DSA-verifier output** — the committee signature verifies under the
unmodified `mldsa{65,87}.Verify`. **interop-tested** (GATE-1).
- **Dealerless committee key/nonce gen** — the **target track**. Today's runnable
keygen is a **trusted-dealer bootstrap quarantined to `_test.go`**; the
dealerless line is the `feat/v02-pedersen-vss-no-reconstruct` exploration; the
leak-free **NonceMPC** is a residual (the `DealNonceMPCDebug` stand-in exposes
`w` to the **test harness only**, PULSAR-V13-W-LEAK). Permissionless safety is
carried by the dealerless **Corona** leg.
- **semi-honest / no-leak today** — the leak-free property is proven **semi-honest**
in an in-process simulation; a malicious deviation is bounded by `FindHint` +
release-gate to a **liveness fault, never a forge/leak**.
- **malicious-CSCP + networked-MPC are gated residuals** — UNBUILT, fail-closed,
tracked (Residual A). The `harden/malicious-security` branch advances this:
nonce single-use, identifiable-abort plumbing, reachability GATE-2; the **sound
valid-sigma wrong-`z` blame** (needs BDLOP lattice commitments) remains residual.
See `ref/go/pkg/pulsar/VERSIONS.md`.
**NEVER claim:** FIPS/NIST-certified threshold ML-DSA · fully-malicious-secure-proven
· global-1000-validator DKG. (Any artifact asserting these is wrong — fix the
artifact, not this file.)
## Assurance vocabulary (canonical)
| Tag | Meaning |
+5 -3
View File
@@ -4,19 +4,21 @@ go 1.26.4
require (
github.com/cloudflare/circl v1.6.3
github.com/luxfi/accel v1.2.0
github.com/luxfi/dkg v0.2.0
github.com/luxfi/lattice/v7 v7.1.4
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.52.0
)
require (
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.45.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+14 -6
View File
@@ -6,12 +6,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/luxfi/accel v1.2.0 h1:mZcR55GUYseKxeEFCY5EHO51tAhkPDry4LBEn4nnllY=
github.com/luxfi/accel v1.2.0/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/dkg v0.2.0 h1:uy1ZrFKcG9CfplIXUdJxDUL7k5JmnLyiBOUEBDR1dvE=
github.com/luxfi/dkg v0.2.0/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
@@ -22,12 +24,18 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+67
View File
@@ -0,0 +1,67 @@
# Pulsar `pkg/pulsar` — version & track ledger (claim hygiene)
> One file, one truth about what each version of the committee threshold
> ML-DSA path IS and is NOT. Read alongside the repo-root `PROOF-CLAIMS.md`
> (the gate-read assurance vocabulary) and `BLOCKERS.md` (open findings).
## Signing-path versions
| Version | Status | What it is | Why it is gone / current |
|---|---|---|---|
| **v0.3 — algebraic broadcast** | **REMOVED** | The `AlgebraicAggregate` path broadcast per-party `c·s2` / `c·t0` residual shares and reconstructed the leaking `MakeHint` residual at the aggregator. | Closed as **PULSAR-V13-HINT-LEAK** — broadcasting those shares leaks the long-term key. Ripped out (commit `b185533`); **no backward-compatible re-entry**. |
| **v0.4 — BCC/CSCP no-reconstruct** | **CURRENT (production sign path)** | Boundary-Cleared Carry-elimination (BCC/CEF) + Comparison-Secure-Comparison-Protocol (CSCP). Each validator holds exactly one poly-vector Shamir share of `s1`; signing aggregates only masked `z`-partials; the hint is recovered from the **public** `w' = A·z − c·t1·2^d` and `w1`. No process forms `s1`, the seed, `sk`, `c·s2`, `c·t0`, `r0`, or full `w`. Output verifies under unmodified FIPS 204 ML-DSA (`mldsa{65,87}.Verify`). | Base `main` @ `v0.4.0` (commit `b652893`). The sole production path. |
| **v0.4.x — `harden/malicious-security`** | **THIS BRANCH (in review, NOT merged)** | Hardens v0.4 from semi-honest/no-leak toward **malicious** security: nonce single-use / anti-replay ledger (closes the nonce-reuse key-recovery vector), identifiable-abort plumbing (duplicate-PartyID rejection, invalid-proof blame attribution, per-party commitment binding), GATE-2 hardened to call-graph reachability, CI invariant against `legacy_trusted_dealer` in the prod build. | Branch off `origin/main`; **a RED agent re-verifies before any merge.** See "Malicious-hardening status" below for implemented-vs-residual. |
## Keygen / nonce-gen track (separate from the sign path)
The no-reconstruct property above is a **SIGN-time** property. Key and nonce
*generation* are a distinct axis with its own honest scope:
| Track | Status | Notes |
|---|---|---|
| **Trusted-dealer bootstrap** (`DealAlgShares`) | **TEST/BOOTSTRAP ONLY — quarantined** | Expands the seed once and Shamir-shares `s1`, then wipes. Confined to `bootstrap_dealer_test.go` (a `_test.go` file → **uncompilable into any production binary**). It seeds the no-reconstruct SIGNING tests; it is **not** a production keygen. |
| **Legacy GF(q) seed-share committee** (`large_*.go`, `largeshamir.go`) | **QUARANTINED behind `//go:build legacy_trusted_dealer`** | `LargeCombine` reconstructs the master seed at sign time (the H-1 footgun). **Absent from the default build.** Item-4 CI invariant asserts the production build never ships this tag. |
| **Pedersen-VSS dealerless exploration** | **SEPARATE BRANCH** `feat/v02-pedersen-vss-no-reconstruct` | The dealerless-keygen research track. Not on `main`; not part of the v0.4 production claim. |
| **Dealerless byte-FIPS-204 KEY DKG** | **PROVEN UNREACHABLE (parameter obstruction)** | `naive_additive_seta_obstruction.go` → `ErrDealerlessByteFIPSUnreachable`: a dealerless sum of `S_η` shares has `‖·‖∞ ≤ N·η > η`, breaking BCC and FIPS-204 byte equality. Permissionless safety is carried by the dealerless **Corona** leg of the Quasar AND-mode cert, not by this keygen. |
| **NonceMPC (leak-free distributed nonce)** | **STAND-IN today** (`DealNonceMPCDebug`) | The production sign path consumes a `NonceCert` (only `w1` public). The stand-in dealer-models the nonce and exposes `DebugW` to the **test harness only** (never on the wire) — the **PULSAR-V13-W-LEAK** residual. The leak-free distributed nonce (HighBits-over-shares MPC / exact-ℓ∞ boundary ZK) is fail-closed behind the same wall as `rangeproof.go`. |
## Malicious-hardening status (this branch — honest implemented-vs-residual)
**Implemented, with a passing gate:**
- **Nonce single-use / anti-replay** (`nonce_ledger.go`) — per-signer ledger keyed on
the nonce commitment `(committeeID ‖ w1)`; `Round2` reserves before emitting the
secret `z`-partial; reuse (even relabeled under a new `nonceID`) is rejected
fail-closed. GATE A (`TestRED_NonceReuse_RecoversS1`) demonstrates the
key-recovery math is real **and** that the guard blocks obtaining the second
partial.
- **Duplicate-PartyID rejection** + **invalid/malformed-proof blame attribution** in
`AggregateBCC` — a deviating partial is attributed to its `PartyID` (structured
`AbortEvidence`), not silently dropped. GATE B covers these.
- **Per-party DKG/nonce commitment binding** — `AggregateBCC` populates and verifies
the commitments the sigma proof is bound to (non-transferability).
- **GATE-2 → reachability** (`gate2_reachability_test.go`) — stdlib AST call-graph;
the reconstruct primitives (`KeyFromSeed`, `deriveKeyMaterial`, `bccSign`,
`shamirReconstruct*`, `LargeCombine`) must be **unreachable** from the committee
sign entrypoints. GATE C catches the `deriveKeyMaterial`+`bccSign` bypass.
- **CI invariant** — the production (default-tag) build never carries
`legacy_trusted_dealer`.
**Designed + scaffolded + FLAGGED (gated residuals — NOT done):**
- **Sound valid-sigma wrong-`z` blame** needs a hiding lattice commitment
(BDLOP/Ajtai) to the dealt share + an extended linear-sigma proof of opening.
A homomorphic `A·s1_i` / `A·y_i` commitment is **rejected** because it reopens
PULSAR-V13-W-LEAK / HINT-LEAK. Until then, a valid-sigma wrong-`z` is a
**liveness fault (unattributed abort), never a forgery or leak**.
- **Malicious-secure CSCP**, **networked MPC transcript**, **authenticated /
signed protocol channels**, **leak-free NonceMPC**, **persistent nonce ledger**
(crash-restart safety), **last-mover-bias controls**. See `BLOCKERS.md`
Residual A and the branch report.
## The honest one-line scope (canonical)
> Dealerless committee key/nonce gen **(target track; trusted-dealer bootstrap is
> test-only, leak-free NonceMPC is a residual)** + CEF/CSCP **no-reconstruct
> signing** + **standard-ML-DSA-verifier output**, **semi-honest / no-leak
> today**; **malicious-CSCP + networked-MPC are gated residuals**.
**NEVER claimed:** FIPS/NIST-certified threshold ML-DSA · fully-malicious-secure-proven · global-1000-validator DKG.
+3 -1
View File
@@ -202,6 +202,8 @@ func ValidateAbortEvidence(e *AbortEvidence) error {
return validateMACFailureEvidence(e.Evidence)
case ComplaintRangeFailure:
return validateRangeFailureEvidence(e.Evidence)
case ComplaintBadPartial:
return validateBadPartialEvidence(e.Evidence)
default:
return ErrComplaintKind
}
@@ -355,7 +357,7 @@ func VerifyAbortEvidenceForm(e *AbortEvidence) error {
}
switch e.Kind {
case ComplaintEquivocation, ComplaintBadDelivery,
ComplaintMACFailure, ComplaintRangeFailure:
ComplaintMACFailure, ComplaintRangeFailure, ComplaintBadPartial:
// known kind
default:
return ErrComplaintKind
+127
View File
@@ -0,0 +1,127 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// blame.go — SIGN-path identifiable abort: attribute a deviating partial to
// its PartyID instead of silently dropping it (RED MEDIUM, identifiable-abort).
//
// AggregateBCC previously `continue`d past a malformed, duplicate, or
// invalid-proof partial — the round could die with no record of WHO caused it
// (unattributable DoS), and a duplicate PartyID could even satisfy the
// threshold with fewer than t DISTINCT signers (CanonicalSignerSet does not
// dedupe). The blame layer below makes every detected sign-time deviation
// produce an attributed PartialBlame; the signer (which holds the quorum →
// NodeID mapping) elevates it to a signed-complaint AbortEvidence for the
// slashing layer.
//
// SCOPE. This attributes deviations the verifier can DETECT today: malformed
// encoding, duplicate / out-of-range PartyID, session/nonce mismatch, and a
// FAILED sigma proof. It does NOT yet attribute a VALID-sigma WRONG-z partial —
// that requires the BDLOP dealt-share binding (share_commit.go, Residual A);
// until then a valid-sigma wrong-z is a liveness fault (never a forgery/leak),
// flagged by ErrIdentifiableAbortResidual.
import "encoding/binary"
// BlameReason enumerates the detectable sign-time deviations.
type BlameReason uint8
const (
// BlameMalformed: the partial's ZShare/Proof did not parse for the mode.
BlameMalformed BlameReason = iota + 1
// BlameDuplicatePartyID: a second partial carrying an already-seen PartyID.
BlameDuplicatePartyID
// BlameUnknownParty: PartyID outside the signing quorum's eval-point range.
BlameUnknownParty
// BlameSessionMismatch: SessionID/NonceID did not match the round.
BlameSessionMismatch
// BlameProofInvalid: the partial-z sigma proof failed verification.
BlameProofInvalid
)
// String returns the canonical reason name.
func (r BlameReason) String() string {
switch r {
case BlameMalformed:
return "malformed-partial"
case BlameDuplicatePartyID:
return "duplicate-party-id"
case BlameUnknownParty:
return "unknown-party"
case BlameSessionMismatch:
return "session-mismatch"
case BlameProofInvalid:
return "proof-invalid"
default:
return "unknown"
}
}
// PartialBlame attributes one detected deviation to a PartyID. AggregateBCC's
// no-NodeID-context layer reports these; the signer maps PartyID → NodeID to
// build a signed AbortEvidence.
type PartialBlame struct {
PartyID uint32
Reason BlameReason
// SessionID / NonceID identify the round the deviation occurred in.
SessionID [32]byte
NonceID [32]byte
}
// badPartialFieldCount: (partyid_reason, sessionid, nonceid) — 3 TLV fields.
const badPartialFieldCount = 3
// badPartialField0MinLen: 4-byte PartyID (BE) + 1-byte reason.
const badPartialField0MinLen = 5
// marshalBadPartialEvidence encodes a PartialBlame into the AbortEvidence TLV
// blob: field0 = PartyID(BE u32)‖reason(u8); field1 = sessionID; field2 = nonceID.
func marshalBadPartialEvidence(b PartialBlame) []byte {
field0 := make([]byte, 5)
binary.BigEndian.PutUint32(field0[:4], b.PartyID)
field0[4] = byte(b.Reason)
out := make([]byte, 0, 4+5+4+32+4+32)
appendField := func(f []byte) {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(f)))
out = append(out, l[:]...)
out = append(out, f...)
}
appendField(field0)
appendField(b.SessionID[:])
appendField(b.NonceID[:])
return out
}
// validateBadPartialEvidence: 3 fields, field0 ≥ 5 bytes, sessionID/nonceID 32.
func validateBadPartialEvidence(blob []byte) error {
fields, err := parseEvidenceFields(blob)
if err != nil {
return err
}
if len(fields) != badPartialFieldCount {
return ErrEvidenceFieldCount
}
if len(fields[0]) < badPartialField0MinLen {
return ErrEvidenceFieldLen
}
if len(fields[1]) < 32 || len(fields[2]) < 32 {
return ErrEvidenceFieldLen
}
return nil
}
// BadPartialEvidence builds a signed-complaint-ready AbortEvidence for a sign
// deviation. accuser is the aggregator/observer; accused is the deviating
// party's NodeID. The Signature field is left empty for the caller's identity
// layer to fill (TranscriptForComplaint gives the to-be-signed bytes).
func BadPartialEvidence(accuser, accused NodeID, epoch uint64, b PartialBlame) AbortEvidence {
return AbortEvidence{
Kind: ComplaintBadPartial,
Accuser: accuser,
Accused: accused,
Epoch: epoch,
Evidence: marshalBadPartialEvidence(b),
}
}
+339
View File
@@ -0,0 +1,339 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// blame_gate_test.go — GATE B (RED MEDIUM: identifiable abort). A deviating
// partial must be ATTRIBUTED to its PartyID (blame), never an anonymous DoS,
// and duplicate PartyIDs must be rejected.
//
// Implemented + gated here (sound, complete):
// - duplicate PartyID -> BlameDuplicatePartyID; duplicates can NOT inflate a
// sub-threshold set to threshold.
// - invalid-sigma partial -> BlameProofInvalid attributed to the exact PartyID
// and elevated to a signed-complaint AbortEvidence.
// - malformed ZShare -> BlameMalformed (no panic).
//
// FLAGGED RESIDUAL (share_commit.go, BDLOP): a VALID-sigma WRONG-z partial is
// NOT yet attributed; the test asserts the honest current assurance — it is a
// liveness fault that produces NO valid FIPS signature (never a forgery/leak).
import (
"crypto/rand"
"errors"
"testing"
)
// bccRound drives a fixture's quorum through SetNonceShare+Round1+Round2 and
// returns the honest partials, the aggregator node, and its bound round1.
func bccRound(t *testing.T, f *bccFixture, msg []byte, sid [32]byte) (partials []Partial, agg *DistributedBCCSigner, aggR1 SignRound1, quorum []NodeID, evalPoints []uint32) {
t.Helper()
quorum, evalPoints, qshares := f.quorum(f.threshold)
var nonceID [32]byte
nonceID[0] = 0x7e
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, f.threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("nonce deal: %v", err)
}
nodes := make([]*DistributedBCCSigner, f.threshold)
partials = make([]Partial, 0, f.threshold)
for i := 0; i < f.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)
}
// Wire the identity layer so partials are authenticated and the aggregator
// can attribute blame soundly (RED MEDIUM). Producer = this node's key;
// verifier = the shared per-validator set.
nd.SetIdentity(f.idset.signerFor(quorum[i]), f.idset)
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
agg = nd
}
p, err := nd.Round2(r1, PartialInput{})
if err != nil {
t.Fatalf("round2 %d: %v", i, err)
}
partials = append(partials, p)
}
if agg == nil {
t.Fatal("no aggregator")
}
return partials, agg, aggR1, quorum, evalPoints
}
func blameFor(blames []PartialBlame, party uint32) (PartialBlame, bool) {
for _, b := range blames {
if b.PartyID == party {
return b, true
}
}
return PartialBlame{}, false
}
// GATE B.1 — duplicate PartyID is attributed and cannot inflate the threshold.
func TestGATE_B_DuplicatePartyID(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate-b-duplicate"))
partials, agg, aggR1, _, _ := bccRound(t, f, []byte("dup test"), sid)
// (a) honest + one DUPLICATE of party 1 (a non-aggregator, so the
// aggregator's evidence is not self-accusation): round still succeeds
// (the dup is dropped) but the duplicate is BLAMED + attributed.
withDup := append(append([]Partial(nil), partials...), partials[1])
_, cert, ev, err := agg.FinalizeWithBlame(aggR1, withDup)
if err != nil {
t.Fatalf("round with one extra duplicate should still finalize: %v", err)
}
if !fipsVerify(t, f.setup, []byte("dup test"), &cert.Signature) {
t.Fatalf("signature with a dropped duplicate failed FIPS verify")
}
foundDup := false
for i := range ev {
e := ev[i]
if e.Kind == ComplaintBadPartial && e.Accused == f.shares[1].NodeID {
if vErr := ValidateAbortEvidence(&e); vErr != nil {
t.Fatalf("duplicate AbortEvidence malformed: %v", vErr)
}
foundDup = true
}
}
if !foundDup {
t.Fatalf("GATE B FAILED: duplicate PartyID 1 was not attributed (anonymous DoS)")
}
// (b) duplicates of ONE party can NOT reach threshold: t copies of party 0
// => 1 distinct signer => ErrInsufficientSigners + every extra blamed.
onlyDups := []Partial{partials[0], partials[0], partials[0]}
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup,
[]uint32{f.shares[0].EvalPoint, f.shares[1].EvalPoint, f.shares[2].EvalPoint},
[]NodeID{f.shares[0].NodeID, f.shares[1].NodeID, f.shares[2].NodeID}, 0, f.idset,
nil, []byte("dup test"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, onlyDups)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("GATE B FAILED: %d duplicates of one party produced err=%v, want ErrInsufficientSigners (duplicates inflated the threshold!)", len(onlyDups), err)
}
if b, ok := blameFor(blames, 0); !ok || b.Reason != BlameDuplicatePartyID {
t.Fatalf("GATE B FAILED: duplicate copies not blamed BlameDuplicatePartyID (got %+v)", blames)
}
t.Logf("GATE B.1 PASS: duplicate PartyID attributed (BlameDuplicatePartyID) and cannot inflate a sub-threshold set")
}
// GATE B.2 — an invalid-sigma partial is attributed to the exact PartyID and
// elevated to a signed-complaint AbortEvidence (not an anonymous round death).
func TestGATE_B_InvalidProofAttributed(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate-b-invalid-proof"))
partials, agg, aggR1, quorum, _ := bccRound(t, f, []byte("invalid proof test"), sid)
const badParty = 1
// Tamper party 1's z WITHOUT updating its proof => sigma equation breaks.
// The malicious validator SIGNS the bad partial (authenticated misbehavior),
// so it passes origin-auth and is attributed BlameProofInvalid — vs a
// transport-tampered partial (stale sig) which would be dropped unattributed.
tampered := append([]Partial(nil), partials...)
z := unpackPolyVec(tampered[badParty].ZShare, mldsaL(f.params.Mode))
z[0][0] = (z[0][0] + 1) % mldsaQ
tampered[badParty].ZShare = packPolyVec(z)
f.idset.signPartial(&tampered[badParty], quorum[badParty], 0)
_, _, ev, err := agg.FinalizeWithBlame(aggR1, tampered)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("dropping the invalid partial should leave t-1 valid => ErrInsufficientSigners, got %v", err)
}
var got *AbortEvidence
for i := range ev {
if ev[i].Kind == ComplaintBadPartial && ev[i].Accused == quorum[badParty] {
got = &ev[i]
}
}
if got == nil {
t.Fatalf("GATE B FAILED: invalid-proof partial from party %d not attributed", badParty)
}
aggID := agg.NodeID()
if got.Accuser != aggID {
t.Fatalf("blame accuser %x, want aggregator %x", got.Accuser[:4], aggID[:4])
}
if vErr := ValidateAbortEvidence(got); vErr != nil {
t.Fatalf("attributed AbortEvidence malformed: %v", vErr)
}
// Confirm the raw reason is proof-invalid.
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, []byte("invalid proof test"), agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, tampered)
if b, ok := blameFor(blames, badParty); !ok || b.Reason != BlameProofInvalid {
t.Fatalf("GATE B FAILED: party %d not blamed BlameProofInvalid (got %+v)", badParty, blames)
}
t.Logf("GATE B.2 PASS: invalid-sigma partial attributed to PartyID %d (NodeID %x) as a signed AbortEvidence", badParty, quorum[badParty][:4])
}
// GATE B.3 — a malformed (truncated) ZShare is rejected WITHOUT panicking and
// attributed BlameMalformed.
func TestGATE_B_MalformedShareNoPanic(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate-b-malformed"))
partials, agg, _, quorum, _ := bccRound(t, f, []byte("malformed test"), sid)
const badParty = 2
mangled := append([]Partial(nil), partials...)
mangled[badParty].ZShare = []byte{0x00, 0x01} // truncated — would panic unpackPolyVec
// Malicious validator SIGNS the malformed partial so it passes origin-auth
// and is attributed BlameMalformed (not dropped as transport corruption).
f.idset.signPartial(&mangled[badParty], quorum[badParty], 0)
_, _, blames, err := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, []byte("malformed test"), agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, mangled)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("malformed share should drop to t-1 => ErrInsufficientSigners, got %v", err)
}
if b, ok := blameFor(blames, badParty); !ok || b.Reason != BlameMalformed {
t.Fatalf("GATE B FAILED: malformed share not blamed BlameMalformed (got %+v)", blames)
}
t.Logf("GATE B.3 PASS: malformed ZShare rejected without panic and attributed BlameMalformed")
}
// GATE B.4 — HONEST RESIDUAL: a VALID-sigma WRONG-z partial is bounded to a
// liveness fault (produces NO valid FIPS signature — never a forgery/leak), but
// is NOT yet individually attributed. This is the BDLOP share-commitment
// residual (share_commit.go / ErrIdentifiableAbortResidual).
func TestGATE_B_ValidSigmaWrongZ_ResidualNoForgery(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("gate-b-wrongz-residual"))
msg := []byte("valid-sigma wrong-z residual")
partials, agg, aggR1, quorum, evalPoints := bccRound(t, f, msg, sid)
const badParty = 1
_, L, _ := modeShape(f.params.Mode)
// Malicious party proves a VALID statement for the WRONG z' = φ(0,0) = 0.
zeros := make(polyVec, L)
lambda := LagrangeAtZeroQ(evalPoints[badParty], evalPoints)
st := &PartialStatement{
Mode: f.params.Mode, Lambda: lambda, C: agg.c, Z: zeros,
SessionID: sid, NonceID: partials[badParty].NonceID, PartyID: badParty,
}
proof, err := ProvePartial(st, &PartialWitness{Y: zeros, S1: zeros}, rand.Reader)
if err != nil {
t.Fatalf("ProvePartial(wrong-z): %v", err)
}
// Sanity: the wrong-z proof is genuinely VALID (so the sigma cannot catch it).
if err := VerifyPartialProof(st, proof); err != nil {
t.Fatalf("wrong-z proof unexpectedly invalid: %v", err)
}
wrong := append([]Partial(nil), partials...)
wrong[badParty] = Partial{PartyID: badParty, NonceID: partials[badParty].NonceID, SessionID: sid, ZShare: packPolyVec(zeros), Proof: proof}
// The malicious validator SIGNS its valid-sigma wrong-z partial (authenticated),
// so origin-auth passes and the partial reaches the sigma check — which it
// passes (valid statement for a wrong z). This is the residual: not attributed.
f.idset.signPartial(&wrong[badParty], quorum[badParty], 0)
agg2, cert, ev, ferr := agg.FinalizeWithBlame(aggR1, wrong)
_ = agg2
// (1) NO FORGERY: either the round errors, or it returns a signature that
// FAILS the standard FIPS verifier.
if ferr == nil {
if fipsVerify(t, f.setup, msg, &cert.Signature) {
t.Fatalf("GATE B FAILED (FORGERY!): valid-sigma wrong-z produced a FIPS-valid signature")
}
}
// (2) RESIDUAL: the wrong-z party is NOT attributed (the sigma passed; only
// BDLOP dealt-share binding could catch it). Document, don't fake.
if _, attributed := func() (PartialBlame, bool) {
for _, e := range ev {
_ = e
}
_, _, blames, _ := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, f.idset,
nil, msg, agg.c, &agg.cHat, agg.w1, sid, partials[badParty].NonceID, threshold, wrong)
return blameFor(blames, badParty)
}(); attributed {
t.Fatalf("unexpected: valid-sigma wrong-z was attributed — update the residual claim (now SOUND)")
}
if ErrIdentifiableAbortResidual == nil {
t.Fatal("residual marker missing")
}
t.Logf("GATE B.4 PASS (honest residual): valid-sigma wrong-z yields NO FIPS-valid signature (no forgery/leak) but is NOT yet attributed — BDLOP share-commitment residual (ErrIdentifiableAbortResidual)")
}
// TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim is RED's MEDIUM PoCs
// (UnauthenticatedPartyID_BlameShift / MalformedFrontRun_ExcludesAndBlamesHonest)
// FLIPPED. An attacker front-runs an honest victim's slot with a forged,
// malformed partial STAMPED with the victim's PartyID. Pre-fix this got the
// victim BLAMED (BlameMalformed/Duplicate) and EXCLUDED off a raw unauthenticated
// PartyID. Post-fix:
// - origin-auth drops the forgery (the attacker cannot sign as the victim), so
// the victim's real partial stands -> NOT excluded, the round still signs;
// - blame is gated on signature validity, so the victim is NEVER blamed.
func TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("red-medium-frame"))
msg := []byte("attacker tries to frame an honest victim")
partials, agg, aggR1, quorum, _ := bccRound(t, f, msg, sid)
const victim = 1 // a non-aggregator slot
// The attacker is NOT in the quorum; it holds its OWN identity key, never the
// victim's. So it cannot produce the victim's signature.
var attacker NodeID
attacker[0] = 0xEE
attackerSet := newTestIdentitySet(attacker)
// (1) Forged partial: victim's PartyID, malformed content, signed with the
// ATTACKER's key (author = attacker). Front-run: placed BEFORE the real one.
forged := Partial{PartyID: victim, SessionID: sid, NonceID: partials[victim].NonceID, ZShare: []byte{0xde, 0xad}}
attackerSet.signPartial(&forged, attacker, 0)
input := []Partial{partials[0], forged, partials[1], partials[2]}
_, cert, ev, err := agg.FinalizeWithBlame(aggR1, input)
if err != nil {
t.Fatalf("RED MEDIUM: honest victim EXCLUDED — round failed with the victim's real partial present: %v", err)
}
if !fipsVerify(t, f.setup, msg, &cert.Signature) {
t.Fatalf("RED MEDIUM: round did not produce a FIPS-valid signature with the victim included")
}
if blamedNode(ev, quorum[victim]) {
t.Fatalf("RED MEDIUM: honest victim %x was BLAMED off an unauthenticated forged PartyID", quorum[victim][:4])
}
// (2) Impersonation variant: author CLAIMS to be the victim but signs with the
// wrong key (attacker lacks the victim's key) -> signature invalid -> dropped.
forged2 := Partial{PartyID: victim, SessionID: sid, NonceID: partials[victim].NonceID, ZShare: []byte{0xba, 0xad}}
forged2.Author = quorum[victim]
forged2.AuthSig = idMAC(attackerSet.keys[attacker], partialAuthTBS(forged2, 0)) // wrong key
input2 := []Partial{partials[0], forged2, partials[1], partials[2]}
_, cert2, ev2, err2 := agg.FinalizeWithBlame(aggR1, input2)
if err2 != nil || !fipsVerify(t, f.setup, msg, &cert2.Signature) {
t.Fatalf("RED MEDIUM (impersonation): victim excluded (err=%v)", err2)
}
if blamedNode(ev2, quorum[victim]) {
t.Fatalf("RED MEDIUM (impersonation): honest victim blamed off a forged victim-authored partial")
}
// (3) CONTRAST — the verifier is load-bearing. With NO identity verifier the
// same front-run EXCLUDES the victim (the forged malformed partial occupies
// the slot; the real one is dropped as a duplicate) -> t-1 valid ->
// ErrInsufficientSigners. No blame is emitted either way (blame is gated on
// auth), but exclusion-resistance REQUIRES the verifier.
_, _, blamesNoAuth, errNoAuth := AggregateBCCWithBlame(f.params, f.setup, agg.evalPoints, quorum, 0, nil,
nil, msg, agg.c, &agg.cHat, agg.w1, sid, partials[victim].NonceID, threshold, input)
if !errors.Is(errNoAuth, ErrInsufficientSigners) {
t.Fatalf("contrast: expected the un-authenticated path to drop to t-1 (front-run exclusion), got %v", errNoAuth)
}
if len(blamesNoAuth) != 0 {
t.Fatalf("contrast: blame MUST NOT be emitted off raw unauthenticated PartyIDs, got %+v", blamesNoAuth)
}
t.Logf("RED MEDIUM FLIPPED: origin-auth drops the forged victim-slot partial (attacker cannot sign as the victim) -> victim NOT excluded, round still signs, victim NEVER blamed; the un-authenticated path neither blames nor (alone) resists exclusion — the verifier is load-bearing")
}
@@ -0,0 +1,139 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// ci_build_invariant_test.go — item 4: the PRODUCTION build must never ship the
// reconstruct-at-sign quarantine. RED LOW: the structural GATE-2 inspects
// build.Default (no tags), so CI must ALSO assert the production binary is never
// compiled with `-tags legacy_trusted_dealer` (which would re-link LargeCombine,
// the H-1 reconstruct footgun) and that the trusted-dealer keygen is test-only.
import (
"go/build"
"os"
"path/filepath"
"strings"
"testing"
)
const legacyTrustedDealerTag = "legacy_trusted_dealer"
func defaultBuildGoFiles(t *testing.T) (dir string, files map[string]bool) {
t.Helper()
pkg, err := build.Default.ImportDir(".", 0)
if err != nil {
t.Fatalf("enumerate default-build package: %v", err)
}
files = make(map[string]bool, len(pkg.GoFiles))
for _, f := range pkg.GoFiles {
files[f] = true
}
return pkg.Dir, files
}
func taggedBuildGoFiles(t *testing.T, tag string) map[string]bool {
t.Helper()
ctx := build.Default
ctx.BuildTags = append(append([]string{}, ctx.BuildTags...), tag)
pkg, err := ctx.ImportDir(".", 0)
if err != nil {
t.Fatalf("enumerate %s-tagged package: %v", tag, err)
}
out := make(map[string]bool, len(pkg.GoFiles))
for _, f := range pkg.GoFiles {
out[f] = true
}
return out
}
// goBuildLine returns the `//go:build ...` constraint line of a file (or "").
func goBuildLine(t *testing.T, path string) string {
t.Helper()
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, line := range strings.Split(string(src), "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, "//go:build ") {
return l
}
// build constraints must precede the package clause.
if strings.HasPrefix(l, "package ") {
break
}
}
return ""
}
// TestCI_ProductionBuildExcludesLegacyTrustedDealer asserts the default build
// excludes every legacy_trusted_dealer file, that the tag actually gates real
// reconstruct files, and that the reconstruct-at-sign combiner is never in prod.
func TestCI_ProductionBuildExcludesLegacyTrustedDealer(t *testing.T) {
dir, defaultFiles := defaultBuildGoFiles(t)
taggedFiles := taggedBuildGoFiles(t, legacyTrustedDealerTag)
// Files the tag ADDS (present under -tags legacy_trusted_dealer, absent by
// default): these are the quarantined reconstruct files.
var legacyOnly []string
for f := range taggedFiles {
if !defaultFiles[f] {
legacyOnly = append(legacyOnly, f)
}
}
if len(legacyOnly) == 0 {
t.Fatalf("CI invariant MISCONFIGURED: the %q tag gates NO files — quarantine mechanism is inert", legacyTrustedDealerTag)
}
// Every tag-gated file must (a) be absent from the default/production build
// and (b) carry the legacy_trusted_dealer constraint (intentional gating).
for _, f := range legacyOnly {
if defaultFiles[f] {
t.Fatalf("CI FAILED: %s is in BOTH the default and tagged build", f)
}
line := goBuildLine(t, filepath.Join(dir, f))
if !strings.Contains(line, legacyTrustedDealerTag) {
t.Fatalf("CI FAILED: %s is tag-gated but its build line %q does not mention %q", f, line, legacyTrustedDealerTag)
}
}
// The reconstruct-at-sign combiner MUST be quarantined, never in prod.
if defaultFiles["large_threshold.go"] {
t.Fatalf("CI FAILED: large_threshold.go (LargeCombine, reconstruct-at-sign) is in the PRODUCTION build")
}
if !taggedFiles["large_threshold.go"] {
t.Fatalf("CI MISCONFIGURED: large_threshold.go not found even under the legacy tag")
}
// No default-build file may itself require the legacy tag (belt-and-suspenders
// against an accidental positive constraint that only builds under the tag).
for f := range defaultFiles {
if line := goBuildLine(t, filepath.Join(dir, f)); strings.Contains(line, legacyTrustedDealerTag) {
t.Fatalf("CI FAILED: default-build file %s carries a %q constraint", f, legacyTrustedDealerTag)
}
}
t.Logf("CI PASS: production build excludes %d legacy_trusted_dealer file(s) %v; LargeCombine quarantined", len(legacyOnly), legacyOnly)
}
// TestCI_TrustedDealerKeygenIsTestOnly asserts the trusted-dealer s1-share
// keygen (DealAlgShares) is defined ONLY in _test.go (uncompilable into any
// production binary) — the quarantine the no-reconstruct claim rests on.
func TestCI_TrustedDealerKeygenIsTestOnly(t *testing.T) {
dir, defaultFiles := defaultBuildGoFiles(t)
for f := range defaultFiles {
src, err := os.ReadFile(filepath.Join(dir, f))
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
if strings.Contains(string(src), "func DealAlgShares(") {
t.Fatalf("CI FAILED: DealAlgShares (trusted-dealer keygen) defined in PRODUCTION file %s — must be _test.go only", f)
}
}
// Sanity: it IS defined somewhere in the test build (the bootstrap file).
if _, err := os.Stat(filepath.Join(dir, "bootstrap_dealer_test.go")); err != nil {
t.Fatalf("bootstrap_dealer_test.go missing — trusted-dealer keygen quarantine file gone: %v", err)
}
t.Logf("CI PASS: DealAlgShares is test-only (bootstrap_dealer_test.go); never in the production build")
}
+13
View File
@@ -41,6 +41,17 @@ type NonceCert struct {
}
// Partial is a proof-carrying z-share. No CS2/CT0/r0/LowBits/hint fields.
//
// AUTHENTICATED ORIGIN (RED MEDIUM). PartyID is an UNauthenticated routing
// field; on its own an attacker can stamp a victim's slot on a forged partial to
// get the victim blamed/excluded. Author + AuthSig bind the partial to the
// validator's long-term identity key: AuthSig is that key's signature over the
// canonical partial-auth TBS (partialAuthTBS — author ‖ slot ‖ H(PartyID,
// session, nonce, ZShare, Proof)). The aggregator accepts a partial for a slot
// ONLY if AuthSig verifies AND Author == quorum[PartyID]; blame is emitted only
// for content faults on such authenticated partials, never off a raw PartyID.
// (Producer: Round2 when an IdentitySigner is set. Verifier: AggregateBCCWith
// Blame when an AbortSignatureVerifier is supplied.)
type Partial struct {
PartyID uint32
NonceID [32]byte
@@ -48,6 +59,8 @@ type Partial struct {
ZShare []byte // packed z_i
Proof []byte // partial-correctness proof (PROTOTYPE)
MAC []byte
Author NodeID // authenticated producer identity (== quorum[PartyID])
AuthSig []byte // identity-key signature over partialAuthTBS (origin auth)
}
// ConsensusCert is the two-certificate consensus artifact: the ML-DSA
+357 -46
View File
@@ -126,6 +126,15 @@ type AlgSetup struct {
tr [64]byte
a []polyVec // K×L public matrix, NTT domain (== circl pk.A)
t1 polyVec // K, high bits in [0, 2^10)
// s1ShareCommit holds the per-party (keyed by GF(q) eval point) BDLOP
// commitment to the dealt s1-share, for identifiable-abort binding of the
// partial-z proof. nil today (the leak-free authoritative commitments are
// the BDLOP residual — see share_commit.go); when populated by a DKG that
// publishes Com_s_i, shareCommitmentsFor returns these and the extended
// sigma enforces the dealt-share opening (flips valid-sigma wrong-z into an
// attributable blame). Public, hiding (MLWE) — carries no secret.
s1ShareCommit map[uint32][]byte
}
// DealAlgShares (the Part-1 TRUSTED-DEALER s1-share keygen) has been RIPPED
@@ -352,14 +361,39 @@ type DistributedBCCSigner struct {
ctx, msg []byte
rng io.Reader
// committeeID binds nonce reservations to THIS (group key, sorted quorum).
committeeID [32]byte
// shareID is the committee-independent identity of this validator's key-share
// (shareIdentityKey). The nonce single-use ledger is resolved from the
// process-global registry by this key (shareLedgerFor), so EVERY signer
// instance over the same share shares ONE ledger — cross-instance nonce reuse
// is refused by DEFAULT, with no per-instance empty-ledger fail-open path.
shareID [32]byte
// optional domain-binding context recorded with each reservation (audit /
// domain separation). Zero by default; set via SetNonceBinding.
epoch uint64
policy [32]byte
messageKind uint32
// identity layer (RED MEDIUM, authenticated PartyID). idSigner signs THIS
// node's z-partials with its long-term identity key (producer side); idVerify
// authenticates OTHER nodes' partials when this node aggregates (verifier
// side). Both nil by default: Round2 then emits an unsigned partial and
// FinalizeWithBlame aggregates sigma-checked partials WITHOUT emitting blame
// (no blame is ever produced off an unauthenticated PartyID). Wire both via
// SetIdentity to enable authenticated blame + front-run-exclusion resistance.
idSigner IdentitySigner
idVerify AbortSignatureVerifier
// 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
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
w1Packed []byte // packed HighBits(w) as carried on the cert — nonce material
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.
@@ -415,20 +449,71 @@ func NewDistributedBCCSigner(params *Params, setup *AlgSetup, share *AlgShare, q
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,
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,
committeeID: deriveCommitteeID(pkID(setup.Pub), quorum),
shareID: shareIdentityKey(share),
}, nil
}
// SetNonceLedger installs a PERSISTENT nonce single-use ledger for THIS signer's
// key-share. It is the seam for crash-restart safety (a flagged residual): the
// in-process default is already safe by construction (every signer over a share
// resolves to ONE registry ledger keyed by share identity), so this is only
// needed to upgrade that share's ledger to durable storage. It writes the
// per-share registry slot — FIRST WRITER WINS — so ALL instances of the share
// (default-constructed, no call needed) pick it up; call it once at startup
// before any Round2 for the share. A nil ledger is ignored (the safe default is
// retained — never fail-open). Returns the signer for chaining.
func (d *DistributedBCCSigner) SetNonceLedger(l NonceLedger) *DistributedBCCSigner {
setShareLedger(d.shareID, l)
return d
}
// IdentitySigner is the PRODUCER side of the protocol-message identity layer
// (the verifier side is AbortSignatureVerifier — one identity layer, two faces).
// A validator signs the canonical to-be-signed bytes of a protocol message with
// its long-term identity key (Ed25519 / hybrid PQ / BLS — opaque here). It is
// the same key whose public half a verifier looks up in the validator set.
type IdentitySigner interface {
// SignProtocolMessage returns the identity-key signature over tbs.
SignProtocolMessage(tbs []byte) []byte
}
// SetIdentity wires this node's identity layer (RED MEDIUM, authenticated
// PartyID). signer signs THIS node's z-partials so the aggregator can bind each
// partial to its true producer; verifier authenticates OTHER nodes' partials
// when this node aggregates, so a forged partial stamped with a victim's PartyID
// is dropped (never blamed/excluded). Both must be set for authenticated blame;
// production wires the validator's identity key + the validator-set verifier
// here. Returns the signer for chaining.
func (d *DistributedBCCSigner) SetIdentity(signer IdentitySigner, verifier AbortSignatureVerifier) *DistributedBCCSigner {
d.idSigner = signer
d.idVerify = verifier
return d
}
// SetNonceBinding sets the optional domain-binding context (epoch, policy,
// message-kind) recorded with each nonce reservation for audit / domain
// separation. The single-use guard is independent of this; the binding is the
// auditable "what was this nonce spent on" record. Returns the signer for
// chaining.
func (d *DistributedBCCSigner) SetNonceBinding(epoch uint64, policy [32]byte, messageKind uint32) *DistributedBCCSigner {
d.epoch = epoch
d.policy = policy
d.messageKind = messageKind
return d
}
// Profile reports the Quasar cert profile this signer serves.
func (d *DistributedBCCSigner) Profile() CertProfile { return ProfilePulsar }
@@ -486,6 +571,9 @@ func (d *DistributedBCCSigner) Round1(sessionID, nonceID [32]byte, cert NonceCer
return SignRound1{}, err
}
d.w1 = w1
// Capture the canonical packed nonce commitment as the single-use material
// (keyed on w1, not the nonceID label, so a relabeled nonce cannot bypass).
d.w1Packed = append([]byte(nil), cert.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.
@@ -516,10 +604,46 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
if r1.NonceID != d.nonceID {
return Partial{}, ErrAlgNonceMismatch
}
// NONCE SINGLE-USE GUARD (RED nonce-reuse, HIGH). Resolve THIS share's
// process-shared single-use ledger by share identity (shareLedgerFor) — every
// signer instance over the same share gets the SAME ledger by construction, so
// the guard is enforced even across the per-message signer objects an
// integrator necessarily creates (no per-instance empty-ledger fail-open).
// Mint the nonce ticket and reserve its MATERIAL key BEFORE the secret is
// touched. A second use of the same joint nonce — even relabeled under a fresh
// nonceID or a different message — is refused FAIL-CLOSED (dedup keys on the
// nonce commitment, not the ticket id), so the attacker can never obtain a
// second z-partial on the same nonce and the (c_A − c_B)·s1 key-recovery
// system can never be assembled. The reservation is never rolled back: if
// proof generation below fails, the nonce stays burned (an aborted attempt
// leaves no reusable nonce state). ReserveNonceTicket is the one and only
// nonce-consume path.
ledger := shareLedgerFor(d.shareID)
if ledger == nil {
return Partial{}, ErrNonceLedgerNil
}
ticket := NewNonceTicket(d.committeeID, d.w1Packed, NonceBinding{
Epoch: d.epoch,
CommitteeID: d.committeeID,
Policy: d.policy,
MessageKind: d.messageKind,
Digest: nonceBindingDigest(d.params.Mode, d.setup.tr, d.ctx, d.msg),
})
if err := ReserveNonceTicket(ledger, ticket); err != nil {
return Partial{}, err
}
// 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)
// Bind the AUTHORITATIVE per-party DKG/nonce commitments (not caller-
// supplied bytes) so the prover and the verifier (AggregateBCC) bind the
// SAME values — they both call shareCommitmentsFor, the single source of
// truth. nil today (BDLOP residual, share_commit.go); the in.* commitment
// fields are no longer trusted for the binding.
dkgCommit, nonceCommit := shareCommitmentsFor(d.setup, d.nonceID, d.share.EvalPoint)
st := &PartialStatement{
Mode: d.params.Mode,
Lambda: d.lambda,
@@ -528,20 +652,31 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
SessionID: d.sid,
NonceID: d.nonceID,
PartyID: d.partyIdx,
DKGCommitment: in.DKGCommitment,
NonceCommitment: in.NonceCommitment,
DKGCommitment: dkgCommit,
NonceCommitment: nonceCommit,
}
proof, err := ProvePartial(st, &PartialWitness{Y: d.yShare, S1: d.share.S1Share}, d.rng)
if err != nil {
return Partial{}, err
}
return Partial{
p := Partial{
PartyID: d.partyIdx,
NonceID: d.nonceID,
SessionID: d.sid,
ZShare: packPolyVec(z),
Proof: proof,
}, nil
}
// AUTHENTICATE ORIGIN (RED MEDIUM). Sign the partial with this validator's
// long-term identity key so the aggregator can bind it to its true producer
// (Author == quorum[PartyID]) and refuse a forged partial stamped with this
// slot. The signature binds the slot AND the content (partialAuthTBS), so it
// is non-malleable. With no identity wired the partial is unsigned and the
// aggregator will not emit blame off it (fail-closed on attribution).
if d.idSigner != nil {
p.Author = d.NodeID()
p.AuthSig = d.idSigner.SignProtocolMessage(partialAuthTBS(p, d.epoch))
}
return p, nil
}
// Finalize is run by the designated aggregator (quorum[0]). It verifies
@@ -551,62 +686,238 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
// 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) {
agg, cert, _, err := d.FinalizeWithBlame(r1, partials)
return agg, cert, err
}
// FinalizeWithBlame is Finalize with IDENTIFIABLE ABORT: it additionally
// returns a signed-complaint-ready AbortEvidence for every attributable
// sign-time deviation (malformed / duplicate-PartyID / unknown-party /
// session-mismatch / invalid-proof). This node is the accuser; PartyIDs are
// mapped to NodeIDs via the signing quorum. The AbortEvidence.Signature is left
// empty for the caller's identity layer (TranscriptForComplaint gives the
// to-be-signed bytes). A VALID-sigma WRONG-z is NOT attributed here (BDLOP
// residual — share_commit.go); it remains a liveness fault, never a forgery.
func (d *DistributedBCCSigner) FinalizeWithBlame(r1 SignRound1, partials []Partial) (Aggregate, ConsensusCert, []AbortEvidence, error) {
if !d.IsAggregator() {
return Aggregate{}, ConsensusCert{}, ErrAlgNotAggregator
return Aggregate{}, ConsensusCert{}, nil, ErrAlgNotAggregator
}
if !d.haveC {
return Aggregate{}, ConsensusCert{}, ErrAlgNoNonceShare
return Aggregate{}, ConsensusCert{}, nil, 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)
agg, cert, blames, err := AggregateBCCWithBlame(d.params, d.setup, d.evalPoints, d.quorum, d.epoch, d.idVerify,
d.ctx, d.msg, d.c, &d.cHat, d.w1, d.sid, d.nonceID, len(d.quorum), partials)
return agg, cert, d.blamesToEvidence(blames), err
}
// blamesToEvidence maps PartyID-level PartialBlame to NodeID-level signed-
// complaint-ready AbortEvidence using this signer's quorum, with this node as
// the accuser. Out-of-range PartyIDs and self-accusation are dropped.
func (d *DistributedBCCSigner) blamesToEvidence(blames []PartialBlame) []AbortEvidence {
if len(blames) == 0 {
return nil
}
out := make([]AbortEvidence, 0, len(blames))
for _, b := range blames {
if int(b.PartyID) >= len(d.quorum) {
continue
}
accused := d.quorum[b.PartyID]
if accused == d.NodeID() {
continue // never self-accuse
}
out = append(out, BadPartialEvidence(d.NodeID(), accused, d.epoch, b))
}
return out
}
// ── partial origin authentication (RED MEDIUM) ────────────────────────────
// partialAuthDigest is the 32-byte payload digest an authenticated z-partial's
// identity signature binds. It commits to the partial's SLOT (PartyID) AND its
// CONTENT (ZShare, Proof) under the round (session, nonce), so the signature is
// non-malleable: changing the claimed PartyID or any byte invalidates it. (MAC,
// Author, AuthSig are excluded — they are the envelope, not the signed payload.)
func partialAuthDigest(p Partial) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/partial-auth-digest/v1"))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], p.PartyID)
_, _ = h.Write(u4[:])
_, _ = h.Write(p.SessionID[:])
_, _ = h.Write(p.NonceID[:])
_, _ = h.Write(p.ZShare)
_, _ = h.Write(p.Proof)
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// partialAuthTBS is the canonical to-be-signed bytes for an authenticated
// z-partial: the producer's identity key signs (author ‖ slot ‖ payload-digest)
// via the shared protocol-message framing (ProtocolMessageTBS, round = Partial).
// One encoding, reused by the verifier — there is exactly one way to compute it.
func partialAuthTBS(p Partial, epoch uint64) []byte {
return ProtocolMessageTBS(p.Author, ProtocolContext{
Epoch: epoch,
SessionID: p.SessionID,
NonceID: p.NonceID,
Round: ProtocolRoundPartial,
}, partialAuthDigest(p))
}
// authenticatePartial reports whether p is a genuine partial from the validator
// expected at p's slot. FAIL CLOSED on every doubt: nil verifier, a partial for
// the wrong round, an author that is not the slot's validator (impersonation),
// or a bad/absent signature all return false. This is the gate that stops an
// attacker stamping a victim's PartyID on a forged partial to get the victim
// blamed or front-run-excluded — the attacker cannot produce the victim's
// identity signature, so the forgery is dropped and the victim's real partial
// stands.
func authenticatePartial(p Partial, expectedAuthor NodeID, epoch uint64, sid, nonceID [32]byte, v AbortSignatureVerifier) bool {
if v == nil {
return false
}
if p.Author != expectedAuthor {
return false // slot impersonation: author is not the slot's validator
}
if p.SessionID != sid || p.NonceID != nonceID {
return false // not a partial for this round
}
m := SignedProtocolMessage{
Author: p.Author,
Context: ProtocolContext{Epoch: epoch, SessionID: sid, NonceID: nonceID, Round: ProtocolRoundPartial},
PayloadDigest: partialAuthDigest(p),
Signature: p.AuthSig,
}
return VerifySignedProtocolMessage(&m, v)
}
// 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.
// its parameter list carries no secret. It is the back-compat wrapper over
// AggregateBCCWithBlame (drops the blame list).
//
// 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) {
func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, quorum []NodeID, epoch uint64, auth AbortSignatureVerifier, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, error) {
agg, cert, _, err := AggregateBCCWithBlame(params, setup, evalPoints, quorum, epoch, auth, ctx, msg, c, cHat, w1, sid, nonceID, threshold, partials)
return agg, cert, err
}
// AggregateBCCWithBlame is the canonical no-reconstruct aggregation surface
// with IDENTIFIABLE ABORT. It verifies every partial's sound linear-sigma proof
// bound to (λ_p, c, z_p, session, nonce, party) and the per-party commitments
// (shareCommitmentsFor — the single source of truth shared with Round2), and
// ATTRIBUTES every detected deviation to its PartyID (PartialBlame) instead of
// silently dropping it. Duplicate PartyIDs are rejected — CanonicalSignerSet
// does not dedupe, so a duplicate could otherwise satisfy the threshold with
// fewer than t DISTINCT signers. Malformed ZShares are rejected without
// panicking (unpackPolyVecChecked). It holds NO share, sk, or seed.
//
// ORIGIN AUTHENTICATION (RED MEDIUM). quorum maps PartyID → expected validator
// NodeID; auth is the identity-layer verifier; epoch binds the round. When auth
// is non-nil, a partial is ACCEPTED only if it carries a valid identity
// signature whose author IS quorum[PartyID] — a forged partial stamped with a
// victim's slot is dropped, so an attacker can neither blame nor front-run-
// exclude an honest victim. Blame is emitted ONLY for authenticated partials;
// it is NEVER produced off a raw unauthenticated PartyID (with auth == nil no
// blame is produced at all — attribution fails closed). The networked transport
// that authenticates DELIVERY remains a flagged residual.
func AggregateBCCWithBlame(params *Params, setup *AlgSetup, evalPoints []uint32, quorum []NodeID, epoch uint64, auth AbortSignatureVerifier, ctx, msg []byte, c poly, cHat *poly, w1 polyVec, sid, nonceID [32]byte, threshold int, partials []Partial) (Aggregate, ConsensusCert, []PartialBlame, error) {
gamma2, beta, omega, ok := bccParams(params.Mode)
if !ok {
return Aggregate{}, ConsensusCert{}, ErrBCCParamSet
return Aggregate{}, ConsensusCert{}, nil, 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).
// 0. AUTHENTICATE ORIGIN before any attribution (RED MEDIUM). PartyID is an
// unauthenticated wire field; an attacker can stamp a victim's slot on a
// forged partial to get the victim blamed or front-run-excluded. When an
// identity verifier is supplied, ACCEPT a partial only if it carries a valid
// identity signature whose author IS the validator at that slot — drop any
// unauthenticated / wrong-slot / forged partial HERE, with NO blame against
// the slot's honest owner, BEFORE the first-per-PartyID and duplicate logic
// (so a forgery cannot occupy the victim's slot or evict the victim's real
// partial). With auth == nil the identity layer is not wired: the round still
// aggregates sigma-checked partials but produces NO blame (see below).
if auth != nil {
authed := make([]Partial, 0, len(partials))
for i := range partials {
p := partials[i]
if int(p.PartyID) >= len(quorum) {
continue // unknown slot — nobody to frame; drop
}
if !authenticatePartial(p, quorum[p.PartyID], epoch, sid, nonceID, auth) {
continue // forged / wrong-slot / unsigned — drop, never blame the victim
}
authed = append(authed, p)
}
partials = authed
}
// 1. Verify + ATTRIBUTE. A deviating partial is attributed to its PartyID
// (PartialBlame), never silently dropped. The FIRST partial per PartyID is
// authoritative; any later one carrying the same PartyID is rejected as a
// duplicate (DoS / sub-threshold-via-duplicates vector).
valid := make([]Partial, 0, len(partials))
zByParty := make(map[uint32]polyVec, len(partials))
seen := make(map[uint32]bool, len(partials))
var blames []PartialBlame
blame := func(party uint32, reason BlameReason) {
// Never emit blame off an unauthenticated PartyID (RED MEDIUM): with no
// identity verifier wired, attribution is not sound, so produce none.
// With auth != nil only authenticated partials reach here, so the PartyID
// is the genuine slot owner and the blame is sound.
if auth == nil {
return
}
blames = append(blames, PartialBlame{PartyID: party, Reason: reason, SessionID: sid, NonceID: nonceID})
}
for i := range partials {
p := partials[i]
if p.SessionID != sid || p.NonceID != nonceID {
blame(p.PartyID, BlameSessionMismatch)
continue
}
if int(p.PartyID) >= len(evalPoints) {
blame(p.PartyID, BlameUnknownParty)
continue
}
if seen[p.PartyID] {
blame(p.PartyID, BlameDuplicatePartyID)
continue
}
z, zerr := unpackPolyVecChecked(p.ZShare, L)
if zerr != nil {
seen[p.PartyID] = true
blame(p.PartyID, BlameMalformed)
continue
}
z := unpackPolyVec(p.ZShare, L)
lambda := LagrangeAtZeroQ(evalPoints[p.PartyID], evalPoints)
dkgCommit, nonceCommit := shareCommitmentsFor(setup, nonceID, evalPoints[p.PartyID])
st := &PartialStatement{
Mode: params.Mode,
Lambda: lambda,
C: c,
Z: z,
SessionID: sid,
NonceID: nonceID,
PartyID: p.PartyID,
Mode: params.Mode,
Lambda: lambda,
C: c,
Z: z,
SessionID: sid,
NonceID: nonceID,
PartyID: p.PartyID,
DKGCommitment: dkgCommit,
NonceCommitment: nonceCommit,
}
if err := VerifyPartialProof(st, p.Proof); err != nil {
seen[p.PartyID] = true
blame(p.PartyID, BlameProofInvalid)
continue
}
seen[p.PartyID] = true
valid = append(valid, p)
zByParty[p.PartyID] = z
}
@@ -614,7 +925,7 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
// 2. Canonical (non-grindable) signer subset of exactly `threshold`.
chosen, bitmap, err := CanonicalSignerSet(valid, threshold)
if err != nil {
return Aggregate{}, ConsensusCert{}, err
return Aggregate{}, ConsensusCert{}, blames, err
}
// 3. z = Σ_chosen z_p (Lagrange-linear; mod q). The per-party z_p are
@@ -628,7 +939,7 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
// 4. ‖z‖∞ < γ1 − β (the FIPS 204 reject bound on z).
if polyVecExceeds(z, gamma1-beta) {
return Aggregate{}, ConsensusCert{}, ErrBCCExhausted
return Aggregate{}, ConsensusCert{}, blames, ErrBCCExhausted
}
// 5. w' = A·z − c·t1·2^d (PUBLIC; mirror bcc_sign step 8).
@@ -661,7 +972,7 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
// retries with a fresh NonceMPC nonce.
hint, ok := FindHint(wPrime, w1, gamma2, omega)
if !ok {
return Aggregate{}, ConsensusCert{}, ErrNoFIPSHint
return Aggregate{}, ConsensusCert{}, blames, ErrNoFIPSHint
}
// 7. sigEncode(c̃, z, h) per FIPS 204 Algorithm 28. c̃ = H(μ, packW1(w1))
@@ -670,7 +981,7 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
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
return Aggregate{}, ConsensusCert{}, blames, err
}
sig := Signature{Mode: params.Mode, Bytes: sigBytes}
@@ -680,7 +991,7 @@ func AggregateBCC(params *Params, setup *AlgSetup, evalPoints []uint32, ctx, msg
SignerBitmap: bitmap,
Signature: sig,
}
return agg, cert, nil
return agg, cert, blames, nil
}
// deriveCTilde is the single source of truth for c̃ = H(μ, packW1(w1)) with
+70 -6
View File
@@ -13,23 +13,84 @@ package pulsar
// (VerifyBytes → cloudflare/circl mldsa{65,87}.Verify).
import (
"bytes"
"crypto/rand"
"errors"
"reflect"
"sort"
"testing"
"golang.org/x/crypto/sha3"
)
// ── per-validator identity layer for authenticated-partial tests (RED MEDIUM) ──
//
// testIdentitySet models an identity layer with PER-VALIDATOR keys: each NodeID
// owns a random secret, so an attacker lacking a victim's key cannot forge the
// victim's signature. It is BOTH faces of the one identity layer — the verifier
// (VerifyAbortSignature) and, via signerFor, the producer (IdentitySigner).
// MAC = SHAKE256(key ‖ tbs)[:64] stands in for a real Ed25519/PQ identity key.
type testIdentitySet struct{ keys map[NodeID][]byte }
func newTestIdentitySet(ids ...NodeID) *testIdentitySet {
s := &testIdentitySet{keys: make(map[NodeID][]byte, len(ids))}
for _, id := range ids {
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
panic(err)
}
s.keys[id] = k
}
return s
}
func idMAC(key, tbs []byte) []byte {
h := sha3.NewShake256()
_, _ = h.Write(key)
_, _ = h.Write(tbs)
out := make([]byte, 64)
_, _ = h.Read(out)
return out
}
// VerifyAbortSignature is the verifier face (AbortSignatureVerifier).
func (s *testIdentitySet) VerifyAbortSignature(author NodeID, transcript, signature []byte) bool {
k, ok := s.keys[author]
if !ok || len(signature) == 0 {
return false
}
return bytes.Equal(signature, idMAC(k, transcript))
}
// signerFor returns an IdentitySigner holding ONLY id's key (producer face).
func (s *testIdentitySet) signerFor(id NodeID) IdentitySigner {
return keyedSigner{key: append([]byte(nil), s.keys[id]...)}
}
type keyedSigner struct{ key []byte }
func (k keyedSigner) SignProtocolMessage(tbs []byte) []byte { return idMAC(k.key, tbs) }
// signPartial stamps p with author + a valid identity signature over its
// (possibly tampered) content — models a validator that SIGNS a bad partial
// (authenticated misbehavior, attributable) as opposed to a transport-tampered
// partial (signature breaks, dropped, not attributed). Requires keys[author].
func (s *testIdentitySet) signPartial(p *Partial, author NodeID, epoch uint64) {
p.Author = author
p.AuthSig = idMAC(s.keys[author], partialAuthTBS(*p, epoch))
}
// 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
params *Params
setup *AlgSetup
committee []NodeID
shares []*AlgShare // sorted ascending by NodeID
threshold int
idset *testIdentitySet // per-validator identity layer (authenticated partials)
}
func newBCCFixture(t *testing.T, mode Mode, n, threshold int) *bccFixture {
@@ -62,7 +123,10 @@ func newBCCFixture(t *testing.T, mode Mode, n, threshold int) *bccFixture {
for i, s := range shares {
committee[i] = s.NodeID
}
return &bccFixture{params: params, setup: setup, committee: committee, shares: shares, threshold: threshold}
return &bccFixture{
params: params, setup: setup, committee: committee, shares: shares, threshold: threshold,
idset: newTestIdentitySet(committee...), // one identity key per committee member
}
}
// quorum returns the first q shares (sorted) and their eval points.
+191
View File
@@ -0,0 +1,191 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// dkg_vss_kat_test.go — Phase-3b foundation KAT (luxfi/dkg rewire, step 1).
//
// Proves the shared luxfi/dkg ring substrate, bound to pulsar's ExpandA(rho)
// matrix via ring.MLDSA65().WithMatrices, reproduces pulsar's own FIPS-204
// arithmetic — the precondition for replacing the in-house reconstruct DKG with
// the dkg/vss no-reconstruct Pedersen-VSS.
//
// Two anchors:
//
// 1. A-MATRIX BINDING. pulsar's A = ExpandA(rho) is extracted in
// convention-neutral coefficient form (unit-vector extraction through
// pulsar's own circl-correct keygen pipeline), loaded into the dkg ring, and
// round-tripped back out: INTT(IMForm(dkgA)) == A_coeff. This proves the
// WithMatrices binding carries pulsar's exact matrix through the dkg ring's
// NTT-Montgomery domain with no spurious Montgomery R factor (pin 4).
//
// 2. FIPS-204 t1 FIDELITY (the pin-4 anchor). For a known seed, the dkg ring —
// bound to that A — recomputes t = A·s1 + s2 and Power2Round → t1 using its
// INTT-ONLY ConvertVecFromNTT convention, and the result is byte-identical to
// pulsar's km.t1 (which is byte-identical to cloudflare/circl). This is the
// TRUE A·s1 (not an R^{-1}-scaled self-consistent value): the dkg ring
// reproduces the exact FIPS-204 public key from the same secrets.
//
// Note on the no-reconstruct group key. The dkg/vss DKG's group key is the
// no-reconstruct root T = A·s1 + B·u (large s2 = B·u), NOT the small-s2 FIPS key
// exercised here. The KAT anchors the dkg ring's ARITHMETIC against FIPS ground
// truth; the vss group key uses the SAME ring with B·u in place of s2 (its
// Power2Round fidelity follows from this arithmetic fidelity). The stock-
// verifiable threshold *signature* keeps the trusted-dealer key (honest residual
// HANDOFF-PHASE3 §67-79): the vss key's large s2 is not directly BCC-signable.
import (
"testing"
dkgring "github.com/luxfi/dkg/ring"
)
// extractACoeffViaPulsar recovers pulsar's public matrix A = ExpandA(rho) in
// convention-neutral STANDARD coefficient form. It runs pulsar's own (circl-
// correct) keygen multiply A·e_j for each unit vector e_j (the constant
// polynomial 1 in slot j, zero elsewhere): (A·e_j)[i] = A[i][j]. Because this is
// the exact arithmetic that produces pulsar's circl-identical km.t1, the
// extracted A_coeff is the true coefficient-form matrix with no Montgomery-factor
// ambiguity.
func extractACoeffViaPulsar(km *mldsaKeyMaterial, K, L int) []polyVec {
aCoeff := make([]polyVec, K)
for i := range aCoeff {
aCoeff[i] = make(polyVec, L)
}
for j := 0; j < L; j++ {
ejHat := make(polyVec, L)
for l := 0; l < L; l++ {
var ej poly
if l == j {
ej[0] = 1 // constant polynomial 1
}
ejHat[l] = ej
ejHat[l].ntt()
}
for i := 0; i < K; i++ {
var col poly
polyDotHat(&col, km.a[i], ejHat)
col.reduceLe2Q()
col.invNTT()
col.normalize()
aCoeff[i][j] = col
}
}
return aCoeff
}
// katDKGMatrixFromCoeff builds a dkg-ring NTT-Montgomery Matrix from a standard
// coefficient-form A (the WithMatrices injection path: coeff -> NTT -> MForm,
// matching dkg/ring.DeriveUniformMatrix's domain convention).
//
// KAT-private name: the production no-reconstruct DKG (talus_dkg_vss.go) owns the
// canonical dkgMatrixFromCoeff; this self-contained KAT keeps its own copy so it
// compiles standalone regardless of that file's presence.
func katDKGMatrixFromCoeff(r *dkgring.Ring, aCoeff []polyVec, K, L int) dkgring.Matrix {
m := make(dkgring.Matrix, K)
for i := 0; i < K; i++ {
m[i] = make([]dkgring.Poly, L)
for j := 0; j < L; j++ {
p := r.NewPoly()
for c := 0; c < mldsaN; c++ {
p.Coeffs[0][c] = uint64(aCoeff[i][j][c])
}
r.NTT(p, p)
r.MForm(p, p)
m[i][j] = p
}
}
return m
}
// TestDKGVSS_RingMLDSA65_ExpandABinding_KAT is Phase-3b step-1: the dkg ring,
// bound to pulsar's ExpandA(rho) A via ring.MLDSA65().WithMatrices, reproduces
// pulsar's A matrix and pulsar's expected FIPS-204 t1 for a known seed (pin 4).
func TestDKGVSS_RingMLDSA65_ExpandABinding_KAT(t *testing.T) {
const K, L = 6, 5 // ML-DSA-65 module shape (== dkg ring.MLDSA65)
var seed [SeedSize]byte
for i := range seed {
seed[i] = byte(0x40 + i) // known, fixed seed
}
km, err := deriveKeyMaterial(ModeP65, &seed)
if err != nil {
t.Fatalf("deriveKeyMaterial: %v", err)
}
// pulsar's A = ExpandA(rho), in convention-neutral coefficient form.
aCoeff := extractACoeffViaPulsar(km, K, L)
// dkg ring.MLDSA65, with A bound to pulsar's ExpandA(rho) via WithMatrices.
profile, err := dkgring.MLDSA65()
if err != nil {
t.Fatalf("dkg ring.MLDSA65: %v", err)
}
R := profile.Ring
if R.Q() != mldsaQ || R.N() != mldsaN {
t.Fatalf("dkg ring mismatch: q=%d n=%d, want q=%d n=%d", R.Q(), R.N(), mldsaQ, mldsaN)
}
dkgA := katDKGMatrixFromCoeff(R, aCoeff, K, L)
bound := profile.WithMatrices(dkgA, profile.B)
if err := bound.Validate(); err != nil {
t.Fatalf("bound profile invalid: %v", err)
}
// (1) A-MATRIX BINDING: round-trip dkgA back to coefficient form
// (INTT(IMForm(.))) and assert byte-identity to pulsar's A_coeff. Catches any
// spurious Montgomery-R scaling in the binding (the pin-4 failure mode).
for i := 0; i < K; i++ {
for j := 0; j < L; j++ {
p := *bound.A[i][j].CopyNew()
R.IMForm(p, p)
R.INTT(p, p)
for c := 0; c < mldsaN; c++ {
if p.Coeffs[0][c] != uint64(aCoeff[i][j][c]) {
t.Fatalf("A binding mismatch at [%d][%d][%d]: dkg=%d pulsar=%d",
i, j, c, p.Coeffs[0][c], aCoeff[i][j][c])
}
}
}
}
// (2) FIPS-204 t1 FIDELITY (pin 4): recompute t = A·s1 + s2 -> Power2Round
// via the dkg ring (INTT-only ConvertVecFromNTT) and assert t1 == km.t1.
s1 := make(polyVec, L)
for l := 0; l < L; l++ {
s1[l] = km.s1[l]
s1[l].normalize() // [q-η, q+η] -> [0, q)
}
s2 := make(polyVec, K)
for k := 0; k < K; k++ {
s2[k] = km.s2[k]
s2[k].normalize()
}
s1v := dkgring.NewVec(R, L)
for l := 0; l < L; l++ {
for c := 0; c < mldsaN; c++ {
s1v[l].Coeffs[0][c] = uint64(s1[l][c])
}
}
dkgring.NTTVec(R, s1v) // plain NTT (Mont matrix × plain vec -> plain product)
tvec := dkgring.NewVec(R, K)
dkgring.MatVecMul(R, dkgA, s1v, tvec)
dkgring.ConvertVecFromNTT(R, tvec) // INTT-ONLY -> true A·s1 (pin 4)
for k := 0; k < K; k++ {
for c := 0; c < mldsaN; c++ {
tvec[k].Coeffs[0][c] = (tvec[k].Coeffs[0][c] + uint64(s2[k][c])) % mldsaQ
}
}
t1dkg, _ := dkgring.Power2RoundVec(R, tvec)
for k := 0; k < K; k++ {
for c := 0; c < mldsaN; c++ {
if t1dkg[k].Coeffs[0][c] != uint64(km.t1[k][c]) {
t.Fatalf("t1 fidelity mismatch at [%d][%d]: dkg=%d pulsar(circl)=%d",
k, c, t1dkg[k].Coeffs[0][c], km.t1[k][c])
}
}
}
}
@@ -0,0 +1,292 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// gate2_reachability_test.go — GATE 2 HARDENED to call-graph REACHABILITY,
// closing RED's GATE-2 bypass (the old gate greps only distributed_bcc.go for
// `KeyFromSeed(` / `func LargeCombine`, so a reconstruct-then-sign wired via
// deriveKeyMaterial+bccSign elsewhere PASSES the scan).
//
// The hardened gate proves the no-reconstruct invariant where it matters: NO
// reconstruct / seed-expand / single-key-sign primitive is REACHABLE in the
// default-build call graph from ANY committee sign entrypoint. It is a stdlib
// (go/ast) name call-graph — no new dependency. The callee side is
// OVER-APPROXIMATED by name (a CallExpr to name X follows every definition
// named X), which is the SOUND bias for a security gate: it can over-report a
// reach, never miss one.
//
// GATE C is two tests: (1) the REAL package is clean (the invariant holds);
// (2) the checker actually CATCHES a deriveKeyMaterial+bccSign bypass wired into
// a sign entrypoint (a synthetic graph modeling RED's PoC).
import (
"go/ast"
"go/build"
"go/parser"
"go/token"
"path/filepath"
"sort"
"testing"
)
// bannedReconstructPrimitives are the seed-expand / reconstruct / single-key
// reconstruct-then-sign primitives that MUST be unreachable from the committee
// sign path. All are free functions (defKey == name).
var bannedReconstructPrimitives = map[string]bool{
"KeyFromSeed": true, // seed -> sk expansion
"deriveKeyMaterial": true, // seed -> full FIPS key material
"bccSign": true, // single-key reconstruct-then-sign reference
"LargeCombine": true, // reconstruct-at-sign combiner
"DealAlgShares": true, // trusted-dealer s1-share keygen
"shamirReconstruct": true,
"shamirReconstructGF": true,
"shamirReconstructGFQ": true,
"shamirReconstructQ": true,
}
// committeeSignEntrypoints are the production committee sign-combine roots that
// MUST stay reconstruct-free. Method keys are receiver-qualified.
var committeeSignEntrypoints = []string{
"AggregateBCC",
"AggregateBCCWithBlame",
"NewDistributedBCCSigner",
"DistributedBCCSigner.Round1",
"DistributedBCCSigner.Round2",
"DistributedBCCSigner.Finalize",
"DistributedBCCSigner.FinalizeWithBlame",
"DistributedBCCSigner.SetNonceShare",
}
// callGraph: defKey -> set of callee (unqualified) names, plus an index from
// unqualified name -> all defKeys with that name.
type callGraph struct {
calls map[string]map[string]bool
nameIndex map[string][]string
}
func recvTypeName(fn *ast.FuncDecl) string {
if fn.Recv == nil || len(fn.Recv.List) == 0 {
return ""
}
switch t := fn.Recv.List[0].Type.(type) {
case *ast.StarExpr:
if id, ok := t.X.(*ast.Ident); ok {
return id.Name
}
case *ast.Ident:
return t.Name
}
return ""
}
func defKeyFor(fn *ast.FuncDecl) (key, unqualified string) {
if rt := recvTypeName(fn); rt != "" {
return rt + "." + fn.Name.Name, fn.Name.Name
}
return fn.Name.Name, fn.Name.Name
}
// calleeName extracts the called function/method name from a CallExpr.
func calleeName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
return fn.Sel.Name
}
return ""
}
func buildCallGraph(files []*ast.File) *callGraph {
g := &callGraph{calls: map[string]map[string]bool{}, nameIndex: map[string][]string{}}
for _, f := range files {
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
key, unqual := defKeyFor(fn)
if g.calls[key] == nil {
g.calls[key] = map[string]bool{}
}
g.nameIndex[unqual] = appendUnique(g.nameIndex[unqual], key)
ast.Inspect(fn.Body, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if name := calleeName(call); name != "" {
g.calls[key][name] = true
}
}
return true
})
}
}
return g
}
func appendUnique(s []string, v string) []string {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
// reachBanned does BFS from the entrypoint defKeys over callee names and reports
// the first banned primitive reached, with the path that reaches it.
func (g *callGraph) reachBanned(entrypoints []string, banned map[string]bool) (hit string, path []string, found bool) {
type item struct {
key string
path []string
}
visited := map[string]bool{}
var queue []item
for _, e := range entrypoints {
queue = append(queue, item{key: e, path: []string{e}})
visited[e] = true
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
callees := make([]string, 0, len(g.calls[cur.key]))
for c := range g.calls[cur.key] {
callees = append(callees, c)
}
sort.Strings(callees) // deterministic path reporting
for _, callee := range callees {
if banned[callee] {
return callee, append(append([]string{}, cur.path...), callee), true
}
for _, nextKey := range g.nameIndex[callee] {
if !visited[nextKey] {
visited[nextKey] = true
queue = append(queue, item{key: nextKey, path: append(append([]string{}, cur.path...), nextKey)})
}
}
}
}
return "", nil, false
}
// defaultBuildFuncFiles parses the package's DEFAULT-build (non-test,
// tag-honouring) Go files into ASTs — the same file set a production binary
// links (legacy_trusted_dealer files excluded).
func defaultBuildFuncFiles(t *testing.T) []*ast.File {
t.Helper()
pkg, err := build.Default.ImportDir(".", 0)
if err != nil {
t.Fatalf("enumerate default-build package: %v", err)
}
fset := token.NewFileSet()
files := make([]*ast.File, 0, len(pkg.GoFiles))
for _, name := range pkg.GoFiles {
f, err := parser.ParseFile(fset, filepath.Join(pkg.Dir, name), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
files = append(files, f)
}
return files
}
// GATE C (the invariant) — no reconstruct primitive is reachable from the
// committee sign path in the real default-build call graph.
func TestGATE_C_NoReconstructReachableFromSignPath(t *testing.T) {
files := defaultBuildFuncFiles(t)
g := buildCallGraph(files)
// Every entrypoint must actually exist (guard against a renamed entrypoint
// silently emptying the gate).
for _, e := range committeeSignEntrypoints {
if _, ok := g.calls[e]; !ok {
t.Fatalf("GATE C MISCONFIGURED: sign entrypoint %q not found in the default build", e)
}
}
if hit, path, found := g.reachBanned(committeeSignEntrypoints, bannedReconstructPrimitives); found {
t.Fatalf("GATE C FAILED: reconstruct primitive %q is REACHABLE from the committee sign path:\n %v", hit, path)
}
// Also assert the banned primitives are actually DEFINED in the build (so we
// are testing reachability of real functions, not dead names).
defined := 0
for name := range bannedReconstructPrimitives {
if _, ok := g.nameIndex[name]; ok {
defined++
}
}
t.Logf("GATE C PASS: %d sign entrypoints; %d/%d banned reconstruct primitives present in the build, NONE reachable from the sign path",
len(committeeSignEntrypoints), defined, len(bannedReconstructPrimitives))
}
// GATE C (the catch) — the checker actually DETECTS RED's bypass: a synthetic
// graph in which the committee aggregator reaches deriveKeyMaterial+bccSign
// (reconstruct-then-sign) must be flagged. This proves the gate would FAIL if
// the bypass were wired in, rather than silently passing like the old grep.
func TestGATE_C_CatchesReconstructBypass(t *testing.T) {
// Model: AggregateBCC -> rogueReconstructSign -> {deriveKeyMaterial, bccSign}
g := &callGraph{
calls: map[string]map[string]bool{
"AggregateBCC": {"rogueReconstructSign": true},
"rogueReconstructSign": {"deriveKeyMaterial": true, "bccSign": true},
"deriveKeyMaterial": {},
"bccSign": {},
},
nameIndex: map[string][]string{
"AggregateBCC": {"AggregateBCC"},
"rogueReconstructSign": {"rogueReconstructSign"},
"deriveKeyMaterial": {"deriveKeyMaterial"},
"bccSign": {"bccSign"},
},
}
hit, path, found := g.reachBanned([]string{"AggregateBCC"}, bannedReconstructPrimitives)
if !found {
t.Fatalf("GATE C BROKEN: the reachability checker did NOT catch the deriveKeyMaterial+bccSign bypass")
}
t.Logf("GATE C catch PASS: bypass detected — banned %q reachable via %v", hit, path)
// Negative control: a clean sign path that never reaches a banned primitive
// must NOT be flagged (no false positive).
clean := &callGraph{
calls: map[string]map[string]bool{
"AggregateBCC": {"VerifyPartialProof": true, "FlatAggregateZ": true},
"VerifyPartialProof": {"partialFSChallenges": true},
"partialFSChallenges": {},
"FlatAggregateZ": {},
},
nameIndex: map[string][]string{
"AggregateBCC": {"AggregateBCC"},
"VerifyPartialProof": {"VerifyPartialProof"},
"partialFSChallenges": {"partialFSChallenges"},
"FlatAggregateZ": {"FlatAggregateZ"},
},
}
if _, _, found := clean.reachBanned([]string{"AggregateBCC"}, bannedReconstructPrimitives); found {
t.Fatalf("GATE C false positive: a clean sign path was flagged")
}
// Full-pipeline catch: parse ROGUE source (RED's exact bypass — a sign
// entrypoint that reconstructs via deriveKeyMaterial then bccSign) through
// the REAL buildCallGraph and assert reachBanned flags it. This exercises
// the actual AST extraction the gate runs on production files, not just a
// hand-built graph.
rogueSrc := `package pulsar
func AggregateBCC() { rogueReconstructSign() }
func rogueReconstructSign() {
km, _ := deriveKeyMaterial(ModeP65, nil)
_, _, _ = bccSign(km, ModeP65, nil, nil, nil, 1)
}
`
fset := token.NewFileSet()
rf, err := parser.ParseFile(fset, "rogue.go", rogueSrc, 0)
if err != nil {
t.Fatalf("parse rogue source: %v", err)
}
rg := buildCallGraph([]*ast.File{rf})
hit2, path2, found2 := rg.reachBanned([]string{"AggregateBCC"}, bannedReconstructPrimitives)
if !found2 {
t.Fatalf("GATE C BROKEN: real parse→graph pipeline missed the deriveKeyMaterial+bccSign bypass")
}
t.Logf("GATE C full-pipeline catch PASS: parsed rogue source flagged banned %q via %v", hit2, path2)
}
@@ -0,0 +1,233 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// malicious_capstone_test.go — item 5 capstone: the load-bearing property that
// EVERY malicious deviation a single member can mount on the no-reconstruct sign
// path results in an ABORT or attributable BLAME — NEVER a FIPS-valid forged
// signature and NEVER a recovered secret. Plus selective-abort (re-form) and
// aggregation order-independence (no last-mover grinding).
import (
"crypto/rand"
"errors"
"testing"
)
// noForgery asserts an aggregation outcome is not a forgery: if it returned a
// signature (err == nil), that signature must FAIL the standard FIPS verifier.
func noForgery(t *testing.T, f *bccFixture, msg []byte, cert ConsensusCert, err error) {
t.Helper()
if err == nil && fipsVerify(t, f.setup, msg, &cert.Signature) {
t.Fatalf("FORGERY: a malicious deviation produced a FIPS-valid signature")
}
}
func TestMalicious_BadBehaviorNeverForgesOrLeaks(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
_, L, _ := modeShape(f.params.Mode)
// Sanity: an HONEST quorum DOES produce a valid signature (so the negative
// results below are meaningful, not a broken harness).
var sid0 [32]byte
copy(sid0[:], []byte("capstone-honest"))
if sig, _, err := runBCCCeremony(t, f, threshold, sid0, nil, []byte("honest")); err != nil || !fipsVerify(t, f.setup, []byte("honest"), sig) {
t.Fatalf("honest baseline failed (err=%v) — harness broken", err)
}
// A — NONCE REUSE: an honest signer refuses the second partial on one nonce,
// so the (c_A−c_B)·s1 recovery system can never be assembled (no leak/forge).
t.Run("nonce-reuse-refused", func(t *testing.T) {
quorum, evalPoints, qshares := f.quorum(threshold)
var nid [32]byte
nid[0] = 0xC1
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, threshold, nid, rand.Reader)
if err != nil {
t.Fatalf("nonce deal: %v", err)
}
ledger := NewInMemoryNonceLedger()
var sA, sB [32]byte
copy(sA[:], []byte("A"))
copy(sB[:], []byte("B"))
mk := func(sid [32]byte, msg []byte) *DistributedBCCSigner {
s, err := NewDistributedBCCSigner(f.params, f.setup, qshares[1], quorum, evalPoints, sid, nil, msg, rand.Reader)
if err != nil {
t.Fatalf("signer: %v", err)
}
s.SetNonceLedger(ledger)
_ = s.SetNonceShare(nid, deal.YShares[quorum[1]])
return s
}
s1 := mk(sA, []byte("msg A"))
r1a, _ := s1.Round1(sA, nid, deal.Cert)
if _, err := s1.Round2(r1a, PartialInput{}); err != nil {
t.Fatalf("first honest use: %v", err)
}
s2 := mk(sB, []byte("msg B"))
r1b, _ := s2.Round1(sB, nid, deal.Cert)
if _, err := s2.Round2(r1b, PartialInput{}); !errors.Is(err, ErrNonceReused) {
t.Fatalf("nonce reuse: got %v want ErrNonceReused (key-recovery precondition not blocked)", err)
}
})
// B — INVALID-SIGMA partial: dropped + attributed; round aborts, no forgery.
t.Run("invalid-proof-blamed", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-invalid"))
partials, agg, aggR1, quorum, _ := bccRound(t, f, []byte("invalid"), sid)
bad := append([]Partial(nil), partials...)
z := unpackPolyVec(bad[1].ZShare, L)
z[0][0] = (z[0][0] + 1) % mldsaQ
bad[1].ZShare = packPolyVec(z)
f.idset.signPartial(&bad[1], quorum[1], 0) // bad validator SIGNS its bad partial
_, cert, ev, err := agg.FinalizeWithBlame(aggR1, bad)
noForgery(t, f, []byte("invalid"), cert, err)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("want abort after drop, got %v", err)
}
if !blamedNode(ev, quorum[1]) {
t.Fatalf("invalid-proof party not attributed")
}
})
// C — DUPLICATE PartyID cannot inflate a sub-threshold set; aborts + blames.
t.Run("duplicate-cannot-inflate", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-dup"))
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("dup"), sid)
dups := []Partial{partials[0], partials[0], partials[0]}
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("dup"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, dups)
noForgery(t, f, []byte("dup"), cert, err)
if !errors.Is(err, ErrInsufficientSigners) {
t.Fatalf("duplicates inflated the threshold: %v", err)
}
if b, ok := blameFor(blames, 0); !ok || b.Reason != BlameDuplicatePartyID {
t.Fatalf("duplicate not blamed")
}
})
// D — MALFORMED share: rejected without panic, attributed, no forgery.
t.Run("malformed-no-panic", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-malformed"))
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("malformed"), sid)
bad := append([]Partial(nil), partials...)
bad[2].ZShare = []byte{0xde, 0xad}
f.idset.signPartial(&bad[2], quorum[2], 0) // bad validator SIGNS its malformed partial
_, cert, blames, err := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("malformed"), agg.c, &agg.cHat, agg.w1, sid, partials[2].NonceID, threshold, bad)
noForgery(t, f, []byte("malformed"), cert, err)
if b, ok := blameFor(blames, 2); !ok || b.Reason != BlameMalformed {
t.Fatalf("malformed not blamed")
}
})
// E — VALID-SIGMA WRONG-z: liveness fault only — no FIPS-valid signature.
t.Run("valid-sigma-wrongz-no-forgery", func(t *testing.T) {
var sid [32]byte
copy(sid[:], []byte("capstone-wrongz"))
msg := []byte("wrongz")
partials, agg, aggR1, quorum, evalPoints := bccRound(t, f, msg, sid)
zeros := make(polyVec, L)
lambda := LagrangeAtZeroQ(evalPoints[1], evalPoints)
st := &PartialStatement{Mode: f.params.Mode, Lambda: lambda, C: agg.c, Z: zeros,
SessionID: sid, NonceID: partials[1].NonceID, PartyID: 1}
proof, err := ProvePartial(st, &PartialWitness{Y: zeros, S1: zeros}, rand.Reader)
if err != nil {
t.Fatalf("prove wrong-z: %v", err)
}
bad := append([]Partial(nil), partials...)
bad[1] = Partial{PartyID: 1, NonceID: partials[1].NonceID, SessionID: sid, ZShare: packPolyVec(zeros), Proof: proof}
f.idset.signPartial(&bad[1], quorum[1], 0) // bad validator SIGNS its valid-sigma wrong-z partial
_, cert, _, ferr := agg.FinalizeWithBlame(aggR1, bad)
noForgery(t, f, msg, cert, ferr) // the load-bearing property: NEVER a forgery
})
t.Logf("CAPSTONE PASS: nonce-reuse / invalid-proof / duplicate / malformed / valid-sigma-wrong-z all => abort or blame, NEVER a FIPS-valid forgery")
}
func blamedNode(ev []AbortEvidence, node NodeID) bool {
for i := range ev {
if ev[i].Kind == ComplaintBadPartial && ev[i].Accused == node {
return true
}
}
return false
}
// TestMalicious_SelectiveAbort_ReformProducesValidSig — the crypto aggregator
// requires an EXACT-threshold evalPoints set (its Lagrange weights are over the
// supplied set), so selective abort is handled by the orchestrator RE-FORMING a
// fresh exact-threshold quorum that excludes the blamed party. This test shows
// the realistic flow: a quorum containing a deviator aborts+blames; a re-formed
// clean quorum then produces a valid signature.
func TestMalicious_SelectiveAbort_ReformProducesValidSig(t *testing.T) {
const n, threshold = 6, 3
f := newBCCFixture(t, ModeP65, n, threshold)
_, L, _ := modeShape(f.params.Mode)
// Quorum {0,1,2}, party 1 deviates (invalid proof) -> abort + blame.
var sid [32]byte
copy(sid[:], []byte("selective-abort-round-1"))
partials, agg, aggR1, quorum, _ := bccRound(t, f, []byte("reform"), sid)
bad := append([]Partial(nil), partials...)
z := unpackPolyVec(bad[1].ZShare, L)
z[0][0] = (z[0][0] + 1) % mldsaQ
bad[1].ZShare = packPolyVec(z)
f.idset.signPartial(&bad[1], quorum[1], 0) // deviator SIGNS its bad partial (attributable)
_, _, ev, err := agg.FinalizeWithBlame(aggR1, bad)
if !errors.Is(err, ErrInsufficientSigners) || !blamedNode(ev, quorum[1]) {
t.Fatalf("round 1 should abort+blame party 1: err=%v", err)
}
// Orchestrator RE-FORMS an exact-threshold clean quorum that excludes the
// blamed party (use members {2,3,4} — three honest shares).
clean := &bccFixture{params: f.params, setup: f.setup, threshold: threshold,
committee: []NodeID{f.shares[2].NodeID, f.shares[3].NodeID, f.shares[4].NodeID},
shares: []*AlgShare{f.shares[2], f.shares[3], f.shares[4]}}
var sid2 [32]byte
copy(sid2[:], []byte("selective-abort-reform"))
sig, _, err := runBCCCeremony(t, clean, threshold, sid2, nil, []byte("reform"))
if err != nil {
t.Fatalf("re-formed clean quorum failed to sign: %v", err)
}
if !fipsVerify(t, f.setup, []byte("reform"), sig) {
t.Fatalf("re-formed signature failed FIPS verify")
}
t.Logf("SELECTIVE-ABORT PASS: deviator aborted+blamed; orchestrator re-formed a clean exact-threshold quorum that produced a FIPS-valid signature")
}
// TestMalicious_AggregationOrderIndependent — the aggregate (hence the signature
// bytes) is INDEPENDENT of the order partials arrive in (CanonicalSignerSet is
// deterministic over PartyID). A last submitter therefore cannot grind the
// signer subset / challenge / output by reordering or timing — no last-mover bias.
func TestMalicious_AggregationOrderIndependent(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
var sid [32]byte
copy(sid[:], []byte("order-independence"))
partials, agg, _, quorum, evalPoints := bccRound(t, f, []byte("order"), sid)
forward, certF, _, errF := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("order"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, partials)
_ = forward
if errF != nil {
t.Fatalf("forward aggregate: %v", errF)
}
reversed := []Partial{partials[2], partials[1], partials[0]}
_, certR, _, errR := AggregateBCCWithBlame(f.params, f.setup, evalPoints, quorum, 0, f.idset,
nil, []byte("order"), agg.c, &agg.cHat, agg.w1, sid, partials[0].NonceID, threshold, reversed)
if errR != nil {
t.Fatalf("reversed aggregate: %v", errR)
}
if string(certF.Signature.Bytes) != string(certR.Signature.Bytes) {
t.Fatalf("LAST-MOVER BIAS: signature depends on partial submission order")
}
if !fipsVerify(t, f.setup, []byte("order"), &certF.Signature) {
t.Fatalf("order-independent signature failed FIPS verify")
}
t.Logf("ORDER-INDEPENDENCE PASS: identical signature regardless of partial order — no last-mover grinding of the subset/output")
}
@@ -0,0 +1,159 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// malicious_hardening_test.go — item 5 achievable subset: nonce-ticket
// lifecycle, authenticated-message equivocation detection. (The capstone
// "bad behavior ⇒ abort/blame, never forge/leak" lives in
// malicious_capstone_test.go.)
import (
"bytes"
"testing"
"golang.org/x/crypto/sha3"
)
// ── nonce-ticket lifecycle ────────────────────────────────────────────────
func TestNonceTicketLifecycle(t *testing.T) {
var committeeID [32]byte
committeeID[0] = 0x11
w1 := []byte("packed-w1-nonce-commitment-AAAA")
digestA := sha3Sum32([]byte("message A"))
digestB := sha3Sum32([]byte("message B"))
bindA := NonceBinding{Epoch: 7, CommitteeID: committeeID, MessageKind: 1, Digest: digestA}
bindB := NonceBinding{Epoch: 7, CommitteeID: committeeID, MessageKind: 1, Digest: digestB}
// (1) UNIQUE ticket_id per (nonce, binding): same nonce material, different
// message ⇒ different TicketID (audit handle) …
tkA := NewNonceTicket(committeeID, w1, bindA)
tkB := NewNonceTicket(committeeID, w1, bindB)
if tkA.TicketID == tkB.TicketID {
t.Fatalf("ticket ids collided for different bindings")
}
// … but the SAME MaterialKey (single-use dedup is on the nonce, not the label).
if tkA.MaterialKey() != tkB.MaterialKey() {
t.Fatalf("material keys differ for the same nonce material — dedup would be bypassable")
}
// (2) ONE-USE + DOMAIN-BOUND: first reserve OK; the same nonce material under
// a DIFFERENT message/ticket is rejected (the nonce is burned).
led := NewInMemoryNonceLedger()
if err := ReserveNonceTicket(led, tkA); err != nil {
t.Fatalf("first ticket reserve must succeed: %v", err)
}
if err := ReserveNonceTicket(led, tkB); err != ErrNonceReused {
t.Fatalf("reusing the same nonce material (msg B) returned %v, want ErrNonceReused", err)
}
// A relabeled ticket (new TicketID via different binding, same w1) is also rejected.
tkRelabel := NewNonceTicket(committeeID, w1, NonceBinding{Epoch: 99, CommitteeID: committeeID, MessageKind: 5, Digest: digestA})
if err := ReserveNonceTicket(led, tkRelabel); err != ErrNonceReused {
t.Fatalf("relabeled reuse returned %v, want ErrNonceReused", err)
}
// (3) DOMAIN binding recorded; CheckBinding tripwire fires on a different
// binding for the SAME material key.
if reserved, err := led.CheckBinding(tkA.MaterialKey(), bindA); !reserved || err != nil {
t.Fatalf("CheckBinding(original)=%v,%v want true,nil", reserved, err)
}
if reserved, err := led.CheckBinding(tkA.MaterialKey(), bindB); !reserved || err != ErrNonceBindingMismatch {
t.Fatalf("CheckBinding(other binding)=%v,%v want true,ErrNonceBindingMismatch", reserved, err)
}
// (4) ABORT DOESN'T LEAK reusable state: a FRESH nonce reserved then "aborted"
// (we simulate the signer failing after reserve) stays burned — a second
// attempt on that nonce is refused, never re-openable.
w1b := []byte("packed-w1-nonce-commitment-BBBB")
tkFresh := NewNonceTicket(committeeID, w1b, NonceBinding{Epoch: 7, CommitteeID: committeeID, Digest: digestA})
if err := ReserveNonceTicket(led, tkFresh); err != nil {
t.Fatalf("fresh reserve: %v", err)
}
// (signer would now abort, e.g. proof failure — no rollback happens)
if err := ReserveNonceTicket(led, tkFresh); err != ErrNonceReused {
t.Fatalf("post-abort reuse returned %v, want ErrNonceReused (aborted attempt leaked reusable nonce state!)", err)
}
t.Logf("nonce-ticket lifecycle PASS: unique ticket ids, single-use on material, domain-bound, abort doesn't leak reusable state")
}
func sha3Sum32(b []byte) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write(b)
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// ── authenticated messages + equivocation detection ───────────────────────
// stubIdentity is a test identity layer: sign = SHAKE256("k"‖author‖tbs)[:64].
type stubIdentity struct{}
func (stubIdentity) sign(author NodeID, tbs []byte) []byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("stub-identity-key"))
_, _ = h.Write(author[:])
_, _ = h.Write(tbs)
out := make([]byte, 64)
_, _ = h.Read(out)
return out
}
func (s stubIdentity) VerifyAbortSignature(author NodeID, transcript, signature []byte) bool {
return bytes.Equal(signature, s.sign(author, transcript))
}
func TestSignedProtocolMessage_Equivocation(t *testing.T) {
id := stubIdentity{}
var author, observer NodeID
author[0] = 0xAA
observer[0] = 0xBB
ctx := ProtocolContext{Epoch: 3, Round: ProtocolRoundPartial}
ctx.SessionID[0] = 0x01
mk := func(payload string) SignedProtocolMessage {
dig := sha3Sum32([]byte(payload))
m := SignedProtocolMessage{Author: author, Context: ctx, PayloadDigest: dig}
m.Signature = id.sign(author, ProtocolMessageTBS(author, ctx, dig))
return m
}
msgX := mk("z-partial X")
msgY := mk("z-partial Y (conflicting, same slot)")
// Both messages verify under the identity layer.
if !VerifySignedProtocolMessage(&msgX, id) || !VerifySignedProtocolMessage(&msgY, id) {
t.Fatal("signed messages failed verification")
}
// Two distinct payloads, same slot, both signed ⇒ provable equivocation.
ev, ok := DetectEquivocation(observer, msgX, msgY, ctx.Epoch, id)
if !ok {
t.Fatal("equivocation not detected on two conflicting signed messages")
}
if ev.Kind != ComplaintEquivocation || ev.Accused != author || ev.Accuser != observer {
t.Fatalf("equivocation evidence fields wrong: kind=%v accused=%x accuser=%x", ev.Kind, ev.Accused[:4], ev.Accuser[:4])
}
if err := ValidateAbortEvidence(ev); err != nil {
t.Fatalf("equivocation AbortEvidence malformed: %v", err)
}
// NON-equivocation: identical payload ⇒ no conflict.
if _, ok := DetectEquivocation(observer, msgX, msgX, ctx.Epoch, id); ok {
t.Fatal("identical payloads wrongly flagged as equivocation")
}
// Different slot ⇒ not equivocation.
other := msgY
other.Context.Round = ProtocolRoundFinal
other.Signature = id.sign(author, ProtocolMessageTBS(author, other.Context, other.PayloadDigest))
if _, ok := DetectEquivocation(observer, msgX, other, ctx.Epoch, id); ok {
t.Fatal("different-slot messages wrongly flagged as equivocation")
}
// Forged (bad signature) ⇒ not provable equivocation.
forged := msgY
forged.Signature = []byte("not-a-valid-signature-................................................")
if _, ok := DetectEquivocation(observer, msgX, forged, ctx.Epoch, id); ok {
t.Fatal("a message with an invalid signature must not yield equivocation evidence")
}
t.Logf("equivocation PASS: two validly-signed conflicting same-slot messages ⇒ ComplaintEquivocation; non-conflicts and forgeries rejected")
}
+14 -1
View File
@@ -41,7 +41,9 @@ func packPolyVec(v polyVec) []byte {
}
// unpackPolyVec is the inverse of packPolyVec for an n-polynomial
// vector. Returns a fresh polyVec.
// vector. Returns a fresh polyVec. The caller MUST have validated the
// length (4·N·n) — use unpackPolyVecChecked for UNTRUSTED input (a short
// buffer panics here).
func unpackPolyVec(buf []byte, n int) polyVec {
v := make(polyVec, n)
off := 0
@@ -54,6 +56,17 @@ func unpackPolyVec(buf []byte, n int) polyVec {
return v
}
// unpackPolyVecChecked is the length-validating unpack for UNTRUSTED bytes
// (e.g. an attacker-supplied Partial.ZShare). It returns ErrWireLengthMismatch
// instead of panicking on a truncated/oversized buffer — closing a malformed-
// share DoS in the aggregation path.
func unpackPolyVecChecked(buf []byte, n int) (polyVec, error) {
if len(buf) != 4*mldsaN*n {
return nil, ErrWireLengthMismatch
}
return unpackPolyVec(buf, n), nil
}
// packW1Vec packs the high-bits vector for the FIPS 204 challenge hash.
func packW1Vec(w1 polyVec, gamma2 uint32, K int) []byte {
var polyW1Size int
+357
View File
@@ -0,0 +1,357 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// nonce_ledger.go — the nonce single-use / anti-replay guard.
//
// THE ATTACK THIS CLOSES (RED finding, HIGH — nonce reuse ⇒ full key recovery).
//
// The committee response is z = ȳ + c·s1 where c = SampleInBall(H(μ, w1))
// depends on the message μ but ȳ (hence w1) is the joint nonce. If the SAME
// joint nonce is consumed for TWO messages A, B, the two public aggregates
//
// z_A = ȳ + c_A·s1 , z_B = ȳ + c_B·s1
//
// subtract to the PUBLIC linear relation
//
// z_A − z_B = (c_A − c_B)·s1 (over R_q^L)
//
// in which EVERYTHING but s1 is public. (c_A − c_B) is invertible in R_q with
// overwhelming probability, so any observer solves for the WHOLE expanded key
// s1 and forges at will. This is the textbook Schnorr/FROST nonce-reuse
// catastrophe carried to the lattice setting — it is intrinsic to the LINEAR
// response and cannot be patched in the algebra. The defence is the same as
// FROST's: an HONEST signer MUST consume each nonce AT MOST ONCE.
//
// THE GUARD (this file + Round2 wiring), SAFE BY CONSTRUCTION.
//
// A NonceLedger records, per VALIDATOR KEY-SHARE, the nonce material that has
// already been consumed. Before a signer emits its secret z-partial it
// RESERVES the nonce; a second reservation of the same material is rejected
// FAIL-CLOSED (ErrNonceReused) and NO partial is produced. The dedup key is
// derived from the nonce COMMITMENT w1 (not the nonceID label), so relabeling
// the same joint nonce under a fresh nonceID does not bypass the guard:
//
// key = SHAKE256("PULSAR/nonce-single-use/v1" ‖ w1)
//
// The key is w1 ALONE — NOT committeeID (RED LOW): the ledger is already
// per-validator-share, so the SAME joint nonce reused across two committees
// that share the victim collapses to ONE key and is deduped (a committeeID in
// the key would split it and let the second use through).
//
// One nonce ⇒ one signature, ever. Even ONE honest member refusing the
// second use starves the attacker of that member's z_B partial, so the
// (c_A − c_B)·s1 system can never be assembled.
//
// SAFE BY DEFAULT — NO OPT-IN (closes the RED HIGH re-finding). The single-use
// store is NOT a per-signer-instance object an integrator must remember to
// share. Because a DistributedBCCSigner fixes (sid,ctx,msg) at construction,
// signing two messages needs two signer objects — so a per-instance ledger
// (the prior default) let cross-message reuse through unless the caller hand-
// threaded ONE ledger. Instead the ledger is now resolved from a process-global
// registry keyed by SHARE IDENTITY (shareIdentityKey): EVERY signer constructed
// over the SAME key-share resolves to the SAME ledger, so cross-instance reuse
// is refused WITHOUT the caller doing anything. There is NO construction path
// that yields a per-instance empty ledger. SetNonceLedger remains the seam to
// install a PERSISTENT ledger for a share (crash-restart safety, a flagged
// residual); it writes the SAME per-share registry slot, so all instances of
// the share share it.
//
// FAIL-CLOSED LIFECYCLE. Reservation happens BEFORE the secret is touched and
// is NEVER rolled back: if proof generation later fails, the nonce stays
// burned. An aborted attempt therefore cannot leave a reusable nonce behind
// (the "abort doesn't leak reusable nonce state" property). Re-transmitting a
// dropped partial is the CALLER's job (cache the Round2 output and re-send it);
// re-INVOKING Round2 for the same nonce is correctly refused.
import (
"encoding/binary"
"errors"
"sync"
"golang.org/x/crypto/sha3"
)
var (
// ErrNonceReused is returned when a signer is asked to consume a nonce
// whose commitment material has already produced a partial. This is the
// load-bearing anti-replay refusal (RED nonce-reuse finding).
ErrNonceReused = errors.New("pulsar: nonce already consumed — single-use guard refuses reuse (key-recovery vector)")
// ErrNonceBindingMismatch is returned by CheckBinding when a reserved
// nonce is re-presented under a DIFFERENT domain binding than it was
// minted for (epoch / committee / policy / message-kind / digest). It is a
// domain-separation tripwire; the single-use guard itself fires first on
// any second use.
ErrNonceBindingMismatch = errors.New("pulsar: nonce binding mismatch — reserved for a different (epoch,committee,policy,kind,digest)")
// ErrNonceLedgerNil guards a signer constructed without a ledger.
ErrNonceLedgerNil = errors.New("pulsar: nonce ledger is nil")
)
// NonceBinding is the domain context a nonce is reserved for. It is recorded
// with the reservation for audit and domain-separation. The single-use guard
// keys on the nonce COMMITMENT (committeeID‖w1), not on this binding; the
// binding is the auditable "what was this nonce spent on" record and the
// tripwire CheckBinding uses.
type NonceBinding struct {
Epoch uint64
CommitteeID [32]byte
Policy [32]byte // policy / domain tag (e.g. block-cert vs warp-msg domain)
MessageKind uint32 // caller-defined message-kind discriminator
Digest [32]byte // digest of the (ctx,msg) this nonce is reserved for
}
func (a NonceBinding) equal(b NonceBinding) bool {
return a.Epoch == b.Epoch &&
a.CommitteeID == b.CommitteeID &&
a.Policy == b.Policy &&
a.MessageKind == b.MessageKind &&
a.Digest == b.Digest
}
// NonceLedger is the single-use store. Implementations MUST be safe for
// concurrent use and MUST be FAIL-CLOSED: Reserve returns ErrNonceReused for
// any key it has already seen, and never forgets a key for the lifetime of the
// share it protects. A production deployment backs this with PERSISTENT storage
// so a crash-and-restart cannot reuse a nonce (the in-memory default does not
// survive restart — flagged residual).
type NonceLedger interface {
// Reserve records the first use of key with its binding and returns nil.
// A second Reserve of the same key returns ErrNonceReused — no exceptions,
// regardless of the binding presented.
Reserve(key [32]byte, binding NonceBinding) error
// CheckBinding reports whether key is reserved and, if so, whether it was
// reserved for the given binding. Returns (true, nil) on a binding match,
// (true, ErrNonceBindingMismatch) on a mismatch, and (false, nil) if the
// key is unknown. It never mutates the ledger.
CheckBinding(key [32]byte, binding NonceBinding) (reserved bool, err error)
}
// InMemoryNonceLedger is the default mutex-guarded in-process ledger. It is
// correct for a single long-lived validator process; it does NOT persist
// across restarts (production must inject a persistent ledger — see residuals).
type InMemoryNonceLedger struct {
mu sync.Mutex
seen map[[32]byte]NonceBinding
}
// NewInMemoryNonceLedger returns an empty in-memory single-use ledger.
func NewInMemoryNonceLedger() *InMemoryNonceLedger {
return &InMemoryNonceLedger{seen: make(map[[32]byte]NonceBinding)}
}
// Reserve implements NonceLedger.
func (l *InMemoryNonceLedger) Reserve(key [32]byte, binding NonceBinding) error {
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.seen[key]; ok {
return ErrNonceReused
}
l.seen[key] = binding
return nil
}
// CheckBinding implements NonceLedger.
func (l *InMemoryNonceLedger) CheckBinding(key [32]byte, binding NonceBinding) (bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
got, ok := l.seen[key]
if !ok {
return false, nil
}
if !got.equal(binding) {
return true, ErrNonceBindingMismatch
}
return true, nil
}
// ── safe-by-construction per-share single-use ledger registry ─────────────
//
// The single-use guard MUST be shared across every signer instance holding the
// SAME key-share (signing two messages needs two signer objects, since a signer
// fixes (sid,ctx,msg) at construction). Rather than make that an opt-in the
// integrator can forget — the fail-open default RED re-found — the ledger is
// resolved from this process-global registry keyed by SHARE IDENTITY. Same
// share ⇒ same ledger ⇒ cross-instance nonce reuse is refused by DEFAULT.
//
// In-memory only (does not survive a process restart). A PERSISTENT per-share
// ledger (crash-restart safety) is a flagged residual; install it once at
// startup via (*DistributedBCCSigner).SetNonceLedger, which writes this same
// registry slot so every instance of the share picks it up.
var (
shareLedgerMu sync.Mutex
shareLedgerRegistry = map[[32]byte]NonceLedger{}
)
// shareLedgerFor returns the ONE process-wide single-use ledger for a key-share
// identity, creating an in-memory ledger on first use. Same identity ⇒ same
// instance, so all signers over a share enforce single-use jointly. Never nil.
func shareLedgerFor(identity [32]byte) NonceLedger {
shareLedgerMu.Lock()
defer shareLedgerMu.Unlock()
l, ok := shareLedgerRegistry[identity]
if !ok {
l = NewInMemoryNonceLedger()
shareLedgerRegistry[identity] = l
}
return l
}
// setShareLedger installs a (typically persistent) ledger for a share identity.
// FIRST WRITER WINS: it never replaces a ledger already established for the
// share, so it cannot silently drop reservations already recorded — one ledger
// per share for the process lifetime. Call it at startup, before any Round2 for
// the share, to upgrade that share to crash-restart-safe persistence.
func setShareLedger(identity [32]byte, l NonceLedger) {
if l == nil {
return
}
shareLedgerMu.Lock()
defer shareLedgerMu.Unlock()
if _, exists := shareLedgerRegistry[identity]; !exists {
shareLedgerRegistry[identity] = l
}
}
// shareIdentityKey is the committee-INDEPENDENT identity of a validator key-
// share: the SAME secret share (across any committee, session, or signer
// instance) hashes to the SAME key, so all of its signers resolve to ONE
// single-use ledger. The secret S1Share is bound one-way (SHAKE256 is
// preimage-resistant — the digest leaks nothing) so a re-shared / proactively
// refreshed key (same NodeID+EvalPoint, NEW S1Share) maps to a DISTINCT ledger
// and is not wrongly bound to the retired key's reservations (a liveness over-
// restriction). Committee membership is deliberately NOT in the identity: the
// same share used across two committees must share ONE ledger so the same joint
// nonce cannot be spent once per committee (the RED LOW cross-committee gap).
func shareIdentityKey(share *AlgShare) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/share-ledger-identity/v1"))
_, _ = h.Write([]byte{byte(share.Mode)})
_, _ = h.Write(share.NodeID[:])
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], share.EvalPoint)
_, _ = h.Write(u4[:])
for l := range share.S1Share {
for j := 0; j < mldsaN; j++ {
binary.BigEndian.PutUint32(u4[:], share.S1Share[l][j])
_, _ = h.Write(u4[:])
}
}
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// NonceTicket is the public, auditable handle for one nonce's single use. The
// TicketID is a UNIQUE label (it folds in the binding, so two tickets minted
// for the same nonce material but different messages have DIFFERENT TicketIDs);
// the SINGLE-USE dedup, however, keys on the nonce MATERIAL (W1 alone) via
// MaterialKey — NOT on TicketID, NOT on committeeID — so a relabeled,
// different-message, or different-committee reuse of the SAME joint nonce is
// still rejected by the victim's per-share ledger. This separation is deliberate:
// TicketID is for logs/correlation, MaterialKey is for security.
type NonceTicket struct {
TicketID [32]byte
CommitteeID [32]byte
W1 []byte // packed nonce commitment (the single-use material)
Binding NonceBinding
}
// encodeBinding is the canonical byte encoding of a NonceBinding (for hashing).
func encodeBinding(b NonceBinding) []byte {
out := make([]byte, 0, 8+32+32+4+32)
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], b.Epoch)
out = append(out, u8[:]...)
out = append(out, b.CommitteeID[:]...)
out = append(out, b.Policy[:]...)
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], b.MessageKind)
out = append(out, u4[:]...)
out = append(out, b.Digest[:]...)
return out
}
// NewNonceTicket mints a ticket for a nonce commitment + binding. TicketID =
// SHAKE256(domain ‖ committeeID ‖ w1 ‖ encode(binding)) — unique per
// (nonce, binding); MaterialKey (the dedup key) ignores the binding.
func NewNonceTicket(committeeID [32]byte, w1 []byte, binding NonceBinding) NonceTicket {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/nonce-ticket-id/v1"))
_, _ = h.Write(committeeID[:])
_, _ = h.Write(w1)
_, _ = h.Write(encodeBinding(binding))
var id [32]byte
_, _ = h.Read(id[:])
return NonceTicket{TicketID: id, CommitteeID: committeeID, W1: append([]byte(nil), w1...), Binding: binding}
}
// MaterialKey is the single-use dedup key for the ticket's nonce material
// (w1 alone; committee-independent — see nonceMaterialKey).
func (tk NonceTicket) MaterialKey() [32]byte {
return nonceMaterialKey(tk.W1)
}
// ReserveNonceTicket is the ONE way a nonce is consumed: it reserves the
// ticket's MATERIAL key in the ledger (single-use, fail-closed). On reuse of
// the same nonce material — under any TicketID or message — it returns
// ErrNonceReused and the secret is never emitted.
func ReserveNonceTicket(l NonceLedger, tk NonceTicket) error {
if l == nil {
return ErrNonceLedgerNil
}
return l.Reserve(tk.MaterialKey(), tk.Binding)
}
// nonceMaterialKey derives the single-use dedup key from the nonce commitment
// w1 ALONE (the packed HighBits(w) carried on the public NonceCert).
//
// w1 ONLY — NOT the nonceID label, NOT committeeID:
// - dropping the nonceID label defeats the relabel bypass: the same joint
// nonce ȳ has the same w1 under any nonceID.
// - dropping committeeID closes the RED LOW cross-committee gap. The ledger is
// already per-VALIDATOR-share (resolved by shareIdentityKey), so within a
// victim's one ledger the SAME joint nonce reused across two committees
// collapses to ONE key and is deduped. With committeeID in the key, the same
// w1 in two committees sharing the victim split into two keys and slipped
// through, handing the attacker two partials on one nonce.
func nonceMaterialKey(w1Packed []byte) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/nonce-single-use/v1"))
_, _ = h.Write(w1Packed)
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// deriveCommitteeID is the canonical 32-byte identity of a (group key, sorted
// quorum) pair. quorum MUST be the sorted, distinct committee (the constructor
// enforces this). It binds a nonce reservation to THIS committee + key.
func deriveCommitteeID(jointPKID [32]byte, quorum []NodeID) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/committee-id/v1"))
_, _ = h.Write(jointPKID[:])
for i := range quorum {
_, _ = h.Write(quorum[i][:])
}
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// nonceBindingDigest derives the 32-byte (ctx,msg) digest recorded in a
// nonce reservation. It binds the reservation to exactly what is being signed.
func nonceBindingDigest(mode Mode, tr [64]byte, ctx, msg []byte) [32]byte {
var mu [64]byte
deriveMuCtx(tr, ctx, msg, mu[:])
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/nonce-binding-digest/v1"))
_, _ = h.Write([]byte{byte(mode)})
_, _ = h.Write(mu[:])
var out [32]byte
_, _ = h.Read(out[:])
return out
}
+480
View File
@@ -0,0 +1,480 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// nonce_reuse_gate_test.go — GATE A (RED finding, HIGH: nonce reuse ⇒ full key
// recovery). Ported from RED's TestRED_NonceReuse_RecoversS1.
//
// The gate has two halves:
//
// (1) THE VULNERABILITY IS REAL. Reusing one joint nonce for two messages
// yields the PUBLIC linear system z_A − z_B = (c_A − c_B)·s1 in which
// everything but s1 is public. We assert the identity holds (via the
// package's own arithmetic) AND actually RECOVER the whole expanded key
// s1 from it by solving the negacyclic system over Z_q — matching the
// dealer's secret bit-for-bit. This leak is intrinsic to the LINEAR
// response; it cannot be patched in the algebra.
//
// (2) THE GUARD BLOCKS THE ATTACK. Driving the REAL signer API, an honest
// validator that has emitted a z-partial for a nonce REFUSES to emit a
// second partial for the same nonce — even relabeled under a fresh
// nonceID — so the attacker can never assemble (z_A, z_B) on one nonce.
// PRE-FIX this Round2 succeeded (key recovered); POST-FIX it returns
// ErrNonceReused. The gate is the second partial FAILING to be produced.
import (
"crypto/rand"
"sort"
"testing"
)
// ─────────────────────────────────────────────────────────────────────────
// negacyclic linear algebra over Z_q (test-only attacker math)
// ─────────────────────────────────────────────────────────────────────────
func powModQ(a uint32, e uint32) uint32 {
r := uint64(1)
b := uint64(a) % mldsaQ
for e > 0 {
if e&1 == 1 {
r = r * b % mldsaQ
}
b = b * b % mldsaQ
e >>= 1
}
return uint32(r)
}
// invModQ returns a^(q-2) mod q (Fermat inverse; q prime).
func invModQ(a uint32) uint32 { return powModQ(a, mldsaQ-2) }
// negacyclicMatrix builds the n×n matrix M_d over Z_q with (d·s)[k] = Σ_j M[k][j]·s[j]
// for the ring R_q = Z_q[X]/(X^n+1): M[k][j] = d[k-j] for k≥j, else −d[k-j+n].
func negacyclicMatrix(d *poly) [][]uint32 {
n := mldsaN
M := make([][]uint32, n)
for k := 0; k < n; k++ {
M[k] = make([]uint32, n)
for j := 0; j < n; j++ {
if k >= j {
M[k][j] = d[k-j] % mldsaQ
} else {
M[k][j] = (mldsaQ - d[k-j+n]%mldsaQ) % mldsaQ
}
}
}
return M
}
// solveModQ solves M·x = b over Z_q by Gauss–Jordan elimination. Returns
// ok=false iff M is singular (d not invertible in R_q).
func solveModQ(M [][]uint32, b []uint32) ([]uint32, bool) {
n := len(b)
A := make([][]uint64, n)
for i := 0; i < n; i++ {
A[i] = make([]uint64, n+1)
for j := 0; j < n; j++ {
A[i][j] = uint64(M[i][j])
}
A[i][n] = uint64(b[i])
}
for col := 0; col < n; col++ {
piv := -1
for r := col; r < n; r++ {
if A[r][col]%mldsaQ != 0 {
piv = r
break
}
}
if piv == -1 {
return nil, false
}
A[col], A[piv] = A[piv], A[col]
inv := uint64(invModQ(uint32(A[col][col] % mldsaQ)))
for j := col; j <= n; j++ {
A[col][j] = A[col][j] % mldsaQ * inv % mldsaQ
}
for r := 0; r < n; r++ {
if r == col {
continue
}
f := A[r][col] % mldsaQ
if f == 0 {
continue
}
for j := col; j <= n; j++ {
A[r][j] = (A[r][j] + (mldsaQ-A[col][j]*f%mldsaQ)) % mldsaQ
}
}
}
x := make([]uint32, n)
for i := 0; i < n; i++ {
x[i] = uint32(A[i][n] % mldsaQ)
}
return x, true
}
// challengeFor derives the challenge poly c = SampleInBall(H(μ, w1)) for a
// message under a FIXED nonce commitment w1 — the per-message challenge a
// reused nonce produces.
func challengeFor(mode Mode, tr [64]byte, ctx, msg []byte, w1 polyVec, gamma2 uint32, K, tau int) poly {
cTilde := deriveCTilde(mode, tr, ctx, msg, w1, gamma2, K)
var c poly
polyDeriveUniformBall(&c, cTilde, tau)
return c
}
// ─────────────────────────────────────────────────────────────────────────
// GATE A
// ─────────────────────────────────────────────────────────────────────────
func TestRED_NonceReuse_RecoversS1(t *testing.T) {
const n, threshold = 5, 3
mode := ModeP65
params := MustParamsFor(mode)
gamma2, _, _, ok := bccParams(mode)
if !ok {
t.Fatalf("bccParams(%v) not in scope", mode)
}
K, L, _ := modeShape(mode)
tau, _, _, _ := modeTauOmega(mode)
// Deal a committee. We KEEP the seed so we can derive the reference s1 the
// attack must recover (the dealer wipes its own copy).
committee := make([]NodeID, n)
for i := range committee {
if _, err := rand.Read(committee[i][:]); err != nil {
t.Fatalf("committee entropy: %v", err)
}
}
var seed [SeedSize]byte
if _, err := rand.Read(seed[:]); err != nil {
t.Fatalf("seed entropy: %v", err)
}
setup, shares, err := DealAlgShares(params, committee, threshold, seed, rand.Reader)
if err != nil {
t.Fatalf("DealAlgShares: %v", err)
}
sort.Slice(shares, func(i, j int) bool { return nodeIDLess(shares[i].NodeID, shares[j].NodeID) })
// Reference secret the attack must recover: s1 normalized to [0,q).
refKM, err := deriveKeyMaterial(mode, &seed)
if err != nil {
t.Fatalf("deriveKeyMaterial(ref): %v", err)
}
refS1 := make(polyVec, L)
for l := 0; l < L; l++ {
refS1[l] = refKM.s1[l]
refS1[l].normalize()
}
// The signing quorum (threshold members) + their eval points / Lagrange λ.
quorum := make([]NodeID, threshold)
evalPoints := make([]uint32, threshold)
qshares := make([]*AlgShare, threshold)
for i := 0; i < threshold; i++ {
quorum[i] = shares[i].NodeID
evalPoints[i] = shares[i].EvalPoint
qshares[i] = shares[i]
}
// ONE joint nonce, shared across the quorum (the reused nonce).
var nonceID [32]byte
nonceID[0] = 0xA1
deal, err := DealNonceMPCDebug(setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("DealNonceMPCDebug: %v", err)
}
w1vec, err := unpackW1Vec(deal.Cert.W1, gamma2, K)
if err != nil {
t.Fatalf("unpackW1Vec: %v", err)
}
// ── Part 1: the vulnerability is real — recover the WHOLE s1. ──
//
// Two messages, SAME nonce ⇒ two challenges; per-party partials
// z_i = λ_i(y_i + c·s1_i) aggregate to z = ȳ + c·s1. We try candidate
// second messages until (c_A − c_B) is invertible in R_q, then solve.
msgA := []byte("transfer 1 LUX to alice — first use of the nonce")
candidatesB := [][]byte{
[]byte("transfer 1000000 LUX to mallory — REUSED nonce, message 2"),
[]byte("mallory variant 2"),
[]byte("mallory variant 3"),
[]byte("mallory variant 4"),
}
cA := challengeFor(mode, setup.tr, nil, msgA, w1vec, gamma2, K, tau)
cAHat := cA
cAHat.ntt()
aggregateFor := func(cHat *poly) polyVec {
parts := make([]polyVec, threshold)
for i := 0; i < threshold; i++ {
lambda := LagrangeAtZeroQ(evalPoints[i], evalPoints)
y := deal.YShares[quorum[i]]
parts[i] = partialLinearMap(lambda, cHat, y, qshares[i].S1Share)
}
return FlatAggregateZ(parts, L)
}
zA := aggregateFor(&cAHat)
recovered := false
for _, msgB := range candidatesB {
cB := challengeFor(mode, setup.tr, nil, msgB, w1vec, gamma2, K, tau)
cBHat := cB
cBHat.ntt()
zB := aggregateFor(&cBHat)
// d = c_A − c_B (normal domain).
var d poly
for j := 0; j < mldsaN; j++ {
d[j] = (cA[j] + (mldsaQ - cB[j]%mldsaQ)) % mldsaQ
}
// (a) Assert the leak identity z_A − z_B == d·s1 via the package's
// own NTT/Montgomery arithmetic — the public solvable system.
dHat := d
dHat.ntt()
for l := 0; l < L; l++ {
var diff poly
for j := 0; j < mldsaN; j++ {
diff[j] = (zA[l][j] + (mldsaQ - zB[l][j]%mldsaQ)) % mldsaQ
}
s1Hat := refS1[l]
s1Hat.reduceLe2Q()
s1Hat.ntt()
var ds poly
ds.mulHat(&dHat, &s1Hat)
ds.reduceLe2Q()
ds.invNTT()
ds.normalize()
if ds != diff {
t.Fatalf("leak identity (z_A−z_B == (c_A−c_B)·s1) failed at l=%d — math model wrong", l)
}
}
// (b) Actually RECOVER s1 from the public system (needs d invertible).
M := negacyclicMatrix(&d)
probe, okSolve := solveModQ(M, func() []uint32 {
b := make([]uint32, mldsaN)
for j := 0; j < mldsaN; j++ {
b[j] = (zA[0][j] + (mldsaQ - zB[0][j]%mldsaQ)) % mldsaQ
}
return b
}())
if !okSolve {
continue // (c_A − c_B) singular — pick another second message
}
// verify coordinate 0 then solve the rest.
for j := 0; j < mldsaN; j++ {
if probe[j] != refS1[0][j] {
t.Fatalf("recovered s1[0] mismatch at coeff %d: got %d want %d", j, probe[j], refS1[0][j])
}
}
for l := 1; l < L; l++ {
b := make([]uint32, mldsaN)
for j := 0; j < mldsaN; j++ {
b[j] = (zA[l][j] + (mldsaQ - zB[l][j]%mldsaQ)) % mldsaQ
}
x, ok2 := solveModQ(M, b)
if !ok2 {
t.Fatalf("solve l=%d unexpectedly singular", l)
}
for j := 0; j < mldsaN; j++ {
if x[j] != refS1[l][j] {
t.Fatalf("recovered s1[%d] mismatch at coeff %d", l, j)
}
}
}
recovered = true
t.Logf("VULN CONFIRMED: nonce reuse over 2 messages recovered the FULL %d-poly s1 (the master signing key) by solving (c_A−c_B)·s1 = z_A−z_B over Z_q", L)
break
}
if !recovered {
t.Fatalf("could not find an invertible (c_A−c_B) across %d candidates (vanishingly unlikely)", len(candidatesB))
}
// ── Part 2: the guard blocks the attack via the REAL signer API. ──
//
// Model ONE honest validator (share[0]) handed two signing requests on the
// SAME nonce. It uses ONE shared ledger across both signer instances (a
// validator's per-share ledger). The first emits its partial; the second
// MUST be refused, so the attacker never gets the second partial.
ledger := NewInMemoryNonceLedger()
var sidA, sidB [32]byte
copy(sidA[:], []byte("session-A-first-use"))
copy(sidB[:], []byte("session-B-replay-attempt"))
signerA, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidA, nil, msgA, rand.Reader)
if err != nil {
t.Fatalf("signerA: %v", err)
}
signerA.SetNonceLedger(ledger)
if err := signerA.SetNonceShare(nonceID, deal.YShares[quorum[0]]); err != nil {
t.Fatalf("signerA SetNonceShare: %v", err)
}
r1A, err := signerA.Round1(sidA, nonceID, deal.Cert)
if err != nil {
t.Fatalf("signerA Round1: %v", err)
}
if _, err := signerA.Round2(r1A, PartialInput{}); err != nil {
t.Fatalf("signerA Round2 (first, honest use) must succeed: %v", err)
}
// Second signing request on the SAME nonce, different message — the attack.
signerB, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidB, nil, candidatesB[0], rand.Reader)
if err != nil {
t.Fatalf("signerB: %v", err)
}
signerB.SetNonceLedger(ledger) // SAME validator, SAME share, SAME ledger
if err := signerB.SetNonceShare(nonceID, deal.YShares[quorum[0]]); err != nil {
t.Fatalf("signerB SetNonceShare: %v", err)
}
r1B, err := signerB.Round1(sidB, nonceID, deal.Cert)
if err != nil {
t.Fatalf("signerB Round1: %v", err)
}
if _, err := signerB.Round2(r1B, PartialInput{}); err != ErrNonceReused {
t.Fatalf("GATE A FAILED: nonce-reuse Round2 returned %v, want ErrNonceReused — the second partial was produced, key recovery is possible", err)
}
// ── Part 3: relabel bypass is closed — same ȳ/w1 under a NEW nonceID. ──
var nonceID2 [32]byte
nonceID2[0] = 0xB2 // different label …
relabeled := deal.Cert
relabeled.NonceID = nonceID2 // … same W1 (same joint nonce material)
signerC, err := NewDistributedBCCSigner(params, setup, qshares[0], quorum, evalPoints, sidB, nil, candidatesB[1], rand.Reader)
if err != nil {
t.Fatalf("signerC: %v", err)
}
signerC.SetNonceLedger(ledger)
if err := signerC.SetNonceShare(nonceID2, deal.YShares[quorum[0]]); err != nil {
t.Fatalf("signerC SetNonceShare: %v", err)
}
r1C, err := signerC.Round1(sidB, nonceID2, relabeled)
if err != nil {
t.Fatalf("signerC Round1: %v", err)
}
if _, err := signerC.Round2(r1C, PartialInput{}); err != ErrNonceReused {
t.Fatalf("GATE A FAILED (relabel): reusing the same w1 under a new nonceID returned %v, want ErrNonceReused", err)
}
t.Logf("GATE A PASS: nonce reuse mathematically recovers the full key, but the single-use guard refuses the second partial (ErrNonceReused) — incl. the relabel bypass — so the (c_A−c_B)·s1 system can never be assembled via honest signers")
}
// TestRED_PoC_DefaultLedger_NonceReuse_Refused is GATE A on the DEFAULT
// construction path — RED's re-finding PoC, FLIPPED.
//
// RED drove the REAL API the way an integrator (and runBCCCeremony) does: a
// FRESH signer per message — forced, since a signer fixes (sid,ctx,msg) at
// construction — and NO SetNonceLedger. On the old default each instance got
// its OWN empty in-memory ledger, so the second Round2 on the same joint nonce
// SUCCEEDED, handing the attacker z_B and recovering the victim's full s1-share.
//
// With the share-identity registry the bare default is safe by construction:
// both signer instances over the same key-share resolve to ONE ledger, so the
// second Round2 on the same nonce is refused FAIL-CLOSED. The attacker can never
// collect (z_A, z_B), so the (c_A−c_B)·s1 system is never assembled. This gate
// asserts the path an integrator ACTUALLY uses — NO SetNonceLedger anywhere.
func TestRED_PoC_DefaultLedger_NonceReuse_Refused(t *testing.T) {
const n, threshold = 5, 3
f := newBCCFixture(t, ModeP65, n, threshold)
quorum, evalPoints, qshares := f.quorum(threshold)
// ONE joint nonce, reused across two signing requests (the attack input).
var nonceID [32]byte
nonceID[0] = 0xD1
deal, err := DealNonceMPCDebug(f.setup, quorum, evalPoints, threshold, nonceID, rand.Reader)
if err != nil {
t.Fatalf("DealNonceMPCDebug: %v", err)
}
// The victim validator is quorum[0], holding qshares[0]. The attacker tries
// to drive it to emit TWO partials on the same nonce via the REAL API,
// EXACTLY like runBCCCeremony: a fresh signer per message, NO SetNonceLedger.
victim := qshares[0]
yshare := deal.YShares[quorum[0]]
drive := func(sidSeed, msg string, ncID [32]byte, cert NonceCert) (Partial, error) {
var sid [32]byte
copy(sid[:], []byte(sidSeed))
s, err := NewDistributedBCCSigner(f.params, f.setup, victim, quorum, evalPoints, sid, nil, []byte(msg), rand.Reader)
if err != nil {
t.Fatalf("signer: %v", err)
}
// NOTE: NO SetNonceLedger — the bare default path RED exploited.
if err := s.SetNonceShare(ncID, yshare); err != nil {
t.Fatalf("SetNonceShare: %v", err)
}
r1, err := s.Round1(sid, ncID, cert)
if err != nil {
t.Fatalf("Round1: %v", err)
}
return s.Round2(r1, PartialInput{})
}
// First (honest) use succeeds and yields z_A.
if _, err := drive("poc-default-A", "transfer 1 LUX to alice", nonceID, deal.Cert); err != nil {
t.Fatalf("first partial (honest use) must succeed on the default path: %v", err)
}
// Second use on the SAME nonce, DIFFERENT message — RED obtained z_B here.
// The default registry ledger now refuses it: there is no second partial.
if _, err := drive("poc-default-B", "transfer 1000000 LUX to mallory", nonceID, deal.Cert); err != ErrNonceReused {
t.Fatalf("RED PoC NOT FLIPPED: default-path second Round2 returned %v, want ErrNonceReused — a SECOND z-partial on the reused nonce is obtainable, so s1 is recoverable", err)
}
// Relabel variant: same joint nonce (same W1) under a FRESH nonceID, still
// the default path. Keying on the nonce commitment (not the label) refuses it.
var nonceID2 [32]byte
nonceID2[0] = 0xD2
relabeled := deal.Cert
relabeled.NonceID = nonceID2
if _, err := drive("poc-default-relabel", "relabel attack", nonceID2, relabeled); err != ErrNonceReused {
t.Fatalf("RED PoC NOT FLIPPED (relabel): default-path reuse of the same w1 under a new nonceID returned %v, want ErrNonceReused", err)
}
t.Logf("RED PoC FLIPPED: on the BARE default path (no SetNonceLedger), two signer instances over the same share share ONE registry ledger; the second partial on the reused nonce is refused (ErrNonceReused), incl. relabel — the (c_A−c_B)·s1 system is never assembled, s1 is NOT recoverable")
}
// TestRED_LOW_CrossCommittee_SameNonce_Deduped closes the RED LOW: the dedup
// key must NOT include committeeID. The SAME joint nonce (same w1) used in two
// DIFFERENT committees that share a victim validator must collapse to ONE key in
// the victim's per-share ledger — else the victim emits a partial in each
// committee and the attacker collects two partials on one nonce. Keying on w1
// alone (within the per-share ledger) refuses the second use regardless of
// committee.
func TestRED_LOW_CrossCommittee_SameNonce_Deduped(t *testing.T) {
// Two DIFFERENT committees (different joint-PK ids AND quorums) that share a
// victim validator v — distinct committeeIDs.
var pkA, pkB [32]byte
pkA[0], pkB[0] = 0x01, 0x02
var v, a, b NodeID
v[0], a[0], b[0] = 0x01, 0x02, 0x03 // v < a, v < b ⇒ {v,a} and {v,b} sorted
cid1 := deriveCommitteeID(pkA, []NodeID{v, a})
cid2 := deriveCommitteeID(pkB, []NodeID{v, b})
if cid1 == cid2 {
t.Fatal("committee ids must differ for this test to be meaningful")
}
// SAME joint nonce material (same w1) presented under both committees.
w1 := []byte("identical-joint-nonce-commitment-w1-AAAA")
bind := NonceBinding{Digest: sha3Sum32([]byte("victim signs in both committees"))}
tk1 := NewNonceTicket(cid1, w1, bind)
tk2 := NewNonceTicket(cid2, w1, bind)
// (1) the dedup keys must be EQUAL despite different committees (w1-keyed).
if tk1.MaterialKey() != tk2.MaterialKey() {
t.Fatalf("RED LOW FAILED: MaterialKey depends on committeeID — the same nonce across committees is NOT deduped (two-partials-on-one-nonce key recovery)")
}
// (2) one per-share ledger refuses the second committee's reuse of the nonce.
led := NewInMemoryNonceLedger()
if err := ReserveNonceTicket(led, tk1); err != nil {
t.Fatalf("first committee reserve must succeed: %v", err)
}
if err := ReserveNonceTicket(led, tk2); err != ErrNonceReused {
t.Fatalf("RED LOW FAILED: the same joint nonce in a second committee returned %v, want ErrNonceReused", err)
}
t.Logf("RED LOW PASS: nonce dedup keys on w1 ALONE; the same joint nonce reused across two committees sharing the victim is refused (ErrNonceReused)")
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// protocol_auth.go — authenticated protocol messages + equivocation detection
// (item 5: authenticated channels / signed protocol messages, equivocation).
//
// SCOPE. This is the CRYPTO primitive: a canonical to-be-signed encoding for a
// protocol message bound to its (epoch, session, round) slot, a pluggable
// identity verifier (the same AbortSignatureVerifier shape — one identity layer,
// DRY), and a SOUND equivocation detector: two validly-signed messages from the
// SAME author for the SAME slot with DIFFERENT payloads is provable misbehavior
// and yields a ComplaintEquivocation AbortEvidence. The NETWORKED transport that
// collects these messages (authenticated channels over the wire) is owned by the
// consensus layer (round.go) and is a flagged residual — this package supplies
// the signable bytes and the detector, not the sockets.
import "bytes"
// ProtocolRound enumerates the signable protocol message slots. Equivocation is
// defined per (Author, Epoch, SessionID, Round): one author may sign at most one
// distinct payload per slot.
type ProtocolRound uint8
const (
ProtocolRoundNonce ProtocolRound = iota + 1 // Round1 nonce binding
ProtocolRoundPartial // Round2 z-partial
ProtocolRoundFinal // Finalize / aggregate
)
// ProtocolContext is the slot a protocol message is bound to.
type ProtocolContext struct {
Epoch uint64
SessionID [32]byte
NonceID [32]byte
Round ProtocolRound
}
// SignedProtocolMessage is an authenticated protocol message: the author's
// identity-key signature over the canonical to-be-signed bytes of (context,
// payload digest). PayloadDigest is a 32-byte digest of the message body
// (e.g. packed z-partial, nonce cert) so equivocation comparison is constant-size.
type SignedProtocolMessage struct {
Author NodeID
Context ProtocolContext
PayloadDigest [32]byte
Signature []byte
}
// ProtocolMessageTBS is the canonical to-be-signed byte string for a protocol
// message: SP 800-185 framed (domain ‖ author ‖ epoch ‖ session ‖ nonce ‖ round
// ‖ payload-digest). The author's identity key signs THESE bytes; a verifier
// re-derives them and checks the signature. There is exactly one encoding.
func ProtocolMessageTBS(author NodeID, ctx ProtocolContext, payloadDigest [32]byte) []byte {
parts := [][]byte{
[]byte("PULSAR/protocol-msg/v1"),
author[:],
u64be(ctx.Epoch),
ctx.SessionID[:],
ctx.NonceID[:],
{byte(ctx.Round)},
payloadDigest[:],
}
out := append([]byte{}, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
out = append(out, encodeString(p)...)
}
return out
}
// u64be is the 8-byte big-endian encoding of x (helper for the TBS framing).
func u64be(x uint64) []byte {
return []byte{
byte(x >> 56), byte(x >> 48), byte(x >> 40), byte(x >> 32),
byte(x >> 24), byte(x >> 16), byte(x >> 8), byte(x),
}
}
// VerifySignedProtocolMessage checks the author's identity-key signature over
// the canonical TBS bytes via the supplied verifier. A nil verifier is rejected
// (no implicit skip-verification path — fail closed).
func VerifySignedProtocolMessage(m *SignedProtocolMessage, v AbortSignatureVerifier) bool {
if m == nil || v == nil || len(m.Signature) == 0 {
return false
}
return v.VerifyAbortSignature(m.Author, ProtocolMessageTBS(m.Author, m.Context, m.PayloadDigest), m.Signature)
}
// DetectEquivocation returns a ComplaintEquivocation AbortEvidence iff a and b
// are two DISTINCT-payload messages from the SAME author for the SAME slot, both
// carrying a VALID identity signature. This is sound non-repudiable misbehavior:
// the two signatures prove the author committed to two payloads in one slot.
// accuser is the observer raising the complaint. Returns (nil, false) when the
// pair is not equivocation (different slot/author, equal payload, or either
// signature invalid).
func DetectEquivocation(accuser NodeID, a, b SignedProtocolMessage, epoch uint64, v AbortSignatureVerifier) (*AbortEvidence, bool) {
if v == nil {
return nil, false
}
if a.Author != b.Author || a.Context != b.Context {
return nil, false
}
if a.PayloadDigest == b.PayloadDigest {
return nil, false // same payload — no conflict
}
if accuser == a.Author {
return nil, false // never self-accuse
}
if !VerifySignedProtocolMessage(&a, v) || !VerifySignedProtocolMessage(&b, v) {
return nil, false // unsigned/forged — not provable equivocation
}
// Order the two commits canonically so the evidence is deterministic.
c1, c2 := a, b
if bytes.Compare(a.PayloadDigest[:], b.PayloadDigest[:]) > 0 {
c1, c2 = b, a
}
ev := &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: accuser,
Accused: a.Author,
Epoch: epoch,
Evidence: buildEquivocationEvidence(
c1.PayloadDigest[:], c2.PayloadDigest[:], c1.Signature, c2.Signature),
}
return ev, true
}
// buildEquivocationEvidence packs the 4-field equivocation evidence blob
// (commit1, commit2, sig1, sig2) in the TLV form abort.go validates.
func buildEquivocationEvidence(commit1, commit2, sig1, sig2 []byte) []byte {
out := make([]byte, 0, 4*4+len(commit1)+len(commit2)+len(sig1)+len(sig2))
for _, f := range [][]byte{commit1, commit2, sig1, sig2} {
var l [4]byte
l[0] = byte(len(f) >> 24)
l[1] = byte(len(f) >> 16)
l[2] = byte(len(f) >> 8)
l[3] = byte(len(f))
out = append(out, l[:]...)
out = append(out, f...)
}
return out
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// share_commit.go — per-party DKG / nonce share commitments and the
// IDENTIFIABLE-ABORT design for valid-sigma wrong-z (RED MEDIUM).
//
// ── What this closes, and the residual it scaffolds ──────────────────────
//
// The partial-z sigma proof (partial_proof.go) is SOUND for what it proves:
// knowledge of SOME (y_i, s1_i) opening the public image z_i under the linear
// map, bound by Fiat–Shamir to (session, nonce, party, c, λ_i) and to the
// DKG/nonce commitment BYTES. It does NOT prove that (y_i, s1_i) are the DEALT
// share — SHA-3 is not linear, so the hash-commitment OPENING is outside the
// linear-sigma scope (partial_proof.go header §SCOPE).
//
// CONSEQUENCE (RED MEDIUM, identifiable-abort): a malicious member can submit a
// VALID-sigma but WRONG-z partial — pick any (y', s') it knows, prove the
// (correct) statement for z' = φ(y', s'). The proof verifies, the aggregate is
// wrong, the round dies, and NO party is blamed (unattributable DoS). Today
// this is bounded to a LIVENESS fault: the wrong aggregate NEVER yields a
// signature that verifies under FIPS 204 (no forgery) and leaks nothing (no
// secret crosses the wire). It is simply not yet ATTRIBUTED.
//
// ── Why the cheap homomorphic fix is REJECTED ────────────────────────────
//
// The textbook identifiable-abort check is per-party A·z_i ?= λ_i·(A·y_i) +
// c·λ_i·(A·s1_i). It needs PUBLIC V_i = A·y_i and U_i = A·s1_i. Both REOPEN
// closed leaks:
// - Σ_i λ_i·V_i = A·ȳ = w ⇒ publishing the V_i reveals the joint
// commitment w (PULSAR-V13-W-LEAK: w' − w = c·t0 − c·s2 leaks the key).
// - Σ_i λ_i·U_i = A·s1 = t − s2 = t1·2^d + t0 − s2; with t1 public this
// reveals (t0 − s2) — t0 is a SECRET ML-DSA component (PULSAR-V13-HINT-LEAK).
// So no homomorphic-image commitment is admissible.
//
// ── The SOUND design (RESIDUAL — scaffolded, fail-closed) ────────────────
//
// Use a HIDING lattice commitment (BDLOP, ia.cr/2017/1235 / Ajtai) under a
// SEPARATE public matrix B (independent of A), established at deal time:
//
// Com_s_i = B·r_s_i + embed(s1_i) (commitment to the key share)
// Com_y_i = B·r_y_i + embed(y_i) (commitment to the nonce share)
//
// with SHORT randomness r_*. These are hiding (MLWE) so they reveal neither
// A·s1_i nor A·y_i ⇒ no W-/HINT-LEAK. At sign time the party proves, in ONE
// extended linear sigma over the joint witness (y_i, s1_i, r_y_i, r_s_i), the
// THREE simultaneous LINEAR relations:
//
// (1) z_i = λ_i·y_i + c·λ_i·s1_i (the partial)
// (2) Com_y_i = B·r_y_i + embed(y_i) (nonce-share opening)
// (3) Com_s_i = B·r_s_i + embed(s1_i) (key-share opening)
//
// All three are Z_q-module homomorphisms, so the existing Maurer/CDS sigma
// framework extends with no novel cryptography (plus a norm proof on the
// witness so BDLOP binding holds under MSIS — the same exact-ℓ∞ wall as
// rangeproof.go). A wrong-z party now CANNOT produce a valid proof (the
// commitment binds (y_i, s1_i)), so wrong-z ⇒ proof fails ⇒ that PartyID is
// BLAMED. The commitments are hiding ⇒ no leak.
//
// IMPLEMENTING THIS is the gated residual (Residual A, BLOCKERS.md). It
// requires: (a) BDLOP commitment matrix B + embed/randomness in the public
// setup and nonce deal; (b) the extended PartialStatement/Witness/proof; (c)
// the witness norm proof. Until adopted, shareCommitmentsFor returns the
// authoritative commitments WHEN the setup carries them, else nil — and the
// wrong-z blame path returns ErrIdentifiableAbortResidual.
import "errors"
// ErrIdentifiableAbortResidual marks the SOUND valid-sigma wrong-z attribution
// as the scaffolded-but-unbuilt BDLOP residual. The malicious-secure path that
// flips wrong-z into an attributable blame is fail-closed behind this marker.
var ErrIdentifiableAbortResidual = errors.New(
"pulsar: sound valid-sigma wrong-z attribution requires BDLOP share-commitment " +
"binding (Residual A) — wrong-z is currently a liveness fault, never a forgery/leak")
// shareCommitmentsFor returns the per-party DKG (s1-share) and nonce (y-share)
// commitment BYTES that the partial-z proof binds via Fiat–Shamir. It is the
// SINGLE SOURCE OF TRUTH called by BOTH the prover (Round2) and the verifier
// (AggregateBCC), so the bound bytes are SYMMETRIC by construction with no
// registry and no globals — a pure function of PUBLIC inputs (setup, nonceID,
// party eval point).
//
// TODAY it returns (nil, nil): the production setup/nonce-deal do not yet carry
// BDLOP commitments, so there is nothing authoritative to bind. The proof's
// non-transferability across (session, nonce, party, c, λ) is ALREADY provided
// by the Fiat–Shamir statement binding in partial_proof.go; these commitments
// add DEALT-SHARE binding, which is meaningful only WITH the BDLOP opening proof
// (the residual above). When the setup carries authoritative commitments
// (S1ShareCommitments / nonce YCommitments), this function returns them and the
// extended sigma enforces the opening — at which point wrong-z becomes
// attributable. Keeping it ONE function guarantees the prover and verifier never
// drift apart.
func shareCommitmentsFor(setup *AlgSetup, nonceID [32]byte, evalPoint uint32) (dkgCommit, nonceCommit []byte) {
if setup == nil {
return nil, nil
}
if c, ok := setup.s1ShareCommit[evalPoint]; ok {
dkgCommit = c
}
// Nonce-share commitments travel with the nonce deal; until the leak-free
// NonceMPC publishes per-party Com_y_i they are absent (nil). Bound here for
// symmetry once present.
_ = nonceID
return dkgCommit, nonceCommit
}
+5 -3
View File
@@ -204,9 +204,11 @@ func CEFComputeW1(setup *AlgSetup, commitmentShares []polyVec, evalPoints []uint
if len(commitmentShares) == 0 {
return nil, ErrCEFNoShares
}
// REAL CarryCompare (CSCP): secure HighBits over the per-party additive
// commitment shares — no node (and no process) ever forms w, w0, or A0.
w1, err := cscpSecureHighBitsVec(setup.Mode, commitmentShares, evalPoints, threshold, rng, nil)
// REAL CarryCompare (CSCP): malicious-secure HighBits over the per-party
// additive commitment shares via the shared luxfi/dkg cscp package — no node
// (and no process) ever forms w, w0, or A0. The in-house semi-honest
// cscpSecureHighBitsVec is retained as a test oracle (talus_cscp.go).
w1, err := cscpSecureHighBitsVecDKG(setup.Mode, commitmentShares, evalPoints, threshold, rng)
if err != nil {
return nil, err
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// talus_cscp_dkg.go — Phase-3b CSCP lift (luxfi/dkg rewire, step 4).
//
// The TALUS Phase-B secure-HighBits boundary-count circuit (CarryCompare) now
// runs on the SHARED, MALICIOUS-secure luxfi/dkg `cscp` package instead of
// pulsar's in-house semi-honest copy. This is the keystone deduplication: the
// boundary-count fold, additive→Shamir reshare, bit-decomposition by mask-open,
// and prefix less-than were carried in BOTH corona and pulsar; luxfi/dkg now
// owns ONE malicious-secure implementation and both consume it.
//
// SECURITY UPGRADE. pulsar's `cscpSecureHighBitsVec` (talus_cscp.go) is
// semi-honest: it realises the ideal secure HighBits but assumes parties follow
// the protocol. `cscp.SecureHighBitsVec` is malicious-secure (committed
// re-shares + identifiable-open + batched bit-validity, per luxfi/dkg's CSCP
// closers a/b/c). The arithmetic on honest inputs is byte-identical (both proven
// == FIPS-204 Decompose over all 8 380 417 residues, m=16 buckets), so the
// stock-verifiable signature is unchanged; the deviation surface shrinks.
//
// FAITHFUL TYPE BRIDGE. The circuit operates per coefficient over GF(q)
// (q = 8 380 417, N = 256), never in the NTT domain and never touching the
// public A/B matrices. pulsar's commitment shares are length-K poly-vectors in
// STANDARD coefficient form, already normalize()'d to [0, q) by
// CEFCommitmentShare. The bridge is therefore a direct coefficient copy into the
// dkg ring's Coeffs[0] lane — exactly the conversion the pin-4 KAT
// (dkg_vss_kat_test.go) proved carries pulsar's arithmetic faithfully.
//
// ML-DSA-87 NOTE. luxfi/dkg ships ring.MLDSA65() (K=6) but not an MLDSA87()
// profile. FIPS-204 fixes q = 8 380 417, N = 256, and γ2 = 261888 for BOTH
// ML-DSA-65 and ML-DSA-87 — only the module dimension K differs (6 vs 8). The
// boundary-count circuit needs ONLY (field, K, γ2); it never reads A/B and
// cscp.SecureHighBitsVec does not Validate() the profile. So ML-DSA-87 reuses
// ML-DSA-65's GF(q) ring with K overridden to 8. The ring (the only shared,
// read-only object) is identical; the shallow copy isolates the K field.
import (
"io"
dkgcscp "github.com/luxfi/dkg/cscp"
dkgmpc "github.com/luxfi/dkg/mpc"
dkgring "github.com/luxfi/dkg/ring"
)
// cscpProfileForMode returns the dkg ring profile carrying the GF(q) field and
// the commit-row count K for the given ML-DSA parameter set. ML-DSA-65 uses the
// stock dkg profile directly; ML-DSA-87 reuses its ring with K=8 (see header).
func cscpProfileForMode(mode Mode) (*dkgring.Profile, error) {
base, err := dkgring.MLDSA65()
if err != nil {
return nil, err
}
k, _, _ := modeShape(mode)
if k == 0 {
return nil, ErrBCCParamSet
}
if k == base.K {
return base, nil
}
p := *base // shallow copy: shares only the read-only GF(q) ring
p.K = k
return &p, nil
}
// cscpSecureHighBitsVecDKG is the production CarryCompare. It computes
// w1 = HighBits(Σ_i g_i mod q) per coefficient over GF(q) via the malicious-
// secure luxfi/dkg cscp package, so NO node and NO process ever forms the joint
// commitment w, the low part w0, or the aggregate low sum A0. It is the
// byte-faithful, malicious-secure replacement for the in-house semi-honest
// cscpSecureHighBitsVec; the in-house ideal cefIdealSecureHighBits survives only
// as the TEST ORACLE proving the two agree.
//
// Inputs match CEFComputeW1's contract exactly: commitShares are the per-party
// ADDITIVE commitment shares {g_i} (Lagrange already folded by
// CEFCommitmentShare, so Σ_i g_i = w), evalPoints[i] is party i's GF(q) Shamir
// x-coordinate for the internal additive→Shamir reshare, and N ≥ 2T−1 is
// required (the malicious reshare runs a BGW multiply).
func cscpSecureHighBitsVecDKG(mode Mode, commitShares []polyVec, evalPoints []uint32, threshold int, rng io.Reader) (polyVec, error) {
profile, err := cscpProfileForMode(mode)
if err != nil {
return nil, err
}
R := profile.Ring
K := profile.K
n := len(commitShares)
if n == 0 || len(evalPoints) != n {
return nil, ErrCEFShape
}
// pulsar polyVec (standard coeff form, [0, q)) -> dkg ring.Vector. The
// conversion is a direct coefficient copy (no NTT): the circuit is a
// per-coefficient GF(q) computation.
ringShares := make([]dkgring.Vector, n)
for i := 0; i < n; i++ {
if len(commitShares[i]) != K {
return nil, ErrCEFShape
}
v := dkgring.NewVec(R, K)
for k := 0; k < K; k++ {
for j := 0; j < mldsaN; j++ {
v[k].Coeffs[0][j] = uint64(commitShares[i][k][j])
}
}
ringShares[i] = v
}
eval := make([]dkgmpc.Elem, n)
for i := range evalPoints {
eval[i] = dkgmpc.Elem(evalPoints[i])
}
w1ring, _, err := dkgcscp.SecureHighBitsVec(profile, dkgcscp.MLDSAGamma2, ringShares, eval, threshold, rng, nil)
if err != nil {
return nil, err
}
// dkg ring.Vector (w1, K polys with coefficients in [0, 16)) -> pulsar polyVec.
w1 := make(polyVec, K)
for k := 0; k < K; k++ {
for j := 0; j < mldsaN; j++ {
w1[k][j] = uint32(w1ring[k].Coeffs[0][j])
}
}
return w1, nil
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// talus_cscp_dkg_test.go — Phase-3b CSCP-lift fidelity proof (step 4).
//
// Proves the deduplication is byte-faithful: the production CarryCompare now
// runs on the SHARED, malicious-secure luxfi/dkg cscp.SecureHighBitsVec (via the
// cscpSecureHighBitsVecDKG bridge), and its w1 is identical to BOTH
//
// 1. the in-house ideal functionality cefIdealSecureHighBits — kept in pulsar
// as the test oracle per the Phase-3b handoff, and
// 2. the ground truth HighBits(A·ȳ) computed in the clear,
//
// for ML-DSA-65 (K=6) and ML-DSA-87 (K=8, exercising the K-override profile
// shim). This is a direct, per-coefficient proof of the lift's correctness that
// complements the end-to-end stock-FIPS-204 round-trip
// (TestTalus_MPC_EndToEnd_StockVerify / _Mode87): the round-trip proves the
// signature verifies; this proves the secure-comparison output itself is exact.
import (
"crypto/rand"
"testing"
)
// TestCSCP_DKGBridge_MatchesIdealOracle proves the luxfi/dkg malicious-secure
// CSCP bridge realises the same ideal HighBits functionality as the retained
// in-house oracle, and matches the cleartext ground truth — for both in-scope
// parameter sets.
func TestCSCP_DKGBridge_MatchesIdealOracle(t *testing.T) {
for _, mode := range []Mode{ModeP65, ModeP87} {
const n, threshold = 5, 3
var nonceID [32]byte
nonceID[0] = 0xD0
nonceID[1] = byte(mode)
_, _, bus, evalPoints, wGround := cscpCommitteeBus(t, mode, n, threshold, nonceID)
// Production path: the shared luxfi/dkg malicious-secure CSCP.
got, err := cscpSecureHighBitsVecDKG(mode, bus, evalPoints, threshold, rand.Reader)
if err != nil {
t.Fatalf("%s dkg CSCP bridge: %v", mode, err)
}
// (1) equals the in-house ideal functionality (retained test oracle).
want, err := cefIdealSecureHighBits(bus, mode)
if err != nil {
t.Fatalf("%s ideal oracle: %v", mode, err)
}
if !polyVecEqual(got, want) {
t.Fatalf("%s dkg CSCP bridge w1 ≠ ideal F_HighBits", mode)
}
// (2) equals the cleartext ground truth HighBits(A·ȳ).
gamma2, _, _, _ := bccParams(mode)
if !polyVecEqual(got, highBitsVec(wGround, gamma2)) {
t.Fatalf("%s dkg CSCP bridge w1 ≠ HighBits(A·ȳ)", mode)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// talus_dkg_vss.go — Phase-3b DKG rewire (luxfi/dkg step 2): the no-reconstruct
// Pedersen-VSS key DKG via the shared github.com/luxfi/dkg/vss package, REPLACING
// the in-house v0.1 RECONSTRUCT DKG (dkg.go DKGSession.Round3, which forms the
// master seed σ via KeyFromSeed — the centralized seam this rewire removes).
//
// WHAT THIS PRODUCES (the no-reconstruct ROOT). DealerlessDKGViaVSS runs the
// dealerless VSS DKG over the ML-DSA-65 ring bound to A = ExpandA(rho) (the
// chain-genesis binding) and B = the domain-separated default. NO party ever
// forms the master seed σ, s1, or the full sk: the group public key is derived
// from the aggregated PUBLIC Pedersen commit T = Σ_i C_{i,0} = A·s1 + B·u, and
// each party retains only its Shamir share of s1. The output is shaped as the
// (*AlgSetup, []*AlgShare) the TALUS signer consumes.
//
// ──────────────────────────────────────────────────────────────────────────────
// HONEST OBSTRUCTION (HANDOFF-PHASE3 §67-79; do NOT overclaim). The vss group key
// T = A·s1 + B·u has a LARGE s2 = B·u (M-LWE-indistinguishable from a fresh χ_η
// sample, but NOT small). The TALUS BCC signer recovers the FIPS hint from the
// PUBLIC w' = A·z − c·t1·2^d = w + c·(t0 − B·u); with a large B·u the per-
// coefficient correction w' − w spans many HighBits buckets, so FindHint returns
// no ±1 hint (weight > ω) and a stock-FIPS-204 signature CANNOT be produced
// against this key. Concretely: this is the no-reconstruct DKG PRIMITIVE and the
// no-reconstruct ROOT, NOT the stock-signable key. The stock-verifiable threshold
// signature keeps the trusted-dealer key (DealAlgShares, test/bootstrap scope) —
// dealerless byte-FIPS-204 keygen is unreachable (naive_additive_seta_obstruction
// / assessDealerlessFIPS: the joint S_η sum violates the BCC norm bound at N≥2).
// Permissionless safety rests on the genuinely-dealerless CORONA leg in the
// AND-mode dual-PQ cert. TestDKG_VSS_NotDirectlyBCCSignable pins the obstruction
// so it is proven, not asserted.
//
// SCOPE. dkg ships ring.MLDSA65() (K=6) only; ML-DSA-87 (K=8) needs an MLDSA87
// profile in luxfi/dkg first. This adapter is ML-DSA-65.
import (
"errors"
"io"
"golang.org/x/crypto/sha3"
dkgchannel "github.com/luxfi/dkg/channel"
dkgring "github.com/luxfi/dkg/ring"
dkgvss "github.com/luxfi/dkg/vss"
)
// ErrVSSDKGScope is returned for a parameter set the vss DKG adapter does not
// bind (only ML-DSA-65: dkg ships ring.MLDSA65() only).
var ErrVSSDKGScope = errors.New(
"pulsar: dealerless VSS DKG is wired for ML-DSA-65 only (luxfi/dkg ships ring.MLDSA65(); " +
"ML-DSA-87 needs an MLDSA87 profile upstream)")
// expandAPulsar derives pulsar's public matrix A = ExpandA(rho) (FIPS-204 §3.5),
// K×L polynomials in pulsar's NTT-Montgomery domain — byte-identical to circl's
// stored pk.A and to deriveKeyMaterial's km.a.
func expandAPulsar(rho [32]byte, K, L int) []polyVec {
a := make([]polyVec, K)
for i := 0; i < K; i++ {
a[i] = make(polyVec, L)
for j := 0; j < L; j++ {
polyDeriveUniform(&a[i][j], &rho, uint16(i)<<8|uint16(j))
}
}
return a
}
// aCoeffFromPulsarA recovers A in convention-neutral STANDARD coefficient form by
// the unit-vector multiply A·e_j through pulsar's own (circl-correct) pipeline —
// the same extraction the pin-4 KAT validated. (A·e_j)[i] = A[i][j], so no
// Montgomery-factor ambiguity is possible.
func aCoeffFromPulsarA(a []polyVec, K, L int) []polyVec {
aCoeff := make([]polyVec, K)
for i := range aCoeff {
aCoeff[i] = make(polyVec, L)
}
for j := 0; j < L; j++ {
ejHat := make(polyVec, L)
for l := 0; l < L; l++ {
var ej poly
if l == j {
ej[0] = 1
}
ejHat[l] = ej
ejHat[l].ntt()
}
for i := 0; i < K; i++ {
var col poly
polyDotHat(&col, a[i], ejHat)
col.reduceLe2Q()
col.invNTT()
col.normalize()
aCoeff[i][j] = col
}
}
return aCoeff
}
// dkgMatrixFromCoeff builds a dkg-ring NTT-Montgomery matrix from standard
// coefficient form (coeff -> NTT -> MForm, matching dkg/ring.DeriveUniformMatrix).
func dkgMatrixFromCoeff(r *dkgring.Ring, aCoeff []polyVec, K, L int) dkgring.Matrix {
m := make(dkgring.Matrix, K)
for i := 0; i < K; i++ {
m[i] = make([]dkgring.Poly, L)
for j := 0; j < L; j++ {
p := r.NewPoly()
for c := 0; c < mldsaN; c++ {
p.Coeffs[0][c] = uint64(aCoeff[i][j][c])
}
r.NTT(p, p)
r.MForm(p, p)
m[i][j] = p
}
}
return m
}
// mldsa65ProfileBoundToRho returns the dkg ML-DSA-65 profile with A replaced by
// ExpandA(rho) (WithMatrices) — the chain-genesis binding. B is the default
// domain-separated matrix.
func mldsa65ProfileBoundToRho(rho [32]byte, K, L int) (*dkgring.Profile, error) {
base, err := dkgring.MLDSA65()
if err != nil {
return nil, err
}
aCoeff := aCoeffFromPulsarA(expandAPulsar(rho, K, L), K, L)
dkgA := dkgMatrixFromCoeff(base.Ring, aCoeff, K, L)
return base.WithMatrices(dkgA, base.B), nil
}
// DealerlessDKGViaVSS runs the luxfi/dkg no-reconstruct Pedersen-VSS DKG and
// returns the group setup and per-party s1-shares in the AlgSetup/AlgShare shape
// the TALUS signer consumes — WITHOUT any party forming σ, s1, or sk. The public
// matrix is bound to A = ExpandA(rho). committee carries the n parties' NodeIDs;
// threshold is the reconstruction threshold t (n ≥ t). rng drives all sampling.
//
// The returned AlgSetup.t1 is the no-reconstruct group key HighBits (T = A·s1 +
// B·u); see the file header — this key is the no-reconstruct ROOT and is NOT
// directly stock-FIPS-204-signable (large s2 = B·u). The stock-verifiable
// threshold signing path keeps the trusted-dealer key.
func DealerlessDKGViaVSS(mode Mode, rho [32]byte, committee []NodeID, threshold int, rng io.Reader) (*AlgSetup, []*AlgShare, error) {
if mode != ModeP65 {
return nil, nil, ErrVSSDKGScope
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, nil, ErrInvalidThreshold
}
K, L, _ := modeShape(mode)
profile, err := mldsa65ProfileBoundToRho(rho, K, L)
if err != nil {
return nil, nil, err
}
// Per-party long-term identities (ML-DSA-65 signed + ML-KEM-768 sealed
// authenticated channels) and the vss NodeIDs.
ids := make([]*dkgchannel.IdentityKey, n)
nodes := make([]dkgchannel.NodeID, n)
for i := 0; i < n; i++ {
id, err := dkgchannel.GenerateIdentity(rng)
if err != nil {
return nil, nil, err
}
ids[i] = id
var nid dkgchannel.NodeID
copy(nid[:], committee[i][:])
nodes[i] = nid
}
// Context binds the run to the genesis A seed (rho).
var ctx [32]byte
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR-DKG-VSS/context/v1"))
_, _ = h.Write(rho[:])
_, _ = h.Read(ctx[:])
res, err := dkgvss.RunDKG(profile, n, threshold, ids, nodes, ctx, rng)
if err != nil {
return nil, nil, err
}
// Group setup. t1 = HighBits(T) from the vss group key (no reconstruction).
t1 := vecFromDKG(res.GroupKey.Finalized, K)
aPulsar := expandAPulsar(rho, K, L)
pub := packMLDSAPub(rho, t1, K)
var tr [64]byte
h.Reset()
_, _ = h.Write(pub)
_, _ = h.Read(tr[:])
setup := &AlgSetup{
Mode: mode,
Pub: &PublicKey{Mode: mode, Bytes: pub},
rho: rho,
tr: tr,
a: aPulsar,
t1: t1,
}
// Per-party s1-shares at Shamir eval point index+1 (the vss convention).
shares := make([]*AlgShare, n)
for i := 0; i < n; i++ {
shares[i] = &AlgShare{
NodeID: committee[i],
EvalPoint: uint32(i + 1),
S1Share: vecFromDKG(res.Shares[i], L),
Mode: mode,
}
}
return setup, shares, nil
}
// vecFromDKG converts a dkg ring.Vector (standard coefficient form) of length d to
// a pulsar poly-vector by direct coefficient copy (the GF(q) field and ring
// degree are identical, so no NTT/domain change is involved).
func vecFromDKG(v dkgring.Vector, d int) polyVec {
out := make(polyVec, d)
for i := 0; i < d; i++ {
for j := 0; j < mldsaN; j++ {
out[i][j] = uint32(v[i].Coeffs[0][j])
}
}
return out
}
// packMLDSAPub packs an ML-DSA public key (rho || PackT1(t1)) per FIPS-204 §5.1,
// identical to deriveKeyMaterial's km.pub layout.
func packMLDSAPub(rho [32]byte, t1 polyVec, K int) []byte {
pub := make([]byte, 32+320*K)
copy(pub[:32], rho[:])
for i := 0; i < K; i++ {
polyPackT1(&t1[i], pub[32+320*i:32+320*(i+1)])
}
return pub
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// talus_dkg_vss_test.go — Phase-3b DKG-rewire proofs (step 2):
//
// 1. DealerlessDKGViaVSS produces the (*AlgSetup, []*AlgShare) shape the TALUS
// signer consumes, bound to ExpandA(rho), with a non-trivial sharing.
// 2. No-reconstruct survives: an AST scan of the DKG-via-vss source proves no
// reconstruction primitive (KeyFromSeed / reconstruct / Lagrange / …) is
// DEFINED or CALLED in the path. The group key comes from the PUBLIC commit;
// forming σ / s1 / sk is structurally absent.
// 3. The honest obstruction (HANDOFF-PHASE3 §67-79) is PROVEN, not asserted: the
// vss group key T = A·s1 + B·u has a LARGE s2 = B·u, so HighBits(A·s1) ≠ t1
// in the vast majority of coefficients — the root of why the BCC signer's
// FindHint cannot reach t1 and a stock FIPS-204 signature cannot be produced
// against this key. (Stock-verifiable signing keeps the trusted-dealer key.)
import (
"crypto/rand"
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// reconstructS1 Lagrange-combines t AlgShares at X=0 to recover s1. TEST-ONLY
// scaffolding to VALIDATE the dealerless sharing; DealerlessDKGViaVSS performs no
// such reconstruction.
func reconstructS1(shares []*AlgShare, L int) polyVec {
evals := make([]uint32, len(shares))
for i, sh := range shares {
evals[i] = sh.EvalPoint
}
s1 := make(polyVec, L)
for _, sh := range shares {
lambda := LagrangeAtZeroQ(sh.EvalPoint, evals)
for l := 0; l < L; l++ {
for j := 0; j < mldsaN; j++ {
s1[l][j] = uint32((uint64(s1[l][j]) + uint64(lambda)*uint64(sh.S1Share[l][j])) % mldsaQ)
}
}
}
return s1
}
// TestDKG_NoReconstruct_ViaVSS proves the adapter output shape, the ExpandA(rho)
// binding, and a non-trivial (no-single-party-holds-s1) sharing.
func TestDKG_NoReconstruct_ViaVSS(t *testing.T) {
const n, threshold = 5, 3
var rho [32]byte
for i := range rho {
rho[i] = byte(0x70 + i)
}
committee := make([]NodeID, n)
for i := range committee {
committee[i] = NodeID{byte(i + 1), 0xDC}
}
setup, shares, err := DealerlessDKGViaVSS(ModeP65, rho, committee, threshold, rand.Reader)
if err != nil {
t.Fatalf("DealerlessDKGViaVSS: %v", err)
}
K, L, _ := modeShape(ModeP65)
// Setup shape.
if setup == nil || setup.Pub == nil {
t.Fatal("nil setup/pub")
}
if len(setup.t1) != K || len(setup.a) != K {
t.Fatalf("setup shape: t1=%d a=%d, want K=%d", len(setup.t1), len(setup.a), K)
}
if len(setup.Pub.Bytes) != 32+320*K {
t.Fatalf("packed pk size %d, want %d", len(setup.Pub.Bytes), 32+320*K)
}
// ExpandA(rho) binding: setup.a == pulsar ExpandA(rho).
wantA := expandAPulsar(rho, K, L)
for i := 0; i < K; i++ {
for j := 0; j < L; j++ {
if setup.a[i][j] != wantA[i][j] {
t.Fatalf("A not bound to ExpandA(rho) at [%d][%d]", i, j)
}
}
}
// Share shape + eval points.
if len(shares) != n {
t.Fatalf("share count %d, want %d", len(shares), n)
}
for i, sh := range shares {
if len(sh.S1Share) != L {
t.Fatalf("share %d S1Share len %d, want L=%d", i, len(sh.S1Share), L)
}
if sh.EvalPoint != uint32(i+1) {
t.Fatalf("share %d eval point %d, want %d", i, sh.EvalPoint, i+1)
}
if sh.NodeID != committee[i] {
t.Fatalf("share %d NodeID mismatch", i)
}
}
// Non-trivial sharing: no single party's share equals the reconstructed s1.
s1 := reconstructS1(shares[:threshold], L)
for i, sh := range shares {
if polyVecEqual(sh.S1Share, s1) {
t.Fatalf("party %d's share equals s1 — sharing trivial/broken", i)
}
}
// t-1 shares reconstruct a DIFFERENT value (threshold privacy).
below := reconstructS1(shares[:threshold-1], L)
if polyVecEqual(below, s1) {
t.Fatal("t-1 shares reconstructed s1 — threshold broken")
}
}
// TestDKG_NoReconstruct_SourceStructural parses the DKG-via-vss source (comments
// excluded) and fails if any reconstruction primitive is defined or called — the
// pulsar-side analogue of luxfi/dkg/vss's TestNoReconstruct_SourceStructural.
func TestDKG_NoReconstruct_SourceStructural(t *testing.T) {
const file = "talus_dkg_vss.go"
fset := token.NewFileSet()
parsed, err := parser.ParseFile(fset, file, nil, 0) // 0 = comments dropped
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
forbidden := []string{
"reconstruct", "lagrange", "interpolate",
"keyfromseed", "combineshares", "recoversecret", "modinverse",
}
check := func(name string) {
low := strings.ToLower(name)
for _, bad := range forbidden {
if strings.Contains(low, bad) {
t.Errorf("forbidden reconstruction identifier %q (matches %q) in the DKG-via-vss path", name, bad)
}
}
}
ast.Inspect(parsed, func(nd ast.Node) bool {
switch node := nd.(type) {
case *ast.FuncDecl:
check(node.Name.Name)
case *ast.Ident:
check(node.Name)
case *ast.SelectorExpr:
check(node.Sel.Name)
}
return true
})
}
// TestDKG_VSS_NotDirectlyBCCSignable PROVES the honest obstruction: the vss group
// key T = A·s1 + B·u has a LARGE s2 = B·u, so HighBits(A·s1) disagrees with the
// group-key t1 in the vast majority of coefficients. A small-s2 FIPS key would
// agree up to a handful of boundary coefficients; the disagreement is exactly why
// the BCC signer's FindHint cannot reach t1 (the per-coefficient correction
// spans many HighBits buckets), so no stock FIPS-204 signature can be produced
// against this key. The no-reconstruct DKG is the ROOT, not the signing key.
func TestDKG_VSS_NotDirectlyBCCSignable(t *testing.T) {
const n, threshold = 5, 3
var rho [32]byte
for i := range rho {
rho[i] = byte(0x33 + i)
}
committee := make([]NodeID, n)
for i := range committee {
committee[i] = NodeID{byte(i + 1), 0x5A}
}
setup, shares, err := DealerlessDKGViaVSS(ModeP65, rho, committee, threshold, rand.Reader)
if err != nil {
t.Fatalf("DealerlessDKGViaVSS: %v", err)
}
K, L, _ := modeShape(ModeP65)
gamma2 := uint32(mldsaGamma2P65)
// Reconstruct s1 (TEST-ONLY) and compute HighBits(A·s1) via pulsar's ring.
s1 := reconstructS1(shares[:threshold], L)
s1Hat := make(polyVec, L)
for l := 0; l < L; l++ {
s1Hat[l] = s1[l]
s1Hat[l].ntt()
}
as1 := make(polyVec, K)
for k := 0; k < K; k++ {
polyDotHat(&as1[k], setup.a[k], s1Hat)
as1[k].reduceLe2Q()
as1[k].invNTT()
as1[k].normalize()
}
hbAs1 := highBitsVec(as1, gamma2)
diff := 0
total := K * mldsaN
for k := 0; k < K; k++ {
for j := 0; j < mldsaN; j++ {
if hbAs1[k][j] != setup.t1[k][j] {
diff++
}
}
}
// A small-s2 FIPS key ⇒ diff ≈ 0. The vss key's large s2 = B·u ⇒ diff is the
// vast majority. Require > 50% to PROVE s2 is large (key not BCC-signable).
if diff*2 <= total {
t.Fatalf("HighBits(A·s1) agrees with vss t1 in %d/%d coeffs — s2 unexpectedly small; obstruction NOT demonstrated",
total-diff, total)
}
t.Logf("obstruction proven: HighBits(A·s1) differs from group-key t1 in %d/%d coeffs (s2 = B·u large) — not stock-BCC-signable", diff, total)
}
+9
View File
@@ -298,6 +298,13 @@ const (
// by an amount inconsistent with honest behaviour. Evidence: the
// per-party transcript line.
ComplaintRangeFailure ComplaintKind = 4
// ComplaintBadPartial: a SIGN-time z-partial deviation attributable to a
// PartyID — malformed encoding, duplicate / out-of-range PartyID,
// session/nonce mismatch, or a FAILED partial-z sigma proof. Evidence:
// (PartyID‖reason, sessionID, nonceID). See blame.go. A VALID-sigma
// WRONG-z is NOT covered here (BDLOP residual, share_commit.go).
ComplaintBadPartial ComplaintKind = 5
)
// String returns the canonical name of the complaint kind.
@@ -311,6 +318,8 @@ func (k ComplaintKind) String() string {
return "mac-failure"
case ComplaintRangeFailure:
return "range-failure"
case ComplaintBadPartial:
return "bad-partial"
default:
return "unknown"
}