mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user