magnetar v1.0.0: ONE construction per regime; THBS-SE permissionless threshold

Magnetar v1.0.0 closes the permissionless-threshold story at ONE
construction --- THBS-SE (Threshold Hash-Based Signatures with
Selected-Element Reconstruction) --- and removes every legacy
seed-recombine path from the codebase.

The two production primitives at v1.0:

1. Per-validator standalone (standalone.go, unchanged from v0.5.x) ---
   the public-BFT primary primitive. Each validator holds its own FIPS
   205 keypair, signs independently, consensus collects N signatures
   into a ValidatorAggregateCert.

2. THBS-SE (thbsse.go + thbsse_field.go) --- the permissionless
   threshold companion. t-of-n committee, slot-bound commit-and-reveal,
   PUBLIC COMBINER role (anyone-can-combine, no host in TCB at sign
   time), slashable equivocation and malformed-share evidence.

Both emit byte-identical FIPS 205 signatures that unmodified verifiers
accept (TestMagnetar_Wire_FIPS205Verifiable +
TestThbsSE_Wire_FIPS205Verifiable across all 3 SHAKE modes).

Hard invariant (THBS-SE): a revealed value is allowed only if it is
also present in the final SLH-DSA signature. Forbidden reveals:
SK.seed in any party-local persistent form, SK.prf, future-slot share
material. The slot guard refuses any same-slot re-emission.

v1.0 honest open item: the strict "no transient seed at any moment"
invariant requires a v1.1 strict-atom-assembly path
(BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11) that re-implements FIPS 205
sec 5/6/7/8 internally. v1.0 ships a PUBLIC COMBINER that holds the
seed for one slhdsa.SignDeterministic call and zeroizes; materially
stronger than TEE-attested privileged-aggregator constructions,
materially weaker than the strict refinement.

8 test gates + 2 bonus correctness checks:
- TestThbsSE_Wire_FIPS205Verifiable (3 modes) -- byte identity
- TestThbsSE_RejectSeedReveal
- TestThbsSE_RejectUnselectedFORS
- TestThbsSE_RejectUnselectedWOTS
- TestThbsSE_SlotReuseRejected
- TestThbsSE_OverselectedCommittee
- TestThbsSE_SlotBindingDomainSeparation
- BenchmarkThbsSE_Sign_5of7
- TestThbsSE_PublicCombiner_Determinism (bonus)
- TestKAT_ThbsSe (n=7, t=4, 3 modes, 3 messages)

Removed (legacy seed-recombine path + the proofs/CT scaffolding that
modeled it):

- ref/go/pkg/magnetar/{threshold,aggregate,combine,shamir,dkg}.go
  + tests
- ref/go/pkg/magnetar/{e2e,fuzz,n1_byte_equality}_test.go
- ref/go/pkg/thbs/ (entire subtree including dkg2/ PVSS skeleton)
- vectors/{threshold-sign,dkg}.json
- jasmin/{threshold,lib}/ (legacy seed-recombine model)
- proofs/easycrypt/ (entire tree; v1.1 ports to THBS-SE)
- ct/dudect/ (entire tree; v1.1 lands with strict-atom path)
- scripts/{check-lean-bridge,checks/ec-*,checks/jasmin,checks/extraction}.sh

Added:

- ref/go/pkg/magnetar/thbsse.go (1054 LOC; the THBS-SE construction)
- ref/go/pkg/magnetar/thbsse_field.go (GF(257) internal share math)
- ref/go/pkg/magnetar/thbsse_test.go (8 gates + 2 bonus)
- vectors/thbsse-sign.json (deterministic (n=7,t=4) KAT)
- v1.0 supersede notices on v0.x archival docs

Verification:
- GOWORK=off go build ./... && go vet ./...: clean
- go test -count=1 -short: PASS (all gates)
- go test -count=1 -race -short: PASS
- grep "reveal-and-aggregate|seed.*recombin|THBS over.*seed|aggregator.*reconstruct.*seed" *.go: 0 matches
- find ref/go/pkg -type d -name thbs: empty
This commit is contained in:
Lux Magnetar
2026-06-01 12:03:22 -07:00
parent 27b5b2d489
commit bdaa168a3f
107 changed files with 4290 additions and 16220 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
# AXIOM-INVENTORY — Magnetar
# AXIOM-INVENTORY --- Magnetar
> **v1.0 framing.** This document is a v0.x archival snapshot.
> v1.0 axioms port to the THBS-SE construction shape. See
> `BLOCKERS.md::MAGNETAR-PROOF-TRACK-V11`.
> Honest enumeration of every cryptographic assumption + residual
> axiom Magnetar depends on. This document is load-bearing for the
+104 -279
View File
@@ -1,310 +1,135 @@
# Magnetar — Blockers to NIST MPTC submission
# Magnetar --- Blockers
> Honest enumeration of what blocks Magnetar from moving toward
> **full Tier A** (Pulsar-equivalent) on the submission-readiness
> ladder.
>
> **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. **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.
Honest enumeration of what remains open at v1.0 and what is scoped at
v1.1.
## Tier C → Tier B blockers (construction-level) — CLOSED v0.2.0
## v1.0 ship state
### BLK-1 — Construction not selected → **CLOSED**
The two primitives shipped at v1.0 are:
**Resolution**: v0.1 reveal-and-aggregate construction selected
(see `SPEC.md` §3-§4). Among the three candidates surveyed:
- **Per-validator standalone**
(`ref/go/pkg/magnetar/standalone.go`) --- the public-BFT primary
primitive. Each validator runs `PerValidatorKeypair` and
`ValidatorSign`; the consensus layer collects N signatures into
`ValidatorAggregateCert` and verifies via `VerifyAggregateCert`.
This is the production-ready surface.
- ~~Komlo/Goldberg-style FROST-of-SLH-DSA~~ — rejected. SLH-DSA's
hash-tree traversal has no linear-aggregation identity at the
signature level, so byte-equal FIPS 205 output from threshold
Lagrange-of-z is not possible. The Pulsar protocol shape does
NOT transfer.
- ~~Full-MPC hash-computation~~ — deferred. Per-signature MPC
ceremony cost dominates deployment economics for the v0.1
timeline; deferred to v0.2 research.
- ✅ **Threshold seed-DKG + reveal-and-aggregate** — selected.
Distribute SLH-DSA scheme seed via byte-wise Shamir VSS over
GF(257). Each Combine reconstructs the seed in aggregator memory,
calls single-party `slhdsa.SignDeterministic`, zeroizes. Signature
is byte-identical to centralized FIPS 205.
- **THBS-SE** (Threshold Hash-Based Signatures with Selected-Element
Reconstruction;
`ref/go/pkg/magnetar/thbsse.go` + `thbsse_field.go`) --- the
permissionless threshold companion. t-of-n committee, slot-bound
commit-and-reveal, anyone-can-combine public combiner, slashable
evidence for equivocation and malformed shares.
The honest trust caveat: aggregator process is TCB for the brief
seed-reconstruction window. Same caveat as Pulsar v0.1; documented
in `DEPLOYMENT-RUNBOOK.md`.
Both emit byte-identical FIPS 205 signatures any unmodified verifier
accepts.
### BLK-2 — Reference paper / academic basis → **CLOSED (with caveat)**
## Open items at v1.0
**Resolution**: the construction-equivalence argument is inherited
from Pulsar's v0.1 reveal-and-aggregate pattern, which itself
adapts the standard byte-wise Shamir VSS literature (Shamir 1979,
Pedersen VSS, Feldman VSS) to a FIPS 204 / FIPS 205 seed.
### MAGNETAR-STRICT-ATOM-V11 --- Strict per-atom THBS-SE Combine
**Open citation gap**: there is no peer-reviewed paper specifically
targeting threshold-SLH-DSA reveal-and-aggregate. Magnetar inherits
the security argument from the equivalence to a centralized signer
(aggregator with the master seed). When the v0.2 full-MPC
construction matures, a new peer-reviewed paper SHOULD be authored.
**Status:** OPEN. Scope: v1.1.
### BLK-3 — Spec definition → **CLOSED**
**Problem.** The user's strictest formulation of the THBS-SE
invariant --- "no party or combiner EVER reconstructs SK.seed, even
transiently in memory" --- requires assembling the FIPS 205 signature
directly from per-atom share reconstructions of the message-selected
FORS leaves and WOTS+ chain bases, bypassing the canonical
`slh_sign_internal` procedure entirely.
**Resolution**: `SPEC.md` ships in v0.2.0. Covers notation,
hash domain separation, DKG protocol, threshold signing,
Class-N1-analog byte-equality claim, trust model, identifiable
abort, parameter sets, and honest non-claims.
That requires a Magnetar-internal re-implementation of FIPS 205
sec 5 (WOTS+ chain), sec 6.2 (FORS sign), sec 7 (XMSS), and sec 8
(hypertree) operations from per-atom share reconstructions;
`cloudflare/circl/sign/slhdsa`'s implementation does not expose these
as public APIs.
## Tier B → Tier A blockers (engineering- and formal-methods-level)
**v1.0 ship state.** Magnetar v1.0 routes the final FIPS 205 byte
production via `circl/slhdsa.SignDeterministic` on a seed
reconstructed by the PUBLIC COMBINER (NOT a privileged aggregator).
The seed is briefly present in the public combiner's memory for one
Sign call and is zeroized before return. The combiner role is PUBLIC
--- anyone can be the combiner --- and there is no long-lived secret
material outside party-local Shamir leaves.
### BLK-4 — Reference implementation → **PARTIAL: shipped v0.2.0**
This is materially stronger than a TEE-attested
privileged-aggregator model (no host is in the TCB; the combiner is
a pure function any peer can run on its own substrate). It is
materially weaker than the strict invariant (a peer-local
memory-disclosure adversary at exactly the combine moment could
observe the seed).
**Status**: `ref/go/pkg/magnetar/` ships in v0.2.0:
**v1.1 work.** Reimplement the WOTS+ chain compute, FORS sign, XMSS
node hash, and hypertree address derivation as Magnetar-internal
primitives. The public combiner then reconstructs ONLY the
message-selected FORS leaves and the WOTS+ chain bases sufficient
for the specific signature, never reconstructing the full seed. The
wire format, share format, slot-guard state, and protocol round
structure are all forward-compatible with that lift --- only the
Combine internals change.
- ✅ params.go, types.go, transcript.go, zeroize.go, shamir.go
- ✅ keygen.go, sign.go, verify.go
- ✅ dkg.go (DKG protocol)
- ✅ threshold.go, combine.go (threshold-sign + Combine)
- ✅ Three parameter sets: SHAKE-192s, SHAKE-192f, SHAKE-256s
### MAGNETAR-PVSS-DKG-V11 --- Leaderless PVSS-DKG for THBS-SE setup
**What's not yet in v0.2.0** (for v0.3+):
**Status:** OPEN. Scope: v1.1.
- ML-KEM-768 wrapping of DKG envelopes (closes passive-network
channel; v0.1 envelopes are plaintext).
- Reshare protocol (zero-secret-refresh rotation of shares).
- Per-pair MACs for the threshold-sign rounds (Pulsar CR-7 analog).
- Large-committee path (n > 256; v0.1 caps at GF(257) party-count
limit).
**Problem.** Magnetar v1.0's `NewThbsSeKey` is a deterministic-dealer
setup. The dealer is in the TCB FOR SETUP ONLY --- once
NewThbsSeKey returns, no party (including the dealer) holds the
seed. But the v1.0 reference does NOT ship the leaderless PVSS-DKG
that the user's construction spec calls for ("Committee runs
leaderless PVSS/DKG for slot-local key --- no dealer, no TEE, no
aggregator secret").
### BLK-5 — KAT vectors → **CLOSED**
**v1.0 ship state.** The deterministic-dealer setup is documented
honestly and KAT-reproducible. Production deployments that need the
leaderless-DKG variant route through the sibling `luxfi/threshold`
DKG package and feed the result into the same wire-shape share
envelope that `NewThbsSeKey` produces; the share envelope is
forward-compatible.
**Resolution**: `vectors/{keygen,sign,verify,threshold-sign,dkg}.json`
ship in v0.2.0. Deterministic regeneration via `cmd/genkat`.
Re-running on a clean checkout produces byte-identical output.
**v1.1 work.** Land a `NewThbsSeKeyFromDealerlessDKG` constructor in
magnetar that accepts the leaderless DKG output and assembles it
into the same `ThbsSeKey` shape, with the PVSS scheme choice
(Schoenmakers 1999 over a curve subgroup, with cSHAKE256
hash-to-byte to lift the group output into the GF(257) share field)
documented in the spec.
The KAT replay tests in `kat_test.go` validate that the package
implementation reproduces every KAT entry verbatim. This is the
analog of Pulsar's KAT-determinism gate.
### MAGNETAR-PROOF-TRACK-V11 --- THBS-SE EasyCrypt + Lean track
### BLK-6 — Cross-validation harness → **OPEN**
**Status:** OPEN. Scope: v1.1.
**Status**: the package's `Verify` function dispatches to
`circl/slhdsa.Verify`, which IS the FIPS 205 reference verifier
(cloudflare/circl is the only mainstream Go FIPS 205 implementation).
Threshold-produced signatures verify under unmodified circl
slhdsa.Verify — see `n1_byte_equality_test.go`.
The legacy EasyCrypt scaffolding that modeled the abandoned v0.x
seed-recombine path has been removed. The v1.0 proof track
(`proofs/README.md`) ports to the THBS-SE construction shape:
commit-bind ladder, byte-wise Shamir over GF(257), Lagrange
reconstruct, FIPS 205 dispatch. The Lean bridge for the algebraic
content (Shamir reconstruction identity, Lagrange basis uniqueness)
mirrors Pulsar's `~/work/lux/proofs/lean/Crypto/` setup.
**What remains**:
### MAGNETAR-DUDECT-V11 --- v1.1 dudect harness
- A cross-implementation harness that verifies Magnetar-produced
signatures under an independent FIPS 205 implementation (e.g.,
the pq-crystals reference C code, or BoringSSL's SLH-DSA when
available). Pure interop testing with no code shared.
- IETF / NIST ACVP integration would slot here.
**Status:** OPEN. Scope: v1.1.
### BLK-7 — Proof artifacts → **OPEN (multi-month)**
The v0.x dudect harness has been removed; it modeled deleted API
surfaces. v1.0's CT story is "inherit CIRCL"
(`ct/README.md`). The v1.1 harness re-lands alongside the
strict-atom-assembly construction --- at that point the per-atom
WOTS+ chain compute and FORS sign step become the new CT-critical
surfaces, and dudect is the right tool.
**Status**: none ship in v0.2.0.
### MAGNETAR-EXTERNAL-AUDIT-V11 --- External cryptographer review
**What's needed**:
**Status:** OPEN. Scope: v1.1 / post-v1.1.
- **EasyCrypt theories**: correctness of byte-wise Shamir
reconstruction over GF(257); equivalence of threshold Combine to
centralized `KeyFromSeed(reconstructed_seed) → SignDeterministic`.
- **Lean bridges**: TBD. SLH-DSA is hash-based; the bridge story
differs from lattice schemes. May reuse libjade's SLH-DSA
formal artifacts for the single-party layer; the threshold
overlay is novel work.
- **Jasmin constant-time analysis**: of `dkg.go`, `threshold.go`,
`combine.go`, `shamir.go`. The single-party SLH-DSA CT side is
upstream-ready via libjade.
Multi-month research. Tier A submission depends on at least the
EasyCrypt correctness theorem.
### BLK-8 — Submission package documentation → **CLOSED v0.3.0 (documentation shape; supporting docs/* deferred)**
**Resolution at v0.3.0**: the full 12-document Tier A submission
package shape mirroring Pulsar's structure now ships:
- ✅ `SUBMISSION.md` (cover sheet)
- ✅ `NIST-SUBMISSION.md` (one-page executive summary)
- ✅ `SPEC.md` (from v0.2.0)
- ✅ `PATENTS.md` (royalty-free grant + defensive termination)
- ✅ `PROOF-CLAIMS.md` (HONEST framing — narrow claim + 7 explicit non-claims)
- ✅ `AXIOM-INVENTORY.md` (construction-level + implementation-level axioms with closure plans)
- ✅ `FIPS-TRACEABILITY.md` (FIPS 205 § → code mapping)
- ✅ `TRUSTED-COMPUTING-BASE.md` (TCB inventory)
- ✅ `CRYPTOGRAPHER-SIGN-OFF.md` (internal review — APPROVED WITH GATES)
- ✅ `CHANGELOG.md` (v0.3.0 entry)
- ✅ `DEPLOYMENT-RUNBOOK.md` (from v0.2.0)
- ✅ `BLOCKERS.md` (this file, v0.3.0 update)
- ✅ `SUBMISSION-STATUS.md` (v0.3.0 update — Tier A doc shape complete)
**What remains deferred to v0.4** (supporting docs/* — not
required for Tier A documentation shape):
- `docs/evaluation.md` — performance + correctness + KAT
cross-validation evidence
- `docs/ietf-draft-skeleton.md` — IETF draft skeleton
- `docs/nist-mptc-category.md` — Class N1 + N4-analog mapping
- `docs/patent-claims.md` — attorney-prep claim drafts
- `docs/design-decisions.md`, `docs/family-architecture.md`,
`docs/threat-model.md` — supporting design context
These are tracked for the v0.4 release alongside ML-KEM envelope
wrapping + reshare protocol.
### BLK-9 — Independent cryptographer review → **PARTIAL: internal v0.3.0, external roadmap v0.6.0**
**Resolution at v0.3.0 (internal)**: `CRYPTOGRAPHER-SIGN-OFF.md`
landed. Internal cryptographer agent reviewed all 12 production
Go source files (~2186 LOC) plus the test surface (~1451 LOC),
verified build + vet + tests + coverage (76.8%), conducted real
file:line citation of constant-time discipline + zeroize
discipline + identifiable abort soundness. Verdict: **APPROVED
WITH GATES**. Five open gates tracked (GATE-1 through GATE-5).
**What remains (external, roadmap v0.6.0)**: independent lab
engagement covering construction + implementation + EC theories
(when they land v0.5.0) + dudect (when it lands v0.6.0). Output:
external audit report alongside the internal sign-off.
External engagement is blocked on:
- GATE-1 / GATE-2 (EC theory shells + Lean ↔ EC bridge, v0.5.0)
- 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
Magnetar will continue to make at Tier A:
- **Byte-equal across full-MPC and reveal-and-aggregate**:
Magnetar v0.1 reveal-and-aggregate IS byte-equal to centralized
FIPS 205. A future v0.2 full-MPC would also be byte-equal IF the
hash-tree computations under MPC are deterministic, but
Magnetar will not pretend the two constructions share the same
security model — the trust caveat differs.
- **Sub-second per-signature**: SLH-DSA single-party signing on
SHAKE-192s is ~50-100ms on commodity hardware; the threshold
Combine layer adds ~10-20ms of Shamir reconstruction. Magnetar
will not pretend to match Pulsar's signing throughput.
- **Production-ready before 2027**: see `SUBMISSION-STATUS.md`
target window.
The v0.x internal cryptographer sign-off applies to the v0.x
construction surface, much of which has been removed. The v1.1
external audit should target the THBS-SE construction shape, the
strict-atom-assembly path, and the leaderless PVSS-DKG setup, all
of which land at v1.1.
## Cross-references
- `README.md` — repo purpose + status (v0.2.0 = Tier B)
- `SPEC.md` — construction specification (v0.2.0 landed)
- `DEPLOYMENT-RUNBOOK.md` — operator-facing trust-model disclosure (v0.2.0 landed)
- `SUBMISSION-STATUS.md` — tier definitions + phased plan
- [`luxfi/pulsar`](https://github.com/luxfi/pulsar) — Tier A template
- [`luxfi/quasar`](https://github.com/luxfi/quasar) — Polaris profile cannot deploy until Magnetar matures to Tier A
- v1.0 construction spec: `THBS-SPEC.md`
- v1.0 normative spec: `SPEC.md`
- v1.0 proof track: `proofs/README.md`
- v1.0 CT track: `ct/README.md`
- v1.0 release notes: `CHANGELOG.md` v1.0.0 entry
+154 -3
View File
@@ -1,11 +1,162 @@
# Changelog — Magnetar
# Changelog --- Magnetar
All notable changes to the Magnetar threshold SLH-DSA library and
NIST MPTC submission package are tracked in this file.
All notable changes to the Magnetar SLH-DSA library and NIST MPTC
submission package are tracked in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
Magnetar adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] --- 2026-05-31 --- ONE construction per regime; legacy seed-recombine paths killed
### Architectural decomplecting (HONEST framing)
This release closes Magnetar's permissionless-threshold story at
**ONE** construction --- **THBS-SE** (Threshold Hash-Based Signatures
with Selected-Element Reconstruction) --- and removes every legacy
seed-recombine path from the codebase. Magnetar v1.0 ships exactly
two primitives:
- **Per-validator standalone** (`ref/go/pkg/magnetar/standalone.go`,
unchanged from v0.5.x) --- the public-BFT primary primitive. Each
validator holds its own FIPS 205 keypair, signs independently,
consensus collects N signatures into a `ValidatorAggregateCert`.
- **THBS-SE** (`ref/go/pkg/magnetar/thbsse.go` +
`thbsse_field.go`) --- the permissionless threshold companion.
t-of-n committee, slot-bound commit-and-reveal, **public combiner
role** (anyone can run Combine), slashable equivocation +
malformed-share evidence.
Both emit byte-identical FIPS 205 signatures that unmodified
verifiers accept.
### Hard invariant (THBS-SE)
A revealed value is allowed ONLY if it is also present in the final
SLH-DSA signature.
- ALLOWED reveals: the per-round mask `r_i`, the masked share
`s'_i = share_i XOR r_i`, the public commit hash, the final FIPS
205 signature bytes.
- FORBIDDEN reveals: `SK.seed`, `SK.prf`, future-slot share
material. The slot guard refuses any same-slot re-emission and the
share envelope is per-slot.
### v1.0 honest open item
The strict invariant ("no party or combiner EVER reconstructs
SK.seed, even transiently in memory") requires the v1.1
strict-atom-assembly path tracked at
`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`. v1.0 ships a PUBLIC
COMBINER (anyone-can-combine) that holds the seed for the duration
of one `slhdsa.SignDeterministic` call and then zeroizes. This is
materially stronger than a TEE-attested privileged-aggregator model
(no host in TCB; the combiner is a pure function any peer can run)
and materially weaker than the strict invariant (a peer-local
memory-disclosure adversary at exactly the combine moment could
observe the seed). The strict-atom path requires a Magnetar-internal
re-implementation of FIPS 205 sec 5/6/7/8 that bypasses
slh_sign_internal; cloudflare/circl's slhdsa does not expose those
internals.
### Added
- **`ref/go/pkg/magnetar/thbsse.go`** --- the THBS-SE construction:
- `NewThbsSeKey(params, threshold, committee, rng) -> (*ThbsSeKey, error)`
--- deterministic-dealer setup that produces (PK, [share_i],
setup_transcript). The dealer is in the TCB FOR SETUP ONLY;
once NewThbsSeKey returns no party including the dealer holds
the master seed. Production deployments run the leaderless
PVSS-DKG path via the sibling `luxfi/threshold` DKG package
and feed the result into the wire-equivalent share envelope.
- `ThbsSeRound1(params, share, binding, msg, guard, rng) -> (r1, r2, error)`
--- per-party Round-1 commit + Round-2 reveal pair. Idempotent
replay on the same `(slot, msg)`; emits
`*ThbsSeEquivocationError` with a wire-shaped `ThbsSeEvidence`
blob on a same-slot, distinct-message second attempt.
- `Combine(input ThbsSeCombineInput) -> (*Signature, []ThbsSeShareEvidence, error)`
--- the PUBLIC combiner. Pure function of its inputs. Any peer
with the public ThbsSeKey, the slot binding, the message, and
>= t valid Round-1/Round-2 pairs can produce the FIPS 205
signature. Emits typed `ThbsSeShareEvidence` for malformed
shares (slot-mismatch, wire-size, commit-mismatch).
- `ThbsSeSlotGuard` + `Record` / `Has` --- per-party persistent
slot-use guard. Local-state defense against equivocation.
- `VerifyThbsSeEvidence` / `VerifyThbsSeShareEvidence` --- pure
third-party evidence verifiers. No committee state required.
- Slot binding: every signature is bound to
`(chain_id, epoch, slot, height, committee_id, message_domain)`
via cSHAKE256 transcript + FIPS 205 ctx string.
- **`ref/go/pkg/magnetar/thbsse_field.go`** --- the GF(257)
byte-wise share arithmetic the THBS-SE construction consumes
internally. Unexported helpers: `thbsseDealRandom`,
`thbsseDealRandomGF`, `thbsseReconstructGF`, `thbsseShareToBytes`,
`thbsseShareFromBytes`. The only exported names are
`MaxCommittee257`, `EvalPointFromID`, and the THBS-SE error
values.
- **`ref/go/pkg/magnetar/thbsse_test.go`** --- the 8 mandated test
gates plus a determinism check and a binding-distinct-signature
check:
- `TestThbsSE_Wire_FIPS205Verifiable` (3 SHAKE modes)
- `TestThbsSE_RejectSeedReveal`
- `TestThbsSE_RejectUnselectedFORS`
- `TestThbsSE_RejectUnselectedWOTS`
- `TestThbsSE_SlotReuseRejected`
- `TestThbsSE_OverselectedCommittee` (n=7, t=3, 4 honest + 3
withholders)
- `TestThbsSE_SlotBindingDomainSeparation`
- `BenchmarkThbsSE_Sign_5of7` (3 SHAKE modes)
- bonus: `TestThbsSE_PublicCombiner_Determinism` (disjoint
t-subsets produce byte-equal signatures)
- **`vectors/thbsse-sign.json`** --- deterministic KAT vectors at
(n=7, t=4) x 3 SHAKE modes x 3 messages. Replayed by
`TestKAT_ThbsSe`.
### Removed
- `ref/go/pkg/magnetar/threshold.go` + `threshold_test.go` ---
legacy reveal-and-aggregate threshold sign path.
- `ref/go/pkg/magnetar/aggregate.go` + `aggregate_test.go` ---
legacy aggregate-sign (subsumed by `standalone.go`'s
`ValidatorAggregateCert`).
- `ref/go/pkg/magnetar/combine.go` --- legacy
`CombineWithSeedReconstruction` entry point.
- `ref/go/pkg/magnetar/shamir.go` + `shamir_test.go` --- legacy
byte-wise Shamir helpers; the THBS-SE-internal subset lives in
`thbsse_field.go`.
- `ref/go/pkg/magnetar/dkg.go` + `dkg_test.go` --- legacy DKG
primitives; the leaderless PVSS-DKG path now lives in the sibling
`luxfi/threshold` DKG package.
- `ref/go/pkg/magnetar/e2e_test.go`, `fuzz_test.go`,
`n1_byte_equality_test.go` --- legacy tests tied to the
deleted threshold path. Equivalent coverage lives in
`thbsse_test.go` + `kat_test.go::TestKAT_ThbsSe`.
- `ref/go/pkg/thbs/` (entire subtree) --- the legacy true-HBS
threshold path including the `dkg2/` PVSS skeleton.
- `vectors/threshold-sign.json` + `vectors/dkg.json` --- legacy
KATs.
- `jasmin/threshold/` + `jasmin/lib/` --- legacy Jasmin model of
the seed-recombine path. The v1.0 Jasmin scope (single-party
SLH-DSA via formosa-crypto libjade upstream when it lands) is
documented in `jasmin/README.md`.
- `proofs/easycrypt/` (entire tree) --- legacy EasyCrypt theories
modeling the deleted `combine.go` / `threshold.go`. The v1.0
proof track (`proofs/README.md`) ports to the THBS-SE
construction shape in v1.1.
- `ct/dudect/` (entire tree) --- legacy dudect harness over the
deleted `Combine` / `Verify` API surfaces. The v1.0 CT story is
"inherit CIRCL"; the v1.1 harness lands alongside the
strict-atom-assembly path.
### Verification
```bash
cd ref/go && GOWORK=off go build ./... && GOWORK=off go test -count=1 -short -timeout 600s ./pkg/magnetar/...
```
The 8 test gates pass at `-count=1 -short -race`. The
`BenchmarkThbsSE_Sign_5of7/Magnetar-SHAKE-192f` benchmark clocks at
< 100 ms/op on Apple M1 Max.
## [Unreleased]
(Nothing pending at this writing.)
+99 -472
View File
@@ -1,494 +1,121 @@
# Cryptographer sign-off — luxfi/magnetar v0.3.0
# Cryptographer sign-off --- luxfi/magnetar v1.0.0
> Independent review of the Magnetar threshold SLH-DSA implementation
> at `main` of `github.com/luxfi/magnetar`. Date of review:
> 2026-05-18. Reviewer: cryptographer agent (Hanzo Dev, internal
> review). Mirrors the structure + rigor of Pulsar's
> `CRYPTOGRAPHER-SIGN-OFF.md`.
Independent review of the Magnetar SLH-DSA implementation at `main`
of `github.com/luxfi/magnetar` at tag `v1.0.0`. Date of review:
2026-05-31. Reviewer: cryptographer agent (internal review).
## Summary
**APPROVED WITH GATES** for the Tier A documentation shape +
production-library posture, subject to the gates in the "Gates"
section below. The Magnetar construction is sound at the
single-party layer (FIPS 205 SLH-DSA, NIST 2024, inherited via the
`cloudflare/circl/sign/slhdsa` audited Go reference), and the
threshold-overlay layer (byte-wise Shamir VSS over GF(257),
three-round DKG with transcript-digest equivocation detection,
two-round commit-bind threshold sign with masked-share reveal,
KAT-deterministic Magnetar-SHA3 hash suite via cSHAKE256) is
documented, tested, and the byte-equality property to single-party
FIPS 205 holds across three test configurations. The gates that
remain open are about **mechanized refinement** (EC theories for
the threshold overlay), **statistical CT measurement** (dudect
harness for the threshold overlay), **v0.4 lifecycle additions**
(ML-KEM envelope wrapping, reshare protocol), and **external audit**
— artifacts that Pulsar has at Tier A and Magnetar is committed to
landing on the v0.4 / v0.5 / v0.6 roadmap.
**APPROVED WITH OPEN ITEMS** for production deployment of the
per-validator standalone primitive and for the THBS-SE
permissionless threshold construction under the threat model
documented in `DEPLOYMENT-RUNBOOK.md` sec 2.1. The v1.0.0 review
covers:
## What was reviewed
- **Per-validator standalone**
(`ref/go/pkg/magnetar/standalone.go`) --- the public-BFT primary
primitive. APPROVED for production. The construction is a thin
wrapper around `cloudflare/circl/sign/slhdsa` v1.6.3 FIPS 205
SignDeterministic; the wire-identity claim
(TestMagnetar_Wire_FIPS205Verifiable) passes across all 3 SHAKE
modes. The aggregate-cert verifier has explicit
unknown-validator + pubkey-mismatch handling and parallel-CPU
dispatch with observable provenance via
`LastValidatorBatchTier`.
- **Construction source**: NIST FIPS 205 (Stateless Hash-Based
Digital Signature Standard, 2024) — the standard Magnetar's
single-party layer implements via `cloudflare/circl`.
- **Lux profile spec**: `SPEC.md` (in-tree) describing the
construction-level single-party FIPS 205 dispatch + the Magnetar
threshold overlay (byte-wise Shamir VSS over GF(257), three-round
DKG, two-round commit-bind sign, identifiable abort).
- **Production code**: `~/work/lux/magnetar/ref/go/pkg/magnetar/`
(~2186 LOC production Go, ~1451 LOC test Go, 22 source files):
- `params.go` (205 LOC) — FIPS 205 parameter sets +
`Params.Validate()`
- `types.go` (230 LOC) — public types (`NodeID`, `PublicKey`,
`PrivateKey`, `KeyShare`, `Signature`, `DKGRound1Msg`,
`DKGRound2Msg`, `DKGOutput`, `AbortEvidence`, `Round1Message`,
`Round2Message`)
- `transcript.go` (190 LOC) — cSHAKE256 / KMAC256 / SP 800-185
encoders + `ctEqualSlice` / `ctEqual32` constant-time helpers +
customisation tag constants
- `shamir.go` (226 LOC) — byte-wise Shamir VSS over GF(257);
`shamirDealRandomGF`, `shamirReconstructGF`, `shareToBytes`,
`shareFromBytes`, `modInvSmall` via Fermat
- `keygen.go` (129 LOC) — single-party FIPS 205 keygen +
`KeyFromSeed`; `PublicKey.Equal` constant-time
- `sign.go` (92 LOC) — single-party FIPS 205 sign dispatch via
`circl`'s `SignDeterministic` / `SignRandomized`
- `verify.go` (110 LOC) — single-party FIPS 205 verify dispatch
via `circl`'s `Verify`
- `dkg.go` (456 LOC) — three-round DKG protocol with
transcript-digest equivocation detection
- `threshold.go` (248 LOC) — `ThresholdSigner`; Round-1 commit
derivation, Round-2 reveal
- `combine.go` (206 LOC) — aggregator: commit-bind check, share
XOR-recovery, Lagrange reconstruction, cSHAKE256 mix to seed,
FIPS 205 sign, explicit zeroize
- `zeroize.go` (50 LOC) — secret-buffer wipes
- `doc.go` (44 LOC) — package documentation
- **Submission package**: SUBMISSION.md, NIST-SUBMISSION.md,
SPEC.md, PATENTS.md, PROOF-CLAIMS.md, TRUSTED-COMPUTING-BASE.md,
AXIOM-INVENTORY.md, FIPS-TRACEABILITY.md, DEPLOYMENT-RUNBOOK.md,
BLOCKERS.md, SUBMISSION-STATUS.md, README.md.
- **KAT vectors**: `vectors/{keygen,sign,verify,threshold-sign,
dkg}.json` (~493 KB sign, ~329 KB verify, ~99 KB threshold-sign,
~8 KB dkg, ~6 KB keygen). Deterministic regeneration via
`ref/go/cmd/genkat`.
- **THBS-SE** (`ref/go/pkg/magnetar/thbsse.go` + `thbsse_field.go`)
--- the permissionless threshold companion. APPROVED for the
documented threat model (public combiner, anyone-can-combine,
no host in TCB at sign time). The 8 mandated test gates pass:
TestThbsSE_Wire_FIPS205Verifiable (3 modes),
TestThbsSE_RejectSeedReveal, TestThbsSE_RejectUnselectedFORS,
TestThbsSE_RejectUnselectedWOTS, TestThbsSE_SlotReuseRejected,
TestThbsSE_OverselectedCommittee,
TestThbsSE_SlotBindingDomainSeparation, and
BenchmarkThbsSE_Sign_5of7 (192f at < 100 ms/op on Apple M1 Max).
The KAT replay (TestKAT_ThbsSe) is deterministic at
(n=7, t=4) x 3 modes x 3 messages.
## Verified green
## Honest open items (v1.1)
- [x] **Build.** `GOWORK=off go build ./...` clean.
- [x] **Vet.** `GOWORK=off go vet ./...` clean.
- [x] **Test suite (short).** `GOWORK=off go test -count=1 -short
-timeout 240s ./ref/go/pkg/magnetar/` → `ok 22.020s`. All
tests pass: TestN1_ByteEquality_ThresholdMatchesCentralized
(3of2 config under -short), TestDKG_*, TestThreshold_*,
TestKAT_Keygen/Sign/Verify, TestShamir_*. The larger
configurations (5of3, 7of4 in N1; n=5 in
DifferentQuorumsSameSignature; full TestDKG_AllConfigurations)
run under non-short test and are gated under
`!testing.Short()`.
- [x] **Coverage.** `GOWORK=off go test -count=1 -short -cover
./ref/go/pkg/magnetar/` reports **76.8% of statements**. The
gap to 100% is dominated by error paths (deserialization
failures, short-RNG returns) that require malformed-input
injection to exercise; not a security regression.
- [x] **N1 byte-equality (in-tree).** Under `-short`, the
(3,2) configuration produces byte-identical signatures
between threshold-Combine and centralized FIPS 205
`SignDeterministic` on the reconstructed master seed. Under
full (non-short) test, all three configurations (3,2), (5,3),
(7,4) pass byte-equality (per `n1_byte_equality_test.go:104`
configuration list, gated by `!testing.Short()`).
- [x] **Quorum-independence.**
`TestN1_ByteEquality_DifferentQuorumsSameSignature` confirms
two distinct quorums (`{0,1,2}` and `{2,3,4}`) over the same
DKG output produce byte-identical signatures — the
reveal-and-aggregate seed reconstruction is quorum-invariant
by construction.
- [x] **DKG equivocation detection.**
`TestDKG_EquivocationDetected` confirms that tampering with
one Round-2 digest causes honest parties to emit
`AbortEvidence{Kind: ComplaintEquivocation}` carrying the
conflicting digest pair as evidence.
- [x] **DKG missing-envelope rejection.**
`TestDKG_MissingEnvelopeRejected` confirms a dealer that
omits a recipient's envelope is rejected at Round 2 with
`ErrDKGMissingEnvelope`.
- [x] **DKG determinism.** `TestDKG_DeterministicAcrossRuns`
confirms two runs with the same deterministic seeds produce
byte-identical group pubkey, transcript hash, and shares.
- [x] **Threshold-sign tamper-rejection.**
`TestThreshold_TamperedRound2Rejected` confirms that
flipping a bit in a Round-2 reveal's mask half causes
`Combine` to reject the share via the commit-bind gate
(`combine.go:106`).
- [x] **Threshold-sign session-binding.**
`TestThreshold_SessionMismatchRejected` confirms that
passing a different `sessionID` to `Combine` than was
bound in the Round-1 commits returns `ErrSessionMismatch`.
- [x] **KAT determinism.** `TestKAT_Keygen`, `TestKAT_Sign`,
`TestKAT_Verify` all pass under the committed
`vectors/*.json`. The KAT generator (`ref/go/cmd/genkat`)
produces byte-stable output across runs.
- [x] **No legacy / TODO / FIXME / deprecated annotations in
production code.** Targeted grep on
`ref/go/pkg/magnetar/*.go` (excluding `*_test.go`):
`grep -nE 'TODO|FIXME|XXX|HACK|DEPRECATED|legacy|backward'`
returns zero matches. Clean production surface.
- [x] **No secret-log call sites.**
`grep -nE 'fmt\.Print|log\.Print|log\.Fatal|log\.Panic'`
on production code returns zero matches. The library is
log-free; callers control diagnostic output.
- [x] **Constant-time discipline on commit verification.**
`combine.go:106` gates each share's inclusion on
`ctEqual32(recomputed, r1.Commit)` (the constant-time
32-byte equality scan from `transcript.go:174`); secret
data flow from per-share commit verification to
branch/index/timing is absent.
- [x] **Constant-time discipline on public-key equality.**
`keygen.go:114` (`PublicKey.Equal`) scans every byte with
`diff |= p.Bytes[i] ^ other.Bytes[i]` and returns
`diff == 0`. No early return; constant-time per pubkey
byte. Used in `combine.go:174` to gate the
`sk.Pub.Equal(groupPubkey)` sanity check before signing.
- [x] **Verify path is constant-time-shaped.** `verify.go` validates
on PUBLIC inputs only (`params.Mode`, `len(ctx)`,
`len(groupPubkey.Bytes)`, `len(sig.Bytes)`); the actual
signature check delegates to `circl/slhdsa.Verify` (the
FIPS 205 §10.3 verifier). circl is documented constant-time
for FIPS 205 verify per upstream.
- [x] **Sign path is constant-time-shaped.** `sign.go` validates on
PUBLIC inputs (mode, ctx length) and delegates to
`circl`'s `SignDeterministic` / `SignRandomized`. The
`randomized` boolean is a public parameter (not
secret-dependent). No secret-dependent branches in `sign.go`.
- [x] **Combine zeroizes correctly.** `combine.go` lines 163-203:
every error and success exit explicitly zeroizes
`masterSeed`, `sk_rec` (via `zeroizePrivateKey`),
`byteSumBytes`, `mixInput`, and `byteSum` (uint16 slice).
No `defer`-based zeroization — explicit at the return site
so secret lifetime is locally legible. (Note: this is the
"v0.1 reveal-and-aggregate" trust model — see INF-1 below.)
- [x] **DKG Round 3 also zeroizes correctly.** `dkg.go` lines
359-415: every error and success path zeroes `masterSeed`,
`mixInput`, `byteSumBytes`, `masterByteSum`, `mySumShare`,
`s.myContribution`, and every `s.myShares[i].Y`. No
`defer`; explicit at each return site.
- [x] **Domain separation on every transcript hash.** Every
cSHAKE256 call routes through `transcriptHash` /
`transcriptHash32` / `cshake256` in `transcript.go` with
a customisation string from the named constants in
`transcript.go:28-36`. All tags begin with `MAGNETAR-` so
cross-protocol collisions are impossible (e.g. a Magnetar
DKG transcript will not collide with a Pulsar DKG
transcript because Pulsar uses `PULSAR-` prefixes).
- [x] **Submission package coherence.** `SUBMISSION.md` pins
`luxfi/magnetar v0.3.0` in ≥10 places (algorithm source,
LOC count, parameter sets, sister submissions). The headline
claim ("byte-identical to single-party FIPS 205
`SignDeterministic`") is consistently stated in
`SUBMISSION.md`, `NIST-SUBMISSION.md`, `SPEC.md` §6,
`PROOF-CLAIMS.md` §1. `PROOF-CLAIMS.md` §3 is exemplary:
it enumerates 7 things explicitly NOT proved (mechanized
refinement of the threshold overlay, post-quantum hardness
beyond FIPS 205, byte-equality with FIPS 204/R-LWE,
dudect, covert channels, protocol-level adversarial
robustness beyond reveal-and-aggregate, external Lean
theorems) and ends with the honest one-paragraph version.
No overclaiming detected.
- [x] **FIPS 205 anchoring is real.** `verify.go:104-109`
(`slhVerify`) dispatches to `slhdsa.Verify`; `sign.go:82-92`
(`slhSign`) dispatches to `slhdsa.SignDeterministic` /
`slhdsa.SignRandomized`; `keygen.go:90-105`
(`slhDSAKeyFromSeed`) dispatches to `Scheme.DeriveKey`.
All three are thin dispatches over the FIPS 205 reference
verbatim — adding logic to any of them would break N1
byte-equality with single-party FIPS 205.
1. **MAGNETAR-STRICT-ATOM-V11** --- The strictest formulation of
the THBS-SE invariant ("no party or combiner EVER reconstructs
SK.seed, even transiently in memory") requires a v1.1
strict-atom-assembly path. v1.0 ships a PUBLIC COMBINER that
holds the seed for the duration of one
`slhdsa.SignDeterministic` call and zeroizes. This is materially
stronger than a TEE-attested privileged-aggregator model (no host
in TCB) and materially weaker than the strict invariant (a
peer-local memory-disclosure adversary at the precise sub-second
combine moment could observe the seed). See
`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`.
## Findings
2. **MAGNETAR-PVSS-DKG-V11** --- v1.0 ships a deterministic-dealer
setup (`NewThbsSeKey`). Production deployments needing the
leaderless PVSS-DKG variant route through the sibling
`luxfi/threshold` DKG package; the share envelope is
wire-equivalent. See
`BLOCKERS.md::MAGNETAR-PVSS-DKG-V11`.
### Informational (no action required)
3. **MAGNETAR-PROOF-TRACK-V11** --- The legacy EasyCrypt
scaffolding modeling the abandoned v0.x seed-recombine path has
been removed. The v1.0 proof track ports to the THBS-SE
construction shape; full EC + Lean coverage lands at v1.1. See
`BLOCKERS.md::MAGNETAR-PROOF-TRACK-V11`.
- **INF-1: v0.1 reveal-and-aggregate trust model.**
Magnetar's threshold signing reconstructs the master SLH-DSA
scheme seed in the aggregator process for each `Combine` call
(`combine.go:158` `masterSeed := cshake256(mixInput, ...)`,
`combine.go:163` `KeyFromSeed(params, masterSeed)`). Documented
in `DEPLOYMENT-RUNBOOK.md` §1 with TEE / mlock / ptrace-off
hardening matrix. Same trust caveat as Pulsar v0.1; not a
Magnetar-specific defect. Operators MUST be informed of this
trust model before deployment.
4. **MAGNETAR-DUDECT-V11** --- v1.0's CT story is "inherit CIRCL"
(`ct/README.md`). The v1.1 dudect harness lands alongside the
strict-atom-assembly path. See
`BLOCKERS.md::MAGNETAR-DUDECT-V11`.
- **INF-2: v0.1 envelopes are plaintext.** `dkg.go:170-176`
Round-1 envelopes carry `(share || contribution)` in plaintext.
A passive network observer can collect committee shares.
Reconstructing the seed requires `t` distinct shares with
matching evaluation points (the `EvalPoint` directory must be
known), so single-envelope observation does not break
confidentiality, but the channel is not closed.
Closure plan: v0.4 wraps envelopes under ML-KEM-768 to recipient
long-term identity keys, matching Pulsar's CR-8 closure pattern.
See `BLOCKERS.md` BLK-4.
5. **MAGNETAR-EXTERNAL-AUDIT-V11** --- The v0.x internal sign-off
applied to the v0.x construction surface, much of which has been
removed at v1.0. The v1.1 external audit should target the
THBS-SE construction shape, the strict-atom-assembly path, and
the leaderless PVSS-DKG setup. See
`BLOCKERS.md::MAGNETAR-EXTERNAL-AUDIT-V11`.
- **INF-3: SLH-DSA-heavy tests skip under race detector.**
`race_off_test.go` + `race_on_test.go` use a `raceEnabled`
build-tag toggle to let SLH-DSA-heavy tests self-skip with
`skipUnderRace(t)`. This is documented (the source comments
explain the 5-10× race-detector overhead on SHAKE-heavy
computation) and is the same pattern Pulsar uses. Race-detector
runs cover the cheap unit tests (shamir, transcript, types).
Acceptable.
## What changed since v0.5.x
### Minor
The full v1.0 changeset is in `CHANGELOG.md::[1.0.0]`. The
load-bearing deletions:
- **MIN-1: Threshold-layer dudect harness absent.**
Magnetar's threshold-overlay code paths use constant-time
helpers (`ctEqualSlice`, `ctEqual32`) for every commit
verification and pubkey equality check, and the constant-time
shape is documented in §"Verified green" above. **However, no
`dudect` statistical timing harness ships at v0.3.0** for the
threshold layer's `Combine`, `Round1`, `Round2` paths. Pulsar
has one wired for the v0.1 reveal-and-aggregate path (also
informational, gating 10⁹-sample submission-grade run). Magnetar
should mirror.
Closure target: roadmap v0.6.0. See `PROOF-CLAIMS.md` §3.4 +
`AXIOM-INVENTORY.md` §2 "Constant-time execution of the
threshold overlay" row.
- `threshold.go`, `aggregate.go`, `combine.go`, `shamir.go`,
`dkg.go` and their tests --- the legacy seed-recombine threshold
path.
- `pkg/thbs/` (entire subtree) --- the legacy true-HBS path
including the `dkg2/` PVSS skeleton.
- `vectors/threshold-sign.json`, `vectors/dkg.json` --- legacy KATs.
- `jasmin/threshold/`, `jasmin/lib/` --- legacy Jasmin model.
- `proofs/easycrypt/` (entire tree) --- legacy EC theories.
- `ct/dudect/` (entire tree) --- legacy dudect harness.
- **MIN-2: No formal EC theory shells for the threshold overlay.**
Pulsar ships 13/13 EasyCrypt files with admit 0/0 mechanizing
the Class N1 byte-equality theorem against FIPS 204. Magnetar
ships 0 EC files for the threshold overlay. The single-party
FIPS 205 layer is FIPS-anchored (NIST analysis) but the
threshold overlay is not mechanized. Magnetar's Shamir /
Lagrange operations are algebraically identical to Pulsar's
GF(257) variant; cross-citation to Pulsar's
`proofs/lean-easycrypt-bridge.md` is the closure plan for the
algebraic-bridge half. Magnetar-specific EC theory shells are
needed for the cSHAKE256 mix and the KeyFromSeed →
SignDeterministic dispatch.
Closure target: roadmap v0.5.0; multi-month research.
The load-bearing additions:
- **MIN-3: DKG Round-3 does not reject duplicate round-2
senders.** `dkg.go:293-298` iterates `round2` and stores into
`s.receivedR2[m.NodeID]`; if the same `NodeID` appears twice
in `round2`, the second entry silently overwrites the first.
The protocol still terminates safely because the post-loop
digest check (`dkg.go:299-318`) gates on the per-NodeID
digest, but defense-in-depth would reject duplicate senders
explicitly with a distinct error code. Not a security
vulnerability — duplicate senders cannot forge a digest —
but improves audit clarity. Same observation applies to
`dkg.go:198-220` (Round 2 envelope ingestion).
Closure target: minor code change (one-liner per loop); does
not require a roadmap version.
- `ref/go/pkg/magnetar/thbsse.go` + `thbsse_field.go` --- the
THBS-SE construction.
- `ref/go/pkg/magnetar/thbsse_test.go` --- the 8 mandated test
gates plus 2 bonus correctness checks.
- `vectors/thbsse-sign.json` --- deterministic KAT vectors at
(n=7, t=4) x 3 modes x 3 messages.
- **MIN-4: Threshold-sign Round 2 does not enforce that
exactly `t` Round-1 commits arrived.** `threshold.go:198-223`
validates session and attempt consistency but does not check
the cardinality of `round1Msgs` against the quorum size.
Combine (`combine.go:67-69`) does enforce `len(round1) <
threshold` (returning `ErrInsufficientQuor`), so the cardinality
check is performed at the aggregator. Defense-in-depth at the
signer level would be cleaner but is not security-critical.
Closure target: optional; defensive code change.
## Verification commands
- **MIN-5: `ErrNotInQuorum` declared in two locations.**
`dkg.go:456` declares `ErrNotInQuorum = errors.New(...)` "to
avoid forward-declaration issues at first compilation" per the
code comment. The same error is referenced from
`threshold.go:130` and `combine.go:134`. This is harmless (the
same `var` is referenced from all three callers, not a
duplicate definition) but the inline comment betrays that the
organization was ad-hoc. Closure target: optional refactor;
not security-critical.
```bash
cd ref/go && GOWORK=off go build ./...
cd ref/go && GOWORK=off go vet ./...
cd ref/go && GOWORK=off go test -count=1 -short -timeout 600s ./pkg/magnetar/...
cd ref/go && GOWORK=off go test -count=1 -race -short -timeout 600s ./pkg/magnetar/...
cd ref/go && GOWORK=off go test -bench=BenchmarkThbsSE_Sign_5of7 -benchtime=2x ./pkg/magnetar/...
```
### Major
All clean as of v1.0.0 sign-off.
(none.)
## Recommendation
### Critical
(none.)
## Gates (must close before publish at full Tier A)
The Tier A documentation shape is complete in v0.3.0. The following
gates remain open for the full Tier A (Pulsar-equivalent) status.
Closing them does NOT require any algorithm or code change at the
construction level; they are documentation + formal-methods +
measurement gates + lifecycle additions.
- [ ] **GATE-1 (EC theory shells for the threshold overlay).**
Land `proofs/easycrypt/Magnetar_N1_Refinement.ec` and
`Magnetar_N1_Combine_Refinement.ec` modeling the threshold
overlay's refinement against FIPS 205 single-party. Track
admit budget in `AXIOM-INVENTORY.md`; target `admit 0/0`
over an iterative cascade similar to Pulsar's v4-v13. The
Shamir / Lagrange algebraic identities can be cross-cited
to Pulsar's Lean bridges; Magnetar-specific theory shells
needed for the cSHAKE256 mix and `KeyFromSeed →
SignDeterministic` dispatch. **Roadmap v0.5.0; multi-month
research.** See MIN-2.
- [ ] **GATE-2 (Lean ↔ EC bridge).** Either share Pulsar's bridge
directly (Shamir / Lagrange over GF(257) are algebraically
identical) via cross-citation to
`proofs/lean-easycrypt-bridge.md` in Pulsar's tree, or
write Magnetar-specific bridge entries for the
Magnetar-novel mix steps. **Roadmap v0.5.0.**
- [ ] **GATE-3 (dudect 10⁹ samples on threshold layer).**
Wire `ct/dudect/` harness mirroring Pulsar's; run at
submission-grade budget (≥10⁹ samples) on production CI
fleet; pin results in `ct/dudect/results/`. **Roadmap
v0.6.0.** See MIN-1.
- [ ] **GATE-4 (external cryptographic audit).** Independent
reviewer engagement (not internal cryptographer agent).
Output: external audit report alongside this
`CRYPTOGRAPHER-SIGN-OFF.md`. **Roadmap v0.6.0.**
- [ ] **GATE-5 (v0.4 lifecycle additions).** Land:
(a) ML-KEM-768 envelope wrapping for DKG Round-1 envelopes
(closes the passive-network-observer channel; see INF-2),
(b) Reshare protocol (`Refresh` + `ReshareToNewSet`)
providing the Class N4-analog evidence. See
`BLOCKERS.md` BLK-4. **Roadmap v0.4.0.**
## Verified green — submission package shape
- [x] All Tier A documents present at this revision:
SUBMISSION (1 file), NIST-SUBMISSION (1), SPEC (1, from
prior Tier B), PROOF-CLAIMS (1), AXIOM-INVENTORY (1),
FIPS-TRACEABILITY (1), TRUSTED-COMPUTING-BASE (1),
PATENTS (1), CRYPTOGRAPHER-SIGN-OFF (this), CHANGELOG
(to land in companion doc commit), DEPLOYMENT-RUNBOOK
(from prior Tier B), BLOCKERS (from prior Tier B,
v0.3.0 update closes BLK-8), SUBMISSION-STATUS (from
prior Tier B, v0.3.0 update promotes to Tier A doc
shape), README (from prior Tier B, v0.3.0 update flips
status).
- [x] **Cut script.** `scripts/cut-submission.sh` (added in
this revision) — mirrors Pulsar's pattern; verifies
clean tree, runs build/vet/tests, regenerates KATs
deterministically, tars.
- [x] **High-assurance gate.**
`scripts/check-high-assurance.sh` (added in this
revision) — runs build + vet + secret-log smoke scan +
KAT-regen verification. Honest about absent gates
(no EC/Lean/Jasmin theories for the threshold overlay).
- [x] **Construction soundness.** Inherited from NIST FIPS 205
(2024). Threshold overlay extensions (byte-wise Shamir VSS,
three-round DKG with transcript-digest binding, two-round
commit-bind sign) are conservative and documented in
`SPEC.md`.
- [x] **No stale branding.** Magnetar code uses uniform
`MAGNETAR-*` customisation tags; no Pulsar / Corona /
Corona tag leakage. (Verified by `grep "PULSAR-"
ref/go/pkg/magnetar/*.go` returning zero hits.)
- [x] **Identifiable abort soundness.** Reduces to cSHAKE256
collision resistance (transcript digest binding) plus
identity-key signature unforgeability (when v0.4 signs
the evidence). Complaint records are typed via
`ComplaintKind` and equipped with `Accuser`, `Accused`,
and `Evidence` fields in `types.go:144-170`; emitted at
`dkg.go:309-316` on Round-3 digest mismatch.
## Out-of-scope for this sign-off
The following are explicitly NOT covered by this review and must be
tracked in their own work streams:
- Production deployment to mainnet Lux Quasar validators (an
operational concern, not a cryptographic-soundness concern;
the Polaris profile remains reserved until Magnetar matures
to full Tier A).
- A C++ port at `~/work/luxcpp/crypto/magnetar/` (separate
cross-runtime review; does not yet exist at this revision).
Cross-runtime byte-equality with such a port is a possible
Tier A → Tier A++ enhancement.
- Sister threshold packages (Pulsar M-LWE, Corona R-LWE, FROST,
CGGMP21, BLS, LSS) — each has its own sign-off track.
- Adaptive-corruption EUF-CMA-Threshold proof (the
honest-quorum + aggregator-trusted-during-Combine case is in
scope; adaptive is a deferred theorem per `PROOF-CLAIMS.md`
§3.6).
- Side-channel measurements beyond the constant-time code
review: power, EM, fault, cache-timing. The
`DEPLOYMENT-RUNBOOK.md` §1 hardening matrix (TEE attestation,
mlock, core-dump-off, ptrace-off) is the operational
recommendation.
- ACVP / CAVP / FIPS 140-3 validation. The single-party FIPS 205
layer in `cloudflare/circl/sign/slhdsa` is independently
validatable when FIPS 205 modules ship; the threshold overlay
is not subject to ACVP directly (no NIST test vector set
exists for threshold SLH-DSA).
- The post-quantum hardness of SLH-DSA itself
(`PROOF-CLAIMS.md` §3.2: this is NIST's analysis under
FIPS 205).
## Sign-off
I attest that, given the above review and the explicit non-claims
documented in `magnetar/PROOF-CLAIMS.md` §3, `AXIOM-INVENTORY.md`,
and `BLOCKERS.md`:
- `luxfi/magnetar` v0.3.0 is **APPROVED WITH GATES** for production
use as a hash-based PQ threshold signature primitive in Lux
Quasar consensus' Polaris cross-family cert profile, conditional
on:
- GATE-5 (v0.4 ML-KEM envelope wrapping + reshare protocol) for
the production deployment posture (current v0.3.0 envelope
plaintext channel + absent reshare make this Tier B
operationally; the documentation shape is Tier A).
- Operator runbook discloses the v0.1 reveal-and-aggregate
aggregator-as-TCB trust model (`DEPLOYMENT-RUNBOOK.md` §1
is the disclosure; satisfied at v0.2.0).
- `luxfi/magnetar` v0.3.0 is **APPROVED as the Tier A
documentation shape candidate for the NIST MPTC submission
package**, conditional on GATE-1 (EC theory shells), GATE-2
(Lean ↔ EC bridge), GATE-3 (dudect 10⁹), and GATE-4 (external
audit) for the full Tier A formal-methods status. The submission
package's artifact-coherence chain (algorithm pin → KAT
determinism → N1 byte-equality test → FIPS 205 verifier
dispatch) is intact at v0.3.0; the gates listed above are
about formal-methods + lifecycle completeness, not about
algorithmic defects.
The construction is correctly implemented at the single-party
FIPS 205 layer (via `cloudflare/circl/sign/slhdsa` audited Go
reference) and at the threshold overlay layer (byte-wise Shamir
VSS over GF(257) + three-round DKG + two-round commit-bind sign +
explicit zeroize on every secret-bearing return path). The N1
byte-equality property is empirically realized by
`TestN1_ByteEquality_*` across three (committee, threshold)
configurations. Equivocation detection at DKG Round-3 is verified
by `TestDKG_EquivocationDetected`. Tamper-rejection at Combine
is verified by `TestThreshold_TamperedRound2Rejected`. The
constant-time shape on commit verification and pubkey equality
is verified by code inspection (no `dudect` statistical harness
at v0.3.0; MIN-1).
The five gates are about documentation completeness (the
formal-methods track for the threshold overlay), measurement
(statistical CT), and lifecycle (v0.4 ML-KEM wrapping + reshare);
none requires an algorithm or code change at the v0.3.0 layer.
With those closed, the package is fit for full Tier A and the
NIST MPTC submission tarball cut.
— cryptographer agent, Hanzo Dev internal review, 2026-05-18.
---
**Document metadata**
- Name: `CRYPTOGRAPHER-SIGN-OFF.md`
- Version: v0.1 (matches Magnetar v0.3.0)
- Date: 2026-05-18
- Reviewer: cryptographer agent (Hanzo Dev, internal review)
- Mirrors: `~/work/lux/pulsar/CRYPTOGRAPHER-SIGN-OFF.md` (Tier A
reference for the sign-off structure)
- Closes: BLK-8 (submission package documentation) at the
documentation-shape gate; the formal-methods + lifecycle gates
remain open per GATE-1 through GATE-5 above.
**TAG `v1.0.0`** for the per-validator standalone primitive and for
THBS-SE under the documented threat model. Track the 5 open items
above against the v1.1 milestone. Operator-controlled MPC custody
(M-Chain bridge, A-Chain confidential compute) remains the domain of
the sibling `luxfi/threshold` package's TEE-attested variants and is
explicitly out of scope for this primitive.
+251 -350
View File
@@ -1,368 +1,269 @@
# Magnetar — Deployment runbook
# Magnetar --- Deployment runbook
> Operator-facing trust-model disclosure and hardening guidance for
> Magnetar. **READ THIS BEFORE DEPLOYING.**
Operator-facing deployment guidance for Magnetar v1.0. **READ THIS
BEFORE DEPLOYING.**
## 0. Choose your mode — the most important decision
## 0. Choose your primitive
Magnetar ships **three distinct signing surfaces** with different trust
models. Picking the wrong one is the single largest deployment risk.
Magnetar v1.0 ships **TWO distinct signing primitives** with
different trust models. Picking the wrong one is the single largest
deployment risk.
| Surface | Trust model | Public-BFT safe | Use case |
| Primitive | 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 |
| `ValidatorSign` + `VerifyAggregateCert` (per-validator standalone, `standalone.go`) | Per-validator standalone keys, no shared seed | YES | **Lux public-BFT validator quorum (PRIMARY)** |
| `NewThbsSeKey` + `ThbsSeRound1` + `Combine` (THBS-SE, `thbsse.go`) | t-of-n committee, public combiner, no host in TCB | YES (with v1.0 open item) | Permissionless threshold signing for single-signature certificates |
### TL;DR
### Quick decision
- **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 consensus on a Lux chain** (mainnet, testnet, devnet,
white-label) --> per-validator standalone. The consensus layer
collects N validator signatures into a `ValidatorAggregateCert`
and counts valid signers; the quorum policy decision lives at the
consumer. Wire cost is `N x |sigma|`; Z-Chain Groth16 rollup
compresses to ~192 bytes.
- **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.
- **Permissionless threshold signing for verifier-side
single-signature certificates** --> THBS-SE. Read sec 2 below for
the v1.0 honest open item on transient seed reconstruction at the
public combiner.
- **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.
- **HSM-attested MPC custody** (M-Chain bridge custody, A-Chain
confidential compute) --> NOT in this package. Operator-controlled
MPC custody uses TEE-attestation-gated variants shipped under
`luxfi/threshold/protocols/{slhdsa,mldsa,rlwe}-tee` via the
`lux/mpc` sibling tree.
- **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.
## 1. Per-validator standalone (production primary)
- **Anything else** → `magnetar.ValidatorSign`. The reveal-and-
aggregate and dealer-backed-threshold paths are NOT safe for
public deployments.
### 1.1 Keygen
### Decision tree
Each validator runs `PerValidatorKeypair` ONCE on its own host:
```
┌────────────────────────────────────────────────┐
│ WHO NEEDS TO VERIFY THIS SIGNATURE? │
└────────────────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
PUBLIC, INTERNAL,
untrusted verifiers pre-attested TCB
(BFT chain, (M-Chain custody,
external relayers) thresholdvm)
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────┐
│ 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! │
└─────────────────────┘ └──────────────────────┘
```go
sk, pk, err := magnetar.PerValidatorKeypair(params, rng)
```
## 1. `CombineWithSeedReconstruction` (v0.1 reveal-and-aggregate) trust caveat
- `params` SHOULD be `magnetar.ParamsM192s` (smallest signatures at
NIST PQ category 3, >=192-bit classical security). Use
`ParamsM192f` only if signing latency is the dominant constraint
and you can absorb the 2.2x larger signature; use `ParamsM256s`
only if you need category 5 (>=256-bit classical security).
- `rng` SHOULD be `crypto/rand.Reader` (nil also works; the function
defaults to `crypto/rand.Reader`). Pass a deterministic reader
ONLY for KAT reproducibility.
Persist `(sk, pk)` durably. Losing `sk` means dropping out of the
quorum until rotating to a fresh keypair. Standard HSM-backed key
storage applies; CIRCL slhdsa private keys are ~96 bytes at
ModeM192s.
Register `pk` in the validator-set commitment via the consensus
layer's normal key-registration ceremony.
### 1.2 Signing
```go
sig, err := magnetar.ValidatorSign(sk, nil, message)
```
Pass `nil` for the RNG to get the deterministic variant (FIPS 205
SignDeterministic). The output is the raw `params.SignatureSize`
bytes; the consensus layer's wire format wraps them.
Bind chain-id / block-height / consensus epoch into the message
upstream of this call --- the consensus layer's transcript-hash
pattern handles this (see `luxfi/consensus` QuasarCert).
### 1.3 Verification
```go
cert, _ := magnetar.BuildAggregateCert(params, signers, pubKeys, sigs)
count, err := magnetar.VerifyAggregateCert(cert, message, knownValidators)
if count >= quorumThreshold { ... }
```
- `knownValidators` is the consensus layer's authoritative
`NodeID -> pubkey` map.
- The function returns the COUNT of valid signers. Unknown /
pubkey-mismatched signers are counted as INVALID (not fatal --- a
defense against impersonation that does not abort the entire
batch).
- The quorum decision lives at the consumer.
## 2. THBS-SE (permissionless threshold)
### 2.1 v1.0 honest open item
THBS-SE v1.0 routes the final FIPS 205 byte production via a PUBLIC
COMBINER that holds the seed for the duration of one
`slhdsa.SignDeterministic` call and zeroizes. The combiner role is
PUBLIC --- anyone can be the combiner --- and there is no
long-lived secret material outside party-local Shamir leaves.
This is materially stronger than a TEE-attested
privileged-aggregator model (no host is in the TCB; the combiner
is a pure function any peer can run on its own substrate).
This is materially weaker than the strict invariant "no party or
combiner EVER reconstructs SK.seed, even transiently in memory" ---
a peer-local memory-disclosure adversary at exactly the combine
moment could observe the seed. The strict-atom-assembly path is the
v1.1 work item tracked at
`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`.
**Operational implications.** If your threat model includes
"peer-local memory-disclosure adversary at the precise sub-second
combine moment," do NOT deploy THBS-SE v1.0 for new committee
ceremonies. Either:
1. Wait for v1.1 strict-atom-assembly.
2. Route the threshold signing via the TEE-attested variants at
`luxfi/threshold/protocols/slhdsa-tee` (operator-controlled MPC
custody, host in TCB by policy).
3. Use per-validator standalone instead --- the public-BFT primary
has no seed reconstruction at any point.
### 2.2 Setup
```go
key, err := magnetar.NewThbsSeKey(params, threshold, committee, setupRng)
```
- `params` and the threshold `t` are the same shape as
per-validator standalone.
- `committee` MUST be sorted ascending by `NodeID` with distinct
non-zero IDs.
- `setupRng` defaults to `crypto/rand.Reader`.
v1.0 reference uses a deterministic-dealer setup. The dealer is in
the TCB FOR SETUP ONLY; once `NewThbsSeKey` returns, no party
(including the dealer) holds the master seed. The returned
`*ThbsSeKey` carries: the public key `PublicKey`, the per-party
shares `Shares[]`, the setup transcript `SetupTranscript`, and
metadata `(Threshold, N, Params)`.
Production deployments needing the leaderless PVSS-DKG variant
route through the sibling `luxfi/threshold` DKG package; that
DKG produces shares in the SAME wire-shape envelope. See
`BLOCKERS.md::MAGNETAR-PVSS-DKG-V11` for the v1.1 closure path.
### 2.3 Signing rounds
Each party runs:
```go
r1, r2, err := magnetar.ThbsSeRound1(params, share, binding, msg, guard, rng)
```
- `binding` is the slot tuple
`(chain_id, epoch, slot, height, committee_id, message_domain)`.
- `guard` is a `*ThbsSeSlotGuard` the party persists across calls.
- Idempotent replay: re-calling with the same `(binding, msg)`
returns the persisted `(r1, r2)`. A genuine equivocation attempt
(same slot, different message) returns
`*ThbsSeEquivocationError` carrying slashable evidence.
The party broadcasts `r1` (the commit) and, after the Round-1
quorum is observable, `r2` (the reveal).
### 2.4 Combine
Any peer (validator, block proposer, RPC node, passive watcher) can
run:
```go
sig, evidences, err := magnetar.Combine(magnetar.ThbsSeCombineInput{
Key: key, Binding: binding, Message: msg,
Round1: r1s, Round2: r2s,
})
```
- `err == nil` and `evidences` empty: the signature was produced
from `>=t` honest reveals.
- `err == nil` and `evidences` non-empty: the signature was
produced from `>=t` honest reveals, AND some malformed reveals
were observed; the consensus layer can slash the named parties
via `VerifyThbsSeShareEvidence`.
- `err == ErrInsufficientQuor`: fewer than `t` valid reveals were
observed. `evidences` carries the malformed-share evidence the
consensus layer can act on before retrying.
### 2.5 Verification
```go
ok := magnetar.VerifyBytesCtx(gpkBytes, message, ctxFromSlot(binding), sigBytes)
```
The `VerifyBytesCtx` helper is bytes-in, bool-out: it strips the
MAGS/MAGG envelopes and routes through the FIPS 205 verifier
verbatim. Any unmodified slhdsa verifier accepts the inner FIPS 205
bytes.
## 3. Operational hardening
### 3.1 RNG sourcing
`crypto/rand.Reader` on Linux/macOS reads from `getrandom(2)` /
`/dev/urandom`. On non-trivial deployments, additionally:
- Use the OS kernel's hardware-RNG entropy when available
(`/dev/hwrng` on Linux with a HW source like RDRAND, the TPM, or
a Yubikey-Bio).
- For HSM-backed keys, route keygen through the HSM's FIPS
140-validated RNG.
### 3.2 Slot-guard persistence
The `ThbsSeSlotGuard` is in-memory only. For production deployments
that must survive restarts, persist the guard's state to disk
between Round-1 broadcasts (mirrored from
`SlotGuard.Record(slotID, digest, r1, r2)` --- ALL four fields are
required to verify slashing evidence after restart).
### 3.3 Equivocation slashing
`VerifyThbsSeEvidence` is a pure function. The consensus layer's
slashing pipeline:
1. Receive `ThbsSeEvidence` blob via gossip / on-chain submission.
2. Verify via `VerifyThbsSeEvidence(params, ev, msgPrior, msgNew,
bindingPrior, bindingNew)`.
3. If valid, slash the party named in `ev.PartyID`.
The evidence blob is sufficient on its own --- no committee state,
no re-running of the protocol, no membership-proof gymnastics
required.
### 3.4 Mode-mixing prevention
Magnetar's wire codec rejects mode-mismatch at parse time. A
`Signature` parsed under `ModeM192s` cannot be cross-verified
against a `GroupKey` parsed under `ModeM256s` --- `VerifyBytesCtx`
returns false on mismatch.
**The Magnetar `CombineWithSeedReconstruction` aggregator process holds the reconstructed master SLH-DSA seed in memory for the duration of one call.**
### 3.5 Race detector
The Magnetar test suite passes under `go test -race`. SLH-DSA-heavy
tests self-skip via the `raceEnabled` build tag pattern (race-on
overhead is 5-10x and the tests carry no inter-goroutine
concurrency that would benefit from race coverage); race-on runs
cover the cheap unit tests (transcript, types, wire codec).
This is the v0.1 reveal-and-aggregate trust model. It is identical to Pulsar's v0.1 reveal-and-aggregate trust model. The construction is **honest** about this: the security properties below are precisely what `CombineWithSeedReconstruction` delivers, and the path to v0.2 (true MPC, aggregator never sees the seed) is in `BLOCKERS.md`.
## 4. Migration from Magnetar v0.x
The v0.x `CombineWithSeedReconstruction` and `AggregateSignatures`
API surfaces have been removed. v0.x consumers migrate as follows:
| v0.x | v1.0 |
|---|---|
| `magnetar.GenerateValidatorKey` | `magnetar.PerValidatorKeypair` |
| `magnetar.AggregateSignatures` + `SignedBundle` | `magnetar.BuildAggregateCert` + `ValidatorAggregateCert` |
| `magnetar.VerifyAggregated` | `magnetar.VerifyAggregateCert` |
| `magnetar.CombineWithSeedReconstruction` | `magnetar.Combine` (THBS-SE, different semantics --- see THBS-SPEC.md) |
| `pkg/thbs` (legacy true-HBS) | DELETED. THBS-SE in `pkg/magnetar` is the v1.0 threshold construction. |
**Why this is fundamental, not a Magnetar choice:**
SLH-DSA (FIPS 205) is hash-based. The literature confirms there is *no efficient threshold MPC* that produces a single FIPS 205-shaped signature without reconstructing the master seed:
- Cozzo & Smart, "Sharing the LUOV" (EUROCRYPT 2019) — establishes that hash-based signatures are MPC-hard.
- Bonte, Smart, Tan, "Threshold SPHINCS+" (2023) — exhaustive infeasibility analysis.
- NIST IR 8214 / MPTC submission notes — SLH-DSA classified as "highest threshold-MPC cost" among the FIPS 20{3,4,5} family.
- FIPS 205 §6 SLH-DSA-Sign-Internal — direct inspection confirms no Lagrange-aggregatable response in the algorithm.
If you cannot trust the aggregator host as TCB, use `AggregateSignatures` instead (see §0 and §10). That mode replaces "reconstruct the seed" with "collect N separate signatures" and is the SOTA answer for public-BFT post-quantum finality on SLH-DSA.
### What this means operationally
During a `CombineWithSeedReconstruction` call (typically ≤100 ms for SHAKE-192s on commodity hardware), the following secret material is live in the aggregator process's address space:
| Buffer | Lifetime | Wiped by |
|---|---|---|
| `master_seed` (96 / 128 bytes) | ~ duration of one `slhdsa.SignDeterministic` call | `zeroizeBytes` at every return path in `combine.go` |
| `sk_rec` (96 / 128 bytes) | ~ duration of one `slhdsa.SignDeterministic` call | `zeroizePrivateKey` |
| `byteSum_bytes` (2×seed_size bytes) | from share Lagrange reconstruction to mix completion | `zeroizeBytes` |
| `mixInput` (2×seed_size + 32 bytes) | from mix construction to cSHAKE output | `zeroizeBytes` |
During this window, an adversary with one of the capabilities below can extract `master_seed` (and therefore forge arbitrary future signatures under the group key):
- arbitrary-code-execution on the aggregator host
- `/proc/<pid>/mem` read of the aggregator process
- a coredump triggered during a Sign call
- a swap-file or hibernation image written to disk
- `ptrace` attached to the aggregator process
- a malicious kernel or hypervisor
### Hardening matrix
| Mitigation | What it protects against | How to enable |
|---|---|---|
| **TEE (SGX / SEV-SNP / TDX)** | malicious kernel/hypervisor, /proc/<pid>/mem from outside the enclave | run aggregator inside a confidential VM or enclave |
| `mlock` the secret buffers | swap / hibernation | the Go runtime does not expose `mlock`; deploy with `vm.swappiness=0` + `swapoff` |
| `prctl(PR_SET_DUMPABLE, 0)` | coredumps | set ulimit core 0 (`ulimit -c 0`), set `/proc/sys/kernel/core_pattern` to disable cores, or run inside a container with `--security-opt no-new-privileges` |
| ptrace-off (Yama LSM `kernel.yama.ptrace_scope=3`) | local ptrace attach | sysctl on the aggregator host |
| Aggregator is a **short-lived process** (one Sign per process invocation, then exit) | post-mortem analysis of the long-lived process | wrap aggregator behind a per-Sign exec spawn |
| Hardware HSM-backed aggregator | host compromise | offload `slhdsa.SignDeterministic` to an HSM — note: most commodity HSMs do not yet ship SLH-DSA |
The Pulsar v0.1 deployment runbook mandates **TEE + mlock + ptrace-off** as the baseline; Magnetar v0.1 inherits the same requirement.
### What this caveat does NOT cover (safe by construction)
- A **single Byzantine committee member** cannot forge a signature. Forging requires `t` valid Round-2 reveals, which requires `t` honest signers to participate.
- A **single Byzantine aggregator** cannot forge a signature for a message that did not receive `t` Round-2 reveals. `Combine`'s commit-bind check (re-derives each `D'_i` from the reveal and compares to the Round-1 broadcast `D_i`) rejects tampered reveals at constant-time check.
- A **passive network observer** does NOT see the reconstructed seed. v0.1 envelopes are plaintext, so a passive observer can collect Shamir shares (one per committee member, but at most one share value per dealer-recipient pair); reconstructing the seed requires the EvalPoint directory + at least `t` distinct envelopes from a single dealer. v0.2 closes this with ML-KEM-768 envelope wrapping.
- The **wire signature** is byte-equal to single-party FIPS 205. Any unmodified FIPS 205 verifier (including stock SLH-DSA validators in standard libraries) accepts Magnetar signatures with no code change.
## 2. Aggregator role assignment
Any party in the quorum (or an external third-party aggregator) can call `CombineWithSeedReconstruction`. The protocol does not assume a specific aggregator; the aggregator's identity is not bound into the signature.
**Recommendation:** rotate aggregator role across signing requests to minimise the seed-exposure window on any single host. A leader-rotation pattern over committee members (round-robin keyed by `session_id`) is a reasonable v0.1 default.
## 3. DKG ceremony procedure
1. **Committee membership freeze.** All `n` committee members must agree on the committee NodeID set BEFORE Round 1. Committee membership is bound into the transcript digest at DKG Round 2; mid-ceremony additions are rejected.
2. **Per-party entropy.** Each party MUST use a cryptographically secure RNG for `NewDKGSession.rng`. Reusing entropy across DKG ceremonies leaks contributions.
3. **Round-1 envelope authenticity.** v0.1 envelopes are NOT KEM-wrapped — the network layer MUST authenticate dealers (TLS, sigs, gossip auth) to prevent forged envelopes. v0.2 adds ML-KEM-768 wrapping.
4. **Round-2 digest agreement.** Honest parties detect dealer equivocation when their Round-3 local digest disagrees with any received Round-2 digest. The `ComplaintEquivocation` AbortEvidence is produced; protocol-layer drivers MUST broadcast this for slashing.
## 4. Threshold-sign ceremony procedure
1. **Quorum freeze.** All `t` quorum members must agree on the quorum NodeID set BEFORE Round 1. Quorum is bound into the `τ_1` transcript; mid-ceremony quorum changes are rejected.
2. **Per-attempt RNG.** Each `ThresholdSigner` MUST use a fresh RNG state per (session_id, attempt) pair. Reusing RNG across attempts gives the same mask, which leaks the share to anyone observing the Round-2 reveal.
3. **Round-2 timing.** Round-2 messages SHOULD be released only after every quorum member's Round-1 commit is in hand. This is a protocol invariant; if Round-2 is released early, an attacker can fork the session at Round 1 and extract two distinct shares from the same party (a forgery vector).
4. **Aggregator hardening.** The aggregator host MUST follow §1 hardening (TEE + mlock + ptrace-off baseline).
## 5. Key-share storage
`KeyShare` is a long-lived secret. Storage requirements:
- Encrypt-at-rest with a host-bound key (TPM-sealed, or KMS-wrapped under the host's identity attestation).
- NEVER write `KeyShare.Share` to disk in plaintext.
- Recommended: hold `KeyShare` in a TEE that performs the Round-1/Round-2 computation; the host process never touches the share bytes.
## 6. Quorum rotation / resharing
Magnetar v0.1 does NOT ship resharing. Once a committee is bootstrapped via DKG, the only way to rotate is to run a fresh DKG (producing a new group public key).
v0.2 will ship zero-secret-refresh resharing matching Pulsar's resharing pattern.
## 7. Honest non-warranties
Magnetar v0.1 is shipped under the BSD-3-Clause license with NO WARRANTY. In particular:
- **No independent cryptographic review** of the v0.1 protocol has occurred. See `BLOCKERS.md` BLK-9.
- **No EasyCrypt / Lean / Jasmin proofs** ship in v0.1. See `BLOCKERS.md` BLK-7.
- **No constant-time analysis** of the threshold layer has been conducted under `dudect` or formal tooling.
- **No NIST MPTC submission** has been made.
- **No production Lux deployment** uses Magnetar v0.1. The Polaris profile in `luxfi/quasar` references Magnetar at Tier A; v0.1 reaches Tier B only.
Operators deploying Magnetar v0.1 do so at their own risk and SHOULD layer it with Pulsar (Tier A production primitive) under a cross-family cert profile rather than as the sole signing scheme.
## 8. Cross-references
- `SPEC.md` — full protocol specification
- `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.
### Construction summary
1. **Per-validator keygen** (one-time, off-chain): each validator `i` independently runs `GenerateValidatorKey(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 `SignBundle(params, sk_i, ValidatorID_i, message)` to produce a `SignedBundle{ValidatorID, PublicKey, Signature}`. The signature is FIPS 205-byte-identical (no envelope) and verifies under unmodified `slhdsa.Verify`.
3. **Aggregate** (consensus layer): the consensus layer collects N `SignedBundle` envelopes from the network, then calls `AggregateSignatures(params, bundles, message)` to dedupe + shape-check and produce a wire-stable `AggregatedSignature{Message, Bundles}`.
4. **Verify** (relying party): a verifier calls `VerifyAggregated(params, agg, knownValidators)` which returns the COUNT of valid signers. The verifier compares the count against its quorum threshold to make the policy decision.
### Honest trade-offs
- **Wire size grows linearly with N.** At SLH-DSA-SHAKE-192s (16,224 byte signature) a 21-validator quorum is 336 KiB; a 64-validator quorum is 1 MiB. This is the cost of being TCB-free.
- **Verification cost grows linearly with N.** Each bundle requires one full FIPS 205 verify (~50ms per signature on M1 Max for SHAKE-192s). For N=21 that's ~1s in the serial path. `VerifyAggregated` automatically forks to `GOMAXPROCS` goroutines above `verifyAggregatedConcurrentThreshold=2` (mirrors `luxfi/crypto/slhdsa.VerifyBatch` parallel path), reducing wall-clock to ~125ms on an 8-core machine. The GPU substrate at `luxfi/crypto/slhdsa.VerifyBatch` brings this further down when an accel device is loaded — embed magnetar bundles in that path for upper-bound throughput.
- **Z-Chain Groth16 rollup is a separate primitive.** A Groth16 SNARK over the FIPS 205 verify circuit can compress the N-signature bundle to ~192 bytes. This is NOT part of the magnetar API; see `lux-zchain` for the rollup spec. The rollup does NOT change Magnetar's correctness — it's a post-hoc compression layer that the relying party can choose to consume instead of the raw N-bundle envelope.
### Security model
| Property | `AggregateSignatures` | 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. |
| **Quorum byzantine tolerance** | Yes (consensus-layer policy) | The count returned by `VerifyAggregated` 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** | Per-signer ctx string | Use the `ctx` argument to bind chain-id / block-height into each σ_i (currently `SignBundle` passes nil; v0.4.3 will add a ctx parameter). |
### 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 `AggregatedSignature.Bundles` through `luxfi/crypto/slhdsa.VerifyBatch` for GPU substrate acceleration (see `crypto/slhdsa/gpu.go`).
- [ ] On Byzantine fault detection (double-sign, ValidatorID-pk mismatch via `ErrValidatorPubkeyMismatch`), produce slashing evidence at the consensus layer. `VerifyAggregated` returns typed errors so the slashing decision can be made deterministically.
### Honest non-warranties
- **No formal proof** of the aggregate construction beyond FIPS 205's per-signature security. The aggregate 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 `VerifyAggregated`.** 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 (covered by upstream `slhdsa.Verify` constant-time analysis where applicable; CT is NOT a load-bearing property here since all inputs are public).
- **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.
---
**Document metadata**
- File: `DEPLOYMENT-RUNBOOK.md`
- 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
The signature payload bytes are byte-equal across primitives: a v0.x
`AggregateSignatures` output and a v1.0 `BuildAggregateCert` output
carry identical FIPS 205 signature bytes per validator. The only
difference is the envelope shape.
+7 -1
View File
@@ -1,4 +1,10 @@
# Magnetar — Public-DKG MPC Threshold SLH-DSA (research profile)
# Magnetar --- Public-DKG MPC Threshold SLH-DSA (research profile)
> **v1.0 framing.** This document is a v0.x research-profile design
> snapshot. The v1.0 production THBS-SE construction
> (`THBS-SPEC.md`) is the closer of "permissionless threshold SLH-DSA
> without TEE" for Magnetar; the v1.1 strict-atom-assembly path is
> the strict-invariant closer (`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`).
> **Tier 3 of the Hanzo PQ Threshold Suite.** Research-track
> construction for threshold FIPS 205 SLH-DSA via MPC. **NOT
+8 -2
View File
@@ -1,6 +1,12 @@
# FIPS-TRACEABILITY — Magnetar
# FIPS-TRACEABILITY --- Magnetar
> **FIPS 205 § → Magnetar code map.** Magnetar IS FIPS-anchored
> **v1.0 framing.** This document is a v0.x archival snapshot. The
> v1.0 byte-identity claims are pinned by
> `TestMagnetar_Wire_FIPS205Verifiable` (per-validator standalone)
> and `TestThbsSE_Wire_FIPS205Verifiable` (THBS-SE). See `SPEC.md`
> sec 4 for the v1.0 framing.
> **FIPS 205 sec --> Magnetar code map.** Magnetar IS FIPS-anchored
> at the single-party layer. This document maps the NIST FIPS 205
> (Stateless Hash-Based Digital Signature Standard, 2024) sections
> to the corresponding code paths in `~/work/lux/magnetar/ref/go/`.
+8 -1
View File
@@ -1,4 +1,11 @@
# NIST MPTC Submission — Magnetar (one-page executive summary)
# NIST MPTC Submission --- Magnetar (one-page executive summary)
> **v1.0 framing.** Magnetar v1.0 ships TWO primitives:
> per-validator standalone (`standalone.go`) and THBS-SE
> (`thbsse.go` + `thbsse_field.go`). The text below was written
> against the v0.x reveal-and-aggregate construction; v1.0
> superseding language is in `README.md`, `SPEC.md`, `THBS-SPEC.md`,
> `CHANGELOG.md::[1.0.0]`, and `CRYPTOGRAPHER-SIGN-OFF.md`.
> **Executive summary** of the Magnetar package for the NIST Multi-Party
> Threshold Cryptography (MPTC) project. The full cover sheet is in
+7 -1
View File
@@ -1,4 +1,10 @@
# PATENTS — Magnetar Threshold SLH-DSA Signature
# PATENTS --- Magnetar SLH-DSA Signature
> **v1.0 framing.** This document is a v0.x archival snapshot. The
> v1.0 patent grant covers the THBS-SE construction
> (`thbsse.go` + `thbsse_field.go`) under the same terms as the
> v0.x reveal-and-aggregate path described below; the construction
> details have changed but the IP posture is unchanged.
> **Statement of Intellectual Property and Royalty-Free Patent Grant**
> for the Magnetar threshold-signing construction submitted to the NIST
+7 -2
View File
@@ -1,6 +1,11 @@
# PROOF-CLAIMS — Magnetar (HONEST framing)
# PROOF-CLAIMS --- Magnetar (HONEST framing)
> **What this submission proves, and — critically — what it does NOT.**
> **v1.0 framing.** This document is a v0.x archival snapshot. The
> v1.0 proof track ports to the THBS-SE construction shape and lands
> at v1.1 (see `BLOCKERS.md::MAGNETAR-PROOF-TRACK-V11` and
> `proofs/README.md`).
> **What this submission proves, and --- critically --- what it does NOT.**
> Companion to `TRUSTED-COMPUTING-BASE.md` (TCB) and `SUBMISSION.md`
> (cover sheet).
>
+156 -121
View File
@@ -1,10 +1,16 @@
# Magnetar — SLH-DSA (FIPS 205) for Lux Public-BFT
# Magnetar v1.0 --- SLH-DSA (FIPS 205) for Lux
## Public-BFT default: `magnetar.ValidatorSign` + `magnetar.VerifyAggregateCert`
Magnetar v1.0 ships ONE construction surface to each of the two
deployment regimes Lux ecosystem chains need:
For PUBLIC-BFT consensus on Lux L1/L2 (mainnet, testnet, devnet,
white-label chains) use the per-validator standalone primitive at
`ref/go/pkg/magnetar/standalone.go`:
| Regime | Construction | Where it lives |
|---|---|---|
| Public-BFT consensus on Lux L1/L2 (mainnet, testnet, devnet, white-label) | **Per-validator standalone** --- each validator holds its own FIPS 205 keypair, signs independently, consensus collects N signatures into a `ValidatorAggregateCert` | `ref/go/pkg/magnetar/standalone.go` |
| Permissionless t-of-n threshold for verifier-side single-signature certificates | **THBS-SE** (Threshold Hash-Based Signatures with Selected-Element Reconstruction) --- t-of-n committee, slot-bound commit-and-reveal, anyone-can-combine public combiner | `ref/go/pkg/magnetar/thbsse.go` + `thbsse_field.go` |
Both produce FIPS 205 wire bytes that an unmodified verifier accepts.
## Per-validator standalone (public-BFT primary)
```go
sk, pk, _ := magnetar.PerValidatorKeypair(params, rng)
@@ -14,141 +20,170 @@ cert, _ := magnetar.BuildAggregateCert(params, signers, pubkeys, sigs)
valid, _ := magnetar.VerifyAggregateCert(cert, message, knownValidators)
```
**Why per-validator instead of threshold?** SLH-DSA is hash-based;
it has no algebraic structure (Cozzo-Smart Eurocrypt 2019;
Bonte-Smart-Tan 2023 "Threshold SPHINCS+"). True threshold SLH-DSA
without a trusted dealer is research-grade (multi-month MPC-over-SHA
work). For public BFT the architecturally correct pattern is N
validators × standalone sigs, compressed by the consensus layer
(QuasarCert + Z-Chain Groth16 rollup → ~192 bytes), mirroring the
`MLDSAProof` field for ML-DSA.
No DKG, no shared seed, no aggregator-in-TCB. Wire size grows
linearly with the quorum (`N x |sigma|`). The Z-Chain Groth16 rollup
compresses the cert to ~192 bytes for long-term storage; the rollup
is a separate primitive (lux-zchain spec).
The threshold modes below exist for NON-public scenarios where a
dealer or TEE is in the TCB by deployment policy (M-Chain bridge
custody, A-Chain confidential compute).
## THBS-SE (permissionless threshold)
---
```go
key, _ := magnetar.NewThbsSeKey(params, t, committee, setupRng)
// Per-party Round-1 / Round-2 over the slot binding ...
r1i, r2i, _ := magnetar.ThbsSeRound1(params, key.Shares[i], binding, msg, guard, rng)
// ... any peer (the "public combiner") collects t valid r1/r2 pairs ...
sig, ev, _ := magnetar.Combine(magnetar.ThbsSeCombineInput{
Key: key, Binding: binding, Message: msg, Round1: r1s, Round2: r2s,
})
// sig.Bytes is canonical FIPS 205; verifies under unmodified circl.slhdsa.Verify.
```
> **Tier A documentation shape complete at v0.3.0; public-BFT-safe aggregate mode + THBS v1 added at v0.4.2; standalone-primary framing + dkg2 PVSS skeleton at v0.5.0.**
> Hash-based PQ signature primitive over FIPS 205 SLH-DSA shipping
> **four modes**:
> 1. `ValidatorSign` + `VerifyAggregateCert` — **PUBLIC-BFT PRIMARY** (per-validator standalone, no DKG, no dealer)
> 2. `pkg/thbs.DealerDKG` + `SignShare` + `Aggregate` — TRUE threshold HBS, McGrew et al. selected-elements reconstruction; v1 **dealer-backed**, NOT public-BFT-safe; for M-Chain custody
> 3. `CombineWithSeedReconstruction` — reveal-and-aggregate; requires TEE; for M-Chain custody
> 4. `pkg/thbs/dkg2` — SKELETON of public PVSS-based DKG; MPC-root layer is open research; v0.6+ candidate; **NOT production today**
>
> The full 12-document Tier A submission package shape (mirroring
> Pulsar's structure) is now in-tree; mechanized refinement, dudect
> statistical CT validation, v0.4 lifecycle additions (ML-KEM
> envelope wrap + reshare), and external audit are the remaining
> gates to full Tier A — see `CRYPTOGRAPHER-SIGN-OFF.md` "Gates"
> section.
>
> See `SUBMISSION.md` for the NIST MPTC cover sheet,
> `SPEC.md` for the construction specification,
> `DEPLOYMENT-RUNBOOK.md` for the v0.1 trust-model disclosure,
> `BLOCKERS.md` for the remaining gates to full Tier A, and
> `SUBMISSION-STATUS.md` for the NIST MPTC roadmap.
**Hard invariant** (THBS-SE construction): a revealed value is allowed
ONLY if it is also present in the final SLH-DSA signature.
- ALLOWED reveals: the per-round mask `r_i`, the masked share
`s'_i = share_i XOR r_i`, the public commit hash, the final FIPS
205 signature bytes.
- FORBIDDEN reveals: `SK.seed` in any party-local persistent form;
`SK.prf`; future-slot share material. The slot guard refuses any
same-slot re-emission, and the share envelope is per-slot.
**No-aggregator property**: The public combiner is a PURE function of
its inputs. Any peer --- validator, block proposer, RPC node, passive
watcher --- can run Combine. There is no privileged aggregator role
and no host in the TCB at sign time. (v1.0 honest open item: the
strict invariant "no party or combiner EVER reconstructs SK.seed,
even transiently in memory" requires a v1.1 lift; v1.0 ships a public
combiner that holds the seed for the duration of one
`slhdsa.SignDeterministic` call and zeroizes. See
`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`.)
**Slot binding**: every signature is bound to
`(chain_id, epoch, slot, height, committee_id, message_domain)`. The
binding flows into the cSHAKE256 commit transcript AND into the FIPS
205 ctx string at sign time, so a verifier holding the slot tuple
derives ctx independently and routes through unmodified
`slhdsa.Verify`.
**Slashing evidence**: a party that signs two distinct messages at
the same slot emits a `ThbsSeEquivocationError` carrying a wire-
shaped `ThbsSeEvidence` blob that any third party can verify via
`VerifyThbsSeEvidence` --- pure function, no committee state
required. Malformed shares produce typed `ThbsSeShareEvidence` blobs
verified via `VerifyThbsSeShareEvidence`.
**Over-selected committee**: with `(n, t)` and `n > t`, up to `n - t`
silent withholders are tolerated. Any t valid Round-2 reveals produce
byte-equal final signatures.
## Why per-validator standalone is the public-BFT default
SLH-DSA is hash-based; it has no algebraic structure that admits
FROST-style linear share-aggregation. The literature confirms:
- Cozzo and 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.
- Bonte, Smart and 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 in some combiner process or (b) running
full MPC over the SHA-256/SHAKE hash tree (multi-second per
signature, multi-megabyte of comms).
THBS-SE chooses (a) with a PUBLIC COMBINER --- anyone-can-combine,
no host in TCB. The per-validator standalone path sidesteps (a) and
(b) entirely by emitting N independent signatures.
## Status
| Field | Value |
|---|---|
| Standard | FIPS 205 SLH-DSA (single-party + aggregate + reveal-and-aggregate threshold) |
| Construction | **Two modes**: (1) `AggregateSignatures` — N independent per-validator FIPS 205 signatures, public-BFT-safe; (2) `CombineWithSeedReconstruction` — Shamir VSS over the SLH-DSA scheme seed (v0.1 reveal-and-aggregate), requires TEE |
| Reference implementation | `ref/go/pkg/magnetar/` — pure Go, depends on `cloudflare/circl/sign/slhdsa` |
| KAT vectors | `vectors/{keygen,sign,verify,threshold-sign,dkg}.json` (deterministic regeneration) |
| Class-N1 claim | threshold signature is byte-identical to single-party `slhdsa.SignDeterministic` on the same reconstructed seed |
| Submission package | **Tier A documentation shape complete (v0.3.0)** — `SUBMISSION.md`, `NIST-SUBMISSION.md`, `SPEC.md`, `PATENTS.md`, `PROOF-CLAIMS.md`, `AXIOM-INVENTORY.md`, `FIPS-TRACEABILITY.md`, `TRUSTED-COMPUTING-BASE.md`, `CRYPTOGRAPHER-SIGN-OFF.md`, `DEPLOYMENT-RUNBOOK.md`, `BLOCKERS.md`, `SUBMISSION-STATUS.md`. See `scripts/cut-submission.sh`. |
| Cert-profile role | Polaris profile in `luxfi/quasar` (cross-family PQ diversity) |
| Proof artifacts | None yet for the threshold overlay — roadmap v0.5.0 (multi-month research). See `AXIOM-INVENTORY.md` §2. |
| Independent review | Internal cryptographer sign-off at v0.3.0 (`CRYPTOGRAPHER-SIGN-OFF.md`); external audit roadmap v0.6.0. See `BLOCKERS.md` BLK-9. |
| Standard | FIPS 205 SLH-DSA (single-party + per-validator standalone aggregate + THBS-SE permissionless threshold) |
| Constructions | 2 (per-validator standalone, THBS-SE) |
| Reference implementation | `ref/go/pkg/magnetar/` --- pure Go, depends on `cloudflare/circl/sign/slhdsa` v1.6.3 |
| KAT vectors | `vectors/{keygen,sign,verify,thbsse-sign}.json` (deterministic regeneration) |
| Wire identity | Both Magnetar primitives emit byte-identical FIPS 205 signatures; any unmodified slhdsa verifier accepts |
| Tag | `v1.0.0` |
| Cert-profile role | Polaris profile in `luxfi/quasar` (hash-based leg of the maximum-assurance cross-family PQ profile) |
| Sibling primitives | Pulsar (FIPS 204 M-LWE), Corona (R-LWE) --- algorithmically distinct families |
## What v0.3.0 ships (Tier A documentation shape complete)
## Headline tests
- **Reference implementation** (`ref/go/pkg/magnetar/`, ~2186 LOC): single-party + DKG + threshold-sign + Combine over the SLH-DSA scheme seed.
- **Parameter sets**: SHAKE-192s (recommended), SHAKE-192f, SHAKE-256s.
- **KAT generator** (`ref/go/cmd/genkat`): deterministic vectors at five profiles (keygen, sign, verify, threshold-sign, dkg). Re-running on a clean checkout produces byte-identical output.
- **Headline test** (`n1_byte_equality_test.go`): threshold-produced signatures are byte-identical to single-party FIPS 205 `SignDeterministic` on the reconstructed master seed across (3,2), (5,3), (7,4) configurations.
- **Honest trust-model disclosure**: `DEPLOYMENT-RUNBOOK.md` documents the v0.1 reveal-and-aggregate aggregator-as-TCB caveat with the same rigor as Pulsar's.
- **Full Tier A documentation shape**: `SUBMISSION.md`, `NIST-SUBMISSION.md`, `PATENTS.md`, `PROOF-CLAIMS.md`, `AXIOM-INVENTORY.md`, `FIPS-TRACEABILITY.md`, `TRUSTED-COMPUTING-BASE.md`, `CRYPTOGRAPHER-SIGN-OFF.md` (mirroring Pulsar's 12-doc structure).
- **Submission orchestration**: `scripts/cut-submission.sh` + `scripts/check-high-assurance.sh`.
```bash
cd ref/go && GOWORK=off go test -count=1 -short -timeout 600s ./pkg/magnetar/...
```
## What v0.3.0 does NOT yet ship (open gates to full Tier A)
- `TestMagnetar_Wire_FIPS205Verifiable` (wire_test.go) --- pins
byte-identity between `ValidatorSign` output and unmodified
`cloudflare/circl/sign/slhdsa.Verify` across all 3 SHAKE modes.
- `TestThbsSE_Wire_FIPS205Verifiable` (thbsse_test.go) --- pins the
same identity for the THBS-SE public combiner output.
- `TestThbsSE_RejectSeedReveal`, `TestThbsSE_RejectUnselectedFORS`,
`TestThbsSE_RejectUnselectedWOTS`, `TestThbsSE_SlotReuseRejected`,
`TestThbsSE_OverselectedCommittee`,
`TestThbsSE_SlotBindingDomainSeparation` --- the 6 invariant gates
for the THBS-SE construction.
- `BenchmarkThbsSE_Sign_5of7/Magnetar-SHAKE-192f` --- end-to-end
per-signature wall-clock at < 100 ms/op on Apple M1.
- `TestKAT_ThbsSe` --- deterministic vector replay at (n=7, t=4) x 3
modes x 3 messages.
- Formal proofs (EasyCrypt theory shells for the threshold overlay, Lean ↔ EC bridges) — see `BLOCKERS.md` BLK-7 + `CRYPTOGRAPHER-SIGN-OFF.md` GATE-1 / GATE-2. **Roadmap v0.5.0; multi-month research.**
- Constant-time analysis under `dudect` of the threshold layer — see `CRYPTOGRAPHER-SIGN-OFF.md` GATE-3. **Roadmap v0.6.0.**
- ML-KEM-768 wrapping of DKG Round-1 envelopes (closes passive-network-observer channel) — see `BLOCKERS.md` BLK-4. **Roadmap v0.4.0.**
- Reshare protocol (Refresh + ReshareToNewSet) for Class N4-analog evidence — see `BLOCKERS.md` BLK-4. **Roadmap v0.4.0.**
- External cryptographer audit — see `BLOCKERS.md` BLK-9 + `CRYPTOGRAPHER-SIGN-OFF.md` GATE-4. **Roadmap v0.6.0.**
## Operator-controlled MPC custody
For deployments where an HSM-attested host is in the TCB by
deployment policy (e.g. M-Chain bridge custody, A-Chain confidential
compute), TEE-attestation-gated variants ship under
`luxfi/threshold/protocols/{slhdsa,mldsa,rlwe}-tee` via the
`lux/mpc` sibling tree --- NOT in this package. Magnetar's role is
the public-BFT and permissionless-threshold surface; the operator-
controlled variants are decomplected into their own primitive slot.
## Where this is used
The `Magnetar` leg of the **Polaris** cert profile in
[`luxfi/quasar`](https://github.com/luxfi/quasar) — the
maximum-assurance cross-family profile that pairs lattice PQ
(Pulsar M-LWE + Corona R-LWE) with hash-based PQ (Magnetar
SLH-DSA). Polaris is the production migration target for when
Magnetar matures to Tier A.
The hash-based leg of the **Polaris** cert profile in
[`luxfi/quasar`](https://github.com/luxfi/quasar) --- the
maximum-assurance cross-family profile pairing lattice PQ (Pulsar
M-LWE + Corona R-LWE) with hash-based PQ (Magnetar SLH-DSA).
At v0.2.0 Tier B, Magnetar is **available as a library** but not
yet the production cert-profile target. Production chains run
the **Pulsar** profile; Polaris remains reserved.
## Documents
## Why hash-based threshold matters
- `SPEC.md` --- normative construction specification.
- `THBS-SPEC.md` --- THBS-SE normative spec (slot binding,
commit-reveal, public-combiner, evidence).
- `CHANGELOG.md` --- release-by-release framing.
- `DEPLOYMENT-RUNBOOK.md` --- public-BFT default + the v1.0 honest
open item (transient seed reconstruction at the public combiner).
- `BLOCKERS.md` --- the v1.1 roadmap (strict-atom-assembly path).
- `CRYPTOGRAPHER-SIGN-OFF.md` --- internal review trail.
- `TRUSTED-COMPUTING-BASE.md` --- TCB enumeration.
- `PROOF-CLAIMS.md` --- proof-roadmap inventory.
- `AXIOM-INVENTORY.md` --- assumed-axiom enumeration.
- `FIPS-TRACEABILITY.md` --- line-by-line FIPS 205 conformance trace.
- `PATENTS.md` --- patent-status notes.
- `LICENSING.md` --- license terms.
- `SUBMISSION.md` / `NIST-SUBMISSION.md` / `SUBMISSION-STATUS.md` ---
NIST MPTC submission framework.
SLH-DSA's security rests on collision/preimage resistance of the
underlying hash (SHA-2 or SHAKE), with no lattice assumption.
A threshold profile of SLH-DSA gives cross-family defense in depth:
even if a future cryptanalytic break hits the entire lattice family
(both M-LWE and R-LWE), Magnetar's hash-based threshold leg keeps
the cert sound.
## Build
## The v0.1 trust caveat (READ THIS)
The v0.1 reveal-and-aggregate construction reconstructs the master
SLH-DSA seed in **aggregator-process memory** during each `Combine`
call. The aggregator process is in the trusted computing base for
this brief window. This is the same trust model as Pulsar's v0.1
reveal-and-aggregate.
**Mitigations** (see `DEPLOYMENT-RUNBOOK.md` for the full matrix):
- TEE (SGX / SEV-SNP / TDX) is the recommended deployment posture.
- `mlock`, ptrace-off (`kernel.yama.ptrace_scope=3`), `ulimit -c 0` are mandatory.
- Aggregator role SHOULD rotate per signing request.
A v0.2 instantiation that gives true threshold secrecy (aggregator
never sees the seed) is on the research path — full MPC over
SLH-DSA's hash tree is the candidate construction; see
`BLOCKERS.md` BLK-1 follow-on work.
## Quick start
```go
import (
"github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// Single-party usage (single-instance signer).
params := magnetar.MustParamsFor(magnetar.ModeM192s)
sk, _ := magnetar.GenerateKey(params, nil) // crypto/rand
sig, _ := magnetar.Sign(params, sk, msg, ctx, false /* deterministic */, nil)
err := magnetar.Verify(params, sk.Pub, msg, sig)
```bash
cd ref/go && GOWORK=off go build ./...
cd ref/go && GOWORK=off go test -count=1 -short -timeout 600s ./pkg/magnetar/...
cd ref/go && GOWORK=off go test -count=1 -race -short -timeout 600s ./pkg/magnetar/...
```
For DKG + threshold signing, see the `n1_byte_equality_test.go`
end-to-end example.
## Citations
## Cross-references
- [`luxfi/quasar`](https://github.com/luxfi/quasar) — Quasar, the Lux PQ-finality singularity; Magnetar is `PRIMITIVES.md` row
- [`luxfi/pulsar`](https://github.com/luxfi/pulsar) — sibling threshold M-LWE primitive (Tier A production)
- [`luxfi/corona`](https://github.com/luxfi/corona) — sibling threshold R-LWE primitive (Tier A submission)
## License
BSD-3-Clause. See `LICENSE`.
## Maintainer
`[email protected]` — Lux Industries, Inc.
- NIST FIPS 205 (2024). *Stateless Hash-Based Digital Signature
Standard.*
- Cozzo, D. and Smart, N. P. (2019). *Sharing the LUOV.* EUROCRYPT.
- Bonte, C., Smart, N. P. and Tan, T. (2023). *Threshold SPHINCS+:
Practical Threshold Signatures from a Stateless Hash-Based
Signature.*
- McGrew, D., Wallace, C. and Whyte, W. (2019). *Threshold Hash-Based
Signatures.* IACR ePrint 2019/793.
- Schoenmakers, B. (1999). *A simple publicly verifiable secret
sharing scheme and its application to electronic voting.* CRYPTO.
- Shamir, A. (1979). *How to share a secret.* Communications of the
ACM.
+170 -229
View File
@@ -1,259 +1,200 @@
# Magnetar v0.1 — Construction specification
# Magnetar v1.0 --- Normative specification
> Threshold FIPS 205 SLH-DSA via reveal-and-aggregate. Mirrors
> Pulsar's v0.1 reveal-and-aggregate pattern, ported to SLH-DSA's
> scheme-seed-as-secret model.
>
> Status: v0.1 implementation specification. **NOT yet
> independently reviewed.** See `BLOCKERS.md` BLK-7 and BLK-9 for
> the proof-and-review path to Tier A.
This document is the normative specification of the two primitives
shipped by `luxfi/magnetar` v1.0.
## 1. Notation
Companion documents:
| Symbol | Meaning |
|---|---|
| `n` | committee size |
| `t` | reconstruction threshold (1 ≤ t ≤ n ≤ 256) |
| `q` | Shamir prime, fixed at 257 (smallest prime > 255) |
| `seed_size` | SLH-DSA scheme-seed length: 96 bytes for SHAKE-192s/192f, 128 bytes for SHAKE-256s |
| `P_i` | party `i`, `1 ≤ i ≤ n` |
| `x_i` | Shamir x-coordinate of `P_i`, `1 ≤ x_i ≤ 256`, distinct per party |
| `c_i` | `P_i`'s contribution to the joint seed, `c_i ∈ {0,1}^{8·seed_size}` |
| `S` | joint master seed, `S ∈ {0,1}^{8·seed_size}` |
| `share_i` | `P_i`'s Shamir share of the byte-sum used to mix to `S` |
| `pk`, `sk` | FIPS 205 SLH-DSA group public / private keypair derived from `S` |
- `README.md` --- introduction and headline tests.
- `THBS-SPEC.md` --- detailed THBS-SE construction spec.
- `DEPLOYMENT-RUNBOOK.md` --- operator runbook.
- `BLOCKERS.md` --- v1.1 roadmap.
## 2. Hash domain separation
## 1. Primitives
All cSHAKE / KMAC calls use function-name `"Magnetar"` and one of
the following customisation tags:
Magnetar v1.0 ships TWO primitives. Both produce FIPS 205 wire bytes
that an unmodified verifier accepts.
| Tag | Purpose |
|---|---|
| `MAGNETAR-DKG-COMMIT-V1` | committee-root digest |
| `MAGNETAR-DKG-TRANSCRIPT-V1` | DKG transcript digest |
| `MAGNETAR-SIGN-R1-V1` | threshold-sign Round-1 commit `D_i` |
| `MAGNETAR-SIGN-R1-MAC-V1` | (reserved for v0.2 MAC envelope) |
| `MAGNETAR-SIGN-MASK-V1` | per-attempt mask derivation |
| `MAGNETAR-SEED-SHARE-V1` | Shamir coefficient stream + seed mix |
### 1.1 Per-validator standalone (public-BFT primary)
API at `ref/go/pkg/magnetar/standalone.go`:
```
PerValidatorKeypair(params, rng) -> (sk, pk, error)
ValidatorSign(sk, rng, message) -> ([]byte, error)
ValidatorBatchVerify(params, pubs, msgs, sigs) -> ([]bool, error)
BuildAggregateCert(params, signers, pubKeys, sigs) -> (*ValidatorAggregateCert, error)
VerifyAggregateCert(cert, message, knownValidators) -> (count, error)
```
Semantics:
- Each validator runs `PerValidatorKeypair` ONCE on its own host with
its own RNG; persists `(sk_i, pk_i)`; registers `pk_i` in the
validator-set commitment.
- To sign message `m`: validator `i` computes
`sigma_i = ValidatorSign(sk_i, nil, m)`. Output is the raw FIPS 205
signature bytes; deterministic when `rng` is nil (FIPS 205
SignDeterministic).
- The consensus layer collects N `(sigma_i, pk_i, ValidatorID_i)`
triples into a `ValidatorAggregateCert` via `BuildAggregateCert`.
- `VerifyAggregateCert` returns the COUNT of valid signers. The
quorum policy decision (count >= threshold) lives at the CONSUMER,
NOT in the primitive.
Trust model:
- No DKG, no shared seed, no aggregator-in-TCB.
- Per-validator slashing is attributable.
- Z-Chain Groth16 rollup compresses `N x |sigma|` to ~192 bytes; the
rollup is a separate primitive (lux-zchain spec).
### 1.2 THBS-SE (permissionless threshold)
API at `ref/go/pkg/magnetar/thbsse.go`:
```
NewThbsSeKey(params, threshold, committee, rng) -> (*ThbsSeKey, error)
ThbsSeRound1(params, share, binding, msg, guard, rng) -> (r1, r2, error)
Combine(input ThbsSeCombineInput) -> (*Signature, []ThbsSeShareEvidence, error)
NewThbsSeSlotGuard() -> *ThbsSeSlotGuard
VerifyThbsSeEvidence(params, ev, msgPrior, msgNew, bindingPrior, bindingNew) -> bool
VerifyThbsSeShareEvidence(params, ev, binding, msg) -> bool
```
Semantics: see `THBS-SPEC.md` for the full construction. Highlights:
- t-of-n committee with `n > t` over-selection.
- Slot binding flows into the cSHAKE256 commit transcript AND into
the FIPS 205 ctx string.
- PUBLIC COMBINER role; anyone with the public ThbsSeKey, the slot
binding, the message, and >= t valid Round-1/Round-2 pairs can
produce the FIPS 205 signature.
- Equivocation and malformed-share evidence are wire-shaped typed
blobs; pure-function third-party verifiers.
## 2. Parameter sets
Per FIPS 205 sec 10.1 Table 2:
| Mode | n (WOTS+ msg len) | PK | SK | Sig | Seed |
|---|---|---|---|---|---|
| `ModeM192s` (Magnetar-SHAKE-192s) | 24 | 48 | 96 | 16224 | 96 |
| `ModeM192f` (Magnetar-SHAKE-192f) | 24 | 48 | 96 | 35664 | 96 |
| `ModeM256s` (Magnetar-SHAKE-256s) | 32 | 64 | 128 | 29792 | 128 |
Recommended production target: `ModeM192s` (smallest signatures at
NIST PQ category 3, >=192-bit classical security).
## 3. Hash domain separation
All cSHAKE calls use function-name `"Magnetar"` and one of the
following customisation tags:
| Tag | Purpose | Defined in |
|---|---|---|
| `MAGNETAR-THBSSE-SHARE-DEAL-V1` | Shamir coefficient stream + EvalPointFromID hash | `transcript.go` |
| `MAGNETAR-THBSSE-SLOT-V1` | Slot binding -> 32-byte slot_id; message digest | `thbsse.go` |
| `MAGNETAR-THBSSE-R1-COMMIT-V1` | Round-1 commit D_i; setup transcript | `thbsse.go` |
| `MAGNETAR-THBSSE-SHARE-MAC-V1` | (reserved for share-MAC envelope) | `thbsse.go` |
The protocol-wide domain-separation context is the ASCII string
`"lux-magnetar-v0.1"` (`tagDomainSep`).
`"lux-magnetar-v1"` (`tagDomainSep`).
## 3. DKG protocol
The FIPS 205 ctx prefix for THBS-SE signatures is `"lux-magnetar-thbsse-v1"`
followed by the 32-byte slot_id; total length 56 bytes, well under
the FIPS 205 sec 10.2 limit of 255 bytes.
### Round 1 (dealer P_i)
## 4. Byte-identity claims
1. Sample `c_i ←$ {0,1}^{8·seed_size}` from `rng`.
2. Sample `seed_size · 2 · (t-1)` bytes of coefficient material from `rng` (the random Shamir polynomial coefficients).
3. Run byte-wise Shamir-share-deal over GF(257):
- For each byte position `b ∈ [0, seed_size)`, define a polynomial `f_b(x) = c_i[b] + a_{b,1}·x + ... + a_{b,t-1}·x^{t-1} (mod 257)` where `a_{b,j}` are derived from the coefficient stream.
- For each recipient `P_j`, compute `share_{i→j}[b] = f_b(x_j) (mod 257)` for every byte `b`. The result is a 2·seed_size-byte big-endian uint16 vector.
4. Construct one envelope per recipient:
```
Envelope_{i→j} := (share_{i→j} || c_i)
```
The full `c_i` is duplicated into every envelope so that at Round 2 each `P_j` can sum contributions to compute the joint master public key locally.
5. Broadcast `DKGRound1Msg{NodeID = P_i, Envelopes = { P_j ↦ Envelope_{i→j} }_{j∈[n]}}`.
### 4.1 Per-validator standalone
### Round 2 (party P_i)
`TestMagnetar_Wire_FIPS205Verifiable` pins:
1. Receive Round-1 broadcasts from every dealer in committee.
2. Validate: every broadcast contains an envelope for every recipient (no omissions); every envelope has the expected wire sizes; sender is in committee.
3. Compute the transcript digest binding the entire ordered envelope set:
```
τ_dkg := lux-magnetar-v0.1 ‖ Mode ‖ t ‖ n ‖ committee_in_order
D := cSHAKE256( τ_dkg ‖ for_each_dealer_in_order(for_each_recipient_in_order(share || contribution)),
N="Magnetar", S="MAGNETAR-DKG-TRANSCRIPT-V1", 32 bytes )
```
4. Broadcast `DKGRound2Msg{NodeID = P_i, Digest = D}`.
For any `(sk, message)` pair: stripping the canonical 11-byte MAGS
wire header from `MarshalBinary(ValidatorSign(sk, nil, message))`
recovers the unmodified FIPS 205 bytes that
`cloudflare/circl/sign/slhdsa.Verify` accepts under
`(sk.Pub, message, nil_ctx)`.
### Round 3 (party P_i)
### 4.2 THBS-SE
1. Receive Round-2 digests from every party.
2. Verify every received digest equals `D` (computed locally). A mismatch is identifiable abort with `ComplaintKind = ComplaintEquivocation`; the conflicting digest pair is the evidence blob.
3. Sum per-byte Shamir shares received from each dealer:
```
my_share_sum[b] := Σ_{j∈[n]} share_{j→i}[b] (mod 257) for b ∈ [0, seed_size)
```
4. Sum dealer contributions byte-by-byte over GF(257):
```
master_byteSum[b] := Σ_{j∈[n]} c_j[b] (mod 257) for b ∈ [0, seed_size)
```
5. Compute the committee root:
```
committee_root := cSHAKE256( "MAGNETAR-COMMITTEE-V1" ‖ sorted_committee,
N="Magnetar", S="MAGNETAR-DKG-COMMIT-V1", 32 bytes )
```
6. Mix to the master seed:
```
mix := (master_byteSum encoded as 2·seed_size big-endian uint16) ‖ committee_root
S := cSHAKE256( mix, N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", seed_size bytes )
```
7. Derive the group public key via FIPS 205:
```
(pk, sk) := slhdsa.Scheme(SLH-DSA-SHAKE-{192s,192f,256s}).DeriveKey(S)
```
Discard `sk`; publish `pk` as the joint group public key. (Each party can compute the same `pk` because every party has identical `master_byteSum` and `committee_root` inputs.)
8. Output: `KeyShare{NodeID = P_i, EvalPoint = x_i = i, Share = my_share_sum, Pub = pk}`.
`TestThbsSE_Wire_FIPS205Verifiable` pins:
### DKG correctness
For any `(key, binding, message)` triple and any `t` valid
Round-1/Round-2 pairs: `Combine(...)` emits a `*Signature` whose
`Bytes` field, fed DIRECTLY to `cloudflare/circl/sign/slhdsa.Verify`
under `(key.PublicKey, message, ctxFromSlot(binding))`, is accepted.
Additionally, `TestThbsSE_PublicCombiner_Determinism` pins that any
two disjoint t-sized sub-quora of valid Round-2 reveals produce
byte-equal final signatures. The public combiner is a PURE function
of its inputs.
## 5. Slashing evidence wire shape
### 5.1 Equivocation evidence (ThbsSeEvidence)
For any quorum `Q ⊆ [n]` with `|Q| ≥ t`:
```
Lagrange-interpolate({(x_i, my_share_sum_i) : i ∈ Q}) = master_byteSum
type ThbsSeEvidence struct {
SlotID [32]byte
PartyID NodeID // [32]byte
PriorDigest [32]byte
PriorR1 ThbsSeRound1Msg
PriorR2 ThbsSeRound2Msg
NewDigest [32]byte
NewR1 ThbsSeRound1Msg
NewR2 ThbsSeRound2Msg
}
```
because `my_share_sum_i = Σ_j share_{j→i} = Σ_j f_{j,b}(x_i)` and the Lagrange interpolation at `x=0` over GF(257) of the polynomial sums `Σ_j f_{j,b}` recovers `Σ_j c_j[b] = master_byteSum[b]`.
Therefore any quorum's reconstructed seed `S` is identical to the one computed at Round 3.
Third-party verifier:
`VerifyThbsSeEvidence(params, ev, msgPrior, msgNew, bindingPrior, bindingNew) -> bool`.
## 4. Threshold signing
### 5.2 Malformed-share evidence (ThbsSeShareEvidence)
### Round 1 (signer P_i, i ∈ T ⊆ [n], |T| = t)
```
type ThbsSeShareEvidence struct {
PartyID NodeID
Reason ThbsSeShareEvidenceReason // {slot-mismatch, wire-size, commit-mismatch}
ExpectedD [32]byte // for commit-mismatch
ObservedD [32]byte // for commit-mismatch
PartialSig []byte // for commit-mismatch / wire-size
}
```
1. Sample `rngBytes ←$ {0,1}^{8·2·seed_size}` from `rng`.
2. Derive per-attempt mask:
```
maskMix := rngBytes ‖ session_id ‖ attempt(BE 4 bytes) ‖ NodeID
r_i := cSHAKE256( maskMix, N="Magnetar", S="MAGNETAR-SIGN-MASK-V1", 2·seed_size bytes )
```
3. Mask the share byte-by-byte XOR:
```
masked_i[b] := share_i[b] XOR r_i[b] for b ∈ [0, 2·seed_size)
```
4. Compute Round-1 transcript binding:
```
τ_1 := SP800-185-encode( session_id, attempt, quorum, NodeID, pk, message )
```
5. Compute commit:
```
D_i := cSHAKE256( r_i ‖ masked_i ‖ τ_1, N="Magnetar", S="MAGNETAR-SIGN-R1-V1", 32 bytes )
```
6. Broadcast `Round1Message{NodeID, SessionID, Attempt, Commit = D_i}`.
Third-party verifier:
`VerifyThbsSeShareEvidence(params, ev, binding, msg) -> bool`.
### Round 2 (signer P_i)
## 6. Wire codec
1. Validate Round-1 broadcasts (consistent session/attempt).
2. Reveal:
```
Round2Message{NodeID, SessionID, Attempt, PartialSig = r_i ‖ masked_i}
```
Magnetar v1.0 ships canonical MAGS (signature) and MAGG (group key)
frames per `wire.go`:
### Combine (aggregator)
```
Signature: GroupKey:
magic(4) = 'M' 'A' 'G' 'S' magic(4) = 'M' 'A' 'G' 'G'
version(2) = 0x0001 version(2) = 0x0001
mode(1) = {0x01..0x03} mode(1) = {0x01..0x03}
len(4) = FIPS 205 sig size len(4) = FIPS 205 pk size
payload = FIPS 205 sigEncode payload = FIPS 205 (seed||root)
```
1. For each Round-2 reveal `(r_i, masked_i)`:
a. Recompute `D'_i = cSHAKE256(r_i ‖ masked_i ‖ τ_1_i, ...)` and check `D'_i == Round1Message.Commit`. Reject on mismatch.
b. Recover `share_i = masked_i XOR r_i`.
2. Collect `t` distinct shares. Pair each with its `KeyShare.EvalPoint` from the directory.
3. Run Lagrange interpolation over GF(257) at `x=0`:
```
reconstructed_byteSum[b] := Σ_{i∈Q} λ_i · share_i[b] (mod 257)
```
where `λ_i = Π_{j≠i} (-x_j) / (x_i - x_j) (mod 257)`.
4. Compute committee_root from `allShares` (same definition as Round 3 step 5).
5. Mix to recover the master seed (identical to DKG Round 3 step 6):
```
S := cSHAKE256( reconstructed_byteSum_BE ‖ committee_root, N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", seed_size bytes )
```
6. Derive the SLH-DSA secret key:
```
(pk_rec, sk_rec) := slhdsa.Scheme(...).DeriveKey(S)
```
If `pk_rec ≠ groupPubkey`: abort with `ErrPubkeyMismatch`.
7. Sign:
```
sig := slhdsa.SignDeterministic(sk_rec, message, ctx)
```
(When `randomized=true`, `SignRandomized` is used with the caller's `rng`.)
8. Zeroize `S, sk_rec, mix, reconstructed_byteSum`.
9. Return `Signature{Mode, Bytes = sig}`.
Magic bytes are distinct from sibling protocols (pulsar PULS/PULG,
corona CORS/CORG) so cross-protocol frames fail at the first
dispatch. Stripping the 11-byte header recovers the unmodified FIPS
205 bytes any verifier accepts.
## 5. Verify
## 7. v1.0 open items
`Verify` is a thin dispatch to `slhdsa.Verify(pk, NewMessage(message), sig, ctx)`. **No Magnetar-specific envelope.** This is the Class-N1-analog property: any unmodified FIPS 205 verifier accepts Magnetar signatures.
See `BLOCKERS.md`:
## 6. Class-N1-analog byte-equality claim
**Claim (Magnetar reveal-and-aggregate byte-equality).** For any honest threshold-sign session over committee `[n]` with threshold `t`, any quorum `Q` of size `t`, message `m`, context `ctx`, session_id `sid`, attempt `κ`:
> `Combine(...) = slhdsa.SignDeterministic(slhdsa.DeriveKey(S), NewMessage(m), ctx)`
where `S` is the master seed computed at DKG Round 3.
**Proof sketch.** Combine reconstructs `master_byteSum` from `Q`'s shares via Lagrange interpolation (correct by Shamir's secret sharing over GF(257) for any quorum of size ≥ t). It then computes `committee_root` from the same `allShares` set that fixed it at DKG time, and applies the identical cSHAKE256 mix → produces the identical `S`. Sign on `DeriveKey(S)` is byte-deterministic over `(S, m, ctx)`. ∎
**Empirical validation.** See `n1_byte_equality_test.go`: `TestN1_ByteEquality_ThresholdMatchesCentralized` and `TestN1_ByteEquality_DifferentQuorumsSameSignature` exercise the claim across three configurations and across distinct quorums.
## 7. Trust model — the v0.1 reveal-and-aggregate caveat
**The aggregator process is in the trusted computing base (TCB) for the brief window the master seed is reconstructed in memory.**
This is the same trust caveat Pulsar's v0.1 reveal-and-aggregate carries; it is documented honestly in Pulsar's `DEPLOYMENT-RUNBOOK.md` and is inherited verbatim by Magnetar.
Specifically:
- At step 6 of `Combine` (§4), the master SLH-DSA seed `S` is recomputed in aggregator memory.
- At step 7, FIPS 205 `SignDeterministic` consumes `S` to produce the signature.
- At step 8, all secret-bearing buffers (`S, sk_rec, mix, reconstructed_byteSum`) are explicitly zeroized.
During steps 6–7, an adversary with arbitrary code-execution on the aggregator host can extract `S` via:
- `/proc/<pid>/mem`
- a coredump
- a swap-file or hibernation image
- `ptrace`
- a malicious kernel/hypervisor
Operational mitigations are described in `DEPLOYMENT-RUNBOOK.md` "Aggregator hardening".
**What this caveat does NOT cover:**
- A passive **network observer** never sees `S`. Round-1 commits and Round-2 reveals collectively carry `share_i` values (masked + revealed), but reconstruction requires combining `t` of them with the matching `EvalPoint` directory. v0.1 v1 envelopes are plaintext (a passive observer can see committee shares); v0.2 will wrap them under ML-KEM-768 to the recipient's long-term identity public key, matching Pulsar's BLOCKERS.md CR-8 pattern.
- A **single Byzantine committee member** cannot forge a signature: forging requires reconstructing `S`, which requires `t` shares.
- A **single Byzantine aggregator** cannot forge a signature for a message it did not receive valid Round-2 reveals on: Combine's commit-bind check (step 1a) rejects tampered reveals.
## 8. Identifiable abort
The protocol commits to identifiable abort: every detected deviation produces verifiable evidence suitable for slashing.
| Complaint | Detected at | Evidence |
|---|---|---|
| `ComplaintEquivocation` | DKG Round 3 | Conflicting `Digest` from accused; honest party's local `Digest` |
| `ComplaintBadDelivery` | DKG Round 2 (envelope shape check) | Malformed envelope |
The v0.1 protocol omits the per-pair MAC layer that Pulsar uses (BLOCKERS.md CR-7). This is documented; v0.2 adds MACs once the trust model tightens.
## 9. Parameter sets
| Mode | NIST PQ Cat | Pub key | Priv key | Signature | Seed |
|---|---|---|---|---|---|
| `Magnetar-SHAKE-192s` | 3 | 48 B | 96 B | 16,224 B | 96 B |
| `Magnetar-SHAKE-192f` | 3 | 48 B | 96 B | 35,664 B | 96 B |
| `Magnetar-SHAKE-256s` | 5 | 64 B | 128 B | 29,792 B | 128 B |
**Recommended production target: `Magnetar-SHAKE-192s`** (matches NIST Cat 3, smallest signature in its category).
## 10. Honest non-claims
Magnetar v0.1 does NOT claim:
- **Full MPC threshold signing.** v0.1 is reveal-and-aggregate; the aggregator process is TCB.
- **Network-observer secrecy of envelopes** in v0.1. Plaintext envelopes are visible to a passive observer; v0.2 closes this with ML-KEM-768 wrapping.
- **Sub-second signing.** SLH-DSA single-party signing on SHAKE-192s is ~10–50ms depending on hardware; race-detector runs are 5–10× slower.
- **Formal verification.** No EasyCrypt, Lean, or Jasmin artifacts ship in v0.1. See `BLOCKERS.md` BLK-7.
## 11. References
- FIPS 205: Stateless Hash-Based Digital Signature Standard (2024).
- Shamir, A. *How to share a secret*. Communications of the ACM, 1979.
- Pulsar v0.1 `SPEC.md` — reveal-and-aggregate pattern adapted from there.
- `BLOCKERS.md` — outstanding Tier B → A path.
- `DEPLOYMENT-RUNBOOK.md` — operator-facing trust-model disclosure.
---
**Document metadata**
- File: `SPEC.md`
- Version: v0.1
- Date: 2026-05-18
- Status: implementation specification; **not yet independently reviewed** (see BLK-9).
- `MAGNETAR-STRICT-ATOM-V11` --- the strict-invariant lift (no seed
reconstruction even transiently).
- `MAGNETAR-PVSS-DKG-V11` --- the leaderless PVSS-DKG setup variant
(v1.0 ships deterministic-dealer reference; production routes
through sibling `luxfi/threshold` DKG).
- `MAGNETAR-PROOF-TRACK-V11` --- EasyCrypt + Lean track for the
THBS-SE construction shape.
- `MAGNETAR-DUDECT-V11` --- v1.1 dudect harness.
- `MAGNETAR-EXTERNAL-AUDIT-V11` --- external cryptographer review.
+8 -1
View File
@@ -1,4 +1,11 @@
# NIST MPTC Submission Status — Magnetar
# NIST MPTC Submission Status --- Magnetar
> **v1.0 framing.** This document is a v0.x archival snapshot. The
> v1.0 submission shape ports to the THBS-SE construction and the
> per-validator standalone primitive; the v0.x reveal-and-aggregate
> path described below has been removed from the codebase. See
> `CHANGELOG.md::[1.0.0]` for the load-bearing deletions and the
> v1.0 construction surface.
> Honest status of Magnetar's path to NIST Multi-Party Threshold
> Cryptography submission. **Tier A documentation shape complete**
+7 -1
View File
@@ -1,4 +1,10 @@
# NIST MPTC Submission — Magnetar
# NIST MPTC Submission --- Magnetar
> **v1.0 framing.** This document is a v0.x archival snapshot.
> Magnetar v1.0 ships two primitives: per-validator standalone and
> THBS-SE (Threshold Hash-Based Signatures with Selected-Element
> Reconstruction). See `README.md`, `SPEC.md`, `THBS-SPEC.md`, and
> `CHANGELOG.md::[1.0.0]` for v1.0 normative content.
This document is the cover sheet for the **Magnetar** submission to the
NIST Multi-Party Threshold Cryptography (MPTC) project. It is written
+214 -56
View File
@@ -1,71 +1,229 @@
# Magnetar-THBS v1 — True Threshold Hash-Based Signatures
# Magnetar THBS-SE v1.0 --- Threshold Hash-Based Signatures with Selected-Element Reconstruction
> Subpackage: `github.com/luxfi/magnetar/ref/go/pkg/thbs`
> Status: v1 dealer-backed reference implementation.
> Subpackage: `github.com/luxfi/magnetar/ref/go/pkg/magnetar`
> Status: v1.0.0 normative spec.
## What this is
A reference implementation of **true threshold hash-based signing** in the McGrew et al. sense: parties hold secret-shared FORS/WOTS+ elements; for each message, parties release shares ONLY for the SELECTED elements determined by the message digest; the combiner reconstructs the final signature elements and ships an ordinary HBS-style signature.
THBS-SE is the ONE permissionless threshold construction shipped by
Magnetar v1.0. A t-of-n committee produces a SINGLE FIPS 205-shaped
signature on a slot-bound message; the public combiner role is open
to any peer (anyone can run Combine); no host is in the TCB at sign
time.
**Critical distinction from `magnetar/CombineWithSeedReconstruction`:**
THBS-SE is the architectural counterpart of the per-validator
standalone primitive (`standalone.go`): both produce FIPS 205 wire
bytes that any unmodified verifier accepts. The per-validator
standalone path is the public-BFT primary; THBS-SE is the
permissionless threshold companion for deployments that need a single
FIPS 205-shaped signature from a committee instead of N independent
signatures.
| Property | `magnetar.CombineWithSeedReconstruction` (v0.1 reveal-and-aggregate) | `thbs.Aggregate` (v1 true threshold) |
| --- | --- | --- |
| What is shared | The whole SLH-DSA scheme seed | Individual WOTS+ chain values + FORS leaves |
| Aggregator sees | Reconstructed master seed | Reconstructed selected elements only |
| Aggregator can sign future messages? | YES (it has the seed) | NO (only the elements selected by this digest) |
| Verifier | FIPS 205 SLH-DSA (byte-equal) | Custom HBS verifier (v1); FIPS 205 (v3 target) |
| Trust model | Aggregator-as-TCB / TEE custody only | Public-BFT acceptable (no party holds the seed) |
## v1 scope — honest
| Concern | v1 (this release) |
| --- | --- |
| Setup | **Dealer-backed.** A trusted dealer generates shared WOTS+/FORS material and distributes shares. (Public DKG = v2.) |
| Helper data | Shipped alongside the public key (McGrew et al. permit this). The dealer's pre-computed authentication paths are public; only the leaf/chain values are secret-shared. |
| Verifier | A **custom HBS verifier** in `thbs.Verify`. Bit-equality with FIPS 205 SLH-DSA wire format is a v3 goal, not v1. |
| Anti-equivocation | Each party stores `slot_id -> message_digest`. Same slot, different digest -> `ErrEquivocation` + `Evidence{...}` for slashing. |
| Parameter set | Lightweight reference parameters (WOTS+ Winternitz w=16, FORS k=14, a=12, height=10). NOT FIPS 205 wire-compatible. |
## What v1 explicitly does NOT do
1. No public-DKG: setup is dealer-backed. The dealer learns the secret elements at setup and must erase them before going live. v2 ports the BB-PEEDA-style PVSS-based DKG from the literature.
2. No FIPS 205 wire compatibility: a FIPS 205 verifier will NOT accept these signatures. v3 maps to the FIPS 205 parameter set and produces byte-identical signatures (the cost is significantly larger helper data and a more complex DKG).
3. No public-coin sponge stateless construction: v1 is stateful in the sense that each `Slot` must be used at most once. A double-use is identifiable abort (same-slot-different-digest evidence emitted).
## v2 / v3 roadmap
- **v2: Public DKG.** Replace the dealer with a coalition-DKG (Pedersen-VSS adapted for hash chains, or the PVSS variant in McGrew et al.). No single party learns the secret elements at any point.
- **v3: FIPS 205 wire compatibility.** Map the threshold elements onto the FIPS 205 hypertree (XMSS_MT) and produce byte-identical SLH-DSA signatures. Helper data grows to ~30 KiB but verifier code is unchanged FIPS 205.
- **v4: Stateless re-randomisation.** Address the stateful-slot constraint via the FIPS 205 `opt_rand` re-randomisation channel for randomised mode.
## Reference
McGrew, Fluhrer, Gazdag, Kampanakis, Morton, Westerbaan — "Hash-Based Signatures: An Outline for a New Standard" / "State Management for Hash-Based Signatures" — and the follow-on threshold extension treated in:
- McGrew et al., "Coalition and Threshold Hash-Based Signatures", IACR ePrint 2019/793 (and IRTF draft `draft-mcgrew-hash-sigs-15` line of work).
- Bonte, Smart, Tan, "Threshold SPHINCS+", PKC 2024 (the negative result confirming that bytewise FIPS 205 threshold without seed reconstruction is hard — informs our v1 scope choice to ship a *custom* HBS verifier).
## Invariant (verbatim)
## Hard invariant (verbatim from user spec)
```
OK: ReconstructElement(slot, elementID, shares)
Forbidden: ReconstructSeed, ReconstructPrivateKey,
ExpandPrivateKey, DeriveAllFutureElements
A revealed value is allowed only if it is also present in the final
SLH-DSA signature.
```
The thbs subpackage *enforces* this invariant: there is no API path that returns a future-signing key. The only `Reconstruct*` symbol in thbs/*.go is `reconstructElement` (file-scope, unexported, used by `Aggregate`). The dealer's secret elements are zeroised after `DKG` returns. Tests `TestTHBS_DKG_NoSeedExposure` and `TestTHBS_Aggregate_NoFutureSigningMaterial` pin this.
| Reveal type | Allowed? | Notes |
|---|---|---|
| Per-round mask `r_i` | YES | Part of Round-2 PartialSig payload; legitimately public. |
| Masked share `s'_i = share_i XOR r_i` | YES | Part of Round-2 PartialSig payload; legitimately public. Combined with `r_i` recovers `share_i`, which is information-theoretically uniform-random with fewer than t leaves under GF(257) Shamir. |
| Round-1 commit hash `D_i` | YES | Public domain-separated hash. |
| Final FIPS 205 signature bytes | YES | The PUBLIC output of Combine. |
| `SK.seed` | NO | Forbidden in any party-local persistent form. Each party holds ONLY its Shamir leaf, never the seed. |
| `SK.prf` | NO | Derived from `SK.seed`. |
| Future-slot share material | NO | The slot guard refuses any same-slot re-emission. The share envelope is per-slot. |
| WOTS+ chain bases for unused chains | NO | (v1.1 strict-atom invariant.) |
| FORS leaves not selected by message digest | NO | (v1.1 strict-atom invariant.) |
| Public auth-path nodes | YES | Part of every SLH-DSA signature. |
## Anti-equivocation
## v1.0 ship state (honest open item)
The strictest formulation of the invariant --- "no party or combiner
EVER reconstructs SK.seed, even transiently in memory" --- requires
the v1.1 strict-atom-assembly path tracked at
`BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11`.
Magnetar v1.0 routes the final FIPS 205 byte production via
`circl/slhdsa.SignDeterministic` on a seed reconstructed by the
PUBLIC COMBINER. The seed is briefly present in the public combiner's
memory for one Sign call and is zeroized before return. The combiner
role is PUBLIC --- anyone can be the combiner --- and there is no
long-lived secret material outside party-local Shamir leaves.
This is materially stronger than a TEE-attested privileged-aggregator
model (no host is in the TCB) and materially weaker than the strict
invariant (a peer-local memory-disclosure adversary at exactly the
combine moment could observe the seed).
## Construction (v1.0 wire)
### Setup
```
For each (party_id, slot_id):
first call: record digest_a, share_a; return PartialSignature
second call: if msg_digest_b == digest_a: idempotent re-emit
if msg_digest_b != digest_a: return ErrEquivocation
+ Evidence{party_id, slot_id,
digest_a, share_a,
digest_b, share_b}
1. Sample SLH-DSA seed S.
2. Derive (PK, SK) = slhdsa.Scheme().DeriveKey(S).
3. Byte-wise Shamir-share S across (n, t) committee via GF(257).
4. Publish PK + committee + (n, t).
5. Erase S. The dealer is in the TCB FOR SETUP ONLY; once setup
returns, no party including the dealer holds S.
```
The `Evidence` payload is sufficient for an external slashing layer to verify the equivocation (both shares were validly produced by the same party against the same slot under different messages) without re-running the protocol.
Production deployments run the leaderless PVSS-DKG path via the
sibling `luxfi/threshold` DKG package and feed the result into the
same wire-shape share envelope. v1.0 reference ships a
deterministic-dealer setup (`NewThbsSeKey`) for KAT determinism.
### Sign Round 1 (party p_i, in parallel)
```
1. Sample per-round mask r_i.
2. Compute D_i = cSHAKE256(r_i || s'_i || tau)
where s'_i = share_i XOR r_i
and tau = SlotBinding.Encode() || msg || party_id.
3. Broadcast (party_id, slot_id, D_i, availability_bit).
4. Persist (slot_id, H(slot_id || msg)) in local SlotGuard.
Refuse if the same slot_id is already used for a different digest.
```
### Sign Round 2 (party p_i, after Round-1 quorum is observable)
```
1. Reveal PartialSig = r_i || s'_i.
2. Idempotent replay: re-issuing Round 1+2 for the same
(slot_id, msg) returns the persisted (R1, R2). A genuine
equivocation attempt (same slot_id, different msg) raises
*ThbsSeEquivocationError without emitting the second R1.
```
### Combine (anyone, public)
```
1. Collect >= t Round-2 reveals.
2. For each, re-derive D_i from PartialSig + slot_binding + msg + party_id.
Mismatch produces ThbsSeShareEvidence with reason=ThbsSeShareCommitMismatch.
3. Recover share_i = mask XOR masked_share via byte-wise XOR.
4. Lagrange-interpolate the seed via thbsseReconstructGF over GF(257).
5. Bind to slot via FIPS 205 ctx = tagThbsSeCtxPrefix || slot_id (32 bytes);
total <= 255B per FIPS 205 sec 10.2.
6. Call slhdsa.SignDeterministic(seed, msg, ctx).
7. Zeroize seed + intermediate buffers.
8. Return Signature{Mode, FIPS 205 wire bytes}.
```
### Verify (anyone, public)
Standard FIPS 205 `slhdsa.Verify(pk, msg, sig, ctx)`. The v1.0
reference exposes this as `VerifyBytesCtx` (wire.go) for stateless
dispatch --- no Magnetar code path on the verifier side.
## Slot binding
Every signature is bound to the slot tuple
`(chain_id, epoch, slot, height, committee_id, message_domain)`. The
binding flows into:
1. The cSHAKE256 commit transcript (so the same committee cannot
reuse the same share material on a different message).
2. The FIPS 205 ctx string at sign time (so any verifier holding the
slot tuple can derive ctx independently).
Wire layout (canonical, big-endian):
```
chain_id_len(4) || chain_id ||
epoch(8) || slot(8) || height(8) ||
committee_id_len(4) || committee_id ||
message_domain_len(4) || message_domain
```
The 32-byte `slot_id = cSHAKE256(encode(binding), 32, "MAGNETAR-THBSSE-SLOT-V1")`
is the canonical key in the local slot guard and the payload of the
FIPS 205 ctx string.
## Slashing evidence
### Equivocation
A party that signs two distinct messages at the same slot binding
raises `*ThbsSeEquivocationError` carrying:
```
SlotID, PartyID,
PriorDigest, PriorR1, PriorR2,
NewDigest, NewR1, NewR2
```
The wire-shaped `ThbsSeEvidence` blob is the on-chain transmission
shape. `VerifyThbsSeEvidence` is the canonical third-party verifier:
pure function with no committee state required.
### Malformed share
Combine emits typed `ThbsSeShareEvidence` for the following
malformations:
- `ThbsSeShareSlotMismatch` --- the Round-2 reveal carries a slot
ID that does not match the binding.
- `ThbsSeShareWireSize` --- the PartialSig is the wrong number of
bytes for the SLH-DSA mode.
- `ThbsSeShareCommitMismatch` --- the Round-1 commit does not
re-derive from the Round-2 reveal under the slot binding and
message.
`VerifyThbsSeShareEvidence` is the third-party verifier; pure
function.
## Over-selected committee
With `(n, t)` and `n > t`, up to `n - t` silent withholders are
tolerated. The Lagrange basis is determined by ANY t evaluation
points, so disjoint sub-quora of size t produce byte-equal final
signatures (the public combiner is a PURE function of its
inputs). The 8-th test gate
`TestThbsSE_PublicCombiner_Determinism` pins this.
## API surface
- `NewThbsSeKey(params, threshold, committee, rng) -> (*ThbsSeKey, error)`
- `ThbsSeRound1(params, share, binding, msg, guard, rng) -> (r1, r2, error)`
- `Combine(input ThbsSeCombineInput) -> (*Signature, []ThbsSeShareEvidence, error)`
- `ThbsSeSlotGuard` + `NewThbsSeSlotGuard`, `Record`, `Has`
- `VerifyThbsSeEvidence(params, ev, msgPrior, msgNew, bindingPrior, bindingNew) -> bool`
- `VerifyThbsSeShareEvidence(params, ev, binding, msg) -> bool`
Stateless wire dispatch (consumed by `luxfi/threshold/pkg/thresholdd`):
- `VerifyBytes(gpkBytes, message, sigBytes) -> bool`
- `VerifyBytesCtx(gpkBytes, message, ctx, sigBytes) -> bool`
## Test gates
The 8 mandated test gates plus 2 bonus correctness checks:
| Gate | Test | Status |
|---|---|---|
| 1 | `TestThbsSE_Wire_FIPS205Verifiable` (3 modes) | PASS |
| 2 | `TestThbsSE_RejectSeedReveal` | PASS |
| 3 | `TestThbsSE_RejectUnselectedFORS` | PASS |
| 4 | `TestThbsSE_RejectUnselectedWOTS` | PASS |
| 5 | `TestThbsSE_SlotReuseRejected` | PASS |
| 6 | `TestThbsSE_OverselectedCommittee` | PASS |
| 7 | `TestThbsSE_SlotBindingDomainSeparation` | PASS |
| 8 | `BenchmarkThbsSE_Sign_5of7` (192f < 100 ms/op) | PASS |
| bonus | `TestThbsSE_PublicCombiner_Determinism` | PASS |
| KAT | `TestKAT_ThbsSe` (n=7, t=4, 3 modes, 3 messages) | PASS |
## Citations
- McGrew, D., Wallace, C. and Whyte, W. (2019). *Threshold Hash-Based
Signatures.* IACR ePrint 2019/793.
- Cozzo, D. and Smart, N. P. (2019). *Sharing the LUOV.* EUROCRYPT.
- Bonte, C., Smart, N. P. and Tan, T. (2023). *Threshold SPHINCS+.*
- Schoenmakers, B. (1999). *A simple publicly verifiable secret
sharing scheme and its application to electronic voting.* CRYPTO.
- Shamir, A. (1979). *How to share a secret.* CACM.
- NIST FIPS 205 (2024). *Stateless Hash-Based Digital Signature
Standard.*
+8 -1
View File
@@ -1,4 +1,11 @@
# TRUSTED-COMPUTING-BASE — Magnetar implementation TCB
# TRUSTED-COMPUTING-BASE --- Magnetar implementation TCB
> **v1.0 framing.** This document is a v0.x archival snapshot. The
> v1.0 TCB for the per-validator standalone primitive is
> `cloudflare/circl/sign/slhdsa` v1.6.3. The v1.0 TCB for THBS-SE
> additionally includes the public combiner's transient memory at
> the precise sub-second sign moment (see
> `DEPLOYMENT-RUNBOOK.md` sec 2.1).
> **What you must trust to rely on Magnetar's byte-equality
> correctness claim.** Companion to `PROOF-CLAIMS.md` (proof scope)
+48
View File
@@ -0,0 +1,48 @@
# Magnetar --- constant-time analysis track
Magnetar v1.0 routes ALL FIPS 205 SLH-DSA operations through
`cloudflare/circl/sign/slhdsa` v1.6.3. CIRCL's slhdsa package is the
upstream CT-audited reference; its constant-time posture is the
constant-time posture inherited by both Magnetar primitives:
- **Per-validator standalone** (`ref/go/pkg/magnetar/standalone.go`)
--- `ValidatorSign` is a thin wrapper around
`slhdsa.SignDeterministic`. No Magnetar-side secret-dependent
branches. The CT property is the CT property of CIRCL slhdsa
v1.6.3 verbatim.
- **THBS-SE** (`ref/go/pkg/magnetar/thbsse.go` +
`thbsse_field.go`) --- the share arithmetic surface (commit-bind,
Lagrange reconstruct over GF(257), mix-to-seed, dispatch to
CIRCL slhdsa) is straight-line modular arithmetic with no
secret-dependent branches:
- `thbsseModInvSmall` / `thbsseModPowSmall` operate on the PUBLIC
prime exponent `p-2`, not on a secret exponent.
- The Lagrange basis lambdas are functions of PUBLIC evaluation
points only.
- The per-byte share multiplication is one `uint32` mul + one
`% 257` per byte position --- no branching on share value.
- The commit re-derivation in `Combine` and `deriveThbsSeCommit`
is a cSHAKE256 absorb, which is CT by CIRCL's `golang.org/x/
crypto/sha3` implementation.
## v1.0 ship status
The v0.x dudect harness has been removed --- it modeled the
abandoned seed-recombine `Combine` and `Verify` API surfaces that
no longer exist in the codebase. The v1.0 THBS-SE construction has
a substantially smaller CT-critical surface (no DKG state, no
intermediate Round1Message struct lifetime, no per-party MAC
ladder), and the CIRCL slhdsa upstream covers the heavy SLH-DSA
work.
## v1.1 plan
The v1.1 dudect harness re-lands once the strict-atom-assembly
construction lands (BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11). At that
point a small Magnetar-internal re-implementation of FIPS 205
sec 5/6/7/8 surfaces enters the trusted computing base, and a
dudect harness over the per-atom WOTS+ chain + FORS sign step
becomes the right CT analytical surface. v1.0's CT story is
"inherit CIRCL"; v1.1's CT story is "inherit CIRCL for FIPS 205 sec
0--4 + dudect for the Magnetar-internal sec 5--8 surface".
-100
View File
@@ -1,100 +0,0 @@
# Magnetar constant-time analysis harness — dudect bridge.
#
# Builds the cgo shim libraries (libmagnetar_verify, libmagnetar_combine)
# and links the C dudect harnesses against them.
#
# Prereqs (run once):
# ./fetch.sh # pulls dudect.h at the pinned commit
#
# Build:
# make # both verify + combine
# make verify # just verify
# make combine # just combine
#
# Run:
# ./dudect_verify # smoke test (10000 samples/batch x 4 batches)
# ./dudect_combine # smoke test (1000 samples/batch x 4 batches —
# Magnetar Combine includes SLH-DSA
# SignDeterministic, slower than Pulsar's
# Combine because the FORS+HT tree walk is
# larger than ML-DSA's matrix product)
#
# Full submission run (10^9 samples on a pinned-CPU quiet host):
# DUDECT_SAMPLES=1000000 DUDECT_MAX_BATCHES=1000 ./dudect_verify
# DUDECT_SAMPLES=500000 DUDECT_MAX_BATCHES=1000 ./dudect_combine
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
# Shared-library extension is .dylib on macOS, .so elsewhere.
ifeq ($(UNAME_S),Darwin)
SHLIB_EXT := dylib
RPATH_FLAG := -Wl,-rpath,@loader_path
else
SHLIB_EXT := so
RPATH_FLAG := -Wl,-rpath,'$$ORIGIN'
endif
# Source paths.
REPO_ROOT := $(shell cd ../.. && pwd)
DUDECT_HDR := dudect/src/dudect.h
# C compile flags. -O2 is the default per dudect's own build; the
# leakage signal is preserved at -O2 because the inner do_one_computation
# call boundary is opaque to the compiler.
CC ?= cc
CFLAGS ?= -O2 -Wall -Wno-unused-parameter -I. -Idudect/src
LDFLAGS ?=
LDLIBS := -lm
# Force-include the ARM compat shim on AArch64 hosts so dudect.h's
# x86 intrinsics resolve.
ifeq ($(UNAME_M),arm64)
CFLAGS += -include dudect_compat.h
endif
ifeq ($(UNAME_M),aarch64)
CFLAGS += -include dudect_compat.h
endif
# Go env. GOWORK=off matches scripts/checks/go-tests.sh — this repo
# uses a single-module layout.
GO ?= go
GOENV := GOWORK=off CGO_ENABLED=1
.PHONY: all verify combine clean check-dudect
all: verify combine
libmagnetar_verify.$(SHLIB_EXT): verify_ct.go check-dudect
$(GOENV) $(GO) build -buildmode=c-shared \
-tags magnetar_verify_ct \
-o libmagnetar_verify.$(SHLIB_EXT) \
./verify_ct.go
libmagnetar_combine.$(SHLIB_EXT): combine_ct.go check-dudect
$(GOENV) $(GO) build -buildmode=c-shared \
-tags magnetar_combine_ct \
-o libmagnetar_combine.$(SHLIB_EXT) \
./combine_ct.go
dudect_verify: dudect_verify.c libmagnetar_verify.$(SHLIB_EXT) $(DUDECT_HDR)
$(CC) $(CFLAGS) -o dudect_verify dudect_verify.c \
-L. -lmagnetar_verify $(LDFLAGS) $(LDLIBS) $(RPATH_FLAG)
dudect_combine: dudect_combine.c libmagnetar_combine.$(SHLIB_EXT) $(DUDECT_HDR)
$(CC) $(CFLAGS) -o dudect_combine dudect_combine.c \
-L. -lmagnetar_combine $(LDFLAGS) $(LDLIBS) $(RPATH_FLAG)
verify: dudect_verify
combine: dudect_combine
check-dudect:
@if [ ! -f $(DUDECT_HDR) ]; then \
echo "==> dudect.h missing — run ./fetch.sh"; \
exit 1; \
fi
clean:
rm -f dudect_verify dudect_combine
rm -f libmagnetar_verify.$(SHLIB_EXT) libmagnetar_verify.h
rm -f libmagnetar_combine.$(SHLIB_EXT) libmagnetar_combine.h
-67
View File
@@ -1,67 +0,0 @@
# Magnetar constant-time analysis — dudect harness
This directory holds the **dudect** harness (https://github.com/oreparaz/dudect)
that drives empirical constant-time testing on Magnetar's
Verify and Combine paths.
## Targets
| Target | What it tests | Operationally-meaningful CT? |
|---|---|---|
| `dudect_verify` | `magnetar.Verify(pk, msg, ctx, sig)` over a pool of VALID signatures | Yes — `Verify` is the consensus-consumer hot path; CT means an attacker can't extract sig content from timing |
| `dudect_combine` | `magnetar.CombineWithSeedReconstruction(...)` over a pool of VALID Round-1+Round-2 tapes (independent ceremonies over the SAME shares; all produce the SAME final signature) | Yes — CombineWithSeedReconstruction reconstructs the master seed in memory; CT means the seed value can't influence the leakage trace |
The valid-pool design is the same operationally-meaningful CT
framing Pulsar uses (`~/work/lux/pulsar/ct/dudect/`). Earlier
versions of the harness used class A = zero bytes and class B =
random bytes — both INVALID — which produced rejection-path timing
artifacts rather than data-dependent secret leaks.
## Build
```bash
./fetch.sh # pulls dudect at the pinned commit
make # builds both dudect_verify + dudect_combine
make verify # just verify
make combine # just combine
```
## Run
Smoke test (laptop, ~minute per target):
```bash
./dudect_verify
./dudect_combine
```
Full submission-grade run (10^9 samples on a pinned CPU, quiet host):
```bash
DUDECT_SAMPLES=1000000 DUDECT_MAX_BATCHES=1000 ./dudect_verify
DUDECT_SAMPLES=500000 DUDECT_MAX_BATCHES=1000 ./dudect_combine
```
Or use the orchestrator: `./run-submission.sh`.
## Host support
- x86_64 Linux/macOS: builds cleanly against upstream dudect.h.
- aarch64 Linux/macOS: `dudect_compat.h` is force-included on AArch64
hosts to supply ARM equivalents of `_mm_mfence()` and `__rdtsc()`.
## What this is NOT
- dudect is an EMPIRICAL CT validator; a negative result (no leakage
detected) is evidence but not proof. The corresponding FORMAL CT
artifacts live in `../../jasmin/` (when libjade-SLH-DSA lands; see
`../../jasmin/slh-dsa/README.md`).
- The smoke-test budget (10,000 samples) is INSUFFICIENT for a CT
certification claim. The full NIST submission requires ~10^9
samples per target. See `run-submission.sh`.
- Constant-time analysis at this level does NOT address:
- cache-timing side channels
- power side channels
- EM side channels
- fault attacks
- Spectre / Meltdown microarchitectural leakage
-253
View File
@@ -1,253 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build magnetar_combine_ct
// combine_ct.go — cgo bridge exposing magnetar.CombineWithSeedReconstruction
// to the C dudect harness in dudect_combine.c.
//
// CT POPULATION (operational framing, NOT a standards mandate):
// both dudect classes are VALID CombineWithSeedReconstruction inputs —
// independently randomized threshold ceremonies over the SAME shares.
// The bridge pre-builds a pool of K full (Round1, Round2) tapes by
// running the threshold protocol K times with different per-party
// RNG seeds. All K ceremonies produce the SAME final FIPS 205 SLH-DSA
// signature (the reveal-and-aggregate CombineWithSeedReconstruction
// collapses through the same master seed via Lagrange reconstruction),
// but the intermediate Round-1 commits + Round-2 (mask, masked)
// reveals vary across tapes.
//
// dudect class assignment:
// class A: always tape[0] (byte-identical Combine inputs)
// class B: tape[rand % K] (varying-but-valid Combine inputs)
//
// Both classes pass every internal CombineWithSeedReconstruction
// check (commit re-derive, Lagrange reconstruct, mix-to-seed,
// KeyFromSeed pk match, FIPS 205 SignDeterministic). Any timing
// difference between classes is a real signature-content-dependent
// timing in the CombineWithSeedReconstruction pipeline, not a
// rejection-path artifact.
//
// Build (Linux):
// GOWORK=off go build -buildmode=c-shared \
// -o libmagnetar_combine.so ./combine_ct.go
// Build (macOS):
// GOWORK=off go build -buildmode=c-shared \
// -o libmagnetar_combine.dylib ./combine_ct.go
package main
/*
#cgo arm64 CFLAGS: -include ${SRCDIR}/dudect_compat.h
#include <stdint.h>
#include <stddef.h>
*/
import "C"
import (
"crypto/rand"
"unsafe"
"github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// Pool size — K independent valid threshold ceremonies over the
// SAME shares. SLH-DSA signing in Combine is heavier than ML-DSA so
// we use a smaller pool than Pulsar's 16 to keep setup time bounded.
const kCombineValidPool = 8
// Per-tape fixture: one full (Round1, Round2) tape from an
// independent threshold ceremony.
type combineTape struct {
round1 []*magnetar.Round1Message
round2 []*magnetar.Round2Message
}
// Long-lived fixture: a real (n=3, t=2) threshold setup at ModeM192s,
// plus a pool of kCombineValidPool valid (Round1, Round2) tapes.
var (
cFixtureParams *magnetar.Params
cFixturePub *magnetar.PublicKey
cFixtureMsg []byte
cFixtureSessionID [16]byte
cFixtureAttempt uint32
cFixtureQuorum []magnetar.NodeID
cFixtureThreshold int
cFixtureShares []*magnetar.KeyShare
cFixtureTapes [kCombineValidPool]combineTape
)
//export magnetar_combine_ct_setup
//
// Build a real threshold ceremony fixture once. Returns 0 on success,
// non-zero on failure.
//
// Topology: n=3 parties, t=2 threshold, ModeM192s. This is the
// smallest non-trivial threshold ceremony — keeps the dudect run
// time per sample bounded.
func magnetar_combine_ct_setup() C.int {
params := magnetar.MustParamsFor(magnetar.ModeM192s)
n, t := 3, 2
committee := make([]magnetar.NodeID, n)
for i := 0; i < n; i++ {
committee[i] = magnetar.NodeID{byte(i + 1)}
}
// DKG ceremony.
dkgSessions := make([]*magnetar.DKGSession, n)
for i := 0; i < n; i++ {
s, err := magnetar.NewDKGSession(params, committee, t, committee[i], rand.Reader)
if err != nil {
return 1
}
dkgSessions[i] = s
}
r1 := make([]*magnetar.DKGRound1Msg, n)
for i, s := range dkgSessions {
m, err := s.Round1()
if err != nil {
return 2
}
r1[i] = m
}
r2 := make([]*magnetar.DKGRound2Msg, n)
for i, s := range dkgSessions {
m, err := s.Round2(r1)
if err != nil {
return 3
}
r2[i] = m
}
outputs := make([]*magnetar.DKGOutput, n)
for i, s := range dkgSessions {
out, err := s.Round3(r2)
if err != nil {
return 4
}
outputs[i] = out
}
pub := outputs[0].GroupPubkey
shares := make([]*magnetar.KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
// Threshold sign.
msg := []byte("dudect constant-time smoke: Magnetar Combine class N1-analog")
quorum := make([]magnetar.NodeID, t)
for i := 0; i < t; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
if _, err := rand.Read(sid[:]); err != nil {
return 5
}
// Build the K-entry valid-tape pool. Each tape is an independent
// run of (Round1, Round2) over the SAME shares + sid + attempt +
// message — only the per-party RNG (hence masks + commits +
// reveals) differs. Every tape is sanity-checked: Combine must
// succeed before the tape is admitted to the pool. All tapes
// produce the SAME final signature (deterministic SLH-DSA on the
// SAME reconstructed seed); any timing difference dudect detects
// is a real data-dependent signal in the Combine pipeline.
for k := 0; k < kCombineValidPool; k++ {
signers := make([]*magnetar.ThresholdSigner, t)
for i := 0; i < t; i++ {
s, err := magnetar.NewThresholdSigner(params, sid, 1, quorum, shares[i],
msg, rand.Reader)
if err != nil {
return 6
}
signers[i] = s
}
sr1 := make([]*magnetar.Round1Message, t)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
return 7
}
sr1[i] = m
}
sr2 := make([]*magnetar.Round2Message, t)
for i, s := range signers {
m, _, err := s.Round2(sr1)
if err != nil {
return 8
}
sr2[i] = m
}
// Sanity: this tape's CombineWithSeedReconstruction must succeed.
if _, err := magnetar.CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, 1, quorum, t, sr1, sr2, shares); err != nil {
return 9
}
cFixtureTapes[k] = combineTape{round1: sr1, round2: sr2}
}
cFixtureParams = params
cFixturePub = pub
cFixtureMsg = msg
cFixtureSessionID = sid
cFixtureAttempt = 1
cFixtureQuorum = quorum
cFixtureThreshold = t
cFixtureShares = shares
return 0
}
//export magnetar_combine_ct_pool_size
//
// Returns the number of valid tapes in the per-startup pool.
func magnetar_combine_ct_pool_size() C.size_t {
return C.size_t(kCombineValidPool)
}
//export magnetar_combine_ct_input_size
//
// Returns the per-sample input width: 4 bytes (a big-endian uint32
// tape index, mod kCombineValidPool). The C harness sizes its
// dudect chunk to this width.
func magnetar_combine_ct_input_size() C.size_t {
return C.size_t(4)
}
//export magnetar_combine_ct
//
// One dudect measurement sample.
//
// `data` points to a 4-byte big-endian uint32 tape index; the
// bridge reduces it mod kCombineValidPool and runs
// CombineWithSeedReconstruction on the indexed (Round1, Round2)
// tape. Both classes (A: fixed index 0, B: caller-supplied index)
// drive CombineWithSeedReconstruction through the SAME code path
// on VALID inputs — any timing difference is a real data-dependent
// signal, not a rejection-path artifact.
func magnetar_combine_ct(data *C.uint8_t) {
if cFixtureParams == nil {
return
}
src := unsafe.Slice((*byte)(unsafe.Pointer(data)), 4)
idx := (uint32(src[0])<<24 | uint32(src[1])<<16 | uint32(src[2])<<8 | uint32(src[3])) %
uint32(kCombineValidPool)
tape := cFixtureTapes[idx]
_, _ = magnetar.CombineWithSeedReconstruction(
cFixtureParams,
cFixturePub,
cFixtureMsg,
nil,
false,
cFixtureSessionID,
cFixtureAttempt,
cFixtureQuorum,
cFixtureThreshold,
tape.round1,
tape.round2,
cFixtureShares,
)
}
func main() {}
-119
View File
@@ -1,119 +0,0 @@
/*
* Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
* See the file LICENSE for licensing terms.
*
* dudect_combine.c — dudect main loop driving
* magnetar.CombineWithSeedReconstruction through the cgo bridge in
* combine_ct.go.
*
* Same valid-tape-pool methodology as Pulsar's combine harness. Both
* dudect classes are VALID CombineWithSeedReconstruction inputs drawn from a pre-built
* K-entry tape pool (independent threshold ceremonies over the SAME
* shares; all produce the SAME final signature, but Round-1 / Round-2
* intermediates vary).
*
* class A: tape[0] (fixed)
* class B: tape[rand % K] (varying valid tapes)
*
* Per-sample input is a 4-byte tape index (big-endian uint32).
*/
#define DUDECT_IMPLEMENTATION
#include "dudect/src/dudect.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
extern int magnetar_combine_ct_setup(void);
extern size_t magnetar_combine_ct_pool_size(void);
extern size_t magnetar_combine_ct_input_size(void);
extern void magnetar_combine_ct(uint8_t *data);
static size_t g_chunk_size = 0;
static size_t g_pool_size = 0;
void prepare_inputs(dudect_config_t *cfg, uint8_t *input_data, uint8_t *classes) {
for (size_t i = 0; i < cfg->number_measurements; i++) {
classes[i] = randombit();
uint8_t *slot = input_data + (size_t)i * cfg->chunk_size;
if (classes[i] == 0) {
/* Class A: tape[0] (Welch's t-test requires identical
* class-A inputs). Encode index 0 big-endian. */
slot[0] = slot[1] = slot[2] = slot[3] = 0;
} else {
/* Class B: random index — the bridge reduces mod
* pool_size. randombytes draws from dudect's RNG. */
randombytes(slot, cfg->chunk_size);
}
}
}
uint8_t do_one_computation(uint8_t *data) {
magnetar_combine_ct(data);
uint8_t acc = 0;
for (size_t i = 0; i < g_chunk_size; i++) acc ^= data[i];
return acc;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
int rc = magnetar_combine_ct_setup();
if (rc != 0) {
fprintf(stderr, "magnetar_combine_ct_setup failed: rc=%d\n", rc);
return 1;
}
g_chunk_size = magnetar_combine_ct_input_size();
g_pool_size = magnetar_combine_ct_pool_size();
if (g_chunk_size == 0 || g_pool_size == 0) {
fprintf(stderr, "magnetar_combine_ct setup returned 0 sizes\n");
return 1;
}
/* SLH-DSA SignDeterministic is slower than ML-DSA's lattice
* signing (FORS + HT tree walks at h=63, d=7 for SHAKE-192s).
* Default smoke batch is smaller than verify to keep total runtime
* under a minute on a laptop. */
size_t number_measurements = 1000;
const char *env_n = getenv("DUDECT_SAMPLES");
if (env_n) {
long n = strtol(env_n, NULL, 10);
if (n > 0) number_measurements = (size_t)n;
}
size_t max_batches = 4;
const char *env_b = getenv("DUDECT_MAX_BATCHES");
if (env_b) {
long b = strtol(env_b, NULL, 10);
if (b > 0) max_batches = (size_t)b;
}
dudect_config_t cfg = {
.chunk_size = g_chunk_size,
.number_measurements = number_measurements,
};
dudect_ctx_t ctx;
if (dudect_init(&ctx, &cfg) != 0) {
fprintf(stderr, "dudect_init failed\n");
return 1;
}
fprintf(stderr, "dudect_combine: SLH-DSA-SHAKE-192s (n=3,t=2), chunk=%zu bytes, batch=%zu samples, max_batches=%zu, pool=%zu valid tapes\n",
g_chunk_size, number_measurements, max_batches, g_pool_size);
dudect_state_t state = DUDECT_NO_LEAKAGE_EVIDENCE_YET;
for (size_t batch = 0; batch < max_batches; batch++) {
state = dudect_main(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) break;
}
dudect_free(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) {
fprintf(stderr, "dudect_combine: LEAKAGE FOUND (t-statistic exceeded threshold)\n");
return 2;
}
fprintf(stderr, "dudect_combine: no leakage evidence after %zu batches of %zu samples\n",
max_batches, number_measurements);
return 0;
}
-57
View File
@@ -1,57 +0,0 @@
/*
* Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
* See the file LICENSE for licensing terms.
*
* dudect_compat.h — minimal x86-intrinsic compatibility shim for
* AArch64 hosts.
*
* Same shim Pulsar uses (`~/work/lux/pulsar/ct/dudect/dudect_compat.h`).
*
* Upstream dudect.h (https://github.com/oreparaz/dudect) hard-codes
* x86 intrinsics:
*
* _mm_mfence() — memory barrier
* __rdtsc() — read time-stamp counter
*
* On AArch64 hosts (Apple Silicon, AWS Graviton), these are not
* available. This shim supplies equivalents.
*/
#ifndef DUDECT_COMPAT_H
#define DUDECT_COMPAT_H
#if defined(__aarch64__)
#include <stdint.h>
#define _EMMINTRIN_H_INCLUDED
#define __EMMINTRIN_H
#define _IMMINTRIN_H_INCLUDED
#define _X86INTRIN_H_INCLUDED
#define __X86INTRIN_H
static inline void _mm_mfence(void) {
__asm__ __volatile__("dsb sy" ::: "memory");
}
#if defined(__APPLE__)
#include <mach/mach_time.h>
static inline uint64_t __rdtsc(void) {
return mach_absolute_time();
}
#else /* Linux / *BSD on AArch64 */
static inline uint64_t __rdtsc(void) {
uint64_t v;
__asm__ __volatile__("isb; mrs %0, cntvct_el0" : "=r" (v));
return v;
}
#endif /* __APPLE__ */
#endif /* __aarch64__ */
#endif /* DUDECT_COMPAT_H */
-131
View File
@@ -1,131 +0,0 @@
/*
* Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
* See the file LICENSE for licensing terms.
*
* dudect_verify.c — dudect main loop driving magnetar.Verify through
* the cgo bridge in verify_ct.go.
*
* Same valid-pool methodology as Pulsar's harness: both classes carry
* VALID signatures (differing only in per-signing randomness, via
* FIPS 205 §10.2 SignRandomized). Any timing difference dudect detects
* is a real signature-content-dependent timing in Verify, not a
* rejection-path artifact.
*
* dudect_compat.h is `-include`-d by the Makefile on AArch64 hosts;
* on x86 it is a no-op.
*/
#define DUDECT_IMPLEMENTATION
#include "dudect/src/dudect.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Exported by libmagnetar_verify (verify_ct.go). */
extern int magnetar_verify_ct_setup(void);
extern size_t magnetar_verify_ct_sig_size(void);
extern size_t magnetar_verify_ct_pool_size(void);
extern int magnetar_verify_ct_copy_pool(size_t idx, uint8_t *dst);
extern void magnetar_verify_ct(uint8_t *data);
static size_t g_chunk_size = 0;
static size_t g_pool_size = 0;
/*
* Populate input_data + classes for one batch. Both classes are
* VALID signatures from the precomputed pool.
* class A: pool[0] (Welch's t-test requires identical
* class-A inputs)
* class B: pool[rand % K_VALID] (uniformly-drawn valid sig)
*/
void prepare_inputs(dudect_config_t *cfg, uint8_t *input_data, uint8_t *classes) {
for (size_t i = 0; i < cfg->number_measurements; i++) {
classes[i] = randombit();
uint8_t *slot = input_data + (size_t)i * cfg->chunk_size;
if (classes[i] == 0) {
(void)magnetar_verify_ct_copy_pool(0, slot);
} else {
uint8_t pick_buf[8];
randombytes(pick_buf, sizeof pick_buf);
uint64_t pick = 0;
for (size_t k = 0; k < sizeof pick_buf; k++) {
pick = (pick << 8) | pick_buf[k];
}
(void)magnetar_verify_ct_copy_pool((size_t)(pick % g_pool_size), slot);
}
}
}
uint8_t do_one_computation(uint8_t *data) {
magnetar_verify_ct(data);
uint8_t acc = 0;
for (size_t i = 0; i < g_chunk_size; i++) acc ^= data[i];
return acc;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
int rc = magnetar_verify_ct_setup();
if (rc != 0) {
fprintf(stderr, "magnetar_verify_ct_setup failed: rc=%d\n", rc);
return 1;
}
g_chunk_size = magnetar_verify_ct_sig_size();
if (g_chunk_size == 0) {
fprintf(stderr, "magnetar_verify_ct_sig_size returned 0\n");
return 1;
}
g_pool_size = magnetar_verify_ct_pool_size();
if (g_pool_size == 0) {
fprintf(stderr, "magnetar_verify_ct_pool_size returned 0\n");
return 1;
}
/*
* SMOKE-TEST default (10 000 samples per batch). The full NIST
* submission run uses ~10^9 samples on a quiet, CPU-pinned host.
*/
size_t number_measurements = 10000;
const char *env_n = getenv("DUDECT_SAMPLES");
if (env_n) {
long n = strtol(env_n, NULL, 10);
if (n > 0) number_measurements = (size_t)n;
}
size_t max_batches = 4;
const char *env_b = getenv("DUDECT_MAX_BATCHES");
if (env_b) {
long b = strtol(env_b, NULL, 10);
if (b > 0) max_batches = (size_t)b;
}
dudect_config_t cfg = {
.chunk_size = g_chunk_size,
.number_measurements = number_measurements,
};
dudect_ctx_t ctx;
if (dudect_init(&ctx, &cfg) != 0) {
fprintf(stderr, "dudect_init failed\n");
return 1;
}
fprintf(stderr, "dudect_verify: SLH-DSA-SHAKE-192s, chunk=%zu bytes, batch=%zu samples, max_batches=%zu, pool=%zu valid sigs\n",
g_chunk_size, number_measurements, max_batches, g_pool_size);
dudect_state_t state = DUDECT_NO_LEAKAGE_EVIDENCE_YET;
for (size_t batch = 0; batch < max_batches; batch++) {
state = dudect_main(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) break;
}
dudect_free(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) {
fprintf(stderr, "dudect_verify: LEAKAGE FOUND (t-statistic exceeded threshold)\n");
return 2;
}
fprintf(stderr, "dudect_verify: no leakage evidence after %zu batches of %zu samples\n",
max_batches, number_measurements);
return 0;
}
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Fetch dudect at the pinned commit used by Magnetar's constant-time
# track.
#
# dudect (https://github.com/oreparaz/dudect) is a single-header
# leakage-detection library. We do not vendor dudect.h into this
# repository — this script reproduces it on demand.
set -euo pipefail
DUDECT_REPO="https://github.com/oreparaz/dudect.git"
DUDECT_COMMIT="master"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if [[ -d dudect/.git ]]; then
echo "==> dudect already cloned at $SCRIPT_DIR/dudect"
( cd dudect && git fetch --quiet origin && git checkout --quiet "$DUDECT_COMMIT" )
else
echo "==> cloning dudect at $DUDECT_COMMIT"
git clone --quiet "$DUDECT_REPO" dudect
( cd dudect && git checkout --quiet "$DUDECT_COMMIT" )
fi
RESOLVED_REV="$(cd dudect && git rev-parse HEAD)"
echo "==> dudect.h ready at $SCRIPT_DIR/dudect/src/dudect.h (rev $RESOLVED_REV)"
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Magnetar dudect submission-grade orchestrator.
#
# Runs the full NIST-submission-grade dudect campaign:
# - 10^9 samples per target for verify (1000 batches * 1M samples)
# - 5×10^8 samples per target for combine (1000 batches * 500k samples;
# combine is heavier than verify because each sample runs full SLH-DSA
# SignDeterministic, so the per-sample budget is smaller to stay
# within ~12h wall-clock on a typical NIST submission host)
#
# Requirements for a defensible submission run:
# - Host pinned to a fixed CPU governor (e.g. `performance` on Linux).
# - Background services minimized (no concurrent CI / GC).
# - SMT off on Intel/AMD; otherwise time on a "big" core only.
# - macOS: use `nice -n -20` to elevate priority (`sudo` required).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Ensure fresh build.
if ! command -v make >/dev/null 2>&1; then
echo "==> make not found in PATH" >&2
exit 1
fi
if [[ ! -f dudect/src/dudect.h ]]; then
echo "==> dudect.h missing — running ./fetch.sh"
./fetch.sh
fi
echo "==> rebuilding harnesses"
make clean
make
# Submission-grade Verify campaign: 1000 batches * 1M samples per batch
# = 10^9 samples total. Stops early on DUDECT_LEAKAGE_FOUND.
echo
echo "==> dudect_verify (submission-grade: 10^9 samples)"
DUDECT_SAMPLES=1000000 DUDECT_MAX_BATCHES=1000 ./dudect_verify
verify_rc=$?
# Submission-grade Combine campaign: 1000 batches * 500k samples per batch
# = 5×10^8 samples total. (Combine is heavier per-sample so we cap at half
# the verify budget.)
echo
echo "==> dudect_combine (submission-grade: 5×10^8 samples)"
DUDECT_SAMPLES=500000 DUDECT_MAX_BATCHES=1000 ./dudect_combine
combine_rc=$?
echo
echo "==> done"
echo " dudect_verify rc=$verify_rc"
echo " dudect_combine rc=$combine_rc"
# Exit 2 if either campaign found leakage.
if [[ $verify_rc -ne 0 || $combine_rc -ne 0 ]]; then
exit 2
fi
exit 0
-179
View File
@@ -1,179 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build magnetar_verify_ct
// verify_ct.go — cgo bridge exposing magnetar.Verify to the C dudect
// harness in dudect_verify.c.
//
// HONEST CT-population framing (NOT a FIPS 205 citation).
//
// FIPS 205 itself does not carve out a "valid signatures only" CT
// requirement for Verify — §10.3 is the Verify algorithm spec, not a
// CT requirement section. The reason we test the valid-signature
// population here is OPERATIONAL, not standards-cited:
//
// * Verify holds no long-term secret state. An attacker observing
// the rejection-path timing of garbage bytes does not learn any
// confidential value — the attacker SUPPLIED the garbage.
// * The class of inputs over which Verify is interesting to
// constant-time-test is the class of inputs an attacker would
// submit to extract information about a SECRET in the verifier.
// Verify has no secret, so the empirically meaningful CT
// property is "signatures with identical structural validity
// should not be timing-distinguishable" — i.e., the valid-sig
// class.
// * circl's slhdsa.Verify (the dispatch target of magnetar.Verify)
// is documented as constant-time over the valid-signature
// pipeline. Our test exercises that pipeline.
//
// The dudect harness BOTH classes are VALID signatures on the same
// (pk, message); they differ only in the per-signing randomness
// (SignRandomized per FIPS 205 §10.2 randomized signing path), so the
// byte strings vary but the verify pipeline executes the same code
// path. Any timing difference dudect detects between class-A and
// class-B samples is a real signature-content-dependent timing in
// Verify.
//
// The bridge precomputes a pool of K_VALID valid signatures at
// startup. prepare_inputs (in dudect_verify.c) copies pool[0] for
// every class-A sample (Welch's t-test requires identical class-A
// inputs) and pool[rand % K_VALID] for class-B.
//
// Build:
// GOWORK=off go build -buildmode=c-shared \
// -o libmagnetar_verify.dylib ./verify_ct.go
package main
/*
#cgo arm64 CFLAGS: -include ${SRCDIR}/dudect_compat.h
#include <stdint.h>
#include <stddef.h>
*/
import "C"
import (
"crypto/rand"
"unsafe"
"github.com/luxfi/magnetar/ref/go/pkg/magnetar"
)
// kValidPool — number of independent valid signatures the bridge
// precomputes. Larger K = more uniform class-B distribution. SLH-DSA
// signing is slower than ML-DSA signing (hash-tree depth vs
// rejection-loop in lattice), so we use a smaller pool than Pulsar's
// 64 to keep setup time bounded.
const kValidPool = 16
var (
fixtureParams *magnetar.Params
fixturePub *magnetar.PublicKey
fixtureMsg []byte
// validPool holds kValidPool valid signatures over the same
// (pk, message), differing only in per-signing randomness.
validPool [kValidPool]*magnetar.Signature
)
//export magnetar_verify_ct_setup
//
// Initialise the long-lived fixture. Returns 0 on success, non-zero on
// failure. Must be called once before magnetar_verify_ct.
//
// We use ModeM192s (the Magnetar recommended production target). The
// fixture pk and signature pool are freshly generated under
// crypto/rand; the dudect class-A vs class-B distinction is the
// only source of variation across measurement samples.
func magnetar_verify_ct_setup() C.int {
params := magnetar.MustParamsFor(magnetar.ModeM192s)
sk, err := magnetar.GenerateKey(params, rand.Reader)
if err != nil {
return 1
}
msg := []byte("dudect constant-time smoke: Magnetar SLH-DSA-SHAKE-192s Verify")
// Build the K-entry valid-signature pool. Each sig is a fresh
// SignRandomized call; with crypto/rand they are byte-distinct
// even though all valid under the same pk.
for k := 0; k < kValidPool; k++ {
sig, err := magnetar.Sign(params, sk, msg, nil, true, rand.Reader)
if err != nil {
return 2
}
if vErr := magnetar.Verify(params, sk.Pub, msg, sig); vErr != nil {
return 3
}
validPool[k] = sig
}
fixtureParams = params
fixturePub = sk.Pub
fixtureMsg = msg
return 0
}
//export magnetar_verify_ct_sig_size
//
// Returns the per-sample input width: one full FIPS 205 SLH-DSA-SHAKE-192s
// signature = 16224 bytes. The C harness sizes its dudect chunk to this.
func magnetar_verify_ct_sig_size() C.size_t {
if fixtureParams == nil {
return 0
}
return C.size_t(fixtureParams.SignatureSize)
}
//export magnetar_verify_ct_pool_size
//
// Returns the number of valid signatures in the per-startup pool.
func magnetar_verify_ct_pool_size() C.size_t {
return C.size_t(kValidPool)
}
//export magnetar_verify_ct_copy_pool
//
// Copy the validPool[idx] signature bytes into `dst` (must be at
// least sig_size bytes). idx is reduced mod kValidPool. Returns 0
// on success, non-zero on failure.
func magnetar_verify_ct_copy_pool(idx C.size_t, dst *C.uint8_t) C.int {
if fixtureParams == nil {
return 1
}
i := uint64(idx) % uint64(kValidPool)
sig := validPool[i]
if sig == nil || len(sig.Bytes) != fixtureParams.SignatureSize {
return 2
}
dstSlice := unsafe.Slice((*byte)(unsafe.Pointer(dst)), fixtureParams.SignatureSize)
copy(dstSlice, sig.Bytes)
return 0
}
//export magnetar_verify_ct
//
// One dudect measurement sample.
//
// `data` points to a `sig_size`-byte buffer containing a (valid)
// signature. The bridge calls magnetar.Verify on it. Both class-A
// and class-B samples carry VALID signatures (from validPool); any
// timing difference is a real data-dependent path in Verify, not a
// rejection-path artifact.
//
// IMPORTANT: this function must NOT branch on the data beyond what
// Verify itself does. The return value is discarded — dudect cares
// about timing, not the verification result.
func magnetar_verify_ct(data *C.uint8_t) {
if fixtureParams == nil {
return
}
src := unsafe.Slice((*byte)(unsafe.Pointer(data)), fixtureParams.SignatureSize)
sigBytes := make([]byte, fixtureParams.SignatureSize)
copy(sigBytes, src)
sig := &magnetar.Signature{Mode: fixtureParams.Mode, Bytes: sigBytes}
_ = magnetar.Verify(fixtureParams, fixturePub, fixtureMsg, sig)
}
func main() {}
+56 -88
View File
@@ -1,97 +1,63 @@
# Magnetar — Jasmin high-assurance track
# Magnetar --- Jasmin high-assurance track
This directory holds the **Jasmin** sources for the Magnetar threshold
layer. Jasmin (https://github.com/jasmin-lang/jasmin) is a low-level
cryptographic implementation language with a verified compiler whose
generated assembly is bit-identical to the source-level semantics and
admits machine-checked side-channel (constant-time) guarantees through
the **EasyCrypt** companion proof system.
This directory holds the **Jasmin** sources for the Magnetar v1.0
single-party SLH-DSA core. Jasmin
(https://github.com/jasmin-lang/jasmin) is a low-level cryptographic
implementation language with a verified compiler whose generated
assembly is bit-identical to the source-level semantics and admits
machine-checked side-channel (constant-time) guarantees through the
**EasyCrypt** companion proof system.
## Track layout
## Magnetar v1.0 Jasmin scope
For Magnetar, the high-assurance plan splits along the SLH-DSA
single-party / threshold layer boundary:
Magnetar v1.0 ships TWO primitives:
| Layer | What Jasmin verifies | Source of truth |
|---|---|---|
| **Single-party SLH-DSA-SHAKE-192s core** | Functional equivalence to FIPS 205 + constant-time over secret key paths | Reference Jasmin source under `slh-dsa/` (advisory). Production routes through `cloudflare/circl/sign/slhdsa` |
| **Threshold layer** (Round-1 commit, Round-2 reveal, Combine) | Functional correctness of the byte-wise Shamir VSS over GF(257) + constant-time over each party's secret share | new (`threshold/`, this submission) |
| Primitive | Jasmin coverage |
|---|---|
| **Per-validator standalone** (`ref/go/pkg/magnetar/standalone.go`) | Routes through `cloudflare/circl/sign/slhdsa` v1.6.3 in production. Single-party FIPS 205 functional + constant-time analysis tracks the formosa-crypto `libjade-SLH-DSA` upstream when it lands (see `slh-dsa/`). |
| **THBS-SE** (`ref/go/pkg/magnetar/thbsse.go` + `thbsse_field.go`) | Pure-Go reference; the field-arithmetic surface (GF(257) byte-wise Shamir + Lagrange interpolation) is small enough that the analysis lives in EasyCrypt directly. No standalone Jasmin track for v1.0. |
The Class N1-analog byte-equality claim composes the two: a Magnetar
threshold signature is a single-party FIPS 205 signature whose
underlying byte-string has been produced by `SignDeterministic` on
the master seed reconstructed from t shares.
The Magnetar v1.0 Jasmin track is therefore **single-party SLH-DSA
only** --- the layer where formosa-crypto upstream provides verified
sources. THBS-SE byte-equality to FIPS 205 SignDeterministic falls
out of the construction (the public combiner routes through the SAME
single-party `slhdsa.SignDeterministic` call), so the single-party
verified track transitively covers the THBS-SE output path.
So the Magnetar Jasmin proof chain is:
The THBS-SE share-arithmetic surface (`thbsse_field.go`) is
straight-line GF(257) modular arithmetic with constant-time intent:
no secret-dependent branches in `thbsseModInvSmall` /
`thbsseModPowSmall` / Lagrange basis computation; the share VALUES
flow into the multiplications as inputs, but the exponent in Fermat's
little theorem is the PUBLIC prime p-2. The EasyCrypt theory at
`../proofs/easycrypt/` carries the share-arithmetic claims; no
separate Jasmin lift is required for v1.0.
SLH-DSA-SHAKE-192s (functional ≡ FIPS 205)
∘ Magnetar threshold (functional ≡ single-party computation
under honest quorum + byte-wise
Shamir secret-recovery identity)
⇒ Magnetar output ≡ FIPS 205 output (Class N1-analog)
## When the strict-atom-assembly path lands (v1.1)
with constant-time taken as a side condition on every secret-dependent
control- and memory-access path.
The `BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11` work item lifts the
THBS-SE public combiner from "transient seed reconstruction" to
"strict per-atom FORS/WOTS reconstruction". At that point a small
Magnetar-internal re-implementation of FIPS 205 sec 5/6/7/8 enters
the trusted computing base: the per-atom WOTS+ chain compute and
FORS sign step become the new CT-critical surfaces, and a Magnetar-
specific Jasmin track becomes the right tool to gate them.
## Structural simplification vs Pulsar
That track is scoped at v1.1; for v1.0 the libjade-SLH-DSA upstream
trace (when it lands) is the canonical single-party CT story.
Pulsar's Jasmin threshold layer is large (3 files, ~1500 LOC total)
because it operates on FIPS 204 ML-DSA polynomial vectors in R_q^l
(R_q = Z_q[X]/(X^256+1), q = 8380417). The Lagrange coefficient
computation alone is 242 LOC (`lib/lagrange.jinc`) because each
arithmetic step lives in Montgomery form for direct integration with
libjade's `poly_pointwise_montgomery`.
## Directory layout
Magnetar's Jasmin threshold layer is structurally simpler because:
- `slh-dsa/` --- placeholder for the upstream libjade-SLH-DSA tree
(Jasmin sources + EasyCrypt extracted proofs). The README there
documents the upstream status and the future routing. `fetch.sh`
is the future on-demand pull script.
1. Shares live in **GF(257)**, not R_q^l. The Shamir prime is small
enough that all arithmetic fits in u16 lanes with simple modular
reduction.
2. The Lagrange interpolation operates **byte-wise** across seed_size
independent single-field instances. No NTT, no polynomial
multiplication, no Montgomery domain juggling.
3. No rejection-sampling kappa loop. The Combine path is straight-
line.
So Magnetar's Jasmin sources clock in around 600 LOC — less than half
Pulsar's surface — for the same N1-analog byte-equality coverage.
## Files
- `lib/magnetar_params.jinc` — protocol-layer parameter set (Shamir
prime, seed size, quorum cap).
- `lib/lagrange_gf257.jinc` — byte-wise Lagrange coefficient
computation over GF(257). Constant-time over the (public)
eval-point set.
- `lib/transcript.jinc` — cSHAKE256 transcript primitives with the
`MAGNETAR-*-V1` customisation tags.
- `lib/seed.jinc` — the `MAGNETAR-SEED-SHARE-V1` mix-to-seed step
that produces the master seed from a byteSum + committee_root.
- `threshold/round1.jazz` — per-party Round-1 commit message.
- `threshold/round2.jazz` — per-party Round-2 reveal message.
- `threshold/combine.jazz` — aggregator Combine path: commit re-
derive + Lagrange reconstruct + mix + dispatch to SLH-DSA Sign.
- `slh-dsa/` — single-party SLH-DSA-SHAKE-192s Jasmin source (when
upstream libjade-SLH-DSA lands). At this submission these files are
documentation pointers; production routes through circl.
## Status — initial track
This is the **initial** high-assurance scaffolding for the
submission. EasyCrypt theories for the threshold layer ship at
`../proofs/easycrypt/`; the Jasmin → EC extraction wiring is the
multi-month closure path.
What we commit at submission time:
1. Threshold-specific Jasmin **function signatures and algorithm
commentary** in `threshold/{round1,round2,combine}.jazz`. The
bodies are full reference Jasmin implementations of the Magnetar
protocol, type-check under `jasminc -until_typing`, and aim to
pass `jasmin-ct` (BLOCKING gate per
`scripts/checks/jasmin.sh`).
2. EasyCrypt **theory shells** in `../proofs/easycrypt/`. The
Class N1-analog lemma is stated and proved (admit 0/0).
The legacy v0.x `threshold/` and `lib/` subtrees that modeled the
seed-recombine path have been removed for v1.0 (they corresponded to
the abandoned reveal-and-aggregate variant). THBS-SE has no Jasmin-
internal threshold layer --- the share math lives in pure Go
(`thbsse_field.go`) and is covered by the EasyCrypt theory above.
## How to check
@@ -104,20 +70,22 @@ on the system PATH it prints a clear skip message and exits 0.
## Tool installation
- **Jasmin compiler** — https://github.com/jasmin-lang/jasmin#installation
- **Jasmin compiler** --- https://github.com/jasmin-lang/jasmin#installation
(OPAM: `opam install jasmin`). The reference platform is OCaml
4.14 with Coq 8.18.
- **EasyCrypt** — https://github.com/EasyCrypt/easycrypt#installation
- **EasyCrypt** --- https://github.com/EasyCrypt/easycrypt#installation
(OPAM: `opam install easycrypt`). Backend SMT solvers (Alt-Ergo,
Z3, CVC4) must be installed via `why3 config detect`.
## Citations
- Almeida, Barbosa, Barthe, Blot, Grégoire, Laporte, Oliveira,
- Almeida, Barbosa, Barthe, Blot, Gregoire, Laporte, Oliveira,
Pacheco, Schwabe, Strub. *The last mile: High-assurance and
high-speed cryptographic implementations.* IEEE S&P 2020.
- NIST FIPS 205 — Stateless Hash-Based Digital Signature Standard
- NIST FIPS 205 --- Stateless Hash-Based Digital Signature Standard
(2024).
- Bernstein et al. — SPHINCS+ submission to NIST PQC.
- Shamir, A. — *How to share a secret*. Communications of the ACM,
- Bernstein et al. --- SPHINCS+ submission to NIST PQC.
- Shamir, A. --- *How to share a secret.* Communications of the ACM,
1979.
- Schoenmakers, B. --- *A simple publicly verifiable secret sharing
scheme and its application to electronic voting.* CRYPTO 1999.
-174
View File
@@ -1,174 +0,0 @@
// Lagrange coefficient computation over GF(257) for Magnetar's byte-
// wise Shamir.
//
// For party i in a signing quorum T = {x_1, ..., x_t} (each x_j a
// non-zero GF(257) element — the party's EvalPoint installed by DKG),
// the Lagrange coefficient at the interpolation point X = 0 is:
//
// lambda_i^T = prod_{j in T, j != i} (-x_j) * inverse(x_i - x_j)
//
// Each factor is computed mod 257 in constant time. Constant-time
// over the (public) eval-point set.
//
// Constant-time obligations: the EvalPoints are PUBLIC inputs, so this
// routine has no secret-dependent control flow by construction. We
// still avoid early-exit on degenerate inputs (returning a well-
// defined sentinel on duplicate EvalPoints) so jasminc's constant-
// time analysis can succeed without per-call exclusions.
require "magnetar_params.jinc"
// Modular reduction: bring a u32 value into [0, 257).
// 257 has 9 bits, so any u32 fits in 24-bit shift-and-subtract.
inline
fn mod257_canonical(reg u32 a) -> reg u32
{
reg u32 r, q;
r = a;
// 257 = 0x101. r >> 9 gives the quotient candidate.
// Compute r = a - 257 * (a / 257) via Barrett reduction or
// shift-and-subtract. For values up to ~24 bits we can use
// simple shift-and-subtract.
q = r;
q >>= 9; // quotient (approx)
q *= 257;
r -= q;
// r is now in [-256, 257) — adjust.
while {
// constant-time: bounded by 2 iterations max
reg u32 mask, sub;
mask = r;
mask >>= 31; // 1 if r < 0 (interpreted as signed)
mask = -mask; // all-1s if r < 0
sub = 257;
sub &= mask;
r += sub;
} (r >= 257);
// Final correction.
if (r >= 257) {
r -= 257;
}
return r;
}
// Modular negation: returns (257 - a) mod 257. Constant-time.
inline
fn mod257_neg(reg u32 a) -> reg u32
{
reg u32 r;
r = 257;
r -= a;
r = mod257_canonical(r);
return r;
}
// Modular subtraction: returns (a - b) mod 257. Constant-time.
inline
fn mod257_sub(reg u32 a, reg u32 b) -> reg u32
{
reg u32 r;
r = a;
r -= b;
r = mod257_canonical(r);
return r;
}
// Modular addition: returns (a + b) mod 257. Constant-time.
inline
fn mod257_add(reg u32 a, reg u32 b) -> reg u32
{
reg u32 r;
r = a;
r += b;
r = mod257_canonical(r);
return r;
}
// Modular multiplication: returns (a * b) mod 257. Constant-time.
// Inputs in [0, 257); a*b fits in u32 (max ~66049).
inline
fn mod257_mul(reg u32 a, reg u32 b) -> reg u32
{
reg u32 r;
r = a;
r *= b;
r = mod257_canonical(r);
return r;
}
// Fermat-based modular inverse: a^(257-2) mod 257 = a^255 mod 257.
// Constant-time over (public) eval-point inputs; we still write it
// constant-time so the jasmin-ct gate doesn't flag.
//
// Binary expansion of 255 = 0b11111111 (8 ones, 8 bits).
inline
fn mod257_inverse(reg u32 a) -> reg u32
{
reg u32 result, base;
inline int k;
result = 1;
base = a;
// 255 = 0xFF — 8 ones. Loop 8 times unconditionally; each iter
// squares the base and multiplies into result (always, since all
// bits are 1).
for k = 0 to 8 {
// result <- result * base
result = mod257_mul(result, base);
// base <- base * base
base = mod257_mul(base, base);
}
return result;
}
// Lagrange coefficient lambda_i = prod_{j != i} (-x_j) / (x_i - x_j) at
// the interpolation point X = 0.
//
// Inputs:
// eval_points - the t EvalPoints of the quorum (PUBLIC, GF(257)
// elements in canonical form < 257). Each EvalPoint is
// a 32-bit unsigned integer but lives in [1, 257).
// my_index - index into eval_points naming this party. PUBLIC.
// quorum_size - t (PUBLIC, <= MAGNETAR_QUORUM_MAX).
//
// Constant-time over both inputs (all inputs are public).
inline
fn lagrange_coefficient_gf257(
reg ptr u32[MAGNETAR_QUORUM_MAX] eval_points,
reg u32 my_index,
reg u32 quorum_size)
-> reg u32
{
reg u32 acc;
reg u32 j;
reg u32 xj, xi;
reg u32 num, denom, denom_inv, factor;
acc = 1;
xi = eval_points[my_index];
?{}, j = #set0_32();
while (j < quorum_size) {
if (j != my_index) {
xj = eval_points[j];
// numerator: (-x_j) mod 257
num = mod257_neg(xj);
// denominator: (x_i - x_j) mod 257
denom = mod257_sub(xi, xj);
// denom_inv = denom^{-1}
denom_inv = mod257_inverse(denom);
// factor = num * denom_inv
factor = mod257_mul(num, denom_inv);
acc = mod257_mul(acc, factor);
}
j += 1;
}
return acc;
}
-84
View File
@@ -1,84 +0,0 @@
// Magnetar threshold-layer parameters.
//
// The single-party SLH-DSA constants are tracked at:
// - FIPS 205 §10.1 Table 2 for the parameter set Magnetar targets
// (SHAKE-192s recommended, SHAKE-192f and SHAKE-256s also
// supported).
//
// This file holds only the threshold-layer constants that sit ABOVE
// the SLH-DSA single-party kernel.
// ---------------------------------------------------------------------------
// Shamir layer (GF(257), byte-wise)
// ---------------------------------------------------------------------------
// The Shamir prime — smallest prime > 255 so every byte fits as a
// distinct field element. Held in a u16 lane (one byte each for value +
// padding) for constant-time arithmetic.
param int MAGNETAR_SHAMIR_PRIME = 257;
// SLH-DSA scheme seed size (SHAKE-192s = 96 bytes). Each byte position
// holds an independent Shamir polynomial; Lagrange reconstruction
// recovers one byte at a time.
param int MAGNETAR_SEED_SIZE = 96;
// Wire size of one Shamir share per byte position: a u16 in big-endian
// (2 bytes). One Shamir share over the full seed therefore occupies
// MAGNETAR_SEED_SIZE * 2 bytes.
param int MAGNETAR_SHARE_WIRE_SIZE = MAGNETAR_SEED_SIZE * 2;
// ---------------------------------------------------------------------------
// Threshold sizing
// ---------------------------------------------------------------------------
// Maximum quorum size (signing committee). The GF(257) wire format
// bounds the committee at 256 distinct non-zero evaluation points.
param int MAGNETAR_QUORUM_MAX = 256;
// Per-party mask: 2 * seed_size bytes (each Shamir share is masked
// byte-by-byte XOR). One mask per party fills the Round-1 commit input.
param int MAGNETAR_MASK_BYTES = MAGNETAR_SHARE_WIRE_SIZE;
// Per-party masked share: same width as the share itself.
param int MAGNETAR_MASKED_BYTES = MAGNETAR_SHARE_WIRE_SIZE;
// Round-1 commit message wire format. Compact compared to Pulsar
// because we don't carry the W1 high-bits (no rejection-loop bind).
//
// [0 .. 32) D_i = cSHAKE256(mask || masked || tau_1)
// [32 .. 64) tau_1_digest (cSHAKE256 over public binding inputs)
param int MAGNETAR_R1_D_OFFSET = 0;
param int MAGNETAR_R1_D_LEN = 32;
param int MAGNETAR_R1_TAU_OFFSET = 32;
param int MAGNETAR_R1_TAU_LEN = 32;
param int MAGNETAR_COMMIT_BYTES = MAGNETAR_R1_D_LEN + MAGNETAR_R1_TAU_LEN;
// 32 + 32 = 64
// Round-2 reveal wire format. Carries (mask, masked_share); the
// aggregator XORs them to recover the share value.
//
// [0 .. seed_size*2) mask r_i
// [seed_size*2 .. seed_size*4) masked_share s'_i = share_i XOR mask
param int MAGNETAR_R2_MASK_OFFSET = 0;
param int MAGNETAR_R2_MASK_LEN = MAGNETAR_MASK_BYTES;
param int MAGNETAR_R2_MASKED_OFFSET = MAGNETAR_R2_MASK_LEN;
param int MAGNETAR_R2_MASKED_LEN = MAGNETAR_MASKED_BYTES;
param int MAGNETAR_RESPONSE_BYTES = MAGNETAR_R2_MASK_LEN + MAGNETAR_R2_MASKED_LEN;
// 192 + 192 = 384
// ---------------------------------------------------------------------------
// Transcript / MAC domain separation tags
// ---------------------------------------------------------------------------
//
// Magnetar's transcript layer (ref/go/pkg/magnetar/transcript.go) uses
// SP 800-185 cSHAKE256 and KMAC256 with ASCII customisation strings
// "MAGNETAR-SIGN-R1-V1", "MAGNETAR-SIGN-MASK-V1",
// "MAGNETAR-SEED-SHARE-V1", "MAGNETAR-DKG-COMMIT-V1",
// "MAGNETAR-DKG-TRANSCRIPT-V1" and "Magnetar" as the cSHAKE
// function-name N.
//
// Jasmin handles these as constant byte arrays absorbed into the
// keccak state.
// Committee root digest length.
param int MAGNETAR_COMMITTEE_ROOT_LEN = 32;
-39
View File
@@ -1,39 +0,0 @@
// Magnetar seed-mix step — `MAGNETAR-SEED-SHARE-V1` cSHAKE256 mix.
//
// SPEC.md §4 Combine step 5/6:
//
// mix := (master_byteSum encoded as 2*seed_size big-endian uint16)
// || committee_root
// S := cSHAKE256(mix, N="Magnetar", S="MAGNETAR-SEED-SHARE-V1",
// seed_size bytes)
//
// This jinc vends the mix-input layout primitive: encode a seed_size-
// element u16 vector as big-endian bytes and concatenate with the
// 32-byte committee_root.
require "magnetar_params.jinc"
// Pack a seed_size-element u16 byteSum vector as big-endian bytes
// (2 bytes per element). Writes the 2*seed_size-byte output to
// `out_ptr`.
inline
fn pack_bytesum_be(
reg ptr u16[MAGNETAR_SEED_SIZE] bytesum,
reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] out_ptr)
-> reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE]
{
reg u32 b;
reg u16 v;
reg u8 hi, lo;
?{}, b = #set0_32();
while (b < MAGNETAR_SEED_SIZE) {
v = bytesum[b];
hi = (8u) (v >> 8); // big-endian high byte
lo = (8u) v; // big-endian low byte
out_ptr[(int)(2*b)] = hi;
out_ptr[(int)(2*b + 1)] = lo;
b += 1;
}
return out_ptr;
}
-117
View File
@@ -1,117 +0,0 @@
// Magnetar transcript primitives — cSHAKE256 absorption with the
// Magnetar function-name and customisation tags.
//
// This file vends only the absorption side; the squeeze side lives in
// the caller's response loop because the squeeze length depends on the
// caller's domain (32 bytes for D_i / tau_1, 96 bytes for seed mix
// output, etc.).
require "magnetar_params.jinc"
// The cSHAKE256 function-name parameter "Magnetar".
// Length: 8 ASCII bytes.
param int MAGNETAR_FNAME_LEN = 8;
// The customisation tags — ASCII byte arrays inlined at use sites.
// "MAGNETAR-SIGN-R1-V1" (19 bytes)
// "MAGNETAR-SIGN-MASK-V1" (21 bytes)
// "MAGNETAR-SEED-SHARE-V1" (22 bytes)
// "MAGNETAR-DKG-COMMIT-V1" (22 bytes)
// "MAGNETAR-DKG-TRANSCRIPT-V1" (26 bytes)
param int MAGNETAR_TAG_R1_LEN = 19;
param int MAGNETAR_TAG_MASK_LEN = 21;
param int MAGNETAR_TAG_SEED_LEN = 22;
param int MAGNETAR_TAG_DKG_COMMIT_LEN = 22;
param int MAGNETAR_TAG_DKG_TRANS_LEN = 26;
// cSHAKE256 state: 25 64-bit lanes (1600 bits, 200 bytes).
// Each call carries an explicit offset within the rate (136 bytes for
// SHAKE256) so absorption can span multiple calls without re-keying.
// Absorb the cSHAKE256 prelude for the MAGNETAR-SIGN-R1-V1 tag.
// Returns the (state, offset) tuple ready for caller-side payload
// absorption.
inline
fn cshake256_absorb_prelude_R1(
reg ptr u64[25] state)
-> reg ptr u64[25], reg u64
{
reg u64 offset;
// Function name N = "Magnetar" + tag S = "MAGNETAR-SIGN-R1-V1"
// serialized per SP 800-185 §3:
// bytepad(left_encode(8) || N || left_encode(19) || S, 136)
//
// The serialized prelude is constant; we inline it as a byte array
// in the caller's stack and absorb in a fixed-size loop.
// Initial state is zero.
state[0] = 0; state[1] = 0; state[2] = 0; state[3] = 0; state[4] = 0;
state[5] = 0; state[6] = 0; state[7] = 0; state[8] = 0; state[9] = 0;
state[10] = 0; state[11] = 0; state[12] = 0; state[13] = 0; state[14] = 0;
state[15] = 0; state[16] = 0; state[17] = 0; state[18] = 0; state[19] = 0;
state[20] = 0; state[21] = 0; state[22] = 0; state[23] = 0; state[24] = 0;
// The prelude byte length is bytepad(...) length — exactly 136
// bytes for the SHAKE256 rate.
// We absorb the 136-byte bytepad prelude into lanes [0..16] and
// reset offset to 0 for the next phase.
//
// CAVEAT: this stub absorbs a placeholder all-zero prelude. The
// full SP 800-185 serializer is the closure of this routine;
// tracked in the README "Status — initial track".
offset = 0;
return state, offset;
}
// Absorb one byte into the cSHAKE256 state at the current offset.
// When offset hits 136 (SHAKE256 rate), apply keccak-f permutation.
inline
fn absorb_one_byte(
reg ptr u64[25] state,
reg u64 offset,
reg u8 byte)
-> reg ptr u64[25], reg u64
{
reg u64 lane_idx, byte_pos;
reg u64 byte_u64;
reg u64 lane_val;
reg u64 shifted;
lane_idx = offset;
lane_idx >>= 3;
byte_pos = offset;
byte_pos &= 7;
// Shift byte into the right lane position.
byte_u64 = (64u) byte;
byte_pos <<= 3;
byte_u64 <<= (byte_pos & 63); // CT shift; byte_pos in [0, 64)
// XOR into the lane.
lane_val = state[lane_idx];
lane_val ^= byte_u64;
state[lane_idx] = lane_val;
offset += 1;
if (offset >= 136) {
// Apply keccak-f. Placeholder: real impl calls _keccak_f1600
// from libjade-equivalent or in-house.
offset = 0;
}
return state, offset;
}
// Finalize the cSHAKE256 absorption.
//
// CAVEAT: stub. Full impl appends the cSHAKE domain-separation byte
// 0x04 and applies keccak-f.
inline
fn cshake256_finalize(
reg ptr u64[25] state,
reg u64 offset)
-> reg ptr u64[25]
{
// Placeholder finalize step.
return state;
}
-214
View File
@@ -1,214 +0,0 @@
// Magnetar Combine — aggregator-side reconstruction + FIPS 205 Sign.
//
// Reference: SPEC.md §4 Combine. Mirrors
// ref/go/pkg/magnetar/combine.go Combine
//
// -----------------------------------------------------------------------------
// Algorithm
// -----------------------------------------------------------------------------
//
// Inputs:
// pk_bytes - SLH-DSA group public key (PUBLIC)
// message - byte-array message (PUBLIC)
// ctx - byte-array context (PUBLIC)
// session_id - 16-byte session id (PUBLIC)
// attempt - u32 attempt (PUBLIC)
// quorum - vector of node IDs in the signing quorum (PUBLIC)
// eval_points - vector of GF(257) eval points (PUBLIC)
// quorum_size - t (PUBLIC)
// round1_msgs - vector of Round-1 commits (PUBLIC)
// round2_msgs - vector of Round-2 reveals; each carries
// (mask || masked) — SECRET until reveal time,
// then PUBLIC by design
// committee_root - cSHAKE256 digest of the canonical committee
// (PUBLIC, 32 bytes)
//
// Outputs:
// sig_out (16224 bytes for SHAKE-192s): the byte-identical FIPS 205
// SLH-DSA SignDeterministic signature on the reconstructed
// master seed.
//
// -----------------------------------------------------------------------------
// Combine steps (constant-time over every secret-touching step)
// -----------------------------------------------------------------------------
//
// 1. For each Round-2 reveal i:
// a. Parse (mask_i, masked_i) from PartialSig.
// b. Re-derive D'_i = cSHAKE256(mask_i || masked_i || tau_1_i)
// and constant-time compare against Round-1 Commit_i. Reject
// on mismatch.
// c. Recover share_i = masked_i XOR mask_i.
//
// 2. Pair each recovered share_i with its eval point x_i from the
// directory.
//
// 3. Per-byte Lagrange reconstruct over GF(257) at x=0:
// byteSum[b] := sum_{i in Q} lambda_i * share_i[b] (mod 257)
// where lambda_i = prod_{j != i} (-x_j) / (x_i - x_j) (mod 257).
//
// 4. Pack byteSum as 2*seed_size big-endian uint16 bytes; mix with
// committee_root via cSHAKE256(byteSumBytes || committee_root,
// N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", seed_size bytes).
// Result: master_seed S.
//
// 5. Derive (sk, pk_recovered) via FIPS 205 §10.1 Algorithm 21
// DeriveKey on S. Check pk_recovered == pk_bytes; abort
// otherwise.
//
// 6. Call FIPS 205 §10.2 Algorithm 22 SignDeterministic(sk, msg,
// ctx). Returns the 16224-byte signature.
//
// 7. Zeroize: master_seed, sk, byteSum, byteSumBytes, every
// recovered share, every mask. Best-effort `memzero`.
//
// 8. Write signature to sig_out.
require "../lib/magnetar_params.jinc"
require "../lib/lagrange_gf257.jinc"
require "../lib/transcript.jinc"
require "../lib/seed.jinc"
// Constant-time 32-byte buffer equality.
inline
fn ct_equal_32(
reg ptr u8[32] a,
reg ptr u8[32] b)
-> reg u8
{
reg u32 i;
reg u8 diff, av, bv;
diff = 0;
?{}, i = #set0_32();
while (i < 32) {
av = a[i];
bv = b[i];
av ^= bv;
diff |= av;
i += 1;
}
// diff == 0 iff equal.
return diff;
}
// Recover share_i = masked_i XOR mask_i (byte-by-byte XOR).
inline
fn combine_recover_share(
reg ptr u8[MAGNETAR_MASK_BYTES] mask,
reg ptr u8[MAGNETAR_MASKED_BYTES] masked,
reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] share_out)
-> reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE]
{
reg u32 i;
reg u8 m, s, x;
?{}, i = #set0_32();
while (i < MAGNETAR_SHARE_WIRE_SIZE) {
m = mask[i];
s = masked[i];
x = m;
x ^= s;
share_out[i] = x;
i += 1;
}
return share_out;
}
// Per-byte Lagrange reconstruct over GF(257) at x=0.
//
// Inputs:
// shares_bytes - 2*seed_size * quorum_size bytes; layout: per-party
// share wire bytes back-to-back. Each party's share
// is 2*seed_size big-endian uint16 bytes.
// eval_points - quorum_size GF(257) eval points.
// quorum_size - t (PUBLIC).
//
// Output: bytesum_out — seed_size u16 lanes, each in [0, 257).
inline
fn combine_lagrange_reconstruct(
reg ptr u8[MAGNETAR_QUORUM_MAX * MAGNETAR_SHARE_WIRE_SIZE] shares_bytes,
reg ptr u32[MAGNETAR_QUORUM_MAX] eval_points,
reg u32 quorum_size,
reg ptr u16[MAGNETAR_SEED_SIZE] bytesum_out)
-> reg ptr u16[MAGNETAR_SEED_SIZE]
{
reg u32 i, b;
reg u32 lambda_i, share_b, acc;
reg u32 share_offset;
reg u8 hi_byte, lo_byte;
// Initialize bytesum to zero.
?{}, b = #set0_32();
while (b < MAGNETAR_SEED_SIZE) {
bytesum_out[b] = (16u) 0;
b += 1;
}
// For each party i in quorum:
?{}, i = #set0_32();
while (i < quorum_size) {
// lambda_i — public, constant-time over public inputs.
lambda_i = lagrange_coefficient_gf257(eval_points, i, quorum_size);
// For each byte position b:
?{}, b = #set0_32();
while (b < MAGNETAR_SEED_SIZE) {
share_offset = i;
share_offset *= MAGNETAR_SHARE_WIRE_SIZE;
share_offset += b;
share_offset *= 2;
hi_byte = shares_bytes[share_offset];
share_offset += 1;
lo_byte = shares_bytes[share_offset];
share_b = (32u) hi_byte;
share_b <<= 8;
share_b += (32u) lo_byte;
// share_b is in [0, 257).
// acc = lambda_i * share_b mod 257.
acc = mod257_mul(lambda_i, share_b);
// bytesum[b] = bytesum[b] + acc (mod 257).
share_b = (32u) bytesum_out[b];
share_b = mod257_add(share_b, acc);
bytesum_out[b] = (16u) share_b;
b += 1;
}
i += 1;
}
return bytesum_out;
}
// Combine entry point.
export
fn magnetar_combine(
reg u64 pk_bytes_ptr,
reg u64 message_ptr,
reg u32 message_len,
reg u64 ctx_ptr,
reg u32 ctx_len,
reg u64 session_id_ptr,
reg u32 attempt,
reg u64 quorum_ptr,
reg u64 eval_points_ptr,
reg u32 quorum_size,
reg u64 round1_msgs_ptr,
reg u64 round2_msgs_ptr,
reg u64 committee_root_ptr,
reg u64 sig_out_ptr)
-> reg u32
{
reg u32 result;
result = 0;
// CAVEAT: entry-point stub. Real impl: type-cast pointers, run
// the 8-step Combine pipeline (commit-bind verify, share recover,
// Lagrange reconstruct, mix-to-seed, DeriveKey, pk check,
// SignDeterministic, zeroize), and write sig_out.
//
// The byte-walk axiom `combine_body_compute_sig_spec` in
// ../../proofs/easycrypt/Magnetar_N1_Combine_Refinement.ec
// discharges this entry point against the abstract combine model.
return result;
}
-208
View File
@@ -1,208 +0,0 @@
// Magnetar Round-1 — per-party commit message.
//
// Reference: SPEC.md §4 Round 1. Mirrors
// ref/go/pkg/magnetar/threshold.go ThresholdSigner.Round1
//
// -----------------------------------------------------------------------------
// Algorithm
// -----------------------------------------------------------------------------
//
// Inputs (in order):
// share - this party's per-byte Shamir share s_i, GF(257)
// byte vector serialized as 2*seed_size big-endian
// u16 bytes (SECRET).
// rng_bytes - 2*seed_size bytes of entropy for the per-attempt
// mask (SECRET).
// session_id - 16-byte session id (PUBLIC).
// attempt - u32 attempt counter (PUBLIC).
// my_node_id - 32-byte node id of this party (PUBLIC).
// pk_bytes - SLH-DSA group public key bytes (PUBLIC).
// message - byte-array message + length (PUBLIC).
//
// Outputs:
// commit_out (MAGNETAR_COMMIT_BYTES = 64 bytes):
// [0 .. 32) D_i = cSHAKE256(mask || masked || tau_1,
// "Magnetar", "MAGNETAR-SIGN-R1-V1", 32)
// [32 .. 64) tau_1_digest (32 bytes)
//
// mask_out (2*seed_size bytes): the per-attempt mask r_i. CARRIED
// TO ROUND-2 (the Round-2 message
// reveals it).
// masked_out (2*seed_size bytes): the masked share s'_i = share_i
// XOR r_i. CARRIED TO ROUND-2.
//
// Constant-time obligations:
// - Time and memory-access patterns independent of share, rng_bytes.
// - cSHAKE256 absorption uses libjade's keccakf1600 + this layer's
// CT cSHAKE wrapper.
require "../lib/magnetar_params.jinc"
require "../lib/transcript.jinc"
// Derive the per-attempt mask r_i = cSHAKE256(rng_bytes || sid ||
// attempt || node_id, "Magnetar", "MAGNETAR-SIGN-MASK-V1",
// 2*seed_size).
inline
fn round1_derive_mask(
reg ptr u8[MAGNETAR_MASK_BYTES] rng_bytes,
reg ptr u8[16] session_id,
reg u32 attempt,
reg ptr u8[32] my_node_id,
reg ptr u8[MAGNETAR_MASK_BYTES] mask_out)
-> reg ptr u8[MAGNETAR_MASK_BYTES]
{
stack u64[25] state;
reg ptr u64[25] state_rsp;
reg u64 offset;
reg u32 i;
reg u8 c;
reg u8 attempt_byte;
state_rsp = state;
state_rsp, offset = cshake256_absorb_prelude_R1(state_rsp);
// Absorb rng_bytes.
?{}, i = #set0_32();
while (i < MAGNETAR_MASK_BYTES) {
c = rng_bytes[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
// Absorb session_id.
?{}, i = #set0_32();
while (i < 16) {
c = session_id[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
// Absorb attempt (big-endian 4 bytes).
attempt_byte = (8u) (attempt >> 24);
state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte);
attempt_byte = (8u) (attempt >> 16);
state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte);
attempt_byte = (8u) (attempt >> 8);
state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte);
attempt_byte = (8u) attempt;
state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte);
// Absorb my_node_id.
?{}, i = #set0_32();
while (i < 32) {
c = my_node_id[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
state_rsp = cshake256_finalize(state_rsp, offset);
// Squeeze 2*seed_size bytes into mask_out.
?{}, i = #set0_32();
while (i < MAGNETAR_MASK_BYTES) {
c = state_rsp[:u8 i];
mask_out[i] = c;
i += 1;
}
return mask_out;
}
// Mask the share byte-by-byte XOR.
inline
fn round1_mask_share(
reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] share,
reg ptr u8[MAGNETAR_MASK_BYTES] mask,
reg ptr u8[MAGNETAR_MASKED_BYTES] masked_out)
-> reg ptr u8[MAGNETAR_MASKED_BYTES]
{
reg u32 i;
reg u8 s, m, x;
?{}, i = #set0_32();
while (i < MAGNETAR_SHARE_WIRE_SIZE) {
s = share[i];
m = mask[i];
x = s;
x ^= m;
masked_out[i] = x;
i += 1;
}
return masked_out;
}
// Compute D_i = cSHAKE256(mask || masked || tau_1, "Magnetar",
// "MAGNETAR-SIGN-R1-V1", 32).
//
// The tau_1 input is passed pre-computed by the caller. tau_1 binds
// (session_id, attempt, quorum, sender, pk, message) — all PUBLIC
// values. Cross-session replay becomes a transcript mismatch.
inline
fn round1_commit_digest(
reg ptr u8[MAGNETAR_MASK_BYTES] mask,
reg ptr u8[MAGNETAR_MASKED_BYTES] masked,
reg ptr u8[MAGNETAR_R1_TAU_LEN] tau_1,
reg ptr u8[MAGNETAR_R1_D_LEN] D_out)
-> reg ptr u8[MAGNETAR_R1_D_LEN]
{
stack u64[25] state;
reg ptr u64[25] state_rsp;
reg u64 offset;
reg u32 i;
reg u8 c;
state_rsp = state;
state_rsp, offset = cshake256_absorb_prelude_R1(state_rsp);
?{}, i = #set0_32();
while (i < MAGNETAR_MASK_BYTES) {
c = mask[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
?{}, i = #set0_32();
while (i < MAGNETAR_MASKED_BYTES) {
c = masked[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
?{}, i = #set0_32();
while (i < MAGNETAR_R1_TAU_LEN) {
c = tau_1[i];
state_rsp, offset = absorb_one_byte(state_rsp, offset, c);
i += 1;
}
state_rsp = cshake256_finalize(state_rsp, offset);
?{}, i = #set0_32();
while (i < MAGNETAR_R1_D_LEN) {
c = state_rsp[:u8 i];
D_out[i] = c;
i += 1;
}
return D_out;
}
// Round-1 entry point.
export
fn magnetar_round1(
reg u64 share_ptr,
reg u64 rng_bytes_ptr,
reg u64 session_id_ptr,
reg u32 attempt,
reg u64 my_node_id_ptr,
reg u64 tau_1_ptr,
reg u64 commit_out_ptr,
reg u64 mask_out_ptr,
reg u64 masked_out_ptr)
-> reg u32
{
// Result code: 0 on success.
reg u32 result;
result = 0;
// CAVEAT: this entry-point stub doesn't yet unmarshal the u64
// pointers into typed array views. The lemma `combine_body_axiom`
// in ../../proofs/easycrypt/Magnetar_N1.ec discharges the byte-
// walk against a typed model; this fn is the call-site target.
//
// Real impl: type-cast the pointers, call round1_derive_mask,
// round1_mask_share, round1_commit_digest in sequence, write
// mask, masked, and (D, tau_1) at the outparams.
return result;
}
-77
View File
@@ -1,77 +0,0 @@
// Magnetar Round-2 — per-party reveal message.
//
// Reference: SPEC.md §4 Round 2. Mirrors
// ref/go/pkg/magnetar/threshold.go ThresholdSigner.Round2
//
// -----------------------------------------------------------------------------
// Algorithm
// -----------------------------------------------------------------------------
//
// Inputs (in order):
// mask - this party's mask r_i from Round-1 (SECRET — but
// about to be REVEALED at Round-2). Layout:
// 2*seed_size bytes (kept in caller state across
// rounds).
// masked - the masked share s'_i = share_i XOR r_i (SECRET —
// about to be REVEALED). Same layout.
// round1_msgs - vector of Round-1 commits from the quorum
// (PUBLIC).
// quorum_size - t (PUBLIC).
// session_id - 16-byte session id (PUBLIC).
// attempt - u32 attempt counter (PUBLIC).
//
// Outputs:
// reveal_out (MAGNETAR_RESPONSE_BYTES = 384 bytes):
// [0 .. 2*seed_size) mask r_i
// [2*seed_size .. 4*seed_size) masked_share s'_i
//
// Constant-time obligations:
// - Time and memory-access patterns independent of mask, masked.
// Round-2 IS the reveal step; secrecy of mask/masked ends here.
// - The session/attempt consistency check is over PUBLIC inputs.
require "../lib/magnetar_params.jinc"
require "../lib/transcript.jinc"
// Concatenate (mask || masked) into the reveal_out buffer.
inline
fn round2_pack_reveal(
reg ptr u8[MAGNETAR_MASK_BYTES] mask,
reg ptr u8[MAGNETAR_MASKED_BYTES] masked,
reg ptr u8[MAGNETAR_RESPONSE_BYTES] reveal_out)
-> reg ptr u8[MAGNETAR_RESPONSE_BYTES]
{
reg u32 i;
reg u8 c;
?{}, i = #set0_32();
while (i < MAGNETAR_MASK_BYTES) {
c = mask[i];
reveal_out[(int)(MAGNETAR_R2_MASK_OFFSET + i)] = c;
i += 1;
}
?{}, i = #set0_32();
while (i < MAGNETAR_MASKED_BYTES) {
c = masked[i];
reveal_out[(int)(MAGNETAR_R2_MASKED_OFFSET + i)] = c;
i += 1;
}
return reveal_out;
}
// Round-2 entry point.
export
fn magnetar_round2(
reg u64 mask_ptr,
reg u64 masked_ptr,
reg u64 session_id_ptr,
reg u32 attempt,
reg u64 reveal_out_ptr)
-> reg u32
{
reg u32 result;
result = 0;
// CAVEAT: entry-point stub. Real impl: type-cast pointers, call
// round2_pack_reveal, write reveal_out.
return result;
}
+68
View File
@@ -0,0 +1,68 @@
# Magnetar --- machine-checked proof track
Magnetar v1.0 ships two primitives:
- **Per-validator standalone** --- routes through
`cloudflare/circl/sign/slhdsa` v1.6.3 in production. The
single-party FIPS 205 functional + constant-time analysis tracks
the formosa-crypto `libjade-SLH-DSA` upstream when it lands. Until
then, the trust model relies on CIRCL's upstream review and the
NIST FIPS 205 reference.
- **THBS-SE (Threshold Hash-Based Signatures with Selected-Element
Reconstruction)** --- permissionless t-of-n threshold signing that
produces a byte-identical FIPS 205-shaped signature. See
`ref/go/pkg/magnetar/thbsse.go` for the construction, and
`THBS-SPEC.md` for the normative spec.
## What the proof track covers in v1.0
For v1.0 the machine-checked proof effort is **scoped to the
algebraic identities the THBS-SE share arithmetic relies on**, plus
the byte-identity claim that ties THBS-SE Combine output to
single-party FIPS 205 SignDeterministic.
The legacy EasyCrypt scaffolding that modeled the abandoned v0.x
seed-recombine path has been removed (it modeled `combine.go` and
`threshold.go`, files that are no longer in the codebase). The
v1.0 proof track is being rebuilt against the THBS-SE construction
shape (commit-bind, byte-wise Shamir over GF(257), Lagrange
reconstruct, FIPS 205 dispatch) and lands in v1.1 alongside the
strict-atom-assembly construction.
## What ships in v1.0
The Go reference implementation under
`ref/go/pkg/magnetar/thbsse.go` and `thbsse_field.go` is the
load-bearing artifact for v1.0. The 8 test gates listed in the
v1.0 sign-off (TestThbsSE_Wire_FIPS205Verifiable,
TestThbsSE_RejectSeedReveal, TestThbsSE_RejectUnselectedFORS,
TestThbsSE_RejectUnselectedWOTS, TestThbsSE_SlotReuseRejected,
TestThbsSE_OverselectedCommittee,
TestThbsSE_SlotBindingDomainSeparation,
BenchmarkThbsSE_Sign_5of7) pin the byte-identity, slot-binding, and
slashing-evidence properties end-to-end against unmodified
`cloudflare/circl/sign/slhdsa.Verify`. The KAT replay
(`TestKAT_ThbsSe`) pins deterministic vectors at (n=7, t=4) across
all three SLH-DSA modes and three messages.
## What lands in v1.1
The v1.1 proof track plan:
1. **EasyCrypt theory shells** for THBS-SE: the commit-bind ladder,
the byte-wise Shamir over GF(257), the Lagrange interpolation at
x=0, and the byte-identity step that ties the final SLH-DSA bytes
to single-party FIPS 205 SignDeterministic on the reconstructed
seed (or, in the strict-atom v1.1 path, on the directly-assembled
FIPS 205 signature atoms).
2. **Lean bridge** for the algebraic content (Shamir reconstruction
identity, Lagrange basis uniqueness over finite fields, polynomial
interpolation existence) mirroring Pulsar's
`~/work/lux/proofs/lean/Crypto/` setup.
3. **Jasmin track** for the strict-atom path (Magnetar-internal FIPS
205 sec 5/6/7/8 surface), once that construction lands at v1.1.
See `BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11` for the v1.1 roadmap.
-615
View File
@@ -1,615 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Class N1-analog byte-equality reduction *)
(* -------------------------------------------------------------------- *)
(* STATUS: CLOSED. 0 admits across the file. *)
(* *)
(* What this file gives reviewers TODAY *)
(* ------------------------------------ *)
(* 1. The full obligation surface (module types + module wiring). *)
(* 2. A FIPS 205 dispatch axiom that models *)
(* circl/sign/slhdsa.SignDeterministic as the single trusted *)
(* base. This is the same axiom that Crypto.Magnetar.Verify (Lean) *)
(* names `magnetar_verify_is_fips_dispatch`. *)
(* 3. A reconstruction lemma `reconstruct_of_share` that is *)
(* DISCHARGED (no admit): byte-wise Lagrange interpolation over *)
(* GF(257) is the left inverse of polynomial evaluation at *)
(* distinct non-zero points. The algebraic content is hoisted *)
(* into the `lagrange_inverse_eval` axiom; the lemma closes by *)
(* `apply`. What the axiom hides is mechanized in the companion *)
(* Lean theory `Crypto.Pulsar.Shamir` (file `Shamir.lean`) — *)
(* Magnetar uses the SAME byte-wise Shamir layer as Pulsar, so *)
(* the Lean bridge is shared. *)
(* 4. A byte-identity step lemma `combine_dispatches_to_slhdsa` that *)
(* reduces the Combine output to a single *)
(* `slhdsa_sign_deterministic` call on the reconstructed seed. *)
(* DISCHARGED (no admit) under the abstract Combine model *)
(* `CombineAbs`. The refinement of the concrete Combine to *)
(* `CombineAbs` is the section-local declared axiom *)
(* `combine_body_axiom`. *)
(* 5. The top-level `magnetar_n1_byte_equality` THEOREM is now *)
(* DISCHARGED. The proof chains the three section-local axioms *)
(* via a procedure-level `transitivity` through *)
(* `SinglePartyRun(FIPS205Sign).run`. *)
(* *)
(* Admit accounting *)
(* ---------------- *)
(* 0 admits. `magnetar_n1_byte_equality` is closed by a *)
(* procedure-level `transitivity` chain through *)
(* `SinglePartyRun(FIPS205Sign).run` that walks the four *)
(* already-discharged sub-lemmas/axioms below: *)
(* - `single_party_run_refines_fips205` (Step 1) *)
(* - `combine_body_axiom` (Step 3) *)
(* - `reconstruct_of_share` (Step 5) *)
(* - `slhdsa_sign_axiom` (FIPS 205 dispatch) *)
(* Hypotheses introduced (axiom): *)
(* - lagrange_inverse_eval (algebraic, Lagrange@0 identity in *)
(* GF(257)) *)
(* - slhdsa_sign_axiom (FIPS 205 functional spec) *)
(* - combine_body_axiom (extracted Combine refines abstract *)
(* CombineAbs) *)
(* The first is mechanized in Lean Crypto.Pulsar.Shamir (cross- *)
(* cited from Magnetar since the algebraic identity is identical); *)
(* the second is inherited from FIPS 205 / cloudflare/circl; the *)
(* third is the Go-extraction trust boundary. *)
(* *)
(* Claim *)
(* ----- *)
(* For every (group_pk, sk_shares) generated by the Magnetar DKG, *)
(* for every message m and every honest quorum Q of size t >= *)
(* threshold, the byte-string produced by *)
(* *)
(* Combine o {Round2_i}_{i in Q} o {Round1_i}_{i in Q} *)
(* *)
(* equals the byte-string produced by *)
(* *)
(* slhdsa_sign_deterministic(sk_group, m, ctx) *)
(* *)
(* on inputs (group_pk = pk_from_seed(S), m, ctx) when S is the *)
(* Magnetar-mixed reconstructed seed (Lagrange-byte-sum + cSHAKE256 *)
(* over MAGNETAR-SEED-SHARE-V1) and sk_group = sk_from_seed(S). *)
(* *)
(* Structural simplification vs Pulsar *)
(* ----------------------------------- *)
(* Pulsar's Class N1 chains through: *)
(* Step 1: Lagrange identity (sum_{i in Q} lambda_i * share_i = sk_group)
— in R_q^ell
Step 2-6: y/z/w1/c_tilde/hint aggregation across the
ML-DSA rejection-sampling kappa loop *)
(* *)
(* Magnetar's Class-N1-analog chains through: *)
(* Step 1: byte-wise Lagrange identity (sum_{i in Q} lambda_i * *)
(* share_i = master_byteSum) in GF(257)^seedSize *)
(* Step 2: cSHAKE256 mix to master seed S (= MAGNETAR-SEED-SHARE-V1) *)
(* Step 3: KeyFromSeed dispatch (= FIPS 205 §10.1 Algorithm 21) *)
(* Step 4: SignDeterministic dispatch (= FIPS 205 §10.2 Algorithm *)
(* 22) — STRAIGHT-LINE, NO REJECTION LOOP *)
(* *)
(* The decisive structural simplification: SLH-DSA SignDeterministic *)
(* is straight-line (no rejection-sampling loop), so the byte-walk *)
(* reduces to a single deterministic dispatch identity at Step 4, *)
(* instead of Pulsar's 5-stage (y/z/w1/c_tilde/hint) decomposition *)
(* across the kappa loop. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
(* The FIPS 205 SLH-DSA-SHAKE-192s signature byte-codec, owning
`signature_t`. Magnetar_N1's protocol-level `signature_t` is
aliased to the codec's type below (single concrete type — no
separate abstract surface). *)
require import Magnetar_N1_Signature_Codec.
(* In-house FIPS 205 functional spec. *)
require import SLHDSA_Functional.
(* -------------------------------------------------------------------- *)
(* Core types — byte-sequence universe and Magnetar nominal types *)
(* -------------------------------------------------------------------- *)
(* The byte-sequence type — every signature, key, and message is a
sequence of bytes at the wire level. *)
type byte_seq = bool list.
(* Per-byte share over GF(257). Magnetar's Shamir polynomial is
instantiated byte-wise across the SLH-DSA scheme seed: for each
byte position `b ∈ [0, seed_size)`, there's an independent
degree-(t-1) polynomial f_b ∈ Z_257[X] with f_b(0) = secret_byte[b]
and shares share_i[b] = f_b(x_i) ∈ Z_257.
At the algebraic level, share_t is the per-byte share value at a
single byte position (a single Z_257 element); a full party share
is a `share_t list` of length seed_size. We keep the per-byte
abstraction because byte-wise independence makes the Magnetar
correctness proof factor cleanly into seed_size identical
single-byte instances. *)
type share_t.
(* Magnetar's per-byte polynomial degree.
Concretely for a t-of-n threshold scheme, every per-byte share's
underlying polynomial has degree = threshold - 1. *)
op poly_degree : share_t -> int.
axiom poly_degree_nonneg (s : share_t) : 0 <= poly_degree s.
(* The SLH-DSA scheme seed (96 bytes for SHAKE-192s). Wired through
`SLHDSA_Functional.seed_t`. *)
type seed_t = SLHDSA_Functional.seed_t.
(* Group public key (FIPS 205 SLH-DSA public-key encoding). Wired
through `SLHDSA_Functional.slh_pk_t`. *)
type group_pk_t = SLHDSA_Functional.slh_pk_t.
(* Message / context / signature wire-level types.
`signature_t` is ALIASED to `Magnetar_N1_Signature_Codec.signature_t`. *)
type message_t = SLHDSA_Functional.slh_msg_t.
type ctx_t = SLHDSA_Functional.slh_ctx_t.
type signature_t = Magnetar_N1_Signature_Codec.signature_t.
(* Per-session per-party randomness used by Round-1 mask sampling and
(if randomized) Round-2 commit blinding. *)
type randomness_t.
(* Round-1 protocol message (per-party masked-share commit + Round-1
transcript bind). *)
type round1_t.
(* Round-2 protocol message (per-party reveal of mask + masked share). *)
type round2_t.
(* Per-session state ID (FIPS 205 ctx, attempt counter, etc.). *)
type session_t.
(* -------------------------------------------------------------------- *)
(* Shamir / Lagrange algebraic kernel *)
(* -------------------------------------------------------------------- *)
(* These operators name the byte-wise Shamir layer over GF(257). The *)
(* companion Lean theory Crypto.Pulsar.Shamir mechanizes their *)
(* algebraic content; here we hoist the two facts the byte-equality *)
(* proof actually depends on to axioms over `share_t`, so the *)
(* EasyCrypt obligation surface is clean. *)
(* *)
(* Magnetar uses the SAME byte-wise Shamir over GF(257) as Pulsar *)
(* (smallest prime > 255, each byte position is an independent *)
(* polynomial). The Lean bridges in `Crypto.Pulsar.Shamir.lean` and *)
(* `Crypto.Threshold_Lagrange.lean` apply VERBATIM. See *)
(* `proofs/lean-easycrypt-bridge.md` for the symbol correspondence. *)
(* -------------------------------------------------------------------- *)
(* Lagrange coefficient at X = 0 for party `i` in quorum `Q`. Lives in
Z_257 at the field level; lifts to share_t coordinate-wise. *)
op lagrange : int list -> int -> share_t -> share_t.
(* Reconstruct the (per-byte) group secret from a quorum's per-byte
shares: returns
sum_{i in Q} lambda_i^Q * share_i over Z_257.
Defined recursively over the quorum list.
At the full-seed level, applying `reconstruct` byte-by-byte to
seed_size pairs of (eval_point, share_byte) recovers the
master_byteSum vector — exactly what Magnetar's Combine does. *)
op reconstruct (Q : int list) (shares : share_t list) : share_t.
(* Polynomial evaluation: poly_eval p i = P(i) where P is the
dealer's byte-wise Shamir polynomial in Z_257[X] (parametrised by
p : share_t abstracting the polynomial's representation as its
constant term). *)
op poly_eval : share_t -> int -> share_t.
(* Group-secret reconstruction is the identity on the dealer's secret.
This is the Lagrange-interpolation identity at X = 0, encoded for a
generic share_t carrying Z_257 (per-byte) coordinate semantics.
PRECONDITION: |Q| > degree(f). Without this bound, Lagrange
interpolation through |Q| points of a degree-d polynomial is
undefined when d >= |Q|. Mirrors the Pulsar EC bridge precondition.
BRIDGED TO LEAN: this axiom corresponds to
Crypto.Pulsar.Shamir.shamir_correct_at_target
(~/work/lux/proofs/lean/Crypto/Pulsar/Shamir.lean:76)
specialized to evaluation at 0 via
Crypto.Threshold.Lagrange.secret_recovery_at_zero
(~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean:62).
See `proofs/lean-easycrypt-bridge.md` for the symbol
correspondence table. The Lean theorem is fully proved against
Mathlib's Lagrange interpolation. *)
axiom lagrange_inverse_eval (s : share_t) (Q : int list) :
uniq Q =>
poly_degree s < size Q =>
reconstruct Q (List.map (poly_eval s) Q) = s.
(* -------------------------------------------------------------------- *)
(* Discharged lemma: reconstruction is a left inverse of sharing *)
(* -------------------------------------------------------------------- *)
lemma reconstruct_of_share (s : share_t) (Q : int list) :
uniq Q =>
poly_degree s < size Q =>
reconstruct Q (List.map (poly_eval s) Q) = s.
proof.
move=> uQ szQ.
exact: (lagrange_inverse_eval s Q uQ szQ).
qed.
(* Quorum invariance: distinct quorums reconstructing honest shares of
the same polynomial recover the same secret. *)
lemma reconstruct_quorum_invariant
(s : share_t) (Q1 Q2 : int list) :
uniq Q1 => poly_degree s < size Q1 =>
uniq Q2 => poly_degree s < size Q2 =>
reconstruct Q1 (List.map (poly_eval s) Q1) =
reconstruct Q2 (List.map (poly_eval s) Q2).
proof.
move=> uQ1 szQ1 uQ2 szQ2.
by rewrite (lagrange_inverse_eval s Q1) // (lagrange_inverse_eval s Q2).
qed.
(* -------------------------------------------------------------------- *)
(* Reconstruct-to-Seed Mix *)
(* -------------------------------------------------------------------- *)
(* SPEC.md §4 step 5: Magnetar Combine recovers the per-byte byteSum *)
(* via Lagrange interpolation, then mixes with the committee root via *)
(* cSHAKE256(MAGNETAR-SEED-SHARE-V1) to produce the master seed. The *)
(* `mix_to_seed` op abstracts this mix: *)
(* *)
(* mix_to_seed(byteSum, committee_root) = *)
(* cSHAKE256(byteSum_BE || committee_root, *)
(* N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", *)
(* seed_size bytes) *)
(* *)
(* The DKG side computes mix_to_seed at Round 3; the Combine side *)
(* recomputes it at step 5; the byte-identity of the two mix calls is *)
(* the protocol's "same seed across DKG and Combine" property. *)
(* -------------------------------------------------------------------- *)
(* The committee-root digest (32 bytes; the canonical
cSHAKE256(MAGNETAR-COMMITTEE-V1, sorted_committee) output). *)
type committee_root_t.
op mix_to_seed : share_t -> committee_root_t -> seed_t.
(* mix_to_seed is INJECTIVE in its first argument (the byteSum).
Captures the cSHAKE256 mix's first-argument injectivity (different
byteSums under the same committee_root produce different seeds).
This rules out adversarial realisations that ignore the byteSum
or collapse distinct byteSums to one.
BRIDGED TO LEAN: this axiom corresponds to
Crypto.Magnetar.Verify.mix_to_seed_first_arg_injective
(~/work/lux/proofs/lean/Crypto/Magnetar/OutputInterchange.lean).
The cSHAKE256 collision-resistance content is shared between EC
and Lean; the Lean side states it identically (Mathlib does not
natively mechanize SHAKE collision resistance, so the bridge
is conceptual rather than mechanical). *)
axiom mix_to_seed_injective_byteSum
(b1 b2 : share_t) (cr : committee_root_t) :
mix_to_seed b1 cr = mix_to_seed b2 cr => b1 = b2.
(* -------------------------------------------------------------------- *)
(* Public-key derivation *)
(* -------------------------------------------------------------------- *)
(* `derive_pk : seed_t -> group_pk_t` is the FIPS 205 §10.1 *)
(* Algorithm 21 DeriveKey path projected onto the public-key half. *)
(* Equal to `slhdsa_pk_from_seed`. *)
(* -------------------------------------------------------------------- *)
op derive_pk : seed_t -> group_pk_t.
(* derive_pk IS the SLHDSA_Functional pk-from-seed projection. *)
axiom derive_pk_is_slhdsa_pk_from_seed (s : seed_t) :
derive_pk s = SLHDSA_Functional.slhdsa_pk_from_seed s.
(* -------------------------------------------------------------------- *)
(* FIPS 205 functional dispatch *)
(* -------------------------------------------------------------------- *)
(* The single trusted base — exactly mirrors Pulsar's mldsa_sign_op / *)
(* FIPS204Sign pattern but routes through SLHDSA_Functional. *)
(* -------------------------------------------------------------------- *)
(* The pure-functional SLH-DSA sign operator on a SEED (Magnetar
convention — the seed is what gets Lagrange-reconstructed). It
chains KeyFromSeed → SignDeterministic; the result is the
centralised FIPS 205 byte-identical signature. *)
op slhdsa_sign_op (S : seed_t) (m : message_t) (ctx : ctx_t)
: signature_t =
SLHDSA_Functional.slhdsa_sign_deterministic
(SLHDSA_Functional.slhdsa_sk_from_seed S) m ctx.
(* Determinism — same inputs produce the same signature (no
rejection-sampling loop; this is FIPS 205 §10.2 SignDeterministic). *)
lemma slhdsa_sign_op_deterministic
(S1 S2 : seed_t) (m1 m2 : message_t) (c1 c2 : ctx_t) :
S1 = S2 => m1 = m2 => c1 = c2 =>
slhdsa_sign_op S1 m1 c1 = slhdsa_sign_op S2 m2 c2.
proof. by move=> -> -> ->. qed.
module type SLHDSA_Sign = {
proc sign(S : seed_t, m : message_t, ctx : ctx_t) : signature_t
}.
module FIPS205Sign : SLHDSA_Sign = {
proc sign(S : seed_t, m : message_t, ctx : ctx_t) : signature_t = {
return slhdsa_sign_op S m ctx;
}
}.
(* Functional spec correspondence — DISCHARGED as a lemma. The body of
FIPS205Sign.sign literally returns slhdsa_sign_op; the Pr-equality
is then a deterministic Hoare assertion closed by `byphoare` + skip. *)
lemma slhdsa_sign_axiom :
forall (S0 : seed_t) (m0 : message_t) (c0 : ctx_t) &mm,
Pr[FIPS205Sign.sign(S0, m0, c0) @ &mm :
res = slhdsa_sign_op S0 m0 c0] = 1%r.
proof.
move=> S0 m0 c0 &mm.
byphoare (_: S = S0 /\ m = m0 /\ ctx = c0
==> res = slhdsa_sign_op S0 m0 c0) => //.
proc; wp; skip => />.
qed.
(* -------------------------------------------------------------------- *)
(* Magnetar threshold-protocol oracle interface *)
(* -------------------------------------------------------------------- *)
(* The protocol-level seed-recovery operator. Given the per-byte
reconstructed byteSum (`reconstruct quorum shares` at the share_t
level, applied byte-wise to seed_size lanes) and the committee
root, mix_to_seed produces the master seed.
PRECONDITION (threshold protocol invariant): the shares are an
honest sharing of the secret reconstructed at any honest quorum.
The full Magnetar Combine path is:
S = mix_to_seed (reconstruct quorum shares) committee_root
sig = slhdsa_sign_op S m ctx
*)
op recover_seed
(Q : int list) (shares : share_t list) (cr : committee_root_t)
: seed_t =
mix_to_seed (reconstruct Q shares) cr.
module type Magnetar_Threshold = {
proc round1(sess : session_t, share : share_t,
rho_rnd : randomness_t) : round1_t
proc round2(sess : session_t, share : share_t,
round1_aggregate : round1_t list,
c_tilde : message_t) : round2_t
proc combine(group_pk : group_pk_t, m : message_t, ctx : ctx_t,
quorum : int list,
shares : share_t list,
committee_root : committee_root_t,
r1s : round1_t list, r2s : round2_t list) : signature_t
}.
(* -------------------------------------------------------------------- *)
(* Combine refines the FIPS 205 dispatch on the reconstructed seed. *)
(* -------------------------------------------------------------------- *)
(* The cryptographic content of SPEC.md §4 Combine: aggregator *)
(* Lagrange-reconstructs the byteSum, mixes to the seed S, calls *)
(* SignDeterministic, and returns the byte-identical signature. *)
(* -------------------------------------------------------------------- *)
module CombineAbs = {
proc combine(group_pk : group_pk_t, m : message_t, ctx : ctx_t,
quorum : int list,
shares : share_t list,
committee_root : committee_root_t,
r1s : round1_t list, r2s : round2_t list) : signature_t = {
var S : seed_t;
var sig : signature_t;
S <- recover_seed quorum shares committee_root;
sig <@ FIPS205Sign.sign(S, m, ctx);
return sig;
}
}.
(* -------------------------------------------------------------------- *)
(* Discharged step: Combine = slhdsa_sign_op o recover_seed *)
(* -------------------------------------------------------------------- *)
(* The abstract dispatch step composes cleanly: under the Combine *)
(* refinement, every signature produced equals *)
(* slhdsa_sign_op (recover_seed Q shares cr) m ctx *)
(* by inlining FIPS205Sign.sign and applying slhdsa_sign_axiom. *)
(* -------------------------------------------------------------------- *)
lemma combine_dispatches_to_slhdsa
(gpk0 : group_pk_t)
(m0 : message_t) (ctx0 : ctx_t)
(Q0 : int list) (shs0 : share_t list)
(cr0 : committee_root_t)
(r1s0 : round1_t list) (r2s0 : round2_t list) &m :
Pr[CombineAbs.combine(gpk0, m0, ctx0, Q0, shs0, cr0, r1s0, r2s0) @ &m :
res = slhdsa_sign_op (recover_seed Q0 shs0 cr0) m0 ctx0] = 1%r.
proof.
byphoare (_:
group_pk = gpk0 /\ m = m0 /\ ctx = ctx0 /\ quorum = Q0
/\ shares = shs0 /\ committee_root = cr0
/\ r1s = r1s0 /\ r2s = r2s0
==> _) => //.
proc; inline FIPS205Sign.sign; wp; skip => /#.
qed.
(* -------------------------------------------------------------------- *)
(* Single-party reference path *)
(* -------------------------------------------------------------------- *)
(* The single-party run for byte-equality comparison: invoke *)
(* SLHDSA_Functional.slhdsa_sign_deterministic on the seed-derived sk *)
(* with the SAME (m, ctx). This is the "right-hand side" of the *)
(* byte-equality claim. *)
(* -------------------------------------------------------------------- *)
module SinglePartyRun (S : SLHDSA_Sign) = {
proc run(group_pk : group_pk_t, shares : share_t list,
quorum : int list, committee_root : committee_root_t,
m : message_t, ctx : ctx_t) : signature_t = {
var seed : seed_t;
var sig : signature_t;
seed <- recover_seed quorum shares committee_root;
sig <@ S.sign(seed, m, ctx);
return sig;
}
}.
(* The threshold path through an arbitrary Magnetar_Threshold module. *)
module ThresholdRun (T : Magnetar_Threshold) = {
proc run(group_pk : group_pk_t, shares : share_t list,
quorum : int list, committee_root : committee_root_t,
m : message_t, ctx : ctx_t) : signature_t = {
var sig : signature_t;
(* Abstract over the Round-1 / Round-2 messaging; the byte-walk
refinement file fills these in from extracted code. *)
sig <@ T.combine(group_pk, m, ctx, quorum, shares,
committee_root, witness, witness);
return sig;
}
}.
(* -------------------------------------------------------------------- *)
(* Class N1-analog — byte-equality theorem *)
(* -------------------------------------------------------------------- *)
section ClassN1.
declare module S <: SLHDSA_Sign.
declare module T <: Magnetar_Threshold.
(* Section-local refinement axiom: the extracted Combine is equivalent
to `CombineAbs.combine` under the honest-quorum precondition.
Discharge path: this axiom's byte-level content is captured
atomically by `combine_body_spec` in `Magnetar_N1_Combine_Refinement.ec`.
That file has ONE byte-walk axiom (`combine_body_compute_sig_spec`)
plus six derived lemmas (layout predicate, read_signature_at_det,
packed_bytes_eq_CombineAbs, combine_body_writes_signature, ...).
The precondition `group_pk{1} = derive_pk(recover_seed quorum{1}
shares{1} cr{1})` is the protocol-consistency obligation: the
group pubkey IS the derived pubkey of the reconstructed seed. *)
declare axiom combine_body_axiom :
equiv [ T.combine ~ CombineAbs.combine :
={arg}
/\ group_pk{1} = derive_pk
(recover_seed quorum{1} shares{1} committee_root{1})
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ poly_degree (reconstruct quorum{1} shares{1})
< size quorum{1}
/\ shares{1} = List.map
(poly_eval (reconstruct quorum{1} shares{1})) quorum{1}
==> ={res} ].
(* Functional-spec hypothesis on the single-party module `S`.
Mirrors `slhdsa_sign_axiom` above for arbitrary `S`. *)
declare axiom S_functional_spec :
forall (S0 : seed_t) (m0 : message_t) (c0 : ctx_t) &mm,
Pr[S.sign(S0, m0, c0) @ &mm :
res = slhdsa_sign_op S0 m0 c0] = 1%r.
(* Step: SinglePartyRun(FIPS205Sign).run produces slhdsa_sign_op on
the recover_seed. This is the centralised FIPS 205 signature. *)
lemma single_party_run_refines_fips205
(gpk0 : group_pk_t) (shs0 : share_t list)
(Q0 : int list) (cr0 : committee_root_t)
(m0 : message_t) (ctx0 : ctx_t) &m :
Pr[SinglePartyRun(FIPS205Sign).run
(gpk0, shs0, Q0, cr0, m0, ctx0) @ &m :
res = slhdsa_sign_op (recover_seed Q0 shs0 cr0) m0 ctx0] = 1%r.
proof.
byphoare (_:
group_pk = gpk0 /\ shares = shs0 /\ quorum = Q0
/\ committee_root = cr0 /\ m = m0 /\ ctx = ctx0
==> _) => //.
proc; inline FIPS205Sign.sign; wp; skip => /#.
qed.
(* -------------------------------------------------------------------- *)
(* Headline theorem: Magnetar Class N1-analog byte-equality *)
(* -------------------------------------------------------------------- *)
lemma magnetar_n1_byte_equality :
equiv [ ThresholdRun(T).run ~ SinglePartyRun(FIPS205Sign).run :
={group_pk, shares, quorum, committee_root, m, ctx}
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ group_pk{1} = derive_pk
(recover_seed quorum{1} shares{1} committee_root{1})
/\ poly_degree (reconstruct quorum{1} shares{1})
< size quorum{1}
/\ shares{1} = List.map
(poly_eval (reconstruct quorum{1} shares{1})) quorum{1}
==> ={res} ].
proof.
proc.
(* Transitivity through CombineAbs: T.combine ~ CombineAbs.combine
(the section-local combine_body_axiom) and CombineAbs.combine
dispatches to FIPS205Sign.sign on recover_seed. *)
inline ThresholdRun(T).run SinglePartyRun(FIPS205Sign).run.
(* The two run procedures are byte-identical modulo the
combine_body_axiom equivalence + the singleparty-side dispatch. *)
transitivity{1}
{ sig <@ CombineAbs.combine(group_pk, m, ctx, quorum, shares,
committee_root, witness, witness); }
( ={group_pk, shares, quorum, committee_root, m, ctx}
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ group_pk{1} = derive_pk
(recover_seed quorum{1} shares{1} committee_root{1})
/\ poly_degree (reconstruct quorum{1} shares{1})
< size quorum{1}
/\ shares{1} = List.map
(poly_eval (reconstruct quorum{1} shares{1})) quorum{1}
==> ={sig} )
( ={group_pk, shares, quorum, committee_root, m, ctx}
==> ={sig} ).
- move=> &m1 &m2 [#] *.
by exists (group_pk{m1}, shares{m1}, quorum{m1},
committee_root{m1}, m{m1}, ctx{m1}) => /#.
- by trivial.
- call combine_body_axiom; skip; smt().
(* Now: CombineAbs.combine ~ SinglePartyRun(FIPS205Sign).run on the
RHS. Both reduce to slhdsa_sign_op on recover_seed by
combine_dispatches_to_slhdsa + single_party_run_refines_fips205. *)
inline CombineAbs.combine FIPS205Sign.sign.
wp; skip => />.
qed.
end section ClassN1.
(* ===================================================================
ACCOUNTING
axioms (3 — protocol-consistency + algebra + FIPS dispatch boundary):
- lagrange_inverse_eval (Lean-bridged to
Crypto.Pulsar.Shamir.shamir_correct_at_target)
- mix_to_seed_injective_byteSum (cSHAKE256 mix injectivity in
first argument)
- derive_pk_is_slhdsa_pk_from_seed (definition pin)
section-local declare axioms (2 — discharged inside refinement files):
- combine_body_axiom (extracted Combine refines CombineAbs)
- S_functional_spec (abstract S dispatches to slhdsa_sign_op)
PROVED lemmas (0 admits):
reconstruct_of_share
reconstruct_quorum_invariant
slhdsa_sign_op_deterministic
slhdsa_sign_axiom
combine_dispatches_to_slhdsa
single_party_run_refines_fips205
magnetar_n1_byte_equality (headline theorem)
Structural simplification vs Pulsar:
Pulsar's analogue has 4 stage-level byte-walk axioms (z, h,
c_tilde, mu) plus 2 c_tilde dependency sub-axioms (w) plus 2
codec-layout axioms (mu_input) plus 5 Lean-bridged algebraic
axioms — 13 total residual axioms at v8 of the cascade.
Magnetar has 3 axioms (lagrange algebra + cSHAKE injectivity +
derive_pk dispatch pin) plus 2 section-local refinement axioms
(combine_body + S_functional) plus 1 codec axiom (signature
length wf) — 6 total, none recursing through a kappa loop.
This is the structural simplification SPHINCS+'s straight-line
hash-tree primitive buys us over Dilithium's rejection-loop
primitive.
=================================================================== *)
@@ -1,152 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Combine wire layout (byte-level) *)
(* -------------------------------------------------------------------- *)
(* The byte-level encode/decode for Combine's argument tuple. This file *)
(* sits between Magnetar_N1_Memory (raw bytes) and Magnetar_N1_Combine_ *)
(* Refinement (the byte-walk on extracted Go code). *)
(* *)
(* Compared to Pulsar's Combine_Layout (which lays out c_tilde, t0, *)
(* r2_msg, etc.), Magnetar's Combine is structurally simpler: its *)
(* arguments are *)
(* *)
(* - group_pk (FIPS 205 PK, 48 bytes for SHAKE-192s) *)
(* - quorum (list of party indices) *)
(* - shares (per-party reveal-recovered shares; the byteSum *)
(* inputs to Lagrange interpolation) *)
(* - committee_root (32-byte cSHAKE256 digest) *)
(* - message (caller-supplied bytes) *)
(* - ctx (caller-supplied bytes, ≤255) *)
(* - sig_out_ptr (where to write the 16224-byte signature) *)
(* *)
(* No FIPS-204-style z/h/c_tilde decomposition, no per-stage layout *)
(* — Magnetar's signature is a single SLH-DSA output blob. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv.
require import Magnetar_N1_Memory.
require import Magnetar_N1_Signature_Codec.
(* ===================================================================
The pointer bundle the extracted body reads from.
=================================================================== *)
type combine_ptrs_t = {
group_pk_ptr : int; (* 48 bytes — FIPS 205 PK *)
quorum_ptr : int; (* uint32 array — party indices *)
shares_ptr : int; (* (seed_size * 2 * t) bytes — Shamir
share vector for the t-quorum *)
committee_root_ptr : int; (* 32 bytes — cSHAKE256 digest *)
msg_ptr : int; (* `msg_len` bytes — caller message *)
msg_len : int;
ctx_ptr : int; (* `ctx_len` bytes — caller context *)
ctx_len : int;
sig_out_ptr : int; (* 16224 bytes — output signature *)
}.
(* The byte-tuple representation of the Combine arguments. These are
the values the WIRE LAYOUT (encode_combine_args / decode_combine_args)
transports through memory. *)
type combine_abs_args_t = {
abs_group_pk_bytes : int list; (* pk_size bytes *)
abs_quorum_bytes : int list; (* 4*t bytes (uint32 array) *)
abs_shares_bytes : int list; (* (seed_size * 2 * t) bytes *)
abs_committee_root_bytes : int list; (* 32 bytes *)
abs_msg_bytes : int list; (* msg_len bytes *)
abs_ctx_bytes : int list; (* ctx_len bytes *)
}.
(* The layout predicate: pointers and abstract args agree on every
byte. *)
op layout_combine_args
(m : mem_t) (ptrs : combine_ptrs_t) (a : combine_abs_args_t)
: bool =
load_bytes m ptrs.`group_pk_ptr (size a.`abs_group_pk_bytes)
= a.`abs_group_pk_bytes
/\ load_bytes m ptrs.`quorum_ptr (size a.`abs_quorum_bytes)
= a.`abs_quorum_bytes
/\ load_bytes m ptrs.`shares_ptr (size a.`abs_shares_bytes)
= a.`abs_shares_bytes
/\ load_bytes m ptrs.`committee_root_ptr
(size a.`abs_committee_root_bytes)
= a.`abs_committee_root_bytes
/\ load_bytes m ptrs.`msg_ptr ptrs.`msg_len
= a.`abs_msg_bytes
/\ ptrs.`msg_len = size a.`abs_msg_bytes
/\ load_bytes m ptrs.`ctx_ptr ptrs.`ctx_len
= a.`abs_ctx_bytes
/\ ptrs.`ctx_len = size a.`abs_ctx_bytes.
(* Encode the abstract args into memory at the supplied pointers. The
encoded layout satisfies `layout_combine_args` for the resulting
memory + same ptrs + same args. *)
op encode_combine_args
(m : mem_t) (ptrs : combine_ptrs_t) (a : combine_abs_args_t)
: mem_t =
let m1 = store_bytes m ptrs.`group_pk_ptr a.`abs_group_pk_bytes in
let m2 = store_bytes m1 ptrs.`quorum_ptr a.`abs_quorum_bytes in
let m3 = store_bytes m2 ptrs.`shares_ptr a.`abs_shares_bytes in
let m4 = store_bytes m3 ptrs.`committee_root_ptr
a.`abs_committee_root_bytes in
let m5 = store_bytes m4 ptrs.`msg_ptr a.`abs_msg_bytes in
store_bytes m5 ptrs.`ctx_ptr a.`abs_ctx_bytes.
(* DISJOINTNESS predicate: every pair of pointer-ranges is disjoint.
The byte-walk axiom requires this so the per-region encode-then-
load identity composes. *)
op combine_ptrs_disjoint (ptrs : combine_ptrs_t) (a : combine_abs_args_t)
: bool =
(* group_pk vs quorum *)
( ptrs.`group_pk_ptr + size a.`abs_group_pk_bytes
<= ptrs.`quorum_ptr
\/ ptrs.`quorum_ptr + size a.`abs_quorum_bytes
<= ptrs.`group_pk_ptr)
(* group_pk vs shares *)
/\ ( ptrs.`group_pk_ptr + size a.`abs_group_pk_bytes
<= ptrs.`shares_ptr
\/ ptrs.`shares_ptr + size a.`abs_shares_bytes
<= ptrs.`group_pk_ptr)
(* group_pk vs committee_root *)
/\ ( ptrs.`group_pk_ptr + size a.`abs_group_pk_bytes
<= ptrs.`committee_root_ptr
\/ ptrs.`committee_root_ptr + size a.`abs_committee_root_bytes
<= ptrs.`group_pk_ptr)
(* msg vs ctx *)
/\ ( ptrs.`msg_ptr + ptrs.`msg_len <= ptrs.`ctx_ptr
\/ ptrs.`ctx_ptr + ptrs.`ctx_len <= ptrs.`msg_ptr)
(* sig_out_ptr disjoint from every input region *)
/\ ( ptrs.`sig_out_ptr + sig_len <= ptrs.`group_pk_ptr
\/ ptrs.`group_pk_ptr + size a.`abs_group_pk_bytes
<= ptrs.`sig_out_ptr)
/\ ( ptrs.`sig_out_ptr + sig_len <= ptrs.`shares_ptr
\/ ptrs.`shares_ptr + size a.`abs_shares_bytes
<= ptrs.`sig_out_ptr)
/\ ( ptrs.`sig_out_ptr + sig_len <= ptrs.`msg_ptr
\/ ptrs.`msg_ptr + ptrs.`msg_len <= ptrs.`sig_out_ptr).
(* Thin wrapper: read the signature blob at sig_out_ptr in m. *)
op read_signature_at (m : mem_t) (p : int) : signature_t =
read_sig_at m p.
(* Write a signature blob at sig_out_ptr in m. *)
op write_signature_at (m : mem_t) (p : int) (s : signature_t) : mem_t =
write_sig_at m p s.
(* Derived structural lemma: writing a signature at sig_out_ptr is
detectable via read_signature_at. *)
lemma read_signature_at_after_write
(m : mem_t) (p : int) (s : signature_t) :
read_signature_at (write_signature_at m p s) p = s.
proof. by rewrite /read_signature_at /write_signature_at read_after_write_sig. qed.
(* ===================================================================
ACCOUNTING
Definitions: combine_ptrs_t, combine_abs_args_t,
layout_combine_args, encode_combine_args,
combine_ptrs_disjoint,
read_signature_at, write_signature_at.
PROVED lemmas: read_signature_at_after_write.
axioms: 0.
=================================================================== *)
@@ -1,362 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Class N1-analog Combine refinement (extracted ↔ abstract) *)
(* -------------------------------------------------------------------- *)
(* This file is the discharge path for Magnetar_N1.ec's section-local *)
(* refinement axiom *)
(* *)
(* declare axiom combine_body_axiom : *)
(* equiv [ T.combine ~ CombineAbs.combine : *)
(* ={arg} ==> ={res} ]. *)
(* *)
(* Structural simplification vs Pulsar Combine_Refinement.ec *)
(* ------------------------------------------------------- *)
(* Pulsar's Combine_Refinement decomposes the byte-walk along the *)
(* FIPS 204 §3.5.5 packing boundary into c_tilde / z / h stages, each *)
(* further decomposed into sub-axioms (mu input, w polynomial, etc.). *)
(* Pulsar v8 has roughly: *)
(* *)
(* - 3 stage-level byte-walks (combine_body_{h,z,c_tilde}_spec *)
(* after derivation cascade) *)
(* - 2 narrow combine z-stage axioms (aggregation shape + per-party PR)
- 2 c_tilde dependency sub-axioms (w)
- 2 codec layout axioms (mu_input prefix + ctx + m sub-axioms) *)
(* *)
(* Magnetar's Combine is structurally simpler: there is no FIPS-204 *)
(* §3.5.5 decomposition because SLH-DSA SignDeterministic emits a *)
(* monolithic signature blob (not a (c_tilde, z, h) triple). The *)
(* signature is the verbatim output of *)
(* slhdsa.SignDeterministic(slhdsa.KeyFromSeed(S), msg, ctx) *)
(* on the reconstructed seed S. So the byte-walk has exactly ONE *)
(* atomic axiom: *)
(* *)
(* combine_body_compute_sig_spec : *)
(* layout-conforming inputs map under the extracted body to *)
(* bytes that decode to slhdsa_sign_op (recover_seed Q shares cr) *)
(* m ctx *)
(* *)
(* Closing this axiom is a byte-walk through *)
(* ref/go/pkg/magnetar/combine.go lines 47-206 *)
(* which is dominated by: *)
(* *)
(* - The Lagrange reconstruct (`shamirReconstructGF`) — algebraic, *)
(* reduces to `lagrange_inverse_eval` (Lean-bridged Shamir). *)
(* - The cSHAKE256 mix (`cshake256(mixInput, …, tagSeedShare)`) — *)
(* STRUCTURAL definition of `mix_to_seed` matches. *)
(* - The `KeyFromSeed` dispatch — the FIPS 205 §10.1 §10.2 Algorithm *)
(* 21/22 entry point. *)
(* - The `slhdsa.SignDeterministic` dispatch — FIPS 205 §10.2 *)
(* Algorithm 22. *)
(* *)
(* The byte-walk is structurally a one-step composition of these four, *)
(* in contrast to Pulsar's k-stage decomposition through the ML-DSA *)
(* rejection-sampling loop. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
(* Concrete byte-level layout. *)
require import Magnetar_N1_Combine_Layout.
(* Magnetar_N1 provides the protocol-level abstract types share_t,
message_t, ctx_t, randomness_t, group_pk_t, round1_t, round2_t,
signature_t, plus slhdsa_sign_op, reconstruct, recover_seed, etc. *)
require import Magnetar_N1.
(* ===================================================================
L2 — Protocol-extended args.
`combine_full_args_t` carries THREE distinct categories of field:
[WIRE] full_wire : laid out in memory by encode_combine_args
(group_pk, quorum, shares, committee_root,
msg, ctx). Concrete bytes read by the
byte-walk axiom through layout_combine_args.
[DERIVED] full_gpk, : protocol-level fields with a CHECKED
BINDING. The group pubkey must equal
derive_pk(recover_seed quorum shares cr)
— enforced by protocol_consistency
below; threaded into the byte-walk
axiom precondition.
full_quorum,
full_shares,
full_committee_root,
[GHOST] full_m, : protocol-level fields with no direct
full_ctx wire counterpart at this layer; the
Wrapper's derive_* ops fold them into
the wire fields.
=================================================================== *)
type combine_full_args_t = {
(* [WIRE] *)
full_wire : Magnetar_N1_Combine_Layout.combine_abs_args_t;
(* [DERIVED] *)
full_gpk : Magnetar_N1.group_pk_t;
full_quorum : int list;
full_shares : Magnetar_N1.share_t list;
full_committee_root : Magnetar_N1.committee_root_t;
(* [GHOST] *)
full_m : Magnetar_N1.message_t;
full_ctx : Magnetar_N1.ctx_t;
}.
op wire_args_of_full (full : combine_full_args_t)
: Magnetar_N1_Combine_Layout.combine_abs_args_t =
full.`full_wire.
(* Protocol consistency predicate.
`combine_full_args_t` carries `full_gpk` as a DERIVED field that
`combine_abs_op` doesn't consume — combine_abs_op uses
`recover_seed quorum shares committee_root` and discards full_gpk
entirely. An adversarial caller could therefore construct a
`combine_full_args_t` with a `full_gpk` UNRELATED to recover_seed,
and the byte-walk axiom would still claim the extracted output
equals slhdsa_sign_op on the wrong key — yielding a "valid
threshold signature" under any chosen group public key.
`protocol_consistency` rules out the inconsistent constructions by
requiring the ghost group pubkey to be the actual derived pubkey of
the reconstructed seed. *)
op protocol_consistency (full : combine_full_args_t) : bool =
full.`full_gpk =
Magnetar_N1.derive_pk
(Magnetar_N1.recover_seed full.`full_quorum
full.`full_shares
full.`full_committee_root).
(* Threshold protocol invariants — preconditions of the Lean Lagrange
theorem applied to combine_full_args_t. Bundles four conjuncts:
- uniq full_quorum — distinct party indices
- size full_shares
= size full_quorum — shape match
- poly_degree (reconstruct ...) < size full_quorum
— sharing polynomial degree bound
- full_shares = map (poly_eval (reconstruct ...)) full_quorum
— honest sharing (each share is the
polynomial evaluation at the
party's index)
Mirrors Magnetar_N1's `magnetar_n1_byte_equality` preconditions. *)
op threshold_protocol_invariants (full : combine_full_args_t) : bool =
let s_recon =
Magnetar_N1.reconstruct full.`full_quorum full.`full_shares in
uniq full.`full_quorum
/\ size full.`full_shares = size full.`full_quorum
/\ Magnetar_N1.poly_degree s_recon < size full.`full_quorum
/\ full.`full_shares
= List.map (Magnetar_N1.poly_eval s_recon) full.`full_quorum.
(* ===================================================================
L2 — Functional spec operator (DEFINITION, not axiom).
`combine_abs_op` is the abstract-side spec of the combine entry
point: it returns the centralised FIPS 205 SLH-DSA signature on the
reconstructed master seed. Since `slhdsa_sign_op` is the FIPS 205
functional operator, this DEFINITION captures the Magnetar
Class-N1-analog correctness identity at the operator level. The
byte-walk axiom below (`combine_body_compute_sig_spec`) discharges
this identity at the byte level for the extracted combine.
=================================================================== *)
op combine_abs_op (full : combine_full_args_t) : Magnetar_N1.signature_t =
Magnetar_N1.slhdsa_sign_op
(Magnetar_N1.recover_seed full.`full_quorum
full.`full_shares
full.`full_committee_root)
full.`full_m full.`full_ctx.
(* ===================================================================
L3 — Status word from the extracted body.
Magnetar's Combine returns (signature, error). On error,
sig_out_ptr is left undefined. The byte-walk axiom is conditioned
on `status = 0` so the rejection branch makes no claim about the
signature buffer content.
=================================================================== *)
op combine_body_compute_status :
Magnetar_N1_Memory.mem_t ->
Magnetar_N1_Combine_Layout.combine_ptrs_t ->
int.
(* ===================================================================
L3 — Refinement-fn projecting the extracted body's output signature.
Mirrors Pulsar's `combine_body_compute_sig` but defined directly as
an abstract op (no per-stage decomposition because SLH-DSA is
straight-line). The byte-walk axiom below claims it equals
combine_abs_op on layout-consistent + threshold-consistent inputs.
=================================================================== *)
op combine_body_compute_sig :
Magnetar_N1_Memory.mem_t ->
Magnetar_N1_Combine_Layout.combine_ptrs_t ->
Magnetar_N1_Signature_Codec.signature_t.
(* combine_body_fn writes the computed signature at sig_out_ptr and
leaves all other memory untouched. *)
op combine_body_fn
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Combine_Layout.combine_ptrs_t)
: Magnetar_N1_Memory.mem_t =
Magnetar_N1_Combine_Layout.write_signature_at
mem_pre
ptrs.`Magnetar_N1_Combine_Layout.sig_out_ptr
(combine_body_compute_sig mem_pre ptrs).
(* ===================================================================
L3 — THE atomic byte-walk axiom.
`combine_body_compute_sig_spec` says: given inputs whose wire-level
layout matches the abstract args, and given the threshold protocol
invariants + protocol consistency hold, and given the extracted
body returns status = 0 (no error), the extracted
`combine_body_compute_sig` returns a `signature_t` equal to
`combine_abs_op full` — i.e., the centralised FIPS 205 SLH-DSA
signature on the reconstructed master seed.
This is the ONLY byte-walk axiom in this file. Closing it is the
byte-walk through `ref/go/pkg/magnetar/combine.go` lines 47-206:
- Round-2 commit-bind loop (lines 82-117): mask + masked share
parsing + cSHAKE256 re-derive + constant-time digest equality.
Algebraic content: the recovered share equals the original
(committed) share by XOR linearity.
- Lagrange reconstruct + mix (lines 130-158): per-byte Lagrange
over GF(257) recovers master_byteSum, mixed with committee_root
via cSHAKE256 to produce master_seed. Algebraic content:
`recover_seed quorum shares committee_root` by definition.
- KeyFromSeed + SignDeterministic (lines 163, 187):
`slhdsa.Scheme(...).DeriveKey(masterSeed)` then
`slhdsa.SignDeterministic(sk, msg, ctx)`. Algebraic content:
`slhdsa_sign_op masterSeed msg ctx` by definition.
- sk.Pub.Equal(groupPubkey) check (line 174): captured by
protocol_consistency + derive_pk_is_slhdsa_pk_from_seed
precondition.
The byte-walk shows that the loop invariants plus the final
slhdsa.SignDeterministic call produce exactly `combine_abs_op full`.
=================================================================== *)
axiom combine_body_compute_sig_spec :
forall (mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Combine_Layout.combine_ptrs_t)
(full : combine_full_args_t),
Magnetar_N1_Combine_Layout.layout_combine_args
mem_pre ptrs (wire_args_of_full full) =>
Magnetar_N1_Combine_Layout.combine_ptrs_disjoint
ptrs (wire_args_of_full full) =>
protocol_consistency full =>
threshold_protocol_invariants full =>
combine_body_compute_status mem_pre ptrs = 0 =>
refine_sig_to_n1
(combine_body_compute_sig mem_pre ptrs)
= combine_abs_op full.
(* The signature-type coercion is IDENTITY (Pulsar pattern v11+):
Magnetar_N1.signature_t IS Magnetar_N1_Signature_Codec.signature_t,
aliased at the type level. Every downstream proof that uses
refine_sig_to_n1 as a coercion now witnesses an honest identity. *)
op refine_sig_to_n1
(s : Magnetar_N1_Signature_Codec.signature_t)
: Magnetar_N1.signature_t = s.
(* ===================================================================
Derived lemmas — wire layout + signature read identities.
=================================================================== *)
(* Reading the signature at sig_out_ptr in the post-combine memory
returns the computed signature. *)
lemma read_signature_at_post_combine
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Combine_Layout.combine_ptrs_t) :
Magnetar_N1_Combine_Layout.read_signature_at
(combine_body_fn mem_pre ptrs)
ptrs.`Magnetar_N1_Combine_Layout.sig_out_ptr
= combine_body_compute_sig mem_pre ptrs.
proof.
rewrite /combine_body_fn.
by rewrite Magnetar_N1_Combine_Layout.read_signature_at_after_write.
qed.
(* Composed: the extracted body's output, read back, equals
combine_abs_op. *)
lemma combine_body_output_eq_abs_op
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Combine_Layout.combine_ptrs_t)
(full : combine_full_args_t) :
Magnetar_N1_Combine_Layout.layout_combine_args
mem_pre ptrs (wire_args_of_full full) =>
Magnetar_N1_Combine_Layout.combine_ptrs_disjoint
ptrs (wire_args_of_full full) =>
protocol_consistency full =>
threshold_protocol_invariants full =>
combine_body_compute_status mem_pre ptrs = 0 =>
refine_sig_to_n1
(Magnetar_N1_Combine_Layout.read_signature_at
(combine_body_fn mem_pre ptrs)
ptrs.`Magnetar_N1_Combine_Layout.sig_out_ptr)
= combine_abs_op full.
proof.
move=> Hlay Hdisj Hcons Hthresh Hstatus.
rewrite read_signature_at_post_combine.
by apply combine_body_compute_sig_spec.
qed.
(* The byte-level write at sig_out_ptr does not affect bytes outside
the signature range. *)
lemma combine_body_separation
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Combine_Layout.combine_ptrs_t)
(q : int) :
q < ptrs.`Magnetar_N1_Combine_Layout.sig_out_ptr
\/ ptrs.`Magnetar_N1_Combine_Layout.sig_out_ptr
+ Magnetar_N1_Signature_Codec.sig_len <= q =>
Magnetar_N1_Memory.load_byte (combine_body_fn mem_pre ptrs) q
= Magnetar_N1_Memory.load_byte mem_pre q.
proof.
move=> Hdisj.
rewrite /combine_body_fn /Magnetar_N1_Combine_Layout.write_signature_at.
by apply Magnetar_N1_Signature_Codec.write_sig_separation.
qed.
(* ===================================================================
ACCOUNTING
ops (definitions; no proof obligation):
combine_full_args_t, wire_args_of_full,
protocol_consistency, threshold_protocol_invariants,
combine_abs_op,
combine_body_compute_status, combine_body_compute_sig,
combine_body_fn,
refine_sig_to_n1.
axioms (1 — the atomic byte-walk; Jasmin / Go extraction trust
boundary):
combine_body_compute_sig_spec
PROVED lemmas (0 admits):
read_signature_at_post_combine
combine_body_output_eq_abs_op
combine_body_separation
Closure plan for combine_body_compute_sig_spec:
- reduce to the 4 sub-claims listed in the axiom's documentation
comment;
- sub-claim 1 (Round-2 commit-bind) reduces to the Lagrange
layer's algebraic content (the recovered share is the original
share — by XOR linearity);
- sub-claim 2 (Lagrange + cSHAKE256 mix) reduces to
`lagrange_inverse_eval` (Lean-bridged) +
`mix_to_seed_injective_byteSum` (cSHAKE256 first-arg
injectivity);
- sub-claim 3 (KeyFromSeed) is a pure dispatch identity on
slhdsa_pk_from_seed / slhdsa_sk_from_seed;
- sub-claim 4 (SignDeterministic) reduces to slhdsa_sign_axiom
(the FIPS 205 functional spec).
=================================================================== *)
@@ -1,131 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Combine wrapper bridge *)
(* -------------------------------------------------------------------- *)
(* The wrapper bridge between: *)
(* - the byte-walk-axiom-discharged extracted Combine *)
(* (Magnetar_N1_Combine_Refinement.combine_body_fn) *)
(* - and the abstract Magnetar_Threshold.combine procedure used by *)
(* Magnetar_N1.magnetar_n1_byte_equality. *)
(* *)
(* The wrapper module `CombineExtractedWrapper` adapts the byte-pointer *)
(* interface to the abstract Magnetar_Threshold.combine procedure *)
(* signature. The bridge lemma *)
(* `combine_wrapper_equiv_CombineAbs` proves the procedure-level equiv *)
(* against `Magnetar_N1.CombineAbs.combine` given the layout-and- *)
(* threshold invariant preconditions. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
require import Magnetar_N1_Memory.
require import Magnetar_N1_Combine_Layout.
require import Magnetar_N1_Combine_Refinement.
require import Magnetar_N1.
require import Magnetar_N1_Signature_Codec.
(* ===================================================================
The extracted-wrapper Combine procedure.
This module adapts the byte-pointer interface (combine_body_fn +
combine_body_compute_sig) to the abstract Magnetar_Threshold.combine
signature. It takes the abstract args, encodes them into a memory
layout, runs the extracted body, and reads back the signature.
=================================================================== *)
module CombineExtractedWrapper : Magnetar_N1.Magnetar_Threshold = {
proc round1(sess : Magnetar_N1.session_t,
share : Magnetar_N1.share_t,
rho_rnd : Magnetar_N1.randomness_t) : Magnetar_N1.round1_t = {
var r1 : Magnetar_N1.round1_t;
r1 <- witness;
return r1;
}
proc round2(sess : Magnetar_N1.session_t,
share : Magnetar_N1.share_t,
round1_aggregate : Magnetar_N1.round1_t list,
c_tilde : Magnetar_N1.message_t) : Magnetar_N1.round2_t = {
var r2 : Magnetar_N1.round2_t;
r2 <- witness;
return r2;
}
proc combine(group_pk : Magnetar_N1.group_pk_t,
m : Magnetar_N1.message_t,
ctx : Magnetar_N1.ctx_t,
quorum : int list,
shares : Magnetar_N1.share_t list,
committee_root : Magnetar_N1.committee_root_t,
r1s : Magnetar_N1.round1_t list,
r2s : Magnetar_N1.round2_t list) : Magnetar_N1.signature_t = {
(* The procedural shape: in the extracted code path this would
allocate memory, layout the arguments, call the extracted
combine body, and read back the signature. At the EC level we
model this as a single dispatch to combine_abs_op (via the
byte-walk axiom). *)
var sig : Magnetar_N1.signature_t;
var full : combine_full_args_t;
full <- {| full_wire = witness;
full_gpk = group_pk;
full_quorum = quorum;
full_shares = shares;
full_committee_root = committee_root;
full_m = m;
full_ctx = ctx; |};
sig <- combine_abs_op full;
return sig;
}
}.
(* ===================================================================
Wrapper bridge lemma.
`combine_wrapper_equiv_CombineAbs` shows that the wrapper's combine
procedure is byte-equivalent to `Magnetar_N1.CombineAbs.combine`
under the threshold-protocol invariants + protocol consistency.
This is the procedure-level equiv that closes Magnetar_N1's
section-local `combine_body_axiom`.
=================================================================== *)
lemma combine_wrapper_equiv_CombineAbs :
equiv [ CombineExtractedWrapper.combine
~ Magnetar_N1.CombineAbs.combine :
={arg}
/\ group_pk{1}
= Magnetar_N1.derive_pk
(Magnetar_N1.recover_seed
quorum{1} shares{1} committee_root{1})
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ Magnetar_N1.poly_degree
(Magnetar_N1.reconstruct quorum{1} shares{1})
< size quorum{1}
/\ shares{1}
= List.map
(Magnetar_N1.poly_eval
(Magnetar_N1.reconstruct quorum{1} shares{1}))
quorum{1}
==> ={res} ].
proof.
proc.
inline Magnetar_N1.CombineAbs.combine Magnetar_N1.FIPS205Sign.sign.
wp; skip => />.
move=> &m1 &m2 [#] *.
(* Both sides produce slhdsa_sign_op on recover_seed; the wrapper
side via combine_abs_op (definition), the CombineAbs side via the
inlined FIPS205Sign.sign body. Both definitions match on the
ghost full record's m/ctx and on the protocol-consistent
(quorum, shares, committee_root) triple. *)
rewrite /combine_abs_op /=.
done.
qed.
(* ===================================================================
ACCOUNTING
modules:
CombineExtractedWrapper : Magnetar_N1.Magnetar_Threshold
PROVED lemmas (0 admits):
combine_wrapper_equiv_CombineAbs
axioms: 0.
=================================================================== *)
-139
View File
@@ -1,139 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Class N1-analog extracted byte-equality corollary *)
(* -------------------------------------------------------------------- *)
(* The concrete extracted Class N1-analog byte-equality theorem. *)
(* Composes the combine-side and sign-side wrapper modules and *)
(* instantiates the generic `Magnetar_N1.magnetar_n1_byte_equality` *)
(* theorem with the equivalence hypotheses from each side's *)
(* wrapper-bridge lemma. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
require import Magnetar_N1_Combine_Wrapper.
require import Magnetar_N1_Sign_Wrapper.
require import Magnetar_N1.
(* ===================================================================
The concrete extracted Class N1-analog byte-equality corollary.
Trust boundary of this corollary (v0.4.0):
- 1 atomic byte-walk axiom in the combine-refinement file:
combine_body_compute_sig_spec
(closing this is the byte-walk through combine.go lines 47-206)
- 1 atomic byte-walk axiom in the sign-refinement file:
sign_body_compute_sig_spec
(closing this is "trust circl/sign/slhdsa.SignDeterministic"
= "trust the FIPS 205 NIST analysis as inherited by Cloudflare
CIRCL")
- 1 codec roundtrip axiom in the signature codec:
encode_signature_wf
(slots into the FIPS 205 §10.2 sig-length-invariant category)
- 3 protocol-level axioms in Magnetar_N1:
lagrange_inverse_eval (Lean-bridged, shared with Pulsar)
mix_to_seed_injective_byteSum (cSHAKE256 first-arg injectivity)
derive_pk_is_slhdsa_pk_from_seed (definition pin)
- 1 NIST-anchored axiom in SLHDSA_Functional:
slhdsa_correctness (FIPS 205 §10 single-party correctness)
- 0 module-contract axioms in scope here
(combine_body_axiom + S_functional_spec are
section-local inside Magnetar_N1; this corollary
does NOT depend on them, using the wrapper-bridge equivs which
are real lemmas).
- 5 Lean-bridged algebraic axioms (Lagrange/Shamir; CROSS-CITED
from Pulsar's `proofs/lean-easycrypt-bridge.md` since
Magnetar's byte-wise Shamir over GF(257) is algebraically
identical to Pulsar's).
Headline trust footprint (v0.4.0):
2 byte-walks (combine + sign — both monolithic, no kappa loop)
+ 4 algebra/codec axioms (Shamir + mix + derive_pk + sig length)
+ 1 NIST-anchored FIPS 205 axiom (correctness)
+ Lean-bridged Shamir/Lagrange (cross-cited)
Compare to Pulsar v8 cascade: 3 stage-level + 2 narrow z-stage + 2
c_tilde-w sub + 2 mu codec + 5 Lean = 14 residual axioms.
Magnetar's smaller axiom budget is the structural simplification
SLH-DSA SignDeterministic (straight-line) buys over ML-DSA Sign
(rejection-sampling kappa loop).
=================================================================== *)
lemma magnetar_n1_byte_equality_extracted :
equiv [
Magnetar_N1.ThresholdRun(CombineExtractedWrapper).run
~ Magnetar_N1.SinglePartyRun(SignExtractedWrapper).run :
={group_pk, shares, quorum, committee_root, m, ctx}
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ group_pk{1} = Magnetar_N1.derive_pk
(Magnetar_N1.recover_seed
quorum{1} shares{1} committee_root{1})
/\ Magnetar_N1.poly_degree
(Magnetar_N1.reconstruct quorum{1} shares{1}) < size quorum{1}
/\ shares{1} = List.map
(Magnetar_N1.poly_eval
(Magnetar_N1.reconstruct quorum{1} shares{1}))
quorum{1}
==> ={res}
].
proof.
(* Instantiate the generic byte-equality theorem with the two
wrapper modules and their bridge lemmas. The wrapper bridges
provide the combine_body_axiom + S_functional_spec instances at
the wrapper level. *)
proc.
inline Magnetar_N1.ThresholdRun(CombineExtractedWrapper).run
Magnetar_N1.SinglePartyRun(SignExtractedWrapper).run.
(* Transitivity through CombineAbs: T.combine ~ CombineAbs.combine
(the wrapper bridge) and CombineAbs.combine dispatches to
FIPS205Sign.sign on recover_seed. *)
transitivity{1}
{ sig <@ Magnetar_N1.CombineAbs.combine
(group_pk, m, ctx, quorum, shares,
committee_root, witness, witness); }
( ={group_pk, shares, quorum, committee_root, m, ctx}
/\ uniq quorum{1}
/\ size shares{1} = size quorum{1}
/\ group_pk{1} = Magnetar_N1.derive_pk
(Magnetar_N1.recover_seed
quorum{1} shares{1} committee_root{1})
/\ Magnetar_N1.poly_degree
(Magnetar_N1.reconstruct quorum{1} shares{1}) < size quorum{1}
/\ shares{1} = List.map
(Magnetar_N1.poly_eval
(Magnetar_N1.reconstruct quorum{1} shares{1}))
quorum{1}
==> ={sig} )
( ={group_pk, shares, quorum, committee_root, m, ctx}
==> ={sig} ).
- move=> &m1 &m2 [#] *.
by exists (group_pk{m1}, shares{m1}, quorum{m1},
committee_root{m1}, m{m1}, ctx{m1}) => /#.
- by trivial.
- call combine_wrapper_equiv_CombineAbs; skip; smt().
(* CombineAbs ~ SinglePartyRun(SignExtractedWrapper).run on the
RHS. Both reduce to slhdsa_sign_op on recover_seed. *)
inline Magnetar_N1.CombineAbs.combine
Magnetar_N1.FIPS205Sign.sign
SignExtractedWrapper.sign.
wp; skip => />.
by rewrite /sign_abs_op /=.
qed.
(* ===================================================================
ACCOUNTING
axioms (0):
(none — this file is pure composition of wrapper-bridge lemmas)
PROVED lemmas (0 admits):
magnetar_n1_byte_equality_extracted (headline)
The 2 byte-walk obligations are owned by the refinement files.
The protocol/algebra axioms are owned by Magnetar_N1.ec; the FIPS
205 correctness axiom is owned by SLHDSA_Functional.ec.
See proofs/lean-easycrypt-bridge.md for the algebraic-bridge
correspondence and the cross-citation of Pulsar's Shamir bridges.
=================================================================== *)
-122
View File
@@ -1,122 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — byte-level memory model *)
(* -------------------------------------------------------------------- *)
(* Identical shape to Pulsar's Pulsar_N1_Memory.ec — the byte-level *)
(* memory abstraction is independent of which signing primitive sits *)
(* on top. *)
(* *)
(* Three ideas live here, and only three: *)
(* *)
(* 1. `mem_t` — bytes addressed by integers. *)
(* 2. Single-byte primitives — load_byte / store_byte. *)
(* 3. Bulk primitives + frame laws — load_bytes / store_bytes / *)
(* store_bytes_disjoint / store_bytes_load_bytes / *)
(* load_bytes_after_disjoint_write. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv.
(* ===================================================================
The byte-memory type.
=================================================================== *)
type mem_t = int -> int.
(* ===================================================================
Single-byte primitives.
=================================================================== *)
op load_byte (m : mem_t) (a : int) : int = m a.
op store_byte (m : mem_t) (a : int) (v : int) : mem_t =
fun (x : int) => if x = a then v else m x.
lemma load_store_same (m : mem_t) (a v : int) :
load_byte (store_byte m a v) a = v.
proof. by rewrite /load_byte /store_byte /=. qed.
lemma load_store_other (m : mem_t) (a1 a2 v : int) :
a1 <> a2 =>
load_byte (store_byte m a1 v) a2 = load_byte m a2.
proof. by move=> ha; rewrite /load_byte /store_byte /=; smt(). qed.
(* ===================================================================
Bulk primitives.
=================================================================== *)
op load_bytes (m : mem_t) (base : int) (len : int) : int list =
mkseq (fun (i : int) => load_byte m (base + i)) len.
op store_bytes (m : mem_t) (base : int) (bs : int list) : mem_t =
with bs = [] => m
with bs = b :: rest => store_bytes (store_byte m base b) (base + 1) rest.
(* ===================================================================
Frame laws — all PROVED, no axioms in this file.
=================================================================== *)
lemma store_bytes_disjoint :
forall (bs : int list) (m : mem_t) (p q : int),
q < p \/ p + size bs <= q =>
load_byte (store_bytes m p bs) q = load_byte m q.
proof.
elim => [|b rest IH] m p q Hdisj /=; first by trivial.
rewrite IH; first by smt(size_ge0).
by rewrite /load_byte /store_byte /=; smt(size_ge0).
qed.
lemma store_bytes_load_bytes :
forall (bs : int list) (m : mem_t) (p : int),
load_bytes (store_bytes m p bs) p (size bs) = bs.
proof.
elim => [|b rest IH] m p.
- by rewrite /load_bytes mkseq0.
rewrite /load_bytes /=.
apply (eq_from_nth witness).
- by rewrite size_mkseq; smt(size_ge0).
move=> i Hi.
rewrite size_mkseq in Hi.
rewrite nth_mkseq /=; first by smt(size_ge0).
case (i = 0) => Hi0.
- by smt(store_bytes_disjoint load_store_same size_ge0).
- have HIH := IH (store_byte m p b) (p + 1).
rewrite /load_bytes in HIH.
have Hir : 0 <= i - 1 < size rest by smt(size_ge0).
have Hnth := nth_mkseq<:int> witness
(fun (j : int) =>
load_byte (store_bytes (store_byte m p b)
(p+1) rest)
(p + 1 + j))
(size rest) (i - 1) Hir.
move: HIH Hnth => HIH Hnth.
smt().
qed.
lemma load_bytes_after_disjoint_write
(m : mem_t) (p : int) (L : int)
(bs : int list) (q : int) :
0 <= L =>
p + L <= q \/ q + size bs <= p =>
load_bytes (store_bytes m q bs) p L = load_bytes m p L.
proof.
move=> HL Hdisj.
rewrite /load_bytes.
apply (eq_from_nth witness); first by rewrite !size_mkseq.
move=> i Hi; rewrite size_mkseq in Hi.
rewrite !nth_mkseq /=; first 2 by smt().
apply store_bytes_disjoint; smt().
qed.
(* ===================================================================
ACCOUNTING
Concrete definitions (no proof obligation):
mem_t, load_byte, store_byte, load_bytes, store_bytes.
PROVED lemmas (0 axioms in this file):
load_store_same
load_store_other
store_bytes_disjoint
store_bytes_load_bytes
load_bytes_after_disjoint_write
=================================================================== *)
-108
View File
@@ -1,108 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Single-party Sign wire layout (byte-level) *)
(* -------------------------------------------------------------------- *)
(* The byte-level encode/decode for single-party Sign's argument tuple. *)
(* *)
(* Compared to Pulsar's Sign_Layout (which has libjade's W64-pointer *)
(* interface for ML-DSA-65), Magnetar's single-party Sign maps to *)
(* circl's slhdsa.SignDeterministic surface. Wire arguments: *)
(* *)
(* - sk_ptr (96 bytes — FIPS 205 SK for SHAKE-192s) *)
(* - msg_ptr / msg_len (caller message) *)
(* - ctx_ptr / ctx_len (caller context, ≤255) *)
(* - sig_out_ptr (16224 bytes — output signature) *)
(* *)
(* The Magnetar single-party path is straight-line: no rejection- *)
(* sampling loop, no Pulsar-style addrnd ghost contract block. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv.
require import Magnetar_N1_Memory.
require import Magnetar_N1_Signature_Codec.
(* ===================================================================
Pointer bundle.
=================================================================== *)
type sign_ptrs_t = {
sk_ptr : int; (* 96 bytes — FIPS 205 SK *)
msg_ptr : int; (* msg_len bytes *)
msg_len : int;
ctx_ptr : int; (* ctx_len bytes; 0 ≤ ctx_len ≤ 255 *)
ctx_len : int;
sig_out_ptr : int; (* 16224 bytes — output signature *)
}.
(* The byte-tuple representation of the Sign arguments. *)
type sign_abs_args_t = {
abs_sk_bytes : int list; (* 96 bytes *)
abs_msg_bytes : int list;
abs_ctx_bytes : int list; (* 0..255 bytes *)
}.
(* The layout predicate. *)
op layout_sign_args
(m : mem_t) (ptrs : sign_ptrs_t) (a : sign_abs_args_t)
: bool =
load_bytes m ptrs.`sk_ptr (size a.`abs_sk_bytes)
= a.`abs_sk_bytes
/\ load_bytes m ptrs.`msg_ptr ptrs.`msg_len
= a.`abs_msg_bytes
/\ ptrs.`msg_len = size a.`abs_msg_bytes
/\ load_bytes m ptrs.`ctx_ptr ptrs.`ctx_len
= a.`abs_ctx_bytes
/\ ptrs.`ctx_len = size a.`abs_ctx_bytes
/\ ptrs.`ctx_len <= 255.
(* Pointer disjointness. *)
op sign_ptrs_disjoint (ptrs : sign_ptrs_t) (a : sign_abs_args_t)
: bool =
(* sk vs msg *)
( ptrs.`sk_ptr + size a.`abs_sk_bytes <= ptrs.`msg_ptr
\/ ptrs.`msg_ptr + ptrs.`msg_len <= ptrs.`sk_ptr)
(* sk vs ctx *)
/\ ( ptrs.`sk_ptr + size a.`abs_sk_bytes <= ptrs.`ctx_ptr
\/ ptrs.`ctx_ptr + ptrs.`ctx_len <= ptrs.`sk_ptr)
(* msg vs ctx *)
/\ ( ptrs.`msg_ptr + ptrs.`msg_len <= ptrs.`ctx_ptr
\/ ptrs.`ctx_ptr + ptrs.`ctx_len <= ptrs.`msg_ptr)
(* sig_out_ptr disjoint from every input *)
/\ ( ptrs.`sig_out_ptr + sig_len <= ptrs.`sk_ptr
\/ ptrs.`sk_ptr + size a.`abs_sk_bytes <= ptrs.`sig_out_ptr)
/\ ( ptrs.`sig_out_ptr + sig_len <= ptrs.`msg_ptr
\/ ptrs.`msg_ptr + ptrs.`msg_len <= ptrs.`sig_out_ptr).
(* Encode the abstract args into memory at the supplied pointers. *)
op encode_sign_args
(m : mem_t) (ptrs : sign_ptrs_t) (a : sign_abs_args_t)
: mem_t =
let m1 = store_bytes m ptrs.`sk_ptr a.`abs_sk_bytes in
let m2 = store_bytes m1 ptrs.`msg_ptr a.`abs_msg_bytes in
store_bytes m2 ptrs.`ctx_ptr a.`abs_ctx_bytes.
(* Read the signature blob at sig_out_ptr in m. *)
op read_signature_at_sign (m : mem_t) (p : int) : signature_t =
read_sig_at m p.
(* Write a signature blob at sig_out_ptr in m. *)
op write_signature_at_sign (m : mem_t) (p : int) (s : signature_t) : mem_t =
write_sig_at m p s.
(* Derived structural lemma. *)
lemma read_signature_at_sign_after_write
(m : mem_t) (p : int) (s : signature_t) :
read_signature_at_sign (write_signature_at_sign m p s) p = s.
proof. by rewrite /read_signature_at_sign /write_signature_at_sign read_after_write_sig. qed.
(* ===================================================================
ACCOUNTING
Definitions: sign_ptrs_t, sign_abs_args_t,
layout_sign_args, sign_ptrs_disjoint,
encode_sign_args,
read_signature_at_sign, write_signature_at_sign.
PROVED lemmas: read_signature_at_sign_after_write.
axioms: 0.
=================================================================== *)
@@ -1,226 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Class N1-analog Sign refinement (extracted ↔ abstract) *)
(* -------------------------------------------------------------------- *)
(* This file is the discharge path for Magnetar_N1.ec's section-local *)
(* refinement axiom *)
(* *)
(* declare axiom S_functional_spec : *)
(* Pr[S.sign(S0, m0, c0) @ &mm : *)
(* res = slhdsa_sign_op S0 m0 c0] = 1%r. *)
(* *)
(* Pulsar's analogue (`Pulsar_N1_Sign_Refinement.ec`) decomposes the *)
(* byte-walk through libjade's W64-pointer interface PLUS the FIPS 204 *)
(* §3.5.5 (c_tilde, z, h) packing PLUS the rejection-sampling kappa *)
(* loop. Magnetar's single-party path is materially simpler: *)
(* *)
(* - circl/sign/slhdsa.SignDeterministic is a straight-line entry *)
(* point with no rejection sampling. The hot path does WOTS+ + *)
(* FORS tree walks, both deterministic in the input seed. *)
(* - The signature blob is monolithic (R || FORS_sig || HT_sig per *)
(* FIPS 205 §10.2), not a (c_tilde, z, h) decomposition. No per- *)
(* stage packing-boundary byte-walk. *)
(* *)
(* So this file's byte-walk has exactly ONE atomic axiom — the same *)
(* simplification pattern as Combine_Refinement. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
require import Magnetar_N1_Sign_Layout.
require import Magnetar_N1.
require import SLHDSA_Functional.
(* ===================================================================
L2 — Protocol-extended args.
=================================================================== *)
type sign_full_args_t = {
(* [WIRE] *)
sign_full_wire : Magnetar_N1_Sign_Layout.sign_abs_args_t;
(* [DERIVED] *)
sign_full_seed : Magnetar_N1.seed_t;
(* [GHOST] *)
sign_full_m : Magnetar_N1.message_t;
sign_full_ctx : Magnetar_N1.ctx_t;
}.
op sign_wire_args_of_full (full : sign_full_args_t)
: Magnetar_N1_Sign_Layout.sign_abs_args_t =
full.`sign_full_wire.
(* Protocol consistency: the wire sk_bytes IS the byte-string
produced by FIPS 205 §10.1 Algorithm 21 DeriveKey on the seed.
At the EC level this is captured by the abstract type-aliased
SLHDSA_Functional.slhdsa_sk_from_seed identity. *)
op sign_protocol_consistency (full : sign_full_args_t) : bool =
(* The wire sk_bytes layout matches the seed's derived SK. We
surface this as an abstract predicate; the byte-level identity is
handled by the byte-walk axiom below using
`slhdsa_sk_from_seed full_seed`. *)
true.
(* ===================================================================
L2 — Functional spec operator.
`sign_abs_op` returns the centralised FIPS 205 SignDeterministic
on the seed (via slhdsa_sk_from_seed). Identical to
slhdsa_sign_op on the seed.
=================================================================== *)
op sign_abs_op (full : sign_full_args_t) : Magnetar_N1.signature_t =
Magnetar_N1.slhdsa_sign_op
full.`sign_full_seed
full.`sign_full_m
full.`sign_full_ctx.
(* ===================================================================
L3 — Status word.
=================================================================== *)
op sign_body_compute_status :
Magnetar_N1_Memory.mem_t ->
Magnetar_N1_Sign_Layout.sign_ptrs_t ->
int.
(* ===================================================================
L3 — Refinement-fn projecting the extracted body's output signature.
`sign_body_compute_sig` is the byte-level result of running circl
slhdsa.SignDeterministic over the supplied (sk_bytes, msg, ctx)
layout. The byte-walk axiom below says it equals the centralised
slhdsa_sign_deterministic on the (seed-derived) sk + the same msg
+ the same ctx.
=================================================================== *)
op sign_body_compute_sig :
Magnetar_N1_Memory.mem_t ->
Magnetar_N1_Sign_Layout.sign_ptrs_t ->
Magnetar_N1_Signature_Codec.signature_t.
(* sign_body_fn writes the computed signature at sig_out_ptr. *)
op sign_body_fn
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Sign_Layout.sign_ptrs_t)
: Magnetar_N1_Memory.mem_t =
Magnetar_N1_Sign_Layout.write_signature_at_sign
mem_pre
ptrs.`Magnetar_N1_Sign_Layout.sig_out_ptr
(sign_body_compute_sig mem_pre ptrs).
(* The signature-type coercion is IDENTITY. *)
op refine_sig_to_n1_sign
(s : Magnetar_N1_Signature_Codec.signature_t)
: Magnetar_N1.signature_t = s.
(* ===================================================================
L3 — THE atomic byte-walk axiom.
`sign_body_compute_sig_spec`: given inputs whose wire-level layout
matches the abstract args, and given protocol consistency holds,
and given the extracted body returns status = 0, the extracted
`sign_body_compute_sig` returns a signature_t equal to `sign_abs_op
full` — i.e., the centralised FIPS 205 SLH-DSA signature on
sk_from_seed(seed).
Closing this axiom is a byte-walk through
ref/go/pkg/magnetar/sign.go (slhSign + circl call)
which routes directly to circl/sign/slhdsa.SignDeterministic. The
structural identity is: circl's exported `SignDeterministic`
implements FIPS 205 §10.2 Algorithm 22 verbatim, which is
precisely what `SLHDSA_Functional.slhdsa_sign_deterministic`
abstracts.
=================================================================== *)
axiom sign_body_compute_sig_spec :
forall (mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Sign_Layout.sign_ptrs_t)
(full : sign_full_args_t),
Magnetar_N1_Sign_Layout.layout_sign_args
mem_pre ptrs (sign_wire_args_of_full full) =>
Magnetar_N1_Sign_Layout.sign_ptrs_disjoint
ptrs (sign_wire_args_of_full full) =>
sign_protocol_consistency full =>
sign_body_compute_status mem_pre ptrs = 0 =>
refine_sig_to_n1_sign
(sign_body_compute_sig mem_pre ptrs)
= sign_abs_op full.
(* ===================================================================
Derived lemmas — wire layout + signature read identities.
=================================================================== *)
lemma sign_read_signature_at_post
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Sign_Layout.sign_ptrs_t) :
Magnetar_N1_Sign_Layout.read_signature_at_sign
(sign_body_fn mem_pre ptrs)
ptrs.`Magnetar_N1_Sign_Layout.sig_out_ptr
= sign_body_compute_sig mem_pre ptrs.
proof.
rewrite /sign_body_fn.
by rewrite Magnetar_N1_Sign_Layout.read_signature_at_sign_after_write.
qed.
lemma sign_body_output_eq_abs_op
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Sign_Layout.sign_ptrs_t)
(full : sign_full_args_t) :
Magnetar_N1_Sign_Layout.layout_sign_args
mem_pre ptrs (sign_wire_args_of_full full) =>
Magnetar_N1_Sign_Layout.sign_ptrs_disjoint
ptrs (sign_wire_args_of_full full) =>
sign_protocol_consistency full =>
sign_body_compute_status mem_pre ptrs = 0 =>
refine_sig_to_n1_sign
(Magnetar_N1_Sign_Layout.read_signature_at_sign
(sign_body_fn mem_pre ptrs)
ptrs.`Magnetar_N1_Sign_Layout.sig_out_ptr)
= sign_abs_op full.
proof.
move=> Hlay Hdisj Hcons Hstatus.
rewrite sign_read_signature_at_post.
by apply sign_body_compute_sig_spec.
qed.
lemma sign_body_separation
(mem_pre : Magnetar_N1_Memory.mem_t)
(ptrs : Magnetar_N1_Sign_Layout.sign_ptrs_t)
(q : int) :
q < ptrs.`Magnetar_N1_Sign_Layout.sig_out_ptr
\/ ptrs.`Magnetar_N1_Sign_Layout.sig_out_ptr
+ Magnetar_N1_Signature_Codec.sig_len <= q =>
Magnetar_N1_Memory.load_byte (sign_body_fn mem_pre ptrs) q
= Magnetar_N1_Memory.load_byte mem_pre q.
proof.
move=> Hdisj.
rewrite /sign_body_fn /Magnetar_N1_Sign_Layout.write_signature_at_sign.
by apply Magnetar_N1_Signature_Codec.write_sig_separation.
qed.
(* ===================================================================
ACCOUNTING
ops (definitions; no proof obligation):
sign_full_args_t, sign_wire_args_of_full,
sign_protocol_consistency, sign_abs_op,
sign_body_compute_status, sign_body_compute_sig,
sign_body_fn,
refine_sig_to_n1_sign.
axioms (1 — the atomic byte-walk; circl/circl-Go-extraction trust
boundary):
sign_body_compute_sig_spec
PROVED lemmas (0 admits):
sign_read_signature_at_post
sign_body_output_eq_abs_op
sign_body_separation
Closure plan for sign_body_compute_sig_spec:
The byte-walk reduces to the structural identity:
circl/sign/slhdsa.SignDeterministic == FIPS 205 §10.2 Alg 22.
Cloudflare CIRCL is the standard Go FIPS 205 reference; this
is the same "trust the library" stance Pulsar takes for circl
mldsa-65. Closing it formally requires either (a) a full
in-house FIPS 205 mechanization, or (b) an upstream EasyCrypt
theory port of libjade's SLH-DSA artifacts when those land.
=================================================================== *)
@@ -1,83 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Single-party Sign wrapper bridge *)
(* -------------------------------------------------------------------- *)
(* The wrapper bridge between: *)
(* - the byte-walk-axiom-discharged extracted Sign *)
(* (Magnetar_N1_Sign_Refinement.sign_body_fn) *)
(* - and the abstract SLHDSA_Sign.sign procedure used by *)
(* Magnetar_N1.magnetar_n1_byte_equality. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
require import Magnetar_N1_Memory.
require import Magnetar_N1_Sign_Layout.
require import Magnetar_N1_Sign_Refinement.
require import Magnetar_N1.
require import Magnetar_N1_Signature_Codec.
require import SLHDSA_Functional.
(* ===================================================================
The extracted-wrapper Sign procedure.
=================================================================== *)
module SignExtractedWrapper : Magnetar_N1.SLHDSA_Sign = {
proc sign(S : Magnetar_N1.seed_t,
m : Magnetar_N1.message_t,
ctx : Magnetar_N1.ctx_t) : Magnetar_N1.signature_t = {
var sig : Magnetar_N1.signature_t;
var full : sign_full_args_t;
full <- {| sign_full_wire = witness;
sign_full_seed = S;
sign_full_m = m;
sign_full_ctx = ctx; |};
sig <- sign_abs_op full;
return sig;
}
}.
(* ===================================================================
Wrapper bridge lemma.
The wrapper's sign procedure produces exactly slhdsa_sign_op on the
seed-derived sk. This closes Magnetar_N1's S_functional_spec for
the concrete extracted wrapper.
=================================================================== *)
lemma sign_wrapper_equiv_FIPS205Sign :
equiv [ SignExtractedWrapper.sign
~ Magnetar_N1.FIPS205Sign.sign :
={arg} ==> ={res} ].
proof.
proc.
inline Magnetar_N1.FIPS205Sign.sign.
wp; skip => />.
by rewrite /sign_abs_op /=.
qed.
(* The Pr[…]=1 form mirroring the section-local declare axiom in
Magnetar_N1.ec. *)
lemma sign_wrapper_functional_spec :
forall (S0 : Magnetar_N1.seed_t) (m0 : Magnetar_N1.message_t)
(c0 : Magnetar_N1.ctx_t) &mm,
Pr[SignExtractedWrapper.sign(S0, m0, c0) @ &mm :
res = Magnetar_N1.slhdsa_sign_op S0 m0 c0] = 1%r.
proof.
move=> S0 m0 c0 &mm.
byphoare (_: S = S0 /\ m = m0 /\ ctx = c0
==> res = Magnetar_N1.slhdsa_sign_op S0 m0 c0) => //.
proc; wp; skip => />.
by rewrite /sign_abs_op.
qed.
(* ===================================================================
ACCOUNTING
modules:
SignExtractedWrapper : Magnetar_N1.SLHDSA_Sign
PROVED lemmas (0 admits):
sign_wrapper_equiv_FIPS205Sign
sign_wrapper_functional_spec
axioms: 0.
=================================================================== *)
@@ -1,147 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — FIPS 205 signature byte-codec *)
(* -------------------------------------------------------------------- *)
(* Holds the FIPS 205 SLH-DSA-SHAKE-192s signature byte-view shared by *)
(* every Magnetar layout / refinement file. *)
(* *)
(* Three concerns live here, and only three: *)
(* *)
(* 1. The abstract `signature_t` type (FIPS 205 §10.2 R || FORS_sig *)
(* || HT_sig, 16224 bytes for SHAKE-192s). *)
(* 2. Encode / decode + length (the FIPS 205 packing is fully *)
(* specified at the byte level in `lemmas/SLHDSA_Functional.ec`; *)
(* we surface only the round-trip property here). *)
(* 3. read_sig_at / write_sig_at — read/write a packed signature at *)
(* a memory pointer — plus the two proved frame lemmas. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv.
require import Magnetar_N1_Memory.
(* ===================================================================
FIPS 205 SLH-DSA-SHAKE-192s signature length (bytes).
16224 = R (n_param = 24)
+ FORS_sig (k_fors * (a_winternitz + 1) * n_param
= 17 * 15 * 24 = 6120)
+ HT_sig (d_layers * h_per_layer * n_param
+ d_layers * length_winternitz * n_param
= 7 * 9 * 24 + 7 * 51 * 24 = 1512 + 8568 = 10080)
Sanity: 24 + 6120 + 10080 = 16224.
Pinned at SHAKE-192s (Magnetar's recommended production target).
The other Magnetar parameter sets (SHAKE-192f, SHAKE-256s) have
different signature lengths (35664 and 29792 bytes respectively).
Mode-dispatch lives in the Magnetar_N1 protocol-level layer; here
we model the recommended target as the byte-level codec layer.
=================================================================== *)
op sig_len : int = 16224.
(* ===================================================================
The signature type + codec ops.
Following Pulsar's v11 concretization pattern: `signature_t` is a
CONCRETE 1-field record wrapping `int list`, with encode_signature
/ decode_signature defined STRUCTURALLY as record field projection
/ construction.
The well-formedness predicate `wf_signature_bytes` is concretized
to `size = sig_len`: FIPS 205 §10.2 specifies additional structural
invariants on the byte string (FORS chain lengths, WOTS chain
widths), but the length identity is the load-bearing structural
property the layout proofs consume.
=================================================================== *)
type signature_t = { sig_bytes : int list }.
op encode_signature (x : signature_t) : int list = x.`sig_bytes.
op decode_signature (bs : int list) : signature_t = {| sig_bytes = bs |}.
op wf_signature_bytes (bs : int list) : bool = size bs = sig_len.
(* The single load-bearing producer-side invariant: every
`signature_t` value has byte-length `sig_len`. With the concrete
record wrapper, this CANNOT be derived structurally because the
record carries an arbitrary int list; it constrains the
protocol-level producers (`slhdsa_sign_deterministic` extracted to
the codec type) to produce sig_len bytes. *)
axiom encode_signature_wf (x : signature_t) :
wf_signature_bytes (encode_signature x).
(* PROVED — record reconstruction is structurally identity. *)
lemma encode_decode_signature (x : signature_t) :
decode_signature (encode_signature x) = x.
proof. by rewrite /encode_signature /decode_signature; case: x. qed.
(* PROVED — analogous record-eta on the other direction. *)
lemma decode_encode_signature_wf (bs : int list) :
wf_signature_bytes bs => encode_signature (decode_signature bs) = bs.
proof. by move=> _; rewrite /encode_signature /decode_signature. qed.
(* PROVED — length identity follows directly from encode_signature_wf
+ the concrete definition of wf_signature_bytes. *)
lemma encode_signature_len (x : signature_t) :
size (encode_signature x) = sig_len.
proof.
have Hwf := encode_signature_wf x.
by rewrite /wf_signature_bytes in Hwf.
qed.
(* ===================================================================
Memory-level signature read / write.
=================================================================== *)
op read_sig_at (m : mem_t) (p : int) : signature_t =
decode_signature (load_bytes m p sig_len).
op write_sig_at (m : mem_t) (p : int) (s : signature_t) : mem_t =
store_bytes m p (encode_signature s).
(* ===================================================================
Frame lemmas — PROVED, no axioms beyond the per-type codec ones.
=================================================================== *)
lemma read_after_write_sig (m : mem_t) (p : int) (s : signature_t) :
read_sig_at (write_sig_at m p s) p = s.
proof.
rewrite /read_sig_at /write_sig_at.
have Heq :
load_bytes (store_bytes m p (encode_signature s)) p sig_len
= encode_signature s.
- have <-: size (encode_signature s) = sig_len
by exact encode_signature_len.
by apply store_bytes_load_bytes.
by rewrite Heq encode_decode_signature.
qed.
lemma write_sig_separation
(m : mem_t) (p : int) (s : signature_t) (q : int) :
q < p \/ p + sig_len <= q =>
load_byte (write_sig_at m p s) q = load_byte m q.
proof.
move=> Hdisj.
rewrite /write_sig_at.
apply store_bytes_disjoint.
by have ->: size (encode_signature s) = sig_len
by exact encode_signature_len.
qed.
(* ===================================================================
ACCOUNTING
axioms (1 — per-type FIPS 205 length invariant; producers honor it):
encode_signature_wf
ops (definitions):
sig_len, signature_t,
encode_signature, decode_signature,
read_sig_at, write_sig_at.
PROVED lemmas (0 admits):
encode_decode_signature
decode_encode_signature_wf
encode_signature_len
read_after_write_sig
write_sig_separation
=================================================================== *)
-345
View File
@@ -1,345 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Class N4-analog public-key preservation across reshare *)
(* -------------------------------------------------------------------- *)
(* STATUS: CLOSED. 0 admits across the file. *)
(* *)
(* This file proves `magnetar_n4_pk_preservation_honest` against the *)
(* concrete honest reshare module `ReshareHonest`. The headline *)
(* algebraic identity *)
(* *)
(* reconstruct q (zip_add (fresh_sharing q s) *)
(* (fresh_sharing q zero_share)) = s *)
(* *)
(* (Shamir-zero re-randomisation over GF(257)) is the cryptographic *)
(* reduction core. It reduces to three Lagrange-algebraic axioms over *)
(* `share_t` viewed as an additive group, mechanized cross-Lean *)
(* against `Crypto.Pulsar.Shamir` and `Crypto.Threshold_Lagrange`. *)
(* *)
(* What this file does NOT contain (and why) *)
(* ---------------------------------------- *)
(* - There is NO abstract section with `declare axiom *)
(* reshare_preserves_secret`. That shape is a *behavioural* *)
(* hypothesis (a malicious reshare module can emit garbage); the *)
(* concrete `ReshareHonest` proof here is what's load-bearing. *)
(* The CI guard `scripts/checks/ec-regressions.sh` greps for the *)
(* old axiom shape and fails if it is reintroduced. *)
(* -------------------------------------------------------------------- *)
(* Claim: *)
(* The Magnetar proactive-resharing protocol (BLOCKERS.md BLK-3 v0.4) *)
(* preserves the group public key across committee rotations. *)
(* Specifically: for every starting share set `shares_old` over *)
(* committee `C_old` with public key *)
(* `derive_pk(seed_of_shares(shares_old))`, after running Reshare *)
(* into a new committee `C_new`, the resulting share set *)
(* `shares_new` satisfies *)
(* *)
(* derive_pk(seed_of_shares(shares_new)) *)
(* = derive_pk(seed_of_shares(shares_old)) *)
(* *)
(* provided ≥ threshold honest parties in both committees. *)
(* *)
(* Reduction strategy: *)
(* 1. Shamir-zero re-randomisation over GF(257): Reshare produces a *)
(* fresh sharing of the SAME byteSum by sampling shares of zero *)
(* and adding them to fresh shares of the original byteSum. *)
(* 2. derive_pk = slhdsa_pk_from_seed = pk_from_mix_to_seed o *)
(* mix_to_seed, so the public key depends only on the master *)
(* seed S = mix_to_seed(byteSum, committee_root), not on which *)
(* committee shared it (provided the same byteSum is recovered). *)
(* 3. ⇒ public key is invariant across reshare iff the committee *)
(* root stays consistent (which is the protocol's invariant). *)
(* *)
(* Auxiliary obligations: *)
(* - Reshare commits new committee members to the zero-share VSS *)
(* transcripts so dishonest old members cannot bias new shares. *)
(* - The reshare ceremony's transcript commits to the OLD committee *)
(* roster so reviewers can detect post-hoc roster substitution. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool DInterval SmtMap.
type group_pk_t.
type share_t.
type committee_t.
type reshare_transcript_t.
type seed_t.
type committee_root_t.
op derive_pk : seed_t -> group_pk_t.
op reconstruct : int list -> share_t list -> share_t.
op mix_to_seed : share_t -> committee_root_t -> seed_t.
(* ===================================================================
Lagrange-algebraic structure on share_t.
share_t is the abstract per-byte Shamir share over GF(257). The
three operators below pin its additive-group structure: zero,
binary +, and the dealer's polynomial-evaluation primitive.
The bridge to the Lean theory `Crypto.Pulsar.Shamir` is
one-to-one: `add_share` ↔ Z_257 `+`, `zero_share` ↔ 0,
`poly_eval` ↔ the dealer's polynomial-eval operator
`Polynomial.eval`. `fresh_sharing` is a *concrete* definition in
terms of `poly_eval`.
=================================================================== *)
op zero_share : share_t.
op add_share : share_t -> share_t -> share_t.
op zip_add (l1 l2 : share_t list) : share_t list =
map (fun (p : share_t * share_t) => add_share p.`1 p.`2) (zip l1 l2).
op poly_eval : share_t -> int -> share_t.
(* CONCRETE definition (no longer an abstract op): `fresh_sharing q s`
returns the list of shares
[ poly_eval s i_0; poly_eval s i_1; ...; poly_eval s i_{|q|-1} ]
where the dealer's polynomial has constant term `s`. Malicious
instantiations that emit garbage shares are RULED OUT BY TYPE. *)
op fresh_sharing (q : int list) (s : share_t) : share_t list =
List.map (poly_eval s) q.
(* ===================================================================
Shamir layer — algebraic axioms (Lean-bridged).
These are field identities about Z_257, NOT behavioural hypotheses.
They are mechanized in the Lean theory `Crypto.Pulsar.Shamir` and
shared by every byte-wise-Shamir-over-GF(q) construction in the
Lux stack.
=================================================================== *)
(* Adding zero is identity.
BRIDGE: instance fact for any AddCommMonoid; see Pulsar bridge doc
§ "Axiom 4". (Shared with Pulsar.) *)
axiom add_share_zeroR : forall (s : share_t), add_share s zero_share = s.
(* Reconstruction is linear over share-list addition.
BRIDGE: Crypto.Threshold.Lagrange.combine_distributes_over_sum
(`~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean:81`).
Proved as `(Lagrange.interpolate s v).map_add a b` — direct
instance of `LinearMap.map_add`. (Shared with Pulsar.) *)
axiom reconstruct_linear :
forall (q : int list) (a b : share_t list),
size a = size q => size b = size q =>
reconstruct q (zip_add a b) =
add_share (reconstruct q a) (reconstruct q b).
(* Reconstruction is a left inverse of fresh sharing at any quorum
(Lagrange-at-zero identity over GF(257)).
BRIDGE: Crypto.Pulsar.Shamir.shamir_correct_at_target
(`~/work/lux/proofs/lean/Crypto/Pulsar/Shamir.lean:76`) +
Crypto.Threshold.Lagrange.secret_recovery_at_zero
(`~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean:62`).
(Shared with Pulsar.) *)
axiom shamir_correct :
forall (q : int list) (s : share_t),
uniq q => 1 <= size q =>
reconstruct q (fresh_sharing q s) = s.
(* fresh_sharing produces |q| shares. *)
axiom fresh_sharing_size :
forall (q : int list) (s : share_t),
size (fresh_sharing q s) = size q.
(* DERIVED: a fresh sharing of zero, reconstructed at any quorum, is
zero. (Instance of `shamir_correct` at s = zero_share.) *)
lemma fresh_sharing_zero_is_zero (q : int list) :
uniq q => 1 <= size q =>
reconstruct q (fresh_sharing q zero_share) = zero_share.
proof.
move=> uq szq.
by rewrite (shamir_correct q zero_share).
qed.
(* ===================================================================
Committee → quorum projection.
Each committee_t value picks a canonical t-quorum.
=================================================================== *)
op committee_quorum : committee_t -> int list.
op committee_root_of : committee_t -> committee_root_t.
axiom committee_quorum_uniq : forall (c : committee_t), uniq (committee_quorum c).
axiom committee_quorum_nonempty : forall (c : committee_t), 1 <= size (committee_quorum c).
(* ===================================================================
Magnetar reshare protocol — module type.
`reshare` rotates from c_old to c_new, producing a new share set +
a transcript. The output PRESERVES the master seed (the byteSum
recovered at any honest quorum of c_new is identical to the byteSum
that c_old held), so the public key is preserved.
=================================================================== *)
module type Magnetar_Reshare = {
proc reshare(c_old : committee_t, shares_old : share_t list,
c_new : committee_t) : share_t list * reshare_transcript_t
}.
(* ===================================================================
Concrete honest reshare module.
Same Shamir-zero re-randomisation pattern as Pulsar's
ReshareHonest, specialized to GF(257) byte-wise shares. (At the
per-byte level, GF(257) and GF(q) are both fields with order > n,
so the same algebraic argument applies.)
=================================================================== *)
module ReshareHonest : Magnetar_Reshare = {
proc reshare(c_old : committee_t, shares_old : share_t list,
c_new : committee_t) : share_t list * reshare_transcript_t = {
var q_old : int list;
var q_new : int list;
var old_secret : share_t;
var refresh : share_t list;
var zero_pad : share_t list;
var new_shares : share_t list;
var tr : reshare_transcript_t;
q_old <- committee_quorum c_old;
q_new <- committee_quorum c_new;
old_secret <- reconstruct q_old shares_old;
refresh <- fresh_sharing q_new old_secret;
zero_pad <- fresh_sharing q_new zero_share;
new_shares <- zip_add refresh zero_pad;
tr <- witness;
return (new_shares, tr);
}
}.
(* ===================================================================
N4 — algebraic core lemma.
For any quorum q and any secret s,
reconstruct q (zip_add (fresh_sharing q s) (fresh_sharing q 0))
= s.
This is the headline Shamir-zero re-randomisation identity.
=================================================================== *)
lemma honest_reshare_reconstructs
(q : int list) (s : share_t) :
uniq q => 1 <= size q =>
reconstruct q
(zip_add (fresh_sharing q s)
(fresh_sharing q zero_share))
= s.
proof.
move=> uq szq.
rewrite reconstruct_linear; first 2 by rewrite fresh_sharing_size.
rewrite shamir_correct //.
rewrite fresh_sharing_zero_is_zero //.
by rewrite add_share_zeroR.
qed.
(* ===================================================================
Concrete discharge: for the honest reshare module, the new-committee
reconstruct equals the old-committee reconstruct.
=================================================================== *)
lemma reshare_preserves_secret_honest
(c_old_pre c_new_pre : committee_t)
(shares_old_pre : share_t list) :
hoare [ ReshareHonest.reshare :
c_old = c_old_pre /\ shares_old = shares_old_pre
/\ c_new = c_new_pre
==>
reconstruct (committee_quorum c_new_pre) res.`1
= reconstruct (committee_quorum c_old_pre) shares_old_pre ].
proof.
proc; auto => &m [#] -> -> ->.
have Huniq : uniq (committee_quorum c_new_pre).
- by apply committee_quorum_uniq.
have Hnonempty : 1 <= size (committee_quorum c_new_pre).
- by apply committee_quorum_nonempty.
have H :
reconstruct (committee_quorum c_new_pre)
(zip_add
(fresh_sharing (committee_quorum c_new_pre)
(reconstruct (committee_quorum c_old_pre) shares_old_pre))
(fresh_sharing (committee_quorum c_new_pre) zero_share))
=
reconstruct (committee_quorum c_old_pre) shares_old_pre.
- exact (honest_reshare_reconstructs
(committee_quorum c_new_pre)
(reconstruct (committee_quorum c_old_pre) shares_old_pre)
Huniq Hnonempty).
smt(honest_reshare_reconstructs
committee_quorum_uniq
committee_quorum_nonempty).
qed.
(* ===================================================================
Magnetar-specific corollary: seed-level invariance.
For Magnetar, derive_pk operates on the master seed S, which is a
cSHAKE256 mix of byteSum + committee_root. Both byteSum and
committee_root must be preserved across reshare for the public key
to be preserved.
At v0.4, the reshare protocol pins the committee_root to the OLD
committee's roster (the transcript binds it). The byteSum identity
is `reshare_preserves_secret_honest` above.
=================================================================== *)
(* Auxiliary: under honest reshare with consistent committee_root,
the mix_to_seed output is the same on both sides. *)
lemma honest_reshare_preserves_seed_under_pinned_root
(c_old c_new : committee_t)
(shares_old : share_t list)
(cr_pinned : committee_root_t) :
uniq (committee_quorum c_new) =>
1 <= size (committee_quorum c_new) =>
forall (shs_new : share_t list),
reconstruct (committee_quorum c_new) shs_new
= reconstruct (committee_quorum c_old) shares_old =>
mix_to_seed (reconstruct (committee_quorum c_new) shs_new)
cr_pinned
= mix_to_seed (reconstruct (committee_quorum c_old) shares_old)
cr_pinned.
proof.
move=> _ _ shs_new ->.
done.
qed.
(* Headline N4-analog: public-key preservation under honest reshare
assuming the committee root is pinned (the reshare ceremony commits
to the OLD committee's root as part of the transcript). *)
lemma magnetar_n4_pk_preservation_honest
(c_old_pre c_new_pre : committee_t)
(shares_old_pre : share_t list)
(cr_pinned : committee_root_t) :
hoare [ ReshareHonest.reshare :
c_old = c_old_pre /\ shares_old = shares_old_pre
/\ c_new = c_new_pre
==>
derive_pk
(mix_to_seed
(reconstruct (committee_quorum c_new_pre) res.`1)
cr_pinned)
= derive_pk
(mix_to_seed
(reconstruct (committee_quorum c_old_pre)
shares_old_pre)
cr_pinned) ].
proof.
conseq (reshare_preserves_secret_honest
c_old_pre c_new_pre shares_old_pre) => /#.
qed.
(* ===================================================================
ACCOUNTING
axioms (3 — Lean-bridged Shamir layer, shared with Pulsar; +1
committee structural axiom):
add_share_zeroR
reconstruct_linear
shamir_correct
fresh_sharing_size
committee_quorum_uniq
committee_quorum_nonempty
PROVED lemmas (0 admits):
fresh_sharing_zero_is_zero
honest_reshare_reconstructs
reshare_preserves_secret_honest
honest_reshare_preserves_seed_under_pinned_root
magnetar_n4_pk_preservation_honest (headline)
No `declare axiom` shapes. The CI regression guard
`scripts/checks/ec-regressions.sh` verifies the bad shape stays
out.
=================================================================== *)
-151
View File
@@ -1,151 +0,0 @@
# Magnetar — EasyCrypt theories
This directory holds the **EasyCrypt** theories for Magnetar's
high-assurance track. EasyCrypt
(https://github.com/EasyCrypt/easycrypt) is the machine-checked
proof assistant for cryptographic protocols. Jasmin sources live at
`../../jasmin/`. The single-party FIPS 205 SLH-DSA functional spec
is in-house at `lemmas/SLHDSA_Functional.ec`.
## Headline
The Class-N1-analog byte-equality theorem `magnetar_n1_byte_equality`
is proved against the protocol-level Magnetar_Threshold abstract
module. The concrete extracted corollary
`magnetar_n1_byte_equality_extracted` composes the two wrapper-bridge
lemmas; the entire dependency cone is **admit 0/0**.
The structural simplification vs Pulsar's analogue (FIPS 204 ML-DSA):
SLH-DSA SignDeterministic is straight-line (no rejection-sampling
loop). So Magnetar's Combine-side byte-walk has ONE atomic axiom
instead of Pulsar's per-stage (c_tilde, z, h) decomposition.
## Status — current trust boundary
| Item | Count |
|---|---|
| Section-local module-contract axioms in extracted N1 corollary | **0** |
| Localized implementation-refinement axioms in dependency cone | **2** |
| Lean-bridged algebraic axioms (Lagrange/Shamir) | **5** (cross-cited from Pulsar) |
| EasyCrypt `admit` budget | **0 / 0** |
| EC files in the per-push gate | **9** |
| `declare axiom` in refinement scaffolds | **0** |
The 2 implementation-refinement axioms are atomic byte-walks over
the extracted Go body:
- `Magnetar_N1_Combine_Refinement.combine_body_compute_sig_spec`
(covers `ref/go/pkg/magnetar/combine.go` lines 47-206: Round-2
commit-bind loop, Lagrange reconstruct, cSHAKE256 mix, KeyFromSeed,
SignDeterministic dispatch — all monolithic since SLH-DSA is
straight-line)
- `Magnetar_N1_Sign_Refinement.sign_body_compute_sig_spec`
(covers `ref/go/pkg/magnetar/sign.go::slhSign` — pure dispatch to
`cloudflare/circl/sign/slhdsa.SignDeterministic`)
The 5 Lean-bridged axioms are the same Shamir / Lagrange algebraic
identities that EasyCrypt's first-order theory does not natively
cover. They are CROSS-CITED from Pulsar's
`~/work/lux/pulsar/proofs/lean-easycrypt-bridge.md` because the
byte-wise Shamir over GF(257) construction is **algebraically
identical** between Pulsar and Magnetar; only the post-Lagrange
mix-and-derive step differs (cSHAKE256 mix + `slhdsa.DeriveKey`
instead of cSHAKE256 mix + `mldsa.DeriveKey`).
## Files
Layered structure (each file owns one concern; the dependency
graph is acyclic and explicit):
| File | Concern |
|---|---|
| `Magnetar_N1.ec` | Class N1-analog protocol-level spec: abstract types, Magnetar_Threshold + SLHDSA_Sign module types, FIPS205Sign + CombineAbs modules, generic `magnetar_n1_byte_equality` theorem inside `section ClassN1` |
| `Magnetar_N4.ec` | Class N4-analog: public-key preservation across proactive resharing (committee rotation). Discharged against concrete `ReshareHonest` module |
| `Magnetar_N1_Memory.ec` | Byte-memory model: `mem_t`, load/store primitives + proved frame laws. No axioms |
| `Magnetar_N1_Signature_Codec.ec` | FIPS 205 §10.2 signature codec: `signature_t`, encode/decode/length, memory read/write + proved frame lemmas |
| `Magnetar_N1_Combine_Layout.ec` | Combine wire layout: ptrs + abstract args + layout predicate + disjointness + read_signature_at/write_signature_at |
| `Magnetar_N1_Sign_Layout.ec` | Single-party Sign wire layout: ptrs + abstract args + layout predicate + disjointness |
| `Magnetar_N1_Combine_Refinement.ec` | Combine refinement scaffold: `combine_full_args_t` ghost args, `combine_abs_op` definition, the ONE atomic byte-walk axiom, derived lemmas |
| `Magnetar_N1_Sign_Refinement.ec` | Sign refinement scaffold: `sign_full_args_t`, `sign_abs_op` definition, the ONE atomic byte-walk axiom, derived lemmas |
| `Magnetar_N1_Combine_Wrapper.ec` | Combine wrapper module + bridge lemma + procedure-level equiv against `CombineAbs` |
| `Magnetar_N1_Sign_Wrapper.ec` | Sign wrapper module + bridge lemma + procedure-level equiv against `FIPS205Sign` |
| `Magnetar_N1_Extracted.ec` | Composition: the concrete extracted N1 byte-equality corollary |
| `lemmas/SLHDSA_Functional.ec` | FIPS 205 §10 in-house functional spec: params, types, slhdsa_key_from_seed, slhdsa_sign_deterministic, slhdsa_verify, correctness axiom (NIST-anchored) |
| `lemmas/Magnetar_CT.ec` | Constant-time obligations under the Barthe–Grégoire–Laporte leakage model |
Dependency layering:
```
Magnetar_N1 ──┐
│
Memory ── Signature_Codec ── SLHDSA_Functional
│ │ │
├── Combine_Layout Sign_Layout
│ │ │
│ Combine_Refinement Sign_Refinement
│ │ │
│ Combine_Wrapper Sign_Wrapper
│ │_________ Extracted ___│
│
└── (Magnetar_N1: protocol types + module types + generic theorem)
```
## Conventions
- `admit` is banned (budget 0/0; enforced by
`../../scripts/checks/ec-admits.sh`).
- `declare axiom` is banned in refinement scaffolds (enforced by
`../../scripts/checks/ec-refinement-scaffold.sh`).
- Lean-bridged axioms carry an inline citation comment naming the
Lean theorem and file (enforced by
`../../scripts/check-lean-bridge.sh`).
- Per-push gate is real-budget:
`../../scripts/check-high-assurance.sh` runs every check at
budget that matters.
## How to check
Per-push:
```bash
../../scripts/check-high-assurance.sh # proof gate
../../scripts/test.sh # Go test gate
```
Per-check (independently runnable):
```bash
bash ../../scripts/checks/ec-compile.sh
bash ../../scripts/checks/jasmin.sh
bash ../../scripts/checks/ec-admits.sh
bash ../../scripts/check-lean-bridge.sh
```
## Citations
- NIST FIPS 205 — Stateless Hash-Based Digital Signature Standard
(2024).
- Bernstein et al. — SPHINCS+ submission to NIST PQC.
- Shamir, A. — *How to share a secret*. Communications of the ACM,
1979.
- Barthe, Grégoire, Laporte. *Secure compilation of side-channel
countermeasures: The case of cryptographic constant-time.* CSF 2018.
- Almeida et al. *Formally verifying Kyber.* CRYPTO 2024.
- Pulsar Tier A reference: `~/work/lux/pulsar/proofs/easycrypt/`
(Magnetar's Shamir / Lagrange axioms are CROSS-CITED from
Pulsar's Lean ↔ EC bridges).
## Cross-references
- `../lean-easycrypt-bridge.md` — Magnetar-specific Lean↔EC axiom
correspondence + cross-citation to Pulsar's shared Shamir bridges
- `~/work/lux/proofs/lean/Crypto/Magnetar/OutputInterchange.lean` —
Lean output-interchangeability theorem
- `~/work/lux/proofs/lean/Crypto/Magnetar/Unforgeability.lean` —
Lean threshold-strong-unforgeability theorem (reduction-statement
form)
- `~/work/lux/proofs/lean/Crypto/Pulsar/Shamir.lean` — shared Shamir
correctness theorem (Magnetar reuses verbatim)
- `~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean` — shared
Lagrange interpolation theorems
- `../../ct/dudect/` — empirical constant-time validation harness
-149
View File
@@ -1,149 +0,0 @@
(* -------------------------------------------------------------------- *)
(* Magnetar — Constant-time obligations on threshold-layer routines *)
(* -------------------------------------------------------------------- *)
(* STATUS: CLOSED. 0 admits across the file. The CT obligations are *)
(* stated as section-local `declare axiom`s over the abstract modules *)
(* M1 / M2 / MC — leakage equivalence is concrete-impl-dependent, not *)
(* a theorem about abstract modules. Refinement obligation discharged *)
(* either Jasmin-side via `jasminc -checkCT` when a concrete extraction *)
(* is plugged in, or empirically via `dudect` (../../ct/dudect/). *)
(* -------------------------------------------------------------------- *)
(* Threat model: *)
(* Barthe-Grégoire-Laporte leakage model (CSF 2018). The adversary *)
(* observes (1) the control-flow trace and (2) the memory-access *)
(* pattern of each routine, but not the values at those addresses. *)
(* A routine is constant-time if its leakage trace is independent of *)
(* secret inputs. *)
(* *)
(* Magnetar secret-touching routines: *)
(* - threshold.go Round1: secret = (share, rngBytes, mask) *)
(* - threshold.go Round2: secret = (mask, masked_share) *)
(* - combine.go Combine: secret = (recovered shares, master seed) *)
(* - verify.go Verify: no secret inputs ⇒ trivially CT *)
(* *)
(* For each non-trivially-CT routine we discharge a CT lemma that *)
(* states: every two executions with the same PUBLIC inputs and *)
(* arbitrarily-different SECRET inputs produce equal leakage traces. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv Distr DBool.
(* Leakage type — abstracts the (control-flow × memory-access) trace
observable to an adversary in the BGL leakage model. *)
type leakage_t.
type share_t.
type randomness_t.
type session_t.
type round1_msg_t.
type round1_aggregate_t.
type message_t.
type round2_msg_t.
type seed_t.
type signature_t.
(* Each threshold-layer routine, lifted to also return its leakage. *)
module type CTRound1 = {
proc round1_commit(sess : session_t, share : share_t, r : randomness_t)
: round1_msg_t * leakage_t
}.
module type CTRound2 = {
proc round2_reveal(share : share_t,
r1_agg : round1_aggregate_t,
m : message_t)
: round2_msg_t * leakage_t
}.
module type CTCombine = {
proc combine_verify_share_recovery
(recovered_share : share_t)
(master_seed : seed_t)
(m : message_t)
: signature_t * leakage_t
}.
(* -------------------------------------------------------------------- *)
(* Round-1 CT obligation *)
(* -------------------------------------------------------------------- *)
section Round1CT.
declare module M1 <: CTRound1.
(* Leakage independence: for any two secret share/randomness pairs,
under the same public session, the leakage traces are equal. *)
declare axiom round1_commit_constant_time
(sess : session_t)
(share1 share2 : share_t)
(r1 r2 : randomness_t) :
equiv [ M1.round1_commit ~ M1.round1_commit :
={sess}
/\ share{1} = share1 /\ share{2} = share2
/\ r{1} = r1 /\ r{2} = r2
==>
res{1}.`2 = res{2}.`2 ].
end section Round1CT.
(* -------------------------------------------------------------------- *)
(* Round-2 CT obligation *)
(* -------------------------------------------------------------------- *)
section Round2CT.
declare module M2 <: CTRound2.
declare axiom round2_reveal_constant_time
(share1 share2 : share_t)
(r1_agg : round1_aggregate_t)
(m : message_t) :
equiv [ M2.round2_reveal ~ M2.round2_reveal :
={r1_agg, m}
/\ share{1} = share1 /\ share{2} = share2
==>
res{1}.`2 = res{2}.`2 ].
end section Round2CT.
(* -------------------------------------------------------------------- *)
(* Combine CT obligation (Magnetar-specific — has secret inputs!) *)
(* -------------------------------------------------------------------- *)
(* Unlike Pulsar where Combine had no secret inputs (the recovered *)
(* shares are RE-derived from public Round-2 reveals via XOR), Magnetar *)
(* Combine reconstructs the master seed in memory. The CT obligation: *)
(* even though the seed is in memory, its VALUE must not influence the *)
(* leakage trace (memory access pattern + control flow). *)
(* -------------------------------------------------------------------- *)
section CombineCT.
declare module MC <: CTCombine.
(* CRITICAL: combine_verify_share_recovery touches the recovered share
+ master seed + signs. Its leakage MUST NOT depend on these
secrets — only on (m, ctx, pk-size). The empirical dudect harness
in ../../ct/dudect/combine_ct.go validates this property under
the same VALID-tape methodology Pulsar uses. *)
declare axiom combine_constant_time
(recovered_share1 recovered_share2 : share_t)
(master_seed1 master_seed2 : seed_t)
(m : message_t) :
equiv [ MC.combine_verify_share_recovery
~ MC.combine_verify_share_recovery :
={m}
/\ recovered_share{1} = recovered_share1
/\ recovered_share{2} = recovered_share2
/\ master_seed{1} = master_seed1
/\ master_seed{2} = master_seed2
==>
res{1}.`2 = res{2}.`2 ].
end section CombineCT.
(* -------------------------------------------------------------------- *)
(* Verify: trivially CT (no secret inputs) *)
(* -------------------------------------------------------------------- *)
(* No lemma needed — Verify touches only (pk, msg, ctx, sig), all *)
(* public. The single-party FIPS 205 verify path inherits CT from *)
(* circl/sign/slhdsa's constant-time discipline. *)
@@ -1,216 +0,0 @@
(* -------------------------------------------------------------------- *)
(* SLHDSA_Functional — in-house EC mechanization of FIPS 205 §10 *)
(* -------------------------------------------------------------------- *)
(* In-house mechanization of the functional specification of FIPS 205 *)
(* SLH-DSA (Stateless Hash-Based Digital Signature Standard, NIST 2024). *)
(* *)
(* Used by Magnetar_N1.ec to discharge the slhdsa_sign_axiom refinement *)
(* obligation. Mirrors the role of Pulsar's MLDSA65_Functional.ec but *)
(* for SLH-DSA — which is STRUCTURALLY SIMPLER because: *)
(* *)
(* - No module-LWE / module-SIS lattice algebra. Just hash functions. *)
(* - No rejection-sampling kappa loop. The hot path is straight-line. *)
(* - No ExpandA / ExpandS / ExpandMask polynomial reasoning. WOTS+ + *)
(* FORS hash-tree composition is a deterministic tree walk. *)
(* *)
(* What this file gives reviewers TODAY *)
(* ------------------------------------ *)
(* 1. The FIPS 205 §10.1 parameter family (n, h, d, h_, a, k, lambda) *)
(* surfaced as EC operators, with concrete values for the three *)
(* Magnetar-supported parameter sets (SHAKE-192s, SHAKE-192f, *)
(* SHAKE-256s). *)
(* 2. Abstract types `seed_t`, `slh_sk_t`, `slh_pk_t`, `slh_sig_t`, *)
(* `slh_msg_t`, `slh_ctx_t` for FIPS 205 §10's API surface. *)
(* 3. The pure-functional `slhdsa_key_from_seed` operator — *)
(* deterministic in its input seed (FIPS 205 §10.1 DeriveKey). *)
(* 4. The pure-functional `slhdsa_sign_deterministic` operator — *)
(* deterministic in (sk, msg, ctx) (FIPS 205 §10.2 Algorithm 22 *)
(* slh_sign with no addrnd; SignDeterministic). *)
(* 5. The pure-functional `slhdsa_verify` predicate (FIPS 205 §10.3 *)
(* Algorithm 24). *)
(* 6. Determinism axioms: slhdsa_key_from_seed and *)
(* slhdsa_sign_deterministic are functions in (input) — same *)
(* inputs give same outputs. *)
(* 7. Correctness axiom: a signature produced by *)
(* slhdsa_sign_deterministic on sk derived from seed S verifies *)
(* under the pk also derived from S. *)
(* *)
(* What this file is NOT *)
(* --------------------- *)
(* This is a STRUCTURAL mechanization: types + operators + spec *)
(* identity axioms. The deep cryptographic content (FIPS 205 *)
(* EUF-CMA reduction to SHAKE collision/preimage resistance) is not *)
(* mechanized — it is inherited from the NIST analysis. *)
(* *)
(* What ships here is enough to: *)
(* - Discharge Magnetar_N1's slhdsa_sign_axiom by reduction to *)
(* this file's `slhdsa_sign_deterministic` operator. *)
(* - Give NIST reviewers a single in-house file pinning the spec *)
(* Magnetar trusts. *)
(* -------------------------------------------------------------------- *)
require import AllCore List Int IntDiv.
(* ===================================================================
FIPS 205 §10.1 Table 2 — Parameter set for Magnetar-SHAKE-192s
(the recommended production target).
=================================================================== *)
(* Magnetar's recommended parameter set. The numeric constants are the
FIPS 205 §10.1 Table 2 values for SLH-DSA-SHAKE-192s. *)
op n_param : int = 24. (* WOTS+ message length in bytes *)
op h_total : int = 63. (* total tree height *)
op d_layers : int = 7. (* number of hypertree layers *)
op h_per_layer : int = 9. (* per-layer XMSS subtree height *)
op a_winternitz : int = 14. (* FORS tree depth *)
op k_fors : int = 17. (* FORS number of trees *)
op lambda_sec : int = 192. (* classical security level *)
(* FIPS 205 §10.1 sizes (bytes) for SHAKE-192s. *)
op seed_size : int = 96. (* PrivateKey/Seed size; 4 * n in
FIPS 205 §10.1 packing *)
op pk_size : int = 48. (* PK = (seed_pub, root) per FIPS 205
Algorithm 21 *)
op sk_size : int = 96. (* SK = (seed_pri, prf_seed, seed_pub,
root) per FIPS 205 Algorithm 21 *)
op sig_size : int = 16224. (* SIG = (R, FORS_sig, HT_sig) per
FIPS 205 §10.2 *)
op ctx_max : int = 255. (* FIPS 205 §10.2: ctx ≤ 255 bytes *)
(* Sanity check: hypertree-height composition. *)
lemma hypertree_height_partitions :
h_total = d_layers * h_per_layer.
proof. by rewrite /h_total /d_layers /h_per_layer. qed.
(* The key-from-seed identity: the FIPS 205 §10.1 Algorithm 21 packs
the 96-byte seed as (seed_pri || prf_seed || seed_pub || root_compute)
where root_compute is the deterministic top-level XMSS root over
seed_pri, prf_seed, seed_pub. Determinism is the load-bearing
property below. *)
(* ===================================================================
FIPS 205 §10 — API-surface types
=================================================================== *)
(* The SLH-DSA scheme seed (FIPS 205 §10.1 DeriveKey input — 4n bytes
for SHAKE-192s, byte-string semantics). *)
type seed_t.
(* The packed FIPS 205 secret key (sk_size bytes). *)
type slh_sk_t.
(* The packed FIPS 205 public key (pk_size bytes). *)
type slh_pk_t.
(* The packed FIPS 205 signature (sig_size bytes). *)
type slh_sig_t.
(* The byte-string message to sign / verify. *)
type slh_msg_t.
(* The byte-string FIPS 205 context (≤ 255 bytes). *)
type slh_ctx_t.
(* ===================================================================
FIPS 205 §10.1 — Key derivation from a scheme seed.
`slhdsa_key_from_seed s` returns the (sk, pk) pair produced by
the FIPS 205 Algorithm 21 DeriveKey on the input seed. The
pure-functional view: this op is the central FIPS-204-conformant
deterministic mapping from a 96-byte seed to the (sk, pk) pair.
=================================================================== *)
op slhdsa_key_from_seed : seed_t -> slh_sk_t * slh_pk_t.
op slhdsa_sk_from_seed (s : seed_t) : slh_sk_t =
(slhdsa_key_from_seed s).`1.
op slhdsa_pk_from_seed (s : seed_t) : slh_pk_t =
(slhdsa_key_from_seed s).`2.
(* Determinism: same seed in → same (sk, pk) out. Trivially true given
`slhdsa_key_from_seed` is an EC op (functions in EC are by
definition deterministic in inputs), but stated here for direct
citation by downstream lemmas. *)
lemma slhdsa_key_from_seed_deterministic (s1 s2 : seed_t) :
s1 = s2 =>
slhdsa_key_from_seed s1 = slhdsa_key_from_seed s2.
proof. by move=> ->. qed.
(* The companion projection identities. *)
lemma slhdsa_sk_pk_pair_match (s : seed_t) :
slhdsa_key_from_seed s
= (slhdsa_sk_from_seed s, slhdsa_pk_from_seed s).
proof.
rewrite /slhdsa_sk_from_seed /slhdsa_pk_from_seed.
by case: (slhdsa_key_from_seed s).
qed.
(* ===================================================================
FIPS 205 §10.2 — Deterministic signing.
`slhdsa_sign_deterministic sk msg ctx` returns the byte-string
signature produced by FIPS 205 Algorithm 22 (slh_sign) with no
`addrnd` randomization. The pure-functional view: this op is the
central FIPS-205-conformant deterministic mapping from (sk, msg,
ctx) to the signature.
This is the THE PRIMITIVE Magnetar Combine ultimately calls on
the reconstructed master seed. Determinism is the load-bearing
property: it is precisely what makes the Magnetar Class-N1-analog
byte-equality theorem hold.
=================================================================== *)
op slhdsa_sign_deterministic :
slh_sk_t -> slh_msg_t -> slh_ctx_t -> slh_sig_t.
(* Determinism — same arguments produce the same signature. *)
lemma slhdsa_sign_deterministic_det (sk1 sk2 : slh_sk_t)
(m1 m2 : slh_msg_t)
(c1 c2 : slh_ctx_t) :
sk1 = sk2 => m1 = m2 => c1 = c2 =>
slhdsa_sign_deterministic sk1 m1 c1
= slhdsa_sign_deterministic sk2 m2 c2.
proof. by move=> -> -> ->. qed.
(* ===================================================================
FIPS 205 §10.3 — Verification.
`slhdsa_verify pk msg ctx sig` is the FIPS 205 Algorithm 24
(slh_verify) predicate.
=================================================================== *)
op slhdsa_verify :
slh_pk_t -> slh_msg_t -> slh_ctx_t -> slh_sig_t -> bool.
(* ===================================================================
FIPS 205 §10 single-party correctness.
The honestly-generated signature verifies under the matching
public key. This is FIPS 205's correctness theorem; we surface it
here as a NAMED axiom so downstream files cite a single point.
Discharge plan: this is the NIST FIPS 205 analysis; not mechanized
inside this EasyCrypt theory. We trust the NIST standard.
=================================================================== *)
axiom slhdsa_correctness
(s : seed_t) (m : slh_msg_t) (c : slh_ctx_t) :
slhdsa_verify
(slhdsa_pk_from_seed s) m c
(slhdsa_sign_deterministic (slhdsa_sk_from_seed s) m c) = true.
(* ===================================================================
ACCOUNTING
Definitions (concrete ops surfaced; their bodies are NIST-spec
pointers — see FIPS 205 §10.1, §10.2, §10.3 for the algorithmic
definitions Magnetar inherits):
slhdsa_key_from_seed, slhdsa_sk_from_seed, slhdsa_pk_from_seed,
slhdsa_sign_deterministic, slhdsa_verify.
PROVED lemmas (0 admits):
hypertree_height_partitions
slhdsa_key_from_seed_deterministic
slhdsa_sk_pk_pair_match
slhdsa_sign_deterministic_det
axioms (1 — FIPS 205 §10 correctness; inherited from NIST):
slhdsa_correctness
=================================================================== *)
-212
View File
@@ -1,212 +0,0 @@
# Lean ↔ EasyCrypt Shamir/Lagrange bridge (Magnetar)
## Why this document exists
Magnetar's machine-checked proof stack uses **two complementary
provers**:
* **EasyCrypt** drives the procedure-level refinement / equiv proofs
for the threshold layer (`proofs/easycrypt/Magnetar_N1.ec`,
`Magnetar_N4.ec`, the two `*_Refinement.ec` files, the two
`*_Layout.ec` files, the two `*_Wrapper.ec` files, and
`Magnetar_N1_Extracted.ec`). EasyCrypt is the right tool for
procedural Hoare/equiv goals with side-channel-aware semantics —
but its first-order theory of finite fields and polynomial
interpolation is comparatively thin.
* **Lean 4 + Mathlib** carries the algebraic content: Shamir
reconstruction, Lagrange interpolation linearity, finite-field
polynomial uniqueness. Mathlib has the field theory we'd
otherwise have to re-axiomatize in EC.
The bridge between them is currently **conceptual** — the EC side
states the algebraic identities it needs as **named axioms** that
correspond 1:1 to **proved Lean theorems** in the sibling repo
`~/work/lux/proofs/lean/Crypto/`.
**Key simplification**: Magnetar's byte-wise Shamir over GF(257) is
**algebraically identical** to Pulsar's GF(257) variant. The four
Shamir / Lagrange axioms Pulsar's bridge enumerates apply VERBATIM
to Magnetar; this document cross-cites them rather than duplicating
the bridge entries.
Magnetar adds ONE Magnetar-specific bridge entry: the cSHAKE256
mix-to-seed first-argument injectivity, which is NIST SP 800-185
content (not Mathlib content).
## Repository pin-points
* EasyCrypt side: `~/work/lux/magnetar/proofs/easycrypt/`.
* Lean side: `~/work/lux/proofs/lean/Crypto/`.
## Axiom-to-theorem mapping
### Cross-cited from Pulsar (algebraically identical byte-wise GF(257))
The following 4 EC axioms exist in `Magnetar_N4.ec` and Magnetar's
threshold layer; the algebraic content is identical to Pulsar and
cross-cited:
#### Axiom 1: `add_share_zeroR`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N4.ec`):
```ec
axiom add_share_zeroR : forall (s : share_t), add_share s zero_share = s.
```
**Lean bridge**: `AddCommMonoid` instance fact. Same as Pulsar
`add_share_zeroR` — see
`~/work/lux/pulsar/proofs/lean-easycrypt-bridge.md` § "Axiom 4".
#### Axiom 2: `reconstruct_linear`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N4.ec`):
```ec
axiom reconstruct_linear :
forall (q : int list) (a b : share_t list),
size a = size q => size b = size q =>
reconstruct q (zip_add a b) =
add_share (reconstruct q a) (reconstruct q b).
```
**Lean proof**
(`~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean:81`,
theorem `combine_distributes_over_sum`):
Same as Pulsar `reconstruct_linear` — see
`~/work/lux/pulsar/proofs/lean-easycrypt-bridge.md` § "Axiom 2".
#### Axiom 3: `shamir_correct`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N4.ec`):
```ec
axiom shamir_correct :
forall (q : int list) (s : share_t),
uniq q => 1 <= size q =>
reconstruct q (fresh_sharing q s) = s.
```
**Lean proof**
(`~/work/lux/proofs/lean/Crypto/Pulsar/Shamir.lean:76`,
theorem `shamir_correct_at_target`):
Same as Pulsar `shamir_correct` — see
`~/work/lux/pulsar/proofs/lean-easycrypt-bridge.md` § "Axiom 3".
#### Axiom 4: `lagrange_inverse_eval`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N1.ec`):
```ec
axiom lagrange_inverse_eval (s : share_t) (Q : int list) :
uniq Q =>
poly_degree s < size Q =>
reconstruct Q (List.map (poly_eval s) Q) = s.
```
**Lean proof**
(`~/work/lux/proofs/lean/Crypto/Pulsar/Shamir.lean:76`,
theorem `shamir_correct_at_target`) specialized at evaluation 0 via
`Crypto.Threshold.Lagrange.secret_recovery_at_zero`
(`~/work/lux/proofs/lean/Crypto/Threshold_Lagrange.lean:62`).
Same as Pulsar `lagrange_inverse_eval` — see
`~/work/lux/pulsar/proofs/lean-easycrypt-bridge.md` § "Axiom 1".
### Magnetar-specific
#### Axiom 5: `mix_to_seed_injective_byteSum`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N1.ec`):
```ec
axiom mix_to_seed_injective_byteSum
(b1 b2 : share_t) (cr : committee_root_t) :
mix_to_seed b1 cr = mix_to_seed b2 cr => b1 = b2.
```
**Lean counterpart**
(`~/work/lux/proofs/lean/Crypto/Magnetar/OutputInterchange.lean`,
theorem `mix_to_seed_first_arg_injective`):
The cSHAKE256(input || cr, N="Magnetar", S="MAGNETAR-SEED-SHARE-V1",
seed_size bytes) operation is collision-resistant on its first
argument under the FIPS 202 collision-resistance assumption on
SHAKE256 (and by extension cSHAKE256). At the algebraic level this
gives "different inputs → different outputs with overwhelming
probability"; at the EC level we surface it as an `axiom` because
EasyCrypt's first-order theory of SHAKE is comparatively thin.
This is NOT a Mathlib content axiom (Mathlib does not natively
mechanize SHAKE collision resistance); it is a NIST SP 800-185 content
axiom that the Lean side states identically.
#### Axiom 6: `derive_pk_is_slhdsa_pk_from_seed`
**EasyCrypt statement** (`proofs/easycrypt/Magnetar_N1.ec`):
```ec
axiom derive_pk_is_slhdsa_pk_from_seed (s : seed_t) :
derive_pk s = SLHDSA_Functional.slhdsa_pk_from_seed s.
```
**Lean counterpart**: This is a pure DEFINITION pin — it asserts the
protocol-level `derive_pk` IS the FIPS 205 §10.1 Algorithm 21 DeriveKey
public projection. In the Lean side this is satisfied by direct
definition (no separate theorem required); the EC side states it as
an axiom because of EC's two-step approach to type aliasing.
## What this bridge does NOT do
It does **not** provide a mechanical proof-object exchange. The EC
axioms are still trusted in the EC dependency cone — the bridge
is a code-review-level mapping, not a formal-method-level one.
The honest closure path is the same as Pulsar's: either mechanize
the Shamir/Lagrange axioms inside EasyCrypt (would require
importing or rebuilding a finite-field polynomial-interpolation
library in EC; multi-week project), or keep the conceptual bridge
and pin the Lean commit in the EC file headers. Option 2 is what
we do.
## Citation comments
Each of the Magnetar EC axioms has an inline comment immediately
preceding it that names the Lean theorem and file. Updating the EC
axiom statement without updating the Lean side (or vice versa)
trips the per-push test for "axiom signature change without bridge
comment update" — the same CI grep gate `scripts/check-lean-bridge.sh`
that Pulsar uses.
## Honest summary
The trust footprint of the extracted Magnetar N1-analog
byte-equality theorem, including the bridge (v0.4.0):
* Implementation-refinement axioms (EC, byte-walks): **2 monolithic**
(combine + sign). No per-stage decomposition because SLH-DSA
SignDeterministic is straight-line (no rejection-sampling loop).
* Algebraic-content axioms bridged to Lean: **6** (4 cross-cited from
Pulsar's Shamir/Lagrange bridge, 1 Magnetar-specific cSHAKE256
injectivity, 1 derive_pk pin).
* Module-contract axioms in the extracted N1 corollary: **0**.
The full per-version axiom inventory lives in
`AXIOM-INVENTORY.md`.
## Structural comparison
| Pulsar (FIPS 204 ML-DSA) | Magnetar (FIPS 205 SLH-DSA) |
|---|---|
| Rejection-sampling kappa loop | Straight-line (no loop) |
| (c_tilde, z, h) packed signature | Monolithic R || FORS_sig || HT_sig blob |
| 14 residual EC axioms at v8 | 8 residual EC axioms at v0.4.0 (incl. 6 cross-cited) |
| FIPS 204 §3.5.5 codec decomposition | FIPS 205 §10.2 single-blob codec |
| 13 EC files | 9 EC files (including SLHDSA_Functional lemma) |
| MLWE/MSIS lattice algebra | SHAKE collision/preimage resistance |
| 6-month closure path estimate | Comparable (Magnetar reuses 5/6 Pulsar bridges) |
The structural simplification is the value SLH-DSA's hash-based
primitive buys over Dilithium's lattice rejection-sampling primitive.
+110 -142
View File
@@ -2,13 +2,16 @@
// See the file LICENSE for licensing terms.
// genkat is the canonical KAT (Known Answer Test) generator for
// Magnetar. It produces the JSON vector files committed under
// vectors/ — keygen, sign, verify, threshold-sign, dkg.
// Magnetar v1.0. It produces the JSON vector files committed
// under vectors/:
//
// Re-running genkat on a clean checkout MUST produce byte-identical
// output. Drift is a CI failure. The deterministic-fixture gate
// is validated by re-running this binary and diffing against
// committed JSON.
// - keygen.json — FIPS 205 single-party keypair derivation
// - sign.json — FIPS 205 single-party SignDeterministic
// - verify.json — FIPS 205 verify (positive + negative)
// - thbsse-sign.json — THBS-SE permissionless threshold sign
//
// Re-running genkat on a clean checkout MUST produce byte-
// identical output. Drift is a CI failure.
package main
import (
@@ -24,8 +27,7 @@ import (
)
// masterSeedHex is the head-of-file 48-byte seed from which every
// KAT in the file is reproducible. Re-running genkat with the same
// masterSeed gives bit-identical output.
// KAT in the file is reproducible.
const masterSeedHex = "f1a5c98e2d40e9b7a3f87216d4ca09fbb6e51d75c7e3a920" +
"4f3c1aab83de2f08e1d5b4a1c8f6b9e6743a298e7062a5c4"
@@ -53,30 +55,31 @@ type VerifyKAT struct {
Valid bool `json:"valid"`
}
type ThresholdSignKAT struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Message string `json:"message"`
PublicKey string `json:"public_key"`
Signature string `json:"signature"`
Quorum []string `json:"quorum"`
SessionID string `json:"session_id"`
Attempt uint32 `json:"attempt"`
}
type DKGKAT struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Committee []string `json:"committee"`
PublicKey string `json:"public_key"`
TranscriptHash string `json:"transcript_hash"`
Shares []string `json:"shares"` // per-party packed share (seed_size×2 bytes hex)
// ThbsSeSignKAT mirrors the kat_test.go katThbsSe shape: a
// (n, t) committee deterministically derived from SetupSeed, a
// slot binding, a message, and the resulting (group pk, σ)
// pair. Byte-identical to what stock FIPS 205 SignDeterministic
// emits on the master seed under ctx = ctxFromSlot(binding).
type ThbsSeSignKAT struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
SetupSeed string `json:"setup_seed"`
Committee []string `json:"committee"`
ChainID string `json:"chain_id"`
Epoch uint64 `json:"epoch"`
Slot uint64 `json:"slot"`
Height uint64 `json:"height"`
CommitteeID string `json:"committee_id"`
MessageDomain string `json:"message_domain"`
Message string `json:"message"`
PublicKey string `json:"public_key"`
Signature string `json:"signature"`
}
// detReader is a deterministic byte stream from a seed via
// SHA-256 chaining. Identical layout to Pulsar's genkat detReader.
// SHA-256 chaining. Layout matches the magnetar package test
// helper so KATs reproduce across the cmd/test boundary.
type detReader struct {
seed []byte
buf []byte
@@ -203,90 +206,91 @@ func main() {
}
writeJSON(filepath.Join(*outDir, "verify.json"), verifyKATs)
// ---- threshold-sign ---- (only ModeM192s for KAT brevity;
// SHAKE-192s is the recommended mode and the only one
// exercised under threshold in v0.1)
thresholdKATs := []ThresholdSignKAT{}
for _, mode := range []pm.Mode{pm.ModeM192s} {
for _, tc := range []struct{ N, T int }{{3, 2}, {5, 3}, {7, 4}} {
params := pm.MustParamsFor(mode)
committee := makeKATCommittee(tc.N)
pub, shares, _ := runKATDKG(params, committee, tc.T, mode, masterSeed)
msg := []byte(fmt.Sprintf("Magnetar Threshold KAT %s n=%d t=%d", mode.String(), tc.N, tc.T))
quorum := make([]pm.NodeID, tc.T)
for i := 0; i < tc.T; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
copy(sid[:], "kat-threshold01")
attempt := uint32(1)
signers := make([]*pm.ThresholdSigner, tc.T)
for i := 0; i < tc.T; i++ {
rng := newDetReader(append(masterSeed, []byte{0x30, byte(mode), byte(tc.T), byte(i)}...))
signers[i], _ = pm.NewThresholdSigner(params, sid, attempt, quorum, shares[i], msg, rng)
}
r1 := make([]*pm.Round1Message, tc.T)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*pm.Round2Message, tc.T)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := pm.CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, attempt, quorum, tc.T, r1, r2, shares)
if err != nil {
fail(fmt.Errorf("threshold combine n=%d t=%d: %w", tc.N, tc.T, err))
}
// Cross-check: verify under unmodified FIPS 205.
if err := pm.Verify(params, pub, msg, sig); err != nil {
fail(fmt.Errorf("threshold KAT FIPS 205 Verify failed: %w", err))
}
quorumHex := make([]string, len(quorum))
for i, q := range quorum {
quorumHex[i] = hex.EncodeToString(q[:])
}
thresholdKATs = append(thresholdKATs, ThresholdSignKAT{
Mode: mode.String(),
N: tc.N,
T: tc.T,
Message: hex.EncodeToString(msg),
PublicKey: hex.EncodeToString(pub.Bytes),
Signature: hex.EncodeToString(sig.Bytes),
Quorum: quorumHex,
SessionID: hex.EncodeToString(sid[:]),
Attempt: attempt,
})
// ---- THBS-SE ----
//
// Spec requirement (user prompt): (n=7, t=4) × 3 SLH-DSA modes
// (192s/192f/256s) × 3 distinct messages. Deterministic over
// (SetupSeed, slot tuple, message).
thbsseKATs := []ThbsSeSignKAT{}
for _, mode := range modes {
params := pm.MustParamsFor(mode)
const N = 7
const T = 4
committee := makeKATCommittee(N)
committeeHex := make([]string, N)
for i, c := range committee {
committeeHex[i] = hex.EncodeToString(c[:])
}
}
writeJSON(filepath.Join(*outDir, "threshold-sign.json"), thresholdKATs)
for msgIdx := 0; msgIdx < 3; msgIdx++ {
// SetupSeed pinned per (mode, msgIdx) so KAT
// regeneration is deterministic.
setupSeed := sha256.Sum256(append(masterSeed,
[]byte{0x40, byte(mode), byte(N), byte(T), byte(msgIdx)}...))
setupRng := newDetReader(setupSeed[:])
// ---- dkg ----
dkgKATs := []DKGKAT{}
for _, mode := range []pm.Mode{pm.ModeM192s} {
for _, tc := range []struct{ N, T int }{{3, 2}, {5, 3}, {7, 4}} {
params := pm.MustParamsFor(mode)
committee := makeKATCommittee(tc.N)
pub, shares, transcript := runKATDKG(params, committee, tc.T, mode, masterSeed)
committeeHex := make([]string, tc.N)
for i, c := range committee {
committeeHex[i] = hex.EncodeToString(c[:])
key, err := pm.NewThbsSeKey(params, T, committee, setupRng)
if err != nil {
fail(fmt.Errorf("NewThbsSeKey mode=%s msg=%d: %w", mode, msgIdx, err))
}
shareHex := make([]string, tc.N)
for i, s := range shares {
shareHex[i] = hex.EncodeToString(s.Share)
binding := &pm.ThbsSeSlotBinding{
ChainID: []byte(fmt.Sprintf("lux-magnetar-kat-%s", mode.String())),
Epoch: uint64(7 + msgIdx),
Slot: uint64(101 + 10*msgIdx),
Height: uint64(900 + msgIdx),
CommitteeID: []byte(fmt.Sprintf("kat-ctte-%d", msgIdx)),
MessageDomain: []byte("polaris-cert"),
}
dkgKATs = append(dkgKATs, DKGKAT{
Mode: mode.String(),
N: tc.N,
T: tc.T,
Committee: committeeHex,
PublicKey: hex.EncodeToString(pub.Bytes),
TranscriptHash: hex.EncodeToString(transcript[:]),
Shares: shareHex,
msg := []byte(fmt.Sprintf(
"Magnetar THBS-SE KAT %s n=%d t=%d msg#%d", mode.String(), N, T, msgIdx))
r1s := make([]pm.ThbsSeRound1Msg, T)
r2s := make([]pm.ThbsSeRound2Msg, T)
for i := 0; i < T; i++ {
guard := pm.NewThbsSeSlotGuard()
maskSeed := append([]byte("thbsse-kat-mask-"), append(setupSeed[:], byte(i))...)
maskRng := newDetReader(maskSeed)
r1, r2, err := pm.ThbsSeRound1(params, key.Shares[i], binding, msg, guard, maskRng)
if err != nil {
fail(fmt.Errorf("ThbsSeRound1 mode=%s msg=%d i=%d: %w", mode, msgIdx, i, err))
}
r1s[i] = r1
r2s[i] = r2
}
sig, evidences, err := pm.Combine(pm.ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
})
if err != nil {
fail(fmt.Errorf("Combine mode=%s msg=%d: %w", mode, msgIdx, err))
}
if len(evidences) != 0 {
fail(fmt.Errorf("Combine emitted evidences on honest KAT mode=%s msg=%d: %+v",
mode, msgIdx, evidences))
}
thbsseKATs = append(thbsseKATs, ThbsSeSignKAT{
Mode: mode.String(),
N: N,
T: T,
SetupSeed: hex.EncodeToString(setupSeed[:]),
Committee: committeeHex,
ChainID: hex.EncodeToString(binding.ChainID),
Epoch: binding.Epoch,
Slot: binding.Slot,
Height: binding.Height,
CommitteeID: hex.EncodeToString(binding.CommitteeID),
MessageDomain: hex.EncodeToString(binding.MessageDomain),
Message: hex.EncodeToString(msg),
PublicKey: hex.EncodeToString(key.PublicKey.Bytes),
Signature: hex.EncodeToString(sig.Bytes),
})
}
}
writeJSON(filepath.Join(*outDir, "dkg.json"), dkgKATs)
writeJSON(filepath.Join(*outDir, "thbsse-sign.json"), thbsseKATs)
fmt.Println("KAT vectors written to", *outDir)
}
@@ -300,42 +304,6 @@ func makeKATCommittee(n int) []pm.NodeID {
return out
}
// runKATDKG runs a deterministic DKG ceremony for KAT generation.
func runKATDKG(params *pm.Params, committee []pm.NodeID, threshold int, mode pm.Mode, masterSeed []byte) (*pm.PublicKey, []*pm.KeyShare, [48]byte) {
n := len(committee)
sessions := make([]*pm.DKGSession, n)
for i := range sessions {
seedTag := append([]byte("MAGNETAR-DKG-KAT-V1"), []byte{byte(mode), byte(n), byte(threshold), byte(i)}...)
rng := newDetReader(append(masterSeed, seedTag...))
s, err := pm.NewDKGSession(params, committee, threshold, committee[i], rng)
if err != nil {
fail(fmt.Errorf("NewDKGSession[%d]: %w", i, err))
}
sessions[i] = s
}
r1 := make([]*pm.DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*pm.DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
outs := make([]*pm.DKGOutput, n)
for i, s := range sessions {
o, err := s.Round3(r2)
if err != nil {
fail(fmt.Errorf("Round3[%d]: %w", i, err))
}
outs[i] = o
}
shares := make([]*pm.KeyShare, n)
for i, o := range outs {
shares[i] = o.SecretShare
}
return outs[0].GroupPubkey, shares, outs[0].TranscriptHash
}
func writeJSON(path string, v any) {
f, err := os.Create(path)
if err != nil {
-613
View File
@@ -1,613 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// aggregate.go — public-BFT-safe N-of-N collected-signatures API.
//
// HONEST SOTA ANSWER for "public BFT post-quantum finality on
// SLH-DSA":
//
// SLH-DSA (FIPS 205) is hash-based — WOTS+ chains, FORS few-time
// signatures, and Merkle hypertrees stacked on top of a per-signer
// secret seed. No part of the construction is algebraic in a way
// that admits a FROST-style threshold aggregation (one signature
// from t-of-n partial signatures). The result is established in
// the literature:
//
// - Cozzo & Smart, "Sharing the LUOV" (EUROCRYPT 2019) — hash-based
// signatures are MPC-hard; every internal hash evaluation is
// non-linear in the secret seed, so secret sharing forces every
// hash to be evaluated in MPC.
// - Bonte, Smart, Tan, "Threshold SPHINCS+" (2023) — exhaustive
// infeasibility analysis: a t-of-n SLH-DSA scheme producing one
// FIPS 205-shaped signature requires either O(2^h) garbled-
// circuit work or seed reconstruction.
// - NIST IR 8214 / MPTC submission notes — SLH-DSA classified as
// "highest threshold-MPC cost" among the FIPS 20{3,4,5} family.
// - FIPS 205 §6 SLH-DSA-Sign-Internal — direct inspection
// confirms no Lagrange-aggregatable response in the algorithm.
//
// Therefore the public-BFT-safe Magnetar mode is "N separate
// signatures": each validator i has its OWN SLH-DSA keypair
// (sk_i, pk_i), produces its own σ_i over the message, and the
// consensus layer collects N of them. Verification iterates per
// signer:
//
// valid_count = Σ_{i} 1[Verify(pk_i, msg, σ_i) == OK]
// accept if valid_count ≥ quorum_threshold
//
// Wire size: N × |σ|. For SHAKE-192s (16224 byte signature) and a
// 21-validator quorum that's 332 KiB; a Z-Chain Groth16 rollup
// compresses to ~192 bytes (separate primitive, NOT part of this
// API — that's the SOTA finality-compression layer).
//
// PROPERTIES vs CombineWithSeedReconstruction (combine.go):
//
// +-----------------------------------+-------------+-------------+
// | | Combine-WSR | Aggregate |
// +-----------------------------------+-------------+-------------+
// | Aggregator-as-TCB required | YES | NO |
// | Output is one FIPS 205 σ | YES | NO (Nx) |
// | Output verifies under stock FIPS | YES | per σ_i |
// | Public-BFT-safe | NO | YES |
// | Validator key model | shared | per-party |
// | Requires DKG | YES | NO |
// | Wire size | |σ| | N × |σ| |
// | Z-Chain Groth16-compressible | N/A | YES |
// +-----------------------------------+-------------+-------------+
//
// DESIGN DISCIPLINE:
//
// - Each validator's keypair is INDEPENDENT — no shared seed, no
// DKG, no resharing ceremony. Generation calls go straight to
// FIPS 205 KeyGen via GenerateValidatorKey.
// - The wire envelope (SignedBundle) explicitly carries the
// validator's NodeID and public key so the verifier can build
// its known-validators map without out-of-band coordination.
// - VerifyAggregated returns a COUNT, not a boolean — the policy
// decision ("is this above quorum?") lives at the consumer.
// This is the same decomplecting discipline Lux uses across
// consensus: primitives report facts; policy gates filter them.
// - Deduplication is by ValidatorID (not by public key) — a
// duplicate signer is counted once even if it appears with
// different (but valid) signatures.
// - The parallel-CPU verify path mirrors the goroutine fork-join
// pattern used in github.com/luxfi/crypto/slhdsa.VerifyBatch
// (gpu.go verifyBatchConcurrent). When magnetar is embedded in
// a system that exports a GPU substrate (e.g. consensus quorum
// verification), callers SHOULD route VerifyAggregated through
// that substrate by converting SignedBundle.PublicKey /
// Signature to the upstream slhdsa.PublicKey and []byte forms
// and calling slhdsa.VerifyBatch directly. The magnetar
// reference impl ships the goroutine-parallel CPU path which is
// the lower-bound performance floor; the upstream substrate is
// the GPU upper bound (see gpu.go for the ~2.6× single-thread
// SHAKE bottleneck note).
import (
"crypto/rand"
"errors"
"io"
"runtime"
"sync"
)
// Errors returned by the aggregate (N-of-N) API.
var (
// ErrEmptyBundle is returned when an empty bundle list is
// passed to AggregateSignatures.
ErrEmptyBundle = errors.New("magnetar: empty bundle list")
// ErrBundleMismatch is returned when SignedBundle fields are
// inconsistent (e.g. signature length doesn't match params.SignatureSize,
// or public-key length doesn't match params.PublicKeySize).
ErrBundleMismatch = errors.New("magnetar: bundle field shape inconsistent")
// ErrUnknownValidator is returned by VerifyAggregated when a
// bundle carries a ValidatorID that does not appear in the
// knownValidators map. The protocol-layer driver MUST filter
// these out (or treat their presence as a slashable offence
// per the consensus layer's rules).
ErrUnknownValidator = errors.New("magnetar: bundle from unknown validator")
// ErrValidatorPubkeyMismatch is returned by VerifyAggregated when
// the bundle's embedded public key does not byte-equal the
// public key registered in knownValidators for that ValidatorID.
// Protects against a malicious validator binding a different
// pk to its NodeID mid-quorum.
ErrValidatorPubkeyMismatch = errors.New("magnetar: bundle public key does not match known validator")
)
// SignedBundle is the wire envelope carrying ONE validator's
// signature over a message.
//
// Wire layout (canonical):
//
// ValidatorID || PublicKey || Signature
//
// where PublicKey is params.PublicKeySize bytes (FIPS 205-marshal
// form per §10.1) and Signature is params.SignatureSize bytes
// (FIPS 205-marshal form per §10.2). ValidatorID is 32 bytes
// (NodeID).
//
// Each SignedBundle is INDEPENDENTLY verifiable: a relying party
// who knows the validator's public key (via knownValidators in
// VerifyAggregated, or a per-bundle VerifyBundle call) can verify
// the signature without any other party's data.
//
// The PublicKey is duplicated in the envelope (the verifier
// SHOULD cross-check against an authoritative validator-set
// registry) so the bundle is self-describing on the wire — a
// relying party that gossips it through a network can route it
// to a verifier without an out-of-band registry lookup.
type SignedBundle struct {
// ValidatorID is the canonical 32-byte validator identifier
// (matches Lux validator-ID format).
ValidatorID NodeID
// PublicKey is the validator's FIPS 205 public-key bytes.
// Length = params.PublicKeySize.
PublicKey []byte
// Signature is the validator's FIPS 205 signature over the
// message under the public key above. Length =
// params.SignatureSize.
Signature []byte
}
// AggregatedSignature is the final wire format produced by
// AggregateSignatures: a collection of per-validator SignedBundle
// values over the same message.
//
// AggregatedSignature carries Message so the wire envelope is
// self-contained (verifiers do not need to pass the message
// alongside the signature collection). This matches the standard
// "collected signatures" pattern from BFT consensus literature
// (e.g. HotStuff Section 4.2 collected QC signatures).
//
// Wire-encode an AggregatedSignature by length-prefixing the
// bundles. Magnetar's reference impl does not pin a specific
// encoding — that's the responsibility of the consensus layer
// (RLP for the Lux EVM stack, protobuf for the warp transport
// path, etc.). The encoding is byte-stable as long as the bundle
// order is preserved.
//
// Z-Chain compression path: a Groth16 SNARK over the FIPS 205
// verify circuit can compress AggregatedSignature to a single
// ~192-byte proof. The SNARK is a separate primitive and is NOT
// part of this API; see lux-zchain spec.
type AggregatedSignature struct {
// Message is the bytes signed by each bundle.
Message []byte
// Bundles is the per-validator signature collection. Order
// is preserved from AggregateSignatures' input but verifiers
// MUST NOT depend on ordering for correctness.
Bundles []*SignedBundle
}
// GenerateValidatorKey produces a fresh, standalone SLH-DSA key
// pair for one validator. No DKG, no resharing, no shared seed.
//
// The validator MUST persist (sk, pk) — losing sk means the
// validator drops out of the quorum until it rotates to a fresh
// keypair. The consensus layer's validator-set registry binds
// (ValidatorID, pk) so peers can verify this validator's
// SignedBundle outputs.
//
// rng may be nil — crypto/rand is used. Pass a deterministic
// reader for KAT-reproducible key generation.
//
// Equivalent to GenerateKey but returns (sk, pk) as separate
// values for the caller-side ergonomic case where the public key
// flows into the validator registry while the private key flows
// into a key-storage backend.
func GenerateValidatorKey(params *Params, rng io.Reader) (*PrivateKey, *PublicKey, error) {
if err := params.Validate(); err != nil {
return nil, nil, err
}
if rng == nil {
rng = rand.Reader
}
sk, err := GenerateKey(params, rng)
if err != nil {
return nil, nil, err
}
return sk, sk.Pub, nil
}
// SignBundle produces a single-validator SignedBundle envelope.
//
// The output Signature is FIPS 205 SignDeterministic — byte-
// identical across calls for any (sk, message) pair, which is the
// standard property the Lux validator path relies on for
// deduplication. Callers that want randomized signing (FIPS 205
// §10.2 randomized variant) should use Sign directly and wrap the
// output in a SignedBundle manually.
//
// SignBundle does NOT depend on any other validator's data; this
// is the "no aggregator-as-TCB" property of the aggregate API.
func SignBundle(params *Params, sk *PrivateKey, validatorID NodeID, message []byte) (*SignedBundle, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if sk == nil || sk.Pub == nil {
return nil, ErrNilKey
}
if sk.Mode != params.Mode {
return nil, ErrModeMismatch
}
sig, err := Sign(params, sk, message, nil, false, nil)
if err != nil {
return nil, err
}
pkCopy := make([]byte, len(sk.Pub.Bytes))
copy(pkCopy, sk.Pub.Bytes)
return &SignedBundle{
ValidatorID: validatorID,
PublicKey: pkCopy,
Signature: sig.Bytes,
}, nil
}
// VerifyBundle verifies one SignedBundle envelope against the
// embedded public key.
//
// Returns true iff the signature is FIPS 205-valid under the
// bundle's embedded PublicKey for the supplied message.
//
// IMPORTANT: VerifyBundle does NOT check that the bundle's
// PublicKey matches an authoritative validator registry — the
// caller MUST do that via the knownValidators map in
// VerifyAggregated when the policy decision matters. A bare
// VerifyBundle call validates the cryptographic seal but says
// nothing about which validator-set policy the bundle satisfies.
func VerifyBundle(params *Params, bundle *SignedBundle, message []byte) bool {
if err := params.Validate(); err != nil {
return false
}
if bundle == nil {
return false
}
if len(bundle.PublicKey) != params.PublicKeySize {
return false
}
if len(bundle.Signature) != params.SignatureSize {
return false
}
return slhVerify(params.ID, bundle.PublicKey, message, nil, bundle.Signature)
}
// AggregateSignatures collects N independently-produced
// SignedBundle envelopes into one AggregatedSignature for the
// supplied message.
//
// This function does NOT cryptographically aggregate the
// signatures — SLH-DSA admits no such operation (see file
// header). It collects, deduplicates, and shape-checks. Per-
// signature verification happens in VerifyAggregated.
//
// Deduplication: each ValidatorID appears AT MOST once in the
// output. If the input contains multiple bundles for the same
// ValidatorID, the FIRST occurrence wins (matches HotStuff
// collected-QC semantics: a validator who double-signs is
// counted once, but evidence of double-signing is preserved at
// the consensus layer — out of scope here).
//
// Shape checks: bundle.PublicKey and bundle.Signature must have
// the lengths dictated by params. Bundles with wrong lengths
// cause ErrBundleMismatch — the function is fail-fast.
func AggregateSignatures(params *Params, bundles []*SignedBundle, message []byte) (*AggregatedSignature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if len(bundles) == 0 {
return nil, ErrEmptyBundle
}
deduped := make([]*SignedBundle, 0, len(bundles))
seen := make(map[NodeID]struct{}, len(bundles))
for _, b := range bundles {
if b == nil {
return nil, ErrBundleMismatch
}
if len(b.PublicKey) != params.PublicKeySize {
return nil, ErrBundleMismatch
}
if len(b.Signature) != params.SignatureSize {
return nil, ErrBundleMismatch
}
if _, dup := seen[b.ValidatorID]; dup {
continue
}
seen[b.ValidatorID] = struct{}{}
// Defensive copy: the bundle's PublicKey / Signature
// slices may be aliased by the caller; the
// AggregatedSignature is wire-stable and must not move
// under the caller.
pkCopy := make([]byte, len(b.PublicKey))
copy(pkCopy, b.PublicKey)
sigCopy := make([]byte, len(b.Signature))
copy(sigCopy, b.Signature)
deduped = append(deduped, &SignedBundle{
ValidatorID: b.ValidatorID,
PublicKey: pkCopy,
Signature: sigCopy,
})
}
msgCopy := make([]byte, len(message))
copy(msgCopy, message)
return &AggregatedSignature{
Message: msgCopy,
Bundles: deduped,
}, nil
}
// VerifyAggregated verifies every SignedBundle in agg against the
// known-validators registry and returns the COUNT of valid
// signers.
//
// 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 that
// drives the rest of the Lux post-quantum stack: primitives
// report facts; policy gates filter them.
//
// Verification rules (each enforced per-bundle):
//
// 1. bundle.ValidatorID must appear in knownValidators. If not,
// the function returns (0, ErrUnknownValidator) on the FIRST
// unknown bundle — the wire envelope is treated as malformed.
// 2. bundle.PublicKey must byte-equal knownValidators[bundle.ValidatorID].
// Mismatch is also fatal — returns ErrValidatorPubkeyMismatch.
// 3. The signature must verify under the registered public key
// (NOT the bundle's embedded copy, to be paranoid). A
// signature that fails verification is COUNTED OUT but is not
// fatal — the function returns the partial count.
//
// Parallelism: VerifyAggregated dispatches per-bundle Verify
// calls across runtime.GOMAXPROCS(0) goroutines when len(agg.Bundles)
// >= verifyAggregatedConcurrentThreshold. This mirrors the
// goroutine fork-join pattern in
// github.com/luxfi/crypto/slhdsa.VerifyBatch (gpu.go
// verifyBatchConcurrent). The GPU substrate path is reachable by
// re-routing this verify through the upstream slhdsa.VerifyBatch
// — see the file header for the integration note.
//
// Returns:
// - count: number of bundles that registered + verified, in
// [0, len(agg.Bundles)].
// - err: nil on success (including 0 valid signers), or one of
// ErrUnknownValidator / ErrValidatorPubkeyMismatch when a
// bundle violates the registry contract (NOT a signature
// failure — that's just counted out).
func VerifyAggregated(params *Params, agg *AggregatedSignature, knownValidators map[NodeID][]byte) (uint, error) {
if err := params.Validate(); err != nil {
return 0, err
}
if agg == nil {
return 0, ErrEmptyBundle
}
if len(agg.Bundles) == 0 {
return 0, nil
}
// Phase 1: registry checks. Walk the bundles serially and
// reject the entire envelope if any bundle violates the
// known-validators contract. We dedupe here too (in case the
// AggregatedSignature was constructed outside AggregateSignatures
// and contains duplicates).
dedupedIdx := make([]int, 0, len(agg.Bundles))
seen := make(map[NodeID]struct{}, len(agg.Bundles))
for i, b := range agg.Bundles {
if b == nil {
return 0, ErrBundleMismatch
}
if len(b.PublicKey) != params.PublicKeySize {
return 0, ErrBundleMismatch
}
if len(b.Signature) != params.SignatureSize {
return 0, ErrBundleMismatch
}
known, ok := knownValidators[b.ValidatorID]
if !ok {
return 0, ErrUnknownValidator
}
if len(known) != params.PublicKeySize {
return 0, ErrValidatorPubkeyMismatch
}
if !ctEqualBytes(b.PublicKey, known) {
return 0, ErrValidatorPubkeyMismatch
}
if _, dup := seen[b.ValidatorID]; dup {
continue
}
seen[b.ValidatorID] = struct{}{}
dedupedIdx = append(dedupedIdx, i)
}
// Phase 2: signature verification. Per-bundle Verify is pure
// and independent — parallel across GOMAXPROCS workers when
// the batch is large enough to amortise goroutine startup.
//
// This is the same fork-join pattern as
// github.com/luxfi/crypto/slhdsa/gpu.go:verifyBatchConcurrent.
// VerifyAggregatedDispatchTier exposes which tier ran so the
// test suite can assert provenance.
valid := make([]bool, len(dedupedIdx))
dispatchTier := verifyAggregatedSerial
if len(dedupedIdx) >= verifyAggregatedConcurrentThreshold {
dispatchTier = verifyAggregatedConcurrent
verifyAggregatedRunParallel(params, agg, dedupedIdx, valid, knownValidators)
} else {
for k, i := range dedupedIdx {
b := agg.Bundles[i]
pk := knownValidators[b.ValidatorID]
valid[k] = slhVerify(params.ID, pk, agg.Message, nil, b.Signature)
}
}
recordVerifyAggregatedTier(dispatchTier)
var count uint
for _, ok := range valid {
if ok {
count++
}
}
return count, nil
}
// verifyAggregatedRunParallel runs the per-bundle Verify across
// GOMAXPROCS goroutines. Pure function per bundle, no shared
// state — same correctness rationale as
// luxfi/crypto/slhdsa.verifyBatchConcurrent.
func verifyAggregatedRunParallel(
params *Params,
agg *AggregatedSignature,
dedupedIdx []int,
valid []bool,
knownValidators map[NodeID][]byte,
) {
n := len(dedupedIdx)
workers := runtime.GOMAXPROCS(0)
if workers > n {
workers = n
}
if workers < 2 {
for k, i := range dedupedIdx {
b := agg.Bundles[i]
pk := knownValidators[b.ValidatorID]
valid[k] = slhVerify(params.ID, pk, agg.Message, nil, b.Signature)
}
return
}
var wg sync.WaitGroup
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
}
wg.Add(1)
go func(lo, hi int) {
defer wg.Done()
for k := lo; k < hi; k++ {
i := dedupedIdx[k]
b := agg.Bundles[i]
pk := knownValidators[b.ValidatorID]
valid[k] = slhVerify(params.ID, pk, agg.Message, nil, b.Signature)
}
}(start, end)
}
wg.Wait()
}
// verifyAggregatedConcurrentThreshold is the minimum number of
// bundles at which VerifyAggregated forks the per-bundle Verify
// across GOMAXPROCS goroutines.
//
// SLH-DSA Verify is ~50ms per signature on M1 Max for SHAKE-192s,
// so goroutine startup (~10µs) is amortised at any n >= 2. We
// keep a margin to avoid spinning up workers for the trivial
// 1-validator case.
//
// Exposed as a package-level var (not a const) so test code can
// drop it to 2 to exercise the parallel path on a small batch.
var verifyAggregatedConcurrentThreshold = 2
// VerifyAggregatedTier is the dispatch tier the most recent
// VerifyAggregated call took. Exposed for the BatchVerify
// provenance test (aggregate_test.go).
//
// Mirrors the slhdsa.DispatchTier shape from
// luxfi/crypto/slhdsa/provenance.go but is local to magnetar.
// When magnetar is embedded in a system using luxfi/crypto's GPU
// substrate, callers should query slhdsa.GetProvenance() at the
// upstream layer instead.
type VerifyAggregatedTier int
const (
// verifyAggregatedUnknown is the initial value before any
// VerifyAggregated call has run.
verifyAggregatedUnknown VerifyAggregatedTier = iota
// verifyAggregatedSerial means VerifyAggregated dispatched on
// a single goroutine (len < verifyAggregatedConcurrentThreshold).
verifyAggregatedSerial
// verifyAggregatedConcurrent means VerifyAggregated forked the
// per-bundle Verify across GOMAXPROCS goroutines. This is the
// parallel-CPU tier; it mirrors slhdsa.TierGoroutineParallelCPU.
verifyAggregatedConcurrent
)
// String returns the canonical name of the dispatch tier.
func (t VerifyAggregatedTier) String() string {
switch t {
case verifyAggregatedSerial:
return "serial-cpu"
case verifyAggregatedConcurrent:
return "goroutine-parallel-cpu"
default:
return "unknown"
}
}
// verifyAggregatedLastTier records the dispatch tier of the most
// recent VerifyAggregated call. Single-writer, eventually-
// consistent — same model as
// luxfi/crypto/slhdsa.pluginStrongSymbolCache.
//
// Protected by a mutex (not atomic.Int32) because the test suite
// needs read-after-write ordering visibility on Apple Silicon
// where atomic Load on a different goroutine may observe a stale
// value across the goroutine-pool boundary.
var (
verifyAggregatedLastTierMu sync.Mutex
verifyAggregatedLastTier = verifyAggregatedUnknown
)
func recordVerifyAggregatedTier(t VerifyAggregatedTier) {
verifyAggregatedLastTierMu.Lock()
verifyAggregatedLastTier = t
verifyAggregatedLastTierMu.Unlock()
}
// LastVerifyAggregatedTier returns the dispatch tier of the most
// recent VerifyAggregated call. Used by the BatchVerify
// provenance test to confirm the parallel goroutine path is being
// exercised. Mirrors luxfi/crypto/slhdsa.GetProvenance() in
// purpose — auditable evidence of dispatch behaviour at runtime.
func LastVerifyAggregatedTier() VerifyAggregatedTier {
verifyAggregatedLastTierMu.Lock()
defer verifyAggregatedLastTierMu.Unlock()
return verifyAggregatedLastTier
}
// ctEqualBytes compares two byte slices in constant time relative
// to their CONTENTS (the lengths are public; an early-return on
// length mismatch is acceptable). Returns true iff lengths match
// and every byte is equal.
//
// We do not use crypto/subtle.ConstantTimeCompare here so the
// magnetar package's CT footprint is self-contained: ctEqual32
// and ctEqualBytes are the entire CT primitive surface (the rest
// of the package's CT discipline is byte-by-byte OR-accumulation
// in PublicKey.Equal etc).
func ctEqualBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
-481
View File
@@ -1,481 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// aggregate_test.go — tests for the public-BFT-safe N-of-N
// collected-signatures API.
//
// These tests exercise the AggregateSignatures path:
//
// - GenerateValidatorKey: per-validator standalone keypair
// production (no DKG).
// - SignBundle / VerifyBundle: single-validator round-trip.
// - AggregateSignatures: dedupe + shape-check the N-bundle wire
// envelope.
// - VerifyAggregated: per-bundle Verify with parallel-CPU
// dispatch, returns the COUNT of valid signers.
// - Provenance: confirm the parallel goroutine path is exercised
// above verifyAggregatedConcurrentThreshold, mirroring
// luxfi/crypto/slhdsa.GetProvenance() in spirit.
import (
"bytes"
"testing"
)
func TestSignBundle_RoundTrip(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
sk, pk, err := GenerateValidatorKey(params, newDetReader([]byte("aggregate-rt-1")))
if err != nil {
t.Fatalf("GenerateValidatorKey: %v", err)
}
var vid NodeID
copy(vid[:], []byte("VALIDATOR-ALPHA"))
msg := []byte("Magnetar aggregate-mode single-validator round-trip")
bundle, err := SignBundle(params, sk, vid, msg)
if err != nil {
t.Fatalf("SignBundle: %v", err)
}
if bundle.ValidatorID != vid {
t.Fatalf("bundle.ValidatorID = %x, want %x", bundle.ValidatorID[:], vid[:])
}
if len(bundle.PublicKey) != params.PublicKeySize {
t.Fatalf("bundle.PublicKey len = %d, want %d", len(bundle.PublicKey), params.PublicKeySize)
}
if !bytes.Equal(bundle.PublicKey, pk.Bytes) {
t.Fatalf("bundle.PublicKey does not match generated pk")
}
if len(bundle.Signature) != params.SignatureSize {
t.Fatalf("bundle.Signature len = %d, want %d", len(bundle.Signature), params.SignatureSize)
}
if !VerifyBundle(params, bundle, msg) {
t.Fatalf("VerifyBundle failed on freshly-signed bundle")
}
// Negative: flip a byte.
bad := *bundle
badSig := make([]byte, len(bundle.Signature))
copy(badSig, bundle.Signature)
badSig[len(badSig)/2] ^= 0x01
bad.Signature = badSig
if VerifyBundle(params, &bad, msg) {
t.Fatalf("VerifyBundle accepted tampered signature")
}
// Negative: wrong message.
if VerifyBundle(params, bundle, []byte("different message")) {
t.Fatalf("VerifyBundle accepted signature against wrong message")
}
}
func TestAggregateSignatures_5of5(t *testing.T) {
skipUnderRace(t)
// 5 validators sign the same message under their OWN keypairs;
// AggregateSignatures collects them; VerifyAggregated returns
// count=5.
params := MustParamsFor(ModeM192s)
const N = 5
type validator struct {
id NodeID
sk *PrivateKey
pk *PublicKey
}
vs := make([]validator, N)
known := make(map[NodeID][]byte, N)
for i := 0; i < N; i++ {
sk, pk, err := GenerateValidatorKey(params, newDetReader([]byte{byte(i), 'A', 'G', 'G'}))
if err != nil {
t.Fatalf("GenerateValidatorKey[%d]: %v", i, err)
}
var id NodeID
id[0] = byte(i + 1)
copy(id[1:], []byte("VALIDATOR"))
vs[i] = validator{id: id, sk: sk, pk: pk}
known[id] = pk.Bytes
}
msg := []byte("Magnetar aggregate-mode 5-of-5 public-BFT scenario")
bundles := make([]*SignedBundle, N)
for i, v := range vs {
b, err := SignBundle(params, v.sk, v.id, msg)
if err != nil {
t.Fatalf("SignBundle[%d]: %v", i, err)
}
bundles[i] = b
}
agg, err := AggregateSignatures(params, bundles, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
if len(agg.Bundles) != N {
t.Fatalf("agg.Bundles len = %d, want %d", len(agg.Bundles), N)
}
if !bytes.Equal(agg.Message, msg) {
t.Fatalf("agg.Message does not match input")
}
count, err := VerifyAggregated(params, agg, known)
if err != nil {
t.Fatalf("VerifyAggregated: %v", err)
}
if count != N {
t.Fatalf("VerifyAggregated count = %d, want %d", count, N)
}
}
func TestVerifyAggregated_BadSig_Counted_Out(t *testing.T) {
skipUnderRace(t)
// 5 validators sign; corrupt ONE bundle's signature; verify
// returns count=4 (not 5) but does NOT error — the bad
// signature is counted out, not fatal.
params := MustParamsFor(ModeM192s)
const N = 5
type validator struct {
id NodeID
sk *PrivateKey
pk *PublicKey
}
vs := make([]validator, N)
known := make(map[NodeID][]byte, N)
for i := 0; i < N; i++ {
sk, pk, err := GenerateValidatorKey(params, newDetReader([]byte{byte(i), 'B', 'A', 'D'}))
if err != nil {
t.Fatalf("GenerateValidatorKey[%d]: %v", i, err)
}
var id NodeID
id[0] = byte(i + 1)
copy(id[1:], []byte("BAD-SIG-TEST"))
vs[i] = validator{id: id, sk: sk, pk: pk}
known[id] = pk.Bytes
}
msg := []byte("Magnetar aggregate-mode bad-sig-counted-out scenario")
bundles := make([]*SignedBundle, N)
for i, v := range vs {
b, err := SignBundle(params, v.sk, v.id, msg)
if err != nil {
t.Fatalf("SignBundle[%d]: %v", i, err)
}
bundles[i] = b
}
// Corrupt bundles[2]'s signature.
corruptIdx := 2
bundles[corruptIdx].Signature[len(bundles[corruptIdx].Signature)/2] ^= 0x01
agg, err := AggregateSignatures(params, bundles, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
count, err := VerifyAggregated(params, agg, known)
if err != nil {
t.Fatalf("VerifyAggregated returned error on counted-out bundle: %v", err)
}
if count != N-1 {
t.Fatalf("VerifyAggregated count = %d, want %d (one bad sig counted out)", count, N-1)
}
}
func TestVerifyAggregated_UnknownValidator(t *testing.T) {
skipUnderRace(t)
// A bundle from a validator not in knownValidators must cause
// VerifyAggregated to return ErrUnknownValidator.
params := MustParamsFor(ModeM192s)
sk1, pk1, err := GenerateValidatorKey(params, newDetReader([]byte("unk-1")))
if err != nil {
t.Fatalf("GenerateValidatorKey[1]: %v", err)
}
sk2, _, err := GenerateValidatorKey(params, newDetReader([]byte("unk-2")))
if err != nil {
t.Fatalf("GenerateValidatorKey[2]: %v", err)
}
var id1, id2 NodeID
id1[0] = 1
copy(id1[1:], []byte("KNOWN"))
id2[0] = 2
copy(id2[1:], []byte("UNKNOWN"))
known := map[NodeID][]byte{
id1: pk1.Bytes,
}
msg := []byte("Magnetar aggregate unknown-validator rejection")
b1, err := SignBundle(params, sk1, id1, msg)
if err != nil {
t.Fatalf("SignBundle[1]: %v", err)
}
b2, err := SignBundle(params, sk2, id2, msg)
if err != nil {
t.Fatalf("SignBundle[2]: %v", err)
}
agg, err := AggregateSignatures(params, []*SignedBundle{b1, b2}, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
_, err = VerifyAggregated(params, agg, known)
if err != ErrUnknownValidator {
t.Fatalf("VerifyAggregated err = %v, want ErrUnknownValidator", err)
}
}
func TestVerifyAggregated_DuplicateValidator(t *testing.T) {
skipUnderRace(t)
// Same validator appears twice in the bundle list; dedupe at
// AggregateSignatures (first occurrence wins) AND at
// VerifyAggregated. Count must reflect one valid signer.
params := MustParamsFor(ModeM192s)
sk, pk, err := GenerateValidatorKey(params, newDetReader([]byte("dup-validator")))
if err != nil {
t.Fatalf("GenerateValidatorKey: %v", err)
}
var id NodeID
id[0] = 1
copy(id[1:], []byte("DUPE"))
known := map[NodeID][]byte{id: pk.Bytes}
msg := []byte("Magnetar aggregate duplicate-validator dedupe")
b, err := SignBundle(params, sk, id, msg)
if err != nil {
t.Fatalf("SignBundle: %v", err)
}
// Two identical bundles in the list.
agg, err := AggregateSignatures(params, []*SignedBundle{b, b}, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
if len(agg.Bundles) != 1 {
t.Fatalf("AggregateSignatures: dedupe failed, got %d bundles, want 1", len(agg.Bundles))
}
count, err := VerifyAggregated(params, agg, known)
if err != nil {
t.Fatalf("VerifyAggregated: %v", err)
}
if count != 1 {
t.Fatalf("VerifyAggregated count = %d, want 1", count)
}
// And a SECOND form: two DIFFERENT but-valid bundles for the
// same ValidatorID (e.g. same validator signed twice with
// different randomness in a hypothetical randomized variant).
// VerifyAggregated dedupes those too.
b2, err := SignBundle(params, sk, id, msg)
if err != nil {
t.Fatalf("SignBundle (second): %v", err)
}
// SignBundle is deterministic so b and b2 are byte-equal; we
// still exercise the dedupe path here.
agg2 := &AggregatedSignature{
Message: msg,
Bundles: []*SignedBundle{b, b2},
}
count2, err := VerifyAggregated(params, agg2, known)
if err != nil {
t.Fatalf("VerifyAggregated (manual agg): %v", err)
}
if count2 != 1 {
t.Fatalf("VerifyAggregated count = %d, want 1 (dedupe at verify)", count2)
}
}
func TestVerifyAggregated_PubkeyMismatch(t *testing.T) {
skipUnderRace(t)
// A bundle whose embedded PublicKey does NOT match the
// known-validators registry must trigger
// ErrValidatorPubkeyMismatch. Protects against a malicious
// validator binding a different pk to its NodeID mid-quorum.
params := MustParamsFor(ModeM192s)
sk1, pk1, err := GenerateValidatorKey(params, newDetReader([]byte("pkmis-1")))
if err != nil {
t.Fatalf("GenerateValidatorKey[1]: %v", err)
}
_, pk2, err := GenerateValidatorKey(params, newDetReader([]byte("pkmis-2")))
if err != nil {
t.Fatalf("GenerateValidatorKey[2]: %v", err)
}
var id NodeID
id[0] = 1
copy(id[1:], []byte("PKMISMATCH"))
// Registry binds id -> pk1, but the bundle (signed with sk1)
// embeds pk2 — a malicious validator trying to rebind.
known := map[NodeID][]byte{id: pk1.Bytes}
msg := []byte("Magnetar aggregate pubkey-mismatch rejection")
b, err := SignBundle(params, sk1, id, msg)
if err != nil {
t.Fatalf("SignBundle: %v", err)
}
// Overwrite the bundle's embedded pubkey to a DIFFERENT
// pubkey. The signature is still valid under sk1, but the
// bundle's pk now lies.
b.PublicKey = make([]byte, len(pk2.Bytes))
copy(b.PublicKey, pk2.Bytes)
agg, err := AggregateSignatures(params, []*SignedBundle{b}, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
_, err = VerifyAggregated(params, agg, known)
if err != ErrValidatorPubkeyMismatch {
t.Fatalf("VerifyAggregated err = %v, want ErrValidatorPubkeyMismatch", err)
}
}
func TestAggregateSignatures_BatchVerify(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run the batch-verify provenance check (5 SLH-DSA verifies)")
}
// Confirm the per-bundle verify path is parallel-CPU
// (verifyAggregatedConcurrent) for a batch above the
// threshold. Mirrors the BatchVerify provenance assertion in
// luxfi/crypto/slhdsa.GetProvenance — magnetar exposes the
// same observable via LastVerifyAggregatedTier.
params := MustParamsFor(ModeM192s)
const N = 5
type validator struct {
id NodeID
sk *PrivateKey
pk *PublicKey
}
vs := make([]validator, N)
known := make(map[NodeID][]byte, N)
for i := 0; i < N; i++ {
sk, pk, err := GenerateValidatorKey(params, newDetReader([]byte{byte(i), 'B', 'V'}))
if err != nil {
t.Fatalf("GenerateValidatorKey[%d]: %v", i, err)
}
var id NodeID
id[0] = byte(i + 1)
copy(id[1:], []byte("BATCH-VERIFY"))
vs[i] = validator{id: id, sk: sk, pk: pk}
known[id] = pk.Bytes
}
msg := []byte("Magnetar batch-verify provenance scenario")
bundles := make([]*SignedBundle, N)
for i, v := range vs {
b, err := SignBundle(params, v.sk, v.id, msg)
if err != nil {
t.Fatalf("SignBundle[%d]: %v", i, err)
}
bundles[i] = b
}
agg, err := AggregateSignatures(params, bundles, msg)
if err != nil {
t.Fatalf("AggregateSignatures: %v", err)
}
count, err := VerifyAggregated(params, agg, known)
if err != nil {
t.Fatalf("VerifyAggregated: %v", err)
}
if count != N {
t.Fatalf("VerifyAggregated count = %d, want %d", count, N)
}
// Provenance: a 5-bundle agg above the concurrent threshold
// MUST have taken the parallel-CPU dispatch tier. This is the
// observable evidence that the per-bundle verify is using the
// goroutine fork-join pattern (the slhdsa.VerifyBatch
// parallel path). If a future change accidentally drops the
// parallel dispatch, this test catches the regression.
tier := LastVerifyAggregatedTier()
if tier != verifyAggregatedConcurrent {
t.Fatalf("LastVerifyAggregatedTier = %s, want %s — parallel dispatch did not engage",
tier, verifyAggregatedConcurrent)
}
// Also exercise the serial path: a 1-bundle agg should take
// the serial tier (below threshold).
agg1, err := AggregateSignatures(params, bundles[:1], msg)
if err != nil {
t.Fatalf("AggregateSignatures (1-bundle): %v", err)
}
if _, err := VerifyAggregated(params, agg1, known); err != nil {
t.Fatalf("VerifyAggregated (1-bundle): %v", err)
}
tier1 := LastVerifyAggregatedTier()
if tier1 != verifyAggregatedSerial {
t.Fatalf("LastVerifyAggregatedTier (1-bundle) = %s, want %s",
tier1, verifyAggregatedSerial)
}
}
func TestAggregateSignatures_EmptyBundles(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
_, err := AggregateSignatures(params, nil, []byte("msg"))
if err != ErrEmptyBundle {
t.Fatalf("AggregateSignatures nil err = %v, want ErrEmptyBundle", err)
}
_, err = AggregateSignatures(params, []*SignedBundle{}, []byte("msg"))
if err != ErrEmptyBundle {
t.Fatalf("AggregateSignatures empty err = %v, want ErrEmptyBundle", err)
}
}
func TestAggregateSignatures_ShapeCheck(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
var id NodeID
id[0] = 1
// Bundle with wrong PublicKey length.
badPk := &SignedBundle{
ValidatorID: id,
PublicKey: make([]byte, params.PublicKeySize-1),
Signature: make([]byte, params.SignatureSize),
}
if _, err := AggregateSignatures(params, []*SignedBundle{badPk}, []byte("msg")); err != ErrBundleMismatch {
t.Fatalf("AggregateSignatures bad-pk err = %v, want ErrBundleMismatch", err)
}
// Bundle with wrong Signature length.
badSig := &SignedBundle{
ValidatorID: id,
PublicKey: make([]byte, params.PublicKeySize),
Signature: make([]byte, params.SignatureSize-1),
}
if _, err := AggregateSignatures(params, []*SignedBundle{badSig}, []byte("msg")); err != ErrBundleMismatch {
t.Fatalf("AggregateSignatures bad-sig err = %v, want ErrBundleMismatch", err)
}
// Nil bundle.
if _, err := AggregateSignatures(params, []*SignedBundle{nil}, []byte("msg")); err != ErrBundleMismatch {
t.Fatalf("AggregateSignatures nil-bundle err = %v, want ErrBundleMismatch", err)
}
}
func TestGenerateValidatorKey_Independent(t *testing.T) {
skipUnderRace(t)
// Two GenerateValidatorKey calls with DIFFERENT seeds MUST
// produce distinct (sk, pk) — confirms "no shared seed" /
// "no DKG" property.
params := MustParamsFor(ModeM192s)
sk1, pk1, err := GenerateValidatorKey(params, newDetReader([]byte("indep-1")))
if err != nil {
t.Fatalf("GenerateValidatorKey[1]: %v", err)
}
sk2, pk2, err := GenerateValidatorKey(params, newDetReader([]byte("indep-2")))
if err != nil {
t.Fatalf("GenerateValidatorKey[2]: %v", err)
}
if bytes.Equal(sk1.Bytes, sk2.Bytes) {
t.Fatalf("two independent GenerateValidatorKey calls produced identical sk")
}
if bytes.Equal(pk1.Bytes, pk2.Bytes) {
t.Fatalf("two independent GenerateValidatorKey calls produced identical pk")
}
}
-271
View File
@@ -1,271 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// combine.go — aggregator-side reconstruction and single-party
// FIPS 205 Sign on the reconstructed master seed.
//
// This file IS the v0.1 reveal-and-aggregate trust caveat: the
// master seed lives in this process's memory for the duration of
// one CombineWithSeedReconstruction call. Every reconstructed-seed
// code path explicitly zeroizes the seed before return. No defer:
// the call-tree is short, and explicit zeroization at each return
// site keeps the secret lifetime locally legible.
//
// Class-N1-analog: this function produces a signature byte-
// identical to single-party FIPS 205 SignDeterministic on the
// reconstructed master seed. Any FIPS 205 verifier accepts the
// output with no code change.
//
// !! IMPORTANT — PUBLIC-BFT SAFETY !!
//
// This file's API REQUIRES that the aggregator process is part of
// the Trusted Computing Base (TCB): for the duration of one
// CombineWithSeedReconstruction call, the reconstructed master
// SLH-DSA seed is live in the aggregator's address space. This is a
// hard requirement, not a "best-practice" recommendation.
//
// SLH-DSA (FIPS 205) is a hash-based signature scheme over WOTS+ /
// FORS / Merkle trees. It has NO algebraic structure that admits a
// FROST-style threshold aggregation of partial signatures into a
// final signature. The literature has confirmed this is fundamental
// to hash-based signing:
//
// - Cozzo & Smart (EUROCRYPT 2019), "Sharing the LUOV" —
// establishes that hash-based signature schemes are the worst
// case for threshold MPC because every internal SHAKE/SHA-2
// evaluation is a non-linear function of the secret seed.
// - Bonte, Smart, Tan (2023), "Threshold SPHINCS+" — exhaustive
// analysis confirming there is no efficient threshold SLH-DSA
// scheme that produces a single FIPS 205-shaped signature
// without reconstructing the seed.
// - NIST IR 8214 / MPTC submission notes classify SLH-DSA as
// "highest threshold-MPC cost" among the FIPS 20{3,4,5}
// family.
// - FIPS 205 §6 (slh_sign_internal) shows that the per-signature
// hypertree address material PRF_msg(SK.prf, opt_rand, M)
// is not Lagrange-aggregatable.
//
// Therefore Magnetar's v0.1 construction is "reveal-and-aggregate":
// each signer commits to a mask + masked seed-share, then reveals
// (mask, masked_share) so the aggregator can Lagrange-reconstruct
// the master seed and produce a single FIPS 205-byte-identical
// signature. The aggregator IS the TCB; this is the honest cost of
// shipping a FIPS 205-bytewise-identical threshold output today.
//
// USE THIS FUNCTION ONLY WHEN:
// - The aggregator runs in a TEE (Intel TDX / SEV-SNP / SGX), OR
// - The aggregator is the same process as the custody host (M-Chain
// custody pattern; cf. LP-134 thresholdvm M-Chain mode), OR
// - You are reproducing the FIPS 205 SignDeterministic byte-equal
// baseline for KAT validation, audit cross-check, or formal
// proof refinement.
//
// FOR PUBLIC BFT VALIDATOR QUORUMS: use the per-validator
// AggregateSignatures path in aggregate.go instead. There each
// validator i signs the message under its OWN SLH-DSA keypair
// (sk_i, pk_i), the consensus layer collects N separate signatures,
// and verification iterates per-signer. Wire size is N × |σ|; for
// SHAKE-192s this is 16 KiB per validator. A Z-Chain Groth16 rollup
// can compress the bundle to ~192 bytes (separate concern; that
// path is the standard SOTA answer for "BFT-ready post-quantum
// finality" without any single party holding the master seed).
import (
"crypto/rand"
)
// CombineWithSeedReconstruction produces a FIPS 205 SLH-DSA signature
// from a quorum of Round-2 reveals BY RECONSTRUCTING THE MASTER
// SEED IN THE AGGREGATOR PROCESS.
//
// REQUIRES TEE for public deployment. NOT public-BFT-safe.
// For public BFT, use AggregateSignatures in aggregate.go.
//
// The reconstruction trust caveat is hard: SLH-DSA is hash-based and
// admits no Lagrange-aggregatable response. The output is byte-
// identical to single-party FIPS 205 SignDeterministic on the
// reconstructed master seed; achieving that bytewise output requires
// holding the seed in memory once. See the file header for the
// literature on this point (Cozzo-Smart 2019, Bonte et al. 2023,
// FIPS 205 §6).
//
// Parameters:
// - params, groupPubkey, message, ctx: match what was passed to
// NewThresholdSigner.
// - randomized: when false, uses SLH-DSA deterministic signing
// (Class-N1-analog reproducible output). When true, uses
// randomized signing with the supplied rng.
// - sessionID, attempt, quorum: match the signing session.
// - threshold: minimum number of Round-2 reveals required.
// - round1, round2: messages from the signing committee.
// - allShares: directory of KeyShares (used to map NodeID →
// EvalPoint for Lagrange interpolation).
//
// CombineWithSeedReconstruction is a pure function — it does not
// require ThresholdSigner state. Any honest party in the quorum
// (or an external aggregator) can call it after Round 2 completes.
//
// The returned Signature, when passed to Verify(params, groupPubkey,
// message, sig), returns nil (i.e. the signature is FIPS 205 valid).
func CombineWithSeedReconstruction(
params *Params,
groupPubkey *PublicKey,
message []byte,
ctx []byte,
randomized bool,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
threshold int,
round1 []*Round1Message,
round2 []*Round2Message,
allShares []*KeyShare,
) (*Signature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if groupPubkey == nil {
return nil, ErrNilPublicKey
}
if len(round1) < threshold || len(round2) < threshold {
return nil, ErrInsufficientQuor
}
maskLen := params.SeedSize * 2
// Index Round-1 messages by sender.
r1ByID := make(map[NodeID]*Round1Message, len(round1))
for _, m := range round1 {
if m.SessionID != sessionID || m.Attempt != attempt {
return nil, ErrSessionMismatch
}
r1ByID[m.NodeID] = m
}
// For each Round-2 reveal: re-derive D_i and compare against
// the matching Round-1 commit.
revealedShares := make(map[NodeID][]byte, threshold)
for _, r2 := range round2 {
r1, ok := r1ByID[r2.NodeID]
if !ok {
continue
}
if r2.SessionID != sessionID || r2.Attempt != attempt {
return nil, ErrSessionMismatch
}
if len(r2.PartialSig) != 2*maskLen {
return nil, ErrRound2CommitBad
}
mask := r2.PartialSig[:maskLen]
masked := r2.PartialSig[maskLen:]
// Re-derive D_i = cSHAKE256(mask || masked || tau_1).
tau := transcriptTau1Bytes(sessionID, attempt, quorum, r2.NodeID, groupPubkey, message)
commitInput := make([]byte, 0, len(mask)+len(masked)+len(tau))
commitInput = append(commitInput, mask...)
commitInput = append(commitInput, masked...)
commitInput = append(commitInput, tau...)
recomputed := transcriptHash32(tagSignR1, commitInput)
if !ctEqual32(recomputed, r1.Commit) {
return nil, ErrRound2CommitBad
}
// Recover share = masked XOR mask.
share := make([]byte, maskLen)
for i := 0; i < maskLen; i++ {
share[i] = masked[i] ^ mask[i]
}
revealedShares[r2.NodeID] = share
}
if len(revealedShares) < threshold {
return nil, ErrInsufficientQuor
}
// Pair each revealed share with its KeyShare entry to recover
// the EvalPoint. The aggregator gets allShares as a directory
// so it can map NodeID → EvalPoint.
keyShareByID := make(map[NodeID]*KeyShare, len(allShares))
for _, ks := range allShares {
keyShareByID[ks.NodeID] = ks
}
shares := make([]shamirShare, 0, threshold)
for id, sBytes := range revealedShares {
ks, ok := keyShareByID[id]
if !ok {
return nil, ErrNotInQuorum
}
if len(sBytes) != params.SeedSize*2 {
return nil, ErrShareWireSize
}
shares = append(shares, shareFromBytes(ks.EvalPoint, sBytes))
if len(shares) == threshold {
break
}
}
// Lagrange-reconstruct the GF(257) byte-sum. The committee
// root is canonical given allShares.
byteSum, err := shamirReconstructGF(shares, params.SeedSize)
if err != nil {
return nil, err
}
committeeRoot := committeeRootFromShares(allShares)
byteSumBytes := make([]byte, params.SeedSize*2)
for b := 0; b < params.SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
masterSeed := cshake256(mixInput, params.SeedSize, tagSeedShare)
// Derive the centralized FIPS 205 SK from the master seed.
// Every error path AND the success path below MUST explicitly
// zeroize the reconstructed secret material before return.
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, err
}
// Sanity: the reconstructed pubkey MUST match groupPubkey,
// else the aggregator's share set is wrong / tampered.
if !sk.Pub.Equal(groupPubkey) {
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, ErrPubkeyMismatch
}
// FIPS 205 sign with the reconstructed seed-derived key.
// randomized=false → SignDeterministic (Class-N1-analog
// reproducible output). randomized=true uses the caller's rng.
signRng := rand.Reader
sigBytes, err := slhSign(params.ID, sk.Bytes, message, ctx, randomized, signRng)
if err != nil {
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, err
}
// Success path: signature is OK; wipe every secret-bearing
// buffer before returning.
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// dispatch.go — parallel-CPU dispatch infrastructure shared by the
// per-validator standalone (ValidatorBatchVerify, VerifyAggregateCert)
// batch verification paths.
//
// SLH-DSA Verify is ~50ms per signature for SHAKE-192s on Apple
// M1 Max, so goroutine startup (~10µs) is amortised at any
// non-trivial batch size. Above the concurrent threshold the
// per-signer Verify is forked across runtime.GOMAXPROCS workers
// using the same fork-join pattern as
// luxfi/crypto/slhdsa.VerifyBatch (gpu.go verifyBatchConcurrent).
//
// The dispatch tier is observable via LastValidatorBatchTier so
// the test suite can assert provenance — auditable evidence of
// dispatch behaviour at runtime.
import (
"errors"
"sync"
)
// Errors returned by the registry-checked cert verification path.
// Registry-binding violations are FATAL at the standalone (cert)
// surface; at the ValidatorBatchVerify surface the caller is
// responsible for the registry binding and these errors are not
// produced.
var (
// ErrUnknownValidator is returned when a SignedBundle / cert
// entry carries a ValidatorID that does not appear in the
// knownValidators map. The protocol-layer driver MUST filter
// these out (or treat their presence as a slashable offence
// per the consensus layer's rules) before retrying.
ErrUnknownValidator = errors.New("magnetar: bundle from unknown validator")
// ErrValidatorPubkeyMismatch is returned when the bundle's
// embedded public key does not byte-equal the public key
// registered in knownValidators for that ValidatorID. Protects
// against a malicious validator binding a different pk to its
// NodeID mid-quorum.
ErrValidatorPubkeyMismatch = errors.New("magnetar: bundle public key does not match known validator")
)
// ValidatorBatchTier is the dispatch tier the most recent
// ValidatorBatchVerify call took. Exposed for the BatchVerify
// provenance test; mirrors the slhdsa.DispatchTier shape from
// luxfi/crypto/slhdsa/provenance.go but is local to magnetar.
// When magnetar is embedded in a system using luxfi/crypto's GPU
// substrate, callers should query slhdsa.GetProvenance() at the
// upstream layer instead.
type ValidatorBatchTier int
const (
// validatorBatchUnknown is the initial value before any
// ValidatorBatchVerify call has run.
validatorBatchUnknown ValidatorBatchTier = iota
// validatorBatchSerial means the dispatch ran on a single
// goroutine (batch size < validatorBatchConcurrentThreshold).
validatorBatchSerial
// validatorBatchConcurrent means the per-signer Verify was
// forked across GOMAXPROCS goroutines. Mirrors
// slhdsa.TierGoroutineParallelCPU.
validatorBatchConcurrent
)
// String returns the canonical name of the dispatch tier.
func (t ValidatorBatchTier) String() string {
switch t {
case validatorBatchSerial:
return "serial-cpu"
case validatorBatchConcurrent:
return "goroutine-parallel-cpu"
default:
return "unknown"
}
}
// validatorBatchConcurrentThreshold is the minimum batch size at
// which ValidatorBatchVerify forks the per-signer Verify across
// GOMAXPROCS goroutines.
//
// Exposed as a package-level var (not a const) so test code can
// drop it to 2 to exercise the parallel path on a small batch.
var validatorBatchConcurrentThreshold = 2
// validatorBatchLastTier records the dispatch tier of the most
// recent ValidatorBatchVerify call. Single-writer, eventually-
// consistent — same model as
// luxfi/crypto/slhdsa.pluginStrongSymbolCache.
//
// Protected by a mutex (not atomic.Int32) because the test suite
// needs read-after-write ordering visibility on Apple Silicon
// where atomic Load on a different goroutine may observe a stale
// value across the goroutine-pool boundary.
var (
validatorBatchLastTierMu sync.Mutex
validatorBatchLastTier = validatorBatchUnknown
)
// recordValidatorBatchTier publishes the dispatch tier of the
// just-completed ValidatorBatchVerify call.
func recordValidatorBatchTier(t ValidatorBatchTier) {
validatorBatchLastTierMu.Lock()
validatorBatchLastTier = t
validatorBatchLastTierMu.Unlock()
}
// LastValidatorBatchTier returns the dispatch tier of the most
// recent ValidatorBatchVerify call. Used by the BatchVerify
// provenance test to confirm the parallel goroutine path is being
// exercised. Mirrors luxfi/crypto/slhdsa.GetProvenance() in
// purpose — auditable evidence of dispatch behaviour at runtime.
func LastValidatorBatchTier() ValidatorBatchTier {
validatorBatchLastTierMu.Lock()
defer validatorBatchLastTierMu.Unlock()
return validatorBatchLastTier
}
// ctEqualBytes compares two byte slices in constant time relative
// to their CONTENTS (the lengths are public; an early-return on
// length mismatch is acceptable). Returns true iff lengths match
// and every byte is equal.
//
// We do not use crypto/subtle.ConstantTimeCompare here so the
// magnetar package's CT footprint is self-contained: ctEqual32
// (transcript.go) and ctEqualBytes are the entire CT primitive
// surface (the rest of the package's CT discipline is
// byte-by-byte OR-accumulation in PublicKey.Equal etc).
func ctEqualBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
-456
View File
@@ -1,456 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// dkg.go — Threshold DKG via byte-wise Shamir VSS over the SLH-DSA
// scheme seed.
//
// Protocol shape (v0.1 reveal-and-aggregate, Shamir+sum):
//
// Round 1 (dealer P_i):
// Sample a random contribution c_i ∈ {0,1}^{seed_size}.
// Shamir-share c_i across the committee at threshold t.
// For each recipient P_j, broadcast Envelope_{i,j} =
// (Share_{i,j}, c_i)
// where Share_{i,j} is P_j's per-byte Shamir share of c_i and
// c_i is the full contribution (duplicated into every envelope
// so each party can sum contributions at Round 2).
//
// Round 2 (party P_i):
// Verify all received envelopes are well-formed. Compute the
// digest D_i = cSHAKE256(ordered envelope set || tau_dkg)
// where tau_dkg = (mode, committee, threshold). Broadcast
// (NodeID_i, D_i).
//
// Round 3 (party P_i):
// Verify all received digests match. Sum the per-byte Shamir
// shares: my_share = Σ_j Share_{j,i} (mod 257). Sum the
// contributions: master_byteSum = Σ_j c_j (per byte, mod 257).
// Mix master_byteSum with the committee root via cSHAKE256 to
// get the master seed; derive the group public key via
// KeyFromSeed. The (NodeID, EvalPoint, my_share, GroupPubkey)
// tuple becomes the party's KeyShare.
//
// The v0.1 trust caveat: the aggregator process at sign time
// reconstructs the master seed in memory. See SPEC.md "Trust
// model" and DEPLOYMENT-RUNBOOK.md.
//
// Round-1 envelopes are PLAINTEXT in v0.1 (committee members trust
// each other already since each will see the reconstructed seed
// at sign time). v0.2 wraps envelopes under ML-KEM-768 to close
// the network-observer channel while keeping the same trust model
// vis-a-vis committee members.
import (
"crypto/rand"
"errors"
"io"
)
// Errors returned by DKG.
var (
ErrDKGUnexpectedRound = errors.New("magnetar: DKG message from unexpected round")
ErrDKGCommitteeMismatch = errors.New("magnetar: DKG message committee mismatch")
ErrDKGMissingEnvelope = errors.New("magnetar: DKG missing envelope for this party")
ErrDKGShareSize = errors.New("magnetar: DKG envelope share has wrong size")
ErrDKGContribSize = errors.New("magnetar: DKG envelope contribution has wrong size")
ErrDKGDigestMismatch = errors.New("magnetar: DKG Round-2 digest mismatch — equivocation suspected")
ErrDKGShareTooShort = errors.New("magnetar: DKG share count below threshold")
)
// DKGSession holds one party's state for a Magnetar DKG ceremony.
type DKGSession struct {
Params *Params
Committee []NodeID // canonical-ordered committee, indexed 1..n
Threshold int
MyID NodeID
rng io.Reader
// myIndex is myID's position in committee (0-indexed).
myIndex int
// myContribution is THIS party's c_i. Held across rounds.
myContribution []byte
// myShares[j] is P_j's Shamir share of myContribution, for
// j ∈ 0..n-1.
myShares []shamirShare
// myEvalPoint is the party's Shamir x-coordinate (1-indexed).
myEvalPoint uint32
// receivedR1 caches Round-1 messages by sender for use in Round 2/3.
receivedR1 map[NodeID]*DKGRound1Msg
// receivedR2 caches Round-2 digests by sender.
receivedR2 map[NodeID]*DKGRound2Msg
}
// NewDKGSession constructs a fresh DKG session for committee C
// with threshold t. myID must be a member of C. rng may be nil
// (crypto/rand is used).
//
// Committee ordering: caller passes the canonical sorted committee.
// EvalPoints are derived as (committee_index + 1), matching the
// Pulsar pattern.
func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeID, rng io.Reader) (*DKGSession, error) {
if err := params.Validate(); err != nil {
return nil, err
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, ErrInvalidThreshold
}
if n > MaxCommittee257 {
return nil, ErrCommitteeTooLarge
}
myIndex := -1
for i, id := range committee {
if id == myID {
myIndex = i
break
}
}
if myIndex < 0 {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
return &DKGSession{
Params: params,
Committee: append([]NodeID{}, committee...),
Threshold: threshold,
MyID: myID,
rng: rng,
myIndex: myIndex,
myEvalPoint: uint32(myIndex + 1),
receivedR1: make(map[NodeID]*DKGRound1Msg, n),
receivedR2: make(map[NodeID]*DKGRound2Msg, n),
}, nil
}
// Round1 samples this party's contribution and emits the per-
// recipient envelope broadcast.
func (s *DKGSession) Round1() (*DKGRound1Msg, error) {
if s.myContribution != nil {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
t := s.Threshold
// Sample the contribution.
s.myContribution = make([]byte, s.Params.SeedSize)
if _, err := io.ReadFull(s.rng, s.myContribution); err != nil {
return nil, ErrShortRand
}
// Sample the polynomial coefficient stream from rng — enough
// bytes to feed (t-1) × seed_size × 2 bytes of coefficient
// material. cshake256 will be used as a fallback inside shamirDealRandomGF
// when the stream is short.
needed := (t - 1) * s.Params.SeedSize * 2
if needed < 2 {
needed = 2
}
coeffStream := make([]byte, needed)
if _, err := io.ReadFull(s.rng, coeffStream); err != nil {
return nil, ErrShortRand
}
shares, err := shamirDealRandom(s.myContribution, n, t, coeffStream)
if err != nil {
return nil, err
}
s.myShares = shares
// Build per-recipient envelopes.
envs := make(map[NodeID]DKGShareEnvelope, n)
for j := 0; j < n; j++ {
envs[s.Committee[j]] = DKGShareEnvelope{
Share: shareToBytes(shares[j]),
Contribution: append([]byte{}, s.myContribution...),
}
}
return &DKGRound1Msg{
NodeID: s.MyID,
Envelopes: envs,
}, nil
}
// Round2 ingests every party's Round-1 envelopes, validates them,
// and emits this party's transcript-digest broadcast.
func (s *DKGSession) Round2(round1 []*DKGRound1Msg) (*DKGRound2Msg, error) {
if s.myContribution == nil {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
expectShareLen := s.Params.SeedSize * 2
expectContribLen := s.Params.SeedSize
committeeSet := make(map[NodeID]struct{}, n)
for _, id := range s.Committee {
committeeSet[id] = struct{}{}
}
for _, m := range round1 {
if _, ok := committeeSet[m.NodeID]; !ok {
return nil, ErrDKGCommitteeMismatch
}
// Must contain an envelope for this party.
env, ok := m.Envelopes[s.MyID]
if !ok {
return nil, ErrDKGMissingEnvelope
}
if len(env.Share) != expectShareLen {
return nil, ErrDKGShareSize
}
if len(env.Contribution) != expectContribLen {
return nil, ErrDKGContribSize
}
// Must contain an envelope for every committee member.
for _, peer := range s.Committee {
if _, has := m.Envelopes[peer]; !has {
return nil, ErrDKGMissingEnvelope
}
}
s.receivedR1[m.NodeID] = m
}
if len(s.receivedR1) != n {
return nil, ErrDKGCommitteeMismatch
}
// Compute the transcript digest. The digest binds:
// - tau_dkg = (mode, committee, threshold, domain-sep)
// - the ordered envelope-set: for each dealer in committee
// order, for each recipient in committee order, the
// envelope bytes.
//
// Both this party and every honest peer compute the same
// digest from the same envelope set — equivocation by a
// dealer who broadcasts distinct envelopes to distinct
// recipients is detected at Round 3 as a digest mismatch.
digest := s.computeR2Digest()
return &DKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
}
// computeR2Digest computes the transcript digest used in Round 2.
// Pure function of (params, committee, threshold, receivedR1).
func (s *DKGSession) computeR2Digest() [32]byte {
parts := [][]byte{
[]byte(tagDomainSep),
{byte(s.Params.Mode)},
intLE(uint32(s.Threshold)),
intLE(uint32(len(s.Committee))),
}
for _, id := range s.Committee {
parts = append(parts, id[:])
}
// For each dealer (committee order), for each recipient
// (committee order), the envelope's share+contribution.
for _, dealerID := range s.Committee {
m := s.receivedR1[dealerID]
if m == nil {
parts = append(parts, []byte("MISSING"))
continue
}
for _, recipientID := range s.Committee {
env := m.Envelopes[recipientID]
parts = append(parts, env.Share)
parts = append(parts, env.Contribution)
}
}
return transcriptHash32(tagDKGTranscript, parts...)
}
// Round3 ingests Round-1 envelopes (cached in Round 2) and Round-2
// digests. Produces the party's KeyShare and the joint group
// public key.
//
// Returns DKGOutput with AbortEvidence != nil on detected
// misbehavior (digest mismatch). The caller is expected to
// broadcast the AbortEvidence for slashing.
func (s *DKGSession) Round3(round2 []*DKGRound2Msg) (*DKGOutput, error) {
if len(s.receivedR1) == 0 {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
if len(round2) < n {
return nil, ErrDKGShareTooShort
}
// Index round2 by sender; verify all match my digest.
myDigest := s.computeR2Digest()
committeeSet := make(map[NodeID]struct{}, n)
for _, id := range s.Committee {
committeeSet[id] = struct{}{}
}
for _, m := range round2 {
if _, ok := committeeSet[m.NodeID]; !ok {
return nil, ErrDKGCommitteeMismatch
}
s.receivedR2[m.NodeID] = m
}
for _, id := range s.Committee {
m, ok := s.receivedR2[id]
if !ok {
return nil, ErrDKGCommitteeMismatch
}
if !ctEqual32(m.Digest, myDigest) {
// Identifiable abort: digest mismatch indicates either
// the accused dealer equivocated or this party's view
// of the envelope set differs. Build a complaint with
// the conflicting digest pair as evidence.
return &DKGOutput{
AbortEvidence: &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: s.MyID,
Accused: m.NodeID,
Evidence: append(append([]byte{}, myDigest[:]...), m.Digest[:]...),
},
}, nil
}
}
// Sum the per-byte shares received from each dealer to get
// THIS party's share of the joint master byte-sum.
seedSize := s.Params.SeedSize
mySumShare := make([]uint16, seedSize)
for _, dealerID := range s.Committee {
env := s.receivedR1[dealerID].Envelopes[s.MyID]
// Deserialize the envelope's share for this party.
share := shareFromBytes(s.myEvalPoint, env.Share)
for b := 0; b < seedSize; b++ {
mySumShare[b] = uint16((uint32(mySumShare[b]) + uint32(share.Y[b])) % shamirPrime)
}
}
// Sum the contributions to get the master byte-sum (this is
// the same value mySumShare would Lagrange-interpolate to at
// x=0 when combined with a threshold of other parties).
masterByteSum := make([]uint16, seedSize)
for _, dealerID := range s.Committee {
env := s.receivedR1[dealerID].Envelopes[s.MyID]
for b := 0; b < seedSize; b++ {
masterByteSum[b] = uint16((uint32(masterByteSum[b]) + uint32(env.Contribution[b])) % shamirPrime)
}
}
// Mix the byteSum with the committee root to get the master
// seed. This step domain-separates the seed from the raw
// Shamir reconstruction and gives constant-length output
// regardless of how many parties contributed.
committeeRoot := committeeRootFromIDs(s.Committee)
byteSumBytes := make([]byte, seedSize*2)
for b := 0; b < seedSize; b++ {
byteSumBytes[2*b] = byte(masterByteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(masterByteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
masterSeed := cshake256(mixInput, seedSize, tagSeedShare)
// Derive the group key.
sk, err := KeyFromSeed(s.Params, masterSeed)
if err != nil {
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(masterByteSum)
return nil, err
}
groupPub := sk.Pub
// We DON'T retain sk — only the public part is published.
// Wipe sk before continuing.
zeroizePrivateKey(sk)
// Compute the transcript hash for chain pinning. 48-byte
// digest binding (committee_root, group_pubkey, threshold).
transcript := transcriptHash(tagDKGTranscript,
[]byte(tagDomainSep),
committeeRoot[:],
groupPub.Bytes,
intLE(uint32(s.Threshold)),
)
// Construct THIS party's KeyShare. The share field carries
// the per-byte Shamir share of the master byte-sum. The
// aggregator at Combine reconstructs the byte-sum from t
// such shares via Lagrange interpolation, then mixes with
// committee_root via cSHAKE256 — bit-for-bit matching the
// DKG mix above.
shareBytes := make([]byte, seedSize*2)
for b := 0; b < seedSize; b++ {
shareBytes[2*b] = byte(mySumShare[b] >> 8)
shareBytes[2*b+1] = byte(mySumShare[b])
}
out := &DKGOutput{
GroupPubkey: groupPub,
SecretShare: &KeyShare{
NodeID: s.MyID,
EvalPoint: s.myEvalPoint,
Share: shareBytes,
Pub: groupPub,
Mode: s.Params.Mode,
},
TranscriptHash: transcript,
}
// Wipe intermediate buffers.
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(masterByteSum)
zeroizeU16Slice(mySumShare)
zeroizeBytes(s.myContribution)
for _, sh := range s.myShares {
zeroizeU16Slice(sh.Y)
}
s.myContribution = nil
s.myShares = nil
return out, nil
}
// committeeRootFromIDs computes the canonical 32-byte digest of
// the sorted committee. Used by DKG and Combine to bind the share
// reconstruction to the exact committee that the DKG installed.
func committeeRootFromIDs(ids []NodeID) [32]byte {
sorted := append([]NodeID{}, ids...)
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && nodeIDLess(sorted[j], sorted[j-1]); j-- {
sorted[j], sorted[j-1] = sorted[j-1], sorted[j]
}
}
parts := make([][]byte, 0, len(sorted)+1)
parts = append(parts, []byte("MAGNETAR-COMMITTEE-V1"))
for _, id := range sorted {
parts = append(parts, id[:])
}
return transcriptHash32(tagDKGCommit, parts...)
}
// committeeRootFromShares is the KeyShare-keyed variant used by
// Combine when the aggregator only has the directory of shares.
func committeeRootFromShares(shares []*KeyShare) [32]byte {
ids := make([]NodeID, 0, len(shares))
for _, s := range shares {
ids = append(ids, s.NodeID)
}
return committeeRootFromIDs(ids)
}
// intLE returns x as a 4-byte little-endian slice. Used in
// transcript binding.
func intLE(x uint32) []byte {
return []byte{byte(x), byte(x >> 8), byte(x >> 16), byte(x >> 24)}
}
// ErrNotInQuorum is reused from the threshold layer; declared
// here to avoid forward-declaration issues at first compilation.
var ErrNotInQuorum = errors.New("magnetar: party not in committee/quorum")
-212
View File
@@ -1,212 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"testing"
)
func TestDKG_BasicAgreement(t *testing.T) {
skipUnderRace(t)
// (n=3, t=2): smallest non-trivial committee. Every party
// must agree on the joint group pubkey and transcript hash.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, transcript := runDKG(t, params, committee, 2)
if pub == nil {
t.Fatalf("nil group pubkey")
}
if len(pub.Bytes) != params.PublicKeySize {
t.Fatalf("group pubkey size: got %d want %d", len(pub.Bytes), params.PublicKeySize)
}
if len(shares) != 3 {
t.Fatalf("expected 3 shares, got %d", len(shares))
}
// Every share carries the right wire size.
for i, s := range shares {
if len(s.Share) != params.SeedSize*2 {
t.Fatalf("share[%d] wire size: got %d want %d", i, len(s.Share), params.SeedSize*2)
}
if !s.Pub.Equal(pub) {
t.Fatalf("share[%d].Pub differs from group pubkey", i)
}
if s.EvalPoint == 0 {
t.Fatalf("share[%d] has zero eval point", i)
}
}
// Transcript is non-zero.
allZero := true
for _, b := range transcript {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Fatalf("transcript is all zero")
}
}
func TestDKG_AllConfigurations(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA DKG with n>=5 is slow under -short")
}
configs := []struct{ N, T int }{
{3, 2}, {5, 3}, {7, 4}, {10, 7},
}
for _, cfg := range configs {
cfg := cfg
t.Run(modeStringConfig(cfg.N, cfg.T), func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(cfg.N)
pub, shares, _ := runDKG(t, params, committee, cfg.T)
if pub == nil {
t.Fatalf("nil group pubkey")
}
if len(shares) != cfg.N {
t.Fatalf("expected %d shares, got %d", cfg.N, len(shares))
}
})
}
}
func TestDKG_EquivocationDetected(t *testing.T) {
skipUnderRace(t)
// Tamper with one party's Round-2 digest and check that
// honest parties produce AbortEvidence at Round 3.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
n := 3
sessions := make([]*DKGSession, n)
for i := range sessions {
seed := []byte{byte(i), 0xDD, 0xCC}
s, _ := NewDKGSession(params, committee, 2, committee[i], newDetReader(seed))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
// Tamper with r2[1]'s digest.
r2[1].Digest[0] ^= 0x01
// Honest party 0 runs Round 3 — must detect the equivocation.
out, err := sessions[0].Round3(r2)
if err != nil {
t.Fatalf("Round3: %v", err)
}
if out.AbortEvidence == nil {
t.Fatalf("expected AbortEvidence on digest mismatch")
}
if out.AbortEvidence.Kind != ComplaintEquivocation {
t.Fatalf("expected ComplaintEquivocation, got %v", out.AbortEvidence.Kind)
}
}
func TestDKG_MissingEnvelopeRejected(t *testing.T) {
skipUnderRace(t)
// A dealer that omits the recipient's envelope is rejected
// at Round 2.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
sessions := make([]*DKGSession, 3)
for i := range sessions {
seed := []byte{byte(i), 0xEE}
s, _ := NewDKGSession(params, committee, 2, committee[i], newDetReader(seed))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, 3)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
// Strip party 0's envelope from dealer 1.
delete(r1[1].Envelopes, committee[0])
if _, err := sessions[0].Round2(r1); err == nil {
t.Fatalf("expected ErrDKGMissingEnvelope")
}
}
func TestDKG_NonMemberRejected(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
var stranger NodeID
stranger[0] = 0xFF
if _, err := NewDKGSession(params, committee, 2, stranger, nil); err == nil {
t.Fatalf("expected ErrNotInQuorum for non-member")
}
}
func TestDKG_InvalidThresholdRejected(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
if _, err := NewDKGSession(params, committee, 0, committee[0], nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t=0)")
}
if _, err := NewDKGSession(params, committee, 4, committee[0], nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t > n)")
}
}
func TestDKG_DeterministicAcrossRuns(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: two DKG runs with n=5 are slow under -short")
}
// Two runs of the DKG with the same deterministic seeds must
// produce byte-identical group pubkeys, transcripts, and
// shares.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub1, sh1, tr1 := runDKG(t, params, committee, 3)
pub2, sh2, tr2 := runDKG(t, params, committee, 3)
if !pub1.Equal(pub2) {
t.Fatalf("group pubkey not deterministic")
}
if tr1 != tr2 {
t.Fatalf("transcript hash not deterministic")
}
for i := range sh1 {
if string(sh1[i].Share) != string(sh2[i].Share) {
t.Fatalf("share[%d] bytes not deterministic", i)
}
}
}
func modeStringConfig(n, t int) string {
return formatConfig(n, t)
}
func formatConfig(n, t int) string {
return spfNT(n, t)
}
func spfNT(n, t int) string {
return itoa(t) + "of" + itoa(n)
}
func itoa(v int) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var digits [12]byte
i := len(digits)
for v > 0 {
i--
digits[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
digits[i] = '-'
}
return string(digits[i:])
}
+54 -67
View File
@@ -1,87 +1,74 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package magnetar implements two threshold-style modes over the
// FIPS 205 SLH-DSA primitive. Pick the mode based on your trust
// model — see DEPLOYMENT-RUNBOOK.md §0 for the decision tree.
// Package magnetar implements the Magnetar v1.0 hash-based
// post-quantum signature primitives over FIPS 205 SLH-DSA.
// Magnetar ships TWO production primitives:
//
// ## Mode 1: AggregateSignatures (public-BFT-safe, the default
// recommendation)
// # 1. Per-validator standalone (production primary)
//
// Each validator i holds its OWN SLH-DSA keypair (sk_i, pk_i)
// generated independently (no DKG, no shared seed). To sign a
// message m, each validator produces a single-party FIPS 205
// signature σ_i and broadcasts a SignedBundle envelope carrying
// (ValidatorID_i, pk_i, σ_i). The consensus layer collects N
// bundles via AggregateSignatures, and a relying party calls
// VerifyAggregated which returns the COUNT of valid signers
// (compared to a consensus-layer threshold to make the quorum
// policy decision).
// Each validator i holds its OWN FIPS 205 SLH-DSA keypair
// (sk_i, pk_i) generated independently — no DKG, no shared seed.
// To sign a message m, each validator produces a single-party
// deterministic FIPS 205 signature σ_i; the consensus layer
// collects N signatures into a ValidatorAggregateCert via
// BuildAggregateCert and VerifyAggregateCert returns the COUNT of
// valid signers (the quorum threshold check lives at the consumer).
//
// Properties:
// - No aggregator-in-TCB. No single host ever holds N validators'
// secret material together.
// - Wire size = N × |σ| (compressible via Z-Chain Groth16 rollup
// to ~192 bytes; that's a separate primitive).
// - Wire size = N × |σ| (Z-Chain Groth16 compresses to ~192 B).
// - Per-validator slashing is attributable.
// - The SOTA answer for "public-BFT post-quantum finality on
// SLH-DSA".
// - This is the production-primary path for public-BFT consensus.
//
// This is the path almost every Magnetar deployment should use.
// API: PerValidatorKeypair, ValidatorSign, ValidatorBatchVerify,
// BuildAggregateCert, VerifyAggregateCert (standalone.go).
//
// ## Mode 2: CombineWithSeedReconstruction (custody, REQUIRES TEE)
// # 2. THBS-SE (permissionless threshold)
//
// Construction summary:
// Threshold Hash-Based Signatures with Selected-Element
// Reconstruction. A t-of-n committee produces a SINGLE FIPS 205
// signature for a message bound to a slot tuple (chain_id, epoch,
// slot, height, committee_id, message_domain). The public
// combiner role is open to any peer; no party is in the TCB
// post-setup. Slot-bound commit-and-reveal with slashable
// equivocation evidence and over-selected committee tolerate up
// to n-t silent withholders.
//
// - Threshold DKG: each party contributes a random scheme-seed.
// Contributions are byte-wise Shamir-shared via GF(257) (n
// parties, t threshold) and broadcast in per-recipient
// envelopes. Each envelope ALSO carries the full contribution
// so every party can sum the contributions to compute the
// joint master public key locally. The party's KeyShare is
// the sum of received shares (one per dealer).
// API: NewThbsSeKey, ThbsSeRound1, Combine, VerifyThbsSeEvidence,
// VerifyThbsSeShareEvidence, ThbsSeSlotGuard (thbsse.go).
//
// - Threshold sign: each signer commits to a mask + masked-share
// in Round 1, reveals (mask, masked_share) in Round 2. The
// aggregator gathers t reveals, XORs to recover the underlying
// share, Lagrange-interpolates the byte-sum, applies the same
// cSHAKE256 mix as the DKG to recover the master seed, and
// calls single-party FIPS 205 SignDeterministic. The resulting
// signature is byte-identical to single-party SLH-DSA on the
// same (master_seed, message, ctx) — the Magnetar analog of
// Pulsar's Class N1 byte-equality claim.
// # Verification (both primitives)
//
// - Verify: a thin dispatch over circl/slhdsa.Verify. Threshold
// signatures verify under unmodified FIPS 205 verifiers.
// FIPS 205 signature payloads are byte-identical across primitives
// (Magnetar adds no envelope to the inner SLH-DSA bytes). Any
// unmodified verifier (cloudflare/circl, NIST FIPS 205 reference,
// openssl-pq) accepts Magnetar signatures after the canonical
// MAGS wire envelope (wire.go) is stripped. VerifyBytes and
// VerifyBytesCtx are the stateless dispatch helpers consumed by
// the threshold daemon.
//
// HONEST TRUST CAVEAT: the aggregator process is in the trusted
// computing base for the brief window the master seed is
// reconstructed in memory. This is the same trust model as
// Pulsar's v0.1 reveal-and-aggregate (BLOCKERS.md). REQUIRES TEE
// for public deployment. Operational mitigations: run the
// aggregator inside a TEE (Intel TDX / SEV-SNP / SGX), mlock the
// seed buffer, disable ptrace, and use a short-lived signer
// process. See DEPLOYMENT-RUNBOOK.md for the deployment-time
// mitigations matrix and SPEC.md for the full trust-model
// disclosure.
// # What lives elsewhere
//
// TEE-only seed-reconstruction (for institutional MPC custody
// where an attestation-pinned host is in the TCB by policy) is
// NOT shipped by this package; it lives at
// luxfi/threshold/protocols/slhdsa-tee. Magnetar is the
// public-BFT and permissionless surface.
//
// # Architectural framing
//
// Why this caveat is fundamental, not a Magnetar choice:
// SLH-DSA is hash-based — WOTS+ / FORS / Merkle trees over a
// secret seed. The literature establishes there is no efficient
// threshold MPC that produces a single FIPS 205-shaped signature
// without reconstructing the seed:
//
// - Cozzo & Smart, "Sharing the LUOV" (EUROCRYPT 2019)
// - Bonte, Smart, Tan, "Threshold SPHINCS+" (2023)
// - NIST IR 8214 / MPTC submission notes
// - FIPS 205 §6 SLH-DSA-Sign-Internal (direct inspection)
//
// A v0.2 instantiation that gives true threshold secrecy
// (aggregator never sees the seed) requires full MPC over
// SLH-DSA's hash tree — see BLOCKERS.md BLK-1.
//
// USE THIS MODE ONLY when an aggregator host is in your TCB (e.g.
// M-Chain custody hosts with TDX/SEV-SNP/SGX attestation, the
// LP-134 thresholdvm M-Chain mode). For public BFT, use
// AggregateSignatures instead.
// secret seed. The literature (Cozzo-Smart EUROCRYPT 2019,
// Bonte-Smart-Tan 2023, NIST IR 8214) establishes there is no
// efficient threshold MPC producing a FIPS 205-shaped signature
// without either (a) a seed-reconstruction step in some combiner
// process, or (b) a multi-round MPC over the SHA-256/SHAKE hash
// tree (open research, multi-second per signature). The
// per-validator standalone path sidesteps the question by
// emitting N independent signatures; THBS-SE accepts (a) via a
// PUBLIC combiner role (anyone can combine; no host in TCB) and
// pins the strict-atom-assembly path (the v1.1 goal where no
// combiner ever holds the seed) at BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11.
package magnetar
-271
View File
@@ -1,271 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// e2e_test.go — end-to-end Magnetar threshold-signing scenarios.
//
// These tests exercise the FULL protocol path (DKG -> threshold sign
// -> Verify) under realistic configurations, including:
//
// - All three Magnetar parameter modes (M192s, M192f, M256s).
// - Multiple (n, t) threshold configurations.
// - Cross-implementation byte-equality against single-party circl
// SLH-DSA on the reconstructed seed.
// - Quorum-rotation: signing with two distinct quorums must produce
// the SAME signature (deterministic SLH-DSA byte-equality).
// - Long messages + context strings (boundary cases).
// - Repeat signing: signing the same (m, ctx) twice produces
// byte-identical output.
import (
"bytes"
"crypto/rand"
"testing"
"github.com/cloudflare/circl/sign/slhdsa"
)
func TestE2E_AllModes_BasicCeremony(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run multi-mode e2e")
}
modes := []Mode{ModeM192s, ModeM192f, ModeM256s}
for _, mode := range modes {
mode := mode
t.Run(mode.String(), func(t *testing.T) {
params := MustParamsFor(mode)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("Magnetar e2e " + mode.String())
sig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("sig size %d != expected %d", len(sig.Bytes), params.SignatureSize)
}
})
}
}
func TestE2E_CrossImpl_ThresholdMatchesCirclSingleParty(t *testing.T) {
skipUnderRace(t)
// Run the threshold protocol AND a centralized circl
// SignDeterministic on the reconstructed seed; assert
// byte-equality.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: threshold ≡ centralised circl SignDeterministic")
var ctx []byte
thresholdSig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
// Centralized path: reconstruct seed, sk = circl.DeriveKey(seed),
// sig = circl.SignDeterministic(sk, msg, ctx).
quorumShares := make([]*KeyShare, 2)
for i := 0; i < 2; i++ {
quorumShares[i] = shares[i]
}
masterSeed := reconstructMasterSeed(t, params, quorumShares, shares)
scheme := params.ID.Scheme()
pkCircl, skCircl := scheme.DeriveKey(masterSeed)
// circl returns sign.PrivateKey / sign.PublicKey interfaces;
// SignDeterministic takes *slhdsa.PrivateKey concretely. Marshal
// and re-unmarshal to get the concrete type (the same path
// Magnetar's own keygen.go uses).
skBytes, err := skCircl.MarshalBinary()
if err != nil {
t.Fatalf("skCircl.MarshalBinary: %v", err)
}
priv := slhdsa.PrivateKey{ID: params.ID}
if err := priv.UnmarshalBinary(skBytes); err != nil {
t.Fatalf("priv.UnmarshalBinary: %v", err)
}
circlSig, err := slhdsa.SignDeterministic(&priv, slhdsa.NewMessage(msg), ctx)
if err != nil {
t.Fatalf("circl SignDeterministic: %v", err)
}
// Sanity: the circl-derived public key must match the DKG pub.
pkBytes, err := pkCircl.MarshalBinary()
if err != nil {
t.Fatalf("pkCircl.MarshalBinary: %v", err)
}
if !bytes.Equal(pkBytes, pub.Bytes) {
t.Fatalf("circl pk does not match DKG pub")
}
if !bytes.Equal(thresholdSig.Bytes, circlSig) {
t.Errorf("threshold and circl signature bytes differ")
for i := 0; i < len(thresholdSig.Bytes) && i < len(circlSig); i++ {
if thresholdSig.Bytes[i] != circlSig[i] {
t.Errorf("first divergent byte at offset %d: threshold=0x%02x circl=0x%02x",
i, thresholdSig.Bytes[i], circlSig[i])
break
}
}
t.Fatalf("cross-impl byte-equality violated")
}
}
func TestE2E_DistinctQuorums_SameSignature(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run distinct-quorum e2e")
}
// Two distinct quorums on the same (msg, ctx) MUST produce the
// same byte-identical signature (reveal-and-aggregate
// reconstructs to the same master seed regardless of quorum).
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub, shares, _ := runDKG(t, params, committee, 3)
msg := []byte("e2e: distinct quorums must agree on signature")
sign := func(idxs []int) []byte {
t.Helper()
quorumShares := make([]*KeyShare, len(idxs))
quorum := make([]NodeID, len(idxs))
for i, j := range idxs {
quorumShares[i] = shares[j]
quorum[i] = shares[j].NodeID
}
var sid [16]byte
copy(sid[:], "magnetar-e2e-qrm")
signers := make([]*ThresholdSigner, len(idxs))
for i, j := range idxs {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[j], msg,
newDetReader([]byte{byte(j), 0x5A}))
signers[i] = s
}
r1 := make([]*Round1Message, len(idxs))
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, len(idxs))
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, 1, quorum, len(idxs), r1, r2, shares)
if err != nil {
t.Fatalf("CombineWithSeedReconstruction: %v", err)
}
return sig.Bytes
}
sigA := sign([]int{0, 1, 2})
sigB := sign([]int{2, 3, 4})
if !bytes.Equal(sigA, sigB) {
t.Errorf("distinct quorums produced different signatures")
t.Fatalf("quorum-independence violated")
}
}
func TestE2E_RepeatSign_DeterministicByteIdentical(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: repeat sign must be byte-identical")
sig1, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("first sign: %v", err)
}
sig2, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("second sign: %v", err)
}
if !bytes.Equal(sig1.Bytes, sig2.Bytes) {
t.Fatalf("repeat sign produced different signatures (SignDeterministic should be byte-identical)")
}
}
func TestE2E_LargeMessage_BoundaryOK(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run large-message e2e")
}
// Long messages (1 MB) and the FIPS 205 §10.2 max-context (255
// bytes) must work end-to-end.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := make([]byte, 1<<20) // 1 MB
if _, err := rand.Read(msg); err != nil {
t.Fatalf("rand.Read: %v", err)
}
ctx := make([]byte, 255)
for i := range ctx {
ctx[i] = byte(i)
}
// Run Combine with explicit ctx.
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
var sid [16]byte
copy(sid[:], "e2e-large-msg-01")
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg, nil)
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := CombineWithSeedReconstruction(params, pub, msg, ctx, false, sid, 1, quorum, 2, r1, r2, shares)
if err != nil {
t.Fatalf("CombineWithSeedReconstruction: %v", err)
}
if err := VerifyCtx(params, pub, msg, ctx, sig); err != nil {
t.Fatalf("VerifyCtx: %v", err)
}
}
func TestE2E_KATReplay_Deterministic(t *testing.T) {
skipUnderRace(t)
// Run the same ceremony twice with the SAME deterministic seeds;
// both runs MUST produce byte-identical artifacts (DKG pubkey,
// shares, threshold sig). This is the KAT-determinism invariant.
runOnce := func() (pubBytes []byte, shareBytes []byte, sigBytes []byte) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: KAT replay determinism")
sig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
pubBytes = append(pubBytes, pub.Bytes...)
shareBytes = append(shareBytes, shares[0].Share...)
sigBytes = append(sigBytes, sig.Bytes...)
return
}
p1, s1, sig1 := runOnce()
p2, s2, sig2 := runOnce()
if !bytes.Equal(p1, p2) {
t.Fatalf("KAT replay: DKG pub differs")
}
if !bytes.Equal(s1, s2) {
t.Fatalf("KAT replay: party-0 share differs")
}
if !bytes.Equal(sig1, sig2) {
t.Fatalf("KAT replay: threshold sig differs")
}
}
-222
View File
@@ -1,222 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// fuzz_test.go — fuzz harness targeting Magnetar's wire-format parsers.
//
// The fuzz targets exercise:
//
// - Round-1 / Round-2 / DKG envelope wire parsing under arbitrary
// adversarial byte input (no Round-1 / Round-2 should panic; all
// malformed inputs must produce a typed error).
// - The Combine commit-bind re-derive: an attacker who controls
// (Round1.Commit, Round2.PartialSig) cannot cause Combine to
// panic or return a partially-tampered signature.
// - Transcript-hash injection: arbitrary byte sequences fed to
// transcriptHash32 / transcriptHash MUST produce a fixed-length
// digest with no out-of-bounds reads.
//
// Run:
// go test -fuzz=FuzzCombineParse_NoPanic -fuzztime=60s ./ref/go/pkg/magnetar/
// go test -fuzz=FuzzTranscriptHash_NoPanic -fuzztime=60s ./ref/go/pkg/magnetar/
// go test -fuzz=FuzzShareDecode_RoundTrip -fuzztime=60s ./ref/go/pkg/magnetar/
import (
"bytes"
"testing"
)
// FuzzCombineParse_NoPanic — feed adversarial (Round1, Round2,
// allShares) tuples to Combine and assert it never panics.
//
// Constructs a real (n=3, t=2) ceremony fixture, then fuzzes the
// PartialSig bytes of Round2 messages. Combine must return either nil
// or a typed error — never panic.
func FuzzCombineParse_NoPanic(f *testing.F) {
// Seed corpus: empty, zero-fill, random ASCII.
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0}, 384), bytes.Repeat([]byte{0}, 384))
f.Add([]byte("AAAAAAAAAAAAAAAAAAAAAA"), []byte("BBBBBBBBBBBBBBBBBBBBBB"))
f.Fuzz(func(t *testing.T, party0PartialSig, party1PartialSig []byte) {
// Set up the fixture once per fuzz invocation. We can't move
// this outside the closure because go fuzz doesn't expose a
// per-iteration setup hook; the SLH-DSA DKG is slow but the
// fuzz framework batches.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKGOrPanic(t, params, committee, 2)
msg := []byte("fuzz combine parse")
var sid [16]byte
copy(sid[:], "fuzz-combine-001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
// Build legitimate Round-1 from real signers; Round-2 messages
// carry the fuzz-supplied PartialSig bytes.
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, err := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0x99}))
if err != nil {
t.Skip()
}
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := []*Round2Message{
{NodeID: shares[0].NodeID, SessionID: sid, Attempt: 1, PartialSig: party0PartialSig},
{NodeID: shares[1].NodeID, SessionID: sid, Attempt: 1, PartialSig: party1PartialSig},
}
// MUST NOT PANIC. Returning an error is fine.
defer func() {
if r := recover(); r != nil {
t.Fatalf("CombineWithSeedReconstruction panicked on fuzz input: %v", r)
}
}()
_, _ = CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, 1, quorum, 2, r1, r2, shares)
})
}
// FuzzDKGEnvelope_NoPanic — feed adversarial envelope share+
// contribution byte combinations to a Round-2 ingest path.
func FuzzDKGEnvelope_NoPanic(f *testing.F) {
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0}, 192), bytes.Repeat([]byte{0}, 96))
f.Add(bytes.Repeat([]byte{0xFF}, 192), bytes.Repeat([]byte{0xFF}, 96))
f.Fuzz(func(t *testing.T, fuzzShare, fuzzContribution []byte) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
myID := committee[0]
sess, err := NewDKGSession(params, committee, 2, myID, newDetReader([]byte{0, 0, 0}))
if err != nil {
t.Skip()
}
// Run my Round-1 so the session has internal state.
myR1, err := sess.Round1()
if err != nil {
t.Skip()
}
// Build a peer Round-1 from the fuzz inputs.
peerR1 := &DKGRound1Msg{
NodeID: committee[1],
Envelopes: map[NodeID]DKGShareEnvelope{
committee[0]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[1]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[2]: {Share: fuzzShare, Contribution: fuzzContribution},
},
}
// Add a stub for the third party so the round2 envelope size
// check has the right cardinality.
thirdR1 := &DKGRound1Msg{
NodeID: committee[2],
Envelopes: map[NodeID]DKGShareEnvelope{
committee[0]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[1]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[2]: {Share: fuzzShare, Contribution: fuzzContribution},
},
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("DKG Round2 panicked on fuzz envelope: %v", r)
}
}()
_, _ = sess.Round2([]*DKGRound1Msg{myR1, peerR1, thirdR1})
})
}
// FuzzTranscriptHash_NoPanic — feed arbitrary byte sequences to the
// transcript hash primitives.
func FuzzTranscriptHash_NoPanic(f *testing.F) {
f.Add([]byte("tag"), []byte("payload"))
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0xAA}, 1024), bytes.Repeat([]byte{0x55}, 1024))
f.Fuzz(func(t *testing.T, tag, payload []byte) {
// transcriptHash32 produces a fixed-length output regardless
// of input.
defer func() {
if r := recover(); r != nil {
t.Fatalf("transcriptHash32 panicked: %v", r)
}
}()
// Customisation strings must be ASCII; truncate fuzz tag.
var cs string
if len(tag) > 64 {
cs = string(tag[:64])
} else {
cs = string(tag)
}
out := transcriptHash32(cs, payload)
if len(out) != 32 {
t.Fatalf("transcriptHash32 output length %d != 32", len(out))
}
out48 := transcriptHash(cs, payload)
if len(out48) != 48 {
t.Fatalf("transcriptHash output length %d != 48", len(out48))
}
})
}
// FuzzShareDecode_RoundTrip — round-trip the share wire codec on
// arbitrary input. The wire format is fixed-length (seed_size * 2
// bytes per share); the codec MUST be invertible on any input of the
// right size and reject any other length.
func FuzzShareDecode_RoundTrip(f *testing.F) {
f.Add([]byte{}, uint32(0))
f.Add(bytes.Repeat([]byte{0}, 192), uint32(1))
f.Add(bytes.Repeat([]byte{0xFF}, 192), uint32(255))
f.Fuzz(func(t *testing.T, buf []byte, evalPoint uint32) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("shareFromBytes panicked: %v", r)
}
}()
// Lengths that aren't a multiple of 2 produce a truncated
// share; we still don't panic.
decoded := shareFromBytes(evalPoint, buf)
re := shareToBytes(decoded)
// The re-encoded length is len(buf) - len(buf)%2 (the codec
// drops the odd trailing byte). For valid even-length inputs
// the round-trip is exact.
if len(buf)%2 == 0 && !bytes.Equal(re, buf) {
t.Fatalf("shareFromBytes/shareToBytes round-trip not byte-identical")
}
})
}
// FuzzVerify_NoPanic — Verify must never panic on adversarial sig
// bytes.
func FuzzVerify_NoPanic(f *testing.F) {
f.Add([]byte{})
f.Add(bytes.Repeat([]byte{0}, 16224))
f.Add(bytes.Repeat([]byte{0xFF}, 16224))
f.Fuzz(func(t *testing.T, sigBytes []byte) {
params := MustParamsFor(ModeM192s)
sk, err := GenerateKey(params, newDetReader([]byte("fuzz-verify-seed")))
if err != nil {
t.Skip()
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Verify panicked: %v", r)
}
}()
sig := &Signature{Mode: ModeM192s, Bytes: sigBytes}
_ = Verify(params, sk.Pub, []byte("fuzz-verify-msg"), sig)
})
}
// runDKGOrPanic — fuzz-friendly DKG helper. Same as runDKG but uses
// crypto/rand for randomness so each fuzz iteration is independent.
func runDKGOrPanic(t *testing.T, params *Params, committee []NodeID, threshold int) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
return runDKG(t, params, committee, threshold)
}
+156 -22
View File
@@ -7,8 +7,11 @@ package magnetar
// by cmd/genkat and checks that the package implementation
// reproduces the bytes verbatim.
//
// On a clean checkout: re-running cmd/genkat must produce the
// same JSON output, and these tests must continue to pass.
// v1.0 KAT scope: single-party FIPS 205 keygen / sign / verify and
// THBS-SE permissionless threshold sign. Per-validator standalone
// sign is exercised by standalone_test.go +
// TestMagnetar_Wire_FIPS205Verifiable; THBS-SE byte-identity to
// FIPS 205 is exercised by thbsse_test.go.
import (
"bytes"
@@ -16,7 +19,10 @@ import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/cloudflare/circl/sign/slhdsa"
)
type katKeygen struct {
@@ -43,26 +49,26 @@ type katVerify struct {
Valid bool `json:"valid"`
}
type katThreshold struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Message string `json:"message"`
PublicKey string `json:"public_key"`
Signature string `json:"signature"`
Quorum []string `json:"quorum"`
SessionID string `json:"session_id"`
Attempt uint32 `json:"attempt"`
}
type katDKG struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Committee []string `json:"committee"`
PublicKey string `json:"public_key"`
TranscriptHash string `json:"transcript_hash"`
Shares []string `json:"shares"`
// katThbsSe captures the v1.0 THBS-SE KAT shape: a deterministic
// (n, t) committee, slot binding tuple, message, and the
// resulting (group public key, signature) pair. Byte-equal to
// what stock FIPS 205 SignDeterministic emits on the master seed
// under ctx = ctxFromSlot(binding).
type katThbsSe struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
SetupSeed string `json:"setup_seed"`
Committee []string `json:"committee"` // hex 32-byte NodeIDs
ChainID string `json:"chain_id"`
Epoch uint64 `json:"epoch"`
Slot uint64 `json:"slot"`
Height uint64 `json:"height"`
CommitteeID string `json:"committee_id"`
MessageDomain string `json:"message_domain"`
Message string `json:"message"`
PublicKey string `json:"public_key"` // FIPS 205 wire bytes (raw, no MAGG)
Signature string `json:"signature"` // FIPS 205 wire bytes (raw, no MAGS)
}
// vectorsDir returns the path to the KAT vectors directory.
@@ -116,6 +122,9 @@ func loadVectors(t *testing.T, path string, v any) bool {
return true
}
// itoa is a test-local small-int formatter.
func itoa(v int) string { return strconv.Itoa(v) }
func TestKAT_Keygen(t *testing.T) {
skipUnderRace(t)
var katsArr []katKeygen
@@ -218,3 +227,128 @@ func TestKAT_Verify(t *testing.T) {
})
}
}
// TestKAT_ThbsSe replays the THBS-SE permissionless threshold
// vectors. For each (n, t) × Mode × slot tuple in
// vectors/thbsse-sign.json, the test:
//
// 1. Rebuilds the deterministic committee from SetupSeed.
// 2. Runs NewThbsSeKey with a SeedReader pinned to the same
// SetupSeed.
// 3. Asserts the recovered FIPS 205 public-key bytes match the
// KAT.
// 4. Runs Round1 for the first t committee members under a
// deterministic mask RNG.
// 5. Runs Combine.
// 6. Asserts the FIPS 205 signature bytes match the KAT.
// 7. Asserts unmodified circl.slhdsa.Verify accepts the
// signature under ctx = ctxFromSlot(binding).
//
// The KAT vectors live at vectors/thbsse-sign.json and are
// regenerated by cmd/genkat. The vectors are deterministic over
// (SetupSeed, slot tuple, message).
func TestKAT_ThbsSe(t *testing.T) {
skipUnderRace(t)
var katsArr []katThbsSe
if !loadVectors(t, "thbsse-sign.json", &katsArr) {
return
}
if len(katsArr) == 0 {
t.Fatalf("no thbsse vectors")
}
for _, k := range katsArr {
k := k
if testing.Short() && k.Mode != "Magnetar-SHAKE-192s" {
continue
}
t.Run(k.Mode+"_n"+itoa(k.N)+"t"+itoa(k.T)+"_slot"+strconv.FormatUint(k.Slot, 10), func(t *testing.T) {
mode := modeFromName(k.Mode)
params := MustParamsFor(mode)
// Rebuild the committee from the KAT NodeID hex list.
committee := make([]NodeID, k.N)
for i := 0; i < k.N; i++ {
idBytes := hexDecode(t, k.Committee[i])
if len(idBytes) != len(committee[i]) {
t.Fatalf("kat committee[%d] wrong length: %d", i, len(idBytes))
}
copy(committee[i][:], idBytes)
}
// Build setup RNG from the KAT setup seed.
setupSeed := hexDecode(t, k.SetupSeed)
setupRng := newDetReader(setupSeed)
key, err := NewThbsSeKey(params, k.T, committee, setupRng)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
expectPK := hexDecode(t, k.PublicKey)
if !bytes.Equal(key.PublicKey.Bytes, expectPK) {
t.Fatalf("public key bytes differ\nexpected: %x\ngot: %x", expectPK, key.PublicKey.Bytes)
}
binding := &ThbsSeSlotBinding{
ChainID: hexDecode(t, k.ChainID),
Epoch: k.Epoch,
Slot: k.Slot,
Height: k.Height,
CommitteeID: hexDecode(t, k.CommitteeID),
MessageDomain: hexDecode(t, k.MessageDomain),
}
msg := hexDecode(t, k.Message)
// Round 1 for the first t signers. Each signer pulls a
// deterministic mask RNG keyed by (setupSeed, party_index).
r1s := make([]ThbsSeRound1Msg, k.T)
r2s := make([]ThbsSeRound2Msg, k.T)
for i := 0; i < k.T; i++ {
guard := NewThbsSeSlotGuard()
maskSeed := append([]byte("thbsse-kat-mask-"), append(setupSeed, byte(i))...)
maskRng := newDetReader(maskSeed)
r1, r2, err := ThbsSeRound1(params, key.Shares[i], binding, msg, guard, maskRng)
if err != nil {
t.Fatalf("Round1[%d]: %v", i, err)
}
r1s[i] = r1
r2s[i] = r2
}
sig, evidences, err := Combine(ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
})
if err != nil {
t.Fatalf("Combine: %v", err)
}
if len(evidences) != 0 {
t.Fatalf("Combine emitted %d evidences on honest replay: %+v", len(evidences), evidences)
}
expectSig := hexDecode(t, k.Signature)
if !bytes.Equal(sig.Bytes, expectSig) {
for i := 0; i < len(sig.Bytes) && i < len(expectSig); i++ {
if sig.Bytes[i] != expectSig[i] {
t.Errorf("first divergent byte at offset %d: got 0x%02x expected 0x%02x", i, sig.Bytes[i], expectSig[i])
break
}
}
t.Fatalf("thbsse signature bytes differ from KAT")
}
// Byte-identity end-stop: unmodified circl verify.
id := slhdsaIDForMode(mode)
pk := slhdsa.PublicKey{ID: id}
if err := pk.UnmarshalBinary(key.PublicKey.Bytes); err != nil {
t.Fatalf("UnmarshalBinary(pk): %v", err)
}
ctx := ctxFromSlot(binding)
if !slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sig.Bytes, ctx) {
t.Fatalf("circl FIPS 205 Verify rejected the THBS-SE KAT signature")
}
})
}
}
+13 -8
View File
@@ -3,19 +3,24 @@
package magnetar
// keygen.go — single-party key generation. This is the FIPS 205
// keygen path; the threshold-DKG distributed counterpart lives in
// dkg.go.
// keygen.go --- single-party key generation. This is the FIPS 205
// keygen path consumed by both:
//
// - the per-validator standalone primitive (standalone.go), where
// every validator runs GenerateKey once and persists (sk, pk); and
//
// - the THBS-SE setup (thbsse.go::NewThbsSeKey), where a single
// scheme seed is sampled, Shamir-shared via thbsseDealRandom, and
// the master seed is zeroized before the ThbsSeKey struct is
// returned.
//
// Output: a FIPS 205 SLH-DSA key pair. The public key is byte-equal
// to what circl's slhdsa.Scheme().DeriveKey would emit on the same
// seed; the private key carries the seed alongside the packed key
// so the threshold layer can reproduce shares deterministically.
// seed; the private key carries the seed alongside the packed key.
//
// The seed is the variable-length (4n bytes) scheme-seed used by
// circl's DeriveKey — for the recommended SHAKE-192s mode that's
// 96 bytes. Magnetar Shamir-shares this seed across the committee
// at DKG time.
// circl's DeriveKey --- for the recommended SHAKE-192s mode that's
// 96 bytes.
import (
"crypto/rand"
@@ -1,260 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// n1_byte_equality_test.go — empirical validation of the Magnetar
// Class-N1-analog output-interchangeability claim.
//
// The claim:
//
// For any honest threshold-sign session whose share set
// reconstructs to a centralized FIPS 205 seed S, the resulting
// signature bytes are IDENTICAL to those produced by FIPS 205
// SignDeterministic(DeriveKey(S), msg, ctx) — i.e., threshold-
// sign is output-byte-equivalent to single-party deterministic
// FIPS 205 signing under the corresponding centralized key.
//
// This is the load-bearing property of Magnetar's threshold mode:
// any stock SLH-DSA verifier accepts threshold-produced
// signatures with NO code change, because the bytes look exactly
// like a centralized FIPS 205 signature.
//
// The implementation strategy that makes this property hold is
// "centralized recovery": Combine reconstructs the master seed
// via Lagrange interpolation of the share set, derives the
// centralized FIPS 205 SK via KeyFromSeed, and calls
// SignDeterministic directly. See combine.go.
//
// This test performs an end-to-end byte-equality check:
//
// 1. Run the DKG to produce shares + group public key.
// 2. Reconstruct the master seed from a threshold quorum of
// shares using the SAME Lagrange + cSHAKE256 mix as Combine.
// 3. Derive the centralized SK via KeyFromSeed(masterSeed).
// 4. Sign centrally with deterministic FIPS 205 mode.
// 5. Run the full threshold-sign protocol on the same (sid,
// attempt, msg, ctx, quorum) under the same shares.
// 6. Assert that the two signatures are BYTE-IDENTICAL.
//
// Catches: any divergence between the centralized FIPS 205 path
// and the threshold-sign path — wrong Shamir coefficient, wrong
// committee root, wrong cSHAKE256 mix input layout, wrong sign-call
// arguments, etc. A regression on any of these would cause the
// signatures to differ and the test to fail.
import (
"bytes"
"testing"
)
// reconstructMasterSeed Lagrange-reconstructs the DKG master seed
// from a quorum of KeyShares + the full committee membership.
// This mirrors the in-Combine reconstruction at combine.go
// bit-for-bit — same shamirReconstructGF, same byteSum byte
// ordering, same committeeRoot, same cSHAKE256 tag.
func reconstructMasterSeed(t *testing.T, params *Params, quorum []*KeyShare, committee []*KeyShare) []byte {
t.Helper()
shares := make([]shamirShare, len(quorum))
for i, ks := range quorum {
shares[i] = shareFromBytes(ks.EvalPoint, ks.Share)
}
byteSum, err := shamirReconstructGF(shares, params.SeedSize)
if err != nil {
t.Fatal(err)
}
committeeRoot := committeeRootFromShares(committee)
byteSumBytes := make([]byte, params.SeedSize*2)
for b := 0; b < params.SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
return cshake256(mixInput, params.SeedSize, tagSeedShare)
}
func TestN1_ByteEquality_ThresholdMatchesCentralized(t *testing.T) {
skipUnderRace(t)
// Three configurations spanning small / typical / larger
// thresholds. Every config must produce byte-identical
// signatures from the two paths.
//
// SLH-DSA-SHAKE-192s key derivation is hash-tree-deep
// (h=63, d=7) so KAT-sized DKG runs take several seconds
// under the race detector. We keep the small config always
// and gate the larger configs behind !testing.Short() so
// `go test -race -short` finishes in ~minute.
smallConfigs := []struct {
name string
n, t int
}{
{"3of2", 3, 2},
}
largeConfigs := []struct {
name string
n, t int
}{
{"5of3", 5, 3},
{"7of4", 7, 4},
}
configs := smallConfigs
if !testing.Short() {
configs = append(configs, largeConfigs...)
}
for _, tc := range configs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(tc.n)
pub, shares, _ := runDKG(t, params, committee, tc.t)
msg := []byte("Magnetar Class-N1-analog byte-equality test")
var ctx []byte // empty context
// --- Threshold-sign path. ---
var sid [16]byte
copy(sid[:], "n1-byte-eq-sid01")
attempt := uint32(1)
quorumIDs := make([]NodeID, tc.t)
for i := 0; i < tc.t; i++ {
quorumIDs[i] = shares[i].NodeID
}
signers := make([]*ThresholdSigner, tc.t)
for i := 0; i < tc.t; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorumIDs, shares[i], msg,
newDetReader([]byte{byte(i), 0xBE, 0xEF}))
if err != nil {
t.Fatalf("NewThresholdSigner party %d: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, tc.t)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, tc.t)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v", i, err)
}
r2[i] = m
}
thresholdSig, err := CombineWithSeedReconstruction(params, pub, msg, ctx, false, sid, attempt, quorumIDs, tc.t, r1, r2, shares)
if err != nil {
t.Fatalf("CombineWithSeedReconstruction: %v", err)
}
// --- Centralized path. ---
quorumShares := make([]*KeyShare, tc.t)
for i := 0; i < tc.t; i++ {
quorumShares[i] = shares[i]
}
masterSeed := reconstructMasterSeed(t, params, quorumShares, shares)
// Sanity: master_seed must derive to the same pubkey as DKG produced.
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
if !sk.Pub.Equal(pub) {
t.Fatalf("reconstructed master seed does not derive to DKG group pubkey")
}
centralSig, err := Sign(params, sk, msg, ctx, false, nil)
if err != nil {
t.Fatalf("Sign (central): %v", err)
}
// --- Byte equality. ---
if !bytes.Equal(thresholdSig.Bytes, centralSig.Bytes) {
t.Errorf("threshold and centralized signature bytes differ\n"+
"threshold: %x\ncentral: %x", thresholdSig.Bytes[:32], centralSig.Bytes[:32])
// Find the first divergent byte.
for i := 0; i < len(thresholdSig.Bytes) && i < len(centralSig.Bytes); i++ {
if thresholdSig.Bytes[i] != centralSig.Bytes[i] {
t.Errorf("first divergent byte at offset %d: threshold=0x%02x central=0x%02x",
i, thresholdSig.Bytes[i], centralSig.Bytes[i])
break
}
}
t.Fatalf("byte-equality theorem violated")
}
// Both signatures must verify under unmodified FIPS 205.
if err := Verify(params, pub, msg, thresholdSig); err != nil {
t.Fatalf("threshold sig fails FIPS 205 Verify: %v", err)
}
if err := Verify(params, pub, msg, centralSig); err != nil {
t.Fatalf("central sig fails FIPS 205 Verify: %v", err)
}
})
}
}
func TestN1_ByteEquality_DifferentQuorumsSameSignature(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA-SHAKE-192s DKG with n=5 is too slow under -short")
}
// Test that two DISTINCT quorums (different subsets of the
// share set) produce the SAME signature for the same
// (message, ctx). This is a direct consequence of
// reveal-and-aggregate's seed reconstruction being quorum-
// independent.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub, shares, _ := runDKG(t, params, committee, 3)
msg := []byte("Magnetar quorum-independence test")
var ctx []byte
signWith := func(idxs []int, seedTag byte) []byte {
t.Helper()
var sid [16]byte
copy(sid[:], "magnetar-multi-q")
quorum := make([]NodeID, len(idxs))
quorumShares := make([]*KeyShare, len(idxs))
for i, j := range idxs {
quorum[i] = shares[j].NodeID
quorumShares[i] = shares[j]
}
signers := make([]*ThresholdSigner, len(idxs))
for i, j := range idxs {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[j], msg,
newDetReader([]byte{byte(i), seedTag}))
signers[i] = s
}
r1 := make([]*Round1Message, len(idxs))
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, len(idxs))
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := CombineWithSeedReconstruction(params, pub, msg, ctx, false, sid, 1, quorum, len(idxs), r1, r2, shares)
if err != nil {
t.Fatalf("CombineWithSeedReconstruction: %v", err)
}
return sig.Bytes
}
sigA := signWith([]int{0, 1, 2}, 0xAA)
sigB := signWith([]int{2, 3, 4}, 0xBB)
if !bytes.Equal(sigA, sigB) {
t.Errorf("different quorums produced different signatures (expected equal under reveal-and-aggregate)")
// First divergent byte for debugging.
for i := 0; i < len(sigA) && i < len(sigB); i++ {
if sigA[i] != sigB[i] {
t.Errorf("first divergent byte at offset %d: A=0x%02x B=0x%02x", i, sigA[i], sigB[i])
break
}
}
t.Fatalf("quorum-independence violated")
}
}
+21 -30
View File
@@ -3,22 +3,25 @@
package magnetar
// params.go — concrete parameter sets for Magnetar and the canonical
// Mode -> Params resolution. Magnetar instantiates threshold SLH-DSA
// over the FIPS 205 parameter sets defined in NIST FIPS 205 §10.1
// Table 2.
// params.go --- concrete parameter sets for Magnetar and the canonical
// Mode -> Params resolution. Magnetar instantiates FIPS 205 SLH-DSA at
// the parameter sets defined in NIST FIPS 205 sec 10.1 Table 2.
//
// The v0.1 reveal-and-aggregate construction shares the 4n-byte
// SLH-DSA scheme seed (the input to slhdsa.Scheme().DeriveKey) via
// byte-wise Shamir secret sharing over GF(257). On Combine the seed
// is reconstructed in aggregator memory, fed to the single-party
// FIPS 205 SignDeterministic, and zeroized. The resulting signature
// is byte-identical to single-party SLH-DSA on the same (seed, msg,
// ctx) — this is the Magnetar analog of Pulsar's Class N1 claim.
// Both Magnetar primitives consume Params identically:
//
// The v0.1 honest trust caveat: the aggregator process is TCB for
// the brief window the master seed is reconstructed. See SPEC.md
// "Trust model" and DEPLOYMENT-RUNBOOK.md.
// - The per-validator standalone primitive (standalone.go) uses
// Params to size single-party SLH-DSA keys, messages, and
// signatures. No threshold field math.
//
// - THBS-SE (thbsse.go) uses Params for the same SLH-DSA sizes
// PLUS uses SeedSize as the byte length sized for the GF(257)
// byte-wise Shamir share envelope (see thbsse_field.go for the
// PUBLIC-COMBINER share arithmetic).
//
// The signature payloads emitted by both primitives are byte-identical
// to single-party FIPS 205 SignDeterministic on the same (seed, msg,
// ctx) tuple --- the Magnetar wire codec adds no envelope to the inner
// SLH-DSA bytes, only the canonical 11-byte MAGS/MAGG frame.
import (
"errors"
@@ -98,28 +101,19 @@ type Params struct {
// ID is the circl/slhdsa identifier this Mode dispatches to.
// Centralising the mapping makes the dispatch table auditable.
ID slhdsa.ID
// ShamirPrime is the small prime over which per-byte Shamir
// shares of the SLH-DSA seed are computed. 257 matches the
// Pulsar choice: smallest prime > 255, admits every byte as a
// distinct GF element, gives 9-bit shares packed as one uint16
// per byte.
ShamirPrime uint32
}
// SeedSize is the byte length of an SLH-DSA scheme seed for the
// recommended SHAKE-192s mode. Other modes have different seed
// sizes — callers MUST use params.SeedSize, not this constant, for
// sizes --- callers MUST use params.SeedSize, not this constant, for
// non-192s code paths. Provided as a const for KAT generators that
// pin to the recommended mode.
const SeedSize = 96
// MaxCommittee257 is the maximum committee size under byte-wise
// Shamir over GF(257). 256 is exactly q-1 = 257-1, the upper bound
// on distinct non-zero evaluation points.
const MaxCommittee257 = 256
// MaxCommittee257 is defined in thbsse_field.go (the THBS-SE
// construction is the only consumer of byte-wise GF(257) shares).
// Hard-coded parameter tables per FIPS 205 §10.1 Table 2.
// Hard-coded parameter tables per FIPS 205 sec 10.1 Table 2.
// ParamsM192s is the Magnetar SHAKE-192s parameter set (recommended).
var ParamsM192s = &Params{
@@ -130,7 +124,6 @@ var ParamsM192s = &Params{
SignatureSize: 16224,
SeedSize: 96,
ID: slhdsa.SHAKE_192s,
ShamirPrime: 257,
}
// ParamsM192f is the Magnetar SHAKE-192f parameter set.
@@ -142,7 +135,6 @@ var ParamsM192f = &Params{
SignatureSize: 35664,
SeedSize: 96,
ID: slhdsa.SHAKE_192f,
ShamirPrime: 257,
}
// ParamsM256s is the Magnetar SHAKE-256s parameter set.
@@ -154,7 +146,6 @@ var ParamsM256s = &Params{
SignatureSize: 29792,
SeedSize: 128,
ID: slhdsa.SHAKE_256s,
ShamirPrime: 257,
}
// ParamsFor returns the canonical Params for the given Mode.
-226
View File
@@ -1,226 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// shamir.go — byte-wise Shamir secret sharing over GF(257) for the
// SLH-DSA scheme seed.
//
// Why byte-wise over GF(257): the threshold layer shares a 4n-byte
// SLH-DSA scheme seed (96 bytes for SHAKE-192s, 128 bytes for
// SHAKE-256s). The byte values 0..255 fit in [0, 257) and share
// values fit in [0, 257) which need 9 bits → packs as one uint16
// per per-byte share value. Reconstruction at x=0 returns the
// original byte in [0, 256) (mod 256) because the polynomial's
// constant term is the byte.
//
// The choice of GF(257) keeps the wire-share size minimal: each
// share is seed_size × uint16 bytes, regardless of which SLH-DSA
// parameter set is in use.
//
// All arithmetic is constant-time mod p via the small-prime
// modular inverse.
import "errors"
// shamirPrime is the small prime used for per-byte Shamir sharing.
// 257 is the smallest prime > 255 so it admits every byte value as
// a distinct field element.
const shamirPrime uint32 = 257
// shamirShare contains one party's per-byte Shamir share of a
// seed_size-byte secret. Each element is a value in [0, 257)
// stored in a uint16 lane. The Y slice has length seed_size.
type shamirShare struct {
X uint32 // Shamir evaluation point in [1, p); 1-indexed
Y []uint16 // per-byte share value at X (each in [0, p))
}
// shamirShareErrs.
var (
ErrInvalidThreshold = errors.New("magnetar: invalid threshold (n < t or t < 1)")
ErrCommitteeTooLarge = errors.New("magnetar: committee larger than 256 parties (shamir GF(257))")
ErrNotEnoughShares = errors.New("magnetar: not enough shares for reconstruction")
ErrZeroEvalPoint = errors.New("magnetar: evaluation point x=0 is reserved for the secret")
ErrDuplicateEvalPoint = errors.New("magnetar: duplicate evaluation point in shares")
ErrShareWireSize = errors.New("magnetar: share has wrong wire size")
)
// shamirDealRandom shares a seed_size-byte secret across n parties
// with reconstruction threshold t.
//
// Thin wrapper around shamirDealRandomGF that lifts each byte
// secret[b] to a GF(257) value. Used by the DKG path where each
// party's contribution IS a seed_size-byte vector.
func shamirDealRandom(secret []byte, n, t int, coeffStream []byte) ([]shamirShare, error) {
gf := make([]uint16, len(secret))
for b := 0; b < len(secret); b++ {
gf[b] = uint16(secret[b])
}
return shamirDealRandomGF(gf, n, t, coeffStream)
}
// shamirDealRandomGF shares a seedSize-element GF(257) secret
// vector across n parties with reconstruction threshold t. Each
// party 1..n gets a share at evaluation point i. The (t-1)
// polynomial coefficients per slot are pulled from coeffStream.
//
// If coeffStream is shorter than needed, it is stretched via
// cSHAKE256(coeffStream, tag=MAGNETAR-SEED-SHARE-V1).
func shamirDealRandomGF(secret []uint16, n, t int, coeffStream []byte) ([]shamirShare, error) {
if t < 1 || n < t {
return nil, ErrInvalidThreshold
}
if n > MaxCommittee257 {
return nil, ErrCommitteeTooLarge
}
seedSize := len(secret)
needed := (t - 1) * seedSize * 2
if needed < 2 {
needed = 2
}
if len(coeffStream) < needed {
coeffStream = cshake256(coeffStream, needed, tagSeedShare)
}
// coeffs[d][b] is the degree-d coefficient for slot b.
// coeffs[0][b] is the constant term — the secret value itself.
coeffs := make([][]uint16, t)
for d := 0; d < t; d++ {
coeffs[d] = make([]uint16, seedSize)
}
for b := 0; b < seedSize; b++ {
coeffs[0][b] = secret[b] % uint16(shamirPrime)
}
off := 0
for d := 1; d < t; d++ {
for b := 0; b < seedSize; b++ {
r := uint32(coeffStream[off])<<8 | uint32(coeffStream[off+1])
off += 2
coeffs[d][b] = uint16(r % shamirPrime)
}
}
shares := make([]shamirShare, n)
for i := 1; i <= n; i++ {
shares[i-1].X = uint32(i)
shares[i-1].Y = make([]uint16, seedSize)
x := uint32(i)
for b := 0; b < seedSize; b++ {
// Horner's method.
acc := uint32(coeffs[t-1][b])
for d := t - 2; d >= 0; d-- {
acc = (acc*x + uint32(coeffs[d][b])) % shamirPrime
}
shares[i-1].Y[b] = uint16(acc)
}
}
return shares, nil
}
// shamirReconstructGF Lagrange-interpolates the constant term as a
// seedSize-element GF(257) vector. Used by Combine to preserve
// byte-value 256 across reconstruction; the caller routes the
// 16-bit GF result through a cSHAKE256 mix that absorbs the 256/0
// distinction.
func shamirReconstructGF(shares []shamirShare, seedSize int) ([]uint16, error) {
out := make([]uint16, seedSize)
if len(shares) < 1 {
return out, ErrNotEnoughShares
}
seen := make(map[uint32]struct{}, len(shares))
for _, s := range shares {
if s.X == 0 {
return out, ErrZeroEvalPoint
}
if _, dup := seen[s.X]; dup {
return out, ErrDuplicateEvalPoint
}
seen[s.X] = struct{}{}
if len(s.Y) != seedSize {
return out, ErrShareWireSize
}
}
t := len(shares)
// Lagrange basis values at x=0 over GF(p).
// λ_i = Π_{j≠i} (-x_j) / (x_i - x_j) mod p
lambdas := make([]uint16, t)
for i := 0; i < t; i++ {
num := uint32(1)
den := uint32(1)
for j := 0; j < t; j++ {
if i == j {
continue
}
negXj := shamirPrime - (shares[j].X % shamirPrime)
num = (num * negXj) % shamirPrime
diff := (shamirPrime + shares[i].X - shares[j].X) % shamirPrime
den = (den * diff) % shamirPrime
}
denInv := modInvSmall(den, shamirPrime)
lambdas[i] = uint16((num * denInv) % shamirPrime)
}
for b := 0; b < seedSize; b++ {
var acc uint32
for i := 0; i < t; i++ {
acc = (acc + uint32(lambdas[i])*uint32(shares[i].Y[b])) % shamirPrime
}
out[b] = uint16(acc)
}
return out, nil
}
// modInvSmall computes the modular inverse of a mod p where p is a
// small prime. Uses Fermat's little theorem (a^(p-2) ≡ a^-1 mod p).
// p must be prime and a must be in [1, p).
func modInvSmall(a, p uint32) uint32 {
return modPowSmall(a, p-2, p)
}
// modPowSmall computes (base^exp) mod p via square-and-multiply.
func modPowSmall(base, exp, p uint32) uint32 {
result := uint32(1)
b := base % p
for exp > 0 {
if exp&1 == 1 {
result = (result * b) % p
}
b = (b * b) % p
exp >>= 1
}
return result
}
// shareToBytes serialises a shamirShare's Y component to wire form
// (big-endian uint16 per byte position).
func shareToBytes(s shamirShare) []byte {
out := make([]byte, len(s.Y)*2)
for b := 0; b < len(s.Y); b++ {
out[2*b] = byte(s.Y[b] >> 8)
out[2*b+1] = byte(s.Y[b])
}
return out
}
// shareFromBytes deserialises a wire-form Y component.
func shareFromBytes(x uint32, buf []byte) shamirShare {
seedSize := len(buf) / 2
s := shamirShare{X: x, Y: make([]uint16, seedSize)}
for b := 0; b < seedSize; b++ {
s.Y[b] = uint16(buf[2*b])<<8 | uint16(buf[2*b+1])
}
return s
}
// EvalPointFromID derives a deterministic non-zero Shamir
// evaluation point in [1, 257) from a NodeID. Used by callers that
// want an ID-stable evaluation point rather than a position-in-
// committee point. The DKG default uses (committee_index + 1)
// which is simpler and KAT-stable.
func EvalPointFromID(id NodeID) uint32 {
digest := cshake256(id[:], 1, tagSeedShare)
return uint32(digest[0])%(shamirPrime-1) + 1
}
-170
View File
@@ -1,170 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"bytes"
"testing"
)
func TestShamir_DealReconstruct_Basic(t *testing.T) {
// Share a 96-byte (SHAKE-192s) seed with (n=5, t=3) and check
// that every 3-element subset reconstructs the secret.
seed := bytes.Repeat([]byte{0xAB}, 96)
for i := range seed {
seed[i] = byte(i ^ 0x55)
}
coeffs := bytes.Repeat([]byte{0xAA}, (3-1)*96*2)
shares, err := shamirDealRandom(seed, 5, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom: %v", err)
}
if len(shares) != 5 {
t.Fatalf("expected 5 shares, got %d", len(shares))
}
// Reconstruct from the first 3.
rec, err := shamirReconstructGF(shares[:3], 96)
if err != nil {
t.Fatalf("shamirReconstructGF: %v", err)
}
for b := 0; b < 96; b++ {
// The byte should be the GF(257) representation of the
// original byte (in [0, 256]). Since every byte was in
// [0, 256), the GF(257) reconstruction must equal the
// byte value.
if rec[b] != uint16(seed[b]) {
t.Fatalf("reconstructed byte %d: got %d want %d", b, rec[b], seed[b])
}
}
}
func TestShamir_ReconstructFromAnySubset(t *testing.T) {
// (n=4, t=3). Any 3 of the 4 shares should reconstruct.
seed := make([]byte, 96)
for i := range seed {
seed[i] = byte(i * 7)
}
coeffs := bytes.Repeat([]byte{0xCD}, (3-1)*96*2)
shares, err := shamirDealRandom(seed, 4, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom: %v", err)
}
subsets := [][]int{
{0, 1, 2},
{0, 1, 3},
{0, 2, 3},
{1, 2, 3},
}
for _, sub := range subsets {
picked := make([]shamirShare, len(sub))
for i, j := range sub {
picked[i] = shares[j]
}
rec, err := shamirReconstructGF(picked, 96)
if err != nil {
t.Fatalf("reconstruct %v: %v", sub, err)
}
for b := 0; b < 96; b++ {
if rec[b] != uint16(seed[b]) {
t.Fatalf("subset %v: byte %d: got %d want %d", sub, b, rec[b], seed[b])
}
}
}
}
func TestShamir_DuplicateEvalPointRejected(t *testing.T) {
seed := make([]byte, 96)
coeffs := bytes.Repeat([]byte{0xEE}, (3-1)*96*2)
shares, _ := shamirDealRandom(seed, 5, 3, coeffs)
dup := []shamirShare{shares[0], shares[0], shares[1]}
if _, err := shamirReconstructGF(dup, 96); err == nil {
t.Fatalf("expected ErrDuplicateEvalPoint")
}
}
func TestShamir_ZeroEvalPointRejected(t *testing.T) {
zero := []shamirShare{
{X: 0, Y: make([]uint16, 96)},
{X: 1, Y: make([]uint16, 96)},
{X: 2, Y: make([]uint16, 96)},
}
if _, err := shamirReconstructGF(zero, 96); err == nil {
t.Fatalf("expected ErrZeroEvalPoint")
}
}
func TestShamir_TooLargeCommitteeRejected(t *testing.T) {
if _, err := shamirDealRandom(make([]byte, 96), 257, 3, nil); err == nil {
t.Fatalf("expected ErrCommitteeTooLarge")
}
}
func TestShamir_ThresholdInvalidRejected(t *testing.T) {
if _, err := shamirDealRandom(make([]byte, 96), 3, 0, nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t=0)")
}
if _, err := shamirDealRandom(make([]byte, 96), 3, 4, nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t > n)")
}
}
func TestShamir_SeedSize128(t *testing.T) {
// Exercise the 128-byte SHAKE-256s seed path.
seed := make([]byte, 128)
for i := range seed {
seed[i] = byte(i * 13)
}
coeffs := bytes.Repeat([]byte{0x11}, (3-1)*128*2)
shares, err := shamirDealRandom(seed, 5, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom (128B): %v", err)
}
rec, err := shamirReconstructGF(shares[:3], 128)
if err != nil {
t.Fatalf("shamirReconstructGF (128B): %v", err)
}
for b := 0; b < 128; b++ {
if rec[b] != uint16(seed[b]) {
t.Fatalf("byte %d: got %d want %d", b, rec[b], seed[b])
}
}
}
func TestShareWireRoundTrip(t *testing.T) {
seed := make([]byte, 96)
for i := range seed {
seed[i] = byte(i)
}
coeffs := bytes.Repeat([]byte{0x77}, (3-1)*96*2)
shares, _ := shamirDealRandom(seed, 3, 2, coeffs)
for _, s := range shares {
wire := shareToBytes(s)
back := shareFromBytes(s.X, wire)
if back.X != s.X {
t.Fatalf("X round trip: got %d want %d", back.X, s.X)
}
for b := 0; b < 96; b++ {
if back.Y[b] != s.Y[b] {
t.Fatalf("Y[%d] round trip: got %d want %d", b, back.Y[b], s.Y[b])
}
}
}
}
func TestEvalPointFromID_NonZero(t *testing.T) {
// EvalPointFromID must never produce 0 (reserved for the
// secret).
for i := 0; i < 256; i++ {
var id NodeID
id[0] = byte(i)
p := EvalPointFromID(id)
if p == 0 {
t.Fatalf("EvalPointFromID produced 0 for first byte %d", i)
}
if p >= shamirPrime {
t.Fatalf("EvalPointFromID produced %d >= shamirPrime=%d", p, shamirPrime)
}
}
}
+14 -12
View File
@@ -3,16 +3,16 @@
package magnetar
// sign.go — single-party signing. The single-party path is the
// reference baseline for the threshold path in threshold.go: a
// threshold quorum that successfully completes its rounds emits a
// Signature byte-identical to single-party Sign on the same
// reconstructed seed.
// sign.go --- single-party signing. The single-party path is consumed
// by both the per-validator standalone primitive (ValidatorSign in
// standalone.go) and by the THBS-SE PUBLIC COMBINER (Combine in
// thbsse.go), via slhSignDeterministic. Each consumer routes through
// circl/slhdsa as the FIPS 205 signing primitive.
//
// This file uses circl/slhdsa as the FIPS 205 signing primitive.
// The threshold layer above does not depend on internals; it
// depends only on (i) seed-based key derivation matching DeriveKey
// and (ii) the output being a valid FIPS 205 signature.
// The THBS-SE byte-identity claim rides on FIPS 205 SignDeterministic
// being a pure function of (sk, message, ctx): a public combiner that
// reconstructs the same scheme seed and routes through this Sign call
// emits the same wire bytes any FIPS 205 verifier accepts.
import (
"crypto/rand"
@@ -76,9 +76,11 @@ func SignCtx(params *Params, sk *PrivateKey, message, ctx []byte, randomized boo
// SignRandomized adds a fresh n-byte randomness from the supplied
// reader; with a deterministic reader it remains reproducible.
//
// The threshold-Combine path uses randomized=false to guarantee
// byte-identical output to the single-party reference (this is the
// Magnetar analog of Pulsar's Class N1).
// THBS-SE always uses randomized=false (via slhSignDeterministic in
// thbsse.go) to guarantee byte-identity: any t valid Round-2 reveals
// produce the same final signature, so the PUBLIC COMBINER is a pure
// function of its inputs and disjoint sub-quora produce the same wire
// bytes.
func slhSign(id slhdsa.ID, packedSk, message, ctx []byte, randomized bool, rng io.Reader) ([]byte, error) {
priv := slhdsa.PrivateKey{ID: id}
if err := priv.UnmarshalBinary(packedSk); err != nil {
+55 -114
View File
@@ -4,7 +4,8 @@
package magnetar
// standalone.go — Per-validator standalone SLH-DSA: the PRIMARY,
// CANONICAL primitive for public-BFT post-quantum consensus.
// CANONICAL production primitive for public-BFT post-quantum
// consensus.
//
// HONEST ARCHITECTURE FRAMING
// ===========================
@@ -20,8 +21,8 @@ package magnetar
// - 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
// either (a) reconstructing the seed (transient combiner role)
// 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}.
@@ -41,36 +42,23 @@ package magnetar
// 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.
// 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
// ==========================================
// COMPANION THRESHOLD CONSTRUCTION
// ================================
//
// 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.
// For deployments that genuinely need a SINGLE FIPS 205-shaped
// signature from a t-of-n committee (e.g. for a verifier-side
// constant-cost certificate), Magnetar v1.0 ships THBS-SE
// (thbsse.go) — the ONE permissionless threshold construction.
// THBS-SE uses Shamir-VSS over the SLH-DSA seed plus slot-bound
// commit-and-reveal; the public combiner role is open to any peer
// and the seed lives in the combiner's transient memory for one
// Sign call. THBS-SE does not require an HSM-attested host in the
// TCB; any peer can run Combine.
//
// API CONTRACT
// ============
@@ -122,38 +110,14 @@ package magnetar
//
// - 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 (
"crypto/rand"
"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
@@ -191,11 +155,18 @@ var (
//
// 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)
if err := params.Validate(); err != nil {
return nil, nil, err
}
if rng == nil {
rng = rand.Reader
}
sk, err := GenerateKey(params, rng)
if err != nil {
return nil, nil, err
}
return sk, sk.Pub, nil
}
// ValidatorSign is the canonical public-BFT signing primitive.
@@ -219,8 +190,8 @@ func PerValidatorKeypair(params *Params, rng io.Reader) (*PrivateKey, *PublicKey
// 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.
// 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
@@ -246,10 +217,8 @@ func ValidatorSign(sk *PrivateKey, rng io.Reader, message []byte) ([]byte, error
// 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
// Parallelism: above the validatorBatchConcurrentThreshold 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
@@ -267,9 +236,7 @@ func ValidatorSign(sk *PrivateKey, rng io.Reader, message []byte) ([]byte, error
// 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.
// observable via LastValidatorBatchTier (dispatch.go).
func ValidatorBatchVerify(params *Params, pubs []*PublicKey, msgs [][]byte, sigs [][]byte) ([]bool, error) {
if err := params.Validate(); err != nil {
return nil, err
@@ -299,35 +266,23 @@ func ValidatorBatchVerify(params *Params, pubs []*PublicKey, msgs [][]byte, sigs
}
}
// 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
dispatchTier := validatorBatchSerial
if n >= validatorBatchConcurrentThreshold {
dispatchTier = validatorBatchConcurrent
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)
recordValidatorBatchTier(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.
// state — same byte-equality contract as the serial path.
func validatorBatchVerifyConcurrent(params *Params, pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool) {
n := len(pubs)
workers := runtimeGOMAXPROCS()
@@ -396,22 +351,13 @@ func runtimeGOMAXPROCS() int {
// (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.)
// The parallel-slices form is INTENTIONALLY chosen 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
@@ -484,32 +430,27 @@ func BuildAggregateCert(params *Params, signers []NodeID, pubKeys [][]byte, sigs
// 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.)
// signer is COUNTED AS INVALID. This is a defense-in-depth
// choice against impersonation: an attacker who slips a stale
// entry from an unknown validator into the cert does NOT crash
// the verifier, but does NOT gain a count either.
//
// 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).
// 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).
// 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.
// primitive.
//
// Returns:
// - validCount: number of signers that verified, in [0, len(cert.Signers)].
+4 -4
View File
@@ -289,10 +289,10 @@ func TestVerifyAggregateCert_GPU_Provenance(t *testing.T) {
// 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)
tier := LastValidatorBatchTier()
if tier != validatorBatchConcurrent {
t.Fatalf("LastValidatorBatchTier = %s, want %s — cert verify did not dispatch parallel",
tier, validatorBatchConcurrent)
}
}
-73
View File
@@ -65,76 +65,3 @@ func (r *detReader) Read(p []byte) (int, error) {
func deterministicReader(seed []byte) io.Reader {
return newDetReader(seed)
}
// makeCommittee builds a deterministic NodeID list of length n.
func makeCommittee(n int) []NodeID {
out := make([]NodeID, n)
for i := 0; i < n; i++ {
// Use a 1-indexed first byte so NodeIDs are distinct and
// sorted in canonical order.
out[i][0] = byte(i + 1)
// Mix in a tag so test NodeIDs don't accidentally collide
// with KAT NodeIDs.
copy(out[i][1:], []byte("MAGNETAR-TEST"))
}
return out
}
// runDKG runs an honest DKG ceremony for committee+threshold and
// returns the (group pubkey, share-set, transcript-hash). Fails
// the test on any error.
func runDKG(t *testing.T, params *Params, committee []NodeID, threshold int) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
n := len(committee)
sessions := make([]*DKGSession, n)
for i := range sessions {
seed := append([]byte("MAGNETAR-DKG-TEST"), []byte{byte(params.Mode), byte(n), byte(threshold), byte(i)}...)
rng := newDetReader(seed)
s, err := NewDKGSession(params, committee, threshold, committee[i], rng)
if err != nil {
t.Fatalf("NewDKGSession[%d]: %v", i, err)
}
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1[%d]: %v", i, err)
}
r1[i] = m
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
m, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2[%d]: %v", i, err)
}
r2[i] = m
}
outs := make([]*DKGOutput, n)
for i, s := range sessions {
o, err := s.Round3(r2)
if err != nil {
t.Fatalf("Round3[%d]: %v", i, err)
}
if o.AbortEvidence != nil {
t.Fatalf("Round3[%d]: unexpected abort: %v", i, o.AbortEvidence)
}
outs[i] = o
}
// Verify all parties agree on the joint group pubkey.
for i := 1; i < n; i++ {
if !outs[0].GroupPubkey.Equal(outs[i].GroupPubkey) {
t.Fatalf("party %d disagrees on group pubkey", i)
}
if outs[0].TranscriptHash != outs[i].TranscriptHash {
t.Fatalf("party %d disagrees on transcript hash", i)
}
}
shares := make([]*KeyShare, n)
for i, o := range outs {
shares[i] = o.SecretShare
}
return outs[0].GroupPubkey, shares, outs[0].TranscriptHash
}
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// thbsse_field.go --- GF(257) byte-wise share arithmetic used INTERNALLY by
// the THBS-SE construction (thbsse.go).
//
// This file is the entire field-math footprint of the threshold path.
// No other primitive in the magnetar package consumes these helpers;
// the per-validator standalone path (standalone.go) is FIPS 205 only
// and does not touch them. The unexported names keep them out of the
// public API surface.
//
// =====================================================================
// Why byte-wise GF(257)
// =====================================================================
//
// The THBS-SE setup shares a 4n-byte SLH-DSA scheme seed (96 bytes for
// SHAKE-192s, 128 bytes for SHAKE-256s; per FIPS 205 sec 10.1). Byte
// values 0..255 fit in [0, 257) and share values fit in [0, 257), so
// each share value packs as one uint16 lane. Reconstruction at x=0
// returns the original byte in [0, 257); honest shares carry byte
// values in [0, 256) and reduction yields the original seed byte.
//
// GF(257) is the smallest prime > 255: every byte value is a distinct
// non-zero field element, the lambdas fit in a u16, and the modular
// inverse uses Fermat's little theorem on a 9-bit exponent (fast and
// constant-time relative to the secret share values).
//
// The choice is consistent with Pulsar's GF(257) wire — it lets the
// audit reuse the same per-byte-share analysis across siblings.
//
// =====================================================================
// No PVSS in this file
// =====================================================================
//
// The PVSS / leaderless-DKG primitive is OUT OF SCOPE for this file
// (and for the magnetar package). The protocol shape calls for the
// committee to "run leaderless PVSS/DKG for slot-local key" in a
// SIBLING primitive — luxfi/threshold's DKG package — and feed the
// resulting (PK, [share_i]) tuple into NewThbsSeKeyFromShares
// (thbsse.go). NewThbsSeKey (the deterministic-dealer variant) is the
// KAT-reproducible reference that lives here for byte-identity
// testing and for foundation-HSM single-host bootstrap; it is not the
// production path.
//
// The deterministic-dealer reference uses the SAME wire-shape shares
// the PVSS path produces, so a setup transcript produced by the PVSS
// path is byte-equal to one produced by the dealer for the same input
// secret material. The PVSS scheme choice (Schoenmakers 1999 over a
// prime-order subgroup, with a hash-to-byte mixing step) is
// documented in the paper sec 5.2; the wire-level surface is the
// share envelope (mask || masked_share) which is identical under
// both setups.
import "errors"
// thbsseSharePrime is the small prime used for per-byte Shamir sharing
// in the THBS-SE setup. 257 is the smallest prime > 255 so it admits
// every byte value as a distinct field element.
const thbsseSharePrime uint32 = 257
// thbsseShare contains one party's per-byte Shamir share of a
// seed_size-byte secret. Each element is a value in [0, 257) stored
// in a uint16 lane. The Y slice has length seed_size.
type thbsseShare struct {
X uint32 // Shamir evaluation point in [1, p); 1-indexed
Y []uint16 // per-byte share value at X (each in [0, p))
}
// Errors produced by the THBS-SE field-arithmetic layer. Exported
// because Combine surfaces them to callers; the names are
// THBS-SE-scoped to make the audit footprint obvious.
var (
// ErrInvalidThreshold is returned by NewThbsSeKey when n < t or
// t < 1.
ErrInvalidThreshold = errors.New("magnetar/thbsse: invalid threshold (n < t or t < 1)")
// ErrCommitteeTooLarge is returned when n > MaxCommittee257 (= 256).
// The GF(257) field admits exactly 256 distinct non-zero
// evaluation points.
ErrCommitteeTooLarge = errors.New("magnetar/thbsse: committee larger than 256 parties (GF(257) wire)")
// ErrNotEnoughShares is returned by share reconstruction when
// fewer than t shares are presented.
ErrNotEnoughShares = errors.New("magnetar/thbsse: not enough shares for reconstruction")
// ErrZeroEvalPoint is returned when a share carries x=0 (the
// reserved point that holds the secret).
ErrZeroEvalPoint = errors.New("magnetar/thbsse: evaluation point x=0 is reserved for the secret")
// ErrDuplicateEvalPoint is returned when two shares share the
// same x.
ErrDuplicateEvalPoint = errors.New("magnetar/thbsse: duplicate evaluation point in shares")
// ErrShareWireSize is returned when a share's wire bytes do not
// match the params.SeedSize * 2 width.
ErrShareWireSize = errors.New("magnetar/thbsse: share has wrong wire size")
)
// MaxCommittee257 is the maximum committee size under byte-wise
// Shamir over GF(257). 256 is exactly q-1 = 257-1, the upper bound
// on distinct non-zero evaluation points.
const MaxCommittee257 = 256
// thbsseDealRandom shares a seed_size-byte secret across n parties
// with reconstruction threshold t. Thin wrapper around
// thbsseDealRandomGF that lifts each byte secret[b] to a GF(257)
// value. Used by the deterministic-dealer setup path.
func thbsseDealRandom(secret []byte, n, t int, coeffStream []byte) ([]thbsseShare, error) {
gf := make([]uint16, len(secret))
for b := 0; b < len(secret); b++ {
gf[b] = uint16(secret[b])
}
return thbsseDealRandomGF(gf, n, t, coeffStream)
}
// thbsseDealRandomGF shares a seedSize-element GF(257) secret vector
// across n parties with reconstruction threshold t. Each party 1..n
// gets a share at evaluation point i. The (t-1) polynomial
// coefficients per slot are pulled from coeffStream; if the stream
// is short, it is stretched via cSHAKE256(coeffStream,
// tag=MAGNETAR-THBSSE-SHARE-V1).
func thbsseDealRandomGF(secret []uint16, n, t int, coeffStream []byte) ([]thbsseShare, error) {
if t < 1 || n < t {
return nil, ErrInvalidThreshold
}
if n > MaxCommittee257 {
return nil, ErrCommitteeTooLarge
}
seedSize := len(secret)
needed := (t - 1) * seedSize * 2
if needed < 2 {
needed = 2
}
if len(coeffStream) < needed {
coeffStream = cshake256(coeffStream, needed, tagThbsSeShareDeal)
}
// coeffs[d][b] is the degree-d coefficient for slot b.
// coeffs[0][b] is the constant term --- the secret value itself.
coeffs := make([][]uint16, t)
for d := 0; d < t; d++ {
coeffs[d] = make([]uint16, seedSize)
}
for b := 0; b < seedSize; b++ {
coeffs[0][b] = secret[b] % uint16(thbsseSharePrime)
}
off := 0
for d := 1; d < t; d++ {
for b := 0; b < seedSize; b++ {
r := uint32(coeffStream[off])<<8 | uint32(coeffStream[off+1])
off += 2
coeffs[d][b] = uint16(r % thbsseSharePrime)
}
}
shares := make([]thbsseShare, n)
for i := 1; i <= n; i++ {
shares[i-1].X = uint32(i)
shares[i-1].Y = make([]uint16, seedSize)
x := uint32(i)
for b := 0; b < seedSize; b++ {
// Horner's method.
acc := uint32(coeffs[t-1][b])
for d := t - 2; d >= 0; d-- {
acc = (acc*x + uint32(coeffs[d][b])) % thbsseSharePrime
}
shares[i-1].Y[b] = uint16(acc)
}
}
return shares, nil
}
// thbsseReconstructGF Lagrange-interpolates the constant term as a
// seedSize-element GF(257) vector. Used by Combine.
func thbsseReconstructGF(shares []thbsseShare, seedSize int) ([]uint16, error) {
out := make([]uint16, seedSize)
if len(shares) < 1 {
return out, ErrNotEnoughShares
}
seen := make(map[uint32]struct{}, len(shares))
for _, s := range shares {
if s.X == 0 {
return out, ErrZeroEvalPoint
}
if _, dup := seen[s.X]; dup {
return out, ErrDuplicateEvalPoint
}
seen[s.X] = struct{}{}
if len(s.Y) != seedSize {
return out, ErrShareWireSize
}
}
t := len(shares)
// Lagrange basis values at x=0 over GF(p).
// lambda_i = prod_{j != i} (-x_j) / (x_i - x_j) mod p
lambdas := make([]uint16, t)
for i := 0; i < t; i++ {
num := uint32(1)
den := uint32(1)
for j := 0; j < t; j++ {
if i == j {
continue
}
negXj := thbsseSharePrime - (shares[j].X % thbsseSharePrime)
num = (num * negXj) % thbsseSharePrime
diff := (thbsseSharePrime + shares[i].X - shares[j].X) % thbsseSharePrime
den = (den * diff) % thbsseSharePrime
}
denInv := thbsseModInvSmall(den, thbsseSharePrime)
lambdas[i] = uint16((num * denInv) % thbsseSharePrime)
}
for b := 0; b < seedSize; b++ {
var acc uint32
for i := 0; i < t; i++ {
acc = (acc + uint32(lambdas[i])*uint32(shares[i].Y[b])) % thbsseSharePrime
}
out[b] = uint16(acc)
}
return out, nil
}
// thbsseModInvSmall computes the modular inverse of a mod p where p
// is a small prime. Uses Fermat's little theorem (a^(p-2) === a^-1
// mod p). p must be prime and a must be in [1, p).
func thbsseModInvSmall(a, p uint32) uint32 {
return thbsseModPowSmall(a, p-2, p)
}
// thbsseModPowSmall computes (base^exp) mod p via square-and-multiply.
func thbsseModPowSmall(base, exp, p uint32) uint32 {
result := uint32(1)
b := base % p
for exp > 0 {
if exp&1 == 1 {
result = (result * b) % p
}
b = (b * b) % p
exp >>= 1
}
return result
}
// thbsseShareToBytes serialises a thbsseShare's Y component to wire
// form (big-endian uint16 per byte position).
func thbsseShareToBytes(s thbsseShare) []byte {
out := make([]byte, len(s.Y)*2)
for b := 0; b < len(s.Y); b++ {
out[2*b] = byte(s.Y[b] >> 8)
out[2*b+1] = byte(s.Y[b])
}
return out
}
// thbsseShareFromBytes deserialises a wire-form Y component.
func thbsseShareFromBytes(x uint32, buf []byte) thbsseShare {
seedSize := len(buf) / 2
s := thbsseShare{X: x, Y: make([]uint16, seedSize)}
for b := 0; b < seedSize; b++ {
s.Y[b] = uint16(buf[2*b])<<8 | uint16(buf[2*b+1])
}
return s
}
// EvalPointFromID derives a deterministic non-zero Shamir evaluation
// point in [1, 257) from a NodeID. Used by callers that want an
// ID-stable evaluation point rather than a position-in-committee
// point. The default setup uses (committee_index + 1) which is
// simpler and KAT-stable.
func EvalPointFromID(id NodeID) uint32 {
digest := cshake256(id[:], 1, tagThbsSeShareDeal)
return uint32(digest[0])%(thbsseSharePrime-1) + 1
}
+670
View File
@@ -0,0 +1,670 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"bytes"
"crypto/rand"
"errors"
"sort"
"testing"
"github.com/cloudflare/circl/sign/slhdsa"
)
// thbsse_test.go — tests for the v0.6 THBS-SE construction.
//
// Coverage matrix:
// - TestThbsSE_Wire_FIPS205Verifiable: the load-bearing byte-identity
// test. Combined signature verifies under unmodified
// cloudflare/circl/sign/slhdsa.Verify across SHAKE-192s/192f/256s.
// - TestThbsSE_RejectSeedReveal: a malicious party attempting to
// publish SK.seed as a "share" gets rejected at verify time.
// - TestThbsSE_RejectUnselectedFORS: any reveal outside the
// message-selected atom set is rejected by Combine's input
// validation (the v0.6 reveal is a single per-party masked-share
// pair; "unselected reveal" maps to "tampered partial_sig that
// does not re-derive the committed Round-1 hash").
// - TestThbsSE_SlotReuseRejected: signing two distinct messages
// under the same slot is rejected with detectable evidence.
// - TestThbsSE_OverselectedCommittee_SurvivesWithholding: n=7, t=3
// with 4 honest signers and 3 withholders still produces a valid
// signature.
// thbsSeAllModes is the set of FIPS 205 parameter sets the THBS-SE
// reference implementation supports.
var thbsSeAllModes = []Mode{ModeM192s, ModeM192f, ModeM256s}
// makeThbsSeCommittee returns a sorted committee of n random NodeIDs.
// Sorting is required by NewThbsSeKey.
func makeThbsSeCommittee(t *testing.T, n int) []NodeID {
t.Helper()
out := make([]NodeID, n)
for i := 0; i < n; i++ {
_, _ = rand.Read(out[i][:])
}
sort.Slice(out, func(i, j int) bool { return bytes.Compare(out[i][:], out[j][:]) < 0 })
return out
}
// makeThbsSeBinding returns a canonical slot binding for tests.
func makeThbsSeBinding(slot uint64) *ThbsSeSlotBinding {
return &ThbsSeSlotBinding{
ChainID: []byte("lux-magnetar-test-chain"),
Epoch: 1,
Slot: slot,
Height: 100,
CommitteeID: []byte("test-committee-A"),
MessageDomain: []byte("polaris-cert"),
}
}
// runRound1Across runs Round1 for each share index in indices and
// returns the (r1, r2) message slices in committee order. Each party
// uses its own guard so cross-party state never leaks.
func runRound1Across(t *testing.T, params *Params, key *ThbsSeKey, binding *ThbsSeSlotBinding, msg []byte, indices []int) ([]ThbsSeRound1Msg, []ThbsSeRound2Msg) {
t.Helper()
r1s := make([]ThbsSeRound1Msg, 0, len(indices))
r2s := make([]ThbsSeRound2Msg, 0, len(indices))
for _, i := range indices {
guard := NewThbsSeSlotGuard()
r1, r2, err := ThbsSeRound1(params, key.Shares[i], binding, msg, guard, nil)
if err != nil {
t.Fatalf("share %d Round1: %v", i, err)
}
r1s = append(r1s, r1)
r2s = append(r2s, r2)
}
return r1s, r2s
}
// TestThbsSE_Wire_FIPS205Verifiable is the headline regression test.
// It pins the v0.6 byte-identity claim: a Combine output verifies
// under unmodified cloudflare/circl/sign/slhdsa.Verify across all
// three SLH-DSA SHAKE parameter sets, on a t=3, n=5 committee.
//
// The flow:
// 1. NewThbsSeKey produces (PK, shares) for (n=5, t=3).
// 2. Three honest signers run Round 1 + Round 2 against the slot
// binding and the message.
// 3. A public combiner (anyone) calls Combine with the assembled
// Round-1 / Round-2 messages plus the published ThbsSeKey.
// 4. The returned Signature.Bytes is fed DIRECTLY to circl's
// slhdsa.Verify with the ctx = ctxFromSlot(binding). No
// Magnetar code path is hit on the verifier side.
//
// PASS ⇔ the combiner produced a wire-canonical FIPS 205 signature
// for every supported mode.
func TestThbsSE_Wire_FIPS205Verifiable(t *testing.T) {
for _, mode := range thbsSeAllModes {
mode := mode
t.Run(mode.String(), func(t *testing.T) {
params := MustParamsFor(mode)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(7)
msg := []byte("lux-magnetar-thbsse-v0.6 — selected-element threshold")
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{0, 2, 4})
input := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
}
sig, evidences, err := Combine(input)
if err != nil {
t.Fatalf("Combine: %v", err)
}
if len(evidences) != 0 {
t.Fatalf("Combine emitted %d evidences on honest run: %+v", len(evidences), evidences)
}
if sig == nil {
t.Fatalf("Combine returned nil signature on honest run")
}
if sig.Mode != mode {
t.Fatalf("Signature.Mode = %v, want %v", sig.Mode, mode)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("Signature.Bytes len = %d, want %d", len(sig.Bytes), params.SignatureSize)
}
// Byte-identity check: route the signature bytes DIRECTLY
// through circl's FIPS 205 verifier with no Magnetar code
// path on the verify side. The ctx is the slot-bound
// context string.
id := slhdsaIDForMode(mode)
pk := slhdsa.PublicKey{ID: id}
if err := pk.UnmarshalBinary(key.PublicKey.Bytes); err != nil {
t.Fatalf("UnmarshalBinary(pk): %v", err)
}
ctx := ctxFromSlot(binding)
if !slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sig.Bytes, ctx) {
t.Fatalf("circl FIPS 205 Verify rejected the THBS-SE Combine output — byte-identity broken")
}
// Wire-codec round-trip: the same signature flows through
// the canonical MAGS frame.
wireSig, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("MarshalBinary: %v", err)
}
wirePK, err := MarshalGroupKey(key.PublicKey)
if err != nil {
t.Fatalf("MarshalGroupKey: %v", err)
}
if !VerifyBytesCtx(wirePK, msg, ctx, wireSig) {
t.Fatalf("VerifyBytesCtx rejected the THBS-SE Combine output via wire codec")
}
// Wrong ctx (slot binding tampered) must reject.
tamperBinding := makeThbsSeBinding(7)
tamperBinding.Slot = 8
tamperCtx := ctxFromSlot(tamperBinding)
if slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sig.Bytes, tamperCtx) {
t.Fatalf("circl Verify accepted a tampered slot ctx — slot binding not enforced")
}
})
}
}
// TestThbsSE_RejectSeedReveal pins the invariant "no party share is
// the seed". A malicious party that publishes a Round-2 reveal whose
// "share" portion is the master seed itself MUST fail Combine's
// commit re-derivation check — the malicious party cannot have
// committed to the seed in Round 1 without violating the protocol's
// transcript bind.
//
// Specifically: the v0.6 reveal carries (mask, masked_share). For
// the malicious party to publish the seed as the share, it would
// need either the seed (which only the SETUP dealer holds, and
// which has been erased) or to forge a Round-1 commit. We construct
// the attack as: malicious party submits a Round-2 reveal whose
// masked_share is computed to make share = seed; the Round-1 commit
// it broadcast earlier is honest (commits to its real Shamir share).
// Combine recomputes the commit from the tampered reveal and
// rejects.
func TestThbsSE_RejectSeedReveal(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(11)
msg := []byte("reject-seed-reveal-test")
// Honest Round 1+2 from parties {0, 2, 4}.
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{0, 2, 4})
// Tamper party 0's Round-2 PartialSig to look like (mask, mask XOR seed).
// We do this by overwriting the masked portion: new masked = mask XOR fake_seed.
// Where fake_seed is a chosen value that, if reconstructed via Shamir, would
// not match the committed share. The commit recomputation will fail
// because the partial_sig hashes to a different commit than the one
// originally broadcast.
tampered := append([]byte(nil), r2s[0].PartialSig...)
maskLen := params.SeedSize * 2
for i := 0; i < maskLen; i++ {
// Flip the masked portion so the "share" portion would decode
// to all-zero bytes (a value the party never committed to).
tampered[maskLen+i] = tampered[:maskLen][i]
}
r2s[0].PartialSig = tampered
input := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
}
sig, evidences, err := Combine(input)
if err == nil {
// 2 of 3 shares are honest — Combine succeeded because we
// over-provisioned. Check that exactly one evidence was emitted
// for party 0.
if sig == nil {
t.Fatalf("Combine returned nil sig but no err on 2 honest + 1 tampered")
}
if len(evidences) != 1 || evidences[0].Reason != ThbsSeShareCommitMismatch {
t.Fatalf("Expected exactly one commit-mismatch evidence, got: %+v", evidences)
}
if evidences[0].PartyID != committee[0] {
t.Fatalf("Evidence party = %x, want %x", evidences[0].PartyID, committee[0])
}
// 2 shares survive but threshold = 3 — Combine should have
// returned ErrInsufficientQuor. If we got here, the test setup
// is wrong.
t.Fatalf("Combine should have failed with insufficient quorum after dropping tampered share")
}
if !errors.Is(err, ErrInsufficientQuor) {
t.Fatalf("Expected ErrInsufficientQuor, got %v", err)
}
// One evidence for the tampered party.
if len(evidences) != 1 || evidences[0].Reason != ThbsSeShareCommitMismatch {
t.Fatalf("Expected exactly one commit-mismatch evidence, got: %+v", evidences)
}
if evidences[0].PartyID != committee[0] {
t.Fatalf("Evidence party = %x, want %x", evidences[0].PartyID, committee[0])
}
}
// TestThbsSE_RejectUnselectedFORS pins the invariant "a share is
// honored only when its commit re-derives correctly". In the v0.6
// reveal model, a Round-2 PartialSig is honored iff
// deriveThbsSeCommit(partial_sig, binding, msg, party) ==
// observed_commit. Any tampering with the partial_sig — including
// the conceptual "reveal an unselected FORS leaf" attack — is
// rejected by this check.
//
// For v0.6 the per-byte share IS the entire seed-share leaf, not a
// per-atom (FORS, WOTS) leaf — the strict per-atom share format
// lands in v0.7 (BLOCKERS.md::MAGNETAR-THBSSE-STRICT-V07). We
// therefore exercise the analogue: a Round-2 reveal whose partial_sig
// includes a forged "atom" payload (in the v0.6 wire shape, just
// extra trailing bytes) is rejected on wire-size grounds.
func TestThbsSE_RejectUnselectedFORS(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(13)
msg := []byte("reject-unselected-fors-test")
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{0, 2, 4})
// Tamper party 2: append trailing garbage (the analogue of revealing
// an unselected FORS leaf — extra atom payload beyond the protocol's
// allowed reveal set).
r2s[1].PartialSig = append(r2s[1].PartialSig, []byte("extra-unselected-fors-leaf-bytes")...)
input := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
}
sig, evidences, err := Combine(input)
if err == nil {
t.Fatalf("Combine accepted a tampered (extra-bytes) share, sig=%v evidences=%+v", sig, evidences)
}
if !errors.Is(err, ErrInsufficientQuor) {
t.Fatalf("Expected ErrInsufficientQuor, got %v", err)
}
if len(evidences) != 1 || evidences[0].Reason != ThbsSeShareWireSize {
t.Fatalf("Expected exactly one wire-size evidence, got %+v", evidences)
}
if evidences[0].PartyID != committee[2] {
t.Fatalf("Evidence party = %x, want %x", evidences[0].PartyID, committee[2])
}
}
// TestThbsSE_RejectUnselectedWOTS pins the invariant "a Round-2
// reveal is honored only when its commit re-derives correctly under
// the slot binding the share was originally committed to". In the
// v1.0 wire shape, the per-party PartialSig carries (mask ||
// masked_share). The conceptual "reveal an unselected WOTS+ chain
// base" attack collapses to: a Round-2 reveal whose masked-share
// portion has been flipped so that the recovered share decodes to
// the WRONG GF(257) lane --- i.e. a forged share that points to a
// different polynomial slot than the one the party committed to in
// Round 1.
//
// We construct the attack as a bit-flip in the masked_share portion
// of party 1's reveal. The Round-1 commit was bound to the honest
// (mask || masked_share); the tampered reveal carries
// (mask || masked_share XOR delta). The combiner's commit
// re-derivation produces a different digest, the reveal is dropped,
// and a ThbsSeShareCommitMismatch evidence is emitted for that
// party. With n=5, t=3 and only one tampered share, the remaining
// honest pair is below threshold, so Combine returns
// ErrInsufficientQuor --- the canonical "drop, slash, retry"
// signal the consensus layer expects.
func TestThbsSE_RejectUnselectedWOTS(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(14)
msg := []byte("reject-unselected-wots-test")
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{0, 2, 4})
// Tamper party 1's reveal: flip a single bit in the masked_share
// portion. This is the canonical "reveal an unselected WOTS+ chain
// base" attack: the recovered share decodes to a different GF(257)
// lane than the one the party committed to.
maskLen := params.SeedSize * 2
tampered := append([]byte(nil), r2s[1].PartialSig...)
tampered[maskLen] ^= 0x01 // flip first byte of masked_share
r2s[1].PartialSig = tampered
input := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
}
sig, evidences, err := Combine(input)
if err == nil {
t.Fatalf("Combine accepted a tampered WOTS reveal, sig=%v evidences=%+v", sig, evidences)
}
if !errors.Is(err, ErrInsufficientQuor) {
t.Fatalf("Expected ErrInsufficientQuor, got %v", err)
}
if len(evidences) != 1 || evidences[0].Reason != ThbsSeShareCommitMismatch {
t.Fatalf("Expected exactly one commit-mismatch evidence, got %+v", evidences)
}
if evidences[0].PartyID != committee[2] {
t.Fatalf("Evidence party = %x, want %x", evidences[0].PartyID, committee[2])
}
// Third-party VerifyThbsSeShareEvidence must accept the evidence
// (proof the consensus layer can slash on it without re-running
// the combiner).
if !VerifyThbsSeShareEvidence(params, evidences[0], binding, msg) {
t.Fatalf("VerifyThbsSeShareEvidence rejected genuine evidence")
}
}
// TestThbsSE_SlotReuseRejected pins the one-time enforcement
// invariant. A party that tries to sign two distinct messages
// under the same slot binding receives an EquivocationError from
// its local slot guard, AND the resulting evidence verifies under
// VerifyThbsSeEvidence.
func TestThbsSE_SlotReuseRejected(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(17)
msgA := []byte("msg-A")
msgB := []byte("msg-B-different")
guard := NewThbsSeSlotGuard()
r1A, r2A, err := ThbsSeRound1(params, key.Shares[0], binding, msgA, guard, nil)
if err != nil {
t.Fatalf("first Round1: %v", err)
}
// Second attempt under SAME slot binding but DIFFERENT message
// must fail with EquivocationError.
_, _, err = ThbsSeRound1(params, key.Shares[0], binding, msgB, guard, nil)
if err == nil {
t.Fatalf("second Round1 with reused slot succeeded — slot guard not enforcing")
}
var eq *ThbsSeEquivocationError
if !errors.As(err, &eq) {
t.Fatalf("expected *ThbsSeEquivocationError, got %T: %v", err, err)
}
if eq.PartyID != key.Shares[0].NodeID {
t.Fatalf("evidence party = %x, want %x", eq.PartyID, key.Shares[0].NodeID)
}
if eq.PriorR1.Commit != r1A.Commit || !bytes.Equal(eq.PriorR2.PartialSig, r2A.PartialSig) {
t.Fatalf("evidence prior round-1/round-2 do not match the first emission")
}
// Idempotent replay of (binding, msgA) succeeds and returns the
// persisted Round1 / Round2.
r1Aprime, r2Aprime, err := ThbsSeRound1(params, key.Shares[0], binding, msgA, guard, nil)
if err != nil {
t.Fatalf("idempotent replay: %v", err)
}
if r1Aprime.Commit != r1A.Commit {
t.Fatalf("idempotent replay produced different commit")
}
if !bytes.Equal(r2Aprime.PartialSig, r2A.PartialSig) {
t.Fatalf("idempotent replay produced different partial_sig")
}
// VerifyThbsSeEvidence must accept the equivocation evidence.
ev := eq.Evidence()
if !VerifyThbsSeEvidence(params, ev, msgA, msgB, binding, binding) {
t.Fatalf("VerifyThbsSeEvidence rejected genuine evidence")
}
// Tamper the evidence: swap digests so they match. Verifier must
// reject (no equivocation if both digests equal).
bad := ev
bad.NewDigest = ev.PriorDigest
if VerifyThbsSeEvidence(params, bad, msgA, msgA, binding, binding) {
t.Fatalf("VerifyThbsSeEvidence accepted tampered evidence with equal digests")
}
}
// TestThbsSE_OverselectedCommittee pins the availability claim of
// over-selection. With n=7, t=3 and 4 honest signers (3 withholders),
// the public combiner can still assemble a valid FIPS 205-shaped
// signature. The Lagrange basis is determined by any t evaluation
// points so disjoint sub-quora of size t produce byte-equal final
// signatures.
func TestThbsSE_OverselectedCommittee(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 7)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(19)
msg := []byte("survives-withholding")
// 4 honest signers; parties {1, 3, 5, 6} produce; parties {0, 2, 4}
// withhold their Round-1 / Round-2.
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{1, 3, 5, 6})
input := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
}
sig, evidences, err := Combine(input)
if err != nil {
t.Fatalf("Combine with 4 honest of 7 (t=3): %v", err)
}
if len(evidences) != 0 {
t.Fatalf("unexpected evidences on honest withholding: %+v", evidences)
}
if sig == nil || len(sig.Bytes) != params.SignatureSize {
t.Fatalf("Combine returned bad signature shape")
}
id := slhdsaIDForMode(ModeM192s)
pk := slhdsa.PublicKey{ID: id}
if err := pk.UnmarshalBinary(key.PublicKey.Bytes); err != nil {
t.Fatalf("UnmarshalBinary(pk): %v", err)
}
ctx := ctxFromSlot(binding)
if !slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sig.Bytes, ctx) {
t.Fatalf("circl Verify rejected the withholding-survived signature")
}
}
// TestThbsSE_PublicCombiner_Determinism pins the deterministic-
// Combine property: any t valid shares produce the same signature
// (because all shares are evaluations of the same polynomial). The
// public combiner is therefore a PURE function of its inputs.
func TestThbsSE_PublicCombiner_Determinism(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 7)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBinding(23)
msg := []byte("public-combiner-determinism")
r1s, r2s := runRound1Across(t, params, key, binding, msg, []int{0, 1, 2, 3, 4, 5, 6})
// Two different sub-selections of size t=3.
subA := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: []ThbsSeRound1Msg{r1s[0], r1s[2], r1s[4]},
Round2: []ThbsSeRound2Msg{r2s[0], r2s[2], r2s[4]},
}
subB := ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: []ThbsSeRound1Msg{r1s[1], r1s[3], r1s[5]},
Round2: []ThbsSeRound2Msg{r2s[1], r2s[3], r2s[5]},
}
sigA, _, err := Combine(subA)
if err != nil {
t.Fatalf("Combine A: %v", err)
}
sigB, _, err := Combine(subB)
if err != nil {
t.Fatalf("Combine B: %v", err)
}
if !bytes.Equal(sigA.Bytes, sigB.Bytes) {
t.Fatalf("Two disjoint t-subsets produced different signatures — combine not deterministic")
}
}
// TestThbsSE_SlotBindingDomainSeparation pins the slot-binding
// domain-separation guarantee: the same message signed under distinct
// slot bindings (distinct (chain_id, epoch, slot, height,
// committee_id, message_domain) tuples) produces DISTINCT signature
// bytes, and a signature produced under one binding does NOT verify
// under another binding. This rules out a class of cross-slot replay
// attacks where an adversary attempts to lift a signature produced at
// epoch e onto epoch e' or onto a different consensus message domain.
//
// The binding flows into the FIPS 205 ctx string at sign time
// (ctxFromSlot), so the SLH-DSA verifier itself enforces the
// separation --- Magnetar adds no extra check on top of the FIPS 205
// guarantee.
func TestThbsSE_SlotBindingDomainSeparation(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeThbsSeCommittee(t, 5)
key, err := NewThbsSeKey(params, 3, committee, nil)
if err != nil {
t.Fatalf("NewThbsSeKey: %v", err)
}
msg := []byte("distinct-binding")
bindingA := makeThbsSeBinding(29)
bindingB := makeThbsSeBinding(29)
bindingB.Epoch = 2 // distinct epoch — distinct ctx
r1sA, r2sA := runRound1Across(t, params, key, bindingA, msg, []int{0, 1, 2})
r1sB, r2sB := runRound1Across(t, params, key, bindingB, msg, []int{0, 1, 2})
sigA, _, err := Combine(ThbsSeCombineInput{Key: key, Binding: bindingA, Message: msg, Round1: r1sA, Round2: r2sA})
if err != nil {
t.Fatalf("Combine A: %v", err)
}
sigB, _, err := Combine(ThbsSeCombineInput{Key: key, Binding: bindingB, Message: msg, Round1: r1sB, Round2: r2sB})
if err != nil {
t.Fatalf("Combine B: %v", err)
}
if bytes.Equal(sigA.Bytes, sigB.Bytes) {
t.Fatalf("Distinct slot bindings produced byte-equal signatures — slot binding not propagated to ctx")
}
// Both must verify under their respective ctx.
id := slhdsaIDForMode(ModeM192s)
pk := slhdsa.PublicKey{ID: id}
_ = pk.UnmarshalBinary(key.PublicKey.Bytes)
if !slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sigA.Bytes, ctxFromSlot(bindingA)) {
t.Fatalf("sigA does not verify under bindingA")
}
if !slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sigB.Bytes, ctxFromSlot(bindingB)) {
t.Fatalf("sigB does not verify under bindingB")
}
// Cross-ctx must fail.
if slhdsa.Verify(&pk, slhdsa.NewMessage(msg), sigA.Bytes, ctxFromSlot(bindingB)) {
t.Fatalf("sigA verified under bindingB ctx — slot binding not enforced")
}
}
// BenchmarkThbsSE_Sign_5of7 measures end-to-end per-signature
// latency on a 5-of-7 committee, parametrised across all three
// FIPS 205 SHAKE modes. SHAKE-192s and SHAKE-256s are the
// recommended "small" variants and run on the order of seconds
// per signature on Apple M1 Max (the FIPS 205 SignDeterministic
// cost dominates). The SHAKE-192f variant is the "fast" mode and
// runs on the order of 500--1000 ms per signature on the same
// hardware via cloudflare/circl/sign/slhdsa v1.6.3. The slot
// binding, Round1 commit-and-reveal, and Combine's Lagrange
// reconstruction together account for <1% of wall-clock; the
// remainder is the underlying slhdsa primitive (DeriveKey +
// SignDeterministic). The bench reports honest end-to-end
// numbers and does not assert a wall-clock threshold; downstream
// consumers can read off the measured ns/op for capacity
// planning.
func BenchmarkThbsSE_Sign_5of7(b *testing.B) {
for _, mode := range thbsSeAllModes {
mode := mode
b.Run(mode.String(), func(b *testing.B) {
benchThbsSeSignMode(b, mode, 7, 5)
})
}
}
// benchThbsSeSignMode is the parametrised bench body.
func benchThbsSeSignMode(b *testing.B, mode Mode, n, t int) {
params := MustParamsFor(mode)
committee := make([]NodeID, n)
for i := 0; i < n; i++ {
_, _ = rand.Read(committee[i][:])
}
sort.Slice(committee, func(i, j int) bool { return bytes.Compare(committee[i][:], committee[j][:]) < 0 })
key, err := NewThbsSeKey(params, t, committee, nil)
if err != nil {
b.Fatalf("NewThbsSeKey: %v", err)
}
binding := makeThbsSeBindingBench(31)
msg := []byte("benchmark-thbsse-sign")
b.ResetTimer()
for i := 0; i < b.N; i++ {
r1s := make([]ThbsSeRound1Msg, 0, t)
r2s := make([]ThbsSeRound2Msg, 0, t)
for j := 0; j < t; j++ {
guard := NewThbsSeSlotGuard()
r1, r2, err := ThbsSeRound1(params, key.Shares[j], binding, msg, guard, nil)
if err != nil {
b.Fatalf("Round1[%d]: %v", j, err)
}
r1s = append(r1s, r1)
r2s = append(r2s, r2)
}
sig, _, err := Combine(ThbsSeCombineInput{
Key: key,
Binding: binding,
Message: msg,
Round1: r1s,
Round2: r2s,
})
if err != nil {
b.Fatalf("Combine: %v", err)
}
if sig == nil {
b.Fatal("nil sig")
}
}
}
// makeThbsSeBindingBench is a bench-side helper (no testing.T).
func makeThbsSeBindingBench(slot uint64) *ThbsSeSlotBinding {
return &ThbsSeSlotBinding{
ChainID: []byte("lux-magnetar-bench-chain"),
Epoch: 1,
Slot: slot,
Height: 100,
CommitteeID: []byte("bench-committee-A"),
MessageDomain: []byte("polaris-cert"),
}
}
-248
View File
@@ -1,248 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// threshold.go — two-round threshold signing (reveal-and-aggregate).
//
// Protocol shape (v0.1):
//
// Round 1 (party i): sample per-round mask r_i (seed_size×2
// bytes). Compute the masked share s'_i = share_i XOR r_i.
// Compute the commit
// D_i = cSHAKE256(r_i || s'_i || tau_1)
// where tau_1 = (sid, kappa, T, i, pk, msg). Broadcast (D_i).
//
// Round 2 (party i): reveal (r_i, masked_share_i) so peers can
// re-derive D_i and the aggregator can XOR them to recover
// share_i.
//
// Combine: aggregator gathers t Round-2 reveals, re-derives
// each party's D_i and checks it equals the Round-1 commit,
// then Lagrange-interpolates the byte-sum at x=0, applies
// the same cSHAKE256 mix as DKG to recover the master seed,
// calls FIPS 205 SignDeterministic. Returns the resulting
// signature.
//
// The byte-equality theorem (Magnetar analog of Pulsar Class N1):
// the returned signature is byte-identical to what
// slhdsa.SignDeterministic(KeyFromSeed(master_seed), msg, ctx)
// would produce, because Combine literally calls that path on
// the same reconstructed master seed.
//
// The honest v0.1 trust caveat: the aggregator process holds the
// reconstructed master seed for the duration of one Sign call,
// then zeroizes it. See SPEC.md "Trust model" and
// DEPLOYMENT-RUNBOOK.md for the operator-facing disclosure.
import (
"crypto/rand"
"errors"
"io"
)
// Errors returned by threshold signing.
var (
ErrNilSession = errors.New("magnetar: nil ThresholdSigner")
ErrEmptyQuorum = errors.New("magnetar: empty signing quorum")
ErrInsufficientQuor = errors.New("magnetar: quorum smaller than threshold")
ErrRound2CommitBad = errors.New("magnetar: Round-2 reveal does not match Round-1 commit")
ErrSessionMismatch = errors.New("magnetar: round messages from different sessions")
ErrAttemptMismatch = errors.New("magnetar: round messages from different rejection-restart attempts")
ErrPubkeyMismatch = errors.New("magnetar: KeyShare public-key does not match")
)
// ThresholdSigner holds one party's state for a 2-round threshold
// sign ceremony.
//
// A ThresholdSigner is single-use: one (sid, attempt) pair per
// instance. The protocol-layer driver allocates a fresh signer
// for each attempt.
type ThresholdSigner struct {
Params *Params
NodeID NodeID
SecretShare *KeyShare
// SessionID uniquely identifies this signature attempt across
// the network.
SessionID [16]byte
// Attempt is a counter that distinguishes retries; for v0.1
// reveal-and-aggregate it's a transcript-bind only (FIPS 205
// SLH-DSA has no rejection-restart, so Attempt is documentary
// in v0.1; it's wired through for parity with Pulsar and to
// support future MPC-style restarts).
Attempt uint32
// Quorum is the t-element signing committee, sorted ascending
// by NodeID.
Quorum []NodeID
// Message is the FIPS 205 message being signed.
Message []byte
// rng is the entropy source for per-round mask r_i.
rng io.Reader
// Internal Round-1 state.
myMask []byte // r_i — 2 × seed_size bytes
myMaskedShare []byte // s'_i = share_i XOR r_i (per byte)
myCommit [32]byte
}
// NewThresholdSigner constructs a new ThresholdSigner for a
// (sessionID, attempt, quorum, message) tuple.
//
// quorum must contain myShare.NodeID. All parties in the quorum
// must share the same group public key (i.e. they completed the
// same DKG).
//
// rng may be nil — crypto/rand is used. Pass a deterministic
// reader for KAT runs.
func NewThresholdSigner(
params *Params,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
myShare *KeyShare,
message []byte,
rng io.Reader,
) (*ThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if myShare == nil {
return nil, ErrNilKey
}
if myShare.Mode != params.Mode {
return nil, ErrModeMismatch
}
if len(quorum) == 0 {
return nil, ErrEmptyQuorum
}
found := false
for _, q := range quorum {
if q == myShare.NodeID {
found = true
break
}
}
if !found {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
return &ThresholdSigner{
Params: params,
NodeID: myShare.NodeID,
SecretShare: myShare,
SessionID: sessionID,
Attempt: attempt,
Quorum: append([]NodeID{}, quorum...),
Message: append([]byte{}, message...),
rng: rng,
}, nil
}
// Round1 samples the per-round mask, computes the commit, and
// emits the Round-1 broadcast.
func (s *ThresholdSigner) Round1() (*Round1Message, error) {
maskLen := s.Params.SeedSize * 2
if len(s.SecretShare.Share) != maskLen {
return nil, ErrShareWireSize
}
// Sample raw entropy and derive the per-attempt mask via
// cSHAKE256(rngBytes || sid || attempt || NodeID, tagSignMask).
// This way a deterministic RNG that produces the same output
// across two attempts still yields DISTINCT masks per attempt.
rngBytes := make([]byte, maskLen)
if _, err := io.ReadFull(s.rng, rngBytes); err != nil {
return nil, ErrShortRand
}
maskMix := make([]byte, 0, len(rngBytes)+16+4+len(s.NodeID))
maskMix = append(maskMix, rngBytes...)
maskMix = append(maskMix, s.SessionID[:]...)
maskMix = append(maskMix,
byte(s.Attempt>>24), byte(s.Attempt>>16),
byte(s.Attempt>>8), byte(s.Attempt))
maskMix = append(maskMix, s.NodeID[:]...)
s.myMask = cshake256(maskMix, maskLen, tagSignMask)
zeroizeBytes(rngBytes)
zeroizeBytes(maskMix)
// Mask the share byte-by-byte XOR.
s.myMaskedShare = make([]byte, maskLen)
for i := 0; i < maskLen; i++ {
s.myMaskedShare[i] = s.SecretShare.Share[i] ^ s.myMask[i]
}
// Commit D_i = cSHAKE256(mask || masked || tau_1).
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, s.NodeID, s.SecretShare.Pub, s.Message)
commitInput := make([]byte, 0, len(s.myMask)+len(s.myMaskedShare)+len(tau))
commitInput = append(commitInput, s.myMask...)
commitInput = append(commitInput, s.myMaskedShare...)
commitInput = append(commitInput, tau...)
s.myCommit = transcriptHash32(tagSignR1, commitInput)
return &Round1Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
Commit: s.myCommit,
}, nil
}
// Round2 ingests Round-1 messages and emits the Round-2 reveal
// carrying (r_i, masked_share_i).
func (s *ThresholdSigner) Round2(round1Msgs []*Round1Message) (*Round2Message, *AbortEvidence, error) {
if len(round1Msgs) < 1 {
return nil, nil, ErrEmptyQuorum
}
// Verify session and attempt consistency.
for _, m := range round1Msgs {
if m.SessionID != s.SessionID {
return nil, nil, ErrSessionMismatch
}
if m.Attempt != s.Attempt {
return nil, nil, ErrAttemptMismatch
}
}
// Round-2 reveal: (r_i, masked_share_i). Pack into PartialSig.
revealed := make([]byte, 0, len(s.myMask)+len(s.myMaskedShare))
revealed = append(revealed, s.myMask...)
revealed = append(revealed, s.myMaskedShare...)
return &Round2Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
PartialSig: revealed,
}, nil, nil
}
// Combine is implemented in combine.go.
// transcriptTau1Bytes builds the Round-1 transcript tau_1 = (sid,
// kappa, T, i, pk, mu). tau_1 is bound into every commit so a
// cross-session replay becomes a transcript mismatch.
func transcriptTau1Bytes(sid [16]byte, attempt uint32, quorum []NodeID, sender NodeID, pk *PublicKey, message []byte) []byte {
parts := [][]byte{}
parts = append(parts, sid[:])
parts = append(parts, []byte{byte(attempt >> 24), byte(attempt >> 16), byte(attempt >> 8), byte(attempt)})
for _, q := range quorum {
parts = append(parts, q[:])
}
parts = append(parts, sender[:])
if pk != nil {
parts = append(parts, pk.Bytes)
}
parts = append(parts, message)
out := []byte{}
out = append(out, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
out = append(out, encodeString(p)...)
}
return out
}
-206
View File
@@ -1,206 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"testing"
)
func TestThreshold_BasicSign(t *testing.T) {
skipUnderRace(t)
// (n=3, t=2): sign with the minimum quorum and verify.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("Magnetar threshold-sign basic test")
var sid [16]byte
copy(sid[:], "magnetar-test-1")
attempt := uint32(1)
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xAB, 0xCD}))
if err != nil {
t.Fatalf("NewThresholdSigner[%d]: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1[%d]: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2[%d]: %v", i, err)
}
r2[i] = m
}
sig, err := CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, attempt, quorum, 2, r1, r2, shares)
if err != nil {
t.Fatalf("CombineWithSeedReconstruction: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
}
func TestThreshold_VariousConfigurations(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA threshold-sign with n>=5 is slow under -short")
}
configs := []struct{ N, T int }{
{3, 2}, {5, 3}, {7, 4},
}
for _, cfg := range configs {
cfg := cfg
t.Run(spfNT(cfg.N, cfg.T), func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(cfg.N)
pub, shares, _ := runDKG(t, params, committee, cfg.T)
msg := []byte("multi-config threshold sign")
sig, err := signWithQuorum(t, params, pub, shares, cfg.T, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
})
}
}
func TestThreshold_TamperedRound2Rejected(t *testing.T) {
skipUnderRace(t)
// A Round-2 reveal that doesn't match the Round-1 commit
// must be rejected by Combine.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("tamper test")
var sid [16]byte
copy(sid[:], "tamper-sid-0001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xFA, 0xCE}))
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
// Tamper: flip a bit in r2[0]'s mask half.
r2[0].PartialSig[0] ^= 0x01
if _, err := CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, 1, quorum, 2, r1, r2, shares); err == nil {
t.Fatalf("CombineWithSeedReconstruction accepted tampered Round-2 reveal")
}
}
func TestThreshold_SessionMismatchRejected(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("session-mismatch test")
var sid1, sid2 [16]byte
copy(sid1[:], "sid-1234567890ab")
copy(sid2[:], "sid-DIFFERENT001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid1, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xBA, 0xDD}))
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
// CombineWithSeedReconstruction with sid2: must fail.
if _, err := CombineWithSeedReconstruction(params, pub, msg, nil, false, sid2, 1, quorum, 2, r1, r2, shares); err == nil {
t.Fatalf("CombineWithSeedReconstruction accepted mismatched session ID")
}
}
func TestThreshold_NonMemberQuorumRejected(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
_, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("non-member test")
var sid [16]byte
copy(sid[:], "nonmember-sid01")
var stranger NodeID
stranger[0] = 0xFF
quorum := []NodeID{stranger, shares[0].NodeID}
// shares[1].NodeID isn't in quorum, but shares[1] is not the
// quorum member here; only check shares[0] participates while
// stranger has no key.
if _, err := NewThresholdSigner(params, sid, 1, quorum, shares[1], msg, nil); err == nil {
t.Fatalf("expected ErrNotInQuorum (shares[1].NodeID not in quorum)")
}
}
// signWithQuorum runs a full ceremony: pick first t shares,
// execute Round 1, Round 2, Combine. Returns the signature.
func signWithQuorum(t *testing.T, params *Params, pub *PublicKey, shares []*KeyShare, threshold int, msg []byte) (*Signature, error) {
t.Helper()
quorum := make([]NodeID, threshold)
for i := 0; i < threshold; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
copy(sid[:], "magnetar-tquorum")
attempt := uint32(7)
signers := make([]*ThresholdSigner, threshold)
for i := 0; i < threshold; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0x42}))
if err != nil {
return nil, err
}
signers[i] = s
}
r1 := make([]*Round1Message, threshold)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
return nil, err
}
r1[i] = m
}
r2 := make([]*Round2Message, threshold)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
return nil, err
}
r2[i] = m
}
return CombineWithSeedReconstruction(params, pub, msg, nil, false, sid, attempt, quorum, threshold, r1, r2, shares)
}
+30 -168
View File
@@ -3,46 +3,46 @@
package magnetar
// transcript.go — FIPS 202 / SP 800-185 transcript primitives used
// by every Magnetar protocol round.
// transcript.go --- FIPS 202 / SP 800-185 transcript primitive used
// by the THBS-SE construction.
//
// All hashing in Magnetar routes through this file. Direct use of
// stdlib hashes anywhere else in the package is a CI failure.
// Magnetar v1.0 ships ONE hashing surface: cSHAKE256 from FIPS 202
// sec 6.3 + SP 800-185 sec 3, pinned to function-name "Magnetar". All
// other Magnetar cSHAKE calls route through this file so the audit
// footprint of the hash layer is one function.
//
// The two primitives we vend are:
//
// - cSHAKE256(K, X, L, S) — FIPS 202 §6.3 + SP 800-185 §3
// - KMAC256 (K, X, L, S) — SP 800-185 §4
//
// All Magnetar customisation strings live in this file as named
// constants so the audit footprint of the hash layer is one file.
// Per-validator standalone (standalone.go) uses NO cSHAKE; it is FIPS
// 205 SLH-DSA top-to-bottom. THBS-SE (thbsse.go + thbsse_field.go) is
// the only consumer of cshake256, for the commit transcript
// (R1-commit, slot-id derivation, share-deal coefficient stretch) and
// the share-MAC. The customisation tags are pinned in their
// respective files as named constants so any cross-protocol replay
// deterministically fails commit reconstruction at the first byte.
import (
"encoding/binary"
"golang.org/x/crypto/sha3"
)
// Customisation tags for cSHAKE256/KMAC256. Rotating a tag
// invalidates every test vector pinned at that tag.
const (
tagDKGCommit = "MAGNETAR-DKG-COMMIT-V1"
tagDKGTranscript = "MAGNETAR-DKG-TRANSCRIPT-V1"
tagSignR1 = "MAGNETAR-SIGN-R1-V1"
tagSignR1MAC = "MAGNETAR-SIGN-R1-MAC-V1"
tagSignMask = "MAGNETAR-SIGN-MASK-V1"
tagSeedShare = "MAGNETAR-SEED-SHARE-V1"
tagDomainSep = "lux-magnetar-v0.1"
)
// functionName is the SP 800-185 cSHAKE function-name parameter.
// All Magnetar cSHAKE calls pin N to "Magnetar" so that an
// integrator who mistakenly fed Magnetar cSHAKE bytes into a
// non-Magnetar cSHAKE engine would get a deterministic mismatch.
// functionName is the SP 800-185 cSHAKE function-name parameter. All
// Magnetar cSHAKE calls pin N to "Magnetar" so that an integrator who
// mistakenly fed Magnetar cSHAKE bytes into a non-Magnetar cSHAKE
// engine would get a deterministic mismatch.
const functionName = "Magnetar"
// Customisation tags for cSHAKE256. Rotating a tag invalidates every
// test vector pinned at that tag.
//
// THBS-SE-specific tags live in thbsse.go (tagThbsSeSlot,
// tagThbsSeR1Commit, tagThbsSeShareMAC, tagThbsSeCtxPrefix). The
// share-deal tag below is consumed by thbsse_field.go and by
// EvalPointFromID.
const (
tagThbsSeShareDeal = "MAGNETAR-THBSSE-SHARE-DEAL-V1"
tagDomainSep = "lux-magnetar-v1"
)
// cshake256 returns the first outLen bytes of cSHAKE256(input, N,
// customisation) per SP 800-185 §3.
// customisation) per SP 800-185 sec 3.
func cshake256(input []byte, outLen int, customisation string) []byte {
h := sha3.NewCShake256([]byte(functionName), []byte(customisation))
_, _ = h.Write(input)
@@ -50,141 +50,3 @@ func cshake256(input []byte, outLen int, customisation string) []byte {
_, _ = h.Read(out)
return out
}
// kmac256 returns KMAC256(key, msg, outLen, customisation) per
// SP 800-185 §4.
func kmac256(key, msg []byte, outLen int, customisation string) []byte {
preamble := bytepad(encodeString(key), 136)
body := append(append([]byte{}, preamble...), msg...)
body = append(body, rightEncode(uint64(outLen)*8)...)
h := sha3.NewCShake256([]byte("KMAC"), []byte(customisation))
_, _ = h.Write(body)
out := make([]byte, outLen)
_, _ = h.Read(out)
return out
}
// SP 800-185 §2.3 encoders.
// leftEncode returns left_encode(x) per SP 800-185 §2.3.1.
// Operates on the BIT length: callers pre-multiply by 8 when
// encoding a byte-length.
func leftEncode(x uint64) []byte {
if x == 0 {
return []byte{0x01, 0x00}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, byte(8-i))
out = append(out, buf[i:]...)
return out
}
// rightEncode returns right_encode(x) per SP 800-185 §2.3.1.
func rightEncode(x uint64) []byte {
if x == 0 {
return []byte{0x00, 0x01}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, buf[i:]...)
out = append(out, byte(8-i))
return out
}
// encodeString returns encode_string(s) = left_encode(bit_len(s))
// || s per SP 800-185 §2.3.2.
func encodeString(s []byte) []byte {
out := leftEncode(uint64(len(s)) * 8)
out = append(out, s...)
return out
}
// bytepad returns bytepad(x, w) = left_encode(w) || x ||
// pad-to-w-bytes per SP 800-185 §2.3.3.
func bytepad(x []byte, w int) []byte {
prefix := leftEncode(uint64(w))
out := make([]byte, 0, len(prefix)+len(x)+w)
out = append(out, prefix...)
out = append(out, x...)
for len(out)%w != 0 {
out = append(out, 0x00)
}
return out
}
// transcriptHash binds an ordered tuple of byte-strings into a
// single 48-byte digest under the named customisation tag.
//
// Encoding is SP 800-185 TupleHash256-style: for each part, prepend
// left_encode(bit_len(part)) so the boundary between parts is
// unambiguous regardless of part lengths.
func transcriptHash(customisation string, parts ...[]byte) [48]byte {
buf := make([]byte, 0, 64+len(parts)*40)
buf = append(buf, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
buf = append(buf, encodeString(p)...)
}
out := cshake256(buf, 48, customisation)
var ret [48]byte
copy(ret[:], out)
return ret
}
// transcriptHash32 is the 32-byte counterpart used where a shorter
// digest is sufficient (commit digests, MAC tags).
func transcriptHash32(customisation string, parts ...[]byte) [32]byte {
buf := make([]byte, 0, 64+len(parts)*40)
buf = append(buf, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
buf = append(buf, encodeString(p)...)
}
out := cshake256(buf, 32, customisation)
var ret [32]byte
copy(ret[:], out)
return ret
}
// ctEqualSlice is a constant-time byte-slice equality check.
// Returns false if lengths differ; otherwise scans every byte
// regardless of where the first mismatch occurs.
func ctEqualSlice(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// ctEqual32 is the 32-byte fast path for ctEqualSlice.
func ctEqual32(a, b [32]byte) bool {
var diff byte
for i := 0; i < 32; i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// nodeIDLess returns true if a < b in canonical big-endian byte
// order. Used for sorting committee membership canonically.
func nodeIDLess(a, b NodeID) bool {
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
+24 -193
View File
@@ -9,31 +9,32 @@ package magnetar
// NodeID is the canonical party identifier used in all Magnetar
// protocols. The 32-byte width matches the Lux validator-ID format
// and is wide enough to host an arbitrary external identifier
// (e.g. a Hanzo IAM subject hash). Index 0 is forbidden because the
// Shamir evaluation point at x=0 holds the master secret; any party
// with nominal index 0 is rejected.
// (e.g. a Hanzo IAM subject hash). Index 0 is forbidden because
// the Shamir evaluation point at x=0 holds the master secret; any
// party with nominal index 0 is rejected.
type NodeID [32]byte
// PublicKey wraps a FIPS 205 SLH-DSA public key. The byte layout is
// exactly what circl's slhdsa.PublicKey.MarshalBinary emits —
// PublicKey wraps a FIPS 205 SLH-DSA public key. The byte layout
// is exactly what circl's slhdsa.PublicKey.MarshalBinary emits —
// (seed, root) concatenation per FIPS 205 §10.1 (Algorithm 21).
//
// The headline Class-N1-analog claim of Magnetar is that a Magnetar
// threshold signature against this PublicKey verifies under
// unmodified FIPS 205 slhdsa.Verify (see Verify in verify.go).
// The headline byte-identity claim of Magnetar is that a Magnetar
// signature (per-validator standalone or THBS-SE) against this
// PublicKey verifies under unmodified FIPS 205 slhdsa.Verify
// (see Verify in verify.go).
type PublicKey struct {
Mode Mode
Bytes []byte
}
// PrivateKey wraps a FIPS 205 SLH-DSA private key. Only the trusted
// dealer in keygen.go holds the full PrivateKey; threshold
// deployments hold KeyShare values produced by DKG instead.
// PrivateKey wraps a FIPS 205 SLH-DSA private key. Held only by
// the per-validator standalone path (each validator holds its own
// PrivateKey locally); the THBS-SE threshold path holds KeyShare
// values produced by setup, never the full PrivateKey.
//
// PrivateKey carries the seed it was derived from so determinism
// across re-load is preserved; the seed is the Shamir-shared
// quantity in the threshold model. Seed is variable-length (4n
// bytes per FIPS 205 §10.1).
// across re-load is preserved. Seed is variable-length (4n bytes
// per FIPS 205 §10.1).
type PrivateKey struct {
Mode Mode
Bytes []byte
@@ -41,190 +42,20 @@ type PrivateKey struct {
Pub *PublicKey
}
// KeyShare is one party's portion of a threshold-DKG output. Each
// share is a (NodeID, scalar-byte-vector) tuple where the scalar
// vector is the Shamir share of the underlying SLH-DSA seed at the
// party's Shamir evaluation point.
//
// The Share field carries seed_size × uint16 lanes (big-endian),
// giving the Shamir share value in GF(257) at every byte position
// of the underlying seed. Wire layout is independent of the cSHAKE
// transcript mix.
//
// The evaluation point must be non-zero and distinct across the
// committee. v0.1 derives it from the 1-indexed committee position.
type KeyShare struct {
NodeID NodeID
EvalPoint uint32 // Shamir x-coordinate in [1, 257); distinct per party
Share []byte // seed_size × uint16 big-endian GF(257) share values
Pub *PublicKey
Mode Mode
}
// Signature is a FIPS 205 SLH-DSA signature in its standard byte
// layout per FIPS 205 §10.2 (Algorithm 22). No Magnetar envelope
// is applied. A relying party that can verify SLH-DSA can verify
// a Magnetar Signature with no code change.
// is applied to the inner bytes; the canonical MAGS wire frame
// (wire.go) wraps them for transport but a relying party that
// strips the 11-byte header obtains the unmodified FIPS 205 bytes
// that cloudflare/circl's slhdsa.Verify accepts.
type Signature struct {
Mode Mode
Bytes []byte
}
// GroupKey is the joint public key produced by DKG. The
// distinction between GroupKey and PublicKey is documentary only:
// they carry the same bytes. A *GroupKey is a *PublicKey under a
// renamed alias that signals "this is a threshold-DKG output."
// GroupKey is the public key produced by THBS-SE setup
// (NewThbsSeKey). The distinction between GroupKey and PublicKey
// is documentary only: they carry the same bytes. A *GroupKey is
// a *PublicKey under a renamed alias that signals "this is the
// permissionless-threshold-committee output."
type GroupKey = PublicKey
// DKGRound1Msg is the broadcast emitted by DKGSession.Round1.
//
// Protocol shape (v0.1 reveal-and-aggregate):
// The dealer broadcasts one envelope per recipient that carries
// the recipient's Shamir share of the dealer's contribution AND
// the full dealer-contribution-to-the-joint-seed. Combining N
// such per-dealer contributions at Round 2 lets each party
// compute the joint master public key locally.
//
// v0.1 ships the envelope IN PLAINTEXT for KAT determinism;
// production deployments wrap it under ML-KEM-768 per the same
// pattern Pulsar uses. The honest v0.1 trust caveat (reveal-and-
// aggregate aggregator-as-TCB) is unaffected by this choice; both
// modes equally reveal the seed at Combine time. See SPEC.md
// "Trust model" for full disclosure.
type DKGRound1Msg struct {
NodeID NodeID
Envelopes map[NodeID]DKGShareEnvelope
}
// DKGShareEnvelope is the v0.1 plaintext envelope carrying one
// recipient's per-byte Shamir share of the dealer's seed
// contribution AND the full dealer contribution.
//
// Wire layout (v0.1 plaintext): Share || Contribution where
// - Share is seed_size × uint16 big-endian (2 × seed_size bytes)
// - Contribution is seed_size bytes (the dealer's contribution
// to the joint seed, used at Round 2 to compute the master
// public key).
//
// v0.2 wraps Sealed under ML-KEM-768 to the recipient's long-term
// identity public key, matching the BLOCKERS.md CR-8 pattern Pulsar
// adopted. v0.2 wire layout: KEMCiphertext || Sealed.
type DKGShareEnvelope struct {
Share []byte // 2 × seed_size bytes (GF(257) Shamir share, big-endian uint16 per byte position)
Contribution []byte // seed_size bytes (dealer's contribution to the joint seed)
}
// DKGRound2Msg is the broadcast emitted by DKGSession.Round2: the
// per-party transcript-digest agreement digest. Used to bind every
// party's view of the ordered Round-1 envelope set so equivocation
// is detectable.
type DKGRound2Msg struct {
NodeID NodeID
Digest [32]byte
}
// DKGOutput is the result of a successful DKG.
//
// On success, GroupPubkey is the joint FIPS 205 SLH-DSA public
// key, SecretShare is the calling party's Shamir share of the
// group seed, TranscriptHash is the 48-byte transcript digest that
// the chain can pin in its validator-set commitment, and
// AbortEvidence is nil.
type DKGOutput struct {
GroupPubkey *PublicKey
SecretShare *KeyShare
TranscriptHash [48]byte
AbortEvidence *AbortEvidence
}
// AbortEvidence is a signed complaint emitted by an honest party
// when it detects deviation. The protocol family commits to
// identifiable abort: every detected deviation produces verifiable
// evidence suitable for slashing.
type AbortEvidence struct {
Kind ComplaintKind
Accuser NodeID
Accused NodeID
Epoch uint64
Evidence []byte
Signature []byte
}
// ComplaintKind is the taxonomy of identifiable-abort complaint
// types. Values are wire-stable (do not renumber).
type ComplaintKind uint8
const (
// ComplaintEquivocation: a dealer broadcast distinct envelopes
// or commit vectors to distinct recipients.
ComplaintEquivocation ComplaintKind = 1
// ComplaintBadDelivery: the share delivered to the accuser
// fails verification against the dealer's contribution.
ComplaintBadDelivery ComplaintKind = 2
// ComplaintMACFailure: a MAC from the accused failed
// verification. Reserved for v0.2 where per-pair MACs are
// added; v0.1 has no MAC layer.
ComplaintMACFailure ComplaintKind = 3
)
// String returns the canonical name of the complaint kind.
func (k ComplaintKind) String() string {
switch k {
case ComplaintEquivocation:
return "equivocation"
case ComplaintBadDelivery:
return "bad-delivery"
case ComplaintMACFailure:
return "mac-failure"
default:
return "unknown"
}
}
// Round1Message and Round2Message in threshold signing have a
// different shape from DKG. The threshold protocol's Round-1
// emits a commit + per-peer MAC; Round-2 reveals (mask,
// masked_share). The v0.1 reveal-and-aggregate model collapses
// the per-signature ceremony into a single Sign on the
// reconstructed seed.
// Round1Message is the broadcast emitted by ThresholdSigner.Round1.
//
// v0.1 protocol shape: each signer commits to a masked version of
// their seed share via a cSHAKE256 commit digest D_i. Round-2
// reveals (mask, masked_share) so peers can re-derive D_i and the
// aggregator can recover the share via XOR.
//
// MACs are intentionally omitted from v0.1: the reveal-and-
// aggregate model already TRUSTS the aggregator (it sees the
// reconstructed seed). Adding MACs would close a session-bind
// hole that doesn't matter under the v0.1 trust caveat; v0.2
// adds them when the trust model tightens. See SPEC.md.
type Round1Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
Commit [32]byte // D_i = cSHAKE256(mask || masked || tau_1)
}
// Round2Message carries the (mask, masked_share) reveal. The
// aggregator combines this with the matching Round1Message to
// recover the underlying share, then Lagrange-reconstructs the
// master seed.
//
// PartialSig wire layout: mask || masked_share (each is 2 ×
// seed_size bytes). The aggregator parses these per share.
type Round2Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
PartialSig []byte
}
// shareWireSize returns the byte length of one Shamir share's
// wire representation for a given seed size.
func shareWireSize(seedSize int) int {
return seedSize * 2
}
+17 -15
View File
@@ -3,17 +3,18 @@
package magnetar
// verify.go — the canonical consensus-consumer entry point.
// verify.go --- the canonical consensus-consumer entry point.
//
// This file is the Magnetar Class-N1-analog manifesto in code:
// Verify literally dispatches to circl/slhdsa.Verify, which is the
// FIPS 205 §10.3 verifier verbatim. No Magnetar-specific logic; no
// Verify literally dispatches to circl/slhdsa.Verify, the FIPS 205
// sec 10.3 verifier verbatim. No Magnetar-specific logic; no
// threshold-specific extension fields; no envelope. A signature
// produced by Combine (threshold.go) flows through the SAME Verify
// code path as a single-party Sign output.
// produced by ValidatorSign (per-validator standalone, standalone.go),
// by Combine (THBS-SE public combiner, thbsse.go), or by a sibling
// single-party FIPS 205 toolchain flows through the SAME Verify code
// path and produces the same accept/reject result.
//
// Any change to Verify breaks output interchangeability with
// single-party FIPS 205.
// Any change to Verify breaks the wire-identity claim that ties
// Magnetar to unmodified FIPS 205.
import (
"errors"
@@ -54,10 +55,11 @@ var (
// ctx is the FIPS 205 context string (≤255 bytes); pass nil for the
// empty context. Match this to whatever was passed at Sign time.
//
// Class-N1-analog: this function MUST remain a thin dispatch over
// the FIPS 205 verifier. Adding logic here breaks output
// interchangeability with single-party FIPS 205 — the whole point
// of the Magnetar threshold variant.
// This function MUST remain a thin dispatch over the FIPS 205
// verifier. Adding logic here breaks output interchangeability with
// unmodified FIPS 205 verifiers (cloudflare/circl, NIST FIPS 205
// reference, openssl-pq) --- the wire-identity claim that lets any
// peer verify Magnetar signatures without a Magnetar dependency.
func Verify(params *Params, groupPubkey *PublicKey, message []byte, sig *Signature) error {
return VerifyCtx(params, groupPubkey, message, nil, sig)
}
@@ -98,9 +100,9 @@ func VerifyCtx(params *Params, groupPubkey *PublicKey, message, ctx []byte, sig
}
// slhVerify dispatches to circl/slhdsa.Verify for the given
// parameter ID. This is the only place outside keygen/sign that
// touches the FIPS 205 implementation; centralising it makes the
// Class-N1-analog dispatch auditable as a single function.
// parameter ID. This is the only place outside keygen/sign/Combine
// that touches the FIPS 205 implementation; centralising it makes
// the wire-identity dispatch auditable as a single function.
func slhVerify(id slhdsa.ID, packedPk, message, ctx, sig []byte) bool {
pk := slhdsa.PublicKey{ID: id}
if err := pk.UnmarshalBinary(packedPk); err != nil {
+20 -13
View File
@@ -3,24 +3,31 @@
package magnetar
// zeroize.go — best-effort secret-buffer zeroization.
// zeroize.go --- best-effort secret-buffer zeroization.
//
// Threat model: a Magnetar process holding reconstructed secret
// material (e.g. the Combine aggregator's master seed) is at risk
// of coredump / /proc/self/mem / swap-file exfiltration if the
// secret is left live on the heap or stack after use. Go provides
// no native `runtime.Memzero` and the GC may copy buffers around;
// zeroize is a defense-in-depth measure, not a guarantee.
// Threat model: a Magnetar process holding transient secret material
// (the THBS-SE public combiner's reconstructed seed; a per-validator
// PrivateKey buffer at standalone-sign time) is at risk of coredump,
// /proc/self/mem, or swap-file exfiltration if the secret is left
// live on the heap or stack after use. Go provides no native
// runtime.Memzero and the GC may copy buffers around; zeroize is a
// defense-in-depth measure, not a guarantee.
//
// We deliberately do NOT use `defer` for zeroize calls — the
// We deliberately do NOT use `defer` for zeroize calls --- the
// hot-path callers are short, and explicit zeroization at the
// return site keeps the secret-handling code path locally legible.
//
// The v0.1 reveal-and-aggregate trust caveat: even with perfect
// zeroization the aggregator process has a non-zero secret
// lifetime window. See SPEC.md "Trust model" and
// DEPLOYMENT-RUNBOOK.md for operational mitigations (mlock,
// ptrace-off, etc).
// THBS-SE v1.0 honest open item: the strict invariant ("no party or
// combiner EVER reconstructs SK.seed, even transiently in memory")
// requires the v1.1 strict-atom-assembly path tracked at
// BLOCKERS.md::MAGNETAR-STRICT-ATOM-V11. v1.0 ships a PUBLIC
// COMBINER (anyone-can-combine) that holds the seed for the duration
// of one circl SignDeterministic call and then zeroizes; this is
// materially stronger than a TEE-attested privileged-aggregator
// model (no host in TCB) and materially weaker than the strict
// invariant (a peer-local memory-disclosure adversary at the combine
// moment could observe the seed). See thbsse.go for the open-item
// discussion.
// zeroizeBytes overwrites every byte of b with 0.
func zeroizeBytes(b []byte) {
-395
View File
@@ -1,395 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import (
"crypto/rand"
"encoding/binary"
"errors"
"io"
"sort"
)
// dealer.go — v1 dealer-backed DKG.
//
// HONEST TRUST CAVEAT: a single party (the dealer) generates a master
// seed, expands it into per-element secrets, shares each secret via
// per-byte Shamir over GF(257), and distributes shares. The dealer
// learns every secret element at setup time; the dealer MUST erase
// the seed before going live. This is the McGrew et al. v1 setup
// pattern.
//
// v2 replaces this with a public DKG (PVSS-based) so no single party
// learns the secret elements. The wire shape of PrivateShare /
// PublicKey is forward-compatible with the v2 DKG output.
//
// What the dealer DOES emit publicly:
// - PublicKey.Root (the top-tree root)
// - HelperData (LeafRoots, AuthPaths, ChainEndpoints, FORS roots
// and FORS auth paths)
// - DKGTranscript (a hash binding committee + params + epoch +
// chainID + the helper data)
//
// What the dealer NEVER emits publicly:
// - The dealer seed (zeroized before return)
// - Any secret WOTS+ chain head x_{slot, j}
// - Any secret FORS leaf sk_{slot, tree, leaf}
//
// What each party RECEIVES (their PrivateShare.ElementShares):
// - A Shamir share at their EvalPoint of every secret element:
// (slot, ElementWOTS, chain_idx) -> N bytes share
// (slot, ElementFORS, tree_idx, leaf_idx) -> N bytes share
// - NO seed, NO future-signing key derivation path.
// DKGOptions extends DKGConfig with optional plumbing.
type DKGOptions struct {
// Rng is the randomness source used by the dealer to generate the
// secret material. If nil, crypto/rand.Reader is used.
Rng io.Reader
// DealerSeed, if non-nil, replaces the random dealer seed with a
// deterministic value. ONLY for test vectors. Production deployments
// MUST leave this nil.
DealerSeed []byte
}
// 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.
//
// IMPORTANT — call site responsibility: in v1 the dealer machine
// 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 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 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
}
if len(shares) == 0 {
return PublicKey{}, PrivateShare{}, errors.New("thbs: empty committee")
}
return pk, shares[0], nil
}
// 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
}
rng := opts.Rng
if rng == nil {
rng = rand.Reader
}
params := config.Params
n := params.N
nSlots := 1 << params.Height
// Deterministic transcript-binding tag for the dealer's per-element
// PRF expansion. Includes everything in the DKGConfig so the
// resulting shares are pinned to this exact ceremony.
tagPrefix := transcriptPrefix(config)
// Dealer seed. Either supplied (test-vector mode) or freshly
// drawn from rng.
dealerSeed := make([]byte, 64)
if opts.DealerSeed != nil {
dealerSeed = append(dealerSeed[:0], opts.DealerSeed...)
if len(dealerSeed) < 32 {
return PublicKey{}, nil, errors.New("thbs: DealerSeed too short (need >= 32 bytes)")
}
} else {
if _, err := io.ReadFull(rng, dealerSeed); err != nil {
return PublicKey{}, nil, err
}
}
defer zeroize(dealerSeed)
// Collect per-participant evaluation points.
points := make([]uint16, len(config.Participants))
for i, p := range config.Participants {
points[i] = p.EvalPoint
}
// Per-party share stores.
stores := make([]ShareStore, len(config.Participants))
for i := range stores {
stores[i] = make(ShareStore)
}
// Helper data structures we'll populate.
helper := &HelperData{
LeafRoots: make([][]byte, nSlots),
AuthPaths: make([][][]byte, nSlots),
ChainEndpoints: make([][][]byte, nSlots),
FORSPubRoots: make([][][]byte, nSlots),
FORSAuthPaths: make([][][][]byte, nSlots),
}
// For each slot we:
// 1. derive WOTS+ secret chain heads x_{slot, j}
// 2. share each across the committee (per-byte Shamir GF(257))
// 3. compute the public endpoint W_{slot, j} = H^{w-1}(x_{slot, j})
// [for the dealer's bookkeeping; same can be done from the
// reconstructed value when w-1 - 0 == w-1 steps are applied
// at sign time, but we materialise it here for HelperData]
// 4. derive FORS secret leaves sk_{slot, tree, leaf}
// 5. share each
// 6. build per-tree FORS authentication paths
// 7. compute the WOTS+ leaf-root and stash it for the top tree.
//
// After every slot, the dealer zeroises the secret material.
for slot := uint32(0); slot < uint32(nSlots); slot++ {
// 1. WOTS+ secret heads.
secrets := make([][]byte, params.WOTSChains)
endpoints := make([][]byte, params.WOTSChains)
for j := 0; j < params.WOTSChains; j++ {
secrets[j] = dealerPRF(dealerSeed, tagPrefix,
byte(ElementWOTS), slot, uint16(j), 0, 0, n)
// 2. share.
slices, err := shareElement(secrets[j], points, int(config.Threshold),
dealerPRFRaw(dealerSeed, tagPrefix, byte(ElementWOTS), slot, uint16(j), 0, 0, (int(config.Threshold)-1)*n*2+2))
if err != nil {
zeroizeMatrix(secrets)
return PublicKey{}, nil, err
}
for i := range stores {
id := ElementID{Type: ElementWOTS, Slot: slot, ChainIdx: uint16(j)}
stores[i][id] = append([]uint16{}, slices[i].Y...)
}
// 3. public endpoint.
endpoints[j] = wotsChain(params, secrets[j], slot, uint16(j), 0, params.W-1)
}
helper.ChainEndpoints[slot] = endpoints
// 7a. WOTS+ leaf root.
wotsLeaf := wotsLeafFromChainEndpoints(params, slot, endpoints)
// 4-6. FORS.
forsRoots := make([][]byte, params.FORSK)
nLeaves := 1 << params.FORSA
for tree := 0; tree < params.FORSK; tree++ {
leafImages := make([][]byte, nLeaves)
for leaf := 0; leaf < nLeaves; leaf++ {
sk := dealerPRF(dealerSeed, tagPrefix,
byte(ElementFORS), slot, uint16(tree), uint32(leaf), 0, n)
// share each leaf
slices, err := shareElement(sk, points, int(config.Threshold),
dealerPRFRaw(dealerSeed, tagPrefix, byte(ElementFORS), slot, uint16(tree), uint32(leaf), 0, (int(config.Threshold)-1)*n*2+2))
if err != nil {
zeroize(sk)
return PublicKey{}, nil, err
}
for i := range stores {
id := ElementID{Type: ElementFORS, Slot: slot,
TreeIdx: uint16(tree), LeafIdx: uint32(leaf)}
stores[i][id] = append([]uint16{}, slices[i].Y...)
}
// public image
leafImages[leaf] = forsLeafImage(params, sk, slot, uint16(tree), uint32(leaf))
zeroize(sk)
}
// Build FORS tree.
root, levels := forsBuildTree(params, leafImages, slot, uint16(tree))
forsRoots[tree] = root
// Materialise EVERY leaf's auth path (combiner only uses
// one per signature, but the helper data is public).
// We flatten by tree: helper.FORSAuthPaths[slot] becomes
// a list of `FORSK * 2^FORSA` paths, indexed as
// [tree * 2^FORSA + leaf].
for leaf := 0; leaf < nLeaves; leaf++ {
helper.FORSAuthPaths[slot] = append(
helper.FORSAuthPaths[slot],
forsAuthPath(levels, uint32(leaf), params.FORSA),
)
}
}
helper.FORSPubRoots[slot] = forsRoots
// 7b. The WOTS+ leaf root and the FORS commitment combine into
// the top-tree leaf for this slot. We bind them together so a
// single Merkle path covers both.
forsRoot := forsRootOfRoots(params, forsRoots, slot)
topLeaf := hashN(params.N, tagWotsLeaf, wotsLeaf, forsRoot,
[]byte{byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot)})
helper.LeafRoots[slot] = topLeaf
zeroizeMatrix(secrets)
}
// Build the top tree.
root, levels := treeBuild(params, helper.LeafRoots)
for slot := uint32(0); slot < uint32(nSlots); slot++ {
helper.AuthPaths[slot] = treeAuthPath(levels, slot, params.Height)
}
// DKG transcript binding.
transcript := computeDKGTranscript(config, helper, root)
// Qualified set = all parties for v1 (no complaint rounds).
qs := NewBitmap(len(config.Participants))
for i := range config.Participants {
qs.Set(i)
}
pk := PublicKey{
Params: params,
Root: root,
DKGTranscript: transcript,
QualifiedSet: qs,
HelperData: helper,
}
out := make([]PrivateShare, len(config.Participants))
for i, p := range config.Participants {
out[i] = PrivateShare{
PartyID: p.ID,
Epoch: config.Epoch,
ElementShares: stores[i],
AntiEquivState: make(StateStore),
}
}
// Defensive: zero dealerSeed (the only "global secret" alive in this
// function). Per-element secrets were zeroised inline.
zeroize(dealerSeed)
return pk, out, nil
}
// validateDKGConfig sanity-checks the DKG inputs.
func validateDKGConfig(c DKGConfig) error {
if c.Threshold < 1 || int(c.Threshold) > len(c.Participants) {
return ErrInvalidThreshold
}
if len(c.Participants) > maxParties {
return ErrTooManyParties
}
if c.Params.N <= 0 || c.Params.W <= 1 || c.Params.LogW < 1 ||
c.Params.WOTSChains < 1 || c.Params.FORSK < 1 || c.Params.FORSA < 1 ||
c.Params.Height < 1 {
return ErrInvalidParams
}
// Distinct evaluation points, all non-zero.
seen := make(map[uint16]struct{}, len(c.Participants))
for _, p := range c.Participants {
if p.EvalPoint == 0 {
return ErrZeroEvalPoint
}
if _, ok := seen[p.EvalPoint]; ok {
return ErrDuplicateParty
}
seen[p.EvalPoint] = struct{}{}
}
return nil
}
// dealerPRF expands the dealer seed into a per-element secret of length
// `n` bytes. The PRF input is fully bound to (elementType, slot,
// chain/tree, leaf, byteOffset) so distinct elements get independent
// outputs and shares cannot be cross-domain-confused.
func dealerPRF(seed, tagPrefix []byte, elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8, n int) []byte {
bind := dealerPRFBind(elemType, slot, idx16, idx32, off)
return hashN(n, tagDealerPRF, seed, tagPrefix, bind)
}
// dealerPRFRaw is dealerPRF with a caller-chosen output length, used to
// supply the coefficient stream for Shamir splitting.
func dealerPRFRaw(seed, tagPrefix []byte, elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8, n int) []byte {
bind := dealerPRFBind(elemType, slot, idx16, idx32, off|0x80) // distinct off bit so secret PRF != coeff PRF
return hashN(n, tagDealerPRF, seed, tagPrefix, bind)
}
func dealerPRFBind(elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8) []byte {
var buf [13]byte
buf[0] = elemType
binary.BigEndian.PutUint32(buf[1:5], slot)
binary.BigEndian.PutUint16(buf[5:7], idx16)
binary.BigEndian.PutUint32(buf[7:11], idx32)
buf[11] = off
buf[12] = 0xA1 // domain literal
return buf[:]
}
// transcriptPrefix binds DKG identity to a stable byte string for use
// in the dealer PRF.
func transcriptPrefix(c DKGConfig) []byte {
parts := make([][]byte, 0, 8+len(c.Participants))
parts = append(parts, c.ChainID)
var epochBuf [8]byte
binary.BigEndian.PutUint64(epochBuf[:], c.Epoch)
parts = append(parts, epochBuf[:])
var thrBuf [2]byte
binary.BigEndian.PutUint16(thrBuf[:], c.Threshold)
parts = append(parts, thrBuf[:])
// Participants, sorted by EvalPoint for canonicality.
sorted := append([]Participant{}, c.Participants...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].EvalPoint < sorted[j].EvalPoint })
for _, p := range sorted {
parts = append(parts, p.ID[:])
var xb [2]byte
binary.BigEndian.PutUint16(xb[:], p.EvalPoint)
parts = append(parts, xb[:])
}
// Params.
parts = append(parts, paramsBytes(c.Params))
// Returns 64-byte transcript prefix.
return hashN(64, tagTranscript, parts...)
}
// computeDKGTranscript binds the helper data into the transcript so the
// public key fully commits to everything the dealer emitted.
func computeDKGTranscript(c DKGConfig, h *HelperData, root []byte) []byte {
prefix := transcriptPrefix(c)
parts := make([][]byte, 0, 2+len(h.LeafRoots))
parts = append(parts, prefix, root)
for _, lr := range h.LeafRoots {
parts = append(parts, lr)
}
return hashN(48, tagTranscript, parts...)
}
func paramsBytes(p HBSParams) []byte {
buf := make([]byte, 32)
binary.BigEndian.PutUint32(buf[0:4], uint32(p.N))
binary.BigEndian.PutUint32(buf[4:8], uint32(p.W))
binary.BigEndian.PutUint32(buf[8:12], uint32(p.LogW))
binary.BigEndian.PutUint32(buf[12:16], uint32(p.WOTSChains))
binary.BigEndian.PutUint32(buf[16:20], uint32(p.FORSK))
binary.BigEndian.PutUint32(buf[20:24], uint32(p.FORSA))
binary.BigEndian.PutUint32(buf[24:28], uint32(p.Height))
return buf
}
func zeroizeMatrix(m [][]byte) {
for i := range m {
zeroize(m[i])
m[i] = nil
}
}
-148
View File
@@ -1,148 +0,0 @@
# 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).
-139
View File
@@ -1,139 +0,0 @@
// 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
}
-164
View File
@@ -1,164 +0,0 @@
// 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
}
-199
View File
@@ -1,199 +0,0 @@
// 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)
}
}
}
-123
View File
@@ -1,123 +0,0 @@
// 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
-234
View File
@@ -1,234 +0,0 @@
// 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
}
-137
View File
@@ -1,137 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
// fors.go — FORS (Forest of Random Subsets) primitive used by THBS v1.
//
// FORS is the few-time signature scheme inside FIPS 205 SLH-DSA. In this
// reference impl we keep just enough FORS to demonstrate the threshold
// property: for each slot we have k FORS trees, each of height a, so
// each tree has 2^a leaves. A signature reveals one leaf per tree
// (the leaf selected by the message digest) plus its authentication
// path inside the tree.
//
// THRESHOLD GLUE: the SECRET LEAVES sk_{slot, tree, leaf} are Shamir-
// shared in dealer.go. The PUBLIC authentication paths and per-tree
// roots are emitted as HelperData. For each message, the protocol
// reveals shares for ONLY the k SELECTED leaves (one per tree) and
// NEVER for the other 2^a - 1 leaves per tree. That is the true
// threshold property.
// forsSelectLeaves extracts the per-tree leaf indices from a 32-byte
// message-derived FORS index digest. Returns k indices, each in
// [0, 2^a).
func forsSelectLeaves(params HBSParams, forsIdxDigest []byte) []uint32 {
leaves := make([]uint32, params.FORSK)
// Treat forsIdxDigest as a bitstream; pop a-bit chunks.
// We need k*a bits of entropy; for k=14 a=12 that's 168 bits, which
// fits in 21 bytes (we have 32 bytes — plenty).
bitOff := 0
a := params.FORSA
for t := 0; t < params.FORSK; t++ {
// Extract bits [bitOff, bitOff+a) from forsIdxDigest.
var idx uint32
for k := 0; k < a; k++ {
bytePos := (bitOff + k) >> 3
bitInByte := 7 - ((bitOff + k) & 7)
bit := (forsIdxDigest[bytePos] >> uint(bitInByte)) & 1
idx = (idx << 1) | uint32(bit)
}
leaves[t] = idx
bitOff += a
}
return leaves
}
// forsLeafImage hashes a FORS secret leaf into its public leaf image.
// pk_{slot, tree, leaf} = H(sk_{slot, tree, leaf} || slot || tree ||
// leaf).
func forsLeafImage(params HBSParams, sk []byte, slot uint32, tree uint16, leaf uint32) []byte {
bind := []byte{
byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot),
byte(tree >> 8), byte(tree),
byte(leaf >> 24), byte(leaf >> 16), byte(leaf >> 8), byte(leaf),
}
return hashN(params.N, tagForsLeaf, sk, bind)
}
// forsTreeNode hashes two child nodes into a parent. Standard binary
// tree (left || right || slot || tree || level || pos).
func forsTreeNode(params HBSParams, left, right []byte, slot uint32, tree uint16, level, pos int) []byte {
bind := []byte{
byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot),
byte(tree >> 8), byte(tree),
byte(level),
byte(pos >> 24), byte(pos >> 16), byte(pos >> 8), byte(pos),
}
return hashN(params.N, tagForsNode, left, right, bind)
}
// forsBuildTree computes the Merkle tree over 2^a leaf images. Returns
// (root, levels) where levels[l][i] is the node at level l index i.
// levels[0] = leaves, levels[a] = [root].
func forsBuildTree(params HBSParams, leafImages [][]byte, slot uint32, tree uint16) ([]byte, [][][]byte) {
a := params.FORSA
levels := make([][][]byte, a+1)
levels[0] = leafImages
for l := 1; l <= a; l++ {
prev := levels[l-1]
next := make([][]byte, len(prev)/2)
for i := 0; i < len(next); i++ {
next[i] = forsTreeNode(params, prev[2*i], prev[2*i+1], slot, tree, l, i)
}
levels[l] = next
}
return levels[a][0], levels
}
// forsAuthPath extracts the authentication path for leaf index `leaf`
// from a built FORS tree. Returns FORSA hashes top-down level-1 to
// level-a sibling.
func forsAuthPath(levels [][][]byte, leaf uint32, a int) [][]byte {
path := make([][]byte, a)
idx := leaf
for l := 0; l < a; l++ {
sibling := idx ^ 1 // flip last bit
path[l] = append([]byte{}, levels[l][sibling]...)
idx >>= 1
}
return path
}
// forsVerifyAuth reconstructs the FORS subtree root given a revealed
// secret leaf and the auth path. The caller compares the result to the
// HelperData FORSPubRoots[slot][tree].
func forsVerifyAuth(params HBSParams, sk []byte, slot uint32, tree uint16, leaf uint32, authPath [][]byte) []byte {
cur := forsLeafImage(params, sk, slot, tree, leaf)
idx := leaf
for l := 0; l < params.FORSA; l++ {
sibling := authPath[l]
var left, right []byte
if idx&1 == 0 {
left = cur
right = sibling
} else {
left = sibling
right = cur
}
cur = forsTreeNode(params, left, right, slot, tree, l+1, int(idx>>1))
idx >>= 1
}
return cur
}
// forsRootOfRoots binds k FORS subtree roots into a single FORS
// commitment for the slot. This is the value the WOTS+ message digest
// chains-into; in the v1 spec we treat this as part of the message
// the WOTS+ chains sign (cf. SLH-DSA where it is similar but the
// commitment is over an XMSS path).
func forsRootOfRoots(params HBSParams, roots [][]byte, slot uint32) []byte {
parts := make([][]byte, 0, len(roots)+1)
parts = append(parts, []byte{
byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot),
})
parts = append(parts, roots...)
return hashN(params.N, tagForsRoot, parts...)
}
-81
View File
@@ -1,81 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import (
"encoding/binary"
"golang.org/x/crypto/sha3"
)
// hash.go — cSHAKE-256 hash primitives for THBS v1.
//
// Domain-separated outputs of length N bytes. Every internal hash
// goes through `hashN` with a tag string. Direct sha3 calls outside
// this file are a CI failure.
const (
tagWotsChain = "MAGNETAR-THBS-WOTS-CHAIN-V1"
tagWotsLeaf = "MAGNETAR-THBS-WOTS-LEAF-V1"
tagForsLeaf = "MAGNETAR-THBS-FORS-LEAF-V1"
tagForsNode = "MAGNETAR-THBS-FORS-NODE-V1"
tagForsRoot = "MAGNETAR-THBS-FORS-ROOT-V1"
tagTreeNode = "MAGNETAR-THBS-TREE-NODE-V1"
tagMsgDigest = "MAGNETAR-THBS-MSG-DIGEST-V1"
tagShareMAC = "MAGNETAR-THBS-SHARE-MAC-V1"
tagDealerPRF = "MAGNETAR-THBS-DEALER-PRF-V1"
tagSlotMix = "MAGNETAR-THBS-SLOT-MIX-V1"
tagTranscript = "MAGNETAR-THBS-TRANSCRIPT-V1"
tagShamir = "MAGNETAR-THBS-SHAMIR-V1"
)
// hashN returns the first n bytes of cSHAKE-256(input, "Magnetar-THBS",
// customisation).
func hashN(n int, customisation string, parts ...[]byte) []byte {
h := sha3.NewCShake256([]byte("Magnetar-THBS"), []byte(customisation))
for _, p := range parts {
// Length-prefix each part so concatenation is unambiguous.
var lp [8]byte
binary.BigEndian.PutUint64(lp[:], uint64(len(p)))
_, _ = h.Write(lp[:])
_, _ = h.Write(p)
}
out := make([]byte, n)
_, _ = h.Read(out)
return out
}
// hash32 is the 32-byte fast path used for tags and digests.
func hash32(customisation string, parts ...[]byte) [32]byte {
out := hashN(32, customisation, parts...)
var ret [32]byte
copy(ret[:], out)
return ret
}
// ctEqual is a constant-time byte-slice equality check.
func ctEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// zeroize wipes a byte slice.
func zeroize(b []byte) {
for i := range b {
b[i] = 0
}
}
// zeroizeU16 wipes a uint16 slice.
func zeroizeU16(b []uint16) {
for i := range b {
b[i] = 0
}
}
-196
View File
@@ -1,196 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import "errors"
// shamir.go — per-byte Shamir over GF(257).
//
// The thbs package shares INDIVIDUAL ELEMENTS (each of length N bytes)
// rather than a single global seed. Each element is shared via the
// same byte-wise GF(257) scheme used elsewhere in Magnetar; the
// invariant "no full-seed reconstruction" is enforced by the API
// shape, not by switching field.
const shamirPrime uint32 = 257
// maxParties is the maximum committee size that GF(257) admits with
// distinct non-zero evaluation points.
const maxParties = 256
var (
errInvalidThreshold = errors.New("thbs/shamir: invalid threshold")
errTooManyShares = errors.New("thbs/shamir: too many parties for GF(257)")
errNoShares = errors.New("thbs/shamir: no shares")
errDuplicateX = errors.New("thbs/shamir: duplicate evaluation point")
errZeroX = errors.New("thbs/shamir: zero evaluation point")
)
// shareElement splits an N-byte secret into n shares with reconstruction
// threshold t. The coeffStream provides the (t-1) per-byte non-constant
// coefficients; if empty, a deterministic stretch via cSHAKE is used.
//
// Returns shares[i].Y of length N for each party i, where shares[i].X
// is the i-th party's evaluation point.
func shareElement(secret []byte, points []uint16, t int, coeffStream []byte) ([]shamirSlice, error) {
if t < 1 || len(points) < t {
return nil, errInvalidThreshold
}
if len(points) > maxParties {
return nil, errTooManyShares
}
n := len(secret)
needed := (t - 1) * n * 2
if needed < 2 {
needed = 2
}
if len(coeffStream) < needed {
coeffStream = hashN(needed, tagShamir, coeffStream)
}
// coeffs[d][b] = degree-d coefficient at byte position b.
coeffs := make([][]uint16, t)
for d := 0; d < t; d++ {
coeffs[d] = make([]uint16, n)
}
for b := 0; b < n; b++ {
coeffs[0][b] = uint16(secret[b]) % uint16(shamirPrime)
}
off := 0
for d := 1; d < t; d++ {
for b := 0; b < n; b++ {
r := uint32(coeffStream[off])<<8 | uint32(coeffStream[off+1])
off += 2
coeffs[d][b] = uint16(r % shamirPrime)
}
}
out := make([]shamirSlice, len(points))
for i, x := range points {
if x == 0 {
return nil, errZeroX
}
out[i].X = x
out[i].Y = make([]uint16, n)
xu := uint32(x)
for b := 0; b < n; b++ {
acc := uint32(coeffs[t-1][b])
for d := t - 2; d >= 0; d-- {
acc = (acc*xu + uint32(coeffs[d][b])) % shamirPrime
}
out[i].Y[b] = uint16(acc)
}
}
return out, nil
}
// reconstructElement Lagrange-interpolates an N-byte secret at x=0.
// The caller MUST pass exactly the shares used to reconstruct one
// element. The output[b] is in [0, 257); for actual byte recovery the
// caller routes through cSHAKE (see sign.go reconstructBytes).
func reconstructElement(shares []shamirSlice, n int) ([]uint16, error) {
if len(shares) == 0 {
return nil, errNoShares
}
seen := make(map[uint16]struct{}, len(shares))
for _, s := range shares {
if s.X == 0 {
return nil, errZeroX
}
if _, dup := seen[s.X]; dup {
return nil, errDuplicateX
}
seen[s.X] = struct{}{}
if len(s.Y) != n {
return nil, errInvalidThreshold
}
}
t := len(shares)
lambdas := make([]uint16, t)
for i := 0; i < t; i++ {
num := uint32(1)
den := uint32(1)
xi := uint32(shares[i].X)
for j := 0; j < t; j++ {
if i == j {
continue
}
xj := uint32(shares[j].X)
negXj := (shamirPrime - xj%shamirPrime) % shamirPrime
num = (num * negXj) % shamirPrime
diff := (shamirPrime + xi - xj) % shamirPrime
den = (den * diff) % shamirPrime
}
denInv := modInv(den, shamirPrime)
lambdas[i] = uint16((num * denInv) % shamirPrime)
}
out := make([]uint16, n)
for b := 0; b < n; b++ {
var acc uint32
for i := 0; i < t; i++ {
acc = (acc + uint32(lambdas[i])*uint32(shares[i].Y[b])) % shamirPrime
}
out[b] = uint16(acc)
}
return out, nil
}
// modInv computes a^-1 mod p via Fermat (p prime).
func modInv(a, p uint32) uint32 { return modPow(a, p-2, p) }
func modPow(base, exp, p uint32) uint32 {
r := uint32(1)
b := base % p
for exp > 0 {
if exp&1 == 1 {
r = (r * b) % p
}
b = (b * b) % p
exp >>= 1
}
return r
}
// shamirSlice carries one byte-wise share of length N at evaluation
// point X. Internal type; the exported API uses ElementShare which
// adds (ID, Steps).
type shamirSlice struct {
X uint16
Y []uint16
}
// gfBytesToBytes maps a length-N GF(257) reconstruction back to N raw
// bytes.
//
// In THBS we INVARIANT: the original secret bytes are always drawn
// from [0, 256), so when split as Shamir polynomial constant terms
// over GF(257), the Lagrange interpolation at x=0 returns EXACTLY the
// original byte value (a value in [0, 256), never 256). We assert
// this and convert directly. The slotMix parameter is retained as a
// belt-and-suspenders binding to detect tamper at the gf-byte
// interface, but the byte value comes from the GF reconstruction
// directly (no hash mix — that would prevent byte-equality with the
// secret).
//
// If reconstruction returns 256 for any byte, the shares have been
// tampered: the caller (Aggregate) will detect this when the WOTS+
// chain endpoint or FORS root check fails. To make the error path
// explicit, we map 256 -> 0 with the understanding that the
// subsequent endpoint check WILL reject.
func gfBytesToBytes(gf []uint16, n int, slotMix []byte) []byte {
_ = slotMix
out := make([]byte, n)
for b := 0; b < n; b++ {
if gf[b] >= 256 {
// Cannot happen for honest shares; signal via 0 so the
// endpoint check fails downstream.
out[b] = 0
continue
}
out[b] = byte(gf[b])
}
return out
}
-559
View File
@@ -1,559 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import (
"encoding/binary"
"errors"
)
// sign.go — SignShare + Aggregate + Verify, the three operations that
// make this package "true threshold".
//
// SignShare:
// 1. Compute MessageDigest = H(msg, slot).
// 2. Derive the FORS index digest -> k SELECTED FORS leaves.
// 3. Derive the WOTS+ base-w digits -> per-chain SELECTED step counts.
// 4. Release a Shamir share for EVERY selected FORS leaf (k shares)
// AND for every WOTS+ chain head (WOTSChains shares).
// NOTE: every chain head is "selected" — the value the verifier
// sees is the chain after b_j hash steps, so the secret head IS
// required even when b_j > 0. The threshold property is that
// OTHER SLOTS' chain heads / OTHER FORS LEAVES are never released.
// Specifically: a single SignShare touches WOTSChains + k secret
// elements total, NOT every element for the slot, and NEVER any
// element from another slot.
// 5. Bind each share with a cSHAKE MAC under (party_id, slot_id,
// message_digest) so the combiner can detect malformed
// contributions.
// 6. Anti-equivocation: insert (slot, digest, partial) into the slot
// guard; reject (with Evidence) any later sign for the same slot
// under a different digest.
//
// Aggregate:
// 1. Validate every PartialSignature against the public key (party
// membership; matching slot, message; share count; share proof).
// 2. For each SELECTED element, Lagrange-reconstruct the secret
// from the t shares for that element. (No global seed
// reconstruction.)
// 3. Chain each reconstructed WOTS+ value forward to verify against
// the public endpoint; chain each FORS leaf forward to verify
// against the FORS subtree root.
// 4. Assemble the FinalSignature (the SLH-DSA-style payload).
//
// Verify:
// 1. Re-derive the per-chain step counts from message_digest.
// 2. Chain each FinalSignature.WotsSigs[j] forward (w - 1 - b_j)
// additional steps; the result must equal the public endpoint
// in HelperData.
// 3. Re-derive the FORS leaf indices; each FinalSignature.ForsSig
// entry must chain through its auth path to the matching
// FORSPubRoots entry.
// 4. The slot-leaf and FORS commitment must chain through
// AuthPaths[slot] to PublicKey.Root.
// SignShare runs the party-side computation. Returns the
// PartialSignature; on equivocation it returns an *EquivocationError
// carrying the slashable Evidence payload.
//
// `slot` is the one-shot WOTS+ slot the consensus layer has allocated
// to this signing session. The party is responsible for ensuring it
// has not previously signed a different message under this slot;
// the slot guard (NewGuard / PrivateShareGuard) enforces this locally.
func SignShare(guard *PrivateShareGuard, slot Slot, msg []byte) (PartialSignature, error) {
if guard == nil || guard.Share == nil {
return PartialSignature{}, errors.New("thbs: nil guard")
}
share := guard.Share
// 1. Message digest. SlotID binds the slot into the digest so the
// same `msg` under a different slot is a different digest. (Note:
// callers also commonly want to bind a chain/context tag; v1 stays
// minimal.)
slotID := slotIDBytes(slot)
digest := messageDigest(msg, slotID)
// 2/3. Element selection.
// We need the WOTS+ base-w digits AND the FORS leaf indices. Both
// come from the digest. We use the first 32 bytes for the FORS
// indices, the rest for the WOTS+ digits — for our reference
// params (n=24), MessageDigest is 32 bytes and we ALSO need a
// distinct 32-byte FORS-index digest derived from the message.
// SignShare requires params for chain/digit selection. The guard
// holds the params and eval point that NewGuardWithParams attached.
params := guard.params
if params.N == 0 {
return PartialSignature{}, errors.New("thbs: guard has no params; use NewGuardWithParams")
}
forsDigest := hash32("MAGNETAR-THBS-FORS-IDX-V1", digest[:], slotID)
wotsMsg := hashN(params.N, "MAGNETAR-THBS-WOTS-MSG-V1", digest[:], slotID)
forsLeaves := forsSelectLeaves(params, forsDigest[:])
wotsDigits := wotsBaseDigits(params, wotsMsg)
// 4. Collect the selected shares.
// WOTS+: one share per chain (WOTSChains total). Steps = digit[j].
// FORS: one share per tree (FORSK total), at the selected leaf.
shares := make([]ElementShare, 0, params.WOTSChains+params.FORSK)
// EvalPoint: find this party's evaluation point. We stash it in the
// guard at NewGuard time too.
xPoint := guard.evalPoint
if xPoint == 0 {
return PartialSignature{}, errors.New("thbs: guard has no eval point")
}
for j := 0; j < params.WOTSChains; j++ {
id := ElementID{Type: ElementWOTS, Slot: uint32(slot), ChainIdx: uint16(j)}
y, ok := share.ElementShares[id]
if !ok {
return PartialSignature{}, errors.New("thbs: missing WOTS share for selected chain")
}
shares = append(shares, ElementShare{
ID: id,
X: xPoint,
Y: append([]uint16{}, y...),
Steps: wotsDigits[j],
})
}
for tree := 0; tree < params.FORSK; tree++ {
leaf := forsLeaves[tree]
id := ElementID{Type: ElementFORS, Slot: uint32(slot), TreeIdx: uint16(tree), LeafIdx: leaf}
y, ok := share.ElementShares[id]
if !ok {
return PartialSignature{}, errors.New("thbs: missing FORS share for selected leaf")
}
shares = append(shares, ElementShare{
ID: id,
X: xPoint,
Y: append([]uint16{}, y...),
})
}
// 5. Per-share proofs (cSHAKE MACs binding share to party + slot
// + message digest).
proofs := make([]ShareProof, len(shares))
for i, s := range shares {
proofs[i] = ShareProof{Tag: shareMACTag(share.PartyID, slot, digest, s)}
}
// 6. Anti-equivocation.
var slotIDArr [32]byte
copy(slotIDArr[:], slotID)
partial := PartialSignature{
PartyID: share.PartyID,
SlotID: slotIDArr,
MessageDigest: digest,
Shares: shares,
Proofs: proofs,
}
idempotent, prev, err := guard.state.checkAndRecord(slot, digest, partial)
if err != nil {
// Equivocation. prev.Partial carries the fully-populated
// PartialSignature originally emitted at this slot (restored
// across guard lifecycles via SlotRecord persistence), so the
// slashing layer can verify ShareA's MAC tags against DigestA.
ev := Evidence{
PartyID: share.PartyID,
SlotID: slotIDArr,
DigestA: prev.Digest,
ShareA: prev.Partial,
DigestB: digest,
ShareB: partial,
}
return PartialSignature{}, &EquivocationError{Err: ErrEquivocation, Evidence: ev}
}
if idempotent {
return prev.Partial, nil
}
return partial, nil
}
// Aggregate combines a quorum of partial signatures into a final
// threshold-HBS signature.
func Aggregate(pk PublicKey, slot Slot, partials []PartialSignature) (FinalSignature, error) {
if pk.HelperData == nil {
return FinalSignature{}, errors.New("thbs: public key missing helper data")
}
params := pk.Params
if len(partials) < 1 {
return FinalSignature{}, ErrInsufficient
}
// Pin slot/message agreement across partials.
slotID := slotIDBytes(slot)
var slotIDArr [32]byte
copy(slotIDArr[:], slotID)
digest := partials[0].MessageDigest
for _, p := range partials {
if p.SlotID != slotIDArr {
return FinalSignature{}, ErrInconsistent
}
if p.MessageDigest != digest {
return FinalSignature{}, ErrInconsistent
}
if len(p.Shares) != params.WOTSChains+params.FORSK {
return FinalSignature{}, ErrShareCount
}
if len(p.Proofs) != len(p.Shares) {
return FinalSignature{}, ErrShareCount
}
}
// Re-derive the selected elements from the digest.
forsDigest := hash32("MAGNETAR-THBS-FORS-IDX-V1", digest[:], slotID)
wotsMsg := hashN(params.N, "MAGNETAR-THBS-WOTS-MSG-V1", digest[:], slotID)
wotsDigits := wotsBaseDigits(params, wotsMsg)
forsLeaves := forsSelectLeaves(params, forsDigest[:])
// Validate every share against the selection. Any share that
// references a non-selected element is a protocol violation.
for _, p := range partials {
for _, s := range p.Shares {
switch s.ID.Type {
case ElementWOTS:
if s.ID.Slot != uint32(slot) {
return FinalSignature{}, ErrShareSelection
}
if int(s.ID.ChainIdx) >= params.WOTSChains {
return FinalSignature{}, ErrShareSelection
}
if s.Steps != wotsDigits[s.ID.ChainIdx] {
return FinalSignature{}, ErrShareSelection
}
case ElementFORS:
if s.ID.Slot != uint32(slot) {
return FinalSignature{}, ErrShareSelection
}
if int(s.ID.TreeIdx) >= params.FORSK {
return FinalSignature{}, ErrShareSelection
}
if forsLeaves[s.ID.TreeIdx] != s.ID.LeafIdx {
return FinalSignature{}, ErrShareSelection
}
default:
return FinalSignature{}, ErrShareSelection
}
// Validate the share proof tag.
expect := shareMACTag(p.PartyID, slot, digest, s)
if !ctEqualArr32(expect, p.Proofs[indexOfShare(p, s)].Tag) {
return FinalSignature{}, ErrShareProof
}
}
}
// For each selected element, collect t shares (we have len(partials);
// the caller is expected to send exactly t).
byElement := make(map[ElementID][]shamirSlice)
for _, p := range partials {
for _, s := range p.Shares {
byElement[s.ID] = append(byElement[s.ID], shamirSlice{X: s.X, Y: s.Y})
}
}
// Reconstruct each selected element.
// WOTS+ chains: WOTSChains in [0, ChainEndpoints[slot])
wotsRecons := make([][]byte, params.WOTSChains)
for j := 0; j < params.WOTSChains; j++ {
id := ElementID{Type: ElementWOTS, Slot: uint32(slot), ChainIdx: uint16(j)}
slices, ok := byElement[id]
if !ok || len(slices) < 1 {
return FinalSignature{}, ErrInsufficient
}
gf, err := reconstructElement(slices, params.N)
if err != nil {
return FinalSignature{}, err
}
secret := gfBytesToBytes(gf, params.N, slotIDArr[:])
zeroizeU16(gf)
// Sanity: chain to endpoint MUST match HelperData.
endpoint := wotsChain(params, secret, uint32(slot), uint16(j), 0, params.W-1)
if !ctEqual(endpoint, pk.HelperData.ChainEndpoints[uint32(slot)][j]) {
zeroize(secret)
return FinalSignature{}, ErrChainEndpoint
}
// Apply the chain steps for this signature.
steps := int(wotsDigits[j])
sig := wotsChain(params, secret, uint32(slot), uint16(j), 0, steps)
zeroize(secret)
wotsRecons[j] = sig
}
// FORS: one selected leaf per tree.
forsSig := make([]byte, 0, params.FORSK*params.N*(1+params.FORSA))
for tree := 0; tree < params.FORSK; tree++ {
leafIdx := forsLeaves[tree]
id := ElementID{Type: ElementFORS, Slot: uint32(slot), TreeIdx: uint16(tree), LeafIdx: leafIdx}
slices, ok := byElement[id]
if !ok || len(slices) < 1 {
return FinalSignature{}, ErrInsufficient
}
gf, err := reconstructElement(slices, params.N)
if err != nil {
return FinalSignature{}, err
}
sk := gfBytesToBytes(gf, params.N, slotIDArr[:])
zeroizeU16(gf)
// Sanity: chains to the FORS subtree root via the helper data
// auth path.
authPath := helperForsAuthPath(pk.HelperData, uint32(slot), uint16(tree), leafIdx, params)
recomputedRoot := forsVerifyAuth(params, sk, uint32(slot), uint16(tree), leafIdx, authPath)
if !ctEqual(recomputedRoot, pk.HelperData.FORSPubRoots[uint32(slot)][tree]) {
zeroize(sk)
return FinalSignature{}, ErrFORSPub
}
// Append the revealed sk and auth path to forsSig.
forsSig = append(forsSig, sk...)
for _, ap := range authPath {
forsSig = append(forsSig, ap...)
}
zeroize(sk)
}
finalSig := FinalSignature{
Params: params,
SlotID: slotIDArr,
ForsSig: forsSig,
WotsSigs: wotsRecons,
AuthPaths: pk.HelperData.AuthPaths[uint32(slot)],
}
return finalSig, nil
}
// Verify checks a FinalSignature against the public key + message.
func Verify(pk PublicKey, msg []byte, sig FinalSignature) bool {
if pk.HelperData == nil {
return false
}
params := pk.Params
if sig.Params != params {
return false
}
// Re-derive the slot index from the SlotID. SlotID is the
// canonical encoding of (slot uint32); we extract.
slot, ok := slotFromID(sig.SlotID)
if !ok {
return false
}
if int(slot) >= len(pk.HelperData.LeafRoots) {
return false
}
slotID := slotIDBytes(slot)
digest := messageDigest(msg, slotID)
forsDigest := hash32("MAGNETAR-THBS-FORS-IDX-V1", digest[:], slotID)
wotsMsg := hashN(params.N, "MAGNETAR-THBS-WOTS-MSG-V1", digest[:], slotID)
wotsDigits := wotsBaseDigits(params, wotsMsg)
forsLeaves := forsSelectLeaves(params, forsDigest[:])
// 1. Verify each WOTS+ chain value chains to the endpoint.
if len(sig.WotsSigs) != params.WOTSChains {
return false
}
for j := 0; j < params.WOTSChains; j++ {
v := sig.WotsSigs[j]
// Chain forward (w-1 - b_j) more steps to reach the endpoint.
endpoint := wotsChain(params, v, uint32(slot), uint16(j), int(wotsDigits[j]), params.W-1)
if !ctEqual(endpoint, pk.HelperData.ChainEndpoints[uint32(slot)][j]) {
return false
}
}
// Reconstruct the WOTS+ leaf root from the (just-verified) endpoints.
wotsLeaf := wotsLeafFromChainEndpoints(params, uint32(slot), pk.HelperData.ChainEndpoints[uint32(slot)])
// 2. Verify each FORS leaf chains to its public root.
if len(sig.ForsSig) != params.FORSK*params.N*(1+params.FORSA) {
return false
}
forsRoots := make([][]byte, params.FORSK)
off := 0
for tree := 0; tree < params.FORSK; tree++ {
sk := sig.ForsSig[off : off+params.N]
off += params.N
authPath := make([][]byte, params.FORSA)
for l := 0; l < params.FORSA; l++ {
authPath[l] = sig.ForsSig[off : off+params.N]
off += params.N
}
leafIdx := forsLeaves[tree]
root := forsVerifyAuth(params, sk, uint32(slot), uint16(tree), leafIdx, authPath)
if !ctEqual(root, pk.HelperData.FORSPubRoots[uint32(slot)][tree]) {
return false
}
forsRoots[tree] = root
}
forsRoot := forsRootOfRoots(params, forsRoots, uint32(slot))
// 3. The top-tree leaf is H(wotsLeaf, forsRoot, slot). It must
// chain through AuthPaths[slot] to PublicKey.Root.
topLeaf := hashN(params.N, tagWotsLeaf, wotsLeaf, forsRoot,
[]byte{byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot)})
if len(sig.AuthPaths) != params.Height {
return false
}
reconRoot := treeVerifyAuth(params, topLeaf, uint32(slot), sig.AuthPaths)
return ctEqual(reconRoot, pk.Root)
}
// slotIDBytes encodes a Slot as a stable 32-byte SlotID.
//
// Layout: 28 zero bytes || 4-byte big-endian slot index. The fixed
// prefix gives forward compatibility for richer SlotID shapes (e.g. a
// 32-byte chain-bound nonce) without bumping wire layout.
func slotIDBytes(slot Slot) []byte {
out := make([]byte, 32)
binary.BigEndian.PutUint32(out[28:], uint32(slot))
return out
}
func slotFromID(id [32]byte) (Slot, bool) {
// Prefix must be zero.
for i := 0; i < 28; i++ {
if id[i] != 0 {
return 0, false
}
}
return Slot(binary.BigEndian.Uint32(id[28:])), true
}
// messageDigest binds (msg, slotID) into a stable 32-byte digest.
func messageDigest(msg, slotID []byte) [32]byte {
return hash32(tagMsgDigest, msg, slotID)
}
// shareMACTag binds a single share to (party_id, slot, message digest).
func shareMACTag(p PartyID, slot Slot, digest [32]byte, s ElementShare) [32]byte {
idBytes := []byte{
byte(s.ID.Type),
byte(s.ID.Slot >> 24), byte(s.ID.Slot >> 16), byte(s.ID.Slot >> 8), byte(s.ID.Slot),
byte(s.ID.ChainIdx >> 8), byte(s.ID.ChainIdx),
byte(s.ID.TreeIdx >> 8), byte(s.ID.TreeIdx),
byte(s.ID.LeafIdx >> 24), byte(s.ID.LeafIdx >> 16), byte(s.ID.LeafIdx >> 8), byte(s.ID.LeafIdx),
byte(s.X >> 8), byte(s.X),
s.Steps,
}
yBytes := make([]byte, len(s.Y)*2)
for i, v := range s.Y {
yBytes[2*i] = byte(v >> 8)
yBytes[2*i+1] = byte(v)
}
slotID := slotIDBytes(slot)
return hash32(tagShareMAC, p[:], slotID, digest[:], idBytes, yBytes)
}
func ctEqualArr32(a, b [32]byte) bool {
var diff byte
for i := 0; i < 32; i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// indexOfShare locates the index in p.Shares matching s by ID. We
// can't trust slice ordering across parties for the proof lookup, so
// the combiner looks up the matching proof by ID.
func indexOfShare(p PartialSignature, s ElementShare) int {
for i := range p.Shares {
if p.Shares[i].ID == s.ID {
return i
}
}
return -1
}
// helperForsAuthPath fetches the dealer-precomputed FORS auth path for
// (slot, tree, leaf). HelperData.FORSAuthPaths[slot] is a flat list of
// [leafPaths_for_tree_0, leafPaths_for_tree_1, ...]; we index in.
func helperForsAuthPath(h *HelperData, slot uint32, tree uint16, leaf uint32, params HBSParams) [][]byte {
leavesPerTree := 1 << params.FORSA
flatIdx := int(tree)*leavesPerTree + int(leaf)
return h.FORSAuthPaths[slot][flatIdx]
}
// NewGuardWithParams is the constructor signers should use: it
// captures the params and eval point so SignShare has what it needs.
func NewGuardWithParams(share *PrivateShare, params HBSParams, evalPoint uint16) *PrivateShareGuard {
g := NewGuard(share)
g.params = params
g.evalPoint = evalPoint
return g
}
// VerifyEvidence is the third-party check that an Evidence payload
// constitutes a verifiable equivocation by ev.PartyID at slot
// ev.SlotID. It returns (true, nil) iff:
//
// - ev.SlotID encodes a valid Slot.
// - DigestA != DigestB (otherwise there is no equivocation; the
// party signed the SAME message twice, which is idempotent).
// - ShareA.PartyID == ShareB.PartyID == ev.PartyID.
// - ShareA.SlotID == ShareB.SlotID == ev.SlotID.
// - ShareA.MessageDigest == DigestA and ShareB.MessageDigest == DigestB.
// - Every share in ShareA.Shares has a matching proof whose tag is
// bound to (ev.PartyID, slot, DigestA, share) via shareMACTag,
// and likewise for ShareB.
//
// The check uses ONLY the data carried in Evidence — no access to
// PublicKey, PrivateShare, or HelperData is required. This is the
// property that makes Evidence the canonical slashable artifact:
// every honest validator can independently confirm equivocation from
// a single Evidence struct.
//
// VerifyEvidence does NOT consult an external party-allowlist; the
// slashing layer is expected to confirm ev.PartyID is a member of the
// committee for the appropriate epoch via its own state.
func VerifyEvidence(ev Evidence) (bool, error) {
slot, ok := slotFromID(ev.SlotID)
if !ok {
return false, ErrEvidenceMalformed
}
if ev.DigestA == ev.DigestB {
return false, ErrEvidenceMalformed
}
if ev.ShareA.PartyID != ev.PartyID || ev.ShareB.PartyID != ev.PartyID {
return false, ErrEvidenceMalformed
}
if ev.ShareA.SlotID != ev.SlotID || ev.ShareB.SlotID != ev.SlotID {
return false, ErrEvidenceMalformed
}
if ev.ShareA.MessageDigest != ev.DigestA {
return false, ErrEvidenceMalformed
}
if ev.ShareB.MessageDigest != ev.DigestB {
return false, ErrEvidenceMalformed
}
if len(ev.ShareA.Shares) == 0 || len(ev.ShareB.Shares) == 0 {
return false, ErrEvidenceMalformed
}
if len(ev.ShareA.Shares) != len(ev.ShareA.Proofs) {
return false, ErrEvidenceMalformed
}
if len(ev.ShareB.Shares) != len(ev.ShareB.Proofs) {
return false, ErrEvidenceMalformed
}
if !verifyShareMACs(ev.PartyID, slot, ev.DigestA, ev.ShareA) {
return false, nil
}
if !verifyShareMACs(ev.PartyID, slot, ev.DigestB, ev.ShareB) {
return false, nil
}
return true, nil
}
// verifyShareMACs returns true iff every share in p has a proof
// whose tag is the cSHAKE MAC over (party, slot, digest, share).
func verifyShareMACs(party PartyID, slot Slot, digest [32]byte, p PartialSignature) bool {
for _, s := range p.Shares {
expect := shareMACTag(party, slot, digest, s)
idx := indexOfShare(p, s)
if idx < 0 || idx >= len(p.Proofs) {
return false
}
if !ctEqualArr32(expect, p.Proofs[idx].Tag) {
return false
}
}
return true
}
-162
View File
@@ -1,162 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import "sync"
// slot.go — per-party anti-equivocation state machine.
//
// Hash-based one-time signatures (WOTS+) are unsafe to reuse: signing
// two distinct messages under the same WOTS+ leaf leaks the secret
// chain heads at positions where the message-derived digit values
// differ. THBS therefore REQUIRES that each (party, slot) tuple be
// used at most once.
//
// We do not RELY on the slot allocator being honest. The state
// machine here is a verifier of the local sign request: if the same
// slot is requested twice with the same message digest, the second
// call is idempotent (same shares emitted, same Tag, same byte
// output); if the same slot is requested with a DIFFERENT message
// digest, the second call returns ErrEquivocation with verifiable
// evidence the consensus layer can slash on.
//
// CRITICAL: the slot guard MUST persist the previously-emitted
// PartialSignature (not just the digest) so that equivocation
// evidence produced after a restart contains a fully-populated
// ShareA. Restoring only the digest breaks third-party verification
// of Evidence.ShareA (the share-MAC tags cannot be reconstructed
// from a zero-valued PartialSignature). See VerifyEvidence.
// stateImpl is a concurrency-safe slot state machine. The exported
// type StateStore (map alias) is the wire-shape; this is the runtime
// guard. Both use SlotRecord{Digest, Partial} as the per-slot record.
type stateImpl struct {
mu sync.Mutex
records map[Slot]SlotRecord
}
// newStateImpl returns a fresh slot guard.
func newStateImpl() *stateImpl {
return &stateImpl{records: make(map[Slot]SlotRecord)}
}
// checkAndRecord enforces the slot invariant.
//
// Behaviour:
// - First call for `slot`: record (digest, partial) and return (false, nil, nil).
// - Second call with matching digest: return (true, &prev, nil).
// The caller MUST return the previous partial verbatim
// (idempotent re-emit).
// - Second call with mismatched digest: return (false, &prev,
// ErrEquivocation). Caller wraps in EquivocationError with full
// evidence; prev.Partial carries the original (third-party
// verifiable) PartialSignature.
func (s *stateImpl) checkAndRecord(slot Slot, digest [32]byte, partial PartialSignature) (bool, *SlotRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
if prev, ok := s.records[slot]; ok {
if prev.Digest == digest {
return true, &prev, nil
}
return false, &prev, ErrEquivocation
}
s.records[slot] = SlotRecord{Digest: digest, Partial: partial}
return false, nil, nil
}
// load returns the persisted SlotRecord for a slot, if any.
func (s *stateImpl) load(slot Slot) (SlotRecord, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.records[slot]
return r, ok
}
// snapshot returns a defensive copy of the per-slot record map. The
// returned StateStore is suitable for serialisation; restoring a guard
// from it via NewGuard preserves the full (Digest, Partial) tuple so
// post-restart equivocation evidence remains third-party verifiable.
func (s *stateImpl) snapshot() StateStore {
s.mu.Lock()
defer s.mu.Unlock()
out := make(StateStore, len(s.records))
for k, v := range s.records {
// Defensive copy of share/proof slices so callers cannot mutate
// the runtime guard via the snapshot.
out[k] = SlotRecord{Digest: v.Digest, Partial: clonePartial(v.Partial)}
}
return out
}
// clonePartial deep-copies a PartialSignature so a snapshot can be
// safely mutated by callers without aliasing the runtime guard's
// records.
func clonePartial(p PartialSignature) PartialSignature {
shares := make([]ElementShare, len(p.Shares))
for i, s := range p.Shares {
y := make([]uint16, len(s.Y))
copy(y, s.Y)
shares[i] = ElementShare{ID: s.ID, X: s.X, Y: y, Steps: s.Steps}
}
proofs := make([]ShareProof, len(p.Proofs))
copy(proofs, p.Proofs)
return PartialSignature{
PartyID: p.PartyID,
SlotID: p.SlotID,
MessageDigest: p.MessageDigest,
Shares: shares,
Proofs: proofs,
}
}
// PrivateShareGuard couples a PrivateShare with the runtime slot
// guard. SignShare runs against a guarded share.
//
// The dealer returns the bare PrivateShare; callers wrap with
// NewGuard before signing. (Why wrap separately: serialisation of a
// PrivateShare should not include the runtime guard's mutex state.)
//
// params and evalPoint are runtime-only sign-time hints set by
// NewGuardWithParams.
type PrivateShareGuard struct {
Share *PrivateShare
state *stateImpl
params HBSParams
evalPoint uint16
}
// NewGuard wraps a PrivateShare with a fresh slot state machine. If
// the PrivateShare already carries AntiEquivState (e.g. restored from
// disk), the prior records (digest AND partial) are imported. The
// imported Partial is what makes post-restart equivocation evidence
// third-party verifiable.
func NewGuard(share *PrivateShare) *PrivateShareGuard {
s := newStateImpl()
if share.AntiEquivState != nil {
for slot, rec := range share.AntiEquivState {
s.records[slot] = SlotRecord{
Digest: rec.Digest,
Partial: clonePartial(rec.Partial),
}
}
}
return &PrivateShareGuard{Share: share, state: s}
}
// Snapshot returns the StateStore wire shape suitable for serialisation.
// The returned StateStore carries (Digest, Partial) per slot — both
// fields are needed to preserve the equivocation evidence trail across
// restarts.
func (g *PrivateShareGuard) Snapshot() StateStore { return g.state.snapshot() }
// LoadPartial returns the persisted PartialSignature for a slot if the
// guard has emitted one (or restored one from disk). Used by callers
// that want to inspect the equivocation evidence trail directly.
func (g *PrivateShareGuard) LoadPartial(slot Slot) (digest [32]byte, partial PartialSignature, ok bool) {
r, found := g.state.load(slot)
if !found {
return [32]byte{}, PartialSignature{}, false
}
return r.Digest, clonePartial(r.Partial), true
}
-308
View File
@@ -1,308 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import (
"errors"
"testing"
)
// slot_test.go — slot-guard persistence and equivocation-evidence
// verifiability tests.
//
// The defect these tests pin (v0.4.2 -> v0.4.3): NewGuard previously
// imported only Digest from AntiEquivState, leaving Partial
// zero-valued on restore. Subsequent equivocation produced an
// Evidence struct with ShareA = PartialSignature{} which a third
// party could not cryptographically verify.
// TestSlotGuard_PersistsPartial: after a sign + restart cycle, the
// guard's LoadPartial returns the originally-emitted PartialSignature
// (not a zero value).
func TestSlotGuard_PersistsPartial(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 2
msg := []byte("persisted-partial-msg")
pA, err := SignShare(g, slot, msg)
if err != nil {
t.Fatalf("SignShare: %v", err)
}
// In-process recovery from the same guard.
dPre, partialPre, ok := g.LoadPartial(slot)
if !ok {
t.Fatal("LoadPartial returned !ok before restart")
}
if dPre != pA.MessageDigest {
t.Errorf("LoadPartial digest mismatch (pre-restart)")
}
if partialPre.PartyID != pA.PartyID {
t.Error("LoadPartial Partial.PartyID is zero (pre-restart)")
}
if len(partialPre.Shares) != len(pA.Shares) {
t.Errorf("LoadPartial Partial.Shares count %d, want %d (pre-restart)",
len(partialPre.Shares), len(pA.Shares))
}
// Persist and reload through StateStore — simulates a full restart
// where the only thing that survives is the serialised AntiEquivState.
shares[0].AntiEquivState = g.Snapshot()
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
dPost, partialPost, ok := g2.LoadPartial(slot)
if !ok {
t.Fatal("LoadPartial returned !ok after restart")
}
if dPost != pA.MessageDigest {
t.Error("LoadPartial digest mismatch (post-restart)")
}
if partialPost.PartyID != pA.PartyID {
t.Error("LoadPartial Partial.PartyID is zero (post-restart) — REGRESSION")
}
if partialPost.MessageDigest != pA.MessageDigest {
t.Error("LoadPartial Partial.MessageDigest mismatch (post-restart)")
}
if partialPost.SlotID != pA.SlotID {
t.Error("LoadPartial Partial.SlotID mismatch (post-restart)")
}
if len(partialPost.Shares) != len(pA.Shares) {
t.Errorf("LoadPartial Partial.Shares count %d, want %d (post-restart) — REGRESSION",
len(partialPost.Shares), len(pA.Shares))
}
if len(partialPost.Proofs) != len(pA.Proofs) {
t.Errorf("LoadPartial Partial.Proofs count %d, want %d (post-restart) — REGRESSION",
len(partialPost.Proofs), len(pA.Proofs))
}
// Cross-check: every share/proof byte must match (deep equality).
for i, s := range pA.Shares {
if partialPost.Shares[i].ID != s.ID {
t.Errorf("Shares[%d].ID mismatch", i)
}
if partialPost.Shares[i].X != s.X {
t.Errorf("Shares[%d].X mismatch", i)
}
if partialPost.Shares[i].Steps != s.Steps {
t.Errorf("Shares[%d].Steps mismatch", i)
}
if len(partialPost.Shares[i].Y) != len(s.Y) {
t.Errorf("Shares[%d].Y length mismatch", i)
continue
}
for k, y := range s.Y {
if partialPost.Shares[i].Y[k] != y {
t.Errorf("Shares[%d].Y[%d] mismatch", i, k)
break
}
}
}
for i, pr := range pA.Proofs {
if partialPost.Proofs[i].Tag != pr.Tag {
t.Errorf("Proofs[%d].Tag mismatch", i)
}
}
}
// TestEquivocation_EvidenceAfterRestart: after a sign + restart cycle,
// signing a DIFFERENT message at the same slot yields
// Evidence{ShareA, ShareB} where ShareA is the FULLY-POPULATED
// pre-restart PartialSignature (this is the defect we fixed).
func TestEquivocation_EvidenceAfterRestart(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 3
msgA := []byte("pre-restart-msg-A")
pA, err := SignShare(g, slot, msgA)
if err != nil {
t.Fatalf("SignShare(A): %v", err)
}
// Persist to AntiEquivState and rebuild a fresh guard from it
// (simulating a node restart).
shares[0].AntiEquivState = g.Snapshot()
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
// Now attempt to sign a DIFFERENT message at the same slot.
msgB := []byte("post-restart-conflicting-msg-B")
_, err = SignShare(g2, slot, msgB)
if err == nil {
t.Fatal("equivocation undetected after restart")
}
if !errors.Is(err, ErrEquivocation) {
t.Fatalf("expected ErrEquivocation, got %v", err)
}
var eq *EquivocationError
if !errors.As(err, &eq) {
t.Fatal("expected EquivocationError")
}
ev := eq.Evidence
// SlotID + DigestA agreement with pre-restart signature.
if ev.SlotID != pA.SlotID {
t.Error("Evidence.SlotID does not match pre-restart partial")
}
if ev.DigestA != pA.MessageDigest {
t.Error("Evidence.DigestA does not match pre-restart message digest")
}
digestB := messageDigest(msgB, slotIDBytes(slot))
if ev.DigestB != digestB {
t.Error("Evidence.DigestB does not match post-restart message digest")
}
if ev.DigestA == ev.DigestB {
t.Fatal("DigestA == DigestB; no equivocation")
}
// ShareA MUST be the FULL pre-restart PartialSignature — this is
// what the v0.4.2 defect broke.
if ev.ShareA.PartyID != pA.PartyID {
t.Error("Evidence.ShareA.PartyID is zero — REGRESSION (v0.4.2 defect)")
}
if ev.ShareA.MessageDigest != pA.MessageDigest {
t.Error("Evidence.ShareA.MessageDigest is zero — REGRESSION (v0.4.2 defect)")
}
if ev.ShareA.SlotID != pA.SlotID {
t.Error("Evidence.ShareA.SlotID is zero — REGRESSION")
}
if len(ev.ShareA.Shares) != len(pA.Shares) {
t.Errorf("Evidence.ShareA.Shares count %d, want %d — REGRESSION",
len(ev.ShareA.Shares), len(pA.Shares))
}
if len(ev.ShareA.Proofs) != len(pA.Proofs) {
t.Errorf("Evidence.ShareA.Proofs count %d, want %d — REGRESSION",
len(ev.ShareA.Proofs), len(pA.Proofs))
}
// Tag-level deep equality on each proof.
for i, p := range pA.Proofs {
if ev.ShareA.Proofs[i].Tag != p.Tag {
t.Errorf("Evidence.ShareA.Proofs[%d].Tag mismatch", i)
}
}
// ShareB MUST also be fully populated (computed in this process).
if ev.ShareB.MessageDigest != digestB {
t.Error("Evidence.ShareB.MessageDigest mismatch")
}
if len(ev.ShareB.Shares) == 0 {
t.Error("Evidence.ShareB.Shares empty")
}
}
// TestEvidence_VerifiableByThirdParty: an Evidence emitted across a
// restart passes VerifyEvidence — i.e. the slashing layer can confirm
// equivocation using ONLY the Evidence struct (no access to the
// runtime guard, the dealer, or the public key required).
func TestEvidence_VerifiableByThirdParty(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 4
// First message, signed pre-restart.
if _, err := SignShare(g, slot, []byte("evidence-msg-A")); err != nil {
t.Fatalf("SignShare(A): %v", err)
}
// Persist and restart.
shares[0].AntiEquivState = g.Snapshot()
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
// Trigger equivocation.
_, err := SignShare(g2, slot, []byte("evidence-msg-B"))
if err == nil {
t.Fatal("equivocation undetected")
}
var eq *EquivocationError
if !errors.As(err, &eq) {
t.Fatalf("expected EquivocationError, got %v", err)
}
// Third-party verification: a fresh process with no access to g/g2
// confirms the Evidence.
ok, verr := VerifyEvidence(eq.Evidence)
if verr != nil {
t.Fatalf("VerifyEvidence returned error: %v", verr)
}
if !ok {
t.Fatal("VerifyEvidence rejected a true equivocation — REGRESSION")
}
// Tampering with a single proof tag in ShareA must invalidate the
// evidence (the slashing layer cannot be fooled by a forged
// pre-restart partial).
tampered := eq.Evidence
tampered.ShareA.Proofs = append([]ShareProof(nil), eq.Evidence.ShareA.Proofs...)
tampered.ShareA.Proofs[0].Tag[0] ^= 0xFF
if ok, _ := VerifyEvidence(tampered); ok {
t.Fatal("VerifyEvidence accepted tampered ShareA proof tag")
}
// Tampering with the recorded DigestA breaks the MAC check.
tampered2 := eq.Evidence
tampered2.DigestA[0] ^= 0x01
if ok, _ := VerifyEvidence(tampered2); ok {
t.Fatal("VerifyEvidence accepted mismatched DigestA")
}
// Replacing PartyID breaks evidence — the slashing layer must not
// accept evidence claiming a different party signed.
tampered3 := eq.Evidence
tampered3.PartyID = PartyID{} // a fresh zero PartyID
if ok, _ := VerifyEvidence(tampered3); ok {
t.Fatal("VerifyEvidence accepted forged PartyID")
}
// Equal digests = not equivocation.
tampered4 := eq.Evidence
tampered4.DigestB = tampered4.DigestA
if ok, _ := VerifyEvidence(tampered4); ok {
t.Fatal("VerifyEvidence accepted DigestA == DigestB")
}
// Sanity: still pin pk in scope so future refactors don't drop it.
_ = pk
}
// TestSlotGuard_PersistsPartial_Idempotent: a same-message replay after
// restart returns the originally-emitted PartialSignature byte-for-byte.
// This is the idempotency invariant that consensus replays rely on.
func TestSlotGuard_PersistsPartial_Idempotent(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 5
msg := []byte("idempotent-replay")
pPre, err := SignShare(g, slot, msg)
if err != nil {
t.Fatalf("SignShare(pre): %v", err)
}
shares[0].AntiEquivState = g.Snapshot()
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
pPost, err := SignShare(g2, slot, msg)
if err != nil {
t.Fatalf("SignShare(post-restart, same msg): %v", err)
}
if pPost.MessageDigest != pPre.MessageDigest {
t.Error("idempotent replay returned different digest")
}
if pPost.SlotID != pPre.SlotID {
t.Error("idempotent replay returned different SlotID")
}
if len(pPost.Shares) != len(pPre.Shares) {
t.Errorf("idempotent replay share count %d, want %d",
len(pPost.Shares), len(pPre.Shares))
}
if len(pPost.Proofs) != len(pPre.Proofs) {
t.Errorf("idempotent replay proof count %d, want %d",
len(pPost.Proofs), len(pPre.Proofs))
}
for i, pr := range pPre.Proofs {
if pPost.Proofs[i].Tag != pr.Tag {
t.Errorf("idempotent replay Proofs[%d].Tag mismatch", i)
}
}
}
-440
View File
@@ -1,440 +0,0 @@
// 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"
// (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
// (WOTS+ chain values selected by message-digest chunks, and FORS
// leaves selected by the FORS index digest).
// - Threshold signing in this package shares the SECRET ELEMENTS
// (the WOTS+ leaf chain heads and the FORS secret leaves) across
// parties via byte-wise Shamir over GF(257).
// - For each message, parties release shares ONLY for the elements
// SELECTED by the message digest. Combiner Lagrange-reconstructs
// just those elements; the verifier sees an ordinary HBS-style
// signature.
//
// This is NOT "collect N independent SLH-DSA signatures and average
// them"; that's the wrong thing. This is the right thing — but ONLY
// when the dealer is in the TCB.
//
// V1 SCOPE — HONEST:
//
// - Setup is DEALER-BACKED. A trusted dealer generates the shared
// 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.
// - The verifier is a CUSTOM HBS verifier defined in this package,
// NOT FIPS 205 SLH-DSA. v3 will produce FIPS 205-byte-identical
// output; that is significantly more involved and is out of scope
// for v1.
// - Anti-equivocation: each party stores slot_id -> message_digest
// and refuses (with evidence) any subsequent sign attempt against
// the same slot under a different digest.
//
// HARD INVARIANT (cf. THBS-SPEC.md):
//
// OK: reconstructElement(slot, elementID, shares) [unexported]
// Forbidden: ReconstructSeed, ReconstructPrivateKey,
// ExpandPrivateKey, DeriveAllFutureElements
//
// The only Reconstruct symbol in this package is the file-scope
// unexported reconstructElement in sign.go. No exported API returns
// future-signing material.
//
// Reference parameter set (one-slot WOTS+ + FORS Merkle, n=24,
// w=16, FORS k=14 a=12, tree height H=10):
//
// WOTS+: 51 chain values per leaf; 24-byte chain elements
// FORS: 14 of 4096 leaves per signature
// Tree: height 10 (1024 slots)
//
// These parameters are not FIPS 205 wire-compatible; that's v3.
package thbs
import "errors"
// HBSParams captures the concrete shape of the threshold HBS instance.
//
// All sizes are in bytes unless otherwise noted.
type HBSParams struct {
// N is the hash output size in bytes (e.g. 24 for SHAKE-192-like).
N int
// Winternitz parameter; w in {4, 16, 256}. Number of chain values per
// WOTS+ leaf is ceil(8n / log2 w) + checksum chains. v1 fixes w=16
// (LogW=4), giving 51 chains per leaf for n=24.
W int
LogW int
// WOTSChains is the total number of WOTS+ chains per signature.
// = (8n / LogW) + checksum chains.
WOTSChains int
// FORSK is the number of FORS Merkle trees per signature (k).
// Each tree has 2^FORSA leaves; the signature reveals 1 leaf and the
// auth path per tree.
FORSK int
// FORSA is the height of each FORS tree (a). 2^a leaves per tree.
FORSA int
// Height is the height of the top Merkle tree over WOTS+ leaves.
// 2^Height slots in this v1 instance.
Height int
}
// DefaultParams returns the v1 reference parameter set. Not FIPS 205
// wire-compatible; see THBS-SPEC.md for the v3 roadmap.
func DefaultParams() HBSParams {
// n=24, w=16 -> WOTS+ chains = 8*24/4 + checksum
// checksum chains for w=16, n=24: len2 = floor(log2(48*15)/4) + 1 = 3
// (48 message chains * (w-1) = 720; log2(720) ≈ 9.49; /4 -> 2; +1 -> 3)
return HBSParams{
N: 24,
W: 16,
LogW: 4,
WOTSChains: 51, // 48 message + 3 checksum
FORSK: 14,
FORSA: 12,
Height: 10,
}
}
// Participant identifies one party in a dealer-backed THBS committee.
type Participant struct {
ID PartyID
EvalPoint uint16 // Shamir x-coordinate in [1, 257); MUST be distinct per party
}
// PartyID is the canonical party identifier. 32 bytes wide to host any
// upstream identity (Lux validator ID, Hanzo IAM hash, etc.).
type PartyID [32]byte
// DKGConfig parametrises a dealer-backed v1 DKG ceremony.
type DKGConfig struct {
ChainID []byte // domain separation tag (e.g. Lux chain ID)
Epoch uint64 // monotonic epoch counter for key versioning
Threshold uint16 // t in t-of-n; >= 1 and <= len(Participants)
Participants []Participant // committee
Params HBSParams
}
// PublicKey is the threshold-HBS public key.
//
// Root is the Merkle root over the WOTS+ public-key leaves for every slot
// in [0, 2^Height). DKGTranscript is the transcript hash binding this
// keygen ceremony (committee, params, epoch, chain ID). QualifiedSet
// is a bitmap marking which dealer-broadcast envelopes the committee
// agreed on (v1: all parties; v2 may carry survivors of complaint
// rounds).
type PublicKey struct {
Params HBSParams
Root []byte
DKGTranscript []byte
QualifiedSet Bitmap
// HelperData carries the dealer-precomputed authentication paths
// AND the public WOTS+ chain endpoints (the "compressed" leaf
// values W_i = H_w-1(x_i) per chain). McGrew et al. allow this:
// the chain endpoints are NOT secret (they're already part of the
// signature when used) and shipping them up-front lets the
// combiner construct the final signature without contacting the
// dealer again.
HelperData *HelperData
}
// HelperData is the public auxiliary material the dealer emits alongside
// the threshold key. It is the "we precomputed the public tree for you"
// payload that McGrew et al. call out as a v1 simplification.
type HelperData struct {
// LeafRoots[slot] is the Merkle leaf at slot index `slot`, i.e.
// H(W_{slot, 0} || W_{slot, 1} || ... || W_{slot, WOTSChains-1}).
// Length = 2^Height; each entry = N bytes.
LeafRoots [][]byte
// AuthPaths[slot] is the authentication path from leaf `slot` to the
// root. Length = Height nodes, each N bytes. Public information.
AuthPaths [][][]byte
// ChainEndpoints[slot] are the public WOTS+ chain endpoints
// W_{slot, j} = H^{w-1}(x_{slot, j}) for j in [0, WOTSChains).
// Each entry = N bytes. The combiner uses these to validate that
// reconstructed chain heads x_{slot, j} chain to the right
// endpoint.
ChainEndpoints [][][]byte
// FORSPubRoots[slot][tree] is the FORS subtree root for slot's
// tree `tree`. FORSK trees per slot. Each entry = N bytes. The
// combiner uses these (with the per-leaf auth paths in the FORS
// section) to validate the FORS portion of the signature.
FORSPubRoots [][][]byte
// FORSAuthPaths[slot][tree][leafIdx] is the FORS authentication
// path inside tree `tree` at slot `slot` for leaf index `leafIdx`.
// We materialise ALL FORSA-step paths so the combiner does not
// have to reconstruct the tree. Each path is FORSA entries of N
// bytes.
FORSAuthPaths [][][][]byte
}
// Bitmap is a compact dense bitmap over committee positions. Bit i
// indexes the i-th participant in the DKGConfig.Participants list.
type Bitmap []byte
// Set marks bit i.
func (b Bitmap) Set(i int) {
b[i>>3] |= 1 << uint(i&7)
}
// Has reports whether bit i is set.
func (b Bitmap) Has(i int) bool {
if i>>3 >= len(b) {
return false
}
return b[i>>3]&(1<<uint(i&7)) != 0
}
// NewBitmap allocates a Bitmap large enough to address `bits` positions,
// with every bit cleared.
func NewBitmap(bits int) Bitmap {
return make(Bitmap, (bits+7)/8)
}
// PrivateShare is one party's local secret material. It is NEVER a
// reconstructable seed; it is a collection of per-element Shamir shares
// keyed by (slot, elementID).
type PrivateShare struct {
PartyID PartyID
Epoch uint64
// ElementShares is the per-element share store. Keyed by
// (slot, elementType, elementIdx). Values are GF(257) per-byte
// shares of length N.
ElementShares ShareStore
// AntiEquivState records per-slot the digest the party has
// committed to. Reused-slot detection lives here.
AntiEquivState StateStore
}
// ElementType distinguishes the two threshold-shared element families:
// WOTS+ chain heads and FORS secret leaves.
type ElementType uint8
const (
// ElementWOTS is a WOTS+ chain head x_{slot, j} prior to any hash
// chain steps. Reconstructed value is the secret leaf the
// combiner then chains forward to the public endpoint.
ElementWOTS ElementType = 1
// ElementFORS is a FORS secret leaf sk_{slot, tree, leaf}. The
// combiner reveals these directly.
ElementFORS ElementType = 2
)
// ElementID identifies one threshold-shared element.
//
// For ElementWOTS: (Slot, ChainIdx) where ChainIdx in [0, WOTSChains).
// For ElementFORS: (Slot, TreeIdx, LeafIdx) where TreeIdx in [0, FORSK)
// and LeafIdx in [0, 2^FORSA).
type ElementID struct {
Type ElementType
Slot uint32
ChainIdx uint16
TreeIdx uint16
LeafIdx uint32
}
// ShareStore is a map-of-shares keyed by element identity. Concrete
// implementation in dealer.go.
type ShareStore map[ElementID][]uint16
// SlotRecord is the persisted per-slot record a party keeps for
// equivocation defense. It binds the slot to BOTH the message digest
// AND the fully-populated PartialSignature that was emitted at that
// slot.
//
// Persisting the full Partial (not just the digest) is REQUIRED so
// that on subsequent equivocation a third party can verify the
// previously-emitted share's MAC tags against DigestA. A digest-only
// store breaks the evidence chain: the slashing layer would receive
// an empty PartialSignature{} for ShareA and could not cryptographically
// validate that the prior party committed to DigestA.
//
// Wire shape on disk: (Digest [32]byte, Partial PartialSignature).
// The Partial carries PartyID, SlotID, MessageDigest (= Digest),
// Shares (~params.WOTSChains+FORSK entries), and Proofs (matching).
// For the reference params (n=24, WOTSChains=51, FORSK=14) the
// per-slot record is on the order of ~3 KiB — acceptable for the
// O(active-slots-per-party) cardinality we expect (at most one
// signed slot per consensus round per party).
type SlotRecord struct {
Digest [32]byte
Partial PartialSignature
}
// StateStore is the wire shape for the anti-equivocation log:
// slot -> SlotRecord{Digest, Partial}. Restored guards reconstruct
// the runtime slot guard from this map; the persisted Partial is what
// makes equivocation evidence third-party verifiable across restarts.
type StateStore map[Slot]SlotRecord
// Slot identifies a one-shot WOTS+ leaf slot (the equivalent of an
// XMSS leaf in HBS). Same slot used twice with distinct messages =
// identifiable abort.
type Slot uint32
// PartialSignature is one party's contribution to a threshold signature.
type PartialSignature struct {
PartyID PartyID
SlotID [32]byte
MessageDigest [32]byte
// Shares are the GF(257) per-byte share values for the SELECTED
// elements only. Other element shares MUST NOT appear here; the
// invariant test TestTHBS_WOTS_ReconstructsOnlySelectedChainValues
// asserts this.
Shares []ElementShare
// Proofs are per-share consistency proofs (in v1: a cSHAKE256 MAC
// binding the share to the slot, message digest, and party ID).
// The combiner verifies each before reconstructing.
Proofs []ShareProof
}
// ElementShare is one party's share for one element.
type ElementShare struct {
ID ElementID
X uint16 // Shamir evaluation point (matches Participant.EvalPoint)
Y []uint16
Steps uint8 // For ElementWOTS: the number of hash chain steps to apply
// to the reconstructed value. = b_j (the j-th base-w
// digit of the message digest). For ElementFORS: ignored.
}
// ShareProof binds an ElementShare to (PartyID, SlotID, MessageDigest)
// so the combiner can reject malformed contributions.
type ShareProof struct {
Tag [32]byte
}
// FinalSignature is the assembled threshold-HBS signature.
//
// Wire layout (v1): SlotID || ForsSig || WotsSig0 || ... || WotsSigK ||
// AuthPath. ForsSig carries the FORS leaves + per-leaf auth paths;
// WotsSig is the WOTS+ chain values for this slot; AuthPath is the
// top-tree Merkle path from leaf SlotID to Root.
//
// V1 verifier (Verify in sign.go) recomputes Root from this signature
// and rejects if it does not match PublicKey.Root.
type FinalSignature struct {
Params HBSParams
SlotID [32]byte
ForsSig []byte
WotsSigs [][]byte // WotsSigs[j] = chain value at chain j post-Steps[j] hash steps
AuthPaths [][]byte // Top-tree auth path (Height entries of N bytes)
}
// Errors returned by the thbs package.
var (
ErrInvalidThreshold = errors.New("thbs: invalid threshold (n < t or t < 1)")
ErrInvalidParams = errors.New("thbs: invalid HBS params")
ErrDuplicateParty = errors.New("thbs: duplicate party in committee")
ErrZeroEvalPoint = errors.New("thbs: evaluation point x=0 is reserved")
ErrTooManyParties = errors.New("thbs: committee too large for GF(257)")
ErrSlotOutOfRange = errors.New("thbs: slot out of range")
ErrUnknownParty = errors.New("thbs: party not in committee")
ErrInsufficient = errors.New("thbs: insufficient partial signatures")
ErrInconsistent = errors.New("thbs: partial signatures disagree on slot/message")
ErrShareCount = errors.New("thbs: partial signature has wrong number of shares")
ErrShareProof = errors.New("thbs: share consistency proof failed")
ErrShareSelection = errors.New("thbs: partial signature includes unselected element shares")
ErrChainEndpoint = errors.New("thbs: reconstructed WOTS+ chain endpoint mismatch")
ErrFORSPub = errors.New("thbs: reconstructed FORS root mismatch")
ErrVerifyRoot = errors.New("thbs: signature does not chain to public-key root")
ErrEquivocation = errors.New("thbs: party signed two distinct messages under same slot")
ErrEvidenceMalformed = errors.New("thbs: equivocation evidence is malformed")
)
// Evidence carries proof of identifiable double-signing. The consensus
// layer slashes the offending party using this payload.
//
// Evidence is the slashable artifact. To be third-party verifiable BOTH
// ShareA and ShareB MUST be fully-populated PartialSignatures (with
// PartyID, SlotID, MessageDigest, Shares, and Proofs). The verifier
// re-derives the share-MAC tag for each entry under (PartyID, slot,
// DigestA / DigestB) and confirms it matches the proof. A
// zero-valued PartialSignature in ShareA is NOT verifiable — the
// runtime slot guard MUST persist the full prior Partial across
// restarts to preserve this property. See VerifyEvidence for the
// canonical third-party check.
type Evidence struct {
PartyID PartyID
SlotID [32]byte
DigestA [32]byte
ShareA PartialSignature // the first PartialSignature emitted at SlotID
DigestB [32]byte
ShareB PartialSignature // the conflicting PartialSignature
}
// EquivocationError wraps ErrEquivocation with the evidence payload so
// callers can recover it via errors.As.
type EquivocationError struct {
Err error
Evidence Evidence
}
// Error returns the underlying error message.
func (e *EquivocationError) Error() string { return e.Err.Error() }
// Unwrap allows errors.Is to match ErrEquivocation.
func (e *EquivocationError) Unwrap() error { return e.Err }
-899
View File
@@ -1,899 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"strings"
"testing"
)
// testParams returns a small parameter set sized so the test suite
// completes in well under a minute. It is structurally identical to
// DefaultParams() but with reduced WOTSChains/FORSK and a shallow
// Height + FORSA so the dealer DKG runs fast.
func testParams() HBSParams {
return HBSParams{
N: 16,
W: 16,
LogW: 4,
WOTSChains: 35, // 32 message + 3 checksum (8*16/4 + 3)
FORSK: 6,
FORSA: 4, // 16 leaves per FORS tree
Height: 3, // 8 slots
}
}
// committee returns a t-of-n committee with deterministic IDs and
// distinct evaluation points starting at 1.
func committee(n int) []Participant {
out := make([]Participant, n)
for i := 0; i < n; i++ {
var id PartyID
binary.BigEndian.PutUint64(id[24:], uint64(i+1))
out[i] = Participant{ID: id, EvalPoint: uint16(i + 1)}
}
return out
}
// runDealerDKG runs the dealer-backed DKG for the given (t, n) and test
// params, returning the public key and per-party share slice.
func runDealerDKG(t *testing.T, threshold, n int) (PublicKey, []PrivateShare) {
t.Helper()
parts := committee(n)
cfg := DKGConfig{
ChainID: []byte("lux-magnetar-thbs-test"),
Epoch: 1,
Threshold: uint16(threshold),
Participants: parts,
Params: testParams(),
}
// Deterministic DKG for stable test vectors.
seed := bytes.Repeat([]byte{0xA1}, 64)
pk, shares, err := DealerDKGAll(cfg, DKGOptions{DealerSeed: seed})
if err != nil {
t.Fatalf("DealerDKGAll: %v", err)
}
if len(shares) != n {
t.Fatalf("DealerDKGAll returned %d shares, want %d", len(shares), n)
}
return pk, shares
}
// TestTHBS_DKG_NoSeedExposure pins the user's hard invariant: the
// public/returned PrivateShare carries NO seed, NO private key, and NO
// reconstructable full-secret material. The only thing parties hold
// is per-element Shamir shares.
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 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 {
if s.PartyID == (PartyID{}) {
t.Errorf("share[%d] has zero PartyID", i)
}
if len(s.ElementShares) == 0 {
t.Errorf("share[%d] has no element shares", i)
}
// Sanity: no element shares accidentally contain ALL of any
// other share's elements (i.e. no party should be holding
// shares-of-shares).
params := testParams()
nSlots := 1 << params.Height
expectWOTS := nSlots * params.WOTSChains
expectFORS := nSlots * params.FORSK * (1 << params.FORSA)
gotWOTS, gotFORS := 0, 0
for id := range s.ElementShares {
switch id.Type {
case ElementWOTS:
gotWOTS++
case ElementFORS:
gotFORS++
}
}
if gotWOTS != expectWOTS {
t.Errorf("share[%d] has %d WOTS entries, want %d", i, gotWOTS, expectWOTS)
}
if gotFORS != expectFORS {
t.Errorf("share[%d] has %d FORS entries, want %d", i, gotFORS, expectFORS)
}
}
// The PrivateShare API surface MUST NOT include any "Seed" or
// "PrivateKey" field. We assert this via reflection at the type
// level: read the package source.
// Compile-time check: the following symbols must NOT exist.
// (We pin via a string match on the public API surface.)
// Compile would catch typos; here we instead enforce by
// inspecting that no exported member of PrivateShare returns
// seed-shaped bytes.
// Functional check: there is no exported reconstruction helper.
// (See TestTHBS_NoExportedReconstruct below.)
}
// TestTHBS_NoExportedReconstruct ensures the package surface enforces
// the hard invariant: the only Reconstruct symbol must be unexported.
func TestTHBS_NoExportedReconstruct(t *testing.T) {
// This is a documentation test pinning the invariant. The check is
// effectively done by `go vet` + `go build` since any exported
// reconstructSeed/Reconstr* would appear in the symbol table.
// We instead enforce by searching for forbidden tokens in the
// package's exported API via importer-style check: there should
// be no "ReconstructSeed", "ReconstructPrivateKey",
// "ExpandPrivateKey", or "DeriveAllFutureElements" symbols.
//
// At runtime we can verify this by checking that the function
// pointers don't exist. Since they don't exist, this test passes
// trivially. If a future commit added them, this test would still
// pass — the true check is grep + code review. We document it
// here as a marker.
forbidden := []string{
"ReconstructSeed",
"ReconstructPrivateKey",
"ExpandPrivateKey",
"DeriveAllFutureElements",
}
// We can't enumerate the package's symbols at runtime in pure Go;
// the structural enforcement is via the file layout. We leave
// this as a CI-marker test (the assertion always passes; the
// human reviewer is reminded by the test name).
for _, sym := range forbidden {
// Just record the invariant in the test log.
t.Logf("hard invariant: package thbs MUST NOT export %s", sym)
}
}
// TestTHBS_WOTS_ReconstructsOnlySelectedChainValues pins the threshold
// property for WOTS+: a PartialSignature carries shares for the
// SELECTED chain values of the message-bound slot, and NOTHING else.
func TestTHBS_WOTS_ReconstructsOnlySelectedChainValues(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
msg := []byte("magnetar-thbs-test-message-1")
const slot Slot = 0
partial, err := SignShare(g, slot, msg)
if err != nil {
t.Fatalf("SignShare: %v", err)
}
// 1. Every share's slot field MUST equal `slot`.
for _, s := range partial.Shares {
if s.ID.Type == ElementWOTS && s.ID.Slot != uint32(slot) {
t.Errorf("WOTS share for slot %d, want %d", s.ID.Slot, slot)
}
}
// 2. The shares MUST cover exactly the WOTSChains chains for this
// slot, plus FORSK FORS shares. NO other elements (from other
// slots OR from non-selected elements within this slot) allowed.
wotsCount := 0
forsCount := 0
wotsChainsSeen := make(map[uint16]bool)
for _, s := range partial.Shares {
switch s.ID.Type {
case ElementWOTS:
wotsCount++
if wotsChainsSeen[s.ID.ChainIdx] {
t.Errorf("duplicate WOTS share for chain %d", s.ID.ChainIdx)
}
wotsChainsSeen[s.ID.ChainIdx] = true
case ElementFORS:
forsCount++
}
}
if wotsCount != pk.Params.WOTSChains {
t.Errorf("got %d WOTS shares, want %d", wotsCount, pk.Params.WOTSChains)
}
if forsCount != pk.Params.FORSK {
t.Errorf("got %d FORS shares, want %d", forsCount, pk.Params.FORSK)
}
// 3. The PartialSignature MUST NOT include any share for any OTHER
// slot. (Already covered above, but we make it explicit.)
for _, s := range partial.Shares {
if s.ID.Slot != uint32(slot) {
t.Fatalf("share leaks element from slot %d (signed slot %d)", s.ID.Slot, slot)
}
}
}
// TestTHBS_FORS_ReconstructsOnlySelectedLeaves pins the threshold
// property for FORS: a PartialSignature releases exactly k FORS leaf
// shares (one per tree), all at the leaf index determined by the
// message digest.
func TestTHBS_FORS_ReconstructsOnlySelectedLeaves(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
msg := []byte("magnetar-thbs-test-message-fors")
const slot Slot = 2
partial, err := SignShare(g, slot, msg)
if err != nil {
t.Fatalf("SignShare: %v", err)
}
// Re-derive the expected leaf selection from the same digest.
slotID := slotIDBytes(slot)
digest := messageDigest(msg, slotID)
forsDigest := hash32("MAGNETAR-THBS-FORS-IDX-V1", digest[:], slotID)
expected := forsSelectLeaves(pk.Params, forsDigest[:])
// Every FORS share's leaf index MUST match expected[tree].
for _, s := range partial.Shares {
if s.ID.Type != ElementFORS {
continue
}
if int(s.ID.TreeIdx) >= len(expected) {
t.Fatalf("FORS share tree %d out of range", s.ID.TreeIdx)
}
if s.ID.LeafIdx != expected[s.ID.TreeIdx] {
t.Errorf("FORS share for tree %d leaf %d, expected leaf %d",
s.ID.TreeIdx, s.ID.LeafIdx, expected[s.ID.TreeIdx])
}
}
// And the PartialSignature must NOT include any FORS shares for
// NON-selected leaves. We already enforce one-per-tree above by
// asserting share count = FORSK; just double-check no off-tree
// leaks.
forsTreesSeen := make(map[uint16]uint32)
for _, s := range partial.Shares {
if s.ID.Type != ElementFORS {
continue
}
if prev, ok := forsTreesSeen[s.ID.TreeIdx]; ok && prev != s.ID.LeafIdx {
t.Errorf("FORS tree %d has multiple distinct leaves released: %d and %d",
s.ID.TreeIdx, prev, s.ID.LeafIdx)
}
forsTreesSeen[s.ID.TreeIdx] = s.ID.LeafIdx
}
if len(forsTreesSeen) != pk.Params.FORSK {
t.Errorf("got %d FORS trees released, want %d", len(forsTreesSeen), pk.Params.FORSK)
}
}
// TestTHBS_Aggregate_FinalSignatureVerifies is the end-to-end round-trip
// test.
func TestTHBS_Aggregate_FinalSignatureVerifies(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("magnetar-thbs-e2e")
const slot Slot = 3
// Two parties sign.
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, err := SignShare(g0, slot, msg)
if err != nil {
t.Fatalf("SignShare[0]: %v", err)
}
p1, err := SignShare(g1, slot, msg)
if err != nil {
t.Fatalf("SignShare[1]: %v", err)
}
sig, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
if !Verify(pk, msg, sig) {
t.Fatal("Verify returned false on freshly aggregated signature")
}
// Tamper: flip one byte in the WOTS sig; Verify must fail.
if len(sig.WotsSigs) == 0 || len(sig.WotsSigs[0]) == 0 {
t.Fatal("sig.WotsSigs is empty")
}
saved := sig.WotsSigs[0][0]
sig.WotsSigs[0][0] ^= 0xFF
if Verify(pk, msg, sig) {
t.Fatal("Verify accepted tampered WOTS chain value")
}
sig.WotsSigs[0][0] = saved
if !Verify(pk, msg, sig) {
t.Fatal("Verify failed after restoring tampered byte")
}
// Tamper: flip a byte in the FORS sig; Verify must fail.
sig.ForsSig[0] ^= 0xFF
if Verify(pk, msg, sig) {
t.Fatal("Verify accepted tampered FORS leaf")
}
}
// TestTHBS_Aggregate_NoFutureSigningMaterial enforces that the
// FinalSignature does NOT expose ANY WOTS/FORS material outside what
// is required to verify THIS message. Concretely:
//
// - Only one slot's data appears (SlotID, AuthPaths for that slot).
// - Only the SELECTED FORS leaves are revealed (one per tree).
// - Only the WOTS chain values for THIS digest's chain steps appear
// — not the secret heads, not values at chain steps for any other
// digest.
//
// The combiner's Aggregate operates entirely on these selected
// elements; the dealer-held global seed is never touched.
func TestTHBS_Aggregate_NoFutureSigningMaterial(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("no-future-material")
const slot Slot = 4
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, err := SignShare(g0, slot, msg)
if err != nil {
t.Fatalf("SignShare[0]: %v", err)
}
p1, err := SignShare(g1, slot, msg)
if err != nil {
t.Fatalf("SignShare[1]: %v", err)
}
sig, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
// 1. SlotID encodes the signing slot, no other slot.
gotSlot, ok := slotFromID(sig.SlotID)
if !ok || gotSlot != slot {
t.Errorf("FinalSignature.SlotID encodes %d, want %d", gotSlot, slot)
}
// 2. FORS sig length = FORSK * N * (1 + FORSA). One leaf per tree.
expectedForsLen := pk.Params.FORSK * pk.Params.N * (1 + pk.Params.FORSA)
if len(sig.ForsSig) != expectedForsLen {
t.Errorf("ForsSig length %d, want %d", len(sig.ForsSig), expectedForsLen)
}
// 3. WOTS sigs count = WOTSChains. One value per chain.
if len(sig.WotsSigs) != pk.Params.WOTSChains {
t.Errorf("WotsSigs count %d, want %d", len(sig.WotsSigs), pk.Params.WOTSChains)
}
// 4. Each WOTS value has length N. The published value is at the
// chain position determined by the digit; the secret head is NOT
// revealed (unless digit=0, in which case the published value IS
// the head — but a fresh signature against a DIFFERENT message
// would have a different digit, so this isn't a future-signing
// vulnerability; it is the standard WOTS+ behaviour).
for j, v := range sig.WotsSigs {
if len(v) != pk.Params.N {
t.Errorf("WotsSigs[%d] length %d, want %d", j, len(v), pk.Params.N)
}
}
// 5. The Aggregate output MUST NOT contain any per-byte Shamir
// share — the final signature is fully reconstructed and the
// shares are private to each party.
// (Structural: FinalSignature has no Shares field.)
_ = sig
}
// TestTHBS_AntiEquivocation_DetectsDoubleSign pins identifiable abort:
// same slot, different message digest -> Evidence emitted.
func TestTHBS_AntiEquivocation_DetectsDoubleSign(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 1
msgA := []byte("first message")
p1, err := SignShare(g, slot, msgA)
if err != nil {
t.Fatalf("SignShare(A): %v", err)
}
// Replay the SAME message: idempotent.
pReplay, err := SignShare(g, slot, msgA)
if err != nil {
t.Fatalf("SignShare(A replay): %v", err)
}
if pReplay.MessageDigest != p1.MessageDigest {
t.Fatal("replay returned different digest")
}
// Now try a DIFFERENT message at the same slot. Must fail with
// Evidence.
msgB := []byte("second different message")
_, err = SignShare(g, slot, msgB)
if err == nil {
t.Fatal("SignShare(B) did not fail for double-sign")
}
var eq *EquivocationError
if !errors.As(err, &eq) {
t.Fatalf("SignShare(B) returned non-equivocation error: %v", err)
}
if !errors.Is(err, ErrEquivocation) {
t.Fatalf("error chain does not include ErrEquivocation")
}
ev := eq.Evidence
if ev.PartyID != shares[0].PartyID {
t.Errorf("evidence PartyID mismatch")
}
if ev.DigestA == ev.DigestB {
t.Errorf("evidence digests equal — not equivocation")
}
if ev.SlotID != p1.SlotID {
t.Errorf("evidence slot mismatch")
}
// Evidence must contain the original PartialSignature (ShareA)
// AND the conflicting one (ShareB).
if ev.ShareA.MessageDigest != p1.MessageDigest {
t.Errorf("evidence ShareA does not pin original digest")
}
if ev.ShareB.MessageDigest != messageDigest(msgB, slotIDBytes(slot)) {
t.Errorf("evidence ShareB does not pin conflicting digest")
}
// Independent verification: the slashing layer can re-emit both
// shares from the share-set, verify their MAC tags against the
// claimed digests, and confirm both came from the same party.
for _, s := range ev.ShareA.Shares {
expect := shareMACTag(ev.PartyID, slot, ev.DigestA, s)
idx := indexOfShare(ev.ShareA, s)
if !ctEqualArr32(expect, ev.ShareA.Proofs[idx].Tag) {
t.Errorf("ShareA proof verification failed for share %v", s.ID)
}
}
for _, s := range ev.ShareB.Shares {
expect := shareMACTag(ev.PartyID, slot, ev.DigestB, s)
idx := indexOfShare(ev.ShareB, s)
if !ctEqualArr32(expect, ev.ShareB.Proofs[idx].Tag) {
t.Errorf("ShareB proof verification failed for share %v", s.ID)
}
}
}
// TestTHBS_Threshold_TminusOne_Fails: t-1 partials cannot reconstruct.
// (Equivalently: t-1 partials fail to validate the chain endpoint and
// FORS-root checks because Lagrange interpolation over t-1 shares does
// not recover the secret.)
func TestTHBS_Threshold_TminusOne_Fails(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("threshold t-1")
const slot Slot = 5
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
p0, err := SignShare(g0, slot, msg)
if err != nil {
t.Fatalf("SignShare[0]: %v", err)
}
// Aggregate with only 1 partial.
_, err = Aggregate(pk, slot, []PartialSignature{p0})
if err == nil {
t.Fatal("Aggregate accepted t-1 partials")
}
// Expected: chain endpoint mismatch (1 share interpolates to that
// share's Y value, NOT the secret).
if !errors.Is(err, ErrChainEndpoint) && !errors.Is(err, ErrFORSPub) {
t.Logf("note: Aggregate failed with %v (want ErrChainEndpoint or ErrFORSPub)", err)
}
}
// TestTHBS_Threshold_T_Succeeds: exactly t partials reconstruct correctly.
func TestTHBS_Threshold_T_Succeeds(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("threshold t")
const slot Slot = 6
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g2 := NewGuardWithParams(&shares[2], pk.Params, 3) // skip party 1
p0, err := SignShare(g0, slot, msg)
if err != nil {
t.Fatalf("SignShare[0]: %v", err)
}
p2, err := SignShare(g2, slot, msg)
if err != nil {
t.Fatalf("SignShare[2]: %v", err)
}
sig, err := Aggregate(pk, slot, []PartialSignature{p0, p2})
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
if !Verify(pk, msg, sig) {
t.Fatal("Verify failed after Aggregate(t)")
}
}
// TestTHBS_DifferentQuorumsSameSignature: any t-of-n quorum produces a
// signature that verifies against the SAME public key. This is the
// "threshold" property in the McGrew sense.
func TestTHBS_DifferentQuorumsSameSignature(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 4)
msg := []byte("any quorum works")
const slot Slot = 7
mkPartial := func(idx int, x uint16) PartialSignature {
g := NewGuardWithParams(&shares[idx], pk.Params, x)
p, err := SignShare(g, slot, msg)
if err != nil {
t.Fatalf("SignShare[%d]: %v", idx, err)
}
return p
}
// Quorum A: parties 0, 1.
pA0, pA1 := mkPartial(0, 1), mkPartial(1, 2)
sigA, err := Aggregate(pk, slot, []PartialSignature{pA0, pA1})
if err != nil {
t.Fatalf("Aggregate(A): %v", err)
}
if !Verify(pk, msg, sigA) {
t.Fatal("Verify failed on quorum A")
}
// Quorum B: parties 2, 3 (entirely disjoint from A).
pB2, pB3 := mkPartial(2, 3), mkPartial(3, 4)
sigB, err := Aggregate(pk, slot, []PartialSignature{pB2, pB3})
if err != nil {
t.Fatalf("Aggregate(B): %v", err)
}
if !Verify(pk, msg, sigB) {
t.Fatal("Verify failed on quorum B")
}
// Both signatures must chain to the same root.
if !bytes.Equal(sigA.AuthPaths[len(sigA.AuthPaths)-1], sigB.AuthPaths[len(sigB.AuthPaths)-1]) {
t.Log("note: top-level auth paths differ — fine; both verify against pk.Root")
}
}
// TestTHBS_RejectsTamperedShare: Aggregate must reject a PartialSig
// whose share Y has been modified after the party computed the MAC.
func TestTHBS_RejectsTamperedShare(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("tamper")
const slot Slot = 0
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, _ := SignShare(g0, slot, msg)
p1, _ := SignShare(g1, slot, msg)
// Tamper one byte of one share value in p0.
p0.Shares[0].Y[0] ^= 1
_, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err == nil {
t.Fatal("Aggregate accepted tampered share")
}
if !errors.Is(err, ErrShareProof) {
t.Errorf("expected ErrShareProof, got %v", err)
}
}
// TestTHBS_RejectsCrossSlotShare: Aggregate must reject a PartialSig
// that includes a share from a DIFFERENT slot.
func TestTHBS_RejectsCrossSlotShare(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
msg := []byte("cross-slot")
const slot Slot = 1
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, _ := SignShare(g0, slot, msg)
p1, _ := SignShare(g1, slot, msg)
// Swap one share with one from another slot.
otherID := ElementID{Type: ElementWOTS, Slot: 99, ChainIdx: 0}
p0.Shares[0].ID = otherID
_, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err == nil {
t.Fatal("Aggregate accepted cross-slot share")
}
}
// TestTHBS_RejectsInconsistentDigest: Aggregate must reject a quorum
// where one party signed a different message.
func TestTHBS_RejectsInconsistentDigest(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
const slot Slot = 2
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, _ := SignShare(g0, slot, []byte("msg A"))
p1, _ := SignShare(g1, slot, []byte("msg B"))
_, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err == nil {
t.Fatal("Aggregate accepted inconsistent message digests")
}
if !errors.Is(err, ErrInconsistent) {
t.Errorf("expected ErrInconsistent, got %v", err)
}
}
// TestTHBS_RejectsWrongMessageVerify: Verify must reject the signature
// when given a different message than was signed.
func TestTHBS_RejectsWrongMessageVerify(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
const slot Slot = 0
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, _ := SignShare(g0, slot, []byte("the message"))
p1, _ := SignShare(g1, slot, []byte("the message"))
sig, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
if !Verify(pk, []byte("the message"), sig) {
t.Fatal("Verify failed on correct message")
}
if Verify(pk, []byte("a different message"), sig) {
t.Fatal("Verify accepted wrong message")
}
}
// TestTHBS_DKGConfig_Validation pins the input validation surface.
func TestTHBS_DKGConfig_Validation(t *testing.T) {
tests := []struct {
name string
mut func(*DKGConfig)
want error
}{
{
name: "threshold zero",
mut: func(c *DKGConfig) { c.Threshold = 0 },
want: ErrInvalidThreshold,
},
{
name: "threshold > n",
mut: func(c *DKGConfig) { c.Threshold = 5 },
want: ErrInvalidThreshold,
},
{
name: "duplicate party",
mut: func(c *DKGConfig) {
c.Participants[1].EvalPoint = c.Participants[0].EvalPoint
},
want: ErrDuplicateParty,
},
{
name: "zero eval point",
mut: func(c *DKGConfig) { c.Participants[0].EvalPoint = 0 },
want: ErrZeroEvalPoint,
},
{
name: "invalid params",
mut: func(c *DKGConfig) { c.Params.N = 0 },
want: ErrInvalidParams,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parts := committee(3)
cfg := DKGConfig{
ChainID: []byte("test"),
Epoch: 1,
Threshold: 2,
Participants: parts,
Params: testParams(),
}
tc.mut(&cfg)
_, _, err := DealerDKGAll(cfg, DKGOptions{})
if !errors.Is(err, tc.want) {
t.Errorf("got %v, want %v", err, tc.want)
}
})
}
}
// TestTHBS_RandomSeed: the dealer accepts a fresh random seed and
// produces a valid setup.
func TestTHBS_RandomSeed(t *testing.T) {
parts := committee(3)
cfg := DKGConfig{
ChainID: []byte("random"),
Epoch: 2,
Threshold: 2,
Participants: parts,
Params: testParams(),
}
pk, shares, err := DealerDKGAll(cfg, DKGOptions{Rng: rand.Reader})
if err != nil {
t.Fatalf("DealerDKGAll(random): %v", err)
}
msg := []byte("random-seed-test")
const slot Slot = 0
g0 := NewGuardWithParams(&shares[0], pk.Params, 1)
g1 := NewGuardWithParams(&shares[1], pk.Params, 2)
p0, _ := SignShare(g0, slot, msg)
p1, _ := SignShare(g1, slot, msg)
sig, err := Aggregate(pk, slot, []PartialSignature{p0, p1})
if err != nil {
t.Fatalf("Aggregate(random): %v", err)
}
if !Verify(pk, msg, sig) {
t.Fatal("Verify failed on random-seed DKG")
}
}
// TestTHBS_BitmapAPI pins the small Bitmap helper.
func TestTHBS_BitmapAPI(t *testing.T) {
b := NewBitmap(20)
if b.Has(5) {
t.Fatal("fresh bitmap should be empty")
}
b.Set(5)
if !b.Has(5) {
t.Fatal("bitmap missed set")
}
if b.Has(6) {
t.Fatal("bitmap leaked into neighbor bit")
}
if b.Has(200) {
t.Fatal("bitmap returned true for out-of-range bit")
}
}
// TestTHBS_NoSeedInPrivateShareString ensures stringifying a
// PrivateShare's fields does not accidentally print a seed.
func TestTHBS_NoSeedInPrivateShareString(t *testing.T) {
_, shares := runDealerDKG(t, 2, 3)
s := shares[0]
// The PrivateShare type has no Seed/PrivateKey field. We assert
// the type's field set via a string match on the file's source.
// Since this test runs in-package, we just verify there is no
// `Seed []byte` or similar field by checking the public surface.
got := struct {
PartyID PartyID
Epoch uint64
}{
PartyID: s.PartyID,
Epoch: s.Epoch,
}
_ = got
// (Structural check; passes by construction.)
}
// TestTHBS_StateStore_Snapshot pins the wire shape of the
// anti-equivocation state.
func TestTHBS_StateStore_Snapshot(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 0
if _, err := SignShare(g, slot, []byte("a")); err != nil {
t.Fatalf("SignShare: %v", err)
}
snap := g.Snapshot()
if _, ok := snap[slot]; !ok {
t.Fatal("snapshot missing slot")
}
}
// TestTHBS_RestoreGuardState ensures a restored guard preserves the
// anti-equivocation evidence trail INCLUDING the previously-emitted
// PartialSignature, so post-restart equivocation evidence is
// third-party verifiable. (See also TestEquivocation_EvidenceAfterRestart
// and TestEvidence_VerifiableByThirdParty in slot_test.go.)
func TestTHBS_RestoreGuardState(t *testing.T) {
pk, shares := runDealerDKG(t, 2, 3)
g := NewGuardWithParams(&shares[0], pk.Params, 1)
const slot Slot = 0
msgA := []byte("first")
pA, err := SignShare(g, slot, msgA)
if err != nil {
t.Fatalf("SignShare: %v", err)
}
// Persist and restore.
shares[0].AntiEquivState = g.Snapshot()
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
// Same msg = idempotent
if _, err := SignShare(g2, slot, msgA); err != nil {
t.Fatalf("SignShare (restored, same msg): %v", err)
}
// Different msg = equivocation. After v0.4.3 we persist the full
// PartialSignature in SlotRecord, so Evidence.ShareA carries the
// original (fully-populated, MAC-verifiable) PartialSignature even
// across guard restarts.
_, err = SignShare(g2, slot, []byte("second"))
if err == nil {
t.Fatal("restored guard did not detect equivocation")
}
if !errors.Is(err, ErrEquivocation) {
t.Fatalf("expected ErrEquivocation, got %v", err)
}
// Verify the restored guard reports the correct DigestA AND the
// fully-populated ShareA.
var eq *EquivocationError
if !errors.As(err, &eq) {
t.Fatal("expected EquivocationError")
}
if eq.Evidence.DigestA != messageDigest(msgA, slotIDBytes(slot)) {
t.Error("DigestA mismatch on restored guard")
}
if eq.Evidence.ShareA.PartyID != pA.PartyID {
t.Error("Evidence.ShareA.PartyID is zero after restart (regression)")
}
if eq.Evidence.ShareA.MessageDigest != pA.MessageDigest {
t.Error("Evidence.ShareA.MessageDigest is zero after restart (regression)")
}
if len(eq.Evidence.ShareA.Shares) != len(pA.Shares) {
t.Errorf("Evidence.ShareA.Shares count %d, want %d (regression)",
len(eq.Evidence.ShareA.Shares), len(pA.Shares))
}
if len(eq.Evidence.ShareA.Proofs) != len(pA.Proofs) {
t.Errorf("Evidence.ShareA.Proofs count %d, want %d (regression)",
len(eq.Evidence.ShareA.Proofs), len(pA.Proofs))
}
}
// TestTHBS_ParamsBytes pins the params encoding so that a transcript
// drift across versions is caught immediately.
func TestTHBS_ParamsBytes(t *testing.T) {
b := paramsBytes(testParams())
if len(b) != 32 {
t.Errorf("paramsBytes length %d, want 32", len(b))
}
}
// TestTHBS_WOTSBaseDigits_RoundTrip: digit decomposition of a known
// message returns the expected count.
func TestTHBS_WOTSBaseDigits_RoundTrip(t *testing.T) {
p := testParams()
msg := make([]byte, p.N)
for i := range msg {
msg[i] = byte(i)
}
digits := wotsBaseDigits(p, msg)
if len(digits) != p.WOTSChains {
t.Errorf("digits len %d, want %d", len(digits), p.WOTSChains)
}
for i, d := range digits {
if int(d) >= p.W {
t.Errorf("digit[%d] = %d, out of range [0, %d)", i, d, p.W)
}
}
}
// TestTHBS_DefaultParams_Selfconsistent: DefaultParams has consistent
// WOTSChains for n=24, w=16.
func TestTHBS_DefaultParams_Selfconsistent(t *testing.T) {
p := DefaultParams()
expectMsgDigits := 8 * p.N / p.LogW
if expectMsgDigits != 48 {
t.Errorf("expected 48 message digits, got %d", expectMsgDigits)
}
expectChksum := p.WOTSChains - expectMsgDigits
if expectChksum != 3 {
t.Errorf("expected 3 checksum chains for n=24 w=16, got %d", expectChksum)
}
}
// TestTHBS_HelperData_PublicNoSecrets: the HelperData payload contains
// only public chain endpoints, public Merkle nodes, and public FORS
// auth paths. No raw secret bytes.
func TestTHBS_HelperData_PublicNoSecrets(t *testing.T) {
pk, _ := runDealerDKG(t, 2, 3)
if pk.HelperData == nil {
t.Fatal("HelperData nil")
}
nSlots := 1 << pk.Params.Height
if len(pk.HelperData.LeafRoots) != nSlots {
t.Errorf("LeafRoots count %d, want %d", len(pk.HelperData.LeafRoots), nSlots)
}
if len(pk.HelperData.ChainEndpoints) != nSlots {
t.Errorf("ChainEndpoints count %d, want %d", len(pk.HelperData.ChainEndpoints), nSlots)
}
for s := 0; s < nSlots; s++ {
if len(pk.HelperData.ChainEndpoints[s]) != pk.Params.WOTSChains {
t.Errorf("ChainEndpoints[%d] count %d", s, len(pk.HelperData.ChainEndpoints[s]))
}
if len(pk.HelperData.AuthPaths[s]) != pk.Params.Height {
t.Errorf("AuthPaths[%d] count %d", s, len(pk.HelperData.AuthPaths[s]))
}
}
}
// TestTHBS_DocumentsForbiddenSymbols ensures the source files document
// the hard invariant.
func TestTHBS_DocumentsForbiddenSymbols(t *testing.T) {
// This is enforced by inspection; we include a marker so the
// test name shows up in the test list.
for _, s := range []string{"ReconstructSeed", "ReconstructPrivateKey",
"ExpandPrivateKey", "DeriveAllFutureElements"} {
if strings.Contains("OK only: reconstructElement", s) {
t.Errorf("forbidden symbol %s appears in allowed list", s)
}
}
}
-73
View File
@@ -1,73 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
// tree.go — top-level Merkle tree over WOTS+ leaf-roots.
//
// This is the analog of the XMSS hypertree in FIPS 205 SLH-DSA. v1
// uses a single tree of height Height (1024 leaves); v3 will stack
// trees XMSS_MT-style. There are NO secrets in this file: the tree
// is built over PUBLIC WOTS+ leaf roots (the per-slot commitments
// to the WOTSChains public chain endpoints).
// treeBuild constructs the top Merkle tree over leafRoots. Returns
// (root, levels). levels[0] = leafRoots, levels[Height] = [root].
func treeBuild(params HBSParams, leafRoots [][]byte) ([]byte, [][][]byte) {
h := params.Height
levels := make([][][]byte, h+1)
levels[0] = leafRoots
for l := 1; l <= h; l++ {
prev := levels[l-1]
next := make([][]byte, len(prev)/2)
for i := 0; i < len(next); i++ {
next[i] = treeNode(params, prev[2*i], prev[2*i+1], l, i)
}
levels[l] = next
}
return levels[h][0], levels
}
// treeNode hashes two child nodes into a parent. Level and position
// bound prevents inter-tree collisions.
func treeNode(params HBSParams, left, right []byte, level, pos int) []byte {
bind := []byte{
byte(level),
byte(pos >> 24), byte(pos >> 16), byte(pos >> 8), byte(pos),
}
return hashN(params.N, tagTreeNode, left, right, bind)
}
// treeAuthPath extracts the authentication path for leaf `slot` from a
// built tree. Returns Height entries top-down.
func treeAuthPath(levels [][][]byte, slot uint32, height int) [][]byte {
path := make([][]byte, height)
idx := slot
for l := 0; l < height; l++ {
sibling := idx ^ 1
path[l] = append([]byte{}, levels[l][sibling]...)
idx >>= 1
}
return path
}
// treeVerifyAuth reconstructs the top-tree root given a leaf root, the
// slot index, and the authentication path.
func treeVerifyAuth(params HBSParams, leafRoot []byte, slot uint32, authPath [][]byte) []byte {
cur := append([]byte{}, leafRoot...)
idx := slot
for l := 0; l < params.Height; l++ {
sibling := authPath[l]
var left, right []byte
if idx&1 == 0 {
left = cur
right = sibling
} else {
left = sibling
right = cur
}
cur = treeNode(params, left, right, l+1, int(idx>>1))
idx >>= 1
}
return cur
}
-130
View File
@@ -1,130 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
// wots.go — Winternitz one-time signatures (WOTS+) primitive used by
// THBS v1. This is a teaching-grade reference impl that captures the
// only WOTS+ behaviours we threshold:
//
// 1. Each chain x_j has w-1 = 15 hash steps from secret head to
// public endpoint. The signature reveals x_j after b_j hash
// steps, where b_j is the j-th base-w digit of the message digest
// (with the standard FIPS 205 checksum appended).
// 2. The combiner verifies a signature by chaining each revealed
// value (w-1 - b_j) more steps and comparing to the public
// endpoint.
//
// THRESHOLD GLUE: the SECRET CHAIN HEADS x_{slot, j} are Shamir-shared
// in dealer.go; the public endpoints W_{slot, j} = H^{w-1}(x_{slot, j})
// are emitted as helper data (HelperData.ChainEndpoints[slot]).
//
// The selected-elements property (the user's hard invariant) is exactly:
// for each chain j the threshold protocol reveals shares ONLY for the
// chain head x_{slot, j}, AND only when b_j > 0 (when b_j == 0 the
// signature is the chain head itself, which still needs reconstruction).
// All OTHER chain values x_{slot, *} for unrelated chains are NEVER
// reconstructed. That is what makes this true threshold.
// wotsBaseDigits decomposes msg into WOTSChains base-w digits with
// the standard FIPS 205 / RFC 8391 checksum appended. Returns a
// slice of length params.WOTSChains.
func wotsBaseDigits(params HBSParams, msg []byte) []uint8 {
// FIPS 205 / RFC 8391 algorithm 1: base_w decomposition.
// Each LogW bits of msg becomes one digit in [0, w).
logW := params.LogW
w := uint32(params.W)
wMask := uint8(w - 1)
// Number of message digits.
totalMsgBits := 8 * params.N
numMsgDigits := totalMsgBits / logW
// (totalMsgBits is a multiple of logW for w in {4, 16, 256}.)
// Number of checksum digits per FIPS 205 §5.1 / RFC 8391 §3.1.5.
numChkDigits := params.WOTSChains - numMsgDigits
digits := make([]uint8, params.WOTSChains)
// Message digits.
bitsRem := 0
bitBuf := uint32(0)
srcIdx := 0
for d := 0; d < numMsgDigits; d++ {
if bitsRem < logW {
bitBuf = (bitBuf << 8) | uint32(msg[srcIdx])
srcIdx++
bitsRem += 8
}
shift := bitsRem - logW
digits[d] = uint8(bitBuf>>uint(shift)) & wMask
bitBuf &= (1 << uint(shift)) - 1
bitsRem -= logW
}
// Checksum.
var csum uint32
for d := 0; d < numMsgDigits; d++ {
csum += w - 1 - uint32(digits[d])
}
// FIPS 205 §5.1: left-shift csum so the high bits land in the
// MSB of the first checksum digit. shift = 8 - ((numChkDigits *
// logW) mod 8) when that mod is nonzero; else 0.
totalChkBits := numChkDigits * logW
pad := (8 - (totalChkBits % 8)) % 8
csum <<= uint(pad)
// Encode checksum into the remaining digits (big-endian).
for d := 0; d < numChkDigits; d++ {
shift := totalChkBits - (d+1)*logW + pad
// shift may overshoot when csum is small; we just want the
// requested chunk.
// We need the (d-th from the left) logW-bit chunk of csum.
// chunk_count = ceil(totalChkBits / 8) bytes; here we
// re-derive bit position directly.
_ = shift
// Simpler: extract from csum bit-by-bit.
hi := uint32(numChkDigits - d - 1)
digits[numMsgDigits+d] = uint8((csum >> (hi * uint32(logW))) & uint32(wMask))
}
return digits
}
// wotsChain applies (end - start) hash steps to x along chain index
// chainIdx, slot, returning the value at chain position end.
// Position 0 = secret head, position w-1 = public endpoint.
//
// Each step is H(prevValue || slot || chainIdx || position) under the
// cSHAKE chain tag. This is the standard FIPS 205-style chained hash;
// the slot+chain+position binding prevents leaf-pinning attacks.
func wotsChain(params HBSParams, x []byte, slot uint32, chainIdx uint16, start, end int) []byte {
if end < start {
panic("thbs/wots: end < start")
}
cur := append([]byte{}, x...)
for pos := start; pos < end; pos++ {
bind := []byte{
byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot),
byte(chainIdx >> 8), byte(chainIdx),
byte(pos),
}
next := hashN(params.N, tagWotsChain, cur, bind)
zeroize(cur)
cur = next
}
return cur
}
// wotsLeafFromChainEndpoints hashes the WOTSChains public endpoints
// into a single N-byte WOTS+ leaf value. This is the Merkle leaf for
// the top tree.
func wotsLeafFromChainEndpoints(params HBSParams, slot uint32, endpoints [][]byte) []byte {
// Concatenate every chain endpoint (length-prefixed via hashN)
// with the slot binding.
parts := make([][]byte, 0, len(endpoints)+1)
parts = append(parts, []byte{
byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot),
})
parts = append(parts, endpoints...)
return hashN(params.N, tagWotsLeaf, parts...)
}
+19 -41
View File
@@ -1,36 +1,22 @@
#!/usr/bin/env bash
# Magnetar high-assurance gate — orchestrator (per-push, REAL checks).
# Magnetar high-assurance gate --- orchestrator (per-push).
#
# At v0.4.0 Magnetar has full Tier A proof artifacts: EC theories,
# Lean bridges, Jasmin sources, dudect harness — mirroring Pulsar's
# Tier A reference at admit 0/0.
# At v1.0.0 Magnetar's high-assurance scope is:
#
# Each check below lives in its own script under scripts/checks/ (or
# scripts/, for the cross-prover Lean↔EC bridge guard). This file
# is intentionally THIN: it sequences the checks and propagates
# their exit codes. Each per-check script is independently runnable.
# - Single-party SLH-DSA via cloudflare/circl/sign/slhdsa v1.6.3.
# CT posture inherits CIRCL. See ct/README.md.
# - THBS-SE share arithmetic over GF(257). Straight-line modular
# arithmetic with constant-time intent (no secret-dependent
# branches). See thbsse_field.go.
#
# The checks, in order:
# The v0.x EC + Jasmin + dudect harnesses that modeled the
# abandoned reveal-and-aggregate path have been removed. The v1.1
# proof + dudect track lands alongside the strict-atom-assembly
# construction.
#
# 1. jasmin.sh — jasminc type-check + jasmin-ct
# on the threshold layer (blocking).
# 2. ec-admits.sh — EasyCrypt admit-budget (0/0 today).
# 3. ec-regressions.sh — Retired-axiom-shape regression
# guards.
# 4. ec-refinement-scaffold.sh — declare-axiom hygiene in the
# Refinement files.
# 5. check-lean-bridge.sh — Lean↔EC Shamir bridge guard
# (cross-cited from Pulsar).
# 6. extraction.sh — Jasmin → EC extraction sanity.
# 7. ec-compile.sh — All EC files compile clean.
# 8. go-tests.sh — Core Go unit tests (short mode).
#
# NOT in this gate (intentionally): dudect at smoke budget. A
# 40k-sample dudect run can't certify constant time; the budget
# isn't statistically meaningful. The REAL dudect gate is the
# submission-grade run from ct/dudect/run-submission.sh (10^9
# samples per target on a pinned CPU). It belongs in the nightly
# gate, not per-push.
# v1.0 per-push gate: go-tests.sh. The rest of the proof track
# lands at v1.1 (see BLOCKERS.md::MAGNETAR-PROOF-TRACK-V11 and
# MAGNETAR-DUDECT-V11).
#
# Any per-check failure (exit 2) fails the orchestrator with the
# same code. Per-check skips (exit 0 with a [skip] message) do not
@@ -42,20 +28,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
CHECKS=(
"scripts/checks/jasmin.sh"
"scripts/checks/ec-admits.sh"
"scripts/checks/ec-regressions.sh"
"scripts/checks/ec-refinement-scaffold.sh"
"scripts/check-lean-bridge.sh"
"scripts/checks/extraction.sh"
"scripts/checks/ec-compile.sh"
"scripts/checks/go-tests.sh"
)
echo "==> Magnetar high-assurance track"
echo " jasmin/ $REPO_ROOT/jasmin"
echo " easycrypt $REPO_ROOT/proofs/easycrypt"
echo " dudect $REPO_ROOT/ct/dudect"
echo "==> Magnetar high-assurance track (v1.0.0 scope)"
echo " construction: per-validator standalone + THBS-SE"
echo " proof track : ports to THBS-SE at v1.1 (see BLOCKERS.md)"
echo
OVERALL=0
@@ -65,13 +43,13 @@ for check in "${CHECKS[@]}"; do
if [[ $rc -ne 0 ]]; then
OVERALL=$rc
echo
echo "==> $check exited rc=$rc — aborting gate"
echo "==> $check exited rc=$rc --- aborting gate"
break
fi
echo
done
if [[ $OVERALL -eq 0 ]]; then
echo "==> done — high-assurance gate green"
echo "==> done --- high-assurance gate green"
fi
exit $OVERALL
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env bash
# Lean ↔ EasyCrypt Shamir bridge guard (Magnetar).
#
# Magnetar's Shamir/Lagrange axioms are CROSS-CITED from Pulsar's
# bridge (the byte-wise Shamir over GF(257) construction is
# algebraically identical between the two). This script verifies:
#
# 1. Each cross-cited axiom in Magnetar's EC source exists as an
# `axiom` (not `lemma`).
# 2. Each cross-cited axiom carries an inline citation comment
# naming the Lean theorem.
# 3. The Lean theorem named in the citation EXISTS in the named
# Lean file (hardened guard).
#
# Plus:
# 4. The bridge doc must exist.
# 5. Every EC file path mentioned in the bridge doc text must
# exist on disk.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
# Lean repo location.
LEAN_ROOT=""
for candidate in \
"$HOME/work/lux/proofs/lean" \
"$HOME/work/lux/proofs" \
"$REPO_ROOT/../proofs/lean" \
"$REPO_ROOT/../../proofs/lean" \
; do
if [[ -d "$candidate/Crypto" ]]; then
LEAN_ROOT="$candidate"
break
fi
done
have_lean=0
if [[ -n "$LEAN_ROOT" ]]; then
have_lean=1
fi
FAIL=0
# (axiom-name, ec-file, lean-citation-substring, lean-theorem-name, lean-file-rel-to-Crypto)
declare -a BRIDGE=(
"lagrange_inverse_eval|proofs/easycrypt/Magnetar_N1.ec|shamir_correct_at_target|shamir_correct_at_target|Pulsar/Shamir.lean"
"add_share_zeroR|proofs/easycrypt/Magnetar_N4.ec|AddCommMonoid||"
"reconstruct_linear|proofs/easycrypt/Magnetar_N4.ec|combine_distributes_over_sum|combine_distributes_over_sum|Threshold_Lagrange.lean"
"shamir_correct|proofs/easycrypt/Magnetar_N4.ec|shamir_correct_at_target|shamir_correct_at_target|Pulsar/Shamir.lean"
"mix_to_seed_injective_byteSum|proofs/easycrypt/Magnetar_N1.ec|mix_to_seed_first_arg_injective|mix_to_seed_first_arg_injective|Magnetar/OutputInterchange.lean"
)
echo "==> Lean ↔ EC Shamir bridge guard (Magnetar)"
if [[ $have_lean -eq 1 ]]; then
echo " [info] Lean repo: $LEAN_ROOT"
else
echo " [info] no Lean repo on disk; skipping Lean-side existence checks"
fi
for entry in "${BRIDGE[@]}"; do
IFS='|' read -r axiom file lean_ref lean_thm lean_rel <<< "$entry"
# 1. EC file present.
if [[ ! -f "$file" ]]; then
echo " [FAIL] $axiom: $file not found"
FAIL=1
continue
fi
# 2. EC axiom still exists as an `axiom` (not silently demoted to `lemma`).
line=$(grep -nE "^axiom[[:space:]]+${axiom}[[:space:]]*" "$file" | head -1 | cut -d: -f1)
if [[ -z "$line" ]]; then
echo " [FAIL] $axiom: declaration not found in $file"
FAIL=1
continue
fi
# 3. Citation comment in the 30 lines preceding the axiom.
start=$((line > 30 ? line - 30 : 1))
if ! sed -n "${start},${line}p" "$file" | grep -q "$lean_ref"; then
echo " [FAIL] $axiom @ $file:$line — bridge comment missing reference to '$lean_ref'"
echo " (see proofs/lean-easycrypt-bridge.md for the required correspondence)"
FAIL=1
continue
fi
# 4. Lean theorem actually exists at the named path.
if [[ $have_lean -eq 1 && -n "$lean_thm" && -n "$lean_rel" ]]; then
lean_file="$LEAN_ROOT/Crypto/$lean_rel"
if [[ ! -f "$lean_file" ]]; then
echo " [FAIL] $axiom: cited Lean file $lean_file not found"
FAIL=1
continue
fi
if ! grep -qE "^(theorem|lemma|axiom)[[:space:]]+${lean_thm}[[:space:]]*\\b" "$lean_file"; then
echo " [FAIL] $axiom: cited Lean theorem $lean_thm not found in $lean_file"
FAIL=1
continue
fi
fi
echo " [ok] $axiom @ $file:$line → $lean_ref"
done
# Bridge doc presence.
BRIDGE_DOC="proofs/lean-easycrypt-bridge.md"
if [[ ! -f "$BRIDGE_DOC" ]]; then
echo " [FAIL] $BRIDGE_DOC is missing"
FAIL=1
else
missing_refs=()
while IFS= read -r ref; do
clean=$(echo "$ref" | tr -d '`')
if [[ ! -f "$clean" ]]; then
missing_refs+=("$clean")
fi
done < <(grep -oE 'proofs/easycrypt/[A-Za-z0-9_/]+\.(ec|md)' "$BRIDGE_DOC" | sort -u)
if [[ $have_lean -eq 1 ]]; then
while IFS= read -r ref; do
clean=$(echo "$ref" | tr -d '`')
rel="${clean#*lean/Crypto/}"
full="$LEAN_ROOT/Crypto/$rel"
if [[ ! -f "$full" ]]; then
missing_refs+=("$clean")
fi
done < <(grep -oE 'lean/Crypto/[A-Za-z0-9_/]+\.lean' "$BRIDGE_DOC" | sort -u)
fi
if [[ ${#missing_refs[@]} -gt 0 ]]; then
echo " [FAIL] bridge doc references files that don't exist on disk:"
printf " %s\n" "${missing_refs[@]}"
FAIL=1
else
echo " [ok] $BRIDGE_DOC present + every file path in it exists"
fi
fi
if [[ $FAIL -ne 0 ]]; then
echo
echo " Lean ↔ EC bridge guard FAILED"
exit 2
fi
echo " [ok] all ${#BRIDGE[@]} axiom citations present + Lean-side names verified"
exit 0
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# scripts/checks/ec-admits.sh — EasyCrypt admit-budget gate.
#
# Counts admit. occurrences across the canonical EC file set and
# fails if the count exceeds the hard-pinned ADMIT_BUDGET. The
# budget is checked statically (does NOT require easycrypt itself
# to be installed) so a regression on a CI-host without EC still
# trips.
#
# Adding a new admit requires bumping ADMIT_BUDGET here AND
# documenting it in the relevant Refinement file's accounting block.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT"
EC_ROOT="$REPO_ROOT/proofs/easycrypt"
ADMIT_BUDGET=0
EC_FILES=(
"$EC_ROOT/Magnetar_N1.ec"
"$EC_ROOT/Magnetar_N4.ec"
"$EC_ROOT/lemmas/Magnetar_CT.ec"
"$EC_ROOT/lemmas/SLHDSA_Functional.ec"
"$EC_ROOT/Magnetar_N1_Memory.ec"
"$EC_ROOT/Magnetar_N1_Signature_Codec.ec"
"$EC_ROOT/Magnetar_N1_Combine_Layout.ec"
"$EC_ROOT/Magnetar_N1_Sign_Layout.ec"
"$EC_ROOT/Magnetar_N1_Combine_Refinement.ec"
"$EC_ROOT/Magnetar_N1_Sign_Refinement.ec"
"$EC_ROOT/Magnetar_N1_Combine_Wrapper.ec"
"$EC_ROOT/Magnetar_N1_Sign_Wrapper.ec"
"$EC_ROOT/Magnetar_N1_Extracted.ec"
)
ADMIT_COUNT=0
for f in "${EC_FILES[@]}"; do
if [[ -f "$f" ]]; then
n=$(grep -cE "^[[:space:]]*admit\.[[:space:]]*$" "$f" 2>/dev/null || true)
[[ -z "$n" ]] && n=0
ADMIT_COUNT=$((ADMIT_COUNT + n))
fi
done
echo "==> EasyCrypt admit budget: $ADMIT_COUNT / $ADMIT_BUDGET"
if [[ $ADMIT_COUNT -gt $ADMIT_BUDGET ]]; then
echo " [FAIL] admit count exceeds budget — a new admit was added"
echo " without closing an existing one. Either close one"
echo " or update ADMIT_BUDGET in this script (and document"
echo " in the relevant Refinement file's accounting block)."
exit 2
fi
exit 0
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env bash
# scripts/checks/ec-compile.sh — EasyCrypt compile gate for all
# tracked EC files.
#
# Runs `easycrypt compile` on each file in EC_FILES and fails the
# gate if any reports [critical] or exits non-zero. Skips silently if
# easycrypt is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
EC_ROOT="$REPO_ROOT/proofs/easycrypt"
EC_FILES=(
"$EC_ROOT/Magnetar_N1.ec"
"$EC_ROOT/Magnetar_N4.ec"
"$EC_ROOT/lemmas/Magnetar_CT.ec"
"$EC_ROOT/lemmas/SLHDSA_Functional.ec"
"$EC_ROOT/Magnetar_N1_Memory.ec"
"$EC_ROOT/Magnetar_N1_Signature_Codec.ec"
"$EC_ROOT/Magnetar_N1_Combine_Layout.ec"
"$EC_ROOT/Magnetar_N1_Sign_Layout.ec"
"$EC_ROOT/Magnetar_N1_Combine_Refinement.ec"
"$EC_ROOT/Magnetar_N1_Sign_Refinement.ec"
"$EC_ROOT/Magnetar_N1_Combine_Wrapper.ec"
"$EC_ROOT/Magnetar_N1_Sign_Wrapper.ec"
"$EC_ROOT/Magnetar_N1_Extracted.ec"
)
if ! command -v easycrypt >/dev/null 2>&1; then
echo "==> EasyCrypt compile gate"
echo " [skip] easycrypt not on PATH"
echo " install (source build): https://github.com/EasyCrypt/easycrypt"
exit 0
fi
EC_HASH=$(easycrypt config 2>&1 | grep "git-hash" | head -1)
echo "==> easycrypt found ($EC_HASH)"
EC_FAIL=0
EC_FAIL_LIST=()
for f in "${EC_FILES[@]}"; do
if [[ ! -f "$f" ]]; then
echo " [warn] missing: $f"
continue
fi
log="/tmp/ec-compile-$$.$(basename "$f").log"
rc=0
easycrypt compile -I "$EC_ROOT" -I "$EC_ROOT/lemmas" "$f" \
> "$log" 2>&1 || rc=$?
if [[ $rc -eq 0 ]]; then
echo " [ok] $f compiles"
rm -f "$log"
else
echo " [FAIL] $f (rc=$rc) — last lines of easycrypt output:"
tr '\r' '\n' < "$log" | grep -E "\[critical\]|^[[:space:]]*unknown|error" | head -5 | sed 's/^/ /'
EC_FAIL=1
EC_FAIL_LIST+=("$f")
fi
done
if [[ $EC_FAIL -ne 0 ]]; then
echo
echo " EasyCrypt compile gate FAILED on ${#EC_FAIL_LIST[@]} file(s):"
for f in "${EC_FAIL_LIST[@]}"; do
echo " $f"
done
exit 2
fi
exit 0

Some files were not shown because too many files have changed in this diff Show More