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