mirror of
https://github.com/luxfi/dkg.git
synced 2026-10-30 16:58:45 +00:00
Replay-bound nonce tickets: offline inventory, consume-once, expiry, binding to (epoch, committee, policy, digest) via transcript.Hash; authenticity via channel.Sign under the new channel.CtxNonce. A ticket carries no secret nonce material (that lives in the dealerless nonce DKG) — it is the public signed lifecycle token. Inventory.Bind/Consume enforce consume-once + expiry; stateless VerifyTicket checks binding + issuer signature. Proven: bind/consume-once, double-consume rejected, expiry, binding-mutation rejected (ErrBinding), tampered signature rejected, exhaustion, foreign-ticket unknown, cross-context id isolation. go build/test/vet/gofmt green.
227 lines
7.5 KiB
Go
227 lines
7.5 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package nonce is the robust nonce-ticket lifecycle of luxfi/dkg (malicious-
|
|
// hardening component 9). Threshold ML-DSA signing (TALUS) consumes ONE-TIME
|
|
// offline-preprocessed nonces; reusing a nonce across two messages leaks the
|
|
// long-term key, so the lifecycle must guarantee:
|
|
//
|
|
// - offline inventory: nonce slots are precomputed before any message is known
|
|
// (the expensive dealerless nonce DKG runs offline);
|
|
// - replay binding: a ticket is bound to a specific (epoch, committee, policy,
|
|
// digest) by an unforgeable id = transcript.Hash(...), so it can authorize a
|
|
// signature for that context and NO other;
|
|
// - consume-once: a ticket is accepted at most once; a second presentation is
|
|
// rejected (the consume-ledger is the in-reference stand-in for the chain's
|
|
// spent-nonce set);
|
|
// - expiry: a ticket past its deadline is rejected, so a stale offline nonce
|
|
// cannot be revived.
|
|
//
|
|
// The ticket carries NO secret nonce material — the nonce shares live in the
|
|
// (separate) dealerless nonce DKG. A ticket is the PUBLIC, signed lifecycle
|
|
// token that binds and tracks one nonce slot. Authenticity rides channel.Sign
|
|
// under channel.CtxNonce; the binding rides transcript (SP 800-185).
|
|
package nonce
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/dkg/channel"
|
|
"github.com/luxfi/dkg/transcript"
|
|
)
|
|
|
|
// Errors.
|
|
var (
|
|
ErrNoneAvailable = errors.New("dkg/nonce: inventory exhausted (no available slot)")
|
|
ErrExpired = errors.New("dkg/nonce: ticket past its expiry")
|
|
ErrConsumed = errors.New("dkg/nonce: ticket already consumed (replay)")
|
|
ErrUnknownTicket = errors.New("dkg/nonce: ticket not issued by this inventory")
|
|
ErrBinding = errors.New("dkg/nonce: ticket id does not match its (epoch,committee,policy,digest)")
|
|
ErrSignature = errors.New("dkg/nonce: ticket issuer signature invalid")
|
|
ErrShape = errors.New("dkg/nonce: invalid inventory size")
|
|
)
|
|
|
|
// Binding is the four values a nonce ticket is replay-bound to. Distinct
|
|
// bindings yield distinct ids, so a ticket can never authorize a signature for a
|
|
// different epoch, committee, policy, or message digest.
|
|
type Binding struct {
|
|
Epoch uint64
|
|
Committee [32]byte // hash of the committee validator set
|
|
Policy [32]byte // hash of the signing policy (tier, params)
|
|
Digest [32]byte // the message digest the nonce signs over
|
|
}
|
|
|
|
// TicketID is the replay-bound id: a TupleHash256-framed transcript hash over
|
|
// the binding. Pure function — any verifier recomputes it to check a ticket.
|
|
func (b Binding) TicketID() [32]byte {
|
|
t := transcript.NewWithDomain(transcript.FuncName, "LUX-DKG-NONCE-TICKET-V1")
|
|
t.AppendU64("epoch", b.Epoch)
|
|
t.AppendHash("committee", b.Committee)
|
|
t.AppendHash("policy", b.Policy)
|
|
t.AppendHash("digest", b.Digest)
|
|
return t.Hash()
|
|
}
|
|
|
|
// Ticket is a signed, replay-bound, one-time nonce authorization. Index names
|
|
// the offline inventory slot; Expiry is a unix deadline (0 = none); Sig is the
|
|
// issuer's ML-DSA-65 signature over SigningBytes under channel.CtxNonce.
|
|
type Ticket struct {
|
|
ID [32]byte
|
|
Binding Binding
|
|
Index uint64
|
|
Expiry int64
|
|
Issuer channel.NodeID
|
|
Sig []byte
|
|
}
|
|
|
|
// SigningBytes are the canonical to-be-signed bytes: the id (which already binds
|
|
// epoch/committee/policy/digest), the slot index, the expiry, and the issuer.
|
|
func (t *Ticket) SigningBytes() []byte {
|
|
tr := transcript.NewWithDomain(transcript.FuncName, "LUX-DKG-NONCE-SIG-V1")
|
|
tr.AppendHash("id", t.ID)
|
|
tr.AppendU64("index", t.Index)
|
|
tr.AppendU64("expiry", uint64(t.Expiry))
|
|
tr.Append("issuer", t.Issuer[:])
|
|
h := tr.Hash48() // 48-byte tag, matches the channel signing convention
|
|
return h[:]
|
|
}
|
|
|
|
// VerifyTicket is the stateless third-party check: the id matches its binding
|
|
// AND the issuer's signature is valid. It does NOT check consume-once or expiry
|
|
// (those need state / a clock) — Inventory.Consume composes all three.
|
|
func VerifyTicket(t *Ticket, dir channel.IdentityDirectory) error {
|
|
if t == nil {
|
|
return ErrUnknownTicket
|
|
}
|
|
if t.Binding.TicketID() != t.ID {
|
|
return ErrBinding
|
|
}
|
|
pub := dir.Get(t.Issuer)
|
|
if pub == nil || !channel.Verify(pub, channel.CtxNonce, t.SigningBytes(), t.Sig) {
|
|
return ErrSignature
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// state is one offline slot's lifecycle position.
|
|
type state uint8
|
|
|
|
const (
|
|
available state = iota // precomputed, unbound
|
|
bound // bound to a context, awaiting consume
|
|
consumed // consumed once; terminal
|
|
)
|
|
|
|
type slot struct {
|
|
st state
|
|
id [32]byte
|
|
expiry int64
|
|
}
|
|
|
|
// Inventory is the issuer's offline nonce pool + consume-ledger. It binds an
|
|
// available slot to a signing context (issuing a signed Ticket) and enforces
|
|
// consume-once + expiry. Safe for concurrent use.
|
|
type Inventory struct {
|
|
mu sync.Mutex
|
|
issuer *channel.IdentityKey
|
|
issuerID channel.NodeID
|
|
slots []slot
|
|
byID map[[32]byte]int // ticket id → slot index (issued tickets)
|
|
clock func() int64 // injectable unix clock (nil ⇒ time.Now)
|
|
}
|
|
|
|
// NewInventory precomputes `size` available nonce slots for issuer. size must be
|
|
// >= 1. (In a deployment the dealerless nonce DKG fills the slots' secret
|
|
// material offline; here a slot is the public lifecycle handle.)
|
|
func NewInventory(issuer *channel.IdentityKey, issuerID channel.NodeID, size int) (*Inventory, error) {
|
|
if issuer == nil || size < 1 {
|
|
return nil, ErrShape
|
|
}
|
|
return &Inventory{
|
|
issuer: issuer,
|
|
issuerID: issuerID,
|
|
slots: make([]slot, size),
|
|
byID: make(map[[32]byte]int, size),
|
|
}, nil
|
|
}
|
|
|
|
func (inv *Inventory) now() int64 {
|
|
if inv.clock != nil {
|
|
return inv.clock()
|
|
}
|
|
return time.Now().Unix()
|
|
}
|
|
|
|
// Available returns the number of slots not yet bound.
|
|
func (inv *Inventory) Available() int {
|
|
inv.mu.Lock()
|
|
defer inv.mu.Unlock()
|
|
c := 0
|
|
for i := range inv.slots {
|
|
if inv.slots[i].st == available {
|
|
c++
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Bind binds the lowest available slot to b and returns a signed Ticket. ttl is
|
|
// the seconds-from-now expiry (0 ⇒ no expiry). The slot moves available→bound;
|
|
// a slot binds to exactly one context, so the same nonce can never be bound to
|
|
// two messages.
|
|
func (inv *Inventory) Bind(b Binding, ttl int64) (*Ticket, error) {
|
|
inv.mu.Lock()
|
|
defer inv.mu.Unlock()
|
|
idx := -1
|
|
for i := range inv.slots {
|
|
if inv.slots[i].st == available {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil, ErrNoneAvailable
|
|
}
|
|
id := b.TicketID()
|
|
var expiry int64
|
|
if ttl > 0 {
|
|
expiry = inv.now() + ttl
|
|
}
|
|
t := &Ticket{ID: id, Binding: b, Index: uint64(idx), Expiry: expiry, Issuer: inv.issuerID}
|
|
sig, err := channel.Sign(inv.issuer, channel.CtxNonce, t.SigningBytes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Sig = sig
|
|
inv.slots[idx] = slot{st: bound, id: id, expiry: expiry}
|
|
inv.byID[id] = idx
|
|
return t, nil
|
|
}
|
|
|
|
// Consume validates and consumes a ticket against this inventory's ledger:
|
|
// stateless verification (binding + issuer signature), then expiry, then
|
|
// consume-once. On success the slot moves bound→consumed and a second Consume of
|
|
// the same ticket returns ErrConsumed. dir supplies the issuer's public key.
|
|
func (inv *Inventory) Consume(t *Ticket, dir channel.IdentityDirectory) error {
|
|
if err := VerifyTicket(t, dir); err != nil {
|
|
return err
|
|
}
|
|
inv.mu.Lock()
|
|
defer inv.mu.Unlock()
|
|
idx, ok := inv.byID[t.ID]
|
|
if !ok {
|
|
return ErrUnknownTicket
|
|
}
|
|
s := &inv.slots[idx]
|
|
if s.st == consumed {
|
|
return ErrConsumed
|
|
}
|
|
if s.expiry != 0 && inv.now() > s.expiry {
|
|
return ErrExpired
|
|
}
|
|
s.st = consumed
|
|
return nil
|
|
}
|