Skip to content

Commit f94534e

Browse files
feat(awm): backup keyserver configs
Ticket: WCN-362
1 parent d34523c commit f94534e

9 files changed

Lines changed: 445 additions & 2 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import 'should';
2+
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
3+
import {
4+
createawmClient,
5+
createAwmBackupClient,
6+
} from '../../../masterBitgoExpress/clients/advancedWalletManagerClient';
7+
8+
describe('AWM Backup Client', () => {
9+
const baseConfig: MasterExpressConfig = {
10+
appMode: AppMode.MASTER_EXPRESS,
11+
port: 3081,
12+
bind: 'localhost',
13+
timeout: 60000,
14+
httpLoggerFile: '',
15+
env: 'test',
16+
disableEnvCheck: true,
17+
authVersion: 2,
18+
advancedWalletManagerUrl: 'http://primary-awm.invalid',
19+
awmServerCaCert: 'dummy-cert',
20+
tlsMode: TlsMode.DISABLED,
21+
clientCertAllowSelfSigned: true,
22+
};
23+
24+
describe('createAwmBackupClient', () => {
25+
it('should return undefined when no backup URL is configured', () => {
26+
const result = createAwmBackupClient(baseConfig, 'tbtc');
27+
(result === undefined).should.be.true();
28+
});
29+
30+
it('should create a client when backup URL is configured', () => {
31+
const config: MasterExpressConfig = {
32+
...baseConfig,
33+
advancedWalletManagerBackupUrl: 'http://backup-awm.invalid',
34+
};
35+
const result = createAwmBackupClient(config, 'tbtc');
36+
(result !== undefined).should.be.true();
37+
});
38+
39+
it('should create a client pointing to the backup URL, not the primary', () => {
40+
const config: MasterExpressConfig = {
41+
...baseConfig,
42+
advancedWalletManagerBackupUrl: 'http://backup-awm.invalid',
43+
};
44+
const backupClient = createAwmBackupClient(config, 'tbtc');
45+
const primaryClient = createawmClient(config, 'tbtc');
46+
47+
// Both clients should exist
48+
(backupClient !== undefined).should.be.true();
49+
(primaryClient !== undefined).should.be.true();
50+
51+
// They should be different instances
52+
(backupClient !== primaryClient).should.be.true();
53+
});
54+
55+
it('should fall back to primary certs when backup-specific certs are not set', () => {
56+
const config: MasterExpressConfig = {
57+
...baseConfig,
58+
tlsMode: TlsMode.MTLS,
59+
advancedWalletManagerBackupUrl: 'https://backup-awm.invalid',
60+
awmServerCaCert: 'primary-ca-cert',
61+
awmClientTlsKey: 'primary-client-key',
62+
awmClientTlsCert: 'primary-client-cert',
63+
// No backup-specific certs set — should fall back to primary
64+
};
65+
const result = createAwmBackupClient(config, 'tbtc');
66+
// Should succeed using the primary certs as fallback
67+
(result !== undefined).should.be.true();
68+
});
69+
70+
it('should use backup-specific certs when provided', () => {
71+
const config: MasterExpressConfig = {
72+
...baseConfig,
73+
tlsMode: TlsMode.MTLS,
74+
advancedWalletManagerBackupUrl: 'https://backup-awm.invalid',
75+
awmServerCaCert: 'primary-ca-cert',
76+
awmClientTlsKey: 'primary-client-key',
77+
awmClientTlsCert: 'primary-client-cert',
78+
awmBackupServerCaCert: 'backup-ca-cert',
79+
awmBackupClientTlsKey: 'backup-client-key',
80+
awmBackupClientTlsCert: 'backup-client-cert',
81+
};
82+
const result = createAwmBackupClient(config, 'tbtc');
83+
(result !== undefined).should.be.true();
84+
});
85+
86+
it('should return undefined when backup URL is set but mTLS certs are missing', () => {
87+
const config: MasterExpressConfig = {
88+
...baseConfig,
89+
tlsMode: TlsMode.MTLS,
90+
advancedWalletManagerBackupUrl: 'https://backup-awm.invalid',
91+
// No certs at all — constructor should throw, factory should catch and return undefined
92+
awmServerCaCert: undefined as any,
93+
awmClientTlsKey: undefined,
94+
awmClientTlsCert: undefined,
95+
};
96+
const result = createAwmBackupClient(config, 'tbtc');
97+
(result === undefined).should.be.true();
98+
});
99+
});
100+
101+
describe('fallback behavior in middleware', () => {
102+
it('should use primary client for both user and backup when no backup URL is set', () => {
103+
const primaryClient = createawmClient(baseConfig, 'tbtc');
104+
const backupClient = createAwmBackupClient(baseConfig, 'tbtc');
105+
106+
(primaryClient !== undefined).should.be.true();
107+
// No backup URL → backup client is undefined → middleware falls back to primary
108+
(backupClient === undefined).should.be.true();
109+
110+
// Middleware would do: awmBackupClient = backupClient ?? primaryClient
111+
const effectiveBackupClient = backupClient ?? primaryClient;
112+
(effectiveBackupClient === primaryClient).should.be.true();
113+
});
114+
115+
it('should use separate client for backup when backup URL is set', () => {
116+
const config: MasterExpressConfig = {
117+
...baseConfig,
118+
advancedWalletManagerBackupUrl: 'http://backup-awm.invalid',
119+
};
120+
const primaryClient = createawmClient(config, 'tbtc');
121+
const backupClient = createAwmBackupClient(config, 'tbtc');
122+
123+
(primaryClient !== undefined).should.be.true();
124+
(backupClient !== undefined).should.be.true();
125+
126+
// Middleware would do: awmBackupClient = backupClient ?? primaryClient
127+
const effectiveBackupClient = backupClient ?? primaryClient;
128+
(effectiveBackupClient === backupClient).should.be.true();
129+
(effectiveBackupClient !== primaryClient).should.be.true();
130+
});
131+
});
132+
});

