diff --git a/modules/passkey-crypto/CHANGELOG.md b/modules/passkey-crypto/CHANGELOG.md index 915a67a74e..64d9522198 100644 --- a/modules/passkey-crypto/CHANGELOG.md +++ b/modules/passkey-crypto/CHANGELOG.md @@ -5,8 +5,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline # 0.2.0 (2026-05-05) - ### Features -* **passkey-crypto:** add @bitgo/passkey-crypto package ([727a8e1](https://github.com/BitGo/BitGoJS/commit/727a8e156bd5cdea72fa0d2820410d75c8663ba2)) -* **passkey-crypto:** extend package with PRF helpers and WebAuthn types ([942bd84](https://github.com/BitGo/BitGoJS/commit/942bd8444bb1b9b726f2e344ce9ddc0fbe718fbf)) +- **passkey-crypto:** add @bitgo/passkey-crypto package ([727a8e1](https://github.com/BitGo/BitGoJS/commit/727a8e156bd5cdea72fa0d2820410d75c8663ba2)) +- **passkey-crypto:** extend package with PRF helpers and WebAuthn types ([942bd84](https://github.com/BitGo/BitGoJS/commit/942bd8444bb1b9b726f2e344ce9ddc0fbe718fbf)) diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 1fb57c0c37..26d5d56657 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@bitgo/passkey-crypto", - "version": "0.2.0", + "version": "0.3.0", "description": "Pure cryptographic primitives for BitGo passkey (WebAuthn PRF) key derivation", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", @@ -39,6 +39,7 @@ "@bitgo/sdk-core": "^36.44.0" }, "devDependencies": { + "@bitgo/sdk-api": "^1.79.2", "@types/node": "^18.0.0", "sjcl": "1.0.1" } diff --git a/modules/passkey-crypto/src/attachPasskeyToWallet.ts b/modules/passkey-crypto/src/attachPasskeyToWallet.ts index 575828f7b9..0366ea7492 100644 --- a/modules/passkey-crypto/src/attachPasskeyToWallet.ts +++ b/modules/passkey-crypto/src/attachPasskeyToWallet.ts @@ -41,7 +41,7 @@ export async function attachPasskeyToWallet(params: { const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId); // Decrypt private key with existing passphrase - const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv }); + const privateKey = await bitgo.decryptAsync({ password: existingPassphrase, input: keychain.encryptedPrv }); // Decode credentialId from base64url to ArrayBuffer for allowCredentials. // The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential, @@ -62,7 +62,11 @@ export async function attachPasskeyToWallet(params: { // Derive password from PRF output and re-encrypt const prfPassword = derivePassword(authResult.prfResult); - const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey }); + const encryptedPrv = await bitgo.encryptAsync({ + password: prfPassword, + input: privateKey, + encryptionVersion: 2, + }); // Convert enterpriseSalt from hex to base64url (URL-safe, no padding) // as required by the server's prfSalt validation. diff --git a/modules/passkey-crypto/src/derivePassword.ts b/modules/passkey-crypto/src/derivePassword.ts index 6865811af5..862fb023dd 100644 --- a/modules/passkey-crypto/src/derivePassword.ts +++ b/modules/passkey-crypto/src/derivePassword.ts @@ -1,8 +1,9 @@ /** * Derives a wallet passphrase from a WebAuthn PRF result. * - * The PRF output (ArrayBuffer) is hex-encoded and used directly as the - * walletPassphrase for SJCL-based encryption (bitgo.encrypt). + * The PRF output (ArrayBuffer) is hex-encoded and used directly as the password + * passed into Argon2id v2 encryption (`bitgo.encryptAsync` with + * `encryptionVersion: 2`) and the auto-detecting `bitgo.decryptAsync` path. * * @param prfResult - Raw PRF output from WebAuthn credential assertion * @returns Lowercase hex string to use as walletPassphrase diff --git a/modules/passkey-crypto/src/removePasskeyFromWallet.ts b/modules/passkey-crypto/src/removePasskeyFromWallet.ts index ec8222b449..831057d9ec 100644 --- a/modules/passkey-crypto/src/removePasskeyFromWallet.ts +++ b/modules/passkey-crypto/src/removePasskeyFromWallet.ts @@ -1,4 +1,4 @@ -import { BitGoBase, decryptKeychainPrivateKey } from '@bitgo/sdk-core'; +import { BitGoBase, decryptKeychainPrivateKeyAsync } from '@bitgo/sdk-core'; import { WebAuthnOtpDevice } from './webAuthnTypes'; export async function removePasskeyFromWallet(params: { @@ -20,7 +20,7 @@ export async function removePasskeyFromWallet(params: { const keychain = await baseCoin.keychains().get({ id: keychainId }); // Verify passphrase before any mutation - const decrypted = decryptKeychainPrivateKey(bitgo, keychain, walletPassphrase); + const decrypted = await decryptKeychainPrivateKeyAsync(bitgo, keychain, walletPassphrase); if (!decrypted) { throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.'); } diff --git a/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts b/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts index 4e6a9d4564..4c47ab7f90 100644 --- a/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts +++ b/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts @@ -1,4 +1,5 @@ import * as sinon from 'sinon'; +import { decryptV2, encryptV2 } from '@bitgo/sdk-api'; import { ASSERTION_CHALLENGE, BASE_SALT, @@ -20,6 +21,33 @@ function realDecrypt({ password, input }: { password: string; input: string }): return sjcl.decrypt(password, typeof input === 'string' ? JSON.parse(input) : input); } +async function mockEncryptAsync(params: { + password: string; + input: string; + encryptionVersion?: number; +}): Promise { + if (params.encryptionVersion === 2) { + return encryptV2(params.password, params.input); + } + return realEncrypt(params); +} + +async function mockDecryptAsync(params: { password: string; input: string }): Promise { + let envelopeVersion: number | undefined; + try { + envelopeVersion = JSON.parse(params.input).v; + } catch { + throw new Error('decrypt: ciphertext is not valid JSON'); + } + if (envelopeVersion === 2) { + return decryptV2(params.password, params.input); + } + if (envelopeVersion !== undefined && envelopeVersion !== 1) { + throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`); + } + return realDecrypt(params); +} + export interface KeychainState { id: string; encryptedPrv: string; @@ -81,6 +109,8 @@ export function makeMockBitGo(initialEncryptedPrv: string): MockBitGo { encrypt: (params: { password: string; input: string }) => realEncrypt(params), decrypt: (params: { password: string; input: string }) => realDecrypt(params), + encryptAsync: mockEncryptAsync, + decryptAsync: mockDecryptAsync, get: sinon.stub().callsFake((url: string) => { if (url.includes('/user/otp/webauthn/register')) { diff --git a/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts b/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts index 5a6fb41aff..5db2ec9102 100644 --- a/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts +++ b/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import { decryptV2, encryptV2 } from '@bitgo/sdk-api'; import { derivePassword } from '../../src/derivePassword'; import { deriveEnterpriseSalt } from '../../src/deriveEnterpriseSalt'; import { registerPasskey } from '../../src/registerPasskey'; @@ -23,7 +24,6 @@ import { // Use sjcl directly for round-trip encryption tests — same crypto as mockBitGo const sjcl = require('sjcl'); const sjclEncrypt = (password: string, input: string) => JSON.stringify(sjcl.encrypt(password, input)); -const sjclDecrypt = (password: string, input: string) => sjcl.decrypt(password, JSON.parse(input)); describe('passkey-crypto integration', function () { let initialEncryptedPrv: string; @@ -52,8 +52,10 @@ describe('passkey-crypto integration', function () { provider, }); - // Verify encryptedPrv round-trips with the PRF-derived password - const decrypted = sjclDecrypt(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv); + assert.strictEqual(JSON.parse(keychainState.encryptedPrv).v, 2); + + // Verify encryptedPrv round-trips with the PRF-derived password (Argon2id v2 envelope) + const decrypted = await decryptV2(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv); assert.strictEqual(decrypted, PRIVATE_KEY); // prfSalt stored in webauthnInfo must be valid base64url @@ -81,7 +83,7 @@ describe('passkey-crypto integration', function () { // Same passphrase as what attach used — decrypts the stored key assert.strictEqual(derivedPassphrase, derivePassword(PRF_OUTPUT)); - assert.strictEqual(sjclDecrypt(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY); + assert.strictEqual(await decryptV2(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY); }); }); @@ -137,7 +139,7 @@ describe('passkey-crypto integration', function () { const { bitgo } = makeMockBitGo(initialEncryptedPrv); const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true }; - // Uses real sjcl decrypt — wrong passphrase genuinely fails decryptKeychainPrivateKey + // Uses real sjcl decrypt — wrong passphrase genuinely fails decryptKeychainPrivateKeyAsync await assert.rejects( () => removePasskeyFromWallet({ @@ -152,5 +154,35 @@ describe('passkey-crypto integration', function () { assert.strictEqual(bitgo.del.callCount, 0); }); + + it('removePasskeyFromWallet accepts correct passphrase and rejects wrong when encryptedPrv is v2', async function () { + const v2Passphrase = 'v2-removal-passphrase'; + const v2EncryptedPrv = await encryptV2(v2Passphrase, PRIVATE_KEY); + const { bitgo } = makeMockBitGo(v2EncryptedPrv); + const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true }; + + await removePasskeyFromWallet({ + bitgo, + coin: COIN, + walletId: WALLET_ID, + device, + walletPassphrase: v2Passphrase, + }); + assert.strictEqual(bitgo.del.callCount, 1); + + const { bitgo: bitgoWrong } = makeMockBitGo(v2EncryptedPrv); + await assert.rejects( + () => + removePasskeyFromWallet({ + bitgo: bitgoWrong, + coin: COIN, + walletId: WALLET_ID, + device, + walletPassphrase: 'wrong-passphrase', + }), + /Incorrect wallet passphrase/ + ); + assert.strictEqual(bitgoWrong.del.callCount, 0); + }); }); }); diff --git a/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts b/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts index 222515cfaa..4cc367a86a 100644 --- a/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts +++ b/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts @@ -1,5 +1,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { decryptV2, encryptV2 } from '@bitgo/sdk-api'; import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet'; import { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from '../../src/webAuthnTypes'; @@ -53,8 +54,8 @@ describe('attachPasskeyToWallet', function () { url: sinon.SinonStub; coin: sinon.SinonStub; put: sinon.SinonStub; - decrypt: sinon.SinonStub; - encrypt: sinon.SinonStub; + decryptAsync: sinon.SinonStub; + encryptAsync: sinon.SinonStub; }; let mockProvider: { @@ -83,8 +84,8 @@ describe('attachPasskeyToWallet', function () { .callsFake((path, version) => `/api/v${version ?? 1}${path}`), coin: sinon.stub().returns(mockBaseCoin), put: sinon.stub(), - decrypt: sinon.stub(), - encrypt: sinon.stub(), + decryptAsync: sinon.stub(), + encryptAsync: sinon.stub(), }; mockProvider = { @@ -92,8 +93,8 @@ describe('attachPasskeyToWallet', function () { get: sinon.stub(), }; - mockBitGo.decrypt.returns(decryptedPrv); - mockBitGo.encrypt.returns(reEncryptedPrv); + mockBitGo.decryptAsync.resolves(decryptedPrv); + mockBitGo.encryptAsync.resolves(reEncryptedPrv); const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) }); mockBitGo.put.returns({ send: putSendStub }); @@ -124,8 +125,8 @@ describe('attachPasskeyToWallet', function () { sinon.assert.calledWith(mockWallets.get, { id: walletId }); sinon.assert.calledOnce(mockWallet.type); sinon.assert.calledOnce(mockWallet.getEncryptedUserKeychain); - sinon.assert.calledOnce(mockBitGo.decrypt); - sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv }); + sinon.assert.calledOnce(mockBitGo.decryptAsync); + sinon.assert.calledWithExactly(mockBitGo.decryptAsync, { password: existingPassphrase, input: encryptedPrv }); // provider.get called with evalByCredential keyed on device.credentialId sinon.assert.calledOnce(mockProvider.get); @@ -139,6 +140,13 @@ describe('attachPasskeyToWallet', function () { assert.strictEqual(getArgs.publicKey.allowCredentials[0].type, 'public-key'); assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer); + sinon.assert.calledOnce(mockBitGo.encryptAsync); + sinon.assert.calledWithExactly(mockBitGo.encryptAsync, { + password: '1e5cb478', + input: decryptedPrv, + encryptionVersion: 2, + }); + // PUT called with correct shape sinon.assert.calledOnce(mockBitGo.put); sinon.assert.calledWith(mockBitGo.put, `/api/v2/${coin}/key/${keychainId}`); @@ -223,7 +231,7 @@ describe('attachPasskeyToWallet', function () { }); it('should propagate decrypt errors', async function () { - mockBitGo.decrypt.throws(new Error('decryption failed')); + mockBitGo.decryptAsync.rejects(new Error('decryption failed')); await assert.rejects( () => callAttach(), @@ -236,6 +244,18 @@ describe('attachPasskeyToWallet', function () { sinon.assert.notCalled(mockBitGo.put); }); + it('should succeed when keychain encryptedPrv is already a v2 envelope', async function () { + const v2Input = await encryptV2(existingPassphrase, decryptedPrv); + mockWallet.getEncryptedUserKeychain.resolves({ id: keychainId, encryptedPrv: v2Input }); + mockBitGo.decryptAsync.callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input)); + + const result = await callAttach(); + + sinon.assert.calledOnce(mockBitGo.decryptAsync); + sinon.assert.calledWithExactly(mockBitGo.decryptAsync, { password: existingPassphrase, input: v2Input }); + assert.strictEqual(result.id, keychainId); + }); + it('should use device.credentialId as the key in evalByCredential', async function () { await callAttach(); diff --git a/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts b/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts index 5ea3a1a818..448218ee37 100644 --- a/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts +++ b/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts @@ -1,5 +1,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { decryptV2, encryptV2 } from '@bitgo/sdk-api'; import { removePasskeyFromWallet } from '../../src'; describe('removePasskeyFromWallet', function () { @@ -39,7 +40,7 @@ describe('removePasskeyFromWallet', function () { wallets: sinon.stub().returns(mockWallets), keychains: sinon.stub().returns(mockKeychains), }), - decrypt: sinon.stub().returns('xprv-decrypted'), + decryptAsync: sinon.stub().resolves('xprv-decrypted'), del: sinon.stub().returns({ result: sinon.stub().resolves({}), }), @@ -75,7 +76,51 @@ describe('removePasskeyFromWallet', function () { }); it('should throw and not call DELETE if passphrase is wrong', async function () { - mockBitGo.decrypt = sinon.stub().throws(new Error('decryption failed')); + mockBitGo.decryptAsync = sinon.stub().rejects(new Error('decryption failed')); + + await assert.rejects( + () => + removePasskeyFromWallet({ + bitgo: mockBitGo, + coin: coinName, + walletId, + device, + walletPassphrase: 'wrong-passphrase', + }), + (err: Error) => { + assert.ok(err.message.includes('Incorrect wallet passphrase')); + return true; + } + ); + + sinon.assert.notCalled(mockBitGo.del); + }); + + it('should verify v2 encryptedPrv then remove device', async function () { + const v2Passphrase = 'unit-v2-wallet-pass'; + const v2Blob = await encryptV2(v2Passphrase, 'xprv-decrypted'); + mockKeychains.get = sinon.stub().resolves({ id: keychainId, encryptedPrv: v2Blob }); + mockBitGo.decryptAsync = sinon + .stub() + .callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input)); + + await removePasskeyFromWallet({ + bitgo: mockBitGo, + coin: coinName, + walletId, + device, + walletPassphrase: v2Passphrase, + }); + + sinon.assert.calledOnce(mockBitGo.del); + }); + + it('should throw and not call DELETE when v2 encryptedPrv and passphrase is wrong', async function () { + const v2Blob = await encryptV2('correct-v2-pass', 'xprv-decrypted'); + mockKeychains.get = sinon.stub().resolves({ id: keychainId, encryptedPrv: v2Blob }); + mockBitGo.decryptAsync = sinon + .stub() + .callsFake(async (p: { password: string; input: string }) => decryptV2(p.password, p.input)); await assert.rejects( () => diff --git a/yarn.lock b/yarn.lock index 3004a71efe..8bccb99fe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18953,7 +18953,7 @@ sisteransi@^1.0.5: resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -sjcl@^1.0.6, "sjcl@npm:@bitgo/sjcl@1.0.1": +sjcl@1.0.1, sjcl@^1.0.6, "sjcl@npm:@bitgo/sjcl@1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@bitgo/sjcl/-/sjcl-1.0.1.tgz#633fa84608c1cb7461b17ceb6131d96722921fd3" integrity sha512-dBICMzShC8gXdpSj9cvl4wl9Jkt4h14wt4XQ+/6V6qcC2IObyKRJfaG5TYUU6RvVknhPBPyBx9v84vNKODM5fQ==