Skip to content

Commit 45a8f59

Browse files
authored
Merge pull request #9204 from BitGo/WCN-174-reencrypt
feat: add script to reencrypt wallet with v2 encryption
2 parents 0b03926 + 6c83cf1 commit 45a8f59

11 files changed

Lines changed: 963 additions & 19 deletions

File tree

modules/bitgo/test/v2/unit/keychains.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,4 +1135,91 @@ describe('V2 Keychains', function () {
11351135
);
11361136
});
11371137
});
1138+
1139+
describe('getEncryptionVersion', function () {
1140+
it('returns 1 when the v field is absent', function () {
1141+
const envelope = JSON.stringify({ ct: 'cipher', iv: 'iv', s: 'salt' });
1142+
keychains.getEncryptionVersion(envelope).should.equal(1);
1143+
});
1144+
1145+
it('returns 1 when v is explicitly 1', function () {
1146+
keychains.getEncryptionVersion(JSON.stringify({ v: 1, ct: 'abc' })).should.equal(1);
1147+
});
1148+
1149+
it('returns 2 when v is 2', function () {
1150+
keychains.getEncryptionVersion(JSON.stringify({ v: 2, ct: 'abc' })).should.equal(2);
1151+
});
1152+
1153+
it('throws on an unrecognized version number', function () {
1154+
assert.throws(
1155+
() => keychains.getEncryptionVersion(JSON.stringify({ v: 3, ct: 'abc' })),
1156+
/Unrecognized encryption version: 3/
1157+
);
1158+
});
1159+
1160+
it('throws when the input is not valid JSON', function () {
1161+
assert.throws(() => keychains.getEncryptionVersion('not-json'), /Failed to parse ciphertext envelope/);
1162+
});
1163+
1164+
it('throws when the input is an empty string', function () {
1165+
assert.throws(() => keychains.getEncryptionVersion(''), /Failed to parse ciphertext envelope/);
1166+
});
1167+
1168+
it('handles a real SJCL v1 envelope', function () {
1169+
const sjcl = JSON.stringify({
1170+
iv: 'aaaa',
1171+
v: 1,
1172+
iter: 10000,
1173+
ks: 256,
1174+
ts: 64,
1175+
mode: 'ccm',
1176+
adata: '',
1177+
cipher: 'aes',
1178+
salt: 'ssss',
1179+
ct: 'cccc',
1180+
});
1181+
keychains.getEncryptionVersion(sjcl).should.equal(1);
1182+
});
1183+
1184+
it('handles a real Argon2id v2 envelope', function () {
1185+
const argon2 = JSON.stringify({
1186+
v: 2,
1187+
ct: 'cccc',
1188+
iv: 'iiii',
1189+
tag: 'tttt',
1190+
salt: 'ssss',
1191+
m: 65536,
1192+
t: 3,
1193+
p: 4,
1194+
});
1195+
keychains.getEncryptionVersion(argon2).should.equal(2);
1196+
});
1197+
});
1198+
1199+
describe('reencryptAsV2', function () {
1200+
it('decrypts a v1 envelope with the passphrase and re-encrypts as v2', async function () {
1201+
const prv = 'thePrivateKey';
1202+
const encryptedV1 = await bitgo.encrypt({ input: prv, password: 'myPass', encryptionVersion: 1 });
1203+
JSON.parse(encryptedV1).v.should.equal(1);
1204+
1205+
const result = await keychains.reencryptAsV2(encryptedV1, 'myPass');
1206+
JSON.parse(result).v.should.equal(2);
1207+
(await bitgo.decrypt({ input: result, password: 'myPass' })).should.equal(prv);
1208+
});
1209+
1210+
it('accepts a v2 envelope and re-encrypts it as v2 (idempotent)', async function () {
1211+
const prv = 'xprv-v2';
1212+
const encryptedV2 = await bitgo.encrypt({ input: prv, password: 'pass', encryptionVersion: 2 });
1213+
JSON.parse(encryptedV2).v.should.equal(2);
1214+
1215+
const result = await keychains.reencryptAsV2(encryptedV2, 'pass');
1216+
JSON.parse(result).v.should.equal(2);
1217+
(await bitgo.decrypt({ input: result, password: 'pass' })).should.equal(prv);
1218+
});
1219+
1220+
it('surfaces decrypt errors directly (no fallback logic in the primitive)', async function () {
1221+
const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 });
1222+
await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejected();
1223+
});
1224+
});
11381225
});

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6846,4 +6846,238 @@ describe('V2 Wallet:', function () {
68466846
});
68476847
});
68486848
});
6849+
6850+
describe('upgradeEncryption', function () {
6851+
const walletId = walletData.id;
6852+
const [userKeyId, backupKeyId, bitgoKeyId] = walletData.keys;
6853+
const passphrase = 'walletPassphrase';
6854+
6855+
beforeEach(function () {
6856+
nock.cleanAll();
6857+
});
6858+
6859+
afterEach(function () {
6860+
// Prevent leftover interceptors from bleeding into other suites in the file.
6861+
nock.cleanAll();
6862+
});
6863+
6864+
function nockUnlock(times = 1) {
6865+
return nock(bgUrl).post('/api/v1/user/unlock').times(times).reply(200, {});
6866+
}
6867+
6868+
function nockKeychain(id: string, keychain: Record<string, unknown>) {
6869+
return nock(bgUrl)
6870+
.get(`/api/v2/tbtc/key/${id}`)
6871+
.reply(200, { id, pub: 'pub', type: 'independent', ...keychain });
6872+
}
6873+
6874+
function nockPecFetch(pec: string) {
6875+
return nock(bgUrl)
6876+
.post(`/api/v2/tbtc/wallet/${walletId}/passcoderecovery`)
6877+
.reply(200, { recoveryInfo: { passcodeEncryptionCode: pec } });
6878+
}
6879+
6880+
it('re-encrypts both user and backup keys and PUTs them as v2', async function () {
6881+
const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
6882+
const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
6883+
6884+
nockUnlock();
6885+
nockKeychain(userKeyId, { encryptedPrv: userEnc });
6886+
nockKeychain(backupKeyId, { encryptedPrv: backupEnc });
6887+
nockKeychain(bitgoKeyId, {});
6888+
6889+
const puts: Array<{ url: string; body: Record<string, unknown> }> = [];
6890+
nock(bgUrl)
6891+
.put(`/api/v2/tbtc/key/${userKeyId}`, (body) => {
6892+
puts.push({ url: userKeyId, body });
6893+
return true;
6894+
})
6895+
.reply(200, {});
6896+
nock(bgUrl)
6897+
.put(`/api/v2/tbtc/key/${backupKeyId}`, (body) => {
6898+
puts.push({ url: backupKeyId, body });
6899+
return true;
6900+
})
6901+
.reply(200, {});
6902+
6903+
const result = await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-xyz' });
6904+
6905+
assert.ok(result === undefined, 'no PDF generator → returns undefined');
6906+
puts.should.have.length(2);
6907+
const userPut = puts.find((p) => p.url === userKeyId)!;
6908+
JSON.parse(userPut.body.encryptedPrv as string).v.should.equal(2);
6909+
JSON.parse(userPut.body.originalEncryptedPrv as string).v.should.equal(2);
6910+
});
6911+
6912+
it('throws when neither passphrase nor boxD is provided', async function () {
6913+
await wallet.upgradeEncryption({}).should.be.rejectedWith(/passphrase.*boxD/);
6914+
});
6915+
6916+
it('throws for an MPCv2 wallet when boxA or boxB are missing', async function () {
6917+
const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const };
6918+
const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData);
6919+
6920+
nockUnlock();
6921+
// PEC fetch happens before the MPCv2 validation short-circuits — allow the call to noop
6922+
nockPecFetch('pec');
6923+
6924+
await mpcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' }).should.be.rejectedWith(/MPCv2/);
6925+
});
6926+
6927+
it('re-encrypts boxA and boxB as reducedEncryptedPrv on the keychains for MPCv2 wallets', async function () {
6928+
const mpcWalletData = { ...walletData, multisigTypeVersion: 'MPCv2' as const };
6929+
const mpcWallet = new Wallet(bitgo, basecoin, mpcWalletData);
6930+
const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
6931+
const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
6932+
const boxA = await bitgo.encrypt({ input: 'userReducedPrv', password: passphrase, encryptionVersion: 1 });
6933+
const boxB = await bitgo.encrypt({ input: 'backupReducedPrv', password: passphrase, encryptionVersion: 1 });
6934+
6935+
nockUnlock();
6936+
nockKeychain(userKeyId, { encryptedPrv: userEnc });
6937+
nockKeychain(backupKeyId, { encryptedPrv: backupEnc });
6938+
nockKeychain(bitgoKeyId, {});
6939+
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});
6940+
nock(bgUrl).put(`/api/v2/tbtc/key/${backupKeyId}`).reply(200, {});
6941+
6942+
const capturedPdfParams: Array<Record<string, unknown>> = [];
6943+
const generatePdf = async (params: Record<string, unknown>) => {
6944+
capturedPdfParams.push(params);
6945+
return { output: () => new ArrayBuffer(0) };
6946+
};
6947+
6948+
await mpcWallet.upgradeEncryption({
6949+
passphrase,
6950+
boxA,
6951+
boxB,
6952+
passcodeEncryptionCode: 'pec',
6953+
generatePdf,
6954+
});
6955+
6956+
capturedPdfParams.should.have.length(1);
6957+
const { userKeychain, backupKeychain } = capturedPdfParams[0] as {
6958+
userKeychain: { reducedEncryptedPrv?: string };
6959+
backupKeychain: { reducedEncryptedPrv?: string };
6960+
};
6961+
assert.ok(userKeychain.reducedEncryptedPrv, 'user reducedEncryptedPrv must be set');
6962+
JSON.parse(userKeychain.reducedEncryptedPrv!).v.should.equal(2);
6963+
assert.ok(backupKeychain.reducedEncryptedPrv, 'backup reducedEncryptedPrv must be set');
6964+
JSON.parse(backupKeychain.reducedEncryptedPrv!).v.should.equal(2);
6965+
});
6966+
6967+
it('skips keychains that are already v2', async function () {
6968+
const userV2 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 2 });
6969+
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
6970+
6971+
nockUnlock();
6972+
nockKeychain(userKeyId, { encryptedPrv: userV2 });
6973+
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
6974+
nockKeychain(bitgoKeyId, {});
6975+
6976+
const puts: string[] = [];
6977+
// Only the backup key should be PUT — user is already v2.
6978+
nock(bgUrl)
6979+
.put(new RegExp(`/api/v2/tbtc/key/(${userKeyId}|${backupKeyId})`))
6980+
.reply(200, function (uri) {
6981+
puts.push(uri);
6982+
return {};
6983+
});
6984+
6985+
await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
6986+
6987+
puts.should.have.length(1);
6988+
puts[0].should.containEql(backupKeyId);
6989+
});
6990+
6991+
it('handles backup keychain with no encryptedPrv (public-key-only) without PUTing', async function () {
6992+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
6993+
6994+
nockUnlock();
6995+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
6996+
nockKeychain(backupKeyId, {}); // public key only
6997+
nockKeychain(bitgoKeyId, {});
6998+
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});
6999+
7000+
await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
7001+
// If we reached here without unhandled nock error, the flow completed without PUTing the backup.
7002+
});
7003+
7004+
it('re-encrypts a backup key from boxB without PUTing to the server', async function () {
7005+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
7006+
const boxB = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
7007+
7008+
nockUnlock();
7009+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
7010+
nockKeychain(backupKeyId, {}); // no encryptedPrv server-side
7011+
nockKeychain(bitgoKeyId, {});
7012+
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});
7013+
7014+
await wallet.upgradeEncryption({ passphrase, boxB, passcodeEncryptionCode: 'pec' });
7015+
});
7016+
7017+
it('derives the passphrase from boxD when passphrase is omitted', async function () {
7018+
const pec = 'pec-derive';
7019+
const derivedPass = 'derivedFromBoxD';
7020+
const boxD = await bitgo.encrypt({ input: derivedPass, password: pec, encryptionVersion: 1 });
7021+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: derivedPass, encryptionVersion: 1 });
7022+
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: derivedPass, encryptionVersion: 1 });
7023+
7024+
nockUnlock();
7025+
nockPecFetch(pec);
7026+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
7027+
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
7028+
nockKeychain(bitgoKeyId, {});
7029+
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});
7030+
7031+
// No passphrase — must be derived from boxD.
7032+
await wallet.upgradeEncryption({ boxD });
7033+
});
7034+
7035+
it('makes no PUT calls in dry-run mode and returns undefined', async function () {
7036+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
7037+
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
7038+
7039+
// No unlock, no PEC, no PUTs expected.
7040+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
7041+
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
7042+
nockKeychain(bitgoKeyId, {});
7043+
7044+
const result = await wallet.upgradeEncryption({ passphrase, dryRun: true });
7045+
assert.strictEqual(result, undefined);
7046+
});
7047+
7048+
it('throws when unlock rejects with an error unrelated to session duration', async function () {
7049+
nock(bgUrl).post('/api/v1/user/unlock').replyWithError('invalid OTP');
7050+
7051+
await wallet
7052+
.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' })
7053+
.should.be.rejectedWith(/invalid OTP/);
7054+
});
7055+
7056+
it('proceeds when unlock reports "already unlocked longer"', async function () {
7057+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
7058+
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
7059+
7060+
nock(bgUrl).post('/api/v1/user/unlock').reply(401, { error: 'Session already unlocked longer' });
7061+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
7062+
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
7063+
nockKeychain(bitgoKeyId, {});
7064+
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});
7065+
7066+
await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
7067+
});
7068+
7069+
it('uses the supplied passcodeEncryptionCode and skips the passcoderecovery fetch', async function () {
7070+
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });
7071+
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });
7072+
7073+
nockUnlock();
7074+
// Intentionally do NOT nock passcoderecovery — the test fails if the code tries to call it.
7075+
nockKeychain(userKeyId, { encryptedPrv: userV1 });
7076+
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
7077+
nockKeychain(bitgoKeyId, {});
7078+
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});
7079+
7080+
await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-supplied' });
7081+
});
7082+
});
68497083
});

