Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 298 additions & 1 deletion modules/express/test/unit/clientRoutes/externalSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
ShareKeyPosition,
TxRequest,
SignatureShareRecord,
generateGPGKeyPair,
} from '@bitgo/sdk-core';
import { Hash } from 'crypto';
import { Hash, createPublicKey, verify as cryptoVerify } from 'crypto';
import { logger } from '@bitgo/logger';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import * as should from 'should';
Expand All @@ -28,6 +29,11 @@ import {
DklsTypes,
DklsComms,
DklsDsg,
EddsaMPSDsg,
MPSUtil,
MPSComms,
MPSTypes,
deriveUnhardenedMps,
} from '@bitgo/sdk-lib-mpc';
import {
MPCv2PartyFromStringOrNumber,
Expand All @@ -36,6 +42,11 @@ import {
MPCv2SignatureShareRound2Input,
MPCv2SignatureShareRound2Output,
MPCv2SignatureShareRound3Input,
EddsaMPCv2SignatureShareRound1Input,
EddsaMPCv2SignatureShareRound1Output,
EddsaMPCv2SignatureShareRound2Input,
EddsaMPCv2SignatureShareRound2Output,
EddsaMPCv2SignatureShareRound3Input,
} from '@bitgo/public-types';
import * as assert from 'assert';
import nock from 'nock';
Expand Down Expand Up @@ -1069,6 +1080,166 @@ describe('External signer', () => {
envStub.restore();
});

it('should read an encrypted prv from signerFileSystemPath and pass it to EddsaMPCv2Round1, EddsaMPCv2Round2 and EddsaMPCv2Round3 share generators', async function () {
// EdDSA MPS DSG flow is CPU-heavy; extend timeout for CI stability
this.timeout(180000);
const walletID = '6a19371071fdd4b35159f3860fc3c4e2';
// 16-byte hex — EdDSA takes raw message bytes (no prehashing required)
const signableHex = 'deadbeef01020304deadbeef01020304';
const derivationPath = 'm/0';
const walletPassphrase = 'testPassEdDSA';

const [userDkg, , bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const userKeyShareBuffer = userDkg.getKeyShare();
const bitgoKeyShareBuffer = bitgoDkg.getKeyShare();

const bgTest = new BitGo({ env: 'test' });
const userKeyShareB64 = userKeyShareBuffer.toString('base64');
const validPrvEdDSA = bgTest.encrypt({ input: userKeyShareB64, password: walletPassphrase });
const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify({ [walletID]: validPrvEdDSA }));
const envStub = sinon.stub(process, 'env').value({ ['WALLET_' + walletID + '_PASSPHRASE']: walletPassphrase });

// EdDSA MPCv2 requires ed25519 GPG keys (not secp256k1) for PGP-authenticated message exchange.
// GPG auth is tested by the sdk-core unit tests; here we stub the comms layer so the test
// exercises only the WASM DSG protocol without needing openpgp as a direct express dep.
const bitgoEddsaGpgKey = await generateGPGKeyPair('ed25519');
const detachSignStub = sinon
.stub(MPSComms, 'detachSignMpsMessage')
.callsFake(async (rawBytes) => ({ message: Buffer.from(rawBytes).toString('base64'), signature: '' }));
const verifyMsgStub = sinon
.stub(MPSComms, 'verifyMpsMessage')
.callsFake(async (msg) => Buffer.from(msg.message, 'base64'));

// Initialise BitGo-side DSG session (party 2, co-signing with User party 0)
const message = Buffer.from(signableHex, 'hex');
const bitgoDsg = new EddsaMPSDsg.DSG(2 /* BITGO */);
bitgoDsg.initDsg(bitgoKeyShareBuffer, message, derivationPath, 0 /* USER */);

const baseTxRequest = {
txRequestId: 'eddsa-mpcv2-round-trip-test',
apiVersion: 'full',
walletId: walletID,
transactions: [
{
unsignedTx: { derivationPath, signableHex },
signatureShares: [] as SignatureShareRecord[],
},
],
} as unknown as TxRequest;

// round 1
const reqEdDSARound1 = {
bitgo: bgTest,
body: { txRequest: baseTxRequest },
decoded: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1', txRequest: baseTxRequest },
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round1Result = await handleV2GenerateShareTSS(reqEdDSARound1);
round1Result.should.have.property('signatureShareRound1');
round1Result.should.have.property('userGpgPubKey');
round1Result.should.have.property('encryptedRound1Session');
round1Result.should.have.property('encryptedUserGpgPrvKey');

const { txRequest: txRequestRound1, bitgoMsg2 } = await signBitgoEddsaMPCv2Round1(
bitgoDsg,
baseTxRequest,
round1Result.signatureShareRound1,
round1Result.userGpgPubKey
);
assert.ok(
txRequestRound1.transactions &&
txRequestRound1.transactions.length === 1 &&
txRequestRound1.transactions[0].signatureShares.length === 2,
'txRequestRound1 should have 2 signatureShares (user round1 + bitgo round1Output) after BitGo round 1'
);

// round 2
const reqEdDSARound2 = {
bitgo: bgTest,
body: {
txRequest: txRequestRound1,
encryptedRound1Session: round1Result.encryptedRound1Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
},
decoded: {
coin: 'tsol',
sharetype: 'EddsaMPCv2Round2',
txRequest: txRequestRound1,
encryptedRound1Session: round1Result.encryptedRound1Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
},
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round2' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round2Result = await handleV2GenerateShareTSS(reqEdDSARound2);
round2Result.should.have.property('signatureShareRound2');
round2Result.should.have.property('encryptedRound2Session');

const { txRequest: txRequestRound2, bitgoMsg3 } = await signBitgoEddsaMPCv2Round2(
bitgoDsg,
txRequestRound1,
round2Result.signatureShareRound2,
round1Result.userGpgPubKey,
bitgoMsg2
);
assert.ok(
txRequestRound2.transactions &&
txRequestRound2.transactions.length === 1 &&
txRequestRound2.transactions[0].signatureShares.length === 4,
'txRequestRound2 should have 4 signatureShares (2 from round1 + user round2 + bitgo round2Output) after BitGo round 2'
);

// round 3
const reqEdDSARound3 = {
bitgo: bgTest,
body: {
txRequest: txRequestRound2,
encryptedRound2Session: round2Result.encryptedRound2Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
},
decoded: {
coin: 'tsol',
sharetype: 'EddsaMPCv2Round3',
txRequest: txRequestRound2,
encryptedRound2Session: round2Result.encryptedRound2Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
},
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round3' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round3Result = await handleV2GenerateShareTSS(reqEdDSARound3);
round3Result.should.have.property('signatureShareRound3');

await signBitgoEddsaMPCv2Round3(bitgoDsg, round3Result.signatureShareRound3, round1Result.userGpgPubKey, bitgoMsg3);

// Verify the 64-byte Ed25519 signature cryptographically, matching the pattern of the
// legacy EdDSA v1 test (MPC.verify) and ECDSA MPCv2 test (DklsUtils.verifyAndConvert...).
// Uses Node.js built-in crypto — no extra npm dependency needed.
const signature = bitgoDsg.getSignature();
const derivedKeychainHex = deriveUnhardenedMps(userDkg.getCommonKeychain(), derivationPath);
const derivedPubKeyBytes = Buffer.from(derivedKeychainHex.slice(0, 64), 'hex');
// Ed25519 SubjectPublicKeyInfo DER header: SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING }
const spkiDer = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), derivedPubKeyBytes]);
const pubKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' });
assert.ok(
cryptoVerify(null, message, pubKeyObj, signature),
'Ed25519 signature must verify under the derived public key'
);

detachSignStub.restore();
verifyMsgStub.restore();
readFileStub.restore();
envStub.restore();
});

it('should accept a local secret and password for a wallet', async () => {
const accessToken = '';
const walletIds = {
Expand Down Expand Up @@ -1154,6 +1325,132 @@ function createExternalSignerTxRequest(txRequestId: string): TxRequest {
};
}

// #region EdDSA MPCv2 utils
/**
* Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 1.
*
* BitGo receives User's round-1 share (WASM-round-0 commitment), runs its own
* WASM-round-0 (getFirstMessage) and WASM-round-1 (handleIncomingMessages), then
* responds with its own WASM-round-0 output (msg1) signed with its GPG key.
*
* Returns the updated txRequest (with both User and BitGo round-1 shares appended)
* and `bitgoMsg2` which must be passed to the round-2 simulator.
*/
async function signBitgoEddsaMPCv2Round1(
bitgoDsg: EddsaMPSDsg.DSG,
txRequest: TxRequest,
userShare: SignatureShareRecord,
userGpgPubKeyArmored: string
): Promise<{ txRequest: TxRequest; bitgoMsg2: MPSTypes.DeserializedMessage }> {
assert.ok(
txRequest.transactions && txRequest.transactions.length === 1,
'txRequest.transactions must be an array of length 1'
);

// User's share must be present so createOfflineRound2Share can find it later
txRequest.transactions[0].signatureShares.push(userShare);

// MPSComms is stubbed for this test — decode the payload directly from the base64 message field
const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound1Input;
const userMsg1: MPSTypes.DeserializedMessage = {
from: 0 /* USER */,
payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg1.message, 'base64')),
};

// BitGo WASM-round-0: produce its own commitment
const bitgoMsg1 = bitgoDsg.getFirstMessage();

// BitGo WASM-round-1: process User's msg1 → produce BitGo's msg2 (carried to round 2)
const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]);

