Files
warp/pulsar/hashsuite_mismatch_test.go
zeekay 948ee85a9c warp: ship Warp 2.0 envelope (Beam + ML-DSA + Pulse, Prism-bound)
Implements the LP-021v2 / LP-105 §"Warp evolution" cross-chain envelope
that carries the Warp 1.x BLS Beam intact alongside transcript-binding
fields and two optional PQ lanes (Pulsar Pulse, ML-DSA cert set).

Wire format: leading 0x02 version byte + RLP-encoded EnvelopeV2 record
binding SourceNebulaRoot, SourceKeyEraID, SourceGeneration, HashSuiteID
(default "Pulsar-SHA3"), plus optional PulsarPulse and MLDSACertSet
lanes. Forward compatibility: ParseEnvelope decodes Warp 1.x bytes into
a v2 envelope with PQ lanes empty. Backward compatibility: v1 receivers
reject v2 bytes (the 0x02 leading byte is not a valid RLP-list prefix);
senders that need to reach v1-only verifiers emit Warp 1.x bytes on the
v1 channel. Replay protection is uniform: EnvelopeV2.ID() returns the
embedded Message.ID().

Verification: VerifyV2 checks structural invariants, hash-suite
consistency, BLS Beam (against the source-chain validator set), then
the optional ML-DSA cert-set and Pulsar Pulse lanes. VerifyPQLanes is
the standalone entrypoint for callers that already verified the Beam
through a separate code path.

Subpackage warp/pulsar holds the Pulse path:
  - KernelVerifier consumes a GroupKeyResolver (the destination chain's
    source-chain key registry) to fetch the source-chain GroupKey for
    a given (KeyEraID, Generation) and verifies the Pulse via the
    luxfi/pulsar kernel.
  - BuildSigningBytes produces the canonical transcript bytes under the
    WARP-PULSAR-ENVELOPE-v1 domain prefix (distinct from any Pulsar
    consensus-side prefix to prevent cross-domain replay).
  - HorizonFromEnvelope lifts a verified envelope into the LP-105
    HorizonCertificate shape consumed by destination consensus / bridge
    code.

Pulse byte format wraps the kernel Signature's C / Z / Delta components
in three little-endian length-prefixed frames so DeserializePulse can
dispatch each component into a fresh slice-backed lattigo buffer
(the recommended path for slice-backed reads).

Tests:
  - envelope_test.go: roundtrip, optional-field defaulting, forward /
    backward / unknown-version dispatch, ID stability, PQ-lane
    verifier dispatch (Pulse, CertSet, required-but-absent flags).
  - pulsar/pulsar_test.go: real t-of-n Pulsar ceremony, serialize /
    deserialize roundtrip, KernelVerifier accept / reject paths
    (transcript-tampering rejection, resolver failure, suite mismatch),
    BuildSigningBytes binds every transcript field, Horizon assembly.

Module wiring: adds github.com/luxfi/pulsar (replace ../pulsar) and
github.com/luxfi/lattice/v7 to warp/go.mod. Restores the pinned
luxfi/precompile h1: hash so go test ./... resolves cleanly.

Documentation: README.md and SPECIFICATION.md rewritten to describe the
shipping v1.x + v2.0 + v2-Private versions, the dispatch flow, and the
Pulse transcript-binding rules.

LP refs: LP-021 (Warp 1.x), LP-021v2 (this work), LP-073 (Pulsar
kernel), LP-075 (BLS aggregate), LP-105 §"Warp evolution".

All warp tests pass: GOWORK=off go test ./...
2026-05-11 22:31:20 -07:00

