From ef086dad2b046b18d7e0ae7f8d5e2e36b55226fc Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Mon, 20 Jul 2026 13:38:11 -0400 Subject: [PATCH] feat(sdk-core): extend upgradeEncryption to support OFC wallets - handle variable key count (OFC has 2 keys, no backup) - skip PDF when backup/bitgo keychains absent - include pub in user key PUT for OFC server validation Ticket: WAL-1694 --- modules/bitgo/test/v2/unit/wallet.ts | 118 +++++++++++++++----- modules/sdk-core/src/bitgo/wallet/wallet.ts | 26 ++--- scripts/upgrade-wallet-encryption.ts | 5 + 3 files changed, 109 insertions(+), 40 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index ad0737df48..81013ade88 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -6882,9 +6882,9 @@ describe('V2 Wallet:', function () { const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); nockUnlock(); - nockKeychain(userKeyId, { encryptedPrv: userEnc }); - nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); const puts: Array<{ url: string; body: Record }> = []; nock(bgUrl) @@ -6933,9 +6933,9 @@ describe('V2 Wallet:', function () { const boxB = await bitgo.encrypt({ input: 'backupReducedPrv', password: passphrase, encryptionVersion: 1 }); nockUnlock(); - nockKeychain(userKeyId, { encryptedPrv: userEnc }); - nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); nock(bgUrl).put(`/api/v2/tbtc/key/${backupKeyId}`).reply(200, {}); @@ -6969,9 +6969,9 @@ describe('V2 Wallet:', function () { const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); nockUnlock(); - nockKeychain(userKeyId, { encryptedPrv: userV2 }); - nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV2 }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); const puts: string[] = []; // Only the backup key should be PUT — user is already v2. @@ -6992,9 +6992,9 @@ describe('V2 Wallet:', function () { const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); nockUnlock(); - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, {}); // public key only - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup' }); // public key only + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); @@ -7006,9 +7006,9 @@ describe('V2 Wallet:', function () { const boxB = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); nockUnlock(); - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, {}); // no encryptedPrv server-side - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup' }); // no encryptedPrv server-side + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); await wallet.upgradeEncryption({ passphrase, boxB, passcodeEncryptionCode: 'pec' }); @@ -7023,9 +7023,9 @@ describe('V2 Wallet:', function () { nockUnlock(); nockPecFetch(pec); - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); // No passphrase — must be derived from boxD. @@ -7037,9 +7037,9 @@ describe('V2 Wallet:', function () { const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); // No unlock, no PEC, no PUTs expected. - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); const result = await wallet.upgradeEncryption({ passphrase, dryRun: true }); assert.strictEqual(result, undefined); @@ -7058,9 +7058,9 @@ describe('V2 Wallet:', function () { const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); nock(bgUrl).post('/api/v1/user/unlock').reply(401, { error: 'Session already unlocked longer' }); - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); @@ -7072,12 +7072,76 @@ describe('V2 Wallet:', function () { nockUnlock(); // Intentionally do NOT nock passcoderecovery — the test fails if the code tries to call it. - nockKeychain(userKeyId, { encryptedPrv: userV1 }); - nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); - nockKeychain(bitgoKeyId, {}); + nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, { source: 'bitgo' }); nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-supplied' }); }); + + describe('OFC wallets (2 keys, no backup)', function () { + const ofcCoin = bitgo.coin('ofc'); + const ofcUserKeyId = 'ofc00000000000000000000000000001'; + const ofcBitgoKeyId = 'ofc00000000000000000000000000002'; + const ofcWalletData = { + id: 'ofc0000000000000000000000wallet1', + coin: 'ofc', + keys: [ofcUserKeyId, ofcBitgoKeyId], + coinSpecific: {}, + multisigType: 'onchain' as const, + type: 'hot' as const, + }; + const ofcWallet = new Wallet(bitgo, ofcCoin, ofcWalletData as any); + + function nockOfcKeychain(id: string, keychain: Record) { + return nock(bgUrl) + .get(`/api/v2/ofc/key/${id}`) + .reply(200, { id, pub: 'pub', type: 'independent', ...keychain }); + } + + it('re-encrypts the user key and skips bitgo key with no encryptedPrv', async function () { + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userEnc }); + nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' }); + + const puts: Array<{ id: string; body: Record }> = []; + nock(bgUrl) + .put(`/api/v2/ofc/key/${ofcUserKeyId}`, (body) => { + puts.push({ id: ofcUserKeyId, body }); + return true; + }) + .reply(200, {}); + + const result = await ofcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + assert.strictEqual(result, undefined); + puts.should.have.length(1); + puts[0].id.should.equal(ofcUserKeyId); + JSON.parse(puts[0].body.encryptedPrv as string).v.should.equal(2); + }); + + it('skips user key already at v2', async function () { + const userV2 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 2 }); + + nockUnlock(); + nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userV2 }); + nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' }); + + await ofcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + // No PUTs expected — nock will error if any unexpected call is made. + }); + + it('makes no PUT calls in dry-run mode', async function () { + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + + nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userEnc }); + nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' }); + + const result = await ofcWallet.upgradeEncryption({ passphrase, dryRun: true }); + assert.strictEqual(result, undefined); + }); + }); }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index aa579a9433..91a2ba63da 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3486,11 +3486,10 @@ export class Wallet implements IWallet { } const keyIds = this.keyIds(); - const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ - keychainsApi.get({ id: keyIds[0] }), - keychainsApi.get({ id: keyIds[1] }), - keychainsApi.get({ id: keyIds[2] }), - ]); + const allKeychains = await Promise.all(keyIds.map((id) => keychainsApi.get({ id }))); + const userKeychain = allKeychains.find((k) => k.source === 'user') ?? allKeychains[KeyIndices.USER]; + const backupKeychain = allKeychains.find((k) => k.source === 'backup'); + const bitgoKeychain = allKeychains.find((k) => k.source === 'bitgo'); const updated: Array<{ type: string; id: string }> = []; const skipped: Array<{ type: string; reason: string }> = []; @@ -3507,7 +3506,7 @@ export class Wallet implements IWallet { if (!dryRun) { await this.bitgo .put(this.baseCoin.url(`/key/${encodeURIComponent(userKeychain.id)}`)) - .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv }) + .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv, pub: userKeychain.pub }) .result(); } updated.push({ type: 'user', id: userKeychain.id }); @@ -3519,8 +3518,8 @@ export class Wallet implements IWallet { // Re-encrypt backup key. May be encrypted under the original passphrase if the wallet // password was changed after creation — fall back to originalPassphrase if current fails. // Source: server-stored encryptedPrv (preferred) or boxB for older/keycard-only wallets. - const serverStored = !!backupKeychain.encryptedPrv; - const backupSource = backupKeychain.encryptedPrv ?? boxB; + const serverStored = !!backupKeychain?.encryptedPrv; + const backupSource = backupKeychain?.encryptedPrv ?? boxB; if (backupSource) { if (keychainsApi.getEncryptionVersion(backupSource) === 2) { skipped.push({ type: 'backup', reason: 'already v2' }); @@ -3531,15 +3530,16 @@ export class Wallet implements IWallet { originalPassphrase, 'backup key' ); - backupKeychain.encryptedPrv = newEncryptedPrv; - if (!dryRun && serverStored) { + if (backupKeychain) backupKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun && serverStored && backupKeychain) { // Only PUT if the key was server-stored; boxB-only wallets have no server record. await this.bitgo .put(this.baseCoin.url(`/key/${encodeURIComponent(backupKeychain.id)}`)) .send({ encryptedPrv: newEncryptedPrv }) .result(); } - updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupKeychain.id }); + const backupId = backupKeychain?.id ?? boxB ?? 'unknown'; + updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupId }); } } else { skipped.push({ type: 'backup', reason: 'public key only' }); @@ -3573,7 +3573,7 @@ export class Wallet implements IWallet { ); updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); } - if (boxB && backupKeychain.encryptedPrv) { + if (boxB && backupKeychain?.encryptedPrv) { // MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not. // Re-encrypt boxB so the new keycard uses the reduced form instead of the full blob. backupKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey( @@ -3585,7 +3585,7 @@ export class Wallet implements IWallet { updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); } - if (!generatePdf) { + if (!generatePdf || !backupKeychain || !bitgoKeychain) { console.log('Done (no PDF generator provided).'); return undefined; } diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts index 001ba4ea90..31c6f38608 100644 --- a/scripts/upgrade-wallet-encryption.ts +++ b/scripts/upgrade-wallet-encryption.ts @@ -16,6 +16,11 @@ * [--passcodeEncryptionCode ] \ * [--dry-run] * + * OFC wallets (--coin ofc): + * Only --passphrase is required. --boxD / --boxA / --boxB do not apply. + * No keycard PDF is produced. The script iterates all key IDs on the wallet + * and re-encrypts any that still use v1 (SJCL) encryption. + * * --accessToken: Short-lived BitGo access token. Generate this using the following guide * https://developers.bitgo.com/docs/get-started-access-tokens#1-create-short-lived-access-token * --boxD: Box D from the original keycard. Required if the wallet passphrase has been changed since the