mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Admission accepted any nonce above the committed one while Verify demanded exactly committed+1. That split was a free, permanent halt: one gapped transaction from any funded account entered the mempool, was drained into every block alongside honest work, failed Verify, was requeued by Reject, and the next build produced the same doomed block forever. Admission, assembly and consensus now decide through one checkTx, and assembly drops what it cannot build, so a block this node builds is a block this node verifies. Block position is now checked before block content. The parent must exist (a block claiming height 0 skipped the check outright), the height must be parent+1, and the timestamp must not rewind or run past a skew ceiling floored at the parent. Block time is the clock every authorization decision is made against, so an unbounded timestamp let a proposer revive a permit the chain had already retired. Also: payload bytes are priced (a megabyte of commitments rode on chain for the flat fee of an empty one); blocks are canonical on the wire, as transactions already were; the processing-block index lives under one mutex instead of two, which the race detector answers with a fatal throw; abandoned blocks are released rather than retained forever; in-memory state advances after the commit rather than before it; the height index is real, so GetBlockIDAtHeight answers; and checkAuth resolves the record Apply works from instead of Apply resolving and re-authorizing it a second time. Deleted: keyvm/config, 25 knobs of which one was ever read, including a ShareCacheSize for a VM whose defining invariant is that it never holds a share; the duplicate networkID the factory set and Initialize silently discarded; the duplicate state handle beside versdb; and a path-normalizing branch for a path that cannot occur. Coverage 58.9% -> 97.9%, with 30 guards mutation-checked.
336 lines
11 KiB
Go
336 lines
11 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package keyvm
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/crypto/mldsa"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// Transaction types. Each is a MUTATING operation that may only take effect
|
|
// through a fee-settled consensus block — never through a synchronous RPC.
|
|
const (
|
|
TxRegisterKey uint8 = 1 // record a key's PUBLIC material + commitments + policy
|
|
TxSetPolicy uint8 = 2 // update a key's authorization policy
|
|
TxAuthorize uint8 = 3 // authorize (trigger) a committee ceremony: dkg/sign/reshare
|
|
TxRevokeKey uint8 = 4 // revoke a key
|
|
)
|
|
|
|
// payerAuthMode is the algorithm K uses to authenticate a transaction's payer.
|
|
// It is the platform service-identity scheme (ML-DSA-65). Authentication is a
|
|
// PUBLIC operation: K parses the payer's public key and verifies a signature;
|
|
// it never possesses the payer's secret.
|
|
const payerAuthMode = mldsa.MLDSA65
|
|
|
|
// Transaction is a K-Chain consensus transaction. Its header is deterministic
|
|
// binary; Payload is an opaque, PUBLIC, op-specific JSON blob; Auth is the
|
|
// payer's ML-DSA-65 public key and Sig the payer's signature over the signing
|
|
// bytes. Nothing here is or can become secret material.
|
|
type Transaction struct {
|
|
Type uint8
|
|
Algorithm string // key algorithm (drives per-algorithm gas); "" for policy-only ops
|
|
Payer fee.Account // fee payer + authorization subject (public address)
|
|
KeyID ids.ID // target key (RegisterKey derives it; others reference it)
|
|
GasLimit uint64 // payer-declared gas ceiling for this tx
|
|
Nonce uint64 // payer replay/uniqueness nonce
|
|
Payload []byte // op-specific PUBLIC encoding
|
|
Auth []byte // payer ML-DSA-65 PUBLIC key
|
|
Sig []byte // payer signature over SigningBytes()
|
|
|
|
id ids.ID // cached, computed from full Bytes()
|
|
}
|
|
|
|
// Operation payloads. All fields are PUBLIC.
|
|
|
|
// RegisterKeyPayload records the PUBLIC result of an off-K DKG (or anchors a new
|
|
// key): its public key, threshold parameters, VSS commitments, the committee
|
|
// that holds the shares off-K, and the initial policy. No shares appear here.
|
|
type RegisterKeyPayload struct {
|
|
Name string `json:"name"`
|
|
PublicKey []byte `json:"publicKey"`
|
|
Threshold uint32 `json:"threshold"`
|
|
TotalShares uint32 `json:"totalShares"`
|
|
Commitments [][]byte `json:"commitments"`
|
|
Committee []ids.NodeID `json:"committee"`
|
|
Policy AuthPolicy `json:"policy"`
|
|
}
|
|
|
|
// SetPolicyPayload replaces a key's authorization policy.
|
|
type SetPolicyPayload struct {
|
|
Policy AuthPolicy `json:"policy"`
|
|
}
|
|
|
|
// AuthorizePayload triggers a committee ceremony. Message is the PUBLIC digest
|
|
// to be signed (for sign ceremonies); empty for dkg/reshare.
|
|
type AuthorizePayload struct {
|
|
Ceremony string `json:"ceremony"` // CeremonyDKG | CeremonySign | CeremonyReshare
|
|
Message []byte `json:"message"`
|
|
}
|
|
|
|
// RevokePayload revokes a key.
|
|
type RevokePayload struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// ID returns the transaction's content hash (over the full Bytes).
|
|
func (tx *Transaction) ID() ids.ID {
|
|
if tx.id == ids.Empty {
|
|
tx.id = ids.ID(sha256.Sum256(tx.Bytes()))
|
|
}
|
|
return tx.id
|
|
}
|
|
|
|
// addressOf derives a payer account from an ML-DSA public key. K is internally
|
|
// consistent: it derives the same address it checks a payer against. This is a
|
|
// PUBLIC, one-way derivation (no secret involved).
|
|
func addressOf(pub []byte) fee.Account {
|
|
h := sha256.Sum256(pub)
|
|
var a fee.Account
|
|
copy(a[:], h[:ids.ShortIDLen])
|
|
return a
|
|
}
|
|
|
|
// SyntacticVerify checks the transaction is well-formed and priceable, without
|
|
// any state. It rejects unknown types, unpriceable algorithms, and undecodable
|
|
// or out-of-range payloads — all fail-closed.
|
|
func (tx *Transaction) SyntacticVerify() error {
|
|
switch tx.Type {
|
|
case TxRegisterKey, TxSetPolicy, TxAuthorize, TxRevokeKey:
|
|
default:
|
|
return ErrInvalidTxType
|
|
}
|
|
// Pricing also validates the algorithm membership for algorithm-bearing ops.
|
|
if _, err := GasFor(tx); err != nil {
|
|
return err
|
|
}
|
|
switch tx.Type {
|
|
case TxRegisterKey:
|
|
var p RegisterKeyPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: register: %v", ErrInvalidPayload, err)
|
|
}
|
|
if p.Name == "" || len(p.PublicKey) == 0 {
|
|
return fmt.Errorf("keyvm: %w: register: empty name or public key", ErrInvalidPayload)
|
|
}
|
|
if p.Threshold == 0 || p.TotalShares == 0 || p.Threshold > p.TotalShares {
|
|
return fmt.Errorf("keyvm: %w: t=%d n=%d", ErrInvalidThreshold, p.Threshold, p.TotalShares)
|
|
}
|
|
if len(p.Commitments) == 0 {
|
|
return fmt.Errorf("keyvm: %w: register: no commitments", ErrInvalidPayload)
|
|
}
|
|
case TxSetPolicy:
|
|
var p SetPolicyPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: setpolicy: %v", ErrInvalidPayload, err)
|
|
}
|
|
case TxAuthorize:
|
|
var p AuthorizePayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: authorize: %v", ErrInvalidPayload, err)
|
|
}
|
|
switch p.Ceremony {
|
|
case CeremonyDKG, CeremonySign, CeremonyReshare:
|
|
default:
|
|
return fmt.Errorf("keyvm: %w: %q", ErrInvalidCeremony, p.Ceremony)
|
|
}
|
|
case TxRevokeKey:
|
|
var p RevokePayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: revoke: %v", ErrInvalidPayload, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authenticate verifies the payer authorized this transaction. PUBLIC ONLY:
|
|
// parse the payer's ML-DSA-65 public key, require it hashes to Payer, and verify
|
|
// the signature over SigningBytes. No secret material is touched.
|
|
func (tx *Transaction) authenticate() error {
|
|
if len(tx.Auth) == 0 || len(tx.Sig) == 0 {
|
|
return ErrUnsignedTx
|
|
}
|
|
if addressOf(tx.Auth) != tx.Payer {
|
|
return ErrPayerMismatch
|
|
}
|
|
pub, err := mldsa.PublicKeyFromBytes(tx.Auth, payerAuthMode)
|
|
if err != nil {
|
|
return fmt.Errorf("keyvm: payer public key: %w", err)
|
|
}
|
|
if !pub.VerifySignature(tx.SigningBytes(), tx.Sig) {
|
|
return ErrBadSignature
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deriveKeyID(name string) ids.ID {
|
|
return ids.ID(sha256.Sum256([]byte("keyvm/key/" + name)))
|
|
}
|
|
|
|
// checkAuth is the single, read-only authorization predicate for a transaction:
|
|
// it decides whether tx may take effect against the CURRENT committed state at
|
|
// time now, and RESOLVES the key record the operation targets. It mutates
|
|
// nothing. It is the one place the policy model is enforced, called at three
|
|
// layers so unauthorized transactions are rejected at the earliest gate and
|
|
// never charged: admission (SubmitTx), block assembly (BuildBlock) and consensus
|
|
// (Block.Verify). Apply then works from the record this returned rather than
|
|
// resolving and re-authorizing it a second time — one resolution, one decision.
|
|
// A registration has no target record yet, so it resolves to nil. The caller
|
|
// holds the appropriate stateLock.
|
|
func (tx *Transaction) checkAuth(vm *VM, now int64) (*KeyRecord, error) {
|
|
switch tx.Type {
|
|
case TxRegisterKey:
|
|
var p RegisterKeyPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return nil, fmt.Errorf("keyvm: %w: register", ErrInvalidPayload)
|
|
}
|
|
if _, ok := vm.getKey(deriveKeyID(p.Name)); ok {
|
|
return nil, ErrKeyExists
|
|
}
|
|
return nil, nil
|
|
case TxSetPolicy:
|
|
rec, ok := vm.getKey(tx.KeyID)
|
|
if !ok {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
if rec.Status != StatusActive {
|
|
return nil, ErrKeyRevoked
|
|
}
|
|
if !rec.Policy.MayAdmin(tx.Payer) {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
return rec, nil
|
|
case TxAuthorize:
|
|
rec, ok := vm.getKey(tx.KeyID)
|
|
if !ok {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
if rec.Status != StatusActive {
|
|
return nil, ErrKeyRevoked
|
|
}
|
|
if !rec.Policy.MayInvoke(tx.Payer, now) {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
return rec, nil
|
|
case TxRevokeKey:
|
|
rec, ok := vm.getKey(tx.KeyID)
|
|
if !ok {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
if !rec.Policy.MayAdmin(tx.Payer) {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
return rec, nil
|
|
default:
|
|
return nil, ErrInvalidTxType
|
|
}
|
|
}
|
|
|
|
// Apply mutates VM state for an already-verified, already-paid transaction. It
|
|
// runs inside block.Accept, writing through the VM's versiondb so the effect
|
|
// commits atomically with the fee burn. now is the accepting block's unix time.
|
|
// It authorizes through checkAuth and applies to the record checkAuth resolved.
|
|
func (tx *Transaction) Apply(vm *VM, now int64) error {
|
|
rec, err := tx.checkAuth(vm, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch tx.Type {
|
|
case TxRegisterKey:
|
|
return tx.applyRegister(vm, now)
|
|
case TxSetPolicy:
|
|
return tx.applySetPolicy(vm, now, rec)
|
|
case TxAuthorize:
|
|
return tx.applyAuthorize(vm, now)
|
|
case TxRevokeKey:
|
|
return tx.applyRevoke(vm, now, rec)
|
|
}
|
|
// checkAuth is the type gate, so no other value reaches here. The arm stays
|
|
// because a fifth operation added to checkAuth and not to this switch must
|
|
// fail closed, never fall through into whichever apply sits last.
|
|
return ErrInvalidTxType
|
|
}
|
|
|
|
func (tx *Transaction) applyRegister(vm *VM, now int64) error {
|
|
var p RegisterKeyPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: register", ErrInvalidPayload)
|
|
}
|
|
// The registrant is always an admin of the key it registers, so it can never
|
|
// lock itself out (fail-secure default).
|
|
policy := p.Policy
|
|
if !policy.MayAdmin(tx.Payer) {
|
|
policy.Admins = append(policy.Admins, tx.Payer)
|
|
}
|
|
rec := &KeyRecord{
|
|
ID: deriveKeyID(p.Name),
|
|
Name: p.Name,
|
|
Algorithm: tx.Algorithm,
|
|
PublicKey: p.PublicKey,
|
|
Threshold: p.Threshold,
|
|
TotalShares: p.TotalShares,
|
|
Commitments: p.Commitments,
|
|
Committee: p.Committee,
|
|
Policy: policy,
|
|
Owner: tx.Payer,
|
|
Status: StatusActive,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
return vm.putKey(rec)
|
|
}
|
|
|
|
func (tx *Transaction) applySetPolicy(vm *VM, now int64, rec *KeyRecord) error {
|
|
var p SetPolicyPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: setpolicy", ErrInvalidPayload)
|
|
}
|
|
// The owner remains an admin no matter what the new policy says — a key's
|
|
// owner can never be evicted by a policy update (fail-secure).
|
|
newPolicy := p.Policy
|
|
if !newPolicy.MayAdmin(rec.Owner) {
|
|
newPolicy.Admins = append(newPolicy.Admins, rec.Owner)
|
|
}
|
|
rec.Policy = newPolicy
|
|
rec.UpdatedAt = now
|
|
return vm.putKey(rec)
|
|
}
|
|
|
|
func (tx *Transaction) applyAuthorize(vm *VM, now int64) error {
|
|
var p AuthorizePayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err != nil {
|
|
return fmt.Errorf("keyvm: %w: authorize", ErrInvalidPayload)
|
|
}
|
|
// CeremonyID binds key, requester, nonce and message so it is unique and
|
|
// auditable. K records the AUTHORIZATION; the committee fulfils it off-K.
|
|
var seed []byte
|
|
seed = append(seed, tx.KeyID[:]...)
|
|
seed = append(seed, tx.Payer[:]...)
|
|
var nb [8]byte
|
|
binary.BigEndian.PutUint64(nb[:], tx.Nonce)
|
|
seed = append(seed, nb[:]...)
|
|
seed = append(seed, p.Message...)
|
|
c := &CeremonyRecord{
|
|
ID: ids.ID(sha256.Sum256(seed)),
|
|
KeyID: tx.KeyID,
|
|
Type: p.Ceremony,
|
|
Requester: tx.Payer,
|
|
Message: p.Message,
|
|
Status: CeremonyAuthorized,
|
|
CreatedAt: now,
|
|
}
|
|
return vm.putCeremony(c)
|
|
}
|
|
|
|
func (tx *Transaction) applyRevoke(vm *VM, now int64, rec *KeyRecord) error {
|
|
rec.Status = StatusRevoked
|
|
rec.UpdatedAt = now
|
|
return vm.putKey(rec)
|
|
}
|