ref: merge pulsar-mptc submission-grade impl into canonical pulsar

Collapse the two parallel implementation trees (pulsar/ref/go/pkg/pulsar
and pulsar-mptc/ref/go/pkg/pulsar) per SINGLE-IMPL-PLAN.md by porting
the mptc submission-grade additions into the canonical pulsar tree.

New files (mptc-only, adopted verbatim):
  - identity.go / identity_test.go: long-term ML-KEM-768 + ML-DSA-65
    keypair, EstablishSession / DeriveSessionKey ephemeral key
    agreement, sealEnvelope / sealOpenEnvelope KEM-wrapped DKG
    envelope (BLOCKERS.md CR-6 / CR-7 / CR-8 closure)
  - n1_byte_equality_test.go: differential test asserting threshold
    Combine output is byte-identical to centralized FIPS 204 Sign
    under the reconstructed key — Class N1 manifesto headline
  - fuzz_test.go: go-test fuzz harness for AbortEvidence parsing,
    verify code paths, share parsing, params parsing
  - zeroize.go: best-effort secret-buffer wipe helpers

Adopted from mptc (security-grade superset; pulsar v0.1 fully
contained within):
  - abort.go / abort_test.go: identifiable-abort evidence parsing
    + per-kind structural validation + pluggable signature verifier
  - dkg.go / dkg_test.go: KEM-wrapped envelope DKG; NewDKGSession
    signature gains (myIdentity, directory) per CR-8
  - reshare.go / reshare_test.go: matching KEM-wrapped reshare;
    NewReshareSession signature gains (myIdentity, newDirectory)
  - threshold.go / threshold_test.go: ephemeral session-keyed MACs;
    NewThresholdSigner signature gains sessionKeys per CR-7;
    TestThresholdSwap_RejectedByCommitBind defense-in-depth proof
  - transcript.go: tagSignMask per-attempt mask derivation tag (H2);
    tagSignR2 commented out as RESERVED so a rebase can't silently
    reuse the name
  - shamir.go: DEPRECATED comment on byte-path shamirReconstruct;
    distribution-honesty comment on shamirEvalPoint
  - bench_test.go / kat_test.go: updated to new signatures
  - cmd/genkat/main.go: makeKATIdentities + makeKATSessionKeys for
    KAT regeneration under the new identity-based protocol

Reconciled (mptc base + pulsar preservation):
  - types.go: adopt mptc's KEM-wrapped DKGShareEnvelope (KEMCiphertext
    + Sealed fields, replacing v0.1 Share + Blind plaintext) and
    ephemeral MAC key comments, PRESERVING pulsar's Field type +
    FieldDefault / FieldGF257 / FieldGFq constants + String() +
    resolveField() helper for large-committee scalability

Preserved (pulsar-only, untouched):
  - large_dkg.go / large_threshold.go / large_reshare.go / large_types.go
    / large_e2e_test.go: GF(q) wide-committee parallel implementation
  - shamir_gfq.go / shamir_gfq_test.go: generic-field Shamir
  - largeshamir.go / largeshamir_test.go: large-committee Shamir
  - round.go / round_test.go: RoundContext / RoundSigner abstraction
    (tests updated to new identity-based threshold signer signature)
  - verify_batch.go / verify_batch_test.go: parallel batch verify
  - precompile_e2e_test.go: EVM precompile end-to-end (updated for
    new identity-based signer signature)
  - params.go: TargetCommitteeSize = 1_111_111 constant (unchanged)

Localised legacy compat:
  - large_threshold.go: legacyDeriveMACKeyLarge renamed from the
    removed package-level deriveMACKey; clearly tagged as v0.1
    pending a parallel large-committee identity layer

Build: GOWORK=off go build ./... clean
Tests: GOWORK=off go test ./... pass (119/119, including fuzz seeds
       for AbortEvidence / VerifySignature / VerifyCtx / ShareFromBytes
       / ParamsFor / N1_ByteEquality_Differential)
