Skip to content

Commit 8b2afaa

Browse files
committed
refactor(sdk-core): clean up backup key fallback in upgradeEncryption (WCN-174)
The retry-with-original-passphrase fallback is specific to keys encrypted at wallet creation (backup key, boxA, boxB — these are never re-encrypted on password change). It doesn't belong on the generic Keychains.reencryptAsV2 primitive, which was leaking backup-key context into its error message. - Keychains.reencryptAsV2 is now a pure primitive: decrypt with passphrase, re-encrypt as v2, surface errors directly - Fallback moves to a private Wallet.reencryptCreationTimeKey helper, used only for backup/boxA/boxB paths where the semantics apply - Error messages now name the specific key that failed to decrypt TICKET: WCN-174
1 parent 364e027 commit 8b2afaa

4 files changed

Lines changed: 59 additions & 46 deletions

File tree

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

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,7 +1197,7 @@ describe('V2 Keychains', function () {
11971197
});
11981198

11991199
describe('reencryptAsV2', function () {
1200-
it('decrypts a v1 envelope with the current passphrase and re-encrypts as v2', async function () {
1200+
it('decrypts a v1 envelope with the passphrase and re-encrypts as v2', async function () {
12011201
const prv = 'thePrivateKey';
12021202
const encryptedV1 = await bitgo.encrypt({ input: prv, password: 'myPass', encryptionVersion: 1 });
12031203
JSON.parse(encryptedV1).v.should.equal(1);
@@ -1207,30 +1207,6 @@ describe('V2 Keychains', function () {
12071207
(await bitgo.decrypt({ input: result, password: 'myPass' })).should.equal(prv);
12081208
});
12091209

1210-
it('falls back to originalPassphrase when the current passphrase fails to decrypt', async function () {
1211-
const prv = 'thePrivateKey';
1212-
const encryptedWithOriginal = await bitgo.encrypt({
1213-
input: prv,
1214-
password: 'originalPass',
1215-
encryptionVersion: 1,
1216-
});
1217-
1218-
const result = await keychains.reencryptAsV2(encryptedWithOriginal, 'currentPass', 'originalPass');
1219-
JSON.parse(result).v.should.equal(2);
1220-
(await bitgo.decrypt({ input: result, password: 'currentPass' })).should.equal(prv);
1221-
await bitgo.decrypt({ input: result, password: 'originalPass' }).should.be.rejected();
1222-
});
1223-
1224-
it('throws with a helpful message when decryption fails and no originalPassphrase is provided', async function () {
1225-
const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 });
1226-
await keychains.reencryptAsV2(encrypted, 'wrongPass').should.be.rejectedWith(/original passphrase/);
1227-
});
1228-
1229-
it('throws when both the current and original passphrases fail', async function () {
1230-
const encrypted = await bitgo.encrypt({ input: 'prv', password: 'realPass', encryptionVersion: 1 });
1231-
await keychains.reencryptAsV2(encrypted, 'wrongCurrent', 'alsoWrong').should.be.rejected();
1232-
});
1233-
12341210
it('accepts a v2 envelope and re-encrypts it as v2 (idempotent)', async function () {
12351211
const prv = 'xprv-v2';
12361212
const encryptedV2 = await bitgo.encrypt({ input: prv, password: 'pass', encryptionVersion: 2 });
@@ -1240,5 +1216,10 @@ describe('V2 Keychains', function () {
12401216
JSON.parse(result).v.should.equal(2);
12411217
(await bitgo.decrypt({ input: result, password: 'pass' })).should.equal(prv);
12421218
});
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+
});
12431224
});
12441225
});

