diff --git a/benchmark/index.ts b/benchmark/index.ts index 6cd1855..df38fd8 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -84,6 +84,35 @@ import {aggCount, runs} from "./params"; runs, }); + // Verify multiple signatures + + await runBenchmark<{pks: PublicKey[]; msgs: Uint8Array[]; sigs: Signature[]}, boolean>({ + id: `${implementation} verifyMultipleSignatures (${aggCount})`, + + prepareTest: () => { + const dataArr = range(aggCount).map(() => { + const sk = bls.SecretKey.fromKeygen(); + const pk = sk.toPublicKey(); + const msg = randomMessage(); + const sig = sk.sign(msg); + return {pk, msg, sig}; + }); + + const pks = dataArr.map((data) => data.pk); + const msgs = dataArr.map((data) => data.msg); + const sigs = dataArr.map((data) => data.sig); + + return { + input: {pks, msgs, sigs}, + resultCheck: (valid) => valid === true, + }; + }, + testRunner: ({pks, msgs, sigs}) => { + return bls.Signature.verifyMultipleSignatures(pks, msgs, sigs); + }, + runs, + }); + // Aggregate pubkeys await runBenchmark({ diff --git a/karma.conf.js b/karma.conf.js index 1423525..09c45ac 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,26 +1,25 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const webpackConfig = require("./webpack.config"); -module.exports = function(config) { - config.set({ +module.exports = function (config) { + config.set({ + basePath: "", + frameworks: ["mocha", "chai"], + files: ["test/unit-web/run-web-implementation.test.ts", "test/unit/index-named-exports.test.ts"], + exclude: [], + preprocessors: { + "test/**/*.ts": ["webpack"], + }, + webpack: { + mode: "production", + node: webpackConfig.node, + module: webpackConfig.module, + resolve: webpackConfig.resolve, + }, + reporters: ["spec"], - basePath: "", - frameworks: ["mocha", "chai"], - files: ["test/unit/run-web-implementation.test.ts", "test/unit/index-named-exports.test.ts"], - exclude: [], - preprocessors: { - "test/**/*.ts": ["webpack"] - }, - webpack: { - mode: "production", - node: webpackConfig.node, - module: webpackConfig.module, - resolve: webpackConfig.resolve - }, - reporters: ["spec"], + browsers: ["ChromeHeadless"], - browsers: ["ChromeHeadless"], - - singleRun: true - }); + singleRun: true, + }); }; diff --git a/src/blst/signature.ts b/src/blst/signature.ts index 0385233..69b0e0f 100644 --- a/src/blst/signature.ts +++ b/src/blst/signature.ts @@ -28,6 +28,14 @@ export class Signature implements ISignature { return new Signature(agg.toSignature()); } + static verifyMultipleSignatures(publicKeys: PublicKey[], messages: Uint8Array[], signatures: Signature[]): boolean { + return blst.verifyMultipleAggregateSignatures( + messages, + publicKeys.map((publicKey) => publicKey.affine), + signatures.map((signature) => signature.affine) + ); + } + verify(publicKey: PublicKey, message: Uint8Array): boolean { // Individual infinity signatures are NOT okay. Aggregated signatures MAY be infinity if (this.affine.value.is_inf()) { diff --git a/src/functional.ts b/src/functional.ts index 61e04c8..e59d0b4 100644 --- a/src/functional.ts +++ b/src/functional.ts @@ -106,6 +106,40 @@ export function functionalInterfaceFactory({ } } + /** + * Verifies multiple signatures at once returning true if all valid or false + * if at least one is not. Optimization useful when knowing which signature is + * wrong is not relevant, i.e. verifying an entire Eth2.0 block. + * + * This method provides a safe way to do so by multiplying each signature by + * a random number so an attacker cannot craft a malicious signature that won't + * verify on its own but will if it's added to a specific predictable signature + * https://ethresear.ch/t/fast-verification-of-multiple-bls-signatures/5407 + */ + function verifyMultipleSignatures( + publicKeys: Uint8Array[], + messages: Uint8Array[], + signatures: Uint8Array[] + ): boolean { + validateBytes(publicKeys, "publicKey"); + validateBytes(messages, "message"); + validateBytes(signatures, "signatures"); + + if (publicKeys.length === 0 || publicKeys.length !== messages.length || publicKeys.length !== signatures.length) { + return false; + } + try { + return Signature.verifyMultipleSignatures( + publicKeys.map((publicKey) => PublicKey.fromBytes(publicKey)), + messages.map((msg) => msg), + signatures.map((signature) => Signature.fromBytes(signature)) + ); + } catch (e) { + if (e instanceof NotInitializedError) throw e; + return false; + } + } + /** * Computes a public key from a secret key */ @@ -121,6 +155,7 @@ export function functionalInterfaceFactory({ verify, verifyAggregate, verifyMultiple, + verifyMultipleSignatures, secretKeyToPublicKey, }; } diff --git a/src/helpers/hex.ts b/src/helpers/hex.ts index 9470cb8..2ba1236 100644 --- a/src/helpers/hex.ts +++ b/src/helpers/hex.ts @@ -26,7 +26,6 @@ export function hexToBytes(hex: string): Uint8Array { * From https://github.com/herumi/bls-eth-wasm/blob/04eedb77aa96e66b4f65a0ab477228adf8090c36/src/bls.js#L50 */ export function bytesToHex(bytes: Uint8Array): string { - // return "0x" + Buffer.from(bytes).toString("hex"); let s = ""; const n = bytes.length; diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 114ab7b..76eae27 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -20,3 +20,17 @@ export function validateBytes( export function isZeroUint8Array(bytes: Uint8Array): boolean { return bytes.every((byte) => byte === 0); } + +export function concatUint8Arrays(bytesArr: Uint8Array[]): Uint8Array { + const totalLen = bytesArr.reduce((total, bytes) => total + bytes.length, 0); + + const merged = new Uint8Array(totalLen); + let mergedLen = 0; + + for (const bytes of bytesArr) { + merged.set(bytes, mergedLen); + mergedLen += bytes.length; + } + + return merged; +} diff --git a/src/herumi/context.ts b/src/herumi/context.ts index 7780283..9527630 100644 --- a/src/herumi/context.ts +++ b/src/herumi/context.ts @@ -6,9 +6,26 @@ type Bls = typeof bls; let blsGlobal: Bls | null = null; let blsGlobalPromise: Promise | null = null; +// Patch to fix multiVerify() calls on a browser with polyfilled NodeJS crypto +declare global { + // eslint-disable-next-line @typescript-eslint/interface-name-prefix + interface Window { + msCrypto: typeof window["crypto"]; + } +} + export async function setupBls(): Promise { if (!blsGlobal) { await bls.init(bls.BLS12_381); + + // Patch to fix multiVerify() calls on a browser with polyfilled NodeJS crypto + if (typeof window === "object") { + const crypto = window.crypto || window.msCrypto; + // getRandomValues is not typed in `bls-eth-wasm` because it's not meant to be exposed + // @ts-ignore + bls.getRandomValues = (x) => crypto.getRandomValues(x); + } + blsGlobal = bls; } } diff --git a/src/herumi/signature.ts b/src/herumi/signature.ts index 6a23c89..e8c41be 100644 --- a/src/herumi/signature.ts +++ b/src/herumi/signature.ts @@ -1,8 +1,8 @@ import {SIGNATURE_LENGTH} from "../constants"; -import {SignatureType} from "bls-eth-wasm"; +import {SignatureType, multiVerify} from "bls-eth-wasm"; import {getContext} from "./context"; import {PublicKey} from "./publicKey"; -import {bytesToHex, hexToBytes, isZeroUint8Array} from "../helpers"; +import {bytesToHex, concatUint8Arrays, hexToBytes, isZeroUint8Array} from "../helpers"; import {Signature as ISignature} from "../interface"; import {EmptyAggregateError, InvalidLengthError, InvalidOrderError} from "../errors"; @@ -45,6 +45,14 @@ export class Signature implements ISignature { return new Signature(signature); } + static verifyMultipleSignatures(publicKeys: PublicKey[], messages: Uint8Array[], signatures: Signature[]): boolean { + return multiVerify( + publicKeys.map((publicKey) => publicKey.value), + signatures.map((signature) => signature.value), + messages + ); + } + verify(publicKey: PublicKey, message: Uint8Array): boolean { return publicKey.value.verify(this.value, message); } @@ -57,10 +65,9 @@ export class Signature implements ISignature { } verifyMultiple(publicKeys: PublicKey[], messages: Uint8Array[]): boolean { - const msgs = Buffer.concat(messages); return this.value.aggregateVerifyNoCheck( publicKeys.map((key) => key.value), - msgs + concatUint8Arrays(messages) ); } diff --git a/src/interface.ts b/src/interface.ts index 5e9dc5b..822e657 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -13,6 +13,7 @@ export interface IBls { fromBytes(bytes: Uint8Array): Signature; fromHex(hex: string): Signature; aggregate(signatures: Signature[]): Signature; + verifyMultipleSignatures(publicKeys: PublicKey[], messages: Uint8Array[], signatures: Signature[]): boolean; }; sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array; @@ -21,6 +22,7 @@ export interface IBls { verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): boolean; verifyAggregate(publicKeys: Uint8Array[], message: Uint8Array, signature: Uint8Array): boolean; verifyMultiple(publicKeys: Uint8Array[], messages: Uint8Array[], signature: Uint8Array): boolean; + verifyMultipleSignatures(publicKeys: Uint8Array[], messages: Uint8Array[], signatures: Uint8Array[]): boolean; secretKeyToPublicKey(secretKey: Uint8Array): Uint8Array; init(): Promise; @@ -49,6 +51,7 @@ export declare class Signature { static fromBytes(bytes: Uint8Array): Signature; static fromHex(hex: string): Signature; static aggregate(signatures: Signature[]): Signature; + static verifyMultipleSignatures(publicKeys: PublicKey[], messages: Uint8Array[], signatures: Signature[]): boolean; verify(publicKey: PublicKey, message: Uint8Array): boolean; verifyAggregate(publicKeys: PublicKey[], message: Uint8Array): boolean; verifyMultiple(publicKeys: PublicKey[], messages: Uint8Array[]): boolean; diff --git a/test/data/malicious-signature-test-data.ts b/test/data/malicious-signature-test-data.ts new file mode 100644 index 0000000..1909aac --- /dev/null +++ b/test/data/malicious-signature-test-data.ts @@ -0,0 +1,32 @@ +/* eslint-disable max-len */ + +/** + * Data computed with lower level BLS primitives + * https://github.com/dapplion/eth2-bls-wasm/blob/2d2f3e6a0487e96706bfd8a1b8039c7d6c79f71f/verifyMultipleSignatures.test.js#L40 + * It creates N valid signatures + * It messes with the last 2 signatures, modifying their values + * such that they wqould not fail in aggregate signature verification. + * Creates a random G2 point with is added to the last signature + * and substracted to the second to last signature + */ +export const maliciousVerifyMultipleSignaturesData = { + pks: [ + "b836ccf44fa01e46745ccc3a47855e959783ef5df5cdcc607354b98d52c16b6613761339bfb833fd525cdca7c8071c6b", + "a317ce36dcf2bf6fd262dbad80427f890bc166152682cb6c600a66eb7d525f200839ab798ca4877c3143a31201905de4", + "b9b7b4f4a88d98f34b4c9ba8ae10e935ba51164ddc045d6ae26b403c87a6934e6c75f9fb5cc4b3b29a1255b316d08de5", + "a386a2bc7e9d13cf9b4ad3c819547534c768aeae6a2414bfcebee50f38aaf85a9d610974db931278c08fe86a91eb2999", + ], + msgs: [ + "690a91fc0a7a49bbc5afe9516c1831ca8845f281ef2e414f7dfeb71b5e91a902", + "3829d4fc2332afc2634079823b89598f3674be5da324b1092b3d8aeb7af5e164", + "9a9406647ed6af16b5ce3e828c5f5ef35f1221ed10476209476c12776ce417ac", + "3e8e4bcb78fda59a43ebfb90970cc6036ce18dc3d3a1b714cc4c1bfc00b8258e", + ], + sigs: [ + "864ed65f224cf4e49e9bbf313d3dc243649885d9bd432a15e6c1259f2e4c29fcefa7a4c3aafaac01519f7c92239702d7096df2971b1801cd26d0ca0d5e7743ccb0abe79d8c383f9bb04ebe553a3094e84d55bc79be7eff5ffdb9b322205acfd1", + "90efd8c82c356956fc170bec2aed874d14cea079625dfe69d8bc375e10fcd96e2c0348dfeb713f1889629ccb9ec95fee0e0c9cc7a728d8a7068701a04192ed585ec761edf6e2c1e44ceaaa61732052af81a6033fa7d375d7f7157909549322da", + "9023f43cc8e05a3e842b242b9f6781a9e2eadbfcbebd1242563e56bb47cd273ef20fc0c5099e05e83093581907bfd02915b5ef8c553918d4524c274a8856950c87c6314a2c003a2ed28e5fb56ddfdb233a2b895c2397bd15629325d95ca43b83", + "82c8fedc6ad43e945bbf7529d55b73d7ce593bc9ea94dfaf91d720b2ab0e51ce551f7fcda96d428b627ff776c94d6f360af425fe7fb4e4469b893071149db747f27a8bd488af7ba7f0edf86c7e551af89d7a55d4fc86968e10f91ed76e68e373", + ], + manipulated: [false, false, true, true], +}; diff --git a/test/unit/run-web-implementation.test.ts b/test/unit-web/run-web-implementation.test.ts similarity index 76% rename from test/unit/run-web-implementation.test.ts rename to test/unit-web/run-web-implementation.test.ts index 1ef54f0..c1cc7ed 100644 --- a/test/unit/run-web-implementation.test.ts +++ b/test/unit-web/run-web-implementation.test.ts @@ -1,7 +1,7 @@ import herumi from "../../src/herumi"; -import {runSecretKeyTests} from "./secretKey.test"; -import {runPublicKeyTests} from "./publicKey.test"; -import {runIndexTests} from "./index.test"; +import {runSecretKeyTests} from "../unit/secretKey.test"; +import {runPublicKeyTests} from "../unit/publicKey.test"; +import {runIndexTests} from "../unit/index.test"; // This file is intended to be compiled and run by Karma // Do not import the node.bindings or it will break with: diff --git a/test/unit/helpers/bytes.test.ts b/test/unit/helpers/bytes.test.ts index 2b14df7..952aaa5 100644 --- a/test/unit/helpers/bytes.test.ts +++ b/test/unit/helpers/bytes.test.ts @@ -1,5 +1,6 @@ import {expect} from "chai"; -import {isZeroUint8Array} from "../../../src/helpers/utils"; +import {concatUint8Arrays, isZeroUint8Array} from "../../../src/helpers/utils"; +import {hexToBytesNode} from "../../util"; describe("helpers / bytes", () => { describe("isZeroUint8Array", () => { @@ -21,8 +22,22 @@ describe("helpers / bytes", () => { }); } }); -}); -function hexToBytesNode(hex: string): Buffer { - return Buffer.from(hex.replace("0x", ""), "hex"); -} + describe("concatUint8Arrays", () => { + it("Should merge multiple Uint8Array", () => { + const bytesArr = [ + new Uint8Array([1, 2, 3]), + new Uint8Array([4, 5]), + new Uint8Array([6]), + new Uint8Array([7, 8]), + new Uint8Array([9, 10, 11]), + ]; + + const expectedBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + + const bytes = concatUint8Arrays(bytesArr); + + expect(bytes.toString()).to.equal(expectedBytes.toString()); + }); + }); +}); diff --git a/test/unit/helpers/hex.test.ts b/test/unit/helpers/hex.test.ts index 93d5c09..cca61a3 100644 --- a/test/unit/helpers/hex.test.ts +++ b/test/unit/helpers/hex.test.ts @@ -1,5 +1,6 @@ import {expect} from "chai"; import {hexToBytes, bytesToHex} from "../../../src/helpers/hex"; +import {hexToBytesNode} from "../../util"; describe("helpers / hex", () => { const testCases: {id: string; hex: string}[] = [ @@ -23,7 +24,3 @@ describe("helpers / hex", () => { }); } }); - -function hexToBytesNode(hex: string): Buffer { - return Buffer.from(hex.replace("0x", ""), "hex"); -} diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index cf50d4a..b9e16a0 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -1,6 +1,8 @@ import {expect} from "chai"; import {IBls} from "../../src/interface"; import {getN, randomMessage} from "../util"; +import {hexToBytes} from "../../src/helpers"; +import {maliciousVerifyMultipleSignaturesData} from "../data/malicious-signature-test-data"; export function runIndexTests(bls: IBls): void { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -90,4 +92,57 @@ export function runIndexTests(bls: IBls): void { expect(isValid).to.be.false; }); }); + + describe("verifyMultipleSignatures", () => { + it("Should verify multiple signatures", () => { + const n = 4; + const dataArr = getN(n, () => { + const sk = bls.SecretKey.fromKeygen(); + const pk = sk.toPublicKey(); + const msg = randomMessage(); + const sig = sk.sign(msg); + return {pk, msg, sig}; + }); + const pks = dataArr.map((data) => data.pk); + const msgs = dataArr.map((data) => data.msg); + const sigs = dataArr.map((data) => data.sig); + + expect(bls.Signature.verifyMultipleSignatures(pks, msgs, sigs)).to.equal(true, "class interface failed"); + + expect( + bls.verifyMultipleSignatures( + pks.map((pk) => pk.toBytes()), + msgs, + sigs.map((sig) => sig.toBytes()) + ) + ).to.equal(true, "functional (bytes serialized) interface failed"); + }); + + it("Test fails correctly against a malicous signature", async () => { + const pks = maliciousVerifyMultipleSignaturesData.pks.map((pk) => bls.PublicKey.fromHex(pk)); + const msgs = maliciousVerifyMultipleSignaturesData.msgs.map(hexToBytes); + const sigs = maliciousVerifyMultipleSignaturesData.sigs.map((sig) => bls.Signature.fromHex(sig)); + + maliciousVerifyMultipleSignaturesData.manipulated.forEach((isManipulated, i) => { + expect(sigs[i].verify(pks[i], msgs[i])).to.equal( + !isManipulated, + isManipulated ? "Manipulated signature should not verify" : "Ok signature should verify" + ); + }); + + // This method (AggregateVerify in BLS spec lingo) should verify + + const dangerousAggSig = bls.Signature.aggregate(sigs); + expect(dangerousAggSig.verifyMultiple(pks, msgs)).to.equal( + true, + "Malicious signature should be validated with bls.verifyMultiple" + ); + + // This method is expected to catch the malicious signature and not verify + expect(bls.Signature.verifyMultipleSignatures(pks, msgs, sigs)).to.equal( + false, + "Malicous signature should not validate with bls.verifyMultipleSignatures" + ); + }); + }); } diff --git a/test/util.ts b/test/util.ts index 37e835a..be1f2ad 100644 --- a/test/util.ts +++ b/test/util.ts @@ -13,3 +13,11 @@ export function range(n: number): number[] { for (let i = 0; i < n; i++) nums.push(i); return nums; } + +/** + * ONLY for NodeJS tests, for any other use src/helpers/hex hexToBytes() + * Serves as a "ground-truth" reference + */ +export function hexToBytesNode(hex: string): Buffer { + return Buffer.from(hex.replace("0x", ""), "hex"); +} diff --git a/tsconfig.json b/tsconfig.json index a15932d..5b70769 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "esnext", "module": "commonjs", "pretty": true, - "lib": ["esnext.bigint"], + "lib": ["esnext.bigint", "DOM"], "typeRoots": ["./node_modules/@types"], "declaration": true, "strict": true, diff --git a/webpack.config.js b/webpack.config.js index 3953a1f..62f6c6e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,17 +2,20 @@ module.exports = { entry: "./src/index.ts", mode: "production", node: { - fs: "empty" + fs: "empty", }, output: { - filename: "dist/bundle.js" + filename: "dist/bundle.js", }, resolve: { - extensions: [".ts", ".js"] + extensions: [".ts", ".js"], }, module: { - rules: [ - {test: /\.ts$/, use: {loader: "ts-loader", options: {transpileOnly: true}}} - ] - } -}; \ No newline at end of file + rules: [{test: /\.ts$/, use: {loader: "ts-loader", options: {transpileOnly: true}}}], + }, + optimization: { + // Disable minification for better debugging on Karma tests + minimize: false, + }, + devtool: "source-map", +};