From 3dbc8aabc6aa48961e202c15e75cd2fd80a4be6c Mon Sep 17 00:00:00 2001 From: Bahaa Desoky Date: Fri, 10 Jul 2026 10:45:50 -0400 Subject: [PATCH 1/4] feat: add script to reencrypt wallet with v2 encryption Ticket: WCN-174 --- modules/key-card/src/drawKeycard.ts | 26 +- scripts/upgrade-wallet-encryption.ts | 382 +++++++++++++++++++++++++++ 2 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 scripts/upgrade-wallet-encryption.ts diff --git a/modules/key-card/src/drawKeycard.ts b/modules/key-card/src/drawKeycard.ts index f60322b0ee..70b00f4e44 100644 --- a/modules/key-card/src/drawKeycard.ts +++ b/modules/key-card/src/drawKeycard.ts @@ -4,6 +4,8 @@ import { IDrawKeyCard } from './types'; import { splitKeys } from './utils'; type jsPDFModule = typeof import('jspdf'); +const isNode = typeof window === 'undefined' || typeof window.document === 'undefined'; + async function loadJSPDF(): Promise { let jsPDF: jsPDFModule; @@ -60,7 +62,7 @@ function moveDown(y: number, ydelta: number): number { // continuation on a later page) and the y-offset just below the drawn QR column (so callers // can place content, e.g. a note, under the QR codes). function drawOnePageOfQrCodes( - qrImages: HTMLCanvasElement[], + qrImages: (HTMLCanvasElement | string)[], doc: jsPDF, y: number, qrSize: number, @@ -75,7 +77,11 @@ function drawOnePageOfQrCodes( return { nextIndex: qrIndex, endY: y }; } - doc.addImage(image, left(0), y, qrSize, qrSize); + if (typeof image === 'string') { + doc.addImage(image, 'PNG', left(0), y, qrSize, qrSize); + } else { + doc.addImage(image, left(0), y, qrSize, qrSize); + } if (qrImages.length === 1) { return { nextIndex: qrIndex + 1, endY: y + qrSize }; @@ -135,7 +141,13 @@ export async function drawKeycard({ if (keyCardImage) { const [imgWidth, imgHeight] = computeKeyCardImageDimensions(keyCardImage); - doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight); + if (isNode) { + // In Node.js, jsPDF cannot extract pixels from an HTMLImageElement (no DOM/canvas). + // The script passes a duck-typed object whose .src is a base64 data URL — use it directly. + doc.addImage((keyCardImage as unknown as { src: string }).src, 'PNG', left(0), y, imgWidth, imgHeight); + } else { + doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight); + } } // Activation Code @@ -204,10 +216,14 @@ export async function drawKeycard({ const textLeft = left(qrSize + 15); let textHeight = 0; - const qrImages: HTMLCanvasElement[] = []; + const qrImages: (HTMLCanvasElement | string)[] = []; const keys = splitKeys(qr.data, QRBinaryMaxLength); for (const key of keys) { - qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + if (isNode) { + qrImages.push(await QRCode.toDataURL(key, { errorCorrectionLevel: 'L' })); + } else { + qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + } } const isMultiPart = qr?.data?.length > QRBinaryMaxLength; diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts new file mode 100644 index 0000000000..dece395213 --- /dev/null +++ b/scripts/upgrade-wallet-encryption.ts @@ -0,0 +1,382 @@ +/** + * Upgrade a wallet's keychain encryption from v1 (SJCL/PBKDF2-SHA256 + AES-256-CCM) to + * v2 (Argon2id + AES-256-GCM) and regenerate the keycard PDF. + * + * Usage: + * npx ts-node scripts/upgrade-wallet-encryption.ts \ + * --env test \ + * --coin tbtc \ + * --walletId \ + * --passphrase \ + * --accessToken \ + * [--otp ] \ + * [--boxD ] \ + * [--boxA ] \ + * [--boxB ] \ + * [--passcodeEncryptionCode ] \ + * [--dry-run] + * + * --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 + * wallet was created (i.e. the current passphrase is not the original one used at creation time). + * --boxA: Box A from the original keycard. Required for MPCv2 wallets — reducedEncryptedPrv is never stored + * server-side. Encrypted with the original passphrase, so --boxD is also required if the passphrase + * has been changed since wallet creation. + * --boxB: Box B from the original keycard. Required for MPCv2 wallets (reducedEncryptedPrv is never stored + * server-side) and older wallets where encryptedPrv was not stored server-side. Like --boxA, encrypted + * with the original passphrase — also requires --boxD if the passphrase has changed. + * --passcodeEncryptionCode: Fetched automatically from BitGo if not provided. Required to produce Box D in the new keycard. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; +import * as yargsLib from 'yargs'; +import { coins } from '@bitgo/statics'; +import { Environments, EnvironmentName } from '../modules/sdk-core/src/bitgo/environments'; +import { Keychain } from '../modules/sdk-core/src/bitgo/keychain'; +import { generateQrData } from '../modules/key-card/src/generateQrData'; +import { generateFaq } from '../modules/key-card/src/faq'; +import { drawKeycard } from '../modules/key-card/src/drawKeycard'; +import { BitGo } from '../modules/bitgo/dist/src/bitgo'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Determine the encryption version of a ciphertext by inspecting the "v" field. + * v1 = SJCL (PBKDF2-SHA256 + AES-256-CCM), no "v" field or v=1 + * v2 = Argon2id + AES-256-GCM, v=2 + */ +function getEncryptionVersion(ciphertext: string): 1 | 2 { + try { + const envelope = JSON.parse(ciphertext); + if (envelope.v === 2) { + return 2; + } + // SJCL envelopes have no "v" field or v=1 + if (!envelope.v || envelope.v === 1) { + return 1; + } + throw new Error(`Unrecognized encryption version: ${envelope.v}`); + } catch (e) { + throw new Error(`Failed to parse ciphertext envelope: ${e.message}`); + } +} + +/** + * Decrypt a v1 ciphertext and re-encrypt it as v2. + * Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails. + */ +async function reencryptAsV2( + bitgo: BitGo, + encryptedPrv: string, + passphrase: string, + originalPassphrase: string | undefined +): Promise { + let prv: string; + try { + prv = await bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + } catch { + if (originalPassphrase) { + prv = await bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); + } else { + throw new Error( + 'Failed to decrypt with the provided passphrase. ' + + 'If the wallet password was changed after creation, provide --boxD so the original passphrase can be recovered.' + ); + } + } + // Always re-encrypt with the current passphrase, regardless of which passphrase decrypted it. + return bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); +} + +/** + * Fetch the passcodeEncryptionCode for a wallet from the BitGo passcoderecovery endpoint. + * This is the symmetric key used to encrypt the wallet passphrase into Box D on the keycard. + * It is required to produce a Box D entry in the regenerated keycard. + */ +async function getPasscodeEncryptionCode(bitgo: BitGo, coin: string, walletId: string): Promise { + const { recoveryInfo } = await bitgo + .post(bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`)) + .result(); + if (!recoveryInfo || typeof recoveryInfo.passcodeEncryptionCode !== 'string') { + throw new Error( + 'passcoderecovery endpoint did not return a passcodeEncryptionCode — pass --passcodeEncryptionCode manually' + ); + } + return recoveryInfo.passcodeEncryptionCode; +} + +/** + * Fetch the coin logo image and return a duck-typed HTMLImageElement-compatible object. + * jsPDF reads `.src` in Node.js; computeKeyCardImageDimensions reads `.width`/`.height`. + * This replicates what the BitGo UI does before calling drawKeycard. + */ +async function loadKeycardImage(url: string): Promise { + return new Promise((resolve) => { + https + .get(url, (res) => { + if (res.statusCode !== 200) { + console.warn(`Warning: coin logo not loaded (HTTP ${res.statusCode}) — keycard will be generated without it`); + resolve(undefined); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const contentType = res.headers['content-type'] ?? 'image/png'; + const dataUrl = `data:${contentType};base64,${Buffer.concat(chunks).toString('base64')}`; + // jsPDF reads .src; computeKeyCardImageDimensions reads .width / .height + const img = { src: dataUrl, width: 303, height: 40 } as unknown as HTMLImageElement; + resolve(img); + }); + res.on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }) + .on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }); +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const argv = yargsLib + .option('env', { type: 'string', default: 'test', description: 'BitGo environment (test, prod, …)' }) + .option('coin', { type: 'string', demandOption: true, description: 'Coin ticker (e.g. tbtc, teth)' }) + .option('walletId', { type: 'string', demandOption: true, description: 'Wallet ID' }) + .option('passphrase', { type: 'string', demandOption: true, description: 'Current wallet passphrase' }) + .option('accessToken', { type: 'string', demandOption: true, description: 'Short-lived BitGo access token' }) + .option('otp', { type: 'string', description: 'OTP for session unlock' }) + .option('boxD', { type: 'string', description: 'Box D ciphertext from the original keycard' }) + .option('boxA', { type: 'string', description: 'Box A ciphertext from the original keycard (MPCv2)' }) + .option('boxB', { type: 'string', description: 'Box B ciphertext from the original keycard' }) + .option('passcodeEncryptionCode', { + type: 'string', + description: 'Passcode encryption code (fetched automatically if omitted)', + }) + .option('dry-run', { type: 'boolean', default: false, description: 'Validate without persisting changes' }) + .parseSync(); + + return { + env: argv.env as EnvironmentName, + coin: argv.coin, + walletId: argv.walletId, + passphrase: argv.passphrase, + accessToken: argv.accessToken, + otp: argv.otp, + boxD: argv.boxD, + boxA: argv.boxA, + boxB: argv.boxB, + passcodeEncryptionCode: argv.passcodeEncryptionCode, + dryRun: argv['dry-run'], + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const { + env, + coin, + walletId, + passphrase, + accessToken, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode: pecArg, + dryRun, + } = parseArgs(); + + if (dryRun) console.log('[dry-run] No changes will be persisted.'); + + const bitgo = new BitGo({ env }); + bitgo.authenticateWithAccessToken({ accessToken }); + + if (!dryRun) { + await bitgo.unlock({ otp: otp ?? '0000000', duration: 600 }); + console.log('Session unlocked.'); + } + + const wallet = await bitgo.coin(coin).wallets().get({ id: walletId }); + console.log(`Wallet: ${wallet.label()} (${walletId})`); + + // Fetch passcodeEncryptionCode — needed to decrypt Box D (when provided) and to generate Box D + // in the new keycard. Always fetched when boxD is provided; otherwise skipped in dry-run. + const needsPec = boxD || !dryRun; + const passcodeEncryptionCode: string | undefined = + pecArg ?? (needsPec ? await getPasscodeEncryptionCode(bitgo, coin, walletId) : undefined); + + if (!passcodeEncryptionCode && !dryRun) { + throw new Error( + 'passcodeEncryptionCode is required — pass --passcodeEncryptionCode or ensure the endpoint is accessible' + ); + } + + // If the wallet password was changed after creation, Box D from the original keycard contains + // the original passphrase encrypted with the passcodeEncryptionCode. Decrypt it to recover the + // passphrase that was used to encrypt the backup key at wallet creation time. + let originalPassphrase: string | undefined; + if (boxD && passcodeEncryptionCode) { + originalPassphrase = await bitgo.decrypt({ + input: boxD.replace(/\s/g, ''), + password: passcodeEncryptionCode, + }); + console.log('Recovered original passphrase from Box D.'); + } + + // Fetch all three keychains + const keyIds = wallet.keyIds(); + const [userKeychain, backupKeychain, bitgoKeychain] = (await Promise.all([ + bitgo.coin(coin).keychains().get({ id: keyIds[0] }), + bitgo.coin(coin).keychains().get({ id: keyIds[1] }), + bitgo.coin(coin).keychains().get({ id: keyIds[2] }), + ])) as [Keychain, Keychain, Keychain]; + + const updated: Array<{ type: string; id: string }> = []; + const skipped: Array<{ type: string; reason: string }> = []; + + // ------------------------------------------------------------------ + // Re-encrypt user key + // The user key is always encrypted with the current passphrase. + // We also set originalEncryptedPrv = encryptedPrv so that BitGo's + // server-side password-change flow remains consistent after this upgrade. + // ------------------------------------------------------------------ + if (userKeychain.encryptedPrv) { + if (getEncryptionVersion(userKeychain.encryptedPrv) === 2) { + skipped.push({ type: 'user', reason: 'already v2' }); + } else { + const userPrv = await bitgo.decrypt({ input: userKeychain.encryptedPrv, password: passphrase }); + const newEncryptedPrv = await bitgo.encrypt({ input: userPrv, password: passphrase, encryptionVersion: 2 }); + userKeychain.encryptedPrv = newEncryptedPrv; + + if (!dryRun) { + await bitgo + .put(bitgo.coin(coin).url(`/key/${encodeURIComponent(userKeychain.id)}`)) + .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv }) + .result(); + } + updated.push({ type: 'user', id: userKeychain.id }); + } + } else { + skipped.push({ type: 'user', reason: 'no encryptedPrv' }); + } + + // ------------------------------------------------------------------ + // Re-encrypt backup key + // The backup key may be encrypted under the original passphrase if the + // wallet password was changed after creation (see design notes above). + // If decryption with the current passphrase fails and --boxD was provided, + // we retry with the recovered original passphrase. + // ------------------------------------------------------------------ + // Source of truth for the backup key ciphertext: server-stored encryptedPrv, or --boxB for older wallets + const serverStored = !!backupKeychain.encryptedPrv; + const backupSource = backupKeychain.encryptedPrv ?? boxB; + if (backupSource) { + if (getEncryptionVersion(backupSource) === 2) { + skipped.push({ type: 'backup', reason: 'already v2' }); + } else { + const newEncryptedPrv = await reencryptAsV2(bitgo, backupSource, passphrase, originalPassphrase); + backupKeychain.encryptedPrv = newEncryptedPrv; + + if (!dryRun && serverStored) { + // Only PUT if the key was server-stored (boxB-only keys have no server record to update) + await bitgo + .put(bitgo.coin(coin).url(`/key/${encodeURIComponent(backupKeychain.id)}`)) + .send({ encryptedPrv: newEncryptedPrv }) + .result(); + } + updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupKeychain.id }); + } + } else { + skipped.push({ type: 'backup', reason: 'no encryptedPrv' }); + } + + if (dryRun) { + console.log('Skipped (dry-run):'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + console.log('Would re-encrypt:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + console.log('[dry-run] Done — no changes persisted.'); + return; + } + + if (updated.length > 0) { + console.log('Re-encrypted:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + } + if (skipped.length > 0) { + console.log('Skipped:'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + } + + // ------------------------------------------------------------------ + // Re-encrypt boxA (MPCv2 reducedEncryptedPrv — keycard-only) + // ------------------------------------------------------------------ + if (boxA) { + userKeychain.reducedEncryptedPrv = await reencryptAsV2(bitgo, boxA, passphrase, originalPassphrase); + updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); + } + + if (boxB && backupKeychain.encryptedPrv) { + // MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not. + // Re-encrypt boxB and set it so generateQrData uses it instead of the full encryptedPrv blob. + backupKeychain.reducedEncryptedPrv = await reencryptAsV2(bitgo, boxB, passphrase, originalPassphrase); + updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); + } + + // ------------------------------------------------------------------ + // Regenerate keycard PDF + // Mirrors the UI flow: generateQrData → generateFaq → drawKeycard → save PDF. + // The coin logo is fetched from the BitGo web app and passed to drawKeycard + // as a duck-typed HTMLImageElement (jsPDF reads .src; drawKeycard reads .width/.height). + // ------------------------------------------------------------------ + if (!passcodeEncryptionCode) { + console.warn('Skipping keycard generation — passcodeEncryptionCode not available.'); + console.log('Done.'); + return; + } + + const staticsCoin = coins.get(coin); + const walletLabel = wallet.label(); + // The keycard image asset uses the coin family name (e.g. "sol" for both sol and tsol) + const baseUrl = Environments[env].uri; + const keyCardImage = await loadKeycardImage(`${baseUrl}/web/assets/keycards/${staticsCoin.family.toLowerCase()}.png`); + + const qrData = await generateQrData({ + coin: staticsCoin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + encryptionVersion: 2, + }); + + const questions = generateFaq(staticsCoin.fullName); + const doc = await drawKeycard({ qrData, questions, walletLabel, keyCardImage }); + + const outputPath = path.resolve(process.cwd(), `BitGo Keycard for ${walletLabel}.pdf`); + fs.writeFileSync(outputPath, Buffer.from(doc.output('arraybuffer'))); + console.log(`Keycard PDF saved to: ${outputPath}`); + + console.log('Done.'); +} + +main().catch((err) => { + console.error('Fatal:', err.message ?? err); + process.exit(1); +}); From 16293657a5d257bca109890cad4fbc3b37181fad Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Fri, 10 Jul 2026 14:16:32 -0400 Subject: [PATCH 2/4] feat(scripts): improve upgrade-wallet-encryption robustness WCN-174 - Fail early for MPCv2 wallets when --boxA/--boxB not provided to avoid generating incomplete keycards - Handle already-unlocked session gracefully instead of crashing - Strip whitespace from box values to handle PDF line-wrapped JSON TICKET: WCN-174 --- scripts/upgrade-wallet-encryption.ts | 29 ++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts index dece395213..feafa843e0 100644 --- a/scripts/upgrade-wallet-encryption.ts +++ b/scripts/upgrade-wallet-encryption.ts @@ -8,7 +8,7 @@ * --coin tbtc \ * --walletId \ * --passphrase \ - * --accessToken \ + * --accessToken \ * [--otp ] \ * [--boxD ] \ * [--boxA ] \ @@ -157,9 +157,9 @@ function parseArgs() { .option('passphrase', { type: 'string', demandOption: true, description: 'Current wallet passphrase' }) .option('accessToken', { type: 'string', demandOption: true, description: 'Short-lived BitGo access token' }) .option('otp', { type: 'string', description: 'OTP for session unlock' }) - .option('boxD', { type: 'string', description: 'Box D ciphertext from the original keycard' }) - .option('boxA', { type: 'string', description: 'Box A ciphertext from the original keycard (MPCv2)' }) - .option('boxB', { type: 'string', description: 'Box B ciphertext from the original keycard' }) + .option('boxD', { type: 'string', description: 'Box D ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) + .option('boxA', { type: 'string', description: 'Box A ciphertext from the original keycard (MPCv2)', coerce: (v: string) => v?.replace(/\s/g, '') }) + .option('boxB', { type: 'string', description: 'Box B ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) .option('passcodeEncryptionCode', { type: 'string', description: 'Passcode encryption code (fetched automatically if omitted)', @@ -204,16 +204,33 @@ async function main() { if (dryRun) console.log('[dry-run] No changes will be persisted.'); const bitgo = new BitGo({ env }); + bitgo.authenticateWithAccessToken({ accessToken }); if (!dryRun) { - await bitgo.unlock({ otp: otp ?? '0000000', duration: 600 }); - console.log('Session unlocked.'); + try { + await bitgo.unlock({ otp: otp ?? '0000000', duration: 600 }); + console.log('Session unlocked.'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already unlocked longer')) { + console.log('Session already unlocked (longer duration) — proceeding.'); + } else { + throw err; + } + } } const wallet = await bitgo.coin(coin).wallets().get({ id: walletId }); console.log(`Wallet: ${wallet.label()} (${walletId})`); + if (wallet.multisigTypeVersion() === 'MPCv2' && (!boxA || !boxB)) { + throw new Error( + 'This is an MPCv2 wallet. --boxA and --boxB are required to re-encrypt the reducedEncryptedPrv ' + + 'key shares stored on the keycard. Without them the new keycard will be missing Box A and Box B.' + ); + } + // Fetch passcodeEncryptionCode — needed to decrypt Box D (when provided) and to generate Box D // in the new keycard. Always fetched when boxD is provided; otherwise skipped in dry-run. const needsPec = boxD || !dryRun; From 8b6909e717a290151eb42600825d69c644486e4f Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Mon, 13 Jul 2026 16:52:58 -0400 Subject: [PATCH 3/4] feat(sdk-core): add argon2 re-encryption to sdk-core (WCN-174) Adds a wallet encryption upgrade path from v1 (SJCL / PBKDF2-SHA256 + AES-256-CCM) to v2 (Argon2id + AES-256-GCM), invokable via the SDK and driven by a CLI script. sdk-core: - Keychains.getEncryptionVersion: now public, throws on unknown versions - Keychains.reencryptAsV2: new instance method for v1 to v2 upgrade - Wallet.upgradeEncryption: orchestrates the full flow with an injectable PDF generator for testability key-card: - Exports loadKeycardImage and createKeycardPdfGenerator factory (returns an UpgradeEncryptionPdfGenerator compatible with the wallet method) scripts/upgrade-wallet-encryption.ts: - Thin CLI wrapper around Wallet.upgradeEncryption - Handles MPCv2 (boxA/boxB), boxD-derived passphrase, dry-run, already-unlocked session, and PDF regeneration TICKET: WCN-174 --- modules/bitgo/test/v2/unit/keychains.ts | 106 ++++++ modules/bitgo/test/v2/unit/wallet.ts | 234 ++++++++++++ modules/key-card/src/index.ts | 1 + .../key-card/src/upgradeWalletEncryption.ts | 89 +++++ .../test/unit/upgradeWalletEncryption.ts | 95 +++++ .../sdk-core/src/bitgo/keychain/iKeychains.ts | 2 + .../sdk-core/src/bitgo/keychain/keychains.ts | 54 ++- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 46 +++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 217 +++++++++++ scripts/upgrade-wallet-encryption.ts | 336 +----------------- 10 files changed, 848 insertions(+), 332 deletions(-) create mode 100644 modules/key-card/src/upgradeWalletEncryption.ts create mode 100644 modules/key-card/test/unit/upgradeWalletEncryption.ts diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 79bd92a001..89f8c6f2c5 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -1135,4 +1135,110 @@ describe('V2 Keychains', function () { ); }); }); + + describe('getEncryptionVersion', function () { + it('returns 1 when the v field is absent', function () { + const envelope = JSON.stringify({ ct: 'cipher', iv: 'iv', s: 'salt' }); + keychains.getEncryptionVersion(envelope).should.equal(1); + }); + + it('returns 1 when v is explicitly 1', function () { + keychains.getEncryptionVersion(JSON.stringify({ v: 1, ct: 'abc' })).should.equal(1); + }); + + it('returns 2 when v is 2', function () { + keychains.getEncryptionVersion(JSON.stringify({ v: 2, ct: 'abc' })).should.equal(2); + }); + + it('throws on an unrecognized version number', function () { + assert.throws( + () => keychains.getEncryptionVersion(JSON.stringify({ v: 3, ct: 'abc' })), + /Unrecognized encryption version: 3/ + ); + }); + + it('throws when the input is not valid JSON', function () { + assert.throws(() => keychains.getEncryptionVersion('not-json'), /Failed to parse ciphertext envelope/); + }); + + it('throws when the input is an empty string', function () { + assert.throws(() => keychains.getEncryptionVersion(''), /Failed to parse ciphertext envelope/); + }); + + it('handles a real SJCL v1 envelope', function () { + const sjcl = JSON.stringify({ + iv: 'aaaa', + v: 1, + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + adata: '', + cipher: 'aes', + salt: 'ssss', + ct: 'cccc', + }); + keychains.getEncryptionVersion(sjcl).should.equal(1); + }); + + it('handles a real Argon2id v2 envelope', function () { + const argon2 = JSON.stringify({ + v: 2, + ct: 'cccc', + iv: 'iiii', + tag: 'tttt', + salt: 'ssss', + m: 65536, + t: 3, + p: 4, + }); + keychains.getEncryptionVersion(argon2).should.equal(2); + }); + }); + + describe('reencryptAsV2', function () { + it('decrypts a v1 envelope with the current passphrase and re-encrypts as v2', async function () { + const prv = 'thePrivateKey'; + const encryptedV1 = await bitgo.encrypt({ input: prv, password: 'myPass', encryptionVersion: 1 }); + JSON.parse(encryptedV1).v.should.equal(1); + + const result = await keychains.reencryptAsV2(encryptedV1, 'myPass'); + JSON.parse(result).v.should.equal(2); + (await bitgo.decrypt({ input: result, password: 'myPass' })).should.equal(prv); + }); + + it('falls back to originalPassphrase when the current passphrase fails to decrypt', async function () { + const prv = 'thePrivateKey'; + const encryptedWithOriginal = await bitgo.encrypt({ + input: prv, + password: 'originalPass', + encryptionVersion: 1, + }); + + const result = await keychains.reencryptAsV2(encryptedWithOriginal, 'currentPass', 'originalPass'); + JSON.parse(result).v.should.equal(2); + (await bitgo.decrypt({ input: result, password: 'currentPass' })).should.equal(prv); + await bitgo.decrypt({ input: result, password: 'originalPass' }).should.be.rejected(); + }); + + it('throws with a helpful message when decryption fails and no originalPassphrase is provided', async function () { + const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); + await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejectedWith(/original passphrase/); + }); + + it('throws when both the current and original passphrases fail', async function () { + const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); + await keychains.reencryptAsV2(encrypted, 'wrongCurrent', 'alsoWrong').should.be.rejected(); + }); + + it('accepts a v2 envelope and re-encrypts it as v2 (idempotent)', async function () { + const prv = 'xprv-v2'; + const encryptedV2 = await bitgo.encrypt({ input: prv, password: 'pass', encryptionVersion: 2 }); + JSON.parse(encryptedV2).v.should.equal(2); + + const result = await keychains.reencryptAsV2(encryptedV2, 'pass'); + JSON.parse(result).v.should.equal(2); + (await bitgo.decrypt({ input: result, password: 'pass' })).should.equal(prv); + }); + }); }); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 761fb25056..ad0737df48 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -6846,4 +6846,238 @@ describe('V2 Wallet:', function () { }); }); }); + + describe('upgradeEncryption', function () { + const walletId = walletData.id; + const [userKeyId, backupKeyId, bitgoKeyId] = walletData.keys; + const passphrase = 'walletPassphrase'; + + beforeEach(function () { + nock.cleanAll(); + }); + + afterEach(function () { + // Prevent leftover interceptors from bleeding into other suites in the file. + nock.cleanAll(); + }); + + function nockUnlock(times = 1) { + return nock(bgUrl).post('/api/v1/user/unlock').times(times).reply(200, {}); + } + + function nockKeychain(id: string, keychain: Record) { + return nock(bgUrl) + .get(`/api/v2/tbtc/key/${id}`) + .reply(200, { id, pub: 'pub', type: 'independent', ...keychain }); + } + + function nockPecFetch(pec: string) { + return nock(bgUrl) + .post(`/api/v2/tbtc/wallet/${walletId}/passcoderecovery`) + .reply(200, { recoveryInfo: { passcodeEncryptionCode: pec } }); + } + + it('re-encrypts both user and backup keys and PUTs them as v2', async function () { + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, {}); + + const puts: Array<{ url: string; body: Record }> = []; + nock(bgUrl) + .put(`/api/v2/tbtc/key/${userKeyId}`, (body) => { + puts.push({ url: userKeyId, body }); + return true; + }) + .reply(200, {}); + nock(bgUrl) + .put(`/api/v2/tbtc/key/${backupKeyId}`, (body) => { + puts.push({ url: backupKeyId, body }); + return true; + }) + .reply(200, {}); + + const result = await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-xyz' }); + + assert.ok(result === undefined, 'no PDF generator → returns undefined'); + puts.should.have.length(2); + const userPut = puts.find((p) => p.url === userKeyId)!; + JSON.parse(userPut.body.encryptedPrv as string).v.should.equal(2); + JSON.parse(userPut.body.originalEncryptedPrv as string).v.should.equal(2); + }); + + it('throws when neither passphrase nor boxD is provided', async function () { + await wallet.upgradeEncryption({}).should.be.rejectedWith(/passphrase.*boxD/); + }); + + it('throws for an MPCv2 wallet when boxA or boxB are missing', async function () { + const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const }; + const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData); + + nockUnlock(); + // PEC fetch happens before the MPCv2 validation short-circuits — allow the call to noop + nockPecFetch('pec'); + + await mpcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }).should.be.rejectedWith(/MPCv2/); + }); + + it('re-encrypts boxA and boxB as reducedEncryptedPrv on the keychains for MPCv2 wallets', async function () { + const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const }; + const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData); + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + const boxA = await bitgo.encrypt({ input: 'userReducedPrv', password: passphrase, encryptionVersion: 1 }); + const boxB = await bitgo.encrypt({ input: 'backupReducedPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${backupKeyId}`).reply(200, {}); + + const capturedPdfParams: Array> = []; + const generatePdf = async (params: Record) => { + capturedPdfParams.push(params); + return { output: () => new ArrayBuffer(0) }; + }; + + await mpcWallet.upgradeEncryption({ + passphrase, + boxA, + boxB, + passcodeEncryptionCode: 'pec', + generatePdf, + }); + + capturedPdfParams.should.have.length(1); + const { userKeychain, backupKeychain } = capturedPdfParams[0] as { + userKeychain: { reducedEncryptedPrv?: string }; + backupKeychain: { reducedEncryptedPrv?: string }; + }; + assert.ok(userKeychain.reducedEncryptedPrv, 'user reducedEncryptedPrv must be set'); + JSON.parse(userKeychain.reducedEncryptedPrv!).v.should.equal(2); + assert.ok(backupKeychain.reducedEncryptedPrv, 'backup reducedEncryptedPrv must be set'); + JSON.parse(backupKeychain.reducedEncryptedPrv!).v.should.equal(2); + }); + + it('skips keychains that are already v2', async function () { + const userV2 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 2 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV2 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + + const puts: string[] = []; + // Only the backup key should be PUT — user is already v2. + nock(bgUrl) + .put(new RegExp(`/api/v2/tbtc/key/(${userKeyId}|${backupKeyId})`)) + .reply(200, function (uri) { + puts.push(uri); + return {}; + }); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + + puts.should.have.length(1); + puts[0].should.containEql(backupKeyId); + }); + + it('handles backup keychain with no encryptedPrv (public-key-only) without PUTing', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, {}); // public key only + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + // If we reached here without unhandled nock error, the flow completed without PUTing the backup. + }); + + it('re-encrypts a backup key from boxB without PUTing to the server', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const boxB = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, {}); // no encryptedPrv server-side + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, boxB, passcodeEncryptionCode: 'pec' }); + }); + + it('derives the passphrase from boxD when passphrase is omitted', async function () { + const pec = 'pec-derive'; + const derivedPass = 'derivedFromBoxD'; + const boxD = await bitgo.encrypt({ input: derivedPass, password: pec, encryptionVersion: 1 }); + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: derivedPass, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: derivedPass, encryptionVersion: 1 }); + + nockUnlock(); + nockPecFetch(pec); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + // No passphrase — must be derived from boxD. + await wallet.upgradeEncryption({ boxD }); + }); + + it('makes no PUT calls in dry-run mode and returns undefined', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + 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, {}); + + const result = await wallet.upgradeEncryption({ passphrase, dryRun: true }); + assert.strictEqual(result, undefined); + }); + + it('throws when unlock rejects with an error unrelated to session duration', async function () { + nock(bgUrl).post('/api/v1/user/unlock').replyWithError('invalid OTP'); + + await wallet + .upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }) + .should.be.rejectedWith(/invalid OTP/); + }); + + it('proceeds when unlock reports "already unlocked longer"', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + 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, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + }); + + it('uses the supplied passcodeEncryptionCode and skips the passcoderecovery fetch', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + 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, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-supplied' }); + }); + }); }); diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index 7c98d4c9f4..c9e908cded 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -14,6 +14,7 @@ export * from './extractKeycardFromPDF'; export * from './faq'; export * from './generateQrData'; export * from './parseKeycard'; +export * from './upgradeWalletEncryption'; export * from './utils'; export * from './types'; diff --git a/modules/key-card/src/upgradeWalletEncryption.ts b/modules/key-card/src/upgradeWalletEncryption.ts new file mode 100644 index 0000000000..44d40bd70e --- /dev/null +++ b/modules/key-card/src/upgradeWalletEncryption.ts @@ -0,0 +1,89 @@ +/** + * PDF-generation helpers for the wallet encryption upgrade flow. + * + * Orchestration (unlocking the session, re-encrypting keychains, PUT-ing to the server) lives on + * `Wallet.upgradeEncryption` in `@bitgo/sdk-core`. This module contains only the pieces that + * depend on `jspdf`/`qrcode` — a factory that returns a `UpgradeEncryptionPdfGenerator` the + * caller can pass into that method, plus a Node-side coin-logo loader. + */ + +import * as https from 'https'; +import { BaseCoin, coins } from '@bitgo/statics'; +import { UpgradeEncryptionPdfGenerator } from '@bitgo/sdk-core'; +import { generateQrData } from './generateQrData'; +import { generateFaq } from './faq'; +import { drawKeycard } from './drawKeycard'; + +/** + * Fetch a coin logo image and return an HTMLImageElement-compatible object. + * jsPDF reads `.src` in Node.js; computeKeyCardImageDimensions reads `.width`/`.height`. + * Returns undefined on any failure — the keycard is generated without the logo in that case. + */ +export async function loadKeycardImage( + url: string, + httpGet: typeof https.get = https.get +): Promise { + return new Promise((resolve) => { + httpGet(url, (res) => { + if (res.statusCode !== 200) { + console.warn(`Warning: coin logo not loaded (HTTP ${res.statusCode}) — keycard will be generated without it`); + resolve(undefined); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const contentType = res.headers['content-type'] ?? 'image/png'; + const dataUrl = `data:${contentType};base64,${Buffer.concat(chunks).toString('base64')}`; + const img = { src: dataUrl, width: 303, height: 40 } as unknown as HTMLImageElement; + resolve(img); + }); + res.on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }).on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }); +} + +export interface KeycardPdfGeneratorOptions { + /** Base URL for loading the coin logo (e.g. Environments[env].uri). Omit to skip the logo. */ + imageBaseUrl?: string; +} + +/** + * Build a {@link UpgradeEncryptionPdfGenerator} that regenerates the keycard PDF with the + * canonical `generateQrData` + `drawKeycard` flow. Pass the returned function into + * `Wallet.upgradeEncryption({ generatePdf })`. + */ +export function createKeycardPdfGenerator(options: KeycardPdfGeneratorOptions = {}): UpgradeEncryptionPdfGenerator { + const { imageBaseUrl } = options; + return async ({ + coinName, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + }) => { + const staticsCoin = coins.get(coinName) as BaseCoin; + const keyCardImage = imageBaseUrl + ? await loadKeycardImage(`${imageBaseUrl}/web/assets/keycards/${staticsCoin.family.toLowerCase()}.png`) + : undefined; + const qrData = await generateQrData({ + coin: staticsCoin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + encryptionVersion: 2, + }); + const questions = generateFaq(staticsCoin.fullName); + return drawKeycard({ qrData, questions, walletLabel, keyCardImage }); + }; +} diff --git a/modules/key-card/test/unit/upgradeWalletEncryption.ts b/modules/key-card/test/unit/upgradeWalletEncryption.ts new file mode 100644 index 0000000000..e7eacd6555 --- /dev/null +++ b/modules/key-card/test/unit/upgradeWalletEncryption.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import * as https from 'https'; +import { EventEmitter } from 'events'; +import 'should'; +import { loadKeycardImage } from '../../src/upgradeWalletEncryption'; + +function makeIncomingMessage(statusCode: number, contentType?: string) { + const emitter = new EventEmitter() as NodeJS.ReadableStream & { statusCode: number; headers: Record }; + emitter.statusCode = statusCode; + emitter.headers = contentType ? { 'content-type': contentType } : {}; + return emitter; +} + +describe('loadKeycardImage', function () { + it('returns an image object on a 200 response', async function () { + const imageData = Buffer.from('PNG_DATA'); + + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200, 'image/png'); + callback(res); + setImmediate(() => { + res.emit('data', imageData); + res.emit('end'); + }); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.ok(result, 'should return an image object'); + assert.ok((result as unknown as { src: string }).src.startsWith('data:image/png;base64,')); + assert.strictEqual((result as unknown as { width: number }).width, 303); + assert.strictEqual((result as unknown as { height: number }).height, 40); + }); + + it('defaults content-type to image/png when the header is absent', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200); + callback(res); + setImmediate(() => { + res.emit('data', Buffer.from('data')); + res.emit('end'); + }); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.ok((result as unknown as { src: string }).src.startsWith('data:image/png;base64,')); + }); + + it('returns undefined on a non-200 HTTP status', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(404); + callback(res); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); + + it('returns undefined on a stream error', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200); + callback(res); + setImmediate(() => res.emit('error', new Error('stream broke'))); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); + + it('returns undefined on a request-level error', async function () { + const mockGet = (_url: string, _callback: unknown) => { + const req = new EventEmitter(); + setImmediate(() => req.emit('error', new Error('ECONNREFUSED'))); + return req as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); +}); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 0a80280446..7d2859767e 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -248,6 +248,8 @@ export interface IKeychains { list(params?: ListKeychainOptions): Promise; updatePassword(params: UpdatePasswordOptions): Promise; updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Promise; + getEncryptionVersion(ciphertext: string): EncryptionVersion; + reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise; create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair; add(params?: AddKeychainOptions): Promise; createBitGo(params?: CreateBitGoOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 96a8678940..8a0e8ca71d 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -151,23 +151,25 @@ export class Keychains implements IKeychains { } /** - * Helper function to determine the encryption version of a ciphertext by parsing it as JSON and checking the "v" field. - * Return undefined if the ciphertext is not a valid JSON or does not contain a supported "v" field. + * Determine the encryption version of a ciphertext by inspecting its "v" field. + * - v1 (SJCL / PBKDF2-SHA256 + AES-256-CCM): "v" absent or explicitly 1 + * - v2 (Argon2id + AES-256-GCM): "v" === 2 + * Throws on unrecognized values so callers cannot silently mis-handle unknown envelopes. */ - private getEncryptionVersion(ciphertext: string): EncryptionVersion | undefined { + getEncryptionVersion(ciphertext: string): EncryptionVersion { + let envelope: { v?: unknown }; try { - const envelope = JSON.parse(ciphertext); - switch (envelope.v) { - case 1: - return 1; - case 2: - return 2; - default: - return undefined; - } - } catch (_) { - return undefined; + envelope = JSON.parse(ciphertext); + } catch (e) { + throw new Error(`Failed to parse ciphertext envelope: ${(e as Error).message}`); + } + if (envelope.v === 2) { + return 2; + } + if (envelope.v === undefined || envelope.v === 1) { + return 1; } + throw new Error(`Unrecognized encryption version: ${String(envelope.v)}`); } /** @@ -209,6 +211,30 @@ export class Keychains implements IKeychains { } } + /** + * Decrypt an encrypted private key and re-encrypt it as a v2 (Argon2id + AES-256-GCM) envelope. + * Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails. + * The result is always encrypted with `passphrase` (the current one), never the original. + * + * Used to upgrade legacy v1 (SJCL) envelopes to v2 without changing the passphrase. + */ + async reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise { + let prv: string; + try { + prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + } catch { + if (originalPassphrase) { + prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); + } else { + throw new Error( + 'Failed to decrypt with the provided passphrase. ' + + 'If the wallet password was changed after creation, provide the original passphrase so the backup key can be decrypted.' + ); + } + } + return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); + } + /** * Create a public/private key pair * @param params - optional params diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 99a3314752..06b30f01d3 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1077,6 +1077,51 @@ export interface RemovePolicyRuleOptions { message?: string; } +/** + * Callback invoked by {@link IWallet.upgradeEncryption} to generate the regenerated keycard PDF. + * The wallet method itself is agnostic to how the PDF is produced — callers wire in the PDF + * generator (typically the one exported from `@bitgo/key-card`) so `sdk-core` does not need + * a browser/canvas dependency at build time. + */ +export type UpgradeEncryptionPdfGenerator = (params: { + coinName: string; + userKeychain: Keychain; + backupKeychain: Keychain; + bitgoKeychain: Keychain; + passphrase: string; + passcodeEncryptionCode: string; + walletLabel: string; +}) => Promise; + +export interface UpgradeEncryptionOptions { + /** + * Current wallet passphrase. Omit when {@link boxD} is provided and the passphrase has never + * been changed — the passphrase is derived by decrypting Box D with the PEC. + */ + passphrase?: string; + otp?: string; + /** + * Box D ciphertext from the original keycard. + * Required when {@link passphrase} is omitted (to derive it). + * Also required when the passphrase was changed after wallet creation (to recover the original + * passphrase for decrypting the backup key). + */ + boxD?: string; + /** Box A ciphertext — required for MPCv2 wallets (user reducedEncryptedPrv). */ + boxA?: string; + /** Box B ciphertext — required for MPCv2 wallets and older wallets without server-stored backup key. */ + boxB?: string; + passcodeEncryptionCode?: string; + dryRun?: boolean; + /** Optional callback for regenerating the keycard PDF. When omitted, no PDF is produced. */ + generatePdf?: UpgradeEncryptionPdfGenerator; +} + +export interface UpgradeEncryptionResult { + doc: unknown; + walletLabel: string; +} + export interface DownloadKeycardOptions { jsPDF?: any; QRCode?: any; @@ -1206,6 +1251,7 @@ export interface IWallet { toGoStakingWallet(): IGoStakingWallet; toAddressBook(): IAddressBook; downloadKeycard(params?: DownloadKeycardOptions): Promise; + upgradeEncryption(params: UpgradeEncryptionOptions): Promise; buildAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; sendAccountConsolidation(params?: PrebuildAndSignTransactionOptions): Promise; sendAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index a5cb336524..94b4c8900f 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -126,6 +126,8 @@ import { UpdateAddressOptions, UpdateBuildDefaultOptions, UpdateWalletOptions, + UpgradeEncryptionOptions, + UpgradeEncryptionResult, WalletCoinSpecific, WalletData, WalletEcdsaChallenges, @@ -3384,6 +3386,221 @@ export class Wallet implements IWallet { doc.save(`BitGo Keycard for ${walletLabel}.pdf`); } + /** + * Upgrade this wallet's keychain encryption from v1 (SJCL / PBKDF2-SHA256 + AES-256-CCM) to + * v2 (Argon2id + AES-256-GCM) and, optionally, regenerate the keycard PDF. + * + * Steps: + * 1. Unlock the BitGo session (skipped in dry-run). + * 2. Validate MPCv2 requirements (boxA/boxB when relevant). + * 3. Resolve the current and original passphrases (deriving from boxD if needed). + * 4. Re-encrypt user and backup keychains from v1 to v2 and PUT them to the server. + * 5. Re-encrypt MPCv2 reducedEncryptedPrv shares from boxA/boxB (keycard-only, no server PUT). + * 6. Invoke the injected {@link UpgradeEncryptionPdfGenerator} to regenerate the keycard PDF. + * + * @returns the generated PDF and wallet label, or undefined in dry-run or when no PDF generator is provided. + */ + async upgradeEncryption(params: UpgradeEncryptionOptions): Promise { + const { + passphrase: passphraseArg, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode: pecArg, + dryRun = false, + generatePdf, + } = params; + + if (!passphraseArg && !boxD) { + throw new Error('Either passphrase or boxD must be provided.'); + } + + const coinName = this.baseCoin.getChain(); + const walletId = this.id(); + const walletLabel = this.label(); + const keychainsApi = this.baseCoin.keychains(); + + if (dryRun) console.log('[dry-run] No changes will be persisted.'); + + if (!dryRun) { + try { + await this.bitgo + .post(this.bitgo.microservicesUrl('/api/v1/user/unlock')) + .send({ otp: otp ?? '0000000', duration: 600 }) + .result(); + console.log('Session unlocked.'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already unlocked longer')) { + console.log('Session already unlocked (longer duration) — proceeding.'); + } else { + throw err; + } + } + } + + console.log(`Wallet: ${walletLabel} (${walletId})`); + + if (this.multisigTypeVersion() === 'MPCv2' && (!boxA || !boxB)) { + throw new Error( + 'This is an MPCv2 wallet. boxA and boxB are required to re-encrypt the reducedEncryptedPrv ' + + 'key shares stored on the keycard. Without them the new keycard will be missing Box A and Box B.' + ); + } + + // PEC is needed to decrypt boxD (when provided) and to generate Box D in the new keycard. + // Always fetched when boxD is provided; otherwise skipped in dry-run. + const needsPec = boxD || !dryRun; + const pecResolved: string | undefined = + pecArg ?? (needsPec ? await this.fetchPasscodeEncryptionCode(coinName, walletId) : undefined); + + if (!pecResolved && !dryRun) { + throw new Error( + 'passcodeEncryptionCode is required — pass passcodeEncryptionCode or ensure the endpoint is accessible' + ); + } + + // pecResolved is guaranteed to be a string when not in dry-run; dry-run returns early before use. + const passcodeEncryptionCode = pecResolved as string; + + // Resolve the wallet passphrase and the original passphrase (if different). + // + // Two scenarios: + // 1. passphrase omitted, boxD provided: passphrase was never changed. Box D decrypts to the + // current passphrase. Derive it — no separate originalPassphrase needed. + // 2. passphrase provided, boxD provided: passphrase was changed after creation. Box D holds + // the original (needed to decrypt the backup key, which was encrypted at creation time). + let passphrase: string; + let originalPassphrase: string | undefined; + + if (!passphraseArg) { + passphrase = await this.bitgo.decrypt({ input: boxD as string, password: passcodeEncryptionCode }); + console.log('Passphrase derived from Box D.'); + } else { + passphrase = passphraseArg; + if (boxD && passcodeEncryptionCode) { + originalPassphrase = await this.bitgo.decrypt({ input: boxD, password: passcodeEncryptionCode }); + console.log('Recovered original passphrase from Box D.'); + } + } + + 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 updated: Array<{ type: string; id: string }> = []; + const skipped: Array<{ type: string; reason: string }> = []; + + // Re-encrypt user key. Always encrypted with the current passphrase (BitGo re-encrypts on + // password change), so no originalPassphrase fallback is needed here. originalEncryptedPrv + // is set to the new value so BitGo's password-change flow stays consistent. + if (userKeychain.encryptedPrv) { + if (keychainsApi.getEncryptionVersion(userKeychain.encryptedPrv) === 2) { + skipped.push({ type: 'user', reason: 'already v2' }); + } else { + const newEncryptedPrv = await keychainsApi.reencryptAsV2(userKeychain.encryptedPrv, passphrase); + userKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun) { + await this.bitgo + .put(this.baseCoin.url(`/key/${encodeURIComponent(userKeychain.id)}`)) + .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv }) + .result(); + } + updated.push({ type: 'user', id: userKeychain.id }); + } + } else { + skipped.push({ type: 'user', reason: 'no encryptedPrv' }); + } + + // 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; + if (backupSource) { + if (keychainsApi.getEncryptionVersion(backupSource) === 2) { + skipped.push({ type: 'backup', reason: 'already v2' }); + } else { + const newEncryptedPrv = await keychainsApi.reencryptAsV2(backupSource, passphrase, originalPassphrase); + backupKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun && serverStored) { + // 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 }); + } + } else { + skipped.push({ type: 'backup', reason: 'public key only' }); + } + + if (dryRun) { + console.log('Skipped (dry-run):'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + console.log('Would re-encrypt:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + console.log('[dry-run] Done — no changes persisted.'); + return undefined; + } + + if (updated.length > 0) { + console.log('Re-encrypted:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + } + if (skipped.length > 0) { + console.log('Skipped:'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + } + + // Re-encrypt MPCv2 key shares (keycard-only — not stored server-side). + if (boxA) { + userKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxA, passphrase, originalPassphrase); + updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); + } + 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 keychainsApi.reencryptAsV2(boxB, passphrase, originalPassphrase); + updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); + } + + if (!generatePdf) { + console.log('Done (no PDF generator provided).'); + return undefined; + } + + const doc = await generatePdf({ + coinName, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + }); + + console.log('Done.'); + return { doc, walletLabel }; + } + + private async fetchPasscodeEncryptionCode(coin: string, walletId: string): Promise { + const response = (await this.bitgo + .post(this.bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`)) + .result()) as { recoveryInfo?: { passcodeEncryptionCode?: string } }; + if (!response.recoveryInfo || typeof response.recoveryInfo.passcodeEncryptionCode !== 'string') { + throw new Error( + 'passcoderecovery endpoint did not return a passcodeEncryptionCode — pass passcodeEncryptionCode manually' + ); + } + return response.recoveryInfo.passcodeEncryptionCode; + } + /** * Builds a set of consolidation transactions for a wallet. * @param params diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts index feafa843e0..001ba4ea90 100644 --- a/scripts/upgrade-wallet-encryption.ts +++ b/scripts/upgrade-wallet-encryption.ts @@ -31,139 +31,23 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as https from 'https'; import * as yargsLib from 'yargs'; -import { coins } from '@bitgo/statics'; import { Environments, EnvironmentName } from '../modules/sdk-core/src/bitgo/environments'; -import { Keychain } from '../modules/sdk-core/src/bitgo/keychain'; -import { generateQrData } from '../modules/key-card/src/generateQrData'; -import { generateFaq } from '../modules/key-card/src/faq'; -import { drawKeycard } from '../modules/key-card/src/drawKeycard'; +import { createKeycardPdfGenerator } from '../modules/key-card/src/upgradeWalletEncryption'; import { BitGo } from '../modules/bitgo/dist/src/bitgo'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Determine the encryption version of a ciphertext by inspecting the "v" field. - * v1 = SJCL (PBKDF2-SHA256 + AES-256-CCM), no "v" field or v=1 - * v2 = Argon2id + AES-256-GCM, v=2 - */ -function getEncryptionVersion(ciphertext: string): 1 | 2 { - try { - const envelope = JSON.parse(ciphertext); - if (envelope.v === 2) { - return 2; - } - // SJCL envelopes have no "v" field or v=1 - if (!envelope.v || envelope.v === 1) { - return 1; - } - throw new Error(`Unrecognized encryption version: ${envelope.v}`); - } catch (e) { - throw new Error(`Failed to parse ciphertext envelope: ${e.message}`); - } -} - -/** - * Decrypt a v1 ciphertext and re-encrypt it as v2. - * Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails. - */ -async function reencryptAsV2( - bitgo: BitGo, - encryptedPrv: string, - passphrase: string, - originalPassphrase: string | undefined -): Promise { - let prv: string; - try { - prv = await bitgo.decrypt({ input: encryptedPrv, password: passphrase }); - } catch { - if (originalPassphrase) { - prv = await bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); - } else { - throw new Error( - 'Failed to decrypt with the provided passphrase. ' + - 'If the wallet password was changed after creation, provide --boxD so the original passphrase can be recovered.' - ); - } - } - // Always re-encrypt with the current passphrase, regardless of which passphrase decrypted it. - return bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); -} - -/** - * Fetch the passcodeEncryptionCode for a wallet from the BitGo passcoderecovery endpoint. - * This is the symmetric key used to encrypt the wallet passphrase into Box D on the keycard. - * It is required to produce a Box D entry in the regenerated keycard. - */ -async function getPasscodeEncryptionCode(bitgo: BitGo, coin: string, walletId: string): Promise { - const { recoveryInfo } = await bitgo - .post(bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`)) - .result(); - if (!recoveryInfo || typeof recoveryInfo.passcodeEncryptionCode !== 'string') { - throw new Error( - 'passcoderecovery endpoint did not return a passcodeEncryptionCode — pass --passcodeEncryptionCode manually' - ); - } - return recoveryInfo.passcodeEncryptionCode; -} - -/** - * Fetch the coin logo image and return a duck-typed HTMLImageElement-compatible object. - * jsPDF reads `.src` in Node.js; computeKeyCardImageDimensions reads `.width`/`.height`. - * This replicates what the BitGo UI does before calling drawKeycard. - */ -async function loadKeycardImage(url: string): Promise { - return new Promise((resolve) => { - https - .get(url, (res) => { - if (res.statusCode !== 200) { - console.warn(`Warning: coin logo not loaded (HTTP ${res.statusCode}) — keycard will be generated without it`); - resolve(undefined); - return; - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const contentType = res.headers['content-type'] ?? 'image/png'; - const dataUrl = `data:${contentType};base64,${Buffer.concat(chunks).toString('base64')}`; - // jsPDF reads .src; computeKeyCardImageDimensions reads .width / .height - const img = { src: dataUrl, width: 303, height: 40 } as unknown as HTMLImageElement; - resolve(img); - }); - res.on('error', (err) => { - console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); - resolve(undefined); - }); - }) - .on('error', (err) => { - console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); - resolve(undefined); - }); - }); -} - -// --------------------------------------------------------------------------- -// CLI argument parsing -// --------------------------------------------------------------------------- - function parseArgs() { const argv = yargsLib .option('env', { type: 'string', default: 'test', description: 'BitGo environment (test, prod, …)' }) .option('coin', { type: 'string', demandOption: true, description: 'Coin ticker (e.g. tbtc, teth)' }) .option('walletId', { type: 'string', demandOption: true, description: 'Wallet ID' }) - .option('passphrase', { type: 'string', demandOption: true, description: 'Current wallet passphrase' }) + .option('passphrase', { type: 'string', description: 'Current wallet passphrase. Omit when --boxD is provided and the passphrase has never been changed — it will be derived from Box D.' }) .option('accessToken', { type: 'string', demandOption: true, description: 'Short-lived BitGo access token' }) .option('otp', { type: 'string', description: 'OTP for session unlock' }) .option('boxD', { type: 'string', description: 'Box D ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) .option('boxA', { type: 'string', description: 'Box A ciphertext from the original keycard (MPCv2)', coerce: (v: string) => v?.replace(/\s/g, '') }) .option('boxB', { type: 'string', description: 'Box B ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) - .option('passcodeEncryptionCode', { - type: 'string', - description: 'Passcode encryption code (fetched automatically if omitted)', - }) + .option('passcodeEncryptionCode', { type: 'string', description: 'Passcode encryption code (fetched automatically if omitted)' }) .option('dry-run', { type: 'boolean', default: false, description: 'Validate without persisting changes' }) .parseSync(); @@ -182,215 +66,31 @@ function parseArgs() { }; } -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - async function main() { - const { - env, - coin, - walletId, - passphrase, - accessToken, - otp, - boxD, - boxA, - boxB, - passcodeEncryptionCode: pecArg, - dryRun, - } = parseArgs(); - - if (dryRun) console.log('[dry-run] No changes will be persisted.'); + const { env, coin, walletId, passphrase, accessToken, otp, boxD, boxA, boxB, passcodeEncryptionCode, dryRun } = + parseArgs(); const bitgo = new BitGo({ env }); - bitgo.authenticateWithAccessToken({ accessToken }); - if (!dryRun) { - try { - await bitgo.unlock({ otp: otp ?? '0000000', duration: 600 }); - console.log('Session unlocked.'); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('already unlocked longer')) { - console.log('Session already unlocked (longer duration) — proceeding.'); - } else { - throw err; - } - } - } - const wallet = await bitgo.coin(coin).wallets().get({ id: walletId }); - console.log(`Wallet: ${wallet.label()} (${walletId})`); - - if (wallet.multisigTypeVersion() === 'MPCv2' && (!boxA || !boxB)) { - throw new Error( - 'This is an MPCv2 wallet. --boxA and --boxB are required to re-encrypt the reducedEncryptedPrv ' + - 'key shares stored on the keycard. Without them the new keycard will be missing Box A and Box B.' - ); - } - - // Fetch passcodeEncryptionCode — needed to decrypt Box D (when provided) and to generate Box D - // in the new keycard. Always fetched when boxD is provided; otherwise skipped in dry-run. - const needsPec = boxD || !dryRun; - const passcodeEncryptionCode: string | undefined = - pecArg ?? (needsPec ? await getPasscodeEncryptionCode(bitgo, coin, walletId) : undefined); - - if (!passcodeEncryptionCode && !dryRun) { - throw new Error( - 'passcodeEncryptionCode is required — pass --passcodeEncryptionCode or ensure the endpoint is accessible' - ); - } - - // If the wallet password was changed after creation, Box D from the original keycard contains - // the original passphrase encrypted with the passcodeEncryptionCode. Decrypt it to recover the - // passphrase that was used to encrypt the backup key at wallet creation time. - let originalPassphrase: string | undefined; - if (boxD && passcodeEncryptionCode) { - originalPassphrase = await bitgo.decrypt({ - input: boxD.replace(/\s/g, ''), - password: passcodeEncryptionCode, - }); - console.log('Recovered original passphrase from Box D.'); - } - - // Fetch all three keychains - const keyIds = wallet.keyIds(); - const [userKeychain, backupKeychain, bitgoKeychain] = (await Promise.all([ - bitgo.coin(coin).keychains().get({ id: keyIds[0] }), - bitgo.coin(coin).keychains().get({ id: keyIds[1] }), - bitgo.coin(coin).keychains().get({ id: keyIds[2] }), - ])) as [Keychain, Keychain, Keychain]; - - const updated: Array<{ type: string; id: string }> = []; - const skipped: Array<{ type: string; reason: string }> = []; - - // ------------------------------------------------------------------ - // Re-encrypt user key - // The user key is always encrypted with the current passphrase. - // We also set originalEncryptedPrv = encryptedPrv so that BitGo's - // server-side password-change flow remains consistent after this upgrade. - // ------------------------------------------------------------------ - if (userKeychain.encryptedPrv) { - if (getEncryptionVersion(userKeychain.encryptedPrv) === 2) { - skipped.push({ type: 'user', reason: 'already v2' }); - } else { - const userPrv = await bitgo.decrypt({ input: userKeychain.encryptedPrv, password: passphrase }); - const newEncryptedPrv = await bitgo.encrypt({ input: userPrv, password: passphrase, encryptionVersion: 2 }); - userKeychain.encryptedPrv = newEncryptedPrv; - - if (!dryRun) { - await bitgo - .put(bitgo.coin(coin).url(`/key/${encodeURIComponent(userKeychain.id)}`)) - .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv }) - .result(); - } - updated.push({ type: 'user', id: userKeychain.id }); - } - } else { - skipped.push({ type: 'user', reason: 'no encryptedPrv' }); - } - - // ------------------------------------------------------------------ - // Re-encrypt backup key - // The backup key may be encrypted under the original passphrase if the - // wallet password was changed after creation (see design notes above). - // If decryption with the current passphrase fails and --boxD was provided, - // we retry with the recovered original passphrase. - // ------------------------------------------------------------------ - // Source of truth for the backup key ciphertext: server-stored encryptedPrv, or --boxB for older wallets - const serverStored = !!backupKeychain.encryptedPrv; - const backupSource = backupKeychain.encryptedPrv ?? boxB; - if (backupSource) { - if (getEncryptionVersion(backupSource) === 2) { - skipped.push({ type: 'backup', reason: 'already v2' }); - } else { - const newEncryptedPrv = await reencryptAsV2(bitgo, backupSource, passphrase, originalPassphrase); - backupKeychain.encryptedPrv = newEncryptedPrv; - - if (!dryRun && serverStored) { - // Only PUT if the key was server-stored (boxB-only keys have no server record to update) - await bitgo - .put(bitgo.coin(coin).url(`/key/${encodeURIComponent(backupKeychain.id)}`)) - .send({ encryptedPrv: newEncryptedPrv }) - .result(); - } - updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupKeychain.id }); - } - } else { - skipped.push({ type: 'backup', reason: 'no encryptedPrv' }); - } - - if (dryRun) { - console.log('Skipped (dry-run):'); - skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); - console.log('Would re-encrypt:'); - updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); - console.log('[dry-run] Done — no changes persisted.'); - return; - } - - if (updated.length > 0) { - console.log('Re-encrypted:'); - updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); - } - if (skipped.length > 0) { - console.log('Skipped:'); - skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); - } - - // ------------------------------------------------------------------ - // Re-encrypt boxA (MPCv2 reducedEncryptedPrv — keycard-only) - // ------------------------------------------------------------------ - if (boxA) { - userKeychain.reducedEncryptedPrv = await reencryptAsV2(bitgo, boxA, passphrase, originalPassphrase); - updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); - } - - if (boxB && backupKeychain.encryptedPrv) { - // MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not. - // Re-encrypt boxB and set it so generateQrData uses it instead of the full encryptedPrv blob. - backupKeychain.reducedEncryptedPrv = await reencryptAsV2(bitgo, boxB, passphrase, originalPassphrase); - updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); - } - - // ------------------------------------------------------------------ - // Regenerate keycard PDF - // Mirrors the UI flow: generateQrData → generateFaq → drawKeycard → save PDF. - // The coin logo is fetched from the BitGo web app and passed to drawKeycard - // as a duck-typed HTMLImageElement (jsPDF reads .src; drawKeycard reads .width/.height). - // ------------------------------------------------------------------ - if (!passcodeEncryptionCode) { - console.warn('Skipping keycard generation — passcodeEncryptionCode not available.'); - console.log('Done.'); - return; - } - - const staticsCoin = coins.get(coin); - const walletLabel = wallet.label(); - // The keycard image asset uses the coin family name (e.g. "sol" for both sol and tsol) - const baseUrl = Environments[env].uri; - const keyCardImage = await loadKeycardImage(`${baseUrl}/web/assets/keycards/${staticsCoin.family.toLowerCase()}.png`); - - const qrData = await generateQrData({ - coin: staticsCoin, - userKeychain, - backupKeychain, - bitgoKeychain, + const result = await wallet.upgradeEncryption({ passphrase, + otp, + boxD, + boxA, + boxB, passcodeEncryptionCode, - encryptionVersion: 2, + dryRun, + generatePdf: createKeycardPdfGenerator({ imageBaseUrl: Environments[env].uri }), }); - const questions = generateFaq(staticsCoin.fullName); - const doc = await drawKeycard({ qrData, questions, walletLabel, keyCardImage }); - - const outputPath = path.resolve(process.cwd(), `BitGo Keycard for ${walletLabel}.pdf`); - fs.writeFileSync(outputPath, Buffer.from(doc.output('arraybuffer'))); - console.log(`Keycard PDF saved to: ${outputPath}`); - - console.log('Done.'); + if (result?.doc) { + const outputPath = path.resolve(process.cwd(), `BitGo Keycard for ${result.walletLabel}.pdf`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fs.writeFileSync(outputPath, Buffer.from((result.doc as any).output('arraybuffer'))); + console.log(`Keycard PDF saved to: ${outputPath}`); + } } main().catch((err) => { From 6c83cf108aa2c798bcc2b4db5b4b0fbcd0b3cc9c Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Tue, 14 Jul 2026 14:30:42 -0400 Subject: [PATCH 4/4] refactor(sdk-core): clean up backup key fallback in upgradeEncryption (WCN-174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry-with-original-passphrase fallback is specific to keys encrypted at wallet creation (backup key, boxA, boxB — these are never re-encrypted on password change). It doesn't belong on the generic Keychains.reencryptAsV2 primitive, which was leaking backup-key context into its error message. - Keychains.reencryptAsV2 is now a pure primitive: decrypt with passphrase, re-encrypt as v2, surface errors directly - Fallback moves to a private Wallet.reencryptCreationTimeKey helper, used only for backup/boxA/boxB paths where the semantics apply - Error messages now name the specific key that failed to decrypt TICKET: WCN-174 --- modules/bitgo/test/v2/unit/keychains.ts | 31 +++--------- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 2 +- .../sdk-core/src/bitgo/keychain/keychains.ts | 24 +++------- modules/sdk-core/src/bitgo/wallet/wallet.ts | 48 +++++++++++++++++-- 4 files changed, 59 insertions(+), 46 deletions(-) diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 89f8c6f2c5..8a840a5ddb 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -1197,7 +1197,7 @@ describe('V2 Keychains', function () { }); describe('reencryptAsV2', function () { - it('decrypts a v1 envelope with the current passphrase and re-encrypts as v2', async function () { + it('decrypts a v1 envelope with the passphrase and re-encrypts as v2', async function () { const prv = 'thePrivateKey'; const encryptedV1 = await bitgo.encrypt({ input: prv, password: 'myPass', encryptionVersion: 1 }); JSON.parse(encryptedV1).v.should.equal(1); @@ -1207,30 +1207,6 @@ describe('V2 Keychains', function () { (await bitgo.decrypt({ input: result, password: 'myPass' })).should.equal(prv); }); - it('falls back to originalPassphrase when the current passphrase fails to decrypt', async function () { - const prv = 'thePrivateKey'; - const encryptedWithOriginal = await bitgo.encrypt({ - input: prv, - password: 'originalPass', - encryptionVersion: 1, - }); - - const result = await keychains.reencryptAsV2(encryptedWithOriginal, 'currentPass', 'originalPass'); - JSON.parse(result).v.should.equal(2); - (await bitgo.decrypt({ input: result, password: 'currentPass' })).should.equal(prv); - await bitgo.decrypt({ input: result, password: 'originalPass' }).should.be.rejected(); - }); - - it('throws with a helpful message when decryption fails and no originalPassphrase is provided', async function () { - const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); - await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejectedWith(/original passphrase/); - }); - - it('throws when both the current and original passphrases fail', async function () { - const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); - await keychains.reencryptAsV2(encrypted, 'wrongCurrent', 'alsoWrong').should.be.rejected(); - }); - it('accepts a v2 envelope and re-encrypts it as v2 (idempotent)', async function () { const prv = 'xprv-v2'; const encryptedV2 = await bitgo.encrypt({ input: prv, password: 'pass', encryptionVersion: 2 }); @@ -1240,5 +1216,10 @@ describe('V2 Keychains', function () { JSON.parse(result).v.should.equal(2); (await bitgo.decrypt({ input: result, password: 'pass' })).should.equal(prv); }); + + it('surfaces decrypt errors directly (no fallback logic in the primitive)', async function () { + const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); + await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejected(); + }); }); }); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 7d2859767e..5b59a84af8 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -249,7 +249,7 @@ export interface IKeychains { updatePassword(params: UpdatePasswordOptions): Promise; updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Promise; getEncryptionVersion(ciphertext: string): EncryptionVersion; - reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise; + reencryptAsV2(encryptedPrv: string, passphrase: string): Promise; create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair; add(params?: AddKeychainOptions): Promise; createBitGo(params?: CreateBitGoOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 8a0e8ca71d..8c7ddc5b09 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -212,26 +212,16 @@ export class Keychains implements IKeychains { } /** - * Decrypt an encrypted private key and re-encrypt it as a v2 (Argon2id + AES-256-GCM) envelope. - * Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails. - * The result is always encrypted with `passphrase` (the current one), never the original. + * Decrypt an encrypted private key with `passphrase` and re-encrypt it as a v2 + * (Argon2id + AES-256-GCM) envelope with the same passphrase. * * Used to upgrade legacy v1 (SJCL) envelopes to v2 without changing the passphrase. + * Callers that need to try a fallback passphrase (e.g. an original passphrase from + * before a password rotation) should handle that themselves — this primitive does one + * thing and lets decryption errors surface directly. */ - async reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise { - let prv: string; - try { - prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase }); - } catch { - if (originalPassphrase) { - prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); - } else { - throw new Error( - 'Failed to decrypt with the provided passphrase. ' + - 'If the wallet password was changed after creation, provide the original passphrase so the backup key can be decrypted.' - ); - } - } + async reencryptAsV2(encryptedPrv: string, passphrase: string): Promise { + const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase }); return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 94b4c8900f..fc372b75f2 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3525,7 +3525,12 @@ export class Wallet implements IWallet { if (keychainsApi.getEncryptionVersion(backupSource) === 2) { skipped.push({ type: 'backup', reason: 'already v2' }); } else { - const newEncryptedPrv = await keychainsApi.reencryptAsV2(backupSource, passphrase, originalPassphrase); + const newEncryptedPrv = await this.reencryptCreationTimeKey( + backupSource, + passphrase, + originalPassphrase, + 'backup key' + ); backupKeychain.encryptedPrv = newEncryptedPrv; if (!dryRun && serverStored) { // Only PUT if the key was server-stored; boxB-only wallets have no server record. @@ -3560,13 +3565,23 @@ export class Wallet implements IWallet { // Re-encrypt MPCv2 key shares (keycard-only — not stored server-side). if (boxA) { - userKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxA, passphrase, originalPassphrase); + userKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey( + boxA, + passphrase, + originalPassphrase, + 'boxA' + ); updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); } 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 keychainsApi.reencryptAsV2(boxB, passphrase, originalPassphrase); + backupKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey( + boxB, + passphrase, + originalPassphrase, + 'boxB' + ); updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); } @@ -3589,6 +3604,33 @@ export class Wallet implements IWallet { return { doc, walletLabel }; } + /** + * Re-encrypt a ciphertext that was written at wallet creation time (backup key, boxA, boxB). + * These are not re-encrypted on password change, so the encryption passphrase may still be + * the original one from when the wallet was created. Try the current passphrase first; fall + * back to `originalPassphrase` if provided. + */ + private async reencryptCreationTimeKey( + encryptedPrv: string, + passphrase: string, + originalPassphrase: string | undefined, + keyDescription: string + ): Promise { + const keychainsApi = this.baseCoin.keychains(); + try { + return await keychainsApi.reencryptAsV2(encryptedPrv, passphrase); + } catch (err) { + if (!originalPassphrase) { + throw new Error( + `Failed to decrypt ${keyDescription} with the provided passphrase. If the wallet passphrase ` + + 'was changed after creation, pass boxD so the original passphrase can be recovered.' + ); + } + const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); + return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); + } + } + private async fetchPasscodeEncryptionCode(coin: string, walletId: string): Promise { const response = (await this.bitgo .post(this.bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`))