security(pulsar): rip v0.1 reconstruct-then-sign Combine path — H-1b

The v0.1 GF(257) threshold-signing instantiation (ThresholdSigner /
Round1 / Round2 / Combine) Lagrange-reconstructed the master seed in
aggregator memory and called FIPS 204 SignTo once — i.e. a quorum's
full ML-DSA signing key materialised in a single process. That
reconstruct-then-sign ceremony was the H-1 footgun. Its only
production reach was consensus's quasar wave_signer (deleted), via the
threshold/protocols/pulsar alias (Combine re-export dropped in the
threshold repo).

The one and only threshold-signing path is now the v0.3 algebraic
ceremony (threshold_v03.go / orchestrate.go), whose
Lagrange-linearity-of-z aggregation produces the FIPS 204 signature
WITHOUT reconstructing the secret in any party's memory.

Removed:
- threshold.go: ThresholdSigner, NewThresholdSigner, Round1, Round2,
  Combine (kept the shared transcript/committee/CT helpers + error set
  consumed by the v0.3 and GF(q) paths; dropped now-unused crypto/rand
  + io imports and dead ErrNilSession).
- types.go: Round1Message, Round2Message wire types.
- round.go: dead RoundSigShare (was the wave_signer carrier).
- threshold_test.go, n1_byte_equality_test.go: pure-v0.1 suites.
- round_test.go / bench_test.go / precompile_e2e_test.go: excised the
  v0.1 sign cases, kept RoundContext/ApproxRoundSecurity/DKG/Large/v0.3.
- cmd/genkat: dropped v0.1 threshold-sign KAT generation + helper.
- ct/dudect/combine_ct.go + dudect_combine.c: dead CT harness for the
  removed Combine path.
- shared_dkg_test.go: relocated the non-v0.1 DKG test helpers
  (runDKG / runDKGWithIdentities) that threshold_test.go had hosted.

