diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 79bd92a001..8a840a5ddb 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -1135,4 +1135,91 @@ describe('V2 Keychains', function () { ); }); }); + + describe('getEncryptionVersion', function () { + it('returns 1 when the v field is absent', function () { + const envelope = JSON.stringify({ ct: 'cipher', iv: 'iv', s: 'salt' }); + keychains.getEncryptionVersion(envelope).should.equal(1); + }); + + it('returns 1 when v is explicitly 1', function () { + keychains.getEncryptionVersion(JSON.stringify({ v: 1, ct: 'abc' })).should.equal(1); + }); + + it('returns 2 when v is 2', function () { + keychains.getEncryptionVersion(JSON.stringify({ v: 2, ct: 'abc' })).should.equal(2); + }); + + it('throws on an unrecognized version number', function () { + assert.throws( + () => keychains.getEncryptionVersion(JSON.stringify({ v: 3, ct: 'abc' })), + /Unrecognized encryption version: 3/ + ); + }); + + it('throws when the input is not valid JSON', function () { + assert.throws(() => keychains.getEncryptionVersion('not-json'), /Failed to parse ciphertext envelope/); + }); + + it('throws when the input is an empty string', function () { + assert.throws(() => keychains.getEncryptionVersion(''), /Failed to parse ciphertext envelope/); + }); + + it('handles a real SJCL v1 envelope', function () { + const sjcl = JSON.stringify({ + iv: 'aaaa', + v: 1, + iter: 10000, + ks: 256, + ts: 64, + mode: 'ccm', + adata: '', + cipher: 'aes', + salt: 'ssss', + ct: 'cccc', + }); + keychains.getEncryptionVersion(sjcl).should.equal(1); + }); + + it('handles a real Argon2id v2 envelope', function () { + const argon2 = JSON.stringify({ + v: 2, + ct: 'cccc', + iv: 'iiii', + tag: 'tttt', + salt: 'ssss', + m: 65536, + t: 3, + p: 4, + }); + keychains.getEncryptionVersion(argon2).should.equal(2); + }); + }); + + describe('reencryptAsV2', function () { + it('decrypts a v1 envelope with the passphrase and re-encrypts as v2', async function () { + const prv = 'thePrivateKey'; + const encryptedV1 = await bitgo.encrypt({ input: prv, password: 'myPass', encryptionVersion: 1 }); + JSON.parse(encryptedV1).v.should.equal(1); + + const result = await keychains.reencryptAsV2(encryptedV1, 'myPass'); + JSON.parse(result).v.should.equal(2); + (await bitgo.decrypt({ input: result, password: 'myPass' })).should.equal(prv); + }); + + it('accepts a v2 envelope and re-encrypts it as v2 (idempotent)', async function () { + const prv = 'xprv-v2'; + const encryptedV2 = await bitgo.encrypt({ input: prv, password: 'pass', encryptionVersion: 2 }); + JSON.parse(encryptedV2).v.should.equal(2); + + const result = await keychains.reencryptAsV2(encryptedV2, 'pass'); + JSON.parse(result).v.should.equal(2); + (await bitgo.decrypt({ input: result, password: 'pass' })).should.equal(prv); + }); + + it('surfaces decrypt errors directly (no fallback logic in the primitive)', async function () { + const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 }); + await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejected(); + }); + }); }); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 761fb25056..ad0737df48 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -6846,4 +6846,238 @@ describe('V2 Wallet:', function () { }); }); }); + + describe('upgradeEncryption', function () { + const walletId = walletData.id; + const [userKeyId, backupKeyId, bitgoKeyId] = walletData.keys; + const passphrase = 'walletPassphrase'; + + beforeEach(function () { + nock.cleanAll(); + }); + + afterEach(function () { + // Prevent leftover interceptors from bleeding into other suites in the file. + nock.cleanAll(); + }); + + function nockUnlock(times = 1) { + return nock(bgUrl).post('/api/v1/user/unlock').times(times).reply(200, {}); + } + + function nockKeychain(id: string, keychain: Record) { + return nock(bgUrl) + .get(`/api/v2/tbtc/key/${id}`) + .reply(200, { id, pub: 'pub', type: 'independent', ...keychain }); + } + + function nockPecFetch(pec: string) { + return nock(bgUrl) + .post(`/api/v2/tbtc/wallet/${walletId}/passcoderecovery`) + .reply(200, { recoveryInfo: { passcodeEncryptionCode: pec } }); + } + + it('re-encrypts both user and backup keys and PUTs them as v2', async function () { + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, {}); + + const puts: Array<{ url: string; body: Record }> = []; + nock(bgUrl) + .put(`/api/v2/tbtc/key/${userKeyId}`, (body) => { + puts.push({ url: userKeyId, body }); + return true; + }) + .reply(200, {}); + nock(bgUrl) + .put(`/api/v2/tbtc/key/${backupKeyId}`, (body) => { + puts.push({ url: backupKeyId, body }); + return true; + }) + .reply(200, {}); + + const result = await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-xyz' }); + + assert.ok(result === undefined, 'no PDF generator → returns undefined'); + puts.should.have.length(2); + const userPut = puts.find((p) => p.url === userKeyId)!; + JSON.parse(userPut.body.encryptedPrv as string).v.should.equal(2); + JSON.parse(userPut.body.originalEncryptedPrv as string).v.should.equal(2); + }); + + it('throws when neither passphrase nor boxD is provided', async function () { + await wallet.upgradeEncryption({}).should.be.rejectedWith(/passphrase.*boxD/); + }); + + it('throws for an MPCv2 wallet when boxA or boxB are missing', async function () { + const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const }; + const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData); + + nockUnlock(); + // PEC fetch happens before the MPCv2 validation short-circuits — allow the call to noop + nockPecFetch('pec'); + + await mpcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }).should.be.rejectedWith(/MPCv2/); + }); + + it('re-encrypts boxA and boxB as reducedEncryptedPrv on the keychains for MPCv2 wallets', async function () { + const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const }; + const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData); + const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + const boxA = await bitgo.encrypt({ input: 'userReducedPrv', password: passphrase, encryptionVersion: 1 }); + const boxB = await bitgo.encrypt({ input: 'backupReducedPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userEnc }); + nockKeychain(backupKeyId, { encryptedPrv: backupEnc }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${backupKeyId}`).reply(200, {}); + + const capturedPdfParams: Array> = []; + const generatePdf = async (params: Record) => { + capturedPdfParams.push(params); + return { output: () => new ArrayBuffer(0) }; + }; + + await mpcWallet.upgradeEncryption({ + passphrase, + boxA, + boxB, + passcodeEncryptionCode: 'pec', + generatePdf, + }); + + capturedPdfParams.should.have.length(1); + const { userKeychain, backupKeychain } = capturedPdfParams[0] as { + userKeychain: { reducedEncryptedPrv?: string }; + backupKeychain: { reducedEncryptedPrv?: string }; + }; + assert.ok(userKeychain.reducedEncryptedPrv, 'user reducedEncryptedPrv must be set'); + JSON.parse(userKeychain.reducedEncryptedPrv!).v.should.equal(2); + assert.ok(backupKeychain.reducedEncryptedPrv, 'backup reducedEncryptedPrv must be set'); + JSON.parse(backupKeychain.reducedEncryptedPrv!).v.should.equal(2); + }); + + it('skips keychains that are already v2', async function () { + const userV2 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 2 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV2 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + + const puts: string[] = []; + // Only the backup key should be PUT — user is already v2. + nock(bgUrl) + .put(new RegExp(`/api/v2/tbtc/key/(${userKeyId}|${backupKeyId})`)) + .reply(200, function (uri) { + puts.push(uri); + return {}; + }); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + + puts.should.have.length(1); + puts[0].should.containEql(backupKeyId); + }); + + it('handles backup keychain with no encryptedPrv (public-key-only) without PUTing', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, {}); // public key only + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + // If we reached here without unhandled nock error, the flow completed without PUTing the backup. + }); + + it('re-encrypts a backup key from boxB without PUTing to the server', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const boxB = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, {}); // no encryptedPrv server-side + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, boxB, passcodeEncryptionCode: 'pec' }); + }); + + it('derives the passphrase from boxD when passphrase is omitted', async function () { + const pec = 'pec-derive'; + const derivedPass = 'derivedFromBoxD'; + const boxD = await bitgo.encrypt({ input: derivedPass, password: pec, encryptionVersion: 1 }); + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: derivedPass, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: derivedPass, encryptionVersion: 1 }); + + nockUnlock(); + nockPecFetch(pec); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + // No passphrase — must be derived from boxD. + await wallet.upgradeEncryption({ boxD }); + }); + + it('makes no PUT calls in dry-run mode and returns undefined', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + // No unlock, no PEC, no PUTs expected. + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + + const result = await wallet.upgradeEncryption({ passphrase, dryRun: true }); + assert.strictEqual(result, undefined); + }); + + it('throws when unlock rejects with an error unrelated to session duration', async function () { + nock(bgUrl).post('/api/v1/user/unlock').replyWithError('invalid OTP'); + + await wallet + .upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }) + .should.be.rejectedWith(/invalid OTP/); + }); + + it('proceeds when unlock reports "already unlocked longer"', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nock(bgUrl).post('/api/v1/user/unlock').reply(401, { error: 'Session already unlocked longer' }); + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }); + }); + + it('uses the supplied passcodeEncryptionCode and skips the passcoderecovery fetch', async function () { + const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 }); + const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 }); + + nockUnlock(); + // Intentionally do NOT nock passcoderecovery — the test fails if the code tries to call it. + nockKeychain(userKeyId, { encryptedPrv: userV1 }); + nockKeychain(backupKeyId, { encryptedPrv: backupV1 }); + nockKeychain(bitgoKeyId, {}); + nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {}); + + await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-supplied' }); + }); + }); }); diff --git a/modules/key-card/src/drawKeycard.ts b/modules/key-card/src/drawKeycard.ts index f60322b0ee..70b00f4e44 100644 --- a/modules/key-card/src/drawKeycard.ts +++ b/modules/key-card/src/drawKeycard.ts @@ -4,6 +4,8 @@ import { IDrawKeyCard } from './types'; import { splitKeys } from './utils'; type jsPDFModule = typeof import('jspdf'); +const isNode = typeof window === 'undefined' || typeof window.document === 'undefined'; + async function loadJSPDF(): Promise { let jsPDF: jsPDFModule; @@ -60,7 +62,7 @@ function moveDown(y: number, ydelta: number): number { // continuation on a later page) and the y-offset just below the drawn QR column (so callers // can place content, e.g. a note, under the QR codes). function drawOnePageOfQrCodes( - qrImages: HTMLCanvasElement[], + qrImages: (HTMLCanvasElement | string)[], doc: jsPDF, y: number, qrSize: number, @@ -75,7 +77,11 @@ function drawOnePageOfQrCodes( return { nextIndex: qrIndex, endY: y }; } - doc.addImage(image, left(0), y, qrSize, qrSize); + if (typeof image === 'string') { + doc.addImage(image, 'PNG', left(0), y, qrSize, qrSize); + } else { + doc.addImage(image, left(0), y, qrSize, qrSize); + } if (qrImages.length === 1) { return { nextIndex: qrIndex + 1, endY: y + qrSize }; @@ -135,7 +141,13 @@ export async function drawKeycard({ if (keyCardImage) { const [imgWidth, imgHeight] = computeKeyCardImageDimensions(keyCardImage); - doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight); + if (isNode) { + // In Node.js, jsPDF cannot extract pixels from an HTMLImageElement (no DOM/canvas). + // The script passes a duck-typed object whose .src is a base64 data URL — use it directly. + doc.addImage((keyCardImage as unknown as { src: string }).src, 'PNG', left(0), y, imgWidth, imgHeight); + } else { + doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight); + } } // Activation Code @@ -204,10 +216,14 @@ export async function drawKeycard({ const textLeft = left(qrSize + 15); let textHeight = 0; - const qrImages: HTMLCanvasElement[] = []; + const qrImages: (HTMLCanvasElement | string)[] = []; const keys = splitKeys(qr.data, QRBinaryMaxLength); for (const key of keys) { - qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + if (isNode) { + qrImages.push(await QRCode.toDataURL(key, { errorCorrectionLevel: 'L' })); + } else { + qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + } } const isMultiPart = qr?.data?.length > QRBinaryMaxLength; diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index 7c98d4c9f4..c9e908cded 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -14,6 +14,7 @@ export * from './extractKeycardFromPDF'; export * from './faq'; export * from './generateQrData'; export * from './parseKeycard'; +export * from './upgradeWalletEncryption'; export * from './utils'; export * from './types'; diff --git a/modules/key-card/src/upgradeWalletEncryption.ts b/modules/key-card/src/upgradeWalletEncryption.ts new file mode 100644 index 0000000000..44d40bd70e --- /dev/null +++ b/modules/key-card/src/upgradeWalletEncryption.ts @@ -0,0 +1,89 @@ +/** + * PDF-generation helpers for the wallet encryption upgrade flow. + * + * Orchestration (unlocking the session, re-encrypting keychains, PUT-ing to the server) lives on + * `Wallet.upgradeEncryption` in `@bitgo/sdk-core`. This module contains only the pieces that + * depend on `jspdf`/`qrcode` — a factory that returns a `UpgradeEncryptionPdfGenerator` the + * caller can pass into that method, plus a Node-side coin-logo loader. + */ + +import * as https from 'https'; +import { BaseCoin, coins } from '@bitgo/statics'; +import { UpgradeEncryptionPdfGenerator } from '@bitgo/sdk-core'; +import { generateQrData } from './generateQrData'; +import { generateFaq } from './faq'; +import { drawKeycard } from './drawKeycard'; + +/** + * Fetch a coin logo image and return an HTMLImageElement-compatible object. + * jsPDF reads `.src` in Node.js; computeKeyCardImageDimensions reads `.width`/`.height`. + * Returns undefined on any failure — the keycard is generated without the logo in that case. + */ +export async function loadKeycardImage( + url: string, + httpGet: typeof https.get = https.get +): Promise { + return new Promise((resolve) => { + httpGet(url, (res) => { + if (res.statusCode !== 200) { + console.warn(`Warning: coin logo not loaded (HTTP ${res.statusCode}) — keycard will be generated without it`); + resolve(undefined); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const contentType = res.headers['content-type'] ?? 'image/png'; + const dataUrl = `data:${contentType};base64,${Buffer.concat(chunks).toString('base64')}`; + const img = { src: dataUrl, width: 303, height: 40 } as unknown as HTMLImageElement; + resolve(img); + }); + res.on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }).on('error', (err) => { + console.warn(`Warning: coin logo not loaded (${err.message}) — keycard will be generated without it`); + resolve(undefined); + }); + }); +} + +export interface KeycardPdfGeneratorOptions { + /** Base URL for loading the coin logo (e.g. Environments[env].uri). Omit to skip the logo. */ + imageBaseUrl?: string; +} + +/** + * Build a {@link UpgradeEncryptionPdfGenerator} that regenerates the keycard PDF with the + * canonical `generateQrData` + `drawKeycard` flow. Pass the returned function into + * `Wallet.upgradeEncryption({ generatePdf })`. + */ +export function createKeycardPdfGenerator(options: KeycardPdfGeneratorOptions = {}): UpgradeEncryptionPdfGenerator { + const { imageBaseUrl } = options; + return async ({ + coinName, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + }) => { + const staticsCoin = coins.get(coinName) as BaseCoin; + const keyCardImage = imageBaseUrl + ? await loadKeycardImage(`${imageBaseUrl}/web/assets/keycards/${staticsCoin.family.toLowerCase()}.png`) + : undefined; + const qrData = await generateQrData({ + coin: staticsCoin, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + encryptionVersion: 2, + }); + const questions = generateFaq(staticsCoin.fullName); + return drawKeycard({ qrData, questions, walletLabel, keyCardImage }); + }; +} diff --git a/modules/key-card/test/unit/upgradeWalletEncryption.ts b/modules/key-card/test/unit/upgradeWalletEncryption.ts new file mode 100644 index 0000000000..e7eacd6555 --- /dev/null +++ b/modules/key-card/test/unit/upgradeWalletEncryption.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import * as https from 'https'; +import { EventEmitter } from 'events'; +import 'should'; +import { loadKeycardImage } from '../../src/upgradeWalletEncryption'; + +function makeIncomingMessage(statusCode: number, contentType?: string) { + const emitter = new EventEmitter() as NodeJS.ReadableStream & { statusCode: number; headers: Record }; + emitter.statusCode = statusCode; + emitter.headers = contentType ? { 'content-type': contentType } : {}; + return emitter; +} + +describe('loadKeycardImage', function () { + it('returns an image object on a 200 response', async function () { + const imageData = Buffer.from('PNG_DATA'); + + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200, 'image/png'); + callback(res); + setImmediate(() => { + res.emit('data', imageData); + res.emit('end'); + }); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.ok(result, 'should return an image object'); + assert.ok((result as unknown as { src: string }).src.startsWith('data:image/png;base64,')); + assert.strictEqual((result as unknown as { width: number }).width, 303); + assert.strictEqual((result as unknown as { height: number }).height, 40); + }); + + it('defaults content-type to image/png when the header is absent', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200); + callback(res); + setImmediate(() => { + res.emit('data', Buffer.from('data')); + res.emit('end'); + }); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.ok((result as unknown as { src: string }).src.startsWith('data:image/png;base64,')); + }); + + it('returns undefined on a non-200 HTTP status', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(404); + callback(res); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); + + it('returns undefined on a stream error', async function () { + const mockGet = ( + _url: string, + callback: (res: NodeJS.ReadableStream & { statusCode: number; headers: Record }) => void + ) => { + const res = makeIncomingMessage(200); + callback(res); + setImmediate(() => res.emit('error', new Error('stream broke'))); + return new EventEmitter() as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); + + it('returns undefined on a request-level error', async function () { + const mockGet = (_url: string, _callback: unknown) => { + const req = new EventEmitter(); + setImmediate(() => req.emit('error', new Error('ECONNREFUSED'))); + return req as ReturnType; + }; + + const result = await loadKeycardImage('https://example.com/logo.png', mockGet as typeof https.get); + assert.strictEqual(result, undefined); + }); +}); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 0a80280446..5b59a84af8 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -248,6 +248,8 @@ export interface IKeychains { list(params?: ListKeychainOptions): Promise; updatePassword(params: UpdatePasswordOptions): Promise; updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Promise; + getEncryptionVersion(ciphertext: string): EncryptionVersion; + reencryptAsV2(encryptedPrv: string, passphrase: string): Promise; create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair; add(params?: AddKeychainOptions): Promise; createBitGo(params?: CreateBitGoOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 96a8678940..8c7ddc5b09 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -151,23 +151,25 @@ export class Keychains implements IKeychains { } /** - * Helper function to determine the encryption version of a ciphertext by parsing it as JSON and checking the "v" field. - * Return undefined if the ciphertext is not a valid JSON or does not contain a supported "v" field. + * Determine the encryption version of a ciphertext by inspecting its "v" field. + * - v1 (SJCL / PBKDF2-SHA256 + AES-256-CCM): "v" absent or explicitly 1 + * - v2 (Argon2id + AES-256-GCM): "v" === 2 + * Throws on unrecognized values so callers cannot silently mis-handle unknown envelopes. */ - private getEncryptionVersion(ciphertext: string): EncryptionVersion | undefined { + getEncryptionVersion(ciphertext: string): EncryptionVersion { + let envelope: { v?: unknown }; try { - const envelope = JSON.parse(ciphertext); - switch (envelope.v) { - case 1: - return 1; - case 2: - return 2; - default: - return undefined; - } - } catch (_) { - return undefined; + envelope = JSON.parse(ciphertext); + } catch (e) { + throw new Error(`Failed to parse ciphertext envelope: ${(e as Error).message}`); + } + if (envelope.v === 2) { + return 2; } + if (envelope.v === undefined || envelope.v === 1) { + return 1; + } + throw new Error(`Unrecognized encryption version: ${String(envelope.v)}`); } /** @@ -209,6 +211,20 @@ export class Keychains implements IKeychains { } } + /** + * Decrypt an encrypted private key with `passphrase` and re-encrypt it as a v2 + * (Argon2id + AES-256-GCM) envelope with the same passphrase. + * + * Used to upgrade legacy v1 (SJCL) envelopes to v2 without changing the passphrase. + * Callers that need to try a fallback passphrase (e.g. an original passphrase from + * before a password rotation) should handle that themselves — this primitive does one + * thing and lets decryption errors surface directly. + */ + async reencryptAsV2(encryptedPrv: string, passphrase: string): Promise { + const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase }); + return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); + } + /** * Create a public/private key pair * @param params - optional params diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 99a3314752..06b30f01d3 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1077,6 +1077,51 @@ export interface RemovePolicyRuleOptions { message?: string; } +/** + * Callback invoked by {@link IWallet.upgradeEncryption} to generate the regenerated keycard PDF. + * The wallet method itself is agnostic to how the PDF is produced — callers wire in the PDF + * generator (typically the one exported from `@bitgo/key-card`) so `sdk-core` does not need + * a browser/canvas dependency at build time. + */ +export type UpgradeEncryptionPdfGenerator = (params: { + coinName: string; + userKeychain: Keychain; + backupKeychain: Keychain; + bitgoKeychain: Keychain; + passphrase: string; + passcodeEncryptionCode: string; + walletLabel: string; +}) => Promise; + +export interface UpgradeEncryptionOptions { + /** + * Current wallet passphrase. Omit when {@link boxD} is provided and the passphrase has never + * been changed — the passphrase is derived by decrypting Box D with the PEC. + */ + passphrase?: string; + otp?: string; + /** + * Box D ciphertext from the original keycard. + * Required when {@link passphrase} is omitted (to derive it). + * Also required when the passphrase was changed after wallet creation (to recover the original + * passphrase for decrypting the backup key). + */ + boxD?: string; + /** Box A ciphertext — required for MPCv2 wallets (user reducedEncryptedPrv). */ + boxA?: string; + /** Box B ciphertext — required for MPCv2 wallets and older wallets without server-stored backup key. */ + boxB?: string; + passcodeEncryptionCode?: string; + dryRun?: boolean; + /** Optional callback for regenerating the keycard PDF. When omitted, no PDF is produced. */ + generatePdf?: UpgradeEncryptionPdfGenerator; +} + +export interface UpgradeEncryptionResult { + doc: unknown; + walletLabel: string; +} + export interface DownloadKeycardOptions { jsPDF?: any; QRCode?: any; @@ -1206,6 +1251,7 @@ export interface IWallet { toGoStakingWallet(): IGoStakingWallet; toAddressBook(): IAddressBook; downloadKeycard(params?: DownloadKeycardOptions): Promise; + upgradeEncryption(params: UpgradeEncryptionOptions): Promise; buildAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; sendAccountConsolidation(params?: PrebuildAndSignTransactionOptions): Promise; sendAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index a5cb336524..fc372b75f2 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -126,6 +126,8 @@ import { UpdateAddressOptions, UpdateBuildDefaultOptions, UpdateWalletOptions, + UpgradeEncryptionOptions, + UpgradeEncryptionResult, WalletCoinSpecific, WalletData, WalletEcdsaChallenges, @@ -3384,6 +3386,263 @@ export class Wallet implements IWallet { doc.save(`BitGo Keycard for ${walletLabel}.pdf`); } + /** + * Upgrade this wallet's keychain encryption from v1 (SJCL / PBKDF2-SHA256 + AES-256-CCM) to + * v2 (Argon2id + AES-256-GCM) and, optionally, regenerate the keycard PDF. + * + * Steps: + * 1. Unlock the BitGo session (skipped in dry-run). + * 2. Validate MPCv2 requirements (boxA/boxB when relevant). + * 3. Resolve the current and original passphrases (deriving from boxD if needed). + * 4. Re-encrypt user and backup keychains from v1 to v2 and PUT them to the server. + * 5. Re-encrypt MPCv2 reducedEncryptedPrv shares from boxA/boxB (keycard-only, no server PUT). + * 6. Invoke the injected {@link UpgradeEncryptionPdfGenerator} to regenerate the keycard PDF. + * + * @returns the generated PDF and wallet label, or undefined in dry-run or when no PDF generator is provided. + */ + async upgradeEncryption(params: UpgradeEncryptionOptions): Promise { + const { + passphrase: passphraseArg, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode: pecArg, + dryRun = false, + generatePdf, + } = params; + + if (!passphraseArg && !boxD) { + throw new Error('Either passphrase or boxD must be provided.'); + } + + const coinName = this.baseCoin.getChain(); + const walletId = this.id(); + const walletLabel = this.label(); + const keychainsApi = this.baseCoin.keychains(); + + if (dryRun) console.log('[dry-run] No changes will be persisted.'); + + if (!dryRun) { + try { + await this.bitgo + .post(this.bitgo.microservicesUrl('/api/v1/user/unlock')) + .send({ otp: otp ?? '0000000', duration: 600 }) + .result(); + console.log('Session unlocked.'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already unlocked longer')) { + console.log('Session already unlocked (longer duration) — proceeding.'); + } else { + throw err; + } + } + } + + console.log(`Wallet: ${walletLabel} (${walletId})`); + + if (this.multisigTypeVersion() === 'MPCv2' && (!boxA || !boxB)) { + throw new Error( + 'This is an MPCv2 wallet. boxA and boxB are required to re-encrypt the reducedEncryptedPrv ' + + 'key shares stored on the keycard. Without them the new keycard will be missing Box A and Box B.' + ); + } + + // PEC is needed to decrypt boxD (when provided) and to generate Box D in the new keycard. + // Always fetched when boxD is provided; otherwise skipped in dry-run. + const needsPec = boxD || !dryRun; + const pecResolved: string | undefined = + pecArg ?? (needsPec ? await this.fetchPasscodeEncryptionCode(coinName, walletId) : undefined); + + if (!pecResolved && !dryRun) { + throw new Error( + 'passcodeEncryptionCode is required — pass passcodeEncryptionCode or ensure the endpoint is accessible' + ); + } + + // pecResolved is guaranteed to be a string when not in dry-run; dry-run returns early before use. + const passcodeEncryptionCode = pecResolved as string; + + // Resolve the wallet passphrase and the original passphrase (if different). + // + // Two scenarios: + // 1. passphrase omitted, boxD provided: passphrase was never changed. Box D decrypts to the + // current passphrase. Derive it — no separate originalPassphrase needed. + // 2. passphrase provided, boxD provided: passphrase was changed after creation. Box D holds + // the original (needed to decrypt the backup key, which was encrypted at creation time). + let passphrase: string; + let originalPassphrase: string | undefined; + + if (!passphraseArg) { + passphrase = await this.bitgo.decrypt({ input: boxD as string, password: passcodeEncryptionCode }); + console.log('Passphrase derived from Box D.'); + } else { + passphrase = passphraseArg; + if (boxD && passcodeEncryptionCode) { + originalPassphrase = await this.bitgo.decrypt({ input: boxD, password: passcodeEncryptionCode }); + console.log('Recovered original passphrase from Box D.'); + } + } + + const keyIds = this.keyIds(); + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + keychainsApi.get({ id: keyIds[0] }), + keychainsApi.get({ id: keyIds[1] }), + keychainsApi.get({ id: keyIds[2] }), + ]); + + const updated: Array<{ type: string; id: string }> = []; + const skipped: Array<{ type: string; reason: string }> = []; + + // Re-encrypt user key. Always encrypted with the current passphrase (BitGo re-encrypts on + // password change), so no originalPassphrase fallback is needed here. originalEncryptedPrv + // is set to the new value so BitGo's password-change flow stays consistent. + if (userKeychain.encryptedPrv) { + if (keychainsApi.getEncryptionVersion(userKeychain.encryptedPrv) === 2) { + skipped.push({ type: 'user', reason: 'already v2' }); + } else { + const newEncryptedPrv = await keychainsApi.reencryptAsV2(userKeychain.encryptedPrv, passphrase); + userKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun) { + await this.bitgo + .put(this.baseCoin.url(`/key/${encodeURIComponent(userKeychain.id)}`)) + .send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv }) + .result(); + } + updated.push({ type: 'user', id: userKeychain.id }); + } + } else { + skipped.push({ type: 'user', reason: 'no encryptedPrv' }); + } + + // Re-encrypt backup key. May be encrypted under the original passphrase if the wallet + // password was changed after creation — fall back to originalPassphrase if current fails. + // Source: server-stored encryptedPrv (preferred) or boxB for older/keycard-only wallets. + const serverStored = !!backupKeychain.encryptedPrv; + const backupSource = backupKeychain.encryptedPrv ?? boxB; + if (backupSource) { + if (keychainsApi.getEncryptionVersion(backupSource) === 2) { + skipped.push({ type: 'backup', reason: 'already v2' }); + } else { + const newEncryptedPrv = await this.reencryptCreationTimeKey( + backupSource, + passphrase, + originalPassphrase, + 'backup key' + ); + backupKeychain.encryptedPrv = newEncryptedPrv; + if (!dryRun && serverStored) { + // Only PUT if the key was server-stored; boxB-only wallets have no server record. + await this.bitgo + .put(this.baseCoin.url(`/key/${encodeURIComponent(backupKeychain.id)}`)) + .send({ encryptedPrv: newEncryptedPrv }) + .result(); + } + updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupKeychain.id }); + } + } else { + skipped.push({ type: 'backup', reason: 'public key only' }); + } + + if (dryRun) { + console.log('Skipped (dry-run):'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + console.log('Would re-encrypt:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + console.log('[dry-run] Done — no changes persisted.'); + return undefined; + } + + if (updated.length > 0) { + console.log('Re-encrypted:'); + updated.forEach((u) => console.log(` ${u.type} key ${u.id}`)); + } + if (skipped.length > 0) { + console.log('Skipped:'); + skipped.forEach((s) => console.log(` ${s.type}: ${s.reason}`)); + } + + // Re-encrypt MPCv2 key shares (keycard-only — not stored server-side). + if (boxA) { + userKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey( + boxA, + passphrase, + originalPassphrase, + 'boxA' + ); + updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id }); + } + if (boxB && backupKeychain.encryptedPrv) { + // MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not. + // Re-encrypt boxB so the new keycard uses the reduced form instead of the full blob. + backupKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey( + boxB, + passphrase, + originalPassphrase, + 'boxB' + ); + updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id }); + } + + if (!generatePdf) { + console.log('Done (no PDF generator provided).'); + return undefined; + } + + const doc = await generatePdf({ + coinName, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletLabel, + }); + + console.log('Done.'); + return { doc, walletLabel }; + } + + /** + * Re-encrypt a ciphertext that was written at wallet creation time (backup key, boxA, boxB). + * These are not re-encrypted on password change, so the encryption passphrase may still be + * the original one from when the wallet was created. Try the current passphrase first; fall + * back to `originalPassphrase` if provided. + */ + private async reencryptCreationTimeKey( + encryptedPrv: string, + passphrase: string, + originalPassphrase: string | undefined, + keyDescription: string + ): Promise { + const keychainsApi = this.baseCoin.keychains(); + try { + return await keychainsApi.reencryptAsV2(encryptedPrv, passphrase); + } catch (err) { + if (!originalPassphrase) { + throw new Error( + `Failed to decrypt ${keyDescription} with the provided passphrase. If the wallet passphrase ` + + 'was changed after creation, pass boxD so the original passphrase can be recovered.' + ); + } + const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase }); + return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 }); + } + } + + private async fetchPasscodeEncryptionCode(coin: string, walletId: string): Promise { + const response = (await this.bitgo + .post(this.bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`)) + .result()) as { recoveryInfo?: { passcodeEncryptionCode?: string } }; + if (!response.recoveryInfo || typeof response.recoveryInfo.passcodeEncryptionCode !== 'string') { + throw new Error( + 'passcoderecovery endpoint did not return a passcodeEncryptionCode — pass passcodeEncryptionCode manually' + ); + } + return response.recoveryInfo.passcodeEncryptionCode; + } + /** * Builds a set of consolidation transactions for a wallet. * @param params diff --git a/scripts/upgrade-wallet-encryption.ts b/scripts/upgrade-wallet-encryption.ts new file mode 100644 index 0000000000..001ba4ea90 --- /dev/null +++ b/scripts/upgrade-wallet-encryption.ts @@ -0,0 +1,99 @@ +/** + * 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 yargsLib from 'yargs'; +import { Environments, EnvironmentName } from '../modules/sdk-core/src/bitgo/environments'; +import { createKeycardPdfGenerator } from '../modules/key-card/src/upgradeWalletEncryption'; +import { BitGo } from '../modules/bitgo/dist/src/bitgo'; + +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', description: 'Current wallet passphrase. Omit when --boxD is provided and the passphrase has never been changed — it will be derived from Box D.' }) + .option('accessToken', { type: 'string', demandOption: true, description: 'Short-lived BitGo access token' }) + .option('otp', { type: 'string', description: 'OTP for session unlock' }) + .option('boxD', { type: 'string', description: 'Box D ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) + .option('boxA', { type: 'string', description: 'Box A ciphertext from the original keycard (MPCv2)', coerce: (v: string) => v?.replace(/\s/g, '') }) + .option('boxB', { type: 'string', description: 'Box B ciphertext from the original keycard', coerce: (v: string) => v?.replace(/\s/g, '') }) + .option('passcodeEncryptionCode', { type: 'string', description: 'Passcode encryption code (fetched automatically if omitted)' }) + .option('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'], + }; +} + +async function main() { + const { env, coin, walletId, passphrase, accessToken, otp, boxD, boxA, boxB, passcodeEncryptionCode, dryRun } = + parseArgs(); + + const bitgo = new BitGo({ env }); + bitgo.authenticateWithAccessToken({ accessToken }); + + const wallet = await bitgo.coin(coin).wallets().get({ id: walletId }); + const result = await wallet.upgradeEncryption({ + passphrase, + otp, + boxD, + boxA, + boxB, + passcodeEncryptionCode, + dryRun, + generatePdf: createKeycardPdfGenerator({ imageBaseUrl: Environments[env].uri }), + }); + + 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) => { + console.error('Fatal:', err.message ?? err); + process.exit(1); +});