pulsar: nonce-ticket lifecycle + authenticated-message equivocation (item 5a)

- NonceTicket (unique TicketID = H(committeeID||w1||binding) audit handle) +
  ReserveNonceTicket: the ONE nonce-consume path. Dedup keys on MATERIAL
  (committeeID||w1), NOT TicketID, so relabel/different-message reuse of the same
  joint nonce is still ErrNonceReused. Round2 refactored to reserve via the
  ticket. Lifecycle test: unique ids, single-use, domain-bound, abort-doesn't-leak.
- protocol_auth.go: canonical to-be-signed encoding for protocol messages bound
  to (epoch,session,nonce,round); pluggable identity verifier (reuses the
  AbortSignatureVerifier shape, DRY); DetectEquivocation -> two validly-signed
  conflicting same-slot messages from one author yield ComplaintEquivocation
  evidence. Networked transport (authenticated channels) is the consensus-layer
  residual; this is the crypto primitive + detector.

Full suite green.
This commit is contained in:
2026-06-27 21:19:02 -07:00
parent ee398080f6
commit d2b4c1eb62
4 changed files with 374 additions and 11 deletions
+12 -11
View File
@@ -574,22 +574,23 @@ func (d *DistributedBCCSigner) Round2(r1 SignRound1, in PartialInput) (Partial,
return Partial{}, ErrNonceLedgerNil
}
// NONCE SINGLE-USE GUARD (RED nonce-reuse, HIGH). Reserve the nonce
// commitment BEFORE the secret is touched. A second use of the same joint
// nonce — even relabeled under a fresh nonceID — is refused FAIL-CLOSED, so
// the attacker can never obtain a second z-partial on the same nonce and the
// (c_A − c_B)·s1 key-recovery system can never be assembled. The reservation
// is never rolled back: if proof generation below fails, the nonce stays
// burned (an aborted attempt leaves no reusable nonce state).
key := nonceMaterialKey(d.committeeID, d.w1Packed)
binding := NonceBinding{
// NONCE SINGLE-USE GUARD (RED nonce-reuse, HIGH). Mint the nonce ticket and
// reserve its MATERIAL key BEFORE the secret is touched. A second use of the
// same joint nonce — even relabeled under a fresh nonceID or a different
// message — is refused FAIL-CLOSED (dedup keys on committeeID‖w1, not the
// ticket id), so the attacker can never obtain a second z-partial on the
// same nonce and the (c_A − c_B)·s1 key-recovery system can never be
// assembled. The reservation is never rolled back: if proof generation below
// fails, the nonce stays burned (an aborted attempt leaves no reusable nonce
// state). ReserveNonceTicket is the one and only nonce-consume path.
ticket := NewNonceTicket(d.committeeID, d.w1Packed, NonceBinding{
Epoch: d.epoch,
CommitteeID: d.committeeID,
Policy: d.policy,
MessageKind: d.messageKind,
Digest: nonceBindingDigest(d.params.Mode, d.setup.tr, d.ctx, d.msg),
}
if err := d.ledger.Reserve(key, binding); err != nil {
})
if err := ReserveNonceTicket(d.ledger, ticket); err != nil {
return Partial{}, err
}
@@ -0,0 +1,159 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// malicious_hardening_test.go — item 5 achievable subset: nonce-ticket
// lifecycle, authenticated-message equivocation detection. (The capstone
// "bad behavior ⇒ abort/blame, never forge/leak" lives in
// malicious_capstone_test.go.)
import (
"bytes"
"testing"
"golang.org/x/crypto/sha3"
)
// ── nonce-ticket lifecycle ────────────────────────────────────────────────
func TestNonceTicketLifecycle(t *testing.T) {
var committeeID [32]byte
committeeID[0] = 0x11
w1 := []byte("packed-w1-nonce-commitment-AAAA")
digestA := sha3Sum32([]byte("message A"))
digestB := sha3Sum32([]byte("message B"))
bindA := NonceBinding{Epoch: 7, CommitteeID: committeeID, MessageKind: 1, Digest: digestA}
bindB := NonceBinding{Epoch: 7, CommitteeID: committeeID, MessageKind: 1, Digest: digestB}
// (1) UNIQUE ticket_id per (nonce, binding): same nonce material, different
// message ⇒ different TicketID (audit handle) …
tkA := NewNonceTicket(committeeID, w1, bindA)
tkB := NewNonceTicket(committeeID, w1, bindB)
if tkA.TicketID == tkB.TicketID {
t.Fatalf("ticket ids collided for different bindings")
}
// … but the SAME MaterialKey (single-use dedup is on the nonce, not the label).
if tkA.MaterialKey() != tkB.MaterialKey() {
t.Fatalf("material keys differ for the same nonce material — dedup would be bypassable")
}
// (2) ONE-USE + DOMAIN-BOUND: first reserve OK; the same nonce material under
// a DIFFERENT message/ticket is rejected (the nonce is burned).
led := NewInMemoryNonceLedger()
if err := ReserveNonceTicket(led, tkA); err != nil {
t.Fatalf("first ticket reserve must succeed: %v", err)
}
if err := ReserveNonceTicket(led, tkB); err != ErrNonceReused {
t.Fatalf("reusing the same nonce material (msg B) returned %v, want ErrNonceReused", err)
}
// A relabeled ticket (new TicketID via different binding, same w1) is also rejected.
tkRelabel := NewNonceTicket(committeeID, w1, NonceBinding{Epoch: 99, CommitteeID: committeeID, MessageKind: 5, Digest: digestA})
if err := ReserveNonceTicket(led, tkRelabel); err != ErrNonceReused {
t.Fatalf("relabeled reuse returned %v, want ErrNonceReused", err)
}
// (3) DOMAIN binding recorded; CheckBinding tripwire fires on a different
// binding for the SAME material key.
if reserved, err := led.CheckBinding(tkA.MaterialKey(), bindA); !reserved || err != nil {
t.Fatalf("CheckBinding(original)=%v,%v want true,nil", reserved, err)
}
if reserved, err := led.CheckBinding(tkA.MaterialKey(), bindB); !reserved || err != ErrNonceBindingMismatch {
t.Fatalf("CheckBinding(other binding)=%v,%v want true,ErrNonceBindingMismatch", reserved, err)
}
// (4) ABORT DOESN'T LEAK reusable state: a FRESH nonce reserved then "aborted"
// (we simulate the signer failing after reserve) stays burned — a second
// attempt on that nonce is refused, never re-openable.
w1b := []byte("packed-w1-nonce-commitment-BBBB")
tkFresh := NewNonceTicket(committeeID, w1b, NonceBinding{Epoch: 7, CommitteeID: committeeID, Digest: digestA})
if err := ReserveNonceTicket(led, tkFresh); err != nil {
t.Fatalf("fresh reserve: %v", err)
}
// (signer would now abort, e.g. proof failure — no rollback happens)
if err := ReserveNonceTicket(led, tkFresh); err != ErrNonceReused {
t.Fatalf("post-abort reuse returned %v, want ErrNonceReused (aborted attempt leaked reusable nonce state!)", err)
}
t.Logf("nonce-ticket lifecycle PASS: unique ticket ids, single-use on material, domain-bound, abort doesn't leak reusable state")
}
func sha3Sum32(b []byte) [32]byte {
h := sha3.NewShake256()
_, _ = h.Write(b)
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// ── authenticated messages + equivocation detection ───────────────────────
// stubIdentity is a test identity layer: sign = SHAKE256("k"‖author‖tbs)[:64].
type stubIdentity struct{}
func (stubIdentity) sign(author NodeID, tbs []byte) []byte {
h := sha3.NewShake256()
_, _ = h.Write([]byte("stub-identity-key"))
_, _ = h.Write(author[:])
_, _ = h.Write(tbs)
out := make([]byte, 64)
_, _ = h.Read(out)
return out
}
func (s stubIdentity) VerifyAbortSignature(author NodeID, transcript, signature []byte) bool {
return bytes.Equal(signature, s.sign(author, transcript))
}
func TestSignedProtocolMessage_Equivocation(t *testing.T) {
id := stubIdentity{}
var author, observer NodeID
author[0] = 0xAA
observer[0] = 0xBB
ctx := ProtocolContext{Epoch: 3, Round: ProtocolRoundPartial}
ctx.SessionID[0] = 0x01
mk := func(payload string) SignedProtocolMessage {
dig := sha3Sum32([]byte(payload))
m := SignedProtocolMessage{Author: author, Context: ctx, PayloadDigest: dig}
m.Signature = id.sign(author, ProtocolMessageTBS(author, ctx, dig))
return m
}
msgX := mk("z-partial X")
msgY := mk("z-partial Y (conflicting, same slot)")
// Both messages verify under the identity layer.
if !VerifySignedProtocolMessage(&msgX, id) || !VerifySignedProtocolMessage(&msgY, id) {
t.Fatal("signed messages failed verification")
}
// Two distinct payloads, same slot, both signed ⇒ provable equivocation.
ev, ok := DetectEquivocation(observer, msgX, msgY, ctx.Epoch, id)
if !ok {
t.Fatal("equivocation not detected on two conflicting signed messages")
}
if ev.Kind != ComplaintEquivocation || ev.Accused != author || ev.Accuser != observer {
t.Fatalf("equivocation evidence fields wrong: kind=%v accused=%x accuser=%x", ev.Kind, ev.Accused[:4], ev.Accuser[:4])
}
if err := ValidateAbortEvidence(ev); err != nil {
t.Fatalf("equivocation AbortEvidence malformed: %v", err)
}
// NON-equivocation: identical payload ⇒ no conflict.
if _, ok := DetectEquivocation(observer, msgX, msgX, ctx.Epoch, id); ok {
t.Fatal("identical payloads wrongly flagged as equivocation")
}
// Different slot ⇒ not equivocation.
other := msgY
other.Context.Round = ProtocolRoundFinal
other.Signature = id.sign(author, ProtocolMessageTBS(author, other.Context, other.PayloadDigest))
if _, ok := DetectEquivocation(observer, msgX, other, ctx.Epoch, id); ok {
t.Fatal("different-slot messages wrongly flagged as equivocation")
}
// Forged (bad signature) ⇒ not provable equivocation.
forged := msgY
forged.Signature = []byte("not-a-valid-signature-................................................")
if _, ok := DetectEquivocation(observer, msgX, forged, ctx.Epoch, id); ok {
t.Fatal("a message with an invalid signature must not yield equivocation evidence")
}
t.Logf("equivocation PASS: two validly-signed conflicting same-slot messages ⇒ ComplaintEquivocation; non-conflicts and forgeries rejected")
}
+60
View File
@@ -49,6 +49,7 @@ package pulsar
// re-INVOKING Round2 for the same nonce is correctly refused.
import (
"encoding/binary"
"errors"
"sync"
@@ -150,6 +151,65 @@ func (l *InMemoryNonceLedger) CheckBinding(key [32]byte, binding NonceBinding) (
return true, nil
}
// NonceTicket is the public, auditable handle for one nonce's single use. The
// TicketID is a UNIQUE label (it folds in the binding, so two tickets minted
// for the same nonce material but different messages have DIFFERENT TicketIDs);
// the SINGLE-USE dedup, however, keys on the nonce MATERIAL (committeeID‖W1) via
// MaterialKey — NOT on TicketID — so a relabeled or different-message reuse of
// the SAME joint nonce is still rejected. This separation is deliberate:
// TicketID is for logs/correlation, MaterialKey is for security.
type NonceTicket struct {
TicketID [32]byte
CommitteeID [32]byte
W1 []byte // packed nonce commitment (the single-use material)
Binding NonceBinding
}
// encodeBinding is the canonical byte encoding of a NonceBinding (for hashing).
func encodeBinding(b NonceBinding) []byte {
out := make([]byte, 0, 8+32+32+4+32)
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], b.Epoch)
out = append(out, u8[:]...)
out = append(out, b.CommitteeID[:]...)
out = append(out, b.Policy[:]...)
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], b.MessageKind)
out = append(out, u4[:]...)
out = append(out, b.Digest[:]...)
return out
}
// NewNonceTicket mints a ticket for a nonce commitment + binding. TicketID =
// SHAKE256(domain ‖ committeeID ‖ w1 ‖ encode(binding)) — unique per
// (nonce, binding); MaterialKey (the dedup key) ignores the binding.
func NewNonceTicket(committeeID [32]byte, w1 []byte, binding NonceBinding) NonceTicket {
h := sha3.NewShake256()
_, _ = h.Write([]byte("PULSAR/nonce-ticket-id/v1"))
_, _ = h.Write(committeeID[:])
_, _ = h.Write(w1)
_, _ = h.Write(encodeBinding(binding))
var id [32]byte
_, _ = h.Read(id[:])
return NonceTicket{TicketID: id, CommitteeID: committeeID, W1: append([]byte(nil), w1...), Binding: binding}
}
// MaterialKey is the single-use dedup key for the ticket's nonce material.
func (tk NonceTicket) MaterialKey() [32]byte {
return nonceMaterialKey(tk.CommitteeID, tk.W1)
}
// ReserveNonceTicket is the ONE way a nonce is consumed: it reserves the
// ticket's MATERIAL key in the ledger (single-use, fail-closed). On reuse of
// the same nonce material — under any TicketID or message — it returns
// ErrNonceReused and the secret is never emitted.
func ReserveNonceTicket(l NonceLedger, tk NonceTicket) error {
if l == nil {
return ErrNonceLedgerNil
}
return l.Reserve(tk.MaterialKey(), tk.Binding)
}
// nonceMaterialKey derives the single-use dedup key from the committee identity
// and the nonce commitment w1 (the packed HighBits(w) carried on the public
// NonceCert). Keying on w1 — NOT on the nonceID label — defeats the relabel
+143
View File
@@ -0,0 +1,143 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// protocol_auth.go — authenticated protocol messages + equivocation detection
// (item 5: authenticated channels / signed protocol messages, equivocation).
//
// SCOPE. This is the CRYPTO primitive: a canonical to-be-signed encoding for a
// protocol message bound to its (epoch, session, round) slot, a pluggable
// identity verifier (the same AbortSignatureVerifier shape — one identity layer,
// DRY), and a SOUND equivocation detector: two validly-signed messages from the
// SAME author for the SAME slot with DIFFERENT payloads is provable misbehavior
// and yields a ComplaintEquivocation AbortEvidence. The NETWORKED transport that
// collects these messages (authenticated channels over the wire) is owned by the
// consensus layer (round.go) and is a flagged residual — this package supplies
// the signable bytes and the detector, not the sockets.
import "bytes"
// ProtocolRound enumerates the signable protocol message slots. Equivocation is
// defined per (Author, Epoch, SessionID, Round): one author may sign at most one
// distinct payload per slot.
type ProtocolRound uint8
const (
ProtocolRoundNonce ProtocolRound = iota + 1 // Round1 nonce binding
ProtocolRoundPartial // Round2 z-partial
ProtocolRoundFinal // Finalize / aggregate
)
// ProtocolContext is the slot a protocol message is bound to.
type ProtocolContext struct {
Epoch uint64
SessionID [32]byte
NonceID [32]byte
Round ProtocolRound
}
// SignedProtocolMessage is an authenticated protocol message: the author's
// identity-key signature over the canonical to-be-signed bytes of (context,
// payload digest). PayloadDigest is a 32-byte digest of the message body
// (e.g. packed z-partial, nonce cert) so equivocation comparison is constant-size.
type SignedProtocolMessage struct {
Author NodeID
Context ProtocolContext
PayloadDigest [32]byte
Signature []byte
}
// ProtocolMessageTBS is the canonical to-be-signed byte string for a protocol
// message: SP 800-185 framed (domain ‖ author ‖ epoch ‖ session ‖ nonce ‖ round
// ‖ payload-digest). The author's identity key signs THESE bytes; a verifier
// re-derives them and checks the signature. There is exactly one encoding.
func ProtocolMessageTBS(author NodeID, ctx ProtocolContext, payloadDigest [32]byte) []byte {
parts := [][]byte{
[]byte("PULSAR/protocol-msg/v1"),
author[:],
u64be(ctx.Epoch),
ctx.SessionID[:],
ctx.NonceID[:],
{byte(ctx.Round)},
payloadDigest[:],
}
out := append([]byte{}, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
out = append(out, encodeString(p)...)
}
return out
}
// u64be is the 8-byte big-endian encoding of x (helper for the TBS framing).
func u64be(x uint64) []byte {
return []byte{
byte(x >> 56), byte(x >> 48), byte(x >> 40), byte(x >> 32),
byte(x >> 24), byte(x >> 16), byte(x >> 8), byte(x),
}
}
// VerifySignedProtocolMessage checks the author's identity-key signature over
// the canonical TBS bytes via the supplied verifier. A nil verifier is rejected
// (no implicit skip-verification path — fail closed).
func VerifySignedProtocolMessage(m *SignedProtocolMessage, v AbortSignatureVerifier) bool {
if m == nil || v == nil || len(m.Signature) == 0 {
return false
}
return v.VerifyAbortSignature(m.Author, ProtocolMessageTBS(m.Author, m.Context, m.PayloadDigest), m.Signature)
}
// DetectEquivocation returns a ComplaintEquivocation AbortEvidence iff a and b
// are two DISTINCT-payload messages from the SAME author for the SAME slot, both
// carrying a VALID identity signature. This is sound non-repudiable misbehavior:
// the two signatures prove the author committed to two payloads in one slot.
// accuser is the observer raising the complaint. Returns (nil, false) when the
// pair is not equivocation (different slot/author, equal payload, or either
// signature invalid).
func DetectEquivocation(accuser NodeID, a, b SignedProtocolMessage, epoch uint64, v AbortSignatureVerifier) (*AbortEvidence, bool) {
if v == nil {
return nil, false
}
if a.Author != b.Author || a.Context != b.Context {
return nil, false
}
if a.PayloadDigest == b.PayloadDigest {
return nil, false // same payload — no conflict
}
if accuser == a.Author {
return nil, false // never self-accuse
}
if !VerifySignedProtocolMessage(&a, v) || !VerifySignedProtocolMessage(&b, v) {
return nil, false // unsigned/forged — not provable equivocation
}
// Order the two commits canonically so the evidence is deterministic.
c1, c2 := a, b
if bytes.Compare(a.PayloadDigest[:], b.PayloadDigest[:]) > 0 {
c1, c2 = b, a
}
ev := &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: accuser,
Accused: a.Author,
Epoch: epoch,
Evidence: buildEquivocationEvidence(
c1.PayloadDigest[:], c2.PayloadDigest[:], c1.Signature, c2.Signature),
}
return ev, true
}
// buildEquivocationEvidence packs the 4-field equivocation evidence blob
// (commit1, commit2, sig1, sig2) in the TLV form abort.go validates.
func buildEquivocationEvidence(commit1, commit2, sig1, sig2 []byte) []byte {
out := make([]byte, 0, 4*4+len(commit1)+len(commit2)+len(sig1)+len(sig2))
for _, f := range [][]byte{commit1, commit2, sig1, sig2} {
var l [4]byte
l[0] = byte(len(f) >> 24)
l[1] = byte(len(f) >> 16)
l[2] = byte(len(f) >> 8)
l[3] = byte(len(f))
out = append(out, l[:]...)
out = append(out, f...)
}
return out
}