pulsar v1.0.14: rename v0.2 Algebraic* → Transitional* (honesty fix)

The v0.2 API previously named Algebraic* was materially misleading:
the file-header docstring claimed "AlgebraicCombine is a pure function
of public-only material; no access to any party's seed share or the
master ML-DSA private key" while the implementation calls
mldsaSign(setup.SkBytes, message, ...) against the master sk packed
in AlgebraicSetup.SkBytes. The aggregator TCB at sign time is
identical to v0.1 reconstruct-and-sign; only the parties' side of
the protocol is honestly algebraic.

v1.0.14 fixes the honesty defect without changing the algorithm.

Forward-only renames (no compat aliases):
- AlgebraicThresholdSigner       → TransitionalThresholdSigner
- NewAlgebraicThresholdSigner    → NewTransitionalThresholdSigner
- AlgebraicCombine               → TransitionalAggregate
- AlgebraicSetup                 → TransitionalSetup
- AlgebraicRound1Message         → TransitionalRound1Message
- AlgebraicRound2Message         → TransitionalRound2Message
- DealAlgebraicShares            → DealTransitionalShares
- ErrAlg*                        → ErrTransitional*
- TestV02_*                      → TestTransitional_*

Docstrings rewritten to honestly disclose the SkBytes dependency:
- threshold_v02.go file header opens with HONEST SCOPE.
- TransitionalAggregate docstring spells out TCB equivalence with v0.1.
- TransitionalSetup names SkBytes as the v0.2 TCB defect.
- types.go Signature docstring rewritten.
- DEPLOYMENT-RUNBOOK.md rewritten with "suitable / not suitable"
  deployment guidance and a v0.3 milestone section.

New load-bearing test:
- TestTransitional_DependsOnSkBytes runs the full Round1/Round2
  cycle, sets setup.SkBytes = nil, asserts TransitionalAggregate
  returns ErrTransitionalNoSetup. Currently PASSES (because v0.2
  depends on SkBytes). When v0.3 lands, this test FAILS — that
  failure is the load-bearing red flag for v0.3 graduation.

New blocker tracker:
- BLOCKERS.md entry PULSAR-V03-1 documents the v0.3 algebraic-sign
  work: port FIPS 204 sign internals to polynomial-share arithmetic,
  drop SkBytes, rename Transitional* → Algebraic* (forward-only).

Wire protocol is byte-for-byte unchanged: PULSAR-ALG-* customisation
strings are preserved so existing v0.2 test vectors keep verifying.

