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.
227 lines
7.8 KiB
Go
227 lines
7.8 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package pulsar
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto/bls"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/warp"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
corona "github.com/luxfi/corona/threshold"
|
|
)
|
|
|
|
// runPulsarCeremony runs a full t-of-n threshold signing ceremony against
|
|
// the given message and returns the Signature plus the GroupKey.
|
|
func runPulsarCeremony(t *testing.T, n, threshold int, message string) (*corona.Signature, *corona.GroupKey) {
|
|
t.Helper()
|
|
|
|
shares, gk, err := corona.GenerateKeys(threshold, n, rand.Reader)
|
|
require.NoError(t, err)
|
|
require.Len(t, shares, n)
|
|
|
|
signers := make([]int, n)
|
|
for i := range signers {
|
|
signers[i] = i
|
|
}
|
|
prfKey := make([]byte, 32)
|
|
_, err = rand.Read(prfKey)
|
|
require.NoError(t, err)
|
|
|
|
parties := make([]*corona.Signer, n)
|
|
for i := range parties {
|
|
parties[i] = corona.NewSigner(shares[i])
|
|
}
|
|
|
|
r1 := make(map[int]*corona.Round1Data, n)
|
|
for i, p := range parties {
|
|
r1[i] = p.Round1(1, prfKey, signers)
|
|
}
|
|
|
|
r2 := make(map[int]*corona.Round2Data, n)
|
|
for i, p := range parties {
|
|
d, err := p.Round2(1, message, prfKey, signers, r1)
|
|
require.NoError(t, err)
|
|
r2[i] = d
|
|
}
|
|
|
|
sig, err := parties[0].Finalize(r2)
|
|
require.NoError(t, err)
|
|
require.True(t, corona.Verify(gk, message, sig))
|
|
return sig, gk
|
|
}
|
|
|
|
// envFixture builds a Envelope with the given Pulsar lineage.
|
|
func envFixture(t *testing.T, eraID, generation uint64) *warp.Envelope {
|
|
t.Helper()
|
|
message := &warp.Message{
|
|
NetworkID: 1,
|
|
SourceChainID: ids.ID{0xC1, 0xC2, 0xC3, 0xC4},
|
|
SourceKeyEraID: eraID,
|
|
SourceGeneration: generation,
|
|
HashSuiteID: warp.MessageHashProfileTag,
|
|
Payload: []byte("warp-pulsar-roundtrip"),
|
|
}
|
|
signers := warp.NewBitSet()
|
|
signers.Add(0)
|
|
signers.Add(2)
|
|
var sigBytes [bls.SignatureLen]byte
|
|
copy(sigBytes[:], bytes.Repeat([]byte{0xAB}, bls.SignatureLen))
|
|
env, err := warp.NewEnvelope(message, warp.NewBitSetSignature(signers, sigBytes), nil, nil)
|
|
require.NoError(t, err)
|
|
return env
|
|
}
|
|
|
|
type stubResolver struct {
|
|
gk *corona.GroupKey
|
|
suiteID string
|
|
err error
|
|
}
|
|
|
|
func (s *stubResolver) ResolveGroupKey(_ [32]byte, _ uint64, _ uint64) (*corona.GroupKey, string, error) {
|
|
return s.gk, s.suiteID, s.err
|
|
}
|
|
|
|
// coronaInputs extracts the (subject, CoronaEvidence) pair for an envelope's
|
|
// corona lane: subject = D, evidence = routing + serialized signature. This
|
|
// mirrors exactly what the warp relay path (verifyPQLanes) hands to the
|
|
// subject-agnostic Corona verifier.
|
|
func coronaInputs(env *warp.Envelope) ([]byte, warp.CoronaEvidence) {
|
|
id := env.Message.ID()
|
|
return id[:], warp.CoronaEvidence{
|
|
ChainID: env.Message.SourceChainID,
|
|
KeyEraID: env.Message.SourceKeyEraID,
|
|
Generation: env.Message.SourceGeneration,
|
|
Sig: env.CoronaSig,
|
|
}
|
|
}
|
|
|
|
func TestSerializeDeserializeCoronaSigRoundTrip(t *testing.T) {
|
|
sig, gk := runPulsarCeremony(t, 3, 2, "round-trip")
|
|
wire, err := SerializeCoronaSig(sig)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, wire)
|
|
parsed, err := DeserializeCoronaSig(wire)
|
|
require.NoError(t, err)
|
|
require.True(t, corona.Verify(gk, "round-trip", parsed))
|
|
}
|
|
|
|
func TestRingtailVerifierAcceptsValidPulse(t *testing.T) {
|
|
env := envFixture(t, 7, 11)
|
|
signing := warp.CoronaSigningBytes(env.Message.ID())
|
|
|
|
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
|
|
pulse, err := SerializeCoronaSig(sig)
|
|
require.NoError(t, err)
|
|
env.CoronaSig = pulse
|
|
|
|
v := NewRingtailVerifier(&stubResolver{gk: gk, suiteID: string(warp.DefaultCoronaSuiteID)})
|
|
require.NoError(t, v.VerifyRingtailThreshold(coronaInputs(env)))
|
|
}
|
|
|
|
func TestRingtailVerifierRejectsTamperedEnvelopeFields(t *testing.T) {
|
|
env := envFixture(t, 7, 11)
|
|
signing := warp.CoronaSigningBytes(env.Message.ID())
|
|
|
|
sig, gk := runPulsarCeremony(t, 3, 2, string(signing))
|
|
pulse, err := SerializeCoronaSig(sig)
|
|
require.NoError(t, err)
|
|
env.CoronaSig = pulse
|
|
|
|
// Tamper with KeyEraID after signing — D changes, so the corona sig over
|
|
// the original D no longer verifies. (The Beam now also binds this
|
|
// field via D, closing the old Beam-unauthenticated-lineage gap.)
|
|
env.Message.SourceKeyEraID = 999
|
|
|
|
v := NewRingtailVerifier(&stubResolver{gk: gk, suiteID: string(warp.DefaultCoronaSuiteID)})
|
|
require.ErrorIs(t, v.VerifyRingtailThreshold(coronaInputs(env)), ErrCoronaVerifyFailed)
|
|
}
|
|
|
|
func TestRingtailVerifierRejectsOnAbsentPulse(t *testing.T) {
|
|
env := envFixture(t, 1, 1)
|
|
v := NewRingtailVerifier(&stubResolver{suiteID: string(warp.DefaultCoronaSuiteID)})
|
|
require.ErrorIs(t, v.VerifyRingtailThreshold(coronaInputs(env)), ErrCoronaAbsent)
|
|
}
|
|
|
|
func TestRingtailVerifierRejectsResolverError(t *testing.T) {
|
|
env := envFixture(t, 1, 1)
|
|
env.CoronaSig = bytes.Repeat([]byte{0xFF}, 32)
|
|
v := NewRingtailVerifier(&stubResolver{err: errors.New("registry down")})
|
|
require.ErrorIs(t, v.VerifyRingtailThreshold(coronaInputs(env)), ErrCoronaGroupKeyResolverFailed)
|
|
}
|
|
|
|
func TestRingtailVerifierRejectsNilGroupKey(t *testing.T) {
|
|
env := envFixture(t, 1, 1)
|
|
env.CoronaSig = bytes.Repeat([]byte{0xFF}, 32)
|
|
v := NewRingtailVerifier(&stubResolver{gk: nil, suiteID: string(warp.DefaultCoronaSuiteID)})
|
|
require.ErrorIs(t, v.VerifyRingtailThreshold(coronaInputs(env)), ErrCoronaGroupKeyResolverFailed)
|
|
}
|
|
|
|
func TestRingtailVerifierRejectsSuiteMismatch(t *testing.T) {
|
|
env := envFixture(t, 1, 1)
|
|
env.CoronaSig = bytes.Repeat([]byte{0xFF}, 32)
|
|
_, gk := runPulsarCeremony(t, 3, 2, "x")
|
|
v := NewRingtailVerifier(&stubResolver{gk: gk, suiteID: "Pulsar-SHA3-experimental"})
|
|
require.ErrorIs(t, v.VerifyRingtailThreshold(coronaInputs(env)), ErrCoronaSuiteMismatch)
|
|
}
|
|
|
|
// TestCoronaSigningBytesBindsAllTranscriptFields proves the Corona subject
|
|
// (CoronaSigningBytes(D)) changes when ANY transcript field changes —
|
|
// because D folds them all.
|
|
func TestCoronaSigningBytesBindsAllTranscriptFields(t *testing.T) {
|
|
env := envFixture(t, 7, 11)
|
|
env.Message.SourceNebulaRoot = [32]byte{0x01, 0x02}
|
|
base := warp.CoronaSigningBytes(env.Message.ID())
|
|
|
|
mutate := func(f func(c *warp.Message)) []byte {
|
|
c := env.Message
|
|
f(&c)
|
|
return warp.CoronaSigningBytes(c.ID())
|
|
}
|
|
require.NotEqual(t, base, mutate(func(c *warp.Message) { c.SourceKeyEraID = 8 }))
|
|
require.NotEqual(t, base, mutate(func(c *warp.Message) { c.SourceGeneration = 12 }))
|
|
require.NotEqual(t, base, mutate(func(c *warp.Message) { c.SourceNebulaRoot = [32]byte{0x99} }))
|
|
require.NotEqual(t, base, mutate(func(c *warp.Message) { c.HashSuiteID = "Pulsar-SHA3-other" }))
|
|
require.NotEqual(t, base, mutate(func(c *warp.Message) { c.Payload = append([]byte("X"), c.Payload...) }))
|
|
}
|
|
|
|
// TestCoronaSigningBytesPrefix proves the Corona subject carries the
|
|
// CORONA domain-separation tag.
|
|
func TestCoronaSigningBytesPrefix(t *testing.T) {
|
|
env := envFixture(t, 1, 1)
|
|
out := warp.CoronaSigningBytes(env.Message.ID())
|
|
require.True(t, bytes.HasPrefix(out, []byte("LUX-WARP-ZAP-CORONA-v1")))
|
|
d := env.Message.ID()
|
|
require.True(t, bytes.HasSuffix(out, d[:]))
|
|
}
|
|
|
|
func TestHorizonFromEnvelopePopulatesAllLanes(t *testing.T) {
|
|
env := envFixture(t, 7, 11)
|
|
env.Message.SourceNebulaRoot = [32]byte{0x42}
|
|
env.CoronaSig = bytes.Repeat([]byte{0xCC}, 16)
|
|
env.MLDSACertSet = bytes.Repeat([]byte{0xDD}, 192)
|
|
|
|
h, err := HorizonFromEnvelope(env)
|
|
require.NoError(t, err)
|
|
require.Equal(t, env.Message.SourceChainID[:], h.SourceChainID[:])
|
|
require.Equal(t, env.Beam.Signature[:], h.Beam)
|
|
require.Equal(t, env.CoronaSig, h.CoronaRingtail)
|
|
require.Equal(t, env.MLDSACertSet, h.MLDSACertSet)
|
|
require.Equal(t, env.Message.SourceNebulaRoot, h.SourceNebulaRoot)
|
|
require.Equal(t, env.Message.SourceKeyEraID, h.SourceKeyEraID)
|
|
require.Equal(t, env.Message.SourceGeneration, h.SourceGeneration)
|
|
require.Equal(t, env.Message.HashSuiteOrDefault(), h.HashSuiteID)
|
|
require.Equal(t, env.Message.Bytes(), h.UnsignedMessageBytes)
|
|
}
|
|
|
|
func TestHorizonFromEnvelopeRejectsNil(t *testing.T) {
|
|
_, err := HorizonFromEnvelope(nil)
|
|
require.Error(t, err)
|
|
}
|