test: add pq-crystals reference cross-validator for FIPS 204 interop

Adds an INDEPENDENT verifier binding to the pq-crystals/dilithium
reference implementation so Pulsar signatures can be cross-checked
against the canonical FIPS 204 reference verifier. This is the same
discipline as the libjade Jasmin track: prove byte-equality / accept-
equivalence against an authoritative third-party impl.

The pq-crystals tree itself (~MB) is fetched on demand via fetch.sh
at a pinned commit and gitignored to keep history small. Only the Go
binding (verifier.go), C glue (pq_crystals_verify.{c,h}), class test
(pq_crystals_class_test.go), and the fetch script live in git.

WHY: closes the third-party-verifier gap for ML-DSA equivalence
testing without vendoring upstream sources.
This commit is contained in:
Hanzo AI
2026-05-20 15:54:06 -07:00
parent 1a084d6b8d
commit 7f8b5e32f0
6 changed files with 800 additions and 0 deletions
+7
View File
@@ -46,3 +46,10 @@ proofs/easycrypt/extraction/build/
# EasyCrypt compiled object files
proofs/easycrypt/*.eco
proofs/easycrypt/lemmas/*.eco
# pq-crystals reference impl (regenerated by test/interoperability/pq_crystals/fetch.sh)
test/interoperability/pq_crystals/dilithium/
test/interoperability/pq_crystals/obj.*/
test/interoperability/pq_crystals/libpqcrystals_*.a
test/interoperability/pq_crystals/libpqcrystals_*.so
test/interoperability/pq_crystals/libpqcrystals_*.dylib
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# Fetch the pq-crystals/dilithium reference implementation at the
# pinned commit used by Pulsar's cross-validation gate.
#
# pq-crystals is the construction reference for FIPS 204 ML-DSA
# (Dilithium was standardised AS ML-DSA in FIPS 204). The repository
# is at <https://github.com/pq-crystals/dilithium>. We do NOT vendor
# the tree into this repository for the same reason the libjade
# Jasmin track does not: the tree is ~MB-scale, its license is
# permissive (CC0 + Apache-2.0 dual), and re-cloning at the pinned
# commit on demand gives a deterministic, reviewer-reproducible
# artefact without bloating the Pulsar git history.
#
# After running this script:
# ./libpqcrystals_dilithium2.a ML-DSA-44 static archive
# ./libpqcrystals_dilithium3.a ML-DSA-65 static archive
# ./libpqcrystals_dilithium5.a ML-DSA-87 static archive
#
# These are linked by verifier.go via #cgo LDFLAGS. The Go binding
# only exposes the verification surface — the keygen and sign code
# paths are present in the archive but the binding does not call
# them; this keeps the "INDEPENDENT verifier" discipline tight
# (the cross-validator literally cannot do anything other than
# verify under FIPS 204's reference verifier).
set -euo pipefail
DILITHIUM_REPO="https://github.com/pq-crystals/dilithium.git"
# Pinned at submission-tag time. pq-crystals master tracks the
# standardised FIPS 204 ML-DSA construction (see upstream README).
# Update IN LOCKSTEP with the Pulsar submission tag so the artefact
# chain stays reproducible.
#
# Commit 6e00625 (2026 master HEAD at sign-off time) corresponds to
# the FIPS 204-aligned ref/ tree with the `crypto_sign_verify(sig,
# siglen, m, mlen, ctx, ctxlen, pk)` C API that this binding uses.
DILITHIUM_COMMIT="6e00625c5b29f516c6de973fe2ee2fbb150973f9"
# SHA-256 of `git archive --format=tar HEAD` at the pinned commit.
# `git archive` output is deterministic for a given tree object, so
# this hash is stable across reviewer clones. (GitHub's autogenerated
# tarball at /archive/<sha>.tar.gz is NOT stable — do not pin against
# it.) Computed from a fresh clone with:
#
# git archive --format=tar HEAD | shasum -a 256
#
# Update when DILITHIUM_COMMIT is bumped.
DILITHIUM_TREE_SHA256="fa6221d8aaa14c9311144b18bc09a217794c3bad11d60a04366d2c3c33f4b48b"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if [[ -d dilithium/.git ]]; then
echo "==> pq-crystals/dilithium already cloned at $SCRIPT_DIR/dilithium"
( cd dilithium && git fetch --quiet origin && git checkout --quiet "$DILITHIUM_COMMIT" )
else
echo "==> cloning pq-crystals/dilithium at $DILITHIUM_COMMIT"
git clone --quiet "$DILITHIUM_REPO" dilithium
( cd dilithium && git checkout --quiet "$DILITHIUM_COMMIT" )
fi
# Verify the pinned tree hash. A mismatch means upstream rewrote
# history or our pin is stale — either way, fail loudly so the
# reviewer is not silently building against an unverified tree.
ACTUAL_SHA256="$(cd dilithium && git archive --format=tar HEAD | shasum -a 256 | awk '{print $1}')"
if [[ "$ACTUAL_SHA256" != "$DILITHIUM_TREE_SHA256" ]]; then
echo "!! pq-crystals tree-hash mismatch at $DILITHIUM_COMMIT" >&2
echo "!! expected: $DILITHIUM_TREE_SHA256" >&2
echo "!! actual: $ACTUAL_SHA256" >&2
echo "!! upstream may have rewritten history; re-verify and re-pin" >&2
exit 1
fi
echo "==> tree hash verified: $ACTUAL_SHA256"
# Compile the reference implementation into a single static archive
# per parameter set. Each archive contains the FIPS 204 sign /
# verify / keypair entry points for one parameter set, with all
# fips202 (SHAKE) helpers included. We never call sign or keypair
# from the cgo binding — only verify — but they are in the archive
# because they share TUs with verify.
#
# The reference Makefile builds .so shared libs that omit the
# fips202.c TU (it lives in libpqcrystals_fips202_ref.so). For a
# static archive consumed by cgo we want everything in one .a so
# the Go linker does not need a separate RPATH-eligible .so for
# the SHAKE helpers. We compile each TU once per parameter set
# (DILITHIUM_MODE) and pack with ar.
CC=${CC:-cc}
AR=${AR:-ar}
RANLIB=${RANLIB:-ranlib}
# pq-crystals' authoring style is -Wall -Wextra -Wpedantic clean;
# we mirror their CFLAGS so the archive matches their build.
#
# CFLAGS rationale:
# -O3 -fomit-frame-pointer matches upstream Makefile
# -fPIC archive may be linked into a PIE
# (cgo on darwin/arm64 produces PIEs)
# no -Werror we want this to compile cleanly across
# clang minor versions without surprise
# warning-as-error breakage
DILCFLAGS=(-Wall -Wextra -Wpedantic -Wmissing-prototypes -Wredundant-decls
-Wshadow -Wvla -Wpointer-arith -O3 -fomit-frame-pointer
-fPIC)
# Per-parameter-set TUs. The pq-crystals symbol-namespacing scheme
# (DILITHIUM_NAMESPACE in params.h, FIPS202_NAMESPACE in symmetric.h)
# makes the compiled symbols distinct per mode (e.g.
# `pqcrystals_dilithium3_ref_verify`,
# `pqcrystals_dilithium3_ref_dilithium_shake128_stream_init`), so
# all three parameter sets must compile each TU under their own
# DILITHIUM_MODE — including `symmetric-shake.c`, which carries
# the dilithium_shake{128,256}_stream_init helpers that ARE mode-
# namespaced. Only `fips202.c` itself is mode-independent (its
# symbols live under `pqcrystals_dilithium_fips202_ref_*` with no
# mode token), so it is compiled exactly once.
DIL_MLDSA_SOURCES=(sign.c packing.c polyvec.c poly.c ntt.c reduce.c rounding.c
symmetric-shake.c)
# Shared FIPS 202 (SHAKE) TUs. NOT mode-dependent.
DIL_FIPS202_SOURCES=(fips202.c)
cd dilithium/ref
OBJDIR="../../obj.dilithium"
rm -rf "$OBJDIR"
mkdir -p "$OBJDIR"
# Compile shared FIPS 202 TUs once (mode-independent).
for src in "${DIL_FIPS202_SOURCES[@]}"; do
obj="$OBJDIR/$(basename "$src" .c).o"
"$CC" "${DILCFLAGS[@]}" -c "$src" -o "$obj"
done
# Compile ML-DSA TUs once per parameter set.
build_objs_for_mode() {
local mode="$1" # 2 | 3 | 5
local suffix="$2" # m2 | m3 | m5
for src in "${DIL_MLDSA_SOURCES[@]}"; do
obj="$OBJDIR/$(basename "$src" .c).${suffix}.o"
"$CC" "${DILCFLAGS[@]}" -DDILITHIUM_MODE="$mode" -c "$src" -o "$obj"
done
}
build_objs_for_mode 2 m2
build_objs_for_mode 3 m3
build_objs_for_mode 5 m5
# Compile a no-op randombytes stub. The pq-crystals reference impl
# pulls `randombytes` (declared in randombytes.h, defined in
# randombytes.c) from sign.c's keygen and signing entry points.
# Our cgo binding NEVER calls keygen or sign — only verify — but
# the unused keygen/sign symbols are present in the archive and
# would create an unresolved-symbol error at Go link time without
# a definition.
#
# Providing a stub that aborts loudly is the safer option than
# pulling in a real CSPRNG: if anyone ever links a Go test binary
# that accidentally calls the keygen or sign path, we want the
# process to die immediately rather than silently producing
# signatures with zero entropy. The "independent verifier"
# discipline lives at the Go API surface (verifier.go exposes
# only Verify*), so this stub is the belt-and-braces enforcement.
cat > "$OBJDIR/randombytes_stub.c" <<'EOF'
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
void randombytes(uint8_t *out, size_t outlen);
void randombytes(uint8_t *out, size_t outlen) {
/* If this function is ever called, the cgo binding has been
* misused: the Pulsar interoperability harness only invokes
* verify, never keygen or sign. Abort loudly so the test
* process dies rather than silently producing all-zero
* randomness. */
(void)out;
(void)outlen;
abort();
}
EOF
"$CC" "${DILCFLAGS[@]}" -c "$OBJDIR/randombytes_stub.c" -o "$OBJDIR/randombytes_stub.o"
# Pack all objects into one archive. The mode-2/3/5 ML-DSA symbols
# do not collide with one another (namespace-distinct), the fips202
# symbols appear exactly once, and the randombytes stub satisfies
# the unused keygen/sign call sites.
ARCHIVE="../../libpqcrystals_dilithium.a"
rm -f "$ARCHIVE"
"$AR" rcs "$ARCHIVE" "$OBJDIR"/*.o
"$RANLIB" "$ARCHIVE"
cd "$SCRIPT_DIR"
echo
echo "==> pq-crystals static archive ready (44/65/87 in one .a):"
ls -la libpqcrystals_dilithium.a
echo
echo "==> verifying expected symbols:"
for mode in 2 3 5; do
if ! nm libpqcrystals_dilithium.a 2>&1 | grep -q "T _pqcrystals_dilithium${mode}_ref_verify"; then
echo "!! missing pqcrystals_dilithium${mode}_ref_verify symbol" >&2
exit 1
fi
echo " pqcrystals_dilithium${mode}_ref_verify present"
done
@@ -0,0 +1,264 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Cross-validation of every Pulsar KAT signature against the
// pq-crystals/dilithium reference FIPS 204 verifier.
//
// This file mirrors test/interoperability/n1_class_test.go subtest
// for subtest. The only difference is the verifier under test: every
// test here dispatches to pq_crystals.Verify* (a cgo binding to the
// upstream reference verifier) instead of cloudflare/circl. Both
// verifiers must accept the same KAT vectors for the cross-validation
// gate to pass.
//
// Tagged build constraint: `pulsar_pqcrystals`. The default Pulsar
// build does not have the static archive available; the cross-
// validation gate sets the tag and runs fetch.sh first.
//go:build pulsar_pqcrystals
package pq_crystals
import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
// Public-key derivation from KAT seed uses cloudflare/circl's
// deterministic keygen — the same convention as n1_class_test.go.
// This is NOT a violation of "independent verifier" because the
// VERIFICATION uses pq-crystals; circl is only used here to
// reproduce the (deterministic) seed → pk mapping the reference
// impl used at vector-generation time.
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
)
// katVector mirrors the schema in n1_class_test.go. We deliberately
// duplicate the struct rather than import — the cross-validation
// suites are independently buildable, and this binding sits behind
// a build tag.
type katVector struct {
Mode string `json:"mode"`
Seed string `json:"seed"`
PublicKey string `json:"public_key,omitempty"`
Message string `json:"message"`
Context string `json:"context,omitempty"`
Signature string `json:"signature"`
}
// vectorsDir is the canonical KAT directory under the repo root.
// From test/interoperability/pq_crystals/ it is three levels up.
const vectorsDir = "../../../vectors"
// TestN1_PqCrystals_SinglePartySignatures_VerifyUnderRef asserts
// every single-party signature in vectors/sign.json verifies under
// the pq-crystals/dilithium reference FIPS 204 verifier — the
// SECOND independent verifier the Pulsar submission cross-validates
// against (the first being cloudflare/circl in n1_class_test.go).
//
// Both verifiers must accept the same KAT. A disagreement is
// either (a) a Pulsar bug that one verifier catches and the other
// misses, or (b) a verifier disagreement worth tracking upstream.
// Either way it is a release blocker — see scripts/checks/test/
// interop.sh.
func TestN1_PqCrystals_SinglePartySignatures_VerifyUnderRef(t *testing.T) {
signs := loadVectors(t, filepath.Join(vectorsDir, "sign.json"))
if len(signs) == 0 {
t.Fatal("vectors/sign.json is empty — run scripts/gen_vectors.sh")
}
for i, v := range signs {
t.Run(fmt.Sprintf("%s/%d", v.Mode, i), func(t *testing.T) {
seed := mustHex(t, v.Seed)
pk, err := derivePublicKeyFromSeed(v.Mode, seed)
if err != nil {
t.Fatalf("derive pk from seed: %v", err)
}
msg := mustHex(t, v.Message)
sig := mustHex(t, v.Signature)
ctx := mustHex(t, v.Context)
if err := verifyUnderPqCrystals(v.Mode, pk, msg, sig, ctx); err != nil {
t.Fatalf("pq-crystals verify failed: %v\n"+
" mode = %s\n seed = %s\n msg len = %d\n sig len = %d",
err, v.Mode, v.Seed, len(msg), len(sig))
}
})
}
}
// TestN1_PqCrystals_ThresholdSignatures_VerifyUnderRef is the
// headline Class N1 cross-validation: every threshold-produced
// signature in vectors/threshold-sign.json verifies under the
// pq-crystals reference verifier. If any KAT here is rejected,
// the byte-equality claim has broken under an independent verifier.
func TestN1_PqCrystals_ThresholdSignatures_VerifyUnderRef(t *testing.T) {
tsigns := loadVectors(t, filepath.Join(vectorsDir, "threshold-sign.json"))
if len(tsigns) == 0 {
t.Fatal("vectors/threshold-sign.json is empty — run scripts/gen_vectors.sh")
}
for i, v := range tsigns {
t.Run(fmt.Sprintf("%s/%d", v.Mode, i), func(t *testing.T) {
if v.PublicKey == "" {
t.Fatalf("threshold-sign.json entry %d missing public_key field", i)
}
pk := mustHex(t, v.PublicKey)
msg := mustHex(t, v.Message)
sig := mustHex(t, v.Signature)
ctx := mustHex(t, v.Context)
if err := verifyUnderPqCrystals(v.Mode, pk, msg, sig, ctx); err != nil {
t.Fatalf(
"CRITICAL: threshold-produced signature did NOT verify under "+
"pq-crystals/dilithium reference verifier.\n"+
"This breaks Class N1 byte-equality under an independent verifier.\n"+
" err = %v\n mode = %s\n seed = %s\n pk len = %d\n sig len = %d",
err, v.Mode, v.Seed, len(pk), len(sig))
}
})
}
}
// TestN1_PqCrystals_TamperedSignatures_Rejected guards against the
// "verifier always accepts" vacuous pass — exactly mirrors the
// circl-side test in n1_class_test.go.
func TestN1_PqCrystals_TamperedSignatures_Rejected(t *testing.T) {
signs := loadVectors(t, filepath.Join(vectorsDir, "sign.json"))
if len(signs) == 0 {
t.Fatal("vectors/sign.json is empty")
}
seen := map[string]bool{}
for _, v := range signs {
if seen[v.Mode] {
continue
}
seen[v.Mode] = true
t.Run(v.Mode, func(t *testing.T) {
seed := mustHex(t, v.Seed)
pk, err := derivePublicKeyFromSeed(v.Mode, seed)
if err != nil {
t.Fatalf("derive pk: %v", err)
}
msg := mustHex(t, v.Message)
sig := mustHex(t, v.Signature)
ctx := mustHex(t, v.Context)
tampered := append([]byte(nil), sig...)
tampered[len(tampered)/2] ^= 0x01
if err := verifyUnderPqCrystals(v.Mode, pk, msg, tampered, ctx); err == nil {
t.Fatal("tampered signature verified successfully under pq-crystals — verifier is broken (vacuous pass)")
}
})
}
}
// TestN1_PqCrystals_WrongMessage_Rejected asserts joint
// (pk, message, signature) binding under the pq-crystals verifier.
func TestN1_PqCrystals_WrongMessage_Rejected(t *testing.T) {
signs := loadVectors(t, filepath.Join(vectorsDir, "sign.json"))
seen := map[string]bool{}
for _, v := range signs {
if seen[v.Mode] {
continue
}
seen[v.Mode] = true
t.Run(v.Mode, func(t *testing.T) {
seed := mustHex(t, v.Seed)
pk, err := derivePublicKeyFromSeed(v.Mode, seed)
if err != nil {
t.Fatalf("derive pk: %v", err)
}
sig := mustHex(t, v.Signature)
ctx := mustHex(t, v.Context)
wrongMsg := []byte("not the original message")
if err := verifyUnderPqCrystals(v.Mode, pk, wrongMsg, sig, ctx); err == nil {
t.Fatal("pq-crystals verifier accepted signature against unrelated message — joint binding broken")
}
})
}
}
// derivePublicKeyFromSeed mirrors n1_class_test.go. We use circl's
// deterministic keygen to obtain the public key bytes — pq-crystals
// uses an internal randombytes path for keygen which our binding
// deliberately does not link, so circl is the deterministic
// substitute. The keygen output is FIPS 204 §3.5.5 byte-for-byte
// identical to pq-crystals' keygen on the same seed (this is
// itself one of the cross-validation invariants Pulsar relies on;
// see ref/go/pkg/pulsar/keygen.go).
func derivePublicKeyFromSeed(mode string, seedBytes []byte) ([]byte, error) {
if len(seedBytes) != 32 {
return nil, fmt.Errorf("seed must be 32 bytes, got %d", len(seedBytes))
}
var seed [32]byte
copy(seed[:], seedBytes)
switch mode {
case "Pulsar-44", "ML-DSA-44":
pk, _ := mldsa44.NewKeyFromSeed(&seed)
return pk.MarshalBinary()
case "Pulsar-65", "ML-DSA-65":
pk, _ := mldsa65.NewKeyFromSeed(&seed)
return pk.MarshalBinary()
case "Pulsar-87", "ML-DSA-87":
pk, _ := mldsa87.NewKeyFromSeed(&seed)
return pk.MarshalBinary()
default:
return nil, fmt.Errorf("unknown mode %q", mode)
}
}
// verifyUnderPqCrystals dispatches to the cgo binding for the
// named ML-DSA parameter set.
func verifyUnderPqCrystals(mode string, pk, msg, sig, ctx []byte) error {
switch mode {
case "Pulsar-44", "ML-DSA-44":
return VerifyMLDSA44(pk, msg, sig, ctx)
case "Pulsar-65", "ML-DSA-65":
return VerifyMLDSA65(pk, msg, sig, ctx)
case "Pulsar-87", "ML-DSA-87":
return VerifyMLDSA87(pk, msg, sig, ctx)
default:
return fmt.Errorf("unknown mode %q (expect Pulsar-{44,65,87} or ML-DSA-{44,65,87})", mode)
}
}
// loadVectors reads a JSON array of katVector.
func loadVectors(t *testing.T, path string) []katVector {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v\n(hint: run scripts/gen_vectors.sh)", path, err)
}
var out []katVector
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
// mustHex decodes a hex string or fails the test.
func mustHex(t *testing.T, s string) []byte {
t.Helper()
if s == "" {
return nil
}
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("decode hex %q: %v", s, err)
}
return b
}
@@ -0,0 +1,92 @@
/* SPDX-License-Identifier: Apache-2.0 OR ISC
*
* Thin C bridge from the cgo binding to the pq-crystals/dilithium
* reference FIPS 204 verifier. The actual verification is the
* unmodified upstream `pqcrystals_dilithium{2,3,5}_ref_verify`
* routine; this file only renames the per-parameter-set symbols
* to a stable surface so cgo does not need to know the namespacing
* convention.
*
* Each parameter set lives in the static archive built by fetch.sh:
* libpqcrystals_dilithium.a
* which packs sign.c / packing.c / polyvec.c / poly.c / ntt.c /
* reduce.c / rounding.c at DILITHIUM_MODE=2,3,5, plus
* fips202.c + symmetric-shake.c shared.
*
* The upstream API:
*
* int pqcrystals_dilithium{N}_ref_verify(
* const uint8_t *sig, size_t siglen,
* const uint8_t *m, size_t mlen,
* const uint8_t *ctx, size_t ctxlen,
* const uint8_t *pk);
*
* Returns 0 on accept, non-zero on reject. We pass it through
* unchanged with the argument order rearranged so the Go-side
* caller can pass (pk, sig, msg, ctx) in the same order it does
* for the cloudflare/circl verifier.
*/
#include <stddef.h>
#include <stdint.h>
#include "pq_crystals_verify.h"
/* Forward declarations for the namespaced upstream symbols. We
* deliberately do NOT include pq-crystals' api.h here — that header
* carries the upstream parameter macros, but the symbol declarations
* with their unambiguous size assumptions are clearer when stated
* directly. The pq-crystals header would also drag in `<stddef.h>`
* and platform-shape includes that are not needed here.
*/
extern int pqcrystals_dilithium2_ref_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *ctx, size_t ctxlen,
const uint8_t *pk);
extern int pqcrystals_dilithium3_ref_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *ctx, size_t ctxlen,
const uint8_t *pk);
extern int pqcrystals_dilithium5_ref_verify(const uint8_t *sig, size_t siglen,
const uint8_t *m, size_t mlen,
const uint8_t *ctx, size_t ctxlen,
const uint8_t *pk);
int lux_pulsar_pqc_verify_mldsa44(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen) {
if (siglen != LUX_PULSAR_MLDSA44_SIG_BYTES) {
return -1;
}
return pqcrystals_dilithium2_ref_verify(sig, siglen,
msg, mlen,
ctx, ctxlen,
pk);
}
int lux_pulsar_pqc_verify_mldsa65(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen) {
if (siglen != LUX_PULSAR_MLDSA65_SIG_BYTES) {
return -1;
}
return pqcrystals_dilithium3_ref_verify(sig, siglen,
msg, mlen,
ctx, ctxlen,
pk);
}
int lux_pulsar_pqc_verify_mldsa87(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen) {
if (siglen != LUX_PULSAR_MLDSA87_SIG_BYTES) {
return -1;
}
return pqcrystals_dilithium5_ref_verify(sig, siglen,
msg, mlen,
ctx, ctxlen,
pk);
}
@@ -0,0 +1,72 @@
/* SPDX-License-Identifier: Apache-2.0 OR ISC
*
* Thin C wrapper exposing pq-crystals/dilithium reference FIPS 204
* verification under stable, non-namespaced symbol names that the
* cgo binding in verifier.go can call without redeclaring every
* pq-crystals symbol.
*
* Each function returns 0 on a verified signature, non-zero on
* rejection. This is the same return convention as upstream
* crypto_sign_verify (FIPS 204 ML-DSA.Verify accept = return 0).
*
* The wrapper bridges only the verification surface — keygen and
* sign live in the underlying static archive but are not exposed
* here. The "independent verifier" discipline requires this
* binding to be incapable of producing signatures of its own;
* it can only accept or reject the byte string under inspection.
*/
#ifndef LUX_PULSAR_PQ_CRYSTALS_VERIFY_H
#define LUX_PULSAR_PQ_CRYSTALS_VERIFY_H
#include <stddef.h>
#include <stdint.h>
/* Public key sizes per FIPS 204 §3.5.5.
* ML-DSA-44 = 1312, ML-DSA-65 = 1952, ML-DSA-87 = 2592.
* Signature sizes per FIPS 204 §3.5.5.
* ML-DSA-44 = 2420, ML-DSA-65 = 3309, ML-DSA-87 = 4627.
*/
#define LUX_PULSAR_MLDSA44_PK_BYTES 1312
#define LUX_PULSAR_MLDSA44_SIG_BYTES 2420
#define LUX_PULSAR_MLDSA65_PK_BYTES 1952
#define LUX_PULSAR_MLDSA65_SIG_BYTES 3309
#define LUX_PULSAR_MLDSA87_PK_BYTES 2592
#define LUX_PULSAR_MLDSA87_SIG_BYTES 4627
#ifdef __cplusplus
extern "C" {
#endif
/* Verify an ML-DSA-44 signature under the pq-crystals/dilithium
* reference FIPS 204 verifier (DILITHIUM_MODE=2).
*
* pk FIPS 204 §3.5.5 public key, must be LUX_PULSAR_MLDSA44_PK_BYTES.
* sig signature, must be LUX_PULSAR_MLDSA44_SIG_BYTES.
* msg, mlen message bytes.
* ctx, ctxlen ML-DSA context string (may be NULL with ctxlen=0).
*
* Returns 0 if the signature verifies, non-zero otherwise.
*/
int lux_pulsar_pqc_verify_mldsa44(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen);
/* Verify an ML-DSA-65 signature (DILITHIUM_MODE=3). See above. */
int lux_pulsar_pqc_verify_mldsa65(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen);
/* Verify an ML-DSA-87 signature (DILITHIUM_MODE=5). See above. */
int lux_pulsar_pqc_verify_mldsa87(const uint8_t *pk,
const uint8_t *sig, size_t siglen,
const uint8_t *msg, size_t mlen,
const uint8_t *ctx, size_t ctxlen);
#ifdef __cplusplus
}
#endif
#endif /* LUX_PULSAR_PQ_CRYSTALS_VERIFY_H */
@@ -0,0 +1,160 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package pq_crystals is a cgo binding to the pq-crystals/dilithium
// reference FIPS 204 verifier — the construction reference for
// ML-DSA. It exists for the sole purpose of CROSS-VALIDATING the
// signatures Pulsar produces against an INDEPENDENT FIPS 204
// verifier that is not cloudflare/circl (the other independent
// verifier already wired in at test/interoperability/n1_class_test.go).
//
// The cgo binding is verify-only. Keygen and sign live in the
// underlying static archive, but the Go surface deliberately does
// not expose them — the "independent verifier" discipline requires
// this binding to be incapable of producing signatures of its own.
// It can only accept or reject a byte string offered to it.
//
// Build requirements:
//
// - CGO_ENABLED=1
// - A working C compiler (any of cc / clang / gcc)
// - Run fetch.sh first to clone pq-crystals at the pinned
// commit and build libpqcrystals_dilithium.a in this directory
//
// If CGO_ENABLED=0 or the archive is missing, the package fails
// to build; callers under the high-assurance gate must arrange for
// fetch.sh to have run before invoking go test on this package.
//
// The build tag `pulsar_pqcrystals` is required to enable this
// binding. Tests under this directory carry the same build tag.
// This keeps the default Pulsar build (which does not have the
// archive) green; the cross-validation gate sets the tag explicitly.
//go:build pulsar_pqcrystals
package pq_crystals
/*
#cgo CFLAGS: -I${SRCDIR}
// macOS and Linux have different conventions for resolving the
// archive path. We pass the archive by absolute path via SRCDIR
// so both platforms resolve identically. The archive lives in
// the same directory as this Go file; fetch.sh puts it there.
#cgo LDFLAGS: ${SRCDIR}/libpqcrystals_dilithium.a
#include "pq_crystals_verify.h"
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
// ErrPqCrystalsReject is returned when the pq-crystals reference
// verifier rejects the (pk, msg, sig, ctx) tuple.
var ErrPqCrystalsReject = errors.New("pq-crystals/dilithium reference verifier rejected the signature")
// VerifyMLDSA44 runs the pq-crystals DILITHIUM_MODE=2 reference
// verifier on (pk, msg, sig) with ML-DSA context string ctx.
//
// FIPS 204 ML-DSA-44 sizes: pk = 1312 bytes, sig = 2420 bytes.
// Returns nil iff the signature verifies; otherwise wraps the
// reference verifier's non-zero return.
func VerifyMLDSA44(pk, msg, sig, ctx []byte) error {
return doVerify(44, pk, msg, sig, ctx)
}
// VerifyMLDSA65 runs the pq-crystals DILITHIUM_MODE=3 reference
// verifier. ML-DSA-65 sizes: pk = 1952, sig = 3309.
func VerifyMLDSA65(pk, msg, sig, ctx []byte) error {
return doVerify(65, pk, msg, sig, ctx)
}
// VerifyMLDSA87 runs the pq-crystals DILITHIUM_MODE=5 reference
// verifier. ML-DSA-87 sizes: pk = 2592, sig = 4627.
func VerifyMLDSA87(pk, msg, sig, ctx []byte) error {
return doVerify(87, pk, msg, sig, ctx)
}
// doVerify dispatches to the parameter-set-specific entry point.
// The size precondition is enforced both here (defensive) and in
// the C wrapper (authoritative); we want a clear Go-side error
// if the caller has handed us malformed input rather than a cryptic
// negative return code from the upstream verifier.
func doVerify(mode int, pk, msg, sig, ctx []byte) error {
var wantPK, wantSig int
switch mode {
case 44:
wantPK = C.LUX_PULSAR_MLDSA44_PK_BYTES
wantSig = C.LUX_PULSAR_MLDSA44_SIG_BYTES
case 65:
wantPK = C.LUX_PULSAR_MLDSA65_PK_BYTES
wantSig = C.LUX_PULSAR_MLDSA65_SIG_BYTES
case 87:
wantPK = C.LUX_PULSAR_MLDSA87_PK_BYTES
wantSig = C.LUX_PULSAR_MLDSA87_SIG_BYTES
default:
return fmt.Errorf("unknown ML-DSA mode %d", mode)
}
if len(pk) != wantPK {
return fmt.Errorf("ML-DSA-%d pk: expected %d bytes, got %d", mode, wantPK, len(pk))
}
if len(sig) != wantSig {
return fmt.Errorf("ML-DSA-%d sig: expected %d bytes, got %d", mode, wantSig, len(sig))
}
// Empty slices map to nil C pointers; cgo will accept this
// and pq-crystals correctly handles ctxlen=0 (per FIPS 204
// §5.4.1, ctx is optional and may be a zero-length string).
pkPtr := cBytesPtr(pk)
msgPtr := cBytesPtr(msg)
sigPtr := cBytesPtr(sig)
ctxPtr := cBytesPtr(ctx)
var rc C.int
switch mode {
case 44:
rc = C.lux_pulsar_pqc_verify_mldsa44(
pkPtr,
sigPtr, C.size_t(len(sig)),
msgPtr, C.size_t(len(msg)),
ctxPtr, C.size_t(len(ctx)),
)
case 65:
rc = C.lux_pulsar_pqc_verify_mldsa65(
pkPtr,
sigPtr, C.size_t(len(sig)),
msgPtr, C.size_t(len(msg)),
ctxPtr, C.size_t(len(ctx)),
)
case 87:
rc = C.lux_pulsar_pqc_verify_mldsa87(
pkPtr,
sigPtr, C.size_t(len(sig)),
msgPtr, C.size_t(len(msg)),
ctxPtr, C.size_t(len(ctx)),
)
}
if rc != 0 {
return fmt.Errorf("%w (rc=%d, mode=ML-DSA-%d)", ErrPqCrystalsReject, int(rc), mode)
}
return nil
}
// cBytesPtr returns a *C.uint8_t pointer to the first byte of the
// slice, or nil if the slice is empty. cgo will not invent a
// non-nil pointer for an empty slice; we encode that explicitly
// so the upstream verifier sees a true NULL for a missing
// context string, matching the FIPS 204 §5.4.1 "no context"
// case (ctxlen = 0).
func cBytesPtr(b []byte) *C.uint8_t {
if len(b) == 0 {
return nil
}
return (*C.uint8_t)(unsafe.Pointer(&b[0]))
}