mirror of
https://github.com/luxfi/magnetar.git
synced 2026-07-27 02:53:47 +00:00
v0.4.3: slot-guard persists full PartialSignature; evidence verifiable post-restart
Red found a HIGH-severity defect in v0.4.2: NewGuard imported only the
message digest from AntiEquivState on restart, leaving the slot record's
Partial field zero-valued. Subsequent equivocation produced an Evidence
whose ShareA = PartialSignature{} — no MAC tags, no shares — which the
slashing layer could not cryptographically verify against DigestA.
Fix:
* StateStore wire shape is now slot -> SlotRecord{Digest, Partial}
(was slot -> [32]byte). NewGuard restores both fields; equivocation
evidence after a restart carries the original (third-party
verifiable) PartialSignature in ShareA.
* Snapshot/restore deep-copy the Partial so callers cannot alias the
runtime guard via the snapshot map.
* New PrivateShareGuard.LoadPartial(slot) accessor returns
(digest, partial, ok) for callers that want to inspect the trail
without provoking an equivocation.
* New VerifyEvidence(ev) in sign.go: pure third-party check that
ShareA/ShareB are well-formed, bound to ev.PartyID/SlotID, and that
every share proof tag is a valid cSHAKE MAC under
(PartyID, slot, DigestA|DigestB, share). Slashing layers consume
Evidence via this function.
* SlotRecord lives in thbs.go alongside StateStore; slot.go uses it
directly (no parallel unexported type).
* Disk-size cost: ~3 KiB per persisted slot at reference params
(n=24, WOTSChains=51, FORSK=14); cardinality is O(active slots per
party), acceptable vs the simpler-design + verifiability win.
Tests:
* slot_test.go: TestSlotGuard_PersistsPartial,
TestEquivocation_EvidenceAfterRestart,
TestEvidence_VerifiableByThirdParty (with tamper cases for ShareA
proof tag, DigestA, PartyID, and equal-digest non-equivocation),
TestSlotGuard_PersistsPartial_Idempotent.
* thbs_test.go: TestTHBS_RestoreGuardState now asserts ShareA is
fully populated (was a stale comment claiming zero-valued ShareA
was expected — that comment encoded the defect).
Invariants preserved:
* Selective-element-reveal: PartialSignature still carries only the
SELECTED shares; nothing in this patch widens that surface.
* Forbidden-symbols grep still returns zero (no exported
ReconstructSeed/ReconstructPrivateKey/ExpandPrivateKey/
DeriveAllFutureElements).
* API surface: DKG, SignShare, Aggregate, Verify unchanged.
Tests pass: go test -count=1 -short -timeout 300s ./ref/go/pkg/thbs/
This commit is contained in:
+81
-1
@@ -151,7 +151,10 @@ func SignShare(guard *PrivateShareGuard, slot Slot, msg []byte) (PartialSignatur
|
||||
|
||||
idempotent, prev, err := guard.state.checkAndRecord(slot, digest, partial)
|
||||
if err != nil {
|
||||
// Equivocation.
|
||||
// Equivocation. prev.Partial carries the fully-populated
|
||||
// PartialSignature originally emitted at this slot (restored
|
||||
// across guard lifecycles via SlotRecord persistence), so the
|
||||
// slashing layer can verify ShareA's MAC tags against DigestA.
|
||||
ev := Evidence{
|
||||
PartyID: share.PartyID,
|
||||
SlotID: slotIDArr,
|
||||
@@ -477,3 +480,80 @@ func NewGuardWithParams(share *PrivateShare, params HBSParams, evalPoint uint16)
|
||||
return g
|
||||
}
|
||||
|
||||
// VerifyEvidence is the third-party check that an Evidence payload
|
||||
// constitutes a verifiable equivocation by ev.PartyID at slot
|
||||
// ev.SlotID. It returns (true, nil) iff:
|
||||
//
|
||||
// - ev.SlotID encodes a valid Slot.
|
||||
// - DigestA != DigestB (otherwise there is no equivocation; the
|
||||
// party signed the SAME message twice, which is idempotent).
|
||||
// - ShareA.PartyID == ShareB.PartyID == ev.PartyID.
|
||||
// - ShareA.SlotID == ShareB.SlotID == ev.SlotID.
|
||||
// - ShareA.MessageDigest == DigestA and ShareB.MessageDigest == DigestB.
|
||||
// - Every share in ShareA.Shares has a matching proof whose tag is
|
||||
// bound to (ev.PartyID, slot, DigestA, share) via shareMACTag,
|
||||
// and likewise for ShareB.
|
||||
//
|
||||
// The check uses ONLY the data carried in Evidence — no access to
|
||||
// PublicKey, PrivateShare, or HelperData is required. This is the
|
||||
// property that makes Evidence the canonical slashable artifact:
|
||||
// every honest validator can independently confirm equivocation from
|
||||
// a single Evidence struct.
|
||||
//
|
||||
// VerifyEvidence does NOT consult an external party-allowlist; the
|
||||
// slashing layer is expected to confirm ev.PartyID is a member of the
|
||||
// committee for the appropriate epoch via its own state.
|
||||
func VerifyEvidence(ev Evidence) (bool, error) {
|
||||
slot, ok := slotFromID(ev.SlotID)
|
||||
if !ok {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if ev.DigestA == ev.DigestB {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if ev.ShareA.PartyID != ev.PartyID || ev.ShareB.PartyID != ev.PartyID {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if ev.ShareA.SlotID != ev.SlotID || ev.ShareB.SlotID != ev.SlotID {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if ev.ShareA.MessageDigest != ev.DigestA {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if ev.ShareB.MessageDigest != ev.DigestB {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if len(ev.ShareA.Shares) == 0 || len(ev.ShareB.Shares) == 0 {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if len(ev.ShareA.Shares) != len(ev.ShareA.Proofs) {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if len(ev.ShareB.Shares) != len(ev.ShareB.Proofs) {
|
||||
return false, ErrEvidenceMalformed
|
||||
}
|
||||
if !verifyShareMACs(ev.PartyID, slot, ev.DigestA, ev.ShareA) {
|
||||
return false, nil
|
||||
}
|
||||
if !verifyShareMACs(ev.PartyID, slot, ev.DigestB, ev.ShareB) {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// verifyShareMACs returns true iff every share in p has a proof
|
||||
// whose tag is the cSHAKE MAC over (party, slot, digest, share).
|
||||
func verifyShareMACs(party PartyID, slot Slot, digest [32]byte, p PartialSignature) bool {
|
||||
for _, s := range p.Shares {
|
||||
expect := shareMACTag(party, slot, digest, s)
|
||||
idx := indexOfShare(p, s)
|
||||
if idx < 0 || idx >= len(p.Proofs) {
|
||||
return false
|
||||
}
|
||||
if !ctEqualArr32(expect, p.Proofs[idx].Tag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+72
-22
@@ -20,26 +20,25 @@ import "sync"
|
||||
// output); if the same slot is requested with a DIFFERENT message
|
||||
// digest, the second call returns ErrEquivocation with verifiable
|
||||
// evidence the consensus layer can slash on.
|
||||
|
||||
// slotRecord is the per-slot record a party keeps.
|
||||
type slotRecord struct {
|
||||
Digest [32]byte
|
||||
// The previously-emitted PartialSignature so we can attach it as
|
||||
// evidence on a subsequent equivocation attempt.
|
||||
Partial PartialSignature
|
||||
}
|
||||
//
|
||||
// CRITICAL: the slot guard MUST persist the previously-emitted
|
||||
// PartialSignature (not just the digest) so that equivocation
|
||||
// evidence produced after a restart contains a fully-populated
|
||||
// ShareA. Restoring only the digest breaks third-party verification
|
||||
// of Evidence.ShareA (the share-MAC tags cannot be reconstructed
|
||||
// from a zero-valued PartialSignature). See VerifyEvidence.
|
||||
|
||||
// stateImpl is a concurrency-safe slot state machine. The exported
|
||||
// type StateStore (map alias) is the wire-shape; this is the runtime
|
||||
// guard.
|
||||
// guard. Both use SlotRecord{Digest, Partial} as the per-slot record.
|
||||
type stateImpl struct {
|
||||
mu sync.Mutex
|
||||
records map[Slot]slotRecord
|
||||
records map[Slot]SlotRecord
|
||||
}
|
||||
|
||||
// newStateImpl returns a fresh slot guard.
|
||||
func newStateImpl() *stateImpl {
|
||||
return &stateImpl{records: make(map[Slot]slotRecord)}
|
||||
return &stateImpl{records: make(map[Slot]SlotRecord)}
|
||||
}
|
||||
|
||||
// checkAndRecord enforces the slot invariant.
|
||||
@@ -51,8 +50,9 @@ func newStateImpl() *stateImpl {
|
||||
// (idempotent re-emit).
|
||||
// - Second call with mismatched digest: return (false, &prev,
|
||||
// ErrEquivocation). Caller wraps in EquivocationError with full
|
||||
// evidence.
|
||||
func (s *stateImpl) checkAndRecord(slot Slot, digest [32]byte, partial PartialSignature) (bool, *slotRecord, error) {
|
||||
// evidence; prev.Partial carries the original (third-party
|
||||
// verifiable) PartialSignature.
|
||||
func (s *stateImpl) checkAndRecord(slot Slot, digest [32]byte, partial PartialSignature) (bool, *SlotRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if prev, ok := s.records[slot]; ok {
|
||||
@@ -61,24 +61,55 @@ func (s *stateImpl) checkAndRecord(slot Slot, digest [32]byte, partial PartialSi
|
||||
}
|
||||
return false, &prev, ErrEquivocation
|
||||
}
|
||||
s.records[slot] = slotRecord{Digest: digest, Partial: partial}
|
||||
s.records[slot] = SlotRecord{Digest: digest, Partial: partial}
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the per-slot digest map. The
|
||||
// PartialSignature payload is intentionally omitted from the snapshot:
|
||||
// the StateStore wire shape is (slot -> digest), not full signature
|
||||
// material. The full record is held only in the runtime guard.
|
||||
// load returns the persisted SlotRecord for a slot, if any.
|
||||
func (s *stateImpl) load(slot Slot) (SlotRecord, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.records[slot]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// snapshot returns a defensive copy of the per-slot record map. The
|
||||
// returned StateStore is suitable for serialisation; restoring a guard
|
||||
// from it via NewGuard preserves the full (Digest, Partial) tuple so
|
||||
// post-restart equivocation evidence remains third-party verifiable.
|
||||
func (s *stateImpl) snapshot() StateStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(StateStore, len(s.records))
|
||||
for k, v := range s.records {
|
||||
out[k] = v.Digest
|
||||
// Defensive copy of share/proof slices so callers cannot mutate
|
||||
// the runtime guard via the snapshot.
|
||||
out[k] = SlotRecord{Digest: v.Digest, Partial: clonePartial(v.Partial)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clonePartial deep-copies a PartialSignature so a snapshot can be
|
||||
// safely mutated by callers without aliasing the runtime guard's
|
||||
// records.
|
||||
func clonePartial(p PartialSignature) PartialSignature {
|
||||
shares := make([]ElementShare, len(p.Shares))
|
||||
for i, s := range p.Shares {
|
||||
y := make([]uint16, len(s.Y))
|
||||
copy(y, s.Y)
|
||||
shares[i] = ElementShare{ID: s.ID, X: s.X, Y: y, Steps: s.Steps}
|
||||
}
|
||||
proofs := make([]ShareProof, len(p.Proofs))
|
||||
copy(proofs, p.Proofs)
|
||||
return PartialSignature{
|
||||
PartyID: p.PartyID,
|
||||
SlotID: p.SlotID,
|
||||
MessageDigest: p.MessageDigest,
|
||||
Shares: shares,
|
||||
Proofs: proofs,
|
||||
}
|
||||
}
|
||||
|
||||
// PrivateShareGuard couples a PrivateShare with the runtime slot
|
||||
// guard. SignShare runs against a guarded share.
|
||||
//
|
||||
@@ -97,16 +128,35 @@ type PrivateShareGuard struct {
|
||||
|
||||
// NewGuard wraps a PrivateShare with a fresh slot state machine. If
|
||||
// the PrivateShare already carries AntiEquivState (e.g. restored from
|
||||
// disk), the prior records are imported.
|
||||
// disk), the prior records (digest AND partial) are imported. The
|
||||
// imported Partial is what makes post-restart equivocation evidence
|
||||
// third-party verifiable.
|
||||
func NewGuard(share *PrivateShare) *PrivateShareGuard {
|
||||
s := newStateImpl()
|
||||
if share.AntiEquivState != nil {
|
||||
for slot, digest := range share.AntiEquivState {
|
||||
s.records[slot] = slotRecord{Digest: digest}
|
||||
for slot, rec := range share.AntiEquivState {
|
||||
s.records[slot] = SlotRecord{
|
||||
Digest: rec.Digest,
|
||||
Partial: clonePartial(rec.Partial),
|
||||
}
|
||||
}
|
||||
}
|
||||
return &PrivateShareGuard{Share: share, state: s}
|
||||
}
|
||||
|
||||
// Snapshot returns the StateStore wire shape suitable for serialisation.
|
||||
// The returned StateStore carries (Digest, Partial) per slot — both
|
||||
// fields are needed to preserve the equivocation evidence trail across
|
||||
// restarts.
|
||||
func (g *PrivateShareGuard) Snapshot() StateStore { return g.state.snapshot() }
|
||||
|
||||
// LoadPartial returns the persisted PartialSignature for a slot if the
|
||||
// guard has emitted one (or restored one from disk). Used by callers
|
||||
// that want to inspect the equivocation evidence trail directly.
|
||||
func (g *PrivateShareGuard) LoadPartial(slot Slot) (digest [32]byte, partial PartialSignature, ok bool) {
|
||||
r, found := g.state.load(slot)
|
||||
if !found {
|
||||
return [32]byte{}, PartialSignature{}, false
|
||||
}
|
||||
return r.Digest, clonePartial(r.Partial), true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package thbs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// slot_test.go — slot-guard persistence and equivocation-evidence
|
||||
// verifiability tests.
|
||||
//
|
||||
// The defect these tests pin (v0.4.2 -> v0.4.3): NewGuard previously
|
||||
// imported only Digest from AntiEquivState, leaving Partial
|
||||
// zero-valued on restore. Subsequent equivocation produced an
|
||||
// Evidence struct with ShareA = PartialSignature{} which a third
|
||||
// party could not cryptographically verify.
|
||||
|
||||
// TestSlotGuard_PersistsPartial: after a sign + restart cycle, the
|
||||
// guard's LoadPartial returns the originally-emitted PartialSignature
|
||||
// (not a zero value).
|
||||
func TestSlotGuard_PersistsPartial(t *testing.T) {
|
||||
pk, shares := runDealerDKG(t, 2, 3)
|
||||
g := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
const slot Slot = 2
|
||||
|
||||
msg := []byte("persisted-partial-msg")
|
||||
pA, err := SignShare(g, slot, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("SignShare: %v", err)
|
||||
}
|
||||
|
||||
// In-process recovery from the same guard.
|
||||
dPre, partialPre, ok := g.LoadPartial(slot)
|
||||
if !ok {
|
||||
t.Fatal("LoadPartial returned !ok before restart")
|
||||
}
|
||||
if dPre != pA.MessageDigest {
|
||||
t.Errorf("LoadPartial digest mismatch (pre-restart)")
|
||||
}
|
||||
if partialPre.PartyID != pA.PartyID {
|
||||
t.Error("LoadPartial Partial.PartyID is zero (pre-restart)")
|
||||
}
|
||||
if len(partialPre.Shares) != len(pA.Shares) {
|
||||
t.Errorf("LoadPartial Partial.Shares count %d, want %d (pre-restart)",
|
||||
len(partialPre.Shares), len(pA.Shares))
|
||||
}
|
||||
|
||||
// Persist and reload through StateStore — simulates a full restart
|
||||
// where the only thing that survives is the serialised AntiEquivState.
|
||||
shares[0].AntiEquivState = g.Snapshot()
|
||||
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
|
||||
dPost, partialPost, ok := g2.LoadPartial(slot)
|
||||
if !ok {
|
||||
t.Fatal("LoadPartial returned !ok after restart")
|
||||
}
|
||||
if dPost != pA.MessageDigest {
|
||||
t.Error("LoadPartial digest mismatch (post-restart)")
|
||||
}
|
||||
if partialPost.PartyID != pA.PartyID {
|
||||
t.Error("LoadPartial Partial.PartyID is zero (post-restart) — REGRESSION")
|
||||
}
|
||||
if partialPost.MessageDigest != pA.MessageDigest {
|
||||
t.Error("LoadPartial Partial.MessageDigest mismatch (post-restart)")
|
||||
}
|
||||
if partialPost.SlotID != pA.SlotID {
|
||||
t.Error("LoadPartial Partial.SlotID mismatch (post-restart)")
|
||||
}
|
||||
if len(partialPost.Shares) != len(pA.Shares) {
|
||||
t.Errorf("LoadPartial Partial.Shares count %d, want %d (post-restart) — REGRESSION",
|
||||
len(partialPost.Shares), len(pA.Shares))
|
||||
}
|
||||
if len(partialPost.Proofs) != len(pA.Proofs) {
|
||||
t.Errorf("LoadPartial Partial.Proofs count %d, want %d (post-restart) — REGRESSION",
|
||||
len(partialPost.Proofs), len(pA.Proofs))
|
||||
}
|
||||
|
||||
// Cross-check: every share/proof byte must match (deep equality).
|
||||
for i, s := range pA.Shares {
|
||||
if partialPost.Shares[i].ID != s.ID {
|
||||
t.Errorf("Shares[%d].ID mismatch", i)
|
||||
}
|
||||
if partialPost.Shares[i].X != s.X {
|
||||
t.Errorf("Shares[%d].X mismatch", i)
|
||||
}
|
||||
if partialPost.Shares[i].Steps != s.Steps {
|
||||
t.Errorf("Shares[%d].Steps mismatch", i)
|
||||
}
|
||||
if len(partialPost.Shares[i].Y) != len(s.Y) {
|
||||
t.Errorf("Shares[%d].Y length mismatch", i)
|
||||
continue
|
||||
}
|
||||
for k, y := range s.Y {
|
||||
if partialPost.Shares[i].Y[k] != y {
|
||||
t.Errorf("Shares[%d].Y[%d] mismatch", i, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, pr := range pA.Proofs {
|
||||
if partialPost.Proofs[i].Tag != pr.Tag {
|
||||
t.Errorf("Proofs[%d].Tag mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquivocation_EvidenceAfterRestart: after a sign + restart cycle,
|
||||
// signing a DIFFERENT message at the same slot yields
|
||||
// Evidence{ShareA, ShareB} where ShareA is the FULLY-POPULATED
|
||||
// pre-restart PartialSignature (this is the defect we fixed).
|
||||
func TestEquivocation_EvidenceAfterRestart(t *testing.T) {
|
||||
pk, shares := runDealerDKG(t, 2, 3)
|
||||
g := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
const slot Slot = 3
|
||||
|
||||
msgA := []byte("pre-restart-msg-A")
|
||||
pA, err := SignShare(g, slot, msgA)
|
||||
if err != nil {
|
||||
t.Fatalf("SignShare(A): %v", err)
|
||||
}
|
||||
|
||||
// Persist to AntiEquivState and rebuild a fresh guard from it
|
||||
// (simulating a node restart).
|
||||
shares[0].AntiEquivState = g.Snapshot()
|
||||
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
|
||||
// Now attempt to sign a DIFFERENT message at the same slot.
|
||||
msgB := []byte("post-restart-conflicting-msg-B")
|
||||
_, err = SignShare(g2, slot, msgB)
|
||||
if err == nil {
|
||||
t.Fatal("equivocation undetected after restart")
|
||||
}
|
||||
if !errors.Is(err, ErrEquivocation) {
|
||||
t.Fatalf("expected ErrEquivocation, got %v", err)
|
||||
}
|
||||
var eq *EquivocationError
|
||||
if !errors.As(err, &eq) {
|
||||
t.Fatal("expected EquivocationError")
|
||||
}
|
||||
ev := eq.Evidence
|
||||
|
||||
// SlotID + DigestA agreement with pre-restart signature.
|
||||
if ev.SlotID != pA.SlotID {
|
||||
t.Error("Evidence.SlotID does not match pre-restart partial")
|
||||
}
|
||||
if ev.DigestA != pA.MessageDigest {
|
||||
t.Error("Evidence.DigestA does not match pre-restart message digest")
|
||||
}
|
||||
digestB := messageDigest(msgB, slotIDBytes(slot))
|
||||
if ev.DigestB != digestB {
|
||||
t.Error("Evidence.DigestB does not match post-restart message digest")
|
||||
}
|
||||
if ev.DigestA == ev.DigestB {
|
||||
t.Fatal("DigestA == DigestB; no equivocation")
|
||||
}
|
||||
|
||||
// ShareA MUST be the FULL pre-restart PartialSignature — this is
|
||||
// what the v0.4.2 defect broke.
|
||||
if ev.ShareA.PartyID != pA.PartyID {
|
||||
t.Error("Evidence.ShareA.PartyID is zero — REGRESSION (v0.4.2 defect)")
|
||||
}
|
||||
if ev.ShareA.MessageDigest != pA.MessageDigest {
|
||||
t.Error("Evidence.ShareA.MessageDigest is zero — REGRESSION (v0.4.2 defect)")
|
||||
}
|
||||
if ev.ShareA.SlotID != pA.SlotID {
|
||||
t.Error("Evidence.ShareA.SlotID is zero — REGRESSION")
|
||||
}
|
||||
if len(ev.ShareA.Shares) != len(pA.Shares) {
|
||||
t.Errorf("Evidence.ShareA.Shares count %d, want %d — REGRESSION",
|
||||
len(ev.ShareA.Shares), len(pA.Shares))
|
||||
}
|
||||
if len(ev.ShareA.Proofs) != len(pA.Proofs) {
|
||||
t.Errorf("Evidence.ShareA.Proofs count %d, want %d — REGRESSION",
|
||||
len(ev.ShareA.Proofs), len(pA.Proofs))
|
||||
}
|
||||
// Tag-level deep equality on each proof.
|
||||
for i, p := range pA.Proofs {
|
||||
if ev.ShareA.Proofs[i].Tag != p.Tag {
|
||||
t.Errorf("Evidence.ShareA.Proofs[%d].Tag mismatch", i)
|
||||
}
|
||||
}
|
||||
|
||||
// ShareB MUST also be fully populated (computed in this process).
|
||||
if ev.ShareB.MessageDigest != digestB {
|
||||
t.Error("Evidence.ShareB.MessageDigest mismatch")
|
||||
}
|
||||
if len(ev.ShareB.Shares) == 0 {
|
||||
t.Error("Evidence.ShareB.Shares empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvidence_VerifiableByThirdParty: an Evidence emitted across a
|
||||
// restart passes VerifyEvidence — i.e. the slashing layer can confirm
|
||||
// equivocation using ONLY the Evidence struct (no access to the
|
||||
// runtime guard, the dealer, or the public key required).
|
||||
func TestEvidence_VerifiableByThirdParty(t *testing.T) {
|
||||
pk, shares := runDealerDKG(t, 2, 3)
|
||||
g := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
const slot Slot = 4
|
||||
|
||||
// First message, signed pre-restart.
|
||||
if _, err := SignShare(g, slot, []byte("evidence-msg-A")); err != nil {
|
||||
t.Fatalf("SignShare(A): %v", err)
|
||||
}
|
||||
|
||||
// Persist and restart.
|
||||
shares[0].AntiEquivState = g.Snapshot()
|
||||
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
|
||||
// Trigger equivocation.
|
||||
_, err := SignShare(g2, slot, []byte("evidence-msg-B"))
|
||||
if err == nil {
|
||||
t.Fatal("equivocation undetected")
|
||||
}
|
||||
var eq *EquivocationError
|
||||
if !errors.As(err, &eq) {
|
||||
t.Fatalf("expected EquivocationError, got %v", err)
|
||||
}
|
||||
|
||||
// Third-party verification: a fresh process with no access to g/g2
|
||||
// confirms the Evidence.
|
||||
ok, verr := VerifyEvidence(eq.Evidence)
|
||||
if verr != nil {
|
||||
t.Fatalf("VerifyEvidence returned error: %v", verr)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("VerifyEvidence rejected a true equivocation — REGRESSION")
|
||||
}
|
||||
|
||||
// Tampering with a single proof tag in ShareA must invalidate the
|
||||
// evidence (the slashing layer cannot be fooled by a forged
|
||||
// pre-restart partial).
|
||||
tampered := eq.Evidence
|
||||
tampered.ShareA.Proofs = append([]ShareProof(nil), eq.Evidence.ShareA.Proofs...)
|
||||
tampered.ShareA.Proofs[0].Tag[0] ^= 0xFF
|
||||
if ok, _ := VerifyEvidence(tampered); ok {
|
||||
t.Fatal("VerifyEvidence accepted tampered ShareA proof tag")
|
||||
}
|
||||
|
||||
// Tampering with the recorded DigestA breaks the MAC check.
|
||||
tampered2 := eq.Evidence
|
||||
tampered2.DigestA[0] ^= 0x01
|
||||
if ok, _ := VerifyEvidence(tampered2); ok {
|
||||
t.Fatal("VerifyEvidence accepted mismatched DigestA")
|
||||
}
|
||||
|
||||
// Replacing PartyID breaks evidence — the slashing layer must not
|
||||
// accept evidence claiming a different party signed.
|
||||
tampered3 := eq.Evidence
|
||||
tampered3.PartyID = PartyID{} // a fresh zero PartyID
|
||||
if ok, _ := VerifyEvidence(tampered3); ok {
|
||||
t.Fatal("VerifyEvidence accepted forged PartyID")
|
||||
}
|
||||
|
||||
// Equal digests = not equivocation.
|
||||
tampered4 := eq.Evidence
|
||||
tampered4.DigestB = tampered4.DigestA
|
||||
if ok, _ := VerifyEvidence(tampered4); ok {
|
||||
t.Fatal("VerifyEvidence accepted DigestA == DigestB")
|
||||
}
|
||||
|
||||
// Sanity: still pin pk in scope so future refactors don't drop it.
|
||||
_ = pk
|
||||
}
|
||||
|
||||
// TestSlotGuard_PersistsPartial_Idempotent: a same-message replay after
|
||||
// restart returns the originally-emitted PartialSignature byte-for-byte.
|
||||
// This is the idempotency invariant that consensus replays rely on.
|
||||
func TestSlotGuard_PersistsPartial_Idempotent(t *testing.T) {
|
||||
pk, shares := runDealerDKG(t, 2, 3)
|
||||
g := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
const slot Slot = 5
|
||||
|
||||
msg := []byte("idempotent-replay")
|
||||
pPre, err := SignShare(g, slot, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("SignShare(pre): %v", err)
|
||||
}
|
||||
|
||||
shares[0].AntiEquivState = g.Snapshot()
|
||||
g2 := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
|
||||
pPost, err := SignShare(g2, slot, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("SignShare(post-restart, same msg): %v", err)
|
||||
}
|
||||
if pPost.MessageDigest != pPre.MessageDigest {
|
||||
t.Error("idempotent replay returned different digest")
|
||||
}
|
||||
if pPost.SlotID != pPre.SlotID {
|
||||
t.Error("idempotent replay returned different SlotID")
|
||||
}
|
||||
if len(pPost.Shares) != len(pPre.Shares) {
|
||||
t.Errorf("idempotent replay share count %d, want %d",
|
||||
len(pPost.Shares), len(pPre.Shares))
|
||||
}
|
||||
if len(pPost.Proofs) != len(pPre.Proofs) {
|
||||
t.Errorf("idempotent replay proof count %d, want %d",
|
||||
len(pPost.Proofs), len(pPre.Proofs))
|
||||
}
|
||||
for i, pr := range pPre.Proofs {
|
||||
if pPost.Proofs[i].Tag != pr.Tag {
|
||||
t.Errorf("idempotent replay Proofs[%d].Tag mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-5
@@ -255,9 +255,35 @@ type ElementID struct {
|
||||
// implementation in dealer.go.
|
||||
type ShareStore map[ElementID][]uint16
|
||||
|
||||
// StateStore is a slot -> message-digest map. Concrete implementation
|
||||
// is in slot.go (defensive copy via slotStateImpl).
|
||||
type StateStore map[Slot][32]byte
|
||||
// SlotRecord is the persisted per-slot record a party keeps for
|
||||
// equivocation defense. It binds the slot to BOTH the message digest
|
||||
// AND the fully-populated PartialSignature that was emitted at that
|
||||
// slot.
|
||||
//
|
||||
// Persisting the full Partial (not just the digest) is REQUIRED so
|
||||
// that on subsequent equivocation a third party can verify the
|
||||
// previously-emitted share's MAC tags against DigestA. A digest-only
|
||||
// store breaks the evidence chain: the slashing layer would receive
|
||||
// an empty PartialSignature{} for ShareA and could not cryptographically
|
||||
// validate that the prior party committed to DigestA.
|
||||
//
|
||||
// Wire shape on disk: (Digest [32]byte, Partial PartialSignature).
|
||||
// The Partial carries PartyID, SlotID, MessageDigest (= Digest),
|
||||
// Shares (~params.WOTSChains+FORSK entries), and Proofs (matching).
|
||||
// For the reference params (n=24, WOTSChains=51, FORSK=14) the
|
||||
// per-slot record is on the order of ~3 KiB — acceptable for the
|
||||
// O(active-slots-per-party) cardinality we expect (at most one
|
||||
// signed slot per consensus round per party).
|
||||
type SlotRecord struct {
|
||||
Digest [32]byte
|
||||
Partial PartialSignature
|
||||
}
|
||||
|
||||
// StateStore is the wire shape for the anti-equivocation log:
|
||||
// slot -> SlotRecord{Digest, Partial}. Restored guards reconstruct
|
||||
// the runtime slot guard from this map; the persisted Partial is what
|
||||
// makes equivocation evidence third-party verifiable across restarts.
|
||||
type StateStore map[Slot]SlotRecord
|
||||
|
||||
// Slot identifies a one-shot WOTS+ leaf slot (the equivalent of an
|
||||
// XMSS leaf in HBS). Same slot used twice with distinct messages =
|
||||
@@ -333,10 +359,21 @@ var (
|
||||
ErrFORSPub = errors.New("thbs: reconstructed FORS root mismatch")
|
||||
ErrVerifyRoot = errors.New("thbs: signature does not chain to public-key root")
|
||||
ErrEquivocation = errors.New("thbs: party signed two distinct messages under same slot")
|
||||
ErrEvidenceMalformed = errors.New("thbs: equivocation evidence is malformed")
|
||||
)
|
||||
|
||||
// Evidence carries proof of identifiable double-signing. The
|
||||
// consensus layer slashes the offending party using this payload.
|
||||
// Evidence carries proof of identifiable double-signing. The consensus
|
||||
// layer slashes the offending party using this payload.
|
||||
//
|
||||
// Evidence is the slashable artifact. To be third-party verifiable BOTH
|
||||
// ShareA and ShareB MUST be fully-populated PartialSignatures (with
|
||||
// PartyID, SlotID, MessageDigest, Shares, and Proofs). The verifier
|
||||
// re-derives the share-MAC tag for each entry under (PartyID, slot,
|
||||
// DigestA / DigestB) and confirms it matches the proof. A
|
||||
// zero-valued PartialSignature in ShareA is NOT verifiable — the
|
||||
// runtime slot guard MUST persist the full prior Partial across
|
||||
// restarts to preserve this property. See VerifyEvidence for the
|
||||
// canonical third-party check.
|
||||
type Evidence struct {
|
||||
PartyID PartyID
|
||||
SlotID [32]byte
|
||||
|
||||
@@ -762,13 +762,17 @@ func TestTHBS_StateStore_Snapshot(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestTHBS_RestoreGuardState ensures a restored guard preserves the
|
||||
// anti-equivocation evidence trail.
|
||||
// anti-equivocation evidence trail INCLUDING the previously-emitted
|
||||
// PartialSignature, so post-restart equivocation evidence is
|
||||
// third-party verifiable. (See also TestEquivocation_EvidenceAfterRestart
|
||||
// and TestEvidence_VerifiableByThirdParty in slot_test.go.)
|
||||
func TestTHBS_RestoreGuardState(t *testing.T) {
|
||||
pk, shares := runDealerDKG(t, 2, 3)
|
||||
g := NewGuardWithParams(&shares[0], pk.Params, 1)
|
||||
const slot Slot = 0
|
||||
msgA := []byte("first")
|
||||
if _, err := SignShare(g, slot, msgA); err != nil {
|
||||
pA, err := SignShare(g, slot, msgA)
|
||||
if err != nil {
|
||||
t.Fatalf("SignShare: %v", err)
|
||||
}
|
||||
// Persist and restore.
|
||||
@@ -778,18 +782,19 @@ func TestTHBS_RestoreGuardState(t *testing.T) {
|
||||
if _, err := SignShare(g2, slot, msgA); err != nil {
|
||||
t.Fatalf("SignShare (restored, same msg): %v", err)
|
||||
}
|
||||
// Different msg = equivocation. NOTE: the previously emitted
|
||||
// share/partial is NOT in the restored guard (we only persist the
|
||||
// digest), so Evidence.ShareA carries a zero PartialSignature. The
|
||||
// digest field IS recovered.
|
||||
_, err := SignShare(g2, slot, []byte("second"))
|
||||
// Different msg = equivocation. After v0.4.3 we persist the full
|
||||
// PartialSignature in SlotRecord, so Evidence.ShareA carries the
|
||||
// original (fully-populated, MAC-verifiable) PartialSignature even
|
||||
// across guard restarts.
|
||||
_, err = SignShare(g2, slot, []byte("second"))
|
||||
if err == nil {
|
||||
t.Fatal("restored guard did not detect equivocation")
|
||||
}
|
||||
if !errors.Is(err, ErrEquivocation) {
|
||||
t.Fatalf("expected ErrEquivocation, got %v", err)
|
||||
}
|
||||
// Verify the restored guard reports the correct DigestA.
|
||||
// Verify the restored guard reports the correct DigestA AND the
|
||||
// fully-populated ShareA.
|
||||
var eq *EquivocationError
|
||||
if !errors.As(err, &eq) {
|
||||
t.Fatal("expected EquivocationError")
|
||||
@@ -797,6 +802,20 @@ func TestTHBS_RestoreGuardState(t *testing.T) {
|
||||
if eq.Evidence.DigestA != messageDigest(msgA, slotIDBytes(slot)) {
|
||||
t.Error("DigestA mismatch on restored guard")
|
||||
}
|
||||
if eq.Evidence.ShareA.PartyID != pA.PartyID {
|
||||
t.Error("Evidence.ShareA.PartyID is zero after restart (regression)")
|
||||
}
|
||||
if eq.Evidence.ShareA.MessageDigest != pA.MessageDigest {
|
||||
t.Error("Evidence.ShareA.MessageDigest is zero after restart (regression)")
|
||||
}
|
||||
if len(eq.Evidence.ShareA.Shares) != len(pA.Shares) {
|
||||
t.Errorf("Evidence.ShareA.Shares count %d, want %d (regression)",
|
||||
len(eq.Evidence.ShareA.Shares), len(pA.Shares))
|
||||
}
|
||||
if len(eq.Evidence.ShareA.Proofs) != len(pA.Proofs) {
|
||||
t.Errorf("Evidence.ShareA.Proofs count %d, want %d (regression)",
|
||||
len(eq.Evidence.ShareA.Proofs), len(pA.Proofs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTHBS_ParamsBytes pins the params encoding so that a transcript
|
||||
|
||||
Reference in New Issue
Block a user