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