Skip to content

Commit 2433d2a

Browse files
authored
Merge pull request #8868 from BitGo/WCN-412
feat: add v2 encrypt/decrypt for passkey
2 parents f50200c + 4326ee1 commit 2433d2a

4 files changed

Lines changed: 37 additions & 15 deletions

File tree

modules/passkey-crypto/src/attachPasskeyToWallet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function attachPasskeyToWallet(params: {
4444
const prfSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
4545

4646
// Decrypt private key with existing passphrase
47-
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
47+
const privateKey = await bitgo.decryptAsync({ password: existingPassphrase, input: keychain.encryptedPrv });
4848

4949
// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
5050
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
@@ -66,7 +66,7 @@ export async function attachPasskeyToWallet(params: {
6666
}
6767

6868
const prfPassword = derivePassword(authResult.prfResult);
69-
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
69+
const encryptedPrv = await bitgo.encryptAsync({ password: prfPassword, input: privateKey, encryptionVersion: 2 });
7070

7171
const updatedKeychain = await bitgo
7272
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))

modules/passkey-crypto/src/removePasskeyFromWallet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BitGoBase, decryptKeychainPrivateKey } from '@bitgo/sdk-core';
1+
import { BitGoBase, decryptKeychainPrivateKeyAsync } from '@bitgo/sdk-core';
22
import { WebAuthnOtpDevice } from './webAuthnTypes';
33

44
export async function removePasskeyFromWallet(params: {
@@ -20,7 +20,7 @@ export async function removePasskeyFromWallet(params: {
2020
const keychain = await baseCoin.keychains().get({ id: keychainId });
2121

2222
// Verify passphrase before any mutation
23-
const decrypted = decryptKeychainPrivateKey(bitgo, keychain, walletPassphrase);
23+
const decrypted = await decryptKeychainPrivateKeyAsync(bitgo, keychain, walletPassphrase);
2424
if (!decrypted) {
2525
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
2626
}

modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as assert from 'assert';
22
import * as sinon from 'sinon';
33
import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet';
4+
import { derivePassword } from '../../src/derivePassword';
45
import { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from '../../src/webAuthnTypes';
56

67
describe('attachPasskeyToWallet', function () {
@@ -53,8 +54,8 @@ describe('attachPasskeyToWallet', function () {
5354
url: sinon.SinonStub;
5455
coin: sinon.SinonStub;
5556
put: sinon.SinonStub;
56-
decrypt: sinon.SinonStub;
57-
encrypt: sinon.SinonStub;
57+
decryptAsync: sinon.SinonStub;
58+
encryptAsync: sinon.SinonStub;
5859
};
5960

6061
let mockProvider: {
@@ -83,17 +84,17 @@ describe('attachPasskeyToWallet', function () {
8384
.callsFake((path, version) => `/api/v${version ?? 1}${path}`),
8485
coin: sinon.stub().returns(mockBaseCoin),
8586
put: sinon.stub(),
86-
decrypt: sinon.stub(),
87-
encrypt: sinon.stub(),
87+
decryptAsync: sinon.stub(),
88+
encryptAsync: sinon.stub(),
8889
};
8990

9091
mockProvider = {
9192
create: sinon.stub(),
9293
get: sinon.stub(),
9394
};
9495

95-
mockBitGo.decrypt.returns(decryptedPrv);
96-
mockBitGo.encrypt.returns(reEncryptedPrv);
96+
mockBitGo.decryptAsync.resolves(decryptedPrv);
97+
mockBitGo.encryptAsync.resolves(reEncryptedPrv);
9798

9899
const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) });
99100
mockBitGo.put.returns({ send: putSendStub });
@@ -124,8 +125,8 @@ describe('attachPasskeyToWallet', function () {
124125
sinon.assert.calledWith(mockWallets.get, { id: walletId });
125126
sinon.assert.calledOnce(mockWallet.type);
126127
sinon.assert.calledOnce(mockWallet.getEncryptedUserKeychain);
127-
sinon.assert.calledOnce(mockBitGo.decrypt);
128-
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv });
128+
sinon.assert.calledOnce(mockBitGo.decryptAsync);
129+
sinon.assert.calledWithExactly(mockBitGo.decryptAsync, { password: existingPassphrase, input: encryptedPrv });
129130

130131
// provider.get called with evalByCredential keyed on device.credentialId
131132
sinon.assert.calledOnce(mockProvider.get);
@@ -151,9 +152,30 @@ describe('attachPasskeyToWallet', function () {
151152
assert.match(putBody.webauthnInfo.prfSalt, /^[A-Za-z0-9\-_]+$/);
152153
assert.strictEqual(typeof putBody.webauthnInfo.encryptedPrv, 'string');
153154

155+
// encryptAsync must be called with encryptionVersion 2
156+
sinon.assert.calledOnce(mockBitGo.encryptAsync);
157+
sinon.assert.calledWithMatch(mockBitGo.encryptAsync, { encryptionVersion: 2 });
158+
154159
assert.strictEqual(result.id, keychainId);
155160
});
156161

162+
it('should re-encrypt the private key as a v2 Argon2id envelope', async function () {
163+
const expectedPrfPassword = derivePassword(prfResultBuffer);
164+
165+
await callAttach();
166+
167+
// The PRF-derived password and the decrypted xprv must be passed to encryptAsync
168+
sinon.assert.calledWithMatch(mockBitGo.encryptAsync, {
169+
password: expectedPrfPassword,
170+
input: decryptedPrv,
171+
encryptionVersion: 2,
172+
});
173+
174+
// The v2 blob returned by encryptAsync is what gets stored on the server
175+
const putBody = mockBitGo.put.firstCall.returnValue.send.firstCall.args[0];
176+
assert.strictEqual(putBody.webauthnInfo.encryptedPrv, reEncryptedPrv);
177+
});
178+
157179
it('should decode credentialId containing base64url-specific characters (- and _)', async function () {
158180
const deviceWithUrlChars: WebAuthnOtpDevice = {
159181
...device,
@@ -223,7 +245,7 @@ describe('attachPasskeyToWallet', function () {
223245
});
224246

225247
it('should propagate decrypt errors', async function () {
226-
mockBitGo.decrypt.throws(new Error('decryption failed'));
248+
mockBitGo.decryptAsync.rejects(new Error('decryption failed'));
227249

228250
await assert.rejects(
229251
() => callAttach(),

modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('removePasskeyFromWallet', function () {
3939
wallets: sinon.stub().returns(mockWallets),
4040
keychains: sinon.stub().returns(mockKeychains),
4141
}),
42-
decrypt: sinon.stub().returns('xprv-decrypted'),
42+
decryptAsync: sinon.stub().resolves('xprv-decrypted'),
4343
del: sinon.stub().returns({
4444
result: sinon.stub().resolves({}),
4545
}),
@@ -75,7 +75,7 @@ describe('removePasskeyFromWallet', function () {
7575
});
7676

7777
it('should throw and not call DELETE if passphrase is wrong', async function () {
78-
mockBitGo.decrypt = sinon.stub().throws(new Error('decryption failed'));
78+
mockBitGo.decryptAsync = sinon.stub().resolves(undefined);
7979

8080
await assert.rejects(
8181
() =>

0 commit comments

Comments
 (0)