Skip to content

Commit 8726eed

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

4 files changed

Lines changed: 228 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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
extensions?: AuthenticationExtensionsClientInputs;
15+
}
16+
17+
interface RegisterOtpResponse {
18+
id: string;
19+
credentialId: string;
20+
prfSalt?: string;
21+
isPasskey?: boolean;
22+
extensions?: Record<string, boolean>;
23+
}
24+
25+
export async function registerPasskey(params: {
26+
bitgo: BitGoBase;
27+
provider: WebAuthnProvider;
28+
label: string;
29+
}): Promise<WebAuthnOtpDevice & { prfSupported: boolean }> {
30+
const { bitgo, provider, label } = params;
31+
32+
// Step 1: Fetch server challenge (contains baseSalt)
33+
const challenge = (await bitgo
34+
.get(bitgo.url('/user/otp/webauthn/register', 2))
35+
.result()) as RegisterChallengeResponse;
36+
37+
// Step 2: Pass challenge to provider.create() — browser returns attestation
38+
const attestation = await provider.create({
39+
challenge: Uint8Array.from(atob(challenge.challenge), (c) => c.charCodeAt(0)),
40+
rp: challenge.rp,
41+
user: challenge.user,
42+
pubKeyCredParams: challenge.pubKeyCredParams,
43+
timeout: challenge.timeout,
44+
excludeCredentials: challenge.excludeCredentials,
45+
authenticatorSelection: challenge.authenticatorSelection,
46+
attestation: challenge.attestation,
47+
extensions: challenge.extensions,
48+
});
49+
50+
const attestationResponse = attestation.response as AuthenticatorAttestationResponse;
51+
52+
// Step 3: Check if PRF output is present in the attestation response
53+
const clientExtensionResults = attestation.getClientExtensionResults() as {
54+
prf?: { results?: { first?: ArrayBuffer } };
55+
};
56+
const prfOutput = clientExtensionResults.prf?.results?.first;
57+
const prfSupported = prfOutput !== undefined;
58+
59+
// Step 4: Build payload — include scopes only if PRF output is present
60+
const otpPayload = {
61+
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.clientDataJSON))),
62+
attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.attestationObject))),
63+
};
64+
65+
const putBody: Record<string, unknown> = {
66+
otp: JSON.stringify(otpPayload),
67+
type: 'webauthn',
68+
label,
69+
};
70+
71+
if (prfSupported) {
72+
putBody.scopes = ['prf'];
73+
}
74+
75+
// Step 5: PUT /api/v2/user/otp
76+
const response = (await bitgo.put(bitgo.url('/user/otp', 2)).send(putBody).result()) as RegisterOtpResponse;
77+
78+
// Step 6: Return WebAuthnOtpDevice + prfSupported
79+
return {
80+
id: response.id,
81+
credentialId: response.credentialId,
82+
prfSalt: response.prfSalt,
83+
isPasskey: response.isPasskey,
84+
extensions: response.extensions,
85+
prfSupported,
86+
};
87+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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(prfFirst?: ArrayBuffer): 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+
prfFirst !== undefined
45+
? ({ prf: { results: { first: prfFirst } } } 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 output present', function () {
63+
it('should include scopes in PUT payload and return prfSupported: true', async function () {
64+
const prfOutput = new ArrayBuffer(32);
65+
const attestation = makeAttestation(prfOutput);
66+
67+
const mockProvider = { create: sinon.stub().resolves(attestation), get: sinon.stub() };
68+
69+
mockBitGo.get.returns({ result: sinon.stub().resolves(mockChallenge) });
70+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
71+
mockBitGo.put.returns({ send: sendStub });
72+
73+
const result = await registerPasskey({
74+
bitgo: mockBitGo as never,
75+
provider: mockProvider,
76+
label: 'My Passkey',
77+
});
78+
79+
assert.ok(mockBitGo.get.calledWith('/api/v2/user/otp/webauthn/register'), 'GET should call correct URL');
80+
assert.ok(mockBitGo.put.calledWith('/api/v2/user/otp'), 'PUT should call correct URL');
81+
82+
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
83+
assert.deepStrictEqual(putBody.scopes, ['prf']);
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 output', function () {
95+
it('should omit scopes from PUT payload and return prfSupported: false', async function () {
96+
const mockProvider = { create: sinon.stub().resolves(makeAttestation(undefined)), 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.scopes === undefined, 'scopes should be omitted when PRF output is absent');
110+
assert.strictEqual(result.prfSupported, false);
111+
});
112+
});
113+
114+
describe('baseSalt sourcing', function () {
115+
it('should call GET challenge before provider.create()', async function () {
116+
const callOrder: string[] = [];
117+
118+
const mockProvider = {
119+
create: sinon.stub().callsFake(() => {
120+
callOrder.push('provider.create');
121+
return Promise.resolve(makeAttestation());
122+
}),
123+
get: sinon.stub(),
124+
};
125+
126+
mockBitGo.get.returns({
127+
result: sinon.stub().callsFake(() => {
128+
callOrder.push('GET challenge');
129+
return Promise.resolve(mockChallenge);
130+
}),
131+
});
132+
mockBitGo.put.returns({ send: sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }) });
133+
134+
await registerPasskey({ bitgo: mockBitGo as never, provider: mockProvider, label: 'Test' });
135+
136+
assert.deepStrictEqual(callOrder, ['GET challenge', 'provider.create']);
137+
});
138+
});
139+
});

0 commit comments

Comments
 (0)