mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
A precompile's input is the two-index slice Memory.GetPtr returns, and nothing on the path to Run copies it. len is what the caller declared and paid for; cap is the rest of EVM memory, which that same caller filled with MSTORE. So a parser reaching past its input does not panic and does not halt a validator -- it reads bytes the attacker chose and answers over them. Measured, not assumed: TestCalldataCarriesCallerChosenSpareCapacity drives the VM and reads back len=8 cap=256 with 0xA5 past the end. Cursor bounds every read against len, never against cap and never by letting a slice expression refuse, and caps what it returns so a field cannot be re-sliced forward. Verdict carries the bit (bool, error) was missing -- did a verifier run -- so "nothing checked this" stops borrowing the spelling of "this failed", and its zero value denies. Poisoned is the one definition of the adversarial fixture: append-built input has spare capacity too, but zeroed, so an over-read reads as a harmless run of zeros. p3q parses through the cursor. Its six hand-written bounds become zero; the class now rests on the single check in Cursor.Bytes, and deleting that check fails both suites -- including with fixtures degraded back to plain append, which is what the class used to depend on.
490 lines
21 KiB
Go
490 lines
21 KiB
Go
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// Package p3q implements the EVM precompile at slot 0x012205 for P3Q
|
||
// — "Post-Quantum Pulsar Proof" — the on-chain Pulsar / FIPS 204
|
||
// ML-DSA threshold-signature verifier.
|
||
//
|
||
// Canonical reference: HANZO-CRYPTO-SUITE §5.2 + LP-218 + ROADMAP
|
||
// §B.11. P3Q is the Solidity-callable verifier shim for the rollup-
|
||
// commit pattern of LP-218: a Tier-3 rollup sequencer Pulsar-threshold-
|
||
// signs the tuple (prev_root, new_root, batch_hash), packs the
|
||
// signature into a RollupBatchTx, and the parent L1 verifies the
|
||
// signature via this precompile inside normal block validation.
|
||
//
|
||
// Cryptographically, Pulsar's headline Class N1 claim is that a
|
||
// threshold-ceremony signature is byte-equal to a single-party
|
||
// FIPS 204 ML-DSA signature on the same message and group public key.
|
||
// Therefore this precompile's verifier core IS the FIPS 204 ML-DSA
|
||
// verifier — there is no Pulsar-specific verification logic. The
|
||
// dispatch goes through luxfi/crypto/mldsa.VerifySignatureCtx, which
|
||
// is a thin alias over cloudflare/circl mldsa{44,65,87} (the FIPS 204
|
||
// §6.3 verifier). One verifier, two import seams, byte-equal output.
|
||
//
|
||
// LP-4200 unified PQCrypto block:
|
||
//
|
||
// 0x012201 = ML-KEM (Module-LWE KEM, FIPS 203)
|
||
// 0x012202 = ML-DSA (Module-LWE single-party signature, FIPS 204)
|
||
// 0x012203 = SLH-DSA (hash-based signature, FIPS 205)
|
||
// 0x012204 = Pulsar (Module-LWE threshold, FIPS 204 byte-equal)
|
||
// 0x012205 = P3Q ← this precompile (LP-218 rollup-commit verifier)
|
||
// 0x012206 = Corona (Module-LWE threshold, Ringtail/Raccoon)
|
||
// 0x012207 = Magnetar (SLH-DSA threshold, FIPS 205 byte-equal)
|
||
// 0x012208 = HQC (code-based KEM, family-disjoint backup)
|
||
//
|
||
// Relationship to the Pulsar precompile at 0x012204:
|
||
//
|
||
// - 0x012204 (Pulsar) is the generic Pulsar / threshold-FIPS-204
|
||
// verifier. Wire: [mode][pubkey][msg_len][msg][sig]. Accepts
|
||
// arbitrary-length messages. Use for: any Pulsar-signed payload
|
||
// that needs Solidity-callable verification.
|
||
// - 0x012205 (P3Q) is the rollup-commit-specialised verifier of
|
||
// LP-218. Wire: [mode][pulsarSigLen][pulsarSig][groupPubKeyLen]
|
||
// [groupPubKey][messageHash:32]. The message is a fixed 32-byte
|
||
// hash. Use for: the LP-218 rollup pattern (rollup sequencer
|
||
// Pulsar-signs (prev_root || new_root || batch_hash)).
|
||
//
|
||
// The two precompiles share a common verifier core but serve distinct
|
||
// consumers — orthogonal slots, orthogonal wire formats, one and one
|
||
// way to do each (HANZO-CRYPTO-SUITE §5.2: "P3Q is a new primitive at
|
||
// its own slot, not bolted onto existing ZK or Pulsar").
|
||
//
|
||
// Wire format (single-call interface, no function selector):
|
||
//
|
||
// [ 1 byte ] kind (0x01 Pulsar / 0x02 Corona / 0x03 Magnetar)
|
||
// [ 1 byte ] mode (primitive-specific level — for Pulsar:
|
||
// 0x44 / 0x65 / 0x87 ML-DSA parameter set)
|
||
// [ 4 bytes ] pulsarSig length (big-endian uint32)
|
||
// [ N bytes ] pulsarSig (length = previous field)
|
||
// [ 4 bytes ] groupPubKey length (big-endian uint32)
|
||
// [ M bytes ] groupPubKey (length = previous field)
|
||
// [ 32 bytes ] messageHash (32-byte digest)
|
||
//
|
||
// Output: 32-byte EVM-ABI-encoded bool. Last byte is 0x01 on
|
||
// successful verification, 0x00 on any verification failure (including
|
||
// malformed input, mode mismatch, length mismatch, signature size
|
||
// mismatch, public-key size mismatch, FIPS 204 verifier reject).
|
||
//
|
||
// Returns `(out, gasLeft, nil)` on every well-gas-supplied call: the
|
||
// boolean result is conveyed via the output word, never via the Go
|
||
// error. This matches the LP-218 §"Failure modes" semantics — the
|
||
// precompile reports verification outcome without reverting, so the
|
||
// caller (Solidity contract) decides whether `false` is fatal.
|
||
//
|
||
// Gas cost: BaseVerifyGas + PerByteGas * len(input). The 50_000 base
|
||
// is the LP-218 calibration (10x BLS verify ≈ 5_000 gas; 17x ecrecover
|
||
// ≈ 3_000 gas) reflecting the inherent cost of ML-DSA lattice
|
||
// verification (NTT inverse + matrix-vector multiplication mod q).
|
||
// The per-byte adder covers serialization / hashing overhead. At a
|
||
// canonical ML-DSA-65 payload (1952-byte pk + 3309-byte sig + 32-byte
|
||
// hash + 10-byte framing [1 kind + 1 mode + 4 sigLen + 4 pkLen] = 5303
|
||
// bytes) the total gas is 50_000 + 5303 * 10 = 103_030 gas — well under
|
||
// the 12M C-Chain block gas limit (per contract.FeeConfigReporter audit
|
||
// at ~117 verifies per block).
|
||
//
|
||
// Domain separation: every P3Q verify binds the FIPS 204 context
|
||
// string `lux-evm-precompile-p3q-v1`. Mismatched context between
|
||
// signer and verifier causes the FIPS 204 verifier to reject — this
|
||
// prevents cross-precompile replay where a Pulsar signature minted
|
||
// for the generic 0x012204 path is re-submitted to the rollup-commit
|
||
// 0x012205 path. The signer SHOULD set ctx = "lux-evm-precompile-p3q-v1"
|
||
// when producing rollup-commit signatures destined for on-chain P3Q
|
||
// verification.
|
||
//
|
||
// Constant-time properties: P3Q operates on PUBLIC calldata — both
|
||
// the signature and the public key are observable on-chain. The CT
|
||
// concern is therefore not confidentiality but soundness: no timing
|
||
// channel may distinguish an accepting verify from a rejecting verify
|
||
// in a way that an adversary could turn into a consensus-relevant
|
||
// oracle. The FIPS 204 verifier is constant-time per FIPS 204 §6.3 and
|
||
// cloudflare/circl's documented CT contract; we forward that property
|
||
// without introducing data-dependent branches at the precompile layer.
|
||
// The only branches in Run are on PUBLIC mode/length fields, which is
|
||
// allowed under the CT contract.
|
||
package p3q
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"errors"
|
||
|
||
"github.com/luxfi/crypto/mldsa"
|
||
"github.com/luxfi/geth/common"
|
||
"github.com/luxfi/precompile/contract"
|
||
)
|
||
|
||
// ContractP3QVerifyAddress is the canonical LP-4200 slot for P3Q per
|
||
// HANZO-CRYPTO-SUITE §5.2 and LP-218.
|
||
var ContractP3QVerifyAddress = common.HexToAddress("0x0000000000000000000000000000000000012205")
|
||
|
||
// P3QVerifyPrecompile is the singleton instance bound to slot
|
||
// 0x012205 by package init() in module.go.
|
||
var P3QVerifyPrecompile = &p3qVerifyPrecompile{}
|
||
|
||
var _ contract.StatefulPrecompiledContract = &p3qVerifyPrecompile{}
|
||
|
||
// Wire format size constants.
|
||
const (
|
||
// KindByte is the single-byte P3Q family discriminator at offset 0
|
||
// of the calldata per LP-218 §"Precompile wire format" (decomplected
|
||
// 2026-06-03 — slot 0x012205 hosts the unified P3Q verifier family;
|
||
// dispatch on the kind byte selects which lattice/hash primitive
|
||
// performs the verify).
|
||
KindByte = 1
|
||
|
||
// ModeByte is the primitive-specific parameter-set identifier at
|
||
// offset 1 of the calldata (post-kind). For KindPulsar / KindCorona,
|
||
// this names the FIPS 204 ML-DSA parameter set (44/65/87). For
|
||
// KindMagnetar, this names the FIPS 205 SLH-DSA parameter set.
|
||
ModeByte = 1
|
||
|
||
// LengthFieldSize is the byte width of each big-endian uint32
|
||
// length field embedded in the wire format.
|
||
LengthFieldSize = 4
|
||
|
||
// MessageHashSize is the byte width of the fixed messageHash field
|
||
// per LP-218 (the precompile binds a 32-byte digest, not a
|
||
// variable-length message — that is the distinguishing feature of
|
||
// P3Q vs the generic Pulsar precompile at 0x012204).
|
||
MessageHashSize = 32
|
||
|
||
// MinInputLength is the smallest structurally valid input:
|
||
// 1 kind + 1 mode + 4 sig_len + 0 sig + 4 pk_len + 0 pk + 32 hash.
|
||
// A zero-length signature or zero-length public key cannot
|
||
// possibly verify (FIPS 204 requires both to be non-empty), but
|
||
// the structural check is constant-time over PUBLIC lengths and
|
||
// the FIPS 204 verifier surfaces the actual rejection.
|
||
MinInputLength = KindByte + ModeByte + LengthFieldSize + LengthFieldSize + MessageHashSize
|
||
)
|
||
|
||
// P3Q family kinds per LP-218 / LP-220. Slot 0x012205 hosts a unified
|
||
// verifier; the kind byte at offset 0 of the wire format selects
|
||
// which primitive performs the verify. Decomplection of 2026-06-03
|
||
// collapsed the prior sibling-slots model (LP-220 §"Architectural
|
||
// decision — single slot, kind-byte dispatch") into one slot.
|
||
//
|
||
// Today this precompile fully implements KindPulsar (FIPS 204 ML-DSA
|
||
// byte-equal output via Pulsar threshold ceremony). KindCorona and
|
||
// KindMagnetar hold their place in the dispatch table and answer
|
||
// false, so the surface is stable while their lattice/hash threshold
|
||
// verifiers land (see LP-220 §"Per-kind specs").
|
||
const (
|
||
KindPulsar uint8 = 0x01 // FIPS 204 ML-DSA (Module-LWE threshold, byte-equal)
|
||
KindCorona uint8 = 0x02 // Module-LWE threshold (Corona eprint 2024/1113)
|
||
KindMagnetar uint8 = 0x03 // FIPS 205 SLH-DSA (hash-based threshold)
|
||
)
|
||
|
||
// ML-DSA parameter-set identifiers. Wire bytes match the canonical
|
||
// FIPS 204 mode numbering used across the Lux precompile family
|
||
// (0x012202 ML-DSA, 0x012204 Pulsar). Aligning with sibling precompiles
|
||
// keeps client tooling uniform.
|
||
const (
|
||
ModeMLDSA44 uint8 = 0x44 // NIST PQ Category 2
|
||
ModeMLDSA65 uint8 = 0x65 // NIST PQ Category 3 (Lux production target)
|
||
ModeMLDSA87 uint8 = 0x87 // NIST PQ Category 5
|
||
)
|
||
|
||
// FIPS 204 byte-size constants per parameter set (FIPS 204 Table 2).
|
||
// Mirrored from the Pulsar precompile at 0x012204 — same lattice,
|
||
// same byte layout, same sizes.
|
||
const (
|
||
MLDSA44PublicKeySize = 1312
|
||
MLDSA44SignatureSize = 2420
|
||
|
||
MLDSA65PublicKeySize = 1952
|
||
MLDSA65SignatureSize = 3309
|
||
|
||
MLDSA87PublicKeySize = 2592
|
||
MLDSA87SignatureSize = 4627
|
||
)
|
||
|
||
// Gas schedule per LP-218 §"Honest cost comparison":
|
||
//
|
||
// P3Q (Pulsar / ML-DSA-65) | 50,000 gas | ~50 μs Blackwell | ~80 μs CPU
|
||
// BLS pairing verify | 5,000 gas | ~10 μs Blackwell | ~6 ms CPU
|
||
// ecrecover (secp256k1) | 3,000 gas | ~3 μs Blackwell | ~10 μs CPU
|
||
//
|
||
// The 10× factor over BLS reflects lattice verification's inherent
|
||
// cost (NTT inverse + matrix-vector mul mod q). The per-byte adder
|
||
// covers serialization / wire-format parsing. Calibrated against the
|
||
// parity audit (luxfi/threshold parity_test): Pulsar verify
|
||
// measures ~0.13-0.17 ms across all N on M1 Max and DGX Spark; at
|
||
// 12k gas/ms (geth's published gas-per-millisecond yardstick), that
|
||
// is ~1.6-2.0k gas of CPU time, dwarfed by the conservative 50k base.
|
||
const (
|
||
BaseVerifyGas uint64 = 50_000
|
||
PerByteGas uint64 = 10
|
||
)
|
||
|
||
// PrecompileCtx is the FIPS 204 context string bound to every P3Q
|
||
// verify call. Distinct from the generic 0x012204 Pulsar precompile's
|
||
// context ("lux-evm-precompile-pulsar-v1") so a signature minted for
|
||
// one slot cannot be replayed against the other.
|
||
//
|
||
// The context is a public, constant byte string — exposing it does not
|
||
// weaken the verifier; rather, it forces an attacker to obtain a fresh
|
||
// signature under the P3Q context for any on-chain replay attempt.
|
||
var PrecompileCtx = []byte("lux-evm-precompile-p3q-v1")
|
||
|
||
// ErrUnsupportedMode is returned by Verify, the in-process entry
|
||
// point, for a mode byte outside {0x44, 0x65, 0x87}.
|
||
//
|
||
// Run's own error return carries nothing but contract.ErrOutOfGas.
|
||
// Every verification failure — malformed input, unknown kind, unknown
|
||
// mode, length mismatch, FIPS 204 reject — is surfaced as
|
||
// `(out=[0x00…00], nil error)`, per LP-218 §"Failure modes": no revert
|
||
// on cryptographic failure, the Solidity caller observes `false` and
|
||
// decides whether that is fatal.
|
||
//
|
||
// The sentinels below never reach a Solidity caller either. They are
|
||
// what verify hands contract.Refused, and they exist because the ABI
|
||
// word cannot tell a check that failed from a check that never ran,
|
||
// while the code must: a reserved kind and a bad signature are the same
|
||
// byte on the wire and different facts in the verifier. Reading them
|
||
// back is what the refusal tests do.
|
||
var (
|
||
ErrUnsupportedMode = errors.New("p3q: unsupported ML-DSA mode (must be 0x44, 0x65, or 0x87)")
|
||
ErrKindReserved = errors.New("p3q: kind reserved, no verifier wired")
|
||
ErrUnknownKind = errors.New("p3q: kind names no primitive")
|
||
ErrSigSize = errors.New("p3q: declared signature length is not the mode's size")
|
||
ErrPubKeySize = errors.New("p3q: declared public key length is not the mode's size")
|
||
)
|
||
|
||
type p3qVerifyPrecompile struct{}
|
||
|
||
// RequiredGas returns BaseVerifyGas + PerByteGas * len(input).
|
||
//
|
||
// The cost function is monotonic in input length and uniform across
|
||
// success / failure paths (per the LP-218 gas-billing contract:
|
||
// "audited gas charge"). A short malformed input pays the base; a
|
||
// well-formed N-byte input pays base + N * PerByteGas. No reduced
|
||
// price for early rejection — uniform billing prevents a gas-side
|
||
// channel from leaking which validation stage rejected the input.
|
||
func (p *p3qVerifyPrecompile) RequiredGas(input []byte) uint64 {
|
||
return BaseVerifyGas + uint64(len(input))*PerByteGas
|
||
}
|
||
|
||
// modeParams resolves the wire-byte mode to (mldsa.Mode, pkSize,
|
||
// sigSize). Returns ErrUnsupportedMode on an unrecognised byte.
|
||
func modeParams(mode uint8) (m mldsa.Mode, pkSize, sigSize int, err error) {
|
||
switch mode {
|
||
case ModeMLDSA44:
|
||
return mldsa.MLDSA44, MLDSA44PublicKeySize, MLDSA44SignatureSize, nil
|
||
case ModeMLDSA65:
|
||
return mldsa.MLDSA65, MLDSA65PublicKeySize, MLDSA65SignatureSize, nil
|
||
case ModeMLDSA87:
|
||
return mldsa.MLDSA87, MLDSA87PublicKeySize, MLDSA87SignatureSize, nil
|
||
default:
|
||
return 0, 0, 0, ErrUnsupportedMode
|
||
}
|
||
}
|
||
|
||
// Run verifies a P3Q-family signature per LP-218 / LP-220. Today only
|
||
// KindPulsar (FIPS 204 ML-DSA byte-equal output) is wired; KindCorona
|
||
// and KindMagnetar are reserved and refuse until their verifiers land.
|
||
//
|
||
// Wire format (single-call interface, no function selector):
|
||
//
|
||
// [ 1 byte ] kind (0x01 Pulsar / 0x02 Corona / 0x03 Magnetar)
|
||
// [ 1 byte ] mode (0x44 / 0x65 / 0x87 — primitive-specific)
|
||
// [ 4 bytes ] pulsarSig length (BE32)
|
||
// [ N bytes ] pulsarSig
|
||
// [ 4 bytes ] groupPubKey length (BE32)
|
||
// [ M bytes ] groupPubKey
|
||
// [ 32 bytes ] messageHash
|
||
//
|
||
// Output: 32-byte EVM-ABI bool (last byte 0x01 on success, 0x00 on
|
||
// any verification failure).
|
||
//
|
||
// Error returns:
|
||
// - contract.ErrOutOfGas when suppliedGas < RequiredGas(input).
|
||
//
|
||
// Every other outcome — malformed input, unknown kind, unknown mode,
|
||
// length mismatch, FIPS 204 reject — is `(word, gasLeft, nil)` with the
|
||
// word false, per the LP-218 contract: no revert on cryptographic
|
||
// failure. The word is the verdict's own encoding, so the two cases
|
||
// the ABI collapses (a check that failed, a check that never ran) stay
|
||
// distinct inside this function even though the caller sees one byte.
|
||
func (p *p3qVerifyPrecompile) Run(
|
||
_ contract.AccessibleState,
|
||
_ common.Address,
|
||
_ common.Address,
|
||
input []byte,
|
||
suppliedGas uint64,
|
||
_ bool,
|
||
) ([]byte, uint64, error) {
|
||
gas := p.RequiredGas(input)
|
||
remaining, err := contract.DeductGas(suppliedGas, gas)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return verify(input).Word(), remaining, nil
|
||
}
|
||
|
||
// verify parses the wire format and runs the FIPS 204 verifier.
|
||
//
|
||
// It reads through a contract.Cursor, so no bound in this function is
|
||
// written by hand and none can be deleted: an over-read is refused by
|
||
// the cursor rather than by a slice expression, which under a real
|
||
// precompile input would not refuse at all. See contract/cursor.go for
|
||
// why — the short version is that calldata's spare capacity belongs to
|
||
// the caller, so reaching past the declared end reads chosen bytes and
|
||
// returns a verdict over them.
|
||
//
|
||
// Every branch on kind, mode and the two length fields is on PUBLIC
|
||
// data. No branch here depends on signature or public-key contents,
|
||
// which is what keeps the FIPS 204 verifier's constant-time contract
|
||
// intact at the precompile layer.
|
||
func verify(input []byte) contract.Verdict {
|
||
in := contract.Read(input)
|
||
|
||
// Family-kind dispatch per LP-218 / LP-220 §"Single slot, kind-byte
|
||
// dispatch". Today only KindPulsar is wired; KindCorona / KindMagnetar
|
||
// reserve their slot and refuse. Adding their verifier paths is a
|
||
// one-line change here and nowhere else.
|
||
kind, err := in.Byte()
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
switch kind {
|
||
case KindPulsar:
|
||
// fall through to the FIPS 204 ML-DSA verify path
|
||
case KindCorona, KindMagnetar:
|
||
return contract.Refused(ErrKindReserved)
|
||
default:
|
||
return contract.Refused(ErrUnknownKind)
|
||
}
|
||
|
||
mode, err := in.Byte()
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
m, pkSize, sigSize, err := modeParams(mode)
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
|
||
// pulsarSig: [4 bytes len][N bytes sig]. The declared length must
|
||
// EQUAL the parameter set's size, not merely fit — a length that
|
||
// only fits would let a caller pad the field and shift every later
|
||
// offset, so the bytes the verifier sees are chosen by the length
|
||
// field rather than by the layout.
|
||
sigLen, err := in.Uint32()
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
if uint64(sigLen) != uint64(sigSize) {
|
||
return contract.Refused(ErrSigSize)
|
||
}
|
||
pulsarSig, err := in.Bytes(uint64(sigLen))
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
|
||
// groupPubKey: [4 bytes len][M bytes pk], same rule.
|
||
pkLen, err := in.Uint32()
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
if uint64(pkLen) != uint64(pkSize) {
|
||
return contract.Refused(ErrPubKeySize)
|
||
}
|
||
groupPubKey, err := in.Bytes(uint64(pkLen))
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
|
||
// messageHash: exactly 32 bytes. The wire layout places it AFTER the
|
||
// variable-length blobs so its offset is content-independent —
|
||
// parseable by reading only the length fields, with no scanning.
|
||
messageHash, err := in.Bytes(MessageHashSize)
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
|
||
// Trailing bytes are accepted, matching the sibling STARK-FRI
|
||
// precompile. That is why in.End() is not called here: admitting
|
||
// padding is this format's documented behaviour, not an oversight.
|
||
|
||
pk, err := mldsa.PublicKeyFromBytes(groupPubKey, m)
|
||
if err != nil {
|
||
return contract.Refused(err)
|
||
}
|
||
return contract.Checked(pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx))
|
||
}
|
||
|
||
// Verify is the in-process Pulsar verification entry-point for callers
|
||
// outside the EVM precompile path (chains/zkvm dispatch, off-chain
|
||
// pre-checks, tests). Bypasses gas accounting and AccessibleState
|
||
// (those concerns belong to the precompile wrapper).
|
||
//
|
||
// Returns nil on a verifying signature; a typed error otherwise. Uses
|
||
// the same FIPS 204 context as the precompile path so off-chain
|
||
// pre-checks are byte-identical to on-chain verification.
|
||
//
|
||
// Pre-conditions:
|
||
// - mode is one of {ModeMLDSA44, ModeMLDSA65, ModeMLDSA87}
|
||
// - len(groupPubKey) matches the mode's pk size
|
||
// - len(pulsarSig) matches the mode's sig size
|
||
// - len(messageHash) is MessageHashSize (32)
|
||
//
|
||
// Violations of these pre-conditions return a typed error rather than
|
||
// a panic.
|
||
func Verify(mode uint8, groupPubKey, pulsarSig, messageHash []byte) error {
|
||
m, pkSize, sigSize, err := modeParams(mode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(groupPubKey) != pkSize {
|
||
return errors.New("p3q: groupPubKey size mismatch")
|
||
}
|
||
if len(pulsarSig) != sigSize {
|
||
return errors.New("p3q: pulsarSig size mismatch")
|
||
}
|
||
if len(messageHash) != MessageHashSize {
|
||
return errors.New("p3q: messageHash must be 32 bytes")
|
||
}
|
||
pk, perr := mldsa.PublicKeyFromBytes(groupPubKey, m)
|
||
if perr != nil {
|
||
return perr
|
||
}
|
||
if !pk.VerifySignatureCtx(messageHash, pulsarSig, PrecompileCtx) {
|
||
return errors.New("p3q: FIPS 204 ML-DSA verify rejected")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// EncodeInput is a helper for callers (tests, Solidity adapters,
|
||
// off-chain pre-flight checks) to serialize (mode, sig, pk, hash) into
|
||
// the wire format the precompile expects. The encoder is also the
|
||
// canonical reference for the wire layout — any deviation between
|
||
// EncodeInput and Run is a bug in one or the other.
|
||
//
|
||
// EncodeInput targets KindPulsar (the only kind with a wired verifier
|
||
// today). For explicit kind selection use EncodeInputKind.
|
||
func EncodeInput(mode uint8, pulsarSig, groupPubKey, messageHash []byte) []byte {
|
||
return EncodeInputKind(KindPulsar, mode, pulsarSig, groupPubKey, messageHash)
|
||
}
|
||
|
||
// EncodeInputKind serializes (kind, mode, sig, pk, hash) into the
|
||
// canonical LP-218 P3Q wire format with the kind byte at offset 0
|
||
// per LP-220 §"Architectural decision — single slot, kind-byte
|
||
// dispatch".
|
||
func EncodeInputKind(kind, mode uint8, pulsarSig, groupPubKey, messageHash []byte) []byte {
|
||
out := make([]byte, 0, KindByte+ModeByte+LengthFieldSize+len(pulsarSig)+LengthFieldSize+len(groupPubKey)+MessageHashSize)
|
||
out = append(out, kind)
|
||
out = append(out, mode)
|
||
var b4 [4]byte
|
||
binary.BigEndian.PutUint32(b4[:], uint32(len(pulsarSig)))
|
||
out = append(out, b4[:]...)
|
||
out = append(out, pulsarSig...)
|
||
binary.BigEndian.PutUint32(b4[:], uint32(len(groupPubKey)))
|
||
out = append(out, b4[:]...)
|
||
out = append(out, groupPubKey...)
|
||
// messageHash MUST be exactly 32 bytes. If the caller passes a
|
||
// different length, the wire format is malformed; the precompile
|
||
// will reject. Surface that to the caller by writing whatever was
|
||
// passed (no silent padding / truncation).
|
||
out = append(out, messageHash...)
|
||
return out
|
||
}
|