diff --git a/README.md b/README.md index 560d6a7d..874289d3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,17 @@ Key features include: - **Advanced Wallet Manager** (Port 3080) - An isolated signing server with no internet access that only connects to your key provider API implementation for key operations. - **Master Express** (Port 3081) - An API gateway providing end-to-end wallet creation and transaction support, integrating [BitGo APIs](https://developers.bitgo.com/reference/overview#/) with secure communication to Advanced Wallet Manager. +### User and Backup AWM Instances + +Master Express supports configuring **separate Advanced Wallet Manager instances** for user and backup key operations. This allows you to isolate the user key and backup key into independent AWM deployments, each connected to its own KMS/HSM, for stronger security and operational separation. + +- **User AWM** — Configured via `ADVANCED_WALLET_MANAGER_URL`. This is the primary AWM instance and handles all user key operations. It is always required. +- **Backup AWM** — Configured via `ADVANCED_WALLET_MANAGER_BACKUP_URL`. This is an optional, separate AWM instance dedicated to backup key operations. + +**Fallback behavior:** If `ADVANCED_WALLET_MANAGER_BACKUP_URL` is not set, backup key operations automatically fall back to the user AWM instance. This means a single AWM instance handles both user and backup keys — which is the default behavior and sufficient for most deployments. + +When a backup AWM URL **is** provided, dedicated mTLS certificates for the backup AWM are also required (see [Backup AWM mTLS Settings](#backup-awm-mtls-settings) below). The backup AWM will not reuse the primary AWM's certificates. + ## Installation ### Prerequisites @@ -185,6 +196,16 @@ curl -X POST http://localhost:3081/ping/advancedWalletManager | `BITGO_AUTH_VERSION` | BitGo authentication version | `2` | ❌ | | `BITGO_CUSTOM_BITCOIN_NETWORK` | Custom Bitcoin network | - | ❌ | +### Backup AWM Settings (Optional) + +These settings are only required when you want to use a **separate AWM instance for backup key operations**. If these are not configured, backup operations fall back to the primary (user) AWM instance. + +| Variable | Description | Default | Required | +| ------------------------------------- | ------------------------------------ | ------- | ------------------------------------- | +| `ADVANCED_WALLET_MANAGER_BACKUP_URL` | Backup AWM URL | - | Only if using a separate backup AWM | + +> **Note:** When `ADVANCED_WALLET_MANAGER_BACKUP_URL` is not set, the system uses the primary AWM (`ADVANCED_WALLET_MANAGER_URL`) for both user and backup key operations. This is the simplest configuration and works well when a single AWM instance manages both keys. + ### Additional Settings | Variable | Description | Default | Applies To | @@ -233,6 +254,19 @@ curl -X POST http://localhost:3081/ping/advancedWalletManager | `AWM_SERVER_CA_CERT` | AWM server CA certificate (alternative) | PEM string | | `AWM_SERVER_CERT_ALLOW_SELF_SIGNED` | Allow self-signed AWM server certificates | Boolean (default: `false`) | +**For Master Express → Backup Advanced Wallet Manager (optional):** + +These are only required when `ADVANCED_WALLET_MANAGER_BACKUP_URL` is set. If the backup URL is not configured, backup key operations use the primary AWM connection and these settings are ignored. + +| Variable | Description | Format | +| ----------------------------------------- | -------------------------------------------------- | -------------------------- | +| `AWM_BACKUP_CLIENT_TLS_KEY_PATH` | Backup AWM client private key file path | File path | +| `AWM_BACKUP_CLIENT_TLS_KEY` | Backup AWM client private key (alternative) | PEM string | +| `AWM_BACKUP_CLIENT_TLS_CERT_PATH` | Backup AWM client certificate file path | File path | +| `AWM_BACKUP_CLIENT_TLS_CERT` | Backup AWM client certificate (alternative) | PEM string | +| `AWM_BACKUP_SERVER_CA_CERT_PATH` | Backup AWM server CA certificate file path | File path | +| `AWM_BACKUP_SERVER_CA_CERT` | Backup AWM server CA certificate (alternative) | PEM string | + **For Advanced Wallet Manager → key provider:** | Variable | Description | Format | @@ -245,7 +279,7 @@ curl -X POST http://localhost:3081/ping/advancedWalletManager | `KEY_PROVIDER_SERVER_CA_CERT` | Key provider server CA certificate (alternative) | PEM string | | `KEY_PROVIDER_SERVER_CERT_ALLOW_SELF_SIGNED` | Allow self-signed key provider server certificates | Boolean (default: `false`) | -> **Note:** For security reasons, when `TLS_MODE=mtls`, outbound client certificates are required and cannot reuse server certificates. When `TLS_MODE=disabled`, these certificates aren't required. +> **Note:** For security reasons, when `TLS_MODE=mtls`, outbound client certificates are required and cannot reuse server certificates. When a backup AWM is configured, it requires its own dedicated set of certificates — it will not reuse the primary AWM's certificates. When `TLS_MODE=disabled`, these certificates aren't required. ## Container Deployment with Podman diff --git a/src/__tests__/api/master/awmBackupClient.test.ts b/src/__tests__/api/master/awmBackupClient.test.ts new file mode 100644 index 00000000..4a7b9f6d --- /dev/null +++ b/src/__tests__/api/master/awmBackupClient.test.ts @@ -0,0 +1,131 @@ +import 'should'; +import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { + createAwmClient, + createAwmBackupClient, +} from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; + +describe('AWM Backup Client', () => { + const baseConfig: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 3081, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + advancedWalletManagerUrl: 'http://primary-awm.invalid', + awmServerCaCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + + describe('createAwmBackupClient', () => { + it('should return undefined when no backup URL is configured', () => { + const result = createAwmBackupClient(baseConfig, 'tbtc'); + (result === undefined).should.be.true(); + }); + + it('should create a client when backup URL is configured', () => { + const config: MasterExpressConfig = { + ...baseConfig, + advancedWalletManagerBackupUrl: 'http://backup-awm.invalid', + }; + const result = createAwmBackupClient(config, 'tbtc'); + (result !== undefined).should.be.true(); + }); + + it('should create a client pointing to the backup URL, not the primary', () => { + const config: MasterExpressConfig = { + ...baseConfig, + advancedWalletManagerBackupUrl: 'http://backup-awm.invalid', + }; + const backupClient = createAwmBackupClient(config, 'tbtc'); + const primaryClient = createAwmClient(config, 'tbtc'); + + // Both clients should exist + (backupClient !== undefined).should.be.true(); + (primaryClient !== undefined).should.be.true(); + + // They should be different instances + (backupClient !== primaryClient).should.be.true(); + }); + + it('should throw when backup URL is set with mTLS but backup server CA cert is missing', () => { + const config: MasterExpressConfig = { + ...baseConfig, + tlsMode: TlsMode.MTLS, + advancedWalletManagerBackupUrl: 'https://backup-awm.invalid', + awmServerCaCert: 'primary-ca-cert', + awmClientTlsKey: 'primary-client-key', + awmClientTlsCert: 'primary-client-cert', + // No backup-specific certs — should NOT fall back to primary + }; + (() => createAwmBackupClient(config, 'tbtc')).should.throw( + /awmBackupServerCaCert is required/, + ); + }); + + it('should throw when backup URL is set with mTLS but backup client certs are missing', () => { + const config: MasterExpressConfig = { + ...baseConfig, + tlsMode: TlsMode.MTLS, + advancedWalletManagerBackupUrl: 'https://backup-awm.invalid', + awmBackupServerCaCert: 'backup-ca-cert', + // No backup client certs + }; + (() => createAwmBackupClient(config, 'tbtc')).should.throw( + /awmBackupClientTlsKey and awmBackupClientTlsCert are required/, + ); + }); + + it('should create a client when all backup-specific certs are provided with mTLS', () => { + const config: MasterExpressConfig = { + ...baseConfig, + tlsMode: TlsMode.MTLS, + advancedWalletManagerBackupUrl: 'https://backup-awm.invalid', + awmServerCaCert: 'primary-ca-cert', + awmClientTlsKey: 'primary-client-key', + awmClientTlsCert: 'primary-client-cert', + awmBackupServerCaCert: 'backup-ca-cert', + awmBackupClientTlsKey: 'backup-client-key', + awmBackupClientTlsCert: 'backup-client-cert', + }; + const result = createAwmBackupClient(config, 'tbtc'); + (result !== undefined).should.be.true(); + }); + }); + + describe('fallback behavior in middleware', () => { + it('should use primary client for both user and backup when no backup URL is set', () => { + const primaryClient = createAwmClient(baseConfig, 'tbtc'); + const backupClient = createAwmBackupClient(baseConfig, 'tbtc'); + + (primaryClient !== undefined).should.be.true(); + // No backup URL → backup client is undefined → middleware falls back to primary + (backupClient === undefined).should.be.true(); + + // Middleware would do: awmBackupClient = backupClient ?? primaryClient + const effectiveBackupClient = backupClient ?? primaryClient; + (effectiveBackupClient === primaryClient).should.be.true(); + }); + + it('should use separate client for backup when backup URL is set', () => { + const config: MasterExpressConfig = { + ...baseConfig, + advancedWalletManagerBackupUrl: 'http://backup-awm.invalid', + }; + const primaryClient = createAwmClient(config, 'tbtc'); + const backupClient = createAwmBackupClient(config, 'tbtc'); + + (primaryClient !== undefined).should.be.true(); + (backupClient !== undefined).should.be.true(); + + // Middleware would do: awmBackupClient = backupClient ?? primaryClient + const effectiveBackupClient = backupClient ?? primaryClient; + (effectiveBackupClient === backupClient).should.be.true(); + (effectiveBackupClient !== primaryClient).should.be.true(); + }); + }); +}); diff --git a/src/__tests__/api/master/generateWallet.test.ts b/src/__tests__/api/master/generateWallet.test.ts index cc47cf73..5e5aba07 100644 --- a/src/__tests__/api/master/generateWallet.test.ts +++ b/src/__tests__/api/master/generateWallet.test.ts @@ -112,6 +112,128 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { sinon.restore(); }); + it('should generate an onchain wallet with separate backup AWM (separate-HSM mode)', async () => { + const backupAwmUrl = 'http://backup-awm.invalid'; + + // Override middleware to inject a separate backup client + sinon.restore(); + const backupBitgo = new BitGoAPI({ env: 'test' }); + const configWithBackup: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + advancedWalletManagerUrl: advancedWalletManagerUrl, + advancedWalletManagerBackupUrl: backupAwmUrl, + awmServerCaCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = backupBitgo; + (req as BitGoRequest).config = configWithBackup; + next(); + }); + + const app = expressApp(configWithBackup); + const backupAgent = request.agent(app); + + // User keychain goes to primary AWM + const userKeychainNock = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/key/independent`, { + source: 'user', + }) + .reply(200, { + pub: 'xpub_user', + source: 'user', + type: 'independent', + }); + + // Backup keychain goes to backup AWM + const backupKeychainNock = nock(backupAwmUrl) + .post(`/api/${coin}/key/independent`, { + source: 'backup', + }) + .reply(200, { + pub: 'xpub_backup', + source: 'backup', + type: 'independent', + }); + + const bitgoAddUserKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/key`, { + pub: 'xpub_user', + keyType: 'independent', + source: 'user', + }) + .matchHeader('any', () => true) + .reply(200, { id: 'user-key-id', pub: 'xpub_user' }); + + const bitgoAddBackupKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/key`, { + pub: 'xpub_backup', + keyType: 'independent', + source: 'backup', + }) + .matchHeader('any', () => true) + .reply(200, { id: 'backup-key-id', pub: 'xpub_backup' }); + + const bitgoAddBitGoKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/key`, { + source: 'bitgo', + keyType: 'independent', + enterprise: 'test_enterprise', + }) + .reply(200, { + id: 'bitgo-key-id', + pub: 'xpub_bitgo', + source: 'bitgo', + type: 'independent', + isBitGo: true, + isTrust: false, + hsmType: 'institutional', + }); + + const bitgoAddWalletNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/add`) + .matchHeader('any', () => true) + .reply( + 200, + mockWalletResponse('new-wallet-id', coin, { + isCold: true, + pendingApprovals: [], + multisigType: 'onchain', + type: 'advanced', + }), + ); + + const response = await backupAgent + .post(`/api/v1/${coin}/advancedwallet/generate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + label: 'test_wallet', + enterprise: 'test_enterprise', + multisigType: 'onchain', + }); + + response.status.should.equal(200); + response.body.should.have.property('wallet'); + + // Verify user keychain went to primary AWM + userKeychainNock.done(); + // Verify backup keychain went to backup AWM (separate HSM) + backupKeychainNock.done(); + bitgoAddUserKeyNock.done(); + bitgoAddBackupKeyNock.done(); + bitgoAddBitGoKeyNock.done(); + bitgoAddWalletNock.done(); + }); + it('should generate a wallet by calling the advanced wallet manager service', async () => { const userKeychainNock = nock(advancedWalletManagerUrl) .post(`/api/${coin}/key/independent`, { @@ -226,14 +348,43 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { bitgoAddWalletNock.done(); }); - it('should generate a TSS MPC v1 wallet by calling the advanced wallet manager service', async () => { - // Mock fetchConstants instead of using nock for URL mocking - sinon.stub(bitgo, 'fetchConstants').resolves({ + it('should generate a TSS MPC v1 (EdDSA) wallet with separate backup AWM', async () => { + const backupAwmUrl = 'http://backup-awm.invalid'; + + sinon.restore(); + const backupBitgo = new BitGoAPI({ env: 'test' }); + const configWithBackup: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + advancedWalletManagerUrl: advancedWalletManagerUrl, + advancedWalletManagerBackupUrl: backupAwmUrl, + awmServerCaCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = backupBitgo; + (req as BitGoRequest).config = configWithBackup; + next(); + }); + + const app = expressApp(configWithBackup); + const backupAgent = request.agent(app); + + sinon.stub(backupBitgo, 'fetchConstants').resolves({ mpc: { bitgoPublicKey: 'test-bitgo-public-key', }, }); + // User init goes to primary AWM const userInitNock = nock(advancedWalletManagerUrl) .post(`/api/${eddsaCoin}/mpc/key/initialize`, { source: 'user', @@ -253,7 +404,8 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { }, }); - const backupInitNock = nock(advancedWalletManagerUrl) + // Backup init goes to backup AWM + const backupInitNock = nock(backupAwmUrl) .post(`/api/${eddsaCoin}/mpc/key/initialize`, { source: 'backup', bitgoGpgPub: 'test-bitgo-public-key', @@ -340,6 +492,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { walletHSMGPGPublicKeySigs: 'hsm-sig', }); + // User finalize goes to primary AWM const userFinalizeNock = nock(advancedWalletManagerUrl) .post(`/api/${eddsaCoin}/mpc/key/finalize`, { source: 'user', @@ -399,6 +552,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { source: 'user', commonKeychain: 'commonKeychain', }); + const addUserKeyNock = nock(bitgoApiUrl) .post(`/api/v2/${eddsaCoin}/key`, { commonKeychain: 'commonKeychain', @@ -406,12 +560,14 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { type: 'tss', }) .reply(200, { - id: 'id', + id: 'user-key-id', source: 'user', type: 'tss', commonKeychain: 'commonKeychain', }); - const backupFinalizeNock = nock(advancedWalletManagerUrl) + + // Backup finalize goes to backup AWM + const backupFinalizeNock = nock(backupAwmUrl) .post(`/api/${eddsaCoin}/mpc/key/finalize`, { source: 'backup', encryptedDataKey: 'key', @@ -469,80 +625,23 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { commonKeychain: 'commonKeychain', }) .reply(200, { - id: 'id', + id: 'backup-key-id', source: 'backup', type: 'tss', commonKeychain: 'commonKeychain', }); const addWalletNock = nock(bitgoApiUrl) - .post(`/api/v2/${eddsaCoin}/wallet/add`, { - label: 'test_wallet', - enterprise: 'test_enterprise', - multisigType: 'tss', - coin: eddsaCoin, - m: 2, - n: 3, - keys: ['id', 'id', 'id'], - type: 'advanced', - }) + .post(`/api/v2/${eddsaCoin}/wallet/add`) + .matchHeader('any', () => true) .reply(200, { - id: 'wallet-id', - users: [ - { - user: 'user-id', - permissions: ['admin', 'spend', 'view'], - }, - ], - coin: eddsaCoin, - label: 'test_wallet', - m: 2, - n: 3, - keys: ['id', 'id', 'id'], - keySignatures: {}, - enterprise: 'test_enterprise', - organization: 'org-id', - bitgoOrg: 'BitGo Inc', - tags: ['wallet-id', 'test_enterprise'], - disableTransactionNotifications: false, - freeze: {}, - deleted: false, - approvalsRequired: 1, - isCold: true, - coinSpecific: { - rootAddress: '74AUHib3F6Fq5eVm2ywP5ik9iQjviwAfZXWnGM9JHhJ4', - pendingChainInitialization: true, - minimumFunding: 2447136, - lastChainIndex: ['Object'], - nonceExpiresAt: '2025-06-25T23:00:12.019Z', - trustedTokens: [], - }, - admin: {}, - pendingApprovals: [], - allowBackupKeySigning: false, - clientFlags: [], - walletFlags: [], - recoverable: false, - startDate: '2025-01-01T00:00:00.000Z', - hasLargeNumberOfAddresses: false, - config: {}, - balanceString: '0', - confirmedBalanceString: '0', - spendableBalanceString: '0', - receiveAddress: { - id: 'addr-id', - address: '93AHaUAExampleRootAddress', - chain: 0, - index: 0, - coin: eddsaCoin, - wallet: 'wallet-id', - coinSpecific: {}, - }, - multisigType: 'tss', - type: 'advanced', + ...mockWalletResponse('wallet-id', eddsaCoin, { + multisigType: 'tss', + type: 'advanced', + }), }); - const response = await agent + const response = await backupAgent .post(`/api/v1/${eddsaCoin}/advancedwallet/generate`) .set('Authorization', `Bearer ${accessToken}`) .send({ @@ -551,16 +650,994 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { multisigType: 'tss', }); - // No need to check constantsNock since we're using sinon stub + response.status.should.equal(200); + response.body.should.have.property('wallet'); + + // Verify user operations went to primary AWM userInitNock.done(); + userFinalizeNock.done(); + // Verify backup operations went to backup AWM backupInitNock.done(); + backupFinalizeNock.done(); + // Verify BitGo API calls bitgoAddKeychainNock.done(); - userFinalizeNock.done(); addUserKeyNock.done(); - backupFinalizeNock.done(); addBackupKeyNock.done(); addWalletNock.done(); - response.status.should.equal(200); + }); + + it('should generate a TSS MPC v1 wallet by calling the advanced wallet manager service', async () => { + // Mock fetchConstants instead of using nock for URL mocking + sinon.stub(bitgo, 'fetchConstants').resolves({ + mpc: { + bitgoPublicKey: 'test-bitgo-public-key', + }, + }); + + const userInitNock = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/key/initialize`, { + source: 'user', + bitgoGpgPub: 'test-bitgo-public-key', + }) + .reply(200, { + encryptedDataKey: 'key', + encryptedData: 'data', + bitgoPayload: { + from: 'user', + to: 'bitgo', + publicShare: 'public-share-user', + privateShare: 'private-share-user-to-bitgo', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'user-key', + }, + }); + + const backupInitNock = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/key/initialize`, { + source: 'backup', + bitgoGpgPub: 'test-bitgo-public-key', + counterPartyGpgPub: 'user-key', + }) + .reply(200, { + encryptedDataKey: 'key', + encryptedData: 'data', + bitgoPayload: { + from: 'backup', + to: 'bitgo', + publicShare: 'public-share-backup', + privateShare: 'private-share-backup-to-bitgo', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'backup-key', + }, + counterPartyKeyShare: { + from: 'backup', + to: 'user', + publicShare: 'public-share-backup', + privateShare: 'private-share-backup-to-user', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'backup-key', + }, + }); + + const bitgoAddKeychainNock = nock(bitgoApiUrl) + .post(`/api/v2/${eddsaCoin}/key`, { + keyType: 'tss', + source: 'bitgo', + enterprise: 'test_enterprise', + keyShares: [ + { + from: 'user', + to: 'bitgo', + publicShare: 'public-share-user', + privateShare: 'private-share-user-to-bitgo', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'user-key', + }, + { + from: 'backup', + to: 'bitgo', + publicShare: 'public-share-backup', + privateShare: 'private-share-backup-to-bitgo', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'backup-key', + }, + ], + userGPGPublicKey: 'user-key', + backupGPGPublicKey: 'backup-key', + }) + .reply(200, { + id: 'id', + source: 'bitgo', + type: 'tss', + commonKeychain: 'commonKeychain', + verifiedVssProof: true, + isBitGo: true, + isTrust: true, + hsmType: 'institutional', + keyShares: [ + { + from: 'bitgo', + to: 'user', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + { + from: 'bitgo', + to: 'backup', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + ], + walletHSMGPGPublicKeySigs: 'hsm-sig', + }); + + const userFinalizeNock = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/key/finalize`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + counterPartyGpgPub: 'backup-key', + bitgoKeyChain: { + id: 'id', + source: 'bitgo', + type: 'tss', + commonKeychain: 'commonKeychain', + verifiedVssProof: true, + isBitGo: true, + isTrust: false, + hsmType: 'institutional', + keyShares: [ + { + from: 'bitgo', + to: 'user', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + { + from: 'bitgo', + to: 'backup', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + ], + walletHSMGPGPublicKeySigs: 'hsm-sig', + }, + coin: 'tsol', + counterPartyKeyShare: { + from: 'backup', + to: 'user', + publicShare: 'public-share-backup', + privateShare: 'private-share-backup-to-user', + privateShareProof: 'proof', + vssProof: 'proof', + gpgKey: 'backup-key', + }, + }) + .reply(200, { + counterpartyKeyShare: { + from: 'user', + to: 'backup', + publicShare: 'publicShare', + privateShare: 'privateShare', + privateShareProof: 'privateShareProof', + vssProof: 'vssProof', + gpgKey: 'user-key', + }, + source: 'user', + commonKeychain: 'commonKeychain', + }); + const addUserKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${eddsaCoin}/key`, { + commonKeychain: 'commonKeychain', + source: 'user', + type: 'tss', + }) + .reply(200, { + id: 'id', + source: 'user', + type: 'tss', + commonKeychain: 'commonKeychain', + }); + const backupFinalizeNock = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/key/finalize`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + counterPartyGpgPub: 'user-key', + bitgoKeyChain: { + id: 'id', + source: 'bitgo', + type: 'tss', + commonKeychain: 'commonKeychain', + verifiedVssProof: true, + isBitGo: true, + isTrust: false, + hsmType: 'institutional', + keyShares: [ + { + from: 'bitgo', + to: 'user', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + { + from: 'bitgo', + to: 'backup', + publicShare: 'publicShare', + privateShare: 'privateShare', + vssProof: 'true', + gpgKey: 'bitgo-key', + }, + ], + walletHSMGPGPublicKeySigs: 'hsm-sig', + }, + coin: 'tsol', + counterPartyKeyShare: { + from: 'user', + to: 'backup', + publicShare: 'publicShare', + privateShare: 'privateShare', + privateShareProof: 'privateShareProof', + vssProof: 'vssProof', + gpgKey: 'user-key', + }, + }) + .reply(200, { + source: 'backup', + commonKeychain: 'commonKeychain', + }); + + const addBackupKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${eddsaCoin}/key`, { + source: 'backup', + type: 'tss', + commonKeychain: 'commonKeychain', + }) + .reply(200, { + id: 'id', + source: 'backup', + type: 'tss', + commonKeychain: 'commonKeychain', + }); + + const addWalletNock = nock(bitgoApiUrl) + .post(`/api/v2/${eddsaCoin}/wallet/add`, { + label: 'test_wallet', + enterprise: 'test_enterprise', + multisigType: 'tss', + coin: eddsaCoin, + m: 2, + n: 3, + keys: ['id', 'id', 'id'], + type: 'advanced', + }) + .reply(200, { + id: 'wallet-id', + users: [ + { + user: 'user-id', + permissions: ['admin', 'spend', 'view'], + }, + ], + coin: eddsaCoin, + label: 'test_wallet', + m: 2, + n: 3, + keys: ['id', 'id', 'id'], + keySignatures: {}, + enterprise: 'test_enterprise', + organization: 'org-id', + bitgoOrg: 'BitGo Inc', + tags: ['wallet-id', 'test_enterprise'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: true, + coinSpecific: { + rootAddress: '74AUHib3F6Fq5eVm2ywP5ik9iQjviwAfZXWnGM9JHhJ4', + pendingChainInitialization: true, + minimumFunding: 2447136, + lastChainIndex: ['Object'], + nonceExpiresAt: '2025-06-25T23:00:12.019Z', + trustedTokens: [], + }, + admin: {}, + pendingApprovals: [], + allowBackupKeySigning: false, + clientFlags: [], + walletFlags: [], + recoverable: false, + startDate: '2025-01-01T00:00:00.000Z', + hasLargeNumberOfAddresses: false, + config: {}, + balanceString: '0', + confirmedBalanceString: '0', + spendableBalanceString: '0', + receiveAddress: { + id: 'addr-id', + address: '93AHaUAExampleRootAddress', + chain: 0, + index: 0, + coin: eddsaCoin, + wallet: 'wallet-id', + coinSpecific: {}, + }, + multisigType: 'tss', + type: 'advanced', + }); + + const response = await agent + .post(`/api/v1/${eddsaCoin}/advancedwallet/generate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + label: 'test_wallet', + enterprise: 'test_enterprise', + multisigType: 'tss', + }); + + // No need to check constantsNock since we're using sinon stub + userInitNock.done(); + backupInitNock.done(); + bitgoAddKeychainNock.done(); + userFinalizeNock.done(); + addUserKeyNock.done(); + backupFinalizeNock.done(); + addBackupKeyNock.done(); + addWalletNock.done(); + response.status.should.equal(200); + }); + + it('should generate a TSS MPC v2 (ECDSA) wallet with separate backup AWM', async () => { + const backupAwmUrl = 'http://backup-awm.invalid'; + + sinon.restore(); + const backupBitgo = new BitGoAPI({ env: 'test' }); + const configWithBackup: MasterExpressConfig = { + appMode: AppMode.MASTER_EXPRESS, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + authVersion: 2, + advancedWalletManagerUrl: advancedWalletManagerUrl, + advancedWalletManagerBackupUrl: backupAwmUrl, + awmServerCaCert: 'dummy-cert', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + }; + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = backupBitgo; + (req as BitGoRequest).config = configWithBackup; + next(); + }); + + const app = expressApp(configWithBackup); + const backupAgent = request.agent(app); + + sinon.stub(backupBitgo, 'fetchConstants').resolves({ + mpc: { + bitgoMPCv2PublicKey: 'test-bitgo-public-key', + }, + }); + + // Init: user goes to primary AWM, backup goes to backup AWM + const userInitNock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/initialize`, { source: 'user' }) + .reply(200, { + encryptedDataKey: 'key', + encryptedData: 'data', + gpgPub: 'test-user-public-key', + }); + + const backupInitNock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/initialize`, { source: 'backup' }) + .reply(200, { + encryptedDataKey: 'key', + encryptedData: 'data', + gpgPub: 'test-backup-public-key', + }); + + // Round 1: user goes to primary, backup goes to backup AWM + const userRound1Nock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 1, + bitgoGpgPub: 'test-bitgo-public-key', + counterPartyGpgPub: 'test-backup-public-key', + }) + .reply(200, { + round: 2, + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessage: { + from: 0, + payload: { message: 'test-broadcast-message-user-1', signature: 'test-signature-user-1' }, + }, + }); + + const backupRound1Nock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 1, + bitgoGpgPub: 'test-bitgo-public-key', + counterPartyGpgPub: 'test-user-public-key', + }) + .reply(200, { + round: 2, + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessage: { + from: 1, + payload: { + message: 'test-broadcast-message-backup-1', + signature: 'test-signature-backup-1', + }, + }, + }); + + // BitGo round 1 & 2 + const bitgoRound1And2Nock = nock(bitgoApiUrl) + .post(`/api/v2/mpc/generatekey`, { + enterprise: 'test-enterprise', + type: 'MPCv2', + round: 'MPCv2-R1', + payload: { + userGpgPublicKey: 'test-user-public-key', + backupGpgPublicKey: 'test-backup-public-key', + userMsg1: { + from: 0, + message: 'test-broadcast-message-user-1', + signature: 'test-signature-user-1', + }, + backupMsg1: { + from: 1, + message: 'test-broadcast-message-backup-1', + signature: 'test-signature-backup-1', + }, + walletId: undefined, + }, + }) + .reply(200, { + walletGpgPubKeySigs: 'test-wallet-gpg-pub-key-sigs', + sessionId: 'test-session-id', + bitgoMsg1: { + from: 2, + message: 'test-broadcast-message-bitgo-1', + signature: 'test-signature-bitgo-1', + }, + bitgoToUserMsg2: { + from: 2, + to: 0, + encryptedMessage: 'test-p2p-message-bitgo-to-user-2', + signature: 'test-signature-bitgo-to-user-2', + }, + bitgoToBackupMsg2: { + from: 2, + to: 1, + encryptedMessage: 'test-p2p-message-bitgo-to-backup-2', + signature: 'test-signature-bitgo-to-backup-2', + }, + }); + + // Round 2: user to primary, backup to backup AWM + const userRound2Nock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 2, + broadcastMessages: { + bitgo: { + from: 2, + payload: { + message: 'test-broadcast-message-bitgo-1', + signature: 'test-signature-bitgo-1', + }, + }, + counterParty: { + from: 1, + payload: { + message: 'test-broadcast-message-backup-1', + signature: 'test-signature-backup-1', + }, + }, + }, + }) + .reply(200, { + round: 3, + encryptedDataKey: 'key', + encryptedData: 'data', + p2pMessages: { + bitgo: { + from: 0, + to: 2, + payload: { + encryptedMessage: 'test-p2p-message-user-to-bitgo-2', + signature: 'test-signature-user-to-bitgo-2', + }, + commitment: 'test-commitment-user-2', + }, + counterParty: { + from: 0, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-user-to-backup-2', + signature: 'test-signature-user-to-backup-2', + }, + commitment: 'test-commitment-user-2', + }, + }, + }); + + const backupRound2Nock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 2, + broadcastMessages: { + bitgo: { + from: 2, + payload: { + message: 'test-broadcast-message-bitgo-1', + signature: 'test-signature-bitgo-1', + }, + }, + counterParty: { + from: 0, + payload: { + message: 'test-broadcast-message-user-1', + signature: 'test-signature-user-1', + }, + }, + }, + }) + .reply(200, { + round: 3, + encryptedDataKey: 'key', + encryptedData: 'data', + p2pMessages: { + bitgo: { + from: 1, + to: 2, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-bitgo-2', + signature: 'test-signature-backup-to-bitgo-2', + }, + commitment: 'test-commitment-backup-2', + }, + counterParty: { + from: 1, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-user-2', + signature: 'test-signature-backup-to-user-2', + }, + commitment: 'test-commitment-backup-2', + }, + }, + }); + + // Round 3: user to primary, backup to backup AWM + const userRound3Nock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 3, + p2pMessages: { + bitgo: { + from: 2, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-bitgo-to-user-2', + signature: 'test-signature-bitgo-to-user-2', + }, + }, + counterParty: { + from: 1, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-user-2', + signature: 'test-signature-backup-to-user-2', + }, + commitment: 'test-commitment-backup-2', + }, + }, + }) + .reply(200, { + round: 4, + encryptedDataKey: 'key', + encryptedData: 'data', + p2pMessages: { + bitgo: { + from: 0, + to: 2, + payload: { + encryptedMessage: 'test-p2p-message-user-to-bitgo-3', + signature: 'test-signature-user-to-bitgo-3', + }, + commitment: 'test-commitment-user-3', + }, + counterParty: { + from: 0, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-user-to-backup-3', + signature: 'test-signature-user-to-backup-3', + }, + commitment: 'test-commitment-user-3', + }, + }, + }); + + const backupRound3Nock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 3, + p2pMessages: { + bitgo: { + from: 2, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-bitgo-to-backup-2', + signature: 'test-signature-bitgo-to-backup-2', + }, + }, + counterParty: { + from: 0, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-user-to-backup-2', + signature: 'test-signature-user-to-backup-2', + }, + commitment: 'test-commitment-user-2', + }, + }, + }) + .reply(200, { + round: 4, + encryptedDataKey: 'key', + encryptedData: 'data', + p2pMessages: { + bitgo: { + from: 1, + to: 2, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-bitgo-3', + signature: 'test-signature-backup-to-bitgo-3', + }, + commitment: 'test-commitment-backup-3', + }, + counterParty: { + from: 1, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-user-3', + signature: 'test-signature-backup-to-user-3', + }, + commitment: 'test-commitment-backup-3', + }, + }, + }); + + // BitGo round 3 + const bitgoRound3Nock = nock(bitgoApiUrl) + .post(`/api/v2/mpc/generatekey`, { + enterprise: 'test-enterprise', + type: 'MPCv2', + round: 'MPCv2-R2', + payload: { + sessionId: 'test-session-id', + userMsg2: { + from: 0, + to: 2, + encryptedMessage: 'test-p2p-message-user-to-bitgo-2', + signature: 'test-signature-user-to-bitgo-2', + }, + userCommitment2: 'test-commitment-user-2', + backupMsg2: { + from: 1, + to: 2, + encryptedMessage: 'test-p2p-message-backup-to-bitgo-2', + signature: 'test-signature-backup-to-bitgo-2', + }, + backupCommitment2: 'test-commitment-backup-2', + }, + }) + .reply(200, { + sessionId: 'test-session-id', + bitgoCommitment2: 'test-commitment-bitgo-2', + bitgoToUserMsg3: { + from: 2, + to: 0, + encryptedMessage: 'test-p2p-message-bitgo-to-user-3', + signature: 'test-signature-bitgo-to-user-3', + }, + bitgoToBackupMsg3: { + from: 2, + to: 1, + encryptedMessage: 'test-p2p-message-bitgo-to-backup-3', + signature: 'test-signature-bitgo-to-backup-3', + }, + }); + + // Round 4: user to primary, backup to backup AWM + const userRound4Nock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 4, + p2pMessages: { + bitgo: { + from: 2, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-bitgo-to-user-3', + signature: 'test-signature-bitgo-to-user-3', + }, + commitment: 'test-commitment-bitgo-2', + }, + counterParty: { + from: 1, + to: 0, + payload: { + encryptedMessage: 'test-p2p-message-backup-to-user-3', + signature: 'test-signature-backup-to-user-3', + }, + commitment: 'test-commitment-backup-3', + }, + }, + }) + .reply(200, { + round: 5, + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessage: { + from: 0, + payload: { message: 'test-broadcast-message-user-4', signature: 'test-signature-user-4' }, + }, + }); + + const backupRound4Nock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/round`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + round: 4, + p2pMessages: { + bitgo: { + from: 2, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-bitgo-to-backup-3', + signature: 'test-signature-bitgo-to-backup-3', + }, + commitment: 'test-commitment-bitgo-2', + }, + counterParty: { + from: 0, + to: 1, + payload: { + encryptedMessage: 'test-p2p-message-user-to-backup-3', + signature: 'test-signature-user-to-backup-3', + }, + commitment: 'test-commitment-user-3', + }, + }, + }) + .reply(200, { + round: 5, + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessage: { + from: 1, + payload: { + message: 'test-broadcast-message-backup-4', + signature: 'test-signature-backup-4', + }, + }, + }); + + // BitGo round 4 + const bitgoRound4Nock = nock(bitgoApiUrl) + .post(`/api/v2/mpc/generatekey`, { + enterprise: 'test-enterprise', + type: 'MPCv2', + round: 'MPCv2-R3', + payload: { + sessionId: 'test-session-id', + userMsg3: { + from: 0, + to: 2, + encryptedMessage: 'test-p2p-message-user-to-bitgo-3', + signature: 'test-signature-user-to-bitgo-3', + }, + backupMsg3: { + from: 1, + to: 2, + encryptedMessage: 'test-p2p-message-backup-to-bitgo-3', + signature: 'test-signature-backup-to-bitgo-3', + }, + userMsg4: { + from: 0, + message: 'test-broadcast-message-user-4', + signature: 'test-signature-user-4', + }, + backupMsg4: { + from: 1, + message: 'test-broadcast-message-backup-4', + signature: 'test-signature-backup-4', + }, + }, + }) + .reply(200, { + sessionId: 'test-session-id', + commonKeychain: 'commonKeychain', + bitgoMsg4: { + from: 2, + message: 'test-broadcast-message-bitgo-4', + signature: 'test-signature-bitgo-4', + }, + }); + + // Finalize: user to primary, backup to backup AWM + const userFinalizeNock = nock(advancedWalletManagerUrl) + .post(`/api/${ecdsaCoin}/mpcv2/finalize`, { + source: 'user', + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessages: { + bitgo: { + from: 2, + payload: { + message: 'test-broadcast-message-bitgo-4', + signature: 'test-signature-bitgo-4', + }, + }, + counterParty: { + from: 1, + payload: { + message: 'test-broadcast-message-backup-4', + signature: 'test-signature-backup-4', + }, + }, + }, + bitgoCommonKeychain: 'commonKeychain', + }) + .reply(200, { source: 'user', commonKeychain: 'commonKeychain' }); + + const backupFinalizeNock = nock(backupAwmUrl) + .post(`/api/${ecdsaCoin}/mpcv2/finalize`, { + source: 'backup', + encryptedDataKey: 'key', + encryptedData: 'data', + broadcastMessages: { + bitgo: { + from: 2, + payload: { + message: 'test-broadcast-message-bitgo-4', + signature: 'test-signature-bitgo-4', + }, + }, + counterParty: { + from: 0, + payload: { + message: 'test-broadcast-message-user-4', + signature: 'test-signature-user-4', + }, + }, + }, + bitgoCommonKeychain: 'commonKeychain', + }) + .reply(200, { source: 'backup', commonKeychain: 'commonKeychain' }); + + // Key creation on BitGo + const bitgoAddUserKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${ecdsaCoin}/key`, { + commonKeychain: 'commonKeychain', + source: 'user', + type: 'tss', + isMPCv2: true, + }) + .reply(200, { id: 'user-key-id', source: 'user', type: 'tss' }); + + const bitgoAddBackupKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${ecdsaCoin}/key`, { + commonKeychain: 'commonKeychain', + source: 'backup', + type: 'tss', + isMPCv2: true, + }) + .reply(200, { id: 'backup-key-id', source: 'backup', type: 'tss' }); + + const bitgoAddBitGoKeyNock = nock(bitgoApiUrl) + .post(`/api/v2/${ecdsaCoin}/key`, { + commonKeychain: 'commonKeychain', + source: 'bitgo', + type: 'tss', + isMPCv2: true, + }) + .reply(200, { + id: 'bitgo-key-id', + source: 'bitgo', + type: 'tss', + commonKeychain: 'commonKeychain', + isBitGo: true, + isTrust: false, + hsmType: 'institutional', + }); + + const bitgoAddWalletNock = nock(bitgoApiUrl) + .post(`/api/v2/${ecdsaCoin}/wallet/add`) + .matchHeader('any', () => true) + .reply(200, { + ...mockWalletResponse('new-wallet-id', ecdsaCoin, { + multisigType: 'tss', + type: 'advanced', + }), + }); + + const response = await backupAgent + .post(`/api/v1/${ecdsaCoin}/advancedwallet/generate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + label: 'test-wallet', + enterprise: 'test-enterprise', + multisigType: 'tss', + }); + + response.status.should.equal(200); + response.body.should.have.property('wallet'); + + // Verify user operations went to primary AWM + userInitNock.done(); + userRound1Nock.done(); + userRound2Nock.done(); + userRound3Nock.done(); + userRound4Nock.done(); + userFinalizeNock.done(); + // Verify backup operations went to backup AWM + backupInitNock.done(); + backupRound1Nock.done(); + backupRound2Nock.done(); + backupRound3Nock.done(); + backupRound4Nock.done(); + backupFinalizeNock.done(); + // Verify BitGo API calls + bitgoRound1And2Nock.done(); + bitgoRound3Nock.done(); + bitgoRound4Nock.done(); + bitgoAddUserKeyNock.done(); + bitgoAddBackupKeyNock.done(); + bitgoAddBitGoKeyNock.done(); + bitgoAddWalletNock.done(); }); it('should generate a TSS MPC v2 wallet by calling the advanced wallet manager service', async () => { diff --git a/src/__tests__/api/master/nonRecovery.test.ts b/src/__tests__/api/master/nonRecovery.test.ts index c8706072..0814299e 100644 --- a/src/__tests__/api/master/nonRecovery.test.ts +++ b/src/__tests__/api/master/nonRecovery.test.ts @@ -61,7 +61,7 @@ describe('Non Recovery Tests', () => { beforeEach(() => { sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { (req as BitGoRequest).params = { coin }; - (req as BitGoRequest).awmClient = new AdvancedWalletManagerClient( + (req as BitGoRequest).awmUserClient = new AdvancedWalletManagerClient( config, coin, ); diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index f2de4360..7c8e0f08 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -99,7 +99,7 @@ describe('Recovery Tests', () => { // Setup coin middleware sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { (req as BitGoRequest).params = { coin }; - (req as BitGoRequest).awmClient = new AdvancedWalletManagerClient( + (req as BitGoRequest).awmUserClient = new AdvancedWalletManagerClient( config, coin, ); @@ -288,7 +288,7 @@ describe('Recovery Tests', () => { // Setup coin middleware for ETH coin sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { (req as BitGoRequest).params = { coin: ethCoinId }; - (req as BitGoRequest).awmClient = new AdvancedWalletManagerClient( + (req as BitGoRequest).awmUserClient = new AdvancedWalletManagerClient( config, ethCoinId, ); @@ -381,7 +381,7 @@ describe('Recovery Tests', () => { // Setup coin middleware for Solana coin sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { (req as BitGoRequest).params = { coin: solCoinId }; - (req as BitGoRequest).awmClient = new AdvancedWalletManagerClient( + (req as BitGoRequest).awmUserClient = new AdvancedWalletManagerClient( config, solCoinId, ); @@ -527,7 +527,7 @@ describe('Recovery Tests', () => { // Setup coin middleware for Sui coin sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { (req as BitGoRequest).params = { coin: suiCoinId }; - (req as BitGoRequest).awmClient = new AdvancedWalletManagerClient( + (req as BitGoRequest).awmUserClient = new AdvancedWalletManagerClient( config, suiCoinId, ); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 747ce578..1ee549bf 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -54,6 +54,12 @@ describe('Configuration', () => { delete process.env.AWM_CLIENT_TLS_CERT_PATH; delete process.env.KEY_PROVIDER_SERVER_CA_CERT_PATH; delete process.env.RECOVERY_MODE; + delete process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL; + delete process.env.AWM_BACKUP_SERVER_CA_CERT_PATH; + delete process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH; + delete process.env.AWM_BACKUP_CLIENT_TLS_CERT_PATH; + delete process.env.AWM_BACKUP_CLIENT_TLS_KEY; + delete process.env.AWM_BACKUP_CLIENT_TLS_CERT; }); after(() => { @@ -462,5 +468,187 @@ describe('Configuration', () => { cfg.httpLoggerFile.should.equal('/tmp/test-http-access.log'); } }); + + it('should not set backup URL when ADVANCED_WALLET_MANAGER_BACKUP_URL is not set', () => { + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + (cfg.advancedWalletManagerBackupUrl === undefined).should.be.true(); + } + }); + + it('should read and protocol-process backup URL when configured', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY = mockClientTlsKey; + process.env.AWM_BACKUP_CLIENT_TLS_CERT = mockClientTlsCert; + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.advancedWalletManagerBackupUrl!.should.equal('https://backup-awm.example.com:3080'); + } + }); + + it('should use http protocol for backup URL when TLS is disabled', () => { + process.env.TLS_MODE = 'disabled'; + delete process.env.AWM_SERVER_CA_CERT_PATH; + delete process.env.SERVER_TLS_KEY_PATH; + delete process.env.SERVER_TLS_CERT_PATH; + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.advancedWalletManagerBackupUrl!.should.equal('http://backup-awm.example.com:3080'); + } + }); + + it('should throw error when backup URL is set without AWM_BACKUP_SERVER_CA_CERT_PATH in MTLS mode', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'https://backup-awm.example.com:3080'; + (() => initConfig()).should.throw( + 'AWM_BACKUP_SERVER_CA_CERT_PATH environment variable is required for MTLS mode when provisioning a backup AWM URL.', + ); + }); + + it('should load backup server CA cert from file', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY = mockClientTlsKey; + process.env.AWM_BACKUP_CLIENT_TLS_CERT = mockClientTlsCert; + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.awmBackupServerCaCert!.should.be.a.String(); + cfg.awmBackupServerCaCert!.length.should.be.greaterThan(0); + } + }); + + it('should not require backup cert paths when backup URL is not set in MTLS mode', () => { + // This verifies backward compatibility — no backup URL means no backup cert validation + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + (cfg.advancedWalletManagerBackupUrl === undefined).should.be.true(); + (cfg.awmBackupServerCaCert === undefined).should.be.true(); + } + }); + + it('should load backup client TLS key from file path', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH = path.resolve( + __dirname, + 'mocks/certs/client.key', + ); + process.env.AWM_BACKUP_CLIENT_TLS_CERT = mockClientTlsCert; + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.awmBackupClientTlsKey!.should.be.a.String(); + cfg.awmBackupClientTlsKey!.length.should.be.greaterThan(0); + } + }); + + it('should load backup client TLS cert from file path', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY = mockClientTlsKey; + process.env.AWM_BACKUP_CLIENT_TLS_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/client.crt', + ); + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.awmBackupClientTlsCert!.should.be.a.String(); + cfg.awmBackupClientTlsCert!.length.should.be.greaterThan(0); + } + }); + + it('should throw error when backup client TLS key path points to a nonexistent file', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH = '/nonexistent/path/client.key'; + (() => initConfig()).should.throw(/Failed to read AWM backup client key/); + }); + + it('should throw error when backup client TLS cert path points to a nonexistent file', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_CERT_PATH = '/nonexistent/path/client.crt'; + (() => initConfig()).should.throw(/Failed to read AWM backup client cert/); + }); + + it('should throw error when backup URL is set in mTLS mode but no backup or primary client certs are available', () => { + // Remove primary client certs so fallback also fails + delete process.env.AWM_CLIENT_TLS_KEY; + delete process.env.AWM_CLIENT_TLS_CERT; + delete process.env.AWM_CLIENT_TLS_KEY_PATH; + delete process.env.AWM_CLIENT_TLS_CERT_PATH; + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + // Primary cert validation fires first since both primary and backup certs are missing + (() => initConfig()).should.throw( + /AWM_CLIENT_TLS_KEY_PATH and AWM_CLIENT_TLS_CERT_PATH.*are required for outbound mTLS connections to Advanced Wallet Manager/, + ); + }); + + it('should succeed when backup URL is set in mTLS mode and dedicated backup client certs are provided', () => { + process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080'; + process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/advanced-wallet-manager-cert.pem', + ); + process.env.AWM_BACKUP_CLIENT_TLS_KEY = mockClientTlsKey; + process.env.AWM_BACKUP_CLIENT_TLS_CERT = mockClientTlsCert; + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + cfg.advancedWalletManagerBackupUrl!.should.be.a.String(); + } + }); + + it('should not load backup client certs from file when backup URL is not set', () => { + process.env.TLS_MODE = 'disabled'; + delete process.env.AWM_SERVER_CA_CERT_PATH; + delete process.env.SERVER_TLS_KEY_PATH; + delete process.env.SERVER_TLS_CERT_PATH; + process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH = path.resolve( + __dirname, + 'mocks/certs/client.key', + ); + process.env.AWM_BACKUP_CLIENT_TLS_CERT_PATH = path.resolve( + __dirname, + 'mocks/certs/client.crt', + ); + const cfg = initConfig(); + isMasterExpressConfig(cfg).should.be.true(); + if (isMasterExpressConfig(cfg)) { + // Paths are stored in config but files are not loaded without a backup URL + (cfg.advancedWalletManagerBackupUrl === undefined).should.be.true(); + (cfg.awmBackupClientTlsKey === undefined).should.be.true(); + (cfg.awmBackupClientTlsCert === undefined).should.be.true(); + } + }); }); }); diff --git a/src/initConfig.ts b/src/initConfig.ts index 8e9c8a86..08cabe9d 100644 --- a/src/initConfig.ts +++ b/src/initConfig.ts @@ -31,6 +31,38 @@ function readEnvVar(name: string): string | undefined { } } +function readCertFile(filePath: string, label: string): string { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + logger.info(`✓ ${label} loaded from file: ${filePath}`); + return content; + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + throw new Error(`Failed to read ${label} from ${filePath}: ${err.message}`); + } +} + +/** + * Loads a certificate/key value from either an environment variable or a file path. + * If the value is already set (from env), it logs and returns it. + * If a path is provided, it reads the file. + * Returns undefined if neither is set. + */ +function loadCert( + value: string | undefined, + path: string | undefined, + label: string, +): string | undefined { + if (value) { + logger.info(`✓ ${label} loaded from environment variable`); + return value; + } + if (path) { + return readCertFile(path, label); + } + return undefined; +} + function determineAppMode(): AppMode { const mode = readEnvVar('APP_MODE') || readEnvVar('BITGO_APP_MODE'); if (!mode) { @@ -182,7 +214,7 @@ function mergeAkmConfigs( }; } -function configureAdvancedWalletManagaerMode(): AdvancedWalletManagerConfig { +function configureAdvancedWalletManagerMode(): AdvancedWalletManagerConfig { const env = advancedWalletManagerEnvConfig(); let config = mergeAkmConfigs(env); @@ -191,87 +223,29 @@ function configureAdvancedWalletManagaerMode(): AdvancedWalletManagerConfig { // Only load certificates if TLS is enabled if (config.tlsMode !== TlsMode.DISABLED) { - // Handle file loading for TLS certificates - if (!config.serverTlsKey && config.serverTlsKeyPath) { - try { - config = { ...config, serverTlsKey: fs.readFileSync(config.serverTlsKeyPath, 'utf-8') }; - logger.info(`✓ TLS private key loaded from file: ${config.serverTlsKeyPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read TLS key from serverTlsKeyPath: ${err.message}`); - } - } else if (config.serverTlsKey) { - logger.info('✓ TLS private key loaded from environment variable'); - } - - if (!config.serverTlsCert && config.serverTlsCertPath) { - try { - config = { - ...config, - serverTlsCert: fs.readFileSync(config.serverTlsCertPath, 'utf-8'), - }; - logger.info(`✓ TLS certificate loaded from file: ${config.serverTlsCertPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read TLS certificate from serverTlsCertPath: ${err.message}`); - } - } else if (config.serverTlsCert) { - logger.info('✓ TLS certificate loaded from environment variable'); - } - if (!config.keyProviderServerCaCertPath) { throw new Error('KEY_PROVIDER_SERVER_CA_CERT_PATH is required when TLS mode is MTLS'); } - if (config.keyProviderServerCaCertPath) { - try { - config.keyProviderServerCaCert = fs.readFileSync( - config.keyProviderServerCaCertPath, - 'utf-8', - ); - logger.info( - `✓ key provider server CA certificate loaded from file: ${config.keyProviderServerCaCertPath}`, - ); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error( - `Failed to read key provider TLS certificate from keyProviderTlsCert: ${err.message}`, - ); - } - } - if (config.keyProviderClientTlsKeyPath) { - try { - config.keyProviderClientTlsKey = fs.readFileSync( - config.keyProviderClientTlsKeyPath, - 'utf-8', - ); - logger.info( - `✓ key provider client key loaded from file: ${config.keyProviderClientTlsKeyPath}`, - ); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error( - `Failed to read key provider client key from keyProviderClientTlsKeyPath: ${err.message}`, - ); - } - } - - if (config.keyProviderClientTlsCertPath) { - try { - config.keyProviderClientTlsCert = fs.readFileSync( - config.keyProviderClientTlsCertPath, - 'utf-8', - ); - logger.info( - `✓ key provider client certificate loaded from file: ${config.keyProviderClientTlsCertPath}`, - ); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error( - `Failed to read key provider client cert from keyProviderClientTlsCertPath: ${err.message}`, - ); - } - } + config = { + ...config, + serverTlsKey: loadCert(config.serverTlsKey, config.serverTlsKeyPath, 'TLS private key'), + serverTlsCert: loadCert(config.serverTlsCert, config.serverTlsCertPath, 'TLS certificate'), + keyProviderServerCaCert: readCertFile( + config.keyProviderServerCaCertPath, + 'Key provider server CA certificate', + ), + keyProviderClientTlsKey: loadCert( + config.keyProviderClientTlsKey, + config.keyProviderClientTlsKeyPath, + 'Key provider client key', + ), + keyProviderClientTlsCert: loadCert( + config.keyProviderClientTlsCert, + config.keyProviderClientTlsCertPath, + 'Key provider client certificate', + ), + }; // Validate that client certificates are provided for outbound mTLS connections if (config.tlsMode === TlsMode.MTLS) { @@ -321,7 +295,9 @@ function determineProtocol(url: string, tlsMode: TlsMode, isBitGo = false): stri function masterExpressEnvConfig(): Partial { const advancedWalletManagerUrl = readEnvVar('ADVANCED_WALLET_MANAGER_URL'); + const advancedWalletManagerBackupUrl = readEnvVar('ADVANCED_WALLET_MANAGER_BACKUP_URL'); const awmServerCaCertPath = readEnvVar('AWM_SERVER_CA_CERT_PATH'); + const awmBackupServerCaCertPath = readEnvVar('AWM_BACKUP_SERVER_CA_CERT_PATH'); const awmServerCertAllowSelfSigned = readEnvVar('AWM_SERVER_CERT_ALLOW_SELF_SIGNED') === 'true'; const tlsMode = determineTlsMode(); @@ -335,6 +311,12 @@ function masterExpressEnvConfig(): Partial { throw new Error('AWM_SERVER_CA_CERT_PATH environment variable is required for MTLS mode.'); } + if (advancedWalletManagerBackupUrl && tlsMode === TlsMode.MTLS && !awmBackupServerCaCertPath) { + throw new Error( + 'AWM_BACKUP_SERVER_CA_CERT_PATH environment variable is required for MTLS mode when provisioning a backup AWM URL.', + ); + } + // Debug mTLS environment variables const clientCertAllowSelfSignedRaw = readEnvVar('CLIENT_CERT_ALLOW_SELF_SIGNED'); const clientCertAllowSelfSigned = clientCertAllowSelfSignedRaw === 'true'; @@ -354,11 +336,17 @@ function masterExpressEnvConfig(): Partial { disableEnvCheck: readEnvVar('BITGO_DISABLE_ENV_CHECK') === 'true', authVersion: Number(readEnvVar('BITGO_AUTH_VERSION')), advancedWalletManagerUrl: advancedWalletManagerUrl, + advancedWalletManagerBackupUrl: advancedWalletManagerBackupUrl, awmServerCaCertPath: awmServerCaCertPath, awmClientTlsKeyPath: readEnvVar('AWM_CLIENT_TLS_KEY_PATH'), awmClientTlsCertPath: readEnvVar('AWM_CLIENT_TLS_CERT_PATH'), awmClientTlsKey: readEnvVar('AWM_CLIENT_TLS_KEY'), awmClientTlsCert: readEnvVar('AWM_CLIENT_TLS_CERT'), + awmBackupServerCaCertPath: awmBackupServerCaCertPath, + awmBackupClientTlsKeyPath: readEnvVar('AWM_BACKUP_CLIENT_TLS_KEY_PATH'), + awmBackupClientTlsCertPath: readEnvVar('AWM_BACKUP_CLIENT_TLS_CERT_PATH'), + awmBackupClientTlsKey: readEnvVar('AWM_BACKUP_CLIENT_TLS_KEY'), + awmBackupClientTlsCert: readEnvVar('AWM_BACKUP_CLIENT_TLS_CERT'), awmServerCertAllowSelfSigned, customBitcoinNetwork: readEnvVar('BITGO_CUSTOM_BITCOIN_NETWORK'), // mTLS server settings @@ -398,12 +386,20 @@ function mergeMasterExpressConfigs( disableEnvCheck: get('disableEnvCheck'), authVersion: get('authVersion'), advancedWalletManagerUrl: get('advancedWalletManagerUrl'), + advancedWalletManagerBackupUrl: get('advancedWalletManagerBackupUrl'), awmServerCaCertPath: get('awmServerCaCertPath'), awmServerCaCert: get('awmServerCaCert'), awmClientTlsKeyPath: get('awmClientTlsKeyPath'), awmClientTlsCertPath: get('awmClientTlsCertPath'), awmClientTlsKey: get('awmClientTlsKey'), awmClientTlsCert: get('awmClientTlsCert'), + // Backup AWM configs + awmBackupServerCaCertPath: get('awmBackupServerCaCertPath'), + awmBackupServerCaCert: get('awmBackupServerCaCert'), + awmBackupClientTlsKeyPath: get('awmBackupClientTlsKeyPath'), + awmBackupClientTlsCertPath: get('awmBackupClientTlsCertPath'), + awmBackupClientTlsKey: get('awmBackupClientTlsKey'), + awmBackupClientTlsCert: get('awmBackupClientTlsCert'), awmServerCertAllowSelfSigned: get('awmServerCertAllowSelfSigned'), customBitcoinNetwork: get('customBitcoinNetwork'), serverTlsKeyPath: get('serverTlsKeyPath'), @@ -433,6 +429,13 @@ export function configureMasterExpressMode(): MasterExpressConfig { false, ); } + if (config.advancedWalletManagerBackupUrl) { + updates.advancedWalletManagerBackupUrl = determineProtocol( + config.advancedWalletManagerBackupUrl, + config.tlsMode, + false, + ); + } config = { ...config, ...updates }; // Certificate Loading Section @@ -440,79 +443,52 @@ export function configureMasterExpressMode(): MasterExpressConfig { // Only load certificates if TLS is enabled if (config.tlsMode !== TlsMode.DISABLED) { - // Handle file loading for TLS certificates - if (!config.serverTlsKey && config.serverTlsKeyPath) { - try { - config = { ...config, serverTlsKey: fs.readFileSync(config.serverTlsKeyPath, 'utf-8') }; - logger.info(`✓ TLS private key loaded from file: ${config.serverTlsKeyPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read TLS key from serverTlsKeyPath: ${err.message}`); - } - } else if (config.serverTlsKey) { - logger.info('✓ TLS private key loaded from environment variable'); - } - - if (!config.serverTlsCert && config.serverTlsCertPath) { - try { - config = { - ...config, - serverTlsCert: fs.readFileSync(config.serverTlsCertPath, 'utf-8'), - }; - logger.info(`✓ TLS certificate loaded from file: ${config.serverTlsCertPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read TLS certificate from serverTlsCertPath: ${err.message}`); - } - } else if (config.serverTlsCert) { - logger.info('✓ TLS certificate loaded from environment variable'); - } + config = { + ...config, + serverTlsKey: loadCert(config.serverTlsKey, config.serverTlsKeyPath, 'TLS private key'), + serverTlsCert: loadCert(config.serverTlsCert, config.serverTlsCertPath, 'TLS certificate'), + }; // Validate that certificates are properly loaded when TLS is enabled validateTlsCertificates(config); } // Handle cert loading for Advanced Wallet Manager (always required for Master Express) - if (config.awmServerCaCertPath) { - try { - if (fs.existsSync(config.awmServerCaCertPath)) { - config = { - ...config, - awmServerCaCert: fs.readFileSync(config.awmServerCaCertPath, 'utf-8'), - }; - logger.info( - `✓ AWM server CA certificate loaded from file: ${config.awmServerCaCertPath?.substring( - 0, - 50, - )}...`, - ); - } else { - throw new Error(`Certificate file not found: ${config.awmServerCaCertPath}`); - } - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read advanced wallet manager cert: ${err.message}`); - } - } - - if (config.awmClientTlsKeyPath) { - try { - config.awmClientTlsKey = fs.readFileSync(config.awmClientTlsKeyPath, 'utf-8'); - logger.info(`✓ AWM client key loaded from file: ${config.awmClientTlsKeyPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read AWM client key from awmClientTlsKeyPath: ${err.message}`); - } - } + config = { + ...config, + awmServerCaCert: loadCert( + config.awmServerCaCert, + config.awmServerCaCertPath, + 'AWM server CA certificate', + ), + awmClientTlsKey: loadCert(config.awmClientTlsKey, config.awmClientTlsKeyPath, 'AWM client key'), + awmClientTlsCert: loadCert( + config.awmClientTlsCert, + config.awmClientTlsCertPath, + 'AWM client certificate', + ), + }; - if (config.awmClientTlsCertPath) { - try { - config.awmClientTlsCert = fs.readFileSync(config.awmClientTlsCertPath, 'utf-8'); - logger.info(`✓ AWM client certificate loaded from file: ${config.awmClientTlsCertPath}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - throw new Error(`Failed to read AWM client cert from awmClientTlsCertPath: ${err.message}`); - } + // Handle cert loading for backup AWM (only when backup URL is configured) + if (config.advancedWalletManagerBackupUrl) { + config = { + ...config, + awmBackupServerCaCert: loadCert( + config.awmBackupServerCaCert, + config.awmBackupServerCaCertPath, + 'AWM backup server CA certificate', + ), + awmBackupClientTlsKey: loadCert( + config.awmBackupClientTlsKey, + config.awmBackupClientTlsKeyPath, + 'AWM backup client key', + ), + awmBackupClientTlsCert: loadCert( + config.awmBackupClientTlsCert, + config.awmBackupClientTlsCertPath, + 'AWM backup client certificate', + ), + }; } logger.info('=========================='); @@ -524,6 +500,20 @@ export function configureMasterExpressMode(): MasterExpressConfig { 'AWM_CLIENT_TLS_KEY_PATH and AWM_CLIENT_TLS_CERT_PATH (or AWM_CLIENT_TLS_KEY and AWM_CLIENT_TLS_CERT) are required for outbound mTLS connections to Advanced Wallet Manager. Client certificates cannot reuse server certificates for security reasons.', ); } + + // Validate that dedicated backup certificates are provided when a backup AWM URL is configured + if (config.advancedWalletManagerBackupUrl) { + if (!config.awmBackupServerCaCert) { + throw new Error( + 'AWM_BACKUP_SERVER_CA_CERT_PATH is required for mTLS communication with the backup Advanced Wallet Manager.', + ); + } + if (!config.awmBackupClientTlsKey || !config.awmBackupClientTlsCert) { + throw new Error( + 'AWM_BACKUP_CLIENT_TLS_KEY_PATH and AWM_BACKUP_CLIENT_TLS_CERT_PATH (or AWM_BACKUP_CLIENT_TLS_KEY and AWM_BACKUP_CLIENT_TLS_CERT) are required for mTLS communication with the backup Advanced Wallet Manager.', + ); + } + } } // Validate Master Express configuration @@ -540,7 +530,7 @@ export function initConfig(): Config { const appMode = determineAppMode(); if (appMode === AppMode.ADVANCED_WALLET_MANAGER) { - return configureAdvancedWalletManagaerMode(); + return configureAdvancedWalletManagerMode(); } else if (appMode === AppMode.MASTER_EXPRESS) { return configureMasterExpressMode(); } else { diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index e17bc4be..10352446 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -811,7 +811,7 @@ export class AdvancedWalletManagerClient { /** * Create an advanced wallet manager client if the configuration is present */ -export function createawmClient( +export function createAwmClient( cfg: MasterExpressConfig, coin?: string, ): AdvancedWalletManagerClient | undefined { @@ -823,3 +823,50 @@ export function createawmClient( return undefined; } } + +/** + * Create a backup AWM client when a separate backup URL is configured. + * Requires dedicated backup certs when mTLS is enabled — does not fall back to primary certs. + * Returns undefined if no backup URL is configured (same-HSM mode). + */ +export function createAwmBackupClient( + cfg: MasterExpressConfig, + coin?: string, +): AdvancedWalletManagerClient | undefined { + if (!cfg.advancedWalletManagerBackupUrl) { + return undefined; + } + + if (cfg.tlsMode === TlsMode.MTLS) { + if (!cfg.awmBackupServerCaCert) { + throw new Error( + 'awmBackupServerCaCert is required for mTLS communication with the backup AWM. ' + + 'Set AWM_BACKUP_SERVER_CA_CERT_PATH to the backup AWM server CA certificate.', + ); + } + if (!cfg.awmBackupClientTlsKey || !cfg.awmBackupClientTlsCert) { + throw new Error( + 'awmBackupClientTlsKey and awmBackupClientTlsCert are required for mTLS communication with the backup AWM. ' + + 'Set AWM_BACKUP_CLIENT_TLS_KEY_PATH and AWM_BACKUP_CLIENT_TLS_CERT_PATH to the backup AWM client credentials.', + ); + } + } + + try { + const backupConfig: MasterExpressConfig = { + ...cfg, + advancedWalletManagerUrl: cfg.advancedWalletManagerBackupUrl, + awmServerCaCert: cfg.awmBackupServerCaCert, + awmClientTlsKey: cfg.awmBackupClientTlsKey, + awmClientTlsCert: cfg.awmBackupClientTlsCert, + }; + return new AdvancedWalletManagerClient(backupConfig, coin); + } catch (error) { + const err = error as Error; + throw new Error( + `Failed to create backup advanced wallet manager client: ${err.message}. ` + + `Backup AWM URL is configured (${cfg.advancedWalletManagerBackupUrl}) but the client could not be initialized. ` + + `Please verify your backup AWM TLS/mTLS configuration.`, + ); + } +} diff --git a/src/masterBitgoExpress/handlers/ecdsa.ts b/src/masterBitgoExpress/handlers/ecdsa.ts index 0fb81d42..74e10baa 100644 --- a/src/masterBitgoExpress/handlers/ecdsa.ts +++ b/src/masterBitgoExpress/handlers/ecdsa.ts @@ -107,6 +107,7 @@ interface OrchestrateEcdsaKeyGenParams { bitgo: BitGoBase; baseCoin: BaseCoin; awmClient: AdvancedWalletManagerClient; + awmBackupClient: AdvancedWalletManagerClient; enterprise: string; walletParams: SupplementGenerateWalletOptions; } @@ -115,6 +116,7 @@ export async function orchestrateEcdsaKeyGen({ bitgo, baseCoin, awmClient, + awmBackupClient, enterprise, walletParams, }: OrchestrateEcdsaKeyGenParams) { @@ -135,7 +137,7 @@ export async function orchestrateEcdsaKeyGen({ ) { throw new Error('Missing required fields in user init response'); } - const backupInitResponse = await awmClient.initEcdsaMpcV2KeyGenMpcV2({ + const backupInitResponse = await awmBackupClient.initEcdsaMpcV2KeyGenMpcV2({ source: 'backup', }); if ( @@ -155,7 +157,7 @@ export async function orchestrateEcdsaKeyGen({ bitgoGpgPub: constants.mpc.bitgoMPCv2PublicKey, counterPartyGpgPub: backupInitResponse.gpgPub, }); - const backupRound1Promise = awmClient.roundEcdsaMPCv2KeyGen({ + const backupRound1Promise = awmBackupClient.roundEcdsaMPCv2KeyGen({ source: 'backup', encryptedData: backupInitResponse.encryptedData, encryptedDataKey: backupInitResponse.encryptedDataKey, @@ -200,7 +202,7 @@ export async function orchestrateEcdsaKeyGen({ counterParty: backupRound1Response.broadcastMessage, }, }); - const backupRound2Promise = awmClient.roundEcdsaMPCv2KeyGen({ + const backupRound2Promise = awmBackupClient.roundEcdsaMPCv2KeyGen({ source: 'backup', encryptedData: backupRound1Response.encryptedData, encryptedDataKey: backupRound1Response.encryptedDataKey, @@ -232,7 +234,7 @@ export async function orchestrateEcdsaKeyGen({ counterParty: backupRound2Response.p2pMessages?.counterParty, }, }); - const backupRound3Promise = awmClient.roundEcdsaMPCv2KeyGen({ + const backupRound3Promise = awmBackupClient.roundEcdsaMPCv2KeyGen({ source: 'backup', encryptedData: backupRound2Response.encryptedData, encryptedDataKey: backupRound2Response.encryptedDataKey, @@ -281,7 +283,7 @@ export async function orchestrateEcdsaKeyGen({ counterParty: backupRound3Response.p2pMessages?.counterParty, }, }); - const backupRound4Promise = awmClient.roundEcdsaMPCv2KeyGen({ + const backupRound4Promise = awmBackupClient.roundEcdsaMPCv2KeyGen({ source: 'backup', encryptedData: backupRound3Response.encryptedData, encryptedDataKey: backupRound3Response.encryptedDataKey, @@ -328,7 +330,7 @@ export async function orchestrateEcdsaKeyGen({ }, bitgoCommonKeychain, }); - const backupFinalizePromise = awmClient.finalizeEcdsaMPCv2KeyGen({ + const backupFinalizePromise = awmBackupClient.finalizeEcdsaMPCv2KeyGen({ source: 'backup', encryptedData: backupRound4Response.encryptedData, encryptedDataKey: backupRound4Response.encryptedDataKey, diff --git a/src/masterBitgoExpress/handlers/eddsa.ts b/src/masterBitgoExpress/handlers/eddsa.ts index 0e0c4b54..5b91eec0 100644 --- a/src/masterBitgoExpress/handlers/eddsa.ts +++ b/src/masterBitgoExpress/handlers/eddsa.ts @@ -120,6 +120,7 @@ interface OrchestrateEddsaKeyGenParams { bitgo: BitGoBase; baseCoin: BaseCoin; awmClient: AdvancedWalletManagerClient; + awmBackupClient: AdvancedWalletManagerClient; enterprise: string; walletParams: any; } @@ -128,6 +129,7 @@ export async function orchestrateEddsaKeyGen({ bitgo, baseCoin, awmClient, + awmBackupClient, enterprise, walletParams, }: OrchestrateEddsaKeyGenParams) { @@ -140,7 +142,7 @@ export async function orchestrateEddsaKeyGen({ source: 'user', bitgoGpgKey: constants.mpc.bitgoPublicKey, }); - const backupInitResponse = await awmClient.initMpcKeyGeneration({ + const backupInitResponse = await awmBackupClient.initMpcKeyGeneration({ source: 'backup', bitgoGpgKey: constants.mpc.bitgoPublicKey, userGpgKey: userInitResponse.bitgoPayload.gpgKey, @@ -197,7 +199,7 @@ export async function orchestrateEddsaKeyGen({ source: 'user', type: 'tss', }); - const backupKeychainPromise = await awmClient.finalizeMpcKeyGeneration({ + const backupKeychainPromise = await awmBackupClient.finalizeMpcKeyGeneration({ source: 'backup', coin: baseCoin.getFamily(), encryptedDataKey: backupInitResponse.encryptedDataKey, diff --git a/src/masterBitgoExpress/handlers/handleAccelerate.ts b/src/masterBitgoExpress/handlers/handleAccelerate.ts index 2dda8b77..6057236b 100644 --- a/src/masterBitgoExpress/handlers/handleAccelerate.ts +++ b/src/masterBitgoExpress/handlers/handleAccelerate.ts @@ -6,7 +6,7 @@ import { getWalletAndSigningKeychain, makeCustomSigningFunction } from './utils/ export async function handleAccelerate( req: MasterApiSpecRouteRequest<'v1.wallet.accelerate', 'post'>, ) { - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const reqId = new RequestTracer(); const bitgo = req.bitgo; const params = req.decoded; diff --git a/src/masterBitgoExpress/handlers/handleConsolidate.ts b/src/masterBitgoExpress/handlers/handleConsolidate.ts index 872b013b..9eedd5a1 100644 --- a/src/masterBitgoExpress/handlers/handleConsolidate.ts +++ b/src/masterBitgoExpress/handlers/handleConsolidate.ts @@ -12,7 +12,7 @@ import { signAndSendTxRequests } from './transactionRequests'; export async function handleConsolidate( req: MasterApiSpecRouteRequest<'v1.wallet.consolidate', 'post'>, ) { - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const reqId = new RequestTracer(); const bitgo = req.bitgo; const params = req.decoded; diff --git a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts index 484d1908..0842c8e0 100644 --- a/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts +++ b/src/masterBitgoExpress/handlers/handleConsolidateUnspents.ts @@ -6,7 +6,7 @@ import { getWalletAndSigningKeychain, makeCustomSigningFunction } from './utils/ export async function handleConsolidateUnspents( req: MasterApiSpecRouteRequest<'v1.wallet.consolidateunspents', 'post'>, ) { - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const reqId = new RequestTracer(); const bitgo = req.bitgo; const params = req.decoded; diff --git a/src/masterBitgoExpress/handlers/handleGenerateWallet.ts b/src/masterBitgoExpress/handlers/handleGenerateWallet.ts index 8824827b..6c8c3b05 100644 --- a/src/masterBitgoExpress/handlers/handleGenerateWallet.ts +++ b/src/masterBitgoExpress/handlers/handleGenerateWallet.ts @@ -44,7 +44,8 @@ async function handleGenerateOnChainWallet( const baseCoin = await coinFactory.getCoin(req.params.coin, bitgo); // The awmClient is now available from the request - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; + const awmBackupClient = req.awmBackupClient; const reqId = new RequestTracer(); @@ -86,7 +87,7 @@ async function handleGenerateOnChainWallet( }; const backupKeychainPromise = async (): Promise => { - const backupKeychain = await awmClient.createIndependentKeychain({ + const backupKeychain = await awmBackupClient.createIndependentKeychain({ source: 'backup', coin: req.params.coin, type: 'independent', @@ -145,7 +146,8 @@ async function handleGenerateMpcWallet( ) { const bitgo = req.bitgo; const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo); - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; + const awmBackupClient = req.awmBackupClient; if (!baseCoin.supportsTss()) { throw new BadRequestError( @@ -185,6 +187,7 @@ async function handleGenerateMpcWallet( bitgo, baseCoin, awmClient, + awmBackupClient, enterprise, walletParams, }); @@ -194,6 +197,7 @@ async function handleGenerateMpcWallet( bitgo, baseCoin, awmClient, + awmBackupClient, walletParams, enterprise, }); diff --git a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts index 35b88bff..4bd75667 100644 --- a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts +++ b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts @@ -41,7 +41,7 @@ export async function handleRecoveryConsolidations( const bitgo = req.bitgo; const coin = req.decoded.coin; - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const isMPC = req.decoded.multisigType === 'tss'; diff --git a/src/masterBitgoExpress/handlers/handleSendMany.ts b/src/masterBitgoExpress/handlers/handleSendMany.ts index 12462b44..6159c8de 100644 --- a/src/masterBitgoExpress/handlers/handleSendMany.ts +++ b/src/masterBitgoExpress/handlers/handleSendMany.ts @@ -72,7 +72,7 @@ async function createMPCSendParamsWithCustomSigningFns( } export async function handleSendMany(req: MasterApiSpecRouteRequest<'v1.wallet.sendMany', 'post'>) { - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const reqId = new RequestTracer(); const bitgo = req.bitgo; const baseCoin = await coinFactory.getCoin(req.params.coin, bitgo); diff --git a/src/masterBitgoExpress/handlers/handleSignAndSendTxRequest.ts b/src/masterBitgoExpress/handlers/handleSignAndSendTxRequest.ts index 15509c58..3a93fa4a 100644 --- a/src/masterBitgoExpress/handlers/handleSignAndSendTxRequest.ts +++ b/src/masterBitgoExpress/handlers/handleSignAndSendTxRequest.ts @@ -7,7 +7,7 @@ import coinFactory from '../../shared/coinFactory'; export async function handleSignAndSendTxRequest( req: MasterApiSpecRouteRequest<'v1.wallet.txrequest.signAndSend', 'post'>, ) { - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const reqId = new RequestTracer(); const bitgo = req.bitgo; const baseCoin = await coinFactory.getCoin(req.params.coin, bitgo); diff --git a/src/masterBitgoExpress/handlers/recoveryWallet.ts b/src/masterBitgoExpress/handlers/recoveryWallet.ts index 87e59e80..581aa2ea 100644 --- a/src/masterBitgoExpress/handlers/recoveryWallet.ts +++ b/src/masterBitgoExpress/handlers/recoveryWallet.ts @@ -222,7 +222,7 @@ export async function handleRecoveryWallet( const bitgo = req.bitgo; const coin = req.decoded.coin; - const awmClient = req.awmClient; + const awmClient = req.awmUserClient; const { recoveryDestinationAddress, coinSpecificParams } = req.decoded; const sdkCoin = await coinFactory.getCoin(coin, bitgo); @@ -350,7 +350,7 @@ export async function handleRecoveryWallet( } if (isUtxoCoin(sdkCoin)) { - return handleUtxoLikeRecovery(sdkCoin, req.awmClient, { + return handleUtxoLikeRecovery(sdkCoin, req.awmUserClient, { userKey: userPub, backupKey: backupPub, bitgoKey: bitgoPub, diff --git a/src/masterBitgoExpress/middleware/middleware.ts b/src/masterBitgoExpress/middleware/middleware.ts index 7c2aea11..6d298d25 100644 --- a/src/masterBitgoExpress/middleware/middleware.ts +++ b/src/masterBitgoExpress/middleware/middleware.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { isMasterExpressConfig } from '../../shared/types'; -import { createawmClient } from '../clients/advancedWalletManagerClient'; +import { createAwmClient, createAwmBackupClient } from '../clients/advancedWalletManagerClient'; import { BitGoRequest } from '../../types/request'; /** @@ -18,7 +18,7 @@ export function validateMasterExpressConfig(req: Request, res: Response, next: N } // Validate advanced wallet manager client - const awmClient = createawmClient(bitgoReq.config, bitgoReq.params?.coin); + const awmClient = createAwmClient(bitgoReq.config, bitgoReq.params?.coin); if (!awmClient) { return res.status(500).json({ error: 'Please configure advanced wallet manager configs.', @@ -27,6 +27,19 @@ export function validateMasterExpressConfig(req: Request, res: Response, next: N } // Attach the client to the request for use in route handlers - bitgoReq.awmClient = awmClient; + bitgoReq.awmUserClient = awmClient; + + // Create backup client if backup URL is configured; falls back to primary client + try { + const awmBackupClient = createAwmBackupClient(bitgoReq.config, bitgoReq.params?.coin); + bitgoReq.awmBackupClient = awmBackupClient ?? awmClient; + } catch (error) { + const err = error as Error; + return res.status(500).json({ + error: 'Failed to initialize backup advanced wallet manager client.', + details: err.message, + }); + } + next(); } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d961aaf9..d07d20da 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -61,6 +61,7 @@ export interface MasterExpressConfig extends BaseConfig { authVersion?: number; // AWM client settings advancedWalletManagerUrl: string; + advancedWalletManagerBackupUrl?: string; awmServerCaCertPath?: string; awmServerCaCert?: string; awmClientTlsKeyPath?: string; @@ -68,6 +69,13 @@ export interface MasterExpressConfig extends BaseConfig { awmClientTlsKey?: string; awmClientTlsCert?: string; awmServerCertAllowSelfSigned?: boolean; + // AWM backup client settings (separate HSM for backup key) + awmBackupServerCaCertPath?: string; + awmBackupServerCaCert?: string; + awmBackupClientTlsKeyPath?: string; + awmBackupClientTlsCertPath?: string; + awmBackupClientTlsKey?: string; + awmBackupClientTlsCert?: string; customBitcoinNetwork?: string; // mTLS server settings serverTlsKeyPath?: string; diff --git a/src/types/request.ts b/src/types/request.ts index e4d61d5e..de0203ae 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -7,7 +7,8 @@ import { AdvancedWalletManagerClient } from '../masterBitgoExpress/clients/advan export interface BitGoRequest extends express.Request { bitgo: BitGoAPI; config: T; - awmClient: AdvancedWalletManagerClient; + awmUserClient: AdvancedWalletManagerClient; + awmBackupClient: AdvancedWalletManagerClient; } export function isBitGoRequest(req: express.Request): req is BitGoRequest {