From 8e6f989ce0d13ca3058f3cceec2b76523c941168 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 20 Aug 2026 17:27:20 -0400 Subject: [PATCH 1/5] feat(sdk-api): extend createEncryptionSession with a v1 shim createEncryptionSession(pw, { encryptionVersion }) returns a V1EncryptionSession shim when v1 is pinned. Same IEncryptionSession interface, runs SJCL PBKDF2 per call instead of opening an HKDF session. Lets callers use one factory regardless of envelope version, no useV2 branching at call sites. TICKET: WCN-2314 --- modules/sdk-api/src/bitgoAPI.ts | 9 ++- modules/sdk-api/src/encryptionSession.ts | 64 ++++++++++++++++++++- modules/sdk-api/test/unit/encrypt.ts | 72 ++++++++++++++++++++++++ modules/sdk-core/src/bitgo/bitgoBase.ts | 3 +- 4 files changed, 141 insertions(+), 7 deletions(-) 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; From 9786c494300c66b388106c649c0b1fe56d91b824 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 20 Aug 2026 17:27:31 -0400 Subject: [PATCH 2/5] perf(sdk-core): use createEncryptionSession in bulkAcceptShare Collapses N encrypt-side Argon2 derivations to 1 (2 with webauthn). One session over newWalletPassphrase + independent session over webauthnInfo.passphrase; per-share adata (walletShare.enterprise) threaded per call so webauthn envelopes stay enterprise-bound. BATCH_SIZE=16 preserved as the decrypt-side WASM OOM guard. TICKET: WCN-2314 --- modules/bitgo/test/v2/unit/wallets.ts | 191 ++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/wallets.ts | 50 ++--- .../bitgo/wallet/walletsEncryptionVersion.ts | 45 +++-- 3 files changed, 250 insertions(+), 36 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index c9e2c3999f..cc46c73508 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -3274,6 +3274,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 () { diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 770f00c602..00166b259e 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -1312,9 +1312,19 @@ export class Wallets implements IWallets { // 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. + // WASM instances are alive at once. The decrypt side still hits Argon2 per share (each share's + // ECDH-derived secret is unique); the encrypt side is collapsed via the session below. 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); + const webauthnSession = webauthnInfo + ? await this.bitgo.createEncryptionSession(webauthnInfo.passphrase, params.encryptionVersion) + : undefined; + const processShare = async (walletShare: WalletShare): Promise => { // Handle userMultiKeyRotationRequired case - these shares don't have keychains if (walletShare.userMultiKeyRotationRequired) { @@ -1322,11 +1332,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 +1355,33 @@ 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) { + if (webauthnInfo && webauthnSession) { 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, 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(); + } } /** 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); }); }); From 1c4924c684710bd7e063ff27b48ef4d75872e76a Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 20 Aug 2026 17:27:42 -0400 Subject: [PATCH 3/5] perf(sdk-core): use createEncryptionSession in bulkUpdateWalletShare Same shape as bulkAcceptShare. One session over newWalletPassphrase || userLoginPassword covers all three accept paths in processAcceptShare. createUserKeychain accepts an optional IEncryptionSession so the specialOverrideCase batch collapses too. Session skipped for reject-only bulks. Mirrors BATCH_SIZE=16. TICKET: WCN-2314 --- modules/bitgo/test/v2/unit/wallets.ts | 232 ++++++++++++++++-- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 8 +- .../sdk-core/src/bitgo/keychain/keychains.ts | 22 +- modules/sdk-core/src/bitgo/wallet/wallets.ts | 128 ++++++---- 4 files changed, 312 insertions(+), 78 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index cc46c73508..25d42f0fb5 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -3900,13 +3900,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)); @@ -3934,20 +3932,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 () => { @@ -4077,7 +4072,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({ @@ -4101,8 +4095,206 @@ 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: BulkWalletShareOptions[] = []; + sinon + .stub(Wallets.prototype, 'bulkUpdateWalletShareRequest') + .callsFake(async (shares: BulkWalletShareOptions[]) => { + 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'); + }); }); }); }); 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/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 00166b259e..d4c0229209 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -7,7 +7,7 @@ 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'; @@ -1491,38 +1491,62 @@ 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 - ); - } + // 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; + + // Mirror the bulkAcceptShare BATCH_SIZE guard. The per-share ECDH-derived decrypt secret is + // still unique, so bounded WASM instances matter on the decrypt side when the sender's + // keychain is v2. + const BATCH_SIZE = 16; - // Handle reject case - return [ - { - walletShareId, - status: 'reject' as const, - }, - ]; - }) - ); + let response: BulkUpdateWalletShareResponse; + let failedUpdates: Array<{ walletShareId: string; reason: string }> = []; + try { + const settledUpdates: PromiseSettledResult[] = []; + for (const batch of _.chunk(resolvedShares, 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({ @@ -1531,12 +1555,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) { @@ -1582,7 +1607,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 ( @@ -1594,9 +1620,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'); } @@ -1627,16 +1655,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 [ { @@ -1677,12 +1704,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 [ { From b8f4284b2ec6993a31b87930557605619c52a3af Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 20 Aug 2026 17:27:54 -0400 Subject: [PATCH 4/5] perf(sdk-core): pre-decrypt once in reshareWalletWithSpenders Decrypt the wallet keychain once and thread the plaintext into every shareWallet call via a new optional decryptedKeychain param on ShareWalletOptions. Saves N-1 Argon2 decrypts of the same encryptedPrv. Per-recipient encrypt still uses a unique ECDH-derived secret (irreducible). Sequential loop preserved to bound WASM instances. TICKET: WCN-2314 --- modules/bitgo/test/v2/unit/wallets.ts | 150 ++++++++++++++++++- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 7 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 20 ++- modules/sdk-core/src/bitgo/wallet/wallets.ts | 57 ++++--- 4 files changed, 202 insertions(+), 32 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index 25d42f0fb5..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, @@ -3455,7 +3456,7 @@ describe('V2 Wallets:', function () { // Factory is still called uniformly, but with encryptionVersion=1 sessionSpy.callCount.should.equal(1); - sessionSpy.firstCall.args[1].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)); @@ -4178,10 +4179,10 @@ describe('V2 Wallets:', function () { const walletPassphrase = 'shared-argon2-salt-test'; const { shareIds } = await stubForAcceptShares(4, 'plaintext-prv', walletPassphrase); - let capturedShares: BulkWalletShareOptions[] = []; + let capturedShares: BulkUpdateWalletShareOptionsRequest[] = []; sinon .stub(Wallets.prototype, 'bulkUpdateWalletShareRequest') - .callsFake(async (shares: BulkWalletShareOptions[]) => { + .callsFake(async (shares: BulkUpdateWalletShareOptionsRequest[]) => { capturedShares = shares; return { acceptedWalletShares: shareIds, rejectedWalletShares: [], walletShareUpdateErrors: [] }; }); @@ -4297,6 +4298,149 @@ describe('V2 Wallets:', 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); + } + }); + }); }); describe('createBulkKeyShares tests', () => { 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 d4c0229209..cf0e22a5a6 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -42,7 +42,7 @@ import { WalletShares, WalletWithKeychains, } from './iWallets'; -import { WalletShare } from './iWallet'; +import { ShareWalletOptions, WalletShare } from './iWallet'; import { Wallet } from './wallet'; import { TssSettings } from '@bitgo/public-types'; import { createEvmKeyRingWallet, validateEvmKeyRingWalletParams } from '../evm/evmUtils'; @@ -1094,23 +1094,44 @@ 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; + try { + decryptedKeychain = await wallet.getDecryptedKeychainForSharing(userPassword); + } catch (e) { + // MissingEncryptedKeychainError -> cold wallet, let shareWallet handle it per-recipient + } + + // 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)!; + await wallet.shareWallet({ + walletId: walletId, + user: user.user, + permissions: user.permissions.join(','), + walletPassphrase: userPassword, + email: userObject.email.email, + reshare: true, + skipKeychain: false, + encryptionVersion, + decryptedKeychain, + } as ShareWalletOptions & { walletId: string; user: string }); } } From 23bbca75fc169d3237e4e7f7d85686cb5b07e7ae Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Wed, 26 Aug 2026 13:07:15 -0400 Subject: [PATCH 5/5] refactor(sdk-core): tighten bulk share session and error handling Destroy the wallet session if the webauthn session fails to initialize, narrow the reshare decrypt catch to MissingEncryptedKeychainError so unrelated errors propagate, replace non-null assertions with assert(), drop a misleading inline cast, and hoist the shared BATCH_SIZE constant. TICKET: WCN-2314 --- modules/bitgo/test/v2/unit/wallets.ts | 49 ++++++++++++---- modules/sdk-core/src/bitgo/wallet/wallets.ts | 61 +++++++++++--------- 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index 660f240c7d..fa33acdbbe 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -29,6 +29,7 @@ import { WalletWithKeychains, multisigTypes, IncorrectPasswordError, + MissingEncryptedKeychainError, NeedUserSignupError, } from '@bitgo/sdk-core'; import { BitGo } from '../../../src'; @@ -3347,7 +3348,7 @@ describe('V2 Wallets:', function () { const decryptedWalletPrv = 'secret-wallet-material'; const { shareIds } = await stubForShares(4, decryptedWalletPrv, walletPassphrase); - let captured: any; + let captured: { keysForWalletShares: AcceptShareOptionsRequest[] } | undefined; nock(bgUrl) .put('/api/v2/walletshares/accept', (body) => { captured = body; @@ -3359,7 +3360,7 @@ describe('V2 Wallets:', function () { await wallets.bulkAcceptShare({ walletShareIds: shareIds, userLoginPassword: walletPassphrase }); - const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + const entries = captured!.keysForWalletShares; entries.should.have.length(4); const envelopes = entries.map((e) => JSON.parse(e.encryptedPrv as string)); @@ -3388,7 +3389,7 @@ describe('V2 Wallets:', function () { const { shareIds } = await stubForShares(2, decryptedWalletPrv, walletPassphrase); const sessionSpy = sinon.spy(bitgo, 'createEncryptionSession'); - let captured: any; + let captured: { keysForWalletShares: AcceptShareOptionsRequest[] } | undefined; nock(bgUrl) .put('/api/v2/walletshares/accept', (body) => { captured = body; @@ -3407,7 +3408,7 @@ describe('V2 Wallets:', function () { passwords.should.containEql(walletPassphrase); passwords.should.containEql(webauthnPassphrase); - const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + const entries = captured!.keysForWalletShares; 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}`); @@ -3440,7 +3441,7 @@ describe('V2 Wallets:', function () { const { shareIds } = await stubForShares(2, decryptedWalletPrv, walletPassphrase); const sessionSpy = sinon.spy(bitgo, 'createEncryptionSession'); - let captured: any; + let captured: { keysForWalletShares: AcceptShareOptionsRequest[] } | undefined; nock(bgUrl) .put('/api/v2/walletshares/accept', (body) => { captured = body; @@ -3458,7 +3459,7 @@ describe('V2 Wallets:', function () { sessionSpy.callCount.should.equal(1); sessionSpy.firstCall.args[1]!.should.equal(1); - const entries = captured.keysForWalletShares as AcceptShareOptionsRequest[]; + const entries = captured!.keysForWalletShares; const envelopes = entries.map((e) => JSON.parse(e.encryptedPrv as string)); for (const env of envelopes) { env.should.not.have.property('hkdfSalt'); @@ -4209,6 +4210,37 @@ describe('V2 Wallets:', function () { } }); + it('bounds concurrent processAcceptShare calls at BULK_SHARE_BATCH_SIZE (16)', async function () { + const walletPassphrase = 'batch-cap-pw'; + const { shareIds } = await stubForAcceptShares(20, 'plaintext-prv', walletPassphrase); + + sinon.stub(Wallets.prototype, 'bulkUpdateWalletShareRequest').resolves({ + acceptedWalletShares: shareIds, + rejectedWalletShares: [], + walletShareUpdateErrors: [], + }); + + // Track concurrent in-flight processAcceptShare invocations. The batching in + // bulkUpdateWalletShare should cap this at BULK_SHARE_BATCH_SIZE (16). + let inFlight = 0; + let maxInFlight = 0; + sinon.stub(Wallets.prototype as any, 'processAcceptShare').callsFake(async (...args: unknown[]) => { + const walletShareId = args[0] as string; + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return [{ walletShareId, status: 'accept' as const }]; + }); + + await wallets.bulkUpdateWalletShare({ + shares: shareIds.map((id) => ({ walletShareId: id, status: 'accept' as const })), + userLoginPassword: walletPassphrase, + }); + + maxInFlight.should.be.lessThanOrEqual(16); + }); + it('does not open a session when the bulk contains only rejects', async function () { sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ incoming: [ @@ -4359,7 +4391,6 @@ describe('V2 Wallets:', function () { 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); } }); @@ -4424,9 +4455,7 @@ describe('V2 Wallets:', function () { // 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' })); + sinon.stub(Wallet.prototype, 'getDecryptedKeychainForSharing').rejects(new MissingEncryptedKeychainError()); const shareWalletStub = sinon.stub(Wallet.prototype, 'shareWallet').resolves({ shared: true }); diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index cf0e22a5a6..0cf5d8ff84 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -12,6 +12,7 @@ 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 { ShareWalletOptions, 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; @@ -1110,20 +1116,24 @@ export class Wallets implements IWallets { // 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; + let decryptedKeychain: DecryptedKeychainData | undefined; try { decryptedKeychain = await wallet.getDecryptedKeychainForSharing(userPassword); } catch (e) { - // MissingEncryptedKeychainError -> cold wallet, let shareWallet handle it per-recipient + 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)!; - await wallet.shareWallet({ - walletId: walletId, - user: user.user, + 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, @@ -1131,7 +1141,8 @@ export class Wallets implements IWallets { skipKeychain: false, encryptionVersion, decryptedKeychain, - } as ShareWalletOptions & { walletId: string; user: string }); + }; + await wallet.shareWallet(shareOptions); } } @@ -1330,21 +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. The decrypt side still hits Argon2 per share (each share's - // ECDH-derived secret is unique); the encrypt side is collapsed via the session below. - 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); - const webauthnSession = webauthnInfo - ? await this.bitgo.createEncryptionSession(webauthnInfo.passphrase, params.encryptionVersion) - : undefined; + 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 @@ -1381,7 +1394,8 @@ export class Wallets implements IWallets { walletShareId: walletShare.id, encryptedPrv: newEncryptedPrv, }; - if (webauthnInfo && webauthnSession) { + if (webauthnInfo) { + assert(webauthnSession, 'webauthnSession must exist when webauthnInfo is provided'); entry.webauthnInfo = { otpDeviceId: webauthnInfo.otpDeviceId, prfSalt: webauthnInfo.prfSalt, @@ -1393,7 +1407,7 @@ export class Wallets implements IWallets { try { const keysForWalletShares: AcceptShareOptionsRequest[] = []; - for (const batch of _.chunk(walletShares, BATCH_SIZE)) { + for (const batch of _.chunk(walletShares, BULK_SHARE_BATCH_SIZE)) { const batchResults = await Promise.all(batch.map((walletShare) => processShare(walletShare))); keysForWalletShares.push(...batchResults.flat()); } @@ -1524,16 +1538,11 @@ export class Wallets implements IWallets { ? await this.bitgo.createEncryptionSession(sessionPassword, encryptionVersion) : undefined; - // Mirror the bulkAcceptShare BATCH_SIZE guard. The per-share ECDH-derived decrypt secret is - // still unique, so bounded WASM instances matter on the decrypt side when the sender's - // keychain is v2. - const BATCH_SIZE = 16; - let response: BulkUpdateWalletShareResponse; let failedUpdates: Array<{ walletShareId: string; reason: string }> = []; try { const settledUpdates: PromiseSettledResult[] = []; - for (const batch of _.chunk(resolvedShares, BATCH_SIZE)) { + for (const batch of _.chunk(resolvedShares, BULK_SHARE_BATCH_SIZE)) { const batchResults = await Promise.allSettled( batch.map(async (share) => { const { walletShareId, status, walletShare } = share;