Skip to content
Merged
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
62 changes: 62 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 @@ -41,8 +42,10 @@ import {
isV2Envelope,
} from '../baseTypes';
import { EncryptionVersion } from '../../../../api';
import { BitGoBase } from '../../../bitgoBase';
import { BaseEddsaUtils } from './base';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
import { EddsaMPCv2RecoveryKeyShares } from './types';

export class EddsaMPCv2Utils extends BaseEddsaUtils {
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
Expand Down Expand Up @@ -923,3 +926,62 @@ 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 getEddsaMpcV2RecoveryKeySharesFromReducedKey(
encryptedUserKey: string,
encryptedBackupKey: string,
walletPassphrase?: string,
bitgo?: BitGoBase
): Promise<EddsaMPCv2RecoveryKeyShares> {
const decodeKey = async (encryptedKey: string): Promise<MPSTypes.EddsaReducedKeyShare> => {
const decrypted = bitgo
? await bitgo.decryptAsync({ input: encryptedKey, password: walletPassphrase })
: sjcl.decrypt(walletPassphrase, encryptedKey);
let reduced: MPSTypes.EddsaReducedKeyShare;
try {
reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64'));
} catch {
throw new Error(
Comment thread
Marzooqa marked this conversation as resolved.
'EdDSA MPCv2 recovery: unable to decode reduced key share from keycard material. The encrypted key may be corrupted, malformed, or not an EdDSA MPCv2 reduced key.'
);
}
if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) {
throw new Error(
'EdDSA MPCv2 recovery: reduced key share is missing keyShare, pub, or 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)]);
Comment thread
vibhavgo marked this conversation as resolved.

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,
};
}
6 changes: 6 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export type CreateEddsaKeychainParams = CreateKeychainParamsBase & {
backupKeyShare: EDDSA.KeyShare;
};

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

export type CreateEddsaBitGoKeychainParams = Omit<CreateEddsaKeychainParams, 'bitgoKeychain'>;

// For backward compatibility
Expand Down
101 changes: 101 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 @@ -341,6 +342,106 @@ describe('EdDSA MPS DSG helper functions', async () => {
});
});

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

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

it('should return recovery key shares from v1-encrypted reduced keys (no bitgo instance)', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const result = await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
encryptKey(userDkg.getReducedKeyShare()),
encryptKey(backupDkg.getReducedKeyShare()),
walletPassphrase
);

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

it('should route decryption through bitgo.decryptAsync when a bitgo instance is provided', async () => {
// sdk-core has no devDependency on sdk-api or argon2, so we cannot encrypt with a real v2 envelope here.
// The stub verifies that the function delegates to bitgo.decryptAsync (which supports v1 + v2 in
// production) rather than falling back to sjcl.decrypt.
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const userKeyBase64 = userDkg.getReducedKeyShare().toString('base64');
const backupKeyBase64 = backupDkg.getReducedKeyShare().toString('base64');

const mockBitgo = {
decryptAsync: sinon.stub().onFirstCall().resolves(userKeyBase64).onSecondCall().resolves(backupKeyBase64),
} as unknown as BitGoBase;

const result = await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
'encrypted-user-key',
'encrypted-backup-key',
walletPassphrase,
mockBitgo
);

sinon.assert.calledTwice(mockBitgo.decryptAsync as sinon.SinonStub);
assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare());
Comment thread
Marzooqa marked this conversation as resolved.
assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare());
assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain());
});

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'));
await assert.rejects(
EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
malformedKey,
encryptKey(userDkg.getReducedKeyShare()),
walletPassphrase
),
/unable to decode reduced key share/
);
});

it('should reject reduced keys from different wallets', async () => {
const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
await assert.rejects(
EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
encryptKey(userDkg.getReducedKeyShare()),
encryptKey(backupDkg.getReducedKeyShare()),
walletPassphrase
),
Comment thread
Marzooqa marked this conversation as resolved.
/pub keys do not match/
);
});

it('should reject reduced keys with matching pub but mismatched rootChainCodes', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const userReducedKeyShare = userDkg.getReducedKeyShare();
const backupReducedKeyShare = backupDkg.getReducedKeyShare();
const getDecodedReducedKeyShare = MPSTypes.getDecodedReducedKeyShare;
const decodeStub = sinon.stub(MPSTypes, 'getDecodedReducedKeyShare').callsFake((buf) => {
const reduced = getDecodedReducedKeyShare(buf);
if (Buffer.from(buf).equals(backupReducedKeyShare)) {
return {
...reduced,
rootChainCode: Array.from(randomBytes(32)),
};
}
return reduced;
});

try {
await assert.rejects(
EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
encryptKey(userReducedKeyShare),
encryptKey(backupReducedKeyShare),
walletPassphrase
),
/rootChainCodes do not match/
);
} finally {
decodeStub.restore();
}
});
});

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