Race:  -race clean
This commit is contained in:
2026-05-18 17:38:43 -07:00
parent b28f2b97d1
commit 881c4e82d8
23 changed files with 3034 additions and 309 deletions
+61 -6
View File
@@ -88,7 +88,7 @@ func newDetReader(seed []byte) *detReader {
func (r *detReader) Read(p []byte) (int, error) {
// We pull blocks of 4096 bytes via SHA-256 chaining for clean
// determinism without requiring the pulsarm package's internal
// determinism without requiring the pulsar package's internal
// cshake256. SHA-256(seed || counter) → 32 bytes per block.
for n := 0; n < len(p); {
if r.off >= len(r.buf) {
@@ -212,7 +212,8 @@ func main() {
for _, tc := range []struct{ N, T int }{{3, 2}, {5, 3}, {7, 4}, {10, 7}} {
params := pm.MustParamsFor(mode)
committee := makeKATCommittee(tc.N)
pub, shares, _ := runKATDKG(params, committee, tc.T, mode)
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++ {
@@ -221,10 +222,14 @@ func main() {
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], msg, rng)
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 {
@@ -267,7 +272,8 @@ func main() {
for _, tc := range []struct{ N, T int }{{3, 2}, {5, 3}, {7, 4}} {
params := pm.MustParamsFor(mode)
committee := makeKATCommittee(tc.N)
pub, shares, transcript := runKATDKG(params, committee, tc.T, mode)
identities := makeKATIdentities(committee, mode, tc.N, tc.T)
pub, shares, transcript := runKATDKG(params, committee, tc.T, mode, identities)
committeeHex := make([]string, tc.N)
for i, c := range committee {
committeeHex[i] = hex.EncodeToString(c[:])
@@ -300,15 +306,64 @@ func makeKATCommittee(n int) []pm.NodeID {
return out
}
// katIdentities is the deterministic IdentityKey set for a KAT DKG
// run. Includes both the per-party IdentityKey (private+public) and
// the published IdentityDirectory (public-only).
type katIdentities struct {
keys map[pm.NodeID]*pm.IdentityKey
dir pm.IdentityDirectory
}
func makeKATIdentities(committee []pm.NodeID, mode pm.Mode, n, threshold int) *katIdentities {
keys := make(map[pm.NodeID]*pm.IdentityKey, len(committee))
pubs := make(map[pm.NodeID]*pm.IdentityPublicKey, len(committee))
for i, id := range committee {
seedTag := append([]byte("PULSAR-IDENT-KAT-V1"), []byte{byte(mode), byte(n), byte(threshold), byte(i)}...)
rng := newDetReader(seedTag)
k, err := pm.GenerateIdentity(rng)
if err != nil {
fail(fmt.Errorf("GenerateIdentity %d: %w", i, err))
}
keys[id] = k
pubs[id] = k.PublicKey()
}
dir, err := pm.NewIdentityDirectory(pubs)
if err != nil {
fail(err)
}
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) (*pm.PublicKey, []*pm.KeyShare, [48]byte) {
func runKATDKG(params *pm.Params, committee []pm.NodeID, threshold int, mode pm.Mode, identities *katIdentities) (*pm.PublicKey, []*pm.KeyShare, [48]byte) {
n := len(committee)
sessions := make([]*pm.DKGSession, n)
for i := range sessions {
// Deterministic per-party seed.
seedTag := append([]byte("PULSAR-DKG-KAT-V1"), []byte{byte(mode), byte(n), byte(threshold), byte(i)}...)
rng := newDetReader(seedTag)
s, _ := pm.NewDKGSession(params, committee, threshold, committee[i], rng)
s, _ := pm.NewDKGSession(params, committee, threshold, committee[i], identities.keys[committee[i]], identities.dir, rng)
sessions[i] = s
}
r1 := make([]*pm.DKGRound1Msg, n)
+277 -3
View File
@@ -16,17 +16,217 @@ package pulsar
// uses to check a complaint is well-formed.
import (
"bytes"
"encoding/binary"
"errors"
)
// Errors returned by abort-evidence verification.
var (
ErrInvalidComplaint = errors.New("pulsar: complaint structure invalid")
ErrComplaintSelfAcc = errors.New("pulsar: complaint accuser equals accused")
ErrComplaintNoSig = errors.New("pulsar: complaint missing accuser signature")
ErrInvalidComplaint = errors.New("pulsar: complaint structure invalid")
ErrComplaintSelfAcc = errors.New("pulsar: complaint accuser equals accused")
ErrComplaintNoSig = errors.New("pulsar: complaint missing accuser signature")
ErrComplaintKind = errors.New("pulsar: complaint kind out of range")
ErrComplaintNoEv = errors.New("pulsar: complaint missing evidence blob")
ErrEvidenceFieldCount = errors.New("pulsar: evidence field count mismatch for kind")
ErrEvidenceFieldLen = errors.New("pulsar: evidence field length below FIPS 204 minimum")
ErrEvidenceTrailing = errors.New("pulsar: trailing bytes after last evidence field")
ErrEvidenceTruncated = errors.New("pulsar: evidence field truncated")
ErrEvidenceDuplicate = errors.New("pulsar: duplicate field where uniqueness required")
)
// Per-kind evidence-field counts (matches types.go:235-254 schema).
//
// Equivocation = (commit1, commit2, broadcast_sig1, broadcast_sig2) — 4 fields
// BadDelivery = (share, blind, commits) — 3 fields
// MACFailure = (mac, key) — 2 fields
// RangeFailure = (transcript_line) — 1 field
const (
equivocationFieldCount = 4
badDeliveryFieldCount = 3
macFailureFieldCount = 2
rangeFailureFieldCount = 1
)
// Per-field minimum byte lengths. Set conservatively against the
// FIPS 204 ML-DSA-65 + Pulsar Pedersen primitives.
//
// commit : 32 bytes (SHAKE-256-truncated Pedersen commit)
// sig : 64 bytes (FIPS 204 §3.7 minimum across schemes —
// Ed25519 = 64; opaque to this package)
// share : 32 bytes (per-party ML-DSA share digest)
// blind : 32 bytes (Pedersen blinding seed)
// mac : 32 bytes (SHAKE-256-truncated MAC)
// key : 32 bytes (recipient identity / MAC key)
// t-line : 1 byte (transcript line is variable; require at
// least one byte so empty doesn't pass)
const (
commitMinLen = 32
sigMinLen = 64
shareMinLen = 32
blindMinLen = 32
commitListMinLen = 32 // at least one commit
macMinLen = 32
keyMinLen = 32
transcriptMinLen = 1
)
// parseEvidenceFields parses an evidence blob in TLV form:
//
// field = 4 bytes (BE u32 length) || N bytes payload
// blob = field || field || ... || field
//
// Returns the parsed payloads (without their length prefixes). A
// trailing-bytes overflow or truncated length-prefix yields a
// distinct error so callers can distinguish framing errors from
// length-bound errors.
func parseEvidenceFields(blob []byte) ([][]byte, error) {
fields := make([][]byte, 0, 4)
off := 0
for off < len(blob) {
if len(blob)-off < 4 {
return nil, ErrEvidenceTruncated
}
l := binary.BigEndian.Uint32(blob[off : off+4])
off += 4
if uint64(off)+uint64(l) > uint64(len(blob)) {
return nil, ErrEvidenceTruncated
}
fields = append(fields, blob[off:off+int(l)])
off += int(l)
}
return fields, nil
}
// validateEquivocationEvidence: 4 fields (commit1, commit2, sig1, sig2).
//
// Adversarial test: commit1 == commit2 means no equivocation; reject
// with ErrEvidenceDuplicate (the package can do this structural check
// even though it can't validate the broadcast signatures themselves).
func validateEquivocationEvidence(blob []byte) error {
fields, err := parseEvidenceFields(blob)
if err != nil {
return err
}
if len(fields) != equivocationFieldCount {
return ErrEvidenceFieldCount
}
commit1, commit2, sig1, sig2 := fields[0], fields[1], fields[2], fields[3]
if len(commit1) < commitMinLen || len(commit2) < commitMinLen {
return ErrEvidenceFieldLen
}
if len(sig1) < sigMinLen || len(sig2) < sigMinLen {
return ErrEvidenceFieldLen
}
if bytes.Equal(commit1, commit2) {
return ErrEvidenceDuplicate
}
return nil
}
// validateBadDeliveryEvidence: 3 fields (share, blind, commits).
func validateBadDeliveryEvidence(blob []byte) error {
fields, err := parseEvidenceFields(blob)
if err != nil {
return err
}
if len(fields) != badDeliveryFieldCount {
return ErrEvidenceFieldCount
}
share, blind, commits := fields[0], fields[1], fields[2]
if len(share) < shareMinLen {
return ErrEvidenceFieldLen
}
if len(blind) < blindMinLen {
return ErrEvidenceFieldLen
}
if len(commits) < commitListMinLen {
return ErrEvidenceFieldLen
}
return nil
}
// validateMACFailureEvidence: 2 fields (mac, key).
func validateMACFailureEvidence(blob []byte) error {
fields, err := parseEvidenceFields(blob)
if err != nil {
return err
}
if len(fields) != macFailureFieldCount {
return ErrEvidenceFieldCount
}
mac, key := fields[0], fields[1]
if len(mac) < macMinLen {
return ErrEvidenceFieldLen
}
if len(key) < keyMinLen {
return ErrEvidenceFieldLen
}
return nil
}
// validateRangeFailureEvidence: 1 field (transcript_line).
func validateRangeFailureEvidence(blob []byte) error {
fields, err := parseEvidenceFields(blob)
if err != nil {
return err
}
if len(fields) != rangeFailureFieldCount {
return ErrEvidenceFieldCount
}
transcript := fields[0]
if len(transcript) < transcriptMinLen {
return ErrEvidenceFieldLen
}
return nil
}
// ValidateAbortEvidence dispatches per-kind structural validation
// of the evidence blob (followup C closure). The package-level
// generic VerifyAbortEvidenceForm calls this internally; consumers
// can call it directly if they want only kind-specific blob shape
// checks without the surrounding form check.
//
// Returns ErrComplaintKind for an unknown kind, or the per-kind
// validator's error for shape violations.
func ValidateAbortEvidence(e *AbortEvidence) error {
if e == nil {
return ErrInvalidComplaint
}
switch e.Kind {
case ComplaintEquivocation:
return validateEquivocationEvidence(e.Evidence)
case ComplaintBadDelivery:
return validateBadDeliveryEvidence(e.Evidence)
case ComplaintMACFailure:
return validateMACFailureEvidence(e.Evidence)
case ComplaintRangeFailure:
return validateRangeFailureEvidence(e.Evidence)
default:
return ErrComplaintKind
}
}
// AbortSignatureVerifier is the pluggable identity-layer signature
// verifier. Third parties (chain validators, audit observers, slashing
// modules) call VerifyAbortEvidence with an instance that wraps their
// production identity-key scheme (Ed25519, hybrid PQ, BLS, etc.).
//
// `transcript` is the canonical to-be-signed bytes returned by
// TranscriptForComplaint(e). `signature` is e.Signature. `accuser` is
// e.Accuser — the verifier looks up the accuser's public identity key
// in its own validator-set record and runs the underlying scheme's
// verify.
//
// Return true iff the signature is valid AND the accuser's identity is
// known to the verifier (i.e., the accuser is a registered party at
// the complained-about epoch). A nil verifier check is rejected by
// VerifyAbortEvidence via ErrComplaintNoSig — there is no implicit
// "skip signature verification" path.
type AbortSignatureVerifier interface {
VerifyAbortSignature(accuser NodeID, transcript, signature []byte) bool
}
// MarshalAbortEvidence serialises an AbortEvidence to its canonical
// wire form. Layout:
//
@@ -135,3 +335,77 @@ func transcriptForComplaint(e *AbortEvidence) []byte {
func TranscriptForComplaint(e *AbortEvidence) []byte {
return transcriptForComplaint(e)
}
// VerifyAbortEvidenceForm performs the third-party-verifiable
// STRUCTURAL well-formedness check on an AbortEvidence (Agent 4 H1
// + followup C closure). Independent of any signature scheme.
//
// Checks (in order):
// - e is non-nil.
// - accuser != accused.
// - kind is in the registered ComplaintKind range.
// - signature is non-empty.
// - evidence blob is non-empty AND passes the kind-specific
// ValidateAbortEvidence shape check (field count, per-field min
// length, no trailing bytes, kind-specific uniqueness checks).
//
// Returns the first matching error, or nil if all structural checks
// pass. Third-party verifiers should call this BEFORE attempting
// signature verification — a well-formed transcript depends on the
// form being valid first.
//
// followup C key adversarial property: a blob valid for kind A will
// NOT validate under kind B, because the field counts differ
// (4/3/2/1) and ValidateAbortEvidence rejects mismatched counts via
// ErrEvidenceFieldCount.
func VerifyAbortEvidenceForm(e *AbortEvidence) error {
if e == nil {
return ErrInvalidComplaint
}
if e.Accuser == e.Accused {
return ErrComplaintSelfAcc
}
switch e.Kind {
case ComplaintEquivocation, ComplaintBadDelivery,
ComplaintMACFailure, ComplaintRangeFailure:
// known kind
default:
return ErrComplaintKind
}
if len(e.Signature) == 0 {
return ErrComplaintNoSig
}
if len(e.Evidence) == 0 {
return ErrComplaintNoEv
}
if err := ValidateAbortEvidence(e); err != nil {
return err
}
return nil
}
// VerifyAbortEvidence performs the FULL third-party verification of an
// AbortEvidence (Agent 4 H1 closure):
//
// 1. Structural well-formedness via VerifyAbortEvidenceForm.
// 2. Identity-layer signature check via the supplied verifier.
//
// The verifier MUST be non-nil — the package does not provide a
// default "skip signature" path. Returns ErrComplaintNoSig if the
// supplied verifier rejects the signature (i.e., the accuser's
// identity is not on file or the signature is forged).
//
// Pure form validation is available standalone via
// VerifyAbortEvidenceForm.
func VerifyAbortEvidence(e *AbortEvidence, v AbortSignatureVerifier) error {
if err := VerifyAbortEvidenceForm(e); err != nil {
return err
}
if v == nil {
return ErrComplaintNoSig
}
if !v.VerifyAbortSignature(e.Accuser, TranscriptForComplaint(e), e.Signature) {
return ErrComplaintNoSig
}
return nil
}
+266 -1
View File
@@ -5,16 +5,61 @@ package pulsar
import (
"bytes"
"encoding/binary"
"testing"
)
// makeTLV builds a length-prefixed evidence blob for tests.
// Each field is encoded as 4-byte BE u32 length || N bytes payload.
func makeTLV(fields ...[]byte) []byte {
out := make([]byte, 0)
var lenBuf [4]byte
for _, f := range fields {
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(f)))
out = append(out, lenBuf[:]...)
out = append(out, f...)
}
return out
}
// rep returns a length-n byte slice filled with byte v. Used to
// build evidence fields of the per-field minimum byte lengths.
func rep(v byte, n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = v
}
return b
}
// validEvidenceFor returns a TLV-encoded evidence blob that passes
// the per-kind structural validator for the given kind.
func validEvidenceFor(kind ComplaintKind) []byte {
switch kind {
case ComplaintEquivocation:
// 4 fields: commit1, commit2, sig1, sig2 (commits must differ)
return makeTLV(rep(0xA1, 32), rep(0xA2, 32), rep(0xB1, 64), rep(0xB2, 64))
case ComplaintBadDelivery:
// 3 fields: share, blind, commits
return makeTLV(rep(0x11, 32), rep(0x22, 32), rep(0x33, 32))
case ComplaintMACFailure:
// 2 fields: mac, key
return makeTLV(rep(0x55, 32), rep(0x66, 32))
case ComplaintRangeFailure:
// 1 field: transcript_line
return makeTLV([]byte("transcript-line-payload"))
default:
return nil
}
}
func TestAbortEvidence_RoundTrip(t *testing.T) {
original := &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: NodeID{1, 2, 3},
Accused: NodeID{4, 5, 6},
Epoch: 42,
Evidence: []byte("some-evidence-blob"),
Evidence: validEvidenceFor(ComplaintEquivocation),
Signature: []byte("identity-key-signature"),
}
wire, err := MarshalAbortEvidence(original)
@@ -86,6 +131,226 @@ func TestAbortEvidence_TranscriptStable(t *testing.T) {
}
}
// stubVerifier is a deterministic AbortSignatureVerifier for tests:
// it accepts iff the signature equals a fixed prefix of the transcript.
type stubVerifier struct {
acceptPrefix []byte
}
func (s *stubVerifier) VerifyAbortSignature(_ NodeID, transcript, signature []byte) bool {
if len(signature) > len(transcript) {
return false
}
if !bytes.HasPrefix(transcript, signature) {
return false
}
if !bytes.Equal(signature[:len(s.acceptPrefix)], s.acceptPrefix) {
return false
}
return true
}
func TestVerifyAbortEvidenceForm_HappyPath(t *testing.T) {
e := &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: NodeID{1},
Accused: NodeID{2},
Epoch: 1,
Evidence: validEvidenceFor(ComplaintBadDelivery),
Signature: []byte("sig"),
}
if err := VerifyAbortEvidenceForm(e); err != nil {
t.Fatalf("form check rejected valid evidence: %v", err)
}
}
func TestVerifyAbortEvidenceForm_RejectsNil(t *testing.T) {
if err := VerifyAbortEvidenceForm(nil); err != ErrInvalidComplaint {
t.Fatalf("nil not rejected: %v", err)
}
}
func TestVerifyAbortEvidenceForm_RejectsSelfAcc(t *testing.T) {
e := &AbortEvidence{Kind: ComplaintEquivocation, Accuser: NodeID{1}, Accused: NodeID{1},
Evidence: []byte("x"), Signature: []byte("y")}
if err := VerifyAbortEvidenceForm(e); err != ErrComplaintSelfAcc {
t.Fatalf("self-acc not rejected: %v", err)
}
}
func TestVerifyAbortEvidenceForm_RejectsBadKind(t *testing.T) {
e := &AbortEvidence{Kind: ComplaintKind(99), Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: []byte("x"), Signature: []byte("y")}
if err := VerifyAbortEvidenceForm(e); err != ErrComplaintKind {
t.Fatalf("bad kind not rejected: %v", err)
}
}
func TestVerifyAbortEvidenceForm_RejectsNoEvidence(t *testing.T) {
e := &AbortEvidence{Kind: ComplaintEquivocation, Accuser: NodeID{1}, Accused: NodeID{2},
Signature: []byte("sig")}
if err := VerifyAbortEvidenceForm(e); err != ErrComplaintNoEv {
t.Fatalf("empty evidence not rejected: %v", err)
}
}
func TestVerifyAbortEvidenceForm_RejectsNoSignature(t *testing.T) {
e := &AbortEvidence{Kind: ComplaintEquivocation, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: validEvidenceFor(ComplaintEquivocation)}
if err := VerifyAbortEvidenceForm(e); err != ErrComplaintNoSig {
t.Fatalf("empty signature not rejected: %v", err)
}
}
// TestAbortEvidence_PerKindValidators — every kind's structurally
// valid evidence blob passes its own validator, every kind's
// structurally invalid blob (bare bytes, no TLV framing) fails.
func TestAbortEvidence_PerKindValidators(t *testing.T) {
cases := []struct {
name string
kind ComplaintKind
}{
{"equivocation", ComplaintEquivocation},
{"bad-delivery", ComplaintBadDelivery},
{"mac-failure", ComplaintMACFailure},
{"range-failure", ComplaintRangeFailure},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
good := &AbortEvidence{Kind: c.kind, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: validEvidenceFor(c.kind), Signature: []byte("sig")}
if err := ValidateAbortEvidence(good); err != nil {
t.Fatalf("valid evidence rejected: %v", err)
}
// Bare bytes (no TLV framing) — either truncated or wrong count.
bad := &AbortEvidence{Kind: c.kind, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: []byte("bare-bytes-no-tlv"), Signature: []byte("sig")}
if err := ValidateAbortEvidence(bad); err == nil {
t.Fatalf("bare-bytes evidence accepted under %s", c.name)
}
})
}
}
// TestAbortEvidence_RejectsWrongKindShape — the adversarial property:
// a blob valid for kind A must not validate under kind B (different
// field counts: 4/3/2/1).
func TestAbortEvidence_RejectsWrongKindShape(t *testing.T) {
all := []ComplaintKind{
ComplaintEquivocation, ComplaintBadDelivery,
ComplaintMACFailure, ComplaintRangeFailure,
}
for _, blobKind := range all {
blob := validEvidenceFor(blobKind)
for _, checkKind := range all {
if blobKind == checkKind {
continue
}
e := &AbortEvidence{Kind: checkKind, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: blob, Signature: []byte("sig")}
err := ValidateAbortEvidence(e)
if err == nil {
t.Fatalf("blob valid for %s accepted under %s — field-count cross-talk",
blobKind, checkKind)
}
}
}
}
func TestAbortEvidence_RejectsTrailingBytes(t *testing.T) {
blob := validEvidenceFor(ComplaintMACFailure)
blob = append(blob, 0xFF, 0xFE) // trailing bytes (incomplete length prefix)
e := &AbortEvidence{Kind: ComplaintMACFailure, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: blob, Signature: []byte("sig")}
if err := ValidateAbortEvidence(e); err != ErrEvidenceTruncated {
t.Fatalf("trailing bytes not rejected as truncated: %v", err)
}
}
func TestAbortEvidence_RejectsEquivocationDuplicateCommits(t *testing.T) {
// Equivocation with commit1 == commit2 means no actual equivocation.
dup := rep(0xA1, 32)
blob := makeTLV(dup, dup, rep(0xB1, 64), rep(0xB2, 64))
e := &AbortEvidence{Kind: ComplaintEquivocation, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: blob, Signature: []byte("sig")}
if err := ValidateAbortEvidence(e); err != ErrEvidenceDuplicate {
t.Fatalf("duplicate-commit equivocation not rejected: %v", err)
}
}
func TestAbortEvidence_RejectsShortFields(t *testing.T) {
// MACFailure with mac/key of 16 bytes — below 32-byte minimum.
blob := makeTLV(rep(0x55, 16), rep(0x66, 16))
e := &AbortEvidence{Kind: ComplaintMACFailure, Accuser: NodeID{1}, Accused: NodeID{2},
Evidence: blob, Signature: []byte("sig")}
if err := ValidateAbortEvidence(e); err != ErrEvidenceFieldLen {
t.Fatalf("short fields not rejected: %v", err)
}
}
func TestValidateAbortEvidence_RejectsNil(t *testing.T) {
if err := ValidateAbortEvidence(nil); err != ErrInvalidComplaint {
t.Fatalf("nil not rejected: %v", err)
}
}
func TestValidateAbortEvidence_RejectsUnknownKind(t *testing.T) {
e := &AbortEvidence{Kind: ComplaintKind(99), Evidence: validEvidenceFor(ComplaintMACFailure)}
if err := ValidateAbortEvidence(e); err != ErrComplaintKind {
t.Fatalf("unknown kind not rejected: %v", err)
}
}
func TestVerifyAbortEvidence_HappyPath(t *testing.T) {
e := &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: NodeID{1},
Accused: NodeID{2},
Epoch: 1,
Evidence: validEvidenceFor(ComplaintBadDelivery),
}
// sign := first 4 bytes of transcript
transcript := TranscriptForComplaint(e)
e.Signature = append([]byte{}, transcript[:4]...)
v := &stubVerifier{acceptPrefix: transcript[:4]}
if err := VerifyAbortEvidence(e, v); err != nil {
t.Fatalf("verify rejected valid: %v", err)
}
}
func TestVerifyAbortEvidence_RejectsBadSignature(t *testing.T) {
e := &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: NodeID{1},
Accused: NodeID{2},
Epoch: 1,
Evidence: validEvidenceFor(ComplaintBadDelivery),
Signature: []byte("forged"),
}
transcript := TranscriptForComplaint(e)
// stub accepts only signatures that PREFIX the transcript;
// "forged" does not match transcript[:6] in general
v := &stubVerifier{acceptPrefix: transcript[:1]}
err := VerifyAbortEvidence(e, v)
if err != ErrComplaintNoSig {
t.Fatalf("forged signature not rejected: %v", err)
}
}
func TestVerifyAbortEvidence_RejectsNilVerifier(t *testing.T) {
e := &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: NodeID{1},
Accused: NodeID{2},
Epoch: 1,
Evidence: validEvidenceFor(ComplaintBadDelivery),
Signature: []byte("sig"),
}
if err := VerifyAbortEvidence(e, nil); err != ErrComplaintNoSig {
t.Fatalf("nil verifier not rejected: %v", err)
}
}
func TestComplaintKind_String(t *testing.T) {
for _, tc := range []struct {
k ComplaintKind
+10 -5
View File
@@ -64,12 +64,13 @@ func BenchmarkDKG_10of7_P65(b *testing.B) { benchDKG(b, 10, 7, ModeP65) }
func benchDKG(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)})
b.ResetTimer()
for iter := 0; iter < b.N; iter++ {
sessions := make([]*DKGSession, n)
for i := range sessions {
rng := deterministicReader([]byte{byte(iter), byte(i)})
s, _ := NewDKGSession(params, committee, t, committee[i], rng)
s, _ := NewDKGSession(params, committee, t, committee[i], ident.keys[committee[i]], ident.directory, rng)
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
@@ -93,11 +94,12 @@ func BenchmarkThresholdSign_10of7_P65(b *testing.B) { benchThresholdSign(b, 10,
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], rng)
s, _ := NewDKGSession(params, committee, t, committee[i], ident.keys[committee[i]], ident.directory, rng)
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
@@ -128,10 +130,11 @@ func benchThresholdSign(b *testing.B, n, t int, mode Mode) {
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], msg, rng)
signers[i], _ = NewThresholdSigner(params, sid, 1, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, rng)
}
sr1 := make([]*Round1Message, t)
for i, s := range signers {
@@ -154,11 +157,12 @@ func benchReshare(b *testing.B, oldN, oldT, newN, newT int, mode Mode) {
params := MustParamsFor(mode)
oldCommittee := makeCommittee(oldN)
newCommittee := makeCommittee(newN)
ident := newIdentityFixture(b, oldCommittee, []byte{byte(oldN), byte(newN), byte(mode)})
// Set up DKG to get the old shares.
sessions := make([]*DKGSession, oldN)
for i := range sessions {
rng := deterministicReader([]byte{byte(i), 0xCD})
s, _ := NewDKGSession(params, oldCommittee, oldT, oldCommittee[i], rng)
s, _ := NewDKGSession(params, oldCommittee, oldT, oldCommittee[i], ident.keys[oldCommittee[i]], ident.directory, rng)
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, oldN)
@@ -187,7 +191,8 @@ func benchReshare(b *testing.B, oldN, oldT, newN, newT int, mode Mode) {
os = oldShares[i]
}
s, _ := NewReshareSession(params, oldCommittee, oldT, newCommittee, newT,
oldCommittee[i], os, nil, deterministicReader([]byte{byte(iter), byte(i)}))
oldCommittee[i], os, ident.keys[oldCommittee[i]], ident.directory,
nil, deterministicReader([]byte{byte(iter), byte(i)}))
resh = append(resh, s)
}
rr1 := []*DKGRound1Msg{}
+220 -129
View File
@@ -6,28 +6,35 @@ package pulsar
// dkg.go — distributed key generation. Three rounds:
//
// Round 1: each party samples a 32-byte seed contribution c_i,
// broadcasts an RO-binding cSHAKE256 commitment to c_i,
// and Shamir-shares c_i byte-wise over GF(257) to the
// committee.
// Round 2: each party broadcasts the digest of the Round-1 commits
// it received from every dealer. Mismatch across recipients
// triggers ComplaintEquivocation.
// byte-wise Shamir-shares c_i over GF(257) to the
// committee, KEM-wraps each per-recipient share envelope
// under the recipient's long-term ML-KEM-768 identity
// public key, and broadcasts the wrapped envelope map.
// Round 2: each party broadcasts the digest of the Round-1
// envelopes it received from every dealer. Mismatch across
// recipients triggers ComplaintEquivocation.
// Round 3: each party verifies the Round-2 digest agreement and
// aggregates its share. The master public key is derived
// from the byte-wise sum-of-contributions mixed through
// cSHAKE256 to produce a uniform ML-DSA seed.
//
// The v0.1 commitment is the RO-binding cSHAKE256 commit
// Protocol shape (CR-6 path A — Shamir+sum, no commit-and-open).
// The legacy v0.1 broadcast carried `myCommit = cSHAKE(c_i || blind_i)`
// but `(c_i, blind_i)` were never transmitted in any later round, so
// the commit bound to nothing the protocol verified (BLOCKERS.md
// CR-6). Path A drops the commit entirely. Binding comes from
// Round-2 digest agreement over the ordered envelope set. The
// v0.2 path replaces this with R_q^k Pedersen per pulsar.tex §3.2
// + §4.1 for stronger PQ-safe binding under M-LWE hardness; that is
// orthogonal to CR-6 and remains the future work.
//
// C_i = cSHAKE256(c_i || blind_i, 32, "PULSAR-DKG-COMMIT-V1")
//
// which is binding under collision resistance and hiding under
// uniformity of blind_i. The v0.2 path replaces this with R_q^k
// Pedersen per pulsar.tex §3.2 + §4.1 to obtain the same
// guarantees under M-LWE hardness (a stronger PQ-safe binding
// argument). Both targets are Class N4-eligible.
//
// See BLOCKERS.md for the v0.1 → v0.2 path.
// Envelope confidentiality (CR-8): each per-recipient envelope is
// ML-KEM-768-sealed against the recipient's long-term identity public
// key so a passive network observer cannot read the per-recipient
// Shamir share from a single broadcast. The DKG ceremony now needs an
// IdentityKey per party (long-term ML-KEM-768 + ML-DSA-65) plus an
// IdentityDirectory of every committee member's published KEM public
// key. See identity.go.
import (
"crypto/rand"
@@ -38,14 +45,15 @@ import (
// Errors returned by DKG.
var (
ErrCommitteeEmpty = errors.New("pulsar: DKG committee is empty")
ErrCommitteeDuplicate = errors.New("pulsar: DKG committee contains duplicate node IDs")
ErrNotInCommittee = errors.New("pulsar: node is not in DKG committee")
ErrEnvelopeMissing = errors.New("pulsar: round-1 envelope missing for committee member")
ErrCommitMismatch = errors.New("pulsar: round-1 commit does not match opening at round 3")
ErrEquivocation = errors.New("pulsar: round-1.5 commit digest mismatch (equivocation)")
ErrTooFewRound1 = errors.New("pulsar: too few round-1 messages — DKG requires all committee members")
ErrTooFewRound2 = errors.New("pulsar: too few round-2 messages — DKG requires all committee members")
ErrCommitteeEmpty = errors.New("pulsar: DKG committee is empty")
ErrCommitteeDuplicate = errors.New("pulsar: DKG committee contains duplicate node IDs")
ErrNotInCommittee = errors.New("pulsar: node is not in DKG committee")
ErrEnvelopeMissing = errors.New("pulsar: round-1 envelope missing for committee member")
ErrCommitMismatch = errors.New("pulsar: round-1 commit does not match opening at round 3")
ErrEquivocation = errors.New("pulsar: round-1.5 envelope digest mismatch (equivocation)")
ErrTooFewRound1 = errors.New("pulsar: too few round-1 messages — DKG requires all committee members")
ErrTooFewRound2 = errors.New("pulsar: too few round-2 messages — DKG requires all committee members")
ErrDirectoryIncomplete = errors.New("pulsar: identity directory missing entry for committee member")
)
// DKGSession holds the per-party state for one DKG ceremony.
@@ -58,17 +66,31 @@ type DKGSession struct {
Committee []NodeID // sorted canonical committee (byte-ascending NodeID)
Threshold int
MyID NodeID
myIndex int // 1-indexed Shamir evaluation point in Committee
myIndex int // 1-indexed Shamir evaluation point in Committee
rng io.Reader // entropy for c_i and blind_i
// Identity material for per-recipient envelope sealing
// (BLOCKERS.md CR-8). The session refuses to construct without
// both a local identity (for decrypting incoming envelopes at
// Round 3) and a directory entry for every committee member (for
// sealing outgoing envelopes at Round 1).
myIdentity *IdentityKey
directory IdentityDirectory
rng io.Reader // entropy for c_i
// Per-round state.
myContribution [SeedSize]byte // c_i sampled at Round 1
myBlind [32]byte // blind_i sampled at Round 1
myCommit [32]byte // C_i = cSHAKE256(c_i || blind_i)
myShares []shamirShare // f_i(j) for each committee position j ∈ {1..n}
round1Cache []*DKGRound1Msg
myDigest [32]byte
myContribution [SeedSize]byte // c_i sampled at Round 1 — SECRET
encapBlindKey [SeedSize]byte // per-session non-secret blind used
// // to diversify per-recipient KEM
// // encapsulation seeds. Sampled fresh
// // at Round 1; NOT derived from
// // myContribution (Agent 4 C2:
// // earlier design fed myContribution
// // into encapBlind, making KEM ct
// // bytes a function of the secret).
myShares []shamirShare // f_i(j) for each committee position j ∈ {1..n}
round1Cache []*DKGRound1Msg
myDigest [32]byte
// Output state after Round 3.
aggregateShare shamirShare
@@ -83,10 +105,24 @@ type DKGSession struct {
// sort) so all parties agree on the position-to-evaluation-point
// mapping without any out-of-band coordination.
//
// myIdentity is the calling party's long-term ML-KEM-768 + ML-DSA-65
// keypair; the KEM secret half is used to open incoming envelopes at
// Round 3. directory must contain a published IdentityPublicKey for
// every committee member (including myID — the round-trip check
// requires we can seal-and-open our own envelope as a sanity gate).
//
// rng may be nil — crypto/rand is used as the default entropy source.
// Pass a deterministic reader (e.g. bytes.NewReader of a fixed seed)
// for KAT-reproducible DKG runs.
func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeID, rng io.Reader) (*DKGSession, error) {
func NewDKGSession(
params *Params,
committee []NodeID,
threshold int,
myID NodeID,
myIdentity *IdentityKey,
directory IdentityDirectory,
rng io.Reader,
) (*DKGSession, error) {
if err := params.Validate(); err != nil {
return nil, err
}
@@ -99,6 +135,12 @@ func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeI
if len(committee) > 256 {
return nil, ErrCommitteeTooLarge
}
if myIdentity == nil {
return nil, ErrIdentityKeyMissing
}
if directory == nil {
return nil, ErrDirectoryIncomplete
}
sorted := make([]NodeID, len(committee))
copy(sorted, committee)
@@ -111,6 +153,14 @@ func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeI
}
}
// Directory must cover every committee member; missing entries
// mean we cannot seal an envelope for that recipient.
for _, id := range sorted {
if directory[id] == nil {
return nil, ErrDirectoryIncomplete
}
}
myIdx := -1
for i := range sorted {
if sorted[i] == myID {
@@ -126,42 +176,56 @@ func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeI
rng = rand.Reader
}
return &DKGSession{
Params: params,
Committee: sorted,
Threshold: threshold,
MyID: myID,
myIndex: myIdx + 1,
rng: rng,
Params: params,
Committee: sorted,
Threshold: threshold,
MyID: myID,
myIndex: myIdx + 1,
myIdentity: myIdentity,
directory: directory,
rng: rng,
}, nil
}
// Round1 samples this party's contribution, Shamir-shares it
// byte-wise, computes the RO-binding commit, and returns the
// per-recipient envelope set.
// Round1 samples this party's contribution, byte-wise Shamir-shares
// it, KEM-wraps each per-recipient envelope under the recipient's
// long-term ML-KEM-768 public key, and returns the broadcast.
//
// All Round-1 messages from the entire committee must be delivered
// to every party before Round 2 begins. Partial delivery becomes a
// timeout abort.
//
// CR-6 path A: no commit-and-open; the broadcast carries no separate
// commitment field. CR-8: per-recipient envelopes are ML-KEM-768
// sealed against the recipient's published identity public key so a
// passive network observer learns nothing about per-recipient shares.
func (s *DKGSession) Round1() (*DKGRound1Msg, error) {
if _, err := io.ReadFull(s.rng, s.myContribution[:]); err != nil {
return nil, ErrShortRand
}
if _, err := io.ReadFull(s.rng, s.myBlind[:]); err != nil {
// Per-session non-secret blind for the per-recipient KEM
// encapseed derivation (Agent 4 C2 fix). Sampled fresh from
// the same RNG as myContribution; NOT fed into anything that
// produces a secret value. Even if encapBlindKey leaks (e.g.
// via a coredump after Round-1), no information about
// myContribution is exposed because the two are
// independently sampled.
if _, err := io.ReadFull(s.rng, s.encapBlindKey[:]); err != nil {
return nil, ErrShortRand
}
commitInput := append(append([]byte{}, s.myContribution[:]...), s.myBlind[:]...)
s.myCommit = transcriptHash32(tagDKGCommit, commitInput)
// Per-byte Shamir share of c_i. Coefficient material is
// domain-separated by (committee root, my-index, blind_i) so two
// DKG sessions on the same contribution never collide.
// domain-separated by (committee root, my-index, contribution-
// derived seed) so two DKG sessions on the same contribution
// never collide. The seed-derived expansion replaces the v0.1
// blind_i material which existed only to feed the dropped
// commit-and-open layer.
committeeRoot := s.commitCommitteeRoot()
keyMaterial := []byte{}
keyMaterial = append(keyMaterial, []byte("PULSAR-DKG-DEALER-V1")...)
keyMaterial = append(keyMaterial, committeeRoot[:]...)
keyMaterial = append(keyMaterial, byte(s.myIndex>>8), byte(s.myIndex))
keyMaterial = append(keyMaterial, s.myBlind[:]...)
keyMaterial = append(keyMaterial, s.myContribution[:]...)
streamLen := (s.Threshold - 1) * SeedSize * 2
if streamLen < 2 {
streamLen = 2
@@ -174,38 +238,49 @@ func (s *DKGSession) Round1() (*DKGRound1Msg, error) {
}
s.myShares = shares
// Build envelopes — one per committee position. We also pack a
// per-recipient blinding share derived from blind_i so that the
// envelope digest binds both share and blind even in v0.1's RO
// model. The blinding share is byte-wise XOR with a per-recipient
// transcript-hash mask; this is sufficient for v0.1 binding
// (a malicious receiver cannot forge a valid open without knowing
// the per-position mask).
// KEM-wrap each per-recipient envelope. The encapsulation seed
// is derived deterministically from (committee root, dealer,
// recipient, per-session NON-SECRET encapBlindKey). KAT
// regeneration is still byte-stable per-RNG-seed (the encapBlindKey
// is sampled from the same rng); the security improvement vs
// the v0.1 design is that encapBlindKey is INDEPENDENT of
// myContribution, so a fault injection on the cSHAKE256 call
// here cannot leak bits of the secret contribution.
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.Committee))
for posIdx, recipient := range s.Committee {
share := shares[posIdx]
shareBytes := shareToBytes(share)
// Derive the per-recipient blinding mask.
blindMask := cshake256(
append(append([]byte{}, s.myBlind[:]...), recipient[:]...),
shareWireSize,
"PULSAR-DKG-BLINDMASK-V1",
// Per-recipient deterministic encapsulation seed material.
encapBlind := cshake256(
append(append(append([]byte{}, s.encapBlindKey[:]...),
s.MyID[:]...), recipient[:]...),
64,
"PULSAR-DKG-ENCAPSEED-V1",
)
var blindOut [shareWireSize]byte
copy(blindOut[:], blindMask)
encapSeed := hashForEncapSeed(committeeRoot, s.MyID, recipient, encapBlind)
var envShare [64]byte
copy(envShare[:], shareBytes[:])
envelopes[recipient] = DKGShareEnvelope{
Share: envShare,
Blind: blindOut,
recipientIPK := s.directory[recipient]
if recipientIPK == nil {
return nil, ErrDirectoryIncomplete
}
env, err := sealEnvelope(
s.MyID,
recipient,
committeeRoot,
shareBytes,
s.myContribution,
recipientIPK.KEMPub,
encapSeed[:],
)
if err != nil {
return nil, err
}
envelopes[recipient] = env
}
return &DKGRound1Msg{
NodeID: s.MyID,
Commits: [][]byte{s.myCommit[:]},
Envelopes: envelopes,
}, nil
}
@@ -270,9 +345,25 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
}
}
// Aggregate the local share. For this party's evaluation point
// (s.myIndex), aggregate share Y = Σ_i envelope[i→me].Y (mod p).
// Decrypt every envelope addressed to me. Each envelope reveals
// BOTH (a) the dealer's Shamir share for me at x=myIndex (which
// I aggregate into my own KeyShare for threshold sign) AND (b)
// the dealer's full contribution c_i (which I sum into the byte-
// sum to derive the master public key).
//
// The per-recipient KEM-wrapping (CR-8) prevents passive network
// observers from learning either component. The Shamir share at
// other recipients' positions remains unknown to me — which is
// exactly the v0.1 reconstruction-aggregator trust model: every
// committee member learns the master secret (via c_i sum), but no
// non-committee observer learns anything from the broadcast.
committeeRoot := s.commitCommitteeRoot()
var aggY [SeedSize]uint16
// byteSum aggregates the per-dealer contributions c_i byte-wise
// over GF(257). At x=0 of the joint polynomial f(x) = Σ_i f_i(x),
// f(0) = Σ_i f_i(0) = Σ_i c_i — i.e. the byte-sum we need for the
// master seed.
var byteSum [SeedSize]uint16
for _, m := range ordered {
env, ok := m.Envelopes[s.MyID]
if !ok {
@@ -284,11 +375,22 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
},
}, nil
}
var senderShareBuf [shareWireSize]byte
copy(senderShareBuf[:], env.Share[:])
senderShare := shareFromBytes(uint32(s.myIndex), senderShareBuf)
senderShareBytes, senderContrib, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, committeeRoot, env, s.myIdentity,
)
if openErr != nil {
return &DKGOutput{
AbortEvidence: &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: s.MyID,
Accused: m.NodeID,
},
}, nil
}
senderShare := shareFromBytes(uint32(s.myIndex), senderShareBytes)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint16((uint32(aggY[b]) + uint32(senderShare.Y[b])) % shamirPrime)
byteSum[b] = uint16((uint32(byteSum[b]) + uint32(senderContrib[b])) % shamirPrime)
}
}
s.aggregateShare = shamirShare{
@@ -298,24 +400,26 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
// Derive the master ML-DSA seed.
//
// The aggregated shares form Shamir shares of the byte-wise sum
// of all dealer contributions: master_byte_sum[b] = Σ_i c_i[b] mod 257.
// To recover a uniform 32-byte ML-DSA seed, we Lagrange-interpolate
// these aggregated shares at x=0 (using the first t committee
// positions, which every party can do because every party holds
// the same Round-1 envelopes), then mix through cSHAKE256 to
// flatten any modular bias.
masterByteSum, err := s.reconstructByteSum(ordered)
if err != nil {
return nil, err
// masterByteSum is the byte-wise sum of all dealer contributions
// over GF(257). The cSHAKE256 mix flattens any modular bias and
// produces a uniform 32-byte ML-DSA seed.
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])
}
committeeRoot := s.commitCommitteeRoot()
mixInput := append(append([]byte{}, masterByteSum...), committeeRoot[:]...)
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
var masterSeed [SeedSize]byte
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
sk, err := KeyFromSeed(s.Params, masterSeed)
if err != nil {
// Zeroize the reconstructed secret material before returning.
// No defer: call-site-local cleanup keeps the secret lifetime
// legible.
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
s.masterPubkey = sk.Pub
@@ -333,7 +437,7 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
// after the cSHAKE256 mix. Threshold sign reconstructs this byte-sum
// then re-applies the mix to recover masterSeed (see threshold.go).
shareWire := shareToBytes(s.aggregateShare)
return &DKGOutput{
out := &DKGOutput{
GroupPubkey: sk.Pub,
SecretShare: &KeyShare{
NodeID: s.MyID,
@@ -344,54 +448,41 @@ func (s *DKGSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg) (*DK
},
TranscriptHash: s.transcript,
AbortEvidence: nil,
}, nil
}
// reconstructByteSum Lagrange-interpolates the aggregated shares at
// the first t committee positions to recover the GF(257) byte-sum
// of all dealer contributions. Returns a 64-byte big-endian
// encoding of the 32-element GF(257) vector — using two bytes per
// slot so the 257-th value is faithfully represented.
func (s *DKGSession) reconstructByteSum(ordered []*DKGRound1Msg) ([]byte, error) {
aggregates := make([]shamirShare, s.Threshold)
for j := 0; j < s.Threshold; j++ {
aggregates[j].X = uint32(j + 1)
recipient := s.Committee[j]
for _, m := range ordered {
env, ok := m.Envelopes[recipient]
if !ok {
return nil, ErrEnvelopeMissing
}
var buf [shareWireSize]byte
copy(buf[:], env.Share[:])
senderShare := shareFromBytes(uint32(j+1), buf)
for b := 0; b < SeedSize; b++ {
aggregates[j].Y[b] = uint16((uint32(aggregates[j].Y[b]) + uint32(senderShare.Y[b])) % shamirPrime)
}
}
}
gf, err := shamirReconstructGF(aggregates)
if err != nil {
return nil, err
}
out := make([]byte, SeedSize*2)
for b := 0; b < SeedSize; b++ {
out[2*b] = byte(gf[b] >> 8)
out[2*b+1] = byte(gf[b])
}
// Wipe the reconstructed master SK + intermediate secret
// material. The per-party SecretShare returned in `out` is the
// caller's responsibility to manage; this scope's local copies
// are wiped here.
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return out, nil
}
// computeRound2Digest returns the canonical 32-byte digest over the
// ordered Round-1 broadcasts and per-recipient envelopes. Every honest
// party computes the SAME digest given the same Round-1 inputs.
// ordered Round-1 broadcasts and per-recipient KEM-wrapped envelopes.
// Every honest party computes the SAME digest given the same Round-1
// inputs because the envelope ciphertext + sealed payload bytes are
// deterministic across recipients given the dealer's contribution and
// the recipient's published KEM public key.
//
// The digest binds the committee root, the dealer-NodeID, every
// recipient's NodeID, the recipient-specific KEM ciphertext, and the
// recipient-specific sealed payload. An equivocating dealer that
// ships different (ct, sealed) pairs to different recipients
// (relative to what they broadcast in this message) is caught by
// Round-3 digest comparison.
//
// committeeRoot binding (post-audit): without it, a colluding
// dealer + recipient pair sharing the same KEM ciphertext across
// committees could replay an envelope across DKG sessions; binding
// the committee root pins the digest to THIS specific committee.
func (s *DKGSession) computeRound2Digest(ordered []*DKGRound1Msg) [32]byte {
parts := [][]byte{}
committeeRoot := s.commitCommitteeRoot()
parts := [][]byte{committeeRoot[:]}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
@@ -400,8 +491,8 @@ func (s *DKGSession) computeRound2Digest(ordered []*DKGRound1Msg) [32]byte {
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
parts = append(parts, env.KEMCiphertext)
parts = append(parts, env.Sealed)
}
}
return transcriptHash32(tagDKGCommit, parts...)
+12 -6
View File
@@ -30,11 +30,12 @@ func TestDKG_HappyPath(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(tc.n)
ident := newIdentityFixture(t, committee, []byte("dkg-happy"))
sessions := make([]*DKGSession, tc.n)
for i := 0; i < tc.n; i++ {
rng := deterministicReader([]byte{byte(i), 0xDE, 0xAD})
s, err := NewDKGSession(params, committee, tc.t, committee[i], rng)
s, err := NewDKGSession(params, committee, tc.t, committee[i], ident.keys[committee[i]], ident.directory, rng)
if err != nil {
t.Fatal(err)
}
@@ -110,9 +111,10 @@ func TestDKG_HappyPath(t *testing.T) {
func TestDKG_Equivocation_Detected(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(3)
ident := newIdentityFixture(t, committee, []byte("dkg-equiv"))
sessions := make([]*DKGSession, 3)
for i := 0; i < 3; i++ {
s, _ := NewDKGSession(params, committee, 2, committee[i], deterministicReader([]byte{byte(i)}))
s, _ := NewDKGSession(params, committee, 2, committee[i], ident.keys[committee[i]], ident.directory, deterministicReader([]byte{byte(i)}))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, 3)
@@ -149,9 +151,10 @@ func TestDKG_ProducesValidPubkey_VerifiableSign(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(5)
threshold := 3
ident := newIdentityFixture(t, committee, []byte("dkg-valid-pub"))
sessions := make([]*DKGSession, 5)
for i := range sessions {
s, _ := NewDKGSession(params, committee, threshold, committee[i], deterministicReader([]byte{byte(i), 0xAB}))
s, _ := NewDKGSession(params, committee, threshold, committee[i], ident.keys[committee[i]], ident.directory, deterministicReader([]byte{byte(i), 0xAB}))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, 5)
@@ -218,7 +221,8 @@ func TestDKG_NodeNotInCommittee(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(3)
stranger := NodeID{0xff}
if _, err := NewDKGSession(params, committee, 2, stranger, nil); err != ErrNotInCommittee {
ident := newIdentityFixture(t, append(append([]NodeID{}, committee...), stranger), []byte("dkg-stranger"))
if _, err := NewDKGSession(params, committee, 2, stranger, ident.keys[stranger], ident.directory, nil); err != ErrNotInCommittee {
t.Fatalf("non-committee node not rejected: %v", err)
}
}
@@ -226,14 +230,16 @@ func TestDKG_NodeNotInCommittee(t *testing.T) {
func TestDKG_DuplicateCommittee(t *testing.T) {
params := MustParamsFor(ModeP65)
c := []NodeID{{1}, {2}, {1}}
if _, err := NewDKGSession(params, c, 2, NodeID{1}, nil); err != ErrCommitteeDuplicate {
ident := newIdentityFixture(t, []NodeID{{1}, {2}}, []byte("dkg-dup"))
if _, err := NewDKGSession(params, c, 2, NodeID{1}, ident.keys[NodeID{1}], ident.directory, nil); err != ErrCommitteeDuplicate {
t.Fatalf("dup committee not rejected: %v", err)
}
}
func TestDKG_EmptyCommittee(t *testing.T) {
params := MustParamsFor(ModeP65)
if _, err := NewDKGSession(params, nil, 1, NodeID{1}, nil); err != ErrCommitteeEmpty {
ident := newIdentityFixture(t, []NodeID{{1}}, []byte("dkg-empty"))
if _, err := NewDKGSession(params, nil, 1, NodeID{1}, ident.keys[NodeID{1}], ident.directory, nil); err != ErrCommitteeEmpty {
t.Fatalf("empty committee not rejected: %v", err)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Package pulsarm is the reference implementation of Pulsar, a threshold
// Package pulsar is the reference implementation of Pulsar, a threshold
// implementation of NIST FIPS 204 ML-DSA targeting NIST MPTC Class N1
// (signing) + N4 (ML keygen / DKG).
//
+337
View File
@@ -0,0 +1,337 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// fuzz_test.go — Layer-6 (fuzzing) targets for the parser surface.
//
// Each target seeds with at least one byte-equal valid value (a hex
// blob from vectors/, or a marshalled in-memory canonical example)
// and then accepts arbitrary mutation. The invariant under mutation is
// REJECT — every parser must return a typed error rather than panic,
// and must never return a value that subsequently passes
// FIPS 204 ML-DSA.Verify against an unrelated public key.
//
// Run smoke locally:
// GOWORK=off go test -run=^$ -fuzz=FuzzUnmarshalAbortEvidence \
// -fuzztime=5s ./ref/go/pkg/pulsar/
//
// Deep fuzzing belongs in a nightly job (-fuzztime=1h, multiple workers,
// preserved corpus under testdata/fuzz/). See Makefile.fuzz.
import (
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
)
// =====================================================================
// Seed material.
// =====================================================================
// validAbortEvidenceWire is a byte-equal canonical wire form produced
// by MarshalAbortEvidence over the fixture in fuzz_test.go's
// TestAbortEvidence_RoundTrip-equivalent inputs. Hardcoded so the fuzz
// corpus is hermetic (no filesystem dependency).
const validAbortEvidenceWireHex = "010102030405000000000000000000000000000000000000000000000000" +
"00006362610000000000000000000000000000000000000000000000000000" +
"00001122334455667788" + // epoch big-endian uint64
"0000001b" + // evidence length = 27
"65766964656e63652d736565642d626c6f622d666f722d66757a7a" + // "evidence-seed-blob-for-fuzz"
"0000001d" + // signature length = 29
"6964656e746974792d7369676e61747572652d62797465732d73656564" // "identity-signature-bytes-seed"
// loadVerifyKAT loads the first entry matching `mode` from
// vectors/verify.json, returning (pk, msg, sig) and (ok=true) on
// success. On failure the caller should skip the seed addition rather
// than fail — the vectors directory may be absent on a fresh checkout
// (kat_test.go uses the same convention).
func loadVerifyKAT(mode string) (pk, msg, sig []byte, ok bool) {
wd, err := os.Getwd()
if err != nil {
return nil, nil, nil, false
}
path := filepath.Join(wd, "..", "..", "..", "..", "vectors", "verify.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, nil, false
}
type entry struct {
Mode string `json:"mode"`
PublicKey string `json:"public_key"`
Message string `json:"message"`
Signature string `json:"signature"`
}
var es []entry
if err := json.Unmarshal(data, &es); err != nil {
return nil, nil, nil, false
}
for _, e := range es {
if e.Mode != mode {
continue
}
pkB, err1 := hex.DecodeString(e.PublicKey)
msgB, err2 := hex.DecodeString(e.Message)
sigB, err3 := hex.DecodeString(e.Signature)
if err1 != nil || err2 != nil || err3 != nil {
return nil, nil, nil, false
}
return pkB, msgB, sigB, true
}
return nil, nil, nil, false
}
// =====================================================================
// FuzzUnmarshalAbortEvidence — direct wire parser for complaints.
// =====================================================================
func FuzzUnmarshalAbortEvidence(f *testing.F) {
// Valid canonical seed.
wire, err := hex.DecodeString(validAbortEvidenceWireHex)
if err != nil {
f.Fatalf("bad embedded seed hex: %v", err)
}
f.Add(wire)
// Construct two more seeds programmatically to widen coverage of
// the kind/length-field state space.
for _, kind := range []ComplaintKind{
ComplaintEquivocation,
ComplaintBadDelivery,
ComplaintMACFailure,
ComplaintRangeFailure,
} {
ev := &AbortEvidence{
Kind: kind,
Accuser: NodeID{byte(kind), 0xaa},
Accused: NodeID{0xbb, byte(kind)},
Epoch: uint64(kind),
Evidence: []byte{},
Signature: []byte{0xde, 0xad, 0xbe, 0xef},
}
if b, err := MarshalAbortEvidence(ev); err == nil {
f.Add(b)
}
}
// Edge: minimum-size valid frame (zero-length evidence and
// signature). Hits the boundary check at the head of
// UnmarshalAbortEvidence.
ev := &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: NodeID{0x01},
Accused: NodeID{0x02},
}
if b, err := MarshalAbortEvidence(ev); err == nil {
f.Add(b)
}
f.Fuzz(func(t *testing.T, data []byte) {
// Contract: must not panic on any input. Either returns a
// well-formed AbortEvidence with consistent field bounds, or a
// typed error. We do not assert which — the only invariant is
// no panic, no out-of-bounds slice.
out, err := UnmarshalAbortEvidence(data)
if err != nil {
if out != nil {
t.Fatalf("error returned with non-nil result: %v", err)
}
return
}
// On accept, the returned struct must satisfy the marshaller's
// own constraint (accuser != accused) — re-marshal and check
// round-trip stability for a non-empty subset.
if out.Accuser == out.Accused {
t.Fatalf("parser accepted self-accusation")
}
if _, mErr := MarshalAbortEvidence(out); mErr != nil {
t.Fatalf("parser returned struct that fails marshal: %v", mErr)
}
})
}
// =====================================================================
// FuzzAbortEvidence_PerKindValidation — fuzz the per-kind structural
// validator. Contract: must not panic on any (kind, blob) input;
// either accepts and returns nil, or returns a typed error. The
// adversarial property "blob valid for kind A must reject under
// kind B" is covered by TestAbortEvidence_RejectsWrongKindShape;
// the fuzz here widens random-input coverage.
// =====================================================================
func FuzzAbortEvidence_PerKindValidation(f *testing.F) {
for _, kind := range []ComplaintKind{
ComplaintEquivocation,
ComplaintBadDelivery,
ComplaintMACFailure,
ComplaintRangeFailure,
ComplaintKind(99), // unknown kind seed
} {
f.Add(uint8(kind), []byte{})
if blob := validEvidenceFor(kind); blob != nil {
f.Add(uint8(kind), blob)
}
}
f.Fuzz(func(t *testing.T, kind uint8, blob []byte) {
e := &AbortEvidence{
Kind: ComplaintKind(kind),
Accuser: NodeID{0x01},
Accused: NodeID{0x02},
Evidence: blob,
}
// Must not panic. Result is checked-error or nil.
_ = ValidateAbortEvidence(e)
})
}
// =====================================================================
// FuzzVerifySignature — fuzz the FIPS 204 signature byte parser via
// the Pulsar consensus-consumer entry point.
// =====================================================================
func FuzzVerifySignature(f *testing.F) {
pk, msg, sig, ok := loadVerifyKAT("Pulsar-44")
if !ok {
// Provide a synthetic seed of the correct length even when
// vectors are absent so the fuzzer has something to mutate.
pk = make([]byte, ParamsP44.PublicKeySize)
msg = []byte("fuzz-seed-msg")
sig = make([]byte, ParamsP44.SignatureSize)
}
f.Add(pk, msg, sig)
// A second seed: deliberately wrong-size signature. Verify must
// reject with ErrSignatureWrongSize, not panic.
f.Add(pk, msg, []byte{0x00})
f.Fuzz(func(t *testing.T, pkBytes, msgBytes, sigBytes []byte) {
// Cap to params.PublicKeySize so we exercise both the size
// gate and the underlying FIPS 204 Unpack path.
params := ParamsP44
pubkey := &PublicKey{Mode: ModeP44, Bytes: pkBytes}
signature := &Signature{Mode: ModeP44, Bytes: sigBytes}
// Must not panic. Verify returns a typed error on any
// structural failure. A successful Verify on fuzzer-mutated
// input would be a forgery, which is computationally
// infeasible — so practically we always expect a non-nil
// error here, but we do NOT assert that (a future bug would
// be a silent forgery; the panic-freedom check is the
// invariant the fuzzer enforces).
_ = Verify(params, pubkey, msgBytes, signature)
})
}
// =====================================================================
// FuzzVerifyCtx — same surface but with mutating ctx bytes.
// =====================================================================
func FuzzVerifyCtx(f *testing.F) {
pk, msg, sig, ok := loadVerifyKAT("Pulsar-44")
if !ok {
pk = make([]byte, ParamsP44.PublicKeySize)
msg = []byte("fuzz-seed-msg")
sig = make([]byte, ParamsP44.SignatureSize)
}
f.Add(pk, msg, sig, []byte{})
f.Add(pk, msg, sig, []byte("ctx-A"))
// 255-byte ctx is the FIPS 204 max; 256 must be rejected.
f.Add(pk, msg, sig, make([]byte, 255))
f.Fuzz(func(t *testing.T, pkBytes, msgBytes, sigBytes, ctxBytes []byte) {
params := ParamsP44
pubkey := &PublicKey{Mode: ModeP44, Bytes: pkBytes}
signature := &Signature{Mode: ModeP44, Bytes: sigBytes}
_ = VerifyCtx(params, pubkey, msgBytes, ctxBytes, signature)
})
}
// =====================================================================
// FuzzShareFromBytes — the Shamir share wire parser.
//
// shareFromBytes is package-internal, but its wire layout (64 bytes of
// big-endian uint16s) is part of the protocol surface and any change
// to its bounds-checking behaviour is a wire incompatibility. The
// fuzz target here verifies it does not panic on any 64-byte input
// (it must not, since the function reads fixed offsets only).
// =====================================================================
func FuzzShareFromBytes(f *testing.F) {
// Seed: all-zero share (corresponds to f(x)=0 which is a valid
// Shamir share, even if unusual).
var z [shareWireSize]byte
f.Add(uint32(1), z[:])
// Seed: max value at every uint16 position.
var m [shareWireSize]byte
for i := range m {
m[i] = 0xff
}
f.Add(uint32(256), m[:])
// Seed: alternating pattern.
var p [shareWireSize]byte
for i := range p {
p[i] = byte(i)
}
f.Add(uint32(42), p[:])
f.Fuzz(func(t *testing.T, x uint32, buf []byte) {
// shareFromBytes takes a fixed-size array, so we pad/truncate
// fuzzer input deterministically. The conversion never panics
// for any 64-byte buffer; this target catches a future
// regression where the loop bounds change.
var fixed [shareWireSize]byte
copy(fixed[:], buf)
s := shareFromBytes(x, fixed)
// Sanity: X round-trips and Y values do not exceed the
// 16-bit width of the wire encoding (always true by
// construction; this is a regression guard).
if s.X != x {
t.Fatalf("X not preserved: got %d want %d", s.X, x)
}
for i, v := range s.Y {
if v > 0xffff {
t.Fatalf("Y[%d]=%d exceeds 16-bit range", i, v)
}
}
})
}
// =====================================================================
// FuzzParamsFor — Mode dispatch (committee/quorum-relevant entry).
//
// ParamsFor is the gate that downstream code uses to convert an
// untrusted Mode byte into a canonical *Params. Fuzzing it confirms
// the dispatch table rejects every non-canonical mode without panic
// and that the returned Params validates iff Mode is in the registry.
// =====================================================================
func FuzzParamsFor(f *testing.F) {
f.Add(uint8(0)) // ModeUnspecified
f.Add(uint8(44)) // ModeP44
f.Add(uint8(65)) // ModeP65
f.Add(uint8(87)) // ModeP87
f.Add(uint8(255)) // out-of-range
f.Fuzz(func(t *testing.T, m uint8) {
mode := Mode(m)
p, err := ParamsFor(mode)
if err != nil {
if p != nil {
t.Fatalf("ParamsFor returned (%v, %v) — expected nil on error", p, err)
}
return
}
// Accepted modes must round-trip through Validate.
if vErr := p.Validate(); vErr != nil {
t.Fatalf("ParamsFor(%d) returned tampered params: %v", mode, vErr)
}
// Mode field must match the requested mode.
if p.Mode != mode {
t.Fatalf("ParamsFor(%d).Mode = %d (mismatch)", mode, p.Mode)
}
})
}
+555
View File
@@ -0,0 +1,555 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// identity.go — long-term party identity + ephemeral per-session
// per-pair key establishment.
//
// Closes BLOCKERS.md CR-7 and CR-8. The v0.1 sign-MAC layer derived
// per-peer MAC keys from public inputs (NodeID pair + group public
// key), letting any network observer forge MACs and DoS the
// identifiable-abort gate at zero cost. The v0.1 DKG envelopes were
// transmitted in plaintext, letting a passive surveillance node
// recover the master secret from a single ceremony.
//
// This file replaces both with a single coherent primitive:
//
// IdentityKey — long-term ML-KEM-768 + ML-DSA-65 keypair, one per
// party. Long-lived; published on-chain alongside the
// party's validator record.
//
// EstablishSession — per-session per-pair Diffie-Hellman-style key
// agreement via ML-KEM-768. Each party encapsulates a
// fresh shared-secret to its peer's long-term ML-KEM
// public key; the two encapsulations are mixed via
// HKDF-SHA3-256 to produce a session key authenticated
// by the long-term ML-DSA signature over each
// encapsulation.
//
// The session key is the input to both:
// - the per-pair MAC key used in ThresholdSigner Round 1
// (replaces deriveMACKey)
// - the per-recipient envelope-wrap key used in DKGSession Round 1
// (replaces plaintext envelope transmission)
//
// Determinism: every primitive in this file accepts a deterministic
// seed reader so KAT regeneration is reproducible from a 32-byte
// master seed.
import (
"errors"
"io"
"github.com/cloudflare/circl/kem/mlkem/mlkem768"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/sha3"
)
// Errors returned by identity / session-key operations.
var (
ErrIdentityKeyMissing = errors.New("pulsar: identity key missing")
ErrSessionKeyMissing = errors.New("pulsar: session key missing for peer")
ErrIdentityCorrupted = errors.New("pulsar: identity key bytes invalid")
ErrSessionEncryption = errors.New("pulsar: session-key derivation failed")
ErrEnvelopeCiphertext = errors.New("pulsar: KEM ciphertext is wrong size or invalid")
ErrEnvelopeAuthBad = errors.New("pulsar: envelope authentication tag invalid")
ErrSessionPeerSelf = errors.New("pulsar: cannot establish session with self")
ErrSessionIdentityPK = errors.New("pulsar: peer identity public key invalid")
)
// IdentityPublicKey is a party's long-term public identity material:
// the ML-KEM-768 encapsulation key (for envelope/session-key
// reception) plus the ML-DSA-65 verification key (for authenticating
// envelopes and complaints).
//
// Both fields are byte-packed per their respective FIPS standards
// (FIPS 203 for ML-KEM, FIPS 204 for ML-DSA) so this struct is wire-
// safe to publish in a chain's validator record.
type IdentityPublicKey struct {
KEMPub []byte // ML-KEM-768 public key (1184 bytes)
MLDSAPub []byte // ML-DSA-65 public key (1952 bytes)
}
// IdentityKey is a party's long-term keypair: ML-KEM-768 (for
// encapsulation) + ML-DSA-65 (for signing envelope-attestations and
// complaints). Publish only the IdentityPublicKey component; keep
// the secret halves in HSM or KMS.
type IdentityKey struct {
KEMPub []byte
KEMPriv []byte
MLDSAPub []byte
MLDSAPriv []byte
}
// PublicKey returns the publishable half of an IdentityKey.
func (k *IdentityKey) PublicKey() *IdentityPublicKey {
if k == nil {
return nil
}
return &IdentityPublicKey{
KEMPub: append([]byte{}, k.KEMPub...),
MLDSAPub: append([]byte{}, k.MLDSAPub...),
}
}
// GenerateIdentity produces a fresh long-term identity keypair from
// the given entropy source. Pass a deterministic reader for KAT
// regeneration; nil falls back to crypto/rand.
func GenerateIdentity(rng io.Reader) (*IdentityKey, error) {
if rng == nil {
return nil, ErrShortRand
}
// ML-KEM-768 keygen consumes 64 bytes of seed material.
var kemSeed [mlkem768.KeySeedSize]byte
if _, err := io.ReadFull(rng, kemSeed[:]); err != nil {
return nil, ErrShortRand
}
kemPub, kemPriv := mlkem768.NewKeyFromSeed(kemSeed[:])
kpubB, err := kemPub.MarshalBinary()
if err != nil {
return nil, ErrIdentityCorrupted
}
kpriB, err := kemPriv.MarshalBinary()
if err != nil {
return nil, ErrIdentityCorrupted
}
// ML-DSA-65 keygen consumes a 32-byte seed.
var dsaSeed [32]byte
if _, err := io.ReadFull(rng, dsaSeed[:]); err != nil {
return nil, ErrShortRand
}
dpub, dpriv := mldsa65.NewKeyFromSeed(&dsaSeed)
var dpubB [mldsa65.PublicKeySize]byte
dpub.Pack(&dpubB)
var dprivB [mldsa65.PrivateKeySize]byte
dpriv.Pack(&dprivB)
return &IdentityKey{
KEMPub: kpubB,
KEMPriv: kpriB,
MLDSAPub: append([]byte{}, dpubB[:]...),
MLDSAPriv: append([]byte{}, dprivB[:]...),
}, nil
}
// EstablishSession performs an authenticated ML-KEM-768 key agreement
// between this party and the peer at peerID.
//
// Protocol:
// 1. Caller (this party, A) encapsulates a fresh shared secret ss_A
// to peer's long-term KEM public key. The encapsulation is
// DETERMINISTIC, seeded from
// HKDF-SHA3-256("PULSAR-SESSION-V1" || sid || A || B || transcript)
// so two parties agreeing on sid + transcript derive the same
// encapsulation seed for KAT reproducibility.
// 2. Peer (B) does the symmetric thing with roles swapped.
// 3. Both sides derive the final session key via DeriveSessionKey
// mixing ss_A + ss_B canonically by NodeID order.
//
// Authentication: the caller MUST sign the encapsulation ciphertext
// with their long-term ML-DSA-65 secret key and broadcast (ciphertext,
// signature) to the peer. The peer verifies the signature against the
// caller's published ML-DSA-65 public key before decapsulating.
//
// For test/KAT use we expose the symmetric two-shot SymmetricSession
// helper below; production callers feed the broadcast (ct, sig) tuple
// through the chain's message bus.
//
// Returns:
// - mySS: this party's contributory shared secret (caller
// keeps it locally; combined with peer's mySS via
// DeriveSessionKey once the peer's ct+sig arrive)
// - kemCT: the ML-KEM ciphertext to send to the peer
// - sigOverCT: the caller's ML-DSA-65 signature over kemCT (peer
// verifies before decapsulating)
//
// Returns ErrSessionPeerSelf if peerID equals myID and
// ErrSessionIdentityPK if the peer's identity key is malformed.
func EstablishSession(
myID NodeID,
myIdentity *IdentityKey,
peerID NodeID,
peerIdentity *IdentityPublicKey,
sid [16]byte,
transcript []byte,
) (mySS []byte, kemCT []byte, sigOverCT []byte, err error) {
if myIdentity == nil {
err = ErrIdentityKeyMissing
return
}
if peerIdentity == nil || len(peerIdentity.KEMPub) != mlkem768.PublicKeySize {
err = ErrSessionIdentityPK
return
}
if myID == peerID {
err = ErrSessionPeerSelf
return
}
// Derive deterministic encapsulation seed: bound to (sid, A→B
// direction, transcript). The encapsulation seed is direction-
// dependent so A→B and B→A produce DIFFERENT shared secrets;
// DeriveSessionKey mixes both contributions into the final key.
seedExpand := hkdf.New(sha3.New256,
append(append([]byte{}, myID[:]...), peerID[:]...),
sid[:], append([]byte(sessionEstablishInfo), transcript...))
var encapSeed [mlkem768.EncapsulationSeedSize]byte
if _, err = io.ReadFull(seedExpand, encapSeed[:]); err != nil {
err = ErrSessionEncryption
return
}
// Decode peer's KEM public key.
var peerKEMPub mlkem768.PublicKey
if e := peerKEMPub.Unpack(peerIdentity.KEMPub); e != nil {
err = ErrSessionIdentityPK
return
}
// Encapsulate deterministically.
ct := make([]byte, mlkem768.CiphertextSize)
ss := make([]byte, mlkem768.SharedKeySize)
peerKEMPub.EncapsulateTo(ct, ss, encapSeed[:])
kemCT = ct
// Sign the ciphertext with our long-term ML-DSA-65 key so the
// peer can authenticate the encapsulation before decapsulating.
var dpriv mldsa65.PrivateKey
if e := dpriv.UnmarshalBinary(myIdentity.MLDSAPriv); e != nil {
err = ErrIdentityCorrupted
return
}
sigBuf := make([]byte, mldsa65.SignatureSize)
// Deterministic signing: ctx binds to the session establishment
// tag so a signature reuse across protocols becomes a domain-tag
// mismatch at verification time.
if e := mldsa65.SignTo(&dpriv, ct, []byte(sessionEstablishInfo), false, sigBuf); e != nil {
err = ErrSessionEncryption
return
}
sigOverCT = sigBuf
// Caller side: at this point we know our ss but not peer's. The
// final session key uses BOTH ss's (this side's encapsulation +
// peer's encapsulation) so the protocol is contributory. The
// caller stores ss locally; the mixing step happens once the
// peer's ct + sig arrive (see DeriveSessionKey).
mySS = ss
return
}
// VerifyPeerEncapsulation authenticates a peer's encapsulation ct
// against their published ML-DSA-65 public key, then decapsulates
// with our long-term ML-KEM-768 secret key.
//
// Returns the 32-byte shared secret that the peer encapsulated to us.
// This shared secret is then combined with our own EstablishSession
// shared secret via DeriveSessionKey to produce the final session key.
func VerifyPeerEncapsulation(
myIdentity *IdentityKey,
peerIdentity *IdentityPublicKey,
peerCT []byte,
peerSig []byte,
) (peerSS []byte, err error) {
if myIdentity == nil {
return nil, ErrIdentityKeyMissing
}
if peerIdentity == nil || len(peerIdentity.MLDSAPub) != mldsa65.PublicKeySize {
return nil, ErrSessionIdentityPK
}
if len(peerCT) != mlkem768.CiphertextSize {
return nil, ErrEnvelopeCiphertext
}
var peerDSAPub mldsa65.PublicKey
if e := peerDSAPub.UnmarshalBinary(peerIdentity.MLDSAPub); e != nil {
return nil, ErrSessionIdentityPK
}
if !mldsa65.Verify(&peerDSAPub, peerCT, []byte(sessionEstablishInfo), peerSig) {
return nil, ErrSessionEncryption
}
// Decapsulate with our KEM secret key.
var myKEMPriv mlkem768.PrivateKey
if e := myKEMPriv.Unpack(myIdentity.KEMPriv); e != nil {
return nil, ErrIdentityCorrupted
}
ss := make([]byte, mlkem768.SharedKeySize)
myKEMPriv.DecapsulateTo(ss, peerCT)
return ss, nil
}
// DeriveSessionKey mixes the two contributory shared secrets (ours
// from EstablishSession + peer's from VerifyPeerEncapsulation) into a
// canonical 32-byte session key. Both ends derive the same key
// because the inputs are canonical-ordered by NodeID.
func DeriveSessionKey(myID, peerID NodeID, sid [16]byte, mySS, peerSS []byte) ([32]byte, error) {
if myID == peerID {
return [32]byte{}, ErrSessionPeerSelf
}
first, second := myID, peerID
mySSFirst, peerSSFirst := mySS, peerSS
if nodeIDLess(peerID, myID) {
first, second = peerID, myID
mySSFirst, peerSSFirst = peerSS, mySS
}
// ikm = ss(smaller) || ss(larger); both ends derive byte-identical
// inputs.
ikm := append(append([]byte{}, mySSFirst...), peerSSFirst...)
info := append([]byte(sessionKeyInfo), append(first[:], second[:]...)...)
r := hkdf.New(sha3.New256, ikm, sid[:], info)
var out [32]byte
if _, err := io.ReadFull(r, out[:]); err != nil {
return [32]byte{}, ErrSessionEncryption
}
return out, nil
}
// SymmetricSession is the two-party convenience helper that runs the
// full session-establishment protocol against in-memory IdentityKey
// peers. Used by tests and KAT generation; production deployments
// drive the exchange through the chain message bus.
//
// Returns the byte-identical session key both parties derive.
func SymmetricSession(
aID NodeID, aKey *IdentityKey,
bID NodeID, bKey *IdentityKey,
sid [16]byte, transcript []byte,
) ([32]byte, error) {
if aID == bID {
return [32]byte{}, ErrSessionPeerSelf
}
// A → B encapsulation.
mySSA, ctA, sigA, err := EstablishSession(aID, aKey, bID, bKey.PublicKey(), sid, transcript)
if err != nil {
return [32]byte{}, err
}
// B → A encapsulation.
mySSB, ctB, sigB, err := EstablishSession(bID, bKey, aID, aKey.PublicKey(), sid, transcript)
if err != nil {
return [32]byte{}, err
}
// A authenticates+decapsulates B's ct.
peerSSForA, err := VerifyPeerEncapsulation(aKey, bKey.PublicKey(), ctB, sigB)
if err != nil {
return [32]byte{}, err
}
// B authenticates+decapsulates A's ct.
peerSSForB, err := VerifyPeerEncapsulation(bKey, aKey.PublicKey(), ctA, sigA)
if err != nil {
return [32]byte{}, err
}
// Sanity: the two parties learned each other's contributions.
if !ctEqualSlice(mySSA, peerSSForB) {
return [32]byte{}, ErrSessionEncryption
}
if !ctEqualSlice(mySSB, peerSSForA) {
return [32]byte{}, ErrSessionEncryption
}
// Both sides run DeriveSessionKey.
keyA, err := DeriveSessionKey(aID, bID, sid, mySSA, peerSSForA)
if err != nil {
return [32]byte{}, err
}
keyB, err := DeriveSessionKey(bID, aID, sid, mySSB, peerSSForB)
if err != nil {
return [32]byte{}, err
}
if keyA != keyB {
return [32]byte{}, ErrSessionEncryption
}
return keyA, nil
}
// envelopeSealedSize is the byte length of the Sealed payload:
// 64 bytes Shamir share
// + 32 bytes dealer contribution c_i
// + 32 bytes authentication tag
// = 128 bytes total.
const envelopeSealedSize = 64 + 32 + 32
// sealEnvelope wraps the per-recipient Shamir share AND the full
// dealer contribution under the recipient's long-term ML-KEM-768
// public key. Used by DKGSession.Round1 (BLOCKERS.md CR-8 fix).
//
// The dealer contribution c_i is included so each committee member
// can independently compute the joint byte-sum at DKG Round 3
// (preserving the v0.1 reconstruction-aggregator trust model) while
// passive network observers — who lack any recipient's KEM secret
// key — read nothing.
//
// Protocol:
// 1. Encapsulate a fresh shared secret ss to recipient's KEM pub.
// Encapsulation seed is derived deterministically by the caller
// (DKGSession) so KAT regeneration is byte-stable.
// 2. Derive an envelope key K_env = HKDF-SHA3-256(ss, salt=dealerID,
// info="PULSAR-DKG-ENVKEY-V1" || recipientID || committee_root).
// 3. Seal (share || contribution || tag) under K_env via XOR with
// a cSHAKE256 stream keyed by K_env. The auth tag is
// KMAC256(K_env, dealerID || recipientID || share || contribution).
//
// Returns ErrSessionIdentityPK if recipientKEMPub is malformed.
func sealEnvelope(
dealerID, recipientID NodeID,
committeeRoot [32]byte,
shareWire [64]byte,
contribution [SeedSize]byte,
recipientKEMPub []byte,
encapSeed []byte,
) (DKGShareEnvelope, error) {
if len(recipientKEMPub) != mlkem768.PublicKeySize {
return DKGShareEnvelope{}, ErrSessionIdentityPK
}
if len(encapSeed) != mlkem768.EncapsulationSeedSize {
return DKGShareEnvelope{}, ErrEnvelopeCiphertext
}
var pk mlkem768.PublicKey
if err := pk.Unpack(recipientKEMPub); err != nil {
return DKGShareEnvelope{}, ErrSessionIdentityPK
}
ct := make([]byte, mlkem768.CiphertextSize)
ss := make([]byte, mlkem768.SharedKeySize)
pk.EncapsulateTo(ct, ss, encapSeed)
kEnv := deriveEnvelopeKey(ss, dealerID, recipientID, committeeRoot)
// Build the plaintext: share || contribution || tag.
plaintext := make([]byte, envelopeSealedSize)
copy(plaintext[:64], shareWire[:])
copy(plaintext[64:96], contribution[:])
// Auth tag binds dealer + recipient + share + contribution so a
// relayed envelope under the same KEM ciphertext but different
// dealer/recipient pair fails the tag.
authInput := append(append([]byte{}, dealerID[:]...), recipientID[:]...)
authInput = append(authInput, shareWire[:]...)
authInput = append(authInput, contribution[:]...)
tag := kmac256(kEnv[:], authInput, 32, dkgEnvelopeAuthTag)
copy(plaintext[96:], tag)
stream := cshake256(kEnv[:], envelopeSealedSize, dkgEnvelopeStreamTag)
sealed := make([]byte, envelopeSealedSize)
for i := 0; i < envelopeSealedSize; i++ {
sealed[i] = plaintext[i] ^ stream[i]
}
return DKGShareEnvelope{
KEMCiphertext: ct,
Sealed: sealed,
}, nil
}
// sealOpenEnvelope is the recipient-side counterpart to sealEnvelope.
// Returns the 64-byte Shamir share and the 32-byte dealer
// contribution on success; ErrEnvelopeAuthBad if the authentication
// tag does not verify (envelope tampered or wrong dealer/recipient
// binding).
func sealOpenEnvelope(
dealerID, recipientID NodeID,
committeeRoot [32]byte,
env DKGShareEnvelope,
myIdentity *IdentityKey,
) (shareWire [64]byte, contribution [SeedSize]byte, err error) {
if myIdentity == nil {
err = ErrIdentityKeyMissing
return
}
if len(env.KEMCiphertext) != mlkem768.CiphertextSize {
err = ErrEnvelopeCiphertext
return
}
if len(env.Sealed) != envelopeSealedSize {
err = ErrEnvelopeCiphertext
return
}
var sk mlkem768.PrivateKey
if e := sk.Unpack(myIdentity.KEMPriv); e != nil {
err = ErrIdentityCorrupted
return
}
ss := make([]byte, mlkem768.SharedKeySize)
sk.DecapsulateTo(ss, env.KEMCiphertext)
kEnv := deriveEnvelopeKey(ss, dealerID, recipientID, committeeRoot)
stream := cshake256(kEnv[:], envelopeSealedSize, dkgEnvelopeStreamTag)
plaintext := make([]byte, envelopeSealedSize)
for i := 0; i < envelopeSealedSize; i++ {
plaintext[i] = env.Sealed[i] ^ stream[i]
}
var gotShare [64]byte
var gotContrib [SeedSize]byte
copy(gotShare[:], plaintext[:64])
copy(gotContrib[:], plaintext[64:96])
// Recompute auth tag and constant-time compare.
authInput := append(append([]byte{}, dealerID[:]...), recipientID[:]...)
authInput = append(authInput, gotShare[:]...)
authInput = append(authInput, gotContrib[:]...)
expectedTag := kmac256(kEnv[:], authInput, 32, dkgEnvelopeAuthTag)
if !ctEqualSlice(expectedTag, plaintext[96:]) {
err = ErrEnvelopeAuthBad
return
}
shareWire = gotShare
contribution = gotContrib
return
}
// deriveEnvelopeKey expands the KEM shared secret into a 32-byte
// envelope-encryption key bound to the (dealer, recipient, committee
// root) tuple.
func deriveEnvelopeKey(ss []byte, dealerID, recipientID NodeID, committeeRoot [32]byte) [32]byte {
info := append([]byte(dkgEnvelopeKeyTag), dealerID[:]...)
info = append(info, recipientID[:]...)
info = append(info, committeeRoot[:]...)
r := hkdf.New(sha3.New256, ss, dealerID[:], info)
var out [32]byte
_, _ = io.ReadFull(r, out[:])
return out
}
// Customisation tags for identity / session-key / envelope derivation.
// These are wire-frozen alongside the other PULSAR-*-V1 tags.
const (
sessionEstablishInfo = "PULSAR-SESSION-ESTABLISH-V1"
sessionKeyInfo = "PULSAR-SESSION-KEY-V1"
dkgEnvelopeKeyTag = "PULSAR-DKG-ENVKEY-V1"
dkgEnvelopeStreamTag = "PULSAR-DKG-ENVSTREAM-V1"
dkgEnvelopeAuthTag = "PULSAR-DKG-ENVAUTH-V1"
)
// IdentityDirectory maps NodeID to the published IdentityPublicKey.
// Used by DKG and ThresholdSigner so callers can look up peer
// identity keys by NodeID without re-passing them on every API call.
type IdentityDirectory map[NodeID]*IdentityPublicKey
// NewIdentityDirectory constructs an IdentityDirectory from a slice of
// (NodeID, IdentityPublicKey) pairs. Returns ErrIdentityKeyMissing if
// any entry has a nil identity public key.
func NewIdentityDirectory(entries map[NodeID]*IdentityPublicKey) (IdentityDirectory, error) {
out := make(IdentityDirectory, len(entries))
for id, ipk := range entries {
if ipk == nil {
return nil, ErrIdentityKeyMissing
}
// Copy bytes so external mutation can't corrupt our state.
out[id] = &IdentityPublicKey{
KEMPub: append([]byte{}, ipk.KEMPub...),
MLDSAPub: append([]byte{}, ipk.MLDSAPub...),
}
}
return out, nil
}
// hashForEncapSeed derives a 64-byte deterministic encapsulation seed
// for ML-KEM-768 from (committee_root, dealer, recipient, blind).
// Used by DKG so the same seed yields byte-identical envelopes across
// KAT regeneration.
func hashForEncapSeed(committeeRoot [32]byte, dealer, recipient NodeID, blind []byte) [mlkem768.EncapsulationSeedSize]byte {
r := hkdf.New(sha3.New256, blind,
committeeRoot[:],
append(append([]byte(dkgEnvelopeKeyTag), dealer[:]...), recipient[:]...))
var out [mlkem768.EncapsulationSeedSize]byte
_, _ = io.ReadFull(r, out[:])
return out
}
+209
View File
@@ -0,0 +1,209 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
import (
"bytes"
"testing"
)
// identityFixture provisions a deterministic IdentityKey for every
// committee member. Test code calls this once per (committee, seed)
// pair; identity material is reused across DKG / threshold / reshare
// sessions so the same identities authenticate every round.
//
// Determinism guarantees: re-running with the same committee + seed
// produces byte-identical IdentityKey objects (and therefore byte-
// identical envelope ciphertexts).
type identityFixture struct {
keys map[NodeID]*IdentityKey
directory IdentityDirectory
}
func newIdentityFixture(t testing.TB, committee []NodeID, seed []byte) *identityFixture {
t.Helper()
keys := make(map[NodeID]*IdentityKey, len(committee))
pubs := make(map[NodeID]*IdentityPublicKey, len(committee))
for i, id := range committee {
rng := deterministicReader(append(append([]byte{}, seed...), byte(i), 0xFE))
k, err := GenerateIdentity(rng)
if err != nil {
t.Fatalf("GenerateIdentity %d: %v", i, err)
}
keys[id] = k
pubs[id] = k.PublicKey()
}
dir, err := NewIdentityDirectory(pubs)
if err != nil {
t.Fatalf("NewIdentityDirectory: %v", err)
}
return &identityFixture{keys: keys, directory: dir}
}
// quorumSessionKeys runs SymmetricSession for every pair in the
// quorum and returns each party's local view (peer -> session key).
//
// For a quorum of t parties this is t*(t-1)/2 key exchanges. Test-
// only helper; production drivers stream KEM ciphertexts + signatures
// through the chain message bus.
func (f *identityFixture) quorumSessionKeys(t testing.TB, quorum []NodeID, sid [16]byte, transcript []byte) map[NodeID]map[NodeID][32]byte {
t.Helper()
out := make(map[NodeID]map[NodeID][32]byte, len(quorum))
for _, id := range quorum {
out[id] = make(map[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 := SymmetricSession(a, f.keys[a], b, f.keys[b], sid, transcript)
if err != nil {
t.Fatalf("SymmetricSession %x↔%x: %v", a[:4], b[:4], err)
}
out[a][b] = key
out[b][a] = key
}
}
return out
}
func TestEstablishSession_AuthenticatesViaIdentity(t *testing.T) {
// Two parties; both derive byte-identical session keys.
a := NodeID{0x01}
b := NodeID{0x02}
aKey, err := GenerateIdentity(deterministicReader([]byte{0xA0}))
if err != nil {
t.Fatal(err)
}
bKey, err := GenerateIdentity(deterministicReader([]byte{0xB0}))
if err != nil {
t.Fatal(err)
}
var sid [16]byte
copy(sid[:], "session-test-01")
transcript := []byte("sample-transcript")
keyAB, err := SymmetricSession(a, aKey, b, bKey, sid, transcript)
if err != nil {
t.Fatalf("SymmetricSession: %v", err)
}
// Different sid → different key.
var sid2 [16]byte
copy(sid2[:], "session-test-02")
keyAB2, err := SymmetricSession(a, aKey, b, bKey, sid2, transcript)
if err != nil {
t.Fatal(err)
}
if keyAB == keyAB2 {
t.Fatal("different sid produced identical session key")
}
// Tampered ML-DSA signature MUST be rejected by VerifyPeerEncapsulation.
_, ct, sig, err := EstablishSession(b, bKey, a, aKey.PublicKey(), sid, transcript)
if err != nil {
t.Fatal(err)
}
sigTamp := append([]byte{}, sig...)
sigTamp[42] ^= 0x80
if _, err := VerifyPeerEncapsulation(aKey, bKey.PublicKey(), ct, sigTamp); err == nil {
t.Fatal("tampered identity signature accepted at VerifyPeerEncapsulation")
}
// Tampered KEM ciphertext MUST be rejected (signature now signs the
// untampered ct; the tampered ct fails verification because the
// signature was over the original).
ctTamp := append([]byte{}, ct...)
ctTamp[100] ^= 0x01
if _, err := VerifyPeerEncapsulation(aKey, bKey.PublicKey(), ctTamp, sig); err == nil {
t.Fatal("tampered KEM ciphertext accepted at VerifyPeerEncapsulation")
}
}
func TestEstablishSession_RejectsSelfPeer(t *testing.T) {
a := NodeID{0x01}
aKey, err := GenerateIdentity(deterministicReader([]byte{0xAB}))
if err != nil {
t.Fatal(err)
}
var sid [16]byte
if _, _, _, err := EstablishSession(a, aKey, a, aKey.PublicKey(), sid, nil); err != ErrSessionPeerSelf {
t.Fatalf("self peer not rejected: %v", err)
}
}
func TestEnvelopeEncryption_PlaintextIsRejected(t *testing.T) {
// A passive observer who picks up the envelope ciphertext but
// lacks the recipient's KEM secret key cannot decrypt or forge.
dealer := NodeID{0x01}
recipient := NodeID{0x02}
dealerKey, _ := GenerateIdentity(deterministicReader([]byte{0xAA}))
recipientKey, _ := GenerateIdentity(deterministicReader([]byte{0xBB}))
_ = dealerKey
var committeeRoot [32]byte
copy(committeeRoot[:], "committee-root-fixed-for-test")
var share [64]byte
for i := range share {
share[i] = byte(i)
}
var contribution [SeedSize]byte
for i := range contribution {
contribution[i] = byte(i ^ 0x55)
}
var encapSeed [32]byte
copy(encapSeed[:], "encap-seed-32-bytes-fixed-here!!")
env, err := sealEnvelope(dealer, recipient, committeeRoot, share, contribution,
recipientKey.PublicKey().KEMPub, encapSeed[:])
if err != nil {
t.Fatal(err)
}
// Verify open round-trips.
gotShare, gotContrib, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, recipientKey)
if err != nil {
t.Fatalf("legitimate open: %v", err)
}
if gotShare != share {
t.Fatal("share round-trip mismatch")
}
if gotContrib != contribution {
t.Fatal("contribution round-trip mismatch")
}
// 1. Random observer with no KEM key — cannot decrypt. We
// simulate by handing in a different IdentityKey: decapsulation
// yields garbage and the auth-tag check fails.
stranger, _ := GenerateIdentity(deterministicReader([]byte{0xCC}))
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, env, stranger); err != ErrEnvelopeAuthBad {
t.Fatalf("stranger decrypted envelope: %v", err)
}
// 2. Wrong dealer / recipient binding — auth tag fails.
wrongDealer := NodeID{0x99}
if _, _, err := sealOpenEnvelope(wrongDealer, recipient, committeeRoot, env, recipientKey); err != ErrEnvelopeAuthBad {
t.Fatalf("wrong-dealer binding accepted: %v", err)
}
// 3. Bit-flipped sealed payload — auth tag fails.
envTamp := DKGShareEnvelope{
KEMCiphertext: env.KEMCiphertext,
Sealed: append([]byte{}, env.Sealed...),
}
envTamp.Sealed[10] ^= 0x01
if _, _, err := sealOpenEnvelope(dealer, recipient, committeeRoot, envTamp, recipientKey); err != ErrEnvelopeAuthBad {
t.Fatalf("tampered sealed payload accepted: %v", err)
}
// 4. Determinism: same inputs → same envelope bytes (KAT gate).
env2, err := sealEnvelope(dealer, recipient, committeeRoot, share, contribution,
recipientKey.PublicKey().KEMPub, encapSeed[:])
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(env.KEMCiphertext, env2.KEMCiphertext) {
t.Fatal("envelope KEM ciphertext is not deterministic from seed")
}
if !bytes.Equal(env.Sealed, env2.Sealed) {
t.Fatal("envelope sealed payload is not deterministic from seed")
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ import (
// vectorsDir resolves to the repository's vectors/ directory.
func vectorsDir() string {
// kat_test.go lives at ref/go/pkg/pulsarm/; the vectors dir is at
// kat_test.go lives at ref/go/pkg/pulsar/; the vectors dir is at
// the repo root, four levels up.
wd, err := os.Getwd()
if err != nil {
+22 -1
View File
@@ -80,7 +80,7 @@ func NewLargeThresholdSigner(params *Params, sessionID [16]byte, attempt uint32,
if peer == myShare.NodeID {
continue
}
macKeys[peer] = deriveMACKey(myShare.NodeID, peer, myShare.Pub)
macKeys[peer] = legacyDeriveMACKeyLarge(myShare.NodeID, peer, myShare.Pub)
}
return &LargeThresholdSigner{
Params: params,
@@ -294,3 +294,24 @@ func (s *LargeThresholdSigner) transcriptTau1() []byte {
func (s *LargeThresholdSigner) transcriptTau1ForSender(sender NodeID) []byte {
return transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, sender, s.SecretShare.Pub, s.Message)
}
// legacyDeriveMACKeyLarge derives a per-pair MAC key from public
// inputs (sorted-NodeID pair + group public key). Retained ONLY for
// the GF(q) large-committee path; the GF(257) small-committee path
// switched to ephemeral KEM-derived session keys (identity.go,
// BLOCKERS.md CR-7) because the v0.1 derivation was forgeable by any
// network observer. The large path keeps the v0.1 derivation pending
// a parallel large-committee identity layer; deployments that need
// CR-7 closure for wide committees must use the small-committee
// (sortitioned) path.
func legacyDeriveMACKeyLarge(a, b NodeID, pk *PublicKey) [32]byte {
first, second := a, b
if nodeIDLess(b, a) {
first, second = b, a
}
parts := [][]byte{first[:], second[:]}
if pk != nil {
parts = append(parts, pk.Bytes)
}
return transcriptHash32("PULSAR-SIGN-MACKEY-V1", parts...)
}
+422
View File
@@ -0,0 +1,422 @@
// 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)
}
})
}
+6 -3
View File
@@ -58,9 +58,9 @@ var precompileCtx = []byte("lux-evm-precompile-pulsar-v1")
func TestPrecompile_E2E_Combine_FIPS204_VerifyCtx(t *testing.T) {
const n, threshold = 3, 2
params := MustParamsFor(ModeP65)
pub, shares, _ := runDKG(t, n, threshold, ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, n, threshold, ModeP65)
msg := []byte("pulsar-m threshold -> precompile ctx -> FIPS 204 verify")
msg := []byte("pulsar threshold -> precompile ctx -> FIPS 204 verify")
var sid [16]byte
copy(sid[:], "precompile-e2e01")
attempt := uint32(0)
@@ -70,9 +70,12 @@ func TestPrecompile_E2E_Combine_FIPS204_VerifyCtx(t *testing.T) {
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], msg, deterministicReader([]byte{byte(i), 'P', 'C'}))
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)
}
+185 -68
View File
@@ -23,6 +23,17 @@ package pulsar
// selecting the first k. Without a beacon, the deterministic
// canonical-order selection is used (acceptable for single-shot
// reshares; mobile-adversary deployments MUST pass a beacon).
//
// Envelope confidentiality (CR-8): per-recipient envelopes are
// ML-KEM-768-wrapped against the new committee's long-term identity
// public keys, mirroring DKG. Each ReshareSession requires a
// IdentityDirectory of the new committee and the caller's
// IdentityKey if it expects to receive new shares.
//
// Commit-and-open (CR-6 path A): the v0.1 myCommit field on
// reshare Round-1 messages was broadcast but never opened; dropped
// alongside DKG's myCommit. Binding comes from Round-2 digest
// agreement over the ordered envelope set.
import (
"crypto/rand"
@@ -33,11 +44,13 @@ import (
// Errors returned by Reshare.
var (
ErrOldCommitteeEmpty = errors.New("pulsar: old committee is empty")
ErrNewCommitteeEmpty = errors.New("pulsar: new committee is empty")
ErrOldThresholdSmall = errors.New("pulsar: old committee smaller than old threshold")
ErrNewThresholdSmall = errors.New("pulsar: new committee smaller than new threshold")
ErrShareCount = errors.New("pulsar: insufficient old-committee shares for reshare")
ErrOldCommitteeEmpty = errors.New("pulsar: old committee is empty")
ErrNewCommitteeEmpty = errors.New("pulsar: new committee is empty")
ErrOldThresholdSmall = errors.New("pulsar: old committee smaller than old threshold")
ErrNewThresholdSmall = errors.New("pulsar: new committee smaller than new threshold")
ErrShareCount = errors.New("pulsar: insufficient old-committee shares for reshare")
ErrPriorPubkeyUnknown = errors.New("pulsar: reshare prior group pubkey not set (new-committee-only party must call SetPriorGroupPubkey)")
ErrPriorPubkeyMismatch = errors.New("pulsar: reshare prior group pubkey from SetPriorGroupPubkey != MyOldShare.Pub")
)
// ReshareSession holds one party's state for a single reshare
@@ -58,12 +71,32 @@ type ReshareSession struct {
// of the old committee).
MyOldShare *KeyShare
// MyIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
// keypair, used to (a) seal outgoing envelopes if in the reshare
// quorum and (b) open incoming envelopes if in the new committee.
MyIdentity *IdentityKey
// NewDirectory carries the published identity public key for
// every new-committee member. The reshare quorum uses this to
// KEM-wrap outgoing envelopes.
NewDirectory IdentityDirectory
// Beacon is the chain randomness used to permute the old
// committee before quorum selection. Pass nil to use canonical
// ordering (acceptable for single-shot reshares; mobile-adversary
// deployments MUST pass a beacon).
Beacon []byte
// priorGroupPubkey is the master public key from BEFORE this
// reshare. Set via SetPriorGroupPubkey before Round3. For
// parties also in the old committee (MyOldShare != nil), the
// pubkey is auto-populated from MyOldShare.Pub if this field
// is left nil; for new-committee-only parties (joiners),
// Round3 returns ErrPriorPubkeyUnknown if this field wasn't
// set, refusing to emit a KeyShare with Pub: nil that a
// malicious driver could overwrite with an arbitrary pubkey.
priorGroupPubkey *PublicKey
rng io.Reader
// Internal state.
@@ -73,16 +106,41 @@ type ReshareSession struct {
round1Cache []*DKGRound1Msg
}
// SetPriorGroupPubkey records the master public key from BEFORE
// this reshare. New-committee-only parties (those without an old
// share) MUST call this before Round3; the reshare driver injects
// the pinned prior pubkey here so Round3 can stamp it into the
// new KeyShare deterministically.
//
// For parties also in the old committee, this is optional — Round3
// falls back to MyOldShare.Pub. When BOTH are set, Round3 verifies
// they match.
//
// NOTE: this records the pubkey; it does NOT cryptographically
// verify that the per-dealer reshare contributions actually
// preserve it. Full binding (Pedersen-style commitment to each
// dealer's zero-secret contribution at Round-1) is a v0.2
// hardening item (see BLOCKERS.md "reshare pk-binding").
func (s *ReshareSession) SetPriorGroupPubkey(pk *PublicKey) {
s.priorGroupPubkey = pk
}
// NewReshareSession constructs a new reshare session.
//
// myOldShare may be nil if the calling party is not in the reshare
// quorum (i.e. is a new-committee-only party that only receives
// shares). If myOldShare is non-nil, it must be a share from the
// most recent DKG/Reshare against the same group public key.
//
// myIdentity is this party's long-term ML-KEM-768 + ML-DSA-65
// keypair. newDirectory must contain a published IdentityPublicKey
// for every new-committee member; the reshare quorum uses these keys
// to KEM-wrap outgoing envelopes (BLOCKERS.md CR-8).
func NewReshareSession(params *Params,
oldCommittee []NodeID, oldThreshold int,
newCommittee []NodeID, newThreshold int,
myID NodeID, myOldShare *KeyShare,
myIdentity *IdentityKey, newDirectory IdentityDirectory,
beacon []byte, rng io.Reader) (*ReshareSession, error) {
if err := params.Validate(); err != nil {
@@ -100,6 +158,12 @@ func NewReshareSession(params *Params,
if newThreshold < 1 || len(newCommittee) < newThreshold {
return nil, ErrNewThresholdSmall
}
if myIdentity == nil {
return nil, ErrIdentityKeyMissing
}
if newDirectory == nil {
return nil, ErrDirectoryIncomplete
}
// Canonicalise committees.
oldSorted := append([]NodeID(nil), oldCommittee...)
@@ -107,6 +171,13 @@ func NewReshareSession(params *Params,
newSorted := append([]NodeID(nil), newCommittee...)
sort.Slice(newSorted, func(i, j int) bool { return nodeIDLess(newSorted[i], newSorted[j]) })
// Directory must cover every new committee member.
for _, id := range newSorted {
if newDirectory[id] == nil {
return nil, ErrDirectoryIncomplete
}
}
// Beacon-permuted reshare-quorum selection.
quorum := selectReshareQuorum(oldSorted, oldThreshold, beacon)
@@ -129,6 +200,8 @@ func NewReshareSession(params *Params,
NewThreshold: newThreshold,
MyID: myID,
MyOldShare: myOldShare,
MyIdentity: myIdentity,
NewDirectory: newDirectory,
Beacon: beacon,
rng: rng,
myIdxInOld: myIdxInOld,
@@ -159,7 +232,9 @@ func (s *ReshareSession) InReshareQuorum() bool {
// old quorum).
//
// The implementation derives λ_i^Q at the byte level over GF(257),
// matching the byte-wise share format installed by DKG.
// matching the byte-wise share format installed by DKG. Envelopes are
// ML-KEM-768-wrapped against each new-committee member's long-term
// identity key (CR-8).
func (s *ReshareSession) Round1() (*DKGRound1Msg, error) {
if !s.InReshareQuorum() {
return nil, ErrNotInCommittee
@@ -184,21 +259,26 @@ func (s *ReshareSession) Round1() (*DKGRound1Msg, error) {
for b := 0; b < SeedSize; b++ {
contribution[b] = uint16((uint32(lambda) * uint32(oldShare.Y[b])) % shamirPrime)
}
// Per-byte representation of contribution as a SeedSize-byte
// value mod 256 for the envelope. Reshare contributions can take
// the 257-th GF value, but for envelope embedding we use the
// least-significant byte (the high byte is implicit from the
// 16-bit Shamir share lanes). The sealing path bundles both the
// Shamir share (16-bit lanes) and the per-byte contribution
// (8-bit reduced mod 256) so the recipient can both aggregate
// shares and recompute the byte-sum-mod-257 by lifting the share
// back into GF(257) at decode.
var contribBytes [SeedSize]byte
for b := 0; b < SeedSize; b++ {
contribBytes[b] = byte(contribution[b] % 256)
}
// Sample blinding for the RO commit.
// Sample deterministic-RNG blinding bytes for envelope-encap seed
// derivation (also kept for future v0.2 algebraic-binding path).
var blind [32]byte
if _, err := io.ReadFull(s.rng, blind[:]); err != nil {
return nil, ErrShortRand
}
// Commit binds the contribution as 2 bytes per GF(257) slot
// (big-endian) so the 257-th value is faithfully represented.
contribBytes := make([]byte, SeedSize*2)
for b := 0; b < SeedSize; b++ {
contribBytes[2*b] = byte(contribution[b] >> 8)
contribBytes[2*b+1] = byte(contribution[b])
}
commitInput := append(append([]byte{}, contribBytes...), blind[:]...)
myCommit := transcriptHash32(tagReshareCommit, commitInput)
// Shamir-share the contribution at threshold newT to the new committee.
keyMaterial := []byte{}
@@ -217,27 +297,44 @@ func (s *ReshareSession) Round1() (*DKGRound1Msg, error) {
}
s.myShares = shares
// committeeRoot for the envelope auth-tag binding uses the NEW
// committee root (recipient-side context).
var newRoot [32]byte
copy(newRoot[:], s.commitNewCommitteeRoot())
envelopes := make(map[NodeID]DKGShareEnvelope, len(s.NewCommittee))
for posIdx, recipient := range s.NewCommittee {
shareBytes := shareToBytes(shares[posIdx])
blindMask := cshake256(
append(append([]byte{}, blind[:]...), recipient[:]...),
shareWireSize,
"PULSAR-RESHARE-BLINDMASK-V1",
// Per-recipient deterministic encapsulation seed.
encapBlind := cshake256(
append(append(append([]byte{}, blind[:]...),
s.MyID[:]...), recipient[:]...),
64,
"PULSAR-RESHARE-ENCAPSEED-V1",
)
var envShare [64]byte
copy(envShare[:], shareBytes[:])
var envBlind [64]byte
copy(envBlind[:], blindMask)
envelopes[recipient] = DKGShareEnvelope{
Share: envShare,
Blind: envBlind,
encapSeed := hashForEncapSeed(newRoot, s.MyID, recipient, encapBlind)
recipientIPK := s.NewDirectory[recipient]
if recipientIPK == nil {
return nil, ErrDirectoryIncomplete
}
env, err := sealEnvelope(
s.MyID,
recipient,
newRoot,
shareBytes,
contribBytes,
recipientIPK.KEMPub,
encapSeed[:],
)
if err != nil {
return nil, err
}
envelopes[recipient] = env
}
return &DKGRound1Msg{
NodeID: s.MyID,
Commits: [][]byte{myCommit[:]},
Envelopes: envelopes,
}, nil
}
@@ -254,12 +351,21 @@ func (s *ReshareSession) Round2(round1 []*DKGRound1Msg) (*DKGRound2Msg, error) {
}
s.round1Cache = ordered
digest := s.computeReshareDigest(ordered)
return &DKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
}
// computeReshareDigest binds the dealer NodeID and every recipient's
// KEM-wrapped envelope (ciphertext + sealed payload) into a single
// 32-byte digest. Equivalent to DKGSession.computeRound2Digest but
// over the reshare tag.
func (s *ReshareSession) computeReshareDigest(ordered []*DKGRound1Msg) [32]byte {
parts := [][]byte{}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
@@ -268,15 +374,11 @@ func (s *ReshareSession) Round2(round1 []*DKGRound1Msg) (*DKGRound2Msg, error) {
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
parts = append(parts, env.KEMCiphertext)
parts = append(parts, env.Sealed)
}
}
digest := transcriptHash32(tagReshareCommit, parts...)
return &DKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
return transcriptHash32(tagReshareCommit, parts...)
}
// Round3 verifies the digest agreement and aggregates the calling
@@ -310,25 +412,7 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
}
// Recompute and verify the canonical digest.
parts := [][]byte{}
for _, m := range ordered {
parts = append(parts, m.NodeID[:])
for _, c := range m.Commits {
parts = append(parts, c)
}
recipKeys := make([]NodeID, 0, len(m.Envelopes))
for k := range m.Envelopes {
recipKeys = append(recipKeys, k)
}
sort.Slice(recipKeys, func(i, j int) bool { return nodeIDLess(recipKeys[i], recipKeys[j]) })
for _, k := range recipKeys {
env := m.Envelopes[k]
parts = append(parts, k[:])
parts = append(parts, env.Share[:])
parts = append(parts, env.Blind[:])
}
}
expected := transcriptHash32(tagReshareCommit, parts...)
expected := s.computeReshareDigest(ordered)
for _, r2 := range round2 {
if !ctEqual32(r2.Digest, expected) {
return nil, &AbortEvidence{
@@ -339,8 +423,11 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
}
}
// Aggregate the new share: sum the per-dealer envelope shares for
// this party's NEW evaluation point.
// Aggregate the new share: decrypt each dealer's envelope
// addressed to me, sum the recovered shares at my NEW evaluation
// point.
var newRoot [32]byte
copy(newRoot[:], s.commitNewCommitteeRoot())
newEval := uint32(myNewIdx + 1)
var aggY [SeedSize]uint16
for _, m := range ordered {
@@ -352,9 +439,17 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
Accused: m.NodeID,
}, ErrEnvelopeMissing
}
var buf [shareWireSize]byte
copy(buf[:], env.Share[:])
senderShare := shareFromBytes(newEval, buf)
senderShareBytes, _, openErr := sealOpenEnvelope(
m.NodeID, s.MyID, newRoot, env, s.MyIdentity,
)
if openErr != nil {
return nil, &AbortEvidence{
Kind: ComplaintBadDelivery,
Accuser: s.MyID,
Accused: m.NodeID,
}, openErr
}
senderShare := shareFromBytes(newEval, senderShareBytes)
for b := 0; b < SeedSize; b++ {
aggY[b] = uint16((uint32(aggY[b]) + uint32(senderShare.Y[b])) % shamirPrime)
}
@@ -362,15 +457,37 @@ func (s *ReshareSession) Round3(round1 []*DKGRound1Msg, round2 []*DKGRound2Msg)
aggregate := shamirShare{X: newEval, Y: aggY}
shareWire := shareToBytes(aggregate)
// The new share recovers the SAME byte-sum at x=0 as the old
// shares did (Theorem reshare-pkinv): pk is invariant. The pub
// is carried via the calling party's old share if available; new-
// committee-only parties get pub injected via the reshare driver
// (it is a public artifact already pinned on-chain).
// Determine the prior group public key — the master pubkey from
// BEFORE this reshare. Resolution order:
// 1. s.priorGroupPubkey (set by SetPriorGroupPubkey)
// 2. s.MyOldShare.Pub (if the party is in the old committee too)
//
// New-committee-only parties (no MyOldShare) MUST have called
// SetPriorGroupPubkey before Round3, otherwise we'd be emitting
// a KeyShare with Pub: nil and trusting the reshare driver to
// overwrite it with the right value — exactly the gap Agent 4 C4
// flagged. When both sources are set, they must agree.
var pub *PublicKey
if s.MyOldShare != nil {
switch {
case s.priorGroupPubkey != nil && s.MyOldShare != nil:
if !s.priorGroupPubkey.Equal(s.MyOldShare.Pub) {
return nil, nil, ErrPriorPubkeyMismatch
}
pub = s.priorGroupPubkey
case s.priorGroupPubkey != nil:
pub = s.priorGroupPubkey
case s.MyOldShare != nil:
pub = s.MyOldShare.Pub
default:
return nil, nil, ErrPriorPubkeyUnknown
}
// The new share recovers the SAME byte-sum at x=0 as the old
// shares did (Theorem reshare-pkinv): pk is invariant by
// construction OF AN HONEST RESHARE-QUORUM (every dealer's
// per-recipient share polynomial has constant term zero). Full
// per-dealer commitment binding to enforce zero-secret contributions
// is a v0.2 hardening item.
return &KeyShare{
NodeID: s.MyID,
EvalPoint: newEval,
+50 -11
View File
@@ -13,7 +13,8 @@ import (
func TestReshare_SameCommittee_PubInvariant(t *testing.T) {
params := MustParamsFor(ModeP65)
committee := makeCommittee(5)
pub, oldShares, _ := runDKG(t, 5, 3, ModeP65)
pub, oldShares, _, _ := runDKGWithIdentities(t, 5, 3, ModeP65)
ident := newIdentityFixture(t, committee, []byte("reshare-same"))
// Run a reshare with the same committee on both sides.
sessions := make([]*ReshareSession, 5)
@@ -24,10 +25,18 @@ func TestReshare_SameCommittee_PubInvariant(t *testing.T) {
oldShare = oldShares[i]
}
s, err := NewReshareSession(params, committee, 3, committee, 3,
committee[i], oldShare, nil, deterministicReader([]byte{byte(i), 0xEE}))
committee[i], oldShare, ident.keys[committee[i]], ident.directory,
nil, deterministicReader([]byte{byte(i), 0xEE}))
if err != nil {
t.Fatalf("NewReshareSession party %d: %v", i, err)
}
// New-committee-only parties (3..4 in this test) MUST be
// told the prior pubkey so Round3 can stamp it deterministically
// rather than emitting Pub: nil for the driver to overwrite.
// Old-committee parties auto-derive from MyOldShare.Pub.
if oldShare == nil {
s.SetPriorGroupPubkey(pub)
}
sessions[i] = s
}
@@ -103,18 +112,41 @@ func TestReshare_SameCommittee_PubInvariant(t *testing.T) {
func TestReshare_NewCommittee(t *testing.T) {
params := MustParamsFor(ModeP65)
oldCommittee := makeCommittee(5)
pub, oldShares, _ := runDKG(t, 5, 3, ModeP65)
pub, oldShares, _, oldIdent := runDKGWithIdentities(t, 5, 3, ModeP65)
// New committee: replace one member.
newCommittee := make([]NodeID, 5)
copy(newCommittee, oldCommittee)
newCommittee[4] = NodeID{0xff, 0xfe} // replace the last member
// Build a fresh identity fixture covering BOTH committees so
// retiring + joining parties both have an IdentityKey available
// (retiring parties still seal envelopes; joining parties decrypt).
allParties := append(append([]NodeID{}, oldCommittee...), newCommittee[4])
newIdent := newIdentityFixture(t, newCommittee, []byte("reshare-new-committee"))
// Old members reuse their old identity; the new joining member uses newIdent.
identKeys := make(map[NodeID]*IdentityKey)
for id, k := range oldIdent.keys {
identKeys[id] = k
}
for id, k := range newIdent.keys {
identKeys[id] = k
}
newDirPubs := make(map[NodeID]*IdentityPublicKey)
for _, id := range newCommittee {
newDirPubs[id] = identKeys[id].PublicKey()
}
newDir, _ := NewIdentityDirectory(newDirPubs)
sessions := make([]*ReshareSession, 0)
// Include the reshare quorum (first 3 old-committee members).
for i := 0; i < 3; i++ {
s, _ := NewReshareSession(params, oldCommittee, 3, newCommittee, 3,
oldCommittee[i], oldShares[i], nil, deterministicReader([]byte{byte(i), 0xAA}))
s, err := NewReshareSession(params, oldCommittee, 3, newCommittee, 3,
oldCommittee[i], oldShares[i], identKeys[oldCommittee[i]], newDir,
nil, deterministicReader([]byte{byte(i), 0xAA}))
if err != nil {
t.Fatalf("quorum party %d: %v", i, err)
}
sessions = append(sessions, s)
}
// Also include new-committee members that aren't in the reshare quorum.
@@ -125,10 +157,15 @@ func TestReshare_NewCommittee(t *testing.T) {
if i < len(oldShares) {
oldShare = oldShares[i]
}
s, _ := NewReshareSession(params, oldCommittee, 3, newCommittee, 3,
newCommittee[i], oldShare, nil, deterministicReader([]byte{byte(i), 0xBB}))
s, err := NewReshareSession(params, oldCommittee, 3, newCommittee, 3,
newCommittee[i], oldShare, identKeys[newCommittee[i]], newDir,
nil, deterministicReader([]byte{byte(i), 0xBB}))
if err != nil {
t.Fatalf("new-only party %d: %v", i, err)
}
sessions = append(sessions, s)
}
_ = allParties
// Only quorum produces Round-1.
r1 := []*DKGRound1Msg{}
@@ -229,16 +266,18 @@ func TestReshare_RejectInvalidCommittees(t *testing.T) {
params := MustParamsFor(ModeP65)
old := makeCommittee(5)
new_ := makeCommittee(5)
if _, err := NewReshareSession(params, nil, 3, new_, 3, old[0], nil, nil, nil); err != ErrOldCommitteeEmpty {
ident := newIdentityFixture(t, old, []byte("reshare-rej"))
dir := ident.directory
if _, err := NewReshareSession(params, nil, 3, new_, 3, old[0], nil, ident.keys[old[0]], dir, nil, nil); err != ErrOldCommitteeEmpty {
t.Fatalf("empty old committee not rejected: %v", err)
}
if _, err := NewReshareSession(params, old, 3, nil, 3, old[0], nil, nil, nil); err != ErrNewCommitteeEmpty {
if _, err := NewReshareSession(params, old, 3, nil, 3, old[0], nil, ident.keys[old[0]], dir, nil, nil); err != ErrNewCommitteeEmpty {
t.Fatalf("empty new committee not rejected: %v", err)
}
if _, err := NewReshareSession(params, old, 6, new_, 3, old[0], nil, nil, nil); err != ErrOldThresholdSmall {
if _, err := NewReshareSession(params, old, 6, new_, 3, old[0], nil, ident.keys[old[0]], dir, nil, nil); err != ErrOldThresholdSmall {
t.Fatalf("old threshold > old committee size not rejected: %v", err)
}
if _, err := NewReshareSession(params, old, 3, new_, 6, old[0], nil, nil, nil); err != ErrNewThresholdSmall {
if _, err := NewReshareSession(params, old, 3, new_, 6, old[0], nil, ident.keys[old[0]], dir, nil, nil); err != ErrNewThresholdSmall {
t.Fatalf("new threshold > new committee size not rejected: %v", err)
}
}
+10 -2
View File
@@ -111,11 +111,16 @@ func TestRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
}
sessionID := RoundSessionID(ctx)
// Per-round identity fixture: each round gets a fresh
// IdentityKey set (deterministic from the round number)
// so KEM-wrapped DKG envelopes are cross-round disjoint.
ident := newIdentityFixture(t, committee, []byte{byte(round), 'R'})
// Per-round DKG.
dkg := make([]*DKGSession, K)
for i := 0; i < K; i++ {
rng := deterministicReader([]byte{byte(round), byte(i), 'D'})
s, err := NewDKGSession(params, committee, T, committee[i], rng)
s, err := NewDKGSession(params, committee, T, committee[i], ident.keys[committee[i]], ident.directory, rng)
if err != nil {
t.Fatalf("round %d DKG party %d: %v", round, i, err)
}
@@ -153,10 +158,13 @@ func TestRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
for i := 0; i < T; i++ {
myShares[committee[i]] = outs[i].SecretShare
}
// CR-7 closure: per-pair ephemeral session keys for the quorum.
sessionKeys := ident.quorumSessionKeys(t, quorum, sessionID, item[:])
signers := make([]*ThresholdSigner, T)
for i := 0; i < T; i++ {
rng := deterministicReader([]byte{byte(round), byte(i), 'S'})
ts, err := NewThresholdSigner(params, sessionID, uint32(round), quorum, myShares[committee[i]], item[:], rng)
ts, err := NewThresholdSigner(params, sessionID, uint32(round), quorum, myShares[committee[i]], sessionKeys[myShares[committee[i]].NodeID], item[:], rng)
if err != nil {
t.Fatalf("round %d signer %d: %v", round, i, err)
}
+28 -6
View File
@@ -124,13 +124,24 @@ func shamirDealRandomGF(secret [SeedSize]uint16, n, t int, coeffStream []byte) (
}
// shamirReconstruct Lagrange-interpolates the constant term from a
// quorum of shares. Returns the 32-byte secret (each byte = constant
// term mod 256 — the cSHAKE256 mix downstream flattens any byte-vs-
// GF(257) bias).
// quorum of shares and reduces each byte modulo 256.
//
// Use shamirReconstructGF if the caller wants the raw GF(257) result
// (e.g. when chaining reshare and DKG arithmetic that must preserve
// the 256-valued element).
// DEPRECATED — do NOT use in new code. The mod-256 reduction
// COLLIDES GF(257)-element 256 with 0, silently losing one bit of
// the underlying constant term per byte position. This is fine for
// the protocol-internal call sites (DKG + threshold-sign), but only
// because they ALL use shamirReconstructGF directly and then route
// the 16-bit GF result through a cSHAKE256 mix that absorbs the
// 256/0 distinction. Any new caller reaching for this byte-form
// function will silently corrupt the secret when the GF
// reconstruction happens to land on 256.
//
// Use shamirReconstructGF instead — it returns the raw GF(257)
// result, preserving the 256-valued element. shamirReconstruct is
// kept ONLY for its existing unit tests (shamir_test.go) which
// exercise the GF→byte mapping for property-test coverage. The
// hot-path callers (threshold.go Combine, dkg.go Round3) all use
// shamirReconstructGF.
func shamirReconstruct(shares []shamirShare) ([SeedSize]byte, error) {
gf, err := shamirReconstructGF(shares)
if err != nil {
@@ -251,6 +262,17 @@ func shareFromBytes(x uint32, buf [shareWireSize]byte) shamirShare {
// ID-stable evaluation point rather than a position-in-committee
// point. The DKG default uses (committee_index + 1) which is simpler
// and KAT-stable.
//
// Distribution honesty (Agent 4 M2): this is `digest[0] % 256 + 1`,
// which gives outputs in [1, 256]. Because `shamirPrime - 1 = 256`
// and `digest[0]` is uniform on [0, 255] (one cSHAKE256 byte), the
// modular reduction is the identity — every output in [1, 256] is
// equiprobable. This is a HAPPY COINCIDENCE of the parameter choice
// (small prime = 257). If shamirPrime ever changes (extremely
// unlikely; the byte-wise GF(257) Shamir field is fixed by spec
// §4.2), this function will silently bias the distribution and
// MUST be rewritten to use rejection sampling. The current
// implementation is correct ONLY because shamirPrime - 1 == 256.
func EvalPointFromID(id NodeID) uint32 {
digest := cshake256(id[:], 1, tagSeedShare)
return uint32(digest[0])%(shamirPrime-1) + 1
+86 -27
View File
@@ -7,11 +7,18 @@ package pulsar
//
// Protocol shape (v0.1 reconstruction-aggregator instantiation):
//
// 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 long-lived MAC key K_{i,j} via
// 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
@@ -85,12 +92,13 @@ type ThresholdSigner struct {
// mldsa.SignTo would consume).
Message []byte
// MACKeys is the long-lived per-peer MAC key set. In v0.1 this
// is derived at session setup via cSHAKE256 over (myNodeID,
// peerNodeID, group-pubkey) — a non-secret value sufficient for
// the v0.1 commit-and-reveal binding. v0.2 (production) replaces
// this with per-session ephemeral keys exchanged at the
// preprocessing phase per pulsar.tex §6.2.
// 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.
@@ -112,9 +120,26 @@ type ThresholdSigner struct {
// 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, message []byte, rng io.Reader) (*ThresholdSigner, error) {
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
}
@@ -141,13 +166,17 @@ func NewThresholdSigner(params *Params, sessionID [16]byte, attempt uint32, quor
if rng == nil {
rng = rand.Reader
}
// Derive per-peer MAC keys.
// 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
}
macKeys[peer] = deriveMACKey(myShare.NodeID, peer, myShare.Pub)
key, ok := sessionKeys[peer]
if !ok {
return nil, ErrSessionKeyMissing
}
macKeys[peer] = key
}
return &ThresholdSigner{
Params: params,
@@ -167,10 +196,28 @@ func NewThresholdSigner(params *Params, sessionID [16]byte, attempt uint32, quor
// 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 mask.
if _, err := io.ReadFull(s.rng, s.myMask[:]); err != nil {
// 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
}
maskMix := make([]byte, 0, 64+16+4+len(s.NodeID))
maskMix = append(maskMix, rngBytes[:]...)
maskMix = append(maskMix, s.SessionID[:]...)
maskMix = append(maskMix,
byte(s.Attempt>>24), byte(s.Attempt>>16),
byte(s.Attempt>>8), byte(s.Attempt))
maskMix = append(maskMix, s.NodeID[:]...)
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]
@@ -386,13 +433,27 @@ func Combine(params *Params, groupPubkey *PublicKey, message []byte, ctx []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.
@@ -400,8 +461,21 @@ func Combine(params *Params, groupPubkey *PublicKey, message []byte, ctx []byte,
// 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
}
@@ -444,21 +518,6 @@ func transcriptTau1Bytes(sid [16]byte, attempt uint32, quorum []NodeID, sender N
return out
}
// deriveMACKey derives a symmetric MAC key between (a, b). Symmetric:
// deriveMACKey(a, b, pk) == deriveMACKey(b, a, pk) for the same pk.
func deriveMACKey(a, b NodeID, pk *PublicKey) [32]byte {
// Sort the pair so both ends derive the same key.
first, second := a, b
if nodeIDLess(b, a) {
first, second = b, a
}
parts := [][]byte{first[:], second[:]}
if pk != nil {
parts = append(parts, pk.Bytes)
}
return transcriptHash32("PULSAR-SIGN-MACKEY-V1", parts...)
}
// committeeRootFromShares reconstructs the DKG committee root from a
// directory of KeyShares. The committee root is the canonical
// 32-byte digest of the sorted committee that DKG installed.
+130 -17
View File
@@ -10,12 +10,23 @@ import (
// 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], deterministicReader([]byte{byte(i), 0xCA, 0xFE}))
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)
}
@@ -44,7 +55,7 @@ func runDKG(t *testing.T, n, threshold int, mode Mode) (*PublicKey, []*KeyShare,
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
return outputs[0].GroupPubkey, shares, outputs[0].TranscriptHash
return outputs[0].GroupPubkey, shares, outputs[0].TranscriptHash, ident
}
func TestThresholdSign_RoundTrip(t *testing.T) {
@@ -59,11 +70,11 @@ func TestThresholdSign_RoundTrip(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _ := runDKG(t, tc.n, tc.t, 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-m-test-01")
copy(sid[:], "pulsar-test-01")
attempt := uint32(1)
// Quorum = first t parties in canonical (sorted) order.
quorum := make([]NodeID, tc.t)
@@ -71,10 +82,13 @@ func TestThresholdSign_RoundTrip(t *testing.T) {
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], msg, deterministicReader([]byte{byte(i), 0xFE}))
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)
}
@@ -120,15 +134,16 @@ func TestThresholdSign_RoundTrip(t *testing.T) {
func TestThresholdSign_BadMAC_Detected(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _ := runDKG(t, 5, 3, 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], msg, deterministicReader([]byte{byte(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)
@@ -154,15 +169,16 @@ func TestThresholdSign_BadMAC_Detected(t *testing.T) {
func TestThresholdSign_DifferentAttempt_Rejected(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _ := runDKG(t, 5, 3, 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], msg, deterministicReader([]byte{byte(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)
@@ -179,15 +195,16 @@ func TestThresholdSign_DifferentAttempt_Rejected(t *testing.T) {
func TestThresholdSign_TamperedReveal_RejectedAtCombine(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _ := runDKG(t, 5, 3, 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], msg, deterministicReader([]byte{byte(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)
@@ -215,41 +232,137 @@ func TestThresholdSign_TamperedReveal_RejectedAtCombine(t *testing.T) {
}
}
// 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, _ := runDKG(t, 5, 3, 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], msg, deterministicReader([]byte{1}))
_, 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], msg, deterministicReader([]byte{1}))
_, 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{NodeID{0xff}}, shares[0], msg, deterministicReader([]byte{1}))
_, err = NewThresholdSigner(params, sid, 1, []NodeID{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, _ := runDKG(t, 7, 4, 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], msg, deterministicReader([]byte{byte(idx)}))
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[idx], sessionKeys[shares[idx].NodeID], msg, deterministicReader([]byte{byte(idx)}))
signers[j] = s
}
r1 := make([]*Round1Message, 4)
+17 -1
View File
@@ -34,10 +34,26 @@ const (
tagDKGTranscript = "PULSAR-DKG-TRANSCRIPT-V1"
tagSignR1 = "PULSAR-SIGN-R1-V1"
tagSignR1MAC = "PULSAR-SIGN-R1-MAC-V1"
tagSignR2 = "PULSAR-SIGN-R2-V1"
// tagSignR2 = "PULSAR-SIGN-R2-V1" — RESERVED for a future
// Round-2 MAC envelope as defense-in-depth (see
// docs/threat-model.md "Round-2 integrity"). The v0.1
// design relies on commit-bind (Round-1's D_i digest
// equals cSHAKE256(mask||masked||τ_1) — tampered Round-2
// reveals fail commit re-derivation in Combine and are
// rejected, verified by TestThresholdSwap_RejectedByCommitBind
// in threshold_test.go). The MAC tag is intentionally left
// undefined here so a stale rebase reusing the name catches
// the inconsistency at compile time.
tagSignPRNG = "PULSAR-SIGN-PRNG-V1"
tagSignPRNGKey = "PULSAR-SIGN-PRNGKEY-V1"
tagSignPRF = "PULSAR-SIGN-PRF-V1"
// tagSignMask: per-attempt Round-1 mask derivation. Mixes the
// raw RNG output with (sid || attempt || NodeID) so a caller
// who accidentally reuses the same deterministic RNG across
// two attempts (or two parallel sessions) gets DISTINCT masks
// per (sid, attempt, NodeID). Closes the cross-attempt mask
// reuse window flagged by the cryptographer review (H2).
tagSignMask = "PULSAR-SIGN-MASK-V1"
tagReshareCommit = "PULSAR-RESHARE-COMMIT-V1"
tagReshareTrans = "PULSAR-RESHARE-TRANSCRIPT-V1"
tagReshareBeacon = "PULSAR-RESHARE-BEACON-V1"
+64 -21
View File
@@ -144,12 +144,19 @@ type Signature struct {
// 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)
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.
@@ -179,32 +186,68 @@ type Round2Message struct {
PartialSig []byte
}
// DKGRound1Msg is the broadcast emitted by DKGSession.Round1: the
// per-party Pedersen commits to the polynomial coefficients of f_i(X),
// along with the encrypted private envelope carrying f_i(j) and
// g_i(j) for each recipient j.
// DKGRound1Msg is the broadcast emitted by DKGSession.Round1.
//
// Protocol shape (CR-6 path A — Shamir+sum, no commit-and-open):
// the dealer broadcasts one KEM-wrapped envelope per recipient that
// carries the recipient's Shamir share of the dealer's contribution
// to the joint seed (BLOCKERS.md CR-6). There is no separate
// "commit-then-open" round: binding comes from Round-2 digest
// agreement over the ordered envelope set. The unkeyed v0.1
// `myCommit = cSHAKE(c_i || blind_i)` field was broadcast but never
// transmitted alongside an opening, so it bound to nothing the
// protocol verified; that field is gone and the protocol is
// documented as Shamir+sum-with-equivocation-digest.
//
// Per-recipient envelopes are KEM-wrapped under ML-KEM-768 against
// the recipient's long-term identity public key (BLOCKERS.md CR-8).
// A passive network observer who reads the broadcast learns only the
// ciphertext; no Shamir share leaks to anyone outside the committee.
type DKGRound1Msg struct {
NodeID NodeID
Commits [][]byte // C_{i,k} for k = 0..t-1; each is a serialized R_q^k vector
// Envelopes carries the per-recipient sealed share (f_i(j), g_i(j)).
// Sealed under the recipient's long-term DH key — in v0.1 we expose
// the plain payload; v0.2 wraps with a noise-or-equivalent KEM.
NodeID NodeID
// Envelopes carries the per-recipient ML-KEM-768-wrapped envelope.
// Recipient decrypts with their long-term identity secret key
// (DKGSession.identityKey.KEMPriv) at Round 2.
Envelopes map[NodeID]DKGShareEnvelope
}
// DKGShareEnvelope is the (share, blinding) pair sent from a Round-1
// dealer to a Round-1 recipient. v0.1 transmits these in the clear
// under the DKG transcript hash; v0.2 will seal each envelope under
// the recipient's identity-key Curve25519 DH (a noise-style framing).
// DKGShareEnvelope is the ML-KEM-768-wrapped envelope carrying one
// recipient's per-byte Shamir share of the dealer's secret seed
// contribution AND the full dealer contribution. Sealing is
// per-recipient with the recipient's long-term ML-KEM-768 identity
// public key (BLOCKERS.md CR-8).
//
// Share is 32 × uint16 big-endian GF(257) lanes carrying the
// per-byte Shamir share of the dealer's secret contribution at the
// recipient's evaluation point. Blind is the matching Pedersen
// blinding share at the same point (used for v0.2 binding; in v0.1
// the binding is by RO-collision of the cSHAKE256 commit).
// Wire layout:
// - KEMCiphertext: ML-KEM-768 ciphertext encapsulating a per-pair
// shared secret to the recipient (1088 bytes for ML-KEM-768).
// - Sealed: stream-cipher-encrypted (Share || Contribution || Tag)
// under HKDF-SHA3-256(shared_secret). The plaintext under Sealed
// is 64 bytes Shamir share + 32 bytes dealer contribution + 32
// bytes authentication tag = 128 bytes total.
//
// The dealer contribution c_i is duplicated into every envelope so
// that each committee member, after decrypting their own envelope,
// learns the full contribution from this dealer. Combining N such
// per-dealer contributions at Round 3 lets each party compute the
// joint master public key locally — without needing to read other
// recipients' envelopes (which they cannot under CR-8).
//
// This preserves the v0.1 reconstruction-aggregator trust model
// (every committee member learns the master secret) while closing
// CR-8 against passive network observers (envelopes are unreadable
// outside the committee). A v0.2 instantiation that gives true
// threshold secrecy lives behind the algebraic Lagrange-linearity
// path of pulsar.tex §4.2.
//
// The authentication tag binds the share+contribution to the
// (dealer, recipient, committee_root) tuple so a relayed envelope
// from a different dealer cannot replay as the recipient's share —
// even after KEM decap.
type DKGShareEnvelope struct {
Share [64]byte // f_i(j) — Shamir share of party i's secret seed contribution at j
Blind [64]byte // g_i(j) — Pedersen blinding share at j
// KEMCiphertext is the ML-KEM-768 ciphertext (1088 bytes).
KEMCiphertext []byte
// Sealed is the AEAD-style sealed payload (128 bytes total).
Sealed []byte
}
// DKGRound2Msg is the broadcast emitted by DKGSession.Round2: the
+65
View File
@@ -0,0 +1,65 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// zeroize.go — best-effort secret-buffer zeroization.
//
// Threat model: a Pulsar process holding reconstructed secret
// material (e.g. the Combine aggregator's master seed) is at risk
// of coredump / /proc/self/mem / swap-file exfiltration if the
// secret is left live on the heap or stack after use. Go provides
// no native `runtime.Memzero` and the GC may copy buffers around;
// zeroize is a defense-in-depth measure, not a guarantee.
//
// We deliberately do NOT use `defer` for zeroize calls — the
// hot-path callers are short, and explicit zeroization at the
// return site keeps the secret-handling code path locally legible
// (per the project's "no defer for secret cleanup" convention).
//
// Use these helpers on every secret-bearing buffer right before
// returning from the function that allocated it.
// zeroizeBytes overwrites every byte of b with 0.
//
// The compiler is unable to elide this loop because b is a
// reference type; the loop visits the underlying backing array.
// On a future Go that recognises this pattern as dead-store, swap
// to `crypto/subtle.ConstantTimeCopy(0, b, zeros)` or `golang.org/
// x/crypto/internal/subtle` once one is exposed.
func zeroizeBytes(b []byte) {
for i := range b {
b[i] = 0
}
}
// zeroizeSeed overwrites a fixed-size [SeedSize]byte buffer.
// Separate from zeroizeBytes so the call site is self-documenting:
// "this is the master seed being wiped."
func zeroizeSeed(s *[SeedSize]byte) {
for i := range s {
s[i] = 0
}
}
// zeroizeU16 overwrites a fixed-size [SeedSize]uint16 buffer
// (e.g. the GF(257) byteSum carried by shamirReconstructGF before
// the cSHAKE256 mix).
func zeroizeU16(s *[SeedSize]uint16) {
for i := range s {
s[i] = 0
}
}
// zeroizePrivateKey wipes the secret-bearing fields of a
// PrivateKey (Bytes and Seed). Sets references to nil where
// possible so the underlying backing arrays can be GC'd; before
// they are, the bytes have been overwritten in place so a
// concurrent coredump captures zeros.
func zeroizePrivateKey(sk *PrivateKey) {
if sk == nil {
return
}
zeroizeBytes(sk.Bytes)
zeroizeSeed(&sk.Seed)
}