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
1 change: 1 addition & 0 deletions modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"dependencies": {
"@bitgo/public-types": "6.1.0",
"@bitgo/sdk-core": "^36.42.0",
"@bitgo/sjcl": "^1.1.0"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions modules/passkey-crypto/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { derivePassword } from './derivePassword';
export { registerPasskey } from './registerPasskey';
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
141 changes: 141 additions & 0 deletions modules/passkey-crypto/src/registerPasskey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { BitGoBase } from '@bitgo/sdk-core';
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';

interface RegisterChallengeResponse {
challenge: string;
baseSalt: string;
rp: PublicKeyCredentialRpEntity;
user: PublicKeyCredentialUserEntity;
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
excludeCredentials?: PublicKeyCredentialDescriptor[];
authenticatorSelection?: AuthenticatorSelectionCriteria;
attestation?: AttestationConveyancePreference;
extensions?: AuthenticationExtensionsClientInputs;
}

interface OtpDevice {
id: string;
credentialId: string;
prfSalt?: string;
isPasskey?: boolean;
extensions?: Record<string, boolean>;
}

interface RegisterOtpResponse {
user: {
otpDevices: OtpDevice[];
};
}

/** Encodes an ArrayBuffer as a base64url string (no padding). */
function encodeBase64Url(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}

/**
* Recursively converts a PublicKeyCredential (or any value it contains) to a
* JSON-serialisable representation, encoding ArrayBuffers as base64url strings.
*/
function publicKeyCredentialToJSON(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(publicKeyCredentialToJSON);
}
if (value instanceof ArrayBuffer) {
return encodeBase64Url(value);
}
if (ArrayBuffer.isView(value)) {
return encodeBase64Url(value.buffer as ArrayBuffer);
}
if (value instanceof Object) {
const result: Record<string, unknown> = {};
// Use for...in to enumerate DOM object properties (non-enumerable own + inherited)
for (const key in value) {
result[key] = publicKeyCredentialToJSON((value as Record<string, unknown>)[key]);
}
return result;
}
return value;
}

/**
* Decodes excluded credential IDs from base64 strings to ArrayBuffers.
* The WebAuthn API requires BufferSource values, not base64 strings.
*/
function preformatExcludedCredentials(
excludeCredentials: PublicKeyCredentialDescriptor[] | undefined
): PublicKeyCredentialDescriptor[] {
if (!excludeCredentials) return [];
return excludeCredentials.map((cred) => ({
...cred,
id: Buffer.from(cred.id as unknown as string, 'base64') as unknown as ArrayBuffer,
}));
}

export async function registerPasskey(params: {
bitgo: BitGoBase;
provider: WebAuthnProvider;
label: string;
}): Promise<WebAuthnOtpDevice & { prfSupported: boolean }> {
const { bitgo, provider, label } = params;

// Step 1: Fetch server challenge (contains baseSalt)
const challenge = (await bitgo
.get(bitgo.url('/user/otp/webauthn/register', 2))
.result()) as RegisterChallengeResponse;

// Step 2: Pass formatted challenge to provider.create() — browser returns attestation
const attestation = await provider.create({
challenge: Buffer.from(challenge.challenge, 'base64'),
rp: challenge.rp,
user: challenge.user,
pubKeyCredParams: challenge.pubKeyCredParams,
timeout: challenge.timeout,
excludeCredentials: preformatExcludedCredentials(challenge.excludeCredentials),
authenticatorSelection: challenge.authenticatorSelection,
attestation: challenge.attestation,
extensions: challenge.extensions,
});

// Step 3: Check if PRF is supported — `prf.enabled` is set during registration
// (not `prf.results.first`, which is only present during authentication assertions)
const clientExtensionResults: AuthenticationExtensionsClientOutputs = attestation.getClientExtensionResults();
const prfEnabled = clientExtensionResults.prf;
const prfSupported = typeof prfEnabled === 'object' && prfEnabled !== null && prfEnabled.enabled === true;

// Step 4: Serialize the full credential using recursive base64url encoding
const otp = JSON.stringify(publicKeyCredentialToJSON(attestation));

const putBody: Record<string, unknown> = {
otp,
type: 'webauthn',
label,
};

if (prfSupported) {
putBody.extensions = ['prf'];
putBody.scopes = ['wallet_hot'];
}

// Step 5: PUT /api/v2/user/otp
const response = (await bitgo.put(bitgo.url('/user/otp', 2)).send(putBody).result()) as RegisterOtpResponse;

// Step 6: Find the newly registered device by matching credentialId
const device = response.user.otpDevices.find((d) => d.credentialId === attestation.id);
if (!device) {
const available = response.user.otpDevices.map((d) => d.credentialId).join(', ');
throw new Error(
`Registered device not found in response (credentialId: ${attestation.id}). Available: [${available}]`
);
}

// Step 7: Return WebAuthnOtpDevice + prfSupported
return {
id: device.id,
credentialId: device.credentialId,
prfSalt: device.prfSalt,
isPasskey: device.isPasskey,
extensions: device.extensions,
prfSupported,
};
}
147 changes: 147 additions & 0 deletions modules/passkey-crypto/test/unit/registerPasskey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import { registerPasskey } from '../../src/registerPasskey';

