Files
zeekay 443aa53f81 feat(warp)!: typed finality-evidence model (Beam/Pulsar/Corona/P3Q) + Corona relabel
Decomplect the PQ finality lanes into a typed, impossible-to-confuse model where
KIND chooses the verifier and SUITE only chooses parameterization (fail-closed on
mismatch). Kills the prior conflation where the corona-backed lattice lane was
mislabeled "Pulse/Pulsar-SHA3".

Four strict evidence kinds — each its own verifier + suite + key registry:
  beam-bls                BLS12-381 aggregate (fast classical)
  pulsar-threshold-mldsa  threshold ML-DSA, verified as a STANDARD FIPS-204
                          ML-DSA signature under a group public key; the TALUS
                          dealerless machinery is entirely offline (VerifyPulsar
                          imports only mldsa65 — enforced by an import-hygiene test)
  corona-ringtail         Ringtail/Module-LWE lattice threshold (the corona kernel)
  p3q-mldsa-rollup        succinct rollup of independent ML-DSA-65 sigs; strict
                          ONLY when its proof system is PQ (classical SNARK gated)
Plus the raw mldsa-cert-set as a NON-strict availability/audit artifact.

Subject-agnostic verifiers take (subject, evidence, keyEra): one dispatch serves
the warp cross-chain digest D and the consensus finality subject
  M = keccak256("QUASAR_FINALITY_V1" || chainID || height || round || blockID ||
      stateRoot || signerSetID || keyEraID || generation || pChainHeight || policyID)
folding generation + pChainHeight + policyID so a signature binds its era, its
validator snapshot, and its tier (tier-downgrade replay fails closed).

Policy tiers (BLS_FAST / HYBRID_PQ_CHECKPOINT / STRICT_QUASAR / RECOVERY) select
which lanes are required — orthogonal to verification and to keys. PulsarKeyEra
and CoronaKeyEra are symmetric but DISTINCT registry types; P3Q/cert-set resolve a
per-validator SignerSetAuthority — no resolver can alias another's key material.
Offline signers (pulsard, coronad, P3Q prover) are interfaces only, never imported
by the verify path.

Wire unchanged: D, the Message c14n, messageDST, the ZAP magic and kind bytes are
byte-identical (forge Go<->Solidity equality holds; golden envelope SHA unchanged).
The corona lane DST is renamed pulseDST->coronaDST (no deployed corona sigs;
envelope KAT regenerated).

22 tests green: non-confusable dispatch, suite/kind fail-closed, Pulsar import
hygiene, subject-identity across lanes, policyID downgrade, key-era distinctness,
P3Q-classical-root strict gate.
2026-06-27 13:47:13 -07:00

