Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions modules/passkey-crypto/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
3 changes: 2 additions & 1 deletion modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
8 changes: 6 additions & 2 deletions modules/passkey-crypto/src/attachPasskeyToWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions modules/passkey-crypto/src/derivePassword.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions modules/passkey-crypto/src/removePasskeyFromWallet.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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.');
}
Expand Down
30 changes: 30 additions & 0 deletions modules/passkey-crypto/test/integration/helpers/mockBitGo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as sinon from 'sinon';
import { decryptV2, encryptV2 } from '@bitgo/sdk-api';
import {
ASSERTION_CHALLENGE,
BASE_SALT,
Expand All @@ -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<string> {
if (params.encryptionVersion === 2) {
return encryptV2(params.password, params.input);
}
return realEncrypt(params);
}

async function mockDecryptAsync(params: { password: string; input: string }): Promise<string> {
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;
Expand Down Expand Up @@ -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')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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({
Expand All @@ -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);
});
});
});
38 changes: 29 additions & 9 deletions modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -83,17 +84,17 @@ 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 = {
create: sinon.stub(),
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 });
Expand Down Expand Up @@ -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);
Expand All @@ -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}`);
Expand Down Expand Up @@ -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(),
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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 () {
Expand Down Expand Up @@ -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({}),
}),
Expand Down Expand Up @@ -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(
() =>
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down
Loading