diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index c9e2c3999f..660f240c7d 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -22,6 +22,7 @@ import { decryptKeychainPrivateKey, makeRandomKey, getSharedSecret, + BulkUpdateWalletShareOptionsRequest, BulkWalletShareOptions, AcceptShareOptionsRequest, KeychainWithEncryptedPrv, @@ -3274,6 +3275,197 @@ describe('V2 Wallets:', function () { }) .should.be.rejectedWith('Request Entity Too Large'); }); + + describe('EncryptionSession (HKDF)', function () { + async function stubForShares(shareCount: number, decryptedWalletPrv: string, walletPassphrase: string) { + const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex')); + const path = 'm/999999/1/1'; + const pubkey = toKeychain.derivePath(path).publicKey.toString('hex'); + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex'); + const senderEncryptedPrv = await bitgo.encrypt({ password: secret, input: decryptedWalletPrv }); + + const shareIds = Array.from({ length: shareCount }, (_, i) => `hkdf-share-${i}`); + const shares = shareIds.map((id, index) => ({ + id, + coin: 'tsol', + walletLabel: `w${index}`, + fromUser: 'from', + toUser: 'to', + wallet: `w${index}`, + enterprise: `ent-${index}`, + permissions: ['spend'], + state: 'active' as const, + keychain: { + path, + fromPubKey: eckey.publicKey.toString('hex'), + encryptedPrv: senderEncryptedPrv, + toPubKey: pubkey, + pub: pubkey, + }, + })); + + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ incoming: shares, outgoing: [] }); + + const ecdh = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: await bitgo.encrypt({ input: ecdh.xprv, password: walletPassphrase }), + }); + + const decryptStub = sinon.stub(bitgo, 'decrypt'); + decryptStub.onFirstCall().resolves(ecdh.xprv); + decryptStub.resolves(decryptedWalletPrv); + sinon.stub(moduleBitgo, 'getSharedSecret').resolves(secret); + + return { shareIds }; + } + + it('opens one session per passphrase and destroys them after the loop', async function () { + const walletPassphrase = 'strong-outer-passphrase'; + const decryptedWalletPrv = 'plaintext-wallet-prv'; + const { shareIds } = await stubForShares(3, decryptedWalletPrv, walletPassphrase); + + const realSession = await bitgo.createEncryptionSession(walletPassphrase); + const destroySpy = sinon.spy(realSession, 'destroy'); + const sessionStub = sinon.stub(bitgo, 'createEncryptionSession').resolves(realSession); + + nock(bgUrl) + .put('/api/v2/walletshares/accept') + .reply(200, { + acceptedWalletShares: shareIds.map((id) => ({ walletShareId: id })), + }); + + await wallets.bulkAcceptShare({ walletShareIds: shareIds, userLoginPassword: walletPassphrase }); + + sessionStub.callCount.should.equal(1); + sessionStub.firstCall.args[0].should.equal(walletPassphrase); + destroySpy.called.should.equal(true); + }); + + it('produces standalone-decryptable v2 envelopes with unique per-envelope hkdfSalts', async function () { + const walletPassphrase = 'session-passphrase-42'; + const decryptedWalletPrv = 'secret-wallet-material'; + const { shareIds } = await stubForShares(4, decryptedWalletPrv, walletPassphrase); + + let captured: any; + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + captured = body; + return true; + }) + .reply(200, { + acceptedWalletShares: shareIds.map((id) => ({ walletShareId: id })), + }); + + await wallets.bulkAcceptShare({ walletShareIds: shareIds, userLoginPassword: walletPassphrase }); + + const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + entries.should.have.length(4); + const envelopes = entries.map((e) => JSON.parse(e.encryptedPrv as string)); + + for (const env of envelopes) { + env.should.have.property('v', 2); + env.should.have.property('hkdfSalt'); + env.should.have.property('salt'); + } + // Same session ⇒ same Argon2 salt across envelopes + const argonSalts = new Set(envelopes.map((e) => e.salt)); + argonSalts.size.should.equal(1); + // Unique HKDF salt per envelope ⇒ independent AES keys + const hkdfSalts = new Set(envelopes.map((e) => e.hkdfSalt)); + hkdfSalts.size.should.equal(4); + // Cross-wallet isolation: every envelope decrypts back under the same passphrase + for (const env of envelopes) { + const roundTrip = await bitgo.decrypt({ password: walletPassphrase, input: JSON.stringify(env) }); + roundTrip.should.equal(decryptedWalletPrv); + } + }); + + it('opens a second, independent session for webauthnInfo.passphrase and binds adata per share', async function () { + const walletPassphrase = 'outer-pw'; + const webauthnPassphrase = 'webauthn-prf-pw'; + const decryptedWalletPrv = 'wallet-prv-webauthn-case'; + const { shareIds } = await stubForShares(2, decryptedWalletPrv, walletPassphrase); + + const sessionSpy = sinon.spy(bitgo, 'createEncryptionSession'); + let captured: any; + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + captured = body; + return true; + }) + .reply(200, { acceptedWalletShares: shareIds.map((id) => ({ walletShareId: id })) }); + + await wallets.bulkAcceptShare({ + walletShareIds: shareIds, + userLoginPassword: walletPassphrase, + webauthnInfo: { otpDeviceId: 'dev', prfSalt: 'salt', passphrase: webauthnPassphrase }, + }); + + sessionSpy.callCount.should.equal(2); + const passwords = sessionSpy.getCalls().map((c) => c.args[0]); + passwords.should.containEql(walletPassphrase); + passwords.should.containEql(webauthnPassphrase); + + const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + for (let i = 0; i < entries.length; i++) { + const webEnv = JSON.parse(entries[i].webauthnInfo!.encryptedPrv as string); + webEnv.should.have.property('adata', `ent-${i}`); + const rt = await bitgo.decrypt({ password: webauthnPassphrase, input: JSON.stringify(webEnv) }); + rt.should.equal(decryptedWalletPrv); + } + }); + + it('destroys the session even when the accept API call fails', async function () { + const walletPassphrase = 'pw-error'; + const decryptedWalletPrv = 'x'; + const { shareIds } = await stubForShares(2, decryptedWalletPrv, walletPassphrase); + + const realSession = await bitgo.createEncryptionSession(walletPassphrase); + const destroySpy = sinon.spy(realSession, 'destroy'); + sinon.stub(bitgo, 'createEncryptionSession').resolves(realSession); + + nock(bgUrl).put('/api/v2/walletshares/accept').reply(500, { error: 'boom' }); + + await wallets + .bulkAcceptShare({ walletShareIds: shareIds, userLoginPassword: walletPassphrase }) + .should.be.rejected(); + + destroySpy.called.should.equal(true); + }); + + it('threads encryptionVersion=1 into the factory so v1 envelopes are produced', async function () { + const walletPassphrase = 'v1-caller'; + const decryptedWalletPrv = 'legacy'; + const { shareIds } = await stubForShares(2, decryptedWalletPrv, walletPassphrase); + const sessionSpy = sinon.spy(bitgo, 'createEncryptionSession'); + + let captured: any; + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + captured = body; + return true; + }) + .reply(200, { acceptedWalletShares: shareIds.map((id) => ({ walletShareId: id })) }); + + await wallets.bulkAcceptShare({ + walletShareIds: shareIds, + userLoginPassword: walletPassphrase, + encryptionVersion: 1, + }); + + // Factory is still called uniformly, but with encryptionVersion=1 + sessionSpy.callCount.should.equal(1); + sessionSpy.firstCall.args[1]!.should.equal(1); + + const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + const envelopes = entries.map((e) => JSON.parse(e.encryptedPrv as string)); + for (const env of envelopes) { + env.should.not.have.property('hkdfSalt'); + env.should.have.property('iter'); // v1 SJCL + } + }); + }); }); describe('bulkUpdateWalletShare', function () { @@ -3709,13 +3901,11 @@ describe('V2 Wallets:', function () { encryptedXprv: await bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), }); - // Setup decrypt and encrypt stubs + // Setup decrypt stubs const decryptStub = sinon.stub(bitgo, 'decrypt'); decryptStub.onFirstCall().resolves(myEcdhKeychain.xprv); // For sharing keychain decryptStub.onSecondCall().resolves(originalPrivKey); // For wallet keychain - const encryptStub = sinon.stub(bitgo, 'encrypt').resolves('newEncryptedPrv'); - // Mock getSharedSecret sinon.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(sharedSecret)); @@ -3743,20 +3933,17 @@ describe('V2 Wallets:', function () { bulkUpdateStub.calledOnce.should.be.true(); const updateParams = bulkUpdateStub.firstCall.args[0]; updateParams.should.have.lengthOf(1); - - // Param should be for share1 with accept status and encryptedPrv - updateParams.should.containDeep([ - { - walletShareId: 'share1', - status: 'accept', - encryptedPrv: 'newEncryptedPrv', - }, - ]); - - // Verify encrypt was called with correct parameters - encryptStub.calledOnce.should.be.true(); - encryptStub.firstCall.args[0].should.have.property('password', 'newPassphrase'); - encryptStub.firstCall.args[0].should.have.property('input', originalPrivKey); + updateParams[0].should.have.property('walletShareId', 'share1'); + updateParams[0].should.have.property('status', 'accept'); + // encryptedPrv is now emitted by the session; verify it's a real v2 envelope that + // decrypts back to the original prv under newPassphrase. + const envelope = JSON.parse(updateParams[0].encryptedPrv as string); + envelope.should.have.property('v', 2); + envelope.should.have.property('hkdfSalt'); + // Session encrypt bypasses bitgo.decrypt's stub -- restore then decrypt the envelope + decryptStub.restore(); + const rt = await bitgo.decrypt({ password: 'newPassphrase', input: updateParams[0].encryptedPrv as string }); + rt.should.equal(originalPrivKey); }); it('should handle rejected promises and add them to walletShareUpdateErrors', async () => { @@ -3886,7 +4073,6 @@ describe('V2 Wallets:', function () { decryptAsyncStub.onFirstCall().resolves(myEcdhKeychain.xprv); // ECDH keychain decryptAsyncStub.onSecondCall().resolves(originalPrivKey); // wallet share prv - const encryptStub = sinon.stub(bitgo, 'encrypt').resolves('newEncryptedPrv'); sinon.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(sharedSecret)); const bulkUpdateStub = sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({ @@ -3910,8 +4096,349 @@ describe('V2 Wallets:', function () { // Both decrypt calls must have gone through decrypt assert.equal(decryptAsyncStub.callCount, 2); bulkUpdateStub.calledOnce.should.be.true(); - encryptStub.calledOnce.should.be.true(); - encryptStub.firstCall.args[0].should.have.property('input', originalPrivKey); + // Encryption now goes through the session; assert a real v2 envelope round-trips + const updateParams = bulkUpdateStub.firstCall.args[0]; + const envelope = JSON.parse(updateParams[0].encryptedPrv as string); + envelope.should.have.property('v', 2); + envelope.should.have.property('hkdfSalt'); + decryptAsyncStub.restore(); + const rt = await bitgo.decrypt({ password: 'newPassphrase', input: updateParams[0].encryptedPrv as string }); + rt.should.equal(originalPrivKey); + }); + + describe('EncryptionSession (HKDF)', function () { + // Build a set of accept-status shares with realistic ECDH-encrypted keychains so + // bulkUpdateWalletShare walks the standard re-encrypt path for every share. + async function stubForAcceptShares(shareCount: number, decryptedWalletPrv: string, walletPassphrase: string) { + const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef03deadbeef03deadbeef03deadbeef03', 'hex')); + const path = 'm/999999/1/1'; + const pubkey = toKeychain.derivePath(path).publicKey.toString('hex'); + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex'); + const senderEncryptedPrv = await bitgo.encrypt({ password: secret, input: decryptedWalletPrv }); + + const shareIds = Array.from({ length: shareCount }, (_, i) => `bulk-update-share-${i}`); + const shares = shareIds.map((id, index) => ({ + id, + coin: 'tsol', + walletLabel: `w${index}`, + fromUser: 'from', + toUser: 'to', + wallet: `w${index}`, + permissions: ['spend'], + state: 'active' as const, + keychain: { + path, + fromPubKey: eckey.publicKey.toString('hex'), + encryptedPrv: senderEncryptedPrv, + toPubKey: pubkey, + pub: pubkey, + }, + })); + + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ incoming: shares, outgoing: [] }); + + const ecdh = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: await bitgo.encrypt({ input: ecdh.xprv, password: walletPassphrase }), + }); + + const decryptStub = sinon.stub(bitgo, 'decrypt'); + decryptStub.onFirstCall().resolves(ecdh.xprv); + decryptStub.resolves(decryptedWalletPrv); + sinon.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(secret)); + + return { shareIds }; + } + + it('opens one session over the outer passphrase and destroys it after the loop', async function () { + const walletPassphrase = 'update-passphrase'; + const { shareIds } = await stubForAcceptShares(3, 'plaintext-prv', walletPassphrase); + + const realSession = await bitgo.createEncryptionSession(walletPassphrase); + const destroySpy = sinon.spy(realSession, 'destroy'); + const sessionStub = sinon.stub(bitgo, 'createEncryptionSession').resolves(realSession); + + sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({ + acceptedWalletShares: shareIds, + rejectedWalletShares: [], + walletShareUpdateErrors: [], + }); + + await wallets.bulkUpdateWalletShare({ + shares: shareIds.map((id) => ({ walletShareId: id, status: 'accept' as const })), + userLoginPassword: walletPassphrase, + }); + + sessionStub.callCount.should.equal(1); + sessionStub.firstCall.args[0].should.equal(walletPassphrase); + destroySpy.called.should.equal(true); + }); + + it('threads the session into processAcceptShare so envelopes carry the same argon2 salt', async function () { + const walletPassphrase = 'shared-argon2-salt-test'; + const { shareIds } = await stubForAcceptShares(4, 'plaintext-prv', walletPassphrase); + + let capturedShares: BulkUpdateWalletShareOptionsRequest[] = []; + sinon + .stub(Wallets.prototype, 'bulkUpdateWalletShareRequest') + .callsFake(async (shares: BulkUpdateWalletShareOptionsRequest[]) => { + capturedShares = shares; + return { acceptedWalletShares: shareIds, rejectedWalletShares: [], walletShareUpdateErrors: [] }; + }); + + await wallets.bulkUpdateWalletShare({ + shares: shareIds.map((id) => ({ walletShareId: id, status: 'accept' as const })), + userLoginPassword: walletPassphrase, + }); + + capturedShares.should.have.length(4); + const envelopes = capturedShares.map((s) => JSON.parse(s.encryptedPrv as string)); + for (const env of envelopes) { + env.should.have.property('v', 2); + env.should.have.property('hkdfSalt'); + } + const argonSalts = new Set(envelopes.map((e) => e.salt)); + argonSalts.size.should.equal(1); // one session -> one argon2 salt + const hkdfSalts = new Set(envelopes.map((e) => e.hkdfSalt)); + hkdfSalts.size.should.equal(4); // per-envelope key isolation + // Cross-envelope round-trip: each envelope decrypts under the same passphrase + for (const env of envelopes) { + const rt = await bitgo.decrypt({ password: walletPassphrase, input: JSON.stringify(env) }); + rt.should.equal('plaintext-prv'); + } + }); + + it('does not open a session when the bulk contains only rejects', async function () { + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: 'reject-1', + coin: 'tsol', + walletLabel: 'x', + fromUser: 'a', + toUser: 'b', + wallet: 'w', + permissions: ['view'], + state: 'active', + }, + ], + outgoing: [], + }); + + const sessionStub = sinon.stub(bitgo, 'createEncryptionSession'); + sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({ + acceptedWalletShares: [], + rejectedWalletShares: ['reject-1'], + walletShareUpdateErrors: [], + }); + + await wallets.bulkUpdateWalletShare({ + shares: [{ walletShareId: 'reject-1', status: 'reject' }], + userLoginPassword: 'irrelevant', + }); + + sessionStub.callCount.should.equal(0); + }); + + it('threads the session through createUserKeychain in the special override path', async function () { + const walletPassphrase = 'override-pw'; + const shareId = 'override-share-1'; + const walletId = 'override-wallet'; + + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: shareId, + coin: 'ofc', + walletLabel: 'x', + fromUser: 'a', + toUser: 'b', + wallet: walletId, + permissions: ['admin', 'spend', 'view'], + state: 'active', + keychainOverrideRequired: true, + }, + ], + outgoing: [], + }); + + const testKeychain = bitgo.coin('ofc').keychains().create(); + // Short-circuit createUserKeychain so the test doesn't hit the /key API dance; the + // point of this test is verifying the session argument threading, nothing more. + const createUserKeychainStub = sinon.stub(ofcWallets.baseCoin.keychains(), 'createUserKeychain').resolves({ + id: 'new-keychain-id', + pub: testKeychain.pub, + encryptedPrv: 'stubbed-encrypted-prv', + type: 'independent', + }); + + // specialOverrideCase pulls the sharing keychain up front, so stub it. + sinon.stub(bitgo, 'getECDHKeychain').resolves({ encryptedXprv: 'stubbed-encrypted-xprv' }); + sinon.stub(ofcWallets.baseCoin, 'signMessage').resolves(Buffer.from('signature-bytes')); + sinon.stub(bitgo, 'decrypt').resolves(testKeychain.prv); + sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({ + acceptedWalletShares: [shareId], + rejectedWalletShares: [], + walletShareUpdateErrors: [], + }); + sinon.stub(Wallets.prototype, 'reshareWalletWithSpenders').resolves(); + + await ofcWallets.bulkUpdateWalletShare({ + shares: [{ walletShareId: shareId, status: 'accept' }], + userLoginPassword: walletPassphrase, + }); + + createUserKeychainStub.calledOnce.should.equal(true); + // 3rd positional arg is the session + const sessionArg = createUserKeychainStub.firstCall.args[2]; + should.exist(sessionArg); + (typeof sessionArg!.encrypt).should.equal('function'); + (typeof sessionArg!.destroy).should.equal('function'); + }); + }); + }); + + describe('reshareWalletWithSpenders', function () { + afterEach(function () { + nock.cleanAll(); + nock.pendingMocks().length.should.equal(0); + sinon.restore(); + }); + + // Build a mock wallet with N spender users on the same enterprise. + function stubForReshare(spenderCount: number, walletId: string, enterprise: string) { + const users = Array.from({ length: spenderCount }, (_, i) => ({ + user: `spender-${i}`, + permissions: ['view', 'spend'], + })); + + const walletObj = { + id: walletId, + coin: 'tsol', + enterprise, + users, + keys: ['userKeyId', 'backupKeyId', 'bitgoKeyId'], + }; + + // wallets.get(walletId) -> Wallet wrapping walletObj + sinon.stub(Wallets.prototype, 'get').resolves(new Wallet(bitgo, bitgo.coin('tsol'), walletObj)); + + nock(bgUrl) + .get(`/api/v1/enterprise/${enterprise}/user`) + .reply(200, { + adminUsers: [], + nonAdminUsers: users.map((u, i) => ({ id: u.user, email: { email: `spender-${i}@example.com` } })), + }); + + return { walletObj, users }; + } + + it('decrypts the wallet keychain once and threads it into every shareWallet call', async function () { + const walletId = 'reshare-wallet-1'; + const enterprise = 'ent-1'; + const userPassword = 'shared-passphrase'; + const decryptedKeychain = { prv: 'plaintext-prv', pub: 'wallet-pub' }; + + const { users } = stubForReshare(3, walletId, enterprise); + + // getDecryptedKeychainForSharing is what runs Argon2id on the wallet's encryptedPrv. + // Assert it fires exactly once regardless of spender count. + const decryptStub = sinon.stub(Wallet.prototype, 'getDecryptedKeychainForSharing').resolves(decryptedKeychain); + + const shareWalletStub = sinon.stub(Wallet.prototype, 'shareWallet').resolves({ shared: true }); + + await wallets.reshareWalletWithSpenders(walletId, userPassword); + + decryptStub.callCount.should.equal(1); + decryptStub.firstCall.args[0]!.should.equal(userPassword); + + // shareWallet called once per spender, each with decryptedKeychain threaded in + shareWalletStub.callCount.should.equal(users.length); + for (let i = 0; i < users.length; i++) { + const call = shareWalletStub.getCall(i); + const shareArg = call.args[0]!; + shareArg.should.have.property('decryptedKeychain').eql(decryptedKeychain); + shareArg.should.have.property('user', users[i].user); + shareArg.should.have.property('walletPassphrase', userPassword); + } + }); + + it('runs shareWallet calls sequentially so per-recipient Argon2id is bounded', async function () { + const walletId = 'reshare-wallet-seq'; + const enterprise = 'ent-2'; + stubForReshare(5, walletId, enterprise); + + sinon.stub(Wallet.prototype, 'getDecryptedKeychainForSharing').resolves({ prv: 'p', pub: 'q' }); + + // Track concurrent in-flight shareWallet calls. Sequential -> max concurrent is 1. + let inFlight = 0; + let maxInFlight = 0; + sinon.stub(Wallet.prototype, 'shareWallet').callsFake(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return { shared: true }; + }); + + await wallets.reshareWalletWithSpenders(walletId, 'pw'); + + maxInFlight.should.equal(1); + }); + + it('returns early when the wallet has no spender users', async function () { + const walletId = 'reshare-empty'; + const enterprise = 'ent-empty'; + + sinon.stub(Wallets.prototype, 'get').resolves( + new Wallet(bitgo, bitgo.coin('tsol'), { + id: walletId, + coin: 'tsol', + enterprise, + users: [{ user: 'admin-only', permissions: ['admin', 'view', 'spend'] }], + keys: ['userKeyId', 'backupKeyId', 'bitgoKeyId'], + }) + ); + nock(bgUrl) + .get(`/api/v1/enterprise/${enterprise}/user`) + .reply(200, { + adminUsers: [{ id: 'admin-only', email: { email: 'admin@example.com' } }], + nonAdminUsers: [], + }); + + const decryptStub = sinon.stub(Wallet.prototype, 'getDecryptedKeychainForSharing'); + const shareWalletStub = sinon.stub(Wallet.prototype, 'shareWallet'); + + await wallets.reshareWalletWithSpenders(walletId, 'pw'); + + // No decrypt burned, no shares attempted + decryptStub.called.should.equal(false); + shareWalletStub.called.should.equal(false); + }); + + it('proceeds with per-recipient shareWallet even when the wallet is cold (no decryptedKeychain)', async function () { + const walletId = 'reshare-cold'; + const enterprise = 'ent-cold'; + stubForReshare(2, walletId, enterprise); + + // Simulate cold wallet: getDecryptedKeychainForSharing throws MissingEncryptedKeychainError + // (which we catch inside reshareWalletWithSpenders and fall through). + sinon + .stub(Wallet.prototype, 'getDecryptedKeychainForSharing') + .rejects(Object.assign(new Error('missing encrypted keychain'), { name: 'MissingEncryptedKeychainError' })); + + const shareWalletStub = sinon.stub(Wallet.prototype, 'shareWallet').resolves({ shared: true }); + + await wallets.reshareWalletWithSpenders(walletId, 'pw'); + + // Still fires per recipient; each call gets undefined decryptedKeychain and shareWallet + // can fall back to its own decrypt path (or a no-op for cold wallets internally). + shareWalletStub.callCount.should.equal(2); + for (let i = 0; i < 2; i++) { + const shareArg = shareWalletStub.getCall(i).args[0]!; + assert.strictEqual(shareArg.decryptedKeychain, undefined); + } }); }); }); diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 12b4344b7c..eb44347ceb 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -854,10 +854,13 @@ export class BitGoAPI implements BitGoBase { /** * Create an encryption session for multi-call operations. - * Runs Argon2id once; all subsequent calls derive keys via HKDF. + * + * v2 (default): runs Argon2id once, all subsequent calls derive per-envelope AES keys via HKDF. + * v1: returns a shim that satisfies the same interface but runs SJCL PBKDF2 per call. Lets + * callers that must produce v1 envelopes use the same factory as v2 callers. */ - async createEncryptionSession(password: string) { - return createEncryptionSession(password); + async createEncryptionSession(password: string, encryptionVersion?: EncryptionVersion) { + return createEncryptionSession(password, { encryptionVersion }); } /** diff --git a/modules/sdk-api/src/encryptionSession.ts b/modules/sdk-api/src/encryptionSession.ts index a939503304..625fe0ac08 100644 --- a/modules/sdk-api/src/encryptionSession.ts +++ b/modules/sdk-api/src/encryptionSession.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'crypto'; +import { decrypt, encrypt } from './encrypt'; import { aesGcmDecrypt, aesGcmEncrypt, @@ -100,11 +101,68 @@ export class EncryptionSession { } } -/** Create an EncryptionSession. Runs Argon2id once; all subsequent calls derive keys via HKDF. */ +/** + * v1 (SJCL) shim that satisfies the same contract as EncryptionSession but does not open a + * real session. Every encrypt/decrypt call runs its own SJCL PBKDF2 derivation via encrypt() / + * decrypt(). Exists so callers that pin `encryptionVersion: 1` can use the same + * `createEncryptionSession(pw, ver)` factory as v2 callers — no per-site useV2 branching. + * Once the sjcl-replacement rollout removes v1 encrypt, this collapses to nothing. + */ +export class V1EncryptionSession { + private password: string | null; + + constructor(password: string) { + this.password = password; + } + + async encrypt(input: string, adata?: string): Promise { + return encrypt(this.getPasswordOrThrow(), input, { adata, encryptionVersion: 1 }); + } + + async decrypt(ciphertext: string): Promise { + return decrypt(this.getPasswordOrThrow(), ciphertext); + } + + destroy(): void { + this.password = null; + } + + private getPasswordOrThrow(): string { + if (this.password === null) { + throw new Error('V1EncryptionSession has been destroyed'); + } + return this.password; + } +} + +/** + * Create an EncryptionSession. + * + * When encryptionVersion is undefined or 2 (the default), runs Argon2id once so every + * subsequent encrypt/decrypt derives a per-call AES key via HKDF (<1ms, native WebCrypto). + * + * When encryptionVersion is 1, returns a V1EncryptionSession shim that runs SJCL PBKDF2 on + * every call. The shim satisfies the same interface so callers that must produce v1 (SJCL) + * envelopes for legacy consumers use one factory regardless of version. No useV2 branching + * needed at the call site. + * + * Callers MUST call destroy() to clear the HKDF root (or retained password in v1 mode) from + * memory. Use-after-destroy throws. + */ export async function createEncryptionSession( password: string, - options?: { memorySize?: number; iterations?: number; parallelism?: number; salt?: Uint8Array } -): Promise { + options?: { + memorySize?: number; + iterations?: number; + parallelism?: number; + salt?: Uint8Array; + encryptionVersion?: 1 | 2; + } +): Promise { + if (options?.encryptionVersion === 1) { + return new V1EncryptionSession(password); + } + const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize; const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations; const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism; diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index 52eacaf608..7a762cd5f3 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -388,6 +388,78 @@ describe('encryption methods tests', () => { }); }); + describe('createEncryptionSession with encryptionVersion=1 (v1 shim)', () => { + const password = 'v1-shim-password'; + const plaintext = 'legacy consumer data'; + + it('produces v1 (SJCL) envelopes', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct = await session.encrypt(plaintext); + const envelope = JSON.parse(ct); + // v1 SJCL envelopes carry iter/mode/ks fields; no hkdfSalt/argon2 params + assert.ok(envelope.iter, 'v1 envelope must have iter'); + assert.ok(envelope.mode, 'v1 envelope must have mode'); + assert.notStrictEqual(envelope.v, 2); + assert.strictEqual(envelope.hkdfSalt, undefined); + session.destroy(); + }); + + it('round-trips via session.decrypt', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct = await session.encrypt(plaintext); + const rt = await session.decrypt(ct); + assert.strictEqual(rt, plaintext); + session.destroy(); + }); + + it('produced envelopes decrypt via the standard decrypt() with the same password', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct = await session.encrypt(plaintext); + session.destroy(); + const rt = await decrypt(password, ct); + assert.strictEqual(rt, plaintext); + }); + + it('multiple encrypts produce distinct ciphertexts (per-call SJCL salt/iv)', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct1 = await session.encrypt(plaintext); + const ct2 = await session.encrypt(plaintext); + assert.notStrictEqual(ct1, ct2); + const e1 = JSON.parse(ct1); + const e2 = JSON.parse(ct2); + // v1 salts are per-call, so they must differ across envelopes even under same password + assert.notStrictEqual(e1.salt, e2.salt); + session.destroy(); + }); + + it('forwards adata to the v1 envelope', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct = await session.encrypt(plaintext, 'enterprise-id-42'); + const envelope = JSON.parse(ct); + assert.strictEqual(envelope.adata, 'enterprise-id-42'); + session.destroy(); + }); + + it('destroy blocks further encrypt calls', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + session.destroy(); + await assert.rejects(() => session.encrypt(plaintext), /destroyed/); + }); + + it('destroy blocks further decrypt calls', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + const ct = await session.encrypt(plaintext); + session.destroy(); + await assert.rejects(() => session.decrypt(ct), /destroyed/); + }); + + it('destroy is idempotent', async () => { + const session = await createEncryptionSession(password, { encryptionVersion: 1 }); + session.destroy(); + session.destroy(); + }); + }); + describe('BitGoAPI.encrypt', () => { let bitgo: BitGoAPI; const password = 'test-password'; diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index a3764ee6c0..d77728d07f 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -2,6 +2,7 @@ import { BitGoRequest, DecryptKeysOptions, DecryptOptions, + EncryptionVersion, EncryptOptions, GetSharingKeyOptions, IEncryptionSession, @@ -43,7 +44,7 @@ export interface BitGoBase { decryptKeys(params: DecryptKeysOptions): Promise; del(url: string): BitGoRequest; encrypt(params: EncryptOptions): Promise; - createEncryptionSession(password: string): Promise; + createEncryptionSession(password: string, encryptionVersion?: EncryptionVersion): Promise; readonly env: EnvironmentName; fetchConstants(): Promise; get(url: string): BitGoRequest; diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 56da033ec9..8d19668127 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -1,4 +1,4 @@ -import { EncryptionVersion, IRequestTracer } from '../../api'; +import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../api'; import { KeychainsTriplet, KeyPair } from '../baseCoin'; import { BitgoPubKeyType } from '../utils/tss/baseTypes'; import { IWallet } from '../wallet'; @@ -282,6 +282,10 @@ export interface IKeychains { createMpc(params: CreateMpcOptions): Promise; recreateMpc(params: RecreateMpcOptions): Promise; createTssBitGoKeyFromOvcShares(ovcOutput: OvcToBitGoJSON, enterprise?: string): Promise; - createUserKeychain(userPassword: string, encryptionVersion?: EncryptionVersion): Promise; + createUserKeychain( + userPassword: string, + encryptionVersion?: EncryptionVersion, + session?: IEncryptionSession + ): Promise; rotateKeychain(params: RotateKeychainOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index b8249b7aeb..c70158ce50 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -25,7 +25,7 @@ import { UpdateSingleKeychainPasswordOptions, } from './iKeychains'; import { BitGoKeyFromOvcShares, BitGoToOvcJSON, OvcToBitGoJSON } from './ovcJsonCodec'; -import { EncryptionVersion } from '../../api'; +import { EncryptionVersion, IEncryptionSession } from '../../api'; export class Keychains implements IKeychains { private readonly bitgo: BitGoBase; @@ -571,16 +571,24 @@ export class Keychains implements IKeychains { * @param walletPassphrase * @returns Keychain including the decrypted private key */ - async createUserKeychain(walletPassphrase: string, encryptionVersion?: EncryptionVersion): Promise { + async createUserKeychain( + walletPassphrase: string, + encryptionVersion?: EncryptionVersion, + session?: IEncryptionSession + ): Promise { const keychains = this.baseCoin.keychains(); const newKeychain = keychains.create(); const originalPasscodeEncryptionCode = generateRandomPassword(5); - const encryptedPrv = await this.bitgo.encrypt({ - password: walletPassphrase, - input: newKeychain.prv, - encryptionVersion, - }); + // When a session is threaded in (typically from a bulk operation that already ran the KDF + // once), reuse it so this envelope's AES key derives via HKDF instead of another Argon2. + const encryptedPrv = session + ? await session.encrypt(newKeychain.prv) + : await this.bitgo.encrypt({ + password: walletPassphrase, + input: newKeychain.prv, + encryptionVersion, + }); return { ...(await keychains.add({ diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 3fb40b46b3..759590dbb5 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -796,6 +796,13 @@ export interface ShareWalletOptions { skipKeychain?: boolean; disableEmail?: boolean; encryptionVersion?: EncryptionVersion; + /** + * Pre-decrypted wallet keychain. When supplied, shareWallet skips its internal + * getDecryptedKeychainForSharing call — useful when the caller is sharing the same wallet + * with many recipients under one walletPassphrase and wants to avoid N-1 redundant Argon2id + * decryptions of the user's wallet keychain. + */ + decryptedKeychain?: DecryptedKeychainData; } export interface BulkCreateShareOption { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 30b1bd4f3d..47f57934a4 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2019,20 +2019,17 @@ export class Wallet implements IWallet { walletPassphrase: string | undefined, pubkey: string, path: string, - encryptionVersion?: EncryptionVersion + encryptionVersion?: EncryptionVersion, + decryptedKeychain?: DecryptedKeychainData ): Promise { try { - const decryptedKeychain = await this.getDecryptedKeychainForSharing(walletPassphrase); - if (!decryptedKeychain) { + // Callers that share the same wallet with many recipients can pass the pre-decrypted + // keychain to avoid re-running Argon2id per recipient. When absent, decrypt on demand. + const keychain = decryptedKeychain ?? (await this.getDecryptedKeychainForSharing(walletPassphrase)); + if (!keychain) { return {}; } - return await this.encryptPrvForUser( - decryptedKeychain.prv, - decryptedKeychain.pub, - pubkey, - path, - encryptionVersion - ); + return await this.encryptPrvForUser(keychain.prv, keychain.pub, pubkey, path, encryptionVersion); } catch (e) { if (e instanceof MissingEncryptedKeychainError) { // ignore this error because this looks like a cold wallet @@ -2082,7 +2079,8 @@ export class Wallet implements IWallet { params.walletPassphrase, sharing.pubkey, sharing.path, - params.encryptionVersion + params.encryptionVersion, + params.decryptedKeychain ); } diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 770f00c602..0cf5d8ff84 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -7,11 +7,12 @@ import { bip32 } from '@bitgo/utxo-lib'; import * as _ from 'lodash'; import { CoinFeature } from '@bitgo/statics'; -import { EncryptionVersion, sanitizeLegacyPath } from '../../api'; +import { EncryptionVersion, IEncryptionSession, sanitizeLegacyPath } from '../../api'; import * as common from '../../common'; import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { getSharedSecret } from '../ecdh'; +import { MissingEncryptedKeychainError } from '../errors'; import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; import { decodeOrElse, ECDSAUtils, EDDSAUtils, promiseProps, RequestTracer } from '../utils'; import { @@ -42,7 +43,7 @@ import { WalletShares, WalletWithKeychains, } from './iWallets'; -import { WalletShare } from './iWallet'; +import { DecryptedKeychainData, ShareWalletOptions, WalletShare } from './iWallet'; import { Wallet } from './wallet'; import { TssSettings } from '@bitgo/public-types'; import { createEvmKeyRingWallet, validateEvmKeyRingWalletParams } from '../evm/evmUtils'; @@ -56,6 +57,11 @@ export function isWalletWithKeychains( return wallet.responseType === 'WalletWithKeychains'; } +// Bound concurrent Argon2id WASM instances across bulk share flows. Each decrypt/encrypt call +// reserves ~2 GiB of virtual address space; running all shares concurrently OOMs the browser +// at scale (e.g. 96 wallets). 16 keeps enough parallelism without exhausting memory. +const BULK_SHARE_BATCH_SIZE = 16; + export class Wallets implements IWallets { private readonly bitgo: BitGoBase; private readonly baseCoin: IBaseCoin; @@ -1094,23 +1100,49 @@ export class Wallets implements IWallets { [...enterpriseUsersResponse?.adminUsers, ...enterpriseUsersResponse?.nonAdminUsers].map((obj) => [obj.id, obj]) ); - if (wallet._wallet.users) { - for (const user of wallet._wallet.users) { - const userObject = usersMap.get(user.user); - if (user.permissions.includes('spend') && !user.permissions.includes('admin') && userObject) { - const shareParams = { - walletId: walletId, - user: user.user, - permissions: user.permissions.join(','), - walletPassphrase: userPassword, - email: userObject.email.email, - reshare: true, - skipKeychain: false, - encryptionVersion, - }; - await wallet.shareWallet(shareParams); - } - } + if (!wallet._wallet.users) { + return; + } + + const spenders = wallet._wallet.users.filter( + (user) => user.permissions.includes('spend') && !user.permissions.includes('admin') && usersMap.has(user.user) + ); + + if (spenders.length === 0) { + return; + } + + // Decrypt the wallet keychain once and thread the plaintext into each shareWallet call. + // Without this, shareWallet would internally re-decrypt the same encryptedPrv under the same + // walletPassphrase N times (one Argon2id per recipient). Per-recipient encrypt still uses a + // unique ECDH-derived secret so no session amortization is possible on that side. + let decryptedKeychain: DecryptedKeychainData | undefined; + try { + decryptedKeychain = await wallet.getDecryptedKeychainForSharing(userPassword); + } catch (e) { + if (!(e instanceof MissingEncryptedKeychainError)) { + throw e; + } + // Cold wallet: no encrypted keychain to decrypt. Fall through and let shareWallet handle + // it per-recipient (which will also throw MissingEncryptedKeychainError but at a point + // where the caller expects it). + } + + // Sequential loop to bound concurrent Argon2id WASM instances on the per-recipient ECDH + // encrypt side (each recipient's secret is unique so Promise.all could OOM at scale). + for (const user of spenders) { + const userObject = usersMap.get(user.user); + assert(userObject, `User ${user.user} not found in enterprise users map`); + const shareOptions: ShareWalletOptions = { + permissions: user.permissions.join(','), + walletPassphrase: userPassword, + email: userObject.email.email, + reshare: true, + skipKeychain: false, + encryptionVersion, + decryptedKeychain, + }; + await wallet.shareWallet(shareOptions); } } @@ -1309,11 +1341,23 @@ export class Wallets implements IWallets { const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword; const webauthnInfo = params.webauthnInfo; - // Each decrypt/encrypt call runs Argon2id inside a WebAssembly instance that reserves ~2 GiB of - // virtual address space. Running all shares concurrently via Promise.all exhausts the browser's - // WASM memory at scale (e.g. 96 wallets). Process in small batches so only a bounded number of - // WASM instances are alive at once. - const BATCH_SIZE = 16; + // One session over newWalletPassphrase (and one more for webauthnInfo.passphrase when + // present). v2: one Argon2id derivation total, per-envelope AES keys via HKDF with fresh + // salts (cross-envelope independence preserved). v1: shim runs SJCL per call so callers + // pinning encryptionVersion=1 still get v1 envelopes without any branching here. + const walletSession = await this.bitgo.createEncryptionSession(newWalletPassphrase, params.encryptionVersion); + let webauthnSession: IEncryptionSession | undefined; + try { + webauthnSession = webauthnInfo + ? await this.bitgo.createEncryptionSession(webauthnInfo.passphrase, params.encryptionVersion) + : undefined; + } catch (e) { + // If the webauthn session fails to initialize (e.g. WASM OOM), destroy the wallet session + // to avoid leaking its HKDF root. Preserves the invariant that no session outlives a + // failure in this method. + walletSession.destroy(); + throw e; + } const processShare = async (walletShare: WalletShare): Promise => { // Handle userMultiKeyRotationRequired case - these shares don't have keychains @@ -1322,11 +1366,7 @@ export class Wallets implements IWallets { throw new Error('userLoginPassword param must be provided to generate user keychain'); } const walletKeychain = this.baseCoin.keychains().create(); - const encryptedPrv = await this.bitgo.encrypt({ - password: newWalletPassphrase, - input: walletKeychain.prv, - encryptionVersion: params.encryptionVersion, - }); + const encryptedPrv = await walletSession.encrypt(walletKeychain.prv); return [ { walletShareId: walletShare.id, @@ -1349,37 +1389,34 @@ export class Wallets implements IWallets { password: secret, input: walletShare.keychain.encryptedPrv, }); - const newEncryptedPrv = await this.bitgo.encrypt({ - password: newWalletPassphrase, - input: decryptedSharedWalletPrv, - encryptionVersion: params.encryptionVersion, - }); + const newEncryptedPrv = await walletSession.encrypt(decryptedSharedWalletPrv); const entry: AcceptShareOptionsRequest = { walletShareId: walletShare.id, encryptedPrv: newEncryptedPrv, }; if (webauthnInfo) { + assert(webauthnSession, 'webauthnSession must exist when webauthnInfo is provided'); entry.webauthnInfo = { otpDeviceId: webauthnInfo.otpDeviceId, prfSalt: webauthnInfo.prfSalt, - encryptedPrv: await this.bitgo.encrypt({ - password: webauthnInfo.passphrase, - input: decryptedSharedWalletPrv, - encryptionVersion: params.encryptionVersion, - adata: walletShare.enterprise, - }), + encryptedPrv: await webauthnSession.encrypt(decryptedSharedWalletPrv, walletShare.enterprise), }; } return [entry]; }; - const keysForWalletShares: AcceptShareOptionsRequest[] = []; - for (const batch of _.chunk(walletShares, BATCH_SIZE)) { - const batchResults = await Promise.all(batch.map((walletShare) => processShare(walletShare))); - keysForWalletShares.push(...batchResults.flat()); - } + try { + const keysForWalletShares: AcceptShareOptionsRequest[] = []; + for (const batch of _.chunk(walletShares, BULK_SHARE_BATCH_SIZE)) { + const batchResults = await Promise.all(batch.map((walletShare) => processShare(walletShare))); + keysForWalletShares.push(...batchResults.flat()); + } - return this.bulkAcceptShareRequest(keysForWalletShares); + return await this.bulkAcceptShareRequest(keysForWalletShares); + } finally { + walletSession.destroy(); + webauthnSession?.destroy(); + } } /** @@ -1489,38 +1526,57 @@ export class Wallets implements IWallets { }); } - const settledUpdates = await Promise.allSettled( - resolvedShares.map(async (share) => { - const { walletShareId, status, walletShare } = share; - - // Handle accept case - if (status === 'accept') { - return this.processAcceptShare( - walletShareId, - walletShare, - userLoginPassword, - newWalletPassphrase, - sharingKeychainPrv, - encryptionVersion - ); - } - - // Handle reject case - return [ - { - walletShareId, - status: 'reject' as const, - }, - ]; - }) - ); + // One session over the outer re-encrypt password. Covers all three accept paths in + // processAcceptShare — specialOverrideCase (threaded through createUserKeychain), + // userMultiKeyRotationRequired, and standard ECDH re-encrypt — since they all use the + // same `newWalletPassphrase || userLoginPassword`. Skip session creation when there's + // nothing to encrypt (reject-only bulks). + const sessionPassword = newWalletPassphrase || userLoginPassword; + const hasSharesToEncrypt = resolvedShares.some((share) => share.status === 'accept'); + const session = + sessionPassword && hasSharesToEncrypt + ? await this.bitgo.createEncryptionSession(sessionPassword, encryptionVersion) + : undefined; + + let response: BulkUpdateWalletShareResponse; + let failedUpdates: Array<{ walletShareId: string; reason: string }> = []; + try { + const settledUpdates: PromiseSettledResult[] = []; + for (const batch of _.chunk(resolvedShares, BULK_SHARE_BATCH_SIZE)) { + const batchResults = await Promise.allSettled( + batch.map(async (share) => { + const { walletShareId, status, walletShare } = share; + + // Handle accept case + if (status === 'accept') { + return this.processAcceptShare( + walletShareId, + walletShare, + userLoginPassword, + newWalletPassphrase, + sharingKeychainPrv, + encryptionVersion, + session + ); + } + + // Handle reject case + return [ + { + walletShareId, + status: 'reject' as const, + }, + ]; + }) + ); + settledUpdates.push(...batchResults); + } - // Extract successful updates - const successfulUpdates = settledUpdates.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])); + // Extract successful updates + const successfulUpdates = settledUpdates.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])); - // Extract failed updates - only from rejected promises - const failedUpdates = settledUpdates.reduce>( - (acc, result, index) => { + // Extract failed updates - only from rejected promises + failedUpdates = settledUpdates.reduce>((acc, result, index) => { if (result.status === 'rejected') { const rejectedResult = result; acc.push({ @@ -1529,12 +1585,13 @@ export class Wallets implements IWallets { }); } return acc; - }, - [] - ); + }, []); - // Send successful updates to the server - const response = await this.bulkUpdateWalletShareRequest(successfulUpdates); + // Send successful updates to the server + response = await this.bulkUpdateWalletShareRequest(successfulUpdates); + } finally { + session?.destroy(); + } // Process accepted special override cases - reshare with spenders if (response.acceptedWalletShares && response.acceptedWalletShares.length > 0 && userLoginPassword) { @@ -1580,7 +1637,8 @@ export class Wallets implements IWallets { userLoginPassword?: string, newWalletPassphrase?: string, sharingKeychainPrv?: string, - encryptionVersion?: EncryptionVersion + encryptionVersion?: EncryptionVersion, + session?: IEncryptionSession ): Promise { // Special override case: requires user keychain and signing if ( @@ -1592,9 +1650,11 @@ export class Wallets implements IWallets { throw new Error('userLoginPassword param must be provided to decrypt shared key'); } + // Thread the outer session into createUserKeychain so its internal encrypt uses HKDF + // instead of running another Argon2id under the same password. const walletKeychain = await this.baseCoin .keychains() - .createUserKeychain(newWalletPassphrase || userLoginPassword, encryptionVersion); + .createUserKeychain(newWalletPassphrase || userLoginPassword, encryptionVersion, session); if (!walletKeychain.encryptedPrv) { throw new Error('encryptedPrv was not found on wallet keychain'); } @@ -1625,16 +1685,15 @@ export class Wallets implements IWallets { // Multi-user-key case: requires user to provide their own public key if (walletShare.userMultiKeyRotationRequired) { - if (!(newWalletPassphrase || userLoginPassword)) { + const password = newWalletPassphrase || userLoginPassword; + if (!password) { throw new Error('userLoginPassword param must be provided to generate user keychain'); } const walletKeychain = this.baseCoin.keychains().create(); - const encryptedPrv = await this.bitgo.encrypt({ - password: newWalletPassphrase || userLoginPassword, - input: walletKeychain.prv, - encryptionVersion, - }); + const encryptedPrv = session + ? await session.encrypt(walletKeychain.prv) + : await this.bitgo.encrypt({ password, input: walletKeychain.prv, encryptionVersion }); return [ { @@ -1675,12 +1734,15 @@ export class Wallets implements IWallets { input: walletShare.keychain.encryptedPrv, }); - // We will now re-encrypt the wallet with our own password - const encryptedPrv = await this.bitgo.encrypt({ - password: newWalletPassphrase || userLoginPassword, - input: decryptedPrv, - encryptionVersion, - }); + // Re-encrypt for the accepter. The per-recipient ECDH decrypt above still runs its own + // Argon2 when the sender's keychain is v2 (unique password per share, no session possible). + const encryptedPrv = session + ? await session.encrypt(decryptedPrv) + : await this.bitgo.encrypt({ + password: newWalletPassphrase || userLoginPassword, + input: decryptedPrv, + encryptionVersion, + }); return [ { diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts index 2c2ae030e1..ab908b00a6 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts @@ -25,6 +25,13 @@ describe('Wallets - encryptionVersion threading', function () { .stub() .callsFake(async ({ password, input }: { password: string; input: string }) => `enc:${password}:${input}`), decrypt: sinon.stub().resolves('decryptedPrv'), + createEncryptionSession: sinon.stub().callsFake(async (password: string, encryptionVersion?: 1 | 2) => ({ + encrypt: sinon + .stub() + .callsFake(async (input: string) => `session-enc:${password}:${encryptionVersion}:${input}`), + decrypt: sinon.stub().resolves('session-decrypted'), + destroy: sinon.stub(), + })), get: sinon.stub().returns({ result: sinon.stub(), query: sinon.stub().returnsThis() }), post: sinon.stub().returns({ send: sinon.stub().returns({ result: sinon.stub().resolves({}) }) }), put: sinon.stub().returns({ @@ -113,27 +120,27 @@ describe('Wallets - encryptionVersion threading', function () { mockBitGo.get.returns({ result: sinon.stub().resolves(walletSharesList) }); }); - it('passes encryptionVersion: 2 to encrypt on the multiUserKeyRotationRequired path', async function () { + it('passes encryptionVersion: 2 to the encryption session', async function () { await wallets.bulkAcceptShare({ walletShareIds: ['share-id'], userLoginPassword: 'login-password', encryptionVersion: 2, }); - assert.ok(mockBitGo.encrypt.called); - const call = mockBitGo.encrypt.firstCall; - assert.strictEqual(call.args[0].encryptionVersion, 2); + assert.ok(mockBitGo.createEncryptionSession.called); + const call = mockBitGo.createEncryptionSession.firstCall; + assert.strictEqual(call.args[1], 2); }); - it('passes encryptionVersion: undefined when not set', async function () { + it('passes encryptionVersion: undefined to the encryption session when not set', async function () { await wallets.bulkAcceptShare({ walletShareIds: ['share-id'], userLoginPassword: 'login-password', }); - assert.ok(mockBitGo.encrypt.called); - const call = mockBitGo.encrypt.firstCall; - assert.strictEqual(call.args[0].encryptionVersion, undefined); + assert.ok(mockBitGo.createEncryptionSession.called); + const call = mockBitGo.createEncryptionSession.firstCall; + assert.strictEqual(call.args[1], undefined); }); it('processes shares in batches of 16 to avoid WASM memory exhaustion', async function () { @@ -148,20 +155,29 @@ describe('Wallets - encryptionVersion threading', function () { result: sinon.stub().resolves({ incoming: manyShares, outgoing: [] }), }); + // Capture the session so we can inspect its encrypt call count + const sessionEncryptStub = sinon.stub().callsFake(async (input: string) => `session-enc:${input}`); + mockBitGo.createEncryptionSession = sinon.stub().resolves({ + encrypt: sessionEncryptStub, + decrypt: sinon.stub(), + destroy: sinon.stub(), + }); + await wallets.bulkAcceptShare({ walletShareIds: manyShares.map((s) => s.id), userLoginPassword: 'login-password', }); - // All 20 shares should have been encrypted (one encrypt call per share) - assert.strictEqual(mockBitGo.encrypt.callCount, 20); + // Session created once, session.encrypt called once per share + assert.strictEqual(mockBitGo.createEncryptionSession.callCount, 1); + assert.strictEqual(sessionEncryptStub.callCount, 20); }); it('never runs more than 16 shares concurrently', async function () { let inFlight = 0; let maxInFlight = 0; - mockBitGo.encrypt.callsFake(() => { + const sessionEncryptStub = sinon.stub().callsFake(() => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); return Promise.resolve('encrypted').then((r) => { @@ -169,6 +185,11 @@ describe('Wallets - encryptionVersion threading', function () { return r; }); }); + mockBitGo.createEncryptionSession = sinon.stub().resolves({ + encrypt: sessionEncryptStub, + decrypt: sinon.stub(), + destroy: sinon.stub(), + }); const manyShares = Array.from({ length: 20 }, (_, i) => ({ id: `share-${i}`, @@ -186,7 +207,7 @@ describe('Wallets - encryptionVersion threading', function () { }); assert.ok(maxInFlight <= 16, `expected max concurrency <= 16, got ${maxInFlight}`); - assert.strictEqual(mockBitGo.encrypt.callCount, 20); + assert.strictEqual(sessionEncryptStub.callCount, 20); }); });