mirror of
https://github.com/luxfi/pulsar.git
synced 2026-07-26 22:53:49 +00:00
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:
@@ -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
|
||||
|
||||
Executable
+205
@@ -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]))
|
||||
}
|
||||
Reference in New Issue
Block a user