704 lines
36 KiB
JavaScript
704 lines
36 KiB
JavaScript
/**
|
|
* FROST: Flexible Round-Optimized Schnorr Threshold Protocol for Two-Round Schnorr Signatures.
|
|
*
|
|
* See [RFC 9591](https://datatracker.ietf.org/doc/rfc9591/) and [frost.zfnd.org](https://frost.zfnd.org).
|
|
* @module
|
|
*/
|
|
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
|
import { bytesToHex, bytesToNumberBE, bytesToNumberLE, concatBytes, hexToBytes, randomBytes, validateObject, } from "../utils.js";
|
|
import { pippenger, validatePointCons } from "./curve.js";
|
|
import { poly } from "./fft.js";
|
|
import {} from "./hash-to-curve.js";
|
|
import { getMinHashLength, mapHashToField } from "./modular.js";
|
|
// PubKey = commitments, verifyingShares
|
|
// PrivKey = id, signingShare, commitment
|
|
const validateSigners = (signers) => {
|
|
if (!Number.isSafeInteger(signers.min) || !Number.isSafeInteger(signers.max))
|
|
throw new Error('Wrong signers info: min=' + signers.min + ' max=' + signers.max);
|
|
// Compatibility with frost-rs intentionally narrows RFC 9591's positive-nonzero threshold rule
|
|
// to `min >= 2`, even though the RFC text itself allows `MIN_PARTICIPANTS = 1`.
|
|
// This API is for actual threshold signing across participants; 1-of-n degenerates to ordinary
|
|
// single-signer mode, which does not need FROST's network/coordination machinery at all.
|
|
if (signers.min < 2 || signers.max < 2 || signers.min > signers.max)
|
|
throw new Error('Wrong signers info: min=' + signers.min + ' max=' + signers.max);
|
|
};
|
|
const validateCommitmentsNum = (signers, len) => {
|
|
// RFC 9591 Sections 5.2/5.3 require MIN_PARTICIPANTS <= NUM_PARTICIPANTS <= MAX_PARTICIPANTS.
|
|
if (len < signers.min || len > signers.max)
|
|
throw new Error('Wrong number of commitments=' + len);
|
|
};
|
|
class AggErr extends Error {
|
|
// Empty means aggregation failed before per-share verification could attribute a signer.
|
|
cheaters;
|
|
constructor(msg, cheaters) {
|
|
super(msg);
|
|
this.cheaters = cheaters;
|
|
}
|
|
}
|
|
export function createFROST(opts) {
|
|
validateObject(opts, {
|
|
name: 'string',
|
|
hash: 'function',
|
|
}, {
|
|
hashToScalar: 'function',
|
|
validatePoint: 'function',
|
|
parsePublicKey: 'function',
|
|
adjustScalar: 'function',
|
|
adjustPoint: 'function',
|
|
challenge: 'function',
|
|
adjustNonces: 'function',
|
|
adjustSecret: 'function',
|
|
adjustPublic: 'function',
|
|
adjustGroupCommitmentShare: 'function',
|
|
adjustDKG: 'function',
|
|
});
|
|
// Cheap constructor-surface sanity check only: this verifies the generic static hooks/fields that
|
|
// FROST consumes, but it does not certify point semantics like BASE/ZERO correctness.
|
|
validatePointCons(opts.Point);
|
|
const { Point } = opts;
|
|
const Fn = opts.Fn === undefined ? Point.Fn : opts.Fn;
|
|
// Hashes
|
|
const hashBytes = opts.hash;
|
|
const hashToScalar = opts.hashToScalar === undefined
|
|
? (msg, opts = { DST: new Uint8Array() }) => {
|
|
const t = hashBytes(concatBytes(opts.DST, msg));
|
|
return Fn.create(Fn.isLE ? bytesToNumberLE(t) : bytesToNumberBE(t));
|
|
}
|
|
: opts.hashToScalar;
|
|
const H1Prefix = utf8ToBytes(opts.H1 !== undefined ? opts.H1 : opts.name + 'rho');
|
|
const H2Prefix = utf8ToBytes(opts.H2 !== undefined ? opts.H2 : opts.name + 'chal');
|
|
const H3Prefix = utf8ToBytes(opts.H3 !== undefined ? opts.H3 : opts.name + 'nonce');
|
|
const H4Prefix = utf8ToBytes(opts.H4 !== undefined ? opts.H4 : opts.name + 'msg');
|
|
const H5Prefix = utf8ToBytes(opts.H5 !== undefined ? opts.H5 : opts.name + 'com');
|
|
const HDKGPrefix = utf8ToBytes(opts.HDKG !== undefined ? opts.HDKG : opts.name + 'dkg');
|
|
const HIDPrefix = utf8ToBytes(opts.HID !== undefined ? opts.HID : opts.name + 'id');
|
|
const H1 = (msg) => hashToScalar(msg, { DST: H1Prefix });
|
|
// Empty H2 still passes `{ DST: new Uint8Array() }` into custom hashToScalar hooks.
|
|
// The built-in fallback hashes that identically to omitted DST, which is how
|
|
// the Ed25519 suite models RFC 9591's undecorated H2 challenge hash.
|
|
const H2 = (msg) => hashToScalar(msg, { DST: H2Prefix });
|
|
const H3 = (msg) => hashToScalar(msg, { DST: H3Prefix });
|
|
const H4 = (msg) => hashBytes(concatBytes(H4Prefix, msg));
|
|
const H5 = (msg) => hashBytes(concatBytes(H5Prefix, msg));
|
|
const HDKG = (msg) => hashToScalar(msg, { DST: HDKGPrefix });
|
|
const HID = (msg) => hashToScalar(msg, { DST: HIDPrefix });
|
|
// /Hashes
|
|
const randomScalar = (rng = randomBytes) => {
|
|
// Intentional divergence from RFC 9591 §4.1 / §5.1: the RFC nonce_generate helper outputs a
|
|
// Scalar in [0, p-1], but round-one commit publishes ScalarBaseMult(nonce) values and §3.1
|
|
// requires SerializeElement / DeserializeElement to reject the identity element. Keep noble's
|
|
// mapHashToField generation here so round-one public nonce commitments stay in 1..n-1.
|
|
const t = mapHashToField(rng(getMinHashLength(Fn.ORDER)), Fn.ORDER, Fn.isLE);
|
|
// We cannot use Fn.fromBytes here because the field can have a different
|
|
// byte width, like ed448.
|
|
return Fn.isLE ? bytesToNumberLE(t) : bytesToNumberBE(t);
|
|
};
|
|
const serializePoint = (p) => p.toBytes();
|
|
const parsePoint = (bytes) => {
|
|
// RFC 9591 Section 3.1 requires DeserializeElement validation. Suite-specific validatePoint
|
|
// hooks tighten this further for ciphersuites in Section 6. Bare createFROST(...) only gets
|
|
// canonical point decoding unless the caller installs those extra subgroup / identity checks.
|
|
const p = Point.fromBytes(bytes);
|
|
if (opts.validatePoint)
|
|
opts.validatePoint(p);
|
|
return p;
|
|
};
|
|
// RFC 9591 Sections 4.1/5.1 model each participant's round-one output as two public commitments.
|
|
const nonceCommitments = (identifier, nonces) => ({
|
|
identifier,
|
|
hiding: serializePoint(Point.BASE.multiply(Fn.fromBytes(nonces.hiding))),
|
|
binding: serializePoint(Point.BASE.multiply(Fn.fromBytes(nonces.binding))),
|
|
});
|
|
const adjustPoint = opts.adjustPoint === undefined ? (n) => n : opts.adjustPoint;
|
|
// We use hex to make it easier to use inside objects
|
|
const validateIdentifier = (n) => {
|
|
// Identifiers are canonical non-zero scalars. Custom / derived identifiers are allowed, so this
|
|
// is intentionally not bounded by the current signers.max slot count.
|
|
if (!Fn.isValid(n) || Fn.is0(n))
|
|
throw new Error('Invalid identifier ' + n);
|
|
return n;
|
|
};
|
|
const serializeIdentifier = (id) => bytesToHex(Fn.toBytes(validateIdentifier(id)));
|
|
const parseIdentifier = (id) => {
|
|
const n = validateIdentifier(Fn.fromBytes(hexToBytes(id)));
|
|
// Keep string-keyed maps stable by accepting only the canonical serialized form.
|
|
if (serializeIdentifier(n) !== id)
|
|
throw new Error('expected canonical identifier hex');
|
|
return n;
|
|
};
|
|
const Signature = {
|
|
// RFC 9591 Appendix A encodes signatures canonically as
|
|
// SerializeElement(R) || SerializeScalar(z).
|
|
encode: (R, z) => {
|
|
let res = concatBytes(serializePoint(R), Fn.toBytes(z));
|
|
if (opts.adjustTx)
|
|
res = opts.adjustTx.encode(res);
|
|
return res;
|
|
},
|
|
decode: (sig) => {
|
|
if (opts.adjustTx)
|
|
sig = opts.adjustTx.decode(sig);
|
|
// We don't know size of point, but we know size of scalar
|
|
const R = parsePoint(sig.subarray(0, -Fn.BYTES));
|
|
const z = Fn.fromBytes(sig.subarray(-Fn.BYTES));
|
|
return { R, z };
|
|
},
|
|
};
|
|
// Generates pair of (scalar, point)
|
|
const genPointScalarPair = (rng = randomBytes) => {
|
|
let n = randomScalar(rng);
|
|
if (opts.adjustScalar)
|
|
n = opts.adjustScalar(n);
|
|
let p = Point.BASE.multiply(n);
|
|
return { scalar: n, point: p };
|
|
};
|
|
// No roots here: root-based methods will throw.
|
|
// `poly` expects a structured roots-of-unity domain, but FROST uses an
|
|
// arbitrary domain and only needs the non-root operations below.
|
|
const nrErr = 'roots are unavailable in FROST polynomial mode';
|
|
const noRoots = {
|
|
info: { G: Fn.ZERO, oddFactor: Fn.ZERO, powerOfTwo: 0 },
|
|
roots() {
|
|
throw new Error(nrErr);
|
|
},
|
|
brp() {
|
|
throw new Error(nrErr);
|
|
},
|
|
inverse() {
|
|
throw new Error(nrErr);
|
|
},
|
|
omega() {
|
|
throw new Error(nrErr);
|
|
},
|
|
clear() { },
|
|
};
|
|
const Poly = poly(Fn, noRoots);
|
|
const msm = (points, scalars) => pippenger(Point, points, scalars);
|
|
// Internal stuff uses bigints & Points, external Uint8Arrays
|
|
const polynomialEvaluate = (x, coeffs) => {
|
|
if (!coeffs.length)
|
|
throw new Error('empty coefficients');
|
|
return Poly.monomial.eval(coeffs, x);
|
|
};
|
|
const deriveInterpolatingValue = (L, xi) => {
|
|
const err = 'invalid parameters';
|
|
// Generates lagrange coefficient
|
|
if (!L.some((x) => Fn.eql(x, xi)))
|
|
throw new Error(err);
|
|
// Throws error if any x-coordinate is represented more than once in L.
|
|
const Lset = new Set(L);
|
|
if (Lset.size !== L.length)
|
|
throw new Error(err);
|
|
// Or if xi is missing
|
|
if (!Lset.has(xi))
|
|
throw new Error(err);
|
|
let num = Fn.ONE;
|
|
let den = Fn.ONE;
|
|
for (const x of L) {
|
|
if (Fn.eql(x, xi))
|
|
continue;
|
|
num = Fn.mul(num, x); // num *= x
|
|
den = Fn.mul(den, Fn.sub(x, xi)); // RFC 9591 §4.2: denominator *= x_j - x_i
|
|
}
|
|
return Fn.div(num, den);
|
|
};
|
|
const evalutateVSS = (identifier, commitment) => {
|
|
// RFC 9591 Appendix C.2: S_i' = Σ_j ScalarMult(vss_commitment[j], i^j).
|
|
const monomial = Poly.monomial.basis(identifier, commitment.length);
|
|
return msm(commitment, monomial);
|
|
};
|
|
// High-level internal stuff
|
|
const generateSecretPolynomial = (signers, secret, coeffs, rng = randomBytes) => {
|
|
validateSigners(signers);
|
|
// Dealer/DKG polynomial sampling reuses the same hardened scalar derivation as round-one
|
|
// nonces: overriding `rng` only swaps the entropy source, not the non-zero `1..n-1` policy.
|
|
const secretScalar = secret === undefined ? randomScalar(rng) : Fn.fromBytes(secret);
|
|
if (!coeffs) {
|
|
coeffs = [];
|
|
for (let i = 0; i < signers.min - 1; i++)
|
|
coeffs.push(randomScalar(rng));
|
|
}
|
|
if (coeffs.length !== signers.min - 1)
|
|
throw new Error('wrong coefficients length');
|
|
const coefficients = [secretScalar, ...coeffs];
|
|
// RFC 9591 Appendix C.2 commits to every polynomial coefficient with ScalarBaseMult.
|
|
const commitment = coefficients.map((i) => Point.BASE.multiply(i));
|
|
return { coefficients, commitment, secret: secretScalar };
|
|
};
|
|
// Pretty much sign+verify, same as basic
|
|
const ProofOfKnowledge = {
|
|
challenge: (id, verKey, R) => HDKG(concatBytes(Fn.toBytes(id), serializePoint(verKey), serializePoint(R))),
|
|
compute(id, coefficents, commitments, rng = randomBytes) {
|
|
if (coefficents.length < 1)
|
|
throw new Error('coefficients should have at least one element');
|
|
const { point: R, scalar: k } = genPointScalarPair(rng);
|
|
const verKey = commitments[0]; // verify key is first one
|
|
const c = this.challenge(id, verKey, R);
|
|
const mu = Fn.add(k, Fn.mul(coefficents[0], c)); // mu = k + coeff[0] * c
|
|
return Signature.encode(R, mu);
|
|
},
|
|
validate(id, commitment, proof) {
|
|
if (commitment.length < 1)
|
|
throw new Error('commitment should have at least one element');
|
|
const { R, z } = Signature.decode(proof);
|
|
const phi = parsePoint(commitment[0]);
|
|
const c = this.challenge(id, phi, R);
|
|
// R === z*G - phi*c
|
|
if (!R.equals(Point.BASE.multiply(z).subtract(phi.multiply(c))))
|
|
throw new Error('invalid proof of knowledge');
|
|
},
|
|
};
|
|
const Basic = {
|
|
challenge: (R, PK, msg) => {
|
|
if (opts.challenge)
|
|
return opts.challenge(R, PK, msg);
|
|
return H2(concatBytes(serializePoint(R), serializePoint(PK), msg));
|
|
},
|
|
sign(msg, sk, rng = randomBytes) {
|
|
const { point: R, scalar: r } = genPointScalarPair(rng);
|
|
const PK = Point.BASE.multiply(sk); // sk*G
|
|
const c = this.challenge(R, PK, msg);
|
|
const z = Fn.add(r, Fn.mul(c, sk)); // r + c * sk
|
|
return [R, z];
|
|
},
|
|
verify(msg, R, z, PK) {
|
|
if (opts.adjustPoint)
|
|
PK = opts.adjustPoint(PK);
|
|
if (opts.adjustPoint)
|
|
R = opts.adjustPoint(R);
|
|
const c = this.challenge(R, PK, msg);
|
|
const zB = Point.BASE.multiply(z); // z*G
|
|
const cA = PK.multiply(c); // c*PK
|
|
let check = zB.subtract(cA).subtract(R); // zB - cA - R
|
|
// No clearCoffactor on ristretto
|
|
if (check.clearCofactor)
|
|
check = check.clearCofactor();
|
|
return Point.ZERO.equals(check);
|
|
},
|
|
};
|
|
// === vssVerify
|
|
const validateSecretShare = (identifier, commitment, signingShare) => {
|
|
// RFC 9591 Appendix C.2 `vss_verify(share_i, vss_commitment)` is purely algebraic.
|
|
// Public FROST packages still go through Section 3.1 element encoding,
|
|
// which rejects identity points, so a zero share or commitment does not
|
|
// become valid wire data just because VSS matches.
|
|
if (!Point.BASE.multiply(signingShare).equals(evalutateVSS(identifier, commitment)))
|
|
throw new Error('invalid secret share');
|
|
};
|
|
const Identifier = {
|
|
fromNumber(n) {
|
|
if (!Number.isSafeInteger(n))
|
|
throw new Error('expected safe interger');
|
|
return serializeIdentifier(BigInt(n));
|
|
},
|
|
// Not in spec, but in FROST implementation,
|
|
// seems useful and nice, no need to sync identifiers (would require more interactions)
|
|
derive(s) {
|
|
if (typeof s !== 'string')
|
|
throw new Error('wrong identifier string: ' + s);
|
|
// Derived identifiers may land anywhere in the scalar field; they are not restricted to
|
|
// sequential `1..max_signers` values.
|
|
return serializeIdentifier(HID(utf8ToBytes(s)));
|
|
},
|
|
};
|
|
// RFC 9591 §4.1: nonce_generate() hashes 32 fresh RNG bytes with SerializeScalar(secret).
|
|
const generateNonce = (secret, rng = randomBytes) => H3(concatBytes(rng(32), Fn.toBytes(secret)));
|
|
const getGroupCommitment = (GPK, commitmentList, msg) => {
|
|
const CL = commitmentList.map((i) => [
|
|
i.identifier,
|
|
parseIdentifier(i.identifier),
|
|
parsePoint(i.hiding),
|
|
parsePoint(i.binding),
|
|
]);
|
|
// RFC 9591 Sections 4.3/4.4/4.5 and 5.2/5.3 treat commitment_list as sorted by identifier.
|
|
CL.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
|
|
// Encode commitment list
|
|
const Cbytes = [];
|
|
for (const [_, id, hC, bC] of CL)
|
|
Cbytes.push(Fn.toBytes(id), serializePoint(hC), serializePoint(bC));
|
|
const encodedCommitmentHash = H5(concatBytes(...Cbytes));
|
|
const rhoPrefix = concatBytes(serializePoint(GPK), H4(msg), encodedCommitmentHash);
|
|
// Compute binding factors
|
|
const bindingFactors = {};
|
|
for (const [i, id] of CL) {
|
|
bindingFactors[i] = H1(concatBytes(rhoPrefix, Fn.toBytes(id)));
|
|
}
|
|
const points = [];
|
|
const scalars = [];
|
|
for (const [i, _, hC, bC] of CL) {
|
|
if (Point.ZERO.equals(hC) || Point.ZERO.equals(bC))
|
|
throw new Error('infinity commitment');
|
|
points.push(hC, bC);
|
|
scalars.push(Fn.ONE, bindingFactors[i]);
|
|
}
|
|
const groupCommitment = msm(points, scalars); // GC += hC + bC*bindingFactor
|
|
const identifiers = CL.map((i) => i[1]);
|
|
return { identifiers, groupCommitment, bindingFactors };
|
|
};
|
|
const prepareShare = (PK, commitmentList, msg, identifier) => {
|
|
// RFC 9591 Sections 4.4/4.5/4.6 feed directly into the Section 5.2 signer computation.
|
|
const GPK = adjustPoint(parsePoint(PK));
|
|
const id = parseIdentifier(identifier);
|
|
const { identifiers, groupCommitment, bindingFactors } = getGroupCommitment(GPK, commitmentList, msg);
|
|
const bindingFactor = bindingFactors[identifier];
|
|
const lambda = deriveInterpolatingValue(identifiers, id);
|
|
const challenge = Basic.challenge(groupCommitment, GPK, msg);
|
|
return { lambda, challenge, bindingFactor, groupCommitment };
|
|
};
|
|
Object.freeze(Identifier);
|
|
const frost = {
|
|
Identifier,
|
|
// DKG is Distributed Key Generation, not Trusted Dealer Key Generation.
|
|
DKG: Object.freeze({
|
|
// NOTE: we allow to pass secret scalar from user side,
|
|
// this way it can be derived, instead of random generation
|
|
round1: (id, signers, secret, rng = randomBytes) => {
|
|
validateSigners(signers);
|
|
const idNum = parseIdentifier(id);
|
|
const { coefficients, commitment } = generateSecretPolynomial(signers, secret, undefined, rng);
|
|
const proofOfKnowledge = ProofOfKnowledge.compute(idNum, coefficients, commitment, rng);
|
|
const commitmentBytes = commitment.map(serializePoint);
|
|
const round1Public = {
|
|
identifier: serializeIdentifier(idNum),
|
|
commitment: commitmentBytes,
|
|
proofOfKnowledge,
|
|
};
|
|
// store secret information for signing
|
|
const round1Secret = {
|
|
identifier: idNum,
|
|
coefficients,
|
|
commitment: commitment.map(serializePoint),
|
|
// Copy threshold metadata instead of retaining the caller-owned object by reference.
|
|
signers: { min: signers.min, max: signers.max },
|
|
step: 1,
|
|
};
|
|
return { public: round1Public, secret: round1Secret };
|
|
},
|
|
round2: (secret, others) => {
|
|
if (others.length !== secret.signers.max - 1)
|
|
throw new Error('wrong number of round1 packages');
|
|
if (!secret.coefficients || secret.step === 3)
|
|
throw new Error('round3 package used in round2');
|
|
const res = {};
|
|
for (const p of others) {
|
|
if (p.commitment.length !== secret.signers.min)
|
|
throw new Error('wrong number of commitments');
|
|
const id = parseIdentifier(p.identifier);
|
|
if (id === secret.identifier)
|
|
throw new Error('duplicate id=' + serializeIdentifier(id));
|
|
ProofOfKnowledge.validate(id, p.commitment, p.proofOfKnowledge);
|
|
for (const c of p.commitment)
|
|
parsePoint(c);
|
|
if (res[p.identifier])
|
|
throw new Error('Duplicate id=' + id);
|
|
const signingShare = Fn.toBytes(polynomialEvaluate(id, secret.coefficients));
|
|
res[p.identifier] = {
|
|
identifier: serializeIdentifier(secret.identifier),
|
|
signingShare: signingShare,
|
|
};
|
|
}
|
|
secret.step = 2;
|
|
return res;
|
|
},
|
|
round3: (secret, round1, round2) => {
|
|
// DKG is outside RFC 9591's signing flow; callers are expected to reuse the same
|
|
// remote round1 packages already accepted in round2, like frost-rs documents.
|
|
if (round1.length !== secret.signers.max - 1)
|
|
throw new Error('wrong length of round1 packages');
|
|
if (!secret.coefficients || secret.step !== 2)
|
|
throw new Error('round2 package used in round3');
|
|
if (round2.length !== round1.length)
|
|
throw new Error('wrong length of round2 packages');
|
|
const merged = {};
|
|
for (const r1 of round1) {
|
|
if (!r1.identifier || !r1.commitment)
|
|
throw new Error('wrong round1 share');
|
|
merged[r1.identifier] = { ...r1 };
|
|
}
|
|
for (const r2 of round2) {
|
|
if (!r2.identifier || !r2.signingShare)
|
|
throw new Error('wrong round2 share');
|
|
if (!merged[r2.identifier])
|
|
throw new Error('round1 share for ' + r2.identifier + ' is missing');
|
|
merged[r2.identifier].signingShare = r2.signingShare;
|
|
}
|
|
if (Object.keys(merged).length !== round1.length)
|
|
throw new Error('mismatch identifiers between rounds');
|
|
let signingShare = Fn.ZERO;
|
|
if (secret.commitment.length !== secret.signers.min)
|
|
throw new Error('wrong commitments length');
|
|
const localCommitment = secret.commitment.map(parsePoint);
|
|
const localShare = polynomialEvaluate(secret.identifier, secret.coefficients);
|
|
validateSecretShare(secret.identifier, localCommitment, localShare);
|
|
const localCommitmentBytes = localCommitment.map(serializePoint);
|
|
const commitments = {
|
|
[serializeIdentifier(secret.identifier)]: localCommitmentBytes,
|
|
};
|
|
for (const k in merged) {
|
|
const v = merged[k];
|
|
if (!v.signingShare || !v.commitment)
|
|
throw new Error('mismatch identifiers');
|
|
const id = parseIdentifier(k); // from
|
|
const signingSharePart = Fn.fromBytes(v.signingShare);
|
|
const commitment = v.commitment.map(parsePoint);
|
|
validateSecretShare(secret.identifier, commitment, signingSharePart);
|
|
signingShare = Fn.add(signingShare, signingSharePart);
|
|
const idSer = serializeIdentifier(id);
|
|
if (commitments[idSer])
|
|
throw new Error('duplicated id=' + idSer);
|
|
commitments[idSer] = v.commitment;
|
|
}
|
|
signingShare = Fn.add(signingShare, localShare);
|
|
const mergedCommitment = new Array(secret.signers.min).fill(Point.ZERO);
|
|
for (const k in commitments) {
|
|
const v = commitments[k];
|
|
if (v.length !== secret.signers.min)
|
|
throw new Error('wrong commitments length');
|
|
for (let i = 0; i < v.length; i++)
|
|
mergedCommitment[i] = mergedCommitment[i].add(parsePoint(v[i]));
|
|
}
|
|
const mergedCommitmentBytes = mergedCommitment.map(serializePoint);
|
|
const verifyingShares = {};
|
|
for (const k in commitments)
|
|
verifyingShares[k] = serializePoint(evalutateVSS(parseIdentifier(k), mergedCommitment));
|
|
// This is enough to sign stuff
|
|
let res = {
|
|
public: {
|
|
signers: { min: secret.signers.min, max: secret.signers.max },
|
|
commitments: mergedCommitmentBytes,
|
|
verifyingShares: Object.fromEntries(Object.entries(verifyingShares).map(([k, v]) => [k, v.slice()])),
|
|
},
|
|
secret: {
|
|
identifier: serializeIdentifier(secret.identifier),
|
|
signingShare: Fn.toBytes(signingShare),
|
|
},
|
|
};
|
|
if (opts.adjustDKG)
|
|
res = opts.adjustDKG(res);
|
|
for (let i = 0; i < secret.coefficients.length; i++)
|
|
secret.coefficients[i] -= secret.coefficients[i];
|
|
delete secret.coefficients;
|
|
secret.step = 3;
|
|
return res;
|
|
},
|
|
clean(secret) {
|
|
// Instead of replacing secret bigint with another (zero?), we subtract it from itself
|
|
// in the hope that JIT will modify it inplace, instead of creating new value.
|
|
// This is unverified and may not work, but it is best we can do in regard of bigints.
|
|
secret.identifier -= secret.identifier;
|
|
if (secret.coefficients) {
|
|
for (let i = 0; i < secret.coefficients.length; i++)
|
|
secret.coefficients[i] -= secret.coefficients[i];
|
|
}
|
|
// for (const c of secret.commitment) c.fill(0);
|
|
secret.step = 3;
|
|
},
|
|
}),
|
|
// Trusted dealer setup
|
|
// Generates keys for all participants
|
|
trustedDealer(signers, identifiers, secret, rng = randomBytes) {
|
|
// if no identifiers provided, we generated default identifiers
|
|
validateSigners(signers);
|
|
if (identifiers === undefined) {
|
|
identifiers = [];
|
|
for (let i = 1; i <= signers.max; i++)
|
|
identifiers.push(Identifier.fromNumber(i));
|
|
}
|
|
else {
|
|
if (!Array.isArray(identifiers) || identifiers.length !== signers.max)
|
|
throw new Error('identifiers should be array of ' + signers.max);
|
|
}
|
|
const identifierNums = {};
|
|
for (const id of identifiers) {
|
|
const idNum = parseIdentifier(id);
|
|
if (id in identifierNums)
|
|
throw new Error('duplicated id=' + id);
|
|
identifierNums[id] = idNum;
|
|
}
|
|
const sp = generateSecretPolynomial(signers, secret, undefined, rng);
|
|
const commitmentBytes = sp.commitment.map(serializePoint);
|
|
const secretShares = {};
|
|
const verifyingShares = {};
|
|
for (const id of identifiers) {
|
|
const signingShare = polynomialEvaluate(identifierNums[id], sp.coefficients);
|
|
verifyingShares[id] = serializePoint(Point.BASE.multiply(signingShare));
|
|
secretShares[id] = {
|
|
identifier: id,
|
|
signingShare: Fn.toBytes(signingShare),
|
|
};
|
|
}
|
|
return {
|
|
public: {
|
|
signers: { min: signers.min, max: signers.max },
|
|
commitments: commitmentBytes,
|
|
verifyingShares,
|
|
},
|
|
secretShares,
|
|
};
|
|
},
|
|
// Validate secret (from trusted dealer or DKG)
|
|
validateSecret(secret, pub) {
|
|
const id = parseIdentifier(secret.identifier);
|
|
const commitment = pub.commitments.map(parsePoint);
|
|
const signingShare = Fn.fromBytes(secret.signingShare);
|
|
validateSecretShare(id, commitment, signingShare);
|
|
},
|
|
// Actual signing
|
|
// Round 1: each participant commit to nonces
|
|
// Nonces kept private, commitments sent to coordinator (or every other participant)
|
|
// NOTE: we don't need the message at this point, which lets a coordinator
|
|
// keep multiple nonce commitments per participant in advance and skip
|
|
// round1 for signing.
|
|
// But then each participant needs to remember generated shares
|
|
commit(secret, rng = randomBytes) {
|
|
const secretScalar = Fn.fromBytes(secret.signingShare);
|
|
const hiding = generateNonce(secretScalar, rng);
|
|
const binding = generateNonce(secretScalar, rng);
|
|
const nonces = { hiding: Fn.toBytes(hiding), binding: Fn.toBytes(binding) };
|
|
return { nonces, commitments: nonceCommitments(secret.identifier, nonces) };
|
|
},
|
|
// Round2: sign. Each participant creates a signature share from the secret
|
|
// and the selected nonce commitments.
|
|
signShare(secret, pub, nonces, commitmentList, msg) {
|
|
validateCommitmentsNum(pub.signers, commitmentList.length);
|
|
const hidingNonce0 = Fn.fromBytes(nonces.hiding);
|
|
const bindingNonce0 = Fn.fromBytes(nonces.binding);
|
|
if (Fn.is0(hidingNonce0) || Fn.is0(bindingNonce0))
|
|
throw new Error('signing nonces already used');
|
|
// Reject a coordinator-assigned commitment pair that does not match the signer's own nonce
|
|
// pair. This must happen before suite-specific nonce adjustment; secp256k1-tr may negate the
|
|
// actual signing nonces later, but the coordinator still assigns the original commitments.
|
|
const expectedCommitment = {
|
|
identifier: secret.identifier,
|
|
hiding: serializePoint(Point.BASE.multiply(hidingNonce0)),
|
|
binding: serializePoint(Point.BASE.multiply(bindingNonce0)),
|
|
};
|
|
const commitment = commitmentList.find((i) => i.identifier === secret.identifier);
|
|
if (!commitment)
|
|
throw new Error('missing signer commitment');
|
|
if (bytesToHex(commitment.hiding) !== bytesToHex(expectedCommitment.hiding) ||
|
|
bytesToHex(commitment.binding) !== bytesToHex(expectedCommitment.binding))
|
|
throw new Error('incorrect signer commitment');
|
|
if (opts.adjustSecret)
|
|
secret = opts.adjustSecret(secret, pub);
|
|
if (opts.adjustPublic)
|
|
pub = opts.adjustPublic(pub);
|
|
const SK = Fn.fromBytes(secret.signingShare);
|
|
const { lambda, challenge, bindingFactor, groupCommitment } = prepareShare(pub.commitments[0], commitmentList, msg, secret.identifier);
|
|
const N = opts.adjustNonces ? opts.adjustNonces(groupCommitment, nonces) : nonces;
|
|
const hidingNonce = opts.adjustNonces ? Fn.fromBytes(N.hiding) : hidingNonce0;
|
|
const bindingNonce = opts.adjustNonces ? Fn.fromBytes(N.binding) : bindingNonce0;
|
|
const t = Fn.mul(Fn.mul(lambda, SK), challenge); // challenge * lambda * SK
|
|
const t2 = Fn.mul(bindingNonce, bindingFactor); // bindingNonce * bindingFactor
|
|
const r = Fn.toBytes(Fn.add(Fn.add(hidingNonce, t2), t)); // t + t2 + hidingNonce
|
|
// RFC 9591 round-one commitments are one-time-use, and round two must use the nonce
|
|
// corresponding to the published commitment. This API returns mutable local nonce bytes,
|
|
// so consume them after a successful signShare() call: later all-zero reuse fails closed.
|
|
nonces.hiding.fill(0);
|
|
nonces.binding.fill(0);
|
|
return r;
|
|
},
|
|
// Each participant (or coordinator) can verify signatures from other participants
|
|
verifyShare(pub, commitmentList, msg, identifier, sigShare) {
|
|
if (opts.adjustPublic)
|
|
pub = opts.adjustPublic(pub);
|
|
const comm = commitmentList.find((i) => i.identifier === identifier);
|
|
if (!comm)
|
|
throw new Error('cannot find identifier commitment');
|
|
const PK = parsePoint(pub.verifyingShares[identifier]);
|
|
const hidingNonceCommitment = parsePoint(comm.hiding);
|
|
const bindingNonceCommitment = parsePoint(comm.binding);
|
|
const { lambda, challenge, bindingFactor, groupCommitment } = prepareShare(pub.commitments[0], commitmentList, msg, identifier);
|
|
// hC + bC * bF
|
|
let commShare = hidingNonceCommitment.add(bindingNonceCommitment.multiply(bindingFactor));
|
|
if (opts.adjustGroupCommitmentShare)
|
|
commShare = opts.adjustGroupCommitmentShare(groupCommitment, commShare);
|
|
const l = Point.BASE.multiply(Fn.fromBytes(sigShare)); // sigShare*G
|
|
// commShare + PK * (challenge * lambda)
|
|
const r = commShare.add(PK.multiply(Fn.mul(challenge, lambda)));
|
|
return l.equals(r);
|
|
},
|
|
// Aggregate multiple signature shares into groupSignature
|
|
aggregate(pub, commitmentList, msg, sigShares) {
|
|
if (opts.adjustPublic)
|
|
pub = opts.adjustPublic(pub);
|
|
try {
|
|
validateCommitmentsNum(pub.signers, commitmentList.length);
|
|
}
|
|
catch {
|
|
throw new AggErr('aggregation failed', []);
|
|
}
|
|
const ids = commitmentList.map((i) => i.identifier);
|
|
if (ids.length !== Object.keys(sigShares).length)
|
|
throw new AggErr('aggregation failed', []);
|
|
for (const id of ids) {
|
|
if (!(id in sigShares) || !(id in pub.verifyingShares))
|
|
throw new AggErr('aggregation failed', []);
|
|
}
|
|
const GPK = parsePoint(pub.commitments[0]);
|
|
const { groupCommitment } = getGroupCommitment(GPK, commitmentList, msg);
|
|
let z = Fn.ZERO;
|
|
// RFC 9591 Section 5.3 aggregates by summing the validated signature shares.
|
|
for (const id of ids)
|
|
z = Fn.add(z, Fn.fromBytes(sigShares[id])); // z += zi
|
|
if (!Basic.verify(msg, groupCommitment, z, GPK)) {
|
|
const cheaters = [];
|
|
for (const id of ids) {
|
|
if (!this.verifyShare(pub, commitmentList, msg, id, sigShares[id]))
|
|
cheaters.push(id);
|
|
}
|
|
throw new AggErr('aggregation failed', cheaters);
|
|
}
|
|
return Signature.encode(groupCommitment, z);
|
|
},
|
|
// Basic sign/verify using single key
|
|
sign(msg, secretKey) {
|
|
let sk = Fn.fromBytes(secretKey);
|
|
// Taproot single-key signing needs the same scalar normalization as threshold keys.
|
|
if (opts.adjustScalar)
|
|
sk = opts.adjustScalar(sk);
|
|
const [R, z] = Basic.sign(msg, sk);
|
|
return Signature.encode(R, z);
|
|
},
|
|
verify(sig, msg, publicKey) {
|
|
const PK = opts.parsePublicKey ? opts.parsePublicKey(publicKey) : parsePoint(publicKey);
|
|
const { R, z } = Signature.decode(sig);
|
|
return Basic.verify(msg, R, z, PK);
|
|
},
|
|
// Combine multiple secret shares to restore secret
|
|
combineSecret(shares, signers) {
|
|
validateSigners(signers);
|
|
if (!Array.isArray(shares) || shares.length < signers.min)
|
|
throw new Error('wrong secret shares array');
|
|
const points = [];
|
|
const seen = {};
|
|
// Interpolate over the full provided share set and reject duplicate identifiers.
|
|
for (const s of shares) {
|
|
const idNum = parseIdentifier(s.identifier);
|
|
const id = serializeIdentifier(idNum);
|
|
if (seen[id])
|
|
throw new Error('duplicated id=' + id);
|
|
seen[id] = true;
|
|
points.push([idNum, Fn.fromBytes(s.signingShare)]);
|
|
}
|
|
const xCoords = points.map(([x]) => x);
|
|
let res = Fn.ZERO;
|
|
for (const [x, y] of points)
|
|
res = Fn.add(res, Fn.mul(y, deriveInterpolatingValue(xCoords, x)));
|
|
return Fn.toBytes(res);
|
|
},
|
|
// Utils
|
|
utils: Object.freeze({
|
|
Fn, // NOTE: we re-export it here because it may be different from Point.Fn (ed448 is fun!)
|
|
// Test RNG overrides still go through noble's non-zero scalar derivation; this is not a raw
|
|
// "bytes become scalar" escape hatch.
|
|
randomScalar: (rng = randomBytes) => Fn.toBytes(genPointScalarPair(rng).scalar),
|
|
generateSecretPolynomial: (signers, secret, coeffs, rng) => {
|
|
const res = generateSecretPolynomial(signers, secret, coeffs, rng);
|
|
return { ...res, commitment: res.commitment.map(serializePoint) };
|
|
},
|
|
}),
|
|
};
|
|
return Object.freeze(frost);
|
|
}
|
|
//# sourceMappingURL=frost.js.map
|