Files
zeekay c79fe61d62 consolidate: merge pulsar-mptc submission framework into canonical luxfi/pulsar
Single repo, one canonical impl + spec + proofs + KAT + cut tool.

Per "ONE and ONLY ONE way to do things" the prior dual-repo split
(luxfi/pulsar = production library, luxfi/pulsar-mptc = NIST MPTC
submission framework) is collapsed into a single canonical home at
github.com/luxfi/pulsar. SINGLE-IMPL-PLAN.md (now marked COMPLETED)
describes the steps; this commit lands the final step.

Files moved (89 new):
  16 submission docs (SUBMISSION, NIST-SUBMISSION, SPEC, SUITE,
     PATENTS, AXIOM-INVENTORY, PROOF-CLAIMS, FIPS-TRACEABILITY,
     TRUSTED-COMPUTING-BASE, HANZO-CRYPTO-SUITE, INFORMATION-
     ARCHITECTURE, ROADMAP, CHANGELOG, SYNC-STATUS, STATUS-
     SUBMISSION-READINESS, SINGLE-IMPL-PLAN)
  10 docs/ supplements (ietf-draft-skeleton, magnetar, evaluation,
     patent-claims, x-wing-sig, design-decisions, family-architecture,
     threat-model, nist-mptc-category, patent-notes-draft)
  19 proofs/ (13 EC theories + 2 EC lemmas + 4 metadata/READMEs +
     lean-easycrypt-bridge.md)
  14 jasmin/ (lib/* + ml-dsa-65 fetch + threshold/{combine,round1,
     round2}.jazz + READMEs)
  10 ct/dudect/ (Makefile, combine_ct.go, dudect_combine.c,
     dudect_compat.h, dudect_verify.c, fetch.sh, README, run-
     submission.sh, verify_ct.go) + ct/jasmin-ct-libjade.md
  1 .github/workflows/ci.yml
  1 test/interoperability/n1_class_test.go (19/19 N1 subtests)
  1 bench/results/REPORT.md
  11 scripts/ (cut-submission, check-high-assurance, check-lean-
     bridge, nightly, extract-jasmin-ec, checks/{ec-admits, ec-
     compile, ec-refinement-scaffold, ec-regressions, extraction,
     jasmin}, checks/test/{go-unit, interop, kat, no-secret-logs})

Files merged (16 overlapping, mptc-canonical chosen because newer +
post-rewire-correct; pulsar versions still bore stale "Pulsar-M" /
"pulsarm" branding):
  README.md, BLOCKERS.md, CONTRIBUTING.md, LICENSE, LICENSING.md,
  SECURITY.md, .gitignore, bench/run_all.sh, vectors/dkg.json (v1.0.7
  regen), scripts/{bench,build,gen_vectors,sbom,test}.sh,
  spec/{pulsar,parameters,system-model}.tex,
  CRYPTOGRAPHER-SIGN-OFF.md (path refs).

Pulsar-only spec supplements preserved:
  spec/{blockers,design-decisions,family-architecture,nist-mptc-
  category,patent-notes,threat-model}.tex.

Path rewrites applied: 158 occurrences across 26 files
  github.com/luxfi/pulsar-mptc -> github.com/luxfi/pulsar
  ~/work/lux/pulsar-mptc/      -> ~/work/lux/pulsar/
  luxfi/pulsar-mptc            -> luxfi/pulsar
  pulsar-mptc/                  -> ./

Narrative collapses (no more two-repo framing):
  SUBMISSION.md "At a glance" — single Repository row
  SYNC-STATUS.md — rewritten to single-repo state
  SINGLE-IMPL-PLAN.md — marked COMPLETED 2026-05-18
  HANZO-CRYPTO-SUITE.md — tier 1 row = single canonical home
  scripts/cut-submission.sh — replace/vendor dance removed; cuts
  directly from in-tree canonical sources

go.mod: dropped mptc's replace directive (no longer needed since this
IS the canonical pulsar repo). Module path is github.com/luxfi/pulsar.
Deps: cloudflare/circl v1.6.3, golang.org/x/crypto v0.32.0.

Verification:
  GOWORK=off go build ./...              PASS
  GOWORK=off go test -count=1 ./...      PASS (1.4s)
  GOWORK=off go test -race ./ref/...     PASS (14.7s)
  go test ./test/interoperability/       PASS (19/19 N1 subtests)
  scripts/check-high-assurance.sh        PASS
  scripts/check-lean-bridge.sh           PASS (5/5 bridges)
  scripts/gen_vectors.sh                 byte-identical replay
  scripts/cut-submission.sh --help       PASS
2026-05-18 19:53:08 -07:00

310 lines
11 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package interoperability exercises the Class N1 manifesto end-to-
// end. Each test in this file loads a KAT vector produced by the
// reference threshold implementation, then verifies the signature
// through an INDEPENDENT FIPS 204 ML-DSA verifier — Cloudflare's
// crypto/sign/mldsa — to demonstrate that the threshold-produced
// signature is admissible by an unmodified FIPS 204 verifier.
//
// This is the load-bearing evidence for the Class N1 output-
// interchangeability claim. If any test in this file fails, the
// submission's headline property has broken: threshold-produced
// signatures must verify under FIPS 204 verification verbatim.
//
// The reference impl's pulsar.Verify() also dispatches to
// cloudflare/circl/sign/mldsa internally (see ref/go/pkg/pulsar/
// verify.go), so the FIPS 204 verifier path is exercised on every
// unit test. This package adds the EXPLICIT acceptance test against
// vectors loaded as opaque bytes — it does not import pulsar.Verify
// at all, only the third-party FIPS 204 verifier. That is the
// independent-verifier discipline NIST asks for.
package interoperability
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
)
// katVector is the schema of an entry in vectors/{sign,threshold-sign}.json.
// Loaded as opaque bytes — this package deliberately does not import
// the reference implementation. The point is to prove that an
// independent verifier accepts the signature.
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.
const vectorsDir = "../../vectors"
// TestN1_SinglePartySignatures_VerifyUnderFIPS204 asserts every
// signature in vectors/sign.json verifies under cloudflare/circl's
// independent FIPS 204 implementation. This is the baseline KAT
// gate: if the reference impl's single-party Sign output does NOT
// verify under circl, the submission is broken before the threshold
// path is even exercised.
//
// vectors/sign.json carries (mode, seed, message, signature). The
// public key is *derived* from the seed by calling circl's
// NewKeyFromSeed directly — circl + ML-DSA keygen is deterministic
// over the 32-byte seed, so this is the exact same pk that the
// reference impl produced.
//
// Doing the derivation here, not loading the pk from keygen.json,
// proves the seed → (pk, sig) chain reaches circl without the
// reference impl in scope. That is the independent-verifier
// discipline at its tightest.
func TestN1_SinglePartySignatures_VerifyUnderFIPS204(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 := verifyUnderFIPS204(v.Mode, pk, msg, sig, ctx); err != nil {
t.Fatalf("FIPS 204 verify failed under independent verifier: %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_ThresholdSignatures_VerifyUnderFIPS204 is the *headline*
// Class N1 test: every signature produced by the threshold ceremony
// (DKG → Round-1 → Round-2 → Combine) verifies under the same
// unmodified FIPS 204 verifier used for single-party signatures.
//
// vectors/threshold-sign.json carries (mode, seed, public_key, msg,
// signature). The public_key field is the group public key emitted
// by DKG, NOT a per-party shard. Verify uses it verbatim.
//
// A failure here breaks the headline submission property.
func TestN1_ThresholdSignatures_VerifyUnderFIPS204(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 := verifyUnderFIPS204(v.Mode, pk, msg, sig, ctx); err != nil {
t.Fatalf(
"CRITICAL: threshold-produced signature did NOT verify under FIPS 204 verifier.\n"+
"This breaks the Class N1 output-interchangeability claim.\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_TamperedSignatures_Rejected asserts the verifier rejects
// bit-flipped signatures. Closes the trivial "verify always accepts"
// vacuous-pass failure mode: if a flipped-bit signature also
// verified, the test above would be meaningless.
func TestN1_TamperedSignatures_Rejected(t *testing.T) {
signs := loadVectors(t, filepath.Join(vectorsDir, "sign.json"))
if len(signs) == 0 {
t.Fatal("vectors/sign.json is empty")
}
// Pick the first vector per mode and flip a byte in the
// signature. The flipped signature MUST be rejected by the
// verifier; if it accepts, the verifier itself is broken (or
// the KAT has an undetected collision).
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)
// Flip one bit roughly in the middle so the tamper is
// not on a padding byte.
tampered := append([]byte(nil), sig...)
tampered[len(tampered)/2] ^= 0x01
if err := verifyUnderFIPS204(v.Mode, pk, msg, tampered, ctx); err == nil {
t.Fatal("tampered signature verified successfully — verifier is broken (vacuous pass)")
}
})
}
}
// TestN1_WrongMessage_Rejected asserts that swapping the message
// under a fixed signature is rejected. The verifier MUST bind
// (pk, message, signature) jointly.
func TestN1_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 := verifyUnderFIPS204(v.Mode, pk, wrongMsg, sig, ctx); err == nil {
t.Fatal("verifier accepted signature against unrelated message — joint binding broken")
}
})
}
}
// derivePublicKeyFromSeed runs circl's deterministic keygen on the
// 32-byte seed and returns the marshalled public key bytes. This is
// the seed → pk mapping that the reference impl uses internally;
// running it here proves the public key the verifier consumes is
// derived by circl alone, with no reference-impl code in scope.
//
// Seed size for ML-DSA is 32 bytes (FIPS 204 §3.3).
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)
}
}
// verifyUnderFIPS204 dispatches to cloudflare/circl's FIPS 204
// reference verifier for the named ML-DSA mode. This is the
// independent third-party verifier the submission claims byte-
// equality against. Returns nil iff the signature verifies.
//
// circl's API takes (publicKey, message, context, signature) for
// Verify — context may be empty for vectors generated with no
// domain-separation tag.
func verifyUnderFIPS204(mode string, pkBytes, msg, sig, ctx []byte) error {
switch mode {
case "Pulsar-44", "ML-DSA-44":
var pk mldsa44.PublicKey
if err := pk.UnmarshalBinary(pkBytes); err != nil {
return fmt.Errorf("mldsa44 unmarshal pk: %w", err)
}
if ok := mldsa44.Verify(&pk, msg, ctx, sig); !ok {
return errors.New("mldsa44.Verify returned false")
}
return nil
case "Pulsar-65", "ML-DSA-65":
var pk mldsa65.PublicKey
if err := pk.UnmarshalBinary(pkBytes); err != nil {
return fmt.Errorf("mldsa65 unmarshal pk: %w", err)
}
if ok := mldsa65.Verify(&pk, msg, ctx, sig); !ok {
return errors.New("mldsa65.Verify returned false")
}
return nil
case "Pulsar-87", "ML-DSA-87":
var pk mldsa87.PublicKey
if err := pk.UnmarshalBinary(pkBytes); err != nil {
return fmt.Errorf("mldsa87 unmarshal pk: %w", err)
}
if ok := mldsa87.Verify(&pk, msg, ctx, sig); !ok {
return errors.New("mldsa87.Verify returned false")
}
return nil
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. Fails the test with a
// descriptive error if the file is missing or malformed — KAT
// regeneration is a deterministic gate; missing vectors mean the
// reproducibility property is broken.
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. KAT vector fields
// are hex-encoded by convention (see vectors/README.md).
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
}