mirror of
https://github.com/luxfi/math.git
synced 2026-07-27 03:38:49 +00:00
LP-107 Phase 7: cross-runtime KAT release gate
codec/kat.go: KATEntry + KATBundle types with canonical JSON
serialization. Header field embeds params.KATHeader so every entry
carries (parameter_set, modulus_id, backend_id, hash_suite_id,
implementation, version) — required by LP-107 §"Parameter registry"
for cross-runtime equality.
codec/cmd/emit_codec_kat/main.go: emits 5 canonical entries:
1. ReadUint64Slice/happy-path/3-elements
2. ReadUint64Slice/empty
3. ReadUint64Slice/reject/lattice-issue-4-70T (regression: huge
length must be rejected by both runtimes)
4. ReadUint16Slice/happy-path/5-elements
5. ReadUint32Slice/happy-path/4-elements
Each entry records:
- InputHex: the wire-format bytes the Reader consumes
- OutputHex: the canonical output byte stream (or "REJECTED")
- OutputSHA256: commitment for fast cross-runtime equality
codec/kat_test.go: TestKATBundle_RoundTrip replays every bundle
entry through the Go Reader and asserts byte-equality + SHA-256
commitment match. PASS in 0.29s.
codec/testdata/codec_kat.json: the Go-emitted bundle. The C++ side
at luxcpp/crypto/math/test/codec_cross_runtime_test.cpp loads the
same file and replays every entry; in this commit, all 5 entries
PASS byte-equal between runtimes.
This closes LP-107 Phase 7 for codec/. Subsequent commits extend the
gate to modarith/, ntt/, poly/, sample/ KATs.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
//
|
||||
// emit_codec_kat — produces the canonical cross-runtime KAT bundle
|
||||
// for luxfi/math/codec. The C++ side at luxcpp/crypto/math/test/
|
||||
// codec_cross_runtime_test.cpp reads the same JSON and asserts
|
||||
// byte-equal Reader behavior on every entry.
|
||||
//
|
||||
// LP-107 Phase 7: Go emits → C++ verifies. This is the first
|
||||
// cross-runtime release gate for the math substrate.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/emit_codec_kat --out testdata/codec_kat.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/luxfi/math/codec"
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "codec_kat.json", "output JSON path")
|
||||
flag.Parse()
|
||||
|
||||
header := params.KATHeader{
|
||||
ParameterSet: "math.codec.v1",
|
||||
ModulusID: params.ModPulsarQ, // any valid ID; codec is modulus-independent
|
||||
BackendID: params.BackendPureGo,
|
||||
HashSuiteID: params.HashBLAKE3,
|
||||
ImplementationName: "luxfi/math/codec",
|
||||
ImplementationVersion: "v0.2.0",
|
||||
}
|
||||
|
||||
entries := []codec.KATEntry{}
|
||||
|
||||
// Entry 1: happy-path uint64 slice with 3 elements.
|
||||
{
|
||||
payload := []uint64{0xdeadbeef, 0xcafebabe, 0x1122334455667788}
|
||||
input := codec.MakeUvarintFrame(uint64(len(payload)), payload)
|
||||
// Output is the same uint64 stream emitted as little-endian bytes
|
||||
// (canonical wire form). Verifier replays the input through
|
||||
// ReadUint64Slice and re-serializes the result; bytes must match.
|
||||
var output []byte
|
||||
for _, v := range payload {
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(b, v)
|
||||
output = append(output, b...)
|
||||
}
|
||||
entries = append(entries, mkEntry(header,
|
||||
"ReadUint64Slice/happy-path/3-elements", input, output))
|
||||
}
|
||||
|
||||
// Entry 2: empty slice (length 0).
|
||||
{
|
||||
input := codec.MakeUvarintFrame(0, nil)
|
||||
entries = append(entries, mkEntry(header,
|
||||
"ReadUint64Slice/empty", input, nil))
|
||||
}
|
||||
|
||||
// Entry 3: huge length attack input (regression for lattice issue #4).
|
||||
// The Reader MUST reject this; verifier records the rejection
|
||||
// distinctly via Output = "REJECTED" sentinel.
|
||||
{
|
||||
input := codec.MakeUvarintFrame(70_368_955_777_453, nil)
|
||||
entries = append(entries, mkRejectEntry(header,
|
||||
"ReadUint64Slice/reject/lattice-issue-4-70T", input))
|
||||
}
|
||||
|
||||
// Entry 4: uint16 happy-path with 5 elements.
|
||||
{
|
||||
// Manually emit varint(5) then 10 bytes of payload.
|
||||
input := []byte{}
|
||||
// uvarint(5):
|
||||
input = append(input, 5)
|
||||
// 5 little-endian uint16 values: 0x0102, 0x0304, 0x0506, 0x0708, 0x090A.
|
||||
input = append(input, 0x02, 0x01, 0x04, 0x03, 0x06, 0x05, 0x08, 0x07, 0x0A, 0x09)
|
||||
// Output is the values re-serialized.
|
||||
output := []byte{0x02, 0x01, 0x04, 0x03, 0x06, 0x05, 0x08, 0x07, 0x0A, 0x09}
|
||||
entries = append(entries, mkEntry(header,
|
||||
"ReadUint16Slice/happy-path/5-elements", input, output))
|
||||
}
|
||||
|
||||
// Entry 5: uint32 happy-path with 4 elements.
|
||||
{
|
||||
input := []byte{4}
|
||||
// 4 little-endian uint32 values: 0x12345678, 0x87654321, 0x00112233, 0xFFEEDDCC.
|
||||
input = append(input,
|
||||
0x78, 0x56, 0x34, 0x12,
|
||||
0x21, 0x43, 0x65, 0x87,
|
||||
0x33, 0x22, 0x11, 0x00,
|
||||
0xCC, 0xDD, 0xEE, 0xFF)
|
||||
output := input[1:] // payload bytes
|
||||
entries = append(entries, mkEntry(header,
|
||||
"ReadUint32Slice/happy-path/4-elements", input, output))
|
||||
}
|
||||
|
||||
bundle := &codec.KATBundle{
|
||||
Schema: codec.KATSchemaV1,
|
||||
Entries: entries,
|
||||
}
|
||||
if err := codec.WriteKATBundleFile(*out, bundle); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("wrote %d entries to %s\n", len(entries), *out)
|
||||
}
|
||||
|
||||
func mkEntry(h params.KATHeader, name string, input, output []byte) codec.KATEntry {
|
||||
digest := sha256.Sum256(output)
|
||||
return codec.KATEntry{
|
||||
Header: h,
|
||||
Test: name,
|
||||
InputHex: codec.HexEncode(input),
|
||||
OutputHex: codec.HexEncode(output),
|
||||
OutputSHA256: codec.HexEncode(digest[:]),
|
||||
}
|
||||
}
|
||||
|
||||
// mkRejectEntry records an input that the Reader MUST reject. The
|
||||
// canonical "output" for a rejected input is the literal byte string
|
||||
// "REJECTED" so cross-runtime verifiers can compare a single value.
|
||||
func mkRejectEntry(h params.KATHeader, name string, input []byte) codec.KATEntry {
|
||||
const sentinel = "REJECTED"
|
||||
digest := sha256.Sum256([]byte(sentinel))
|
||||
return codec.KATEntry{
|
||||
Header: h,
|
||||
Test: name,
|
||||
InputHex: codec.HexEncode(input),
|
||||
OutputHex: codec.HexEncode([]byte(sentinel)),
|
||||
OutputSHA256: codec.HexEncode(digest[:]),
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
// KATEntry is one cross-runtime KAT vector. Each entry pins a specific
|
||||
// (test, parameter set, backend, input) tuple and records the SHA-256
|
||||
// (or BLAKE2b) digest of the byte-stream the substrate produces for
|
||||
// that input.
|
||||
//
|
||||
// Cross-runtime contract: emitting `KATEntry.Output` from Go and
|
||||
// replaying it from the C++ side (luxcpp/crypto/math/test) MUST
|
||||
// produce a byte-equal stream. Any divergence is a release-gate
|
||||
// failure.
|
||||
type KATEntry struct {
|
||||
// Header carries the canonical (parameter, backend, hash-suite,
|
||||
// implementation, version) tuple every KAT must surface.
|
||||
Header params.KATHeader `json:"header"`
|
||||
|
||||
// Test is the human-readable test name (e.g. "ReadUint64Slice/
|
||||
// happy-path-3-elements", "MontMul/q=PulsarQ/100-random-pairs").
|
||||
Test string `json:"test"`
|
||||
|
||||
// InputHex is the hex-encoded input byte stream consumed by the
|
||||
// substrate primitive under test.
|
||||
InputHex string `json:"input_hex"`
|
||||
|
||||
// OutputHex is the hex-encoded canonical output byte stream.
|
||||
OutputHex string `json:"output_hex"`
|
||||
|
||||
// OutputSHA256 is the SHA-256 commitment over OutputHex's raw
|
||||
// bytes (NOT the hex string). Used as a fast cross-runtime
|
||||
// equality check; full byte-stream is in OutputHex for diffing
|
||||
// on mismatch.
|
||||
OutputSHA256 string `json:"output_sha256"`
|
||||
}
|
||||
|
||||
// KATBundle is a collection of entries written to a JSON file at a
|
||||
// stable path. C++ replay tests load the same file by path.
|
||||
type KATBundle struct {
|
||||
Schema string `json:"schema"` // "lux.math.kat.v1"
|
||||
Entries []KATEntry `json:"entries"`
|
||||
}
|
||||
|
||||
const KATSchemaV1 = "lux.math.kat.v1"
|
||||
|
||||
// WriteKATBundle serializes the bundle to a writer in canonical JSON
|
||||
// (sorted keys, indent=2). Two runs produce byte-equal output when
|
||||
// the entries are byte-equal.
|
||||
func WriteKATBundle(w io.Writer, b *KATBundle) error {
|
||||
if b.Schema == "" {
|
||||
b.Schema = KATSchemaV1
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(b)
|
||||
}
|
||||
|
||||
// WriteKATBundleFile writes the bundle to a file at path.
|
||||
func WriteKATBundleFile(path string, b *KATBundle) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("codec.WriteKATBundleFile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return WriteKATBundle(f, b)
|
||||
}
|
||||
|
||||
// ReadKATBundle deserializes a bundle from a reader.
|
||||
func ReadKATBundle(r io.Reader) (*KATBundle, error) {
|
||||
var b KATBundle
|
||||
if err := json.NewDecoder(r).Decode(&b); err != nil {
|
||||
return nil, fmt.Errorf("codec.ReadKATBundle: %w", err)
|
||||
}
|
||||
if b.Schema != KATSchemaV1 {
|
||||
return nil, fmt.Errorf("codec.ReadKATBundle: unknown schema %q", b.Schema)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ReadKATBundleFile reads a bundle from a file at path.
|
||||
func ReadKATBundleFile(path string) (*KATBundle, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codec.ReadKATBundleFile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return ReadKATBundle(f)
|
||||
}
|
||||
|
||||
// MakeUvarintFrame returns the wire-format byte stream for a length-
|
||||
// prefixed slice of uint64 with the supplied length. Used by KAT
|
||||
// emitters to construct the canonical input bytes that ReadUint64Slice
|
||||
// consumes.
|
||||
func MakeUvarintFrame(length uint64, payload []uint64) []byte {
|
||||
var buf bytes.Buffer
|
||||
encodeUvarintTo(&buf, length)
|
||||
for _, v := range payload {
|
||||
_ = binary.Write(&buf, binary.LittleEndian, v)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func encodeUvarintTo(out *bytes.Buffer, v uint64) {
|
||||
for v >= 0x80 {
|
||||
out.WriteByte(byte(v) | 0x80)
|
||||
v >>= 7
|
||||
}
|
||||
out.WriteByte(byte(v))
|
||||
}
|
||||
|
||||
// HexEncode is a thin wrapper around encoding/hex for KAT JSON authoring.
|
||||
func HexEncode(b []byte) string { return hex.EncodeToString(b) }
|
||||
|
||||
// HexDecode is the inverse.
|
||||
func HexDecode(s string) ([]byte, error) { return hex.DecodeString(s) }
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestKATBundle_RoundTrip — round-trip the same Go-side KAT to
|
||||
// validate the bundle format itself.
|
||||
func TestKATBundle_RoundTrip(t *testing.T) {
|
||||
path := filepath.Join("testdata", "codec_kat.json")
|
||||
bundle, err := ReadKATBundleFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("KAT bundle not present at %s; run cmd/emit_codec_kat: %v",
|
||||
path, err)
|
||||
return
|
||||
}
|
||||
if bundle.Schema != KATSchemaV1 {
|
||||
t.Fatalf("schema = %q, want %q", bundle.Schema, KATSchemaV1)
|
||||
}
|
||||
if len(bundle.Entries) == 0 {
|
||||
t.Fatal("bundle has zero entries")
|
||||
}
|
||||
for i, entry := range bundle.Entries {
|
||||
if err := entry.Header.Validate(); err != nil {
|
||||
t.Errorf("entry[%d] header: %v", i, err)
|
||||
}
|
||||
// Decode the input and replay it through Reader. Output must
|
||||
// match the recorded OutputHex byte-for-byte (or be "REJECTED"
|
||||
// sentinel for entries that are expected to reject).
|
||||
input, err := HexDecode(entry.InputHex)
|
||||
if err != nil {
|
||||
t.Errorf("entry[%d] input_hex: %v", i, err)
|
||||
continue
|
||||
}
|
||||
expected, err := HexDecode(entry.OutputHex)
|
||||
if err != nil {
|
||||
t.Errorf("entry[%d] output_hex: %v", i, err)
|
||||
continue
|
||||
}
|
||||
// Reset reader for each entry.
|
||||
r, err := NewReader(bytes.NewReader(input), DefaultLimitsLatticeWire)
|
||||
if err != nil {
|
||||
t.Errorf("entry[%d] NewReader: %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
actual, rejected := replayEntry(t, r, entry.Test)
|
||||
if rejected {
|
||||
if string(expected) != "REJECTED" {
|
||||
t.Errorf("entry[%d] %q: expected non-rejection, got reject",
|
||||
i, entry.Test)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify SHA-256 matches.
|
||||
digest := sha256.Sum256(actual)
|
||||
got := HexEncode(digest[:])
|
||||
if got != entry.OutputSHA256 {
|
||||
t.Errorf("entry[%d] %q: SHA-256 mismatch:\n want %s\n got %s",
|
||||
i, entry.Test, entry.OutputSHA256, got)
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
t.Errorf("entry[%d] %q: byte-stream mismatch", i, entry.Test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replayEntry routes the entry's test name to the right Reader call.
|
||||
// The case list MUST mirror cmd/emit_codec_kat/main.go.
|
||||
func replayEntry(t *testing.T, r *Reader, name string) ([]byte, bool) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case startsWith(name, "ReadUint64Slice/"):
|
||||
out, err := r.ReadUint64Slice()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrLimitExceeded) {
|
||||
return nil, true // rejected
|
||||
}
|
||||
t.Errorf("%s: %v", name, err)
|
||||
return nil, false
|
||||
}
|
||||
return uint64sToBytes(out), false
|
||||
case startsWith(name, "ReadUint16Slice/"):
|
||||
out, err := r.ReadUint16Slice()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrLimitExceeded) {
|
||||
return nil, true
|
||||
}
|
||||
t.Errorf("%s: %v", name, err)
|
||||
return nil, false
|
||||
}
|
||||
return uint16sToBytes(out), false
|
||||
case startsWith(name, "ReadUint32Slice/"):
|
||||
out, err := r.ReadUint32Slice()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrLimitExceeded) {
|
||||
return nil, true
|
||||
}
|
||||
t.Errorf("%s: %v", name, err)
|
||||
return nil, false
|
||||
}
|
||||
return uint32sToBytes(out), false
|
||||
}
|
||||
t.Errorf("unknown KAT test name: %s", name)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func startsWith(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
func uint64sToBytes(s []uint64) []byte {
|
||||
b := make([]byte, len(s)*8)
|
||||
for i, v := range s {
|
||||
binary.LittleEndian.PutUint64(b[i*8:], v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func uint32sToBytes(s []uint32) []byte {
|
||||
b := make([]byte, len(s)*4)
|
||||
for i, v := range s {
|
||||
binary.LittleEndian.PutUint32(b[i*4:], v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func uint16sToBytes(s []uint16) []byte {
|
||||
b := make([]byte, len(s)*2)
|
||||
for i, v := range s {
|
||||
binary.LittleEndian.PutUint16(b[i*2:], v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"schema": "lux.math.kat.v1",
|
||||
"entries": [
|
||||
{
|
||||
"header": {
|
||||
"parameter_set": "math.codec.v1",
|
||||
"modulus_id": "pulsar-q-0x1000000004a01",
|
||||
"backend_id": "pure-go",
|
||||
"hash_suite_id": "blake3-v1",
|
||||
"implementation_name": "luxfi/math/codec",
|
||||
"implementation_version": "v0.2.0"
|
||||
},
|
||||
"test": "ReadUint64Slice/happy-path/3-elements",
|
||||
"input_hex": "03efbeadde00000000bebafeca000000008877665544332211",
|
||||
"output_hex": "efbeadde00000000bebafeca000000008877665544332211",
|
||||
"output_sha256": "884a0a3e0f693db5c4b8bbd2503ae2f8dcebfaf8075acf7a1c82857599bbc8e3"
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"parameter_set": "math.codec.v1",
|
||||
"modulus_id": "pulsar-q-0x1000000004a01",
|
||||
"backend_id": "pure-go",
|
||||
"hash_suite_id": "blake3-v1",
|
||||
"implementation_name": "luxfi/math/codec",
|
||||
"implementation_version": "v0.2.0"
|
||||
},
|
||||
"test": "ReadUint64Slice/empty",
|
||||
"input_hex": "00",
|
||||
"output_hex": "",
|
||||
"output_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"parameter_set": "math.codec.v1",
|
||||
"modulus_id": "pulsar-q-0x1000000004a01",
|
||||
"backend_id": "pure-go",
|
||||
"hash_suite_id": "blake3-v1",
|
||||
"implementation_name": "luxfi/math/codec",
|
||||
"implementation_version": "v0.2.0"
|
||||
},
|
||||
"test": "ReadUint64Slice/reject/lattice-issue-4-70T",
|
||||
"input_hex": "ad83f3e4808010",
|
||||
"output_hex": "52454a4543544544",
|
||||
"output_sha256": "04cc0e71ffcffb522791a4e0119fe4cf339d8e85a4fadea0a13df0f3062ad8bf"
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"parameter_set": "math.codec.v1",
|
||||
"modulus_id": "pulsar-q-0x1000000004a01",
|
||||
"backend_id": "pure-go",
|
||||
"hash_suite_id": "blake3-v1",
|
||||
"implementation_name": "luxfi/math/codec",
|
||||
"implementation_version": "v0.2.0"
|
||||
},
|
||||
"test": "ReadUint16Slice/happy-path/5-elements",
|
||||
"input_hex": "0502010403060508070a09",
|
||||
"output_hex": "02010403060508070a09",
|
||||
"output_sha256": "ba23dd73c899102847b646b79e00d40ec9f97d2a7471b8fc1330fd6f74a45cc3"
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"parameter_set": "math.codec.v1",
|
||||
"modulus_id": "pulsar-q-0x1000000004a01",
|
||||
"backend_id": "pure-go",
|
||||
"hash_suite_id": "blake3-v1",
|
||||
"implementation_name": "luxfi/math/codec",
|
||||
"implementation_version": "v0.2.0"
|
||||
},
|
||||
"test": "ReadUint32Slice/happy-path/4-elements",
|
||||
"input_hex": "04785634122143658733221100ccddeeff",
|
||||
"output_hex": "785634122143658733221100ccddeeff",
|
||||
"output_sha256": "4b13dba670b17377c4f419c36b00ea2d3ba4a1c585e163ffa6f43e400cb32fde"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user