Skip to content

Commit d25cc70

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

7 files changed

Lines changed: 277 additions & 3 deletions

File tree

Dockerfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ COPY --from=builder /tmp/bitgo/modules/express /var/bitgo-express/
4343
#COPY_START
4444
COPY --from=builder /tmp/bitgo/modules/abstract-lightning /var/modules/abstract-lightning/
4545
COPY --from=builder /tmp/bitgo/modules/sdk-core /var/modules/sdk-core/
46+
COPY --from=builder /tmp/bitgo/modules/passkey-crypto /var/modules/passkey-crypto/
47+
COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/
4648
COPY --from=builder /tmp/bitgo/modules/sdk-lib-mpc /var/modules/sdk-lib-mpc/
4749
COPY --from=builder /tmp/bitgo/modules/sdk-opensslbytes /var/modules/sdk-opensslbytes/
48-
COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/
4950
COPY --from=builder /tmp/bitgo/modules/secp256k1 /var/modules/secp256k1/
5051
COPY --from=builder /tmp/bitgo/modules/statics /var/modules/statics/
5152
COPY --from=builder /tmp/bitgo/modules/utxo-lib /var/modules/utxo-lib/
@@ -145,9 +146,10 @@ COPY --from=builder /tmp/bitgo/modules/sdk-coin-zec /var/modules/sdk-coin-zec/
145146

146147
RUN cd /var/modules/abstract-lightning && yarn link && \
147148
cd /var/modules/sdk-core && yarn link && \
149+
cd /var/modules/passkey-crypto && yarn link && \
150+
cd /var/modules/sjcl && yarn link && \
148151
cd /var/modules/sdk-lib-mpc && yarn link && \
149152
cd /var/modules/sdk-opensslbytes && yarn link && \
150-
cd /var/modules/sjcl && yarn link && \
151153
cd /var/modules/secp256k1 && yarn link && \
152154
cd /var/modules/statics && yarn link && \
153155
cd /var/modules/utxo-lib && yarn link && \
@@ -250,9 +252,10 @@ cd /var/modules/sdk-coin-zec && yarn link
250252
RUN cd /var/bitgo-express && \
251253
yarn link @bitgo/abstract-lightning && \
252254
yarn link @bitgo/sdk-core && \
255+
yarn link @bitgo/passkey-crypto && \
256+
yarn link @bitgo/sjcl && \
253257
yarn link @bitgo/sdk-lib-mpc && \
254258
yarn link @bitgo/sdk-opensslbytes && \
255-
yarn link @bitgo/sjcl && \
256259
yarn link @bitgo/secp256k1 && \
257260
yarn link @bitgo/statics && \
258261
yarn link @bitgo/utxo-lib && \

