From a943cdf95423a150f6f7e0233b7f23e0d922196e Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Mon, 27 Apr 2026 17:44:40 -0400 Subject: [PATCH] feat(sdk-core): add registerPasskey function TICKET: WCN-188 --- modules/passkey-crypto/package.json | 1 + modules/passkey-crypto/src/index.ts | 1 + modules/passkey-crypto/src/registerPasskey.ts | 141 +++++++++++++++++ .../test/unit/registerPasskey.test.ts | 147 ++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 modules/passkey-crypto/src/registerPasskey.ts create mode 100644 modules/passkey-crypto/test/unit/registerPasskey.test.ts diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 0010782ef2..e492b18060 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@bitgo/public-types": "6.1.0", + "@bitgo/sdk-core": "^36.42.0", "@bitgo/sjcl": "^1.1.0" }, "devDependencies": { diff --git a/modules/passkey-crypto/src/index.ts b/modules/passkey-crypto/src/index.ts index 787f130bd7..b97995580b 100644 --- a/modules/passkey-crypto/src/index.ts +++ b/modules/passkey-crypto/src/index.ts @@ -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'; diff --git a/modules/passkey-crypto/src/registerPasskey.ts b/modules/passkey-crypto/src/registerPasskey.ts new file mode 100644 index 0000000000..71c96fb290 --- /dev/null +++ b/modules/passkey-crypto/src/registerPasskey.ts @@ -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; +} + +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 = {}; + // Use for...in to enumerate DOM object properties (non-enumerable own + inherited) + for (const key in value) { + result[key] = publicKeyCredentialToJSON((value as Record)[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 { + 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 = { + 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, + }; +} diff --git a/modules/passkey-crypto/test/unit/registerPasskey.test.ts b/modules/passkey-crypto/test/unit/registerPasskey.test.ts new file mode 100644 index 0000000000..5975980d85 --- /dev/null +++ b/modules/passkey-crypto/test/unit/registerPasskey.test.ts @@ -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; + 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; + 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']); + }); + }); +});