Skip to content
Closed
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
121 changes: 120 additions & 1 deletion 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 All @@ -11,7 +12,8 @@ import {
MPCv2KeyGenStateEnum,
MPCv2PartyFromStringOrNumber,
} from '@bitgo/public-types';
import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc';
import * as nacl from 'tweetnacl';
import { deriveUnhardenedMps, EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
import { KeychainsTriplet } from '../../../baseCoin';
import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from '../../../keychain';
import { envRequiresBitgoPubGpgKeyConfig, isBitgoEddsaMpcv2PubKey } from '../../../tss/bitgoPubKeys';
Expand Down Expand Up @@ -41,9 +43,16 @@ import {
isV2Envelope,
} from '../baseTypes';
import { EncryptionVersion } from '../../../../api';
import { BitGoBase } from '../../../bitgoBase';
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 @@ -923,3 +932,113 @@ 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,
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(
'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,
};
}

/**
* Sign a message for recovery using EdDSA MPCv2 (MPS) with user and backup key shares.
*
* Runs the MPS DSG protocol locally to round 3, then verifies the resulting
* Ed25519 signature against the public key derived from the common keychain.
*
* @param message raw bytes to sign
* @param derivationPath BIP-32-style derivation path, e.g. `"m/0/0"`
* @param userKeyShare opaque MPS signing key-share bytes for the user party
* @param backupKeyShare opaque MPS signing key-share bytes for the backup party
* @param commonKeyChain 128-hex-char string: 32-byte pub + 32-byte rootChainCode
* @returns 64-byte Ed25519 signature Buffer
*/
export function signRecoveryMpcV2(
message: Buffer,
derivationPath: string,
userKeyShare: Buffer,
backupKeyShare: Buffer,
commonKeyChain: string
): Buffer {
const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER);
const backupDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BACKUP);

const signature = MPSUtil.executeTillRound(
3,
userDsg,
backupDsg,
userKeyShare,
backupKeyShare,
message,
derivationPath
) as Buffer;

// deriveUnhardenedMps returns 128 hex chars: first 64 are the 32-byte public key
const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath);
const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex');

const verified = nacl.sign.detached.verify(
new Uint8Array(message),
new Uint8Array(signature),
new Uint8Array(publicKeyBytes)
);
if (!verified) {
throw new Error('EdDSA MPCv2 recovery signature verification failed');
}

return signature;
}
144 changes: 143 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as assert from 'assert';
import * as sinon from 'sinon';
import * as pgp from 'openpgp';
import { randomBytes } from 'crypto';
import { EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
import { deriveUnhardenedMps, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc';
import * as nacl from 'tweetnacl';
import * as sjcl from '@bitgo/sjcl';
import {
EddsaMPCv2SignatureShareRound1Input,
Expand All @@ -17,6 +18,7 @@ import {
CustomEddsaMPCv2SigningRound1GeneratingFunction,
CustomEddsaMPCv2SigningRound2GeneratingFunction,
CustomEddsaMPCv2SigningRound3GeneratingFunction,
EDDSAUtils,
EddsaMPCv2Utils,
IBaseCoin,
IWallet,
Expand Down Expand Up @@ -341,6 +343,75 @@ 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.getEddsaMPCv2RecoveryKeyShares(
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.getEddsaMPCv2RecoveryKeyShares(
'encrypted-user-key',
'encrypted-backup-key',
walletPassphrase,
mockBitgo
);

sinon.assert.calledTwice(mockBitgo.decryptAsync as sinon.SinonStub);
assert.deepStrictEqual(result.userKeyShare, userDkg.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.getEddsaMPCv2RecoveryKeyShares(
malformedKey,
encryptKey(userDkg.getReducedKeyShare()),
walletPassphrase
),
/keyShare, pub, and rootChainCode/
);
});

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

describe('EddsaMPCv2Utils.createOfflineRound1Share', () => {
let eddsaMPCv2Utils: EddsaMPCv2Utils;
let mockBitgo: BitGoBase;
Expand Down Expand Up @@ -1666,3 +1737,74 @@ describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => {
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false);
});
});

describe('signRecoveryMpcV2', () => {
const derivationPath = 'm/0/0';

it('should return a 64-byte signature that verifies against the derived public key', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const message = Buffer.from('deadbeef', 'hex');
const commonKeyChain = userDkg.getCommonKeychain();

const signature = EDDSAUtils.signRecoveryMpcV2(
message,
derivationPath,
userDkg.getKeyShare(),
backupDkg.getKeyShare(),
commonKeyChain
);

assert.strictEqual(signature.length, 64);

const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath);
const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex');
const ok = nacl.sign.detached.verify(
new Uint8Array(message),
new Uint8Array(signature),
new Uint8Array(publicKeyBytes)
);
assert.strictEqual(ok, true);
});

it('should throw when the signed message is different from the verified message', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const message = Buffer.from('deadbeef', 'hex');
const commonKeyChain = userDkg.getCommonKeychain();

const signature = EDDSAUtils.signRecoveryMpcV2(
message,
derivationPath,
userDkg.getKeyShare(),
backupDkg.getKeyShare(),
commonKeyChain
);

const differentMessage = Buffer.from('cafebabe', 'hex');
const derivedKeychain = deriveUnhardenedMps(commonKeyChain, derivationPath);
const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex');
const ok = nacl.sign.detached.verify(
new Uint8Array(differentMessage),
new Uint8Array(signature),
new Uint8Array(publicKeyBytes)
);
assert.strictEqual(ok, false);
});

it('should throw when a wrong commonKeyChain is provided (verification mismatch)', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const [wrongDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const message = Buffer.from('deadbeef', 'hex');

assert.throws(
() =>
EDDSAUtils.signRecoveryMpcV2(
message,
derivationPath,
userDkg.getKeyShare(),
backupDkg.getKeyShare(),
wrongDkg.getCommonKeychain() // key chain from a different wallet
),
/EdDSA MPCv2 recovery signature verification failed/
);
});
});
Loading