Skip to content

Commit 6c83cf1

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 8b6909e commit 6c83cf1

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
@@ -3525,7 +3525,12 @@ export class Wallet implements IWallet {
35253525
if (keychainsApi.getEncryptionVersion(backupSource) === 2) {
35263526
skipped.push({ type: 'backup', reason: 'already v2' });
35273527
} else {
3528-
const newEncryptedPrv = await keychainsApi.reencryptAsV2(backupSource, passphrase, originalPassphrase);
3528+
const newEncryptedPrv = await this.reencryptCreationTimeKey(
3529+
backupSource,
3530+
passphrase,
3531+
originalPassphrase,
3532+
'backup key'
3533+
);
35293534
backupKeychain.encryptedPrv = newEncryptedPrv;
35303535
if (!dryRun && serverStored) {
35313536
// Only PUT if the key was server-stored; boxB-only wallets have no server record.
@@ -3560,13 +3565,23 @@ export class Wallet implements IWallet {
35603565

35613566
// Re-encrypt MPCv2 key shares (keycard-only — not stored server-side).
35623567
if (boxA) {
3563-
userKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxA, passphrase, originalPassphrase);
3568+
userKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey(
3569+
boxA,
3570+
passphrase,
3571+
originalPassphrase,
3572+
'boxA'
3573+
);
35643574
updated.push({ type: 'user reducedEncryptedPrv (keycard only)', id: userKeychain.id });
35653575
}
35663576
if (boxB && backupKeychain.encryptedPrv) {
35673577
// MPCv2: encryptedPrv exists server-side but reducedEncryptedPrv does not.
35683578
// Re-encrypt boxB so the new keycard uses the reduced form instead of the full blob.
3569-
backupKeychain.reducedEncryptedPrv = await keychainsApi.reencryptAsV2(boxB, passphrase, originalPassphrase);
3579+
backupKeychain.reducedEncryptedPrv = await this.reencryptCreationTimeKey(
3580+
boxB,
3581+
passphrase,
3582+
originalPassphrase,
3583+
'boxB'
3584+
);
35703585
updated.push({ type: 'backup reducedEncryptedPrv (keycard only)', id: backupKeychain.id });
35713586
}
35723587

@@ -3589,6 +3604,33 @@ export class Wallet implements IWallet {
35893604
return { doc, walletLabel };
35903605
}
35913606

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

0 commit comments

Comments
 (0)