modules/sdk-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
]
4141
},
4242
"dependencies": {
43+
"@bitgo/passkey-crypto": "^0.1.0",
4344
"@bitgo/public-types": "5.97.0",
4445
"@bitgo/sdk-lib-mpc": "^10.11.1",
4546
"@bitgo/secp256k1": "^1.11.0",

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './internal';
1717
export * from './keychain';
1818
export * as bitcoin from './legacyBitcoin';
1919
export * from './market';
20+
export * from './passkey';
2021
export * from './pendingApproval';
2122
export { WalletProofs } from './proofs';
2223
export * from './recovery';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './registerPasskey';
2+
export * from './types';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { BitGoBase } from '../bitgoBase';
2+
import { WebAuthnOtpDevice, WebAuthnProvider } from './types';
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type { WebAuthnOtpDevice, WebAuthnProvider, PasskeyAuthResult, PasskeyGetOptions } from '@bitgo/passkey-crypto';
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import 'should';
4+
import { registerPasskey } from '../../../../src/bitgo/passkey/registerPasskey';
5+
6+
describe('registerPasskey', function () {
7+
let mockBitGo: sinon.SinonStubbedInstance<{
8+
get: (url: string) => { result: <T>() => Promise<T> };
9+
put: (url: string) => { send: (body: unknown) => { result: <T>() => Promise<T> } };
10+
url: (path: string, version?: number) => string;
11+
}>;
12+
13+
const mockChallenge = {
14+
challenge: btoa('server-challenge-bytes'),
15+
baseSalt: 'server-base-salt-abc123',
16+
rp: { id: 'bitgo.com', name: 'BitGo' },
17+
user: { id: new Uint8Array([1, 2, 3]), name: 'test@bitgo.com', displayName: 'Test User' },
18+
pubKeyCredParams: [{ type: 'public-key' as const, alg: -7 }],
19+
timeout: 60000,
20+
};
21+
22+
const mockOtpResponse = {
23+
id: 'mongo-device-id-123',
24+
credentialId: 'cred-id-base64',
25+
prfSalt: 'server-assigned-prf-salt',
26+
isPasskey: true,
27+
extensions: { prf: true },
28+
};
29+
30+
const clientDataJSON = new TextEncoder().encode(JSON.stringify({ type: 'webauthn.create' }));
31+
const attestationObject = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
32+
33+
function makeAttestation(prfFirst?: ArrayBuffer): PublicKeyCredential {
34+
return {
35+
id: 'cred-id-base64',
36+
rawId: new ArrayBuffer(8),
37+
type: 'public-key',
38+
response: {
39+
clientDataJSON: clientDataJSON.buffer,
40+
attestationObject: attestationObject.buffer,
41+
getTransports: () => [],
42+
} as unknown as AuthenticatorAttestationResponse,
43+
authenticatorAttachment: null,
44+
getClientExtensionResults: () =>
45+
prfFirst !== undefined
46+
? ({ prf: { results: { first: prfFirst } } } as unknown as AuthenticationExtensionsClientOutputs)
47+
: ({} as AuthenticationExtensionsClientOutputs),
48+
} as unknown as PublicKeyCredential;
49+
}
50+
51+
beforeEach(function () {
52+
mockBitGo = {
53+
get: sinon.stub(),
54+
put: sinon.stub(),
55+
url: sinon.stub().callsFake((path: string, _version?: number) => `/api/v2${path}`),
56+
} as unknown as typeof mockBitGo;
57+
});
58+
59+
afterEach(function () {
60+
sinon.restore();
61+
});
62+
63+
describe('with PRF output present', function () {
64+
it('should include scopes in PUT payload and return prfSupported: true', async function () {
65+
const prfOutput = new ArrayBuffer(32);
66+
const attestation = makeAttestation(prfOutput);
67+
68+
const mockProvider = {
69+
create: sinon.stub().resolves(attestation),
70+
get: sinon.stub(),
71+
};
72+
73+
const getResultStub = sinon.stub().resolves(mockChallenge);
74+
(mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub });
75+
76+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
77+
(mockBitGo.put as sinon.SinonStub).returns({ send: sendStub });
78+
79+
const result = await registerPasskey({
80+
bitgo: mockBitGo as unknown as Parameters<typeof registerPasskey>[0]['bitgo'],
81+
provider: mockProvider,
82+
label: 'My Passkey',
83+
});
84+
85+
// Verify GET challenge called
86+
assert.ok((mockBitGo.get as sinon.SinonStub).calledOnce, 'GET challenge should be called');
87+
assert.ok(
88+
(mockBitGo.get as sinon.SinonStub).calledWith('/api/v2/user/otp/webauthn/register'),
89+
'GET should call correct URL'
90+
);
91+
92+
// Verify PUT called with scopes
93+
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
94+
assert.ok(putBody.scopes !== undefined, 'scopes should be present when PRF output exists');
95+
assert.deepStrictEqual(putBody.scopes, ['prf']);
96+
assert.strictEqual(putBody.type, 'webauthn');
97+
assert.strictEqual(putBody.label, 'My Passkey');
98+
99+
// Verify PUT uses correct endpoint
100+
assert.ok((mockBitGo.put as sinon.SinonStub).calledWith('/api/v2/user/otp'), 'PUT should call correct URL');
101+
102+
// Verify return shape
103+
assert.strictEqual(result.id, mockOtpResponse.id);
104+
assert.strictEqual(result.credentialId, mockOtpResponse.credentialId);
105+
assert.strictEqual(result.prfSalt, mockOtpResponse.prfSalt);
106+
assert.strictEqual(result.isPasskey, mockOtpResponse.isPasskey);
107+
assert.strictEqual(result.prfSupported, true);
108+
});
109+
});
110+
111+
describe('without PRF output', function () {
112+
it('should omit scopes from PUT payload and return prfSupported: false', async function () {
113+
const attestation = makeAttestation(undefined);
114+
115+
const mockProvider = {
116+
create: sinon.stub().resolves(attestation),
117+
get: sinon.stub(),
118+
};
119+
120+
const getResultStub = sinon.stub().resolves(mockChallenge);
121+
(mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub });
122+
123+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
124+
(mockBitGo.put as sinon.SinonStub).returns({ send: sendStub });
125+
126+
const result = await registerPasskey({
127+
bitgo: mockBitGo as unknown as Parameters<typeof registerPasskey>[0]['bitgo'],
128+
provider: mockProvider,
129+
label: 'My Passkey No PRF',
130+
});
131+
132+
// Verify scopes omitted
133+
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
134+
assert.ok(putBody.scopes === undefined, 'scopes should be omitted when PRF output is absent');
135+
assert.strictEqual(putBody.type, 'webauthn');
136+
assert.strictEqual(putBody.label, 'My Passkey No PRF');
137+
138+
// Verify return shape
139+
assert.strictEqual(result.id, mockOtpResponse.id);
140+
assert.strictEqual(result.credentialId, mockOtpResponse.credentialId);
141+
assert.strictEqual(result.prfSupported, false);
142+
});
143+
});
144+
145+
describe('baseSalt sourcing', function () {
146+
it('should call GET challenge before provider.create()', async function () {
147+
const callOrder: string[] = [];
148+
149+
const mockProvider = {
150+
create: sinon.stub().callsFake(() => {
151+
callOrder.push('provider.create');
152+
return Promise.resolve(makeAttestation());
153+
}),
154+
get: sinon.stub(),
155+
};
156+
157+
const getResultStub = sinon.stub().callsFake(() => {
158+
callOrder.push('GET challenge');
159+
return Promise.resolve(mockChallenge);
160+
});
161+
(mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub });
162+
163+
const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) });
164+
(mockBitGo.put as sinon.SinonStub).returns({ send: sendStub });
165+
166+
await registerPasskey({
167+
bitgo: mockBitGo as unknown as Parameters<typeof registerPasskey>[0]['bitgo'],
168+
provider: mockProvider,
169+
label: 'Test',
170+
});
171+
172+
assert.deepStrictEqual(
173+
callOrder,
174+
['GET challenge', 'provider.create'],
175+
'GET challenge must precede provider.create'
176+
);
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)