describe('registerPasskey', function () {
let mockBitGo: {
get: sinon.SinonStub;
put: sinon.SinonStub;
url: sinon.SinonStub;
};

const mockChallenge = {
challenge: btoa('server-challenge-bytes'),
baseSalt: 'server-base-salt-abc123',
rp: { id: 'bitgo.com', name: 'BitGo' },
user: { id: new Uint8Array([1, 2, 3]), name: 'test@bitgo.com', displayName: 'Test User' },
pubKeyCredParams: [{ type: 'public-key' as const, alg: -7 }],
timeout: 60000,
};

const mockOtpResponse = {
user: {
otpDevices: [
{
id: 'mongo-device-id-123',
credentialId: 'cred-id-base64',
prfSalt: 'server-assigned-prf-salt',
isPasskey: true,
extensions: { prf: true },
},
],
},
};

const clientDataJSON = new TextEncoder().encode(JSON.stringify({ type: 'webauthn.create' }));
const attestationObject = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);

function makeAttestation(prfEnabled = false): PublicKeyCredential {
return {
id: 'cred-id-base64',
rawId: new ArrayBuffer(8),
type: 'public-key',
response: {
clientDataJSON: clientDataJSON.buffer,
attestationObject: attestationObject.buffer,
getTransports: () => [],
} as unknown as AuthenticatorAttestationResponse,
authenticatorAttachment: null,
getClientExtensionResults: () =>
prfEnabled
? ({ prf: { enabled: true } } as unknown as AuthenticationExtensionsClientOutputs)
: ({} as AuthenticationExtensionsClientOutputs),
} as unknown as PublicKeyCredential;
}

beforeEach(function () {
mockBitGo = {
get: sinon.stub(),
put: sinon.stub(),
url: sinon.stub().callsFake((path: string, _version?: number) => `/api/v2${path}`),
};
});

afterEach(function () {
sinon.restore();
});

describe('with PRF supported', function () {
it('should include scopes in PUT payload and return prfSupported: true', async function () {
const attestation = makeAttestation(true);

const mockProvider = { create: sinon.stub().resolves(attestation), get: sinon.stub() };

mockBitGo.get.returns({ result: sinon.stub().resolves(mockChallenge) });
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
mockBitGo.put.returns({ send: sendStub });

const result = await registerPasskey({
bitgo: mockBitGo as never,
provider: mockProvider,
label: 'My Passkey',
});

assert.ok(mockBitGo.get.calledWith('/api/v2/user/otp/webauthn/register'), 'GET should call correct URL');
assert.ok(mockBitGo.put.calledWith('/api/v2/user/otp'), 'PUT should call correct URL');

const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
assert.deepStrictEqual(putBody.extensions, ['prf']);
assert.deepStrictEqual(putBody.scopes, ['wallet_hot']);
assert.strictEqual(putBody.type, 'webauthn');
assert.strictEqual(putBody.label, 'My Passkey');

const device = mockOtpResponse.user.otpDevices[0];
assert.strictEqual(result.id, device.id);
assert.strictEqual(result.credentialId, device.credentialId);
assert.strictEqual(result.prfSalt, device.prfSalt);
assert.strictEqual(result.prfSupported, true);
});
});

describe('without PRF support', function () {
it('should omit scopes from PUT payload and return prfSupported: false', async function () {
const mockProvider = { create: sinon.stub().resolves(makeAttestation(false)), get: sinon.stub() };

mockBitGo.get.returns({ result: sinon.stub().resolves(mockChallenge) });
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
mockBitGo.put.returns({ send: sendStub });

const result = await registerPasskey({
bitgo: mockBitGo as never,
provider: mockProvider,
label: 'My Passkey No PRF',
});

const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
assert.ok(putBody.extensions === undefined, 'extensions should be omitted when PRF is not supported');
assert.ok(putBody.scopes === undefined, 'scopes should be omitted when PRF is not supported');
assert.strictEqual(result.prfSupported, false);
});
});

describe('baseSalt sourcing', function () {
it('should call GET challenge before provider.create()', async function () {
const callOrder: string[] = [];

const mockProvider = {
create: sinon.stub().callsFake(() => {
callOrder.push('provider.create');
return Promise.resolve(makeAttestation());
}),
get: sinon.stub(),
};

mockBitGo.get.returns({
result: sinon.stub().callsFake(() => {
callOrder.push('GET challenge');
return Promise.resolve(mockChallenge);
}),
});
mockBitGo.put.returns({ send: sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }) });

await registerPasskey({ bitgo: mockBitGo as never, provider: mockProvider, label: 'Test' });

assert.deepStrictEqual(callOrder, ['GET challenge', 'provider.create']);
});
});
});
Loading