Skip to content

Commit 19fc785

Browse files
rajangarg047claude
andcommitted
feat(sdk-core): send webauthnInfo with enterpriseId for MPC user keychain
MPC/TSS wallet creation attached the user keychain's passkey by sending a bare webauthnDevices array (no enterpriseId) on POST /api/v2/:coin/key. The wallet-platform atomic key-creation endpoint only consumes webauthnInfo (a single object including enterpriseId, used to validate the PRF salt) and ignores webauthnDevices on input, so passkeys were never persisted for TSS/MPC user keychains. Switch MPC user-keychain creation to send webauthnInfo with enterpriseId, mirroring the onchain key-creation contract. Applied across all four MPC keychain implementations (ECDSA + EdDSA, MPCv1 + MPCv2), threading the existing createKeychains enterprise param down to the USER participant, and widen WebauthnInfo with optional enterpriseId. Add unit tests asserting webauthnInfo (with enterpriseId) is sent on the user keychain across all four MPC paths, that the deprecated webauthnDevices array is not sent, and that the PRF-encrypted prv decrypts with the webauthn passphrase. WCN-848 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a7025e commit 19fc785

9 files changed

Lines changed: 296 additions & 55 deletions

File tree

modules/bitgo/test/v2/unit/internal/tssUtils/ecdsa.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { BitGo, createSharedDataProof, TssUtils, RequestType } from '../../../../../src';
1515
import {
1616
BackupGpgKey,
17+
AddKeychainOptions,
1718
BackupKeyShare,
1819
BaseCoin,
1920
BitgoGPGPublicKey,
@@ -309,6 +310,48 @@ describe('TSS Ecdsa Utils:', async function () {
309310
should.exist(backupKeychain.encryptedPrv);
310311
});
311312

313+
it('should send webauthnInfo (with enterpriseId) on the user keychain when webauthnInfo is provided', async function () {
314+
// Keep the real crypto deps (constants w/ bitgo gpg key for verifyWalletSignatures) and
315+
// capture the user keychain add() params by stubbing baseCoin.keychains().
316+
nock.cleanAll();
317+
nock(bgUrl)
318+
.get('/api/v1/client/constants')
319+
.times(16)
320+
.reply(200, { ttl: 3600, constants: { mpc: { bitgoPublicKey: bitGoGPGKeyPair.publicKey } } });
321+
322+
const addStub = sandbox.stub().resolves({ id: '1', pub: '', type: 'tss' });
323+
sandbox.stub(baseCoin, 'keychains').returns({ add: addStub } as unknown as ReturnType<BaseCoin['keychains']>);
324+
325+
const enterpriseId = 'enterprise_id';
326+
const webauthnInfo = { otpDeviceId: 'device-123', prfSalt: 'salt-abc', passphrase: 'prf-derived-passphrase' };
327+
await tssUtils.createParticipantKeychain(
328+
userGpgKey,
329+
userLocalBackupGpgKey,
330+
bitgoPublicKey,
331+
1,
332+
userKeyShare,
333+
backupKeyShare,
334+
nockedBitGoKeychain,
335+
'passphrase',
336+
undefined,
337+
webauthnInfo,
338+
undefined,
339+
enterpriseId
340+
);
341+
342+
// User keychain must carry webauthnInfo (the field the backend POST /key consumes), including
343+
// enterpriseId, and must NOT use the deprecated webauthnDevices array.
344+
assert.ok(addStub.calledOnce, 'keychains().add should have been called for the user keychain');
345+
const body = addStub.firstCall.args[0] as AddKeychainOptions;
346+
assert.ok(body.webauthnInfo, 'user keychain body should include webauthnInfo');
347+
assert.equal(body.webauthnInfo.otpDeviceId, webauthnInfo.otpDeviceId);
348+
assert.equal(body.webauthnInfo.prfSalt, webauthnInfo.prfSalt);
349+
assert.equal(body.webauthnInfo.enterpriseId, enterpriseId);
350+
assert.ok(body.webauthnInfo.encryptedPrv, 'encryptedPrv should be set');
351+
assert.ok(bitgo.decrypt({ input: body.webauthnInfo.encryptedPrv, password: webauthnInfo.passphrase }));
352+
assert.strictEqual(body.webauthnDevices, undefined, 'deprecated webauthnDevices should not be sent');
353+
});
354+
312355
it('should generate TSS key chains with optional params', async function () {
313356
const enterprise = 'enterprise_id';
314357
const backupShareHolder: BackupKeyShare = {

modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ describe('TSS Ecdsa MPCv2 Utils:', async function () {
5757
});
5858

5959
before(async function () {
60+
// Allow secp256k1 GPG keys used by these fixtures (the full suite enables this
61+
// globally via sibling test files; set it here so this file also runs in isolation).
62+
openpgp.config.rejectCurves = new Set();
6063
bitGoGgpKey = await openpgp.generateKey({
6164
userIDs: [
6265
{
@@ -176,6 +179,68 @@ describe('TSS Ecdsa MPCv2 Utils:', async function () {
176179
assert.equal(bitgoKeychain.source, 'bitgo');
177180
});
178181

182+
it('should send webauthnInfo (with enterpriseId) on the user keychain when webauthnInfo is provided', async function () {
183+
const bitgoSession = new DklsDkg.Dkg(3, 2, 2);
184+
185+
const round1Nock = await nockKeyGenRound1(bitgoSession, 1);
186+
const round2Nock = await nockKeyGenRound2(bitgoSession, 1);
187+
const round3Nock = await nockKeyGenRound3(bitgoSession, 1);
188+
189+
// Capture each keychain POST body by source so we can assert what the user key sends.
190+
const capturedBodies: Record<string, AddKeychainOptions> = {};
191+
const addKeyNock = nock('https://bitgo.fakeurl')
192+
.post(`/api/v2/${coinName}/key`, (body) => body.keyType === 'tss' && body.isMPCv2)
193+
.times(3)
194+
.reply(200, async (uri, requestBody: AddKeychainOptions) => {
195+
capturedBodies[requestBody.source as string] = requestBody;
196+
const key = {
197+
id: requestBody.source,
198+
source: requestBody.source,
199+
type: requestBody.keyType,
200+
commonKeychain: requestBody.commonKeychain,
201+
encryptedPrv: requestBody.encryptedPrv,
202+
};
203+
nock('https://bitgo.fakeurl').get(`/api/v2/${coinName}/key/${requestBody.source}`).reply(200, key);
204+
return key;
205+
});
206+
207+
const webauthnInfo = {
208+
otpDeviceId: 'device-123',
209+
prfSalt: 'salt-abc',
210+
passphrase: 'prf-derived-passphrase',
211+
};
212+
const params = {
213+
passphrase: 'test',
214+
enterprise: enterpriseId,
215+
originalPasscodeEncryptionCode: '123456',
216+
webauthnInfo,
217+
};
218+
await tssUtils.createKeychains(params);
219+
assert.ok(round1Nock.isDone());
220+
assert.ok(round2Nock.isDone());
221+
assert.ok(round3Nock.isDone());
222+
assert.ok(addKeyNock.isDone());
223+
224+
// User keychain must carry webauthnInfo (the field the backend POST /key consumes),
225+
// including enterpriseId, and must NOT use the deprecated webauthnDevices array.
226+
const userBody = capturedBodies['user'];
227+
assert.ok(userBody, 'user keychain should have been created');
228+
assert.ok(userBody.webauthnInfo, 'user keychain body should include webauthnInfo');
229+
assert.equal(userBody.webauthnInfo.otpDeviceId, webauthnInfo.otpDeviceId);
230+
assert.equal(userBody.webauthnInfo.prfSalt, webauthnInfo.prfSalt);
231+
assert.equal(userBody.webauthnInfo.enterpriseId, enterpriseId);
232+
assert.ok(userBody.webauthnInfo.encryptedPrv, 'encryptedPrv should be set');
233+
// encryptedPrv is the user key share encrypted with the PRF-derived passphrase.
234+
assert.ok(bitgo.decrypt({ input: userBody.webauthnInfo.encryptedPrv, password: webauthnInfo.passphrase }));
235+
assert.strictEqual(userBody.webauthnDevices, undefined, 'deprecated webauthnDevices should not be sent');
236+
237+
// Backup keychain must never carry passkey material.
238+
const backupBody = capturedBodies['backup'];
239+
assert.ok(backupBody, 'backup keychain should have been created');
240+
assert.strictEqual(backupBody.webauthnInfo, undefined);
241+
assert.strictEqual(backupBody.webauthnDevices, undefined);
242+
});
243+
179244
it('should generate TSS MPCv2 keys with v2 encryption envelopes', async function () {
180245
const bitgoSession = new DklsDkg.Dkg(3, 2, 2);
181246

modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as sinon from 'sinon';
88
import { TestableBG, TestBitGo } from '@bitgo/sdk-test';
99
import { BitGo } from '../../../../../src';
1010
import {
11+
AddKeychainOptions,
1112
BaseCoin,
1213
BitgoGPGPublicKey,
1314
CommitmentShareRecord,
@@ -269,6 +270,62 @@ describe('TSS Utils:', async function () {
269270
should.exist(backupKeychain.encryptedPrv);
270271
});
271272

273+
it('should send webauthnInfo (with enterpriseId) on the user keychain when webauthnInfo is provided', async function () {
274+
const userKeyShare = MPC.keyShare(1, 2, 3);
275+
const backupKeyShare = MPC.keyShare(2, 2, 3);
276+
277+
// Real crypto deps (constants w/ bitgo gpg key for verifyWalletSignatures + bitgo keychain),
278+
// then capture the user keychain add() params by stubbing baseCoin.keychains().
279+
nock.cleanAll();
280+
nock(bgUrl)
281+
.get('/api/v1/client/constants')
282+
.times(23)
283+
.reply(200, { ttl: 3600, constants: { mpc: { bitgoPublicKey: bitgoGpgKey.publicKey } } });
284+
await nockBitgoKeychain({
285+
coin: coinName,
286+
userKeyShare,
287+
backupKeyShare,
288+
bitgoKeyShare,
289+
userGpgKey,
290+
backupGpgKey,
291+
bitgoGpgKey,
292+
});
293+
const bitgoKeychain = await tssUtils.createBitgoKeychain({
294+
userGpgKey,
295+
backupGpgKey,
296+
userKeyShare,
297+
backupKeyShare,
298+
});
299+
300+
const addStub = sandbox.stub().resolves({ id: '1', pub: '', type: 'tss' });
301+
sandbox.stub(baseCoin, 'keychains').returns({ add: addStub } as unknown as ReturnType<BaseCoin['keychains']>);
302+
303+
const enterpriseId = 'enterprise_id';
304+
const webauthnInfo = { otpDeviceId: 'device-123', prfSalt: 'salt-abc', passphrase: 'prf-derived-passphrase' };
305+
await tssUtils.createUserKeychain({
306+
userGpgKey,
307+
backupGpgKey,
308+
userKeyShare,
309+
backupKeyShare,
310+
bitgoKeychain,
311+
passphrase: 'passphrase',
312+
webauthnInfo,
313+
enterprise: enterpriseId,
314+
});
315+
316+
// User keychain must carry webauthnInfo (the field the backend POST /key consumes), including
317+
// enterpriseId, and must NOT use the deprecated webauthnDevices array.
318+
assert.ok(addStub.calledOnce, 'keychains().add should have been called for the user keychain');
319+
const body = addStub.firstCall.args[0] as AddKeychainOptions;
320+
assert.ok(body.webauthnInfo, 'user keychain body should include webauthnInfo');
321+
assert.equal(body.webauthnInfo.otpDeviceId, webauthnInfo.otpDeviceId);
322+
assert.equal(body.webauthnInfo.prfSalt, webauthnInfo.prfSalt);
323+
assert.equal(body.webauthnInfo.enterpriseId, enterpriseId);
324+
assert.ok(body.webauthnInfo.encryptedPrv, 'encryptedPrv should be set');
325+
assert.ok(bitgo.decrypt({ input: body.webauthnInfo.encryptedPrv, password: webauthnInfo.passphrase }));
326+
assert.strictEqual(body.webauthnDevices, undefined, 'deprecated webauthnDevices should not be sent');
327+
});
328+
272329
it('should generate TSS key chains without passphrase', async function () {
273330
const userKeyShare = MPC.keyShare(1, 2, 3);
274331
const backupKeyShare = MPC.keyShare(2, 2, 3);

modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/createKeychains.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,52 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
109109
assert.equal(userKeychain.commonKeychain, bitgoKeychain.commonKeychain);
110110
});
111111

112+
it('should send webauthnInfo (with enterpriseId) on the user keychain when webauthnInfo is provided', async function () {
113+
const commonKeychain = 'a'.repeat(64);
114+
const capturedBodies: Record<string, AddKeychainOptions> = {};
115+
const addKeyNock = nock('https://bitgo.fakeurl')
116+
.post(`/api/v2/${coinName}/key`, (body) => body.keyType === 'tss' && body.isMPCv2)
117+
.times(1)
118+
.reply(200, async (uri, requestBody: AddKeychainOptions) => {
119+
capturedBodies[requestBody.source as string] = requestBody;
120+
return {
121+
id: requestBody.source,
122+
source: requestBody.source,
123+
type: requestBody.keyType,
124+
commonKeychain: requestBody.commonKeychain,
125+
encryptedPrv: requestBody.encryptedPrv,
126+
};
127+
});
128+
129+
const webauthnInfo = { otpDeviceId: 'device-123', prfSalt: 'salt-abc', passphrase: 'prf-derived-passphrase' };
130+
// Direct participant-keychain call avoids the EdDSA DKG ceremony while still exercising the
131+
// user-keychain webauthn assembly that POSTs to /key.
132+
await tssUtils.createParticipantKeychain(
133+
MPCv2PartiesEnum.USER,
134+
commonKeychain,
135+
Buffer.from('userPrivate'),
136+
Buffer.from('userReduced'),
137+
'passphrase',
138+
undefined,
139+
webauthnInfo,
140+
undefined,
141+
enterpriseId
142+
);
143+
assert.ok(addKeyNock.isDone());
144+
145+
// User keychain must carry webauthnInfo (the field the backend POST /key consumes), including
146+
// enterpriseId, and must NOT use the deprecated webauthnDevices array.
147+
const userBody = capturedBodies['user'];
148+
assert.ok(userBody, 'user keychain should have been created');
149+
assert.ok(userBody.webauthnInfo, 'user keychain body should include webauthnInfo');
150+
assert.equal(userBody.webauthnInfo.otpDeviceId, webauthnInfo.otpDeviceId);
151+
assert.equal(userBody.webauthnInfo.prfSalt, webauthnInfo.prfSalt);
152+
assert.equal(userBody.webauthnInfo.enterpriseId, enterpriseId);
153+
assert.ok(userBody.webauthnInfo.encryptedPrv, 'encryptedPrv should be set');
154+
assert.ok(bitgo.decrypt({ input: userBody.webauthnInfo.encryptedPrv, password: webauthnInfo.passphrase }));
155+
assert.strictEqual(userBody.webauthnDevices, undefined, 'deprecated webauthnDevices should not be sent');
156+
});
157+
112158
it('should create TSS key chains', async function () {
113159
const fakeCommonKeychain = 'a'.repeat(64);
114160

modules/sdk-core/src/bitgo/keychain/iKeychains.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export interface WebauthnInfo {
1010
prfSalt: string;
1111
otpDeviceId: string;
1212
encryptedPrv: string;
13+
/** Enterprise the key is being created under. Required by the atomic POST /key
14+
* creation endpoint so the backend can validate the PRF salt against the
15+
* enterprise (the key is not yet attached to a wallet at creation time). The
16+
* PUT /key/:id update path resolves the enterprise from the wallet and does
17+
* not need this. */
18+
enterpriseId?: string;
1319
}
1420

1521
import type { WebauthnKeyEncryptionInfo } from '../wallet/iWallets';

modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export class EcdsaUtils extends BaseEcdsaUtils {
145145
originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode,
146146
webauthnInfo: params.webauthnInfo,
147147
encryptionVersion: params.encryptionVersion,
148+
enterprise: params.enterprise,
148149
});
149150
const backupKeychainPromise = this.createBackupKeychain({
150151
userGpgKey,
@@ -187,6 +188,7 @@ export class EcdsaUtils extends BaseEcdsaUtils {
187188
originalPasscodeEncryptionCode,
188189
webauthnInfo,
189190
encryptionVersion,
191+
enterprise,
190192
}: CreateEcdsaKeychainParams): Promise<Keychain> {
191193
if (!passphrase) {
192194
throw new Error('Please provide a wallet passphrase');
@@ -203,7 +205,8 @@ export class EcdsaUtils extends BaseEcdsaUtils {
203205
passphrase,
204206
originalPasscodeEncryptionCode,
205207
webauthnInfo,
206-
encryptionVersion
208+
encryptionVersion,
209+
enterprise
207210
);
208211
}
209212

@@ -322,7 +325,8 @@ export class EcdsaUtils extends BaseEcdsaUtils {
322325
passphrase: string,
323326
originalPasscodeEncryptionCode?: string,
324327
webauthnInfo?: WebauthnKeyEncryptionInfo,
325-
encryptionVersion?: EncryptionVersion
328+
encryptionVersion?: EncryptionVersion,
329+
enterprise?: string
326330
): Promise<Keychain> {
327331
const bitgoKeyShares = bitgoKeychain.keyShares;
328332
if (!bitgoKeyShares) {
@@ -418,19 +422,21 @@ export class EcdsaUtils extends BaseEcdsaUtils {
418422
encryptionVersion,
419423
}),
420424
originalPasscodeEncryptionCode,
421-
webauthnDevices:
425+
// Send the passkey as `webauthnInfo` (single object, including `enterpriseId`) — the field
426+
// the backend's atomic POST /key endpoint consumes. The deprecated `webauthnDevices` array
427+
// is ignored by the create endpoint.
428+
webauthnInfo:
422429
webauthnInfo && recipientIndex === ShareKeyPosition.USER
423-
? [
424-
{
425-
otpDeviceId: webauthnInfo.otpDeviceId,
426-
prfSalt: webauthnInfo.prfSalt,
427-
encryptedPrv: await this.bitgo.encryptAsync({
428-
input: prv,
429-
password: webauthnInfo.passphrase,
430-
encryptionVersion,
431-
}),
432-
},
433-
]
430+
? {
431+
otpDeviceId: webauthnInfo.otpDeviceId,
432+
prfSalt: webauthnInfo.prfSalt,
433+
encryptedPrv: await this.bitgo.encryptAsync({
434+
input: prv,
435+
password: webauthnInfo.passphrase,
436+
encryptionVersion,
437+
}),
438+
enterpriseId: enterprise,
439+
}
434440
: undefined,
435441
};
436442

0 commit comments

Comments
 (0)