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