// Build Round1Output using the same shape MPSComms.detachSignMpsMessage stub returns
const bitgoRound1Output: EddsaMPCv2SignatureShareRound1Output = {
type: 'round1Output',
data: { msg1: { message: Buffer.from(bitgoMsg1.payload).toString('base64'), signature: '' } },
};
txRequest.transactions[0].signatureShares.push({
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: JSON.stringify(bitgoRound1Output),
});

return { txRequest, bitgoMsg2 };
}

/**
* Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 2.
*
* BitGo receives User's round-2 share (WASM-round-1 response to BitGo's msg1),
* runs its own WASM-round-2 (handling User's msg2 → producing BitGo's msg3),
* then responds with its own WASM-round-1 output (msg2) signed with its GPG key.
*
* Note: BitGo defers sending msg2 until it has received User's msg2 — this is the
* EdDSA-specific protocol asymmetry vs ECDSA (which bundles round1+round2 together).
*/
async function signBitgoEddsaMPCv2Round2(
bitgoDsg: EddsaMPSDsg.DSG,
txRequest: TxRequest,
userShare: SignatureShareRecord,
userGpgPubKeyArmored: string,
bitgoMsg2: MPSTypes.DeserializedMessage
): Promise<{ txRequest: TxRequest; bitgoMsg3: MPSTypes.DeserializedMessage }> {
assert.ok(
txRequest.transactions && txRequest.transactions.length === 1,
'txRequest.transactions must be an array of length 1'
);

txRequest.transactions[0].signatureShares.push(userShare);

// MPSComms is stubbed — decode payload directly
const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound2Input;
const userMsg2: MPSTypes.DeserializedMessage = {
from: 0 /* USER */,
payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg2.message, 'base64')),
};

