import { sha512 } from '@noble/hashes/sha2.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { describe, it } from '@paulmillr/jsbt/test.js'; import { deepStrictEqual as eql, throws } from 'node:assert'; import type { DKG_Round1, DKG_Round2, DKG_Secret, FROST, FrostPublic, FrostSecret, Key, NonceCommitments, Nonces, } from '../src/abstract/frost.ts'; import { createFROST } from '../src/abstract/frost.ts'; import * as mod from '../src/abstract/modular.ts'; import { ED25519_TORSION_SUBGROUP, ed25519, ed25519_FROST, ristretto255, ristretto255_FROST, } from '../src/ed25519.ts'; import { ed448, ed448_FROST } from '../src/ed448.ts'; import { p256, p256_FROST } from '../src/nist.ts'; import { schnorr, schnorr_FROST, secp256k1, secp256k1_FROST, __TEST as secpTEST, } from '../src/secp256k1.ts'; import { bytesToNumberBE, concatBytes, numberToBytesBE, numberToBytesLE } from '../src/utils.ts'; import { jsonGZ } from './utils.ts'; type ScalarField = { fromBytes: (bytes: Uint8Array) => bigint; toBytes: (value: bigint) => Uint8Array; add: (a: bigint, b: bigint) => bigint; ZERO: bigint; isLE?: boolean; ORDER: bigint; }; type SignCfg = { MIN_PARTICIPANTS: number | string; MAX_PARTICIPANTS: number | string }; type ParticipantShare = { identifier: number; participant_share: string }; type RoundOneOutput = { identifier: number; hiding_nonce_randomness: string; binding_nonce_randomness: string; binding_nonce: string; hiding_nonce: string; binding_nonce_commitment: string; hiding_nonce_commitment: string; }; type RoundTwoOutput = { identifier: number; sig_share: string }; type SignVector = { config: SignCfg; inputs: { message: string; group_secret_key: string; verifying_key_key: string; share_polynomial_coefficients: string[]; participant_shares: ParticipantShare[]; }; round_one_outputs: { outputs: RoundOneOutput[] }; round_two_outputs: { outputs: RoundTwoOutput[] }; final_output: { sig: string }; }; type DkgInput = { identifier?: number; signing_key?: string; coefficient?: string; vss_commitments: string[]; proof_of_knowledge: string; signing_shares?: Record; verifying_share?: string; }; type DkgVector = { config: { MIN_PARTICIPANTS: number; MAX_PARTICIPANTS: number }; inputs: { verifying_key: string } & Record; }; type SampleVector = { identifier: string; proof_of_knowledge: string; element1: string; element2: string; scalar1: string; }; type RepairVector = { scalar_generation: Record; sigma_generation: Record; }; type ElementVector = { elements: { invalid_element: string } }; type PointLike = { add(rhs: T): T; toBytes(compressed?: boolean): Uint8Array; }; type PointCtor = { BASE: T; fromHex(hex: string): T; }; type Suite = { frost: FROST; loadSign: (index: number) => SignVector; signCount: number; loadDkg: (index: number) => DkgVector; dkgCount: number; loadSample: () => SampleVector; loadRepair: () => RepairVector; loadElement: () => ElementVector; base: string; doubleBase: string; proofPrefix: string; }; type Actor = { id: string; secret: Uint8Array; round1?: { public: DKG_Round1; secret: DKG_Secret }; round2?: Record; round3?: Key; }; const getJson = (path: string): T => jsonGZ(path) as T; const getPointBytes =

>(Point: PointCtor

) => ({ base: bytesToHex(Point.BASE.toBytes()), doubleBase: bytesToHex(Point.BASE.add(Point.BASE).toBytes()), }); const getVectorsSingle =

