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
66 changes: 47 additions & 19 deletions modules/passkey-crypto/src/derivePasskeyPrfKey.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import type { BitGoBase, IWallet } from '@bitgo/sdk-core';
import type { BitGoBase, IWallet, KeychainWebauthnDevice, KeychainWithEncryptedPrv } from '@bitgo/sdk-core';
import { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
import { derivePassword } from './derivePassword';
import type { WebAuthnProvider } from './webAuthnTypes';

interface AssertionChallengeResponse {
challenge: string;
/** API payloads may use either spelling for the webauthn device list. */
type UserKeychainResponse = KeychainWithEncryptedPrv & {
webAuthnDevices?: KeychainWebauthnDevice[];
};

function webauthnDevicesFromKeychain(keychain: UserKeychainResponse): KeychainWebauthnDevice[] | undefined {
const lower = keychain.webauthnDevices;
if (lower !== undefined && lower.length > 0) {
return lower;
}
const upper = keychain.webAuthnDevices;
if (upper !== undefined && upper.length > 0) {
return upper;
}
return undefined;
}

function challengeFromAuthResponse(body: unknown): string {
if (typeof body !== 'object' || body === null) {
throw new Error('Invalid assertion challenge response');
}
const rec = body as Record<string, unknown>;
if (typeof rec.challenge !== 'string') {
throw new Error('Invalid assertion challenge response');
}
return rec.challenge;
}

/**
Expand All @@ -21,38 +45,42 @@ export async function derivePasskeyPrfKey(params: {
}): Promise<string> {
const { bitgo, wallet, provider } = params;

// Fetch the wallet's user keychain to get webauthnDevices
const keychain = await wallet.getEncryptedUserKeychain();
const devices = keychain.webauthnDevices;
const keychain: UserKeychainResponse = await wallet.getEncryptedUserKeychain();
const devices = webauthnDevicesFromKeychain(keychain);

if (!devices || devices.length === 0) {
throw new Error('No passkey devices available');
}

// Build PRF eval map from devices
const { evalByCredential } = buildEvalByCredential(devices as Parameters<typeof buildEvalByCredential>[0]);
const { evalByCredential } = buildEvalByCredential(devices);

if (Object.keys(evalByCredential).length === 0) {
throw new Error('No passkey devices available with a valid PRF salt');
}

// Fetch a server-issued assertion challenge
const { challenge } = (await bitgo
.get(bitgo.url('/user/otp/webauthn/assertion', 2))
.result()) as AssertionChallengeResponse;
const challenge = challengeFromAuthResponse(await bitgo.get(bitgo.url('/user/otp/webauthn/auth', 2)).result());

const allowCredentials = Object.keys(evalByCredential).map((credId) => {
const nodeBuf = Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
Comment thread
derranW26 marked this conversation as resolved.
const id = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.byteLength);
return {
type: 'public-key' as const,
id,
};
});

const publicKey: PublicKeyCredentialRequestOptions = {
challenge: new Uint8Array(Buffer.from(challenge, 'base64')),
allowCredentials,
};

// Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer)
const result = await provider.get({
publicKey: {
challenge: Buffer.from(challenge, 'base64'),
} as PublicKeyCredentialRequestOptions,
publicKey,
evalByCredential,
});

// Verify the credential matches a known device
matchDeviceByCredentialId(devices as Parameters<typeof matchDeviceByCredentialId>[0], result.credentialId);
matchDeviceByCredentialId(devices, result.credentialId);

// Derive and return hex-encoded wallet passphrase
if (!result.prfResult) {
throw new Error('PRF output was not returned by the authenticator');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('derivePasskeyPrfKey', function () {
assert.strictEqual(getCallArgs.evalByCredential['cred-bbb'], 'salt-bbb');
// Verify bitgo was used to fetch the assertion challenge
assert.ok(mockBitGo.get.calledOnce);
assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/assertion', 2));
assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/auth', 2));
});

it("should throw 'No passkey devices available' when no devices", async function () {
Expand Down
1 change: 1 addition & 0 deletions modules/web-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@bitgo/sdk-coin-xtz": "^2.10.7",
"@bitgo/sdk-coin-zec": "^2.8.7",
"@bitgo/sdk-core": "^36.44.0",
"@bitgo/passkey-crypto": "^0.2.0",
"@bitgo/sdk-hmac": "^1.9.0",
"@bitgo/sdk-lib-mpc": "^10.12.0",
"@bitgo/sdk-opensslbytes": "^2.1.0",
Expand Down
2 changes: 2 additions & 0 deletions modules/web-demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const EcdsaChallengeComponent = lazy(
() => import('@components/EcdsaChallenge'),
);
const WebCryptoAuthComponent = lazy(() => import('@components/WebCryptoAuth'));
const PasskeyDemo = lazy(() => import('@components/PasskeyDemo'));

const Loading = () => <div>Loading route...</div>;

Expand All @@ -40,6 +41,7 @@ const App = () => {
path="/webcrypto-auth"
element={<WebCryptoAuthComponent />}
/>
<Route path="/passkey-demo" element={<PasskeyDemo />} />
</Routes>
</Suspense>
</Layout>
Expand Down
6 changes: 6 additions & 0 deletions modules/web-demo/src/components/Navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ const Navbar = () => {
>
WebCrypto Auth
</NavItem>
<NavItem
activeRoute={pathname === '/passkey-demo'}
onClick={() => navigate('/passkey-demo')}
>
Passkey Demo
</NavItem>
</NavbarContainer>
);
};
Expand Down
Loading
Loading