196 lines
6.8 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package warp
import (
"fmt"
"github.com/luxfi/ids"
)
// MessageHashProfileTag is the GENERIC message-level hash-profile tag a
// Message folds into D via its c14n HashSuiteID field. It is NOT a lane
// verifier selector and NOT the Corona lane suite (that is
// DefaultCoronaSuiteID, resolved independently — see evidence.go). Decoupling
// these two is what kills the historical "Pulsar aliases Corona" bug.
//
// Its VALUE ("Pulsar-SHA3") is PINNED for teleport/BridgeV2 on-chain
// D-lockstep: teleport.ComputeMessageHash builds a Message with this tag and
// BridgeV2.sol recomputes the SAME c14n preimage and keccak256 (see
// teleport/payload.go §4.2). Changing this string changes D for every
// default-tag Message and is therefore a COORDINATED HARD-FORK with the
// on-chain contract — never a refactor.
//
// A Message's HashSuiteID MUST be resolved to a concrete value before it is
// marshaled or signed — there is NO sign-time defaulting inside the codec.
// This constant is the resolution target callers use at construction time.
const MessageHashProfileTag = "Pulsar-SHA3"
// Message is the single signed subject of a Warp message. It folds the
// former UnsignedMessage (NetworkID, SourceChainID, Payload) together with
// the Pulsar PQ lineage (SourceNebulaRoot, SourceKeyEraID, SourceGeneration,
// HashSuiteID) that previously lived only on the v2 envelope. Folding the
// lineage into the signed subject means every lane — BLS Beam included —
// authenticates it; under the old split the Beam signed only the unsigned
// body and the lineage was Beam-unauthenticated.
//
// Canonical c14n layout (== Bytes(), the lane subject hashed into D):
//
// zapKindMessage u8 (0x01)
// NetworkID u32 big-endian
// SourceChainID [32] raw
// SourceNebulaRoot [32] raw
// SourceKeyEraID u64 big-endian
// SourceGeneration u64 big-endian
// HashSuiteID u32-len ‖ utf8
// Payload u32-len ‖ bytes
//
// The digest is D = keccak256("LUX-WARP-ZAP-CORE-v1" ‖ Bytes()).
type Message struct {
NetworkID uint32
SourceChainID ids.ID
SourceNebulaRoot [32]byte
SourceKeyEraID uint64
SourceGeneration uint64
HashSuiteID string
Payload []byte
}
// NewMessage builds a Message for a locally-originated message:
// zero PQ lineage and HashSuiteID resolved to MessageHashProfileTag. Callers
// that bind a specific corona lineage construct the struct directly with
// the resolved fields.
func NewMessage(networkID uint32, sourceChainID ids.ID, payload []byte) (*Message, error) {
c := &Message{
NetworkID: networkID,
SourceChainID: sourceChainID,
HashSuiteID: MessageHashProfileTag,
Payload: payload,
}
if err := c.Verify(); err != nil {
return nil, err
}
return c, nil
}
// HashSuiteOrDefault returns the resolved HashSuiteID, falling back to
// MessageHashProfileTag for the zero value. This is a READ helper for
// downstream policy checks; it does NOT influence marshaling — Bytes()
// always encodes the field verbatim. It returns the message-level c14n
// tag, NOT a lane suite.
func (c *Message) HashSuiteOrDefault() string {
if c == nil || c.HashSuiteID == "" {
return MessageHashProfileTag
}
return c.HashSuiteID
}
// marshalZAP returns the canonical c14n bytes of the message. This is the
// lane subject and the digest preimage; it is total-order canonical, so
// re-marshaling a decoded message reproduces the exact bytes.
func (c *Message) marshalZAP() []byte {
out := make([]byte, 0, 1+4+32+32+8+8+4+len(c.HashSuiteID)+4+len(c.Payload))
out = appendU8(out, zapKindMessage)
out = appendU32(out, c.NetworkID)
out = appendFixed(out, c.SourceChainID[:])
out = appendFixed(out, c.SourceNebulaRoot[:])
out = appendU64(out, c.SourceKeyEraID)
out = appendU64(out, c.SourceGeneration)
out = appendVar(out, []byte(c.HashSuiteID))
out = appendVar(out, c.Payload)
return out
}
// Verify checks the structural invariants: the canonical encoding must
// not exceed MaxMessageSize.
func (c *Message) Verify() error {
if n := len(c.marshalZAP()); n > MaxMessageSize {
return fmt.Errorf("%w: signed message size %d exceeds maximum %d", ErrInvalidMessage, n, MaxMessageSize)
}
return nil
}
// Bytes returns the canonical c14n encoding of the message (== zap_c14n).
func (c *Message) Bytes() []byte { return c.marshalZAP() }
// ID returns D, the Warp message ID: keccak256(messageDST ‖ Bytes()). It is
// the replay key and the on-chain messageHash. D is recomputed from the
// struct, never sliced out of a wire envelope.
func (c *Message) ID() ids.ID {
return ids.ID(keccak256([]byte(messageDST), c.marshalZAP()))
}
// parseMessage decodes a Message from the cursor. It validates the
// kind byte but leaves trailing-byte / size checks to the caller.
func parseMessage(r *zapReader) (Message, error) {
var c Message
kind, err := r.u8()
if err != nil {
return c, err
}
if kind != zapKindMessage {
return c, fmt.Errorf("%w: signed-message kind 0x%02x", ErrInvalidMessage, kind)
}
if c.NetworkID, err = r.u32(); err != nil {
return c, err
}
if err = r.fixedInto(c.SourceChainID[:]); err != nil {
return c, err
}
if err = r.fixedInto(c.SourceNebulaRoot[:]); err != nil {
return c, err
}
if c.SourceKeyEraID, err = r.u64(); err != nil {
return c, err
}
if c.SourceGeneration, err = r.u64(); err != nil {
return c, err
}
suite, err := r.varbytes()
if err != nil {
return c, err
}
c.HashSuiteID = string(suite)
if c.Payload, err = r.varbytes(); err != nil {
return c, err
}
return c, nil
}
// ParseMessage decodes a standalone Message from its canonical
// c14n bytes, rejecting trailing bytes and over-size payloads.
func ParseMessage(b []byte) (*Message, error) {
r := newZapReader(b)
c, err := parseMessage(r)
if err != nil {
return nil, fmt.Errorf("failed to parse signed message: %w", err)
}
if err := r.end(); err != nil {
return nil, err
}
if err := c.Verify(); err != nil {
return nil, err
}
return &c, nil
}
// VerifyWeight verifies that the signed weight meets the quorum threshold:
// signedWeight / totalWeight >= quorumNum / quorumDen.
func VerifyWeight(signedWeight, totalWeight, quorumNum, quorumDen uint64) error {
if signedWeight == 0 {
return fmt.Errorf("%w: signed weight is 0", ErrInsufficientWeight)
}
if err := CheckMulDoesNotOverflow(quorumNum, totalWeight); err != nil {
return fmt.Errorf("%w: quorumNum * totalWeight overflows", err)
}
if err := CheckMulDoesNotOverflow(quorumDen, signedWeight); err != nil {
return fmt.Errorf("%w: quorumDen * signedWeight overflows", err)
}
if quorumNum*totalWeight > quorumDen*signedWeight {
return fmt.Errorf("%w: signed weight %d / total weight %d < quorum %d / %d",
ErrInsufficientWeight, signedWeight, totalWeight, quorumNum, quorumDen)
}
return nil
}