From 19a6f4641f2cda0129225182b4989f23034e4912 Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Wed, 14 Oct 2026 14:01:43 +0530 Subject: [PATCH] feat(awm): add EdDSA MPCv2 key generation handler, types and route Add a single AWM handler (eddsaMPCV2WalletGeneration.ts) with a shareType switch matching the ecdsaEddsaSignTransaction pattern, one route (POST /api/{coin}/eddsampcv2/keygen/{shareType}) with per-share request/response codecs in advancedWalletManagerApiSpec.ts, and handler tests. - initialize: generates an ed25519 GPG keypair and data key, returns the GPG public key, the data-key-encrypted GPG private key and the encrypted data key - round1/round2/finalize: decrypt the data key and delegate to EddsaMPCv2Utils.createOfflineKeyGenRound{1,2}Share and createOfflineKeyGenFinalizeShare, passing partyId derived from source on every round (required by the SDK's validateAdata session binding) - finalize also stores the key share via the key provider postKey and returns { commonKeychain, source } - per-share required-field validation rejects with 400 Ticket: WCI-892 --- .../eddsaMpcV2KeyGen.test.ts | 265 ++++++++++++++++++ .../handlers/eddsaMPCV2WalletGeneration.ts | 147 ++++++++++ .../routers/advancedWalletManagerApiSpec.ts | 67 +++++ 3 files changed, 479 insertions(+) create mode 100644 src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts create mode 100644 src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts diff --git a/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts b/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts new file mode 100644 index 00000000..31562899 --- /dev/null +++ b/src/__tests__/api/advancedWalletManager/eddsaMpcV2KeyGen.test.ts @@ -0,0 +1,265 @@ +import { AppMode, AdvancedWalletManagerConfig, TlsMode, SigningMode } from '../../../initConfig'; +import { app as advancedWalletManagerApp } from '../../../advancedWalletManagerApp'; + +import express from 'express'; +import nock from 'nock'; +import should from 'should'; +import * as request from 'supertest'; +import * as sinon from 'sinon'; +import * as configModule from '../../../initConfig'; +import { EddsaMPCv2Utils } from '@bitgo-beta/sdk-core'; +import { MPCv2PartiesEnum } from '@bitgo-beta/sdk-core/dist/src/bitgo/utils/tss/ecdsa'; + +describe('eddsaMPCv2KeyGen', () => { + let cfg: AdvancedWalletManagerConfig; + let app: express.Application; + let agent: request.SuperAgentTest; + + const keyProviderUrl = 'http://key-provider.invalid'; + const coin = 'tsol'; + const accessToken = 'test-token'; + + const sandbox = sinon.createSandbox(); + + const signedMessage = { + message: 'bWVzc2FnZQ==', + signature: '-----BEGIN PGP SIGNATURE-----\n\ntest\n-----END PGP SIGNATURE-----\n', + }; + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + cfg = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + keyProviderUrl, + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + + sandbox.stub(configModule, 'initConfig').returns(cfg); + + app = advancedWalletManagerApp(cfg); + agent = request.agent(app); + }); + + beforeEach(() => { + nock(keyProviderUrl) + .post('/generateDataKey') + .reply(200, { + plaintextKey: 'test-plaintext-key', + encryptedKey: 'test-encrypted-key', + }) + .persist(); + nock(keyProviderUrl) + .post('/decryptDataKey') + .reply(200, { + plaintextKey: 'test-plaintext-key', + }) + .persist(); + nock(keyProviderUrl) + .post('/key') + .reply(200, { + pub: 'test-pub-key', + coin, + source: 'user', + type: 'tss', + }) + .persist(); + }); + + afterEach(() => { + nock.cleanAll(); + sandbox.restore(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + it('initialize returns a GPG public key, the encrypted GPG private key and the encrypted data key', async () => { + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/initialize`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ source: 'user' }); + + response.status.should.equal(200); + response.body.should.have.property('gpgPub').which.is.a.String(); + response.body.gpgPub.should.containEql('BEGIN PGP PUBLIC KEY'); + response.body.should.have.property('encryptedUserGpgPrvKey').which.is.a.String(); + response.body.encryptedUserGpgPrvKey.should.not.containEql('PRIVATE KEY'); + response.body.should.have.property('encryptedDataKey', 'test-encrypted-key'); + }); + + it('round1 delegates to createOfflineKeyGenRound1Share with the source-derived partyId', async () => { + const round1Stub = sandbox + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenRound1Share') + .resolves({ signedMsg1: signedMessage, encryptedRound1Session: 'encrypted-round1-session' }); + + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/round1`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + encryptedDataKey: 'test-encrypted-key', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + }); + + response.status.should.equal(200); + sinon.assert.calledOnce(round1Stub); + sinon.assert.calledWith(round1Stub, { + walletPassphrase: 'test-plaintext-key', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + partyId: MPCv2PartiesEnum.USER, + }); + response.body.signedMsg1.should.deepEqual(signedMessage); + response.body.should.have.property('encryptedRound1Session', 'encrypted-round1-session'); + }); + + it('round1 passes MPCv2PartiesEnum.BACKUP for source backup', async () => { + const round1Stub = sandbox + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenRound1Share') + .resolves({ signedMsg1: signedMessage, encryptedRound1Session: 'encrypted-round1-session' }); + + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/round1`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'backup', + encryptedDataKey: 'test-encrypted-key', + encryptedUserGpgPrvKey: 'encrypted-backup-gpg-prv', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + }); + + response.status.should.equal(200); + sinon.assert.calledOnce(round1Stub); + sinon.assert.calledWithMatch(round1Stub, { partyId: MPCv2PartiesEnum.BACKUP }); + }); + + it('round2 delegates to createOfflineKeyGenRound2Share with the round 1 session and messages', async () => { + const round2Stub = sandbox + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenRound2Share') + .resolves({ signedMsg2: signedMessage, encryptedRound2Session: 'encrypted-round2-session' }); + + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/round2`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + encryptedDataKey: 'test-encrypted-key', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv', + encryptedRound1Session: 'encrypted-round1-session', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + bitgoMsg1: signedMessage, + counterPartyMsg1: signedMessage, + }); + + response.status.should.equal(200); + sinon.assert.calledOnce(round2Stub); + sinon.assert.calledWith(round2Stub, { + walletPassphrase: 'test-plaintext-key', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv', + encryptedRound1Session: 'encrypted-round1-session', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + bitgoMsg1: signedMessage, + counterPartyMsg1: signedMessage, + partyId: MPCv2PartiesEnum.USER, + }); + response.body.signedMsg2.should.deepEqual(signedMessage); + response.body.should.have.property('encryptedRound2Session', 'encrypted-round2-session'); + }); + + it('finalize delegates to createOfflineKeyGenFinalizeShare and stores the key share via postKey', async () => { + nock.cleanAll(); + nock(keyProviderUrl) + .post('/decryptDataKey') + .reply(200, { + plaintextKey: 'test-plaintext-key', + }) + .persist(); + let postedKey: Record | undefined; + nock(keyProviderUrl) + .post('/key') + .reply(200, (_uri, body) => { + if (typeof body === 'object' && body !== null) { + postedKey = body; + } + return { + pub: 'common-keychain', + coin, + source: 'user', + type: 'tss', + }; + }); + + const finalizeStub = sandbox + .stub(EddsaMPCv2Utils.prototype, 'createOfflineKeyGenFinalizeShare') + .resolves({ commonKeychain: 'common-keychain', keyShare: 'a2V5LXNoYXJl' }); + + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/finalize`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + source: 'user', + encryptedDataKey: 'test-encrypted-key', + encryptedRound2Session: 'encrypted-round2-session', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + bitgoMsg2: signedMessage, + counterPartyMsg2: signedMessage, + bitgoCommonKeychain: 'common-keychain', + }); + + response.status.should.equal(200); + sinon.assert.calledOnce(finalizeStub); + sinon.assert.calledWith(finalizeStub, { + walletPassphrase: 'test-plaintext-key', + encryptedRound2Session: 'encrypted-round2-session', + bitgoGpgPubKey: 'bitgo-gpg-pub', + counterPartyGpgPubKey: 'counterparty-gpg-pub', + bitgoMsg2: signedMessage, + counterPartyMsg2: signedMessage, + bitgoCommonKeychain: 'common-keychain', + partyId: MPCv2PartiesEnum.USER, + }); + response.body.should.deepEqual({ commonKeychain: 'common-keychain', source: 'user' }); + if (!postedKey) { + throw new Error('postKey was not called'); + } + should(postedKey.coin).equal(coin); + should(postedKey.source).equal('user'); + should(postedKey.pub).equal('common-keychain'); + should(postedKey.prv).equal('a2V5LXNoYXJl'); + should(postedKey.type).equal('tss'); + }); + + it('rejects an unsupported share type', async () => { + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/bogus`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ source: 'user' }); + + response.status.should.equal(400); + }); + + it('rejects round1 with missing required fields', async () => { + const response = await agent + .post(`/api/${coin}/eddsampcv2/keygen/round1`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ source: 'user', encryptedDataKey: 'test-encrypted-key' }); + + response.status.should.equal(400); + }); +}); diff --git a/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts b/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts new file mode 100644 index 00000000..68db82cd --- /dev/null +++ b/src/advancedWalletManager/handlers/eddsaMPCV2WalletGeneration.ts @@ -0,0 +1,147 @@ +import { EddsaMPCv2Utils, generateGPGKeyPair } from '@bitgo-beta/sdk-core'; +import { MPCv2PartiesEnum } from '@bitgo-beta/sdk-core/dist/src/bitgo/utils/tss/ecdsa'; +import { + AwmApiSpecRouteRequest, + EddsaMPCv2KeyGenResponseType, +} from '../routers/advancedWalletManagerApiSpec'; +import { KeyProviderClient } from '../keyProviderClient/keyProviderClient'; +import coinFactory from '../../shared/coinFactory'; +import { BadRequestError } from '../../shared/errors'; + +enum EddsaKeyGenShareType { + Initialize = 'initialize', + Round1 = 'round1', + Round2 = 'round2', + Finalize = 'finalize', +} + +export async function eddsaMPCv2KeyGen( + req: AwmApiSpecRouteRequest<'v1.eddsampcv2.keygen', 'post'>, +): Promise { + const { source, shareType, coin } = req.decoded; + const bitgo = req.bitgo; + const keyProvider = new KeyProviderClient(req.config); + + if (shareType === EddsaKeyGenShareType.Initialize) { + const gpgKeyPair = await generateGPGKeyPair('ed25519'); + const { plaintextKey, encryptedKey } = await keyProvider.generateDataKey({ + keyType: 'AES-256', + }); + const encryptedUserGpgPrvKey = await bitgo.encrypt({ + input: gpgKeyPair.privateKey, + password: plaintextKey, + }); + return { gpgPub: gpgKeyPair.publicKey, encryptedUserGpgPrvKey, encryptedDataKey: encryptedKey }; + } + + const coinInstance = await coinFactory.getCoin(coin, bitgo); + const eddsaMPCv2Utils = new EddsaMPCv2Utils(bitgo, coinInstance); + const partyId = source === 'backup' ? MPCv2PartiesEnum.BACKUP : MPCv2PartiesEnum.USER; + + switch (shareType) { + case EddsaKeyGenShareType.Round1: { + const { encryptedDataKey, encryptedUserGpgPrvKey, bitgoGpgPubKey, counterPartyGpgPubKey } = + req.decoded; + if ( + !encryptedDataKey || + !encryptedUserGpgPrvKey || + !bitgoGpgPubKey || + !counterPartyGpgPubKey + ) { + throw new BadRequestError( + 'encryptedDataKey, encryptedUserGpgPrvKey, bitgoGpgPubKey and counterPartyGpgPubKey are required for share type round1', + ); + } + const { plaintextKey } = await keyProvider.decryptDataKey({ encryptedKey: encryptedDataKey }); + return await eddsaMPCv2Utils.createOfflineKeyGenRound1Share({ + walletPassphrase: plaintextKey, + encryptedUserGpgPrvKey, + bitgoGpgPubKey, + counterPartyGpgPubKey, + partyId, + }); + } + case EddsaKeyGenShareType.Round2: { + const { + encryptedDataKey, + encryptedUserGpgPrvKey, + encryptedRound1Session, + bitgoGpgPubKey, + counterPartyGpgPubKey, + bitgoMsg1, + counterPartyMsg1, + } = req.decoded; + if ( + !encryptedDataKey || + !encryptedUserGpgPrvKey || + !encryptedRound1Session || + !bitgoGpgPubKey || + !counterPartyGpgPubKey || + !bitgoMsg1 || + !counterPartyMsg1 + ) { + throw new BadRequestError( + 'encryptedDataKey, encryptedUserGpgPrvKey, encryptedRound1Session, bitgoGpgPubKey, counterPartyGpgPubKey, bitgoMsg1 and counterPartyMsg1 are required for share type round2', + ); + } + const { plaintextKey } = await keyProvider.decryptDataKey({ encryptedKey: encryptedDataKey }); + return await eddsaMPCv2Utils.createOfflineKeyGenRound2Share({ + walletPassphrase: plaintextKey, + encryptedUserGpgPrvKey, + encryptedRound1Session, + bitgoGpgPubKey, + counterPartyGpgPubKey, + bitgoMsg1, + counterPartyMsg1, + partyId, + }); + } + case EddsaKeyGenShareType.Finalize: { + const { + encryptedDataKey, + encryptedRound2Session, + bitgoGpgPubKey, + counterPartyGpgPubKey, + bitgoMsg2, + counterPartyMsg2, + bitgoCommonKeychain, + } = req.decoded; + if ( + !encryptedDataKey || + !encryptedRound2Session || + !bitgoGpgPubKey || + !counterPartyGpgPubKey || + !bitgoMsg2 || + !counterPartyMsg2 || + !bitgoCommonKeychain + ) { + throw new BadRequestError( + 'encryptedDataKey, encryptedRound2Session, bitgoGpgPubKey, counterPartyGpgPubKey, bitgoMsg2, counterPartyMsg2 and bitgoCommonKeychain are required for share type finalize', + ); + } + const { plaintextKey } = await keyProvider.decryptDataKey({ encryptedKey: encryptedDataKey }); + const result = await eddsaMPCv2Utils.createOfflineKeyGenFinalizeShare({ + walletPassphrase: plaintextKey, + encryptedRound2Session, + bitgoGpgPubKey, + counterPartyGpgPubKey, + bitgoMsg2, + counterPartyMsg2, + bitgoCommonKeychain, + partyId, + }); + await keyProvider.postKey({ + coin, + source, + pub: result.commonKeychain, + prv: result.keyShare, + type: 'tss', + }); + return { commonKeychain: result.commonKeychain, source }; + } + default: + throw new BadRequestError( + `Share type ${shareType} not supported for EdDSA MPCv2 key generation`, + ); + } +} diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 958ce036..0087993b 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -33,6 +33,7 @@ import { ecdsaMPCv2Initialize } from '../handlers/ecdsaMPCV2WalletGenerationInit import { ecdsaMPCv2Round } from '../handlers/ecdsaMPCV2WalletGenerationRound'; import { ecdsaMPCv2Finalize } from '../handlers/ecdsaMPCV2WalletGenerationFinalize'; import { ecdsaMPCv2Recovery } from '../handlers/ecdsaMPCV2Recovery'; +import { eddsaMPCv2KeyGen } from '../handlers/eddsaMPCV2WalletGeneration'; import { signEddsaRecoveryTransaction } from '../handlers/eddsaMPCRecovery'; import { isEddsaCoin } from '../../shared/coinUtils'; import { customDecodeErrorFormatter } from '../../shared/errorFormatters'; @@ -320,6 +321,42 @@ const MpcV2RecoveryResponse = { const MpcV2RecoveryResponseType = t.exact(t.type(MpcV2RecoveryResponse)); export type MpcV2RecoveryResponseType = t.TypeOf; +const EddsaMPCv2SignedMessage = t.type({ + message: t.string, + signature: t.string, +}); + +const EddsaMPCv2KeyGenRequest = { + source: t.union([t.literal('user'), t.literal('backup')]), + encryptedDataKey: optional(t.string), + encryptedUserGpgPrvKey: optional(t.string), + bitgoGpgPubKey: optional(t.string), + counterPartyGpgPubKey: optional(t.string), + encryptedRound1Session: optional(t.string), + encryptedRound2Session: optional(t.string), + bitgoMsg1: optional(EddsaMPCv2SignedMessage), + counterPartyMsg1: optional(EddsaMPCv2SignedMessage), + bitgoMsg2: optional(EddsaMPCv2SignedMessage), + counterPartyMsg2: optional(EddsaMPCv2SignedMessage), + bitgoCommonKeychain: optional(t.string), +}; +const EddsaMPCv2KeyGenRequestType = t.type(EddsaMPCv2KeyGenRequest); +export type EddsaMPCv2KeyGenRequestType = t.TypeOf; + +const EddsaMPCv2KeyGenResponse = { + gpgPub: optional(t.string), + encryptedUserGpgPrvKey: optional(t.string), + encryptedDataKey: optional(t.string), + signedMsg1: optional(EddsaMPCv2SignedMessage), + encryptedRound1Session: optional(t.string), + signedMsg2: optional(EddsaMPCv2SignedMessage), + encryptedRound2Session: optional(t.string), + commonKeychain: optional(t.string), + source: optional(t.union([t.literal('user'), t.literal('backup')])), +}; +const EddsaMPCv2KeyGenResponseType = t.exact(optionalized(EddsaMPCv2KeyGenResponse)); +export type EddsaMPCv2KeyGenResponseType = t.TypeOf; + // API Specification export const AdvancedWalletManagerApiSpec = apiSpec({ 'v1.multisig.sign': { @@ -491,6 +528,29 @@ export const AdvancedWalletManagerApiSpec = apiSpec({ description: 'Recover a MPC transaction', }), }, + 'v1.eddsampcv2.keygen': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/eddsampcv2/keygen/{shareType}', + request: httpRequest({ + params: { + coin: t.string, + shareType: t.union([ + t.literal('initialize'), + t.literal('round1'), + t.literal('round2'), + t.literal('finalize'), + ]), + }, + body: EddsaMPCv2KeyGenRequest, + }), + response: { + 200: EddsaMPCv2KeyGenResponseType, + ...ErrorResponses, + }, + description: 'Perform an EdDSA MPCv2 key generation share', + }), + }, }); export type AkmApiSpecRouteHandler< @@ -622,6 +682,13 @@ export function createKeyGenRouter( return Response.ok(result); }), ]); + router.post('v1.eddsampcv2.keygen', [ + responseHandler(async (req) => { + const typedReq = req as AwmApiSpecRouteRequest<'v1.eddsampcv2.keygen', 'post'>; + const result = await eddsaMPCv2KeyGen(typedReq); + return Response.ok(result); + }), + ]); return router; }