Skip to content

Commit aaae6b6

Browse files
committed
feat(passkey-crypto): migrate encryption from SJCL to Argon2id v2
Replace synchronous SJCL-based encrypt/decrypt calls with async Argon2id v2 equivalents across attachPasskeyToWallet and removePasskeyFromWallet. - attachPasskeyToWallet: use decryptAsync + encryptAsync with encryptionVersion: 2 - removePasskeyFromWallet: use decryptKeychainPrivateKeyAsync instead of sync variant - derivePassword: update JSDoc to reflect Argon2id v2 password usage - Test mocks updated to support encryptAsync/decryptAsync with version-dispatching - Integration tests assert v2 envelope and use decryptV2 for round-trip verification - Unit tests add v2-specific coverage for both attach and remove flows - Bump package version to 0.3.0 and add @bitgo/sdk-api devDependency Ticket: WCN-412
1 parent c08a68e commit aaae6b6

10 files changed

Lines changed: 159 additions & 27 deletions

File tree

modules/passkey-crypto/CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
55

66
# 0.2.0 (2026-05-05)
77

8-
98
### Features
109

11-
* **passkey-crypto:** add @bitgo/passkey-crypto package ([727a8e1](https://github.com/BitGo/BitGoJS/commit/727a8e156bd5cdea72fa0d2820410d75c8663ba2))
12-
* **passkey-crypto:** extend package with PRF helpers and WebAuthn types ([942bd84](https://github.com/BitGo/BitGoJS/commit/942bd8444bb1b9b726f2e344ce9ddc0fbe718fbf))
10+
- **passkey-crypto:** add @bitgo/passkey-crypto package ([727a8e1](https://github.com/BitGo/BitGoJS/commit/727a8e156bd5cdea72fa0d2820410d75c8663ba2))
11+
- **passkey-crypto:** extend package with PRF helpers and WebAuthn types ([942bd84](https://github.com/BitGo/BitGoJS/commit/942bd8444bb1b9b726f2e344ce9ddc0fbe718fbf))

modules/passkey-crypto/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bitgo/passkey-crypto",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Pure cryptographic primitives for BitGo passkey (WebAuthn PRF) key derivation",
55
"main": "./dist/src/index.js",
66
"types": "./dist/src/index.d.ts",
@@ -39,6 +39,7 @@
3939
"@bitgo/sdk-core": "^36.44.0"
4040
},
4141
"devDependencies": {
42+
"@bitgo/sdk-api": "^1.79.2",
4243
"@types/node": "^18.0.0",
4344
"sjcl": "1.0.1"
4445
}

modules/passkey-crypto/src/attachPasskeyToWallet.ts

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

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

4646
// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
4747
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
@@ -62,7 +62,11 @@ export async function attachPasskeyToWallet(params: {
6262

6363
// Derive password from PRF output and re-encrypt
6464
const prfPassword = derivePassword(authResult.prfResult);
65-
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
65+
const encryptedPrv = await bitgo.encryptAsync({
66+
password: prfPassword,
67+
input: privateKey,
68+
encryptionVersion: 2,
69+
});
6670

6771
// Convert enterpriseSalt from hex to base64url (URL-safe, no padding)
6872
// as required by the server's prfSalt validation.

modules/passkey-crypto/src/derivePassword.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* Derives a wallet passphrase from a WebAuthn PRF result.
33
*
4-
* The PRF output (ArrayBuffer) is hex-encoded and used directly as the
5-
* walletPassphrase for SJCL-based encryption (bitgo.encrypt).
4+
* The PRF output (ArrayBuffer) is hex-encoded and used directly as the password
5+
* passed into Argon2id v2 encryption (`bitgo.encryptAsync` with
6+
* `encryptionVersion: 2`) and the auto-detecting `bitgo.decryptAsync` path.
67
*
78
* @param prfResult - Raw PRF output from WebAuthn credential assertion
89
* @returns Lowercase hex string to use as walletPassphrase

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/integration/helpers/mockBitGo.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as sinon from 'sinon';
2+
import { decryptV2, encryptV2 } from '@bitgo/sdk-api';
23
import {
34
ASSERTION_CHALLENGE,
45
BASE_SALT,
@@ -20,6 +21,33 @@ function realDecrypt({ password, input }: { password: string; input: string }):
2021
return sjcl.decrypt(password, typeof input === 'string' ? JSON.parse(input) : input);
2122
}
2223

24+
async function mockEncryptAsync(params: {
25+
password: string;
26+
input: string;
27+
encryptionVersion?: number;
28+
}): Promise<string> {
29+
if (params.encryptionVersion === 2) {
30+
return encryptV2(params.password, params.input);
31+
}
32+
return realEncrypt(params);
33+
}
34+
35+
async function mockDecryptAsync(params: { password: string; input: string }): Promise<string> {
36+
let envelopeVersion: number | undefined;
37+
try {
38+
envelopeVersion = JSON.parse(params.input).v;
39+
} catch {
40+
throw new Error('decrypt: ciphertext is not valid JSON');
41+
}
42+
if (envelopeVersion === 2) {
43+
return decryptV2(params.password, params.input);
44+
}
45+
if (envelopeVersion !== undefined && envelopeVersion !== 1) {
46+
throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`);
47+
}
48+
return realDecrypt(params);
49+
}
50+
2351
export interface KeychainState {
2452
id: string;
2553
encryptedPrv: string;
@@ -81,6 +109,8 @@ export function makeMockBitGo(initialEncryptedPrv: string): MockBitGo {
81109

82110
encrypt: (params: { password: string; input: string }) => realEncrypt(params),
83111
decrypt: (params: { password: string; input: string }) => realDecrypt(params),
112+
encryptAsync: mockEncryptAsync,
113+
decryptAsync: mockDecryptAsync,
84114

85115
get: sinon.stub().callsFake((url: string) => {
86116
if (url.includes('/user/otp/webauthn/register')) {

modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as assert from 'assert';
2+
import { decryptV2, encryptV2 } from '@bitgo/sdk-api';
23
import { derivePassword } from '../../src/derivePassword';
34
import { deriveEnterpriseSalt } from '../../src/deriveEnterpriseSalt';
45
import { registerPasskey } from '../../src/registerPasskey';
@@ -23,7 +24,6 @@ import {
2324
// Use sjcl directly for round-trip encryption tests — same crypto as mockBitGo
2425
const sjcl = require('sjcl');
2526
const sjclEncrypt = (password: string, input: string) => JSON.stringify(sjcl.encrypt(password, input));
26-
const sjclDecrypt = (password: string, input: string) => sjcl.decrypt(password, JSON.parse(input));
2727

2828
describe('passkey-crypto integration', function () {
2929
let initialEncryptedPrv: string;
@@ -52,8 +52,10 @@ describe('passkey-crypto integration', function () {
5252
provider,
5353
});
5454

55-
// Verify encryptedPrv round-trips with the PRF-derived password
56-
const decrypted = sjclDecrypt(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv);
55+
assert.strictEqual(JSON.parse(keychainState.encryptedPrv).v, 2);
56+
57+
// Verify encryptedPrv round-trips with the PRF-derived password (Argon2id v2 envelope)
58+
const decrypted = await decryptV2(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv);
5759
assert.strictEqual(decrypted, PRIVATE_KEY);
5860

5961
// prfSalt stored in webauthnInfo must be valid base64url
@@ -81,7 +83,7 @@ describe('passkey-crypto integration', function () {
8183

8284
// Same passphrase as what attach used — decrypts the stored key
8385
assert.strictEqual(derivedPassphrase, derivePassword(PRF_OUTPUT));
84-
assert.strictEqual(sjclDecrypt(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY);
86+
assert.strictEqual(await decryptV2(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY);
8587
});
8688
});
8789

@@ -137,7 +139,7 @@ describe('passkey-crypto integration', function () {
137139
const { bitgo } = makeMockBitGo(initialEncryptedPrv);
138140
const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
139141

140-
// Uses real sjcl decrypt — wrong passphrase genuinely fails decryptKeychainPrivateKey
142+
// Uses real sjcl decrypt — wrong passphrase genuinely fails decryptKeychainPrivateKeyAsync
141143
await assert.rejects(
142144
() =>
143145
removePasskeyFromWallet({
@@ -152,5 +154,35 @@ describe('passkey-crypto integration', function () {
152154

153155
assert.strictEqual(bitgo.del.callCount, 0);
154156
});
157+
158+
it('removePasskeyFromWallet accepts correct passphrase and rejects wrong when encryptedPrv is v2', async function () {
159+
const v2Passphrase = 'v2-removal-passphrase';
160+
const v2EncryptedPrv = await encryptV2(v2Passphrase, PRIVATE_KEY);
161+
const { bitgo } = makeMockBitGo(v2EncryptedPrv);
162+
const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
163+
164+
await removePasskeyFromWallet({
165+
bitgo,
166+
coin: COIN,
167+
walletId: WALLET_ID,
168+
device,
169+
walletPassphrase: v2Passphrase,
170+
});
171+
assert.strictEqual(bitgo.del.callCount, 1);
172+
173+
const { bitgo: bitgoWrong } = makeMockBitGo(v2EncryptedPrv);
174+
await assert.rejects(
175+
() =>
176+
removePasskeyFromWallet({
177+
bitgo: bitgoWrong,
178+
coin: COIN,
179+
walletId: WALLET_ID,
180+
device,
181+
walletPassphrase: 'wrong-passphrase',
182+
}),
183+
/Incorrect wallet passphrase/
184+
);
185+
assert.strictEqual(bitgoWrong.del.callCount, 0);
186+
});
155187
});
156188
});

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as assert from 'assert';
22
import * as sinon from 'sinon';
3+
import { decryptV2, encryptV2 } from '@bitgo/sdk-api';
34
import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet';
45
import { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from '../../src/webAuthnTypes';
56

@@ -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);
@@ -139,6 +140,13 @@ describe('attachPasskeyToWallet', function () {
139140
assert.strictEqual(getArgs.publicKey.allowCredentials[0].type, 'public-key');
140141
assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer);
141142

143+
sinon.assert.calledOnce(mockBitGo.encryptAsync);
144+
sinon.assert.calledWithExactly(mockBitGo.encryptAsync, {
145+
password: '1e5cb478',
146+
input: decryptedPrv,
147+
encryptionVersion: 2,
148+
});
149+
142150
// PUT called with correct shape
143151
sinon.assert.calledOnce(mockBitGo.put);
144152
sinon.assert.calledWith(mockBitGo.put, `/api/v2/${coin}/key/${keychainId}`);
@@ -223,7 +231,7 @@ describe('attachPasskeyToWallet', function () {
223231
});
224232

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

228236
await assert.rejects(
229237
() => callAttach(),
@@ -236,6 +244,18 @@ describe('attachPasskeyToWallet', function () {
236244
sinon.assert.notCalled(mockBitGo.put);
237245
});
238246

247+
it('should succeed when keychain encryptedPrv is already a v2 envelope', async function () {
248+
const v2Input = await encryptV2(existingPassphrase, decryptedPrv);
249+
mockWallet.getEncryptedUserKeychain.resolves({ id: keychainId, encryptedPrv: v2Input });
250+
mockBitGo.decryptAsync.callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input));
251+
252+
const result = await callAttach();
253+
254+
sinon.assert.calledOnce(mockBitGo.decryptAsync);
255+
sinon.assert.calledWithExactly(mockBitGo.decryptAsync, { password: existingPassphrase, input: v2Input });
256+
assert.strictEqual(result.id, keychainId);
257+
});
258+
239259
it('should use device.credentialId as the key in evalByCredential', async function () {
240260
await callAttach();
241261

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as assert from 'assert';
22
import * as sinon from 'sinon';
3+
import { decryptV2, encryptV2 } from '@bitgo/sdk-api';
34
import { removePasskeyFromWallet } from '../../src';
45

56
describe('removePasskeyFromWallet', function () {
@@ -39,7 +40,7 @@ describe('removePasskeyFromWallet', function () {
3940
wallets: sinon.stub().returns(mockWallets),
4041
keychains: sinon.stub().returns(mockKeychains),
4142
}),
42-
decrypt: sinon.stub().returns('xprv-decrypted'),
43+
decryptAsync: sinon.stub().resolves('xprv-decrypted'),
4344
del: sinon.stub().returns({
4445
result: sinon.stub().resolves({}),
4546
}),
@@ -75,7 +76,51 @@ describe('removePasskeyFromWallet', function () {
7576
});
7677

7778
it('should throw and not call DELETE if passphrase is wrong', async function () {
78-
mockBitGo.decrypt = sinon.stub().throws(new Error('decryption failed'));
79+
mockBitGo.decryptAsync = sinon.stub().rejects(new Error('decryption failed'));
80+
81+
await assert.rejects(
82+
() =>
83+
removePasskeyFromWallet({
84+
bitgo: mockBitGo,
85+
coin: coinName,
86+
walletId,
87+
device,
88+
walletPassphrase: 'wrong-passphrase',
89+
}),
90+
(err: Error) => {
91+
assert.ok(err.message.includes('Incorrect wallet passphrase'));
92+
return true;
93+
}
94+
);
95+
96+
sinon.assert.notCalled(mockBitGo.del);
97+
});
98+
99+
it('should verify v2 encryptedPrv then remove device', async function () {
100+
const v2Passphrase = 'unit-v2-wallet-pass';
101+
const v2Blob = await encryptV2(v2Passphrase, 'xprv-decrypted');
102+
mockKeychains.get = sinon.stub().resolves({ id: keychainId, encryptedPrv: v2Blob });
103+
mockBitGo.decryptAsync = sinon
104+
.stub()
105+
.callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input));
106+
107+
await removePasskeyFromWallet({
108+
bitgo: mockBitGo,
109+
coin: coinName,
110+
walletId,
111+
device,
112+
walletPassphrase: v2Passphrase,
113+
});
114+
115+
sinon.assert.calledOnce(mockBitGo.del);
116+
});
117+
118+
it('should throw and not call DELETE when v2 encryptedPrv and passphrase is wrong', async function () {
119+
const v2Blob = await encryptV2('correct-v2-pass', 'xprv-decrypted');
120+
mockKeychains.get = sinon.stub().resolves({ id: keychainId, encryptedPrv: v2Blob });
121+
mockBitGo.decryptAsync = sinon
122+
.stub()
123+
.callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input));
79124

80125
await assert.rejects(
81126
() =>

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18953,7 +18953,7 @@ sisteransi@^1.0.5:
1895318953
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
1895418954
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
1895518955

18956-
sjcl@^1.0.6, "sjcl@npm:@bitgo/sjcl@1.0.1":
18956+
sjcl@1.0.1, sjcl@^1.0.6, "sjcl@npm:@bitgo/sjcl@1.0.1":
1895718957
version "1.0.1"
1895818958
resolved "https://registry.npmjs.org/@bitgo/sjcl/-/sjcl-1.0.1.tgz#633fa84608c1cb7461b17ceb6131d96722921fd3"
1895918959
integrity sha512-dBICMzShC8gXdpSj9cvl4wl9Jkt4h14wt4XQ+/6V6qcC2IObyKRJfaG5TYUU6RvVknhPBPyBx9v84vNKODM5fQ==

0 commit comments

Comments
 (0)