Skip to content

Commit e2c5ccd

Browse files
committed
feat(sdk-core): add derivePasskeyPrfKey function
- fetch keychain webauthn devices and build PRF eval map - fetch server-issued assertion challenge via bitgo - trigger WebAuthn assertion via provider (navigator layer) - derive hex wallet passphrase from PRF output Ticket: WCN-192
1 parent 3dd77d6 commit e2c5ccd

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"dependencies": {
3737
"@bitgo/public-types": "6.1.0",
38+
"@bitgo/sdk-core": "^36.44.0",
3839
"@bitgo/sjcl": "^1.1.0"
3940
},
4041
"devDependencies": {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { BitGoBase, IWallet } from '@bitgo/sdk-core';
2+
import { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
3+
import { derivePassword } from './derivePassword';
4+
import type { WebAuthnProvider } from './webAuthnTypes';
5+
6+
interface AssertionChallengeResponse {
7+
challenge: string;
8+
}
9+
10+
/**
11+
* Derives a wallet passphrase from a passkey PRF output.
12+
*
13+
* Fetches the wallet's user keychain, triggers a WebAuthn assertion with PRF
14+
* evaluation, and returns a hex-encoded passphrase suitable for use as
15+
* walletPassphrase in signing calls.
16+
*/
17+
export async function derivePasskeyPrfKey(params: {
18+
bitgo: BitGoBase;
19+
wallet: IWallet;
20+
provider: WebAuthnProvider;
21+
}): Promise<string> {
22+
const { bitgo, wallet, provider } = params;
23+
24+
// Fetch the wallet's user keychain to get webauthnDevices
25+
const keychain = await wallet.getEncryptedUserKeychain();
26+
const devices = keychain.webauthnDevices;
27+
28+
if (!devices || devices.length === 0) {
29+
throw new Error('No passkey devices available');
30+
}
31+
32+
// Build PRF eval map from devices
33+
const { evalByCredential } = buildEvalByCredential(devices as Parameters<typeof buildEvalByCredential>[0]);
34+
35+
if (Object.keys(evalByCredential).length === 0) {
36+
throw new Error('No passkey devices available with a valid PRF salt');
37+
}
38+
39+
// Fetch a server-issued assertion challenge
40+
const { challenge } = (await bitgo
41+
.get(bitgo.url('/user/otp/webauthn/assertion', 2))
42+
.result()) as AssertionChallengeResponse;
43+
44+
// Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer)
45+
const result = await provider.get({
46+
publicKey: {
47+
challenge: Buffer.from(challenge, 'base64'),
48+
} as PublicKeyCredentialRequestOptions,
49+
evalByCredential,
50+
});
51+
52+
// Verify the credential matches a known device
53+
matchDeviceByCredentialId(devices as Parameters<typeof matchDeviceByCredentialId>[0], result.credentialId);
54+
55+
// Derive and return hex-encoded wallet passphrase
56+
if (!result.prfResult) {
57+
throw new Error('PRF output was not returned by the authenticator');
58+
}
59+
60+
return derivePassword(result.prfResult);
61+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { derivePasskeyPrfKey } from '../../src/derivePasskeyPrfKey';
4+
5+
describe('derivePasskeyPrfKey', function () {
6+
const mockDevices = [
7+
{
8+
otpDeviceId: 'device-1',
9+
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none' as const, publicKey: 'pk-1' },
10+
prfSalt: 'salt-aaa',
11+
encryptedPrv: 'enc-prv-1',
12+
},
13+
{
14+
otpDeviceId: 'device-2',
15+
authenticatorInfo: { credID: 'cred-bbb', fmt: 'none' as const, publicKey: 'pk-2' },
16+
prfSalt: 'salt-bbb',
17+
encryptedPrv: 'enc-prv-2',
18+
},
19+
];
20+
21+
function makeWallet(devices: typeof mockDevices | undefined) {
22+
return {
23+
getEncryptedUserKeychain: sinon.stub().resolves({
24+
id: 'keychain-id',
25+
pub: 'xpub123',
26+
encryptedPrv: 'encrypted-prv',
27+
type: 'independent',
28+
webauthnDevices: devices,
29+
}),
30+
};
31+
}
32+
33+
function makeBitGo(challenge = 'dGVzdC1jaGFsbGVuZ2U=') {
34+
return {
35+
url: sinon.stub().callsFake((path: string) => `https://app.bitgo.com/api/v2${path}`),
36+
get: sinon.stub().returns({
37+
result: sinon.stub().resolves({ challenge }),
38+
}),
39+
};
40+
}
41+
42+
afterEach(function () {
43+
sinon.restore();
44+
});
45+
46+
it('should return a hex string on happy path', async function () {
47+
const prfResult = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer;
48+
49+
const mockProvider = {
50+
create: sinon.stub(),
51+
get: sinon.stub().resolves({
52+
prfResult,
53+
credentialId: 'cred-aaa',
54+
otpCode: 'otp-123',
55+
}),
56+
};
57+
58+
const wallet = makeWallet(mockDevices);
59+
const mockBitGo = makeBitGo();
60+
61+
const result = await derivePasskeyPrfKey({
62+
bitgo: mockBitGo as any,
63+
wallet: wallet as any,
64+
provider: mockProvider,
65+
});
66+
67+
// derivePassword converts ArrayBuffer to hex
68+
assert.strictEqual(result, 'deadbeef');
69+
assert.ok(mockProvider.get.calledOnce);
70+
// Verify evalByCredential was passed
71+
const getCallArgs = mockProvider.get.firstCall.args[0];
72+
assert.strictEqual(getCallArgs.evalByCredential['cred-aaa'], 'salt-aaa');
73+
assert.strictEqual(getCallArgs.evalByCredential['cred-bbb'], 'salt-bbb');
74+
// Verify bitgo was used to fetch the assertion challenge
75+
assert.ok(mockBitGo.get.calledOnce);
76+
assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/assertion', 2));
77+
});
78+
79+
it("should throw 'No passkey devices available' when no devices", async function () {
80+
const wallet = makeWallet(undefined);
81+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
82+
83+
await assert.rejects(
84+
() => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }),
85+
(err: Error) => {
86+
assert.strictEqual(err.message, 'No passkey devices available');
87+
return true;
88+
}
89+
);
90+
});
91+
92+
it("should throw 'No passkey devices available' when devices array is empty", async function () {
93+
const wallet = makeWallet([] as any);
94+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
95+
96+
await assert.rejects(
97+
() => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }),
98+
(err: Error) => {
99+
assert.strictEqual(err.message, 'No passkey devices available');
100+
return true;
101+
}
102+
);
103+
});
104+
105+
it("should throw 'No passkey devices available with a valid PRF salt' when no device has prfSalt", async function () {
106+
const devicesWithoutSalt = [
107+
{
108+
otpDeviceId: 'device-1',
109+
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none' as const, publicKey: 'pk-1' },
110+
prfSalt: '', // empty — buildEvalByCredential skips falsy prfSalt
111+
encryptedPrv: 'enc-prv-1',
112+
},
113+
];
114+
115+
const wallet = makeWallet(devicesWithoutSalt as any);
116+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
117+
118+
await assert.rejects(
119+
() => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }),
120+
(err: Error) => {
121+
assert.strictEqual(err.message, 'No passkey devices available with a valid PRF salt');
122+
return true;
123+
}
124+
);
125+
});
126+
127+
it("should throw 'Could not identify which passkey device was used' when credentialId not found", async function () {
128+
const mockProvider = {
129+
create: sinon.stub(),
130+
get: sinon.stub().resolves({
131+
prfResult: new ArrayBuffer(32),
132+
credentialId: 'unknown-cred-id',
133+
otpCode: 'otp-123',
134+
}),
135+
};
136+
137+
const wallet = makeWallet(mockDevices);
138+
139+
await assert.rejects(
140+
() => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }),
141+
(err: Error) => {
142+
assert.strictEqual(err.message, 'Could not identify which passkey device was used');
143+
return true;
144+
}
145+
);
146+
});
147+
});

0 commit comments

Comments
 (0)