Skip to content

Commit 3745bf4

Browse files
authored
Merge pull request #8760 from BitGo/WCN-269
feat(sdk-core): update decrypt calls to use decryptAsync
2 parents 121175f + b5f1ca1 commit 3745bf4

2 files changed

Lines changed: 323 additions & 69 deletions

File tree

modules/bitgo/test/v2/unit/wallets.ts

Lines changed: 261 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,6 +2451,86 @@ describe('V2 Wallets:', function () {
24512451
acceptShareNock.done();
24522452
});
24532453

2454+
describe('v2 envelope support in acceptShare', () => {
2455+
const sandbox = sinon.createSandbox();
2456+
afterEach(() => sandbox.verifyAndRestore());
2457+
2458+
it('should accept share when ECDH sharing keychain encryptedXprv is v2-encrypted', async function () {
2459+
const shareId = 'v2-ecdh-xprv-1';
2460+
const userPassword = 'test_password';
2461+
2462+
// Simulate a v2 envelope for the ECDH root key
2463+
const v2EncryptedXprv = JSON.stringify({ v: 2, iv: 'aabbcc', ct: 'ddeeff', adata: '00' });
2464+
const fakeXprv = 'xprvSomeBase58Value';
2465+
const fakeSecret = 'fakeEcdhSharedSecret';
2466+
const decryptedWalletPrv = 'walletPrivKeyPlaintext';
2467+
2468+
const eckey = makeRandomKey();
2469+
const v2EncryptedPrv = bitgo.encrypt({ password: fakeSecret, input: decryptedWalletPrv });
2470+
2471+
nock(bgUrl)
2472+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
2473+
.reply(200, {
2474+
keychain: {
2475+
path: 'm/0/0/1',
2476+
fromPubKey: eckey.publicKey.toString('hex'),
2477+
toPubKey: eckey.publicKey.toString('hex'),
2478+
encryptedPrv: v2EncryptedPrv,
2479+
pub: eckey.publicKey.toString('hex'),
2480+
},
2481+
});
2482+
nock(bgUrl).post(`/api/v2/tbtc/walletshare/${shareId}`).reply(200, { changed: true, state: 'accepted' });
2483+
2484+
sandbox.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: v2EncryptedXprv });
2485+
const decryptAsyncStub = sandbox.stub(bitgo, 'decryptAsync');
2486+
// First call decrypts the ECDH keychain (v2), second decrypts the shared wallet prv
2487+
decryptAsyncStub.onFirstCall().resolves(fakeXprv);
2488+
decryptAsyncStub.onSecondCall().resolves(decryptedWalletPrv);
2489+
sandbox.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(fakeSecret));
2490+
2491+
const res = await wallets.acceptShare({ walletShareId: shareId, userPassword });
2492+
should.equal(res.changed, true);
2493+
should.equal(res.state, 'accepted');
2494+
// Must have called decryptAsync (not the sync decrypt) for both the ECDH key and the wallet prv
2495+
assert.equal(decryptAsyncStub.callCount, 2);
2496+
});
2497+
2498+
it('should accept share when wallet keychain encryptedPrv is v2-encrypted', async function () {
2499+
const shareId = 'v2-wallet-prv-1';
2500+
const userPassword = 'test_password';
2501+
2502+
const v2EncryptedXprv = JSON.stringify({ v: 2, iv: 'aabbcc', ct: 'ddeeff', adata: '00' });
2503+
const fakeXprv = 'xprvAnotherKey';
2504+
const fakeSecret = 'ecdhSharedSecret2';
2505+
const decryptedWalletPrv = 'walletPrivKey2';
2506+
const v2EncryptedPrv = JSON.stringify({ v: 2, iv: '112233', ct: '445566', adata: '77' });
2507+
const eckey = makeRandomKey();
2508+
2509+
nock(bgUrl)
2510+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
2511+
.reply(200, {
2512+
keychain: {
2513+
path: 'm/0/0/2',
2514+
fromPubKey: eckey.publicKey.toString('hex'),
2515+
toPubKey: eckey.publicKey.toString('hex'),
2516+
encryptedPrv: v2EncryptedPrv,
2517+
pub: eckey.publicKey.toString('hex'),
2518+
},
2519+
});
2520+
nock(bgUrl).post(`/api/v2/tbtc/walletshare/${shareId}`).reply(200, { changed: true, state: 'accepted' });
2521+
2522+
sandbox.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: v2EncryptedXprv });
2523+
const decryptAsyncStub = sandbox.stub(bitgo, 'decryptAsync');
2524+
decryptAsyncStub.onFirstCall().resolves(fakeXprv);
2525+
decryptAsyncStub.onSecondCall().resolves(decryptedWalletPrv);
2526+
sandbox.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(fakeSecret));
2527+
2528+
const res = await wallets.acceptShare({ walletShareId: shareId, userPassword });
2529+
should.equal(res.changed, true);
2530+
should.equal(res.state, 'accepted');
2531+
});
2532+
});
2533+
24542534
describe('bulkAcceptShare', function () {
24552535
afterEach(function () {
24562536
nock.cleanAll();
@@ -2614,7 +2694,7 @@ describe('V2 Wallets:', function () {
26142694
password: walletPassphrase,
26152695
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
26162696
});
2617-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2697+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
26182698
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
26192699

26202700
const share = await wallets.bulkAcceptShare({
@@ -2630,6 +2710,107 @@ describe('V2 Wallets:', function () {
26302710
});
26312711
});
26322712

2713+
it('should accept share when ECDH sharing keychain is v2-encrypted', async () => {
2714+
const walletPassphrase = 'bitgo1234';
2715+
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
2716+
2717+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2718+
const path = 'm/999999/1/1';
2719+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2720+
const eckey = makeRandomKey();
2721+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2722+
const decryptedPrv = 'someWalletPrivateKey';
2723+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: decryptedPrv });
2724+
2725+
// Simulate v2-encrypted ECDH keychain xprv (envelope with v:2 marker)
2726+
const v2EncryptedXprv = JSON.stringify({ v: 2, iv: 'aaa', ct: 'bbb', adata: 'ccc' });
2727+
const myEcdhXprv = 'xprvSomeBase58Key';
2728+
2729+
nock(bgUrl)
2730+
.get('/api/v2/walletshares')
2731+
.reply(200, {
2732+
incoming: [
2733+
{
2734+
id: shareId,
2735+
isUMSInitiated: true,
2736+
keychain: {
2737+
path,
2738+
fromPubKey: eckey.publicKey.toString('hex'),
2739+
encryptedPrv: newEncryptedPrv,
2740+
toPubKey: pubkey,
2741+
pub: pubkey,
2742+
},
2743+
},
2744+
],
2745+
});
2746+
nock(bgUrl)
2747+
.put('/api/v2/walletshares/accept')
2748+
.reply(200, { acceptedWalletShares: [{ walletShareId: shareId }] });
2749+
2750+
sinon.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: v2EncryptedXprv });
2751+
// decryptAsync resolves with the xprv regardless of whether the envelope is v1 or v2
2752+
sinon.stub(bitgo, 'decryptAsync').resolves(myEcdhXprv);
2753+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2754+
2755+
const share = await wallets.bulkAcceptShare({
2756+
walletShareIds: [shareId],
2757+
userLoginPassword: walletPassphrase,
2758+
});
2759+
assert.deepEqual(share, { acceptedWalletShares: [{ walletShareId: shareId }] });
2760+
});
2761+
2762+
it('should accept share when wallet keychain encryptedPrv is v2-encrypted', async () => {
2763+
const walletPassphrase = 'bitgo1234';
2764+
const shareId = 'v2-prv-share-1';
2765+
2766+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2767+
const path = 'm/999999/1/1';
2768+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2769+
const eckey = makeRandomKey();
2770+
2771+
// Simulate v2-encrypted encryptedPrv in the wallet share keychain
2772+
const v2EncryptedPrv = JSON.stringify({ v: 2, iv: 'xxx', ct: 'yyy', adata: 'zzz' });
2773+
const v2EncryptedXprv = JSON.stringify({ v: 2, iv: 'aaa', ct: 'bbb', adata: 'ccc' });
2774+
const myEcdhXprv = 'xprvSomeBase58Key';
2775+
const decryptedWalletPrv = 'decryptedWalletPrivKey';
2776+
2777+
nock(bgUrl)
2778+
.get('/api/v2/walletshares')
2779+
.reply(200, {
2780+
incoming: [
2781+
{
2782+
id: shareId,
2783+
isUMSInitiated: true,
2784+
keychain: {
2785+
path,
2786+
fromPubKey: eckey.publicKey.toString('hex'),
2787+
encryptedPrv: v2EncryptedPrv,
2788+
toPubKey: pubkey,
2789+
pub: pubkey,
2790+
},
2791+
},
2792+
],
2793+
});
2794+
nock(bgUrl)
2795+
.put('/api/v2/walletshares/accept')
2796+
.reply(200, { acceptedWalletShares: [{ walletShareId: shareId }] });
2797+
2798+
sinon.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: v2EncryptedXprv });
2799+
// First call: decrypt ECDH keychain xprv; second call: decrypt v2 wallet share prv
2800+
const decryptAsyncStub = sinon.stub(bitgo, 'decryptAsync');
2801+
decryptAsyncStub.onFirstCall().resolves(myEcdhXprv);
2802+
decryptAsyncStub.onSecondCall().resolves(decryptedWalletPrv);
2803+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2804+
2805+
const share = await wallets.bulkAcceptShare({
2806+
walletShareIds: [shareId],
2807+
userLoginPassword: walletPassphrase,
2808+
});
2809+
assert.deepEqual(share, { acceptedWalletShares: [{ walletShareId: shareId }] });
2810+
// Both decrypt calls must use decryptAsync (not the sync decrypt)
2811+
assert.equal(decryptAsyncStub.callCount, 2);
2812+
});
2813+
26332814
it('should include webauthnInfo in request when provided (ECDH branch)', async () => {
26342815
const fromUserPrv = Math.random();
26352816
const walletPassphrase = 'bitgo1234';
@@ -2687,7 +2868,7 @@ describe('V2 Wallets:', function () {
26872868
password: walletPassphrase,
26882869
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
26892870
});
2690-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2871+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
26912872
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
26922873

26932874
await wallets.bulkAcceptShare({
@@ -2765,7 +2946,7 @@ describe('V2 Wallets:', function () {
27652946
password: walletPassphrase,
27662947
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
27672948
});
2768-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2949+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
27692950
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
27702951

27712952
await wallets.bulkAcceptShare({
@@ -2834,7 +3015,7 @@ describe('V2 Wallets:', function () {
28343015
password: walletPassphrase,
28353016
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
28363017
});
2837-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
3018+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
28383019
sinon.stub(bitgo, 'encrypt').returns(userPrv + 'X'.repeat(100000));
28393020
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
28403021

@@ -2934,7 +3115,7 @@ describe('V2 Wallets:', function () {
29343115
password: walletPassphrase,
29353116
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
29363117
});
2937-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
3118+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
29383119
sinon.stub(bitgo, 'encrypt').returns(userPrv + 'X'.repeat(100000));
29393120
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
29403121

@@ -3036,7 +3217,7 @@ describe('V2 Wallets:', function () {
30363217
password: walletPassphrase,
30373218
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
30383219
});
3039-
sinon.stub(bitgo, 'decrypt').returns(prvKey);
3220+
sinon.stub(bitgo, 'decryptAsync').resolves(prvKey);
30403221
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
30413222

30423223
// Always throw 413 error, even for batch size 1
@@ -3284,7 +3465,7 @@ describe('V2 Wallets:', function () {
32843465
.resolves(userKeychain);
32853466

32863467
// Mock decrypt and signMessage
3287-
sinon.stub(bitgo, 'decrypt').returns('decryptedPrivateKey');
3468+
sinon.stub(bitgo, 'decryptAsync').resolves('decryptedPrivateKey');
32883469
sinon.stub(wallets.baseCoin, 'signMessage').resolves(Buffer.from('signature'));
32893470

32903471
// Mock bulkUpdateWalletShareRequest
@@ -3348,7 +3529,7 @@ describe('V2 Wallets:', function () {
33483529
sinon.stub(ofcWallets.baseCoin.keychains(), 'createUserKeychain').resolves(userKeychain);
33493530

33503531
// Mock decrypt and signMessage
3351-
sinon.stub(bitgo, 'decrypt').returns('decryptedPrivateKey');
3532+
sinon.stub(bitgo, 'decryptAsync').resolves('decryptedPrivateKey');
33523533
sinon.stub(ofcWallets.baseCoin, 'signMessage').resolves(Buffer.from('signature'));
33533534

33543535
// Mock getECDHKeychain
@@ -3491,9 +3672,9 @@ describe('V2 Wallets:', function () {
34913672
});
34923673

34933674
// Setup decrypt and encrypt stubs
3494-
const decryptStub = sinon.stub(bitgo, 'decrypt');
3495-
decryptStub.onFirstCall().returns(myEcdhKeychain.xprv); // For sharing keychain
3496-
decryptStub.onSecondCall().returns(originalPrivKey); // For wallet keychain
3675+
const decryptStub = sinon.stub(bitgo, 'decryptAsync');
3676+
decryptStub.onFirstCall().resolves(myEcdhKeychain.xprv); // For sharing keychain
3677+
decryptStub.onSecondCall().resolves(originalPrivKey); // For wallet keychain
34973678

34983679
const encryptStub = sinon.stub(bitgo, 'encrypt').returns('newEncryptedPrv');
34993680

@@ -3625,6 +3806,75 @@ describe('V2 Wallets:', function () {
36253806
result.walletShareUpdateErrors[0].should.have.property('walletShareId', 'share2');
36263807
result.walletShareUpdateErrors[0].should.have.property('reason', 'Failed to process share2');
36273808
});
3809+
3810+
it('should accept share with v2-encrypted ECDH keychain and wallet prv', async () => {
3811+
const walletPassphrase = 'bitgo1234';
3812+
const path = 'm/999999/1/1';
3813+
3814+
const fromKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef01deadbeef01deadbeef01deadbeef01', 'hex'));
3815+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
3816+
const toPubKey = toKeychain.derivePath(path).publicKey.toString('hex');
3817+
const fromPubKey = fromKeychain.publicKey.toString('hex');
3818+
3819+
const originalPrivKey = 'originalPrivateKey';
3820+
const sharedSecret = getSharedSecret(fromKeychain, Buffer.from(toPubKey, 'hex')).toString('hex');
3821+
const encryptedPrv = bitgo.encrypt({ password: sharedSecret, input: originalPrivKey });
3822+
3823+
// Both the ECDH keychain and share prv are v2-encrypted in this scenario
3824+
const v2EncryptedXprv = JSON.stringify({ v: 2, iv: 'aabbcc', ct: 'ddeeff', adata: '00' });
3825+
3826+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
3827+
incoming: [
3828+
{
3829+
id: 'share1',
3830+
coin: 'tsol',
3831+
walletLabel: 'testing',
3832+
fromUser: 'dummyFromUser',
3833+
toUser: 'dummyToUser',
3834+
wallet: 'wallet1',
3835+
permissions: ['spend'],
3836+
state: 'active',
3837+
keychain: { pub: toPubKey, toPubKey, fromPubKey, encryptedPrv, path },
3838+
},
3839+
],
3840+
outgoing: [],
3841+
});
3842+
3843+
const myEcdhKeychain = await bitgo.keychains().create();
3844+
sinon.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: v2EncryptedXprv });
3845+
3846+
// decryptAsync handles v2 transparently; stub to return expected values
3847+
const decryptAsyncStub = sinon.stub(bitgo, 'decryptAsync');
3848+
decryptAsyncStub.onFirstCall().resolves(myEcdhKeychain.xprv); // ECDH keychain
3849+
decryptAsyncStub.onSecondCall().resolves(originalPrivKey); // wallet share prv
3850+
3851+
const encryptStub = sinon.stub(bitgo, 'encrypt').returns('newEncryptedPrv');
3852+
sinon.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(sharedSecret));
3853+
3854+
const bulkUpdateStub = sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({
3855+
acceptedWalletShares: ['share1'],
3856+
rejectedWalletShares: [],
3857+
walletShareUpdateErrors: [],
3858+
});
3859+
3860+
const result = await wallets.bulkUpdateWalletShare({
3861+
shares: [{ walletShareId: 'share1', status: 'accept' }],
3862+
userLoginPassword: walletPassphrase,
3863+
newWalletPassphrase: 'newPassphrase',
3864+
});
3865+
3866+
assert.deepEqual(result, {
3867+
acceptedWalletShares: ['share1'],
3868+
rejectedWalletShares: [],
3869+
walletShareUpdateErrors: [],
3870+
});
3871+
3872+
// Both decrypt calls must have gone through decryptAsync
3873+
assert.equal(decryptAsyncStub.callCount, 2);
3874+
bulkUpdateStub.calledOnce.should.be.true();
3875+
encryptStub.calledOnce.should.be.true();
3876+
encryptStub.firstCall.args[0].should.have.property('input', originalPrivKey);
3877+
});
36283878
});
36293879
});
36303880

0 commit comments

Comments
 (0)