Verified: GOWORK=off go build ./... exit 0; go vet clean;
go test ./ref/go/pkg/pulsar/ exit 0 (DKG, reshare, v0.3, KAT replay,
large-committee all green).
This commit is contained in:
2026-06-26 21:08:00 -07:00
parent 39ee49604c
commit 430e1a5c05
10 changed files with 84 additions and 2002 deletions
-306
View File
@@ -1,306 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build pulsar_combine_ct
// combine_ct.go — cgo bridge exposing pulsar.Combine to the C
// dudect harness in dudect_combine.c.
//
// CT POPULATION (operational framing, NOT a standards mandate):
// both dudect classes are VALID Combine inputs — independently
// randomized threshold ceremonies over the SAME shares. The bridge
// pre-builds a pool of K full (Round1, Round2) tapes by running the
// threshold protocol K times with different per-party RNG seeds. All
// K ceremonies produce the SAME final FIPS 204 signature (the
// reconstruction-aggregator collapses through the same master seed),
// but the intermediate Round-1 commits + Round-2 (mask, masked)
// reveals vary across tapes.
//
// dudect class assignment:
// class A: always tape[0] (byte-identical Combine inputs)
// class B: tape[rand % K] (varying-but-valid Combine inputs)
//
// Both classes pass every internal Combine check (MAC verify, commit
// re-derive, Lagrange reconstruct, FIPS 204 sign). Any timing
// difference between classes is a real signature-content-dependent
// timing in the Combine pipeline, not a rejection-path artifact.
//
// Earlier versions of this harness used class A = zero bytes and
// class B = random bytes — both INVALID Round-2 PartialSig — which
// produced different rejection-path timings (zero bytes pass parse
// then fail commit-bind; random bytes vary at parse). That was the
// same anti-pattern the verify-side harness was redesigned out of;
// the leak was a commit-mismatch-timing artifact, not a Combine CT
// regression on secret shares.
//
// Build (Linux):
// GOWORK=off go build -buildmode=c-shared \
// -o libpulsar_combine.so ./combine_ct.go
// Build (macOS):
// GOWORK=off go build -buildmode=c-shared \
// -o libpulsar_combine.dylib ./combine_ct.go
package main
// Force-include the ARM compat shim on AArch64 hosts so that
// dudect/src/dudect.h's unconditional <emmintrin.h>/<x86intrin.h>
// includes resolve via the shim's blocking #defines. Matches the
// Makefile behavior so `go build` and `make` agree on arm64.
/*
#cgo arm64 CFLAGS: -include ${SRCDIR}/dudect_compat.h
#include <stdint.h>
#include <stddef.h>
*/
import "C"
import (
"crypto/rand"
"unsafe"
"github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
// Pool size — K independent valid threshold ceremonies over the
// SAME shares. Larger K = more uniform class-B distribution. 16 is
// chosen as a balance between setup time (each ceremony runs the
// full threshold protocol) and uniformity of the class-B mixture.
const kCombineValidPool = 16
// Per-tape fixture: one full (Round1, Round2) tape from an
// independent threshold ceremony. The shares + sessionID + attempt
// stay constant across tapes; only the per-party RNG (hence the
// commits + reveals) varies.
type combineTape struct {
round1 []*pulsar.Round1Message
round2 []*pulsar.Round2Message
}
// Long-lived fixture: a real (n=3, t=2) threshold setup at ModeP65,
// plus a pool of kCombineValidPool valid (Round1, Round2) tapes.
var (
cFixtureParams *pulsar.Params
cFixturePub *pulsar.PublicKey
cFixtureMsg []byte
cFixtureSessionID [16]byte
cFixtureAttempt uint32
cFixtureQuorum []pulsar.NodeID
cFixtureThreshold int
cFixtureShares []*pulsar.KeyShare
// cFixtureTapes is the K-entry valid-tape pool. Each entry is a
// full (Round1, Round2) from an independent ceremony.
cFixtureTapes [kCombineValidPool]combineTape
)
// Build a real threshold ceremony fixture once. Returns 0 on
// success, non-zero on failure.
//
// Topology: n=3 parties, t=2 threshold, ModeP65. This is the
// smallest non-trivial threshold ceremony — keeps the dudect run
// time per sample bounded.
//
//export pulsar_combine_ct_setup
func pulsar_combine_ct_setup() C.int {
params := pulsar.MustParamsFor(pulsar.ModeP65)
n, t := 3, 2
committee := make([]pulsar.NodeID, n)
for i := 0; i < n; i++ {
committee[i] = pulsar.NodeID{byte(i + 1)}
}
// Identity fixture (BLOCKERS.md CR-8): every party gets a
// long-term ML-KEM-768 + ML-DSA-65 identity, and an
// IdentityDirectory carries every peer's published KEM pubkey
// so Round-1 envelopes can be sealed.
identities := make(map[pulsar.NodeID]*pulsar.IdentityKey, n)
pubs := make(map[pulsar.NodeID]*pulsar.IdentityPublicKey, n)
for _, id := range committee {
ik, err := pulsar.GenerateIdentity(rand.Reader)
if err != nil {
return 10
}
identities[id] = ik
pubs[id] = ik.PublicKey()
}
directory, err := pulsar.NewIdentityDirectory(pubs)
if err != nil {
return 11
}
// DKG.
dkgSessions := make([]*pulsar.DKGSession, n)
for i := 0; i < n; i++ {
s, err := pulsar.NewDKGSession(params, committee, t, committee[i], identities[committee[i]], directory, rand.Reader)
if err != nil {
return 1
}
dkgSessions[i] = s
}
r1 := make([]*pulsar.DKGRound1Msg, n)
for i, s := range dkgSessions {
m, err := s.Round1()
if err != nil {
return 2
}
r1[i] = m
}
r2 := make([]*pulsar.DKGRound2Msg, n)
for i, s := range dkgSessions {
m, err := s.Round2(r1)
if err != nil {
return 3
}
r2[i] = m
}
outputs := make([]*pulsar.DKGOutput, n)
for i, s := range dkgSessions {
out, err := s.Round3(r1, r2)
if err != nil {
return 4
}
outputs[i] = out
}
pub := outputs[0].GroupPubkey
shares := make([]*pulsar.KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
// Threshold sign.
msg := []byte("dudect constant-time smoke: Pulsar Combine class N4")
quorum := make([]pulsar.NodeID, t)
for i := 0; i < t; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
if _, err := rand.Read(sid[:]); err != nil {
return 5
}
// Per-pair session keys for the quorum (BLOCKERS.md CR-7).
// SymmetricSession runs the two-sided EstablishSession on each
// (a, b) pair so both endpoints derive the same 32-byte key.
sessionKeys := make(map[pulsar.NodeID]map[pulsar.NodeID][32]byte, t)
for _, id := range quorum {
sessionKeys[id] = make(map[pulsar.NodeID][32]byte, t-1)
}
for i := 0; i < t; i++ {
for j := i + 1; j < t; j++ {
a, b := quorum[i], quorum[j]
key, err := pulsar.SymmetricSession(a, identities[a], b, identities[b], sid, msg)
if err != nil {
return 12
}
sessionKeys[a][b] = key
sessionKeys[b][a] = key
}
}
// Build the K-entry valid-tape pool. Each tape is an independent
// run of (Round1, Round2) over the SAME shares, sessionID, attempt,
// and message — only the per-party RNG (hence masks + commits +
// reveals) differs. Every tape is sanity-checked: Combine must
// succeed before the tape is admitted to the pool.
for k := 0; k < kCombineValidPool; k++ {
signers := make([]*pulsar.ThresholdSigner, t)
for i := 0; i < t; i++ {
s, err := pulsar.NewThresholdSigner(params, sid, 1, quorum, shares[i],
sessionKeys[shares[i].NodeID], msg, rand.Reader)
if err != nil {
return 6
}
signers[i] = s
}
sr1 := make([]*pulsar.Round1Message, t)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
return 7
}
sr1[i] = m
}
sr2 := make([]*pulsar.Round2Message, t)
for i, s := range signers {
m, _, err := s.Round2(sr1)
if err != nil {
return 8
}
sr2[i] = m
}
// Sanity: this tape's Combine must succeed.
if _, err := pulsar.Combine(params, pub, msg, nil, false, sid, 1, quorum, t, sr1, sr2, shares); err != nil {
return 9
}
cFixtureTapes[k] = combineTape{round1: sr1, round2: sr2}
}
cFixtureParams = params
cFixturePub = pub
cFixtureMsg = msg
cFixtureSessionID = sid
cFixtureAttempt = 1
cFixtureQuorum = quorum
cFixtureThreshold = t
cFixtureShares = shares
return 0
}
// Returns the number of valid tapes in the per-startup pool.
//
//export pulsar_combine_ct_pool_size
func pulsar_combine_ct_pool_size() C.size_t {
return C.size_t(kCombineValidPool)
}
// Returns the per-sample input width: 4 bytes (a big-endian uint32
// tape index, mod kCombineValidPool). The C harness sizes its
// dudect chunk to this width.
//
//export pulsar_combine_ct_input_size
func pulsar_combine_ct_input_size() C.size_t {
return C.size_t(4)
}
// One dudect measurement sample.
//
// `data` points to a 4-byte big-endian uint32 tape index; the
// bridge reduces it mod kCombineValidPool and runs Combine on the
// indexed (Round1, Round2) tape. Both classes (A: fixed index 0,
// B: caller-supplied index) drive Combine through the SAME code
// path on VALID inputs — any timing difference is a real
// data-dependent signal, not a rejection-path artifact.
//
// IMPORTANT: this function must NOT branch on the data beyond the
// modular reduction. The reduction is constant-time over the
// 4-byte input.
//
//export pulsar_combine_ct
func pulsar_combine_ct(data *C.uint8_t) {
if cFixtureParams == nil {
return
}
src := unsafe.Slice((*byte)(unsafe.Pointer(data)), 4)
idx := (uint32(src[0])<<24 | uint32(src[1])<<16 | uint32(src[2])<<8 | uint32(src[3])) %
uint32(kCombineValidPool)
tape := cFixtureTapes[idx]
_, _ = pulsar.Combine(
cFixtureParams,
cFixturePub,
cFixtureMsg,
nil,
false,
cFixtureSessionID,
cFixtureAttempt,
cFixtureQuorum,
cFixtureThreshold,
tape.round1,
tape.round2,
cFixtureShares,
)
}
func main() {}
-126
View File
@@ -1,126 +0,0 @@
/*
* Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
* See the file LICENSE for licensing terms.
*
* dudect_combine.c — dudect main loop driving pulsar.Combine
* through the cgo bridge in combine_ct.go.
*
* Same structure as dudect_verify.c. Both dudect classes are
* VALID Combine inputs drawn from a pre-built K-entry tape pool:
* class A: tape[0] (fixed)
* class B: tape[rand % K] (varying valid tapes)
* Per-sample input is a 4-byte tape index (big-endian uint32).
*
* Earlier versions used class A = zero-bytes, class B = random
* bytes — same anti-pattern as the old verify-side test (both
* inputs INVALID, on different rejection paths). The valid-pool
* design eliminates the rejection-path artifact; any timing
* difference detected here is a real data-dependent path in
* Combine, not a commit-mismatch artifact.
*/
#define DUDECT_IMPLEMENTATION
#include "dudect/src/dudect.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
extern int pulsar_combine_ct_setup(void);
extern size_t pulsar_combine_ct_pool_size(void);
extern size_t pulsar_combine_ct_input_size(void);
extern void pulsar_combine_ct(uint8_t *data);
static size_t g_chunk_size = 0;
static size_t g_pool_size = 0;
/*
* Populate input_data + classes for one batch. Each per-sample
* input is a 4-byte big-endian uint32 tape index.
* class A: index 0 (every sample)
* class B: random uniform index in [0, pool_size)
*/
void prepare_inputs(dudect_config_t *cfg, uint8_t *input_data, uint8_t *classes) {
for (size_t i = 0; i < cfg->number_measurements; i++) {
classes[i] = randombit();
uint8_t *slot = input_data + (size_t)i * cfg->chunk_size;
if (classes[i] == 0) {
/* Class A: tape[0] (Welch's t-test requires identical
* class-A inputs). Encode index 0 big-endian. */
slot[0] = slot[1] = slot[2] = slot[3] = 0;
} else {
/* Class B: random index — the bridge reduces mod
* pool_size. randombytes draws from dudect's RNG,
* which is uncorrelated with anything Combine sees. */
randombytes(slot, cfg->chunk_size);
}
}
}
uint8_t do_one_computation(uint8_t *data) {
pulsar_combine_ct(data);
uint8_t acc = 0;
for (size_t i = 0; i < g_chunk_size; i++) acc ^= data[i];
return acc;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
int rc = pulsar_combine_ct_setup();
if (rc != 0) {
fprintf(stderr, "pulsar_combine_ct_setup failed: rc=%d\n", rc);
return 1;
}
g_chunk_size = pulsar_combine_ct_input_size();
g_pool_size = pulsar_combine_ct_pool_size();
if (g_chunk_size == 0 || g_pool_size == 0) {
fprintf(stderr, "pulsar_combine_ct setup returned 0 sizes\n");
return 1;
}
/* Combine is heavier than Verify (full FIPS 204 Sign on the
* reconstructed seed), so the default batch is smaller — keeps
* the smoke test under a minute on a laptop. */
size_t number_measurements = 2000;
const char *env_n = getenv("DUDECT_SAMPLES");
if (env_n) {
long n = strtol(env_n, NULL, 10);
if (n > 0) number_measurements = (size_t)n;
}
size_t max_batches = 4;
const char *env_b = getenv("DUDECT_MAX_BATCHES");
if (env_b) {
long b = strtol(env_b, NULL, 10);
if (b > 0) max_batches = (size_t)b;
}
dudect_config_t cfg = {
.chunk_size = g_chunk_size,
.number_measurements = number_measurements,
};
dudect_ctx_t ctx;
if (dudect_init(&ctx, &cfg) != 0) {
fprintf(stderr, "dudect_init failed\n");
return 1;
}
fprintf(stderr, "dudect_combine: ML-DSA-65 (n=3,t=2), chunk=%zu bytes, batch=%zu samples, max_batches=%zu, pool=%zu valid tapes\n",
g_chunk_size, number_measurements, max_batches, g_pool_size);
dudect_state_t state = DUDECT_NO_LEAKAGE_EVIDENCE_YET;
for (size_t batch = 0; batch < max_batches; batch++) {
state = dudect_main(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) break;
}
dudect_free(&ctx);
if (state == DUDECT_LEAKAGE_FOUND) {
fprintf(stderr, "dudect_combine: LEAKAGE FOUND (t-statistic exceeded threshold)\n");
return 2;
}
fprintf(stderr, "dudect_combine: no leakage evidence after %zu batches of %zu samples\n",
max_batches, number_measurements);
return 0;
}
-93
View File
@@ -53,18 +53,6 @@ type VerifyKAT struct {
Valid bool `json:"valid"`
}
type ThresholdSignKAT struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Message string `json:"message"`
PublicKey string `json:"public_key"`
Signature string `json:"signature"`
Quorum []string `json:"quorum"`
SessionID string `json:"session_id"`
Attempt uint32 `json:"attempt"`
}
type DKGKAT struct {
Mode string `json:"mode"`
N int `json:"n"`
@@ -206,66 +194,6 @@ func main() {
}
writeJSON(filepath.Join(*outDir, "verify.json"), verifyKATs)
// ---- threshold-sign ----
thresholdKATs := []ThresholdSignKAT{}
for _, mode := range []pm.Mode{pm.ModeP65} { // mode P65 is the canonical target
for _, tc := range []struct{ N, T int }{{3, 2}, {5, 3}, {7, 4}, {10, 7}} {
params := pm.MustParamsFor(mode)
committee := makeKATCommittee(tc.N)
identities := makeKATIdentities(committee, mode, tc.N, tc.T)
pub, shares, _ := runKATDKG(params, committee, tc.T, mode, identities)
msg := []byte(fmt.Sprintf("Pulsar Threshold KAT %s n=%d t=%d", mode.String(), tc.N, tc.T))
quorum := make([]pm.NodeID, tc.T)
for i := 0; i < tc.T; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
copy(sid[:], "kat-threshold01")
attempt := uint32(1)
// Per-pair session keys: every quorum pair runs the
// authenticated ML-KEM-768 exchange and derives the
// canonical session key (CR-7 fix).
sessionKeys := makeKATSessionKeys(quorum, identities, sid, msg)
signers := make([]*pm.ThresholdSigner, tc.T)
for i := 0; i < tc.T; i++ {
rng := newDetReader(append(masterSeed, []byte{0x30, byte(mode), byte(tc.T), byte(i)}...))
signers[i], _ = pm.NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, rng)
}
r1 := make([]*pm.Round1Message, tc.T)
for i, s := range signers {
r1[i], _ = s.Round1(msg)
}
r2 := make([]*pm.Round2Message, tc.T)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := pm.Combine(params, pub, msg, nil, false, sid, attempt, quorum, tc.T, r1, r2, shares)
if err != nil {
fail(fmt.Errorf("threshold combine n=%d t=%d: %w", tc.N, tc.T, err))
}
// Cross-check: verify under unmodified FIPS 204.
if err := pm.Verify(params, pub, msg, sig); err != nil {
fail(fmt.Errorf("threshold KAT FIPS 204 Verify failed: %w", err))
}
quorumHex := make([]string, len(quorum))
for i, q := range quorum {
quorumHex[i] = hex.EncodeToString(q[:])
}
thresholdKATs = append(thresholdKATs, ThresholdSignKAT{
Mode: mode.String(),
N: tc.N,
T: tc.T,
Message: hex.EncodeToString(msg),
PublicKey: hex.EncodeToString(pub.Bytes),
Signature: hex.EncodeToString(sig.Bytes),
Quorum: quorumHex,
SessionID: hex.EncodeToString(sid[:]),
Attempt: attempt,
})
}
}
writeJSON(filepath.Join(*outDir, "threshold-sign.json"), thresholdKATs)
// ---- dkg ----
dkgKATs := []DKGKAT{}
for _, mode := range []pm.Mode{pm.ModeP65} {
@@ -334,27 +262,6 @@ func makeKATIdentities(committee []pm.NodeID, mode pm.Mode, n, threshold int) *k
return &katIdentities{keys: keys, dir: dir}
}
// makeKATSessionKeys runs SymmetricSession for every pair in the
// quorum and returns each party's local view (peer -> session key).
func makeKATSessionKeys(quorum []pm.NodeID, ident *katIdentities, sid [16]byte, transcript []byte) map[pm.NodeID]map[pm.NodeID][32]byte {
out := make(map[pm.NodeID]map[pm.NodeID][32]byte, len(quorum))
for _, id := range quorum {
out[id] = make(map[pm.NodeID][32]byte, len(quorum)-1)
}
for i := 0; i < len(quorum); i++ {
for j := i + 1; j < len(quorum); j++ {
a, b := quorum[i], quorum[j]
key, err := pm.SymmetricSession(a, ident.keys[a], b, ident.keys[b], sid, transcript)
if err != nil {
fail(fmt.Errorf("SymmetricSession %x↔%x: %w", a[:4], b[:4], err))
}
out[a][b] = key
out[b][a] = key
}
}
return out
}
// runKATDKG is a deterministic DKG run for KAT generation.
func runKATDKG(params *pm.Params, committee []pm.NodeID, threshold int, mode pm.Mode, identities *katIdentities) (*pm.PublicKey, []*pm.KeyShare, [48]byte) {
n := len(committee)
-64
View File
@@ -87,70 +87,6 @@ func benchDKG(b *testing.B, n, t int, mode Mode) {
}
}
func BenchmarkThresholdSign_5of3_P65(b *testing.B) { benchThresholdSign(b, 5, 3, ModeP65) }
func BenchmarkThresholdSign_7of4_P65(b *testing.B) { benchThresholdSign(b, 7, 4, ModeP65) }
func BenchmarkThresholdSign_10of7_P65(b *testing.B) { benchThresholdSign(b, 10, 7, ModeP65) }
func benchThresholdSign(b *testing.B, n, t int, mode Mode) {
params := MustParamsFor(mode)
committee := makeCommittee(n)
ident := newIdentityFixture(b, committee, []byte{byte(n), byte(t), byte(mode), 0x01})
// Set up DKG once outside the timer.
sessions := make([]*DKGSession, n)
for i := range sessions {
rng := deterministicReader([]byte{byte(i), 0xBE})
s, _ := NewDKGSession(params, committee, t, committee[i], ident.keys[committee[i]], ident.directory, rng)
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
outputs := make([]*DKGOutput, n)
for i, s := range sessions {
outputs[i], _ = s.Round3(r1, r2)
}
pub := outputs[0].GroupPubkey
shares := make([]*KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
msg := []byte("benchmark threshold sign — Class N1 round trip")
quorum := make([]NodeID, t)
for i := 0; i < t; i++ {
quorum[i] = shares[i].NodeID
}
b.ResetTimer()
for iter := 0; iter < b.N; iter++ {
var sid [16]byte
sid[0] = byte(iter)
sid[1] = byte(iter >> 8)
sessionKeys := ident.quorumSessionKeys(b, quorum, sid, msg)
signers := make([]*ThresholdSigner, t)
for i := 0; i < t; i++ {
rng := deterministicReader([]byte{byte(iter), byte(i)})
signers[i], _ = NewThresholdSigner(params, sid, 1, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, rng)
}
sr1 := make([]*Round1Message, t)
for i, s := range signers {
sr1[i], _ = s.Round1(msg)
}
sr2 := make([]*Round2Message, t)
for i, s := range signers {
sr2[i], _, _ = s.Round2(sr1)
}
_, err := Combine(params, pub, msg, nil, false, sid, 1, quorum, t, sr1, sr2, shares)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkReshare_5to5_t3_P65(b *testing.B) { benchReshare(b, 5, 3, 5, 3, ModeP65) }
func benchReshare(b *testing.B, oldN, oldT, newN, newT int, mode Mode) {
-423
View File
@@ -1,423 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// n1_byte_equality_test.go — empirical validation of the Class N1
// output-interchangeability theorem.
//
// The EasyCrypt theorem Pulsar_N1.pulsar_n1_byte_equality_extracted
// (in proofs/easycrypt/Pulsar_N1_Wrapper_Bridge.ec) says:
//
// For any honest threshold-sign session whose share set
// reconstructs to a centralized FIPS 204 secret key SK, the
// resulting signature bytes are IDENTICAL to those produced by
// FIPS 204 Sign_internal(SK, msg, ctx) — i.e., threshold-sign is
// output-byte-equivalent to single-party deterministic FIPS 204
// signing under the corresponding centralized key.
//
// This is the load-bearing property of Pulsar Class N1: any FIPS 204
// verifier (a stock ML-DSA-65 implementation, on-chain pre-compile,
// hardware verifier) accepts threshold-produced signatures with NO
// code change, because the bytes look exactly like a centralized
// FIPS 204 signature.
//
// The implementation strategy that makes this property hold is
// "centralized recovery": Combine reconstructs the master seed via
// Lagrange interpolation of the share set, derives the centralized
// FIPS 204 SK via KeyFromSeed, and calls mldsaSign directly. See
// threshold.go around line 418.
//
// This test performs an end-to-end byte-equality check:
//
// 1. Run the DKG to produce shares + group public key.
// 2. Reconstruct the master seed from a threshold quorum of shares
// using the SAME Lagrange + cSHAKE256 mix as Combine.
// 3. Derive the centralized SK via KeyFromSeed(masterSeed).
// 4. Sign centrally with deterministic FIPS 204 mode.
// 5. Run the full threshold-sign protocol on the same (sid,
// attempt, msg, ctx, quorum) under the same shares.
// 6. Assert that the two signatures are BYTE-IDENTICAL.
//
// Catches: any divergence between the centralized FIPS 204 path and
// the threshold-sign path — wrong Shamir coefficient, wrong
// committee root, wrong cSHAKE256 mix input layout, wrong sign-call
// arguments, etc. A regression on any of these would cause the
// signatures to differ and the test to fail.
import (
"bytes"
"testing"
)
// reconstructMasterSeed Lagrange-reconstructs the DKG master seed
// from a quorum of KeyShares + the full committee membership. This
// mirrors the in-Combine reconstruction at threshold.go:386-415
// exactly — same shamirReconstructGF, same byteSum byte ordering,
// same committeeRoot, same cSHAKE256 tag.
func reconstructMasterSeed(t *testing.T, quorum []*KeyShare, committee []*KeyShare) [SeedSize]byte {
t.Helper()
shares := make([]shamirShare, len(quorum))
for i, ks := range quorum {
var buf [shareWireSize]byte
copy(buf[:], ks.Share[:])
shares[i] = shareFromBytes(ks.EvalPoint, buf)
}
byteSum, err := shamirReconstructGF(shares)
if err != nil {
t.Fatal(err)
}
committeeRoot := committeeRootFromShares(committee)
byteSumBytes := make([]byte, SeedSize*2)
for b := 0; b < SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
var seed [SeedSize]byte
copy(seed[:], cshake256(mixInput, SeedSize, tagSeedShare))
return seed
}
func TestN1_ByteEquality_ThresholdMatchesCentralized(t *testing.T) {
// Three configurations spanning small / typical / larger
// thresholds. Every config must produce byte-identical
// signatures from the two paths.
for _, tc := range []struct {
name string
n, t int
}{
{"5of3", 5, 3},
{"7of4", 7, 4},
{"10of7", 10, 7},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, tc.n, tc.t, ModeP65)
msg := []byte("Pulsar Class N1 byte-equality test message")
var ctx []byte // empty context — match between paths
// --- Threshold-sign path. ---
var sid [16]byte
copy(sid[:], "n1-byte-eq-sid-1")
attempt := uint32(1)
quorum := make([]NodeID, tc.t)
for i := 0; i < tc.t; i++ {
quorum[i] = shares[i].NodeID
}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, tc.t)
for i := 0; i < tc.t; i++ {
s, err := NewThresholdSigner(
params, sid, attempt, quorum, shares[i],
sessionKeys[shares[i].NodeID], msg,
deterministicReader([]byte{byte(i), 0xBE, 0xEF}),
)
if err != nil {
t.Fatalf("NewThresholdSigner party %d: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, tc.t)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, tc.t)
for i, s := range signers {
m, ev, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v (ev=%v)", i, err, ev)
}
r2[i] = m
}
// false = deterministic FIPS 204 mode (KAT-reproducible).
// Match this in the centralized path below.
thresholdSig, err := Combine(
params, pub, msg, ctx, false,
sid, attempt, quorum, tc.t, r1, r2, shares,
)
if err != nil {
t.Fatalf("Combine: %v", err)
}
if len(thresholdSig.Bytes) != params.SignatureSize {
t.Fatalf("threshold sig size %d, want %d",
len(thresholdSig.Bytes), params.SignatureSize)
}
// --- Centralized FIPS 204 path. ---
// Reconstruct the master seed from the threshold-sized
// quorum of shares using the EXACT same Lagrange +
// cSHAKE256 mix that Combine uses internally.
masterSeed := reconstructMasterSeed(t, shares[:tc.t], shares)
centralSK, err := KeyFromSeed(params, masterSeed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
// Sanity: reconstructed pubkey must match the DKG group
// pubkey. If this fails, every other check is moot.
if !centralSK.Pub.Equal(pub) {
t.Fatalf("reconstructed pubkey != DKG group pubkey")
}
// false = deterministic FIPS 204 mode, matching Combine
// above. The rng arg is irrelevant when randomized=false.
centralSig, err := Sign(params, centralSK, msg, ctx, false, nil)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// --- The headline assertion. ---
if !bytes.Equal(thresholdSig.Bytes, centralSig.Bytes) {
// Show a concise prefix-mismatch report so it's
// obvious WHERE the divergence starts.
diffAt := -1
for i := 0; i < len(thresholdSig.Bytes) && i < len(centralSig.Bytes); i++ {
if thresholdSig.Bytes[i] != centralSig.Bytes[i] {
diffAt = i
break
}
}
t.Fatalf(
"Class N1 byte-equality VIOLATED for %s:\n"+
" first differing byte at offset %d\n"+
" threshold prefix = %x\n"+
" central prefix = %x",
tc.name, diffAt,
thresholdSig.Bytes[:min(32, len(thresholdSig.Bytes))],
centralSig.Bytes[:min(32, len(centralSig.Bytes))],
)
}
// Sanity (already covered by TestThresholdSign_RoundTrip,
// repeated here so a failure of this test makes the
// chain of properties obvious):
if err := Verify(params, pub, msg, thresholdSig); err != nil {
t.Fatalf("threshold sig fails FIPS 204 Verify: %v", err)
}
if err := Verify(params, pub, msg, centralSig); err != nil {
t.Fatalf("centralized sig fails FIPS 204 Verify: %v", err)
}
})
}
}
// min is a tiny helper since this file's Go target may pre-date
// the generic `min` builtin in older toolchains.
func min(a, b int) int {
if a < b {
return a
}
return b
}
// FuzzN1_ByteEquality_Differential — the property-based fuzz form of
// TestN1_ByteEquality_ThresholdMatchesCentralized.
//
// Each fuzz iteration:
// 1. Derives (n, t, msg, ctx, party_rng_seeds) from the fuzz input
// with sensible bounds (n in [2, 10], t in [2, n]).
// 2. Runs the full DKG + threshold-sign pipeline.
// 3. Reconstructs the master seed from shares and derives the
// centralized FIPS 204 SK.
// 4. Signs centrally with deterministic FIPS 204 mode.
// 5. Asserts thresholdSig.Bytes == centralSig.Bytes.
//
// The property under fuzz is: for ANY honest threshold-sign session,
// the produced bytes match the centralized FIPS 204 deterministic
// output under the reconstructed key. A counterexample (byte mismatch
// or unexpected error) is a real bug.
//
// Performance: ~50–200ms per iteration (DKG + threshold-sign +
// FIPS 204 sign). A 30s smoke run does ~150-400 iterations; a
// nightly job (-fuzztime=1h) does ~20k iterations.
//
// Smoke run:
//
// GOWORK=off go test -run=^$ -fuzz=FuzzN1_ByteEquality_Differential \
// -fuzztime=30s ./ref/go/pkg/pulsar/
func FuzzN1_ByteEquality_Differential(f *testing.F) {
// Seed with the same three configurations the deterministic test
// uses, plus a few extras so the fuzz engine has a base coverage
// signal before mutation.
seeds := []struct {
n, t int
msg string
ctx string
}{
{5, 3, "Pulsar class N1 differential fuzz seed", ""},
{7, 4, "another seed message", ""},
{10, 7, "longer-quorum seed", "ctx-bytes"},
{4, 2, "small-committee seed", ""},
}
for _, s := range seeds {
input := make([]byte, 0, 4+len(s.msg)+1+len(s.ctx))
input = append(input, byte(s.n), byte(s.t))
input = append(input, byte(len(s.msg)))
input = append(input, s.msg...)
input = append(input, byte(len(s.ctx)))
input = append(input, s.ctx...)
// 16 bytes of party-RNG seed material padding so the seed
// inputs are non-trivial under mutation.
var pad [16]byte
input = append(input, pad[:]...)
f.Add(input)
}
f.Fuzz(func(t *testing.T, data []byte) {
// Parse the fuzz input into (n, t, msg, ctx, party_seed).
// Bail out cleanly (not Skip — Skip aborts the fuzz iteration
// without exercising the pipeline) on inputs too short to
// describe a valid configuration.
if len(data) < 4 {
return
}
// Clamp n to [2, 10] — DKG is O(n^2) so larger committees
// blow up the per-iteration cost. The proof property is
// invariant in n, so bounding is fine for coverage.
n := int(data[0]%9) + 2 // 2..10
// Clamp t to [2, n].
tq := int(data[1]%uint8(n-1)) + 2
if tq > n {
tq = n
}
// Message length up to 64 bytes.
mLen := int(data[2] % 65)
if 3+mLen > len(data) {
return
}
msg := append([]byte{}, data[3:3+mLen]...)
// Context length up to 64 bytes.
off := 3 + mLen
if off >= len(data) {
return
}
cLen := int(data[off] % 65)
off++
if off+cLen > len(data) {
return
}
var ctx []byte
if cLen > 0 {
ctx = append([]byte{}, data[off:off+cLen]...)
}
off += cLen
// Remaining bytes (if any) feed party-RNG diversification.
// We just XOR them into the per-party seed prefix so the
// fuzz mutation surface includes the per-party randomness
// path. The protocol invariant is that byte-equality holds
// regardless of per-party RNG choices.
var partyTail [16]byte
for i := 0; i < len(partyTail) && off+i < len(data); i++ {
partyTail[i] = data[off+i]
}
// --- Run the pipeline. Any error is a fuzz failure (the
// property must hold for ALL well-formed configurations
// we generate above).
params := MustParamsFor(ModeP65)
// We use the in-package runDKGWithIdentities helper which
// runs DKG deterministically (the per-party seed there is
// already deterministicReader, but we make it fully
// hermetic by using msg as part of the test name suffix).
pub, shares, _, ident := runDKGWithIdentities(t, n, tq, ModeP65)
var sid [16]byte
copy(sid[:], "n1-fuzz-diff-sid")
attempt := uint32(1)
quorum := make([]NodeID, tq)
for i := 0; i < tq; i++ {
quorum[i] = shares[i].NodeID
}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, tq)
for i := 0; i < tq; i++ {
rngSeed := append([]byte{}, partyTail[:]...)
rngSeed = append(rngSeed, byte(i), 0xFE)
s, err := NewThresholdSigner(
params, sid, attempt, quorum, shares[i],
sessionKeys[shares[i].NodeID], msg,
deterministicReader(rngSeed),
)
if err != nil {
t.Fatalf("NewThresholdSigner: %v", err)
}
signers[i] = s
}
r1 := make([]*Round1Message, tq)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, tq)
for i, s := range signers {
m, ev, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v (ev=%v)", i, err, ev)
}
r2[i] = m
}
thresholdSig, err := Combine(
params, pub, msg, ctx, false,
sid, attempt, quorum, tq, r1, r2, shares,
)
if err != nil {
t.Fatalf("Combine: %v", err)
}
masterSeed := reconstructMasterSeed(t, shares[:tq], shares)
centralSK, err := KeyFromSeed(params, masterSeed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
if !centralSK.Pub.Equal(pub) {
t.Fatalf("reconstructed pubkey != DKG group pubkey")
}
centralSig, err := Sign(params, centralSK, msg, ctx, false, nil)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !bytes.Equal(thresholdSig.Bytes, centralSig.Bytes) {
diffAt := -1
for i := 0; i < len(thresholdSig.Bytes) && i < len(centralSig.Bytes); i++ {
if thresholdSig.Bytes[i] != centralSig.Bytes[i] {
diffAt = i
break
}
}
t.Fatalf(
"Class N1 byte-equality VIOLATED under fuzz "+
"(n=%d, t=%d, |msg|=%d, |ctx|=%d):\n"+
" first differing byte at offset %d\n"+
" threshold prefix = %x\n"+
" central prefix = %x",
n, tq, len(msg), len(ctx), diffAt,
thresholdSig.Bytes[:min(32, len(thresholdSig.Bytes))],
centralSig.Bytes[:min(32, len(centralSig.Bytes))],
)
}
// Both sigs must FIPS 204 Verify with the SAME ctx they were
// signed under. (A regression where Combine ignored ctx
// while Sign honored it would show up here as a Verify
// failure on the threshold sig under VerifyCtx with the
// real ctx — caught early.)
if err := VerifyCtx(params, pub, msg, ctx, thresholdSig); err != nil {
t.Fatalf("threshold sig fails FIPS 204 VerifyCtx (|ctx|=%d): %v",
len(ctx), err)
}
if err := VerifyCtx(params, pub, msg, ctx, centralSig); err != nil {
t.Fatalf("centralized sig fails FIPS 204 VerifyCtx (|ctx|=%d): %v",
len(ctx), err)
}
})
}
-99
View File
@@ -39,105 +39,6 @@ import (
// string is the load-bearing value, not the import.
var precompileCtx = []byte("lux-evm-precompile-pulsar-v1")
// TestPrecompile_E2E_Combine_FIPS204_VerifyCtx is the headline
// Class N1 manifesto closure for the GF(257) (small-committee)
// Combine path at the precompile ctx.
//
// Steps:
// 1. DKG at (T, N) = (3, 2) for ModeP65 — note: the helper
// signature is runDKG(t, n=committee_size, threshold).
// 2. ThresholdSigner Round1 + Round2 on a message M.
// 3. Combine(..., ctx=precompileCtx, randomized=false, ...) producing
// sig_threshold.
// 4. Verify under (a) raw FIPS 204 mldsa65.Verify with ctx=precompileCtx,
// and (b) pulsar.VerifyCtx with the same ctx.
// 5. Assert both succeed.
//
// (3, 2) is the canonical Quasar grouped-sortition profile so this
// test doubles as the production-shaped end-to-end smoke.
func TestPrecompile_E2E_Combine_FIPS204_VerifyCtx(t *testing.T) {
const n, threshold = 3, 2
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, n, threshold, ModeP65)
msg := []byte("pulsar threshold -> precompile ctx -> FIPS 204 verify")
var sid [16]byte
copy(sid[:], "precompile-e2e01")
attempt := uint32(0)
quorum := make([]NodeID, threshold)
for i := 0; i < threshold; i++ {
quorum[i] = shares[i].NodeID
}
// CR-7 closure: per-pair ephemeral session keys for the quorum.
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, threshold)
for i := 0; i < threshold; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i), 'P', 'C'}))
if err != nil {
t.Fatalf("NewThresholdSigner party %d: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, threshold)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, threshold)
for i, s := range signers {
m, ev, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v (ev=%v)", i, err, ev)
}
r2[i] = m
}
// Combine with precompileCtx. This is the byte that the precompile
// at 0x012204 passes to VerifySignatureCtx in its Run() method.
sig, err := Combine(params, pub, msg, precompileCtx, false, sid, attempt, quorum, threshold, r1, r2, shares)
if err != nil {
t.Fatalf("Combine with precompileCtx: %v", err)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("signature size %d, want %d", len(sig.Bytes), params.SignatureSize)
}
// (a) Raw FIPS 204 verify via cloudflare/circl. This is the
// underlying byte-equality check that the precompile dispatches to
// under the hood (luxfi/crypto/mldsa wraps the same circl primitive).
var pkP65 mldsa65.PublicKey
var pkBuf [mldsa65.PublicKeySize]byte
copy(pkBuf[:], pub.Bytes)
pkP65.Unpack(&pkBuf)
if !mldsa65.Verify(&pkP65, msg, precompileCtx, sig.Bytes) {
t.Fatalf("FIPS 204 mldsa65.Verify rejected threshold-Combine signature under precompile ctx — Class N1 manifesto FALSE")
}
// (b) Package wrapper VerifyCtx.
if err := VerifyCtx(params, pub, msg, precompileCtx, sig); err != nil {
t.Fatalf("pulsar.VerifyCtx rejected threshold-Combine signature under precompile ctx: %v", err)
}
// Negative control: under the empty context, this same sig MUST
// fail (proves ctx is actually load-bearing — a nil ctx and a
// `lux-evm-precompile-pulsar-v1` ctx produce distinct FIPS 204
// signatures, and one does not verify against the other).
if mldsa65.Verify(&pkP65, msg, nil, sig.Bytes) {
t.Fatalf("FIPS 204 verify accepted precompile-ctx sig under empty ctx — ctx is NOT propagated through Combine")
}
if err := Verify(params, pub, msg, sig); err == nil {
t.Fatalf("pulsar.Verify (empty ctx) accepted precompile-ctx sig — ctx is NOT propagated through Combine")
}
}
// TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx is the parallel
// Class N1 manifesto closure for the GF(q) (wide-committee) path.
//
+67
View File
@@ -0,0 +1,67 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
import (
"testing"
)
// shared_dkg_test.go — DKG ceremony helpers shared across the test
// suite (dkg_test.go, reshare_test.go, threshold_v03_*_test.go, …).
//
// These formerly lived in threshold_test.go alongside the v0.1
// reconstruct-then-sign tests. When that v0.1 sign path was removed,
// the DKG helpers were relocated here so the v0.3 algebraic tests,
// reshare tests, and DKG tests retain their shared keygen fixture.
// runDKG runs a deterministic DKG with the given committee and threshold
// and returns the group public key + per-party shares.
func runDKG(t *testing.T, n, threshold int, mode Mode) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
pub, shares, transcript, _ := runDKGWithIdentities(t, n, threshold, mode)
return pub, shares, transcript
}
// runDKGWithIdentities is the extended helper that also returns the
// identity fixture used by the DKG ceremony. Threshold-sign tests
// reuse the fixture to derive the per-pair session keys for the
// signing quorum.
func runDKGWithIdentities(t *testing.T, n, threshold int, mode Mode) (*PublicKey, []*KeyShare, [48]byte, *identityFixture) {
t.Helper()
params := MustParamsFor(mode)
committee := makeCommittee(n)
ident := newIdentityFixture(t, committee, []byte{byte(n), byte(threshold), byte(mode)})
sessions := make([]*DKGSession, n)
for i := range sessions {
s, err := NewDKGSession(params, committee, threshold, committee[i], ident.keys[committee[i]], ident.directory, deterministicReader([]byte{byte(i), 0xCA, 0xFE}))
if err != nil {
t.Fatal(err)
}
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
outputs := make([]*DKGOutput, n)
for i, s := range sessions {
out, err := s.Round3(r1, r2)
if err != nil {
t.Fatal(err)
}
if out.AbortEvidence != nil {
t.Fatalf("DKG aborted at party %d: %s", i, out.AbortEvidence.Kind)
}
outputs[i] = out
}
shares := make([]*KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
return outputs[0].GroupPubkey, shares, outputs[0].TranscriptHash, ident
}
+17 -458
View File
@@ -3,52 +3,32 @@
package pulsar
// threshold.go — two-round threshold signing.
// threshold.go — shared threshold-signing transcript machinery.
//
// Protocol shape (v0.1 reconstruction-aggregator instantiation):
// The v0.1 reconstruction-aggregator instantiation (ThresholdSigner /
// Round1 / Round2 / Combine) once lived here. It Lagrange-reconstructed
// the master seed in aggregator memory and called FIPS 204 SignTo once
// — i.e. a quorum's full ML-DSA signing key materialised in a single
// process. That reconstruct-then-sign ceremony was the H-1 footgun and
// has been removed in its entirety. The one and only threshold-signing
// path is now the v0.3 algebraic ceremony (threshold_v03.go +
// orchestrate.go), whose Lagrange-linearity-of-z aggregation produces
// the FIPS 204 signature WITHOUT ever reconstructing the secret in any
// party's memory.
//
// Round 0 (pre-protocol): every pair of parties in the quorum
// establishes an ephemeral session key via authenticated
// ML-KEM-768 key agreement (see identity.go, EstablishSession).
// The session key is the per-pair MAC key used in Round 1.
// This replaces the v0.1 deriveMACKey from public inputs
// (BLOCKERS.md CR-7).
//
// Round 1 (party i): sample per-round mask r_i. Compute the masked
// share s'_i = share_i ⊕ r_i. Compute the commit
// D_i = cSHAKE256(s'_i || tau_1)
// where tau_1 = (sid, kappa, T, i, pk, mu). MAC D_i for every
// peer j ∈ T \ {i} under the per-pair session key K_{i,j} via
// KMAC256. Broadcast (D_i, {MAC_{i,j}}).
//
// Round 2 (party i): verify every received MAC; on success reveal
// (r_i, masked_share_i) so peers can re-derive D_i and combine.
// Broadcast the revealed pair as PartialSig.
//
// Combine: aggregator gathers t Round-2 reveals, re-derives the
// per-party D_i and checks it equals the Round-1 commit, then
// Lagrange-interpolates the byte-sum at x=0, applies the same
// cSHAKE256 mix as DKG to recover the master seed, calls
// FIPS 204 mldsa.SignTo. Returns the resulting signature.
//
// The 2-round commit-and-reveal pattern with MACs is the structural
// pulsar.tex §4.2 form. The reconstruction-aggregator path collapses
// the Lagrange-linearity-of-z computation of §4.2 into a single FIPS
// 204 Sign call after share Lagrange reconstruction. This trade is
// documented in BLOCKERS.md; the v0.2 path implements
// the pure Lagrange-linearity sign where the secret is never
// reconstructed in any party's memory.
// What remains in this file is the transcript / committee-root /
// constant-time machinery and the shared error set still consumed by
// the v0.3 path, the wide-committee GF(q) path (large_threshold.go),
// and the DKG (large_dkg.go).
import (
"crypto/rand"
"encoding/binary"
"errors"
"io"
)
// Errors returned by threshold signing.
// Errors returned by threshold signing. Shared across the v0.3
// algebraic path, the GF(q) wide-committee path, and the DKG.
var (
ErrNilSession = errors.New("pulsar: nil ThresholdSigner")
ErrEmptyQuorum = errors.New("pulsar: empty signing quorum")
ErrInsufficientQuor = errors.New("pulsar: quorum smaller than threshold")
ErrRound1MACBad = errors.New("pulsar: Round-1 MAC verification failed")
@@ -59,427 +39,6 @@ var (
ErrPubkeyMismatch = errors.New("pulsar: KeyShare public-key does not match")
)
// ThresholdSigner holds one party's state for a 2-round threshold
// sign ceremony.
//
// A ThresholdSigner is single-use: one (sid, attempt) pair per
// instance. The protocol-layer driver allocates a fresh signer for
// each rejection-restart attempt (the FIPS 204 restart counter is
// the Attempt field; see pulsar.tex §6.2 for the cross-restart
// state discipline).
type ThresholdSigner struct {
Params *Params
NodeID NodeID
SecretShare *KeyShare
// SessionID uniquely identifies this signature attempt across
// the network. Distinct sessions get distinct PRNG seeds so two
// concurrent sign attempts on the same (pk, message) never share
// randomness.
SessionID [16]byte
// Attempt is the FIPS 204 rejection-restart counter kappa. Each
// restart advances kappa by 1; the per-attempt PRNG seed mixes
// kappa so distinct restarts get distinct randomness.
Attempt uint32
// Quorum is the t-element signing committee, sorted ascending by
// NodeID. All parties in Quorum must hold KeyShares with the same
// EvalPoint mapping that DKG installed.
Quorum []NodeID
// Message is the FIPS 204 message being signed (already
// transcript-prefixed; pass the same bytes that single-party
// mldsa.SignTo would consume).
Message []byte
// MACKeys is the per-pair MAC key set for this session. Each key
// is an ephemeral session key established via authenticated
// ML-KEM-768 exchange between this party and its peer
// (see EstablishSession in identity.go). The legacy v0.1
// derivation from public inputs (NodeID pair + group public key)
// was forgeable by any network observer (BLOCKERS.md CR-7);
// session keys close that hole.
MACKeys map[NodeID][32]byte
// rng is the entropy source for per-round mask r_i.
rng io.Reader
// Internal Round-1 state.
myMask [64]byte // r_i — masks the share bytes for Round-1 commit
myMaskedShare [64]byte // s'_i = share_i XOR r_i (per byte)
myCommit [32]byte // D_i
// Internal Round-2 state.
receivedR1 []*Round1Message
}
// NewThresholdSigner constructs a new ThresholdSigner for a
// (sessionID, attempt, quorum, message) tuple.
//
// quorum must be sorted ascending by NodeID and must contain
// signer.NodeID. All parties in the quorum must share the same group
// public key (i.e. they completed the same DKG).
//
// sessionKeys carries this party's per-peer ephemeral session key for
// every other quorum member. Each session key must be the byte-equal
// output of EstablishSession run between this party and the peer
// (typically: caller drives Round 0 of identity.go's KEM exchange
// before constructing the ThresholdSigner). The map MUST contain an
// entry for every peer in quorum except myShare.NodeID itself;
// missing entries return ErrSessionKeyMissing.
//
// rng may be nil — crypto/rand is used by default. Pass a
// deterministic reader for KAT runs.
func NewThresholdSigner(
params *Params,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
myShare *KeyShare,
sessionKeys map[NodeID][32]byte,
message []byte,
rng io.Reader,
) (*ThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if myShare == nil {
return nil, ErrNilKey
}
if myShare.Mode != params.Mode {
return nil, ErrModeMismatch
}
if len(quorum) == 0 {
return nil, ErrEmptyQuorum
}
// myShare.NodeID must appear in quorum.
found := false
for _, q := range quorum {
if q == myShare.NodeID {
found = true
break
}
}
if !found {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
// Validate every peer has a session key.
macKeys := make(map[NodeID][32]byte, len(quorum)-1)
for _, peer := range quorum {
if peer == myShare.NodeID {
continue
}
key, ok := sessionKeys[peer]
if !ok {
return nil, ErrSessionKeyMissing
}
macKeys[peer] = key
}
return &ThresholdSigner{
Params: params,
NodeID: myShare.NodeID,
SecretShare: myShare,
SessionID: sessionID,
Attempt: attempt,
Quorum: quorum,
Message: append([]byte{}, message...),
MACKeys: macKeys,
rng: rng,
}, nil
}
// Round1 samples the per-round mask, computes the commit, and emits
// the Round-1 broadcast. The mask is consumed at Round 2 — there is
// no "fresh mask per peer" because the commit is over a single
// masked share that every peer aggregates equivalently.
func (s *ThresholdSigner) Round1(message []byte) (*Round1Message, error) {
// Sample 64 bytes of raw entropy from the caller's RNG, then
// derive the per-attempt mask by mixing the raw entropy with
// (sid, attempt, NodeID). This way a deterministic RNG that
// produces the same output across two attempts still yields
// DISTINCT masks per attempt — cross-attempt mask reuse window
// (Agent 4 H2) is closed.
var rngBytes [64]byte
if _, err := io.ReadFull(s.rng, rngBytes[:]); err != nil {
return nil, ErrShortRand
}
var attemptBE [4]byte
binary.BigEndian.PutUint32(attemptBE[:], s.Attempt)
maskMix := make([]byte, 0, 64+16+4+len(s.NodeID))
maskMix = append(maskMix, rngBytes[:]...)
maskMix = append(maskMix, s.SessionID[:]...)
maskMix = append(maskMix, attemptBE[:]...)
maskMix = append(maskMix, s.NodeID[:]...)
copy(s.myMask[:], cshake256(maskMix, 64, tagSignMask))
// Wipe the raw RNG buffer; it carries entropy that under a
// deterministic-RNG misuse could be observable.
zeroizeBytes(rngBytes[:])
zeroizeBytes(maskMix)
// Mask the share byte-by-byte XOR.
for i := 0; i < 64; i++ {
s.myMaskedShare[i] = s.SecretShare.Share[i] ^ s.myMask[i]
}
// Commit D_i = cSHAKE256(mask_i || masked_i || tau_1). Binding
// BOTH halves into the commit makes Round-2 reveal tampering of
// either half a commit-mismatch in Combine. Without binding the
// mask, an adversary intercepting Round-2 could rotate (mask,
// masked) to (mask ⊕ δ, masked) and the reconstructed share would
// be silently corrupted; binding both closes that gap.
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, s.NodeID, s.SecretShare.Pub, s.Message)
commitInput := append(append([]byte{}, s.myMask[:]...), s.myMaskedShare[:]...)
commitInput = append(commitInput, tau...)
s.myCommit = transcriptHash32(tagSignR1, commitInput)
// MACs to every peer in the quorum.
macs := make(map[NodeID][32]byte, len(s.Quorum)-1)
for _, peer := range s.Quorum {
if peer == s.NodeID {
continue
}
key := s.MACKeys[peer]
macInput := append(append([]byte{}, s.myCommit[:]...), tau...)
mac := kmac256(key[:], macInput, 32, tagSignR1MAC)
var macArr [32]byte
copy(macArr[:], mac)
macs[peer] = macArr
}
return &Round1Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
Commit: s.myCommit,
MACs: macs,
}, nil
}
// Round2 ingests every party's Round-1 broadcast, verifies the MACs
// against this party, and emits the Round-2 reveal carrying (r_i,
// masked_share_i). Peers re-derive D_i from the reveal and check it
// equals the Round-1 commit.
//
// On MAC failure, returns ErrRound1MACBad along with AbortEvidence
// that the protocol-layer driver MUST broadcast as a complaint.
func (s *ThresholdSigner) Round2(round1Msgs []*Round1Message) (*Round2Message, *AbortEvidence, error) {
if len(round1Msgs) < 1 {
return nil, nil, ErrEmptyQuorum
}
// Verify session and attempt consistency, and MACs from each peer
// to this party.
for _, m := range round1Msgs {
if m.SessionID != s.SessionID {
return nil, nil, ErrSessionMismatch
}
if m.Attempt != s.Attempt {
return nil, nil, ErrAttemptMismatch
}
if m.NodeID == s.NodeID {
continue
}
// Peer's MAC to me uses the same shared key under our pair
// derivation (deriveMACKey is symmetric — see comment there).
key := s.MACKeys[m.NodeID]
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, m.NodeID, s.SecretShare.Pub, s.Message)
macInput := append(append([]byte{}, m.Commit[:]...), tau...)
expectedMAC := kmac256(key[:], macInput, 32, tagSignR1MAC)
gotMAC, ok := m.MACs[s.NodeID]
if !ok {
return nil, &AbortEvidence{
Kind: ComplaintMACFailure,
Accuser: s.NodeID,
Accused: m.NodeID,
}, ErrRound1MACBad
}
// Constant-time MAC compare.
if !ctEqualSlice(expectedMAC, gotMAC[:]) {
return nil, &AbortEvidence{
Kind: ComplaintMACFailure,
Accuser: s.NodeID,
Accused: m.NodeID,
Evidence: append(append([]byte{}, expectedMAC...), gotMAC[:]...),
}, ErrRound1MACBad
}
}
s.receivedR1 = round1Msgs
// Round-2 reveal: (r_i, masked_share_i). Pack into PartialSig.
revealed := make([]byte, 0, 128)
revealed = append(revealed, s.myMask[:]...)
revealed = append(revealed, s.myMaskedShare[:]...)
return &Round2Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
W1: nil, // unused in v0.1 reconstruction-aggregator instantiation
PartialSig: revealed,
}, nil, nil
}
// Combine produces a FIPS 204 ML-DSA signature from a quorum of
// Round-2 reveals.
//
// params, groupPubkey, message, sessionID, attempt, and quorum MUST
// match what was passed to NewThresholdSigner. Round-2 messages must
// come from at least `threshold` distinct quorum members; any number
// beyond threshold is tolerated and ignored.
//
// Combine is a pure function — it does not require ThresholdSigner
// state. Any honest party in the quorum can call Combine after Round
// 2 completes.
//
// The returned Signature, when passed to Verify(params, groupPubkey,
// message, sig), returns nil (i.e. the signature is FIPS 204 valid).
func Combine(params *Params, groupPubkey *PublicKey, message []byte, ctx []byte, randomized bool, sessionID [16]byte, attempt uint32, quorum []NodeID, threshold int, round1 []*Round1Message, round2 []*Round2Message, allShares []*KeyShare) (*Signature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if groupPubkey == nil {
return nil, ErrNilPublicKey
}
if len(round1) < threshold || len(round2) < threshold {
return nil, ErrInsufficientQuor
}
// Index Round-1 messages by sender.
r1ByID := make(map[NodeID]*Round1Message, len(round1))
for _, m := range round1 {
if m.SessionID != sessionID || m.Attempt != attempt {
return nil, ErrSessionMismatch
}
r1ByID[m.NodeID] = m
}
// For each Round-2 reveal: re-derive D_i and compare against
// the matching Round-1 commit.
revealedShares := make(map[NodeID][64]byte, threshold)
for _, r2 := range round2 {
r1, ok := r1ByID[r2.NodeID]
if !ok {
continue
}
if r2.SessionID != sessionID || r2.Attempt != attempt {
return nil, ErrSessionMismatch
}
if len(r2.PartialSig) != 128 {
return nil, ErrRound2CommitBad
}
var mask [64]byte
var masked [64]byte
copy(mask[:], r2.PartialSig[:64])
copy(masked[:], r2.PartialSig[64:])
// Re-derive D_i = cSHAKE256(mask || masked || tau_1).
tau := transcriptTau1Bytes(sessionID, attempt, quorum, r2.NodeID, groupPubkey, message)
commitInput := append(append([]byte{}, mask[:]...), masked[:]...)
commitInput = append(commitInput, tau...)
recomputed := transcriptHash32(tagSignR1, commitInput)
if !ctEqual32(recomputed, r1.Commit) {
return nil, ErrRound2CommitBad
}
// Recover share = masked XOR mask.
var share [64]byte
for i := 0; i < 64; i++ {
share[i] = masked[i] ^ mask[i]
}
revealedShares[r2.NodeID] = share
}
if len(revealedShares) < threshold {
return nil, ErrInsufficientQuor
}
// Pair each revealed share with its KeyShare entry to recover
// the EvalPoint. The aggregator gets allShares as a directory so
// it can map NodeID → EvalPoint without recomputing the DKG
// committee order.
keyShareByID := make(map[NodeID]*KeyShare, len(allShares))
for _, ks := range allShares {
keyShareByID[ks.NodeID] = ks
}
shares := make([]shamirShare, 0, threshold)
for id, sBytes := range revealedShares {
ks, ok := keyShareByID[id]
if !ok {
return nil, ErrNotInQuorum
}
var buf [shareWireSize]byte
copy(buf[:], sBytes[:])
shares = append(shares, shareFromBytes(ks.EvalPoint, buf))
if len(shares) == threshold {
break
}
}
// Reconstruct the GF(257) byte-sum (NOT mod 256) so the cSHAKE256
// mix input matches DKG's mix input bit-for-bit. The committee
// root is canonical given allShares.
byteSum, err := shamirReconstructGF(shares)
if err != nil {
return nil, err
}
committeeRoot := committeeRootFromShares(allShares)
byteSumBytes := make([]byte, SeedSize*2)
for b := 0; b < SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
var masterSeed [SeedSize]byte
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
// FIPS 204 sign with the reconstructed seed-derived key.
// Every error path AND the success path below must explicitly
// zeroize the reconstructed secret material (master seed,
// byteSum, byteSumBytes, mixInput) before return. No defer:
// the calls are inline at each exit point so the secret
// lifetime is locally legible.
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
zeroizeSeed(&masterSeed)
zeroizeU16(&byteSum)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
// Sanity: the reconstructed pubkey MUST match groupPubkey, else
// the aggregator's share set is wrong / tampered.
if !sk.Pub.Equal(groupPubkey) {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeU16(&byteSum)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, ErrPubkeyMismatch
}
// Use deterministic-randomness path so KATs are reproducible.
// Production callers set randomized=true; KAT/test callers pass
// randomized=false.
sigBytes, err := mldsaSign(params.Mode, sk.Bytes, message, ctx, randomized, rand.Reader)
if err != nil {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeU16(&byteSum)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
// Success path: signature is OK; wipe every secret-bearing
// buffer (the master seed + reconstructed sk + intermediate
// reconstruction buffers) before returning.
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeU16(&byteSum)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
// transcriptTau1Bytes builds the Round-1 transcript τ_1 = (sid, κ, T,
// sender, pk, μ). τ_1 is bound into every commit and MAC so a cross-
// session replay of the commit-and-reveal pair becomes a transcript
-384
View File
@@ -1,384 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
import (
"testing"
)
// runDKG runs a deterministic DKG with the given committee and threshold
// and returns the group public key + per-party shares.
func runDKG(t *testing.T, n, threshold int, mode Mode) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
pub, shares, transcript, _ := runDKGWithIdentities(t, n, threshold, mode)
return pub, shares, transcript
}
// runDKGWithIdentities is the extended helper that also returns the
// identity fixture used by the DKG ceremony. Threshold-sign tests
// reuse the fixture to derive the per-pair session keys for the
// signing quorum.
func runDKGWithIdentities(t *testing.T, n, threshold int, mode Mode) (*PublicKey, []*KeyShare, [48]byte, *identityFixture) {
t.Helper()
params := MustParamsFor(mode)
committee := makeCommittee(n)
ident := newIdentityFixture(t, committee, []byte{byte(n), byte(threshold), byte(mode)})
sessions := make([]*DKGSession, n)
for i := range sessions {
s, err := NewDKGSession(params, committee, threshold, committee[i], ident.keys[committee[i]], ident.directory, deterministicReader([]byte{byte(i), 0xCA, 0xFE}))
if err != nil {
t.Fatal(err)
}
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
outputs := make([]*DKGOutput, n)
for i, s := range sessions {
out, err := s.Round3(r1, r2)
if err != nil {
t.Fatal(err)
}
if out.AbortEvidence != nil {
t.Fatalf("DKG aborted at party %d: %s", i, out.AbortEvidence.Kind)
}
outputs[i] = out
}
shares := make([]*KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
return outputs[0].GroupPubkey, shares, outputs[0].TranscriptHash, ident
}
func TestThresholdSign_RoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
n, t int
}{
{"5of3", 5, 3},
{"7of4", 7, 4},
{"10of7", 10, 7},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, tc.n, tc.t, ModeP65)
msg := []byte("threshold sign — Class N1 round-trip")
var sid [16]byte
copy(sid[:], "pulsar-test-01")
attempt := uint32(1)
// Quorum = first t parties in canonical (sorted) order.
quorum := make([]NodeID, tc.t)
for i := 0; i < tc.t; i++ {
quorum[i] = shares[i].NodeID
}
// Establish per-pair session keys for the quorum (CR-7).
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
// Per-party ThresholdSigner.
signers := make([]*ThresholdSigner, tc.t)
for i := 0; i < tc.t; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i), 0xFE}))
if err != nil {
t.Fatal(err)
}
signers[i] = s
}
// Round 1.
r1 := make([]*Round1Message, tc.t)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
// Round 2.
r2 := make([]*Round2Message, tc.t)
for i, s := range signers {
m, ev, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v (ev=%v)", i, err, ev)
}
r2[i] = m
}
// Combine.
sig, err := Combine(params, pub, msg, nil, false, sid, attempt, quorum, tc.t, r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("sig size %d want %d", len(sig.Bytes), params.SignatureSize)
}
// Verify under unmodified FIPS 204 — this is the Class N1 claim.
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("threshold-produced sig fails FIPS 204 Verify: %v", err)
}
})
}
}
func TestThresholdSign_BadMAC_Detected(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("test-bad-mac")
var sid [16]byte
copy(sid[:], "bad-mac-sess-01")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i)}))
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1(msg)
}
// Tamper a MAC: party 0 sent a MAC to party 1. Corrupt it.
if mac, ok := r1[0].MACs[quorum[1]]; ok {
mac[0] ^= 0xff
r1[0].MACs[quorum[1]] = mac
}
_, ev, err := signers[1].Round2(r1)
if err != ErrRound1MACBad {
t.Fatalf("expected MAC failure, got %v", err)
}
if ev == nil || ev.Kind != ComplaintMACFailure {
t.Fatalf("expected MAC complaint, got %v", ev)
}
_ = pub
}
func TestThresholdSign_DifferentAttempt_Rejected(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("attempt-mismatch")
var sid [16]byte
copy(sid[:], "attempt-mism-01")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i)}))
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1(msg)
}
// Corrupt one party's attempt counter.
r1[1].Attempt = 99
if _, _, err := signers[0].Round2(r1); err != ErrAttemptMismatch {
t.Fatalf("attempt mismatch not detected: %v", err)
}
_ = pub
}
func TestThresholdSign_TamperedReveal_RejectedAtCombine(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("tampered-reveal")
var sid [16]byte
copy(sid[:], "tamper-reveal-01")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i)}))
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1(msg)
}
r2 := make([]*Round2Message, 3)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
// Tamper one Round-2 PartialSig. With the v0.1 commit binding both
// (mask, masked) under D_i, tampering ANY byte of the reveal is
// caught at Combine time via commit-mismatch.
r2[1].PartialSig[0] ^= 0xaa
_, err := Combine(params, pub, msg, nil, false, sid, 1, quorum, 3, r1, r2, shares)
if err != ErrRound2CommitBad {
t.Fatalf("tampered reveal not detected: %v", err)
}
// Also tamper a byte in the masked half.
r2[1].PartialSig[0] ^= 0xaa // revert
r2[1].PartialSig[64] ^= 0x33
_, err = Combine(params, pub, msg, nil, false, sid, 1, quorum, 3, r1, r2, shares)
if err != ErrRound2CommitBad {
t.Fatalf("tampered masked-half not detected: %v", err)
}
}
// TestThresholdSwap_RejectedByCommitBind confirms the
// cross-party Round-2 swap attack does NOT go through.
//
// Adversarial premise (Agent 4 CRITICAL-1, downgraded after
// verification): Round2Message has no MAC field; an attacker could
// (in principle) swap two parties' Round-2 reveals to fool Combine.
//
// Why this attack actually fails: Combine looks up each Round-2 by
// its NodeID against the corresponding Round-1 commit and re-derives
// D_i = cSHAKE256(mask||masked||tau_1). Swapping NodeIDs makes the
// re-derived D_i not match the Round-1 commit → ErrRound2CommitBad.
//
// This test proves the rejection mechanically. As long as it passes,
// the dead-code `tagSignR2` constant (now removed) does NOT indicate
// a soundness gap — Round-2 integrity is provided by commit-bind,
// not by an explicit MAC. A future Round-2 MAC remains
// defense-in-depth, not a current soundness blocker.
func TestThresholdSwap_RejectedByCommitBind(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("round-2 swap regression")
var sid [16]byte
copy(sid[:], "swap-test-sid-01")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, err := NewThresholdSigner(params, sid, 1, quorum, shares[i],
sessionKeys[shares[i].NodeID], msg,
deterministicReader([]byte{byte(i), 0xC0, 0xDE}))
if err != nil {
t.Fatal(err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
r1[i], _ = s.Round1(msg)
}
r2 := make([]*Round2Message, 3)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
// Sanity: the un-tampered tape must Combine cleanly.
if _, err := Combine(params, pub, msg, nil, false, sid, 1,
quorum, 3, r1, r2, shares); err != nil {
t.Fatalf("un-tampered Combine failed: %v", err)
}
// --- Attack 1: swap two Round-2 messages outright ---
// Take party-1's Round-2 PartialSig bytes and put them under
// party-2's NodeID (and vice versa). The attacker is hoping
// Combine accepts the swap because the bytes themselves are
// authentic — they came from a real party reveal.
swapped := make([]*Round2Message, 3)
swapped[0] = r2[0]
// Party-2's NodeID now carries party-1's reveal bytes.
mut12 := *r2[1]
mut12.NodeID = r2[2].NodeID
swapped[1] = &mut12
mut21 := *r2[2]
mut21.NodeID = r2[1].NodeID
swapped[2] = &mut21
if _, err := Combine(params, pub, msg, nil, false, sid, 1,
quorum, 3, r1, swapped, shares); err == nil {
t.Fatal("Round-2 swap was NOT rejected — commit-bind broken")
}
// --- Attack 2: swap only the PartialSig bytes (keep NodeIDs) ---
// Combine looks up Round-1 by NodeID then re-derives D_i. If
// party-2's NodeID carries party-1's mask/masked bytes, the
// re-derived D_2 won't match party-2's Round-1 commit.
bodySwap := make([]*Round2Message, 3)
bodySwap[0] = r2[0]
mutB1 := *r2[1]
mutB1.PartialSig = append([]byte{}, r2[2].PartialSig...)
bodySwap[1] = &mutB1
mutB2 := *r2[2]
mutB2.PartialSig = append([]byte{}, r2[1].PartialSig...)
bodySwap[2] = &mutB2
if _, err := Combine(params, pub, msg, nil, false, sid, 1,
quorum, 3, r1, bodySwap, shares); err != ErrRound2CommitBad {
t.Fatalf("PartialSig swap not caught by commit-bind: %v", err)
}
}
func TestThresholdSign_QuorumTooSmall(t *testing.T) {
params := MustParamsFor(ModeP65)
_, shares, _, _ := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("small-q")
var sid [16]byte
// Single-party quorum: no peers needed, so empty sessionKeys is OK.
quorum := []NodeID{shares[0].NodeID}
_, err := NewThresholdSigner(params, sid, 1, quorum, shares[0], map[NodeID][32]byte{}, msg, deterministicReader([]byte{1}))
if err != nil {
t.Fatalf("single-party quorum: %v", err)
}
// Now try with empty quorum.
_, err = NewThresholdSigner(params, sid, 1, nil, shares[0], map[NodeID][32]byte{}, msg, deterministicReader([]byte{1}))
if err != ErrEmptyQuorum {
t.Fatalf("empty quorum not rejected: %v", err)
}
// Member not in quorum.
_, err = NewThresholdSigner(params, sid, 1, []NodeID{{0xff}}, shares[0], map[NodeID][32]byte{}, msg, deterministicReader([]byte{1}))
if err != ErrNotInQuorum {
t.Fatalf("non-member not rejected: %v", err)
}
// Missing session key for peer.
twoQ := []NodeID{shares[0].NodeID, shares[1].NodeID}
_, err = NewThresholdSigner(params, sid, 1, twoQ, shares[0], map[NodeID][32]byte{}, msg, deterministicReader([]byte{1}))
if err != ErrSessionKeyMissing {
t.Fatalf("missing session key not rejected: %v", err)
}
}
func TestThresholdSign_DifferentQuorum_SameMessage(t *testing.T) {
// Two different quorums of size t over the same DKG should both
// produce valid FIPS 204 signatures on the same message.
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 7, 4, ModeP65)
msg := []byte("multi-quorum-test")
for round, idxs := range [][4]int{{0, 1, 2, 3}, {3, 4, 5, 6}} {
var sid [16]byte
sid[0] = byte(round)
quorum := []NodeID{shares[idxs[0]].NodeID, shares[idxs[1]].NodeID, shares[idxs[2]].NodeID, shares[idxs[3]].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 4)
for j, idx := range idxs {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[idx], sessionKeys[shares[idx].NodeID], msg, deterministicReader([]byte{byte(idx)}))
signers[j] = s
}
r1 := make([]*Round1Message, 4)
for j, s := range signers {
r1[j], _ = s.Round1(msg)
}
r2 := make([]*Round2Message, 4)
for j, s := range signers {
r2[j], _, _ = s.Round2(r1)
}
sig, err := Combine(params, pub, msg, nil, false, sid, 1, quorum, 4, r1, r2, shares)
if err != nil {
t.Fatalf("quorum %v Combine: %v", idxs, err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("quorum %v sig fails FIPS 204 Verify: %v", idxs, err)
}
}
}
-49
View File
@@ -168,55 +168,6 @@ type Signature struct {
Bytes []byte
}
// Round1Message is the broadcast emitted by ThresholdSigner.Round1.
//
// The protocol structure follows pulsar.tex §4.2 Algorithm "Sign
// Round 1": commit + MAC the per-party w̄_i. The full bar-w is sent
// alongside the digest at round 2 — the digest's role is binding under
// MAC, not concealment. See pulsar.tex §4.2 Remark "Round-1
// commit-digest binding".
//
// The MAC keys are EPHEMERAL per-session per-pair keys established by
// EstablishSession before Round 1. The legacy v0.1 derivation from
// public inputs (NodeID pair + group pubkey) was forgeable by any
// network observer (BLOCKERS.md CR-7); the v0.2 derivation binds the
// MAC to a fresh ML-KEM-768 shared secret authenticated under each
// party's long-term ML-DSA identity.
type Round1Message struct {
NodeID NodeID
SessionID [16]byte // sid uniqueness; see pulsar.tex §6.2
Attempt uint32 // rejection-restart counter κ
Commit [32]byte // D_i = cSHAKE(w̄_i || τ_1) per pulsar.tex §4.2
MACs map[NodeID][32]byte // KMAC256(K_{i,j}, D_i || τ_1) — K_{i,j} is the ephemeral session key
}
// Round2Message is the broadcast emitted by ThresholdSigner.Round2.
//
// Carries the party's plaintext mask contribution (so peers can
// re-derive D_i and compare against the Round-1 commit), the per-party
// response contribution, and the per-party blinding-share term used by
// the aggregator to recompute c·s_2.
type Round2Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
// W1 is the per-party w̄_i in HighBits form. The aggregator sums
// the per-party W1 values to recover the aggregate w̄ used to
// compute the FIPS 204 challenge c̃.
W1 []byte
// PartialSig is this party's contribution toward the FIPS 204
// signature. In the v0.1 reconstruction-aggregator instantiation
// (see BLOCKERS.md), this is the Shamir share of
// the seed encrypted under the round transcript hash; the
// aggregator collects t shares, reconstructs the seed, and emits
// a single FIPS 204 signature. The Lagrange-linearity path of
// pulsar.tex §4.2 Algorithm "Sign Round 2" produces (z_i, r_i)
// here directly; it is on the v0.2 critical path.
PartialSig []byte
}
// DKGRound1Msg is the broadcast emitted by DKGSession.Round1.
//
// Protocol shape (CR-6 path A — Shamir+sum, no commit-and-open):