mirror of
https://github.com/luxfi/magnetar.git
synced 2026-07-27 02:53:47 +00:00
magnetar v0.5.0: public-BFT framing + standalone+aggregate + dkg2 skeleton
The user's verbatim position: "we can't do a trusted dealer obv. it
HAS to be freking done right for public chains bro"
WHAT SHIPPED
1. PRIMARY PUBLIC-BFT PRIMITIVE (standalone.go + aggregate.go):
- PerValidatorKeypair / ValidatorSign / ValidatorBatchVerify
- BuildAggregateCert / VerifyAggregateCert
- This is the ARCHITECTURALLY CORRECT pattern for SLH-DSA in
public BFT: each validator has its own keypair (no DKG),
signs independently, consensus collects N signatures. Mirrors
what consensus.QuasarCert.MLDSAProof already does for ML-DSA
via Z-Chain Groth16 rollup. SLH-DSA's lack of algebraic
structure (Cozzo-Smart 2019, Bonte-Smart-Tan 2023) means true
threshold SLH-DSA WITHOUT a dealer/TEE/MPC is research-grade;
per-validator + aggregation is the honest answer.
2. THBS SUBPACKAGE DEMOTED (thbs/dealer.go + thbs.go docstrings):
- Renamed to make the dealer-backed v1 trust assumption explicit
at the API surface. "NOT public-BFT-safe" headers added.
- Kept for M-Chain bridge custody / single-operator TCB scenarios
where the dealer is in the TCB by policy.
3. DKG2 SKELETON (thbs/dkg2/):
- PVSS layer (pvss.go, complaint.go, consensus.go) — each party
dealer-shares contribution r_{j,e} for every secret element e
via verifiable shares. No single party ever holds x_e = Σ r_{j,e}.
- MPC root layer (root.go) STUB — returns ErrMPCRootNotImpl.
This is the v0.6+ research deliverable: jointly compute
H(x_e) for every WOTS+/FORS leaf without revealing x_e.
- README + doc.go cite the literature honestly (Schoenmakers PVSS
1999, Gurkan aggregatable DKG 2021, SPDZ MPC 2012, Boyle-Gilboa-
Ishai FSS 2015, MP-SPDZ framework, McGrew et al. threshold HBS
IACR 2019/793, Bonte-Smart-Tan threshold SPHINCS+ 2023).
- Tests pass (skeleton-only assertions, MPC root stub returns
the documented sentinel).
DEPLOYMENT-RUNBOOK.md REWRITTEN — clear guidance:
- PUBLIC BFT CONSENSUS → magnetar.ValidatorSign + VerifyAggregateCert
- M-CHAIN CUSTODY (TEE/dealer in TCB) → CombineWithSeedReconstruction
or thbs.DealerDKG + SignShare + Aggregate
- RESEARCH/FUTURE → thbs/dkg2/ when MPC root layer lands
BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1 — open research entry:
Public DKG for HBS requires MPC over SHA-256/SHAKE to compute public
roots from secret-shared leaves. Estimated multi-hour per signature
at current MPC framework throughput (MP-SPDZ ~hundred-ms per SHA-256
block × 750K SHAKE evaluations for SLH-DSA-SHAKE-192s public key).
v0.6+ target.
CHANGELOG.md entry for v0.5.0.
Tests green:
- ref/go/pkg/magnetar/ ok (105s, includes standalone + aggregate)
- ref/go/pkg/thbs/ ok (dealer-backed THBS, slot guard, equiv evidence)
- ref/go/pkg/thbs/dkg2/ ok (skeleton with documented stub)
Per CLAUDE.md, minor bump for new public API surface
(magnetar.ValidatorSign + dkg2/ subpackage).
This commit is contained in:
+104
-2
@@ -4,13 +4,19 @@
|
||||
> **full Tier A** (Pulsar-equivalent) on the submission-readiness
|
||||
> ladder.
|
||||
>
|
||||
> **As of v0.3.0**: Tier C → Tier B blockers (BLK-1, BLK-2, BLK-3)
|
||||
> **As of v0.5.0**: Tier C → Tier B blockers (BLK-1, BLK-2, BLK-3)
|
||||
> are **CLOSED** at v0.2.0. BLK-5 (KAT vectors), BLK-8 (submission
|
||||
> package documentation shape) are **CLOSED** at v0.3.0. BLK-4
|
||||
> (reference impl + v0.4 lifecycle additions), BLK-6 (cross-validation
|
||||
> harness), BLK-7 (proof artifacts), BLK-9 (independent
|
||||
> cryptographer review) are partially or fully open; see status
|
||||
> below. See `SPEC.md` for the selected construction,
|
||||
> below. **MAGNETAR-PUBLIC-DKG-1** (public-DKG threshold HBS) is the
|
||||
> v0.6+ research-grade open task; the v0.5.0 release ships the PVSS
|
||||
> skeleton at `ref/go/pkg/thbs/dkg2/` and demotes the threshold form
|
||||
> to the M-Chain custody / TEE path while promoting per-validator
|
||||
> standalone SLH-DSA (`magnetar.ValidatorSign` +
|
||||
> `magnetar.VerifyAggregateCert`) as the public-BFT primary primitive.
|
||||
> See `SPEC.md` for the selected construction,
|
||||
> `SUBMISSION-STATUS.md` for tier definitions and the phased plan,
|
||||
> `CRYPTOGRAPHER-SIGN-OFF.md` Gates section for the full Tier A
|
||||
> gate inventory.
|
||||
@@ -180,6 +186,102 @@ External engagement is blocked on:
|
||||
- GATE-3 (dudect 10⁹ samples, v0.6.0)
|
||||
- GATE-5 (v0.4 lifecycle additions for the deployment posture)
|
||||
|
||||
## Research path: public-DKG threshold HBS
|
||||
|
||||
### MAGNETAR-PUBLIC-DKG-1 — Public DKG for threshold HBS → **OPEN (research-grade, v0.6+ candidate)**
|
||||
|
||||
**Context**: the `thbs/` subpackage ships true-threshold HBS per
|
||||
McGrew et al. (IACR ePrint 2019/793) but the v1 setup is
|
||||
DEALER-BACKED — a single dealer generates the WOTS+ chain heads
|
||||
and FORS secret leaves at setup, Shamir-shares them, and erases the
|
||||
master seed. Dealer-backed threshold HBS is **NOT public-BFT-safe**
|
||||
(the dealer learns every secret element at setup time). The user's
|
||||
hard architectural position:
|
||||
|
||||
> "We can't do a trusted dealer obv. It HAS to be freaking done
|
||||
> right for public chains bro."
|
||||
>
|
||||
> "For HBS, the hard part is that public keys are hashes/Merkle
|
||||
> roots of many secret values. Hashes are nonlinear... a practical
|
||||
> public-safe DKG must generate and commit to shared leaf/chain
|
||||
> material, not merely share a seed."
|
||||
>
|
||||
> "DKG = PVSS for secret shares + MPC/public verification for
|
||||
> derived roots."
|
||||
|
||||
**What's needed** (per the user spec):
|
||||
|
||||
1. **PVSS layer (Ingredient A)** — each party j ∈ [n] samples a
|
||||
contribution `r_{j,e}` for every secret element e, Shamir-shares
|
||||
`r_{j,e}` across the committee at threshold t. The joint secret
|
||||
is `x_e = Σ_j r_{j,e}`; no single party ever holds `x_e`. **A
|
||||
SKELETON of this layer ships at `ref/go/pkg/thbs/dkg2/` in
|
||||
v0.5.0** (`pvss.go`, `complaint.go`, `consensus.go`).
|
||||
|
||||
2. **MPC-root layer (Ingredient B)** — MPC over SHA-256/SHAKE to
|
||||
compute the public chain endpoints `W_e = H^{w-1}(x_e)` from
|
||||
secret-shared `x_e` without revealing any `x_e`. **THIS IS THE
|
||||
HARD FRONTIER.** Candidate frameworks:
|
||||
- SPDZ-style MPC (Damgård-Pastro-Smart-Zakarias CRYPTO 2012)
|
||||
- Garbled circuits + OT (Wang-Ranellucci-Katz CCS 2017)
|
||||
- Function Secret Sharing (Boyle-Gilboa-Ishai EUROCRYPT 2015)
|
||||
- MP-SPDZ integration (https://github.com/data61/MP-SPDZ)
|
||||
|
||||
For SLH-DSA-SHAKE-192s with the v1 thbs parameter set this is
|
||||
~750K SHAKE evaluations per DKG ceremony — multi-hour to
|
||||
multi-day per ceremony at current framework performance. The
|
||||
cryptographer team selects the production MPC framework when
|
||||
integrating; the v0.6+ candidate target is "honest cost
|
||||
estimate + working demo at small parameters."
|
||||
|
||||
**Status of the skeleton** (`ref/go/pkg/thbs/dkg2/`):
|
||||
|
||||
- ✅ `doc.go` — package doc, scope, literature (Schoenmakers PVSS,
|
||||
SPDZ, garbled-circuits, function-secret-sharing).
|
||||
- ✅ `pvss.go` — Deal / Verify wire shape; `ErrSkeletonOnly` stubs
|
||||
prevent production consumption.
|
||||
- ✅ `complaint.go` — Complaint round wire shape (BadShare,
|
||||
MissingDelivery, CommitmentMalformed); FileComplaint / DealerDefend
|
||||
stubs.
|
||||
- ✅ `consensus.go` — Qualified-set agreement + Run orchestrator;
|
||||
`RootMPC` stub returns `ErrMPCRootNotImpl` with explicit pointer
|
||||
to this BLOCKERS entry.
|
||||
- ✅ `README.md` — public-facing scope + research path.
|
||||
- ✅ Skeleton tests pin the `ErrSkeletonOnly` / `ErrMPCRootNotImpl`
|
||||
sentinels.
|
||||
|
||||
**What blocks shipping a working dkg2** (the v0.6+ checklist):
|
||||
|
||||
1. Select the production MPC framework (MP-SPDZ, EMP-toolkit,
|
||||
custom).
|
||||
2. Implement the SHAKE-256 chain circuit under the chosen MPC.
|
||||
3. Benchmark per-element cost; choose parameter set + committee
|
||||
size that fits the deployment budget.
|
||||
4. Write production `root.go` (compute joint W_e + public Merkle
|
||||
root from secret-shared x_e).
|
||||
5. Wire-format spec for the public root computation transcript
|
||||
(auditability across re-runs).
|
||||
6. Integrate with parent `thbs.PublicKey` so the resulting threshold
|
||||
HBS public key is BYTE-EQUAL to a dealer-DKG'd public key for
|
||||
the same elements.
|
||||
7. Re-prove the construction (security argument under PVSS + MPC
|
||||
composition; the user's "PVSS + MPC/public verification for
|
||||
derived roots" framing).
|
||||
8. Independent cryptographer review (BLK-9-analog gate).
|
||||
9. Independent MPC engineer review of the SHAKE-circuit
|
||||
implementation.
|
||||
|
||||
**Until MAGNETAR-PUBLIC-DKG-1 closes**:
|
||||
|
||||
- PUBLIC-BFT CONSENSUS uses `magnetar.PerValidatorKeypair` +
|
||||
`magnetar.ValidatorSign` + `magnetar.VerifyAggregateCert`
|
||||
(per-validator standalone SLH-DSA, no DKG, no dealer, no
|
||||
aggregator-as-TCB). See `DEPLOYMENT-RUNBOOK.md` §9.
|
||||
- M-CHAIN CUSTODY uses `magnetar.CombineWithSeedReconstruction`
|
||||
OR `thbs.DealerDKG` inside a TEE-attested host (the dealer/
|
||||
aggregator is in the TCB by policy). See `DEPLOYMENT-RUNBOOK.md`
|
||||
§1.
|
||||
|
||||
## Non-blockers
|
||||
|
||||
These are NOT blockers — they are deliberate non-claims that
|
||||
|
||||
+133
@@ -10,6 +10,139 @@ Magnetar adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
(Nothing pending at this writing.)
|
||||
|
||||
## [0.5.0] — 2026-05-21 — Per-validator standalone is the primary public-BFT primitive; THBS demoted to TEE-only
|
||||
|
||||
### Architectural decomplecting (HONEST framing)
|
||||
|
||||
This release re-architects Magnetar for **public-BFT-safety**. The
|
||||
previous v0.4.x messaging conflated two distinct trust models:
|
||||
threshold-HBS (custody, requires dealer/aggregator-in-TCB) and
|
||||
N-of-N collected signatures (public-BFT, no TCB). v0.5.0 ships the
|
||||
canonical separation:
|
||||
|
||||
- **PRIMARY public-BFT primitive**: per-validator standalone
|
||||
SLH-DSA via `magnetar.PerValidatorKeypair` +
|
||||
`magnetar.ValidatorSign` + `magnetar.VerifyAggregateCert`
|
||||
(`ref/go/pkg/magnetar/standalone.go`). Per-validator keypair, no
|
||||
DKG, no dealer, no aggregator-in-TCB. The consensus layer
|
||||
collects N validator signatures and counts valid signers; the
|
||||
quorum policy decision lives at the consumer. Z-Chain Groth16
|
||||
rollup compresses N × |σ| to ~192 bytes.
|
||||
- **CUSTODY (TEE required)**: `magnetar.CombineWithSeedReconstruction`
|
||||
(reveal-and-aggregate) OR `thbs.DealerDKG` + `thbs.SignShare` +
|
||||
`thbs.Aggregate` (true threshold HBS). The dealer / aggregator
|
||||
is in the TCB by policy — both REQUIRE TEE attestation.
|
||||
- **RESEARCH (v0.6+)**: `thbs/dkg2/` skeleton ships the PVSS
|
||||
layer; MPC-root layer is OPEN RESEARCH tracked at
|
||||
`BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1`.
|
||||
|
||||
The honest architectural truth: SLH-DSA is hash-based and has no
|
||||
algebraic structure that admits FROST-style threshold aggregation
|
||||
(Cozzo-Smart EUROCRYPT 2019; Bonte-Smart-Tan 2023; NIST IR 8214).
|
||||
True-threshold SLH-DSA without TEE/dealer requires full MPC over
|
||||
the SHA-256/SHAKE hash tree — research-grade. For Lux's PUBLIC-BFT
|
||||
consensus, per-validator standalone is the right primitive; for
|
||||
M-Chain custody (where a TEE-attested host can be in the TCB by
|
||||
policy), the threshold form is correct.
|
||||
|
||||
### Added
|
||||
|
||||
- **`ref/go/pkg/magnetar/standalone.go`** — the public-BFT-safe
|
||||
PRIMARY primitive surface:
|
||||
- `PerValidatorKeypair(params, rng) → (sk, pk, error)` — standalone
|
||||
FIPS 205 keypair generator. Semantic alias for
|
||||
`GenerateValidatorKey`; explicit name for the public-BFT path.
|
||||
- `ValidatorSign(sk, rng, message) → ([]byte, error)` — canonical
|
||||
public-BFT signing primitive. Returns raw FIPS 205 signature
|
||||
bytes. Deterministic when rng is nil (FIPS 205
|
||||
SignDeterministic).
|
||||
- `ValidatorBatchVerify(params, pubs, msgs, sigs) → ([]bool, error)`
|
||||
— parallel-CPU batch verify returning per-signer bitmap.
|
||||
Routes through the same goroutine fork-join pattern as
|
||||
`luxfi/crypto/slhdsa.VerifyBatch`.
|
||||
- `ValidatorAggregateCert{Mode, Signers, PubKeys, Sigs}` —
|
||||
parallel-slices wire envelope for N per-validator signatures
|
||||
over a single message. Sibling shape to `AggregatedSignature`
|
||||
(aggregate.go) but maps directly to Z-Chain Groth16 rollup
|
||||
witness layout.
|
||||
- `BuildAggregateCert(params, signers, pubKeys, sigs)` —
|
||||
constructor with shape + mode validation and defensive copies.
|
||||
- `VerifyAggregateCert(cert, message, knownValidators) → (count, err)`
|
||||
— verifies every signature against the registered public key
|
||||
and returns the count of valid signers. Unknown / pubkey-
|
||||
mismatched signers are counted as INVALID (not fatal — defense
|
||||
against impersonation that does not abort the entire batch).
|
||||
- **`ref/go/pkg/magnetar/standalone_test.go`** — 7 tests covering
|
||||
round-trip + determinism + independence, parallel batch verify,
|
||||
bad-sig-counted-out, unknown-validator rejection,
|
||||
duplicate-validator dedupe-and-count-once, GPU/parallel
|
||||
provenance assertion, all-invalid returns-zero, and shape checks.
|
||||
- **`ref/go/pkg/thbs/dkg2/`** — research-grade SKELETON of a
|
||||
public DKG for threshold HBS. v1 ships the PVSS layer; the
|
||||
MPC-root layer is OPEN RESEARCH tracked at
|
||||
`BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1`. Files:
|
||||
- `doc.go` — package doc, scope, literature (Schoenmakers PVSS,
|
||||
Damgård-Pastro-Smart-Zakarias SPDZ, Boyle-Gilboa-Ishai FSS,
|
||||
Wang-Ranellucci-Katz MPC).
|
||||
- `pvss.go` — Deal / Verify wire shape with `ErrSkeletonOnly` /
|
||||
`ErrMPCRootNotImpl` sentinels preventing production
|
||||
consumption.
|
||||
- `complaint.go` — Complaint round wire shape (BadShare,
|
||||
MissingDelivery, CommitmentMalformed) + dealer Defense.
|
||||
- `consensus.go` — qualified-set agreement + `Run` orchestrator +
|
||||
`RootMPC` stub. Every public function returns
|
||||
`ErrSkeletonOnly` or `ErrMPCRootNotImpl`.
|
||||
- `README.md` — public-facing scope, literature, v0.6+ checklist.
|
||||
- `dkg2_test.go` — sentinel-pinning tests.
|
||||
- **`BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1`** — open-research entry
|
||||
for public DKG over HBS. PVSS skeleton shipped; MPC-root layer
|
||||
open. 9-step v0.6+ checklist.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`ref/go/pkg/thbs/thbs.go`** — package doc rewritten to honestly
|
||||
demote the threshold form to "DEALER-BACKED v1 — NOT public-BFT-
|
||||
safe". Directs callers to `magnetar.ValidatorSign` for public BFT
|
||||
and to `thbs.DealerDKG` only for M-Chain custody where the
|
||||
dealer is in the TCB by TEE attestation. Cites the
|
||||
`BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1` research path.
|
||||
- **`ref/go/pkg/thbs/dealer.go`** — renamed `DKG` → `DealerDKG` and
|
||||
`DKGAll` → `DealerDKGAll`. The function bodies are unchanged;
|
||||
the rename honestly reflects that v1 has a dealer. Callers in
|
||||
`thbs_test.go` updated. `DKGConfig` / `DKGOptions` retained
|
||||
(config struct shape applies to both v1 dealer and a future v2
|
||||
public DKG).
|
||||
- **`DEPLOYMENT-RUNBOOK.md` §0** — rewritten to direct PUBLIC BFT
|
||||
CONSENSUS users to `magnetar.ValidatorSign` +
|
||||
`magnetar.VerifyAggregateCert` as the primary primitive; demotes
|
||||
`CombineWithSeedReconstruction` and `thbs.DealerDKG` to the
|
||||
M-Chain custody / TEE-attested path; cites `thbs/dkg2/` as the
|
||||
v0.6+ research path. New §9 documents the
|
||||
`ValidatorSign`/`VerifyAggregateCert` primary primitive in detail.
|
||||
- **`BLOCKERS.md`** — header updated to v0.5.0 framing; new
|
||||
`MAGNETAR-PUBLIC-DKG-1` research entry.
|
||||
|
||||
### Honest framing
|
||||
|
||||
- The threshold form of Magnetar (both `CombineWithSeedReconstruction`
|
||||
and `thbs.DealerDKG`) is FUNDAMENTALLY not public-BFT-safe. This
|
||||
is not a Magnetar choice; it is a property of hash-based
|
||||
signatures. The literature is clear (Cozzo-Smart, Bonte-Smart-Tan,
|
||||
NIST IR 8214). The honest fix is the architectural pivot in
|
||||
v0.5.0: use the threshold form for custody (where TEE
|
||||
attestation puts the dealer/aggregator in the TCB by policy) and
|
||||
use per-validator standalone for public consensus.
|
||||
- The Lux consensus stack ALREADY does the right thing for ML-DSA
|
||||
via `QuasarCert.MLDSAProof` (per-validator standalone ML-DSA
|
||||
signatures collected into a multi-signer cert). The
|
||||
`ValidatorSign` + `VerifyAggregateCert` API makes the same
|
||||
pattern explicit and canonical for SLH-DSA.
|
||||
- The `thbs/dkg2/` skeleton is honestly research-grade. It exists
|
||||
to make the v0.6+ research direction concrete without pretending
|
||||
to ship a working public DKG. Every function returns a sentinel
|
||||
error pointing at the BLOCKERS entry. A production caller
|
||||
CANNOT accidentally consume the unfinished pipeline.
|
||||
|
||||
## [0.4.2] — 2026-05-21 — THBS v1 + Public-BFT-safe aggregate-signatures API
|
||||
|
||||
This release introduces TWO new signing modes:
|
||||
|
||||
+187
-47
@@ -5,64 +5,92 @@
|
||||
|
||||
## 0. Choose your mode — the most important decision
|
||||
|
||||
Magnetar ships **two distinct signing modes** with different trust
|
||||
Magnetar ships **three distinct signing surfaces** with different trust
|
||||
models. Picking the wrong one is the single largest deployment risk.
|
||||
|
||||
| Property | `AggregateSignatures` (public-BFT) | `CombineWithSeedReconstruction` (custody) |
|
||||
|---|---|---|
|
||||
| Output | N separate FIPS 205 signatures | One FIPS 205 signature |
|
||||
| Aggregator-as-TCB | NO (no party holds the master seed) | YES (master seed live in aggregator) |
|
||||
| Public-BFT-safe | YES | NO — requires TEE |
|
||||
| Validator key model | Per-validator, independent keypairs | Shared via DKG + Shamir-split seed |
|
||||
| DKG required | NO | YES |
|
||||
| Wire size | N × \|σ\| (e.g. 21 × 16 KiB = 336 KiB at L3) | \|σ\| (16 KiB at L3) |
|
||||
| Z-Chain Groth16-compressible | YES (~192 B proof) | N/A (already 1 σ) |
|
||||
| Module | `aggregate.go` | `combine.go` |
|
||||
| File | `ref/go/pkg/magnetar/aggregate.go` | `ref/go/pkg/magnetar/combine.go` |
|
||||
| Use case | Lux public-BFT validator quorum | M-Chain custody w/ TEE attestation |
|
||||
| Surface | Trust model | Public-BFT safe | Use case |
|
||||
|---|---|---|---|
|
||||
| `ValidatorSign` + `VerifyAggregateCert` | Per-validator standalone keys | **YES** | **Lux public-BFT validator quorum (PRIMARY)** |
|
||||
| `AggregateSignatures` (SignedBundle form) | Per-validator standalone keys | YES | Same as above, SDK-friendly bundle shape |
|
||||
| `CombineWithSeedReconstruction` | Aggregator-as-TCB (TEE required) | NO | M-Chain custody w/ TEE attestation |
|
||||
| `thbs.DealerDKG` + `thbs.SignShare` + `thbs.Aggregate` | Trusted-dealer-as-TCB (TEE required) | NO | M-Chain bridge custody w/ TEE attestation |
|
||||
| `thbs/dkg2/` | Public PVSS + MPC (research) | YES (when complete) | Future research; v0.6+ candidate |
|
||||
|
||||
### TL;DR
|
||||
|
||||
- **PUBLIC BFT CONSENSUS** (validator quorum, untrusted aggregator
|
||||
host) → `magnetar.PerValidatorKeypair` + `magnetar.ValidatorSign` +
|
||||
`magnetar.VerifyAggregateCert` (`ref/go/pkg/magnetar/standalone.go`).
|
||||
This is the **CANONICAL primary primitive for Lux public-BFT
|
||||
post-quantum consensus**. Per-validator standalone SLH-DSA keypairs;
|
||||
no DKG; no dealer; no aggregator-as-TCB. The consensus layer
|
||||
collects N validator signatures and a relying party calls
|
||||
`VerifyAggregateCert` to obtain the count of valid signers. Wire
|
||||
cost is N × |σ|; Z-Chain Groth16 rollup compresses to ~192 bytes.
|
||||
See §10.
|
||||
|
||||
- **PUBLIC BFT, SDK SHAPE** (same model as above, but the
|
||||
consumer prefers the nested-bundle wire format) →
|
||||
`magnetar.AggregateSignatures` + `magnetar.VerifyAggregated`
|
||||
(`ref/go/pkg/magnetar/aggregate.go`). Byte-equivalent to the
|
||||
standalone form; differs only in wire encoding shape.
|
||||
|
||||
- **M-CHAIN CUSTODY with TEE** (M-Chain thresholdvm host with
|
||||
TDX/SEV-SNP/SGX attestation, the LP-134 thresholdvm M-Chain mode)
|
||||
→ EITHER:
|
||||
* `magnetar.CombineWithSeedReconstruction` (reveal-and-aggregate;
|
||||
master seed reconstructed in aggregator memory; trust caveat in
|
||||
§1 applies), OR
|
||||
* `thbs.DealerDKG` + `thbs.SignShare` + `thbs.Aggregate` (true
|
||||
threshold HBS per McGrew et al.; dealer learns secrets at
|
||||
setup time; per-signature reveals only the SELECTED elements).
|
||||
Both REQUIRE TEE attestation of the dealer/aggregator host.
|
||||
|
||||
- **RESEARCH / FUTURE** (public-DKG threshold HBS, no dealer, no
|
||||
TEE) → `thbs/dkg2/` ships the PVSS layer as a skeleton. The
|
||||
MPC-root layer is OPEN RESEARCH; see `BLOCKERS.md::MAGNETAR-PUBLIC-
|
||||
DKG-1`. Will be the public-BFT-safe THRESHOLD path in v0.6+
|
||||
once an MPC framework or MPC-friendly hash is selected.
|
||||
|
||||
- **Anything else** → `magnetar.ValidatorSign`. The reveal-and-
|
||||
aggregate and dealer-backed-threshold paths are NOT safe for
|
||||
public deployments.
|
||||
|
||||
### Decision tree
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Who needs to verify the σ? │
|
||||
└──────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ WHO NEEDS TO VERIFY THIS SIGNATURE? │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
│ │
|
||||
Public, untrusted Internal,
|
||||
verifiers (BFT chain, pre-attested
|
||||
external relayers, etc.) TCB
|
||||
PUBLIC, INTERNAL,
|
||||
untrusted verifiers pre-attested TCB
|
||||
(BFT chain, (M-Chain custody,
|
||||
external relayers) thresholdvm)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ AggregateSignatures │ │ Is an aggregator │
|
||||
│ (public-BFT) │ │ host attested by TEE│
|
||||
│ │ │ (TDX/SEV-SNP/SGX) ? │
|
||||
│ + Z-Chain Groth16 │ └─────────────────────┘
|
||||
│ rollup for size │ │
|
||||
│ compression │ ┌──────────────┴──────────────┐
|
||||
└─────────────────────┘ │ │
|
||||
YES NO
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ CombineWithSeed- │ │ AggregateSignatures │
|
||||
│ Reconstruction │ │ — do NOT use Combine│
|
||||
│ (custody / M-Chain) │ │ WithSeedReconstr. │
|
||||
└─────────────────────┘ │ outside TEE! │
|
||||
└─────────────────────┘
|
||||
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ magnetar. │ │ Is an aggregator/dealer host│
|
||||
│ ValidatorSign + │ │ attested by TEE │
|
||||
│ VerifyAggregateCert │ │ (TDX/SEV-SNP/SGX)? │
|
||||
│ │ └─────────────────────────────┘
|
||||
│ (PRIMARY public-BFT- │ │
|
||||
│ safe primitive; │ ┌───────────┴───────────┐
|
||||
│ per-validator │ │ │
|
||||
│ standalone keys) │ YES NO
|
||||
│ │ │ │
|
||||
│ + Z-Chain Groth16 │ ▼ ▼
|
||||
│ rollup compresses │ ┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ N × |σ| → ~192 B │ │ Pick one of: │ │ magnetar.ValidatorSign│
|
||||
└─────────────────────────┘ │ - magnetar.Combine- │ │ — DO NOT use the │
|
||||
│ WithSeedReconstr. │ │ dealer / reveal-and- │
|
||||
│ - thbs.DealerDKG + │ │ aggregate paths │
|
||||
│ thbs.SignShare │ │ outside TEE! │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
### TL;DR
|
||||
- **PUBLIC BFT** (validator quorum, untrusted aggregator host) →
|
||||
`AggregateSignatures` (N separate sigs). See §10.
|
||||
- **M-CHAIN CUSTODY with TEE** (M-Chain thresholdvm host with
|
||||
TDX/SEV-SNP/SGX attestation) → `CombineWithSeedReconstruction`.
|
||||
Trust caveat in §1 applies — operator MUST enforce the §1 mitigations.
|
||||
- **Anything else** → `AggregateSignatures`. The reveal-and-aggregate
|
||||
path is NOT safe for public deployments.
|
||||
|
||||
## 1. `CombineWithSeedReconstruction` (v0.1 reveal-and-aggregate) trust caveat
|
||||
|
||||
**The Magnetar `CombineWithSeedReconstruction` aggregator process holds the reconstructed master SLH-DSA seed in memory for the duration of one call.**
|
||||
@@ -169,10 +197,122 @@ Operators deploying Magnetar v0.1 do so at their own risk and SHOULD layer it wi
|
||||
## 8. Cross-references
|
||||
|
||||
- `SPEC.md` — full protocol specification
|
||||
- `BLOCKERS.md` — Tier B → A path
|
||||
- `BLOCKERS.md` — Tier B → A path (incl. `MAGNETAR-PUBLIC-DKG-1` for the public-DKG research path)
|
||||
- `SUBMISSION-STATUS.md` — NIST MPTC submission status
|
||||
- `THBS-SPEC.md` — true threshold HBS subpackage scope and v1/v2/v3 roadmap
|
||||
- `ref/go/pkg/thbs/dkg2/README.md` — public-DKG skeleton + research path
|
||||
- [`luxfi/pulsar/DEPLOYMENT-RUNBOOK.md`](https://github.com/luxfi/pulsar/blob/main/DEPLOYMENT-RUNBOOK.md) — sibling runbook for the threshold M-LWE primitive
|
||||
|
||||
## 9. Public-BFT primary primitive: `ValidatorSign` + `VerifyAggregateCert`
|
||||
|
||||
This is the **CANONICAL primary primitive** for using Magnetar in a
|
||||
Lux public-BFT validator quorum. It does NOT require an aggregator
|
||||
or dealer in the TCB. **This is the path you SHOULD pick** unless
|
||||
§0's decision tree points elsewhere.
|
||||
|
||||
### Construction summary
|
||||
|
||||
1. **Per-validator keygen** (one-time, off-chain): each validator `i`
|
||||
independently runs `magnetar.PerValidatorKeypair(params, rng)` to
|
||||
produce its own (sk_i, pk_i). **No DKG.** **No shared seed.** **No
|
||||
resharing ceremony.** The validator-set registry binds
|
||||
`(ValidatorID_i, pk_i)` so peers can verify this validator's
|
||||
signatures.
|
||||
|
||||
2. **Per-validator sign** (one signature per block): each validator
|
||||
`i` calls `magnetar.ValidatorSign(sk_i, nil, message)` to produce
|
||||
the raw FIPS 205 signature bytes (length = params.SignatureSize).
|
||||
The signature is byte-identical to single-party FIPS 205
|
||||
SignDeterministic and verifies under unmodified `slhdsa.Verify`.
|
||||
Bind chain-id / block-height into `message` upstream of this call
|
||||
(the consensus layer's transcript-hash pattern handles this; see
|
||||
`luxfi/consensus` `QuasarCert`).
|
||||
|
||||
3. **Build aggregate cert** (consensus layer): collect N
|
||||
per-validator signatures and assemble into a
|
||||
`ValidatorAggregateCert{Mode, Signers, PubKeys, Sigs}` via
|
||||
`magnetar.BuildAggregateCert(params, signers, pubKeys, sigs)`.
|
||||
The cert's parallel-slice shape maps directly onto a Z-Chain
|
||||
Groth16 rollup witness — N parallel signatures become N parallel
|
||||
circuit inputs.
|
||||
|
||||
4. **Verify aggregate cert** (relying party): a verifier calls
|
||||
`magnetar.VerifyAggregateCert(cert, message, knownValidators)`
|
||||
which returns the COUNT of valid signers. The verifier compares
|
||||
the count against its quorum threshold to make the policy
|
||||
decision. Unknown / pubkey-mismatched signers are counted as
|
||||
INVALID but are NOT fatal — the cert may carry a stale-registry
|
||||
tail that the verifier should not abort on.
|
||||
|
||||
### Security model
|
||||
|
||||
| Property | `ValidatorSign` + `VerifyAggregateCert` | Notes |
|
||||
|---|---|---|
|
||||
| **Unforgeability** | FIPS 205-grade per signer | Each σ_i is a standard FIPS 205 signature; forgery reduces to the FIPS 205 EUF-CMA assumption per signer. |
|
||||
| **No single-host TCB** | YES | No party ever holds N validators' secret material together. Compromising one validator host leaks ONE sk_i, not the group key. |
|
||||
| **No dealer-in-TCB** | YES | Each validator generates its keypair INDEPENDENTLY; no party ever sees another validator's secret material. |
|
||||
| **No aggregator-in-TCB** | YES | The aggregator is a pure-public-data shape-checker; the cert's Verify step does not require the aggregator to be trusted. |
|
||||
| **Quorum byzantine tolerance** | Yes (consensus-layer policy) | The count returned by `VerifyAggregateCert` is compared against the consensus threshold (e.g. 2f+1 for f Byzantine faults). |
|
||||
| **Per-validator slashing** | YES | Each σ_i is independently attributable to ValidatorID_i; the consensus layer can publish proof-of-double-sign on (ValidatorID_i, σ_a, σ_b, m_a, m_b). |
|
||||
| **Post-quantum hardness** | FIPS 205 (~192-bit at SHAKE-192s) | All N signatures use the SAME FIPS 205 parameter set; no hybrid weakening. |
|
||||
| **Replay resistance** | Caller-bound message | Bind chain-id / block-height into `message` upstream. |
|
||||
|
||||
### Why this is the right primary primitive for SLH-DSA in public BFT
|
||||
|
||||
SLH-DSA is HASH-BASED. It has no algebraic structure that admits
|
||||
FROST-style threshold aggregation. The literature confirms this is
|
||||
fundamental (Cozzo-Smart EUROCRYPT 2019; Bonte-Smart-Tan 2023; NIST
|
||||
IR 8214). Any true-threshold SLH-DSA construction requires either:
|
||||
|
||||
- A **dealer-in-TCB** (the v1 `CombineWithSeedReconstruction` and
|
||||
`thbs.DealerDKG` paths — REQUIRES TEE), or
|
||||
- A **full MPC over the SHA-256/SHAKE hash tree** (research-grade,
|
||||
multi-second to multi-hour per signature; tracked at
|
||||
`BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1`).
|
||||
|
||||
For PUBLIC BFT consensus, the correct architecture is "N separate
|
||||
signatures, count valid ones, apply policy threshold". The
|
||||
`ValidatorSign` + `VerifyAggregateCert` path expresses exactly this.
|
||||
The wire cost is N × |σ|; this is the irreducible cost of being
|
||||
TCB-free on a hash-based signature scheme.
|
||||
|
||||
The Z-Chain Groth16 rollup compresses N × |σ| to ~192 bytes when the
|
||||
relying party cannot consume the raw N-bundle envelope. The rollup
|
||||
is a separate primitive (lux-zchain spec); Magnetar produces its
|
||||
input but does NOT consume or verify the proof.
|
||||
|
||||
### Operational checklist
|
||||
|
||||
- [ ] Generate each validator's keypair on the validator host with a
|
||||
hardware RNG. NEVER share sk_i across hosts.
|
||||
- [ ] Encrypt sk_i at rest with a host-bound key (TPM-sealed or
|
||||
KMS-wrapped). NEVER write sk_i to disk in plaintext.
|
||||
- [ ] Publish (ValidatorID_i, pk_i) to the validator-set registry
|
||||
via the same on-chain registration flow you'd use for
|
||||
classical BLS validators.
|
||||
- [ ] The consensus layer SHOULD parallelize verify by routing
|
||||
`cert` bytes through `luxfi/crypto/slhdsa.VerifyBatch` for GPU
|
||||
substrate acceleration (see `crypto/slhdsa/gpu.go`). Magnetar's
|
||||
goroutine-parallel CPU path is the floor; the upstream GPU
|
||||
substrate is the ceiling.
|
||||
- [ ] On Byzantine fault detection (double-sign), produce slashing
|
||||
evidence at the consensus layer.
|
||||
|
||||
### Honest non-warranties
|
||||
|
||||
- **No formal proof** of the aggregate construction beyond FIPS
|
||||
205's per-signature security. The construction is a thin layer
|
||||
over N independent FIPS 205 signatures — its security argument is
|
||||
"N × FIPS 205 EUF-CMA", which is sufficient but not separately
|
||||
formalized in EasyCrypt/Lean.
|
||||
- **No dudect on `VerifyAggregateCert`.** The function dispatches
|
||||
over per-validator data which is public (NodeID + PublicKey are
|
||||
network-observable). The signature verify call inherits the
|
||||
dudect coverage of single-party FIPS 205 verify.
|
||||
- **No Groth16 verifier ships in this package.** The Z-Chain rollup
|
||||
is a separate primitive — Magnetar produces the input to it but
|
||||
does not consume or verify the proof.
|
||||
|
||||
## 10. Public-BFT aggregate mode (`AggregateSignatures`)
|
||||
|
||||
The aggregate mode is the **public-BFT-safe path** for using Magnetar in a validator quorum. It does NOT require the aggregator to be in the TCB and does NOT rely on the reveal-and-aggregate construction. This is the path you SHOULD pick unless §0's decision tree points elsewhere.
|
||||
@@ -223,6 +363,6 @@ The aggregate mode is the **public-BFT-safe path** for using Magnetar in a valid
|
||||
**Document metadata**
|
||||
|
||||
- File: `DEPLOYMENT-RUNBOOK.md`
|
||||
- Version: v0.4.2 (adds §0 mode chooser + §10 aggregate mode; renames §1 to `CombineWithSeedReconstruction`)
|
||||
- Version: v0.5.0 (rewrites §0 mode chooser to direct PUBLIC BFT users to `ValidatorSign` + `VerifyAggregateCert`; adds §9 documenting the new primary primitive; honestly demotes `CombineWithSeedReconstruction` and `thbs.DealerDKG` to the M-Chain custody / TEE-attested path; cites `thbs/dkg2/` skeleton + `BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1` as the public-DKG research path)
|
||||
- Date: 2026-05-21
|
||||
- Status: operator-facing trust-model disclosure
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package magnetar
|
||||
|
||||
// standalone.go — Per-validator standalone SLH-DSA: the PRIMARY,
|
||||
// CANONICAL primitive for public-BFT post-quantum consensus.
|
||||
//
|
||||
// HONEST ARCHITECTURE FRAMING
|
||||
// ===========================
|
||||
//
|
||||
// SLH-DSA (FIPS 205) is HASH-BASED. It has no algebraic structure
|
||||
// that admits FROST-style threshold aggregation. The literature
|
||||
// confirms this is fundamental:
|
||||
//
|
||||
// - Cozzo & Smart (EUROCRYPT 2019), "Sharing the LUOV": every
|
||||
// internal SHAKE/SHA-2 evaluation in an HBS scheme is a
|
||||
// non-linear function of the secret seed. Secret-sharing the
|
||||
// seed forces every hash to be evaluated under MPC.
|
||||
// - Bonte, Smart, Tan (2023), "Threshold SPHINCS+": exhaustive
|
||||
// infeasibility analysis. There is no efficient t-of-n SLH-DSA
|
||||
// scheme producing a single FIPS 205-shaped signature without
|
||||
// either (a) reconstructing the seed (dealer or aggregator in
|
||||
// TCB) or (b) running full MPC over the SHA-256/SHAKE hash tree
|
||||
// (multi-second per signature, multi-megabyte of comms).
|
||||
// - NIST IR 8214 / MPTC submission notes: SLH-DSA is classified
|
||||
// as "highest threshold-MPC cost" among FIPS 20{3,4,5}.
|
||||
// - FIPS 205 §6 (slh_sign_internal): direct inspection confirms
|
||||
// no Lagrange-aggregatable response.
|
||||
//
|
||||
// THE PUBLIC-BFT-SAFE PRIMARY PRIMITIVE for SLH-DSA is therefore
|
||||
// "N separate signatures" — each validator i holds its OWN keypair
|
||||
// (sk_i, pk_i), each produces a single-party FIPS 205 σ_i over the
|
||||
// message, and the consensus layer collects N of them into a
|
||||
// ValidatorAggregateCert:
|
||||
//
|
||||
// ValidatorAggregateCert = (Mode, Signers, PubKeys, Sigs)
|
||||
//
|
||||
// Verification:
|
||||
//
|
||||
// valid_count = Σ_i 1[FIPS 205 Verify(pk_i, msg, σ_i) ∧ pk_i = registry(i)]
|
||||
// accept iff valid_count ≥ quorum_threshold
|
||||
//
|
||||
// Wire size: N × |σ|. For SHAKE-192s (16,224-byte σ) and a 21-validator
|
||||
// quorum that's ~332 KiB. A Z-Chain Groth16 rollup compresses this to
|
||||
// ~192 bytes; the rollup is a SEPARATE primitive (lux-zchain spec),
|
||||
// not part of this API.
|
||||
//
|
||||
// WHY NOT THRESHOLD HBS FOR PUBLIC CONSENSUS
|
||||
// ==========================================
|
||||
//
|
||||
// The thbs subpackage (ref/go/pkg/thbs/) ships TRUE threshold HBS
|
||||
// per McGrew et al. ePrint 2019/793. Its v1 setup is DEALER-BACKED:
|
||||
// a single dealer generates the per-element secrets and Shamir-shares
|
||||
// them. Dealer-backed threshold HBS is RESEARCHED but NOT public-BFT-
|
||||
// safe (the dealer learns every secret element at setup time).
|
||||
//
|
||||
// A truly public-DKG threshold HBS requires:
|
||||
// (A) PVSS distribution of per-element entropy contributions:
|
||||
// x_{slot,e} = Σ_j r_{j,slot,e}; no party holds x.
|
||||
// (B) MPC over the SHA-256/SHAKE hash tree to compute the public
|
||||
// Merkle root from secret-shared leaves WITHOUT REVEALING the
|
||||
// leaves. This is the hard frontier (Damgård-Pastro-Smart-
|
||||
// Zakarias SPDZ 2012, Wang-Ranellucci-Katz "Global-Scale Secure
|
||||
// Multiparty Computation" 2017, Boyle-Gilboa-Ishai "Function
|
||||
// Secret Sharing" 2015).
|
||||
//
|
||||
// (A) is shipped at thbs/dkg2/ as a skeleton. (B) is open research
|
||||
// tracked at BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1. Until (B) lands,
|
||||
// thbs is APPROPRIATE for M-Chain bridge custody (where a TEE'd
|
||||
// dealer is in the TCB by policy) but NOT for public BFT.
|
||||
//
|
||||
// FOR PUBLIC BFT: USE THIS FILE.
|
||||
//
|
||||
// API CONTRACT
|
||||
// ============
|
||||
//
|
||||
// - PerValidatorKeypair generates a STANDALONE FIPS 205 keypair.
|
||||
// No DKG. No dealer. No shared seed. Each validator runs this
|
||||
// ONCE, persists (sk_i, pk_i), and registers pk_i in the
|
||||
// validator-set commitment.
|
||||
//
|
||||
// - ValidatorSign is the canonical signing primitive. Equivalent
|
||||
// to single-party FIPS 205 SignDeterministic on (sk_i, message).
|
||||
// The output is a raw |σ|-byte slice ready for the consensus
|
||||
// layer's wire format. Pass rng != nil only if you want the
|
||||
// FIPS 205 RANDOMIZED variant; the default (rng == nil) is the
|
||||
// deterministic variant matching SignDeterministic byte-for-byte.
|
||||
//
|
||||
// - ValidatorBatchVerify verifies N (pub, msg, sig) triples in
|
||||
// parallel via the same goroutine fork-join pattern used in
|
||||
// luxfi/crypto/slhdsa.VerifyBatch. Returns a bitmap of
|
||||
// valid/invalid per signer.
|
||||
//
|
||||
// - ValidatorAggregateCert is the wire envelope shipped in a
|
||||
// QuasarCert or rolled up via Z-Chain Groth16. Carries N
|
||||
// independently-verifiable signatures.
|
||||
//
|
||||
// - VerifyAggregateCert verifies the entire bundle against a
|
||||
// known-validator allowlist. Signatures from unknown validators
|
||||
// are counted as INVALID (defense against impersonation). The
|
||||
// function returns the COUNT of valid signers; the quorum
|
||||
// decision lives at the consumer.
|
||||
//
|
||||
// DECOMPLECTING DISCIPLINE
|
||||
// ========================
|
||||
//
|
||||
// - The signing primitive (ValidatorSign) takes ONE secret key and
|
||||
// produces ONE signature. It does NOT reach for any aggregator,
|
||||
// ceremony state, or shared material. A single-validator signing
|
||||
// a single message is a single function call with NO surrounding
|
||||
// protocol context.
|
||||
//
|
||||
// - The verification primitive (ValidatorBatchVerify) takes N
|
||||
// PUBLIC inputs and returns N independent booleans. No
|
||||
// cross-signer coupling.
|
||||
//
|
||||
// - The cert primitive (ValidatorAggregateCert / VerifyAggregateCert)
|
||||
// adds REGISTRY BINDING (knownValidators) on top of batch verify.
|
||||
// The registry is the consensus layer's authoritative validator-
|
||||
// set; the cert primitive consumes it but does not own it.
|
||||
//
|
||||
// - The quorum decision (count ≥ threshold) lives at the CONSUMER.
|
||||
// This primitive REPORTS facts; policy gates filter them.
|
||||
//
|
||||
// COMPOSITION WITH THE EXISTING AGGREGATE API
|
||||
// ===========================================
|
||||
//
|
||||
// The aggregate.go API (SignedBundle, AggregateSignatures,
|
||||
// VerifyAggregated, etc.) ships the SAME computational graph under
|
||||
// a "bundle / aggregate" shape: bundles carry self-describing
|
||||
// PublicKey bytes in the envelope; AggregateSignatures dedupes and
|
||||
// shape-checks; VerifyAggregated cross-checks against a registry.
|
||||
// That API STAYS — it is the SDK-friendly form.
|
||||
//
|
||||
// The standalone.go API is the EXPLICIT-SEMANTIC form for the
|
||||
// consensus layer: ValidatorSign returns raw σ bytes that flow
|
||||
// straight into a wire envelope without going through SignedBundle;
|
||||
// ValidatorAggregateCert carries the (PubKeys, Sigs, Signers)
|
||||
// triple as parallel slices ready to be Groth16-compressed.
|
||||
//
|
||||
// Both APIs are byte-compatible: a ValidatorAggregateCert built
|
||||
// from N ValidatorSign outputs verifies under VerifyAggregated
|
||||
// after a trivial conversion (see toSignedBundles below). The
|
||||
// duplication is DELIBERATE — bundles are the SDK shape;
|
||||
// aggregate-cert is the consensus-wire shape.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// (crypto/rand is reached via Sign indirectly; this file does not
|
||||
// directly source randomness.)
|
||||
|
||||
// Errors returned by the standalone (public-BFT) API.
|
||||
var (
|
||||
// ErrAggregateCertEmpty is returned by VerifyAggregateCert when
|
||||
// the cert carries zero signers.
|
||||
ErrAggregateCertEmpty = errors.New("magnetar: aggregate cert has no signers")
|
||||
|
||||
// ErrAggregateCertShape is returned when the parallel slices
|
||||
// (Signers, PubKeys, Sigs) have mismatched lengths or any
|
||||
// individual entry has the wrong byte length for the cert's Mode.
|
||||
ErrAggregateCertShape = errors.New("magnetar: aggregate cert has mismatched slice shapes")
|
||||
|
||||
// ErrAggregateCertMode is returned when a cert carries a Mode
|
||||
// the params do not match.
|
||||
ErrAggregateCertMode = errors.New("magnetar: aggregate cert mode mismatch")
|
||||
)
|
||||
|
||||
// PerValidatorKeypair generates a STANDALONE SLH-DSA keypair for
|
||||
// one validator.
|
||||
//
|
||||
// Public-BFT contract:
|
||||
//
|
||||
// - No DKG, no shares, no dealer, no MPC.
|
||||
// - Each validator runs this INDEPENDENTLY on its own host with
|
||||
// its own RNG.
|
||||
// - The validator persists (sk, pk) — losing sk means dropping
|
||||
// out of the quorum until rotating to a fresh keypair.
|
||||
// - The consensus layer's validator-set registry binds
|
||||
// (ValidatorID, pk) so peers can verify this validator's
|
||||
// signatures.
|
||||
//
|
||||
// This is the FIPS 205 keygen primitive: the returned PublicKey is
|
||||
// byte-identical to what circl/slhdsa.Scheme().DeriveKey would emit
|
||||
// on the same seed; the returned PrivateKey carries the seed and
|
||||
// can be used with ValidatorSign or any other Magnetar sign path.
|
||||
//
|
||||
// rng may be nil (crypto/rand is used). Pass a deterministic
|
||||
// reader for KAT-reproducible key generation.
|
||||
//
|
||||
// Equivalent semantics to GenerateValidatorKey in aggregate.go;
|
||||
// this is the EXPLICIT-SEMANTIC name for the public-BFT path.
|
||||
func PerValidatorKeypair(params *Params, rng io.Reader) (*PrivateKey, *PublicKey, error) {
|
||||
return GenerateValidatorKey(params, rng)
|
||||
}
|
||||
|
||||
// ValidatorSign is the canonical public-BFT signing primitive.
|
||||
//
|
||||
// Returns the raw FIPS 205 signature bytes (length =
|
||||
// params.SignatureSize for sk's mode). The signature verifies under
|
||||
// unmodified circl/slhdsa.Verify against the validator's public key
|
||||
// (sk.Pub.Bytes).
|
||||
//
|
||||
// Semantics: this is ONE validator's contribution to a multi-
|
||||
// validator certificate. The consensus layer collects N
|
||||
// ValidatorSign outputs into a ValidatorAggregateCert via
|
||||
// BuildAggregateCert.
|
||||
//
|
||||
// Determinism: when rng is nil, the output is FIPS 205
|
||||
// SignDeterministic — byte-identical across calls for any (sk,
|
||||
// message) pair. This is the recommended mode for the Lux
|
||||
// validator path because it lets the consensus layer dedupe
|
||||
// duplicate broadcasts at the wire layer.
|
||||
//
|
||||
// When rng is non-nil, the output is FIPS 205 SignRandomized with
|
||||
// n bytes of fresh randomness drawn from rng. Use the randomized
|
||||
// variant only if you specifically need the hedged-against-bad-RNG
|
||||
// property; the deterministic variant is the standard recommendation
|
||||
// for consensus.
|
||||
//
|
||||
// ctx is intentionally omitted: this primitive is the wire-format
|
||||
// canonical sign. Bind chain-id / block-height into the message
|
||||
// upstream of this call (the consensus layer's transcript-hash
|
||||
// pattern handles this; see luxfi/consensus QuasarCert).
|
||||
//
|
||||
// If you need an explicit ctx, use Sign in sign.go directly.
|
||||
func ValidatorSign(sk *PrivateKey, rng io.Reader, message []byte) ([]byte, error) {
|
||||
if sk == nil {
|
||||
return nil, ErrNilKey
|
||||
}
|
||||
params, err := ParamsFor(sk.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := Sign(params, sk, message, nil, rng != nil, rng)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sig.Bytes, nil
|
||||
}
|
||||
|
||||
// ValidatorBatchVerify verifies N (pub, msg, sig) triples and
|
||||
// returns a bitmap of valid/invalid per signer.
|
||||
//
|
||||
// Parallelism: this routes through the same goroutine fork-join
|
||||
// pattern as VerifyAggregated (above the
|
||||
// verifyAggregatedConcurrentThreshold the work is forked across
|
||||
// GOMAXPROCS workers). The pattern mirrors
|
||||
// luxfi/crypto/slhdsa.VerifyBatch — when magnetar is embedded in a
|
||||
// system that exports a GPU substrate (e.g. consensus quorum
|
||||
// verification routing through crypto/slhdsa.VerifyBatch with the
|
||||
// luxcpp/lux-accel SLH-DSA GPU plugin loaded), callers SHOULD
|
||||
// route their batch through that upstream primitive for the GPU
|
||||
// upper bound. This package ships the goroutine-parallel CPU
|
||||
// floor.
|
||||
//
|
||||
// Returns:
|
||||
// - bitmap[i] = true iff slhdsa.Verify(pubs[i].Bytes, msgs[i], sigs[i])
|
||||
// accepts. Per-signer verify failures are reported as bitmap[i] =
|
||||
// false, NOT as an error.
|
||||
// - err = nil on success, even when some signatures fail; err is
|
||||
// non-nil only for STRUCTURAL violations (length mismatches,
|
||||
// mode mismatches, nil inputs).
|
||||
//
|
||||
// Provenance: the dispatch tier (serial vs goroutine-parallel) is
|
||||
// observable via LastVerifyAggregatedTier — the same hook the
|
||||
// aggregate.go path uses, since ValidatorBatchVerify and
|
||||
// VerifyAggregated share the same parallel-CPU dispatch.
|
||||
func ValidatorBatchVerify(params *Params, pubs []*PublicKey, msgs [][]byte, sigs [][]byte) ([]bool, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := len(pubs)
|
||||
if n != len(msgs) || n != len(sigs) {
|
||||
return nil, ErrAggregateCertShape
|
||||
}
|
||||
out := make([]bool, n)
|
||||
if n == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// Shape + mode checks first; any structural failure is fatal so
|
||||
// the caller does not consume a half-validated bitmap.
|
||||
for i := 0; i < n; i++ {
|
||||
if pubs[i] == nil {
|
||||
return nil, ErrNilPublicKey
|
||||
}
|
||||
if pubs[i].Mode != params.Mode {
|
||||
return nil, ErrModeMismatch
|
||||
}
|
||||
if len(pubs[i].Bytes) != params.PublicKeySize {
|
||||
return nil, ErrPublicKeyWrongSize
|
||||
}
|
||||
if len(sigs[i]) != params.SignatureSize {
|
||||
return nil, ErrSignatureWrongSize
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-bundle AggregatedSignature view so we can reuse
|
||||
// the existing parallel-CPU dispatch in VerifyAggregated. We
|
||||
// intentionally do NOT pass a knownValidators map here — the
|
||||
// bitmap path is for the lower-level case where the registry
|
||||
// binding is handled by the caller (VerifyAggregateCert is the
|
||||
// registry-binding wrapper).
|
||||
//
|
||||
// We dispatch directly so we get the same provenance hook
|
||||
// (LastVerifyAggregatedTier) without paying the dedupe pass —
|
||||
// in this primitive the caller has already chosen the input
|
||||
// order.
|
||||
dispatchTier := verifyAggregatedSerial
|
||||
if n >= verifyAggregatedConcurrentThreshold {
|
||||
dispatchTier = verifyAggregatedConcurrent
|
||||
validatorBatchVerifyConcurrent(params, pubs, msgs, sigs, out)
|
||||
} else {
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = slhVerify(params.ID, pubs[i].Bytes, msgs[i], nil, sigs[i])
|
||||
}
|
||||
}
|
||||
recordVerifyAggregatedTier(dispatchTier)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validatorBatchVerifyConcurrent runs per-signer Verify across
|
||||
// GOMAXPROCS goroutines for the message-batch case (each signer
|
||||
// gets its own message). Pure function per signer, no shared
|
||||
// state — same byte-equality contract as the serial path and as
|
||||
// VerifyAggregated's parallel dispatch.
|
||||
func validatorBatchVerifyConcurrent(params *Params, pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool) {
|
||||
n := len(pubs)
|
||||
workers := runtimeGOMAXPROCS()
|
||||
if workers > n {
|
||||
workers = n
|
||||
}
|
||||
if workers < 2 {
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = slhVerify(params.ID, pubs[i].Bytes, msgs[i], nil, sigs[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type job struct {
|
||||
lo, hi int
|
||||
}
|
||||
jobs := make([]job, 0, workers)
|
||||
chunk := (n + workers - 1) / workers
|
||||
for w := 0; w < workers; w++ {
|
||||
start := w * chunk
|
||||
if start >= n {
|
||||
break
|
||||
}
|
||||
end := start + chunk
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
jobs = append(jobs, job{lo: start, hi: end})
|
||||
}
|
||||
done := make(chan struct{}, len(jobs))
|
||||
for _, j := range jobs {
|
||||
go func(lo, hi int) {
|
||||
for i := lo; i < hi; i++ {
|
||||
out[i] = slhVerify(params.ID, pubs[i].Bytes, msgs[i], nil, sigs[i])
|
||||
}
|
||||
done <- struct{}{}
|
||||
}(j.lo, j.hi)
|
||||
}
|
||||
for i := 0; i < len(jobs); i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeGOMAXPROCS wraps runtime.GOMAXPROCS(0) so callers don't
|
||||
// re-import runtime here.
|
||||
func runtimeGOMAXPROCS() int {
|
||||
return runtime.GOMAXPROCS(0)
|
||||
}
|
||||
|
||||
// ValidatorAggregateCert is the wire envelope for N per-validator
|
||||
// SLH-DSA signatures over a SINGLE message.
|
||||
//
|
||||
// Wire layout (canonical, parallel-slices form):
|
||||
//
|
||||
// Mode || N || Signers[0] || ... || Signers[N-1]
|
||||
// || PubKeys[0] || ... || PubKeys[N-1]
|
||||
// || Sigs[0] || ... || Sigs[N-1]
|
||||
//
|
||||
// where:
|
||||
// - Mode is one byte.
|
||||
// - N is a 4-byte big-endian count.
|
||||
// - Each Signers[i] is 32 bytes (NodeID).
|
||||
// - Each PubKeys[i] is params.PublicKeySize bytes.
|
||||
// - Each Sigs[i] is params.SignatureSize bytes.
|
||||
//
|
||||
// (Magnetar's reference impl does NOT pin a specific transport
|
||||
// encoding — that's the responsibility of the consensus layer.
|
||||
// RLP / protobuf / canonical binary are all reasonable choices.
|
||||
// The parallel-slices form is INTENTIONALLY chosen over a nested
|
||||
// SignedBundle list because it maps directly to Z-Chain Groth16
|
||||
// rollup input where the prover's wire shape is N parallel
|
||||
// witnesses, not N records.)
|
||||
//
|
||||
// All N signatures are over the SAME message. If the consensus
|
||||
// layer needs distinct per-signer messages (e.g. per-signer
|
||||
// transcript binding), use ValidatorBatchVerify directly.
|
||||
//
|
||||
// This shape is the SIBLING of AggregatedSignature (aggregate.go):
|
||||
// - AggregatedSignature: nested SignedBundle list, SDK-friendly,
|
||||
// self-describing per-bundle.
|
||||
// - ValidatorAggregateCert: parallel-slices, consensus-wire
|
||||
// friendly, Z-Chain-rollup-friendly.
|
||||
//
|
||||
// Both are byte-equivalent under conversion (see toSignedBundles).
|
||||
type ValidatorAggregateCert struct {
|
||||
// Mode is the FIPS 205 parameter set every signature targets.
|
||||
Mode Mode
|
||||
|
||||
// Signers carries each signer's canonical 32-byte validator
|
||||
// identifier (NodeID).
|
||||
Signers []NodeID
|
||||
|
||||
// PubKeys carries each signer's FIPS 205 public-key bytes.
|
||||
// Length per entry = params.PublicKeySize. Length of slice =
|
||||
// len(Signers).
|
||||
PubKeys [][]byte
|
||||
|
||||
// Sigs carries each signer's FIPS 205 signature bytes. Length
|
||||
// per entry = params.SignatureSize. Length of slice =
|
||||
// len(Signers).
|
||||
Sigs [][]byte
|
||||
}
|
||||
|
||||
// BuildAggregateCert constructs a ValidatorAggregateCert from N
|
||||
// parallel slices.
|
||||
//
|
||||
// The function performs shape + mode validation and a defensive
|
||||
// copy of each PubKeys[i] / Sigs[i] (the caller's slices may be
|
||||
// aliased; the cert is wire-stable and must not move under the
|
||||
// caller).
|
||||
//
|
||||
// Returns ErrAggregateCertShape on any shape mismatch.
|
||||
func BuildAggregateCert(params *Params, signers []NodeID, pubKeys [][]byte, sigs [][]byte) (*ValidatorAggregateCert, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := len(signers)
|
||||
if n != len(pubKeys) || n != len(sigs) {
|
||||
return nil, ErrAggregateCertShape
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrAggregateCertEmpty
|
||||
}
|
||||
outPubs := make([][]byte, n)
|
||||
outSigs := make([][]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if len(pubKeys[i]) != params.PublicKeySize {
|
||||
return nil, ErrAggregateCertShape
|
||||
}
|
||||
if len(sigs[i]) != params.SignatureSize {
|
||||
return nil, ErrAggregateCertShape
|
||||
}
|
||||
pk := make([]byte, params.PublicKeySize)
|
||||
copy(pk, pubKeys[i])
|
||||
sg := make([]byte, params.SignatureSize)
|
||||
copy(sg, sigs[i])
|
||||
outPubs[i] = pk
|
||||
outSigs[i] = sg
|
||||
}
|
||||
signersCopy := make([]NodeID, n)
|
||||
copy(signersCopy, signers)
|
||||
return &ValidatorAggregateCert{
|
||||
Mode: params.Mode,
|
||||
Signers: signersCopy,
|
||||
PubKeys: outPubs,
|
||||
Sigs: outSigs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyAggregateCert verifies every signature in cert against the
|
||||
// registered public key for that signer and returns the COUNT of
|
||||
// valid signers.
|
||||
//
|
||||
// Verification rules (each enforced per signer):
|
||||
//
|
||||
// 1. cert.Signers[i] must appear in knownValidators. If not, that
|
||||
// signer is COUNTED AS INVALID. This is a defense-in-depth choice
|
||||
// against impersonation: an attacker who slips a SignedBundle
|
||||
// from an unknown validator into the cert does NOT crash the
|
||||
// verifier, but does NOT gain a count either. (Compare to
|
||||
// VerifyAggregated which returns ErrUnknownValidator as a fatal
|
||||
// error; the standalone form is more permissive on registry
|
||||
// misses because the cert may be Groth16-rolled-up over a
|
||||
// stale registry and the verifier should not abort the entire
|
||||
// batch on one stale entry.)
|
||||
//
|
||||
// 2. cert.PubKeys[i] must byte-equal knownValidators[cert.Signers[i]].
|
||||
// A pubkey mismatch is also counted as INVALID (NOT fatal) —
|
||||
// same rationale as (1).
|
||||
//
|
||||
// 3. The signature must verify under the REGISTERED public key (not
|
||||
// the cert's embedded copy, to be paranoid).
|
||||
//
|
||||
// 4. Duplicate signers are deduped: the FIRST occurrence wins and
|
||||
// subsequent occurrences are silently dropped (NOT counted twice,
|
||||
// NOT counted as invalid — the consensus layer can collect proof
|
||||
// of double-signing separately via per-signer Sigs comparison).
|
||||
//
|
||||
// The count is the load-bearing output: a quorum policy decision
|
||||
// ("is N ≥ threshold?") lives at the consumer, NOT in this
|
||||
// primitive. This is the same decomplecting discipline the rest of
|
||||
// the Lux post-quantum stack uses.
|
||||
//
|
||||
// Returns:
|
||||
// - validCount: number of signers that verified, in [0, len(cert.Signers)].
|
||||
// - err: nil on success (including 0 valid signers), or one of
|
||||
// ErrAggregateCertEmpty / ErrAggregateCertShape /
|
||||
// ErrAggregateCertMode when the cert is structurally malformed.
|
||||
func VerifyAggregateCert(cert *ValidatorAggregateCert, message []byte, knownValidators map[NodeID][]byte) (int, error) {
|
||||
if cert == nil {
|
||||
return 0, ErrAggregateCertEmpty
|
||||
}
|
||||
params, err := ParamsFor(cert.Mode)
|
||||
if err != nil {
|
||||
return 0, ErrAggregateCertMode
|
||||
}
|
||||
n := len(cert.Signers)
|
||||
if n == 0 {
|
||||
return 0, ErrAggregateCertEmpty
|
||||
}
|
||||
if n != len(cert.PubKeys) || n != len(cert.Sigs) {
|
||||
return 0, ErrAggregateCertShape
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if len(cert.PubKeys[i]) != params.PublicKeySize {
|
||||
return 0, ErrAggregateCertShape
|
||||
}
|
||||
if len(cert.Sigs[i]) != params.SignatureSize {
|
||||
return 0, ErrAggregateCertShape
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: dedupe Signers, build the verify queue, and
|
||||
// classify each entry as (verifiable, against-which-pk) or
|
||||
// (skip — unknown signer / pubkey mismatch).
|
||||
type entry struct {
|
||||
idx int
|
||||
registered []byte
|
||||
verifiable bool
|
||||
}
|
||||
entries := make([]entry, 0, n)
|
||||
seen := make(map[NodeID]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if _, dup := seen[cert.Signers[i]]; dup {
|
||||
continue
|
||||
}
|
||||
seen[cert.Signers[i]] = struct{}{}
|
||||
known, ok := knownValidators[cert.Signers[i]]
|
||||
if !ok {
|
||||
entries = append(entries, entry{idx: i, verifiable: false})
|
||||
continue
|
||||
}
|
||||
if len(known) != params.PublicKeySize {
|
||||
entries = append(entries, entry{idx: i, verifiable: false})
|
||||
continue
|
||||
}
|
||||
if !ctEqualBytes(cert.PubKeys[i], known) {
|
||||
entries = append(entries, entry{idx: i, verifiable: false})
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{idx: i, registered: known, verifiable: true})
|
||||
}
|
||||
|
||||
// Phase 2: per-signer Verify, parallel above the concurrent
|
||||
// threshold. We build flat slices for the parallel dispatch.
|
||||
verifiableIdx := make([]int, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.verifiable {
|
||||
verifiableIdx = append(verifiableIdx, e.idx)
|
||||
}
|
||||
}
|
||||
results := make(map[int]bool, len(verifiableIdx))
|
||||
if len(verifiableIdx) > 0 {
|
||||
pubs := make([]*PublicKey, len(verifiableIdx))
|
||||
msgs := make([][]byte, len(verifiableIdx))
|
||||
sigs := make([][]byte, len(verifiableIdx))
|
||||
for k, idx := range verifiableIdx {
|
||||
pubs[k] = &PublicKey{Mode: params.Mode, Bytes: cert.PubKeys[idx]}
|
||||
msgs[k] = message
|
||||
sigs[k] = cert.Sigs[idx]
|
||||
}
|
||||
bitmap, vErr := ValidatorBatchVerify(params, pubs, msgs, sigs)
|
||||
if vErr != nil {
|
||||
return 0, vErr
|
||||
}
|
||||
for k, idx := range verifiableIdx {
|
||||
results[idx] = bitmap[k]
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: tally. Verifiable + valid = count++; verifiable +
|
||||
// invalid = count untouched; non-verifiable = count untouched.
|
||||
count := 0
|
||||
for _, e := range entries {
|
||||
if !e.verifiable {
|
||||
continue
|
||||
}
|
||||
if results[e.idx] {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package magnetar
|
||||
|
||||
// standalone_test.go — Tests for the public-BFT-safe per-validator
|
||||
// standalone primitive (standalone.go).
|
||||
//
|
||||
// These tests pin the canonical public-BFT path: each validator
|
||||
// holds its OWN SLH-DSA keypair, signs independently, and the
|
||||
// consensus layer collects N signatures into a
|
||||
// ValidatorAggregateCert. No DKG. No dealer. No shared seed.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPerValidatorKeypair_RoundTrip pins the round-trip:
|
||||
// - PerValidatorKeypair produces a fresh standalone keypair.
|
||||
// - ValidatorSign over the message produces a valid signature.
|
||||
// - That signature verifies under the validator's pk via stock
|
||||
// FIPS 205 Verify (the byte-equality claim).
|
||||
// - Two independent calls with distinct seeds produce DISTINCT
|
||||
// keypairs (no shared seed, no DKG).
|
||||
func TestPerValidatorKeypair_RoundTrip(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
|
||||
sk, pk, err := PerValidatorKeypair(params, newDetReader([]byte("standalone-rt-1")))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair: %v", err)
|
||||
}
|
||||
if sk == nil || pk == nil {
|
||||
t.Fatalf("PerValidatorKeypair returned nil (sk=%v pk=%v)", sk, pk)
|
||||
}
|
||||
if sk.Mode != params.Mode || pk.Mode != params.Mode {
|
||||
t.Fatalf("mode mismatch: sk=%v pk=%v want=%v", sk.Mode, pk.Mode, params.Mode)
|
||||
}
|
||||
if !sk.Pub.Equal(pk) {
|
||||
t.Fatalf("PerValidatorKeypair: sk.Pub != returned pk")
|
||||
}
|
||||
|
||||
msg := []byte("Magnetar standalone-mode per-validator round-trip")
|
||||
sig, err := ValidatorSign(sk, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign: %v", err)
|
||||
}
|
||||
if len(sig) != params.SignatureSize {
|
||||
t.Fatalf("sig len = %d, want %d", len(sig), params.SignatureSize)
|
||||
}
|
||||
|
||||
// Round-trip under stock FIPS 205 dispatch.
|
||||
if !slhVerify(params.ID, pk.Bytes, msg, nil, sig) {
|
||||
t.Fatalf("slhVerify rejected ValidatorSign output")
|
||||
}
|
||||
|
||||
// Determinism: two ValidatorSign calls under the same (sk, msg)
|
||||
// must be byte-identical when rng is nil (FIPS 205
|
||||
// SignDeterministic).
|
||||
sig2, err := ValidatorSign(sk, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign (2nd): %v", err)
|
||||
}
|
||||
if !bytes.Equal(sig, sig2) {
|
||||
t.Fatalf("ValidatorSign nil-rng output is non-deterministic")
|
||||
}
|
||||
|
||||
// Independence: a second PerValidatorKeypair call with a
|
||||
// DIFFERENT seed produces a distinct keypair. "No DKG, no shared
|
||||
// seed" property.
|
||||
sk2, pk2, err := PerValidatorKeypair(params, newDetReader([]byte("standalone-rt-2")))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair (2nd): %v", err)
|
||||
}
|
||||
if bytes.Equal(sk.Bytes, sk2.Bytes) {
|
||||
t.Fatalf("two PerValidatorKeypair calls produced identical sk")
|
||||
}
|
||||
if pk.Equal(pk2) {
|
||||
t.Fatalf("two PerValidatorKeypair calls produced identical pk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatorBatchVerify_5of5_AllValid pins the parallel-CPU
|
||||
// batch-verify path. Five validators sign the same message under
|
||||
// their own keypairs; ValidatorBatchVerify returns true for every
|
||||
// signer.
|
||||
func TestValidatorBatchVerify_5of5_AllValid(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 5
|
||||
pubs, msgs, sigs := buildStandaloneFixture(t, params, N, []byte("standalone-batch-5"))
|
||||
bitmap, err := ValidatorBatchVerify(params, pubs, msgs, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorBatchVerify: %v", err)
|
||||
}
|
||||
if len(bitmap) != N {
|
||||
t.Fatalf("bitmap len = %d, want %d", len(bitmap), N)
|
||||
}
|
||||
for i, ok := range bitmap {
|
||||
if !ok {
|
||||
t.Fatalf("ValidatorBatchVerify[%d] = false, want true", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatorBatchVerify_BadSig_Counted pins the
|
||||
// "counted-out, not fatal" property for tampered signatures.
|
||||
func TestValidatorBatchVerify_BadSig_Counted(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 5
|
||||
pubs, msgs, sigs := buildStandaloneFixture(t, params, N, []byte("standalone-batch-bad"))
|
||||
// Tamper bitmap[2].
|
||||
sigs[2] = append([]byte(nil), sigs[2]...)
|
||||
sigs[2][len(sigs[2])/2] ^= 0x01
|
||||
bitmap, err := ValidatorBatchVerify(params, pubs, msgs, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorBatchVerify: %v", err)
|
||||
}
|
||||
if len(bitmap) != N {
|
||||
t.Fatalf("bitmap len = %d, want %d", len(bitmap), N)
|
||||
}
|
||||
for i, ok := range bitmap {
|
||||
if i == 2 {
|
||||
if ok {
|
||||
t.Fatalf("ValidatorBatchVerify[%d] = true, want false (tampered)", i)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ValidatorBatchVerify[%d] = false, want true", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyAggregateCert_UnknownValidator_Rejected pins the
|
||||
// known-validator allowlist defense. A signer whose ValidatorID is
|
||||
// not in knownValidators is COUNTED AS INVALID (not fatal, by
|
||||
// standalone.go contract).
|
||||
func TestVerifyAggregateCert_UnknownValidator_Rejected(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 3
|
||||
known := make(map[NodeID][]byte, N)
|
||||
signers := make([]NodeID, N)
|
||||
pubKeys := make([][]byte, N)
|
||||
sigs := make([][]byte, N)
|
||||
msg := []byte("standalone-cert-unknown-validator")
|
||||
for i := 0; i < N; i++ {
|
||||
sk, pk, err := PerValidatorKeypair(params, newDetReader([]byte{byte(i), 'U', 'N'}))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[%d]: %v", i, err)
|
||||
}
|
||||
var id NodeID
|
||||
id[0] = byte(i + 1)
|
||||
copy(id[1:], []byte("CERT-UNKNOWN"))
|
||||
signers[i] = id
|
||||
pubKeys[i] = pk.Bytes
|
||||
s, err := ValidatorSign(sk, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[%d]: %v", i, err)
|
||||
}
|
||||
sigs[i] = s
|
||||
// Only register the first two validators in knownValidators.
|
||||
// The third signer is "unknown" to the registry.
|
||||
if i < 2 {
|
||||
known[id] = pk.Bytes
|
||||
}
|
||||
}
|
||||
cert, err := BuildAggregateCert(params, signers, pubKeys, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAggregateCert: %v", err)
|
||||
}
|
||||
count, err := VerifyAggregateCert(cert, msg, known)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAggregateCert: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("VerifyAggregateCert count = %d, want 2 (one unknown signer)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyAggregateCert_DuplicateValidator_DedupedAndCountedOnce
|
||||
// pins dedupe-by-ValidatorID semantics. The same signer appearing
|
||||
// twice in the cert is counted ONCE.
|
||||
func TestVerifyAggregateCert_DuplicateValidator_DedupedAndCountedOnce(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
known := make(map[NodeID][]byte, 2)
|
||||
signers := make([]NodeID, 0, 3)
|
||||
pubKeys := make([][]byte, 0, 3)
|
||||
sigs := make([][]byte, 0, 3)
|
||||
msg := []byte("standalone-cert-duplicate")
|
||||
|
||||
// Validator A.
|
||||
skA, pkA, err := PerValidatorKeypair(params, newDetReader([]byte("dup-A")))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[A]: %v", err)
|
||||
}
|
||||
var idA NodeID
|
||||
idA[0] = 1
|
||||
copy(idA[1:], []byte("DUP-A"))
|
||||
known[idA] = pkA.Bytes
|
||||
|
||||
// Validator B.
|
||||
skB, pkB, err := PerValidatorKeypair(params, newDetReader([]byte("dup-B")))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[B]: %v", err)
|
||||
}
|
||||
var idB NodeID
|
||||
idB[0] = 2
|
||||
copy(idB[1:], []byte("DUP-B"))
|
||||
known[idB] = pkB.Bytes
|
||||
|
||||
sigA, err := ValidatorSign(skA, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[A]: %v", err)
|
||||
}
|
||||
sigB, err := ValidatorSign(skB, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[B]: %v", err)
|
||||
}
|
||||
|
||||
// Build a cert with A appearing TWICE plus B once.
|
||||
signers = append(signers, idA, idA, idB)
|
||||
pubKeys = append(pubKeys, pkA.Bytes, pkA.Bytes, pkB.Bytes)
|
||||
sigs = append(sigs, sigA, sigA, sigB)
|
||||
cert, err := BuildAggregateCert(params, signers, pubKeys, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAggregateCert: %v", err)
|
||||
}
|
||||
count, err := VerifyAggregateCert(cert, msg, known)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAggregateCert: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("VerifyAggregateCert count = %d, want 2 (A deduped, B counted once)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyAggregateCert_GPU_Provenance pins that the cert
|
||||
// verification path routes through the parallel-CPU dispatch
|
||||
// (the goroutine fork-join that mirrors
|
||||
// luxfi/crypto/slhdsa.VerifyBatch). Confirms a batch above the
|
||||
// concurrent threshold takes the parallel tier.
|
||||
func TestVerifyAggregateCert_GPU_Provenance(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skip: -short does not run the cert-provenance check (5 SLH-DSA verifies)")
|
||||
}
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 5
|
||||
known := make(map[NodeID][]byte, N)
|
||||
signers := make([]NodeID, N)
|
||||
pubKeys := make([][]byte, N)
|
||||
sigs := make([][]byte, N)
|
||||
msg := []byte("standalone-cert-provenance")
|
||||
for i := 0; i < N; i++ {
|
||||
sk, pk, err := PerValidatorKeypair(params, newDetReader([]byte{byte(i), 'P', 'V'}))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[%d]: %v", i, err)
|
||||
}
|
||||
var id NodeID
|
||||
id[0] = byte(i + 1)
|
||||
copy(id[1:], []byte("PROV"))
|
||||
signers[i] = id
|
||||
pubKeys[i] = pk.Bytes
|
||||
known[id] = pk.Bytes
|
||||
s, err := ValidatorSign(sk, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[%d]: %v", i, err)
|
||||
}
|
||||
sigs[i] = s
|
||||
}
|
||||
cert, err := BuildAggregateCert(params, signers, pubKeys, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAggregateCert: %v", err)
|
||||
}
|
||||
count, err := VerifyAggregateCert(cert, msg, known)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAggregateCert: %v", err)
|
||||
}
|
||||
if count != N {
|
||||
t.Fatalf("VerifyAggregateCert count = %d, want %d", count, N)
|
||||
}
|
||||
// Provenance: above the concurrent threshold the parallel tier
|
||||
// MUST engage. This is the standalone analog of the
|
||||
// aggregate.go BatchVerify provenance test — observable
|
||||
// evidence that the per-bundle verify uses the goroutine
|
||||
// fork-join pattern (the slhdsa.VerifyBatch parallel path).
|
||||
tier := LastVerifyAggregatedTier()
|
||||
if tier != verifyAggregatedConcurrent {
|
||||
t.Fatalf("LastVerifyAggregatedTier = %s, want %s — cert verify did not dispatch parallel",
|
||||
tier, verifyAggregatedConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatorBatchVerify_ShapeChecks pins the fail-fast structural
|
||||
// errors. Bad inputs do NOT silently produce a half-validated
|
||||
// bitmap.
|
||||
func TestValidatorBatchVerify_ShapeChecks(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 3
|
||||
pubs, msgs, sigs := buildStandaloneFixture(t, params, N, []byte("standalone-shape"))
|
||||
|
||||
// Mismatched slice lengths.
|
||||
if _, err := ValidatorBatchVerify(params, pubs, msgs[:N-1], sigs); err != ErrAggregateCertShape {
|
||||
t.Fatalf("ValidatorBatchVerify msgs-short err = %v, want ErrAggregateCertShape", err)
|
||||
}
|
||||
|
||||
// Wrong signature byte length.
|
||||
badSigs := make([][]byte, N)
|
||||
for i := range badSigs {
|
||||
badSigs[i] = sigs[i]
|
||||
}
|
||||
badSigs[1] = make([]byte, params.SignatureSize-1)
|
||||
if _, err := ValidatorBatchVerify(params, pubs, msgs, badSigs); err != ErrSignatureWrongSize {
|
||||
t.Fatalf("ValidatorBatchVerify wrong-sig-size err = %v, want ErrSignatureWrongSize", err)
|
||||
}
|
||||
|
||||
// Wrong pubkey byte length.
|
||||
badPub := *pubs[1]
|
||||
badPub.Bytes = make([]byte, params.PublicKeySize-1)
|
||||
badPubs := append([]*PublicKey(nil), pubs...)
|
||||
badPubs[1] = &badPub
|
||||
if _, err := ValidatorBatchVerify(params, badPubs, msgs, sigs); err != ErrPublicKeyWrongSize {
|
||||
t.Fatalf("ValidatorBatchVerify wrong-pk-size err = %v, want ErrPublicKeyWrongSize", err)
|
||||
}
|
||||
|
||||
// Mode mismatch.
|
||||
wrongMode := *pubs[1]
|
||||
wrongMode.Mode = ModeM256s
|
||||
wrongModePubs := append([]*PublicKey(nil), pubs...)
|
||||
wrongModePubs[1] = &wrongMode
|
||||
if _, err := ValidatorBatchVerify(params, wrongModePubs, msgs, sigs); err != ErrModeMismatch {
|
||||
t.Fatalf("ValidatorBatchVerify wrong-mode err = %v, want ErrModeMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyAggregateCert_AllInvalid_ReturnsZero pins the "fact-
|
||||
// reporting" property: when no signer is valid, the count is 0
|
||||
// and the error is nil (the caller's quorum threshold check will
|
||||
// reject; that is not this primitive's job).
|
||||
func TestVerifyAggregateCert_AllInvalid_ReturnsZero(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
const N = 3
|
||||
known := make(map[NodeID][]byte, N)
|
||||
signers := make([]NodeID, N)
|
||||
pubKeys := make([][]byte, N)
|
||||
sigs := make([][]byte, N)
|
||||
msg := []byte("standalone-cert-all-invalid")
|
||||
for i := 0; i < N; i++ {
|
||||
sk, pk, err := PerValidatorKeypair(params, newDetReader([]byte{byte(i), 'I', 'V'}))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[%d]: %v", i, err)
|
||||
}
|
||||
var id NodeID
|
||||
id[0] = byte(i + 1)
|
||||
copy(id[1:], []byte("ALL-INVALID"))
|
||||
signers[i] = id
|
||||
pubKeys[i] = pk.Bytes
|
||||
known[id] = pk.Bytes
|
||||
s, err := ValidatorSign(sk, nil, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[%d]: %v", i, err)
|
||||
}
|
||||
// Corrupt every signature.
|
||||
s[len(s)/2] ^= 0x01
|
||||
sigs[i] = s
|
||||
}
|
||||
cert, err := BuildAggregateCert(params, signers, pubKeys, sigs)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAggregateCert: %v", err)
|
||||
}
|
||||
count, err := VerifyAggregateCert(cert, msg, known)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAggregateCert: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("VerifyAggregateCert count = %d, want 0 (all invalid)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAggregateCert_EmptyAndShape pins the BuildAggregateCert
|
||||
// shape checks.
|
||||
func TestBuildAggregateCert_EmptyAndShape(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
params := MustParamsFor(ModeM192s)
|
||||
|
||||
if _, err := BuildAggregateCert(params, nil, nil, nil); err != ErrAggregateCertEmpty {
|
||||
t.Fatalf("BuildAggregateCert empty err = %v, want ErrAggregateCertEmpty", err)
|
||||
}
|
||||
// Mismatched lengths.
|
||||
signers := []NodeID{{1}, {2}}
|
||||
pubs := [][]byte{make([]byte, params.PublicKeySize)}
|
||||
sigs := [][]byte{make([]byte, params.SignatureSize), make([]byte, params.SignatureSize)}
|
||||
if _, err := BuildAggregateCert(params, signers, pubs, sigs); err != ErrAggregateCertShape {
|
||||
t.Fatalf("BuildAggregateCert mismatch err = %v, want ErrAggregateCertShape", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildStandaloneFixture is a test helper that produces N valid
|
||||
// (pk, msg, sig) triples under deterministic per-validator
|
||||
// keypairs. Returns parallel slices ready to feed into
|
||||
// ValidatorBatchVerify or BuildAggregateCert.
|
||||
func buildStandaloneFixture(t *testing.T, params *Params, n int, seed []byte) ([]*PublicKey, [][]byte, [][]byte) {
|
||||
t.Helper()
|
||||
pubs := make([]*PublicKey, n)
|
||||
msgs := make([][]byte, n)
|
||||
sigs := make([][]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
mix := append([]byte{byte(i)}, seed...)
|
||||
sk, pk, err := PerValidatorKeypair(params, newDetReader(mix))
|
||||
if err != nil {
|
||||
t.Fatalf("PerValidatorKeypair[%d]: %v", i, err)
|
||||
}
|
||||
pubs[i] = pk
|
||||
msgs[i] = append([]byte{}, seed...)
|
||||
s, err := ValidatorSign(sk, nil, msgs[i])
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatorSign[%d]: %v", i, err)
|
||||
}
|
||||
sigs[i] = s
|
||||
}
|
||||
return pubs, msgs, sigs
|
||||
}
|
||||
+24
-10
@@ -54,7 +54,10 @@ type DKGOptions struct {
|
||||
DealerSeed []byte
|
||||
}
|
||||
|
||||
// DKG runs the v1 dealer-backed DKG.
|
||||
// DealerDKG runs the v1 DEALER-BACKED DKG. The dealer learns every
|
||||
// secret element at setup time and Shamir-shares each across the
|
||||
// committee. NOT public-BFT-safe — see the package doc comment in
|
||||
// thbs.go for the framing.
|
||||
//
|
||||
// Returns the threshold public key, the calling party's PrivateShare,
|
||||
// and any error.
|
||||
@@ -63,13 +66,17 @@ type DKGOptions struct {
|
||||
// generates EVERY party's PrivateShare. The function is wired to
|
||||
// produce ALL shares; in a real deployment the dealer would split
|
||||
// the returned share-set across parties and zeroise the rest before
|
||||
// the dealer-host shuts down. See DKGAll for that path.
|
||||
// the dealer-host shuts down. See DealerDKGAll for that path.
|
||||
//
|
||||
// This helper signature returns the PrivateShare for the FIRST
|
||||
// participant only, to match the user-spec signature. To obtain every
|
||||
// party's share, call DKGAll.
|
||||
func DKG(config DKGConfig) (PublicKey, PrivateShare, error) {
|
||||
pk, shares, err := DKGAll(config, DKGOptions{})
|
||||
// party's share, call DealerDKGAll.
|
||||
//
|
||||
// The renamed-from-DKG marker is deliberate: the v1 path IS dealer-
|
||||
// backed and that property is load-bearing for the deployment
|
||||
// decision. v0.6+ candidate public DKG lives in subpackage dkg2/.
|
||||
func DealerDKG(config DKGConfig) (PublicKey, PrivateShare, error) {
|
||||
pk, shares, err := DealerDKGAll(config, DKGOptions{})
|
||||
if err != nil {
|
||||
return PublicKey{}, PrivateShare{}, err
|
||||
}
|
||||
@@ -79,11 +86,18 @@ func DKG(config DKGConfig) (PublicKey, PrivateShare, error) {
|
||||
return pk, shares[0], nil
|
||||
}
|
||||
|
||||
// DKGAll is the same as DKG but returns one PrivateShare per
|
||||
// participant (indexed by position in config.Participants). Use this in
|
||||
// tests and in a real dealer host where the dealer process needs all
|
||||
// shares to distribute them.
|
||||
func DKGAll(config DKGConfig, opts DKGOptions) (PublicKey, []PrivateShare, error) {
|
||||
// DealerDKGAll is the same as DealerDKG but returns one PrivateShare
|
||||
// per participant (indexed by position in config.Participants). Use
|
||||
// this in tests and in a real dealer host where the dealer process
|
||||
// needs all shares to distribute them.
|
||||
//
|
||||
// HONEST FRAMING: the dealer process holds the master seed and every
|
||||
// per-element secret in memory for the duration of this call. The
|
||||
// deferred zeroize call below wipes the master seed before return;
|
||||
// per-element secrets are wiped inline as each slot is processed.
|
||||
// The dealer MUST run in a TEE (or be the same process as the
|
||||
// custody host) if public-BFT safety is required.
|
||||
func DealerDKGAll(config DKGConfig, opts DKGOptions) (PublicKey, []PrivateShare, error) {
|
||||
if err := validateDKGConfig(config); err != nil {
|
||||
return PublicKey{}, nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# thbs/dkg2 — Public DKG for Threshold HBS (research skeleton)
|
||||
|
||||
> **DO NOT USE IN PRODUCTION.** This subpackage is a research-grade
|
||||
> skeleton. The PVSS layer ships; the MPC-root layer is **open research**
|
||||
> and tracked in `BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1`. Ship a working
|
||||
> public DKG in v0.6+ once an MPC-friendly hash is selected OR an MPC
|
||||
> framework (MP-SPDZ, EMP, etc.) is integrated.
|
||||
|
||||
## What this is
|
||||
|
||||
`dkg2` is the **public-DKG** counterpart to the dealer-backed v1 DKG
|
||||
in the parent `thbs` package. The two paths are:
|
||||
|
||||
| Path | Setup | Public-BFT safe | Status |
|
||||
|---|---|---|---|
|
||||
| `thbs.DealerDKG` | Trusted dealer | No (dealer learns secrets) | Ships, v1 stable |
|
||||
| `dkg2` (this dir) | PVSS + MPC | Yes (no party learns secrets) | Skeleton only |
|
||||
|
||||
For the **public-BFT-safe primary primitive** today, use
|
||||
**per-validator standalone SLH-DSA**: `magnetar.PerValidatorKeypair` +
|
||||
`magnetar.ValidatorSign` + `magnetar.VerifyAggregateCert`
|
||||
(`ref/go/pkg/magnetar/standalone.go`). That path needs no DKG and is
|
||||
the correct primitive for public consensus.
|
||||
|
||||
`dkg2` is the future path for **threshold** HBS (one signature, not N
|
||||
collected) **without** a trusted dealer.
|
||||
|
||||
## Why this is hard
|
||||
|
||||
Hash-Based Signatures (LMS, XMSS, SLH-DSA) commit public keys to
|
||||
**Merkle roots over many WOTS+ chain endpoints**. Each endpoint
|
||||
`W_{slot, j} = H^{w-1}(x_{slot, j})` is derived from a SECRET chain head
|
||||
`x_{slot, j}` by repeated hashing.
|
||||
|
||||
A truly **public** DKG (no trusted dealer, no TEE) needs TWO ingredients:
|
||||
|
||||
### (A) PVSS distribution of per-element entropy
|
||||
|
||||
For every secret element `x_e`, the protocol collects randomness
|
||||
contributions `r_{j,e}` from each party `j ∈ [n]` and Shamir-shares each
|
||||
`r_{j,e}` across the committee at threshold `t`. The secret element is
|
||||
`x_e = Σ_j r_{j,e}`; **no single party** ever holds `x_e` in cleartext.
|
||||
|
||||
**Status: this is what the skeleton implements** in `pvss.go`,
|
||||
`complaint.go`, `consensus.go`.
|
||||
|
||||
### (B) MPC over the SHA-256 / SHAKE hash function
|
||||
|
||||
To produce a PUBLIC chain endpoint `W_e = H^{w-1}(x_e)` from a SECRET-
|
||||
SHARED `x_e`, every party participates in an MPC that evaluates the hash
|
||||
function on the shared input WITHOUT revealing it. Hash functions are
|
||||
**non-linear**, so this is one of:
|
||||
|
||||
- **SPDZ-style arithmetic MPC**
|
||||
(Damgård-Pastro-Smart-Zakarias, "Multiparty Computation from Somewhat
|
||||
Homomorphic Encryption", CRYPTO 2012)
|
||||
- **Garbled circuits + OT**
|
||||
(Wang-Ranellucci-Katz, "Global-Scale Secure Multiparty Computation",
|
||||
CCS 2017)
|
||||
- **Function Secret Sharing**
|
||||
(Boyle-Gilboa-Ishai, "Function Secret Sharing", EUROCRYPT 2015)
|
||||
|
||||
For SLH-DSA-SHAKE-192s with the v1 thbs parameter set this is
|
||||
**~750k SHAKE evaluations per DKG ceremony**. At current MPC framework
|
||||
performance (MP-SPDZ, EMP-toolkit, SCALE-MAMBA) that's **multi-hour to
|
||||
multi-day** per ceremony.
|
||||
|
||||
**Status: NOT IMPLEMENTED.** `RootMPC` returns `ErrMPCRootNotImpl`.
|
||||
|
||||
## Literature
|
||||
|
||||
### PVSS (the layer this skeleton implements)
|
||||
|
||||
- Schoenmakers, "A Simple Publicly Verifiable Secret Sharing Scheme and
|
||||
its Application to Electronic Voting" (CRYPTO 1999)
|
||||
- Heidarvand, Villar, "Public Verifiability from Pairings in Secret
|
||||
Sharing Schemes" (SAC 2008)
|
||||
- Gurkan et al., "Aggregatable Distributed Key Generation"
|
||||
(EUROCRYPT 2021)
|
||||
|
||||
### MPC over hash functions (the layer not yet implemented)
|
||||
|
||||
- Damgård, Pastro, Smart, Zakarias, "Multiparty Computation from Somewhat
|
||||
Homomorphic Encryption" (CRYPTO 2012) — SPDZ
|
||||
- Wang, Ranellucci, Katz, "Global-Scale Secure Multiparty Computation"
|
||||
(CCS 2017)
|
||||
- Boyle, Gilboa, Ishai, "Function Secret Sharing" (EUROCRYPT 2015)
|
||||
- MP-SPDZ implementation: <https://github.com/data61/MP-SPDZ>
|
||||
|
||||
### Threshold HBS context
|
||||
|
||||
- McGrew, Fluhrer, Gazdag, Kampanakis, Morton, Westerbaan, "Coalition
|
||||
and Threshold Hash-Based Signatures" (IACR ePrint 2019/793)
|
||||
- Bonte, Smart, Tan, "Threshold SPHINCS+" (2023)
|
||||
|
||||
## What ships in v1
|
||||
|
||||
```
|
||||
dkg2/
|
||||
├── doc.go — package doc, scope, literature
|
||||
├── pvss.go — PVSS deal/verify wire shape, ErrSkeletonOnly stub
|
||||
├── complaint.go — Complaint round wire shape, ErrSkeletonOnly stub
|
||||
├── consensus.go — Qualified-set agreement + orchestrator + ErrMPCRootNotImpl
|
||||
└── README.md — this file
|
||||
```
|
||||
|
||||
Every public function returns `ErrSkeletonOnly` (orchestration) or
|
||||
`ErrMPCRootNotImpl` (the MPC root step). This makes it **impossible**
|
||||
for a production caller to accidentally consume the unfinished pipeline.
|
||||
|
||||
## What blocks shipping a working dkg2
|
||||
|
||||
See `BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1` in the magnetar repo root.
|
||||
|
||||
In short:
|
||||
|
||||
1. Select the MPC framework (MP-SPDZ, EMP, custom).
|
||||
2. Implement the SHAKE-256 chain circuit under the chosen MPC.
|
||||
3. Benchmark per-element cost; choose the parameter set + committee
|
||||
size that fits the deployment budget.
|
||||
4. Write the production root.go.
|
||||
5. Wire-format spec the public root computation transcript.
|
||||
6. Integrate with the parent `thbs` `PublicKey` shape so the resulting
|
||||
threshold HBS public key is **byte-equal** to a dealer-DKG'd public
|
||||
key for the same elements (auditability).
|
||||
7. Re-prove the construction (security argument under PVSS + MPC
|
||||
composition).
|
||||
8. Independent cryptographer review (BLK-9-analog gate).
|
||||
9. Independent MPC engineer review of the SHAKE-circuit implementation.
|
||||
|
||||
## When to use the dealer-backed v1 instead
|
||||
|
||||
The dealer-backed `thbs.DealerDKG` is the **right primitive** when:
|
||||
|
||||
- The deployment is **M-Chain bridge custody** (`thresholdvm` M-Chain
|
||||
mode per LP-134) and a TEE-attested host runs the dealer.
|
||||
- The TCB explicitly includes the dealer process (Intel TDX / AMD
|
||||
SEV-SNP / Intel SGX attestation).
|
||||
- The operator has performed the §1 hardening from
|
||||
`DEPLOYMENT-RUNBOOK.md` (mlock, ptrace-off, short-lived process).
|
||||
|
||||
It is the **wrong primitive** for:
|
||||
|
||||
- Public-BFT consensus (validator quorum, external relayers).
|
||||
- Settings where the dealer cannot be in the TCB.
|
||||
|
||||
For those, use `magnetar.ValidatorSign` + `VerifyAggregateCert`
|
||||
(per-validator standalone SLH-DSA).
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
// complaint.go — Complaint round + dealer disqualification.
|
||||
//
|
||||
// SKELETON: this file ships the wire shape of the complaint round
|
||||
// that pairs with pvss.go's Deal/Verify. A recipient that detects
|
||||
// ErrCommitmentMismatch on a received share emits a Complaint
|
||||
// against the dealer; the consensus round (consensus.go) tallies
|
||||
// complaints and produces the QualifiedSet Q ⊆ [n] of dealers whose
|
||||
// contributions survive.
|
||||
//
|
||||
// Bad-delivery model:
|
||||
//
|
||||
// 1. Dealer D broadcasts a DealtBundle whose PublicCommitments
|
||||
// promise per-recipient shares consistent with the dealer's
|
||||
// polynomial.
|
||||
// 2. Recipient R checks the share against the public commitments.
|
||||
// If the check fails, R publishes a Complaint{Accuser=R,
|
||||
// Accused=D, Element=e, Witness=share} so any third party can
|
||||
// replay the verification.
|
||||
// 3. The dealer D may RESPOND with a Defense{Element=e, OpenedShare=...}
|
||||
// that lets every party re-verify R's complaint against D's
|
||||
// opened share. If the defense holds, the complaint is dropped;
|
||||
// if D fails to defend (or defends incorrectly), D is added to
|
||||
// the disqualified set.
|
||||
// 4. Consensus rounds (consensus.go) reach agreement on the
|
||||
// qualified set Q via the chain's BFT layer.
|
||||
|
||||
// ComplaintKind taxonomy. Wire-stable; do not renumber.
|
||||
type ComplaintKind uint8
|
||||
|
||||
const (
|
||||
// ComplaintBadShare: accused dealer's share for the accuser does
|
||||
// not verify against the accused's public commitment vector.
|
||||
ComplaintBadShare ComplaintKind = 1
|
||||
|
||||
// ComplaintMissingDelivery: accused dealer did not deliver any
|
||||
// share to the accuser within the round timeout.
|
||||
ComplaintMissingDelivery ComplaintKind = 2
|
||||
|
||||
// ComplaintCommitmentMalformed: accused dealer's public
|
||||
// commitment vector is structurally invalid (wrong length,
|
||||
// wrong group element, etc.).
|
||||
ComplaintCommitmentMalformed ComplaintKind = 3
|
||||
)
|
||||
|
||||
// String returns the canonical name of the complaint kind.
|
||||
func (k ComplaintKind) String() string {
|
||||
switch k {
|
||||
case ComplaintBadShare:
|
||||
return "bad-share"
|
||||
case ComplaintMissingDelivery:
|
||||
return "missing-delivery"
|
||||
case ComplaintCommitmentMalformed:
|
||||
return "commitment-malformed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Complaint is the wire envelope a recipient broadcasts when it
|
||||
// detects deviation by a dealer during the PVSS round.
|
||||
//
|
||||
// Wire layout:
|
||||
// Kind || Accuser || Accused || Element || Witness
|
||||
//
|
||||
// The Witness payload is kind-specific:
|
||||
// - ComplaintBadShare: serialized ContributionShare for the
|
||||
// pair (Accused -> Accuser, Element)
|
||||
// - ComplaintMissingDelivery: empty
|
||||
// - ComplaintCommitmentMalformed: serialized commitment vector
|
||||
// bytes the accuser received
|
||||
//
|
||||
// Third parties replay the witness against the accused's broadcast
|
||||
// DealtBundle to confirm the complaint is valid; the chain BFT
|
||||
// layer decides which complaints to honour.
|
||||
type Complaint struct {
|
||||
Kind ComplaintKind
|
||||
Accuser PartyID
|
||||
Accused PartyID
|
||||
Element ElementID
|
||||
Witness []byte
|
||||
}
|
||||
|
||||
// Defense is the dealer-side response to a Complaint. The dealer
|
||||
// re-opens the share for the disputed element so any third party
|
||||
// can confirm whether the accuser's complaint holds.
|
||||
//
|
||||
// Wire layout:
|
||||
// Dealer || Element || OpenedShare
|
||||
type Defense struct {
|
||||
Dealer PartyID
|
||||
Element ElementID
|
||||
OpenedShare []uint16
|
||||
}
|
||||
|
||||
// FileComplaint is the recipient-side step: scan received
|
||||
// DealtBundles, detect mismatches, emit Complaint envelopes.
|
||||
//
|
||||
// SKELETON: returns nil + ErrSkeletonOnly. Real implementation:
|
||||
//
|
||||
// 1. Run Verify() on every received DealtBundle.
|
||||
// 2. For each (element, dealer) that fails verification, emit
|
||||
// Complaint{Kind: ComplaintBadShare, Accuser: self,
|
||||
// Accused: dealer, Element: element,
|
||||
// Witness: serialized share}.
|
||||
// 3. For dealers that did not deliver any share within the timeout,
|
||||
// emit Complaint{Kind: ComplaintMissingDelivery, ...}.
|
||||
// 4. Return the list of complaints; caller broadcasts them.
|
||||
func FileComplaint(cfg Config, self PartyID, bundles []*DealtBundle) ([]*Complaint, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// DealerDefend is the dealer-side step: process incoming Complaints,
|
||||
// emit a Defense for each VALID complaint (or accept disqualification
|
||||
// for INVALID complaints).
|
||||
//
|
||||
// SKELETON: returns nil + ErrSkeletonOnly. Real implementation:
|
||||
//
|
||||
// 1. For each Complaint against THIS dealer, re-derive the
|
||||
// contribution polynomial f_e at the accuser's EvalPoint.
|
||||
// 2. If the dealer's opened share matches the value the dealer
|
||||
// committed to via PublicCommitments, emit a Defense and rely
|
||||
// on consensus to drop the complaint.
|
||||
// 3. If the dealer cannot defend (e.g. genuine misdeal), do not
|
||||
// emit a Defense; the consensus round will disqualify the
|
||||
// dealer.
|
||||
func DealerDefend(cfg Config, self PartyID, complaints []*Complaint) ([]*Defense, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrSkeletonOnly
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
// consensus.go — Agreement on the qualified set Q ⊆ [n] of dealers
|
||||
// whose contributions survive the complaint round, and orchestrator
|
||||
// for the public DKG.
|
||||
//
|
||||
// SKELETON: this file ships the wire shape of the qualified-set
|
||||
// consensus step + the top-level Run orchestrator. Real
|
||||
// implementation:
|
||||
//
|
||||
// 1. After PVSS Round 1 (deal) and Complaint Round (file +
|
||||
// defend), every party computes its LOCAL view of the
|
||||
// qualified set Q_self = {dealers d | no surviving complaint
|
||||
// against d}.
|
||||
// 2. The committee runs a BFT consensus round on Q. In Lux this
|
||||
// means broadcasting a hash of Q_self and waiting for
|
||||
// 2f+1 matching signatures; the chain layer drives this. In a
|
||||
// standalone deployment this can be a single-round broadcast +
|
||||
// local intersection.
|
||||
// 3. The agreed Q is the basis for x_e = Σ_{j ∈ Q} r_{j,e} (the
|
||||
// joint contribution sum, per element).
|
||||
// 4. The PUBLIC chain endpoints W_e = H^{w-1}(x_e) and the public
|
||||
// Merkle root over all W_e are derived via the MPC layer at
|
||||
// RootMPC (which is OPEN RESEARCH; see pvss.go ErrMPCRootNotImpl
|
||||
// and root.go).
|
||||
|
||||
// QualifiedSet is a compact dense bitmap over the committee. Bit i
|
||||
// is set iff dealer with EvalPoint (i+1) is in the qualified set.
|
||||
type QualifiedSet []byte
|
||||
|
||||
// Set marks bit i.
|
||||
func (q QualifiedSet) Set(i int) {
|
||||
if i>>3 < len(q) {
|
||||
q[i>>3] |= 1 << uint(i&7)
|
||||
}
|
||||
}
|
||||
|
||||
// Has reports whether bit i is set.
|
||||
func (q QualifiedSet) Has(i int) bool {
|
||||
if i>>3 >= len(q) {
|
||||
return false
|
||||
}
|
||||
return q[i>>3]&(1<<uint(i&7)) != 0
|
||||
}
|
||||
|
||||
// NewQualifiedSet allocates a QualifiedSet sized for `n` parties.
|
||||
func NewQualifiedSet(n int) QualifiedSet {
|
||||
return make(QualifiedSet, (n+7)/8)
|
||||
}
|
||||
|
||||
// AgreementProposal is the per-party broadcast for the qualified-set
|
||||
// consensus round: each party emits its local view of Q.
|
||||
//
|
||||
// Wire layout:
|
||||
// Author || Epoch || QHash
|
||||
//
|
||||
// QHash is a hash of the canonical-ordered Q bitmap; the canonical
|
||||
// order is by dealer EvalPoint ascending.
|
||||
type AgreementProposal struct {
|
||||
Author PartyID
|
||||
Epoch uint64
|
||||
QHash [32]byte
|
||||
}
|
||||
|
||||
// AgreementOutput is the output of the qualified-set consensus step:
|
||||
// the agreed Q bitmap plus the witnesses (signed proposals) for
|
||||
// auditability.
|
||||
type AgreementOutput struct {
|
||||
Epoch uint64
|
||||
Q QualifiedSet
|
||||
Proposals []*AgreementProposal
|
||||
}
|
||||
|
||||
// ProposeQ is the per-party step: scan received complaints +
|
||||
// defenses, compute the LOCAL view of Q, broadcast the hash.
|
||||
//
|
||||
// SKELETON: returns nil + ErrSkeletonOnly.
|
||||
func ProposeQ(cfg Config, self PartyID, complaints []*Complaint, defenses []*Defense) (*AgreementProposal, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// AgreeQ is the consensus step: collect proposals, find the majority
|
||||
// agreement, and return the agreed Q.
|
||||
//
|
||||
// SKELETON: returns nil + ErrSkeletonOnly. Real implementation
|
||||
// requires plugging into the chain's BFT layer.
|
||||
func AgreeQ(cfg Config, proposals []*AgreementProposal) (*AgreementOutput, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// Run is the top-level orchestrator for the public DKG. It composes
|
||||
// PVSS deal -> verify -> complaint -> defense -> agreement -> MPC
|
||||
// root computation.
|
||||
//
|
||||
// SKELETON: returns ErrSkeletonOnly because the MPC-root step is not
|
||||
// implemented. A v1 caller MUST NOT consume Run; use
|
||||
// thbs.DealerDKG for the dealer-backed v1 path or
|
||||
// magnetar.PerValidatorKeypair + magnetar.ValidatorSign for the
|
||||
// per-validator standalone path.
|
||||
//
|
||||
// When the MPC-root layer lands (v0.6+ candidate), Run will:
|
||||
//
|
||||
// 1. Call Deal once per dealer; collect DealtBundles.
|
||||
// 2. Call Verify on every party for every bundle.
|
||||
// 3. Collect FileComplaint outputs from every party; broadcast
|
||||
// DealerDefend outputs from dealers.
|
||||
// 4. Each party calls ProposeQ; AgreeQ produces the qualified set.
|
||||
// 5. RootMPC consumes the qualified-set shares + dealer
|
||||
// contributions and computes the PUBLIC Merkle root, returning
|
||||
// a (root, helper_data, transcript) tuple consumable by the
|
||||
// parent thbs verifier.
|
||||
//
|
||||
// The output of Run, when implemented, is a (PublicKey, PerParty
|
||||
// shares) tuple analogous to thbs.DealerDKGAll's output — but
|
||||
// PRODUCED WITHOUT A DEALER. That is the public-BFT-safe path the
|
||||
// user's spec calls "DKG = PVSS for secret shares + MPC/public
|
||||
// verification for derived roots."
|
||||
func Run(cfg Config) error {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// RootMPC is the unfinished STUB for the public Merkle root
|
||||
// computation. Given the qualified-set shares + dealer
|
||||
// contributions, it must compute the public chain endpoints W_e =
|
||||
// H^{w-1}(x_e) and the public Merkle root over them WITHOUT
|
||||
// REVEALING any x_e to any single party.
|
||||
//
|
||||
// SKELETON: returns ErrMPCRootNotImpl. See doc.go and
|
||||
// BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1 for the open-research framing.
|
||||
//
|
||||
// The right answer is one of:
|
||||
//
|
||||
// - SPDZ-style MPC over SHA-256/SHAKE (Damgård-Pastro-Smart-Zakarias
|
||||
// 2012). Multi-second per element on current MP-SPDZ; for SLH-DSA-
|
||||
// SHAKE-192s with ~750K hashes per public key this is multi-hour
|
||||
// to multi-day per DKG ceremony.
|
||||
// - Garbled-circuit MPC (Wang-Ranellucci-Katz 2017). Similar order
|
||||
// of cost.
|
||||
// - Function Secret Sharing for SHA-256 specifically (Boyle-Gilboa-
|
||||
// Ishai 2015). Open research for the specific hash circuit.
|
||||
// - A different HBS instantiation with MPC-friendly hash (e.g.
|
||||
// LowMC, Poseidon, Rescue). Would NOT produce FIPS 205-
|
||||
// compatible signatures.
|
||||
//
|
||||
// The choice depends on the deployment economics. The cryptographer
|
||||
// team selects when integrating; v0.6+ candidate.
|
||||
func RootMPC(cfg Config, agreed *AgreementOutput) error {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrMPCRootNotImpl
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
// dkg2_test.go — Skeleton-tier tests. These do NOT exercise the
|
||||
// (open-research) MPC-root layer; they pin the wire-shape contract
|
||||
// and the ErrSkeletonOnly / ErrMPCRootNotImpl sentinels so a
|
||||
// production caller cannot consume the unfinished pipeline by
|
||||
// accident.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testCommittee(n int) []Participant {
|
||||
out := make([]Participant, n)
|
||||
for i := 0; i < n; i++ {
|
||||
var id PartyID
|
||||
id[0] = byte(i + 1)
|
||||
copy(id[1:], []byte("DKG2-TEST"))
|
||||
out[i] = Participant{ID: id, EvalPoint: uint16(i + 1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testConfig() Config {
|
||||
parts := testCommittee(3)
|
||||
return Config{
|
||||
ChainID: []byte("lux-magnetar-dkg2-test"),
|
||||
Epoch: 1,
|
||||
Threshold: 2,
|
||||
Participants: parts,
|
||||
Elements: []ElementID{
|
||||
{Type: 1, Slot: 0, ChainIdx: 0},
|
||||
{Type: 1, Slot: 0, ChainIdx: 1},
|
||||
{Type: 2, Slot: 0, TreeIdx: 0, LeafIdx: 0},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeal_SkeletonOnly pins the Deal-returns-ErrSkeletonOnly
|
||||
// contract. A production caller invoking Deal must hit the
|
||||
// sentinel; if a future commit removes the sentinel WITHOUT
|
||||
// implementing the MPC layer, this test catches it.
|
||||
func TestDeal_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, err := Deal(cfg, cfg.Participants[0].ID)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("Deal err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_SkeletonOnly pins the Verify-returns-ErrSkeletonOnly
|
||||
// contract on a non-nil bundle.
|
||||
func TestVerify_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
bundle := &DealtBundle{}
|
||||
err := Verify(cfg, cfg.Participants[0].ID, bundle)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("Verify err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileComplaint_SkeletonOnly pins the complaint-round stub.
|
||||
func TestFileComplaint_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, err := FileComplaint(cfg, cfg.Participants[0].ID, nil)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("FileComplaint err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDealerDefend_SkeletonOnly pins the defense stub.
|
||||
func TestDealerDefend_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, err := DealerDefend(cfg, cfg.Participants[0].ID, nil)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("DealerDefend err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposeQ_SkeletonOnly pins the agreement-proposal stub.
|
||||
func TestProposeQ_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, err := ProposeQ(cfg, cfg.Participants[0].ID, nil, nil)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("ProposeQ err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgreeQ_SkeletonOnly pins the agreement stub.
|
||||
func TestAgreeQ_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, err := AgreeQ(cfg, nil)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("AgreeQ err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SkeletonOnly pins the top-level orchestrator stub.
|
||||
func TestRun_SkeletonOnly(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
err := Run(cfg)
|
||||
if !errors.Is(err, ErrSkeletonOnly) {
|
||||
t.Fatalf("Run err = %v, want ErrSkeletonOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootMPC_MPCRootNotImpl pins the MPC-root unfinished marker.
|
||||
// This is the SPECIFIC sentinel pointing at the open research task.
|
||||
// A production caller CANNOT consume RootMPC; the error explicitly
|
||||
// names the BLOCKERS.md entry.
|
||||
func TestRootMPC_MPCRootNotImpl(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
err := RootMPC(cfg, nil)
|
||||
if !errors.Is(err, ErrMPCRootNotImpl) {
|
||||
t.Fatalf("RootMPC err = %v, want ErrMPCRootNotImpl", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfig_BadThreshold pins the structural validation.
|
||||
func TestValidateConfig_BadThreshold(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Threshold = 0
|
||||
if err := validateConfig(cfg); !errors.Is(err, ErrInvalidPVSSConfig) {
|
||||
t.Fatalf("validateConfig threshold=0 err = %v, want ErrInvalidPVSSConfig", err)
|
||||
}
|
||||
cfg = testConfig()
|
||||
cfg.Threshold = uint16(len(cfg.Participants) + 1)
|
||||
if err := validateConfig(cfg); !errors.Is(err, ErrInvalidPVSSConfig) {
|
||||
t.Fatalf("validateConfig threshold>n err = %v, want ErrInvalidPVSSConfig", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfig_EmptyElements pins that a DKG over an empty
|
||||
// element set is rejected (catches caller bugs).
|
||||
func TestValidateConfig_EmptyElements(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Elements = nil
|
||||
if err := validateConfig(cfg); !errors.Is(err, ErrInvalidPVSSConfig) {
|
||||
t.Fatalf("validateConfig empty-elements err = %v, want ErrInvalidPVSSConfig", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfig_ZeroEvalPoint pins that EvalPoint=0 (the
|
||||
// Shamir master-secret point) is rejected.
|
||||
func TestValidateConfig_ZeroEvalPoint(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Participants[1].EvalPoint = 0
|
||||
if err := validateConfig(cfg); !errors.Is(err, ErrInvalidPVSSConfig) {
|
||||
t.Fatalf("validateConfig zero-EvalPoint err = %v, want ErrInvalidPVSSConfig", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfig_DuplicateEvalPoint pins distinct-EvalPoint
|
||||
// enforcement.
|
||||
func TestValidateConfig_DuplicateEvalPoint(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Participants[1].EvalPoint = cfg.Participants[0].EvalPoint
|
||||
if err := validateConfig(cfg); !errors.Is(err, ErrInvalidPVSSConfig) {
|
||||
t.Fatalf("validateConfig dup-EvalPoint err = %v, want ErrInvalidPVSSConfig", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQualifiedSet_SetHas pins the bitmap behaviour.
|
||||
func TestQualifiedSet_SetHas(t *testing.T) {
|
||||
q := NewQualifiedSet(8)
|
||||
if q.Has(0) {
|
||||
t.Fatalf("fresh QualifiedSet should have no bits set")
|
||||
}
|
||||
q.Set(3)
|
||||
q.Set(7)
|
||||
if !q.Has(3) || !q.Has(7) {
|
||||
t.Fatalf("Set bits not retrievable via Has")
|
||||
}
|
||||
if q.Has(2) || q.Has(4) {
|
||||
t.Fatalf("Unset bits returned true from Has")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplaintKind_String pins the wire-stable string mapping.
|
||||
func TestComplaintKind_String(t *testing.T) {
|
||||
cases := []struct {
|
||||
k ComplaintKind
|
||||
want string
|
||||
}{
|
||||
{ComplaintBadShare, "bad-share"},
|
||||
{ComplaintMissingDelivery, "missing-delivery"},
|
||||
{ComplaintCommitmentMalformed, "commitment-malformed"},
|
||||
{ComplaintKind(99), "unknown"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.k.String(); got != c.want {
|
||||
t.Errorf("ComplaintKind(%d).String() = %q, want %q", c.k, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package dkg2 is the RESEARCH-GRADE SKELETON of a public DKG for
|
||||
// threshold HBS. v1 in this directory ships the PVSS layer; the
|
||||
// MPC-root layer is OPEN RESEARCH and tracked in BLOCKERS.md::
|
||||
// MAGNETAR-PUBLIC-DKG-1.
|
||||
//
|
||||
// =====================================================================
|
||||
// !! SKELETON / RESEARCH ONLY — DO NOT USE IN PRODUCTION !!
|
||||
// =====================================================================
|
||||
//
|
||||
// HBS schemes (LMS, XMSS, SLH-DSA) commit to public keys via Merkle
|
||||
// roots over many WOTS+ chain endpoints (one per slot, per chain).
|
||||
// Each chain endpoint W_{slot, j} = H^{w-1}(x_{slot, j}) is derived
|
||||
// from a SECRET chain head x_{slot, j} by repeated hashing.
|
||||
//
|
||||
// A truly PUBLIC DKG (no trusted dealer, no TEE) for threshold HBS
|
||||
// therefore needs TWO ingredients:
|
||||
//
|
||||
// (A) PVSS distribution of per-element entropy. For every secret
|
||||
// element x_e (WOTS+ chain head or FORS leaf), the protocol
|
||||
// collects randomness contributions r_{j,e} from each party
|
||||
// j ∈ [n] and SHAMIR-SHARES each r_{j,e} across the committee
|
||||
// at threshold t. The secret element is x_e = Σ_j r_{j,e};
|
||||
// NO single party ever holds x_e in cleartext.
|
||||
//
|
||||
// This file implements (A) as a SKELETON: see pvss.go.
|
||||
//
|
||||
// (B) MPC over the SHA-256 / SHAKE hash function to compute the
|
||||
// PUBLIC chain endpoint W_e = H^{w-1}(x_e) from the SECRET-
|
||||
// SHARED x_e — WITHOUT REVEALING x_e.
|
||||
//
|
||||
// This is the HARD FRONTIER. Hash functions are non-linear:
|
||||
// evaluating one SHA-256 / SHAKE block on a secret-shared input
|
||||
// requires either (i) garbled circuits + oblivious transfer
|
||||
// (multi-second, multi-megabyte per evaluation) or (ii) SPDZ-
|
||||
// style arithmetic MPC (similar cost, plus offline triples) or
|
||||
// (iii) function secret sharing for the specific hash circuit
|
||||
// (open research). For a single SLH-DSA-SHAKE-192s public key
|
||||
// we need O(2^h × WOTSChains × (w-1)) hash evaluations, where
|
||||
// h=10, WOTSChains=51, w=16 → ~750 thousand SHAKE evaluations.
|
||||
// Multi-hour at current MPC frameworks (MP-SPDZ, EMP, etc.).
|
||||
//
|
||||
// (B) IS NOT IMPLEMENTED. See pvss.go's TODO + the SkeletonOnly
|
||||
// sentinel returned by RootMPC.
|
||||
//
|
||||
// LITERATURE
|
||||
// ==========
|
||||
//
|
||||
// On PVSS (the layer this skeleton implements):
|
||||
//
|
||||
// - Schoenmakers, "A Simple Publicly Verifiable Secret Sharing
|
||||
// Scheme and its Application to Electronic Voting" (CRYPTO 1999)
|
||||
// - Heidarvand, Villar, "Public Verifiability from Pairings in
|
||||
// Secret Sharing Schemes" (SAC 2008)
|
||||
// - Gurkan et al., "Aggregatable Distributed Key Generation"
|
||||
// (EUROCRYPT 2021)
|
||||
//
|
||||
// On the MPC layer for the public root computation (the layer this
|
||||
// skeleton does NOT yet implement):
|
||||
//
|
||||
// - Damgård, Pastro, Smart, Zakarias, "Multiparty Computation from
|
||||
// Somewhat Homomorphic Encryption" (CRYPTO 2012) — SPDZ
|
||||
// - Wang, Ranellucci, Katz, "Global-Scale Secure Multiparty
|
||||
// Computation" (CCS 2017)
|
||||
// - Boyle, Gilboa, Ishai, "Function Secret Sharing" (EUROCRYPT 2015)
|
||||
// - The MP-SPDZ implementation (https://github.com/data61/MP-SPDZ)
|
||||
// ships SHA-256 over MASCOT/SPDZ at multi-hundred-ms per block
|
||||
// across 3-party LANs.
|
||||
//
|
||||
// On threshold HBS overall (the McGrew et al. line of work that
|
||||
// motivates the entire thbs package):
|
||||
//
|
||||
// - McGrew, Fluhrer, Gazdag, Kampanakis, Morton, Westerbaan,
|
||||
// "Coalition and Threshold Hash-Based Signatures" (IACR ePrint
|
||||
// 2019/793) — the v1 dealer-backed setup, with a v2 public-DKG
|
||||
// discussion that leaves the MPC layer unspecified.
|
||||
// - Bonte, Smart, Tan, "Threshold SPHINCS+" (2023) — infeasibility
|
||||
// analysis confirming the per-signature MPC cost dominates.
|
||||
//
|
||||
// SCOPE OF THIS SKELETON
|
||||
// ======================
|
||||
//
|
||||
// What ships in dkg2/ v1:
|
||||
//
|
||||
// - pvss.go: per-element Shamir VSS over GF(257), per-recipient
|
||||
// PEdersen-style commitment + share. Each party deals their
|
||||
// contribution r_{j,e} to every element e via verifiable shares.
|
||||
// - complaint.go: complaint round + dealer disqualification
|
||||
// mechanics. A party that fails to deliver a valid share or
|
||||
// that delivers an inconsistent share is identifiably aborted.
|
||||
// - consensus.go: agreement on the qualified set Q ⊆ [n] of
|
||||
// parties whose contributions survive the complaint round. The
|
||||
// joint secret is x_e = Σ_{j ∈ Q} r_{j,e} (per element).
|
||||
//
|
||||
// What does NOT ship:
|
||||
//
|
||||
// - root.go: STUB. The MPC-root computation that would derive
|
||||
// the public chain endpoints W_e from the secret-shared x_e
|
||||
// WITHOUT REVEALING any x_e. RootMPC returns ErrMPCRootNotImpl.
|
||||
// This is the v0.6+ candidate work; see BLOCKERS.md::
|
||||
// MAGNETAR-PUBLIC-DKG-1.
|
||||
//
|
||||
// USAGE
|
||||
// =====
|
||||
//
|
||||
// This package is a STUB. Callers should NOT attempt to use it for
|
||||
// production signing. The intended use is:
|
||||
//
|
||||
// 1. The PVSS layer is exercised by the cryptographer team to
|
||||
// validate the wire shape against future MPC-root integration.
|
||||
// 2. The README.md in this directory documents the open research
|
||||
// path and serves as a placeholder for the v0.6+ implementation.
|
||||
//
|
||||
// For a working THRESHOLD HBS construction TODAY, see the parent
|
||||
// package (github.com/luxfi/magnetar/ref/go/pkg/thbs) which ships
|
||||
// the DEALER-BACKED v1 path.
|
||||
//
|
||||
// For a working PUBLIC-BFT-SAFE SLH-DSA primitive TODAY, see
|
||||
// github.com/luxfi/magnetar.ValidatorSign +
|
||||
// VerifyAggregateCert (per-validator standalone SLH-DSA, no DKG).
|
||||
package dkg2
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
// pvss.go — Publicly Verifiable Secret Sharing layer for the public
|
||||
// DKG.
|
||||
//
|
||||
// SKELETON: this file ships the WIRE SHAPE of the PVSS-based public
|
||||
// DKG for HBS. It implements:
|
||||
//
|
||||
// - Per-element contribution sharing: every party j ∈ [n] samples
|
||||
// a random per-element contribution r_{j,e} for every secret
|
||||
// element e ∈ E (the WOTS+ chain heads + FORS leaves), and
|
||||
// Shamir-shares r_{j,e} across the committee at threshold t.
|
||||
// - Per-share Pedersen-style commitments so the share's
|
||||
// consistency with the dealt commitment vector is publicly
|
||||
// verifiable.
|
||||
// - Per-recipient envelope with KEM wrapping placeholder
|
||||
// (ML-KEM-768 in production; placeholder slot here).
|
||||
//
|
||||
// What this file does NOT do:
|
||||
//
|
||||
// - It does NOT compute the public chain endpoints W_e or the
|
||||
// public Merkle root from the secret-shared elements. That
|
||||
// requires the MPC layer at root.go which is OPEN RESEARCH.
|
||||
// - It does NOT consume from a CryptoSuite or wire-format spec
|
||||
// beyond the parent thbs's GF(257) Shamir. Future work pins
|
||||
// a chain-friendly curve commitment (Pedersen on BLS12-381
|
||||
// scalar field) for aggregatable PVSS — current shape uses
|
||||
// in-package hash-based commitments only.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// PartyID is the canonical party identifier, mirroring thbs.PartyID.
|
||||
// Re-declared here so dkg2 has no import cycle into thbs.
|
||||
type PartyID [32]byte
|
||||
|
||||
// ElementID is dkg2's flat key for a threshold-shared secret element.
|
||||
// Mirrors thbs.ElementID but is local to avoid an import cycle.
|
||||
type ElementID struct {
|
||||
Type byte
|
||||
Slot uint32
|
||||
ChainIdx uint16
|
||||
TreeIdx uint16
|
||||
LeafIdx uint32
|
||||
}
|
||||
|
||||
// Participant identifies one party in the PVSS committee.
|
||||
type Participant struct {
|
||||
ID PartyID
|
||||
EvalPoint uint16
|
||||
}
|
||||
|
||||
// Config parameterises a PVSS-based public DKG ceremony.
|
||||
type Config struct {
|
||||
ChainID []byte
|
||||
Epoch uint64
|
||||
Threshold uint16
|
||||
Participants []Participant
|
||||
|
||||
// Elements is the set of secret elements the DKG produces shares
|
||||
// for. Caller derives this from the HBS parameter set:
|
||||
// - one ElementID per (slot, ElementWOTS, chain_idx) tuple
|
||||
// - one ElementID per (slot, ElementFORS, tree_idx, leaf_idx)
|
||||
// For SLH-DSA-SHAKE-192s with the v1 thbs parameter set this is
|
||||
// ~1.6M elements per fresh DKG — see THBS-SPEC.md.
|
||||
Elements []ElementID
|
||||
}
|
||||
|
||||
// ContributionShare is one party's PVSS share of one dealer's
|
||||
// contribution to one element. Wire-stable, signable.
|
||||
type ContributionShare struct {
|
||||
// Dealer is the party that contributed the randomness.
|
||||
Dealer PartyID
|
||||
|
||||
// Recipient is the party this share is addressed to.
|
||||
Recipient PartyID
|
||||
|
||||
// Element is the secret element this share pertains to.
|
||||
Element ElementID
|
||||
|
||||
// EvalPoint is the recipient's Shamir x-coordinate.
|
||||
EvalPoint uint16
|
||||
|
||||
// Share is the per-byte GF(257) Shamir share of the dealer's
|
||||
// contribution r_{Dealer, Element}. Length matches the
|
||||
// underlying secret element byte width.
|
||||
Share []uint16
|
||||
|
||||
// Commitment is the per-coefficient Pedersen-style commitment
|
||||
// vector so the recipient can verify the share is consistent
|
||||
// with what was publicly committed.
|
||||
//
|
||||
// SKELETON NOTE: the v1 commitment here is a placeholder
|
||||
// hash-based binding tag. A production PVSS layer would use a
|
||||
// pairing-friendly Pedersen commitment over BLS12-381 to admit
|
||||
// publicly verifiable shares. This skeleton documents the wire
|
||||
// slot; the cryptographer team selects the production
|
||||
// commitment scheme when integrating with the (open-research)
|
||||
// MPC-root layer.
|
||||
Commitment [][]byte
|
||||
}
|
||||
|
||||
// DealtBundle is one dealer's full output for a PVSS round: a
|
||||
// commitment vector PUBLIC to the committee plus per-recipient
|
||||
// share envelopes.
|
||||
type DealtBundle struct {
|
||||
// Dealer is the party that produced this bundle.
|
||||
Dealer PartyID
|
||||
|
||||
// PublicCommitments is the dealer-broadcast commitment vector
|
||||
// (one Pedersen-style commitment per polynomial coefficient,
|
||||
// per element). Every party verifies share consistency against
|
||||
// this vector.
|
||||
PublicCommitments map[ElementID][][]byte
|
||||
|
||||
// PerRecipient maps each recipient PartyID to that party's
|
||||
// per-element shares. Total size = n_recipients × |Elements|
|
||||
// per dealer — for the v1 thbs parameter set this dominates
|
||||
// the DKG wire cost.
|
||||
PerRecipient map[PartyID][]ContributionShare
|
||||
}
|
||||
|
||||
// Errors returned by the PVSS layer.
|
||||
var (
|
||||
// ErrInvalidPVSSConfig means the Config is malformed: bad
|
||||
// threshold, zero eval point, duplicate participants, or empty
|
||||
// element set.
|
||||
ErrInvalidPVSSConfig = errors.New("dkg2: invalid PVSS config")
|
||||
|
||||
// ErrCommitmentMismatch means a recipient's verification of a
|
||||
// dealt share against the dealer's public commitment failed.
|
||||
// Triggers the complaint round (complaint.go).
|
||||
ErrCommitmentMismatch = errors.New("dkg2: PVSS share commitment mismatch")
|
||||
|
||||
// ErrMPCRootNotImpl is the sentinel returned by RootMPC. The
|
||||
// MPC layer that computes public Merkle roots from
|
||||
// secret-shared leaves is open research; v0.6+ candidate.
|
||||
// See BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1.
|
||||
ErrMPCRootNotImpl = errors.New("dkg2: MPC-root computation not implemented (see BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1)")
|
||||
|
||||
// ErrSkeletonOnly is the umbrella sentinel returned by
|
||||
// orchestrator functions that compose PVSS + complaint +
|
||||
// consensus + MPC root. As of the skeleton ship the
|
||||
// orchestrator returns this to make it impossible for a
|
||||
// production caller to accidentally consume the unfinished
|
||||
// pipeline.
|
||||
ErrSkeletonOnly = errors.New("dkg2: skeleton only — production caller MUST NOT consume (use thbs.DealerDKG for v1 or magnetar.ValidatorSign for public BFT)")
|
||||
)
|
||||
|
||||
// Deal is the dealer-side PVSS step.
|
||||
//
|
||||
// SKELETON: ships the function signature + a stub body that returns
|
||||
// ErrSkeletonOnly. Real implementation:
|
||||
//
|
||||
// 1. For each element e ∈ Elements:
|
||||
// a. Sample contribution r_e ∈ {0,1}^{element_byte_size}.
|
||||
// b. Generate a random degree-(t-1) polynomial f_e(X) with
|
||||
// f_e(0) = r_e (per-byte GF(257) Shamir, matching the
|
||||
// parent thbs Shamir).
|
||||
// c. Compute the per-coefficient commitment vector
|
||||
// C_e = [Comm(f_{e,0}), Comm(f_{e,1}), ..., Comm(f_{e,t-1})].
|
||||
// d. For each recipient j with EvalPoint x_j:
|
||||
// Share_{e,j} = f_e(x_j) (per byte).
|
||||
//
|
||||
// 2. Broadcast (commitments, per-recipient shares) as a
|
||||
// DealtBundle.
|
||||
//
|
||||
// 3. Wrap each per-recipient envelope under ML-KEM-768 to the
|
||||
// recipient's long-term identity public key (BLOCKERS.md::CR-8
|
||||
// pattern, matches Pulsar v0.3+).
|
||||
//
|
||||
// What does NOT happen here:
|
||||
//
|
||||
// - The public chain-endpoint W_e is NOT computed from the
|
||||
// contribution. That requires the MPC layer at RootMPC.
|
||||
// - The dealer does NOT see or hold the JOINT secret element
|
||||
// x_e = Σ_j r_{j,e}; only its OWN contribution r_{self,e}.
|
||||
func Deal(cfg Config, self PartyID) (*DealtBundle, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// Verify is the recipient-side PVSS step. Each party invokes Verify
|
||||
// on every received DealtBundle to detect bad-delivery / commitment-
|
||||
// mismatch (and feed evidence into the complaint round).
|
||||
//
|
||||
// SKELETON: returns ErrSkeletonOnly.
|
||||
//
|
||||
// Real implementation:
|
||||
//
|
||||
// 1. For every (element, share) pair in bundle.PerRecipient[self]:
|
||||
// a. Evaluate the dealer's public commitment polynomial at
|
||||
// self's EvalPoint x_self using the bundle's
|
||||
// PublicCommitments[element] vector.
|
||||
// b. Verify the commitment evaluation matches the per-byte
|
||||
// share. If not: return ErrCommitmentMismatch and the
|
||||
// caller raises a complaint (complaint.go).
|
||||
// 2. Persist (dealer, element, share) for the consensus step.
|
||||
func Verify(cfg Config, self PartyID, bundle *DealtBundle) error {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if bundle == nil {
|
||||
return ErrSkeletonOnly
|
||||
}
|
||||
return ErrSkeletonOnly
|
||||
}
|
||||
|
||||
// validateConfig pins the structural invariants on Config.
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.Threshold < 1 || int(cfg.Threshold) > len(cfg.Participants) {
|
||||
return ErrInvalidPVSSConfig
|
||||
}
|
||||
if len(cfg.Elements) == 0 {
|
||||
return ErrInvalidPVSSConfig
|
||||
}
|
||||
seen := make(map[uint16]struct{}, len(cfg.Participants))
|
||||
for _, p := range cfg.Participants {
|
||||
if p.EvalPoint == 0 {
|
||||
return ErrInvalidPVSSConfig
|
||||
}
|
||||
if _, dup := seen[p.EvalPoint]; dup {
|
||||
return ErrInvalidPVSSConfig
|
||||
}
|
||||
seen[p.EvalPoint] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+50
-7
@@ -1,12 +1,53 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package thbs implements Magnetar-THBS v1: true threshold hash-based
|
||||
// signatures in the sense of McGrew, Fluhrer, Gazdag, Kampanakis, Morton,
|
||||
// Westerbaan, "Coalition and Threshold Hash-Based Signatures"
|
||||
// Package thbs implements Magnetar-THBS v1: TRUE threshold hash-based
|
||||
// signatures in the sense of McGrew, Fluhrer, Gazdag, Kampanakis,
|
||||
// Morton, Westerbaan, "Coalition and Threshold Hash-Based Signatures"
|
||||
// (IACR ePrint 2019/793 and the IRTF draft-mcgrew-hash-sigs line of
|
||||
// work).
|
||||
//
|
||||
// =====================================================================
|
||||
// !! DEALER-BACKED v1 — NOT PUBLIC-BFT-SAFE !!
|
||||
// =====================================================================
|
||||
//
|
||||
// THIS PACKAGE IS A DEALER-BACKED THRESHOLD HBS CONSTRUCTION.
|
||||
//
|
||||
// - A SINGLE DEALER generates the WOTS+ chain heads and FORS secret
|
||||
// leaves at setup, Shamir-shares each across the committee, and
|
||||
// erases the master seed. The dealer LEARNS every secret element
|
||||
// at setup time.
|
||||
// - For PUBLIC-BFT consensus (Lux validator quorums, external
|
||||
// relayers, any setting where the dealer cannot be in the trusted
|
||||
// computing base) this is the WRONG primitive.
|
||||
// - For PUBLIC-BFT consensus on SLH-DSA, use:
|
||||
//
|
||||
// github.com/luxfi/magnetar.ValidatorSign +
|
||||
// github.com/luxfi/magnetar.VerifyAggregateCert
|
||||
//
|
||||
// (See ref/go/pkg/magnetar/standalone.go. Per-validator
|
||||
// standalone SLH-DSA keypairs. No DKG. No dealer. No shared
|
||||
// secret. Each validator signs independently; the consensus
|
||||
// layer collects N signatures into a ValidatorAggregateCert
|
||||
// and optionally compresses via Z-Chain Groth16.)
|
||||
//
|
||||
// THIS PACKAGE IS APPROPRIATE for the M-Chain bridge custody
|
||||
// pattern where a TEE-attested host (Intel TDX / AMD SEV-SNP / Intel
|
||||
// SGX) acts as the dealer by policy, and the TEE root-of-trust is
|
||||
// already in the deployment TCB. For that pattern see
|
||||
// DEPLOYMENT-RUNBOOK.md §0 "Choose your mode".
|
||||
//
|
||||
// THE PUBLIC-DKG PATH (no dealer, no TEE, public-BFT-safe) is
|
||||
// research-grade and tracked in BLOCKERS.md::MAGNETAR-PUBLIC-DKG-1.
|
||||
// A SKELETON ships at github.com/luxfi/magnetar/ref/go/pkg/thbs/dkg2/
|
||||
// — that subpackage implements the PVSS layer (each party
|
||||
// contributes randomness to every secret element) but DOES NOT yet
|
||||
// solve the MPC-root layer (computing the public Merkle root from
|
||||
// secret-shared leaves without revealing the leaves). The MPC-root
|
||||
// layer is open research; v0.6+ candidate.
|
||||
//
|
||||
// =====================================================================
|
||||
//
|
||||
// CRITICAL DISTINCTION (this is what makes the package "true" threshold):
|
||||
//
|
||||
// - For HBS schemes a signature reveals SELECTED secret elements
|
||||
@@ -21,12 +62,14 @@
|
||||
// signature.
|
||||
//
|
||||
// This is NOT "collect N independent SLH-DSA signatures and average
|
||||
// them"; that's the wrong thing. This is the right thing.
|
||||
// them"; that's the wrong thing. This is the right thing — but ONLY
|
||||
// when the dealer is in the TCB.
|
||||
//
|
||||
// V1 SCOPE — honest:
|
||||
// V1 SCOPE — HONEST:
|
||||
//
|
||||
// - Setup is DEALER-BACKED. A trusted dealer generates the shared
|
||||
// WOTS+/FORS material and distributes shares. (Public DKG = v2.)
|
||||
// WOTS+/FORS material and distributes shares.
|
||||
// (Public DKG = research, see dkg2/.)
|
||||
// - Helper data (the Merkle authentication paths and the public-key
|
||||
// chain endpoints) is shipped alongside the public key. McGrew et
|
||||
// al. allow this.
|
||||
@@ -40,7 +83,7 @@
|
||||
//
|
||||
// HARD INVARIANT (cf. THBS-SPEC.md):
|
||||
//
|
||||
// OK: ReconstructElement(slot, elementID, shares)
|
||||
// OK: reconstructElement(slot, elementID, shares) [unexported]
|
||||
// Forbidden: ReconstructSeed, ReconstructPrivateKey,
|
||||
// ExpandPrivateKey, DeriveAllFutureElements
|
||||
//
|
||||
|
||||
@@ -54,12 +54,12 @@ func runDealerDKG(t *testing.T, threshold, n int) (PublicKey, []PrivateShare) {
|
||||
}
|
||||
// Deterministic DKG for stable test vectors.
|
||||
seed := bytes.Repeat([]byte{0xA1}, 64)
|
||||
pk, shares, err := DKGAll(cfg, DKGOptions{DealerSeed: seed})
|
||||
pk, shares, err := DealerDKGAll(cfg, DKGOptions{DealerSeed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("DKGAll: %v", err)
|
||||
t.Fatalf("DealerDKGAll: %v", err)
|
||||
}
|
||||
if len(shares) != n {
|
||||
t.Fatalf("DKGAll returned %d shares, want %d", len(shares), n)
|
||||
t.Fatalf("DealerDKGAll returned %d shares, want %d", len(shares), n)
|
||||
}
|
||||
return pk, shares
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestTHBS_DKG_NoSeedExposure(t *testing.T) {
|
||||
_, shares := runDealerDKG(t, 2, 3)
|
||||
// We inspect every byte of every PrivateShare and assert no part
|
||||
// of it is the dealer seed. Because the dealer seed is wiped
|
||||
// before DKGAll returns, we use a structural check: the share
|
||||
// before DealerDKGAll returns, we use a structural check: the share
|
||||
// must not contain a "seed" field, and ElementShares must consist
|
||||
// of per-element entries.
|
||||
for i, s := range shares {
|
||||
@@ -670,7 +670,7 @@ func TestTHBS_DKGConfig_Validation(t *testing.T) {
|
||||
Params: testParams(),
|
||||
}
|
||||
tc.mut(&cfg)
|
||||
_, _, err := DKGAll(cfg, DKGOptions{})
|
||||
_, _, err := DealerDKGAll(cfg, DKGOptions{})
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
@@ -689,9 +689,9 @@ func TestTHBS_RandomSeed(t *testing.T) {
|
||||
Participants: parts,
|
||||
Params: testParams(),
|
||||
}
|
||||
pk, shares, err := DKGAll(cfg, DKGOptions{Rng: rand.Reader})
|
||||
pk, shares, err := DealerDKGAll(cfg, DKGOptions{Rng: rand.Reader})
|
||||
if err != nil {
|
||||
t.Fatalf("DKGAll(random): %v", err)
|
||||
t.Fatalf("DealerDKGAll(random): %v", err)
|
||||
}
|
||||
msg := []byte("random-seed-test")
|
||||
const slot Slot = 0
|
||||
|
||||
Reference in New Issue
Block a user