Skip to content

Commit 173132d

Browse files
committed
feat(sdk-core): add derivePasskeyPrfKey function
- fetch keychain webauthn devices and build PRF eval map - trigger WebAuthn assertion via provider - derive hex wallet passphrase from PRF output Ticket: WCN-411
1 parent 94da3fc commit 173132d

9 files changed

Lines changed: 463 additions & 0 deletions

File tree

modules/sdk-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
]
4141
},
4242
"dependencies": {
43+
"@bitgo/passkey-crypto": "^0.1.0",
4344
"@bitgo/public-types": "5.97.0",
4445
"@bitgo/sdk-lib-mpc": "^10.11.1",
4546
"@bitgo/secp256k1": "^1.11.0",

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './internal';
1717
export * from './keychain';
1818
export * as bitcoin from './legacyBitcoin';
1919
export * from './market';
20+
export * from './passkey';
2021
export * from './pendingApproval';
2122
export { WalletProofs } from './proofs';
2223
export * from './recovery';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
buildEvalByCredential,
3+
matchDeviceByCredentialId,
4+
derivePassword,
5+
type WebAuthnProvider,
6+
} from '@bitgo/passkey-crypto';
7+
import type { BitGoBase } from '../bitgoBase';
8+
import type { IWallet } from '../wallet/iWallet';
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 { 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+
// Trigger WebAuthn assertion with PRF evaluation
40+
const result = await provider.get({
41+
publicKey: { challenge: new Uint8Array() } as PublicKeyCredentialRequestOptions,
42+
evalByCredential,
43+
});
44+
45+
// Verify the credential matches a known device
46+
matchDeviceByCredentialId(devices as Parameters<typeof matchDeviceByCredentialId>[0], result.credentialId);
47+
48+
// Derive and return hex-encoded wallet passphrase
49+
if (!result.prfResult) {
50+
throw new Error('PRF output was not returned by the authenticator');
51+
}
52+
53+
return derivePassword(result.prfResult);
54+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { derivePasskeyPrfKey } from './derivePasskeyPrfKey';
2+
export * from './registerPasskey';
3+
export * from './types';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { BitGoBase } from '../bitgoBase';
2+
import { WebAuthnOtpDevice, WebAuthnProvider } from './types';
3+
4+
interface RegisterChallengeResponse {
5+
challenge: string;
6+
baseSalt: string;
7+
rp: PublicKeyCredentialRpEntity;
8+
user: PublicKeyCredentialUserEntity;
9+
pubKeyCredParams: PublicKeyCredentialParameters[];
10+
timeout?: number;
11+
excludeCredentials?: PublicKeyCredentialDescriptor[];
12+
authenticatorSelection?: AuthenticatorSelectionCriteria;
13+
attestation?: AttestationConveyancePreference;
14+
extensions?: AuthenticationExtensionsClientInputs;
15+
}
16+
17+
interface RegisterOtpResponse {
18+
id: string;
19+
credentialId: string;
20+
prfSalt?: string;
21+
isPasskey?: boolean;
22+
extensions?: Record<string, boolean>;
23+
}
24+
25+
export async function registerPasskey(params: {
26+
bitgo: BitGoBase;
27+
provider: WebAuthnProvider;
28+
label: string;
29+
}): Promise<WebAuthnOtpDevice & { prfSupported: boolean }> {
30+
const { bitgo, provider, label } = params;
31+
32+
// Step 1: Fetch server challenge (contains baseSalt)
33+
const challenge = (await bitgo
34+
.get(bitgo.url('/user/otp/webauthn/register', 2))
35+
.result()) as RegisterChallengeResponse;
36+
37+
// Step 2: Pass challenge to provider.create() — browser returns attestation
38+
const attestation = await provider.create({
39+
challenge: Uint8Array.from(atob(challenge.challenge), (c) => c.charCodeAt(0)),
40+
rp: challenge.rp,
41+
user: challenge.user,
42+
pubKeyCredParams: challenge.pubKeyCredParams,
43+
timeout: challenge.timeout,
44+
excludeCredentials: challenge.excludeCredentials,
45+
authenticatorSelection: challenge.authenticatorSelection,
46+
attestation: challenge.attestation,
47+
extensions: challenge.extensions,
48+
});
49+
50+
const attestationResponse = attestation.response as AuthenticatorAttestationResponse;
51+
52+
// Step 3: Check if PRF output is present in the attestation response
53+
const clientExtensionResults = attestation.getClientExtensionResults() as {
54+
prf?: { results?: { first?: ArrayBuffer } };
55+
};
56+
const prfOutput = clientExtensionResults.prf?.results?.first;
57+
const prfSupported = prfOutput !== undefined;
58+
59+
// Step 4: Build payload — include scopes only if PRF output is present
60+
const otpPayload = {
61+
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.clientDataJSON))),
62+
attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.attestationObject))),
63+
};
64+
65+
const putBody: Record<string, unknown> = {
66+
otp: JSON.stringify(otpPayload),
67+
type: 'webauthn',
68+
label,
69+
};
70+
71+
if (prfSupported) {
72+
putBody.scopes = ['prf'];
73+
}
74+
75+
// Step 5: PUT /api/v2/user/otp
76+
const response = (await bitgo.put(bitgo.url('/user/otp', 2)).send(putBody).result()) as RegisterOtpResponse;
77+
78+
// Step 6: Return WebAuthnOtpDevice + prfSupported
79+
return {
80+
id: response.id,
81+
credentialId: response.credentialId,
82+
prfSalt: response.prfSalt,
83+
isPasskey: response.isPasskey,
84+
extensions: response.extensions,
85+
prfSupported,
86+
};
87+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type { WebAuthnOtpDevice, WebAuthnProvider, PasskeyAuthResult, PasskeyGetOptions } from '@bitgo/passkey-crypto';
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { derivePasskeyPrfKey } from '../../../../src/bitgo/passkey/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+
afterEach(function () {
34+
sinon.restore();
35+
});
36+
37+
it('should return a hex string on happy path', async function () {
38+
const prfResult = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer;
39+
40+
const mockProvider = {
41+
create: sinon.stub(),
42+
get: sinon.stub().resolves({
43+
prfResult,
44+
credentialId: 'cred-aaa',
45+
otpCode: 'otp-123',
46+
}),
47+
};
48+
49+
const wallet = makeWallet(mockDevices);
50+
51+
const result = await derivePasskeyPrfKey({
52+
bitgo: {} as any,
53+
wallet: wallet as any,
54+
provider: mockProvider,
55+
});
56+
57+
// derivePassword converts ArrayBuffer to hex
58+
assert.strictEqual(result, 'deadbeef');
59+
assert.ok(mockProvider.get.calledOnce);
60+
// Verify evalByCredential was passed
61+
const getCallArgs = mockProvider.get.firstCall.args[0];
62+
assert.strictEqual(getCallArgs.evalByCredential['cred-aaa'], 'salt-aaa');
63+
assert.strictEqual(getCallArgs.evalByCredential['cred-bbb'], 'salt-bbb');
64+
});
65+
66+
it("should throw 'No passkey devices available' when no devices", async function () {
67+
const wallet = makeWallet(undefined);
68+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
69+
70+
await assert.rejects(
71+
() => derivePasskeyPrfKey({ bitgo: {} as any, wallet: wallet as any, provider: mockProvider }),
72+
(err: Error) => {
73+
assert.strictEqual(err.message, 'No passkey devices available');
74+
return true;
75+
}
76+
);
77+
});
78+
79+
it("should throw 'No passkey devices available' when devices array is empty", async function () {
80+
const wallet = makeWallet([] as any);
81+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
82+
83+
await assert.rejects(
84+
() => derivePasskeyPrfKey({ bitgo: {} 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 with a valid PRF salt' when no device has prfSalt", async function () {
93+
const devicesWithoutSalt = [
94+
{
95+
otpDeviceId: 'device-1',
96+
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none' as const, publicKey: 'pk-1' },
97+
prfSalt: '', // empty — buildEvalByCredential skips falsy prfSalt
98+
encryptedPrv: 'enc-prv-1',
99+
},
100+
];
101+
102+
const wallet = makeWallet(devicesWithoutSalt as any);
103+
const mockProvider = { create: sinon.stub(), get: sinon.stub() };
104+
105+
await assert.rejects(
106+
() => derivePasskeyPrfKey({ bitgo: {} as any, wallet: wallet as any, provider: mockProvider }),
107+
(err: Error) => {
108+
assert.strictEqual(err.message, 'No passkey devices available with a valid PRF salt');
109+
return true;
110+
}
111+
);
112+
});
113+
114+
it("should throw 'Could not identify which passkey device was used' when credentialId not found", async function () {
115+
const mockProvider = {
116+
create: sinon.stub(),
117+
get: sinon.stub().resolves({
118+
prfResult: new ArrayBuffer(32),
119+
credentialId: 'unknown-cred-id',
120+
otpCode: 'otp-123',
121+
}),
122+
};
123+
124+
const wallet = makeWallet(mockDevices);
125+
126+
await assert.rejects(
127+
() => derivePasskeyPrfKey({ bitgo: {} as any, wallet: wallet as any, provider: mockProvider }),
128+
(err: Error) => {
129+
assert.strictEqual(err.message, 'Could not identify which passkey device was used');
130+
return true;
131+
}
132+
);
133+
});
134+
});

0 commit comments

Comments
 (0)