From 3fc3f3bf0bf9f62750518d9f7a3d6d5431f234ce Mon Sep 17 00:00:00 2001 From: Cesar Patino Date: Thu, 24 Jul 2025 09:02:50 -0400 Subject: [PATCH 1/2] feat(mbe): hide recovery apis under non recovery mode for mbe --- .../api/master/musigRecovery.test.ts | 1 + src/__tests__/api/master/nonRecovery.test.ts | 154 ++++++++++++++++++ .../recoveryConsolidationsWallet.test.ts | 1 + .../api/master/recoveryWallet.test.ts | 1 + src/__tests__/config.test.ts | 6 + src/api/master/handlerUtils.ts | 9 + .../handlers/recoveryConsolidationsWallet.ts | 4 + src/api/master/handlers/recoveryWallet.ts | 5 +- src/initConfig.ts | 4 + src/shared/types/index.ts | 1 + 10 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/api/master/nonRecovery.test.ts diff --git a/src/__tests__/api/master/musigRecovery.test.ts b/src/__tests__/api/master/musigRecovery.test.ts index c3f90c9..6856f13 100644 --- a/src/__tests__/api/master/musigRecovery.test.ts +++ b/src/__tests__/api/master/musigRecovery.test.ts @@ -31,6 +31,7 @@ describe('POST /api/:coin/wallet/recovery', () => { enclavedExpressCert: 'dummy-cert', tlsMode: TlsMode.DISABLED, allowSelfSigned: true, + recoveryMode: true, }; const app = expressApp(config); diff --git a/src/__tests__/api/master/nonRecovery.test.ts b/src/__tests__/api/master/nonRecovery.test.ts new file mode 100644 index 0000000..bdc2ad4 --- /dev/null +++ b/src/__tests__/api/master/nonRecovery.test.ts @@ -0,0 +1,154 @@ +import 'should'; +import * as request from 'supertest'; +import nock from 'nock'; +import { app as expressApp } from '../../../masterExpressApp'; +import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import sinon from 'sinon'; +import * as middleware from '../../../shared/middleware'; +import * as masterMiddleware from '../../../api/master/middleware/middleware'; +import { BitGoRequest } from '../../../types/request'; +import { BitGoAPI } from '@bitgo-beta/sdk-api'; +import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient'; + +describe('Non Recovery Tests', () => { + let agent: request.SuperAgentTest; + let mockBitgo: BitGoAPI; + const enclavedExpressUrl = 'http://enclaved.invalid'; + const accessToken = 'test-token'; + const config: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 60000, + logFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + enclavedExpressUrl: enclavedExpressUrl, + enclavedExpressCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + mtlsRequestCert: false, + allowSelfSigned: true, + recoveryMode: false, + }; + + beforeEach(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + // Create mock BitGo instance with base functionality + mockBitgo = { + coin: sinon.stub(), + _coinFactory: {}, + _useAms: false, + initCoinFactory: sinon.stub(), + registerToken: sinon.stub(), + getValidate: sinon.stub(), + validateAddress: sinon.stub(), + verifyAddress: sinon.stub(), + verifyPassword: sinon.stub(), + encrypt: sinon.stub(), + decrypt: sinon.stub(), + lock: sinon.stub(), + unlock: sinon.stub(), + getSharingKey: sinon.stub(), + ping: sinon.stub(), + authenticate: sinon.stub(), + authenticateWithAccessToken: sinon.stub(), + logout: sinon.stub(), + me: sinon.stub(), + session: sinon.stub(), + getUser: sinon.stub(), + users: sinon.stub(), + getWallet: sinon.stub(), + getWallets: sinon.stub(), + addWallet: sinon.stub(), + removeWallet: sinon.stub(), + getAsUser: sinon.stub(), + register: sinon.stub(), + } as unknown as BitGoAPI; + + // Setup middleware stubs before creating app + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = mockBitgo; + (req as BitGoRequest).config = config; + next(); + }); + + // Create app after middleware is stubbed + const app = expressApp(config); + agent = request.agent(app); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + describe('Recovery', () => { + const coin = 'tbtc'; + + beforeEach(() => { + sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { + (req as BitGoRequest).params = { coin }; + (req as BitGoRequest).enclavedExpressClient = + new EnclavedExpressClient(config, coin); + next(); + return undefined; + }); + }); + + it('should fail to run recovery if not in recovery mode', async () => { + const coin = 'tbtc'; + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + const response = await agent + .post(`/api/${coin}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { + evmRecoveryOptions: { + gasPrice: 20000000000, + gasLimit: 500000, + }, + }, + }); + response.status.should.equal(500); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', + ); + }); + }); + + describe('Recovery Consolidation', () => { + it('should fail to run recovery consolidation if not in recovery mode', async () => { + const response = await agent + .post(`/api/trx/wallet/recoveryconsolidations`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multisigType: 'onchain', + userPub: 'user-xpub', + backupPub: 'backup-xpub', + bitgoPub: 'bitgo-xpub', + tokenContractAddress: 'tron-token', + startingScanIndex: 1, + endingScanIndex: 3, + }); + + response.status.should.equal(500); + }); + }); +}); diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index 0bbccab..f5e9d24 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -38,6 +38,7 @@ describe('POST /api/:coin/wallet/recoveryconsolidations', () => { enclavedExpressCert: 'test-cert', tlsMode: TlsMode.DISABLED, allowSelfSigned: true, + recoveryMode: true, }; const app = expressApp(config); agent = request.agent(app); diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index 2a2d536..5fb3f6b 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -30,6 +30,7 @@ describe('Recovery Tests', () => { enclavedExpressCert: 'dummy-cert', tlsMode: TlsMode.DISABLED, allowSelfSigned: true, + recoveryMode: true, }; beforeEach(() => { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index d058791..efd372a 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -75,6 +75,12 @@ describe('Configuration', () => { } }); + it('should read the recovery mode from the env', () => { + process.env.RECOVERY_MODE = 'true'; + const cfg = initConfig(); + cfg.recoveryMode!.should.be.true(); + }); + it('should read port from environment variable', () => { process.env.ENCLAVED_EXPRESS_PORT = '4000'; process.env.KMS_URL = 'http://localhost:3000'; diff --git a/src/api/master/handlerUtils.ts b/src/api/master/handlerUtils.ts index 69c64a1..77a4fe8 100644 --- a/src/api/master/handlerUtils.ts +++ b/src/api/master/handlerUtils.ts @@ -2,6 +2,7 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { CustomSigningFunction, RequestTracer } from '@bitgo-beta/sdk-core'; import { EnclavedExpressClient } from './clients/enclavedExpressClient'; import coinFactory from '../../shared/coinFactory'; +import { MasterExpressConfig } from '../../shared/types'; /** * Fetch wallet and signing keychain, with validation for source and pubkey. @@ -73,3 +74,11 @@ export function makeCustomSigningFunction({ }); }; } + +export function checkRecoveryMode(config: MasterExpressConfig) { + if (!config.recoveryMode) { + throw new Error( + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', + ); + } +} diff --git a/src/api/master/handlers/recoveryConsolidationsWallet.ts b/src/api/master/handlers/recoveryConsolidationsWallet.ts index 9f47297..6915391 100644 --- a/src/api/master/handlers/recoveryConsolidationsWallet.ts +++ b/src/api/master/handlers/recoveryConsolidationsWallet.ts @@ -20,6 +20,8 @@ import type { Ada, Tada } from '@bitgo-beta/sdk-coin-ada'; import type { Dot, Tdot } from '@bitgo-beta/sdk-coin-dot'; import type { Tao, Ttao } from '@bitgo-beta/sdk-coin-tao'; import coinFactory from '../../../shared/coinFactory'; +import { checkRecoveryMode } from '../handlerUtils'; +import { MasterExpressConfig } from '../../../shared/types'; type RecoveryConsolidationParams = | ConsolidationRecoveryOptions @@ -77,6 +79,8 @@ export async function recoveryConsolidateWallets( export async function handleRecoveryConsolidationsOnPrem( req: MasterApiSpecRouteRequest<'v1.wallet.recoveryConsolidations', 'post'>, ) { + checkRecoveryMode(req.config as MasterExpressConfig); + const bitgo = req.bitgo; const coin = req.decoded.coin; const enclavedExpressClient = req.enclavedExpressClient; diff --git a/src/api/master/handlers/recoveryWallet.ts b/src/api/master/handlers/recoveryWallet.ts index ed9d9e8..c18fc4c 100644 --- a/src/api/master/handlers/recoveryWallet.ts +++ b/src/api/master/handlers/recoveryWallet.ts @@ -28,10 +28,11 @@ import { UtxoRecoveryOptions, } from '../routers/masterApiSpec'; import { recoverEddsaWallets } from './recoverEddsaWallets'; -import { EnvironmentName } from '../../../shared/types'; +import { EnvironmentName, MasterExpressConfig } from '../../../shared/types'; import logger from '../../../logger'; import { CoinFamily } from '@bitgo-beta/statics'; import { ValidationError } from '../../../shared/errors'; +import { checkRecoveryMode } from '../handlerUtils'; interface RecoveryParams { userKey: string; @@ -186,6 +187,8 @@ async function handleUtxoLikeRecovery( export async function handleRecoveryWalletOnPrem( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, ) { + checkRecoveryMode(req.config as MasterExpressConfig); + const bitgo = req.bitgo; const coin = req.decoded.coin; const enclavedExpressClient = req.enclavedExpressClient; diff --git a/src/initConfig.ts b/src/initConfig.ts index cb24c18..4329911 100644 --- a/src/initConfig.ts +++ b/src/initConfig.ts @@ -101,6 +101,7 @@ function enclavedEnvConfig(): Partial { tlsMode: determineTlsMode(), mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), allowSelfSigned: readEnvVar('ALLOW_SELF_SIGNED') === 'true', + recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', }; } @@ -130,6 +131,7 @@ function mergeEnclavedConfigs(...configs: Partial[]): EnclavedCo tlsMode: get('tlsMode'), mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), allowSelfSigned: get('allowSelfSigned'), + recoveryMode: get('recoveryMode'), }; } @@ -241,6 +243,7 @@ function masterExpressEnvConfig(): Partial { tlsMode, mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), allowSelfSigned, + recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', }; } @@ -278,6 +281,7 @@ function mergeMasterExpressConfigs( tlsMode: get('tlsMode'), mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), allowSelfSigned: get('allowSelfSigned'), + recoveryMode: get('recoveryMode'), }; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 05f7c65..9947170 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -20,6 +20,7 @@ export interface BaseConfig { keepAliveTimeout?: number; headersTimeout?: number; httpLoggerFile: string; + recoveryMode?: boolean; } // Enclaved mode specific configuration From 788e3f8b7c507a5081d4b4f7d063121eb539a5c3 Mon Sep 17 00:00:00 2001 From: Cesar Patino Date: Fri, 25 Jul 2025 12:01:19 -0400 Subject: [PATCH 2/2] feat(mbe): fix rebase --- src/__tests__/api/master/nonRecovery.test.ts | 38 +++----------------- src/__tests__/config.test.ts | 15 ++++---- 2 files changed, 13 insertions(+), 40 deletions(-) diff --git a/src/__tests__/api/master/nonRecovery.test.ts b/src/__tests__/api/master/nonRecovery.test.ts index bdc2ad4..6567d96 100644 --- a/src/__tests__/api/master/nonRecovery.test.ts +++ b/src/__tests__/api/master/nonRecovery.test.ts @@ -20,14 +20,13 @@ describe('Non Recovery Tests', () => { port: 0, bind: 'localhost', timeout: 60000, - logFile: '', env: 'test', disableEnvCheck: true, authVersion: 2, enclavedExpressUrl: enclavedExpressUrl, enclavedExpressCert: 'dummy-cert', tlsMode: TlsMode.DISABLED, - mtlsRequestCert: false, + httpLoggerFile: '', allowSelfSigned: true, recoveryMode: false, }; @@ -37,36 +36,7 @@ describe('Non Recovery Tests', () => { nock.enableNetConnect('127.0.0.1'); // Create mock BitGo instance with base functionality - mockBitgo = { - coin: sinon.stub(), - _coinFactory: {}, - _useAms: false, - initCoinFactory: sinon.stub(), - registerToken: sinon.stub(), - getValidate: sinon.stub(), - validateAddress: sinon.stub(), - verifyAddress: sinon.stub(), - verifyPassword: sinon.stub(), - encrypt: sinon.stub(), - decrypt: sinon.stub(), - lock: sinon.stub(), - unlock: sinon.stub(), - getSharingKey: sinon.stub(), - ping: sinon.stub(), - authenticate: sinon.stub(), - authenticateWithAccessToken: sinon.stub(), - logout: sinon.stub(), - me: sinon.stub(), - session: sinon.stub(), - getUser: sinon.stub(), - users: sinon.stub(), - getWallet: sinon.stub(), - getWallets: sinon.stub(), - addWallet: sinon.stub(), - removeWallet: sinon.stub(), - getAsUser: sinon.stub(), - register: sinon.stub(), - } as unknown as BitGoAPI; + mockBitgo = new BitGoAPI({ env: 'test' }); // Setup middleware stubs before creating app sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { @@ -98,7 +68,7 @@ describe('Non Recovery Tests', () => { }); }); - it('should fail to run recovery if not in recovery mode', async () => { + it('should fail to run mbe recovery if not in recovery mode', async () => { const coin = 'tbtc'; const userPub = 'xpub_user'; const backupPub = 'xpub_backup'; @@ -134,7 +104,7 @@ describe('Non Recovery Tests', () => { }); describe('Recovery Consolidation', () => { - it('should fail to run recovery consolidation if not in recovery mode', async () => { + it('should fail to run mbe recovery consolidation if not in recovery mode', async () => { const response = await agent .post(`/api/trx/wallet/recoveryconsolidations`) .set('Authorization', `Bearer ${accessToken}`) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index efd372a..4bcfd2a 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -75,12 +75,6 @@ describe('Configuration', () => { } }); - it('should read the recovery mode from the env', () => { - process.env.RECOVERY_MODE = 'true'; - const cfg = initConfig(); - cfg.recoveryMode!.should.be.true(); - }); - it('should read port from environment variable', () => { process.env.ENCLAVED_EXPRESS_PORT = '4000'; process.env.KMS_URL = 'http://localhost:3000'; @@ -96,6 +90,15 @@ describe('Configuration', () => { } }); + it('should read the recovery mode from the env', () => { + process.env.KMS_URL = 'http://localhost:3000'; + process.env.TLS_KEY = mockTlsKey; + process.env.TLS_CERT = mockTlsCert; + process.env.RECOVERY_MODE = 'true'; + const cfg = initConfig(); + cfg.recoveryMode!.should.be.true(); + }); + it('should read TLS mode from environment variables', () => { process.env.KMS_URL = 'http://localhost:3000'; process.env.TLS_KEY = mockTlsKey;