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,
};
}
94 changes: 94 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,99 @@ describe('EdDSA MPS DSG helper functions', async () => {
});
});

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

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

const makeReducedKeyShare = (keyShare: number[], pub: number[], rootChainCode: number[]): string =>
encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, pub, rootChainCode }));

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 keycard missing keyShare', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const pub = Array.from(randomBytes(32));
const rootChainCode = Array.from(randomBytes(32));
const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ pub, rootChainCode }));
const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase),
/keyShare, pub, and rootChainCode/
);
});

it('should reject a keycard missing pub', async () => {
const keyShare = Array.from(randomBytes(64));
const rootChainCode = Array.from(randomBytes(32));
const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, rootChainCode }));
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase),
/keyShare, pub, and rootChainCode/
);
});

it('should reject a keycard missing rootChainCode', async () => {
const keyShare = Array.from(randomBytes(64));
const pub = Array.from(randomBytes(32));
const badKey = encryptReducedKeyShare(MPSUtil.cborEncode({ keyShare, pub }));
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const goodKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(badKey, goodKey, walletPassphrase),
/keyShare, pub, and rootChainCode/
);
});

it('should reject reduced keys with mismatched pub', 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/
);
});

it('should reject reduced keys with mismatched rootChainCode', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const userReduced = MPSTypes.getDecodedReducedKeyShare(userDkg.getReducedKeyShare());
// Same pub, different rootChainCode
const backupKey = makeReducedKeyShare(Array.from(randomBytes(64)), userReduced.pub, Array.from(randomBytes(32)));
const userKey = encryptReducedKeyShare(userDkg.getReducedKeyShare());
await assert.rejects(
EDDSAUtils.getEddsaMPCv2RecoveryKeyShares(userKey, backupKey, walletPassphrase),
/rootChainCodes do not match/
);
});
});

describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'crypto';
import assert from 'assert';
import { encode } from 'cbor-x';
import { x25519 } from '@noble/curves/ed25519';
import { DKG } from './dkg';
import { DSG } from './dsg';
Expand Down Expand Up @@ -120,3 +121,8 @@ export function executeTillRound(
assert(party1Dsg.getSignature().toString('hex') === party2Dsg.getSignature().toString('hex'));
return party1Dsg.getSignature();
}

/** CBOR-encodes an arbitrary object. Exposed so consumers don't need a direct cbor-x dependency. */
export function cborEncode(value: unknown): Buffer {
return Buffer.from(encode(value));
}
Loading