Tests: full suite green (go test -count=1 -short -timeout 300s
./pkg/pulsar/ → ok 2.053s).
This commit is contained in:
2026-05-21 18:23:00 -07:00
parent bd7c96f28f
commit 64d5000717
6 changed files with 732 additions and 318 deletions
+120
View File
@@ -0,0 +1,120 @@
# BLOCKERS — luxfi/pulsar
Issue tracker for open work that gates a deployment claim or a
documentation claim. One entry per issue. Status is `OPEN` until the
graduation gate (the bottom of each entry) is met; then move to
`CLOSED` and link the commit / tag that closed it.
## PULSAR-V03-1 — v0.3 algebraic sign: remove SkBytes dependency from TransitionalAggregate
**Status**: OPEN
**Opened**: v1.0.14
**Closes**: v0.3 ship
**Owner**: cryptographer
**Related test**: `ref/go/pkg/pulsar/threshold_v02_test.go::TestTransitional_DependsOnSkBytes`
### Problem
`TransitionalAggregate` in `ref/go/pkg/pulsar/threshold_v02.go`
validates the v0.2 wire protocol (Round-1 commits, Round-2 MACs,
per-party `(Z, CS2, CT0)` contributions) but does NOT consume the
per-party contributions when emitting the FIPS 204 signature.
Instead it calls `mldsaSign(setup.SkBytes, message, ...)` against
the master ML-DSA private key packed in `TransitionalSetup.SkBytes`.
Effect: the aggregator TCB at sign time is identical to v0.1
reconstruct-and-sign. Only the parties' side of the protocol is
honestly algebraic. The naming `Transitional*` (introduced in
v1.0.14) declares this honestly; the `Algebraic*` naming that
v1.0.13 shipped was materially misleading.
### Implementation work
The v0.3 patch is conceptually a self-contained line-replacement in
`TransitionalAggregate`:
```
- sigBytes, err := mldsaSign(params.Mode, setup.SkBytes, message, nil, false, rand.Reader)
+ sigBytes, err := algebraicEmitSignature(params.Mode, r1ByID, r2ByID,
+ quorum, quorumEvalPoints, message, setup.Pub, setup.Rho, setup.Tr, setup.A)
```
What that requires:
1. **Polynomial-share aggregation in NTT-domain over GF(q)**:
- sum `Z` across the quorum → `z` (L polynomials in NTT-domain)
- sum `CS2` across the quorum → `c·s_2`
- sum `CT0` across the quorum → `c·t_0`
2. **FIPS 204 sign-side polynomial-ring primitives**, ported from
`mldsa_lattice.go` keygen-grade implementations to be
sign-grade byte-for-byte against cloudflare/circl's internal
package:
- `decompose` (FIPS 204 §4.5)
- `MakeHint` (FIPS 204 §4.6)
- `polyPackZ` (FIPS 204 §6.2 / Algorithm 23)
- `polyPackHint` (FIPS 204 §6.4 / Algorithm 25)
- signature serialisation matching `sigEncode` (FIPS 204
Algorithm 28)
3. **Global rejection bounds** (FIPS 204 §6.2):
- `||z||_∞ ≥ γ_1 − β` → restart
- `||r_0||_∞ ≥ γ_2 − β` → restart (`r_0` derived from
`w_0 - c·s_2 + c·t_0` mod q)
- `||c·t_0||_∞ ≥ γ_2` → restart
- `popcount(h) > ω` → restart
4. **Drop `SkBytes` from `TransitionalSetup`**. Update
`DealTransitionalShares` to not store it. Rename:
- `TransitionalAggregate` → `AlgebraicAggregate`
- `TransitionalSetup` → `AlgebraicSetup`
- `TransitionalThresholdSigner` → `AlgebraicThresholdSigner`
- `TransitionalRound1Message` → `AlgebraicRound1Message`
- `TransitionalRound2Message` → `AlgebraicRound2Message`
- `DealTransitionalShares` → `DealAlgebraicShares`
- `Err Transitional…` → `ErrAlgebraic…`
- Forward-only — no compat aliases.
The wire shape is already correct: Round-1 commits, intermediate
`w`-reveal, Round-2 `(Z, CS2, CT0)` payloads, and MAC structure
all stay byte-for-byte identical across v0.2 → v0.3. The change
is purely the inner sign step.
### Known obstacle
Per the v1.0.13 cryptographer report, the sign-path equivalents of
the FIPS 204 polynomial-ring primitives in `mldsa_lattice.go` have
a subtle Montgomery-scaling discrepancy against cloudflare/circl's
internal package that is non-trivial to diagnose without access to
circl's internal NTT test fixtures. The v0.3 work resolves this
discrepancy or routes around it (e.g., port directly from the
pq-crystals reference C implementation, cross-validate against
both circl and pq-crystals).
### Graduation gate
When all of the following are true, this issue is CLOSED:
1. `TestTransitional_DependsOnSkBytes` FAILS (because
`TransitionalAggregate` no longer needs `SkBytes`).
2. The `SkBytes` field is removed from `TransitionalSetup`.
3. The renames listed under (4) above are applied consistently
across `threshold_v02.go`, `threshold_v02_test.go`, `types.go`,
`DEPLOYMENT-RUNBOOK.md`.
4. The file-header honesty block in `threshold_v02.go` is rewritten
to drop the SkBytes caveat (the new block describes
`AlgebraicAggregate` as honestly public-BFT-safe).
5. `TestTransitional_DependsOnSkBytes` is deleted or rewritten as
`TestAlgebraic_*` documenting the v0.3 property.
6. `DEPLOYMENT-RUNBOOK.md`'s "v0.2 trust model" section is
rewritten to reflect that v0.3 is safe for public adversarial
deployments.
7. Full test suite passes:
`cd ref/go && GOWORK=off go test -count=1 -short -timeout 300s ./pkg/pulsar/`
8. Class N1 byte-equality holds against FIPS 204 ML-DSA Verify
(preserved property; v0.3 must not regress N1).
### Closes this entry
Tag: `pulsar v0.3.x` ship. Update this entry with the closing
commit SHA, tag, and date when graduated.
+70
View File
@@ -4,6 +4,76 @@ This file tracks substantive changes to the EasyCrypt proof
artifact, the spec, and the residual trust footprint. For
implementation-level changes see `git log`.
## v1.0.14 — v0.2 honesty rename (Algebraic → Transitional)
The v0.2 API previously named `Algebraic*` was materially
misleading: the file-header docstring claimed
`AlgebraicCombine is a pure function of public-only material;
no access to any party's seed share or the master ML-DSA private
key` while the implementation calls
`mldsaSign(setup.SkBytes, message, ...)` against the master sk
packed in `AlgebraicSetup.SkBytes`. The aggregator TCB at sign
time is therefore identical to v0.1 reconstruct-and-sign — only
the parties' side of the protocol is honestly algebraic.
v1.0.14 fixes the honesty defect without changing the algorithm:
**API rename (forward-only, no compat aliases):**
- `AlgebraicThresholdSigner` → `TransitionalThresholdSigner`
- `NewAlgebraicThresholdSigner` → `NewTransitionalThresholdSigner`
- `AlgebraicCombine` → `TransitionalAggregate`
- `AlgebraicSetup` → `TransitionalSetup`
- `AlgebraicRound1Message` → `TransitionalRound1Message`
- `AlgebraicRound2Message` → `TransitionalRound2Message`
- `DealAlgebraicShares` → `DealTransitionalShares`
- `ErrAlgRound1MACBad` → `ErrTransitionalRound1MACBad`
- `ErrAlgRound2MACBad` → `ErrTransitionalRound2MACBad`
- `ErrAlgRound2CommitBad` → `ErrTransitionalRound2CommitBad`
- `ErrAlgRestart` → `ErrTransitionalRestart`
- `ErrAlgNoSetup` → `ErrTransitionalNoSetup`
- All `TestV02_*` test functions renamed to `TestTransitional_*`.
**Honesty docstrings rewritten:**
- `threshold_v02.go` file header now opens with HONEST SCOPE
declaring the aggregator-side SkBytes dependency.
- `TransitionalAggregate` docstring spells out the TCB equivalence
with v0.1 at sign time.
- `TransitionalSetup` docstring declares SkBytes as the v0.2 TCB
defect and the v0.3 graduation gate.
- `types.go` Signature docstring rewritten.
- `DEPLOYMENT-RUNBOOK.md` v0.2 section rewritten with explicit
"suitable / not suitable" deployment guidance and a v0.3
milestone section.
**New load-bearing test:**
- `TestTransitional_DependsOnSkBytes` — runs the full Round1/Round2
cycle then sets `setup.SkBytes = nil` and asserts
`TransitionalAggregate` returns `ErrTransitionalNoSetup`. The
test currently PASSES (because v0.2 indeed depends on SkBytes).
When v0.3 ships and removes the SkBytes dependency, this test
starts FAILING — that failure is the load-bearing red flag
documenting the v0.3 graduation.
**New blocker tracker:**
- `BLOCKERS.md` entry `PULSAR-V03-1` documents the v0.3
algebraic-sign work: port FIPS 204 sign internals to
polynomial-share arithmetic, drop SkBytes, rename
`Transitional*` → `Algebraic*` again (forward-only).
The wire protocol is byte-for-byte unchanged: protocol constants
(`PULSAR-ALG-*` customisation strings) are preserved so any
existing v0.2 test vectors continue to verify.
## v1.0.13
(See git log; v0.2 algebraic threshold wire shape — the rename
above retroactively documents the honesty caveat that should have
shipped with this tag.)
## v0.1.0 (target tag: `submission-2026-11-16`)
### Proof artifact progression (v4 → v11)
+106 -56
View File
@@ -119,20 +119,24 @@ encoding differs) but byte-equivalent at the output (final FIPS 204
signature is identical). Coordinated validator-set rollover is
required.
## v0.2 algebraic threshold — public-BFT path
## v0.2 transitional threshold — trust model
> **Status (v1.0.13)**: v0.2 wire shape ships in
> `ref/go/pkg/pulsar/threshold_v02.go`. The protocol surface
> implements the FROST-for-FSwA flow: parties hold polynomial-vector
> Shamir shares of `(s_1, s_2, t_0)` over GF(q) (not seed shares),
> **Status (v1.0.14)**: v0.2 ships in
> `ref/go/pkg/pulsar/threshold_v02.go` under the **Transitional**
> name. The wire protocol is honestly algebraic — parties hold
> polynomial-vector Shamir shares of `(s_1, s_2, t_0)` over GF(q),
> Round 1 commits `w_i = A·y_i`, an intermediate `w`-reveal pass
> exchanges per-party `w_i` under MAC, Round 2 contributes
> `(z_i, cs2_i, ct0_i)` algebraically, and `AlgebraicCombine`
> aggregates after verifying every commit-bind and Round-2 MAC.
> `(z_i, cs2_i, ct0_i)` algebraically. **The aggregator side is NOT
> yet algebraic**: `TransitionalAggregate` validates every
> commit-bind and MAC, then calls `mldsa{44,65,87}.SignTo` against
> the master sk packed in `TransitionalSetup.SkBytes`. This is the
> v0.2 honesty caveat the rename from `Algebraic*` to
> `Transitional*` exists to declare.
### Two paths, two trust models
Pulsar v1.0.13 ships **both** trust-model targets in parallel:
Pulsar v1.0.14 ships **both** trust-model targets in parallel:
- **v0.1 reveal-and-aggregate path** — `pulsar.Combine` in
`ref/go/pkg/pulsar/threshold.go`. The aggregator briefly
@@ -142,65 +146,107 @@ Pulsar v1.0.13 ships **both** trust-model targets in parallel:
funds-bearing networks; the aggregator process is in the trusted
computing base for the duration of one sign call.
- **v0.2 algebraic-threshold path** — `pulsar.AlgebraicCombine` in
`ref/go/pkg/pulsar/threshold_v02.go`. The protocol-side wire shape
is the FROST-for-FSwA structure with polynomial-vector shares as
the carrier. Parties never hold the master seed in any form. **At
the inner sign step the current v1.0.13 implementation invokes
`mldsa{44,65,87}.SignTo` via the `AlgebraicSetup.SkBytes` field;
see the file header status block for the v0.3 work that closes
this transitional gap.** The wire shape, share distribution,
Round-1 commitments, intermediate `w`-reveal, and Round-2 MACs are
all production-grade and exercised by the test suite.
- **v0.2 transitional path** — `pulsar.TransitionalAggregate` in
`ref/go/pkg/pulsar/threshold_v02.go`. The protocol-side wire
shape is FROST-for-FSwA with polynomial-vector shares as the
carrier; parties never broadcast a share of the master seed in
any form. **The aggregator side still holds the master sk** via
`TransitionalSetup.SkBytes` and signs with it — the per-party
`(Z, CS2, CT0)` contributions are validated for commit-bind /
MAC integrity but are NOT consumed by the inner sign step. The
aggregator TCB at sign time is identical to v0.1; only the
parties' side of the protocol is honestly algebraic.
`TransitionalAggregate` enforces `len(setup.SkBytes) > 0` —
`TestTransitional_DependsOnSkBytes` pins this in code as the
load-bearing v0.3 graduation criterion.
### Public-BFT custody choice
### v0.2 trust model — when is it safe to deploy?
The v0.2 path is the **target** for public-BFT custody surfaces (Lux
consensus quorums where no single operator can be assumed in the TCB).
While the v0.3 work to remove the transitional `SkBytes` field is in
flight, integrators MUST choose between:
`TransitionalAggregate` IS safe to deploy when:
1. **v0.1 with TEE attestation** — production-ready today; operator
sign-off on the TEE requirement is on file.
2. **v0.2 wire-shape adoption** — production-deploy the protocol
surface today (the consensus-layer message routing, share
directory, and commit-and-reveal exchange match v0.3 byte-for-
byte); the inner sign step delegates to the v0.1 reconstruction
path **at the aggregator role only**, gated behind the same TEE
requirement until v0.3 ships. Adopters get the v0.2 wire-shape
migration done; the v0.3 inner-sign swap is a drop-in replacement
that preserves the message flow and test vectors.
1. The aggregator host is **already in the TCB by other means**
(TEE attestation pinned to a reproducible Pulsar build; HSM-
custody; single-operator deployment where the operator already
sees all key material).
2. M-Chain bridge custody where the bridge MPC node is the
aggregator and TEE attestation is mandatory at the operator
layer.
3. A-Chain confidential-compute subnets where the entire
aggregator process runs inside SGX/TDX with remote attestation.
### What v0.2 buys today (v1.0.13)
`TransitionalAggregate` is **NOT** safe to deploy when:
- **No seed-share leakage at parties**: polynomial-vector shares are
the only secret material parties hold. A `PolyKeyShare` is
information-theoretically `(t-1)`-secret against any coalition of
fewer than `t` parties.
1. The aggregator host is **not** in the TCB (public adversarial
deployment where any node may be compromised).
2. The validator set assumes "no single host holds the master sk
ever" — v0.2 violates this at the aggregator's brief sign
window.
For deployments in the "NOT safe" bucket, use v0.1 Combine behind
an explicit TEE attestation layer at the consumer (the wire shape
is mature, the TCB caveat is well-documented in the v0.1
disclosure section above), OR wait for v0.3.
### v0.3 milestone — graduation gate
The v0.3 work removes the `SkBytes` field from `TransitionalSetup`
entirely. The graduation gate is one bit:
> When `TransitionalAggregate` no longer needs `setup.SkBytes` —
> i.e. when it emits the FIPS 204 signature directly from the
> aggregated per-party `(Z, CS2, CT0)` contributions — the v0.3
> milestone is met.
Operationally this is tracked by `TestTransitional_DependsOnSkBytes`
in `threshold_v02_test.go`. The test currently PASSES (because
`TransitionalAggregate` does depend on `SkBytes`); when v0.3 lands,
that test FAILS, and the failure is the load-bearing red flag that
the v0.3 graduation is complete. At that point:
1. Delete the `SkBytes` field from `TransitionalSetup`.
2. Rename `TransitionalAggregate` → `AlgebraicAggregate`
(forward-only, no compat alias — same discipline as the
`Algebraic` → `Transitional` rename in v1.0.14).
3. Rewrite the file-header honesty block to drop the SkBytes
caveat.
4. Delete or rewrite `TestTransitional_DependsOnSkBytes`.
5. Update this section of DEPLOYMENT-RUNBOOK.md to reflect that
v0.3 is safe for public adversarial deployments.
6. Close `PULSAR-V03-1` in BLOCKERS.md.
Tracked in `BLOCKERS.md` as **PULSAR-V03-1**.
### What v0.2 buys today (v1.0.14)
- **No seed-share leakage at parties**: polynomial-vector shares
are the only secret material parties hold. A `PolyKeyShare` is
information-theoretically `(t-1)`-secret against any coalition
of fewer than `t` parties.
- **Wire-shape stability**: the v0.2 message flow (commit /
intermediate w-reveal / sign-contribute / aggregate) is stable
across v0.2 → v0.3. Consensus integrators that adopt the v0.2
protocol surface today get the message routing right;
v0.3 only changes the cryptographic content of `AlgebraicCombine`.
v0.3 only changes the cryptographic content of
`TransitionalAggregate` → `AlgebraicAggregate`.
- **Identifiable-abort gates**: every MAC and commit-bind check in
`AlgebraicCombine` emits `AbortEvidence` on tamper detection,
matching v0.1's complaint taxonomy.
`TransitionalAggregate` emits `AbortEvidence` on tamper
detection, matching v0.1's complaint taxonomy.
### What v0.2 does NOT yet do (v1.0.13)
### What v0.2 does NOT yet do (v1.0.14)
- The inner sign step still uses the master FIPS 204 packed sk via
`AlgebraicSetup.SkBytes`. This means the aggregator side of the
v0.2 path has the same TCB property as v0.1 at sign time. The v0.3
patch removes `SkBytes` from `AlgebraicSetup` and emits the
signature from the aggregated per-party contributions directly.
- The byte-equality test (`v0.2 algebraic signature byte-equal to
stock FIPS 204 ML-DSA on the same y aggregate`) is delivered via
the transitional path; the v0.3 pure-algebraic emission requires
a self-contained FIPS 204 sign-side polynomial-ring implementation
in `mldsa_lattice.go` (NTT, Montgomery, decompose, MakeHint) that
currently has a subtle Montgomery-scaling discrepancy against
cloudflare/circl's internal package — see `threshold_v02.go`
header status block for the diagnosis.
`TransitionalSetup.SkBytes`. The aggregator TCB at sign time is
identical to v0.1. The v0.3 patch removes `SkBytes` from
`TransitionalSetup` and emits the signature from the aggregated
per-party contributions directly.
- The byte-equality test (`v0.2 transitional signature byte-equal
to stock FIPS 204 ML-DSA on the same y aggregate`) is delivered
via the transitional path; the v0.3 pure-algebraic emission
requires a self-contained FIPS 204 sign-side polynomial-ring
implementation in `mldsa_lattice.go` (NTT, Montgomery, decompose,
MakeHint) that currently has a subtle Montgomery-scaling
discrepancy against cloudflare/circl's internal package — see
`threshold_v02.go` header for the diagnosis.
## Reference: Cryptographer GATEs
@@ -224,6 +270,10 @@ The four gates from `CRYPTOGRAPHER-SIGN-OFF.md`:
**Document metadata**
- Name: `DEPLOYMENT-RUNBOOK.md`
- Version: v0.1 (matches Pulsar v1.0.8)
- Date: 2026-05-18
- Version: v0.2 (matches Pulsar v1.0.14)
- Closes: `CRYPTOGRAPHER-SIGN-OFF.md` GATE-1.
- v1.0.14: renamed v0.2 API from `Algebraic*` → `Transitional*` and
rewrote the trust-model section to honestly disclose the
aggregator-side `SkBytes` dependency. The v0.2 wire protocol is
algebraic; the v0.2 aggregator is not. v0.3 (PULSAR-V03-1) closes
the gap.
+195 -168
View File
@@ -3,48 +3,47 @@
package pulsar
// threshold_v02.go — algebraic threshold ML-DSA scaffolding. The v0.2 path.
// threshold_v02.go — v0.2 TRANSITIONAL threshold ML-DSA.
//
// Status. v0.1 (threshold.go) reconstructs the master ML-DSA seed at
// the aggregator before calling stock FIPS 204 Sign. That instantiation
// is byte-equal to single-party signing — exactly the property we want
// — but the aggregator must be in the trusted computing base for the
// duration of one Sign call. v0.2, implemented here, is the public-
// BFT-safe construction at the protocol surface: parties hold
// polynomial-vector Shamir shares of (s_1, s_2, t_0) over GF(q) (not
// seed shares), they exchange commitments and reveals in the FROST-
// for-FSwA shape, and AlgebraicCombine aggregates contributions
// algebraically.
// HONEST SCOPE (read this before claiming public-BFT-safe):
//
// Implementation status (v1.0.13). The protocol wire shape, share
// distribution, commit-and-reveal Round 1, and aggregation Round 2
// are all implemented and tested. The FINAL aggregation step that
// produces a FIPS 204 byte-equal signature from the algebraic
// contributions currently uses cloudflare/circl's mldsa65.SignTo
// after assembling a polynomial-form PrivateKey from the per-party
// contributions. This is BYTE-EQUAL to FIPS 204 (it IS FIPS 204), and
// the assembled polynomial-form sk in the aggregator is the SAME
// secret material the master seed would derive — so the trust model
// at sign time is identical to v0.1's. The structural property that
// PARTIES never hold the master seed in any form is preserved
// (polynomial shares are the carrier, not seed shares), which is the
// PRECONDITION for a true algebraic instantiation in a follow-up
// patch.
// v0.2 carries polynomial-vector Shamir shares of (s_1, s_2, t_0)
// over GF(q) across all parties. The Round-1 + Round-2 wire
// protocol IS algebraic — no party broadcasts a share of the
// master ML-DSA seed at any point during signing.
//
// True-algebraic gap (v0.3 work). A from-scratch FROST-for-FSwA
// instantiation that emits a FIPS 204 byte-equal signature WITHOUT
// any aggregator-side polynomial-form sk reconstruction requires a
// self-contained FIPS 204 sign-side polynomial-ring implementation
// (NTT, Montgomery, decompose, MakeHint, packing). The current
// mldsa_lattice.go contains keygen-grade primitives validated against
// circl byte-for-byte; the sign-path equivalents are correct in
// algebraic intent but have a subtle Montgomery-scaling discrepancy
// against circl's internal package that is non-trivial to diagnose
// without access to circl's internal NTT test fixtures. The
// transitional sign path through circl.SignTo preserves the v0.2
// wire format and protocol structure so a v0.3 swap-in of the pure
// algebraic AlgebraicCombine inner step does not change the message
// flow or test vectors.
// HOWEVER, the inner FIPS 204 sign step inside
// TransitionalAggregate currently materialises the master sk
// bytes (TransitionalSetup.SkBytes) and calls
// mldsa{44,65,87}.SignTo on it. This means:
//
// - The PROTOCOL surface is public-BFT-safe (validators only
// see algebraic shares, never the master seed).
// - The OPERATIONAL TCB at the aggregator is identical to v0.1
// reconstruct-and-sign. Whoever runs TransitionalAggregate
// briefly holds the master sk in process memory.
//
// This is INTENTIONAL for v0.2 — it lets the wire shape stabilise
// while the FIPS 204 sign internals are ported to polynomial-share
// arithmetic. The v0.3 work (tracked in BLOCKERS.md as PULSAR-V03-1)
// gates on a self-contained polynomial sign implementation that
// never materialises the master sk.
//
// v0.2 IS suitable for: M-Chain bridge custody (TEE in TCB),
// A-Chain confidential compute, single-operator deployments where
// the aggregator host is already trusted.
//
// v0.2 IS NOT suitable for: public adversarial deployments where
// the aggregator host is not in the TCB. For those, use v0.1
// Combine + an explicit TEE attestation layer at the consumer, OR
// wait for v0.3 which removes the SkBytes dependency.
//
// Naming. The exported API uses "Transitional" (not "Algebraic")
// precisely because the wire shape is algebraic but the inner sign
// step is not. When v0.3 removes SkBytes and emits the signature
// directly from per-party (z_i, cs2_i, ct0_i) contributions, the
// "Transitional" prefix retires and a new pure-algebraic API ships
// alongside (forward-only, no compat alias).
//
// Construction. FROST-for-FSwA, the Fiat–Shamir-with-aborts shape of
// FROST (RFC 9591). The same algebraic shape implemented for R-LWE in
@@ -71,32 +70,23 @@ package pulsar
// where c = SampleInBall(c̃), c̃ = H(μ ‖ EncodeHigh(Σ w_j)).
// - Local rejection: if ‖z_i‖_∞ ≥ γ_1 - β · λ_i_bound, restart κ+1
// (the per-party local check is a fast-fail; the global check
// runs in AlgebraicCombine).
// runs in TransitionalAggregate).
//
// AlgebraicCombine
// - Sums z = Σ z_i, cs2 = Σ cs2_i, ct0 = Σ ct0_i (all in NTT-domain
// over GF(q)).
// - Recomputes w = Σ w_i, c̃, c by replay from Round-2 reveals.
// - Global rejection bounds (FIPS 204 §6.2): if ‖z‖_∞ ≥ γ_1 - β
// or ‖r_0‖_∞ ≥ γ_2 - β or ‖c·t_0‖_∞ ≥ γ_2 or popcount(h) > ω,
// return ErrRestart so the caller can rerun with κ+1.
// - Computes hint h, packages FIPS 204 (c̃, z, h) signature
// byte-identical to stock FIPS 204 Sign on the master sk.
//
// Public-BFT safety. AlgebraicCombine is a pure function of public-
// only material: Round-1 broadcasts, Round-2 reveals (y_i, z_i, cs2_i,
// ct0_i are all sent in the clear under MAC), and the group public
// key. No call to KeyFromSeed; no access to any party's seed share or
// the master ML-DSA private key. A snooping aggregator learns nothing
// it could not also learn from passively watching the wire — and the
// wire reveals no party's s_{1,i}, s_{2,i}, t_{0,i} polynomial shares
// directly (they appear only contracted with the public c · λ_i).
// TransitionalAggregate
// - Validates Round-1 commit-bind on every revealed w_i.
// - Validates Round-2 MACs (tamper detection on z_i, cs2_i, ct0_i).
// - Calls mldsaSign(setup.SkBytes, message, ...) for the inner
// sign step — this is the SkBytes dependency the v0.3 work
// removes (PULSAR-V03-1). The per-party Z, CS2, CT0
// contributions are validated for commit-bind / MAC integrity
// but are NOT yet consumed by the inner sign step. v0.3 swaps
// the inner step for direct (z, h) emission from the per-party
// contributions.
//
// Byte-equality with FIPS 204. The final (c̃, z, h) packs through the
// same FIPS 204 sigEncode (Algorithm 28). Any party that knew the
// master seed and produced y = Σ y_i, c̃, c identically would emit
// the same bytes. The v0.2 path is therefore Class N1 (FIPS 204
// output interchangeability) preserving by construction.
// same FIPS 204 sigEncode (Algorithm 28). The v0.2 path is therefore
// Class N1 (FIPS 204 output interchangeability) preserving by
// construction.
//
// References.
// - FIPS 204 — Module-Lattice-Based Digital Signature Standard
@@ -113,7 +103,7 @@ package pulsar
// Signatures
//
// File scope. This file owns the v0.2 wire types, signer state, and
// the AlgebraicCombine pure function. The FIPS 204 polynomial-ring
// the TransitionalAggregate function. The FIPS 204 polynomial-ring
// primitives live in mldsa_lattice.go; the GF(q) polynomial-Shamir
// helpers live in shamir_poly.go; the seed-to-key expansion lives in
// mldsa_keyderive.go.
@@ -131,10 +121,14 @@ import (
"golang.org/x/crypto/sha3"
)
// Customisation tags for the v0.2 algebraic path. Each tag is rotated
// independently of the v0.1 commit-and-reveal tags so a cross-protocol
// replay (v0.1 broadcast fed into v0.2 verifier or vice versa) is
// guaranteed to fail at the transcript hash.
// Customisation tags for the v0.2 transitional path. Each tag is
// rotated independently of the v0.1 commit-and-reveal tags so a
// cross-protocol replay (v0.1 broadcast fed into v0.2 verifier or
// vice versa) is guaranteed to fail at the transcript hash.
//
// The literal string values ("PULSAR-ALG-*") are wire-format
// constants pinned for test-vector stability; the Go identifier
// prefix (tagAlg*) is package-private and tracks the literal.
const (
tagAlgR1 = "PULSAR-ALG-R1-V1"
tagAlgR1MAC = "PULSAR-ALG-R1-MAC-V1"
@@ -143,30 +137,30 @@ const (
tagAlgY = "PULSAR-ALG-Y-V1"
)
// Errors specific to the v0.2 algebraic threshold path.
// Errors specific to the v0.2 transitional threshold path.
var (
// ErrAlgRound1MACBad is returned by Round2 when a peer's Round-1
// ErrTransitionalRound1MACBad is returned by Round2 when a peer's Round-1
// MAC fails to verify under the per-pair session key.
ErrAlgRound1MACBad = errors.New("pulsar: v0.2 Round-1 MAC verification failed")
ErrTransitionalRound1MACBad = errors.New("pulsar: v0.2 Round-1 MAC verification failed")
// ErrAlgRound2MACBad is returned by AlgebraicCombine when a
// ErrTransitionalRound2MACBad is returned by TransitionalAggregate when a
// Round-2 reveal carries an invalid MAC. v0.2 MACs Round-2 messages
// so the aggregator can attribute tampering during reconstruction.
ErrAlgRound2MACBad = errors.New("pulsar: v0.2 Round-2 MAC verification failed")
ErrTransitionalRound2MACBad = errors.New("pulsar: v0.2 Round-2 MAC verification failed")
// ErrAlgRound2CommitBad is returned by AlgebraicCombine when the
// ErrTransitionalRound2CommitBad is returned by TransitionalAggregate when the
// per-party w_i revealed in Round 2 does not match the Round-1
// commit D_i.
ErrAlgRound2CommitBad = errors.New("pulsar: v0.2 Round-2 reveal does not match Round-1 commit")
ErrTransitionalRound2CommitBad = errors.New("pulsar: v0.2 Round-2 reveal does not match Round-1 commit")
// ErrAlgRestart is returned by AlgebraicCombine when the global
// ErrTransitionalRestart is returned by TransitionalAggregate when the global
// FIPS 204 norm bounds reject the candidate signature. Caller
// restarts the protocol with κ+1.
ErrAlgRestart = errors.New("pulsar: v0.2 rejection-restart triggered (κ+1)")
ErrTransitionalRestart = errors.New("pulsar: v0.2 rejection-restart triggered (κ+1)")
// ErrAlgNoSetup is returned when v0.2 is invoked without prior
// ErrTransitionalNoSetup is returned when v0.2 is invoked without prior
// trusted-dealer setup or DKG-for-v0.2 (which produces PolyKeyShares).
ErrAlgNoSetup = errors.New("pulsar: v0.2 requires PolyKeyShare; v0.1 KeyShare is incompatible")
ErrTransitionalNoSetup = errors.New("pulsar: v0.2 requires PolyKeyShare; v0.1 KeyShare is incompatible")
)
// PolyKeyShare is one party's polynomial-vector Shamir share of the
@@ -195,9 +189,9 @@ type PolyKeyShare struct {
Mode Mode
}
// AlgebraicSetup carries the public per-party state derived once at
// TransitionalSetup carries the per-committee state derived once at
// trusted-dealer setup (or DKG-for-v0.2). Every party in the committee
// holds the same AlgebraicSetup; only the PolyKeyShare differs.
// holds the same TransitionalSetup; only the PolyKeyShare differs.
//
// A is the K × L public matrix in NTT-domain (so every party can do
// matrix–vector mul in the NTT domain at sign time without re-deriving
@@ -205,20 +199,29 @@ type PolyKeyShare struct {
// and FIPS 204 challenge derivation reproducibility). Tr is the SHAKE-
// 256(pk, 64) hash bound into the message digest μ per FIPS 204.
//
// SkBytes carries the full packed FIPS 204 ML-DSA private key needed
// for the transitional AlgebraicCombine inner sign call. In a v0.3
// pure-algebraic implementation, SkBytes would not appear in the
// AlgebraicSetup; it is here only to bridge the wire-shape correct
// FROST-for-FSwA outer protocol with a circl-backed inner sign step.
// IMPORTANT: SkBytes contains the master ML-DSA private key. It is
// stored in the AlgebraicSetup ONLY for the transitional path and
// MUST be wiped after the protocol moves to v0.3. The trust model at
// sign time is currently equivalent to v0.1's reveal-and-aggregate.
// SkBytes carries the FULL packed FIPS 204 ML-DSA master private key.
// It is the load-bearing reason v0.2 is "Transitional" rather than
// "Algebraic":
//
// - At sign time, TransitionalAggregate calls
// mldsaSign(params.Mode, setup.SkBytes, message, ...) — i.e. it
// materialises the master sk in process memory and signs with it.
// The per-party (Z, CS2, CT0) contributions are validated for
// wire-shape integrity but NOT consumed by the sign step.
//
// - Whoever holds a TransitionalSetup holds the master sk. The
// aggregator TCB is therefore identical to v0.1 reconstruct-and-
// sign at sign time, only with a different storage layer.
//
// - v0.3 (PULSAR-V03-1) removes SkBytes from TransitionalSetup
// entirely. The signature is then emitted from the per-party
// contributions directly; no party (including the aggregator) ever
// holds the master sk.
//
// The setup is byte-equal to what FIPS 204 ML-DSA-KeyGen would produce
// from the master seed: A, ρ, t1, tr are all derived from the same
// expansion path as cloudflare/circl's NewKeyFromSeed.
type AlgebraicSetup struct {
type TransitionalSetup struct {
Mode Mode
Pub *PublicKey
@@ -226,12 +229,14 @@ type AlgebraicSetup struct {
Tr [64]byte
A []polyVec // K × L matrix in NTT-domain
// SkBytes is the full FIPS 204 packed private key. Transitional
// path; v0.3 removes this. See package-header status block.
// SkBytes is the full FIPS 204 packed MASTER private key. It is
// the v0.2 TCB defect — v0.3 removes this field. See the file
// header and TransitionalAggregate docstring for the full TCB
// story.
SkBytes []byte
}
// DealAlgebraicShares is the trusted-dealer setup for the v0.2 path.
// DealTransitionalShares is the trusted-dealer setup for the v0.2 path.
// Given a 32-byte master seed (caller-controlled; MUST be wiped after),
// a committee directory (sorted NodeIDs), and the reconstruction
// threshold t, deals one PolyKeyShare per party such that any t parties
@@ -248,16 +253,16 @@ type AlgebraicSetup struct {
//
// Production note. A real v0.2 deployment will run a polynomial-share
// DKG (a follow-up to this PR) so no single party ever holds the
// master seed. DealAlgebraicShares is the trusted-dealer baseline used
// master seed. DealTransitionalShares is the trusted-dealer baseline used
// by KATs, tests, and single-operator deployments that already trust
// one dealer to materialise the seed once.
func DealAlgebraicShares(
func DealTransitionalShares(
params *Params,
committee []NodeID,
threshold int,
seed [SeedSize]byte,
rng io.Reader,
) (*AlgebraicSetup, []*PolyKeyShare, error) {
) (*TransitionalSetup, []*PolyKeyShare, error) {
if err := params.Validate(); err != nil {
return nil, nil, err
}
@@ -291,11 +296,11 @@ func DealAlgebraicShares(
}
defer zeroizeKeyMaterial(km)
// Build the public AlgebraicSetup. A is already in NTT-domain
// Build the public TransitionalSetup. A is already in NTT-domain
// inside deriveKeyMaterial. SkBytes is included as the transitional
// material for the inner sign call (see header status block).
pub := &PublicKey{Mode: params.Mode, Bytes: append([]byte{}, km.pub...)}
setup := &AlgebraicSetup{
setup := &TransitionalSetup{
Mode: params.Mode,
Pub: pub,
Rho: km.rho,
@@ -387,13 +392,13 @@ func DealAlgebraicShares(
return setup, out, nil
}
// AlgebraicRound1Message is the broadcast emitted by
// AlgebraicThresholdSigner.Round1.
// TransitionalRound1Message is the broadcast emitted by
// TransitionalThresholdSigner.Round1.
//
// Commit binds the party's w_i polynomial vector under τ_1; w_i is
// not revealed in Round 1 (only its hash digest) so a half-completed
// protocol leaks no signing randomness. The Round-2 reveal carries
// w_i alongside the per-party z_i, cs2_i, ct0_i; AlgebraicCombine
// w_i alongside the per-party z_i, cs2_i, ct0_i; TransitionalAggregate
// re-derives the commit and rejects on mismatch.
//
// MACs is the per-peer MAC of (Commit ‖ τ_1) under the pair session
@@ -401,7 +406,7 @@ func DealAlgebraicShares(
// ML-KEM-768 + ML-DSA-65 exchange used by v0.1 (identity.go's
// EstablishSession / SymmetricSession); no fresh key material is
// invented here.
type AlgebraicRound1Message struct {
type TransitionalRound1Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
@@ -409,12 +414,12 @@ type AlgebraicRound1Message struct {
MACs map[NodeID][32]byte
}
// AlgebraicRound2Message is the broadcast emitted by
// AlgebraicThresholdSigner.Round2.
// TransitionalRound2Message is the broadcast emitted by
// TransitionalThresholdSigner.Round2.
//
// W is the per-party w_i in packed polynomial-vector form (K · 4 · N
// bytes; un-normalised little-endian per-coefficient). After every
// quorum member's Round-2 lands, AlgebraicCombine sums them to recover
// quorum member's Round-2 lands, TransitionalAggregate sums them to recover
// w, derives c̃ and c, then validates the per-party Z, CS2, CT0
// contributions against the bound and emits the final signature.
//
@@ -424,16 +429,16 @@ type AlgebraicRound1Message struct {
//
// CS2 is c · λ_i · s_{2,i}, summed across the quorum to recover c·s_2.
// CT0 is c · λ_i · t_{0,i}, summed across the quorum to recover c·t_0.
// AlgebraicCombine uses these to compute the FIPS 204 r_0 vector and
// TransitionalAggregate uses these to compute the FIPS 204 r_0 vector and
// hint h.
//
// MAC binds Round-2 under the pair session key the same way Round-1
// does. Tampering with any of (W, Z, CS2, CT0) flips the MAC and is
// caught at AlgebraicCombine. The v0.1 path relied on commit-bind for
// caught at TransitionalAggregate. The v0.1 path relied on commit-bind for
// Round-2 integrity; v0.2 adopts explicit MACs because the Round-2
// payload binds material the aggregator cannot re-derive from Round-1
// alone (CS2, CT0).
type AlgebraicRound2Message struct {
type TransitionalRound2Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
@@ -453,18 +458,22 @@ type AlgebraicRound2Message struct {
// MAC binds (NodeID, sid, attempt, W, Z, CS2, CT0) under the
// per-peer session key. Per-peer MACs (one per peer) follow the
// v0.1 pattern; AlgebraicCombine looks up the MAC for itself when
// v0.1 pattern; TransitionalAggregate looks up the MAC for itself when
// verifying a peer's Round-2.
MACs map[NodeID][32]byte
}
// AlgebraicThresholdSigner is the v0.2 algebraic-threshold party state.
// TransitionalThresholdSigner is the v0.2 transitional-threshold
// party state. The "party side" of v0.2 is honestly algebraic
// (PolyKeyShare-only, no master seed). The "aggregator side" runs
// TransitionalAggregate and still holds the master sk briefly —
// see the file header for the full TCB story.
//
// Single-use: one (sid, attempt) per instance. On rejection-restart
// the protocol-layer driver allocates a fresh signer with attempt+1.
type AlgebraicThresholdSigner struct {
type TransitionalThresholdSigner struct {
Params *Params
Setup *AlgebraicSetup
Setup *TransitionalSetup
NodeID NodeID
Share *PolyKeyShare
@@ -492,7 +501,7 @@ type AlgebraicThresholdSigner struct {
lambda uint32
}
// NewAlgebraicThresholdSigner constructs a v0.2 party-state machine.
// NewTransitionalThresholdSigner constructs a v0.2 party-state machine.
//
// quorum is the t-element committee for this signature attempt, sorted
// ascending by NodeID. share is THIS party's PolyKeyShare. sessionKeys
@@ -502,9 +511,9 @@ type AlgebraicThresholdSigner struct {
//
// rng may be nil — crypto/rand is used by default. Pass a deterministic
// reader for KAT runs.
func NewAlgebraicThresholdSigner(
func NewTransitionalThresholdSigner(
params *Params,
setup *AlgebraicSetup,
setup *TransitionalSetup,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
@@ -512,12 +521,12 @@ func NewAlgebraicThresholdSigner(
sessionKeys map[NodeID][32]byte,
message []byte,
rng io.Reader,
) (*AlgebraicThresholdSigner, error) {
) (*TransitionalThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if setup == nil {
return nil, ErrAlgNoSetup
return nil, ErrTransitionalNoSetup
}
if share == nil {
return nil, ErrNilKey
@@ -576,7 +585,7 @@ func NewAlgebraicThresholdSigner(
// Instead: each party knows ONLY its own EvalPoint and the quorum's
// NodeID list. We require the EvalPoint of every quorum member to
// be deterministically derivable from its NodeID OR from its
// committee position. The trusted-dealer DealAlgebraicShares uses
// committee position. The trusted-dealer DealTransitionalShares uses
// committee-position-based EvalPoints, so we recover the same
// mapping by sorting the quorum (which we already require) and
// indexing the quorum into the original sorted committee. For the
@@ -590,7 +599,7 @@ func NewAlgebraicThresholdSigner(
// therefore needs the quorum's EvalPoint set, not just NodeIDs.
//
// The Signer keeps only its own share — it does not know peers'
// EvalPoints from the NewAlgebraicThresholdSigner inputs alone.
// EvalPoints from the NewTransitionalThresholdSigner inputs alone.
// We accept this and require the protocol-layer driver to thread
// the EvalPoint set through. The cleanest API encodes the quorum's
// EvalPoints in NodeID-sorted order alongside the quorum list. To
@@ -599,7 +608,7 @@ func NewAlgebraicThresholdSigner(
// pin position-based points OR we require the trusted-dealer setup
// to use EvalPointFromIDQ.
//
// Decision: DealAlgebraicShares above uses committee-position+1
// Decision: DealTransitionalShares above uses committee-position+1
// for KAT stability; we mirror that here by deriving the EvalPoint
// of every quorum member from its index in the sorted quorum AND
// the caller's pre-passed committee directory. To keep the API
@@ -608,15 +617,15 @@ func NewAlgebraicThresholdSigner(
// See QuorumEvalPoints helper below.
//
// In this constructor, we expect the caller to have set
// share.EvalPoint via DealAlgebraicShares and pass the quorum
// share.EvalPoint via DealTransitionalShares and pass the quorum
// EvalPoints implicitly via the SetEvalPoints method or via the
// AlgebraicSetup.QuorumEvalPoints field (preferred). We keep the
// TransitionalSetup.QuorumEvalPoints field (preferred). We keep the
// session minimal here and require the caller to call
// signer.SetQuorumEvalPoints AFTER construction; sign attempts
// without that call get ErrAlgNoSetup at Round1.
// without that call get ErrTransitionalNoSetup at Round1.
_ = myIdxInQuorum
s := &AlgebraicThresholdSigner{
s := &TransitionalThresholdSigner{
Params: params,
Setup: setup,
NodeID: share.NodeID,
@@ -638,7 +647,7 @@ func NewAlgebraicThresholdSigner(
// The session-keyed driver is responsible for collecting EvalPoints
// from PolyKeyShares at quorum-selection time (the trusted-dealer set
// or DKG-for-v0.2 output) and feeding them here.
func (s *AlgebraicThresholdSigner) SetQuorumEvalPoints(xs []uint32) error {
func (s *TransitionalThresholdSigner) SetQuorumEvalPoints(xs []uint32) error {
if len(xs) != len(s.Quorum) {
return errors.New("pulsar: quorum EvalPoint count does not match quorum size")
}
@@ -675,9 +684,9 @@ func (s *AlgebraicThresholdSigner) SetQuorumEvalPoints(xs []uint32) error {
// τ · η is the FIPS 204 challenge-share bound. This is the FROST-for-
// FSwA scaling described in Boschini–Takahashi–Tibouchi 2024/1113 §4,
// adapted to the FIPS 204 uniform-y distribution.
func (s *AlgebraicThresholdSigner) Round1() (*AlgebraicRound1Message, error) {
func (s *TransitionalThresholdSigner) Round1() (*TransitionalRound1Message, error) {
if s.lambda == 0 {
return nil, ErrAlgNoSetup
return nil, ErrTransitionalNoSetup
}
K, L, eta := modeShape(s.Params.Mode)
tau, _, _, _ := modeTauOmega(s.Params.Mode)
@@ -771,7 +780,7 @@ func (s *AlgebraicThresholdSigner) Round1() (*AlgebraicRound1Message, error) {
s.myW = wCopy
s.myWCoeff = wCoeff
return &AlgebraicRound1Message{
return &TransitionalRound1Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
@@ -783,13 +792,13 @@ func (s *AlgebraicThresholdSigner) Round1() (*AlgebraicRound1Message, error) {
// Round2 verifies the Round-1 MACs from every peer and emits this
// party's signature contribution.
//
// The aggregator (any quorum member running AlgebraicCombine) combines
// The aggregator (any quorum member running TransitionalAggregate) combines
// the per-party (w_i, z_i, cs2_i, ct0_i) to produce the final FIPS 204
// signature.
//
// On MAC failure, returns ErrAlgRound1MACBad with AbortEvidence the
// On MAC failure, returns ErrTransitionalRound1MACBad with AbortEvidence the
// caller broadcasts as a complaint.
func (s *AlgebraicThresholdSigner) Round2(round1 []*AlgebraicRound1Message) (*AlgebraicRound2Message, *AbortEvidence, error) {
func (s *TransitionalThresholdSigner) Round2(round1 []*TransitionalRound1Message) (*TransitionalRound2Message, *AbortEvidence, error) {
// Two-pass staging. FROST-for-FSwA has a fundamental need for the
// per-party w_i values to be known across the quorum BEFORE each
// party can compute its signature contribution (because c =
@@ -816,11 +825,11 @@ func (s *AlgebraicThresholdSigner) Round2(round1 []*AlgebraicRound1Message) (*Al
// If peerW is nil, Round2 enters a "W-only" mode that emits just
// this party's w_i for the intermediate reveal. The caller follows
// up with Round2Sign once peerW is collected.
func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Message, peerW map[NodeID]polyVec) (*AlgebraicRound2Message, *AbortEvidence, error) {
func (s *TransitionalThresholdSigner) round2EmitFull(round1 []*TransitionalRound1Message, peerW map[NodeID]polyVec) (*TransitionalRound2Message, *AbortEvidence, error) {
// Verify MACs (already done in the public Round2 caller above; this
// helper assumes that gate has passed, but we repeat for direct
// callers via Round2Sign). Note: this is the bottom-half routine;
// any MAC failure returns ErrAlgRound1MACBad as in v0.1.
// any MAC failure returns ErrTransitionalRound1MACBad as in v0.1.
for _, m := range round1 {
if m.SessionID != s.SessionID {
return nil, nil, ErrSessionMismatch
@@ -841,7 +850,7 @@ func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Messa
Kind: ComplaintMACFailure,
Accuser: s.NodeID,
Accused: m.NodeID,
}, ErrAlgRound1MACBad
}, ErrTransitionalRound1MACBad
}
if !ctEqualSlice(expectedMAC, gotMAC[:]) {
return nil, &AbortEvidence{
@@ -849,14 +858,14 @@ func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Messa
Accuser: s.NodeID,
Accused: m.NodeID,
Evidence: append(append([]byte{}, expectedMAC...), gotMAC[:]...),
}, ErrAlgRound1MACBad
}, ErrTransitionalRound1MACBad
}
}
K, L, _ := modeShape(s.Params.Mode)
_, _, _, gamma2 := modeTauOmega(s.Params.Mode)
r2 := &AlgebraicRound2Message{
r2 := &TransitionalRound2Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
@@ -877,13 +886,13 @@ func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Messa
}
wj, ok := peerW[m.NodeID]
if !ok {
return nil, nil, ErrAlgRound2CommitBad
return nil, nil, ErrTransitionalRound2CommitBad
}
tau := algTranscriptTau1(s.SessionID, s.Attempt, s.Quorum, m.NodeID, s.Setup.Pub, s.Message)
commitInput := append(append([]byte{}, packPolyVec(wj)...), tau...)
recomputed := transcriptHash32(tagAlgR1, commitInput)
if !ctEqual32(recomputed, m.Commit) {
return nil, nil, ErrAlgRound2CommitBad
return nil, nil, ErrTransitionalRound2CommitBad
}
}
@@ -953,7 +962,7 @@ func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Messa
// IMPORTANT. poly.ntt() operates in-place; we MUST work on a copy
// of the share polynomial each call, otherwise repeated Sign
// invocations on the same signer (or attempts under the same
// AlgebraicThresholdSigner if we ever supported retry-in-place)
// TransitionalThresholdSigner if we ever supported retry-in-place)
// would NTT-an-already-NTT'd value. Take a stack copy below.
z := make(polyVec, L)
for i := 0; i < L; i++ {
@@ -1020,7 +1029,7 @@ func (s *AlgebraicThresholdSigner) round2EmitFull(round1 []*AlgebraicRound1Messa
// Round-1.5 reveal) packed in a Round-2 message with Z=CS2=CT0=nil.
// The protocol-layer driver calls Round2W on every party, collects
// the peer-W map, then calls Round2Sign to produce the full broadcast.
func (s *AlgebraicThresholdSigner) Round2W(round1 []*AlgebraicRound1Message) (*AlgebraicRound2Message, *AbortEvidence, error) {
func (s *TransitionalThresholdSigner) Round2W(round1 []*TransitionalRound1Message) (*TransitionalRound2Message, *AbortEvidence, error) {
return s.round2EmitFull(round1, nil)
}
@@ -1030,47 +1039,62 @@ func (s *AlgebraicThresholdSigner) Round2W(round1 []*AlgebraicRound1Message) (*A
// peerW maps every other quorum member's NodeID to its revealed w_j.
// (This party's own w_i is taken from local state — do not include
// it in peerW.)
func (s *AlgebraicThresholdSigner) Round2Sign(round1 []*AlgebraicRound1Message, peerW map[NodeID]polyVec) (*AlgebraicRound2Message, *AbortEvidence, error) {
func (s *TransitionalThresholdSigner) Round2Sign(round1 []*TransitionalRound1Message, peerW map[NodeID]polyVec) (*TransitionalRound2Message, *AbortEvidence, error) {
return s.round2EmitFull(round1, peerW)
}
// AlgebraicCombine aggregates Round-1 and Round-2 messages into a
// TransitionalAggregate aggregates Round-1 and Round-2 messages into a
// FIPS 204 ML-DSA signature.
//
// The function returns:
// - sig != nil, err == nil : signature emits cleanly
// - sig == nil, err != nil : tampering/invalid input
//
// Protocol wire shape (commits, MACs, w-reveals) is fully checked
// here. The inner sign step uses circl.SignTo with setup.SkBytes —
// this is the transitional path documented at the top of this file.
// The polynomial-share contributions are verified for consistency
// (each party's commit binds its w_i; MACs bind Round-2 payloads),
// but the actual signature production is delegated to circl. v0.3
// replaces this with a pure-algebraic AlgebraicCombine that emits
// the FIPS 204 signature from the per-party (z_i, cs2_i, ct0_i)
// contributions directly.
func AlgebraicCombine(
// TCB HONESTY (read the file header before claiming public-BFT-safe):
//
// The protocol wire shape (commits, MACs, w-reveals, Z/CS2/CT0
// contributions) is fully checked here. The per-party Z, CS2, CT0
// contributions are validated for commit-bind and MAC integrity but
// are NOT consumed by the inner sign step. The actual signature
// comes from mldsaSign(setup.SkBytes, message, ...) — i.e. the
// master ML-DSA private key packed in TransitionalSetup.SkBytes.
//
// Therefore: whoever runs TransitionalAggregate briefly holds the
// master sk in process memory. The aggregator TCB is identical to
// v0.1 reconstruct-and-sign; only the storage layer for the master
// sk differs. The wire shape is the v0.2 deliverable; the v0.3
// deliverable (PULSAR-V03-1 in BLOCKERS.md) removes the SkBytes
// dependency by porting FIPS 204 sign internals to polynomial-share
// arithmetic and emits (z, h) directly from the per-party (Z, CS2,
// CT0) contributions.
//
// Suitable for: TEE-bound custody (M-Chain bridge, A-Chain confidential
// compute), single-operator deployments where the aggregator host is
// already in the TCB.
//
// NOT suitable for: public adversarial deployments where the aggregator
// host is not in the TCB.
func TransitionalAggregate(
params *Params,
setup *AlgebraicSetup,
setup *TransitionalSetup,
message []byte,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
quorumEvalPoints []uint32,
threshold int,
round1 []*AlgebraicRound1Message,
round2 []*AlgebraicRound2Message,
round1 []*TransitionalRound1Message,
round2 []*TransitionalRound2Message,
sessionKeys map[NodeID]map[NodeID][32]byte,
) (*Signature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if setup == nil {
return nil, ErrAlgNoSetup
return nil, ErrTransitionalNoSetup
}
if len(setup.SkBytes) == 0 {
return nil, ErrAlgNoSetup
return nil, ErrTransitionalNoSetup
}
if len(round1) < threshold || len(round2) < threshold {
return nil, ErrInsufficientQuor
@@ -1080,7 +1104,7 @@ func AlgebraicCombine(
}
// Index Round-1 by sender; verify session/attempt.
r1ByID := make(map[NodeID]*AlgebraicRound1Message, len(round1))
r1ByID := make(map[NodeID]*TransitionalRound1Message, len(round1))
for _, m := range round1 {
if m.SessionID != sessionID || m.Attempt != attempt {
return nil, ErrSessionMismatch
@@ -1090,7 +1114,7 @@ func AlgebraicCombine(
// Index Round-2 by sender; verify session/attempt, MAC, and
// commit-bind on W.
r2ByID := make(map[NodeID]*AlgebraicRound2Message, len(round2))
r2ByID := make(map[NodeID]*TransitionalRound2Message, len(round2))
for _, r2 := range round2 {
if r2.SessionID != sessionID || r2.Attempt != attempt {
return nil, ErrSessionMismatch
@@ -1104,7 +1128,7 @@ func AlgebraicCombine(
commitInput := append(append([]byte{}, r2.W...), tau1...)
recomputed := transcriptHash32(tagAlgR1, commitInput)
if !ctEqual32(recomputed, r1.Commit) {
return nil, ErrAlgRound2CommitBad
return nil, ErrTransitionalRound2CommitBad
}
// Verify Round-2 MAC. The aggregator's role is canonically
// quorum[0]; verify each peer's MAC addressed to that role.
@@ -1123,10 +1147,10 @@ func AlgebraicCombine(
expected := kmac256(key[:], tau2, 32, tagAlgR2MAC)
got, gotOK := r2.MACs[quorum[0]]
if !gotOK {
return nil, ErrAlgRound2MACBad
return nil, ErrTransitionalRound2MACBad
}
if !ctEqualSlice(expected, got[:]) {
return nil, ErrAlgRound2MACBad
return nil, ErrTransitionalRound2MACBad
}
}
r2ByID[r2.NodeID] = r2
@@ -1135,11 +1159,14 @@ func AlgebraicCombine(
return nil, ErrInsufficientQuor
}
// Transitional inner sign. The v0.2 protocol-side wire shape is
// fully validated above; the inner sign step delegates to circl's
// SignTo using setup.SkBytes. A v0.3 pure-algebraic AlgebraicCombine
// replaces this step with direct (z, h) emission from the per-party
// (W, Z, CS2, CT0) contributions — without ever touching SkBytes.
// Transitional inner sign — SkBytes dependency lives here.
//
// The v0.2 protocol-side wire shape is fully validated above; the
// inner sign step calls mldsaSign with setup.SkBytes (the master
// FIPS 204 packed sk). The per-party (Z, CS2, CT0) contributions
// are NOT consumed here. v0.3 (PULSAR-V03-1) replaces this line
// with direct (z, h) emission from the per-party contributions
// and drops the SkBytes field entirely.
sigBytes, err := mldsaSign(params.Mode, setup.SkBytes, message, nil, false, rand.Reader)
if err != nil {
return nil, err
@@ -1336,7 +1363,7 @@ func polyDeriveUniformBounded(p *poly, seed *[64]byte, nonce uint16, bound uint3
// SAME canonical-sorted order as the quorum.
//
// Used by the protocol-layer driver to thread EvalPoints from the
// trusted-dealer or DKG output into AlgebraicCombine and into
// trusted-dealer or DKG output into TransitionalAggregate and into
// signer.SetQuorumEvalPoints.
func QuorumEvalPoints(quorum []NodeID, shares []*PolyKeyShare) ([]uint32, error) {
byID := make(map[NodeID]uint32, len(shares))
+220 -86
View File
@@ -4,17 +4,22 @@
package pulsar
// threshold_v02_test.go — round-trip + audit tests for the v0.2
// algebraic threshold ML-DSA path.
// TRANSITIONAL threshold ML-DSA path. See threshold_v02.go file
// header for the honest TCB scope.
//
// Test discipline:
// 1. Full Round1/Round2/AlgebraicCombine cycle across n-of-t.
// 1. Full Round1/Round2/TransitionalAggregate cycle across n-of-t.
// 2. Output signature byte-passes FIPS 204 Verify against the
// group public key.
// 3. AlgebraicCombine never invokes KeyFromSeed or reads sk.Bytes
// — enforced by a code-level assertion (see TestV02_NoMasterKeyAccess).
// 3. Parties never broadcast a share of the master ML-DSA seed —
// enforced structurally by the wire types (PolyKeyShare, not
// KeyShare). The aggregator DOES briefly hold the master sk
// via TransitionalSetup.SkBytes; see
// TestTransitional_DependsOnSkBytes for the load-bearing
// v0.3 graduation criterion.
// 4. Multiple parameter sets: (5, 3), (7, 4), (10, 7).
// 5. Tamper detection: bad MAC → ComplaintMACFailure; bad reveal
// → ErrAlgRound2CommitBad.
// → ErrTransitionalRound2CommitBad.
// 6. Rejection restart: contrive a session that rejects on κ=0 and
// succeeds on κ=1.
@@ -23,17 +28,17 @@ import (
"testing"
)
// stageAlgebraic runs a deterministic algebraic-threshold ceremony to
// produce a v0.2 signature, returning the signature, group public key,
// and the trusted-dealer setup. Used by every test below.
func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, attempt uint32) (
// stageTransitional runs a deterministic v0.2 transitional-threshold
// ceremony to produce a signature, returning the signature, group
// public key, and the trusted-dealer setup. Used by every test below.
func stageTransitional(t *testing.T, n, threshold int, msg []byte, sid [16]byte, attempt uint32) (
*Signature,
*PublicKey,
*AlgebraicSetup,
*TransitionalSetup,
[]*PolyKeyShare,
*identityFixture,
[]*AlgebraicRound1Message,
[]*AlgebraicRound2Message,
[]*TransitionalRound1Message,
[]*TransitionalRound2Message,
error,
) {
t.Helper()
@@ -47,7 +52,7 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
var seed [SeedSize]byte
copy(seed[:], "pulsar-v02-test-master-seed-32!!")
dealerRng := deterministicReader([]byte{0xAB, 0xCD, byte(n), byte(threshold)})
setup, shares, err := DealAlgebraicShares(params, committee, threshold, seed, dealerRng)
setup, shares, err := DealTransitionalShares(params, committee, threshold, seed, dealerRng)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, err
}
@@ -64,7 +69,7 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
quorumShares[i] = shares[i]
}
// Quorum must be sorted ascending. shares are emitted by
// DealAlgebraicShares in canonical sorted order so quorum is
// DealTransitionalShares in canonical sorted order so quorum is
// already sorted.
// Per-pair session keys for the quorum.
@@ -77,9 +82,9 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
}
// Per-party signers.
signers := make([]*AlgebraicThresholdSigner, threshold)
signers := make([]*TransitionalThresholdSigner, threshold)
for i := 0; i < threshold; i++ {
s, err := NewAlgebraicThresholdSigner(params, setup, sid, attempt, quorum, quorumShares[i],
s, err := NewTransitionalThresholdSigner(params, setup, sid, attempt, quorum, quorumShares[i],
allSessionKeys[quorum[i]], msg, deterministicReader([]byte{0xFE, byte(i), byte(attempt)}))
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, err
@@ -91,7 +96,7 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
}
// Round 1.
r1 := make([]*AlgebraicRound1Message, threshold)
r1 := make([]*TransitionalRound1Message, threshold)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
@@ -101,7 +106,7 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
}
// Round 2-W (intermediate w-reveal). Each party emits w_i.
r2W := make([]*AlgebraicRound2Message, threshold)
r2W := make([]*TransitionalRound2Message, threshold)
for i, s := range signers {
m, _, err := s.Round2W(r1)
if err != nil {
@@ -126,7 +131,7 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
}
// Round 2-Sign (final per-party signature contribution).
r2 := make([]*AlgebraicRound2Message, threshold)
r2 := make([]*TransitionalRound2Message, threshold)
for i, s := range signers {
m, _, err := s.Round2Sign(r1, peerWByParty[i])
if err != nil {
@@ -136,12 +141,12 @@ func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, at
}
// Aggregate.
sig, err := AlgebraicCombine(params, setup, msg, sid, attempt, quorum, evalPoints,
sig, err := TransitionalAggregate(params, setup, msg, sid, attempt, quorum, evalPoints,
threshold, r1, r2, allSessionKeys)
return sig, setup.Pub, setup, shares, ident, r1, r2, err
}
func TestV02_RoundTripAndVerify(t *testing.T) {
func TestTransitional_RoundTripAndVerify(t *testing.T) {
for _, tc := range []struct {
name string
n, t int
@@ -164,11 +169,11 @@ func TestV02_RoundTripAndVerify(t *testing.T) {
err error
)
for attempt := uint32(0); attempt < 16; attempt++ {
sig, pub, _, _, _, _, _, err = stageAlgebraic(t, tc.n, tc.t, msg, sid, attempt)
sig, pub, _, _, _, _, _, err = stageTransitional(t, tc.n, tc.t, msg, sid, attempt)
if err == nil {
break
}
if err != ErrAlgRestart {
if err != ErrTransitionalRestart {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
@@ -188,21 +193,18 @@ func TestV02_RoundTripAndVerify(t *testing.T) {
}
}
// TestV02_NoMasterKeyAccess asserts that AlgebraicCombine does not
// invoke KeyFromSeed or any other master-seed derivation at the
// PROTOCOL surface. In v1.0.13 the inner sign step uses
// setup.SkBytes (the full FIPS 204 packed sk), which is the same
// secret material a seed reconstruction would re-derive — so the
// trust model at the AGGREGATOR side is currently equivalent to
// v0.1's reveal-and-aggregate. The structural property that PARTIES
// never hold the master seed is preserved (polynomial shares are the
// carrier, not seed shares).
// TestTransitional_NoPartyHoldsMasterSeed pins the PARTY-side
// structural property of v0.2: no party (other than the aggregator,
// briefly, via TransitionalSetup.SkBytes) ever holds the master
// ML-DSA seed in any form. Parties carry PolyKeyShare values
// (polynomial-vector Shamir shares of (s_1, s_2, t_0)), which are
// (t-1)-secret against any sub-quorum coalition.
//
// The v0.3 pure-algebraic path will close this gap by removing
// SkBytes from AlgebraicSetup entirely; this test pins the
// PROTOCOL-side invariant that ensure-the-aggregator-uses-only-
// public-and-shared-material remains the v0.2 wire-shape goal.
func TestV02_NoMasterKeyAccess(t *testing.T) {
// This test does NOT claim aggregator TCB freedom. The aggregator
// running TransitionalAggregate briefly materialises the master sk
// from setup.SkBytes — see TestTransitional_DependsOnSkBytes for
// the v0.3 graduation criterion that removes that dependency.
func TestTransitional_NoPartyHoldsMasterSeed(t *testing.T) {
msg := []byte("no master key access check")
var sid [16]byte
copy(sid[:], "v02-nokey-test01")
@@ -211,11 +213,11 @@ func TestV02_NoMasterKeyAccess(t *testing.T) {
var sig *Signature
var pub *PublicKey
for attempt := uint32(0); attempt < 16; attempt++ {
sig, pub, _, _, _, _, _, err = stageAlgebraic(t, 5, 3, msg, sid, attempt)
sig, pub, _, _, _, _, _, err = stageTransitional(t, 5, 3, msg, sid, attempt)
if err == nil {
break
}
if err != ErrAlgRestart {
if err != ErrTransitionalRestart {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
@@ -226,15 +228,16 @@ func TestV02_NoMasterKeyAccess(t *testing.T) {
if err := Verify(MustParamsFor(ModeP65), pub, msg, sig); err != nil {
t.Fatalf("v0.2 sig fails Verify: %v", err)
}
// AlgebraicCombine returned a Signature constructed from public
// material only. The fact that this code path completed without
// taking a *PrivateKey or master seed proves the API does not
// require master-secret access.
// The signers (party-side state) never took a *PrivateKey nor a
// seed: they hold only PolyKeyShare values, which are
// information-theoretically (t-1)-secret. The aggregator side
// still holds setup.SkBytes in v0.2 — see
// TestTransitional_DependsOnSkBytes.
}
// TestV02_BadMAC_Detected confirms tampering a Round-1 MAC causes a
// TestTransitional_BadMAC_Detected confirms tampering a Round-1 MAC causes a
// peer to emit ComplaintMACFailure during Round2.
func TestV02_BadMAC_Detected(t *testing.T) {
func TestTransitional_BadMAC_Detected(t *testing.T) {
params := MustParamsFor(ModeP65)
msg := []byte("v0.2 bad-mac")
var sid [16]byte
@@ -244,7 +247,7 @@ func TestV02_BadMAC_Detected(t *testing.T) {
ident := newIdentityFixture(t, committee, []byte{0xAA, 0xBB})
var seed [SeedSize]byte
copy(seed[:], "v02-mac-test-seed-32-byteslayer!")
setup, shares, err := DealAlgebraicShares(params, committee, 3, seed, deterministicReader([]byte{0xCC, 0xDD}))
setup, shares, err := DealTransitionalShares(params, committee, 3, seed, deterministicReader([]byte{0xCC, 0xDD}))
if err != nil {
t.Fatal(err)
}
@@ -258,14 +261,14 @@ func TestV02_BadMAC_Detected(t *testing.T) {
t.Fatal(err)
}
signers := make([]*AlgebraicThresholdSigner, 3)
signers := make([]*TransitionalThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewAlgebraicThresholdSigner(params, setup, sid, 1, quorum, shares[i],
s, _ := NewTransitionalThresholdSigner(params, setup, sid, 1, quorum, shares[i],
sessionKeys[quorum[i]], msg, deterministicReader([]byte{byte(i)}))
_ = s.SetQuorumEvalPoints(evalPoints)
signers[i] = s
}
r1 := make([]*AlgebraicRound1Message, 3)
r1 := make([]*TransitionalRound1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1()
}
@@ -275,7 +278,7 @@ func TestV02_BadMAC_Detected(t *testing.T) {
r1[0].MACs[quorum[1]] = mac
}
_, ev, err := signers[1].Round2W(r1)
if err != ErrAlgRound1MACBad {
if err != ErrTransitionalRound1MACBad {
t.Fatalf("MAC tamper not caught: %v", err)
}
if ev == nil || ev.Kind != ComplaintMACFailure {
@@ -283,9 +286,9 @@ func TestV02_BadMAC_Detected(t *testing.T) {
}
}
// TestV02_BadCommit_Detected confirms a Round-1 commit that does not
// match the revealed w_i is rejected at AlgebraicCombine.
func TestV02_BadCommit_Detected(t *testing.T) {
// TestTransitional_BadCommit_Detected confirms a Round-1 commit that
// does not match the revealed w_i is rejected at TransitionalAggregate.
func TestTransitional_BadCommit_Detected(t *testing.T) {
params := MustParamsFor(ModeP65)
msg := []byte("v0.2 bad-commit")
var sid [16]byte
@@ -293,14 +296,14 @@ func TestV02_BadCommit_Detected(t *testing.T) {
var (
err error
r1 []*AlgebraicRound1Message
r2 []*AlgebraicRound2Message
r1 []*TransitionalRound1Message
r2 []*TransitionalRound2Message
)
committee := makeCommittee(5)
ident := newIdentityFixture(t, committee, []byte{0xCC, 0xDE})
var seed [SeedSize]byte
copy(seed[:], "v02-commit-test-seed-bytes-fix32")
setup, shares, err := DealAlgebraicShares(params, committee, 3, seed, deterministicReader([]byte{0xCC, 0xEE}))
setup, shares, err := DealTransitionalShares(params, committee, 3, seed, deterministicReader([]byte{0xCC, 0xEE}))
if err != nil {
t.Fatal(err)
}
@@ -310,18 +313,18 @@ func TestV02_BadCommit_Detected(t *testing.T) {
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
evalPoints, _ := QuorumEvalPoints(quorum, shares)
signers := make([]*AlgebraicThresholdSigner, 3)
signers := make([]*TransitionalThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewAlgebraicThresholdSigner(params, setup, sid, 1, quorum, shares[i],
s, _ := NewTransitionalThresholdSigner(params, setup, sid, 1, quorum, shares[i],
sessionKeys[quorum[i]], msg, deterministicReader([]byte{0x77, byte(i)}))
_ = s.SetQuorumEvalPoints(evalPoints)
signers[i] = s
}
r1 = make([]*AlgebraicRound1Message, 3)
r1 = make([]*TransitionalRound1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2W := make([]*AlgebraicRound2Message, 3)
r2W := make([]*TransitionalRound2Message, 3)
for i, s := range signers {
r2W[i], _, _ = s.Round2W(r1)
}
@@ -337,34 +340,34 @@ func TestV02_BadCommit_Detected(t *testing.T) {
}
peerWByParty[i] = peerW
}
r2 = make([]*AlgebraicRound2Message, 3)
r2 = make([]*TransitionalRound2Message, 3)
for i, s := range signers {
r2[i], _, _ = s.Round2Sign(r1, peerWByParty[i])
}
// Tamper party 1's W bytes at AlgebraicCombine time.
// Tamper party 1's W bytes at TransitionalAggregate time.
r2[1].W[0] ^= 0xAA
_, err = AlgebraicCombine(params, setup, msg, sid, 1, quorum, evalPoints, 3, r1, r2, sessionKeys)
if err != ErrAlgRound2CommitBad {
_, err = TransitionalAggregate(params, setup, msg, sid, 1, quorum, evalPoints, 3, r1, r2, sessionKeys)
if err != ErrTransitionalRound2CommitBad {
t.Fatalf("commit-bind not enforced: %v", err)
}
}
// TestV02_RestartConverges drives the protocol through several
// TestTransitional_RestartConverges drives the protocol through several
// attempts with random RNG; at least one attempt must accept. This
// pins the rejection-restart loop.
func TestV02_RestartConverges(t *testing.T) {
func TestTransitional_RestartConverges(t *testing.T) {
msg := []byte("v0.2 restart-converges")
var sid [16]byte
copy(sid[:], "v02-restart-0001")
var err error
accepted := false
for attempt := uint32(0); attempt < 64; attempt++ {
_, _, _, _, _, _, _, err = stageAlgebraic(t, 5, 3, msg, sid, attempt)
_, _, _, _, _, _, _, err = stageTransitional(t, 5, 3, msg, sid, attempt)
if err == nil {
accepted = true
break
}
if err != ErrAlgRestart {
if err != ErrTransitionalRestart {
t.Fatalf("attempt %d unexpected error: %v", attempt, err)
}
}
@@ -373,20 +376,20 @@ func TestV02_RestartConverges(t *testing.T) {
}
}
// TestV02_DealerReproducible checks the trusted-dealer setup is
// TestTransitional_DealerReproducible checks the trusted-dealer setup is
// deterministic given a fixed master seed + RNG seed. Important for
// KAT generation.
func TestV02_DealerReproducible(t *testing.T) {
func TestTransitional_DealerReproducible(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(5)
var seed [SeedSize]byte
copy(seed[:], "v02-reproducible-master-seed-32!")
setup1, shares1, err := DealAlgebraicShares(params, committee, 3, seed, deterministicReader([]byte{0x01}))
setup1, shares1, err := DealTransitionalShares(params, committee, 3, seed, deterministicReader([]byte{0x01}))
if err != nil {
t.Fatal(err)
}
setup2, shares2, err := DealAlgebraicShares(params, committee, 3, seed, deterministicReader([]byte{0x01}))
setup2, shares2, err := DealTransitionalShares(params, committee, 3, seed, deterministicReader([]byte{0x01}))
if err != nil {
t.Fatal(err)
}
@@ -422,7 +425,7 @@ func TestV02_DealerReproducible(t *testing.T) {
}
}
// TestV02_AlgebraicAgreesWithSinglePartySign checks that v0.2 output
// TestTransitional_AgreesWithSinglePartySign checks that v0.2 output
// equals what stock FIPS 204 ML-DSA Sign would produce on the master
// seed, when both use the same y and randomness. This is the
// byte-equality contract.
@@ -436,7 +439,7 @@ func TestV02_DealerReproducible(t *testing.T) {
// signature on the master sk over that message. (FIPS 204 Verify is
// the canonical decoder, so "passes Verify" = "is a valid byte-encoded
// FIPS 204 signature".)
func TestV02_AlgebraicAgreesWithSinglePartySign(t *testing.T) {
func TestTransitional_AgreesWithSinglePartySign(t *testing.T) {
msg := []byte("v0.2 agrees with single-party FIPS 204")
var sid [16]byte
copy(sid[:], "v02-agree-001234")
@@ -444,11 +447,11 @@ func TestV02_AlgebraicAgreesWithSinglePartySign(t *testing.T) {
var pub *PublicKey
var err error
for attempt := uint32(0); attempt < 16; attempt++ {
sig, pub, _, _, _, _, _, err = stageAlgebraic(t, 5, 3, msg, sid, attempt)
sig, pub, _, _, _, _, _, err = stageTransitional(t, 5, 3, msg, sid, attempt)
if err == nil {
break
}
if err != ErrAlgRestart {
if err != ErrTransitionalRestart {
t.Fatalf("unexpected err on attempt %d: %v", attempt, err)
}
}
@@ -460,10 +463,10 @@ func TestV02_AlgebraicAgreesWithSinglePartySign(t *testing.T) {
}
}
// TestV02_RealRNG_Smokes covers the production code path with
// TestTransitional_RealRNG_Smokes covers the production code path with
// crypto/rand to make sure no determinism assumption is baked into
// the v0.2 internals.
func TestV02_RealRNG_Smokes(t *testing.T) {
func TestTransitional_RealRNG_Smokes(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(5)
ident := newIdentityFixture(t, committee, []byte("real-rng-fixture"))
@@ -471,7 +474,7 @@ func TestV02_RealRNG_Smokes(t *testing.T) {
if _, err := rand.Read(seed[:]); err != nil {
t.Fatal(err)
}
setup, shares, err := DealAlgebraicShares(params, committee, 3, seed, nil)
setup, shares, err := DealTransitionalShares(params, committee, 3, seed, nil)
if err != nil {
t.Fatal(err)
}
@@ -488,18 +491,18 @@ func TestV02_RealRNG_Smokes(t *testing.T) {
evalPoints, _ := QuorumEvalPoints(quorum, shares)
for attempt := uint32(0); attempt < 32; attempt++ {
signers := make([]*AlgebraicThresholdSigner, 3)
signers := make([]*TransitionalThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewAlgebraicThresholdSigner(params, setup, sid, attempt, quorum, shares[i],
s, _ := NewTransitionalThresholdSigner(params, setup, sid, attempt, quorum, shares[i],
sessionKeys[quorum[i]], msg, nil)
_ = s.SetQuorumEvalPoints(evalPoints)
signers[i] = s
}
r1 := make([]*AlgebraicRound1Message, 3)
r1 := make([]*TransitionalRound1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2W := make([]*AlgebraicRound2Message, 3)
r2W := make([]*TransitionalRound2Message, 3)
for i, s := range signers {
r2W[i], _, _ = s.Round2W(r1)
}
@@ -515,12 +518,12 @@ func TestV02_RealRNG_Smokes(t *testing.T) {
}
peerWByParty[i] = peerW
}
r2 := make([]*AlgebraicRound2Message, 3)
r2 := make([]*TransitionalRound2Message, 3)
for i, s := range signers {
r2[i], _, _ = s.Round2Sign(r1, peerWByParty[i])
}
sig, err := AlgebraicCombine(params, setup, msg, sid, attempt, quorum, evalPoints, 3, r1, r2, sessionKeys)
if err == ErrAlgRestart {
sig, err := TransitionalAggregate(params, setup, msg, sid, attempt, quorum, evalPoints, 3, r1, r2, sessionKeys)
if err == ErrTransitionalRestart {
continue
}
if err != nil {
@@ -533,3 +536,134 @@ func TestV02_RealRNG_Smokes(t *testing.T) {
}
t.Fatal("real-rng did not converge within 32 attempts")
}
// TestTransitional_DependsOnSkBytes pins the v0.2 honesty caveat in
// code. The aggregator path (TransitionalAggregate) currently
// materialises the master ML-DSA private key via
// TransitionalSetup.SkBytes — this is the load-bearing TCB defect
// "Transitional" names. The test exercises a full Round1/Round2
// cycle, then nils setup.SkBytes immediately before
// TransitionalAggregate and asserts the call fails.
//
// v0.3 GRADUATION CRITERION (PULSAR-V03-1 in BLOCKERS.md):
//
// When v0.3 ships a pure-algebraic aggregator that emits (z, h)
// directly from the per-party (Z, CS2, CT0) contributions and
// drops SkBytes from TransitionalSetup, THIS TEST WILL START
// FAILING (because nil-SkBytes will no longer error). That
// failure is the LOAD-BEARING RED FLAG that v0.3 has landed:
//
// 1. Delete the SkBytes field from TransitionalSetup.
// 2. Delete or rewrite this test.
// 3. Rename TransitionalAggregate → AlgebraicAggregate
// (forward-only, no compat alias).
// 4. Rewrite the file-header honesty block to match.
// 5. Update DEPLOYMENT-RUNBOOK.md v0.3 milestone section.
// 6. Close PULSAR-V03-1.
//
// Until then, the test passing is the load-bearing proof that the
// docstrings are not lying: TransitionalAggregate IS
// mldsaSign(setup.SkBytes, …) at the inner sign step.
func TestTransitional_DependsOnSkBytes(t *testing.T) {
params := MustParamsFor(ModeP65)
msg := []byte("v0.2 SkBytes dependency honesty pin")
var sid [16]byte
copy(sid[:], "v02-sk-honesty01")
committee := makeCommittee(5)
ident := newIdentityFixture(t, committee, []byte{0x5C, 0xBE})
var seed [SeedSize]byte
copy(seed[:], "v02-sk-honesty-master-seed-bytes")
setup, shares, err := DealTransitionalShares(params, committee, 3, seed,
deterministicReader([]byte{0x5C, 0xBE}))
if err != nil {
t.Fatal(err)
}
for i := range seed {
seed[i] = 0
}
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
evalPoints, err := QuorumEvalPoints(quorum, shares)
if err != nil {
t.Fatal(err)
}
signers := make([]*TransitionalThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, err := NewTransitionalThresholdSigner(params, setup, sid, 1, quorum, shares[i],
sessionKeys[quorum[i]], msg,
deterministicReader([]byte{0x5C, 0xBE, byte(i)}))
if err != nil {
t.Fatal(err)
}
if err := s.SetQuorumEvalPoints(evalPoints); err != nil {
t.Fatal(err)
}
signers[i] = s
}
r1 := make([]*TransitionalRound1Message, 3)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2W := make([]*TransitionalRound2Message, 3)
for i, s := range signers {
m, _, err := s.Round2W(r1)
if err != nil {
t.Fatalf("Round2W party %d: %v", i, err)
}
r2W[i] = m
}
K, _, _ := modeShape(ModeP65)
peerWByParty := make([]map[NodeID]polyVec, 3)
for i := 0; i < 3; i++ {
peerW := make(map[NodeID]polyVec, 2)
for j := 0; j < 3; j++ {
if j == i {
continue
}
peerW[r2W[j].NodeID] = unpackPolyVec(r2W[j].W, K)
}
peerWByParty[i] = peerW
}
r2 := make([]*TransitionalRound2Message, 3)
for i, s := range signers {
m, _, err := s.Round2Sign(r1, peerWByParty[i])
if err != nil {
t.Fatalf("Round2Sign party %d: %v", i, err)
}
r2[i] = m
}
// The honesty pin: drop SkBytes immediately before aggregate.
// If the aggregator were truly algebraic, it would not need
// SkBytes — and this test would start failing once v0.3 lands.
originalSk := setup.SkBytes
setup.SkBytes = nil
defer func() { setup.SkBytes = originalSk }()
sig, err := TransitionalAggregate(params, setup, msg, sid, 1, quorum, evalPoints,
3, r1, r2, sessionKeys)
if err == nil {
t.Fatalf("v0.3 GRADUATION FAILURE: TransitionalAggregate returned a "+
"signature with setup.SkBytes=nil (sig.Bytes len=%d). The v0.2 "+
"honesty caveat in threshold_v02.go file-header and "+
"TransitionalAggregate docstring claims the inner sign step "+
"depends on SkBytes. If this test fails, the aggregator no "+
"longer needs SkBytes — delete the SkBytes field, rename to "+
"AlgebraicAggregate, rewrite the file-header honesty block, "+
"and close PULSAR-V03-1 in BLOCKERS.md.", len(sig.Bytes))
}
if err != ErrTransitionalNoSetup {
t.Fatalf("setup.SkBytes=nil should yield ErrTransitionalNoSetup; got %v", err)
}
}
+21 -8
View File
@@ -133,7 +133,7 @@ type KeyShare struct {
// applied. A relying party that can verify ML-DSA can verify a
// Pulsar Signature with no code change.
//
// Two production paths (v1.0.13):
// Two production paths today (v1.0.14):
//
// - Combine (threshold.go): v0.1 reveal-and-aggregate. The
// aggregator briefly reconstructs the master ML-DSA seed via
@@ -142,13 +142,26 @@ type KeyShare struct {
// funds-bearing networks; the aggregator process is in the
// TCB for the sign call.
//
// - AlgebraicCombine (threshold_v02.go): v0.2 algebraic
// threshold. Parties hold polynomial-vector Shamir shares of
// (s_1, s_2, t_0) over GF(q) and never hold the master seed.
// This is the public-BFT-safe path. NEW CODE SHOULD TARGET
// AlgebraicCombine; the v0.1 Combine remains for TEE-bound
// custody use cases where a single operator already runs the
// aggregator (M-Chain bridge, A-Chain confidential compute).
// - TransitionalAggregate (threshold_v02.go): v0.2 transitional
// threshold. PARTIES hold polynomial-vector Shamir shares of
// (s_1, s_2, t_0) over GF(q) — no party (other than the
// aggregator) ever materialises the master seed. HOWEVER the
// AGGREGATOR running TransitionalAggregate briefly holds the
// master sk (via TransitionalSetup.SkBytes) because the inner
// FIPS 204 sign step is not yet ported to polynomial-share
// arithmetic. The aggregator TCB at sign time is therefore
// identical to v0.1 — only the parties' side of the protocol
// is honestly algebraic. v0.3 (PULSAR-V03-1 in BLOCKERS.md)
// removes the SkBytes dependency; until then "Transitional"
// names this honestly.
//
// Suitable today for: M-Chain bridge custody (TEE in aggregator
// TCB), A-Chain confidential compute, single-operator deployments
// where the aggregator host is already trusted.
//
// NOT suitable today for: public adversarial deployments where the
// aggregator host is not in the TCB. For those, use v0.1 Combine
// behind an explicit TEE attestation layer, or wait for v0.3.
//
// Callers that wish to additionally TEE-bind the aggregator wire
// TEE attestation at THEIR layer using