src/__tests__/api/master/generateWallet.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,128 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => {
112112
sinon.restore();
113113
});
114114

115+
it('should generate an onchain wallet with separate backup AWM (separate-HSM mode)', async () => {
116+
const backupAwmUrl = 'http://backup-awm.invalid';
117+
118+
// Override middleware to inject a separate backup client
119+
sinon.restore();
120+
const backupBitgo = new BitGoAPI({ env: 'test' });
121+
const configWithBackup: MasterExpressConfig = {
122+
appMode: AppMode.MASTER_EXPRESS,
123+
port: 0,
124+
bind: 'localhost',
125+
timeout: 60000,
126+
httpLoggerFile: '',
127+
env: 'test',
128+
disableEnvCheck: true,
129+
authVersion: 2,
130+
advancedWalletManagerUrl: advancedWalletManagerUrl,
131+
advancedWalletManagerBackupUrl: backupAwmUrl,
132+
awmServerCaCert: 'dummy-cert',
133+
tlsMode: TlsMode.DISABLED,
134+
clientCertAllowSelfSigned: true,
135+
};
136+
137+
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
138+
(req as BitGoRequest<MasterExpressConfig>).bitgo = backupBitgo;
139+
(req as BitGoRequest<MasterExpressConfig>).config = configWithBackup;
140+
next();
141+
});
142+
143+
const app = expressApp(configWithBackup);
144+
const backupAgent = request.agent(app);
145+
146+
// User keychain goes to primary AWM
147+
const userKeychainNock = nock(advancedWalletManagerUrl)
148+
.post(`/api/${coin}/key/independent`, {
149+
source: 'user',
150+
})
151+
.reply(200, {
152+
pub: 'xpub_user',
153+
source: 'user',
154+
type: 'independent',
155+
});
156+
157+
// Backup keychain goes to backup AWM
158+
const backupKeychainNock = nock(backupAwmUrl)
159+
.post(`/api/${coin}/key/independent`, {
160+
source: 'backup',
161+
})
162+
.reply(200, {
163+
pub: 'xpub_backup',
164+
source: 'backup',
165+
type: 'independent',
166+
});
167+
168+
const bitgoAddUserKeyNock = nock(bitgoApiUrl)
169+
.post(`/api/v2/${coin}/key`, {
170+
pub: 'xpub_user',
171+
keyType: 'independent',
172+
source: 'user',
173+
})
174+
.matchHeader('any', () => true)
175+
.reply(200, { id: 'user-key-id', pub: 'xpub_user' });
176+
177+
const bitgoAddBackupKeyNock = nock(bitgoApiUrl)
178+
.post(`/api/v2/${coin}/key`, {
179+
pub: 'xpub_backup',
180+
keyType: 'independent',
181+
source: 'backup',
182+
})
183+
.matchHeader('any', () => true)
184+
.reply(200, { id: 'backup-key-id', pub: 'xpub_backup' });
185+
186+
const bitgoAddBitGoKeyNock = nock(bitgoApiUrl)
187+
.post(`/api/v2/${coin}/key`, {
188+
source: 'bitgo',
189+
keyType: 'independent',
190+
enterprise: 'test_enterprise',
191+
})
192+
.reply(200, {
193+
id: 'bitgo-key-id',
194+
pub: 'xpub_bitgo',
195+
source: 'bitgo',
196+
type: 'independent',
197+
isBitGo: true,
198+
isTrust: false,
199+
hsmType: 'institutional',
200+
});
201+
202+
const bitgoAddWalletNock = nock(bitgoApiUrl)
203+
.post(`/api/v2/${coin}/wallet/add`)
204+
.matchHeader('any', () => true)
205+
.reply(
206+
200,
207+
mockWalletResponse('new-wallet-id', coin, {
208+
isCold: true,
209+
pendingApprovals: [],
210+
multisigType: 'onchain',
211+
type: 'advanced',
212+
}),
213+
);
214+
215+
const response = await backupAgent
216+
.post(`/api/v1/${coin}/advancedwallet/generate`)
217+
.set('Authorization', `Bearer ${accessToken}`)
218+
.send({
219+
label: 'test_wallet',
220+
enterprise: 'test_enterprise',
221+
multisigType: 'onchain',
222+
});
223+
224+
response.status.should.equal(200);
225+
response.body.should.have.property('wallet');
226+
227+
// Verify user keychain went to primary AWM
228+
userKeychainNock.done();
229+
// Verify backup keychain went to backup AWM (separate HSM)
230+
backupKeychainNock.done();
231+
bitgoAddUserKeyNock.done();
232+
bitgoAddBackupKeyNock.done();
233+
bitgoAddBitGoKeyNock.done();
234+
bitgoAddWalletNock.done();
235+
});
236+
115237
it('should generate a wallet by calling the advanced wallet manager service', async () => {
116238
const userKeychainNock = nock(advancedWalletManagerUrl)
117239
.post(`/api/${coin}/key/independent`, {

src/__tests__/config.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ describe('Configuration', () => {
5353
delete process.env.AWM_CLIENT_TLS_CERT_PATH;
5454
delete process.env.KMS_SERVER_CA_CERT_PATH;
5555
delete process.env.RECOVERY_MODE;
56+
delete process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL;
57+
delete process.env.AWM_BACKUP_SERVER_CA_CERT_PATH;
58+
delete process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH;
59+
delete process.env.AWM_BACKUP_CLIENT_TLS_CERT_PATH;
60+
delete process.env.AWM_BACKUP_CLIENT_TLS_KEY;
61+
delete process.env.AWM_BACKUP_CLIENT_TLS_CERT;
5662
});
5763

5864
after(() => {
@@ -432,5 +438,70 @@ describe('Configuration', () => {
432438
cfg.httpLoggerFile.should.equal('/tmp/test-http-access.log');
433439
}
434440
});
441+
442+
it('should not set backup URL when ADVANCED_WALLET_MANAGER_BACKUP_URL is not set', () => {
443+
const cfg = initConfig();
444+
isMasterExpressConfig(cfg).should.be.true();
445+
if (isMasterExpressConfig(cfg)) {
446+
(cfg.advancedWalletManagerBackupUrl === undefined).should.be.true();
447+
}
448+
});
449+
450+
it('should read and protocol-process backup URL when configured', () => {
451+
process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080';
452+
process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve(
453+
__dirname,
454+
'mocks/certs/advanced-wallet-manager-cert.pem',
455+
);
456+
const cfg = initConfig();
457+
isMasterExpressConfig(cfg).should.be.true();
458+
if (isMasterExpressConfig(cfg)) {
459+
cfg.advancedWalletManagerBackupUrl!.should.equal('https://backup-awm.example.com:3080');
460+
}
461+
});
462+
463+
it('should use http protocol for backup URL when TLS is disabled', () => {
464+
process.env.TLS_MODE = 'disabled';
465+
delete process.env.AWM_SERVER_CA_CERT_PATH;
466+
delete process.env.SERVER_TLS_KEY_PATH;
467+
delete process.env.SERVER_TLS_CERT_PATH;
468+
process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080';
469+
const cfg = initConfig();
470+
isMasterExpressConfig(cfg).should.be.true();
471+
if (isMasterExpressConfig(cfg)) {
472+
cfg.advancedWalletManagerBackupUrl!.should.equal('http://backup-awm.example.com:3080');
473+
}
474+
});
475+
476+
it('should throw error when backup URL is set without AWM_BACKUP_SERVER_CA_CERT_PATH in MTLS mode', () => {
477+
process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'https://backup-awm.example.com:3080';
478+
(() => initConfig()).should.throw(
479+
'AWM_BACKUP_SERVER_CA_CERT_PATH environment variable is required for MTLS mode when provisioning a backup AWM URL.',
480+
);
481+
});
482+
483+
it('should load backup server CA cert from file', () => {
484+
process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL = 'backup-awm.example.com:3080';
485+
process.env.AWM_BACKUP_SERVER_CA_CERT_PATH = path.resolve(
486+
__dirname,
487+
'mocks/certs/advanced-wallet-manager-cert.pem',
488+
);
489+
const cfg = initConfig();
490+
isMasterExpressConfig(cfg).should.be.true();
491+
if (isMasterExpressConfig(cfg)) {
492+
cfg.awmBackupServerCaCert!.should.be.a.String();
493+
cfg.awmBackupServerCaCert!.length.should.be.greaterThan(0);
494+
}
495+
});
496+
497+
it('should not require backup cert paths when backup URL is not set in MTLS mode', () => {
498+
// This verifies backward compatibility — no backup URL means no backup cert validation
499+
const cfg = initConfig();
500+
isMasterExpressConfig(cfg).should.be.true();
501+
if (isMasterExpressConfig(cfg)) {
502+
(cfg.advancedWalletManagerBackupUrl === undefined).should.be.true();
503+
(cfg.awmBackupServerCaCert === undefined).should.be.true();
504+
}
505+
});
435506
});
436507
});

0 commit comments

Comments
 (0)