From 941dd672dc6bf75c253720cf3469da99e1466c5b Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Wed, 6 May 2026 15:38:23 -0400 Subject: [PATCH] feat(passkey-crypto): add integration tests and complete package exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test/integration/passkeyLifecycle.test.ts with 5 describe blocks covering register→attach, attach→derive, full lifecycle, PRF salt wiring, and error propagation - Add test/integration/helpers/ (fixtures, mockBitGo, mockProvider) - Export attachPasskeyToWallet and derivePasskeyPrfKey from index.ts - Add integration-test script to package.json - Add sjcl to devDependencies (used directly in mockBitGo for real encrypt/decrypt — same underlying library as bitgo.encrypt/decrypt) TICKET: WCN-195 --- modules/passkey-crypto/package.json | 6 +- modules/passkey-crypto/src/index.ts | 2 + .../test/integration/helpers/fixtures.ts | 11 ++ .../test/integration/helpers/mockBitGo.ts | 152 +++++++++++++++++ .../test/integration/helpers/mockProvider.ts | 39 +++++ .../test/integration/passkeyLifecycle.test.ts | 156 ++++++++++++++++++ yarn.lock | 2 +- 7 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 modules/passkey-crypto/test/integration/helpers/fixtures.ts create mode 100644 modules/passkey-crypto/test/integration/helpers/mockBitGo.ts create mode 100644 modules/passkey-crypto/test/integration/helpers/mockProvider.ts create mode 100644 modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index d87804a456..1fb57c0c37 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -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 ", "license": "MIT", @@ -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" } } diff --git a/modules/passkey-crypto/src/index.ts b/modules/passkey-crypto/src/index.ts index 256add52db..cd04d59d4a 100644 --- a/modules/passkey-crypto/src/index.ts +++ b/modules/passkey-crypto/src/index.ts @@ -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'; diff --git a/modules/passkey-crypto/test/integration/helpers/fixtures.ts b/modules/passkey-crypto/test/integration/helpers/fixtures.ts new file mode 100644 index 0000000000..80efc56e98 --- /dev/null +++ b/modules/passkey-crypto/test/integration/helpers/fixtures.ts @@ -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'); diff --git a/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts b/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts new file mode 100644 index 0000000000..4e6a9d4564 --- /dev/null +++ b/modules/passkey-crypto/test/integration/helpers/mockBitGo.ts @@ -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 }; +} diff --git a/modules/passkey-crypto/test/integration/helpers/mockProvider.ts b/modules/passkey-crypto/test/integration/helpers/mockProvider.ts new file mode 100644 index 0000000000..c27f3bd79d --- /dev/null +++ b/modules/passkey-crypto/test/integration/helpers/mockProvider.ts @@ -0,0 +1,39 @@ +import { PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from '../../../src/webAuthnTypes'; +import { CREDENTIAL_ID, PRF_OUTPUT } from './fixtures'; + +export function makeMockProvider(): WebAuthnProvider & { lastEvalByCredential: Record | undefined } { + let lastEvalByCredential: Record | undefined; + + const provider: WebAuthnProvider & { lastEvalByCredential: Record | undefined } = { + get lastEvalByCredential() { + return lastEvalByCredential; + }, + + async create(options: PublicKeyCredentialCreationOptions): Promise { + 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 { + lastEvalByCredential = options.evalByCredential; + return { + prfResult: PRF_OUTPUT, + credentialId: CREDENTIAL_ID, + otpCode: '123456', + }; + }, + }; + + return provider; +} diff --git a/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts b/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts new file mode 100644 index 0000000000..5a6fb41aff --- /dev/null +++ b/modules/passkey-crypto/test/integration/passkeyLifecycle.test.ts @@ -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); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 58f73d91f8..59dba94c09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==