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
118 changes: 91 additions & 27 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6882,9 +6882,9 @@ describe('V2 Wallet:', function () {
const backupEnc = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockKeychain(userKeyId, { encryptedPrv: userEnc });
nockKeychain(backupKeyId, { encryptedPrv: backupEnc });
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userEnc });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupEnc });
nockKeychain(bitgoKeyId, { source: 'bitgo' });

const puts: Array<{ url: string; body: Record<string, unknown> }> = [];
nock(bgUrl)
Expand Down Expand Up @@ -6933,9 +6933,9 @@ describe('V2 Wallet:', function () {
const boxB = await bitgo.encrypt({ input: 'backupReducedPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockKeychain(userKeyId, { encryptedPrv: userEnc });
nockKeychain(backupKeyId, { encryptedPrv: backupEnc });
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userEnc });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupEnc });
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});
nock(bgUrl).put(`/api/v2/tbtc/key/${backupKeyId}`).reply(200, {});

Expand Down Expand Up @@ -6969,9 +6969,9 @@ describe('V2 Wallet:', function () {
const backupV1 = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockKeychain(userKeyId, { encryptedPrv: userV2 });
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV2 });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, { source: 'bitgo' });

const puts: string[] = [];
// Only the backup key should be PUT — user is already v2.
Expand All @@ -6992,9 +6992,9 @@ describe('V2 Wallet:', function () {
const userV1 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockKeychain(userKeyId, { encryptedPrv: userV1 });
nockKeychain(backupKeyId, {}); // public key only
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup' }); // public key only
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});

await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
Expand All @@ -7006,9 +7006,9 @@ describe('V2 Wallet:', function () {
const boxB = await bitgo.encrypt({ input: 'backupPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockKeychain(userKeyId, { encryptedPrv: userV1 });
nockKeychain(backupKeyId, {}); // no encryptedPrv server-side
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup' }); // no encryptedPrv server-side
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(`/api/v2/tbtc/key/${userKeyId}`).reply(200, {});

await wallet.upgradeEncryption({ passphrase, boxB, passcodeEncryptionCode: 'pec' });
Expand All @@ -7023,9 +7023,9 @@ describe('V2 Wallet:', function () {

nockUnlock();
nockPecFetch(pec);
nockKeychain(userKeyId, { encryptedPrv: userV1 });
nockKeychain(backupKeyId, { encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});

// No passphrase — must be derived from boxD.
Expand All @@ -7037,9 +7037,9 @@ describe('V2 Wallet:', function () {
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, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, { source: 'bitgo' });

const result = await wallet.upgradeEncryption({ passphrase, dryRun: true });
assert.strictEqual(result, undefined);
Expand All @@ -7058,9 +7058,9 @@ describe('V2 Wallet:', function () {
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, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});

await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
Expand All @@ -7072,12 +7072,76 @@ describe('V2 Wallet:', function () {

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, {});
nockKeychain(userKeyId, { source: 'user', encryptedPrv: userV1 });
nockKeychain(backupKeyId, { source: 'backup', encryptedPrv: backupV1 });
nockKeychain(bitgoKeyId, { source: 'bitgo' });
nock(bgUrl).put(new RegExp(`/api/v2/tbtc/key/`)).twice().reply(200, {});

await wallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec-supplied' });
});

describe('OFC wallets (2 keys, no backup)', function () {
const ofcCoin = bitgo.coin('ofc');
const ofcUserKeyId = 'ofc00000000000000000000000000001';
const ofcBitgoKeyId = 'ofc00000000000000000000000000002';
const ofcWalletData = {
id: 'ofc0000000000000000000000wallet1',
coin: 'ofc',
keys: [ofcUserKeyId, ofcBitgoKeyId],
coinSpecific: {},
multisigType: 'onchain' as const,
type: 'hot' as const,
};
const ofcWallet = new Wallet(bitgo, ofcCoin, ofcWalletData as any);

function nockOfcKeychain(id: string, keychain: Record<string, unknown>) {
return nock(bgUrl)
.get(`/api/v2/ofc/key/${id}`)
.reply(200, { id, pub: 'pub', type: 'independent', ...keychain });
}

it('re-encrypts the user key and skips bitgo key with no encryptedPrv', async function () {
const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });

nockUnlock();
nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userEnc });
nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' });

const puts: Array<{ id: string; body: Record<string, unknown> }> = [];
nock(bgUrl)
.put(`/api/v2/ofc/key/${ofcUserKeyId}`, (body) => {
puts.push({ id: ofcUserKeyId, body });
return true;
})
.reply(200, {});

const result = await ofcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
assert.strictEqual(result, undefined);
puts.should.have.length(1);
puts[0].id.should.equal(ofcUserKeyId);
JSON.parse(puts[0].body.encryptedPrv as string).v.should.equal(2);
});

it('skips user key already at v2', async function () {
const userV2 = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 2 });

nockUnlock();
nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userV2 });
nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' });

await ofcWallet.upgradeEncryption({ passphrase, passcodeEncryptionCode: 'pec' });
// No PUTs expected — nock will error if any unexpected call is made.
});

