Skip to content
Merged
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
6 changes: 4 additions & 2 deletions modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"lint": "eslint --quiet .",
"prepare": "npm run build",
"test": "npm run unit-test",
"unit-test": "mocha 'test/unit/**/*.ts'"
"unit-test": "mocha 'test/unit/**/*.ts'",
"integration-test": "mocha 'test/integration/**/*.ts'"
},
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
"license": "MIT",
Expand All @@ -38,6 +39,7 @@
"@bitgo/sdk-core": "^36.44.0"
},
"devDependencies": {
"@types/node": "^18.0.0"
"@types/node": "^18.0.0",
"sjcl": "1.0.1"
}
}
2 changes: 2 additions & 0 deletions modules/passkey-crypto/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
export { removePasskeyFromAccount } from './removePasskeyFromAccount';
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
export { removePasskeyFromWallet } from './removePasskeyFromWallet';
export { attachPasskeyToWallet } from './attachPasskeyToWallet';
export { derivePasskeyPrfKey } from './derivePasskeyPrfKey';
11 changes: 11 additions & 0 deletions modules/passkey-crypto/test/integration/helpers/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const ENTERPRISE_ID = 'enterprise-abc';
export const WALLET_ID = 'wallet-hot-123';
export const KEYCHAIN_ID = 'key-user-001';
export const COIN = 'tbtc';
export const EXISTING_PASSPHRASE = 'my-existing-passphrase';
export const PRF_OUTPUT = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x01, 0x02]).buffer;
export const CREDENTIAL_ID = 'Y3JlZC1pZC00NTY';
export const DEVICE_MONGO_ID = 'device-mongo-id-1';
export const BASE_SALT = 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8';
export const REGISTER_CHALLENGE = Buffer.from('random-challenge').toString('base64');
export const ASSERTION_CHALLENGE = Buffer.from('assertion-challenge').toString('base64');
152 changes: 152 additions & 0 deletions modules/passkey-crypto/test/integration/helpers/mockBitGo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as sinon from 'sinon';
import {
ASSERTION_CHALLENGE,
BASE_SALT,
CREDENTIAL_ID,
DEVICE_MONGO_ID,
ENTERPRISE_ID,
KEYCHAIN_ID,
REGISTER_CHALLENGE,
} from './fixtures';

// Use sjcl directly — same underlying library as bitgo.encrypt/decrypt
const sjcl = require('sjcl');

function realEncrypt({ password, input }: { password: string; input: string }): string {
return JSON.stringify(sjcl.encrypt(password, input));
}

function realDecrypt({ password, input }: { password: string; input: string }): string {
return sjcl.decrypt(password, typeof input === 'string' ? JSON.parse(input) : input);
}

export interface KeychainState {
id: string;
encryptedPrv: string;
webauthnDevices?: Array<{
authenticatorInfo: { credID: string };
prfSalt: string;
id?: string;
encryptedPrv?: string;
}>;
}

export interface MockBitGo {
bitgo: any;
keychainState: KeychainState;
wallet: any;
encrypt: (params: { password: string; input: string }) => string;
decrypt: (params: { password: string; input: string }) => string;
}

