Skip to content

Commit 28cbfd6

Browse files
vibhavgoclaude
andcommitted
feat(sdk-core): add getEddsaMPCv2RecoveryKeyShares helper
Ticket: WCI-396 Decrypt both keycards in parallel, validate pub and rootChainCode separately with distinct errors, and surface a clear message when keycard material is missing or a v2 Argon2id envelope is detected. Adds 7 unit tests covering happy path, field validation, and pub/rootChainCode mismatch cases. Fix cbor-x missing-package CI error by exporting MPSUtil.cborEncode from sdk-lib-mpc (which already declares cbor-x) and using that in the sdk-core test instead of require('cbor-x') directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bbe7ebb commit 28cbfd6

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'assert';
22
import * as pgp from 'openpgp';
3+
import * as sjcl from '@bitgo/sjcl';
34
import { NonEmptyString } from 'io-ts-types';
45
import {
56
EddsaMPCv2KeyGenRound1Request,
@@ -43,6 +44,12 @@ import {
4344
import { BaseEddsaUtils } from './base';
4445
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
4546

47+
export interface EddsaMPCv2RecoveryKeyShares {
48+
userKeyShare: Buffer;
49+
backupKeyShare: Buffer;
50+
commonKeyChain: string;
51+
}
52+
4653
export class EddsaMPCv2Utils extends BaseEddsaUtils {
4754
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
4855
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
@@ -913,3 +920,67 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
913920
}
914921
// #endregion
915922
}
923+
924+
/**
925+
* Get EdDSA MPCv2 recovery key shares from encrypted reduced user and backup keys.
926+
*
927+
* The encrypted inputs are the `reducedEncryptedPrv` values stored on EdDSA MPCv2
928+
* key cards. They decrypt to CBOR-encoded reduced shares that contain the opaque
929+
* MPS signing key-share bytes plus the common public keychain material.
930+
*
931+
* @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key
932+
* @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key
933+
* @param walletPassphrase password for user and backup keys
934+
* @returns EdDSA MPCv2 recovery key shares and common keychain
935+
*/
936+
export async function getEddsaMPCv2RecoveryKeyShares(
937+
encryptedUserKey: string,
938+
encryptedBackupKey: string,
939+
walletPassphrase?: string
940+
): Promise<EddsaMPCv2RecoveryKeyShares> {
941+
const decodeKey = async (encryptedKey: string): Promise<MPSTypes.EddsaReducedKeyShare> => {
942+
if (isV2Envelope(encryptedKey)) {
943+
throw new Error(
944+
'EdDSA MPCv2 recovery: v2 (Argon2id) encrypted keycards are not yet supported in standalone recovery. ' +
945+
'Use the BitGo SDK with a BitGoBase instance to decrypt v2 keycards.'
946+
);
947+
}
948+
const decrypted = sjcl.decrypt(walletPassphrase ?? '', encryptedKey);
949+
let reduced: MPSTypes.EddsaReducedKeyShare;
950+
try {
951+
reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64'));
952+
} catch {
953+
throw new Error(
954+
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
955+
'This keycard may be public-only and cannot be used for recovery.'
956+
);
957+
}
958+
if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) {
959+
throw new Error(
960+
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
961+
'This keycard may be public-only and cannot be used for recovery.'
962+
);
963+
}
964+
return reduced;
965+
};
966+
967+
const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]);
968+
969+
const userPub = Buffer.from(userReduced.pub).toString('hex');
970+
const backupPub = Buffer.from(backupReduced.pub).toString('hex');
971+
if (userPub !== backupPub) {
972+
throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match');
973+
}
974+
975+
const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex');
976+
const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex');
977+
if (userChainCode !== backupChainCode) {
978+
throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match');
979+
}
980+
981+
return {
982+
userKeyShare: Buffer.from(userReduced.keyShare),
983+
backupKeyShare: Buffer.from(backupReduced.keyShare),
984+
commonKeyChain: userPub + userChainCode,
985+
};
986+
}

modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
CustomEddsaMPCv2SigningRound1GeneratingFunction,
1818
CustomEddsaMPCv2SigningRound2GeneratingFunction,
1919
CustomEddsaMPCv2SigningRound3GeneratingFunction,
20+
EDDSAUtils,
2021
EddsaMPCv2Utils,
2122
IBaseCoin,
2223
IWallet,
@@ -340,6 +341,58 @@ describe('EdDSA MPS DSG helper functions', async () => {
340341
});
341342
});
342343

344+
describe('getEddsaMPCv2RecoveryKeyShares', () => {
345+
const walletPassphrase = 'testPass';
346+
347+
const encryptReducedKeyShare = (keyShare: Buffer): string =>
348+
sjcl.encrypt(walletPassphrase, keyShare.toString('base64'));
349+
350+
it('should return recovery key shares from v1 SJCL-encrypted reduced keys', async () => {
351+
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
352+
const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
353+
const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare());
354+
355+
const result = await EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(
356+
encryptedUserKey,
357+
encryptedBackupKey,
358+
walletPassphrase
359+
);
360+
361+
assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare());
362+
assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare());
363+
assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain());
364+
});
365+
366+
it('should reject v2 (Argon2id) encrypted keycards with a clear error', async () => {
367+
const v2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AA==', iv: 'AA==', ct: 'AA==' });
368+
await assert.rejects(
369+
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(v2Envelope, v2Envelope, walletPassphrase),
370+
/v2 \(Argon2id\) encrypted keycards are not yet supported/
371+
);
372+
});
373+
374+
it('should reject a malformed keycard with a descriptive error', async () => {
375+
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
376+
const malformedKey = sjcl.encrypt(walletPassphrase, randomBytes(64).toString('base64'));
377+
const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
378+
await assert.rejects(
379+
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(malformedKey, goodKey, walletPassphrase),
380+
/keyShare, pub, and rootChainCode/
381+
);
382+
});
383+
384+
it('should reject reduced keys from different wallets', async () => {
385+
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
386+
const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
387+
const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
388+
const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare());
389+
await assert.rejects(
390+
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(encryptedUserKey, encryptedBackupKey, walletPassphrase),
391+
/pub keys do not match/
392+
);
393+
});
394+
});
395+
343396
describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
344397
let eddsaMPCv2Utils: EddsaMPCv2Utils;
345398
let mockBitgo: BitGoBase;

0 commit comments

Comments
 (0)