Skip to content
Open
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
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './market';
export * from './pendingApproval';
export { WalletProofs } from './proofs';
export * from './recovery';
export * from './passkey';
export * from './staking';
export * from './trading';
export * from './tss';
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from './types';
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
38 changes: 38 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/prfHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { KeychainWebauthnDevice } from '../keychain/iKeychains';

/**
* Builds the `evalByCredential` map passed to `WebAuthnProvider.get()`.
* Maps each device's credID to its prfSalt so the authenticator can
* evaluate the PRF with the correct salt for whichever credential it selects.
*
* Mirrors retail's buildEvalByCredentialFromKeychain — takes KeychainWebauthnDevice[]
* directly so credID is read from authenticatorInfo where it actually lives.
*
* @param devices - webauthnDevices from the wallet keychain
* @returns a map of { [credID]: prfSalt }
*/
export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): Record<string, string> {
return Object.fromEntries(devices.map((d) => [d.authenticatorInfo.credID, d.prfSalt]));
}

/**
* Finds the KeychainWebauthnDevice whose credID matches the credential ID
* returned by the WebAuthn assertion.
*
* @param devices - webauthnDevices from the wallet keychain
* @param credentialId - base64url credential ID from the WebAuthn assertion
* @throws if no device matches
*/
export function matchDeviceByCredentialId(
devices: KeychainWebauthnDevice[],
credentialId: string
): KeychainWebauthnDevice {
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
if (!device) {
throw new Error(
`No passkey device found matching credential ID "${credentialId}". ` +
`Known credential IDs: [${devices.map((d) => d.authenticatorInfo.credID).join(', ')}]`
);
}
return device;
}
28 changes: 28 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface WebAuthnOtpDevice {
/** MongoDB ObjectId — used for deletion API calls */
otpDeviceId: string;
/** base64url WebAuthn credential ID */
credID: string;
/** WebAuthn attestation format */
fmt: 'none' | 'packed' | 'fido-u2f';
/** Base64-encoded public key from the authenticator */
publicKey: string;
/** base64url-encoded salt from the server — optional */
prfSalt?: string;
/** SJCL-encrypted private key (present once passkey is attached to a wallet keychain) */
encryptedPrv?: string;
}

export interface PasskeyAuthResult {
/** Raw PRF output — undefined if the authenticator does not support PRF */
prfResult: ArrayBuffer | undefined;
/** base64url credential ID returned by the authenticator — matches KeychainWebauthnDevice.authenticatorInfo.credID */
credentialId: string;
/** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */
otpCode: string;
}

export interface WebAuthnProvider {
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential | null>;
get(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential | null>;
}
64 changes: 64 additions & 0 deletions modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as assert from 'assert';
import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers';
import { KeychainWebauthnDevice } from '../../../../src/bitgo/keychain/iKeychains';

const device1: KeychainWebauthnDevice = {
otpDeviceId: 'oid-1',
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none', publicKey: 'pk-1' },
prfSalt: 'salt-aaa',
encryptedPrv: 'enc-prv-1',
};

const device2: KeychainWebauthnDevice = {
otpDeviceId: 'oid-2',
authenticatorInfo: { credID: 'cred-bbb', fmt: 'none', publicKey: 'pk-2' },
prfSalt: 'salt-bbb',
encryptedPrv: 'enc-prv-2',
};

describe('buildEvalByCredential', function () {
it('maps each device authenticatorInfo.credID to its prfSalt', function () {
const result = buildEvalByCredential([device1, device2]);
assert.deepStrictEqual(result, {
'cred-aaa': 'salt-aaa',
'cred-bbb': 'salt-bbb',
});
});

it('returns an empty object for an empty device list', function () {
assert.deepStrictEqual(buildEvalByCredential([]), {});
});

it('returns a single-entry map for one device', function () {
const result = buildEvalByCredential([device1]);
assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' });
});
});

describe('matchDeviceByCredentialId', function () {
it('returns the matching device', function () {
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
assert.strictEqual(result, device2);
});

it('returns the first device when it matches', function () {
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
assert.strictEqual(result, device1);
});

it('throws a descriptive error when no device matches', function () {
assert.throws(
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
(err: Error) => {
assert.ok(err.message.includes('cred-unknown'));
assert.ok(err.message.includes('cred-aaa'));
assert.ok(err.message.includes('cred-bbb'));
return true;
}
);
});

it('throws when the device list is empty', function () {
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
});
});
Loading