Skip to content

Commit e1a3022

Browse files
authored
Merge pull request #8701 from BitGo/WCN-195/passkey-integration-tests
feat(passkey-crypto): WCN-195 integration tests and complete package exports
2 parents 39f1a27 + 941dd67 commit e1a3022

6 files changed

Lines changed: 364 additions & 2 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"lint": "eslint --quiet .",
1616
"prepare": "npm run build",
1717
"test": "npm run unit-test",
18-
"unit-test": "mocha 'test/unit/**/*.ts'"
18+
"unit-test": "mocha 'test/unit/**/*.ts'",
19+
"integration-test": "mocha 'test/integration/**/*.ts'"
1920
},
2021
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
2122
"license": "MIT",
@@ -38,6 +39,7 @@
3839
"@bitgo/sdk-core": "^36.44.0"
3940
},
4041
"devDependencies": {
41-
"@types/node": "^18.0.0"
42+
"@types/node": "^18.0.0",
43+
"sjcl": "1.0.1"
4244
}
4345
}

modules/passkey-crypto/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
55
export { removePasskeyFromAccount } from './removePasskeyFromAccount';
66
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
77
export { removePasskeyFromWallet } from './removePasskeyFromWallet';
8+
export { attachPasskeyToWallet } from './attachPasskeyToWallet';
9+
export { derivePasskeyPrfKey } from './derivePasskeyPrfKey';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const ENTERPRISE_ID = 'enterprise-abc';
2+
export const WALLET_ID = 'wallet-hot-123';
3+
export const KEYCHAIN_ID = 'key-user-001';
4+
export const COIN = 'tbtc';
5+
export const EXISTING_PASSPHRASE = 'my-existing-passphrase';
6+
export const PRF_OUTPUT = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x01, 0x02]).buffer;
7+
export const CREDENTIAL_ID = 'Y3JlZC1pZC00NTY';
8+
export const DEVICE_MONGO_ID = 'device-mongo-id-1';
9+
export const BASE_SALT = 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8';
10+
export const REGISTER_CHALLENGE = Buffer.from('random-challenge').toString('base64');
11+
export const ASSERTION_CHALLENGE = Buffer.from('assertion-challenge').toString('base64');
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import * as sinon from 'sinon';
2+
import {
3+
ASSERTION_CHALLENGE,
4+
BASE_SALT,
5+
CREDENTIAL_ID,
6+
DEVICE_MONGO_ID,
7+
ENTERPRISE_ID,
8+
KEYCHAIN_ID,
9+
REGISTER_CHALLENGE,
10+
} from './fixtures';
11+
12+
// Use sjcl directly — same underlying library as bitgo.encrypt/decrypt
13+
const sjcl = require('sjcl');
14+
15+
function realEncrypt({ password, input }: { password: string; input: string }): string {
16+
return JSON.stringify(sjcl.encrypt(password, input));
17+
}
18+
19+
function realDecrypt({ password, input }: { password: string; input: string }): string {
20+
return sjcl.decrypt(password, typeof input === 'string' ? JSON.parse(input) : input);
21+
}
22+
23+
export interface KeychainState {
24+
id: string;
25+
encryptedPrv: string;
26+
webauthnDevices?: Array<{
27+
authenticatorInfo: { credID: string };
28+
prfSalt: string;
29+
id?: string;
30+
encryptedPrv?: string;
31+
}>;
32+
}
33+
34+
export interface MockBitGo {
35+
bitgo: any;
36+
keychainState: KeychainState;
37+
wallet: any;
38+
encrypt: (params: { password: string; input: string }) => string;
39+
decrypt: (params: { password: string; input: string }) => string;
40+
}
41+
42+
export function makeMockBitGo(initialEncryptedPrv: string): MockBitGo {
43+
const keychainState: KeychainState = {
44+
id: KEYCHAIN_ID,
45+
encryptedPrv: initialEncryptedPrv,
46+
webauthnDevices: undefined,
47+
};
48+
49+
// Build the mock request chain: bitgo.get(url).result() etc.
50+
function makeRequest(result: unknown) {
51+
const req = {
52+
send: sinon.stub().returnsThis(),
53+
result: sinon.stub().resolves(result),
54+
};
55+
return req;
56+
}
57+
58+
// Stub the wallet
59+
const mockWallet = {
60+
type: sinon.stub().returns('hot'),
61+
toJSON: sinon.stub().returns({ enterprise: ENTERPRISE_ID }),
62+
keyIds: sinon.stub().returns([KEYCHAIN_ID]),
63+
getEncryptedUserKeychain: sinon.stub().callsFake(async () => ({ ...keychainState })),
64+
};
65+
66+
// Stub baseCoin
67+
const mockBaseCoin = {
68+
wallets: sinon.stub().returns({
69+
get: sinon.stub().resolves(mockWallet),
70+
}),
71+
keychains: sinon.stub().returns({
72+
get: sinon.stub().callsFake(async () => ({ ...keychainState })),
73+
}),
74+
};
75+
76+
// Build the bitgo stub
77+
const bitgo: any = {
78+
coin: sinon.stub().returns(mockBaseCoin),
79+
80+
url: (path: string, version?: number) => `https://app.bitgo-test.com/api/v${version ?? 2}${path}`,
81+
82+
encrypt: (params: { password: string; input: string }) => realEncrypt(params),
83+
decrypt: (params: { password: string; input: string }) => realDecrypt(params),
84+
85+
get: sinon.stub().callsFake((url: string) => {
86+
if (url.includes('/user/otp/webauthn/register')) {
87+
return makeRequest({
88+
challenge: REGISTER_CHALLENGE,
89+
baseSalt: BASE_SALT,
90+
rp: { name: 'BitGo', id: 'bitgo.com' },
91+
user: { id: Buffer.from('user-id'), name: 'test@bitgo.com', displayName: 'Test User' },
92+
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
93+
});
94+
}
95+
if (url.includes('/user/otp/webauthn/assertion')) {
96+
return makeRequest({ challenge: ASSERTION_CHALLENGE });
97+
}
98+
return makeRequest({});
99+
}),
100+
101+
put: sinon.stub().callsFake((url: string) => {
102+
// PUT /user/otp — register device
103+
if (url.includes('/user/otp') && !url.includes('/key/')) {
104+
return {
105+
send: sinon.stub().returnsThis(),
106+
result: sinon.stub().resolves({
107+
user: {
108+
otpDevices: [
109+
{
110+
id: DEVICE_MONGO_ID,
111+
credentialId: CREDENTIAL_ID,
112+
prfSalt: BASE_SALT,
113+
isPasskey: true,
114+
extensions: { prf: true },
115+
},
116+
],
117+
},
118+
}),
119+
};
120+
}
121+
// PUT /{coin}/key/{keychainId} — attach webauthnInfo
122+
if (url.includes('/key/')) {
123+
return {
124+
send: sinon.stub().callsFake((body: any) => ({
125+
result: sinon.stub().callsFake(async () => {
126+
// Update keychainState with webauthnInfo — simulates server persisting it
127+
if (body?.webauthnInfo) {
128+
keychainState.webauthnDevices = [
129+
{
130+
authenticatorInfo: { credID: CREDENTIAL_ID },
131+
prfSalt: body.webauthnInfo.prfSalt,
132+
id: DEVICE_MONGO_ID,
133+
encryptedPrv: body.webauthnInfo.encryptedPrv,
134+
},
135+
];
136+
keychainState.encryptedPrv = body.webauthnInfo.encryptedPrv;
137+
}
138+
return { ...keychainState };
139+
}),
140+
})),
141+
};
142+
}
143+
return { send: sinon.stub().returnsThis(), result: sinon.stub().resolves({}) };
144+
}),
145+
146+
del: sinon.stub().callsFake((_url: string) => {
147+
return { result: sinon.stub().resolves({}) };
148+
}),
149+
};
150+
151+
return { bitgo, keychainState, wallet: mockWallet, encrypt: realEncrypt, decrypt: realDecrypt };
152+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from '../../../src/webAuthnTypes';
2+
import { CREDENTIAL_ID, PRF_OUTPUT } from './fixtures';
3+
4+
export function makeMockProvider(): WebAuthnProvider & { lastEvalByCredential: Record<string, string> | undefined } {
5+
let lastEvalByCredential: Record<string, string> | undefined;
6+
7+
const provider: WebAuthnProvider & { lastEvalByCredential: Record<string, string> | undefined } = {
8+
get lastEvalByCredential() {
9+
return lastEvalByCredential;
10+
},
11+
12+
async create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> {
13+
return {
14+
id: CREDENTIAL_ID,
15+
rawId: Buffer.from(CREDENTIAL_ID, 'base64'),
16+
type: 'public-key',
17+
response: {
18+
attestationObject: new ArrayBuffer(0),
19+
clientDataJSON: new ArrayBuffer(0),
20+
getTransports: () => [],
21+
},
22+
authenticatorAttachment: 'platform',
23+
getClientExtensionResults: () => ({ prf: { enabled: true } }),
24+
toJSON: () => ({} as PublicKeyCredentialJSON),
25+
} as unknown as PublicKeyCredential;
26+
},
27+
28+
async get(options: PasskeyGetOptions): Promise<PasskeyAuthResult> {
29+
lastEvalByCredential = options.evalByCredential;
30+
return {
31+
prfResult: PRF_OUTPUT,
32+
credentialId: CREDENTIAL_ID,
33+
otpCode: '123456',
34+
};
35+
},
36+
};
37+
38+
return provider;
39+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import * as assert from 'assert';
2+
import { derivePassword } from '../../src/derivePassword';
3+
import { deriveEnterpriseSalt } from '../../src/deriveEnterpriseSalt';
4+
import { registerPasskey } from '../../src/registerPasskey';
5+
import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet';
6+
import { derivePasskeyPrfKey } from '../../src/derivePasskeyPrfKey';
7+
import { removePasskeyFromWallet } from '../../src/removePasskeyFromWallet';
8+
import { removePasskeyFromAccount } from '../../src/removePasskeyFromAccount';
9+
import { makeMockBitGo } from './helpers/mockBitGo';
10+
import { makeMockProvider } from './helpers/mockProvider';
11+
import {
12+
ENTERPRISE_ID,
13+
WALLET_ID,
14+
COIN,
15+
EXISTING_PASSPHRASE,
16+
PRF_OUTPUT,
17+
CREDENTIAL_ID,
18+
DEVICE_MONGO_ID,
19+
BASE_SALT,
20+
KEYCHAIN_ID,
21+
} from './helpers/fixtures';
22+
23+
// Use sjcl directly for round-trip encryption tests — same crypto as mockBitGo
24+
const sjcl = require('sjcl');
25+
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));
27+
28+
describe('passkey-crypto integration', function () {
29+
let initialEncryptedPrv: string;
30+
const PRIVATE_KEY = 'xprv-test-private-key-12345';
31+
32+
before(function () {
33+
// Encrypt the private key with the existing passphrase (simulates what the server stores)
34+
initialEncryptedPrv = sjclEncrypt(EXISTING_PASSPHRASE, PRIVATE_KEY);
35+
});
36+
37+
describe('register → attach', function () {
38+
it('re-encrypts the private key under the PRF-derived password', async function () {
39+
const { bitgo, keychainState } = makeMockBitGo(initialEncryptedPrv);
40+
const provider = makeMockProvider();
41+
42+
const device = await registerPasskey({ bitgo, provider, label: 'test-key' });
43+
assert.strictEqual(device.credentialId, CREDENTIAL_ID);
44+
assert.strictEqual(device.prfSupported, true);
45+
46+
await attachPasskeyToWallet({
47+
bitgo,
48+
coin: COIN,
49+
walletId: WALLET_ID,
50+
device,
51+
existingPassphrase: EXISTING_PASSPHRASE,
52+
provider,
53+
});
54+
55+
// Verify encryptedPrv round-trips with the PRF-derived password
56+
const decrypted = sjclDecrypt(derivePassword(PRF_OUTPUT), keychainState.encryptedPrv);
57+
assert.strictEqual(decrypted, PRIVATE_KEY);
58+
59+
// prfSalt stored in webauthnInfo must be valid base64url
60+
assert.ok(keychainState.webauthnDevices);
61+
assert.match(keychainState.webauthnDevices[0].prfSalt, /^[A-Za-z0-9\-_]+$/);
62+
});
63+
});
64+
65+
describe('attach → derivePasskeyPrfKey', function () {
66+
it('derives the same passphrase used to re-encrypt during attach', async function () {
67+
const { bitgo, keychainState, wallet } = makeMockBitGo(initialEncryptedPrv);
68+
const provider = makeMockProvider();
69+
70+
const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
71+
await attachPasskeyToWallet({
72+
bitgo,
73+
coin: COIN,
74+
walletId: WALLET_ID,
75+
device,
76+
existingPassphrase: EXISTING_PASSPHRASE,
77+
provider,
78+
});
79+
80+
const derivedPassphrase = await derivePasskeyPrfKey({ bitgo, wallet, provider });
81+
82+
// Same passphrase as what attach used — decrypts the stored key
83+
assert.strictEqual(derivedPassphrase, derivePassword(PRF_OUTPUT));
84+
assert.strictEqual(sjclDecrypt(derivedPassphrase, keychainState.encryptedPrv), PRIVATE_KEY);
85+
});
86+
});
87+
88+
describe('full lifecycle (register → attach → derive → remove)', function () {
89+
it('completes all steps and hits both DEL endpoints', async function () {
90+
const { bitgo, wallet } = makeMockBitGo(initialEncryptedPrv);
91+
const provider = makeMockProvider();
92+
93+
const device = await registerPasskey({ bitgo, provider, label: 'lifecycle-key' });
94+
await attachPasskeyToWallet({
95+
bitgo,
96+
coin: COIN,
97+
walletId: WALLET_ID,
98+
device,
99+
existingPassphrase: EXISTING_PASSPHRASE,
100+
provider,
101+
});
102+
103+
const passphrase = await derivePasskeyPrfKey({ bitgo, wallet, provider });
104+
await removePasskeyFromWallet({ bitgo, coin: COIN, walletId: WALLET_ID, device, walletPassphrase: passphrase });
105+
await removePasskeyFromAccount({ bitgo, device });
106+
107+
const delUrls = bitgo.del.args.map((a: string[]) => a[0]);
108+
assert.ok(delUrls.some((url: string) => url.includes(`/key/${KEYCHAIN_ID}/webauthndevice/${DEVICE_MONGO_ID}`)));
109+
assert.ok(delUrls.some((url: string) => url.includes(`/user/otp/${DEVICE_MONGO_ID}`)));
110+
});
111+
});
112+
113+
describe('PRF salt derivation wiring', function () {
114+
it('passes deriveEnterpriseSalt output as the eval salt in attachPasskeyToWallet', async function () {
115+
const { bitgo } = makeMockBitGo(initialEncryptedPrv);
116+
const provider = makeMockProvider();
117+
118+
const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
119+
await attachPasskeyToWallet({
120+
bitgo,
121+
coin: COIN,
122+
walletId: WALLET_ID,
123+
device,
124+
existingPassphrase: EXISTING_PASSPHRASE,
125+
provider,
126+
});
127+
128+
assert.strictEqual(
129+
provider.lastEvalByCredential?.[CREDENTIAL_ID],
130+
deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID)
131+
);
132+
});
133+
});
134+
135+
describe('error propagation', function () {
136+
it('aborts removePasskeyFromWallet and does not DEL when passphrase is wrong', async function () {
137+
const { bitgo } = makeMockBitGo(initialEncryptedPrv);
138+
const device = { id: DEVICE_MONGO_ID, credentialId: CREDENTIAL_ID, prfSalt: BASE_SALT, isPasskey: true };
139+
140+
// Uses real sjcl decrypt — wrong passphrase genuinely fails decryptKeychainPrivateKey
141+
await assert.rejects(
142+
() =>
143+
removePasskeyFromWallet({
144+
bitgo,
145+
coin: COIN,
146+
walletId: WALLET_ID,
147+
device,
148+
walletPassphrase: 'wrong-passphrase',
149+
}),
150+
/Incorrect wallet passphrase/
151+
);
152+
153+
assert.strictEqual(bitgo.del.callCount, 0);
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)