Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'assert';
import * as pgp from 'openpgp';
import * as sjcl from '@bitgo/sjcl';
import { NonEmptyString } from 'io-ts-types';
import {
EddsaMPCv2KeyGenRound1Request,
Expand Down Expand Up @@ -43,6 +44,12 @@ import {
import { BaseEddsaUtils } from './base';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';

export interface EddsaMPCv2RecoveryKeyShares {
userKeyShare: Buffer;
backupKeyShare: Buffer;
commonKeyChain: string;
}

export class EddsaMPCv2Utils extends BaseEddsaUtils {
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
Expand Down Expand Up @@ -913,3 +920,67 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
}
// #endregion
}

/**
* Get EdDSA MPCv2 recovery key shares from encrypted reduced user and backup keys.
*
* The encrypted inputs are the `reducedEncryptedPrv` values stored on EdDSA MPCv2
* key cards. They decrypt to CBOR-encoded reduced shares that contain the opaque
* MPS signing key-share bytes plus the common public keychain material.
*
* @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key
* @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key
* @param walletPassphrase password for user and backup keys
* @returns EdDSA MPCv2 recovery key shares and common keychain
*/
export async function getEddsaMPCv2RecoveryKeyShares(
encryptedUserKey: string,
encryptedBackupKey: string,
walletPassphrase?: string
): Promise<EddsaMPCv2RecoveryKeyShares> {
const decodeKey = async (encryptedKey: string): Promise<MPSTypes.EddsaReducedKeyShare> => {
if (isV2Envelope(encryptedKey)) {
throw new Error(
'EdDSA MPCv2 recovery: v2 (Argon2id) encrypted keycards are not yet supported in standalone recovery. ' +
'Use the BitGo SDK with a BitGoBase instance to decrypt v2 keycards.'
);
}
const decrypted = sjcl.decrypt(walletPassphrase ?? '', encryptedKey);
let reduced: MPSTypes.EddsaReducedKeyShare;
try {
reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64'));
} catch {
throw new Error(
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
'This keycard may be public-only and cannot be used for recovery.'
);
}
if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) {
throw new Error(
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
'This keycard may be public-only and cannot be used for recovery.'
);
}
return reduced;
};

const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]);

const userPub = Buffer.from(userReduced.pub).toString('hex');
const backupPub = Buffer.from(backupReduced.pub).toString('hex');
if (userPub !== backupPub) {
throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match');
}

const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex');
const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex');
if (userChainCode !== backupChainCode) {
throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match');
}

return {
userKeyShare: Buffer.from(userReduced.keyShare),
backupKeyShare: Buffer.from(backupReduced.keyShare),
commonKeyChain: userPub + userChainCode,
};
}
53 changes: 53 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
CustomEddsaMPCv2SigningRound1GeneratingFunction,
CustomEddsaMPCv2SigningRound2GeneratingFunction,
CustomEddsaMPCv2SigningRound3GeneratingFunction,
EDDSAUtils,
EddsaMPCv2Utils,
IBaseCoin,
IWallet,
Expand Down Expand Up @@ -340,6 +341,58 @@ describe('EdDSA MPS DSG helper functions', async () => {
});
});

describe('getEddsaMPCv2RecoveryKeyShares', () => {
const walletPassphrase = 'testPass';

const encryptReducedKeyShare = (keyShare: Buffer): string =>
sjcl.encrypt(walletPassphrase, keyShare.toString('base64'));

it('should return recovery key shares from v1 SJCL-encrypted reduced keys', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare());

const result = await EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(
encryptedUserKey,
encryptedBackupKey,
walletPassphrase
);

assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare());
assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare());
assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain());
});

it('should reject v2 (Argon2id) encrypted keycards with a clear error', async () => {
const v2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AA==', iv: 'AA==', ct: 'AA==' });
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(v2Envelope, v2Envelope, walletPassphrase),
/v2 \(Argon2id\) encrypted keycards are not yet supported/
);
});

it('should reject a malformed keycard with a descriptive error', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const malformedKey = sjcl.encrypt(walletPassphrase, randomBytes(64).toString('base64'));
const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(malformedKey, goodKey, walletPassphrase),
/keyShare, pub, and rootChainCode/
);
});

it('should reject reduced keys from different wallets', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const encryptedUserKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
const encryptedBackupKey = encryptReducedKeyShare(backupDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(encryptedUserKey, encryptedBackupKey, walletPassphrase),
/pub keys do not match/
);
});
});

describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
Expand Down
Loading