it('makes no PUT calls in dry-run mode', async function () {
const userEnc = await bitgo.encrypt({ input: 'userPrv', password: passphrase, encryptionVersion: 1 });

nockOfcKeychain(ofcUserKeyId, { source: 'user', encryptedPrv: userEnc });
nockOfcKeychain(ofcBitgoKeyId, { source: 'bitgo' });

const result = await ofcWallet.upgradeEncryption({ passphrase, dryRun: true });
assert.strictEqual(result, undefined);
});
});
});
});
26 changes: 13 additions & 13 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3486,11 +3486,10 @@ export class Wallet implements IWallet {
}

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 allKeychains = await Promise.all(keyIds.map((id) => keychainsApi.get({ id })));
const userKeychain = allKeychains.find((k) => k.source === 'user') ?? allKeychains[KeyIndices.USER];
const backupKeychain = allKeychains.find((k) => k.source === 'backup');
const bitgoKeychain = allKeychains.find((k) => k.source === 'bitgo');

const updated: Array<{ type: string; id: string }> = [];
const skipped: Array<{ type: string; reason: string }> = [];
Expand All @@ -3507,7 +3506,7 @@ export class Wallet implements IWallet {
if (!dryRun) {
await this.bitgo
.put(this.baseCoin.url(`/key/${encodeURIComponent(userKeychain.id)}`))
.send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv })
.send({ encryptedPrv: newEncryptedPrv, originalEncryptedPrv: newEncryptedPrv, pub: userKeychain.pub })
.result();
}
updated.push({ type: 'user', id: userKeychain.id });
Expand All @@ -3519,8 +3518,8 @@ export class Wallet implements IWallet {
// 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;
const serverStored = !!backupKeychain?.encryptedPrv;
const backupSource = backupKeychain?.encryptedPrv ?? boxB;
if (backupSource) {
if (keychainsApi.getEncryptionVersion(backupSource) === 2) {
skipped.push({ type: 'backup', reason: 'already v2' });
Expand All @@ -3531,15 +3530,16 @@ export class Wallet implements IWallet {
originalPassphrase,
'backup key'
);
backupKeychain.encryptedPrv = newEncryptedPrv;
if (!dryRun && serverStored) {
if (backupKeychain) backupKeychain.encryptedPrv = newEncryptedPrv;
if (!dryRun && serverStored && backupKeychain) {
// 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 });
const backupId = backupKeychain?.id ?? boxB ?? 'unknown';
updated.push({ type: serverStored ? 'backup' : 'backup (keycard only)', id: backupId });
}
} else {
skipped.push({ type: 'backup', reason: 'public key only' });
Expand Down Expand Up @@ -3573,7 +3573,7 @@ export class Wallet implements IWallet {
);
updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id });
}
if (boxB && backupKeychain.encryptedPrv) {
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(
Expand All @@ -3585,7 +3585,7 @@ export class Wallet implements IWallet {
updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id });
}

if (!generatePdf) {
if (!generatePdf || !backupKeychain || !bitgoKeychain) {
console.log('Done (no PDF generator provided).');
return undefined;
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/upgrade-wallet-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
* [--passcodeEncryptionCode <code>] \
* [--dry-run]
*
* OFC wallets (--coin ofc):
* Only --passphrase is required. --boxD / --boxA / --boxB do not apply.
* No keycard PDF is produced. The script iterates all key IDs on the wallet
* and re-encrypts any that still use v1 (SJCL) encryption.
*
* --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
Expand Down
Loading