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..b9bd2aae06 --- /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 { 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 | undefined { + 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; + } catch { + // not JSON + } + return undefined; +} + +/** + * 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 || !('passcodeEncryptionCode' in recoveryInfo)) { + throw new Error( + 'passcoderecovery endpoint did not return a passcodeEncryptionCode — pass --passcodeEncryptionCode manually' + ); + } + return recoveryInfo.passcodeEncryptionCode as string; +} + +/** + * 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) { + 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', () => resolve(undefined)); + }) + .on('error', () => resolve(undefined)); + }); +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag: string): string | undefined => { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : undefined; + }; + + const env = (get('--env') ?? 'test') as EnvironmentName; + const coin = get('--coin'); + const walletId = get('--walletId'); + const passphrase = get('--passphrase'); + const accessToken = get('--accessToken'); + const otp = get('--otp'); + const boxD = get('--boxD'); + const boxA = get('--boxA'); + const boxB = get('--boxB'); + const passcodeEncryptionCode = get('--passcodeEncryptionCode'); + const dryRun = args.includes('--dry-run'); + + if (!coin || !walletId || !passphrase || !accessToken) { + console.error( + 'Usage: ts-node scripts/upgrade-wallet-encryption.ts ' + + '--env test --coin tbtc --walletId --passphrase --accessToken ' + + '[--otp ] [--boxD ] [--boxB ] [--passcodeEncryptionCode ] [--dry-run]' + ); + process.exit(1); + } + + return { + env, + coin: coin, + walletId: walletId, + passphrase: passphrase, + accessToken: accessToken, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode, + dryRun, + }; +} + +// --------------------------------------------------------------------------- +// 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, never server-stored) + // generateQrData prefers reducedEncryptedPrv over encryptedPrv for Box A/B; without it the + // full encryptedPrv (the raw DKLs session blob) would be used, producing a multi-hundred MB PDF. + // ------------------------------------------------------------------ + 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); +});