modules/sdk-core/src/bitgo/keychain/iKeychains.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export interface IKeychains {
249249
updatePassword(params: UpdatePasswordOptions): Promise<ChangedKeychains>;
250250
updateSingleKeychainPassword(params?: UpdateSingleKeychainPasswordOptions): Promise<Keychain>;
251251
getEncryptionVersion(ciphertext: string): EncryptionVersion;
252-
reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise<string>;
252+
reencryptAsV2(encryptedPrv: string, passphrase: string): Promise<string>;
253253
create(params?: { seed?: Buffer; isRootKey?: boolean }): KeyPair;
254254
add(params?: AddKeychainOptions): Promise<Keychain>;
255255
createBitGo(params?: CreateBitGoOptions): Promise<Keychain>;

modules/sdk-core/src/bitgo/keychain/keychains.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -212,26 +212,16 @@ export class Keychains implements IKeychains {
212212
}
213213

214214
/**
215-
* Decrypt an encrypted private key and re-encrypt it as a v2 (Argon2id + AES-256-GCM) envelope.
216-
* Tries `passphrase` first; falls back to `originalPassphrase` if provided and decryption fails.
217-
* The result is always encrypted with `passphrase` (the current one), never the original.
215+
* Decrypt an encrypted private key with `passphrase` and re-encrypt it as a v2
216+
* (Argon2id + AES-256-GCM) envelope with the same passphrase.
218217
*
219218
* Used to upgrade legacy v1 (SJCL) envelopes to v2 without changing the passphrase.
219+
* Callers that need to try a fallback passphrase (e.g. an original passphrase from
220+
* before a password rotation) should handle that themselves — this primitive does one
221+
* thing and lets decryption errors surface directly.
220222
*/
221-
async reencryptAsV2(encryptedPrv: string, passphrase: string, originalPassphrase?: string): Promise<string> {
222-
let prv: string;
223-
try {
224-
prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase });
225-
} catch {
226-
if (originalPassphrase) {
227-
prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase });
228-
} else {
229-
throw new Error(
230-
'Failed to decrypt with the provided passphrase. ' +
231-
'If the wallet password was changed after creation, provide the original passphrase so the backup key can be decrypted.'
232-
);
233-
}
234-
}
223+
async reencryptAsV2(encryptedPrv: string, passphrase: string): Promise<string> {
224+
const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: passphrase });
235225
return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 });
236226
}
237227

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3523,7 +3523,12 @@ export class Wallet implements IWallet {
35233523
if (keychainsApi.getEncryptionVersion(backupSource) === 2) {
35243524
skipped.push({ type: 'backup', reason: 'already v2' });
35253525
} else {
3526-
const newEncryptedPrv = await keychainsApi.reencryptAsV2(backupSource, passphrase, originalPassphrase);
3526+
const newEncryptedPrv = await this.reencryptCreationTimeKey(
3527+
backupSource,
3528+
passphrase,
3529+
originalPassphrase,
3530+
'backup key'
3531+
);
35273532
backupKeychain.encryptedPrv = newEncryptedPrv;
35283533
if (!dryRun && serverStored) {
35293534
// Only PUT if the key was server-stored; boxB-only wallets have no server record.
@@ -3558,13 +3563,23 @@ export class Wallet implements IWallet {
35583563

35593564
// Re-encrypt MPCv2 key shares (keycard-only — not stored server-side).
35603565
if (boxA) {
3561-
userKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxA, passphrase, originalPassphrase);
3566+
userKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey(
3567+
boxA,
3568+
passphrase,
3569+
originalPassphrase,
3570+
'boxA'
3571+
);
35623572
updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id });
35633573
}
35643574
if (boxB && backupKeychain.encryptedPrv) {
35653575
// MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not.
35663576
// Re-encrypt boxB so the new keycard uses the reduced form instead of the full blob.
3567-
backupKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxB, passphrase, originalPassphrase);
3577+
backupKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey(
3578+
boxB,
3579+
passphrase,
3580+
originalPassphrase,
3581+
'boxB'
3582+
);
35683583
updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id });
35693584
}
35703585

@@ -3587,6 +3602,33 @@ export class Wallet implements IWallet {
35873602
return { doc, walletLabel };
35883603
}
35893604

3605+
/**
3606+
* Re-encrypt a ciphertext that was written at wallet creation time (backup key, boxA, boxB).
3607+
* These are not re-encrypted on password change, so the encryption passphrase may still be
3608+
* the original one from when the wallet was created. Try the current passphrase first; fall
3609+
* back to `originalPassphrase` if provided.
3610+
*/
3611+
private async reencryptCreationTimeKey(
3612+
encryptedPrv: string,
3613+
passphrase: string,
3614+
originalPassphrase: string | undefined,
3615+
keyDescription: string
3616+
): Promise<string> {
3617+
const keychainsApi = this.baseCoin.keychains();
3618+
try {
3619+
return await keychainsApi.reencryptAsV2(encryptedPrv, passphrase);
3620+
} catch (err) {
3621+
if (!originalPassphrase) {
3622+
throw new Error(
3623+
`Failed to decrypt ${keyDescription} with the provided passphrase. If the wallet passphrase ` +
3624+
'was changed after creation, pass boxD so the original passphrase can be recovered.'
3625+
);
3626+
}
3627+
const prv = await this.bitgo.decrypt({ input: encryptedPrv, password: originalPassphrase });
3628+
return this.bitgo.encrypt({ input: prv, password: passphrase, encryptionVersion: 2 });
3629+
}
3630+
}
3631+
35903632
private async fetchPasscodeEncryptionCode(coin: string, walletId: string): Promise<string> {
35913633
const response = (await this.bitgo
35923634
.post(this.bitgo.microservicesUrl(`/api/v2/${coin}/wallet/${walletId}/passcoderecovery`))

0 commit comments

Comments
 (0)