mirror of
https://github.com/luxfi/warp.git
synced 2026-10-30 15:13:04 +00:00
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.
196 lines
6.8 KiB
Go
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
|
|
}
|