diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d142259..69e6bea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,5 +30,9 @@ jobs: run: bun install # Run unit tests using "bun test:unit" - - name: Run Unit Tests - run: bun test:unit \ No newline at end of file + - name: Unit Tests + run: bun test:unit + - name: Download spec tests + run: bun download-spec-tests + - name: Spec tests + run: bun test:spec \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6c2dbf4..167f2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,8 @@ dist bun.lockb # prebuild -prebuild \ No newline at end of file +prebuild + +# Eth2.0 spec tests data +spec-tests +spec-tests-bls \ No newline at end of file diff --git a/package.json b/package.json index cb222df..fec758d 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "module": "src/index.ts", "type": "module", "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", + "tar": "^7.4.0", + "js-yaml": "^4.1.0" }, "peerDependencies": { "typescript": "^5.0.0" }, "scripts": { "test:unit": "bun test test/unit", - "postinstall": "bun scripts/install.ts" + "test:spec": "bun test test/spec/*.test.ts", + "postinstall": "bun scripts/install.ts", + "download-spec-tests": "bun test/spec/downloadTests.ts" } } \ No newline at end of file diff --git a/scripts/install.ts b/scripts/install.ts index 230c4bd..bb1a917 100644 --- a/scripts/install.ts +++ b/scripts/install.ts @@ -6,7 +6,7 @@ import {ReadableStream} from "stream/web"; import {createWriteStream, existsSync, mkdirSync} from "fs"; import {PREBUILD_DIR, getBinaryName, getPrebuiltBinaryPath} from "../utils"; -const VERSION = "0.1.0-rc.1"; +const VERSION = "0.1.0-rc.3"; // CLI runner and entrance for this file when called by npm/yarn install().then( diff --git a/src/secretKey.ts b/src/secretKey.ts index bae0e85..77f566d 100644 --- a/src/secretKey.ts +++ b/src/secretKey.ts @@ -1,8 +1,8 @@ import { binding } from "./binding"; -import { BLST_SUCCESS, PUBLIC_KEY_LENGTH_UNCOMPRESSED, SECRET_KEY_LENGTH } from "./const"; +import { BLST_SUCCESS, SECRET_KEY_LENGTH } from "./const"; import { PublicKey } from "./publicKey"; import { Signature } from "./signature"; -import { blstErrorToReason, fromHex, toError, toHex } from "./util"; +import { fromHex, toError, toHex } from "./util"; export class SecretKey { private blst_point: Uint8Array; diff --git a/src/signature.ts b/src/signature.ts index 9b5710d..faec9a6 100644 --- a/src/signature.ts +++ b/src/signature.ts @@ -1,6 +1,6 @@ import { binding, writeReference } from "./binding"; import { BLST_SUCCESS, SIGNATURE_LENGTH_COMPRESSED, SIGNATURE_LENGTH_UNCOMPRESSED } from "./const"; -import { blstErrorToReason, fromHex, toHex } from "./util"; +import { fromHex, toError, toHex } from "./util"; export class Signature { // this is mapped directly to `*const SignatureType` in Zig @@ -43,7 +43,7 @@ export class Signature { } if (res !== BLST_SUCCESS) { - throw new Error(blstErrorToReason(res)); + throw toError(res); } return new Signature(buffer); @@ -90,7 +90,7 @@ export class Signature { public sigValidate(sigInfcheck?: boolean | undefined | null): void { const res = binding.validateSignature(this.blst_point, sigInfcheck ?? true); if (res !== BLST_SUCCESS) { - throw new Error(blstErrorToReason(res)); + throw toError(res); } } diff --git a/src/util.ts b/src/util.ts index 98be240..f459f53 100644 --- a/src/util.ts +++ b/src/util.ts @@ -21,11 +21,12 @@ export function fromHex(hex: string): Uint8Array { export function toError(blstErrorCode: number): Error { const message = blstErrorToReason(blstErrorCode); const error = new Error(message); + // this make it compliant to napi-rs binding (error as unknown as {code: string}).code = blstErrorToCode(blstErrorCode); return error; } -export function blstErrorToReason(blstErrorCode: number): string { +function blstErrorToReason(blstErrorCode: number): string { switch (blstErrorCode) { case 0: return "BLST_SUCCESS"; diff --git a/src/verify.ts b/src/verify.ts index ad04063..d8b15e6 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -26,7 +26,8 @@ export function verify(msg: Uint8Array, pk: PublicKey, sig: Signature, pkValidat */ export function aggregateVerify(msgs: Array, pks: Array, sig: Signature, pkValidate?: boolean | undefined | null, sigsGroupcheck?: boolean | undefined | null): boolean { if (msgs.length < 1) { - throw new Error("At least one message is required"); + // this is the same to the original napi-rs blst-ts + return false; } if (msgs.length !== pks.length) { throw new Error("Number of messages must be equal to the number of public keys"); diff --git a/src/verifyMultipleAggregateSignatures.ts b/src/verifyMultipleAggregateSignatures.ts index 5525ed6..95e5770 100644 --- a/src/verifyMultipleAggregateSignatures.ts +++ b/src/verifyMultipleAggregateSignatures.ts @@ -30,6 +30,11 @@ export function verifyMultipleAggregateSignatures(sets: SignatureSet[], pksValid writeSignatureSetsReference(sets, signature_sets_ref.subarray(0, sets.length * 2)); const msgLength = 32; + for (const set of sets) { + if (set.msg.length !== msgLength) { + throw new Error("All messages must be 32 bytes"); + } + } const res = binding.verifyMultipleAggregateSignatures(signature_sets_ref, sets.length, msgLength, pksValidate ?? false, sigsGroupcheck ?? false, pairing, pairing.length); return res === 0; } diff --git a/test/spec/downloadTests.ts b/test/spec/downloadTests.ts new file mode 100644 index 0000000..9601bb2 --- /dev/null +++ b/test/spec/downloadTests.ts @@ -0,0 +1,11 @@ +import {downloadTests} from "./utils"; +import {ethereumConsensusSpecsTests, blsSpecTests} from "./specTestVersioning"; + +/* eslint-disable no-console */ + +for (const downloadTestOpts of [ethereumConsensusSpecsTests, blsSpecTests]) { + downloadTests(downloadTestOpts, console.log).catch((e: Error) => { + console.error(e); + process.exit(1); + }); +} diff --git a/test/spec/functions.ts b/test/spec/functions.ts new file mode 100644 index 0000000..4340e8b --- /dev/null +++ b/test/spec/functions.ts @@ -0,0 +1,213 @@ +import { + SecretKey, + PublicKey, + Signature, + aggregatePublicKeys, + aggregateSignatures, + verify as VERIFY, + aggregateVerify, + fastAggregateVerify, + verifyMultipleAggregateSignatures, + type SignatureSet, +} from "../../src/index.ts"; +import {type CodeError, fromHex} from "../utils"; +import {G2_POINT_AT_INFINITY} from "./utils"; + +export const testFnByName: Record any> = { + sign, + eth_aggregate_pubkeys, + aggregate, + verify, + aggregate_verify, + fast_aggregate_verify, + eth_fast_aggregate_verify, + batch_verify, + deserialization_G1, + deserialization_G2, +}; + +function catchBLSTError(e: unknown): boolean { + if ((e as CodeError).code?.startsWith("BLST")) return false; + throw e; +} + +/** + * ``` + * input: List[BLS Signature] -- list of input BLS signatures + * output: BLS Signature -- expected output, single BLS signature or empty. + * ``` + */ +function aggregate(input: string[]): string | null { + return aggregateSignatures(input.map((hex) => Signature.fromHex(hex))).toHex(); +} + +/** + * ``` + * input: + * pubkeys: List[BLS Pubkey] -- the pubkeys + * messages: List[bytes32] -- the messages + * signature: BLS Signature -- the signature to verify against pubkeys and messages + * output: bool -- true (VALID) or false (INVALID) + * ``` + */ +function aggregate_verify(input: {pubkeys: string[]; messages: string[]; signature: string}): boolean { + const {pubkeys, messages, signature} = input; + try { + return aggregateVerify( + messages.map(fromHex), + pubkeys.map((hex) => PublicKey.fromHex(hex)), + Signature.fromHex(signature) + ); + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * ``` + * input: List[BLS Signature] -- list of input BLS signatures + * output: BLS Signature -- expected output, single BLS signature or empty. + * ``` + */ +function eth_aggregate_pubkeys(input: string[]): string | null { + return aggregatePublicKeys(input.map((hex) => PublicKey.fromHex(hex, true))).toHex(); +} + +/** + * ``` + * input: + * pubkeys: List[BLS Pubkey] -- list of input BLS pubkeys + * message: bytes32 -- the message + * signature: BLS Signature -- the signature to verify against pubkeys and message + * output: bool -- true (VALID) or false (INVALID) + * ``` + */ +function eth_fast_aggregate_verify(input: {pubkeys: string[]; message: string; signature: string}): boolean { + const {pubkeys, message, signature} = input; + + if (pubkeys.length === 0 && signature === G2_POINT_AT_INFINITY) { + return true; + } + + try { + return fastAggregateVerify( + fromHex(message), + pubkeys.map((hex) => PublicKey.fromHex(hex, true)), + Signature.fromHex(signature) + ); + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * ``` + * input: + * pubkeys: List[BLS Pubkey] -- list of input BLS pubkeys + * message: bytes32 -- the message + * signature: BLS Signature -- the signature to verify against pubkeys and message + * output: bool -- true (VALID) or false (INVALID) + * ``` + */ +function fast_aggregate_verify(input: {pubkeys: string[]; message: string; signature: string}): boolean { + const {pubkeys, message, signature} = input; + + try { + return fastAggregateVerify( + fromHex(message), + pubkeys.map((hex) => PublicKey.fromHex(hex, true)), + Signature.fromHex(signature) + ); + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * input: + * privkey: bytes32 -- the private key used for signing + * message: bytes32 -- input message to sign (a hash) + * output: BLS Signature -- expected output, single BLS signature or empty. + */ +function sign(input: {privkey: string; message: string}): string | null { + const {privkey, message} = input; + return SecretKey.fromHex(privkey).sign(fromHex(message)).toHex(); +} + +/** + * input: + * pubkey: bytes48 -- the pubkey + * message: bytes32 -- the message + * signature: bytes96 -- the signature to verify against pubkey and message + * output: bool -- VALID or INVALID + */ +function verify(input: {pubkey: string; message: string; signature: string}): boolean { + const {pubkey, message, signature} = input; + try { + return VERIFY(fromHex(message), PublicKey.fromHex(pubkey), Signature.fromHex(signature)); + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * ``` + * input: + * pubkeys: List[bytes48] -- the pubkeys + * messages: List[bytes32] -- the messages + * signatures: List[bytes96] -- the signatures to verify against pubkeys and messages + * output: bool -- VALID or INVALID + * ``` + * https://github.com/ethereum/bls12-381-tests/blob/master/formats/batch_verify.md + */ +function batch_verify(input: {pubkeys: string[]; messages: string[]; signatures: string[]}): boolean | null { + const length = input.pubkeys.length; + if (input.messages.length !== length && input.signatures.length !== length) { + throw new Error("Invalid spec test. Must have same number in each array. Check spec yaml file"); + } + const sets: SignatureSet[] = []; + try { + for (let i = 0; i < length; i++) { + sets.push({ + msg: fromHex(input.messages[i]), + pk: PublicKey.fromHex(input.pubkeys[i]), + sig: Signature.fromHex(input.signatures[i]), + }); + } + return verifyMultipleAggregateSignatures(sets); + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * ``` + * input: pubkey: bytes48 -- the pubkey + * output: bool -- VALID or INVALID + * ``` + * https://github.com/ethereum/bls12-381-tests/blob/master/formats/deserialization_G1.md + */ +function deserialization_G1(input: {pubkey: string}): boolean { + try { + PublicKey.fromHex(input.pubkey, true); + return true; + } catch (e) { + return catchBLSTError(e); + } +} + +/** + * ``` + * input: signature: bytes92 -- the signature + * output: bool -- VALID or INVALID + * ``` + * https://github.com/ethereum/bls12-381-tests/blob/master/formats/deserialization_G2.md + */ +function deserialization_G2(input: {signature: string}): boolean { + try { + Signature.fromHex(input.signature, true); + return true; + } catch (e) { + return catchBLSTError(e); + } +} diff --git a/test/spec/index.test.ts b/test/spec/index.test.ts new file mode 100644 index 0000000..adbd928 --- /dev/null +++ b/test/spec/index.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeAll } from "bun:test"; +import {type TestBatchMeta, getTestBatch} from "./utils"; +import {testFnByName} from "./functions"; + +const testLocations: TestBatchMeta[] = [ + {directory: "spec-tests/tests/general/phase0/bls", innerBlsFolder: true}, + {directory: "spec-tests/tests/general/altair/bls", innerBlsFolder: true}, + {directory: "spec-tests-bls", namedYamlFiles: true}, +]; + +const skippedFunctions: string[] = ["hash_to_G2"]; + +const skippedTestCaseNames: string[] = [ + // TODO: BLS dealing of the Infinity public key does not allow to validate `infinity_with_true_b_flag`. + // This _should_ not have any impact of Beacon Chain in production, so it's ignored until fixed upstream + "deserialization_succeeds_infinity_with_true_b_flag", +]; + +(function runTests(): void { + const batches = testLocations.map(getTestBatch); + for (const {directory, testGroups: tests} of batches) { + describe(directory, () => { + for (const {functionName, testCases} of tests) { + if (skippedFunctions.includes(functionName)) continue; + describe(functionName, () => { + const testFn = testFnByName[functionName]; + beforeAll(() => { + if (!testFn) throw Error(`Unknown test function: ${functionName}`); + }); + + for (const {testCaseName, testCaseData} of testCases) { + if (skippedTestCaseNames.includes(testCaseName)) { + continue; + } + if (process.env.DEBUG) { + console.log(testCaseData); + } + it(testCaseName, () => { + if (testCaseData.output === null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + expect(() => testFn(testCaseData.input)).toThrow(); + } else { + expect(testFn(testCaseData.input)).toEqual(testCaseData.output); + } + }); + } + }); + } + }); + } +})(); diff --git a/test/spec/specTestVersioning.ts b/test/spec/specTestVersioning.ts new file mode 100644 index 0000000..0ee39ca --- /dev/null +++ b/test/spec/specTestVersioning.ts @@ -0,0 +1,27 @@ +import {join} from "node:path"; +import {DownloadTestsOptions} from "./utils"; + +// WARNING! Don't move or rename this file !!! +// +// This file is used to generate the cache ID for spec tests download in Github Actions CI +// It's path is hardcoded in: `.github/workflows/test-spec.yml` +// +// The contents of this file MUST include the URL, version and target path, and nothing else. + +// Target directory is the host package root: '/spec-tests' + +export const ethereumConsensusSpecsTests: DownloadTestsOptions = { + specVersion: "v1.4.0", + // Target directory is the host package root: 'packages/*/spec-tests' + outputDir: join(__dirname, "../../spec-tests"), + specTestsRepoUrl: "https://github.com/ethereum/consensus-spec-tests", + testsToDownload: ["general"], +}; + +export const blsSpecTests: DownloadTestsOptions = { + specVersion: "v0.1.2", + // Target directory is the host package root: 'packages/*/spec-tests-bls' + outputDir: join(__dirname, "../../spec-tests-bls"), + specTestsRepoUrl: "https://github.com/ethereum/bls12-381-tests", + testsToDownload: ["bls_tests_yaml"], +}; diff --git a/test/spec/utils.ts b/test/spec/utils.ts new file mode 100644 index 0000000..6a49d03 --- /dev/null +++ b/test/spec/utils.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import path from "node:path"; +import stream from "node:stream"; +import type {ReadableStream} from "node:stream/web"; +import * as tar from "tar"; +import jsYaml from "js-yaml"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +export const SPEC_TEST_REPO_URL = "https://github.com/ethereum/consensus-spec-tests"; + +export const G2_POINT_AT_INFINITY = + "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; +export const G1_POINT_AT_INFINITY = + "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + +// Examples of parsed YAML +// { +// input: [ +// '0x91347bccf740d859038fcdcaf233eeceb2a436bcaaee9b2aa3bfb70efe29dfb2677562ccbea1c8e061fb9971b0753c240622fab78489ce96768259fc01360346da5b9f579e5da0d941e4c6ba18a0e64906082375394f337fa1af2b7127b0d121', +// '0x9674e2228034527f4c083206032b020310face156d4a4685e2fcaec2f6f3665aa635d90347b6ce124eb879266b1e801d185de36a0a289b85e9039662634f2eea1e02e670bc7ab849d006a70b2f93b84597558a05b879c8d445f387a5d5b653df', +// '0xae82747ddeefe4fd64cf9cedb9b04ae3e8a43420cd255e3c7cd06a8d88b7c7f8638543719981c5d16fa3527c468c25f0026704a6951bde891360c7e8d12ddee0559004ccdbe6046b55bae1b257ee97f7cdb955773d7cf29adf3ccbb9975e4eb9' +// ], +// output: '0x9712c3edd73a209c742b8250759db12549b3eaf43b5ca61376d9f30e2747dbcf842d8b2ac0901d2a093713e20284a7670fcf6954e9ab93de991bb9b313e664785a075fc285806fa5224c82bde146561b446ccfc706a64b8579513cfc4ff1d930' +// } +// +// { +// input: ['0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'], +// output: null +// } +// +// { +// input: ..., +// output: false +// } +export interface TestCaseData { + input: unknown; + output: unknown; +} + +export interface TestCase { + testCaseName: string; + testCaseData: TestCaseData; +} + +export interface TestGroup { + functionName: string; + directory: string; + testCases: TestCase[]; +} + +export interface TestBatchMeta { + directory: string; + innerBlsFolder?: boolean; + namedYamlFiles?: boolean; +} + +export interface TestBatch { + directory: string; + testGroups: TestGroup[]; +} + +function getTestCasesWithDataYaml(testDirectory: string): TestCase[] { + const testCases: TestCase[] = []; + for (const testCaseName of fs.readdirSync(testDirectory)) { + const testCaseDir = path.resolve(testDirectory, testCaseName); + const yamlPath = path.resolve(testCaseDir, "data.yaml"); + if (!fs.existsSync(yamlPath)) { + throw new Error(`Missing yaml data for ${testCaseDir}`); + } + testCases.push({ + testCaseName, + testCaseData: jsYaml.load(fs.readFileSync(yamlPath, "utf8")) as { + input: unknown; + output: unknown; + }, + }); + } + return testCases; +} + +function getTestCasesWithNamedYaml(testDirectory: string): TestCase[] { + const testCases: TestCase[] = []; + for (const testCaseYaml of fs.readdirSync(testDirectory)) { + const [testCaseName] = testCaseYaml.split("."); + const yamlPath = path.resolve(testDirectory, testCaseYaml); + if (!fs.existsSync(yamlPath)) { + throw new Error(`Missing yaml data for ${testCaseName}`); + } + testCases.push({ + testCaseName, + testCaseData: jsYaml.load(fs.readFileSync(yamlPath, "utf8")) as { + input: unknown; + output: unknown; + }, + }); + } + return testCases; +} + +export function getTestBatch({directory, innerBlsFolder, namedYamlFiles}: TestBatchMeta): TestBatch { + const testBatch: TestBatch = {directory, testGroups: []}; + + const fullDirPath = path.resolve(REPO_ROOT, directory); + for (const functionName of fs.readdirSync(fullDirPath)) { + const pathSegments = [fullDirPath, functionName]; + if (innerBlsFolder) pathSegments.push("bls"); + const testDirectory = path.resolve(...pathSegments); + if (!fs.statSync(testDirectory).isDirectory()) { + continue; + } + const testGroup: TestGroup = { + functionName, + directory, + testCases: namedYamlFiles ? getTestCasesWithNamedYaml(testDirectory) : getTestCasesWithDataYaml(testDirectory), + }; + testBatch.testGroups.push(testGroup); + } + + return testBatch; +} + +const logEmpty = (): void => {}; + +export type DownloadTestsOptions = { + specVersion: string; + outputDir: string; + /** Root Github URL `https://github.com/ethereum/consensus-spec-tests` */ + specTestsRepoUrl: string; + /** Release files names to download without prefix `["general", "mainnet", "minimal"]` */ + testsToDownload: string[]; +}; + +/** + * Generic Github release downloader. + * Used by spec tests and SlashingProtectionInterchangeTest + */ +export async function downloadTests( + {specVersion, specTestsRepoUrl, outputDir, testsToDownload}: DownloadTestsOptions, + log: (msg: string) => void = logEmpty +): Promise { + log(`outputDir = ${outputDir}`); + + // Use version.txt as a flag to prevent re-downloading the tests + const versionFile = path.join(outputDir, "version.txt"); + const existingVersion = fs.existsSync(versionFile) && fs.readFileSync(versionFile, "utf8").trim(); + + if (existingVersion === specVersion) { + return log(`version ${specVersion} already downloaded`); + } else { + log(`Downloading new version ${specVersion}`); + } + + if (fs.existsSync(outputDir)) { + log(`Cleaning existing version ${existingVersion} at ${outputDir}`); + fs.rmSync(outputDir, {recursive: true, force: true}); + } + + fs.mkdirSync(outputDir, {recursive: true}); + + await Promise.all( + testsToDownload.map(async (test) => { + const url = `${specTestsRepoUrl ?? SPEC_TEST_REPO_URL}/releases/download/${specVersion}/${test}.tar.gz`; + const fileName = url.split("/").pop(); + const filePath = path.resolve(outputDir, String(fileName)); + const {body, ok, headers} = await fetch(url); + if (!ok || !body) { + throw new Error(`Failed to download ${url}`); + } + + const totalSize = headers.get("content-length"); + log(`Downloading ${url} - ${totalSize} bytes`); + + await stream.promises.finished( + stream.Readable.fromWeb(body as ReadableStream).pipe(fs.createWriteStream(filePath)) + ); + + log(`Downloaded ${url}`); + + await tar.x({ + file: filePath, + cwd: outputDir, + }); + }) + ); + + fs.writeFileSync(versionFile, specVersion); +}