Skip to content

Commit 55613a8

Browse files
committed
feat(sdk-core): add registerPasskey function
TICKET: WCN-188
1 parent 94da3fc commit 55613a8

4 files changed

Lines changed: 315 additions & 0 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"dependencies": {
3737
"@bitgo/public-types": "6.1.0",
38+
"@bitgo/sdk-core": "^36.42.0",
3839
"@bitgo/sjcl": "^1.1.0"
3940
},
4041
"devDependencies": {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { derivePassword } from './derivePassword';
2+
export { registerPasskey } from './registerPasskey';
23
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
34
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
45
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { BitGoBase } from '@bitgo/sdk-core';
2+
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';
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+
/**
15+
* Extensions from the server. If the server includes a PRF eval hint,
16+
* `extensions.prf.eval.first` will be a base64-encoded string that must
17+
* be decoded to an ArrayBuffer before passing to provider.create().
18+
*/
19+
extensions?: AuthenticationExtensionsClientInputs & {
20+
prf?: { eval?: { first?: string } };
21+
};
22+
}
23+
24+
interface OtpDevice {
25+
id: string;
26+
credentialId: string;
27+
prfSalt?: string;
28+
isPasskey?: boolean;
29+
extensions?: Record<string, boolean>;
30+
}
31+
32+
interface RegisterOtpResponse {
33+
user: {
34+
otpDevices: OtpDevice[];
35+
};
36+
}
37+
38+
/** Encodes an ArrayBuffer as a base64url string (no padding). */
39+
function encodeBase64Url(buffer: ArrayBuffer): string {
40+
return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
41+
}
42+
43+
/**
44+
* Recursively converts a PublicKeyCredential (or any value it contains) to a
45+
* JSON-serialisable representation, encoding ArrayBuffers as base64url strings.
46+
*/
47+
function publicKeyCredentialToJSON(value: unknown): unknown {
48+
if (Array.isArray(value)) {
49+
return value.map(publicKeyCredentialToJSON);
50+
}
51+
if (value instanceof ArrayBuffer) {
52+
return encodeBase64Url(value);
53+
}
54+
if (ArrayBuffer.isView(value)) {
55+
return encodeBase64Url(value.buffer as ArrayBuffer);
56+
}
57+
if (value instanceof Object) {
58+
const result: Record<string, unknown> = {};
59+
// Use for...in to enumerate DOM object properties (non-enumerable own + inherited)
60+
for (const key in value) {
61+
result[key] = publicKeyCredentialToJSON((value as Record<string, unknown>)[key]);
62+
}
63+
return result;
64+
}
65+
return value;
66+
}
67+
68+
/**
69+
* Decodes excluded credential IDs from base64 strings to ArrayBuffers.
70+
* The WebAuthn API requires BufferSource values, not base64 strings.
71+
*/
72+
function preformatExcludedCredentials(
73+
excludeCredentials: PublicKeyCredentialDescriptor[] | undefined
74+
): PublicKeyCredentialDescriptor[] {
75+
if (!excludeCredentials) return [];
76+
return excludeCredentials.map((cred) => ({
77+
...cred,
78+
id: Buffer.from(cred.id as unknown as string, 'base64') as unknown as ArrayBuffer,
79+
}));
80+
}
81+
82+
export async function registerPasskey(params: {
83+
bitgo: BitGoBase;
84+
provider: WebAuthnProvider;
85+
label: string;
86+
}): Promise<WebAuthnOtpDevice & { prfSupported: boolean }> {
87+
const { bitgo, provider, label } = params;
88+
89+
// Step 1: Fetch server challenge (contains baseSalt)
90+
const challenge = (await bitgo
91+
.get(bitgo.url('/user/otp/webauthn/register', 2))
92+
.result()) as RegisterChallengeResponse;
93+
94+
// Step 2: Decode PRF extension hint if the server included one
95+
const serverPrfFirst = challenge.extensions?.prf?.eval?.first;
96+
const extensions: AuthenticationExtensionsClientInputs | undefined = challenge.extensions
97+
? {
98+
...challenge.extensions,
99+
...(serverPrfFirst
100+
? {
101+
prf: {
102+
eval: {
103+
first: Buffer.from(serverPrfFirst, 'base64') as unknown as ArrayBuffer,
104+
},
105+
},
106+
}
107+
: {}),
108+
}
109+
: undefined;
110+
111+
// Step 3: Pass formatted challenge to provider.create() — browser returns attestation
112+
const attestation = await provider.create({
113+
challenge: Buffer.from(challenge.challenge, 'base64'),
114+
rp: challenge.rp,
115+
user: challenge.user,
116+
pubKeyCredParams: challenge.pubKeyCredParams,
117+
timeout: challenge.timeout,
118+
excludeCredentials: preformatExcludedCredentials(challenge.excludeCredentials),
119+
authenticatorSelection: challenge.authenticatorSelection,
120+
attestation: challenge.attestation,
121+
extensions,
122+
});
123+
124+
// Step 4: Check if PRF is supported — `prf.enabled` is set during registration
125+
// (not `prf.results.first`, which is only present during authentication assertions)
126+
const clientExtensionResults = attestation.getClientExtensionResults() as {
127+
prf?: { enabled?: boolean };
128+
};
129+
const prfSupported = clientExtensionResults.prf?.enabled === true;
130+
131+
// Step 5: Serialize the full credential using recursive base64url encoding
132+
const otp = JSON.stringify(publicKeyCredentialToJSON(attestation));
133+
134+
const putBody: Record<string, unknown> = {
135+
otp,
136+
type: 'webauthn',
137+
label,
138+
};
139+
140+
if (prfSupported) {
141+
putBody.extensions = ['prf'];
142+
putBody.scopes = ['wallet_hot'];
143+
}
144+
145+
// Step 6: PUT /api/v2/user/otp
146+
const response = (await bitgo.put(bitgo.url('/user/otp', 2)).send(putBody).result()) as RegisterOtpResponse;
147+
148+
// Step 7: Find the newly registered device by matching credentialId
149+
const device = response.user.otpDevices.find((d) => d.credentialId === attestation.id);
150+
if (!device) {
151+
const available = response.user.otpDevices.map((d) => d.credentialId).join(', ');
152+
throw new Error(
153+
`Registered device not found in response (credentialId: ${attestation.id}). Available: [${available}]`
154+
);
155+
}
156+
157+
// Step 8: Return WebAuthnOtpDevice + prfSupported
158+
return {
159+
id: device.id,
160+
credentialId: device.credentialId,
161+
prfSalt: device.prfSalt,
162+
isPasskey: device.isPasskey,
163+
extensions: device.extensions,
164+
prfSupported,
165+
};
166+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { registerPasskey } from '../../src/registerPasskey';
4+
5+
describe('registerPasskey', function () {
6+
let mockBitGo: {
7+
get: sinon.SinonStub;
8+
put: sinon.SinonStub;
9+
url: sinon.SinonStub;
10+
};
11+
12+
const mockChallenge = {
13+
challenge: btoa('server-challenge-bytes'),
14+
baseSalt: 'server-base-salt-abc123',
15+
rp: { id: 'bitgo.com', name: 'BitGo' },
16+
user: { id: new Uint8Array([1, 2, 3]), name: 'test@bitgo.com', displayName: 'Test User' },
17+
pubKeyCredParams: [{ type: 'public-key' as const, alg: -7 }],
18+
timeout: 60000,
19+
};
20+
21+
const mockOtpResponse = {
22+
user: {
23+
otpDevices: [
24+
{
25+
id: 'mongo-device-id-123',
26+
credentialId: 'cred-id-base64',
27+
prfSalt: 'server-assigned-prf-salt',
28+
isPasskey: true,
29+
extensions: { prf: true },
30+
},
31+
],
32+
},
33+
};
34+
35+
const clientDataJSON = new TextEncoder().encode(JSON.stringify({ type: 'webauthn.create' }));
36+
const attestationObject = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
37+
38+
function makeAttestation(prfEnabled = false): PublicKeyCredential {
39+
return {
40+
id: 'cred-id-base64',
41+
rawId: new ArrayBuffer(8),
42+
type: 'public-key',
43+
response: {
44+
clientDataJSON: clientDataJSON.buffer,
45+
attestationObject: attestationObject.buffer,
46+
getTransports: () => [],
47+
} as unknown as AuthenticatorAttestationResponse,
48+
authenticatorAttachment: null,
49+
getClientExtensionResults: () =>
50+
prfEnabled
51+
? ({ prf: { enabled: true } } as unknown as AuthenticationExtensionsClientOutputs)
52+
: ({} as AuthenticationExtensionsClientOutputs),
53+
} as unknown as PublicKeyCredential;
54+
}
55+
56+
beforeEach(function () {
57+
mockBitGo = {
58+
get: sinon.stub(),
59+
put: sinon.stub(),
60+
url: sinon.stub().callsFake((path: string, _version?: number) => `/api/v2${path}`),
61+
};
62+
});
63+
64+
afterEach(function () {
65+
sinon.restore();
66+
});
67+
68+
describe('with PRF supported', function () {
69+
it('should include scopes in PUT payload and return prfSupported: true', async function () {
70+
const attestation = makeAttestation(true);
71+
72+
const mockProvider = { create: sinon.stub().resolves(attestation), get: sinon.stub() };
73+
74+
mockBitGo.get.returns({ result: sinon.stub().resolves(mockChallenge) });
75+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
76+
mockBitGo.put.returns({ send: sendStub });
77+
78+
const result = await registerPasskey({
79+
bitgo: mockBitGo as never,
80+
provider: mockProvider,
81+
label: 'My Passkey',
82+
});
83+
84+
assert.ok(mockBitGo.get.calledWith('/api/v2/user/otp/webauthn/register'), 'GET should call correct URL');
85+
assert.ok(mockBitGo.put.calledWith('/api/v2/user/otp'), 'PUT should call correct URL');
86+
87+
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
88+
assert.deepStrictEqual(putBody.extensions, ['prf']);
89+
assert.deepStrictEqual(putBody.scopes, ['wallet_hot']);
90+
assert.strictEqual(putBody.type, 'webauthn');
91+
assert.strictEqual(putBody.label, 'My Passkey');
92+
93+
const device = mockOtpResponse.user.otpDevices[0];
94+
assert.strictEqual(result.id, device.id);
95+
assert.strictEqual(result.credentialId, device.credentialId);
96+
assert.strictEqual(result.prfSalt, device.prfSalt);
97+
assert.strictEqual(result.prfSupported, true);
98+
});
99+
});
100+
101+
describe('without PRF support', function () {
102+
it('should omit scopes from PUT payload and return prfSupported: false', async function () {
103+
const mockProvider = { create: sinon.stub().resolves(makeAttestation(false)), get: sinon.stub() };
104+
105+
mockBitGo.get.returns({ result: sinon.stub().resolves(mockChallenge) });
106+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
107+
mockBitGo.put.returns({ send: sendStub });
108+
109+
const result = await registerPasskey({
110+
bitgo: mockBitGo as never,
111+
provider: mockProvider,
112+
label: 'My Passkey No PRF',
113+
});
114+
115+
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
116+
assert.ok(putBody.extensions === undefined, 'extensions should be omitted when PRF is not supported');
117+
assert.ok(putBody.scopes === undefined, 'scopes should be omitted when PRF is not supported');
118+
assert.strictEqual(result.prfSupported, false);
119+
});
120+
});
121+
122+
describe('baseSalt sourcing', function () {
123+
it('should call GET challenge before provider.create()', async function () {
124+
const callOrder: string[] = [];
125+
126+
const mockProvider = {
127+
create: sinon.stub().callsFake(() => {
128+
callOrder.push('provider.create');
129+
return Promise.resolve(makeAttestation());
130+
}),
131+
get: sinon.stub(),
132+
};
133+
134+
mockBitGo.get.returns({
135+
result: sinon.stub().callsFake(() => {
136+
callOrder.push('GET challenge');
137+
return Promise.resolve(mockChallenge);
138+
}),
139+
});
140+
mockBitGo.put.returns({ send: sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }) });
141+
142+
await registerPasskey({ bitgo: mockBitGo as never, provider: mockProvider, label: 'Test' });
143+
144+
assert.deepStrictEqual(callOrder, ['GET challenge', 'provider.create']);
145+
});
146+
});
147+
});

0 commit comments

Comments
 (0)