// BitGo WASM-round-2: process User's msg2 → produce BitGo's msg3 (carried to round 3)
const [bitgoMsg3] = bitgoDsg.handleIncomingMessages([bitgoMsg2, userMsg2]);

// Build Round2Output: BitGo sends msg2 (its WASM-round-1 output) back to User
const bitgoRound2Output: EddsaMPCv2SignatureShareRound2Output = {
type: 'round2Output',
data: { msg2: { message: Buffer.from(bitgoMsg2.payload).toString('base64'), signature: '' } },
};
txRequest.transactions[0].signatureShares.push({
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: JSON.stringify(bitgoRound2Output),
});

return { txRequest, bitgoMsg3 };
}

/**
* Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 3.
*
* BitGo receives User's round-3 share (WASM-round-2 partial signature contribution),
* runs its own WASM-round-3 to complete the protocol.
* After this call, `bitgoDsg.getSignature()` returns the 64-byte Ed25519 signature.
*/
async function signBitgoEddsaMPCv2Round3(
bitgoDsg: EddsaMPSDsg.DSG,
userShare: SignatureShareRecord,
userGpgPubKeyArmored: string,
bitgoMsg3: MPSTypes.DeserializedMessage
): Promise<void> {
// MPSComms is stubbed — decode payload directly
const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound3Input;
const userMsg3: MPSTypes.DeserializedMessage = {
from: 0 /* USER */,
payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg3.message, 'base64')),
};

// BitGo WASM-round-3: process User's msg3 → complete DSG
bitgoDsg.handleIncomingMessages([bitgoMsg3, userMsg3]);
// bitgoDsg.getSignature() is now available (DsgState.Complete)
}
// #endregion EdDSA MPCv2 utils

// #region MPCv2 utils
function getBitGoPartyGpgKeyPrv(bitgoPrvKey: string): DklsTypes.PartyGpgKey {
return {
Expand Down
Loading