>( name: string, frost: FROST, Point: PointCtor

, proofPrefix = bytesToHex(Point.BASE.toBytes()) ): Suite => { const signPaths = [ `vectors/acvp-vectors/rfc/9591-frost/${name}-vectors.json.gz`, `vectors/acvp-vectors/rfc/9591-frost/${name}-vectors-big-identifier.json.gz`, ]; const dkgPaths = [`vectors/acvp-vectors/rfc/9591-frost/${name}-vectors_dkg.json.gz`]; return { frost, loadSign: (index: number) => getJson(signPaths[index]), signCount: signPaths.length, loadDkg: (index: number) => getJson(dkgPaths[index]), dkgCount: dkgPaths.length, loadSample: () => getJson(`vectors/acvp-vectors/rfc/9591-frost/${name}-samples.json.gz`), loadRepair: () => getJson(`vectors/acvp-vectors/rfc/9591-frost/${name}-repair-share.json.gz`), loadElement: () => getJson(`vectors/acvp-vectors/rfc/9591-frost/${name}-elements.json.gz`), ...getPointBytes(Point), proofPrefix, }; }; const sumHexScalars = (Fn: ScalarField, values: string[]) => { let sum = Fn.ZERO; for (const value of values) sum = Fn.add(sum, Fn.fromBytes(hexToBytes(value))); return bytesToHex(Fn.toBytes(sum)); }; const VECTORS: Record = { ed25519: getVectorsSingle('ed25519', ed25519_FROST, ed25519.Point), ed448: getVectorsSingle('ed448', ed448_FROST, ed448.Point), p256: getVectorsSingle('p256', p256_FROST, p256.Point), ristretto255: getVectorsSingle('ristretto255', ristretto255_FROST, ristretto255.Point), secp256k1: getVectorsSingle('secp256k1', secp256k1_FROST, secp256k1.Point), secp256k1_tr: getVectorsSingle( 'secp256k1-tr', schnorr_FROST, secp256k1.Point, bytesToHex(secp256k1.Point.BASE.toBytes(true).subarray(1)) ), }; it('signShare rejects nonce reuse across signing sessions across suites', () => { const check = (suite: typeof ed448_FROST | typeof p256_FROST) => { const deal = suite.trustedDealer({ min: 2, max: 2 }); const ids = Object.keys(deal.secretShares); const alice = deal.secretShares[ids[0]]; const bob = deal.secretShares[ids[1]]; const aliceRound1 = suite.commit(alice); const bobRound1 = suite.commit(bob); const commitmentList = [aliceRound1.commitments, bobRound1.commitments]; suite.signShare(alice, deal.public, aliceRound1.nonces, commitmentList, new Uint8Array([1])); throws(() => suite.signShare(alice, deal.public, aliceRound1.nonces, commitmentList, new Uint8Array([2])) ); }; check(ed448_FROST); check(p256_FROST); }); it('createFROST rejects opts without a usable Point constructor', () => { throws(() => createFROST({ name: 'FROST-TEST-SHA512-v1', hash: sha512, Fn: ed25519.Point.Fn } as any) ); }); it('DKG binds round3 to an owned authenticated round1 transcript', () => { const frost = p256_FROST; const signers = { min: 2, max: 2 }; const victim = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const attacker = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const genuine = structuredClone(attacker.public); const callerBytes = (bytes: Uint8Array): Uint8Array => typeof Buffer === 'undefined' ? Uint8Array.from(bytes) : Buffer.from(bytes); const supplied = { ...attacker.public, commitment: attacker.public.commitment.map(callerBytes), proofOfKnowledge: callerBytes(attacker.public.proofOfKnowledge), }; frost.DKG.round2(victim.secret, [supplied]); const attackerRound2 = frost.DKG.round2(attacker.secret, [victim.public]); const shareForVictim = attackerRound2[victim.public.identifier]; // Mutating Buffer-backed caller data after round2 must not rewrite the cached transcript. for (const commitment of supplied.commitment) commitment.fill(0); supplied.proofOfKnowledge.fill(0); // Round3 may inspect its compatibility argument once, but finalization must consume the cache. let commitmentReads = 0; const oneReadPackage = { identifier: genuine.identifier, get commitment() { commitmentReads++; if (commitmentReads > 1) throw new Error('round3 reused caller-owned transcript'); return genuine.commitment.map((c) => c.slice()); }, proofOfKnowledge: genuine.proofOfKnowledge.slice(), } as DKG_Round1; const installed = frost.DKG.round3(victim.secret, [oneReadPackage], [shareForVictim]); eql(installed.public.signers, signers); eql(commitmentReads, 1); eql(victim.secret.round1Cache, undefined); eql(victim.secret.round2Cache, undefined); eql(victim.secret.coefficients, undefined); }); it('DKG round3 rejects an algebraically valid replacement commitment transcript', () => { const frost = p256_FROST; const Fn = frost.utils.Fn; const signers = { min: 2, max: 2 }; const victim = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const attacker = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); frost.DKG.round2(victim.secret, [attacker.public]); const attackerRound2 = frost.DKG.round2(attacker.secret, [victim.public]); const shareForVictim = attackerRound2[victim.public.identifier]; const oldShare = Fn.fromBytes(shareForVictim.signingShare); const victimConstant = p256.Point.fromBytes(victim.public.commitment[0]); const replacementConstant = p256.Point.BASE.multiply(0x123456789abcdefn).subtract(victimConstant); const replacementLinear = p256.Point.BASE.multiply(oldShare) .subtract(replacementConstant) .multiply(Fn.inv(1n)); const replacement = { ...attacker.public, commitment: [replacementConstant.toBytes(true), replacementLinear.toBytes(true)], }; throws( () => frost.DKG.round3(victim.secret, [replacement], [shareForVictim]), /round1 packages do not match authenticated transcript/ ); // A failed finalization does not consume the local secret state; the genuine transcript retries. const installed = frost.DKG.round3( victim.secret, [structuredClone(attacker.public)], [shareForVictim] ); eql(installed.public.signers, signers); }); it('DKG round2 retry rejects a rogue round1 package with a stale proof of knowledge', () => { // Regression test for the round2 retry rogue-key attack reported against 2.3.0: a peer // who behaves honestly during the first round2() call aborts the victim's round3() with // an invalid share, then substitutes a commitment B0 = t*G - A0 on the retry. He cannot // prove knowledge of B0's discrete log, so he replays his stale proof. The retry must // reject the substitution instead of silently returning the cached packages. const frost = p256_FROST; const Fn = frost.utils.Fn; const signers = { min: 2, max: 2 }; const victim = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const attacker = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const first = frost.DKG.round2(victim.secret, [structuredClone(attacker.public)]); // Abort the victim's round3() with a share that fails validateSecretShare. const badShare = { identifier: attacker.public.identifier, signingShare: Fn.toBytes(Fn.create(0x51ce5ed1n)), }; throws( () => frost.DKG.round3(victim.secret, [structuredClone(attacker.public)], [badShare]), /invalid secret share/ ); // Rogue replacement: the merged group key lands on t*G, the sent share stays // VSS-consistent at the victim's identifier, but the proof of knowledge is stale. const t = Fn.create(0x1337c0d3n); const s = Fn.create(0x515151n); const A0 = p256.Point.fromBytes(victim.public.commitment[0]); const B0 = p256.Point.BASE.multiply(t).subtract(A0); const B1 = p256.Point.BASE.multiply(s).subtract(B0); const rogue = { identifier: attacker.public.identifier, commitment: [B0.toBytes(true), B1.toBytes(true)], proofOfKnowledge: structuredClone(attacker.public.proofOfKnowledge), }; throws( () => frost.DKG.round2(victim.secret, [rogue]), /round1 packages do not match authenticated transcript/ ); // The genuine transcript still retries cleanly after the rejected substitution. eql(frost.DKG.round2(victim.secret, [structuredClone(attacker.public)]), first); }); describe('createFROST', () => { const create = () => { const frost = createFROST({ name: 'TRACE', Point: ed25519.Point, hash: sha512, H2: '' }); const secretKey = new Uint8Array(32).fill(7); const msg = new Uint8Array([1, 2, 3]); const sig = frost.sign(msg, secretKey); const publicKey = ed25519.Point.BASE.multiply(ed25519.Point.Fn.fromBytes(secretKey)).toBytes(); return { frost, msg, sig, publicKey }; }; it('createFROST.parsePoint still accepts canonical ed25519 public keys on the verify path', () => { const { frost, msg, sig, publicKey } = create(); eql(frost.verify(sig, msg, publicKey), true); }); it('snapshots callbacks and nested transaction hooks', () => { const adjustTx = { encode: (tx: Uint8Array) => tx, decode: (tx: Uint8Array) => tx, }; const opts: any = { name: 'TRACE', Point: ed25519.Point, hash: sha512, H2: '', adjustTx, }; const frost = createFROST(opts); const r = 7n; const forged = concatBytes( ed25519.Point.BASE.multiply(r).toBytes(), ed25519.Point.Fn.toBytes(r) ); const msg = Uint8Array.of(1, 2, 3); const publicKey = ed25519.Point.BASE.toBytes(); eql(frost.verify(forged, msg, publicKey), false, 'forgery rejected before mutation'); opts.challenge = () => 0n; adjustTx.decode = () => { throw new Error('mutated decoder used'); }; eql(frost.verify(forged, msg, publicKey), false, 'factory policy remains unchanged'); }); it('verify enforces mandatory point checks even with optional hooks', () => { const { frost, msg } = create(); const forged = concatBytes(ed25519.Point.BASE.toBytes(), ed25519.Point.Fn.toBytes(1n)); for (const encoded of ED25519_TORSION_SUBGROUP) { throws(() => frost.verify(forged, msg, hexToBytes(encoded))); } const permissive = createFROST({ name: 'TRACE', Point: ed25519.Point, hash: sha512, H2: '', validatePoint: () => {}, }); throws(() => permissive.verify(forged, msg, new Uint8Array(32)), /prime-order subgroup/); const customParser = createFROST({ name: 'TRACE', Point: ed25519.Point, hash: sha512, H2: '', parsePublicKey: (bytes) => ed25519.Point.fromBytes(bytes), validatePoint: (point) => { if (point.equals(ed25519.Point.BASE)) throw new Error('suite point policy'); }, }); throws( () => customParser.verify(new Uint8Array(), msg, ed25519.Point.BASE.toBytes()), /suite point policy/ ); const offCurve = p256.Point.fromAffine({ x: 1n, y: 1n }); eql(offCurve.is0(), false); eql(offCurve.isTorsionFree(), true); // cofactor-one shortcut is not an on-curve check const offCurveParser = createFROST({ name: 'TRACE-P256', Point: p256.Point, hash: sha512, parsePublicKey: () => offCurve, }); throws( () => offCurveParser.verify(new Uint8Array(), msg, new Uint8Array()), /equation left != right/ ); }); it('aggregate passes unadjusted public package to verifyShare attribution', () => { const adjusted = new WeakSet(); const frost = createFROST({ name: 'TRACE', Point: ed25519.Point, hash: sha512, H2: '', adjustPublic(pub) { if (adjusted.has(pub as object)) throw new Error('adjustPublic received adjusted package'); const res = { signers: { min: pub.signers.min, max: pub.signers.max }, commitments: pub.commitments.map((i) => i.slice()), verifyingShares: Object.fromEntries( Object.entries(pub.verifyingShares).map(([k, v]) => [k, v.slice()]) ), } as FrostPublic; adjusted.add(res); return res; }, }); const { publicKey, secretShares, ids, msg, commitmentList, secretNonces } = createSession(frost); const sigShares: Record = {}; for (const id of ids) sigShares[id] = frost.signShare( secretShares[id], publicKey, secretNonces[id], commitmentList, msg ); sigShares[ids[0]][0] ^= 1; throws( () => frost.aggregate(publicKey, commitmentList, msg, sigShares), (err: any) => err.message === 'aggregation failed' && err.cheaters.includes(ids[0]) ); }); }); const Identifiers: Record> = { ed25519: { 7: '0700000000000000000000000000000000000000000000000000000000000000', 'alice@example.com': '697dd8ec4846026115571eb037aefc99579c63a39baf715f051069ca393d0d06', }, ed448: { 7: '070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', 'alice@example.com': 'c3d472f37818fe4a273745d83758066066de3c9ed2a2b65ef3d7b5495e0bb3966c965691988afcdcc87a20b711d5890022674c602bf4743b00', }, ristretto255: { 7: '0700000000000000000000000000000000000000000000000000000000000000', 'alice@example.com': 'da57abef2150fdc6f5dadb29891f410356c811fd79f987243d037f4c2149990e', }, p256: { 7: '0000000000000000000000000000000000000000000000000000000000000007', 'alice@example.com': '2349cacbc2dd7dc5d11f5aa1ff03b9b97f04521eb5147f0f80d6a132c42e596e', }, secp256k1: { 7: '0000000000000000000000000000000000000000000000000000000000000007', 'alice@example.com': '961cce175dd5f9864d7f255d5aa9e8cf4e513f8a57df7bcb0dd44793c18980c7', }, secp256k1_tr: { 7: '0000000000000000000000000000000000000000000000000000000000000007', 'alice@example.com': '9cfff9f4fb5eb8afd389ff1c9f0f9e2c48e20d85bea2e96fe21773aff1a58301', }, }; const BufferRNG = (lst: Uint8Array[]) => { return (len: number) => { const res = lst.shift(); if (!res) throw new Error('RNG empty'); if (res.length !== len) throw new Error(`RNG wrong length ${len} (expected ${res.length})`); return res; }; }; const MockScalar = (Fn: ScalarField, bytes: Uint8Array) => { const n = Fn.fromBytes(bytes); return (Fn.isLE ? numberToBytesLE : numberToBytesBE)(n - 1n, mod.getMinHashLength(Fn.ORDER)); }; const secp256k1SecretByY = (even: boolean) => { for (let i = 1; i < 512; i++) { const secretKey = numberToBytesBE(BigInt(i), 32); const point = secp256k1.Point.fromBytes(secp256k1.getPublicKey(secretKey)); if ((point.y & 1n) === (even ? 0n : 1n)) return secretKey; } throw new Error('no matching secp256k1 secret key found'); }; const createSession = (frost: FROST, identifiers?: string[]) => { const deal = frost.trustedDealer({ min: 2, max: 2 }, identifiers); const ids = Object.keys(deal.secretShares); const msg = new Uint8Array([1, 2, 3, 4]); const secretNonces: Record = {}; const commitmentList: NonceCommitments[] = []; for (const id of ids) { const { nonces, commitments } = frost.commit(deal.secretShares[id]); secretNonces[id] = nonces; commitmentList.push(commitments); } return { publicKey: deal.public, secretShares: deal.secretShares, ids, msg, secretNonces, commitmentList, }; }; const fullSign = (frost: FROST, msg: Uint8Array) => { const { publicKey, secretShares, ids, secretNonces, commitmentList } = createSession(frost); const sigShares: Record = {}; for (const id of ids) sigShares[id] = frost.signShare( secretShares[id], publicKey, secretNonces[id], commitmentList, msg ); const sig = frost.aggregate(publicKey, commitmentList, msg, sigShares); return { sig, groupPk: publicKey.commitments[0] }; }; it('aggregated signatures verify under plain single-signer verifiers', () => { const msg = new Uint8Array([1, 2, 3, 4, 5]); // FROST(Ed25519, SHA-512) uses the undecorated RFC 8032 challenge hash, so its // aggregated output is a plain ed25519 signature for the group public key. const e = fullSign(ed25519_FROST, msg); eql(ed25519.verify(e.sig, msg, e.groupPk), true); // FROST(secp256k1, SHA-256, TR) produces BIP340 signatures for the x-only group key. const t = fullSign(schnorr_FROST, msg); eql(t.sig.length, 64); eql(schnorr.verify(t.sig, msg, t.groupPk.subarray(1)), true); }); it('verify rejects re-encoded signatures (weierstrass uncompressed R)', () => { const msg = new Uint8Array([5, 4, 3, 2, 1]); // RFC 9591 SerializeElement is canonical (compressed): the same signature re-encoded // with an uncompressed R must not verify. const check = ( frost: FROST, fromBytes: (b: Uint8Array) => { toBytes(c?: boolean): Uint8Array } ) => { const { sig, groupPk } = fullSign(frost, msg); eql(frost.verify(sig, msg, groupPk), true); const bad = concatBytes(fromBytes(sig.subarray(0, 33)).toBytes(false), sig.subarray(33)); eql(bad.length, 97); throws(() => frost.verify(bad, msg, groupPk)); }; check(secp256k1_FROST, (b) => secp256k1.Point.fromBytes(b)); check(p256_FROST, (b) => p256.Point.fromBytes(b)); }); describe('FROST (RFC 9591)', () => { for (const name in VECTORS) { const { frost, loadSign, signCount, loadDkg, dkgCount, loadSample, loadRepair, loadElement, base, doubleBase, proofPrefix, } = VECTORS[name]; describe(`${name}`, () => { const Fn = frost.utils.Fn; it('identifiers, samples, repair fixtures, and invalid elements', () => { const sample = loadSample(); const repair = loadRepair(); const element = loadElement(); const t = Identifiers[name]; eql(frost.Identifier.fromNumber(7), t[7], 'identifier number'); eql( frost.Identifier.derive('alice@example.com'), t['alice@example.com'], 'identifier derive' ); throws(() => frost.Identifier.fromNumber(0)); eql(frost.Identifier.fromNumber(42), sample.identifier, 'sample identifier'); eql(sample.element1, base, 'sample element1'); eql(sample.element2, doubleBase, 'sample element2'); eql(sample.proof_of_knowledge, proofPrefix + sample.scalar1, 'proof of knowledge'); eql( bytesToHex(Fn.toBytes(Fn.fromBytes(hexToBytes(sample.scalar1)))), sample.scalar1, 'sample scalar' ); const { scalar_generation: sg, sigma_generation: gg } = repair; eql( sumHexScalars(Fn, [sg.random_scalar_1, sg.random_scalar_2, sg.random_scalar_3]), sg.random_scalar_sum, 'repair scalar sum' ); eql( sumHexScalars(Fn, [gg.sigma_1, gg.sigma_2, gg.sigma_3, gg.sigma_4]), gg.sigma_sum, 'repair sigma sum' ); const deal = frost.trustedDealer({ min: 2, max: 2 }); const ids = Object.keys(deal.secretShares); const { nonces, commitments } = frost.commit(deal.secretShares[ids[0]]); const { commitments: commitments2 } = frost.commit(deal.secretShares[ids[1]]); const commitmentList = [ { ...commitments, hiding: hexToBytes(element.elements.invalid_element) }, commitments2, ]; throws(() => frost.signShare( deal.secretShares[ids[0]], deal.public, nonces, commitmentList, new Uint8Array([1, 2, 3]) ) ); }); const testSign = (publicKey: FrostPublic, secretShares: Record) => { // Round 1: everybody commit nonces const secretNonces: Record = {}; const commitmentList: NonceCommitments[] = []; // Nonce commitments from participants merged in commitmentList for (const k in secretShares) { const { nonces, commitments } = frost.commit(secretShares[k]); secretNonces[k] = nonces; commitmentList.push(commitments); } // Round 2: everybody sign message const msg = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); const sigShares: Record = {}; for (const k in secretShares) { sigShares[k] = frost.signShare( secretShares[k], publicKey, secretNonces[k], commitmentList, msg ); } // Each participant (or coordinator) can verify signature shares for (const id in secretShares) { for (const sid in sigShares) { eql(frost.verifyShare(publicKey, commitmentList, msg, sid, sigShares[sid]), true); } } for (const id in secretShares) { const groupSig = frost.aggregate(publicKey, commitmentList, msg, sigShares); // Verify group signature eql(frost.verify(groupSig, msg, publicKey.commitments[0]), true); } }; it('Example (DKG, no dealer)', () => { // Alice, Bob and Carol decide to create 2-3 multisig (this is outside of protocol) const signers = { min: 2, max: 3 }; const alice: Actor = { id: frost.Identifier.derive('alice@example.com'), secret: frost.utils.randomScalar(), }; const bob: Actor = { id: frost.Identifier.fromNumber(2), secret: frost.utils.randomScalar(), }; const carol: Actor = { id: frost.Identifier.derive('carol@apt.org'), secret: frost.utils.randomScalar(), }; // Everybody prepare round1 packages alice.round1 = frost.DKG.round1(alice.id, signers, alice.secret); bob.round1 = frost.DKG.round1(bob.id, signers, bob.secret); carol.round1 = frost.DKG.round1(carol.id, signers, carol.secret); // Now they exchange public information from round1 and do round2 const aliceRound1Received = [bob.round1.public, carol.round1.public]; const bobRound1Received = [alice.round1.public, carol.round1.public]; const carolRound1Received = [bob.round1.public, alice.round1.public]; alice.round2 = frost.DKG.round2(alice.round1.secret, aliceRound1Received); bob.round2 = frost.DKG.round2(bob.round1.secret, bobRound1Received); carol.round2 = frost.DKG.round2(carol.round1.secret, carolRound1Received); // Now each sends information about round2 to others const aliceRound2Received = [bob.round2[alice.id], carol.round2[alice.id]]; const bobRound2Received = [alice.round2[bob.id], carol.round2[bob.id]]; const carolRound2Received = [bob.round2[carol.id], alice.round2[carol.id]]; alice.round3 = frost.DKG.round3( alice.round1.secret, aliceRound1Received, aliceRound2Received ); bob.round3 = frost.DKG.round3(bob.round1.secret, bobRound1Received, bobRound2Received); carol.round3 = frost.DKG.round3( carol.round1.secret, carolRound1Received, carolRound2Received ); // previous secrets can be safely removed: for (const s of [alice.round1.secret, bob.round1.secret, carol.round1.secret]) frost.DKG.clean(s); const keys = { [alice.id]: alice.round3, [bob.id]: bob.round3, [carol.id]: carol.round3, }; for (const k in keys) frost.validateSecret(keys[k].secret, keys[k].public); // Now, with round3 info we can sign stuff testSign(alice.round3.public, { [alice.id]: alice.round3.secret, [bob.id]: bob.round3.secret, [carol.id]: carol.round3.secret, }); }); it('Example (trusted dealer)', () => { const signers = { min: 2, max: 3 }; // Even if no identifiers & secret key provided, we still can generate everything // Trusted dealer generates key for everybody const keys = frost.trustedDealer(signers); // Each participant verify their own key for (const k in keys.secretShares) frost.validateSecret(keys.secretShares[k], keys.public); // Now participants can sign stuff testSign(keys.public, keys.secretShares); }); if (name === 'secp256k1_tr') { it('Taproot single-key signing and x-only verify behavior', () => { const msg = new Uint8Array([9, 8, 7, 6]); const secretKey = secp256k1SecretByY(false); const sig = schnorr_FROST.sign(msg, secretKey); eql( schnorr_FROST.verify(sig, msg, secp256k1.getPublicKey(secretKey)), true, 'odd-Y secret' ); const xOnlyMsg = new Uint8Array([1, 3, 3, 7]); const xOnlySecretKey = secp256k1SecretByY(true); const xOnlySig = schnorr_FROST.sign(xOnlyMsg, xOnlySecretKey); eql( schnorr_FROST.verify(xOnlySig, xOnlyMsg, schnorr.getPublicKey(xOnlySecretKey)), true, 'x-only public key' ); throws(() => schnorr_FROST.verify(xOnlySig, xOnlyMsg, secp256k1.getPublicKey(xOnlySecretKey, false)) ); }); it('frostTweak helpers: undefined merkleRoot disables TapTweak', () => { const Point = secp256k1.Point; const Fn = Point.Fn; const keys = schnorr_FROST.trustedDealer({ min: 2, max: 3 }); const VK = Point.fromBytes(keys.public.commitments[0]); const evenVK = VK.y % 2n === 0n ? VK : VK.negate(); // Disabled tweak (t=0): must not throw and only normalize to even Y. const untweaked = secpTEST.frostTweakPublic(keys.public); eql(untweaked.commitments[0], evenVK.toBytes()); const id = Object.keys(keys.secretShares)[0]; const untweakedShare = secpTEST.frostTweakSecret(keys.secretShares[id], keys.public); const s0 = Fn.fromBytes(keys.secretShares[id].signingShare); eql( Fn.fromBytes(untweakedShare.signingShare), VK.y % 2n === 0n ? s0 : Fn.neg(s0), 'disabled tweak keeps share up to even-Y negation' ); // Empty merkle root: BIP-341 tweak with t = TapTweak(x-only VK). const tweaked = secpTEST.frostTweakPublic(keys.public, new Uint8Array(0)); const t = bytesToNumberBE( schnorr.utils.taggedHash('TapTweak', evenVK.toBytes(true).subarray(1)) ); eql(tweaked.commitments[0], evenVK.add(Point.BASE.multiply(t)).toBytes()); }); } it('reject non-canonical identifier hex', () => { const bad = frost.Identifier.fromNumber(11).toUpperCase(); const good = frost.Identifier.fromNumber(12); throws(() => frost.trustedDealer({ min: 2, max: 2 }, [bad, good])); }); it('reject duplicate explicit identifiers in trustedDealer', () => { const id = frost.Identifier.fromNumber(7); throws(() => frost.trustedDealer({ min: 2, max: 2 }, [id, id])); }); it('trustedDealer returns one shared public package', () => { const deal = frost.trustedDealer({ min: 2, max: 2 }); eql(Object.keys(deal).sort(), ['public', 'secretShares']); eql(Object.keys(deal.public).sort(), ['commitments', 'signers', 'verifyingShares']); eql(deal.public.signers, { min: 2, max: 2 }); }); it('trustedDealer secrets do not alias commitment buffers', () => { const deal = frost.trustedDealer({ min: 2, max: 2 }); const before = Uint8Array.from(deal.public.commitments[0]); deal.secretShares[Object.keys(deal.secretShares)[0]].signingShare[0] ^= 1; eql(deal.public.commitments[0], before); }); it('DKG round3 does not alias public and secret buffers', () => { const signers = { min: 2, max: 2 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const aliceRound2 = frost.DKG.round2(alice.secret, [bob.public]); const bobRound2 = frost.DKG.round2(bob.secret, [alice.public]); const round3 = frost.DKG.round3( alice.secret, [bob.public], [bobRound2[frost.Identifier.fromNumber(1)]] ); const before = Uint8Array.from( round3.public.verifyingShares[frost.Identifier.fromNumber(1)] ); round3.public.commitments[0][0] ^= 1; eql(round3.public.verifyingShares[frost.Identifier.fromNumber(1)], before); }); it('normalize unsorted commitment lists', () => { const { publicKey, secretShares, ids, msg, secretNonces, commitmentList } = createSession(frost); const nonces2 = { hiding: Uint8Array.from(secretNonces[ids[0]].hiding), binding: Uint8Array.from(secretNonces[ids[0]].binding), }; const sortedShare = frost.signShare( secretShares[ids[0]], publicKey, secretNonces[ids[0]], commitmentList, msg ); const reversed = [...commitmentList].reverse(); const reversedShare = frost.signShare( secretShares[ids[0]], publicKey, nonces2, reversed, msg ); eql(reversedShare, sortedShare); }); it('reject mismatched signer commitment pairs', () => { const { publicKey, secretShares, ids, msg, secretNonces, commitmentList } = createSession(frost); const tampered = [...commitmentList]; tampered[0] = { ...tampered[0], hiding: tampered[1].hiding, binding: tampered[1].binding, }; throws(() => frost.signShare(secretShares[ids[0]], publicKey, secretNonces[ids[0]], tampered, msg) ); }); it('reject under-threshold signing sessions before share generation', () => { const signers = { min: 2, max: 3 }; const deal = frost.trustedDealer(signers); const id = Object.keys(deal.secretShares)[0]; const { nonces, commitments } = frost.commit(deal.secretShares[id]); throws(() => frost.signShare( deal.secretShares[id], deal.public, nonces, [commitments], new Uint8Array([1, 2, 3]) ) ); }); it('reject over-capacity signing sessions before share generation', () => { const deal = frost.trustedDealer({ min: 2, max: 2 }); const id = Object.keys(deal.secretShares)[0]; const { nonces, commitments } = frost.commit(deal.secretShares[id]); const extra = { ...commitments, identifier: frost.Identifier.fromNumber(3) }; throws(() => frost.signShare( deal.secretShares[id], deal.public, nonces, [commitments, commitments, extra], new Uint8Array([1, 2, 3]) ) ); }); it('combineSecret rejects duplicate shares beyond threshold', () => { const signers = { min: 2, max: 3 }; const deal = frost.trustedDealer(signers); const ids = Object.keys(deal.secretShares); throws(() => frost.combineSecret( [deal.secretShares[ids[0]], deal.secretShares[ids[1]], deal.secretShares[ids[0]]], signers ) ); }); it('combineSecret rejects more shares than signers.max', () => { const deal = frost.trustedDealer({ min: 2, max: 4 }); throws(() => frost.combineSecret(Object.values(deal.secretShares), { min: 2, max: 3 })); }); it('reject renamed signature shares during aggregation', () => { const { publicKey, secretShares, ids, msg, commitmentList, secretNonces } = createSession(frost); const sigShares: Record = {}; for (const id of ids) sigShares[id] = frost.signShare( secretShares[id], publicKey, secretNonces[id], commitmentList, msg ); const renamed: Record = { [frost.Identifier.fromNumber(10)]: sigShares[ids[0]], [frost.Identifier.fromNumber(11)]: sigShares[ids[1]], }; throws(() => frost.aggregate(publicKey, commitmentList, msg, renamed)); }); it('reject duplicate commitment identifiers during aggregation', () => { const { publicKey, secretShares, ids, msg, commitmentList, secretNonces } = createSession(frost); const sigShares: Record = {}; for (const id of ids) sigShares[id] = frost.signShare( secretShares[id], publicKey, secretNonces[id], commitmentList, msg ); throws( () => frost.aggregate(publicKey, [commitmentList[0], commitmentList[0]], msg, sigShares), (err: any) => err.message === 'aggregation failed' && Array.isArray(err.cheaters) && err.cheaters.length === 0 ); }); it('DKG round2 rejects caller package in others', () => { const signers = { min: 2, max: 3 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); throws(() => frost.DKG.round2(alice.secret, [alice.public, bob.public])); }); it('DKG round2 rejects malformed higher commitments', () => { const signers = { min: 2, max: 3 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const carol = frost.DKG.round1(frost.Identifier.fromNumber(3), signers); const malformed = { ...bob.public, commitment: [...bob.public.commitment] }; malformed.commitment[1] = new Uint8Array([1]); throws(() => frost.DKG.round2(alice.secret, [malformed, carol.public])); }); it('DKG clean does not mutate shared signers state', () => { const signers = { min: 2, max: 3 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); frost.DKG.clean(alice.secret); eql(signers, { min: 2, max: 3 }); eql(alice.secret.signers, { min: 2, max: 3 }); eql(bob.secret.signers, { min: 2, max: 3 }); }); it('DKG round3 rejects tampered local secret state', () => { const signers = { min: 2, max: 2 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); frost.DKG.round2(alice.secret, [bob.public]); const bobRound2 = frost.DKG.round2(bob.secret, [alice.public]); alice.secret.coefficients![0] += 1n; throws(() => frost.DKG.round3(alice.secret, [bob.public], [bobRound2[frost.Identifier.fromNumber(1)]]) ); }); it('DKG round2 can retry after a late round3 failure', () => { const signers = { min: 2, max: 2 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const first = frost.DKG.round2(alice.secret, [bob.public]); const bobRound2 = frost.DKG.round2(bob.secret, [alice.public]); const commitment0 = alice.secret.commitment[0]; alice.secret.commitment[0] = new Uint8Array([1]); throws(() => frost.DKG.round3(alice.secret, [bob.public], [bobRound2[frost.Identifier.fromNumber(1)]]) ); alice.secret.commitment[0] = commitment0; eql(frost.DKG.round2(alice.secret, [bob.public]), first); }); it('DKG round2 replay does not expose enough shares to recover the local polynomial', () => { const signers = { min: 2, max: 2 }; const alice = frost.DKG.round1(frost.Identifier.fromNumber(1), signers); const bob = frost.DKG.round1(frost.Identifier.fromNumber(2), signers); const carol = frost.DKG.round1(frost.Identifier.fromNumber(3), signers); const bobId = bob.public.identifier; const first = frost.DKG.round2(alice.secret, [bob.public]); eql(frost.DKG.round2(alice.secret, [structuredClone(bob.public)]), first); throws( () => frost.DKG.round2(alice.secret, [carol.public]), /round1 packages do not match authenticated transcript/ ); eql(Object.keys(first), [bobId]); }); for (let signIndex = 0; signIndex < signCount; signIndex++) { it(`sign ${signIndex}`, () => { const t = loadSign(signIndex); const signers = { min: +t.config.MIN_PARTICIPANTS, max: +t.config.MAX_PARTICIPANTS }; const msg = hexToBytes(t.inputs.message); const secretKey = hexToBytes(t.inputs.group_secret_key); // 0. Trusted dealear generates keys for multisig const deal = frost.trustedDealer( signers, undefined, secretKey, BufferRNG([ ...t.inputs.share_polynomial_coefficients.map((i) => MockScalar(Fn, hexToBytes(i))), ]) ); const ids = Object.keys(deal.secretShares); eql(ids.length, signers.max, 'participant count'); eql(bytesToHex(deal.public.commitments[0]), t.inputs.verifying_key_key); // We can combine shards back to key eql( bytesToHex( frost.combineSecret( ids.map((i) => deal.secretShares[i]), signers ) ), t.inputs.group_secret_key ); // Use combine secret key to sign & verify const sigGroup = frost.sign(msg, hexToBytes(t.inputs.group_secret_key)); eql(frost.verify(sigGroup, msg, hexToBytes(t.inputs.verifying_key_key)), true); // Validate generated shares for (const ps of t.inputs.participant_shares) { const id = ids[ps.identifier - 1]; eql(frost.Identifier.fromNumber(ps.identifier), id); eql(bytesToHex(deal.secretShares[id].signingShare), ps.participant_share); } // Then dealer sends keys to everybody. Each participant validates secret share for (const k in deal.secretShares) frost.validateSecret(deal.secretShares[k], deal.public); // Round 1: each participant generate nonce and commitments // Nonces kept private, commitments sent to coordinator (or every other participant) const secretNonces: Record = {}; const commitmentList: NonceCommitments[] = []; // Nonce commitments from participants merged in commitmentList for (const o of t.round_one_outputs.outputs) { const id = ids[o.identifier - 1]; const { nonces, commitments } = frost.commit( deal.secretShares[id], BufferRNG( [o.hiding_nonce_randomness, o.binding_nonce_randomness].map((i) => hexToBytes(i)) ) ); eql(bytesToHex(nonces.binding), o.binding_nonce); eql(bytesToHex(nonces.hiding), o.hiding_nonce); eql(bytesToHex(commitments.binding), o.binding_nonce_commitment); eql(bytesToHex(commitments.hiding), o.hiding_nonce_commitment); eql(commitments.identifier, id); secretNonces[id] = nonces; commitmentList.push(commitments); } // Round 2: each participant signs message const sigShares: Record = {}; for (const o of t.round_two_outputs.outputs) { const id = ids[o.identifier - 1]; const sigShare = frost.signShare( deal.secretShares[id], deal.public, secretNonces[id], commitmentList, msg ); eql(bytesToHex(sigShare), o.sig_share); sigShares[id] = sigShare; } // Verification is caller-independent: check each distinct signature share once. for (const sid in sigShares) { eql(frost.verifyShare(deal.public, commitmentList, msg, sid, sigShares[sid]), true); } // Aggregation is also coordinator-independent: merge and verify the group signature once. const groupSig = frost.aggregate(deal.public, commitmentList, msg, sigShares); eql(bytesToHex(groupSig), t.final_output.sig); eql(frost.verify(groupSig, msg, deal.public.commitments[0]), true); }); } for (let dkgIndex = 0; dkgIndex < dkgCount; dkgIndex++) { // DKG is Distributed Key Generation (not related to Trusted Dealer Key Generation) // Awesome naming! it(`dkg ${dkgIndex}`, () => { const t = loadDkg(dkgIndex); const getInput = (id: number): DkgInput => { const input = t.inputs[String(id)]; if (!input || typeof input === 'string') throw new Error('missing DKG input ' + id); return input; }; // Official tests check only participant 1, this part of test vectors is broken. // We replace invalid value with real (verified with official implementation). if (name === 'ed448') { const input = getInput(3); if (!input.signing_shares) throw new Error('missing ed448 signing shares'); input.signing_shares['2'] = bytesToHex( new Uint8Array([ 106, 167, 228, 143, 61, 127, 77, 227, 177, 160, 187, 149, 165, 8, 87, 5, 229, 97, 139, 143, 103, 216, 156, 244, 61, 216, 214, 73, 5, 125, 41, 95, 240, 200, 55, 228, 169, 24, 99, 74, 148, 167, 115, 96, 110, 209, 86, 201, 219, 84, 60, 59, 205, 30, 42, 14, 0, ]) ); } const signers = { min: t.config.MIN_PARTICIPANTS, max: t.config.MAX_PARTICIPANTS }; const ids: number[] = []; const round1: Record = {}; const round1Secret: Record = {}; const round2Recv: Record = {}; const id2id: Record = {}; let round3Participants = 0; for (const k in t.inputs) { const v = t.inputs[k]; if (typeof v === 'string' || !v.identifier) continue; const id = +v.identifier; id2id[frost.Identifier.fromNumber(id)] = id; ids.push(id); round1[id] = { identifier: frost.Identifier.fromNumber(id), commitment: v.vss_commitments.map(hexToBytes), proofOfKnowledge: hexToBytes(v.proof_of_knowledge), }; if (!v.signing_key) continue; const { coefficients, commitment } = frost.utils.generateSecretPolynomial( signers, hexToBytes(v.signing_key), [Fn.fromBytes(hexToBytes(v.coefficient))] ); // Re-create first round package, because we don't have random info in tests const identifier = Fn.fromBytes(hexToBytes(frost.Identifier.fromNumber(id))); round1Secret[id] = { identifier, coefficients, commitment, signers, }; // What we receive from others const shares = []; for (const k in v.signing_shares) { shares.push({ identifier: frost.Identifier.fromNumber(+k), signingShare: hexToBytes(v.signing_shares[k]), }); } round2Recv[id] = shares; } for (const id of ids) { const other = ids.filter((i) => i !== id); if (round1Secret[id]) { const otherSecretShares = {}; for (const otherId of other) { const input = getInput(otherId); if (!input.signing_shares) continue; otherSecretShares[frost.Identifier.fromNumber(otherId)] = hexToBytes( input.signing_shares[String(id)] ); } // Skip inputs without info (broken in taproot stuff) const input = getInput(id); if (!input.signing_shares) continue; // What we send const round2 = frost.DKG.round2( round1Secret[id], other.map((i) => round1[i]) ); for (const k in round2) { if (!otherSecretShares[k]) continue; eql(round2[k].signingShare, otherSecretShares[k]); } if (!round2Recv[id].length) continue; // What we receive const round3 = frost.DKG.round3( round1Secret[id], other.map((i) => round1[i]), round2Recv[id] ); round3Participants++; eql(round3.public.commitments[0], hexToBytes(t.inputs.verifying_key)); for (const k in round3.public.verifyingShares) { const v = round3.public.verifyingShares[k]; eql(v, hexToBytes(getInput(id2id[k]).verifying_share!)); } } } eql(round3Participants, ids.length, 'round3 covers every DKG participant'); }); } }); } }); it.runWhen(import.meta.url);