Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ describe('postMpcV2Key', () => {
clientCertAllowSelfSigned: true,
};

// Restore any existing stub from other test suites before re-stubbing
if (typeof (configModule.initConfig as any).restore === 'function') {
(configModule.initConfig as any).restore();
}
configStub = sinon.stub(configModule, 'initConfig').returns(cfg);

// app setup
Expand Down Expand Up @@ -93,7 +97,7 @@ describe('postMpcV2Key', () => {
});

after(() => {
configStub.restore();
configStub?.restore();
});

it('should be able to create a new MPC V2 wallet', async () => {
Expand Down
66 changes: 63 additions & 3 deletions src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ describe('recoveryMpcV2', async () => {
const cosmosLikeCoin = 'tsei';
const accessToken = 'test-token';

// sinon stubs
// sinon sandbox
const sandbox = sinon.createSandbox();
let configStub: sinon.SinonStub;

// key provider nocks setup
Expand Down Expand Up @@ -67,7 +68,11 @@ describe('recoveryMpcV2', async () => {
recoveryMode: true,
};

configStub = sinon.stub(configModule, 'initConfig').returns(cfg);
// Restore any existing stub from other test suites before re-stubbing
if (typeof (configModule.initConfig as any).restore === 'function') {
(configModule.initConfig as any).restore();
}
configStub = sandbox.stub(configModule, 'initConfig').returns(cfg);

// app setup
app = advancedWalletManagerApp(cfg);
Expand All @@ -79,7 +84,7 @@ describe('recoveryMpcV2', async () => {
});

after(() => {
configStub.restore();
sandbox.restore();
});

// happy path test
Expand Down Expand Up @@ -132,6 +137,61 @@ describe('recoveryMpcV2', async () => {
backupKeyProviderNock.isDone().should.be.true();
});

it('should route backup key retrieval to backup KMS when configured', async () => {
const kmsUrl = 'http://kms.invalid';
const backupKmsUrl = 'http://backup-kms.invalid';

const mockKmsUserResponse = {
prv: JSON.stringify(userKeyShare),
pub: commonKeychain,
source: 'user',
type: 'tss',
};

const mockKmsBackupResponse = {
prv: JSON.stringify(backupKeyShare),
pub: commonKeychain,
source: 'backup',
type: 'tss',
};

// Reconfigure app with backup KMS URL
const dualCfg: AdvancedWalletManagerConfig = {
...cfg,
keyProviderUrl: kmsUrl,
backupKmsUrl,
};
configStub.returns(dualCfg);
const dualApp = advancedWalletManagerApp(dualCfg);
const dualAgent = request.agent(dualApp);

// User key served from primary KMS
const userKmsNock = nock(kmsUrl)
.get(`/key/${input.pub}`)
.query({ source: 'user' })
.reply(200, mockKmsUserResponse)
.persist();

// Backup key served from backup KMS
const backupKmsNock = nock(backupKmsUrl)
.get(`/key/${input.pub}`)
.query({ source: 'backup' })
.reply(200, mockKmsBackupResponse)
.persist();

const response = await dualAgent
.post(`/api/${ethLikeCoin}/mpcv2/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send(input);

response.status.should.equal(200);
response.body.should.have.property('txHex');
response.body.should.have.property('stringifiedSignature');

userKmsNock.isDone().should.be.true();
backupKmsNock.isDone().should.be.true();
});

// failure test case
it('should throw 400 Bad Request if failed to construct eth transaction from message hex', async () => {
const input = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ describe('recoveryMultisigTransaction', () => {
const coin = 'hteth';
const accessToken = 'test-token';

// sinon stubs
// sinon sandbox
const sandbox = sinon.createSandbox();
let configStub: sinon.SinonStub;

before(() => {
Expand All @@ -44,7 +45,11 @@ describe('recoveryMultisigTransaction', () => {
recoveryMode: true,
};

configStub = sinon.stub(configModule, 'initConfig').returns(cfg);
// Restore any existing stub from other test suites before re-stubbing
if (typeof (configModule.initConfig as any).restore === 'function') {
(configModule.initConfig as any).restore();
}
configStub = sandbox.stub(configModule, 'initConfig').returns(cfg);

// app setup
app = advancedWalletManagerApp(cfg);
Expand All @@ -56,7 +61,7 @@ describe('recoveryMultisigTransaction', () => {
});

after(() => {
configStub.restore();
sandbox.restore();
});

it('should generate a successful txHex from unsigned sweep prebuild data', async () => {
Expand Down Expand Up @@ -106,6 +111,67 @@ describe('recoveryMultisigTransaction', () => {
keyProviderNockBackup.done();
});

it('should route backup key retrieval to backup KMS when configured', async () => {
const kmsUrl = 'http://kms.invalid';
const backupKmsUrl = 'http://backup-kms.invalid';
const { userPub, backupPub, walletContractAddress, userPrv, backupPrv, txHexResult } = awmData;
const unsignedSweepPrebuildTx = unsignedSweepRecJSON as unknown as any;

// Reconfigure app with backup KMS URL
const dualCfg: AdvancedWalletManagerConfig = {
...cfg,
keyProviderUrl: kmsUrl,
backupKmsUrl,
};
configStub.returns(dualCfg);
const dualApp = advancedWalletManagerApp(dualCfg);
const dualAgent = request.agent(dualApp);

const mockKmsUserResponse = {
prv: userPrv,
pub: userPub,
source: 'user',
type: 'independent',
};

const mockKmsBackupResponse = {
prv: backupPrv,
pub: backupPub,
source: 'backup',
type: 'independent',
};

// User key from primary KMS
const kmsNockUser = nock(kmsUrl)
.get(`/key/${userPub}`)
.query({ source: 'user' })
.reply(200, mockKmsUserResponse);

// Backup key from backup KMS
const kmsNockBackup = nock(backupKmsUrl)
.get(`/key/${backupPub}`)
.query({ source: 'backup' })
.reply(200, mockKmsBackupResponse);

const response = await dualAgent
.post(`/api/${coin}/multisig/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
userPub,
backupPub,
apiKey: 'etherscan-api-token',
unsignedSweepPrebuildTx,
walletContractAddress,
coinSpecificParams: undefined,
});

response.status.should.equal(200);
response.body.should.have.property('txHex', txHexResult);

kmsNockUser.done();
kmsNockBackup.done();
});

it('should fail when prv keys non related to pub keys', async () => {
const { userPub, backupPub, walletContractAddress } = awmData;
const unsignedSweepPrebuildTx = unsignedSweepRecJSON as unknown as any;
Expand Down
13 changes: 8 additions & 5 deletions src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,14 @@ export async function ecdsaMPCv2Recovery(
);
}

// setup clients and retreive the keys
// TODO: this needs to be segerated if the EBE instance cannot retrieve both keys
const keyProvider = new KeyProviderClient(req.config);
const { prv: userPrv } = await keyProvider.getKey({ pub, source: 'user' });
const { prv: backupPrv } = await keyProvider.getKey({ pub, source: 'backup' });
// setup clients and retrieve the keys
const userKeyProvider = new KeyProviderClient(req.config);
const backupCfg = req.config.backupKmsUrl
? { ...req.config, keyProviderUrl: req.config.backupKmsUrl }
: req.config;
const backupKeyProvider = new KeyProviderClient(backupCfg);
const { prv: userPrv } = await userKeyProvider.getKey({ pub, source: 'user' });
const { prv: backupPrv } = await backupKeyProvider.getKey({ pub, source: 'backup' });

// construct tx builder
const txHash = await getMessageHash(coin, txHex);
Expand Down
4 changes: 3 additions & 1 deletion src/advancedWalletManager/handlers/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export async function retrieveKeyProviderPrvKey({
source: string;
cfg: AdvancedWalletManagerConfig;
}): Promise<string> {
const keyProvider = new KeyProviderClient(cfg);
const effectiveCfg =
source === 'backup' && cfg.backupKmsUrl ? { ...cfg, keyProviderUrl: cfg.backupKmsUrl } : cfg;
const keyProvider = new KeyProviderClient(effectiveCfg);
// Retrieve the private key from key provider
let prv: string;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ const RecoveryMultisigRequest = {
bitgoPub: optional(t.string),
unsignedSweepPrebuildTx: t.any,
walletContractAddress: optional(t.string),
// When set, only sign with the specified key (user half-sign or backup full-sign).
// When omitted, the endpoint signs with both keys (default single-AWM behavior).
keyToSign: optional(t.union([t.literal('user'), t.literal('backup')])),
// Required when keyToSign is 'backup': the half-signed transaction from the user-key phase.
halfSignedTransaction: optional(t.any),
};

// Response type for /multisig/recovery endpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ interface RecoveryMultisigOptions {
| MPCTx
| RecoveryTransaction;
walletContractAddress: string;
// When set, only sign with the specified key (user half-sign or backup full-sign).
keyToSign?: 'user' | 'backup';
// Required when keyToSign is 'backup': the half-signed transaction from the user-key phase.
halfSignedTransaction?: any;
}

interface SignMpcCommitmentParams {
Expand Down
10 changes: 10 additions & 0 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export interface AdvancedWalletManagerConfig extends BaseConfig {
keyProviderClientTlsCert?: string;
keyProviderServerCertAllowSelfSigned?: boolean;

// Backup KMS settings (separate HSM for backup key)
backupKmsUrl?: string;
backupKmsServerCaCertPath?: string;
backupKmsServerCaCert?: string;
backupKmsClientTlsKeyPath?: string;
backupKmsClientTlsCertPath?: string;
backupKmsClientTlsKey?: string;
backupKmsClientTlsCert?: string;
backupKmsServerCertAllowSelfSigned?: boolean;

// mTLS server settings
serverTlsKeyPath?: string;
serverTlsCertPath?: string;
Expand Down
Loading