modules/key-card/src/drawKeycard.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { IDrawKeyCard } from './types';
44
import { splitKeys } from './utils';
55
type jsPDFModule = typeof import('jspdf');
66

7+
const isNode = typeof window === 'undefined' || typeof window.document === 'undefined';
8+
79
async function loadJSPDF(): Promise<jsPDFModule> {
810
let jsPDF: jsPDFModule;
911

@@ -60,7 +62,7 @@ function moveDown(y: number, ydelta: number): number {
6062
// continuation on a later page) and the y-offset just below the drawn QR column (so callers
6163
// can place content, e.g. a note, under the QR codes).
6264
function drawOnePageOfQrCodes(
63-
qrImages: HTMLCanvasElement[],
65+
qrImages: (HTMLCanvasElement | string)[],
6466
doc: jsPDF,
6567
y: number,
6668
qrSize: number,
@@ -75,7 +77,11 @@ function drawOnePageOfQrCodes(
7577
return { nextIndex: qrIndex, endY: y };
7678
}
7779

78-
doc.addImage(image, left(0), y, qrSize, qrSize);
80+
if (typeof image === 'string') {
81+
doc.addImage(image, 'PNG', left(0), y, qrSize, qrSize);
82+
} else {
83+
doc.addImage(image, left(0), y, qrSize, qrSize);
84+
}
7985

8086
if (qrImages.length === 1) {
8187
return { nextIndex: qrIndex + 1, endY: y + qrSize };
@@ -135,7 +141,13 @@ export async function drawKeycard({
135141

136142
if (keyCardImage) {
137143
const [imgWidth, imgHeight] = computeKeyCardImageDimensions(keyCardImage);
138-
doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight);
144+
if (isNode) {
145+
// In Node.js, jsPDF cannot extract pixels from an HTMLImageElement (no DOM/canvas).
146+
// The script passes a duck-typed object whose .src is a base64 data URL — use it directly.
147+
doc.addImage((keyCardImage as unknown as { src: string }).src, 'PNG', left(0), y, imgWidth, imgHeight);
148+
} else {
149+
doc.addImage(keyCardImage, left(0), y, imgWidth, imgHeight);
150+
}
139151
}
140152

141153
// Activation Code
@@ -204,10 +216,14 @@ export async function drawKeycard({
204216
const textLeft = left(qrSize + 15);
205217
let textHeight = 0;
206218

207-
const qrImages: HTMLCanvasElement[] = [];
219+
const qrImages: (HTMLCanvasElement | string)[] = [];
208220
const keys = splitKeys(qr.data, QRBinaryMaxLength);
209221
for (const key of keys) {
210-
qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' }));
222+
if (isNode) {
223+
qrImages.push(await QRCode.toDataURL(key, { errorCorrectionLevel: 'L' }));
224+
} else {
225+
qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' }));
226+
}
211227
}
212228

213229
const isMultiPart = qr?.data?.length > QRBinaryMaxLength;

0 commit comments

Comments
 (0)