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:
Hanzo AI
2026-05-21 18:22:04 -07:00
parent 3951c5a46c
commit 5a13eda0e9
5 changed files with 530 additions and 36 deletions
+81 -1
View File
@@ -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
View File
@@ -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
}
+308
View File
@@ -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
View File
@@ -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
+27 -8
View File
@@ -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