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