Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions modules/bitgo/test/v2/unit/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
234 changes: 234 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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<string, unknown> }> = [];
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<Record<string, unknown>> = [];
const generatePdf = async (params: Record<string, unknown>) => {
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' });
});
});
});
26 changes: 21 additions & 5 deletions modules/key-card/src/drawKeycard.ts
Comment thread
pranavjain97 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<jsPDFModule> {
let jsPDF: jsPDFModule;

Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading