From 6f43e3f4825c7d756b39af0d98e080f26e1b1091 Mon Sep 17 00:00:00 2001 From: Bahaa Desoky Date: Fri, 10 Jul 2026 10:45:50 -0400 Subject: [PATCH 1/3] 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 a37d093b9c..489d033796 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; @@ -53,7 +55,7 @@ function moveDown(y: number, ydelta: number): number { } function drawOnePageOfQrCodes( - qrImages: HTMLCanvasElement[], + qrImages: (HTMLCanvasElement | string)[], doc: jsPDF, y: number, qrSize: number, @@ -68,7 +70,11 @@ function drawOnePageOfQrCodes( return qrIndex; } - 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 qrIndex + 1; @@ -127,7 +133,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 @@ -195,10 +207,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' })); + } } let nextQrIndex = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, 0); 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 455f6c12fdf039a5bc44cbd028edd858f68564c1 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Fri, 10 Jul 2026 14:16:32 -0400 Subject: [PATCH 2/3] 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 5051e01378319c30390890727359de4cc40c345b Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Fri, 10 Jul 2026 16:54:29 -0400 Subject: [PATCH 3/3] feat(key-card): move wallet encryption upgrade logic to key-card module (WCN-174) Extracts runUpgradeWalletEncryption and helpers from scripts/ into @bitgo/key-card so the upgrade flow is invokable via the SDK. The script becomes a thin CLI wrapper. Adds 61 unit tests covering all wallet types (standard, MPCv2, boxD passphrase recovery, dry-run). TICKET: WCN-174 --- modules/key-card/src/index.ts | 1 + .../key-card/src/upgradeWalletEncryption.ts | 393 ++++++++++ .../test/unit/upgradeWalletEncryption.ts | 714 ++++++++++++++++++ scripts/upgrade-wallet-encryption.ts | 333 +------- 4 files changed, 1125 insertions(+), 316 deletions(-) create mode 100644 modules/key-card/src/upgradeWalletEncryption.ts create mode 100644 modules/key-card/test/unit/upgradeWalletEncryption.ts diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index 6409632a0c..f39f0a2ea8 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -9,6 +9,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..46cc7c7597 --- /dev/null +++ b/modules/key-card/src/upgradeWalletEncryption.ts @@ -0,0 +1,393 @@ +/** + * Utilities for upgrading wallet keychain encryption from v1 (SJCL/PBKDF2-SHA256 + AES-256-CCM) + * to v2 (Argon2id + AES-256-GCM) as part of regenerating a keycard. + */ + +import * as https from 'https'; +import { BaseCoin, coins } from '@bitgo/statics'; +import { Keychain } from '@bitgo/sdk-core'; +import { generateQrData } from './generateQrData'; +import { generateFaq } from './faq'; +import { drawKeycard } from './drawKeycard'; + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +export interface CryptoAdapter { + decrypt(params: { input: string; password: string }): Promise; + encrypt(params: { input: string; password: string; encryptionVersion?: number }): Promise; +} + +export interface PostAdapter { + post(url: string): { result(): Promise }; + microservicesUrl(path: string): string; +} + +export interface WalletLike { + label(): string; + keyIds(): string[]; + multisigTypeVersion(): string | undefined; +} + +export interface BitGoApiClient extends CryptoAdapter, PostAdapter { + unlock(params: { otp: string; duration: number }): Promise; + put(url: string): { send(data: unknown): { result(): Promise } }; + coin(name: string): { + url(path: string): string; + keychains(): { get(params: { id: string }): Promise }; + wallets(): { get(params: { id: string }): Promise }; + }; +} + +export type PdfGenerator = (params: { + coin: BaseCoin; + userKeychain: Keychain; + backupKeychain: Keychain; + bitgoKeychain: Keychain; + passphrase: string; + passcodeEncryptionCode: string; + walletLabel: string; + keyCardImage: HTMLImageElement | undefined; +}) => Promise; + +export interface UpgradeWalletEncryptionOptions { + coin: string; + walletId: string; + passphrase: string; + otp?: string; + /** Box D ciphertext — supply when the wallet passphrase was changed after creation. */ + 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; + /** Base URL for the coin logo (e.g. Environments[env].uri). Omit to skip image loading. */ + imageBaseUrl?: string; + /** Injectable PDF generator. Defaults to the real generateQrData + drawKeycard flow. Override in tests. */ + generatePdf?: PdfGenerator; +} + +export interface UpgradeWalletEncryptionResult { + doc: unknown; + walletLabel: string; +} + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/** + * 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 + */ +export function getEncryptionVersion(ciphertext: string): 1 | 2 { + try { + const envelope = JSON.parse(ciphertext); + if (envelope.v === 2) { + return 2; + } + 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 as Error).message}`); + } +} + +/** + * Decrypt a v1 ciphertext and re-encrypt it as v2. + * Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails. + * Always re-encrypts with `passphrase` (the current one), never the original. + */ +export async function reencryptAsV2( + encryptedPrv: string, + passphrase: string, + originalPassphrase: string | undefined, + crypto: CryptoAdapter +): Promise { + let prv: string; + try { + prv = await crypto.decrypt({ input: encryptedPrv, password: passphrase }); + } catch { + if (originalPassphrase) { + prv = await crypto.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.' + ); + } + } + return crypto.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. + */ +export async function getPasscodeEncryptionCode(bitgo: PostAdapter, coin: string, walletId: string): Promise { + const response = (await bitgo + .post(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; +} + +/** + * Fetch the coin logo image and return a duck-typed HTMLImageElement-compatible object. + * jsPDF reads `.src` in Node.js; computeKeyCardImageDimensions reads `.width`/`.height`. + */ +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); + }); + }); +} + +async function defaultGeneratePdf(params: Parameters[0]): Promise { + const { + coin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + keyCardImage, + } = params; + const qrData = await generateQrData({ + coin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + encryptionVersion: 2, + }); + const questions = generateFaq(coin.fullName); + return drawKeycard({ qrData, questions, walletLabel, keyCardImage }); +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +/** + * Orchestrates the full wallet encryption upgrade flow: + * 1. Unlocks the BitGo session (skipped in dry-run) + * 2. Validates MPCv2 requirements + * 3. Re-encrypts user and backup keychains from v1 to v2 and PUTs them to the server + * 4. Re-encrypts MPCv2 key shares (boxA, boxB) from the original keycard + * 5. Regenerates the keycard PDF with v2 encryption + * + * Returns the generated PDF document and wallet label (for the caller to save to disk), + * or undefined in dry-run mode or when passcodeEncryptionCode is unavailable. + */ +export async function runUpgradeWalletEncryption( + bitgo: BitGoApiClient, + options: UpgradeWalletEncryptionOptions +): Promise { + const { + coin, + walletId, + passphrase, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode: pecArg, + dryRun = false, + imageBaseUrl, + generatePdf = defaultGeneratePdf, + } = options; + + if (dryRun) console.log('[dry-run] No changes will be persisted.'); + + 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.' + ); + } + + // 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 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 contains the original passphrase + // encrypted with the PEC. Decrypt it so we can re-encrypt the backup key (which may have been + // encrypted with the original passphrase at wallet creation time). + let originalPassphrase: string | undefined; + if (boxD && passcodeEncryptionCode) { + originalPassphrase = await bitgo.decrypt({ input: boxD, password: passcodeEncryptionCode }); + console.log('Recovered original passphrase from Box D.'); + } + + 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] }), + ]); + + const updated: Array<{ type: string; id: string }> = []; + const skipped: Array<{ type: string; reason: string }> = []; + + // Re-encrypt user key (always encrypted with current passphrase). + // originalEncryptedPrv is set to the new value so BitGo's password-change flow stays consistent. + if (userKeychain.encryptedPrv) { + if (getEncryptionVersion(userKeychain.encryptedPrv) === 2) { + skipped.push({ type: 'user', reason: 'already v2' }); + } else { + const newEncryptedPrv = await reencryptAsV2(userKeychain.encryptedPrv, passphrase, undefined, bitgo); + 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. 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 (getEncryptionVersion(backupSource) === 2) { + skipped.push({ type: 'backup', reason: 'already v2' }); + } else { + const newEncryptedPrv = await reencryptAsV2(backupSource, passphrase, originalPassphrase, bitgo); + backupKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun && serverStored) { + // Only PUT if the key was server-stored; boxB-only wallets 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 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 reencryptAsV2(boxA, passphrase, originalPassphrase, bitgo); + 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 generateQrData uses the reduced form instead of the full blob. + backupKeychain.reducedEncryptedPrv = await reencryptAsV2(boxB, passphrase, originalPassphrase, bitgo); + updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); + } + + if (!passcodeEncryptionCode) { + console.warn('Skipping keycard generation — passcodeEncryptionCode not available.'); + console.log('Done.'); + return undefined; + } + + const staticsCoin = coins.get(coin) as BaseCoin; + const walletLabel = wallet.label(); + const keyCardImage = imageBaseUrl + ? await loadKeycardImage(`${imageBaseUrl}/web/assets/keycards/${staticsCoin.family.toLowerCase()}.png`) + : undefined; + + const doc = await generatePdf({ + coin: staticsCoin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + keyCardImage, + }); + + console.log('Done.'); + return { doc, walletLabel }; +} diff --git a/modules/key-card/test/unit/upgradeWalletEncryption.ts b/modules/key-card/test/unit/upgradeWalletEncryption.ts new file mode 100644 index 0000000000..c9e29e9702 --- /dev/null +++ b/modules/key-card/test/unit/upgradeWalletEncryption.ts @@ -0,0 +1,714 @@ +import * as assert from 'assert'; +import { EventEmitter } from 'events'; +import 'should'; +import { + BitGoApiClient, + CryptoAdapter, + PostAdapter, + WalletLike, + getEncryptionVersion, + getPasscodeEncryptionCode, + loadKeycardImage, + reencryptAsV2, + runUpgradeWalletEncryption, +} from '../../src/upgradeWalletEncryption'; +import { Keychain } from '@bitgo/sdk-core'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeCrypto( + decryptImpl: (params: { input: string; password: string }) => Promise, + encryptImpl?: (params: { input: string; password: string; encryptionVersion?: number }) => Promise +): CryptoAdapter { + return { + decrypt: decryptImpl, + encrypt: + encryptImpl ?? + (async ({ input, password, encryptionVersion }) => + JSON.stringify({ v: encryptionVersion ?? 2, ct: Buffer.from(input).toString('base64'), key: password })), + }; +} + +function v1Envelope(ct = 'abc'): string { + return JSON.stringify({ ct, iv: 'iv', s: 'salt' }); +} + +function v2Envelope(ct = 'abc'): string { + return JSON.stringify({ v: 2, ct, iv: 'iv' }); +} + +// --------------------------------------------------------------------------- +// getEncryptionVersion +// --------------------------------------------------------------------------- + +describe('getEncryptionVersion', function () { + it('returns 1 when the v field is absent', function () { + assert.strictEqual(getEncryptionVersion(v1Envelope()), 1); + }); + + it('returns 1 when v is explicitly 1', function () { + assert.strictEqual(getEncryptionVersion(JSON.stringify({ v: 1, ct: 'abc' })), 1); + }); + + it('returns 2 when v is 2', function () { + assert.strictEqual(getEncryptionVersion(v2Envelope()), 2); + }); + + it('throws on an unrecognized version number', function () { + assert.throws( + () => getEncryptionVersion(JSON.stringify({ v: 3, ct: 'abc' })), + /Unrecognized encryption version: 3/ + ); + }); + + it('throws when the input is not valid JSON', function () { + assert.throws(() => getEncryptionVersion('not-json'), /Failed to parse ciphertext envelope/); + }); + + it('throws when the input is an empty string', function () { + assert.throws(() => getEncryptionVersion(''), /Failed to parse ciphertext envelope/); + }); + + it('handles a real v1 SJCL-style 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', + }); + assert.strictEqual(getEncryptionVersion(sjcl), 1); + }); + + it('handles a real v2 Argon2id-style envelope', function () { + const argon2 = JSON.stringify({ + v: 2, + ct: 'cccc', + iv: 'iiii', + tag: 'tttt', + salt: 'ssss', + m: 65536, + t: 3, + p: 4, + }); + assert.strictEqual(getEncryptionVersion(argon2), 2); + }); +}); + +// --------------------------------------------------------------------------- +// reencryptAsV2 +// --------------------------------------------------------------------------- + +describe('reencryptAsV2', function () { + it('decrypts with the current passphrase and re-encrypts as v2', async function () { + const crypto = makeCrypto(async ({ password }) => { + if (password === 'myPass') return 'thePrivateKey'; + throw new Error('wrong password'); + }); + + const result = await reencryptAsV2(v1Envelope(), 'myPass', undefined, crypto); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.v, 2); + assert.ok(Buffer.from(parsed.ct, 'base64').toString(), 'thePrivateKey'); + }); + + it('falls back to originalPassphrase when the current passphrase fails to decrypt', async function () { + const crypto = makeCrypto(async ({ password }) => { + if (password === 'originalPass') return 'thePrivateKey'; + throw new Error('wrong password'); + }); + + const result = await reencryptAsV2(v1Envelope(), 'currentPass', 'originalPass', crypto); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.v, 2); + }); + + it('always re-encrypts with the current passphrase, not the original', async function () { + const encryptCalls: Array<{ password: string; encryptionVersion?: number }> = []; + + const crypto: CryptoAdapter = { + decrypt: async ({ password }) => { + if (password === 'original') return 'theKey'; + throw new Error('wrong'); + }, + encrypt: async (params) => { + encryptCalls.push(params); + return JSON.stringify({ v: 2 }); + }, + }; + + await reencryptAsV2(v1Envelope(), 'current', 'original', crypto); + + assert.strictEqual(encryptCalls.length, 1); + assert.strictEqual(encryptCalls[0].password, 'current'); + }); + + it('passes encryptionVersion: 2 to the encrypt call', async function () { + const encryptCalls: Array<{ encryptionVersion?: number }> = []; + + const crypto = makeCrypto( + async () => 'key', + async (params) => { + encryptCalls.push(params); + return JSON.stringify({ v: 2 }); + } + ); + + await reencryptAsV2(v1Envelope(), 'pass', undefined, crypto); + assert.strictEqual(encryptCalls[0].encryptionVersion, 2); + }); + + it('throws with a helpful message when decryption fails and no originalPassphrase is provided', async function () { + const crypto = makeCrypto(async () => { + throw new Error('decrypt failed'); + }); + + await assert.rejects(() => reencryptAsV2(v1Envelope(), 'badPass', undefined, crypto), /boxD/); + }); + + it('throws when both the current and original passphrases fail', async function () { + const crypto = makeCrypto(async () => { + throw new Error('bad passphrase'); + }); + + await assert.rejects(() => reencryptAsV2(v1Envelope(), 'current', 'alsoWrong', crypto), /bad passphrase/); + }); + + it('propagates encrypt errors', async function () { + const crypto = makeCrypto( + async () => 'key', + async () => { + throw new Error('encrypt failed'); + } + ); + + await assert.rejects(() => reencryptAsV2(v1Envelope(), 'pass', undefined, crypto), /encrypt failed/); + }); + + it('skips re-encryption when the ciphertext is already v2 (caller responsibility — documents expected usage)', async function () { + // getEncryptionVersion should be used to guard calls to reencryptAsV2. + // Calling it on a v2 envelope with the right passphrase still works — it just decrypts and re-encrypts. + const crypto = makeCrypto(async ({ password }) => { + if (password === 'pass') return 'key'; + throw new Error('wrong'); + }); + + const result = await reencryptAsV2(v2Envelope(), 'pass', undefined, crypto); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.v, 2); + }); +}); + +// --------------------------------------------------------------------------- +// getPasscodeEncryptionCode +// --------------------------------------------------------------------------- + +function makePostAdapter(recoveryInfo: unknown): PostAdapter { + return { + post: () => ({ result: async () => ({ recoveryInfo }) }), + microservicesUrl: (path) => `https://app.bitgo.test${path}`, + }; +} + +describe('getPasscodeEncryptionCode', function () { + it('returns the passcodeEncryptionCode from the API response', async function () { + const bitgo = makePostAdapter({ passcodeEncryptionCode: 'pec-abc-123' }); + const result = await getPasscodeEncryptionCode(bitgo, 'tbtc', 'wallet-id'); + assert.strictEqual(result, 'pec-abc-123'); + }); + + it('throws when recoveryInfo is absent', async function () { + const bitgo = makePostAdapter(undefined); + await assert.rejects(() => getPasscodeEncryptionCode(bitgo, 'tbtc', 'wallet-id'), /passcoderecovery endpoint/); + }); + + it('throws when passcodeEncryptionCode is not a string', async function () { + const bitgo = makePostAdapter({ passcodeEncryptionCode: null }); + await assert.rejects(() => getPasscodeEncryptionCode(bitgo, 'tbtc', 'wallet-id'), /passcoderecovery endpoint/); + }); + + it('throws when passcodeEncryptionCode is a number', async function () { + const bitgo = makePostAdapter({ passcodeEncryptionCode: 12345 }); + await assert.rejects(() => getPasscodeEncryptionCode(bitgo, 'tbtc', 'wallet-id'), /passcoderecovery endpoint/); + }); + + it('constructs the correct API URL with coin and walletId', async function () { + let capturedUrl = ''; + const bitgo: PostAdapter = { + post: (url) => { + capturedUrl = url; + return { result: async () => ({ recoveryInfo: { passcodeEncryptionCode: 'x' } }) }; + }, + microservicesUrl: (path) => `https://ms.bitgo.test${path}`, + }; + await getPasscodeEncryptionCode(bitgo, 'tbtc', 'abc123'); + assert.ok(capturedUrl.includes('/tbtc/'), `URL should include coin: ${capturedUrl}`); + assert.ok(capturedUrl.includes('/abc123/'), `URL should include walletId: ${capturedUrl}`); + assert.ok(capturedUrl.includes('passcoderecovery'), `URL should include endpoint: ${capturedUrl}`); + }); +}); + +// --------------------------------------------------------------------------- +// loadKeycardImage +// --------------------------------------------------------------------------- + +function makeIncomingMessage(statusCode: number, body?: Buffer, 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, imageData, '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); + }); +}); + +// --------------------------------------------------------------------------- +// runUpgradeWalletEncryption +// --------------------------------------------------------------------------- + +const V1 = JSON.stringify({ ct: 'cipher', iv: 'iv', s: 'salt' }); // no v field → version 1 +const V2 = JSON.stringify({ v: 2, ct: 'cipher', iv: 'iv' }); + +function makeKeychain(overrides: Partial & { id: string }): Keychain { + return { pub: 'pub', type: 'independent', ...overrides }; +} + +function makeWallet(overrides: { label?: string; keyIds?: string[]; multisigTypeVersion?: string } = {}): WalletLike { + return { + label: () => overrides.label ?? 'Test Wallet', + keyIds: () => overrides.keyIds ?? ['user-id', 'backup-id', 'bitgo-id'], + multisigTypeVersion: () => overrides.multisigTypeVersion, + }; +} + +function makeBitGoClient({ + wallet, + keychains, + pecResponse = { recoveryInfo: { passcodeEncryptionCode: 'pec-123' } }, + decryptImpl, + onPut, +}: { + wallet: WalletLike; + keychains: Record; + pecResponse?: unknown; + decryptImpl?: (params: { input: string; password: string }) => Promise; + onPut?: (url: string, data: unknown) => void; +}): BitGoApiClient { + return { + decrypt: decryptImpl ?? (async ({ password }) => `decrypted-with-${password}`), + encrypt: async ({ input, encryptionVersion }) => + JSON.stringify({ v: encryptionVersion ?? 2, ct: Buffer.from(input).toString('base64') }), + unlock: () => Promise.resolve(), + post: () => ({ result: async () => pecResponse }), + microservicesUrl: (path) => `https://app.bitgo.test${path}`, + put: (url) => ({ + send: (data) => ({ + result: async () => { + onPut?.(url, data); + return {}; + }, + }), + }), + coin: (name) => ({ + url: (path) => `/api/v2/${name}${path}`, + keychains: () => ({ + get: async ({ id }) => { + const kc = keychains[id]; + if (!kc) throw new Error(`No keychain fixture for id ${id}`); + return kc; + }, + }), + wallets: () => ({ + get: async () => wallet, + }), + }), + }; +} + +const stubPdf = async () => ({ output: () => new ArrayBuffer(0) }); + +describe('runUpgradeWalletEncryption', function () { + describe('standard hot wallet (BTC)', function () { + it('re-encrypts both user and backup keys and PUTs them to the server', async function () { + const putCalls: Array<{ url: string; data: unknown }> = []; + const wallet = makeWallet(); + const userKc = makeKeychain({ id: 'user-id', encryptedPrv: V1 }); + const backupKc = makeKeychain({ id: 'backup-id', encryptedPrv: V1 }); + const bitgoKc = makeKeychain({ id: 'bitgo-id' }); + + const bitgo = makeBitGoClient({ + wallet, + keychains: { 'user-id': userKc, 'backup-id': backupKc, 'bitgo-id': bitgoKc }, + onPut: (url, data) => putCalls.push({ url, data }), + }); + + const result = await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'myPass', + generatePdf: stubPdf, + }); + + assert.strictEqual(putCalls.length, 2); + assert.ok(putCalls.some((c) => c.url.includes('user-id'))); + assert.ok(putCalls.some((c) => c.url.includes('backup-id'))); + assert.ok(result); + assert.strictEqual(result.walletLabel, 'Test Wallet'); + + // PUT bodies contain re-encrypted (v2) ciphertext + const userPut = putCalls.find((c) => c.url.includes('user-id'))!.data as { encryptedPrv: string }; + assert.strictEqual(JSON.parse(userPut.encryptedPrv).v, 2); + }); + }); + + describe('MPCv2 wallet', function () { + const mpcWallet = makeWallet({ multisigTypeVersion: 'MPCv2' }); + + it('throws when boxA or boxB are not provided', async function () { + const bitgo = makeBitGoClient({ + wallet: mpcWallet, + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + }); + + await assert.rejects( + () => + runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'myPass', + generatePdf: stubPdf, + }), + /MPCv2/ + ); + }); + + it('re-encrypts boxA and boxB as reducedEncryptedPrv on the keychains', async function () { + const userKc = makeKeychain({ id: 'user-id', encryptedPrv: V1 }); + const backupKc = makeKeychain({ id: 'backup-id', encryptedPrv: V1 }); + const bitgoKc = makeKeychain({ id: 'bitgo-id' }); + const bitgo = makeBitGoClient({ + wallet: mpcWallet, + keychains: { 'user-id': userKc, 'backup-id': backupKc, 'bitgo-id': bitgoKc }, + }); + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'myPass', + boxA: V1, + boxB: V1, + generatePdf: stubPdf, + }); + + assert.ok(userKc.reducedEncryptedPrv, 'reducedEncryptedPrv should be set on user keychain'); + assert.strictEqual(JSON.parse(userKc.reducedEncryptedPrv!).v, 2); + assert.ok(backupKc.reducedEncryptedPrv, 'reducedEncryptedPrv should be set on backup keychain'); + assert.strictEqual(JSON.parse(backupKc.reducedEncryptedPrv!).v, 2); + }); + }); + + describe('boxD — changed wallet passphrase', function () { + it('decrypts boxD with PEC to recover the original passphrase, then uses it for backup key', async function () { + const decryptCalls: Array<{ input: string; password: string }> = []; + const userKc = makeKeychain({ id: 'user-id', encryptedPrv: V1 }); + const backupKc = makeKeychain({ id: 'backup-id', encryptedPrv: V1 }); + const bitgoKc = makeKeychain({ id: 'bitgo-id' }); + + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { 'user-id': userKc, 'backup-id': backupKc, 'bitgo-id': bitgoKc }, + decryptImpl: async (params) => { + decryptCalls.push(params); + return 'decryptedValue'; + }, + }); + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'currentPass', + boxD: V1, + passcodeEncryptionCode: 'pec-xyz', + generatePdf: stubPdf, + }); + + // First decrypt should be boxD with the PEC + assert.strictEqual(decryptCalls[0].password, 'pec-xyz'); + assert.strictEqual(decryptCalls[0].input, V1); + }); + }); + + describe('already-v2 keychains', function () { + it('skips user key when already v2', async function () { + const putCalls: string[] = []; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V2 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + onPut: (url) => putCalls.push(url), + }); + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + generatePdf: stubPdf, + }); + + // Only backup key PUT — user was skipped + assert.strictEqual(putCalls.length, 1); + assert.ok(putCalls[0].includes('backup-id')); + }); + + it('skips backup key when already v2', async function () { + const putCalls: string[] = []; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V2 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + onPut: (url) => putCalls.push(url), + }); + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + generatePdf: stubPdf, + }); + + assert.strictEqual(putCalls.length, 1); + assert.ok(putCalls[0].includes('user-id')); + }); + }); + + describe('backup key from keycard only (no server-stored encryptedPrv)', function () { + it('re-encrypts from boxB but does not PUT to the server', async function () { + const putCalls: string[] = []; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id' }), // no encryptedPrv + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + onPut: (url) => putCalls.push(url), + }); + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + boxB: V1, + generatePdf: stubPdf, + }); + + // Only user key PUT — no server record for backup + assert.ok( + putCalls.every((u) => !u.includes('backup-id')), + 'backup should not be PUT' + ); + }); + }); + + describe('dry-run mode', function () { + it('makes no PUT calls and returns undefined', async function () { + const putCalls: string[] = []; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + pecResponse: undefined, // PEC not needed in dry-run without boxD + onPut: (url) => putCalls.push(url), + }); + + const result = await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + dryRun: true, + generatePdf: stubPdf, + }); + + assert.strictEqual(result, undefined); + assert.strictEqual(putCalls.length, 0); + }); + + it('does not call unlock in dry-run', async function () { + let unlockCalled = false; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + pecResponse: undefined, + }); + (bitgo as BitGoApiClient).unlock = async () => { + unlockCalled = true; + }; + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + dryRun: true, + }); + + assert.strictEqual(unlockCalled, false); + }); + }); + + describe('session already unlocked', function () { + it('proceeds without throwing when unlock returns "already unlocked longer"', async function () { + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + }); + bitgo.unlock = async () => { + throw new Error('Session already unlocked longer'); + }; + + await assert.doesNotReject(() => + runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + generatePdf: stubPdf, + }) + ); + }); + }); + + describe('passcode encryption code', function () { + it('uses the supplied passcodeEncryptionCode and does not fetch from API', async function () { + let postCalled = false; + const bitgo = makeBitGoClient({ + wallet: makeWallet(), + keychains: { + 'user-id': makeKeychain({ id: 'user-id', encryptedPrv: V1 }), + 'backup-id': makeKeychain({ id: 'backup-id', encryptedPrv: V1 }), + 'bitgo-id': makeKeychain({ id: 'bitgo-id' }), + }, + }); + bitgo.post = () => { + postCalled = true; + return { result: async () => ({}) }; + }; + + await runUpgradeWalletEncryption(bitgo, { + coin: 'tbtc', + walletId: 'wallet-id', + passphrase: 'pass', + passcodeEncryptionCode: 'pec-provided', + generatePdf: stubPdf, + }); + + assert.strictEqual(postCalled, false); + }); + }); +}); diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts index feafa843e0..c441b3bb37 100644 --- a/scripts/upgrade-wallet-encryption.ts +++ b/scripts/upgrade-wallet-encryption.ts @@ -31,124 +31,11 @@ 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 { runUpgradeWalletEncryption } 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, …)' }) @@ -160,10 +47,7 @@ function parseArgs() { .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,32 @@ function parseArgs() { }; } -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - async function main() { - const { - env, + const { env, coin, walletId, passphrase, accessToken, otp, boxD, boxA, boxB, passcodeEncryptionCode, dryRun } = + parseArgs(); + + const bitgo = new BitGo({ env }); + bitgo.authenticateWithAccessToken({ accessToken }); + + const result = await runUpgradeWalletEncryption(bitgo, { 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) { - 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, - passphrase, passcodeEncryptionCode, - encryptionVersion: 2, + dryRun, + 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) => {