lotus-sdk
Version:
Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem
337 lines (336 loc) • 14.7 kB
JavaScript
import { Signature, Random, Hash } from '../crypto/index.js';
import { musigKeyAgg, musigNonceGen, musigNonceAgg, musigPartialSign, musigPartialSigVerify, musigSigAgg, } from '../crypto/musig2.js';
import { verifyTaprootKeyPathMuSigPartial } from '../taproot/musig2.js';
import { calculateTapTweak, tweakPublicKey } from '../taproot.js';
export var MuSigSessionPhase;
(function (MuSigSessionPhase) {
MuSigSessionPhase["INIT"] = "init";
MuSigSessionPhase["NONCE_EXCHANGE"] = "nonce-exchange";
MuSigSessionPhase["PARTIAL_SIG_EXCHANGE"] = "partial-sig-exchange";
MuSigSessionPhase["COMPLETE"] = "complete";
MuSigSessionPhase["ABORTED"] = "aborted";
})(MuSigSessionPhase || (MuSigSessionPhase = {}));
export class MuSigSessionManager {
createSession(signers, myPrivateKey, message, metadata) {
if (signers.length === 0) {
throw new Error('Cannot create MuSig2 session with zero signers');
}
if (!message || message.length === 0) {
throw new Error('Cannot create MuSig2 session with empty message');
}
const keyAggContext = musigKeyAgg(signers);
const myPubKey = myPrivateKey.publicKey;
const myIndex = keyAggContext.pubkeys.findIndex(signer => signer.toString() === myPubKey.toString());
if (myIndex === -1) {
throw new Error('Private key does not correspond to any signer in the session');
}
const sessionId = this._generateSessionId(keyAggContext.pubkeys, message);
const now = Date.now();
const session = {
sessionId,
signers: keyAggContext.pubkeys,
myIndex,
keyAggContext,
message,
metadata,
receivedPublicNonces: new Map(),
receivedPartialSigs: new Map(),
phase: MuSigSessionPhase.INIT,
createdAt: now,
updatedAt: now,
};
return session;
}
generateNonces(session, privateKey, extraInput) {
if (session.phase !== MuSigSessionPhase.INIT) {
throw new Error(`Cannot generate nonces in phase ${session.phase}. Must be in INIT phase.`);
}
if (session.mySecretNonce || session.myPublicNonce) {
throw new Error('Nonces already generated for this session. NEVER reuse nonces!');
}
const entropy = extraInput !== undefined ? extraInput : Random.getRandomBuffer(32);
const nonce = musigNonceGen(privateKey, session.keyAggContext.aggregatedPubKey, session.message, entropy);
session.mySecretNonce = nonce;
session.myPublicNonce = nonce.publicNonces;
session.phase = MuSigSessionPhase.NONCE_EXCHANGE;
session.updatedAt = Date.now();
if (this.hasAllNonces(session)) {
this._aggregateNonces(session);
}
return nonce.publicNonces;
}
receiveNonces(session, signerIndex, publicNonces) {
if (session.phase !== MuSigSessionPhase.NONCE_EXCHANGE &&
session.phase !== MuSigSessionPhase.INIT) {
throw new Error(`Cannot receive nonces in phase ${session.phase}. Must be in INIT or NONCE_EXCHANGE phase.`);
}
if (signerIndex < 0 || signerIndex >= session.signers.length) {
throw new Error(`Invalid signer index: ${signerIndex}`);
}
if (signerIndex === session.myIndex) {
throw new Error('Cannot receive nonce from self');
}
if (session.receivedPublicNonces.has(signerIndex)) {
throw new Error(`Already received nonce from signer ${signerIndex}. Possible equivocation!`);
}
try {
publicNonces[0].validate();
publicNonces[1].validate();
}
catch (error) {
throw new Error(`Invalid public nonce from signer ${signerIndex}: ${error}`);
}
session.receivedPublicNonces.set(signerIndex, publicNonces);
session.updatedAt = Date.now();
if (this.hasAllNonces(session)) {
this._aggregateNonces(session);
}
}
createPartialSignature(session, privateKey) {
if (!session.aggregatedNonce) {
throw new Error('Cannot create partial signature: nonces not yet aggregated. Wait for all nonces.');
}
if (!session.mySecretNonce) {
throw new Error('Cannot create partial signature: secret nonce not found');
}
if (session.phase !== MuSigSessionPhase.NONCE_EXCHANGE &&
session.phase !== MuSigSessionPhase.PARTIAL_SIG_EXCHANGE) {
throw new Error(`Cannot create partial signature in phase ${session.phase}. Must be in NONCE_EXCHANGE or PARTIAL_SIG_EXCHANGE.`);
}
const partialSig = musigPartialSign(session.mySecretNonce, privateKey, session.keyAggContext, session.myIndex, session.aggregatedNonce, session.message);
session.myPartialSig = partialSig;
session.phase = MuSigSessionPhase.PARTIAL_SIG_EXCHANGE;
session.updatedAt = Date.now();
this._clearSecretNonce(session);
return partialSig;
}
receivePartialSignature(session, signerIndex, partialSig) {
if (session.phase !== MuSigSessionPhase.PARTIAL_SIG_EXCHANGE) {
throw new Error(`Cannot receive partial signatures in phase ${session.phase}`);
}
if (signerIndex < 0 || signerIndex >= session.signers.length) {
throw new Error(`Invalid signer index: ${signerIndex}`);
}
if (signerIndex === session.myIndex) {
throw new Error('Cannot receive partial signature from self');
}
if (session.receivedPartialSigs.has(signerIndex)) {
throw new Error(`Already received partial signature from signer ${signerIndex}`);
}
const publicNonce = session.receivedPublicNonces.get(signerIndex);
if (!publicNonce) {
throw new Error(`No public nonce found for signer ${signerIndex}. Cannot verify.`);
}
let isValid;
if (session.metadata?.inputScriptType === 'taproot') {
const merkleRoot = Buffer.alloc(32);
const tweak = calculateTapTweak(session.keyAggContext.aggregatedPubKey, merkleRoot);
isValid = verifyTaprootKeyPathMuSigPartial(partialSig, publicNonce, session.signers[signerIndex], session.keyAggContext, signerIndex, session.aggregatedNonce, session.message, tweak);
}
else {
isValid = musigPartialSigVerify(partialSig, publicNonce, session.signers[signerIndex], session.keyAggContext, signerIndex, session.aggregatedNonce, session.message);
}
if (!isValid) {
this._abortSession(session, `Invalid partial signature from signer ${signerIndex}`);
throw new Error(`Invalid partial signature from signer ${signerIndex}. Session aborted.`);
}
session.receivedPartialSigs.set(signerIndex, partialSig);
session.updatedAt = Date.now();
if (this.hasAllPartialSignatures(session)) {
this._finalizeSignature(session);
}
}
getFinalSignature(session) {
if (session.phase !== MuSigSessionPhase.COMPLETE) {
throw new Error(`Cannot get final signature: session is in phase ${session.phase}`);
}
if (!session.finalSignature) {
throw new Error('Final signature not found');
}
return session.finalSignature;
}
abortSession(session, reason) {
this._abortSession(session, reason);
}
getSessionStatus(session) {
const noncesTotal = session.signers.length;
const noncesCollected = session.receivedPublicNonces.size + (session.myPublicNonce ? 1 : 0);
const partialSigsTotal = session.signers.length;
const partialSigsCollected = session.receivedPartialSigs.size + (session.myPartialSig ? 1 : 0);
return {
phase: session.phase,
noncesCollected,
noncesTotal,
partialSigsCollected,
partialSigsTotal,
isComplete: session.phase === MuSigSessionPhase.COMPLETE,
isAborted: session.phase === MuSigSessionPhase.ABORTED,
abortReason: session.abortReason,
};
}
_generateSessionId(signers, message) {
const signersHash = Hash.sha256(Buffer.concat(signers.map(s => s.toBuffer())));
const messageHash = Hash.sha256(message);
const combined = Buffer.concat([signersHash, messageHash]);
return Hash.sha256(combined).toString('hex').slice(0, 16);
}
_transitionPhaseForTesting(session, newPhase) {
session.phase = newPhase;
session.updatedAt = Date.now();
}
_aggregateNonces(session) {
if (!session.myPublicNonce) {
throw new Error('My public nonce not set');
}
const allNonces = [];
for (let i = 0; i < session.signers.length; i++) {
if (i === session.myIndex) {
allNonces.push(session.myPublicNonce);
}
else {
const nonce = session.receivedPublicNonces.get(i);
if (!nonce) {
throw new Error(`Missing nonce from signer ${i}`);
}
allNonces.push(nonce);
}
}
session.aggregatedNonce = musigNonceAgg(allNonces);
session.updatedAt = Date.now();
}
_finalizeSignature(session) {
if (!session.myPartialSig) {
throw new Error('My partial signature not set');
}
if (!session.aggregatedNonce) {
throw new Error('Aggregated nonce not set');
}
const allPartialSigs = [];
for (let i = 0; i < session.signers.length; i++) {
if (i === session.myIndex) {
allPartialSigs.push(session.myPartialSig);
}
else {
const partialSig = session.receivedPartialSigs.get(i);
if (!partialSig) {
throw new Error(`Missing partial signature from signer ${i}`);
}
allPartialSigs.push(partialSig);
}
}
let pubKeyForAggregation = session.keyAggContext.aggregatedPubKey;
if (session.metadata?.inputScriptType === 'taproot') {
const merkleRoot = Buffer.alloc(32);
pubKeyForAggregation = tweakPublicKey(session.keyAggContext.aggregatedPubKey, merkleRoot);
}
const sighashType = session.metadata?.sighashType
? session.metadata.sighashType
: session.metadata?.inputScriptType === 'taproot'
? Signature.SIGHASH_ALL | Signature.SIGHASH_LOTUS
: undefined;
session.finalSignature = musigSigAgg(allPartialSigs, session.aggregatedNonce, session.message, pubKeyForAggregation, sighashType);
session.phase = MuSigSessionPhase.COMPLETE;
session.updatedAt = Date.now();
}
_clearSecretNonce(session) {
if (session.mySecretNonce) {
const [k1, k2] = session.mySecretNonce.secretNonces;
session.mySecretNonce = undefined;
}
}
_abortSession(session, reason) {
session.phase = MuSigSessionPhase.ABORTED;
session.abortReason = reason;
session.updatedAt = Date.now();
this._clearSecretNonce(session);
}
hasAllNonces(session) {
if (!session.receivedPublicNonces)
return false;
return session.receivedPublicNonces.size === session.signers.length - 1;
}
hasAllPartialSignatures(session) {
if (!session.receivedPartialSigs)
return false;
return session.receivedPartialSigs.size === session.signers.length - 1;
}
isCoordinator(session) {
if (session.coordinatorIndex === undefined)
return false;
return session.myIndex === session.coordinatorIndex;
}
validateBIP327Compliance(session) {
if (!this.areKeysSorted(session.signers)) {
throw new Error('Signers must be sorted lexicographically (BIP327 requirement)');
}
if (!session.keyAggContext) {
throw new Error('Key aggregation context required (BIP327)');
}
this.validateMuSig2Rounds(session);
}
areKeysSorted(signers) {
if (signers.length <= 1)
return true;
for (let i = 0; i < signers.length - 1; i++) {
const current = signers[i].toBuffer();
const next = signers[i + 1].toBuffer();
if (current.compare(next) > 0) {
return false;
}
}
return true;
}
validateMuSig2Rounds(session) {
const validPhases = [
MuSigSessionPhase.INIT,
MuSigSessionPhase.NONCE_EXCHANGE,
MuSigSessionPhase.PARTIAL_SIG_EXCHANGE,
MuSigSessionPhase.COMPLETE,
MuSigSessionPhase.ABORTED,
];
if (!validPhases.includes(session.phase)) {
throw new Error(`Invalid session phase: ${session.phase}`);
}
switch (session.phase) {
case MuSigSessionPhase.NONCE_EXCHANGE:
if (!session.myPublicNonce && !session.mySecretNonce) {
throw new Error('NONCE_EXCHANGE phase requires nonces to be generated');
}
break;
case MuSigSessionPhase.PARTIAL_SIG_EXCHANGE:
if (!session.aggregatedNonce) {
throw new Error('PARTIAL_SIG_EXCHANGE phase requires aggregated nonce');
}
break;
case MuSigSessionPhase.COMPLETE:
if (!session.finalSignature) {
throw new Error('COMPLETE phase requires final signature');
}
break;
case MuSigSessionPhase.ABORTED:
if (!session.abortReason) {
throw new Error('ABORTED phase requires abort reason');
}
break;
}
}
initiateRound1(session, privateKey) {
try {
this.validateBIP327Compliance(session);
if (session.phase !== MuSigSessionPhase.INIT) {
return {
error: `Cannot start Round 1 in phase ${session.phase}. Must be in INIT phase.`,
};
}
const publicNonces = this.generateNonces(session, privateKey);
return {
shouldTransitionTo: MuSigSessionPhase.NONCE_EXCHANGE,
shouldRevealNonces: true,
broadcastNonces: publicNonces,
};
}
catch (error) {
return { error: error.message };
}
}
}