mirror of
https://github.com/luxfi/magnetar.git
synced 2026-09-24 00:25:23 +00:00
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:
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
=================================================================== *)
|
||||
@@ -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.
|
||||
=================================================================== *)
|
||||
@@ -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
|
||||
=================================================================== *)
|
||||
@@ -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
|
||||
=================================================================== *)
|
||||
@@ -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.
|
||||
=================================================================== *)
|
||||
@@ -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
|
||||
@@ -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
|
||||
=================================================================== *)
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)].
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user