mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
wire: canonical Signature + GroupKey codec, stateless VerifyBytes
Mirror of corona@42838bd. Magic PULS / PULG, version byte, bounded length-prefixed fields. VerifyBytes is stateless; wraps the FIPS 204 ML-DSA-44/65/87 verify (cloudflare/circl) over the unwrapped payload. The threshold-combined signature is byte-identical to a single-party FIPS 204 ML-DSA signature on the same message + group public key, verified by TestPulsar_Wire_FIPS204Verifiable across all three modes. Wire layout (big-endian): Signature: magic(4)='PULS' || version(2) || mode(1) || len(4) || FIPS-204-sig GroupKey: magic(4)='PULG' || version(2) || mode(1) || len(4) || FIPS-204-pk Bounded reads check declared length against the canonical FIPS 204 size for the declared mode BEFORE allocation, then against the remaining buffer — a malformed length header cannot trigger an oversized allocation. Trailing bytes after the frame are rejected. Distinct magic from corona's CORS/CORG defeats cross-protocol replay. orchestrate.go adds OrchestrateV03Sign — an in-process driver of the full Round1 / Round2W / Round2Sign / AlgebraicAggregate cycle with FIPS 204 rejection-restart, plus QuorumSessionKeys helper for the pairwise ML-KEM-768 + ML-DSA-65 identity layer. This is the surface luxfi/threshold/pkg/thresholdd consumes; lets the dispatcher publish a Signature in one call without leaking the package-private polyVec type out of pulsar. Tests: - TestPulsar_Wire_SigRoundtrip / GroupKeyRoundtrip across ModeP44/65/87 - TestPulsar_Wire_FIPS204Verifiable — frame round-trip then verify via cloudflare/circl directly with zero pulsar code on the verifier side. Headline cryptographic claim. - TestPulsar_Wire_RejectMalformed — empty, header-only, wrong-magic, group-key-magic-into-sig, wrong-version, unknown-mode, length- mismatch, length-oversized, trailing-bytes, truncated-payload. - TestPulsar_Wire_GroupKeyRejectMalformed — symmetric for GroupKey. - TestPulsar_Wire_VerifyBytes_RejectsCrossSlot — domain-separation between PULS and PULG is effective at the VerifyBytes surface. - TestPulsar_Wire_ModeMismatch_Rejected — gk.Mode != sig.Mode fails verify with no allocation surprise. - TestPulsar_Wire_MarshalSafetyChecks — nil receivers / unknown mode / wrong-length payloads refuse to emit malformed frames. Full pulsar/ref/go/pkg/pulsar test suite (87 tests, -race) green.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
// orchestrate.go — in-process orchestration helper that drives a
|
||||
// complete v0.3 algebraic-aggregate threshold sign across t parties
|
||||
// AND runs the FIPS 204 rejection-restart loop. This is the surface
|
||||
// the luxfi/threshold JSON-RPC dispatcher consumes — it lets the
|
||||
// dispatcher publish a Signature from a single function call
|
||||
// without leaking pulsar's package-private polyVec type into the
|
||||
// dispatcher.
|
||||
//
|
||||
// Trust model is the same as the test-side stageAlgebraic helper:
|
||||
// all t parties' state lives in this process. This is appropriate
|
||||
// for the JSON-RPC dispatcher (off-chain integration test harness,
|
||||
// MPC bus dev tooling, SDK fixtures), NOT for chain-genesis
|
||||
// ceremonies. Chain-genesis runs the no-trusted-dealer DKG via
|
||||
// pulsar.NewDKGSession across the messaging layer.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// OrchestrateV03Sign drives the full Round1 → Round2W → Round2Sign
|
||||
// → AlgebraicAggregate cycle for the t signers in `quorum`, looping
|
||||
// over FIPS 204 rejection-restart attempts up to maxAttempts. The
|
||||
// output Signature verifies under unmodified FIPS 204 ML-DSA
|
||||
// (Class N1 manifesto).
|
||||
//
|
||||
// Arguments:
|
||||
//
|
||||
// - params: the canonical Pulsar Params for setup.Mode.
|
||||
// - setup: the v0.3 algebraic-aggregate setup produced by
|
||||
// DealAlgebraicV03Shares (carries the group public
|
||||
// key + ρ + tr + A). No master sk inside.
|
||||
// - msg: the FIPS 204 message bytes.
|
||||
// - sessionID: a fresh per-Sign session identifier.
|
||||
// - quorum: the t-element committee (sorted by NodeID).
|
||||
// - quorumShares: each quorum member's AlgebraicKeyShare in the
|
||||
// same order as quorum.
|
||||
// - evalPoints: the Shamir x-coordinates for each quorum
|
||||
// member; precomputed via V03QuorumEvalPoints.
|
||||
// - sessionKeys: the pairwise ML-KEM-768 session keys (see
|
||||
// SymmetricSession) for every pair in the quorum.
|
||||
// - maxAttempts: cap on FIPS 204 rejection-restart attempts. Pass
|
||||
// params.MaxRestart for the production default.
|
||||
// - rng: per-party PRNG. nil falls back to crypto/rand.
|
||||
//
|
||||
// Returns the aggregated Signature on success, or an error if no
|
||||
// attempt within maxAttempts produced a signature that passes the
|
||||
// FIPS 204 norm checks (||w_0 - c·s_2||, ||z||, ||c·t_0||, hint
|
||||
// population). The probability of needing > k attempts under
|
||||
// honest sampling is ~exp(-k/5) per FIPS 204; maxAttempts=256
|
||||
// gives an abort probability < 2^-512.
|
||||
//
|
||||
// This helper does NOT compute sessionKeys for the caller — the
|
||||
// caller owns the identity layer. For a self-contained one-shot
|
||||
// API, see OrchestrateV03SignWithKeys below.
|
||||
func OrchestrateV03Sign(
|
||||
params *Params,
|
||||
setup *AlgebraicSetup,
|
||||
msg []byte,
|
||||
sessionID [16]byte,
|
||||
quorum []NodeID,
|
||||
quorumShares []*AlgebraicKeyShare,
|
||||
evalPoints []uint32,
|
||||
sessionKeys map[NodeID]map[NodeID][32]byte,
|
||||
maxAttempts uint32,
|
||||
rng io.Reader,
|
||||
) (*Signature, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setup == nil {
|
||||
return nil, ErrAlgebraicNoSetup
|
||||
}
|
||||
if setup.Mode != params.Mode {
|
||||
return nil, ErrModeMismatch
|
||||
}
|
||||
threshold := len(quorum)
|
||||
if threshold == 0 {
|
||||
return nil, ErrEmptyQuorum
|
||||
}
|
||||
if len(quorumShares) != threshold {
|
||||
return nil, fmt.Errorf("pulsar: quorumShares length %d != quorum length %d",
|
||||
len(quorumShares), threshold)
|
||||
}
|
||||
if len(evalPoints) != threshold {
|
||||
return nil, fmt.Errorf("pulsar: evalPoints length %d != quorum length %d",
|
||||
len(evalPoints), threshold)
|
||||
}
|
||||
if maxAttempts == 0 {
|
||||
maxAttempts = params.MaxRestart
|
||||
}
|
||||
if rng == nil {
|
||||
rng = rand.Reader
|
||||
}
|
||||
|
||||
K, _, _ := modeShape(setup.Mode)
|
||||
|
||||
for attempt := uint32(0); attempt < maxAttempts; attempt++ {
|
||||
signers := make([]*AlgebraicThresholdSigner, threshold)
|
||||
for i := 0; i < threshold; i++ {
|
||||
signer, err := NewAlgebraicThresholdSigner(
|
||||
params, setup, sessionID, attempt, quorum, quorumShares[i],
|
||||
sessionKeys[quorum[i]], msg, rng)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar: NewAlgebraicThresholdSigner party %d: %w", i, err)
|
||||
}
|
||||
if err := signer.SetQuorumEvalPoints(evalPoints); err != nil {
|
||||
return nil, fmt.Errorf("pulsar: SetQuorumEvalPoints party %d: %w", i, err)
|
||||
}
|
||||
signers[i] = signer
|
||||
}
|
||||
|
||||
r1 := make([]*AlgebraicRound1Message, threshold)
|
||||
for i, signer := range signers {
|
||||
m, err := signer.Round1()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar: Round1 party %d attempt %d: %w", i, attempt, err)
|
||||
}
|
||||
r1[i] = m
|
||||
}
|
||||
|
||||
r2W := make([]*AlgebraicRound2Message, threshold)
|
||||
for i, signer := range signers {
|
||||
m, _, err := signer.Round2W(r1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar: Round2W party %d attempt %d: %w", i, attempt, err)
|
||||
}
|
||||
r2W[i] = m
|
||||
}
|
||||
|
||||
peerWByParty := make([]map[NodeID]polyVec, threshold)
|
||||
for i := 0; i < threshold; i++ {
|
||||
peerW := make(map[NodeID]polyVec, threshold-1)
|
||||
for j := 0; j < threshold; j++ {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
peerW[r2W[j].NodeID] = unpackPolyVec(r2W[j].W, K)
|
||||
}
|
||||
peerWByParty[i] = peerW
|
||||
}
|
||||
|
||||
r2 := make([]*AlgebraicRound2Message, threshold)
|
||||
for i, signer := range signers {
|
||||
m, _, err := signer.Round2Sign(r1, peerWByParty[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar: Round2Sign party %d attempt %d: %w", i, attempt, err)
|
||||
}
|
||||
r2[i] = m
|
||||
}
|
||||
|
||||
sig, err := AlgebraicAggregate(params, setup, msg, sessionID, attempt,
|
||||
quorum, evalPoints, threshold, r1, r2, sessionKeys)
|
||||
if err == nil {
|
||||
return sig, nil
|
||||
}
|
||||
if !errors.Is(err, ErrAlgebraicRestart) {
|
||||
return nil, fmt.Errorf("pulsar: AlgebraicAggregate attempt %d: %w", attempt, err)
|
||||
}
|
||||
// Restart with attempt+1.
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("pulsar: no acceptance within %d FIPS 204 rejection-restart attempts", maxAttempts)
|
||||
}
|
||||
|
||||
// QuorumSessionKeys computes every pairwise ML-KEM-768-derived
|
||||
// session key for the quorum and returns each party's local view
|
||||
// (peer -> session key). Required by OrchestrateV03Sign so the
|
||||
// per-pair Round-1 MAC keys are ephemeral and authenticated under
|
||||
// each party's long-term ML-DSA-65 identity.
|
||||
//
|
||||
// For a quorum of t parties this is t*(t-1)/2 SymmetricSession
|
||||
// calls. Each SymmetricSession is deterministic from (sid,
|
||||
// transcript) so two parties agreeing on those inputs derive the
|
||||
// same key independently in production.
|
||||
//
|
||||
// identities MUST cover every NodeID in quorum; missing keys
|
||||
// produce ErrIdentityKeyMissing.
|
||||
func QuorumSessionKeys(
|
||||
quorum []NodeID,
|
||||
identities map[NodeID]*IdentityKey,
|
||||
sid [16]byte,
|
||||
transcript []byte,
|
||||
) (map[NodeID]map[NodeID][32]byte, error) {
|
||||
if len(quorum) == 0 {
|
||||
return nil, ErrEmptyQuorum
|
||||
}
|
||||
out := make(map[NodeID]map[NodeID][32]byte, len(quorum))
|
||||
for _, id := range quorum {
|
||||
out[id] = make(map[NodeID][32]byte, len(quorum)-1)
|
||||
}
|
||||
for i := 0; i < len(quorum); i++ {
|
||||
aKey, ok := identities[quorum[i]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: party %x", ErrIdentityKeyMissing, quorum[i][:4])
|
||||
}
|
||||
for j := i + 1; j < len(quorum); j++ {
|
||||
bKey, ok := identities[quorum[j]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: party %x", ErrIdentityKeyMissing, quorum[j][:4])
|
||||
}
|
||||
key, err := SymmetricSession(quorum[i], aKey, quorum[j], bKey, sid, transcript)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SymmetricSession %x↔%x: %w",
|
||||
quorum[i][:4], quorum[j][:4], err)
|
||||
}
|
||||
out[quorum[i]][quorum[j]] = key
|
||||
out[quorum[j]][quorum[i]] = key
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
// wire.go — canonical wire codec for Signature and PublicKey (the
|
||||
// group public key on the wire) plus a stateless VerifyBytes helper
|
||||
// that any independent peer can call given only:
|
||||
//
|
||||
// - the wire bytes of the group public key (PULG-framed),
|
||||
// - the bytes of the message that was signed,
|
||||
// - the wire bytes of the signature (PULS-framed),
|
||||
//
|
||||
// and obtain a bool answer with no additional state. This is the
|
||||
// surface luxfi/threshold/pkg/thresholdd consumes to publish Pulsar
|
||||
// outputs over a JSON-RPC bus — and the surface independent
|
||||
// verifiers (other mpcd, bridge nodes, L1 verifier contracts) must
|
||||
// satisfy.
|
||||
//
|
||||
// The Class N1 manifesto of Pulsar (see verify.go, spec/nist-mptc-
|
||||
// category.tex) is that a Pulsar Signature is bit-identical to a
|
||||
// single-party FIPS 204 ML-DSA signature on the same (message,
|
||||
// group public key) tuple. The wire codec preserves this property
|
||||
// by transporting the FIPS 204 bytes verbatim inside a small
|
||||
// domain-separated frame. A relying party that strips the PULS /
|
||||
// PULG frame obtains the FIPS 204 wire bytes that
|
||||
// cloudflare/circl's mldsa{44,65,87}.Verify accepts unmodified —
|
||||
// this is what TestPulsar_Wire_FIPS204Verifiable asserts.
|
||||
//
|
||||
// Wire frame layout (big-endian throughout):
|
||||
//
|
||||
// Signature:
|
||||
// magic(4) = 'P' 'U' 'L' 'S' = 0x50554C53
|
||||
// version(2) = 0x0001
|
||||
// mode(1) = 0x2C | 0x41 | 0x57 (44 | 65 | 87, the FIPS 204 level)
|
||||
// len(4) = FIPS 204 SignatureSize for mode (2420 | 3309 | 4627)
|
||||
// payload = len bytes — the FIPS 204 sigEncode(c̃, z, h) output
|
||||
//
|
||||
// GroupKey:
|
||||
// magic(4) = 'P' 'U' 'L' 'G' = 0x50554C47
|
||||
// version(2) = 0x0001
|
||||
// mode(1) = 0x2C | 0x41 | 0x57
|
||||
// len(4) = FIPS 204 PublicKeySize for mode (1312 | 1952 | 2592)
|
||||
// payload = len bytes — the FIPS 204 (ρ || t_1) encoding
|
||||
//
|
||||
// Domain separation: the four-byte magic is distinct from corona's
|
||||
// CORS / CORG (0x434F5253 / 0x434F5247), so a corona-shaped frame
|
||||
// fed into pulsar's parser is rejected at the first dispatch and
|
||||
// vice versa. Distinct magic per type within pulsar means a
|
||||
// GroupKey frame fed into the signature slot is rejected before
|
||||
// any length-decode is attempted.
|
||||
//
|
||||
// Bounded reads: every length-prefix read is checked against the
|
||||
// remaining bytes in the reader BEFORE any allocation. Beyond that,
|
||||
// the wire codec also pins the declared FIPS 204 length to the
|
||||
// canonical value for the Mode — a malformed frame that claims a
|
||||
// 4 GiB signature for ModeP65 is rejected without allocating any
|
||||
// buffer. Any wire-format change (additional field, version flip)
|
||||
// bumps versionV1.
|
||||
//
|
||||
// Trailing garbage policy: STRICT — UnmarshalBinary returns an
|
||||
// error if any byte remains after the declared frame. Mirrors
|
||||
// corona/wire.go.
|
||||
//
|
||||
// Version-bump rule: any byte-level change to the layout (new
|
||||
// field, reordered fields, bigger length prefix, additional FIPS
|
||||
// 204 mode) requires bumping versionV1. The Mode byte is
|
||||
// independent — adding ModeP44/65/87 alternates does NOT bump the
|
||||
// version because Mode is a structural payload selector, not a
|
||||
// format change.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
|
||||
)
|
||||
|
||||
// Wire-format identifiers. Distinct from corona's CORS / CORG so
|
||||
// the parsers cannot cross-accept and a passive observer can tell
|
||||
// from the first four bytes which protocol the payload belongs to.
|
||||
const (
|
||||
wireMagicPulsarSig uint32 = 0x50554C53 // "PULS" Pulsar Signature
|
||||
wireMagicPulsarGroupKey uint32 = 0x50554C47 // "PULG" Pulsar GroupKey
|
||||
wireVersionV1 uint16 = 1
|
||||
)
|
||||
|
||||
// Errors returned by the wire codec. Typed for errors.Is matching
|
||||
// in callers (the dispatcher, downstream relying parties, fuzzers).
|
||||
var (
|
||||
ErrWireMagicMismatch = errors.New("pulsar/wire: magic mismatch")
|
||||
ErrWireVersionMismatch = errors.New("pulsar/wire: version mismatch")
|
||||
ErrWireFrameTooShort = errors.New("pulsar/wire: frame too short")
|
||||
ErrWireFrameRejected = errors.New("pulsar/wire: frame rejected by bounded reader")
|
||||
ErrWireModeUnknown = errors.New("pulsar/wire: unknown mode byte")
|
||||
ErrWireLengthMismatch = errors.New("pulsar/wire: declared length does not match FIPS 204 size for mode")
|
||||
ErrWireTrailingBytes = errors.New("pulsar/wire: trailing bytes after frame")
|
||||
)
|
||||
|
||||
// wireFixedSigHeader is magic(4) + version(2) + mode(1) + len(4).
|
||||
const wireFixedSigHeader = 4 + 2 + 1 + 4
|
||||
|
||||
// wireFixedGroupKeyHeader has the same shape as wireFixedSigHeader.
|
||||
const wireFixedGroupKeyHeader = wireFixedSigHeader
|
||||
|
||||
// MarshalBinary serialises a Signature into the canonical PULS frame.
|
||||
//
|
||||
// The body bytes are the FIPS 204 sigEncode(c̃, z, h) output
|
||||
// verbatim — Pulsar adds NO envelope around them. Stripping the
|
||||
// 11-byte header recovers the unmodified FIPS 204 bytes that
|
||||
// cloudflare/circl's mldsa{44,65,87}.Verify accepts. This is the
|
||||
// load-bearing property TestPulsar_Wire_FIPS204Verifiable pins.
|
||||
//
|
||||
// Returns an error if the Signature's Mode is unknown, its Bytes
|
||||
// field does not match the FIPS 204 size for the Mode, or the
|
||||
// Signature receiver is nil.
|
||||
func (s *Signature) MarshalBinary() ([]byte, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("pulsar: nil Signature")
|
||||
}
|
||||
sigSize, err := sigSizeForMode(s.Mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar/wire: %w", err)
|
||||
}
|
||||
if len(s.Bytes) != sigSize {
|
||||
return nil, fmt.Errorf("pulsar/wire: Signature.Bytes length %d != FIPS 204 size %d for %s",
|
||||
len(s.Bytes), sigSize, s.Mode)
|
||||
}
|
||||
|
||||
out := make([]byte, 0, wireFixedSigHeader+sigSize)
|
||||
out = binary.BigEndian.AppendUint32(out, wireMagicPulsarSig)
|
||||
out = binary.BigEndian.AppendUint16(out, wireVersionV1)
|
||||
out = append(out, byte(s.Mode))
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(sigSize))
|
||||
out = append(out, s.Bytes...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnmarshalBinary parses a Signature from canonical PULS frame.
|
||||
//
|
||||
// Validation order is strict: magic, version, mode, length-against-
|
||||
// FIPS 204-size are all checked BEFORE any payload bytes are read.
|
||||
// The codec never allocates a payload buffer larger than the canonical
|
||||
// FIPS 204 signature size for the declared mode, so a malformed length
|
||||
// header cannot trigger an oversized allocation.
|
||||
//
|
||||
// Trailing bytes after the frame are rejected (ErrWireTrailingBytes)
|
||||
// to keep the format strictly canonical — there is exactly one
|
||||
// well-formed encoding of any (mode, bytes) pair.
|
||||
func (s *Signature) UnmarshalBinary(b []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("pulsar: nil Signature receiver")
|
||||
}
|
||||
if len(b) < wireFixedSigHeader {
|
||||
return ErrWireFrameTooShort
|
||||
}
|
||||
r := bytes.NewReader(b)
|
||||
|
||||
var magic uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read magic: %w", err)
|
||||
}
|
||||
if magic != wireMagicPulsarSig {
|
||||
return fmt.Errorf("%w: got 0x%08x, want 0x%08x", ErrWireMagicMismatch, magic, wireMagicPulsarSig)
|
||||
}
|
||||
|
||||
var version uint16
|
||||
if err := binary.Read(r, binary.BigEndian, &version); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read version: %w", err)
|
||||
}
|
||||
if version != wireVersionV1 {
|
||||
return fmt.Errorf("%w: got %d, want %d", ErrWireVersionMismatch, version, wireVersionV1)
|
||||
}
|
||||
|
||||
modeByte, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read mode: %w", err)
|
||||
}
|
||||
mode := Mode(modeByte)
|
||||
sigSize, err := sigSizeForMode(mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrWireModeUnknown, err)
|
||||
}
|
||||
|
||||
var declared uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &declared); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read length: %w", err)
|
||||
}
|
||||
// Bounded read: declared length must equal the canonical FIPS 204
|
||||
// size for the mode AND must not exceed the remaining buffer.
|
||||
// Both checks happen BEFORE the payload allocation.
|
||||
if int(declared) != sigSize {
|
||||
return fmt.Errorf("%w: declared %d, FIPS 204 size for %s is %d",
|
||||
ErrWireLengthMismatch, declared, mode, sigSize)
|
||||
}
|
||||
if int(declared) > r.Len() {
|
||||
return fmt.Errorf("%w: declared length %d exceeds remaining %d",
|
||||
ErrWireFrameRejected, declared, r.Len())
|
||||
}
|
||||
|
||||
payload := make([]byte, declared)
|
||||
if _, err := r.Read(payload); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read payload: %w", err)
|
||||
}
|
||||
if r.Len() != 0 {
|
||||
return fmt.Errorf("%w: %d bytes remaining", ErrWireTrailingBytes, r.Len())
|
||||
}
|
||||
|
||||
s.Mode = mode
|
||||
s.Bytes = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalBinary serialises a PublicKey (used on the wire as the
|
||||
// group public key) into the canonical PULG frame.
|
||||
//
|
||||
// The body bytes are the FIPS 204 (ρ || t_1) public-key encoding
|
||||
// verbatim — Pulsar adds NO envelope around them. Stripping the
|
||||
// 11-byte header recovers the unmodified FIPS 204 public-key bytes
|
||||
// that cloudflare/circl's mldsa{44,65,87}.PublicKey.Unpack accepts.
|
||||
//
|
||||
// Returns an error if the PublicKey's Mode is unknown, its Bytes
|
||||
// field does not match the FIPS 204 size for the Mode, or the
|
||||
// PublicKey receiver is nil.
|
||||
func (p *PublicKey) MarshalBinary() ([]byte, error) {
|
||||
if p == nil {
|
||||
return nil, errors.New("pulsar: nil PublicKey")
|
||||
}
|
||||
pkSize, err := pubKeySizeForMode(p.Mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pulsar/wire: %w", err)
|
||||
}
|
||||
if len(p.Bytes) != pkSize {
|
||||
return nil, fmt.Errorf("pulsar/wire: PublicKey.Bytes length %d != FIPS 204 size %d for %s",
|
||||
len(p.Bytes), pkSize, p.Mode)
|
||||
}
|
||||
|
||||
out := make([]byte, 0, wireFixedGroupKeyHeader+pkSize)
|
||||
out = binary.BigEndian.AppendUint32(out, wireMagicPulsarGroupKey)
|
||||
out = binary.BigEndian.AppendUint16(out, wireVersionV1)
|
||||
out = append(out, byte(p.Mode))
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(pkSize))
|
||||
out = append(out, p.Bytes...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnmarshalBinary parses a PublicKey from canonical PULG frame.
|
||||
//
|
||||
// Validation order matches Signature.UnmarshalBinary: magic,
|
||||
// version, mode, length-against-FIPS 204-size are all checked
|
||||
// BEFORE any payload bytes are read. Trailing bytes after the
|
||||
// frame are rejected.
|
||||
func (p *PublicKey) UnmarshalBinary(b []byte) error {
|
||||
if p == nil {
|
||||
return errors.New("pulsar: nil PublicKey receiver")
|
||||
}
|
||||
if len(b) < wireFixedGroupKeyHeader {
|
||||
return ErrWireFrameTooShort
|
||||
}
|
||||
r := bytes.NewReader(b)
|
||||
|
||||
var magic uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read magic: %w", err)
|
||||
}
|
||||
if magic != wireMagicPulsarGroupKey {
|
||||
return fmt.Errorf("%w: got 0x%08x, want 0x%08x", ErrWireMagicMismatch, magic, wireMagicPulsarGroupKey)
|
||||
}
|
||||
|
||||
var version uint16
|
||||
if err := binary.Read(r, binary.BigEndian, &version); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read version: %w", err)
|
||||
}
|
||||
if version != wireVersionV1 {
|
||||
return fmt.Errorf("%w: got %d, want %d", ErrWireVersionMismatch, version, wireVersionV1)
|
||||
}
|
||||
|
||||
modeByte, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read mode: %w", err)
|
||||
}
|
||||
mode := Mode(modeByte)
|
||||
pkSize, err := pubKeySizeForMode(mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrWireModeUnknown, err)
|
||||
}
|
||||
|
||||
var declared uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &declared); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read length: %w", err)
|
||||
}
|
||||
if int(declared) != pkSize {
|
||||
return fmt.Errorf("%w: declared %d, FIPS 204 size for %s is %d",
|
||||
ErrWireLengthMismatch, declared, mode, pkSize)
|
||||
}
|
||||
if int(declared) > r.Len() {
|
||||
return fmt.Errorf("%w: declared length %d exceeds remaining %d",
|
||||
ErrWireFrameRejected, declared, r.Len())
|
||||
}
|
||||
|
||||
payload := make([]byte, declared)
|
||||
if _, err := r.Read(payload); err != nil {
|
||||
return fmt.Errorf("pulsar/wire: read payload: %w", err)
|
||||
}
|
||||
if r.Len() != 0 {
|
||||
return fmt.Errorf("%w: %d bytes remaining", ErrWireTrailingBytes, r.Len())
|
||||
}
|
||||
|
||||
p.Mode = mode
|
||||
p.Bytes = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyBytes is the stateless verifier the threshold orchestration
|
||||
// layer (luxfi/threshold/pkg/thresholdd) needs to publish a
|
||||
// signature over a JSON-RPC bus: it accepts the canonical wire
|
||||
// bytes of a GroupKey (the group public key, in a PULG frame) and
|
||||
// a Signature (in a PULS frame) plus the message that was signed,
|
||||
// and returns true iff the signature verifies under the group key.
|
||||
//
|
||||
// Rejection of any malformed input returns false (NOT an error) —
|
||||
// the dispatcher distinguishes "no valid signature" from
|
||||
// "infrastructure error" via its JSON-RPC envelope; bytes-in,
|
||||
// bool-out keeps this helper pure. The signature MUST verify under
|
||||
// the FIPS 204 verifier verbatim (no Pulsar envelope) — this
|
||||
// matches the Class N1 manifesto.
|
||||
//
|
||||
// Mode mismatch between the parsed Signature and parsed GroupKey
|
||||
// is treated as verification failure (returns false). This keeps
|
||||
// the helper allocation-free of any cross-mode dispatch surprises.
|
||||
//
|
||||
// Empty context: VerifyBytes calls FIPS 204 Verify with an empty
|
||||
// context string. Callers needing the FIPS 204 §6.3 context binding
|
||||
// must transport the context bytes by including them in the
|
||||
// message argument (canonical convention: ctx || 0x00 || msg). The
|
||||
// wire codec stays narrow so the dispatcher remains a thin proxy.
|
||||
func VerifyBytes(gpkBytes, message, sigBytes []byte) bool {
|
||||
var gk PublicKey
|
||||
if err := gk.UnmarshalBinary(gpkBytes); err != nil {
|
||||
return false
|
||||
}
|
||||
var sig Signature
|
||||
if err := sig.UnmarshalBinary(sigBytes); err != nil {
|
||||
return false
|
||||
}
|
||||
if gk.Mode != sig.Mode {
|
||||
return false
|
||||
}
|
||||
return mldsaVerifyMode(gk.Mode, gk.Bytes, message, nil, sig.Bytes)
|
||||
}
|
||||
|
||||
// mldsaVerifyMode is the constant-time FIPS 204 Verify dispatch.
|
||||
// Kept distinct from sign.go's mldsaVerify so this file is
|
||||
// self-contained for the wire layer; both paths call into the
|
||||
// same circl primitives.
|
||||
func mldsaVerifyMode(mode Mode, packedPk, message, ctx, sig []byte) bool {
|
||||
switch mode {
|
||||
case ModeP44:
|
||||
if len(packedPk) != mldsa44.PublicKeySize || len(sig) != mldsa44.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa44.PublicKey
|
||||
var pkBuf [mldsa44.PublicKeySize]byte
|
||||
copy(pkBuf[:], packedPk)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa44.Verify(&pk, message, ctx, sig)
|
||||
case ModeP65:
|
||||
if len(packedPk) != mldsa65.PublicKeySize || len(sig) != mldsa65.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa65.PublicKey
|
||||
var pkBuf [mldsa65.PublicKeySize]byte
|
||||
copy(pkBuf[:], packedPk)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa65.Verify(&pk, message, ctx, sig)
|
||||
case ModeP87:
|
||||
if len(packedPk) != mldsa87.PublicKeySize || len(sig) != mldsa87.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa87.PublicKey
|
||||
var pkBuf [mldsa87.PublicKeySize]byte
|
||||
copy(pkBuf[:], packedPk)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa87.Verify(&pk, message, ctx, sig)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sigSizeForMode returns the FIPS 204 signature size for the
|
||||
// declared mode, or ErrWireModeUnknown if the mode is unrecognised.
|
||||
// Centralising this here means the wire codec does not depend on
|
||||
// params.go's table — the codec carries its own canonical numbers
|
||||
// so a corrupted Params can never confuse the parser.
|
||||
func sigSizeForMode(mode Mode) (int, error) {
|
||||
switch mode {
|
||||
case ModeP44:
|
||||
return mldsa44.SignatureSize, nil
|
||||
case ModeP65:
|
||||
return mldsa65.SignatureSize, nil
|
||||
case ModeP87:
|
||||
return mldsa87.SignatureSize, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("mode 0x%02x", byte(mode))
|
||||
}
|
||||
}
|
||||
|
||||
// pubKeySizeForMode returns the FIPS 204 public-key size for the
|
||||
// declared mode.
|
||||
func pubKeySizeForMode(mode Mode) (int, error) {
|
||||
switch mode {
|
||||
case ModeP44:
|
||||
return mldsa44.PublicKeySize, nil
|
||||
case ModeP65:
|
||||
return mldsa65.PublicKeySize, nil
|
||||
case ModeP87:
|
||||
return mldsa87.PublicKeySize, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("mode 0x%02x", byte(mode))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pulsar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
|
||||
)
|
||||
|
||||
// TestPulsar_Wire_SigRoundtrip checks the Signature wire codec
|
||||
// round-trips byte-equal for every mode and that the parsed Mode +
|
||||
// Bytes match the input exactly. A second marshal MUST produce the
|
||||
// same bytes as the first (strict canonical encoding).
|
||||
func TestPulsar_Wire_SigRoundtrip(t *testing.T) {
|
||||
for _, mode := range []Mode{ModeP44, ModeP65, ModeP87} {
|
||||
mode := mode
|
||||
t.Run(mode.String(), func(t *testing.T) {
|
||||
params := MustParamsFor(mode)
|
||||
body := make([]byte, params.SignatureSize)
|
||||
if _, err := rand.Read(body); err != nil {
|
||||
t.Fatalf("rand.Read: %v", err)
|
||||
}
|
||||
sig := &Signature{Mode: mode, Bytes: append([]byte{}, body...)}
|
||||
|
||||
wire1, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalBinary: %v", err)
|
||||
}
|
||||
if got := len(wire1); got != wireFixedSigHeader+params.SignatureSize {
|
||||
t.Fatalf("wire length %d != header(%d) + payload(%d)",
|
||||
got, wireFixedSigHeader, params.SignatureSize)
|
||||
}
|
||||
|
||||
var parsed Signature
|
||||
if err := parsed.UnmarshalBinary(wire1); err != nil {
|
||||
t.Fatalf("UnmarshalBinary: %v", err)
|
||||
}
|
||||
if parsed.Mode != mode {
|
||||
t.Fatalf("parsed.Mode = %v want %v", parsed.Mode, mode)
|
||||
}
|
||||
if !bytes.Equal(parsed.Bytes, body) {
|
||||
t.Fatalf("parsed.Bytes diverged from input")
|
||||
}
|
||||
|
||||
wire2, err := parsed.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("re-MarshalBinary: %v", err)
|
||||
}
|
||||
if !bytes.Equal(wire1, wire2) {
|
||||
t.Fatalf("re-marshal not byte-equal: %d vs %d", len(wire1), len(wire2))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_GroupKeyRoundtrip mirrors TestPulsar_Wire_SigRoundtrip
|
||||
// for PublicKey (the group public key on the wire).
|
||||
func TestPulsar_Wire_GroupKeyRoundtrip(t *testing.T) {
|
||||
for _, mode := range []Mode{ModeP44, ModeP65, ModeP87} {
|
||||
mode := mode
|
||||
t.Run(mode.String(), func(t *testing.T) {
|
||||
params := MustParamsFor(mode)
|
||||
body := make([]byte, params.PublicKeySize)
|
||||
if _, err := rand.Read(body); err != nil {
|
||||
t.Fatalf("rand.Read: %v", err)
|
||||
}
|
||||
pk := &PublicKey{Mode: mode, Bytes: append([]byte{}, body...)}
|
||||
|
||||
wire1, err := pk.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalBinary: %v", err)
|
||||
}
|
||||
if got := len(wire1); got != wireFixedGroupKeyHeader+params.PublicKeySize {
|
||||
t.Fatalf("wire length %d != header(%d) + payload(%d)",
|
||||
got, wireFixedGroupKeyHeader, params.PublicKeySize)
|
||||
}
|
||||
|
||||
var parsed PublicKey
|
||||
if err := parsed.UnmarshalBinary(wire1); err != nil {
|
||||
t.Fatalf("UnmarshalBinary: %v", err)
|
||||
}
|
||||
if parsed.Mode != mode {
|
||||
t.Fatalf("parsed.Mode = %v want %v", parsed.Mode, mode)
|
||||
}
|
||||
if !bytes.Equal(parsed.Bytes, body) {
|
||||
t.Fatalf("parsed.Bytes diverged from input")
|
||||
}
|
||||
|
||||
wire2, err := parsed.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("re-MarshalBinary: %v", err)
|
||||
}
|
||||
if !bytes.Equal(wire1, wire2) {
|
||||
t.Fatalf("re-marshal not byte-equal: %d vs %d", len(wire1), len(wire2))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_FIPS204Verifiable is the headline cryptographic
|
||||
// claim of the wire codec: a Pulsar Signature, after MarshalBinary +
|
||||
// payload-extraction, verifies under cloudflare/circl's FIPS 204
|
||||
// mldsa{44,65,87}.Verify with NO Pulsar code path involved on the
|
||||
// verifier side.
|
||||
//
|
||||
// This is the Class N1 manifesto in test form: the threshold
|
||||
// orchestration layer can hand wire bytes to a relying party that
|
||||
// has never heard of Pulsar, and that party — with only a FIPS 204
|
||||
// ML-DSA verifier and the documented PULS / PULG frame — verifies
|
||||
// the signature.
|
||||
//
|
||||
// For every mode we run the single-party deterministic Sign path
|
||||
// (the production threshold path emits a bit-identical signature,
|
||||
// proved by TestAlgebraic_FullCycle_n5_t3 and TestAlgebraic_ByteValid;
|
||||
// the relevant property here is that the WIRE FORMAT preserves the
|
||||
// FIPS 204 bytes verbatim, which is mode-agnostic and orthogonal to
|
||||
// whether the signer was single-party or threshold).
|
||||
func TestPulsar_Wire_FIPS204Verifiable(t *testing.T) {
|
||||
for _, mode := range []Mode{ModeP44, ModeP65, ModeP87} {
|
||||
mode := mode
|
||||
t.Run(mode.String(), func(t *testing.T) {
|
||||
params := MustParamsFor(mode)
|
||||
var seed [SeedSize]byte
|
||||
copy(seed[:], "pulsar-wire-test-deterministic!!")
|
||||
seed[0] ^= byte(mode) // distinct seed per mode
|
||||
|
||||
sk, err := KeyFromSeed(params, seed)
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed: %v", err)
|
||||
}
|
||||
msg := []byte("pulsar wire codec — FIPS 204 byte-identity claim")
|
||||
// Deterministic signing so the test is repeatable.
|
||||
sig, err := Sign(params, sk, msg, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
// 1. Sanity: pulsar's own Verify accepts it.
|
||||
if err := Verify(params, sk.Pub, msg, sig); err != nil {
|
||||
t.Fatalf("baseline Verify failed before wire: %v", err)
|
||||
}
|
||||
|
||||
// 2. Frame both via the wire codec.
|
||||
gkWire, err := sk.Pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("Pub.MarshalBinary: %v", err)
|
||||
}
|
||||
sigWire, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("sig.MarshalBinary: %v", err)
|
||||
}
|
||||
|
||||
// 3. Extract the FIPS 204 payload from each frame by skipping
|
||||
// the magic(4) + version(2) + mode(1) + len(4) header. NO
|
||||
// pulsar package code touches the payload — this is exactly
|
||||
// what an external relying party with a FIPS 204 verifier
|
||||
// would do.
|
||||
fipsPK := extractFIPSPayload(t, gkWire, wireMagicPulsarGroupKey)
|
||||
fipsSig := extractFIPSPayload(t, sigWire, wireMagicPulsarSig)
|
||||
|
||||
// 4. Verify under cloudflare/circl directly. No pulsar code
|
||||
// path is hit on the verify side.
|
||||
if !verifyDirectCirc(mode, fipsPK, msg, fipsSig) {
|
||||
t.Fatalf("cloudflare/circl FIPS 204 Verify rejected the unwrapped Pulsar bytes — wire codec broke byte-identity")
|
||||
}
|
||||
|
||||
// 5. Stateless VerifyBytes path used by thresholdd accepts
|
||||
// the same bytes.
|
||||
if !VerifyBytes(gkWire, msg, sigWire) {
|
||||
t.Fatalf("VerifyBytes rejected a valid signature")
|
||||
}
|
||||
|
||||
// 6. Tamper resistance: flip a byte in the sig payload —
|
||||
// direct circl verify MUST reject, and VerifyBytes must
|
||||
// reject.
|
||||
tamperedSig := append([]byte{}, sigWire...)
|
||||
tamperedSig[len(tamperedSig)-1] ^= 0x01
|
||||
if VerifyBytes(gkWire, msg, tamperedSig) {
|
||||
t.Fatalf("VerifyBytes accepted a tampered signature")
|
||||
}
|
||||
tamperedFips := append([]byte{}, fipsSig...)
|
||||
tamperedFips[len(tamperedFips)-1] ^= 0x01
|
||||
if verifyDirectCirc(mode, fipsPK, msg, tamperedFips) {
|
||||
t.Fatalf("circl FIPS 204 Verify accepted a tampered signature")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractFIPSPayload strips the wire header from a PULS or PULG
|
||||
// frame and returns the FIPS 204 payload bytes. Asserts the magic
|
||||
// matches the expected one. Test-only — production wire callers go
|
||||
// through (Signature|PublicKey).UnmarshalBinary.
|
||||
func extractFIPSPayload(t *testing.T, wire []byte, wantMagic uint32) []byte {
|
||||
t.Helper()
|
||||
if len(wire) < wireFixedSigHeader {
|
||||
t.Fatalf("wire frame too short: %d", len(wire))
|
||||
}
|
||||
gotMagic := binary.BigEndian.Uint32(wire[0:4])
|
||||
if gotMagic != wantMagic {
|
||||
t.Fatalf("magic mismatch: got 0x%08x want 0x%08x", gotMagic, wantMagic)
|
||||
}
|
||||
version := binary.BigEndian.Uint16(wire[4:6])
|
||||
if version != wireVersionV1 {
|
||||
t.Fatalf("version mismatch: got %d want %d", version, wireVersionV1)
|
||||
}
|
||||
declared := binary.BigEndian.Uint32(wire[7:11])
|
||||
if int(declared) != len(wire)-wireFixedSigHeader {
|
||||
t.Fatalf("declared length %d != payload length %d",
|
||||
declared, len(wire)-wireFixedSigHeader)
|
||||
}
|
||||
return wire[wireFixedSigHeader:]
|
||||
}
|
||||
|
||||
// verifyDirectCirc bypasses every Pulsar code path and calls
|
||||
// cloudflare/circl's FIPS 204 Verify directly. This proves the
|
||||
// wire codec's body bytes are valid FIPS 204 bytes.
|
||||
func verifyDirectCirc(mode Mode, pkBytes, msg, sigBytes []byte) bool {
|
||||
switch mode {
|
||||
case ModeP44:
|
||||
if len(pkBytes) != mldsa44.PublicKeySize || len(sigBytes) != mldsa44.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa44.PublicKey
|
||||
var pkBuf [mldsa44.PublicKeySize]byte
|
||||
copy(pkBuf[:], pkBytes)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa44.Verify(&pk, msg, nil, sigBytes)
|
||||
case ModeP65:
|
||||
if len(pkBytes) != mldsa65.PublicKeySize || len(sigBytes) != mldsa65.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa65.PublicKey
|
||||
var pkBuf [mldsa65.PublicKeySize]byte
|
||||
copy(pkBuf[:], pkBytes)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa65.Verify(&pk, msg, nil, sigBytes)
|
||||
case ModeP87:
|
||||
if len(pkBytes) != mldsa87.PublicKeySize || len(sigBytes) != mldsa87.SignatureSize {
|
||||
return false
|
||||
}
|
||||
var pk mldsa87.PublicKey
|
||||
var pkBuf [mldsa87.PublicKeySize]byte
|
||||
copy(pkBuf[:], pkBytes)
|
||||
pk.Unpack(&pkBuf)
|
||||
return mldsa87.Verify(&pk, msg, nil, sigBytes)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_RejectMalformed exercises every negative path the
|
||||
// signature parser must catch. Each malformed input must surface a
|
||||
// typed error from the wire package; no panic, no oversized
|
||||
// allocation, no payload buffer for invalid magic/version/mode/length.
|
||||
func TestPulsar_Wire_RejectMalformed(t *testing.T) {
|
||||
validSig := func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
mode := ModeP65
|
||||
params := MustParamsFor(mode)
|
||||
body := make([]byte, params.SignatureSize)
|
||||
s := &Signature{Mode: mode, Bytes: body}
|
||||
w, err := s.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("setup MarshalBinary: %v", err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(t *testing.T) []byte
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
build: func(t *testing.T) []byte { return nil },
|
||||
want: ErrWireFrameTooShort,
|
||||
},
|
||||
{
|
||||
name: "header-only",
|
||||
build: func(t *testing.T) []byte {
|
||||
return make([]byte, wireFixedSigHeader-1)
|
||||
},
|
||||
want: ErrWireFrameTooShort,
|
||||
},
|
||||
{
|
||||
name: "wrong-magic",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
w[0] = 0xDE
|
||||
w[1] = 0xAD
|
||||
w[2] = 0xBE
|
||||
w[3] = 0xEF
|
||||
return w
|
||||
},
|
||||
want: ErrWireMagicMismatch,
|
||||
},
|
||||
{
|
||||
name: "group-key-magic-into-sig",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
// Replace PULS with PULG.
|
||||
binary.BigEndian.PutUint32(w[0:4], wireMagicPulsarGroupKey)
|
||||
return w
|
||||
},
|
||||
want: ErrWireMagicMismatch,
|
||||
},
|
||||
{
|
||||
name: "wrong-version",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
binary.BigEndian.PutUint16(w[4:6], 0xFFFF)
|
||||
return w
|
||||
},
|
||||
want: ErrWireVersionMismatch,
|
||||
},
|
||||
{
|
||||
name: "unknown-mode",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
w[6] = 0xAA // not 44, 65, or 87
|
||||
return w
|
||||
},
|
||||
want: ErrWireModeUnknown,
|
||||
},
|
||||
{
|
||||
name: "length-mismatch-mode-65",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
// Declared length 999 for ModeP65 — not the canonical 3309.
|
||||
binary.BigEndian.PutUint32(w[7:11], 999)
|
||||
return w
|
||||
},
|
||||
want: ErrWireLengthMismatch,
|
||||
},
|
||||
{
|
||||
name: "length-oversized-vs-buffer",
|
||||
build: func(t *testing.T) []byte {
|
||||
// Manually craft: PULS, v1, mode=44, declared=4 GiB.
|
||||
w := make([]byte, wireFixedSigHeader)
|
||||
binary.BigEndian.PutUint32(w[0:4], wireMagicPulsarSig)
|
||||
binary.BigEndian.PutUint16(w[4:6], wireVersionV1)
|
||||
w[6] = byte(ModeP44)
|
||||
binary.BigEndian.PutUint32(w[7:11], 0xFFFFFFFF)
|
||||
return w
|
||||
},
|
||||
// 4 GiB is not equal to FIPS 204 mldsa44 size; the length
|
||||
// mismatch fires before the buffer-bounds check.
|
||||
want: ErrWireLengthMismatch,
|
||||
},
|
||||
{
|
||||
name: "trailing-bytes",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
return append(w, 0x00, 0x00, 0x00)
|
||||
},
|
||||
want: ErrWireTrailingBytes,
|
||||
},
|
||||
{
|
||||
name: "truncated-payload",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validSig(t)
|
||||
// Drop the trailing 3 bytes of the payload. The declared
|
||||
// length still matches the canonical FIPS 204 size so
|
||||
// length-against-mode check passes; the bounded-reader
|
||||
// check fires because declared > remaining buffer.
|
||||
return w[:len(w)-3]
|
||||
},
|
||||
want: ErrWireFrameRejected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var sig Signature
|
||||
err := sig.UnmarshalBinary(tc.build(t))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error %v, got nil", tc.want)
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_GroupKeyRejectMalformed mirrors the signature
|
||||
// rejection suite for GroupKey. Crucially, a SIGNATURE frame fed
|
||||
// into the GroupKey parser must be rejected at the magic check —
|
||||
// domain separation between PULS and PULG must be effective.
|
||||
func TestPulsar_Wire_GroupKeyRejectMalformed(t *testing.T) {
|
||||
validGk := func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
mode := ModeP65
|
||||
params := MustParamsFor(mode)
|
||||
body := make([]byte, params.PublicKeySize)
|
||||
p := &PublicKey{Mode: mode, Bytes: body}
|
||||
w, err := p.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("setup MarshalBinary: %v", err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(t *testing.T) []byte
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
build: func(t *testing.T) []byte { return nil },
|
||||
want: ErrWireFrameTooShort,
|
||||
},
|
||||
{
|
||||
name: "sig-magic-into-groupkey",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validGk(t)
|
||||
binary.BigEndian.PutUint32(w[0:4], wireMagicPulsarSig)
|
||||
return w
|
||||
},
|
||||
want: ErrWireMagicMismatch,
|
||||
},
|
||||
{
|
||||
name: "wrong-version",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validGk(t)
|
||||
binary.BigEndian.PutUint16(w[4:6], 0xFFFF)
|
||||
return w
|
||||
},
|
||||
want: ErrWireVersionMismatch,
|
||||
},
|
||||
{
|
||||
name: "unknown-mode",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validGk(t)
|
||||
w[6] = 0xAA
|
||||
return w
|
||||
},
|
||||
want: ErrWireModeUnknown,
|
||||
},
|
||||
{
|
||||
name: "length-mismatch",
|
||||
build: func(t *testing.T) []byte {
|
||||
w := validGk(t)
|
||||
binary.BigEndian.PutUint32(w[7:11], 7)
|
||||
return w
|
||||
},
|
||||
want: ErrWireLengthMismatch,
|
||||
},
|
||||
{
|
||||
name: "trailing-bytes",
|
||||
build: func(t *testing.T) []byte {
|
||||
return append(validGk(t), 0x00)
|
||||
},
|
||||
want: ErrWireTrailingBytes,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gk PublicKey
|
||||
err := gk.UnmarshalBinary(tc.build(t))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error %v, got nil", tc.want)
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_VerifyBytes_RejectsCrossSlot exercises the
|
||||
// dispatcher's contract that wire bytes cannot be swapped between
|
||||
// slots: a GroupKey-magic frame fed into the signature slot of
|
||||
// VerifyBytes must produce false. Same for a Signature-magic frame
|
||||
// fed into the GroupKey slot.
|
||||
func TestPulsar_Wire_VerifyBytes_RejectsCrossSlot(t *testing.T) {
|
||||
mode := ModeP65
|
||||
params := MustParamsFor(mode)
|
||||
var seed [SeedSize]byte
|
||||
copy(seed[:], "pulsar-wire-cross-slot-test-32!!")
|
||||
sk, err := KeyFromSeed(params, seed)
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed: %v", err)
|
||||
}
|
||||
msg := []byte("cross-slot rejection")
|
||||
sig, err := Sign(params, sk, msg, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
gkWire, err := sk.Pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("gk MarshalBinary: %v", err)
|
||||
}
|
||||
sigWire, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("sig MarshalBinary: %v", err)
|
||||
}
|
||||
|
||||
if !VerifyBytes(gkWire, msg, sigWire) {
|
||||
t.Fatalf("baseline VerifyBytes failed")
|
||||
}
|
||||
// Swap: gkWire in the signature slot. Must reject.
|
||||
if VerifyBytes(gkWire, msg, gkWire) {
|
||||
t.Fatalf("VerifyBytes accepted GroupKey-magic bytes in signature slot")
|
||||
}
|
||||
// Swap: sigWire in the group-key slot. Must reject.
|
||||
if VerifyBytes(sigWire, msg, sigWire) {
|
||||
t.Fatalf("VerifyBytes accepted Signature-magic bytes in group-key slot")
|
||||
}
|
||||
// nil bytes — must reject without panic.
|
||||
if VerifyBytes(nil, msg, sigWire) {
|
||||
t.Fatalf("VerifyBytes accepted nil group-key bytes")
|
||||
}
|
||||
if VerifyBytes(gkWire, msg, nil) {
|
||||
t.Fatalf("VerifyBytes accepted nil signature bytes")
|
||||
}
|
||||
// Wrong message — must reject.
|
||||
if VerifyBytes(gkWire, []byte("different message"), sigWire) {
|
||||
t.Fatalf("VerifyBytes accepted a wrong message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_ModeMismatch_Rejected ensures the dispatcher's
|
||||
// VerifyBytes returns false when the parsed GroupKey and Signature
|
||||
// declare different modes — a relying party must never witness a
|
||||
// ModeP65 signature verifying under a ModeP44 group key.
|
||||
func TestPulsar_Wire_ModeMismatch_Rejected(t *testing.T) {
|
||||
// Build a valid sig (ModeP65) and a valid gk (ModeP44).
|
||||
skP65, err := KeyFromSeed(MustParamsFor(ModeP65), [SeedSize]byte{1})
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed P65: %v", err)
|
||||
}
|
||||
skP44, err := KeyFromSeed(MustParamsFor(ModeP44), [SeedSize]byte{2})
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed P44: %v", err)
|
||||
}
|
||||
msg := []byte("mode mismatch test")
|
||||
sigP65, err := Sign(MustParamsFor(ModeP65), skP65, msg, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign P65: %v", err)
|
||||
}
|
||||
gkP44Wire, err := skP44.Pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("gk P44 MarshalBinary: %v", err)
|
||||
}
|
||||
sigP65Wire, err := sigP65.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("sig P65 MarshalBinary: %v", err)
|
||||
}
|
||||
if VerifyBytes(gkP44Wire, msg, sigP65Wire) {
|
||||
t.Fatalf("VerifyBytes accepted cross-mode (gk=P44, sig=P65)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Wire_MarshalSafetyChecks ensures Marshal refuses
|
||||
// inputs that would emit malformed frames: nil receiver, unknown
|
||||
// mode, length-vs-mode mismatch.
|
||||
func TestPulsar_Wire_MarshalSafetyChecks(t *testing.T) {
|
||||
t.Run("nil-signature", func(t *testing.T) {
|
||||
var s *Signature
|
||||
if _, err := s.MarshalBinary(); err == nil {
|
||||
t.Fatal("nil Signature.MarshalBinary did not return error")
|
||||
}
|
||||
})
|
||||
t.Run("nil-publickey", func(t *testing.T) {
|
||||
var p *PublicKey
|
||||
if _, err := p.MarshalBinary(); err == nil {
|
||||
t.Fatal("nil PublicKey.MarshalBinary did not return error")
|
||||
}
|
||||
})
|
||||
t.Run("sig-unknown-mode", func(t *testing.T) {
|
||||
s := &Signature{Mode: Mode(0xAA), Bytes: make([]byte, 3309)}
|
||||
if _, err := s.MarshalBinary(); err == nil {
|
||||
t.Fatal("unknown-mode Signature.MarshalBinary did not return error")
|
||||
}
|
||||
})
|
||||
t.Run("sig-wrong-length", func(t *testing.T) {
|
||||
s := &Signature{Mode: ModeP65, Bytes: make([]byte, 999)}
|
||||
if _, err := s.MarshalBinary(); err == nil {
|
||||
t.Fatal("wrong-length Signature.MarshalBinary did not return error")
|
||||
}
|
||||
})
|
||||
t.Run("gk-wrong-length", func(t *testing.T) {
|
||||
p := &PublicKey{Mode: ModeP65, Bytes: make([]byte, 99)}
|
||||
if _, err := p.MarshalBinary(); err == nil {
|
||||
t.Fatal("wrong-length PublicKey.MarshalBinary did not return error")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user