168 lines
6.4 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package pulsar — Gate 3D HashSuite-mismatch tests for Warp 2.0
// envelope verification (Mar-3-2026 PQ Consensus Architecture Freeze).
//
// These tests exercise the receiver-side hash-suite check on Warp 2.0
// envelopes:
//
// 1. A Pulse signed under Pulsar-SHA3 verifies under Pulsar-SHA3.
// 2. The same envelope verified while the receiver expects
// Pulsar-BLAKE3 is rejected with the canonical
// ErrEnvelopeBadSuiteID error.
// 3. A receiver who sees a mutated HashSuiteID (the field on the
// envelope is tampered AFTER signing, then re-serialised, with
// the Pulse byte-identical) MUST reject — the Pulse transcript
// binds HashSuiteID, so the kernel rejects on signature verify
// even before the receiver-side string-equality check fires.
//
// Citations (canonical proof bucket):
//
// proofs/pulsar/hash-suite-separation.tex
// Theorem ref:hash-suite-separation
// proofs/definitions/transcript-binding.tex
// Definition ref:pulsar-transcript and Definition ref:warp-v2-envelope
package pulsar
import (
"testing"
"github.com/luxfi/warp"
"github.com/stretchr/testify/require"
)
// TestVerifyV2AcceptsMatchingPulsarSHA3 — a Warp 2.0 envelope with
// HashSuiteID="Pulsar-SHA3" and a real Pulse over its transcript
// verifies cleanly when the receiver expects Pulsar-SHA3.
func TestVerifyV2AcceptsMatchingPulsarSHA3(t *testing.T) {
env, _ := envFixture(t, 7, 11)
env.HashSuiteID = warp.DefaultHashSuiteID
msgBytes := env.Message.UnsignedMessage.Bytes()
signing := BuildSigningBytes(env, msgBytes)
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
pulse, err := SerializePulse(sig)
require.NoError(t, err)
env.PulsarPulse = pulse
verifier := NewKernelVerifier(&stubResolver{gk: gk, suiteID: warp.DefaultHashSuiteID})
opts := warp.VerifyV2Options{
SkipBeam: true,
Pulse: verifier,
RequirePulse: true,
HashSuiteID: warp.DefaultHashSuiteID,
}
require.NoError(t, warp.VerifyV2(env, opts))
}
// TestVerifyV2RejectsExpectedSuiteMismatch — the same envelope
// verified while the RECEIVER expects "Pulsar-BLAKE3" must be rejected
// with the canonical ErrEnvelopeBadSuiteID. The envelope itself is
// honest; the receiver's policy disagrees.
func TestVerifyV2RejectsExpectedSuiteMismatch(t *testing.T) {
env, _ := envFixture(t, 7, 11)
env.HashSuiteID = warp.DefaultHashSuiteID
msgBytes := env.Message.UnsignedMessage.Bytes()
signing := BuildSigningBytes(env, msgBytes)
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
pulse, err := SerializePulse(sig)
require.NoError(t, err)
env.PulsarPulse = pulse
verifier := NewKernelVerifier(&stubResolver{gk: gk, suiteID: warp.DefaultHashSuiteID})
opts := warp.VerifyV2Options{
SkipBeam: true,
Pulse: verifier,
RequirePulse: true,
// Receiver expects the legacy profile; envelope declares
// production. VerifyV2 fires ErrEnvelopeBadSuiteID before any
// PQ-lane verification runs.
HashSuiteID: "Pulsar-BLAKE3",
}
err = warp.VerifyV2(env, opts)
require.ErrorIs(t, err, warp.ErrEnvelopeBadSuiteID)
}
// TestVerifyV2RejectsMutatedHashSuiteIDPostSign — the envelope is
// signed with HashSuiteID="Pulsar-SHA3", a real Pulse is bound, the
// envelope is re-serialised, and then the HashSuiteID field is
// MUTATED (re-encoded) to "Pulsar-BLAKE3" while the Pulse stays
// byte-identical. Even if the receiver expects "Pulsar-BLAKE3" — i.e.
// the receiver-side string-equality check passes — the kernel
// verification must reject because BuildSigningBytes binds the
// HashSuiteID into the transcript that the threshold signature
// covers.
func TestVerifyV2RejectsMutatedHashSuiteIDPostSign(t *testing.T) {
env, _ := envFixture(t, 7, 11)
env.HashSuiteID = warp.DefaultHashSuiteID
msgBytes := env.Message.UnsignedMessage.Bytes()
signing := BuildSigningBytes(env, msgBytes)
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
pulse, err := SerializePulse(sig)
require.NoError(t, err)
env.PulsarPulse = pulse
// Round-trip the envelope through the wire so we are testing the
// re-serialised bytes, not a Go-only mutation.
wire, err := env.Bytes()
require.NoError(t, err)
parsed, err := warp.ParseEnvelopeV2(wire)
require.NoError(t, err)
// Mutate HashSuiteID after signing. Re-serialise and re-parse so
// the Pulse bytes are unchanged but the envelope's declared profile
// has been swapped.
parsed.HashSuiteID = "Pulsar-BLAKE3"
mutatedWire, err := parsed.Bytes()
require.NoError(t, err)
mutated, err := warp.ParseEnvelopeV2(mutatedWire)
require.NoError(t, err)
verifier := NewKernelVerifier(&stubResolver{gk: gk, suiteID: "Pulsar-BLAKE3"})
// Receiver expects "Pulsar-BLAKE3" — same as the envelope's mutated
// declaration — so the receiver-side string-equality check passes.
// The kernel still rejects because the signed transcript bound the
// original "Pulsar-SHA3" HashSuiteID.
opts := warp.VerifyV2Options{
SkipBeam: true,
Pulse: verifier,
RequirePulse: true,
HashSuiteID: "Pulsar-BLAKE3",
}
err = warp.VerifyV2(mutated, opts)
require.Error(t, err)
// The kernel's rejection surfaces through verifyPQLanes wrapping
// the kernel error; assert it is the kernel-rejection error (not
// the receiver-side ErrEnvelopeBadSuiteID).
require.ErrorIs(t, err, ErrPulseVerifyFailed)
}
// TestKernelVerifierRejectsHashSuiteFieldMutation — direct kernel-side
// test of the same property: mutate the envelope's HashSuiteID after
// signing, keep the Pulse, and call VerifyPulse directly. The
// transcript binding catches it.
func TestKernelVerifierRejectsHashSuiteFieldMutation(t *testing.T) {
env, _ := envFixture(t, 7, 11)
env.HashSuiteID = warp.DefaultHashSuiteID
msgBytes := env.Message.UnsignedMessage.Bytes()
signing := BuildSigningBytes(env, msgBytes)
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
pulse, err := SerializePulse(sig)
require.NoError(t, err)
env.PulsarPulse = pulse
// Mutate HashSuiteID after signing. The Pulse stays unchanged.
env.HashSuiteID = "Pulsar-BLAKE3"
// Resolver agrees with the mutated declaration so the suite-mismatch
// short-circuit does not fire — the only thing that can reject is
// the kernel's transcript-binding check.
verifier := NewKernelVerifier(&stubResolver{gk: gk, suiteID: "Pulsar-BLAKE3"})
err = verifier.VerifyPulse(env, msgBytes)
require.ErrorIs(t, err, ErrPulseVerifyFailed)
}