Skip to content

Commit f60f19c

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

4 files changed

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

0 commit comments

Comments
 (0)