export function makeMockBitGo(initialEncryptedPrv: string): MockBitGo {
const keychainState: KeychainState = {
id: KEYCHAIN_ID,
encryptedPrv: initialEncryptedPrv,
webauthnDevices: undefined,
};

// Build the mock request chain: bitgo.get(url).result() etc.
function makeRequest(result: unknown) {
const req = {
send: sinon.stub().returnsThis(),
result: sinon.stub().resolves(result),
};
return req;
}

// Stub the wallet
const mockWallet = {
type: sinon.stub().returns('hot'),
toJSON: sinon.stub().returns({ enterprise: ENTERPRISE_ID }),
keyIds: sinon.stub().returns([KEYCHAIN_ID]),
getEncryptedUserKeychain: sinon.stub().callsFake(async () => ({ ...keychainState })),
};

// Stub baseCoin
const mockBaseCoin = {
wallets: sinon.stub().returns({
get: sinon.stub().resolves(mockWallet),
}),
keychains: sinon.stub().returns({
get: sinon.stub().callsFake(async () => ({ ...keychainState })),
}),
};

// Build the bitgo stub
const bitgo: any = {
coin: sinon.stub().returns(mockBaseCoin),

url: (path: string, version?: number) => `https://app.bitgo-test.com/api/v${version ?? 2}${path}`,

encrypt: (params: { password: string; input: string }) => realEncrypt(params),
decrypt: (params: { password: string; input: string }) => realDecrypt(params),

get: sinon.stub().callsFake((url: string) => {
if (url.includes('/user/otp/webauthn/register')) {
return makeRequest({
challenge: REGISTER_CHALLENGE,
baseSalt: BASE_SALT,
rp: { name: 'BitGo', id: 'bitgo.com' },
user: { id: Buffer.from('user-id'), name: 'test@bitgo.com', displayName: 'Test User' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
});
}
if (url.includes('/user/otp/webauthn/assertion')) {
return makeRequest({ challenge: ASSERTION_CHALLENGE });
}
return makeRequest({});
}),

put: sinon.stub().callsFake((url: string) => {
// PUT /user/otp — register device
if (url.includes('/user/otp') && !url.includes('/key/')) {
return {
send: sinon.stub().returnsThis(),
result: sinon.stub().resolves({
user: {
otpDevices: [
{
id: DEVICE_MONGO_ID,
credentialId: CREDENTIAL_ID,
prfSalt: BASE_SALT,
isPasskey: true,
extensions: { prf: true },
},
],
},
}),
};
}
// PUT /{coin}/key/{keychainId} — attach webauthnInfo
if (url.includes('/key/')) {
return {
send: sinon.stub().callsFake((body: any) => ({
result: sinon.stub().callsFake(async () => {
// Update keychainState with webauthnInfo — simulates server persisting it
if (body?.webauthnInfo) {
keychainState.webauthnDevices = [
{
authenticatorInfo: { credID: CREDENTIAL_ID },
prfSalt: body.webauthnInfo.prfSalt,
id: DEVICE_MONGO_ID,
encryptedPrv: body.webauthnInfo.encryptedPrv,
},
];
keychainState.encryptedPrv = body.webauthnInfo.encryptedPrv;
}
return { ...keychainState };
}),
})),
};
}
return { send: sinon.stub().returnsThis(), result: sinon.stub().resolves({}) };
}),

del: sinon.stub().callsFake((_url: string) => {
return { result: sinon.stub().resolves({}) };
}),
};

return { bitgo, keychainState, wallet: mockWallet, encrypt: realEncrypt, decrypt: realDecrypt };
}
39 changes: 39 additions & 0 deletions modules/passkey-crypto/test/integration/helpers/mockProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from '../../../src/webAuthnTypes';
import { CREDENTIAL_ID, PRF_OUTPUT } from './fixtures';

export function makeMockProvider(): WebAuthnProvider & { lastEvalByCredential: Record<string, string> | undefined } {
let lastEvalByCredential: Record<string, string> | undefined;

const provider: WebAuthnProvider & { lastEvalByCredential: Record<string, string> | undefined } = {
get lastEvalByCredential() {
return lastEvalByCredential;
},

async create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> {
return {
id: CREDENTIAL_ID,
rawId: Buffer.from(CREDENTIAL_ID, 'base64'),
type: 'public-key',
response: {
attestationObject: new ArrayBuffer(0),
clientDataJSON: new ArrayBuffer(0),
getTransports: () => [],
},
authenticatorAttachment: 'platform',
getClientExtensionResults: () => ({ prf: { enabled: true } }),
toJSON: () => ({} as PublicKeyCredentialJSON),
} as unknown as PublicKeyCredential;
},

async get(options: PasskeyGetOptions): Promise<PasskeyAuthResult> {
lastEvalByCredential = options.evalByCredential;
return {
prfResult: PRF_OUTPUT,
credentialId: CREDENTIAL_ID,
otpCode: '123456',
};
},
};

return provider;
}
156 changes: 156 additions & 0 deletions modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as assert from 'assert';
import { derivePassword } from '../../src/derivePassword';
import { deriveEnterpriseSalt } from '../../src/deriveEnterpriseSalt';
import { registerPasskey } from '../../src/registerPasskey';
import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet';
import { derivePasskeyPrfKey } from '../../src/derivePasskeyPrfKey';
import { removePasskeyFromWallet } from '../../src/removePasskeyFromWallet';
import { removePasskeyFromAccount } from '../../src/removePasskeyFromAccount';
import { makeMockBitGo } from './helpers/mockBitGo';
import { makeMockProvider } from './helpers/mockProvider';
import {
ENTERPRISE_ID,
WALLET_ID,
COIN,
EXISTING_PASSPHRASE,
PRF_OUTPUT,
CREDENTIAL_ID,
DEVICE_MONGO_ID,
BASE_SALT,
KEYCHAIN_ID,
} from './helpers/fixtures';

// 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;
const PRIVATE_KEY = 'xprv-test-private-key-12345';

before(function () {
// Encrypt the private key with the existing passphrase (simulates what the server stores)
initialEncryptedPrv = sjclEncrypt(EXISTING_PASSPHRASE, PRIVATE_KEY);
});

describe('register → attach', function () {
it('re-encrypts the private key under the PRF-derived password', async function () {
const { bitgo, keychainState } = makeMockBitGo(initialEncryptedPrv);
const provider = makeMockProvider();

const device = await registerPasskey({ bitgo, provider, label: 'test-key' });
assert.strictEqual(device.credentialId, CREDENTIAL_ID);
assert.strictEqual(device.prfSupported, true);

await attachPasskeyToWallet({
bitgo,
coin: COIN,
walletId: WALLET_ID,
device,
existingPassphrase: EXISTING_PASSPHRASE,
provider,
});

// Verify encryptedPrv round-trips with the PRF-derived password
const decrypted = sjclDecrypt(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv);
assert.strictEqual(decrypted, PRIVATE_KEY);

// prfSalt stored in webauthnInfo must be valid base64url
assert.ok(keychainState.webauthnDevices);
assert.match(keychainState.webauthnDevices[0].prfSalt, /^[A-Za-z0-9\-_]+$/);
});
});

describe('attach → derivePasskeyPrfKey', function () {
it('derives the same passphrase used to re-encrypt during attach', async function () {
const { bitgo, keychainState, wallet } = makeMockBitGo(initialEncryptedPrv);
const provider = makeMockProvider();

const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
await attachPasskeyToWallet({
bitgo,
coin: COIN,
walletId: WALLET_ID,
device,
existingPassphrase: EXISTING_PASSPHRASE,
provider,
});

const derivedPassphrase = await derivePasskeyPrfKey({ bitgo, wallet, provider });

// Same passphrase as what attach used — decrypts the stored key
assert.strictEqual(derivedPassphrase, derivePassword(PRF_OUTPUT));
assert.strictEqual(sjclDecrypt(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY);
});
});

describe('full lifecycle (register → attach → derive → remove)', function () {
it('completes all steps and hits both DEL endpoints', async function () {
const { bitgo, wallet } = makeMockBitGo(initialEncryptedPrv);
const provider = makeMockProvider();

const device = await registerPasskey({ bitgo, provider, label: 'lifecycle-key' });
await attachPasskeyToWallet({
bitgo,
coin: COIN,
walletId: WALLET_ID,
device,
existingPassphrase: EXISTING_PASSPHRASE,
provider,
});

const passphrase = await derivePasskeyPrfKey({ bitgo, wallet, provider });
await removePasskeyFromWallet({ bitgo, coin: COIN, walletId: WALLET_ID, device, walletPassphrase: passphrase });
await removePasskeyFromAccount({ bitgo, device });

const delUrls = bitgo.del.args.map((a: string[]) => a[0]);
assert.ok(delUrls.some((url: string) => url.includes(`/key/${KEYCHAIN_ID}/webauthndevice/${DEVICE_MONGO_ID}`)));
assert.ok(delUrls.some((url: string) => url.includes(`/user/otp/${DEVICE_MONGO_ID}`)));
});
});

describe('PRF salt derivation wiring', function () {
it('passes deriveEnterpriseSalt output as the eval salt in attachPasskeyToWallet', async function () {
const { bitgo } = makeMockBitGo(initialEncryptedPrv);
const provider = makeMockProvider();

const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
await attachPasskeyToWallet({
bitgo,
coin: COIN,
walletId: WALLET_ID,
device,
existingPassphrase: EXISTING_PASSPHRASE,
provider,
});

assert.strictEqual(
provider.lastEvalByCredential?.[CREDENTIAL_ID],
deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID)
);
});
});

describe('error propagation', function () {
it('aborts removePasskeyFromWallet and does not DEL when passphrase is wrong', async 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
await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo,
coin: COIN,
walletId: WALLET_ID,
device,
walletPassphrase: 'wrong-passphrase',
}),
/Incorrect wallet passphrase/
);

assert.strictEqual(bitgo.del.callCount, 0);
});
});
});
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7605,7 +7605,7 @@ aws4@^1.8.0:
resolved "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz"
integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==

axios@0.25.0, axios@0.27.2, axios@1.15.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.15.2, axios@^1.6.0, axios@^1.8.3:
axios@0.25.0, axios@0.27.2, axios@1.15.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.6.0, axios@^1.8.3:
version "1.15.2"
resolved "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b"
integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==
Expand Down
Loading