Skip to content

Commit de845fb

Browse files
WIP: draft for eddsa recvoery
1 parent e718dd8 commit de845fb

File tree

11 files changed

+1178
-783
lines changed

11 files changed

+1178
-783
lines changed

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
"@api-ts/typed-express-router": "^1.1.13",
2828
"@bitgo/sdk-core": "^35.3.0",
2929
"@bitgo-beta/sdk-lib-mpc": "8.2.1-alpha.291",
30+
"@bitgo/sdk-coin-ada": "^4.11.5",
31+
"@bitgo/sdk-coin-dot": "^4.3.5",
32+
"@bitgo/sdk-coin-sui": "^5.15.5",
33+
"@bitgo/sdk-coin-near": "^2.7.0",
34+
"@bitgo/sdk-coin-sol": "^4.12.5",
3035
"bitgo": "^48.1.0",
3136
"@bitgo/abstract-utxo": "^9.21.4",
3237
"@bitgo/statics": "^54.6.0",
@@ -44,6 +49,9 @@
4449
"winston": "^3.11.0",
4550
"zod": "^3.25.48"
4651
},
52+
"resolutions": {
53+
"@bitgo/sdk-core": "^35.3.0"
54+
},
4755
"devDependencies": {
4856
"@api-ts/openapi-generator": "^5.7.0",
4957
"@types/body-parser": "^1.17.0",

src/__tests__/masterBitgoExpress/generateWallet.test.ts

Whitespace-only changes.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { BitGoAPI } from '@bitgo/sdk-api';
2+
import { retrieveKmsPrvKey } from '../utils';
3+
import { EnclavedConfig } from '../../../shared/types';
4+
import { BaseCoin, Eddsa, YShare, SigningMaterial } from '@bitgo/sdk-core';
5+
import { Ed25519Bip32HdTree } from '@bitgo/sdk-lib-mpc';
6+
import { CoinFamily, coins } from '@bitgo/statics';
7+
8+
export type SignEddsaRecoveryTransactionParams = {
9+
sdk: BitGoAPI;
10+
request: {
11+
commonKeychain: string;
12+
signableHex: string;
13+
derivationPath: string;
14+
};
15+
cfg: EnclavedConfig;
16+
coin: BaseCoin;
17+
};
18+
19+
export async function signEddsaRecoveryTransaction({
20+
sdk,
21+
cfg,
22+
request,
23+
coin
24+
}: SignEddsaRecoveryTransactionParams) {
25+
let txBuilder;
26+
switch (coin.getFamily()) {
27+
case CoinFamily.NEAR: {
28+
const { TransactionBuilderFactory, register } = await import('@bitgo/sdk-coin-near');
29+
register(sdk);
30+
const staticCoin = coins.get(coin.getFamily());
31+
txBuilder = new TransactionBuilderFactory(staticCoin).from(request.signableHex);
32+
break;
33+
} case CoinFamily.SOL: {
34+
35+
}
36+
default: {
37+
throw new Error();
38+
}
39+
}
40+
// Get user and backup private keys
41+
const userPrv = await retrieveKmsPrvKey({
42+
pub: request.commonKeychain.toString(),
43+
source: 'user',
44+
cfg,
45+
options: { useLocalEncipherment: false },
46+
});
47+
48+
const backupPrv = await retrieveKmsPrvKey({
49+
pub: request.commonKeychain.toString(),
50+
source: 'backup',
51+
cfg,
52+
options: { useLocalEncipherment: false },
53+
});
54+
55+
if (!userPrv || !backupPrv) {
56+
throw new Error('Missing required private keys for recovery');
57+
}
58+
59+
const userSigningMaterial = JSON.parse(userPrv) as SigningMaterial;
60+
const backupSigningMaterial = JSON.parse(backupPrv) as SigningMaterial;
61+
62+
// Initialize MPC components
63+
const hdTree = await Ed25519Bip32HdTree.initialize();
64+
const MPC = await Eddsa.initialize(hdTree);
65+
66+
// Get the first transaction request
67+
const messageBuffer = Buffer.from(request.signableHex, 'hex');
68+
69+
// Derive user subkey
70+
const userSubkey = MPC.keyDerive(
71+
userSigningMaterial.uShare,
72+
[userSigningMaterial.bitgoYShare, userSigningMaterial.backupYShare as YShare],
73+
request.derivationPath,
74+
);
75+
76+
// Combine backup subkey
77+
const backupSubkey = MPC.keyCombine(backupSigningMaterial.uShare, [
78+
userSubkey.yShares[2],
79+
backupSigningMaterial.bitgoYShare,
80+
]);
81+
82+
// Create partial signatures
83+
const userSignShare = MPC.signShare(messageBuffer, userSubkey.pShare, [userSubkey.yShares[2]]);
84+
85+
const backupSignShare = MPC.signShare(messageBuffer, backupSubkey.pShare, [
86+
backupSubkey.jShares[1],
87+
]);
88+
89+
// Sign with both keys
90+
const userSign = MPC.sign(
91+
messageBuffer,
92+
userSignShare.xShare,
93+
[backupSignShare.rShares[1]],
94+
[userSigningMaterial.bitgoYShare],
95+
);
96+
97+
const backupSign = MPC.sign(
98+
messageBuffer,
99+
backupSignShare.xShare,
100+
[userSignShare.rShares[2]],
101+
[backupSigningMaterial.bitgoYShare],
102+
);
103+
104+
// Combine signatures
105+
const signature = MPC.signCombine([userSign, backupSign]);
106+
107+
return {
108+
txHex: Buffer.concat([
109+
Buffer.from(signature.R, 'hex'),
110+
Buffer.from(signature.sigma, 'hex'),
111+
]).toString('hex'),
112+
};
113+
}

src/api/master/clients/enclavedExpressClient.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
GShare,
1414
Keychain,
1515
ApiKeyShare,
16+
MPCSweepTxs,
17+
MPCTx,
18+
MPCTxs,
1619
} from '@bitgo/sdk-core';
1720
import { superagentRequestFactory, buildApiClient, ApiClient } from '@api-ts/superagent-wrapper';
1821
import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth';
@@ -173,6 +176,72 @@ interface SignMpcV2Round3Response {
173176
}
174177

175178
export class EnclavedExpressClient {
179+
async recoveryMPC(params: {
180+
unsignedSweepPrebuildTx: MPCTx | MPCSweepTxs | MPCTxs;
181+
userPub: string;
182+
backupPub: string;
183+
apiKey: string;
184+
coinSpecificParams: any;
185+
walletContractAddress: string;
186+
}): Promise<SignedTransaction> {
187+
if (!this.coin) {
188+
throw new Error('Coin must be specified to recover MPC');
189+
}
190+
191+
try {
192+
debugLogger('Recovering MPC for coin: %s', this.coin);
193+
194+
// Extract the required information from the sweep tx
195+
const tx = params.unsignedSweepPrebuildTx;
196+
const txRequest = {
197+
unsignedTx: '',
198+
signableHex: '',
199+
derivationPath: '',
200+
};
201+
202+
// Handle different tx formats
203+
if ('txRequests' in tx && Array.isArray(tx.txRequests)) {
204+
// MPCTxs format
205+
const firstRequest = tx.txRequests[0];
206+
if (firstRequest && firstRequest.transactions && firstRequest.transactions[0]) {
207+
const firstTx = firstRequest.transactions[0];
208+
txRequest.signableHex = firstTx.unsignedTx?.serializedTx || '';
209+
txRequest.derivationPath = firstTx.unsignedTx?.derivationPath || '';
210+
}
211+
} else if ('signableHex' in tx) {
212+
// MPCTx format
213+
txRequest.signableHex = tx.signableHex || '';
214+
txRequest.derivationPath = tx.derivationPath || '';
215+
} else if (Array.isArray(tx) && tx.length > 0) {
216+
// MPCSweepTxs format
217+
const firstTx = tx[0];
218+
if (firstTx && firstTx.transactions && firstTx.transactions[0]) {
219+
const transaction = firstTx.transactions[0];
220+
txRequest.signableHex = transaction.unsignedTx?.signableHex || '';
221+
txRequest.derivationPath = transaction.unsignedTx?.derivationPath || '';
222+
}
223+
}
224+
225+
let request = this.apiClient['v1.mpc.recovery'].post({
226+
coin: this.coin,
227+
commonKeychain: params.userPub,
228+
unsignedSweepPrebuildTx: {
229+
txRequests: [txRequest],
230+
},
231+
});
232+
233+
if (this.tlsMode === TlsMode.MTLS) {
234+
request = request.agent(this.createHttpsAgent());
235+
}
236+
237+
const response = await request.decodeExpecting(200);
238+
return response.body;
239+
} catch (error) {
240+
const err = error as Error;
241+
debugLogger('Failed to recover MPC: %s', err.message);
242+
throw err;
243+
}
244+
}
176245
private readonly baseUrl: string;
177246
private readonly enclavedExpressCert: string;
178247
private readonly tlsKey?: string;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { BaseCoin, MPCRecoveryOptions, MPCSweepTxs, MPCTx, MPCTxs } from 'bitgo';
2+
import { BitGoBase } from '@bitgo/sdk-core';
3+
import { CoinFamily } from '@bitgo/statics';
4+
import type { SolRecoveryOptions } from '@bitgo/sdk-coin-sol';
5+
import type { Sol, Tsol } from '@bitgo/sdk-coin-sol';
6+
import type { Near, TNear } from '@bitgo/sdk-coin-near';
7+
import type { Sui, Tsui } from '@bitgo/sdk-coin-sui';
8+
import type { Ada, Tada } from '@bitgo/sdk-coin-ada';
9+
import type { Dot, Tdot } from '@bitgo/sdk-coin-dot';
10+
11+
export type RecoverEddsaWalletsParams = MPCRecoveryOptions | SolRecoveryOptions;
12+
13+
export async function recoverEddsaWallets(
14+
sdk: BitGoBase,
15+
baseCoin: BaseCoin,
16+
params: RecoverEddsaWalletsParams,
17+
): Promise<MPCTx | MPCSweepTxs | MPCTxs> {
18+
const family = baseCoin.getFamily();
19+
20+
switch (family) {
21+
case CoinFamily.SOL: {
22+
const { register } = await import('@bitgo/sdk-coin-sol');
23+
register(sdk);
24+
const solCoin = baseCoin as unknown as Sol | Tsol;
25+
const solParams = params as SolRecoveryOptions;
26+
return await solCoin.recover(solParams);
27+
}
28+
case CoinFamily.NEAR: {
29+
const { register } = await import('@bitgo/sdk-coin-near');
30+
register(sdk);
31+
const nearCoin = baseCoin as unknown as Near | TNear;
32+
const nearParams: Parameters<Near['recover']>[0] = {
33+
userKey: params.bitgoKey,
34+
backupKey: params.bitgoKey,
35+
bitgoKey: params.bitgoKey,
36+
recoveryDestination: params.recoveryDestination,
37+
walletPassphrase: '',
38+
};
39+
return await nearCoin.recover(nearParams);
40+
}
41+
default: {
42+
const [{ register: registerSui }, { register: registerAda }, { register: registerDot }] =
43+
await Promise.all([
44+
import('@bitgo/sdk-coin-sui'),
45+
import('@bitgo/sdk-coin-ada'),
46+
import('@bitgo/sdk-coin-dot'),
47+
]);
48+
registerAda(sdk);
49+
registerSui(sdk);
50+
registerDot(sdk);
51+
const coin = baseCoin as unknown as Sui | Tsui | Ada | Tada | Dot | Tdot;
52+
return await coin.recover(params as MPCRecoveryOptions);
53+
}
54+
}
55+
}

src/api/master/handlers/recoveryWallet.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { BaseCoin, MethodNotImplementedError } from 'bitgo';
1+
import { BaseCoin, BitGoAPI, MethodNotImplementedError } from 'bitgo';
22

33
import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth';
44
import { AbstractUtxoCoin } from '@bitgo/abstract-utxo';
55

66
import {
7+
isEddsaCoin,
78
isEthLikeCoin,
89
isFormattedOfflineVaultTxInfo,
910
isUtxoCoin,
@@ -12,9 +13,10 @@ import {
1213
DEFAULT_MUSIG_ETH_GAS_PARAMS,
1314
getReplayProtectionOptions,
1415
} from '../../../shared/recoveryUtils';
15-
import { EnvironmentName } from '../../../shared/types/index';
1616
import { EnclavedExpressClient } from '../clients/enclavedExpressClient';
1717
import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec';
18+
import { recoverEddsaWallets } from './recoverEddsaWallets';
19+
import { EnvironmentName } from '../../../shared/types';
1820

1921
interface RecoveryParams {
2022
userKey: string;
@@ -64,6 +66,51 @@ async function handleEthLikeRecovery(
6466
}
6567
}
6668

69+
// function getKeyNonceFromParams(
70+
// coinSpecificParams: EnclavedRecoveryParams['coinSpecificParams'] | undefined,
71+
// ) {
72+
// // formatted as in WRW
73+
// if (!coinSpecificParams) return undefined;
74+
//
75+
// const { publicKeyNonce, secretKeyNonce } = coinSpecificParams;
76+
// if (!publicKeyNonce || !secretKeyNonce) return undefined;
77+
//
78+
// // coinSpecificParams is untyped so we need to cast the keys in order to avoid build errors.
79+
// return { publicKey: publicKeyNonce as string, secretKey: secretKeyNonce as string };
80+
// }
81+
82+
async function handleEddsaRecovery(
83+
bitgo: BitGoAPI,
84+
sdkCoin: BaseCoin,
85+
commonRecoveryParams: RecoveryParams,
86+
enclavedExpressClient: EnclavedExpressClient,
87+
params: EnclavedRecoveryParams,
88+
) {
89+
const { recoveryDestination, userKey } = commonRecoveryParams;
90+
try {
91+
const unsignedSweepPrebuildTx = await recoverEddsaWallets(bitgo, sdkCoin, {
92+
bitgoKey: userKey,
93+
recoveryDestination,
94+
apiKey: params.apiKey,
95+
});
96+
console.log('Unsigned sweep tx');
97+
console.log(JSON.stringify(unsignedSweepPrebuildTx, null, 2));
98+
99+
const fullSignedRecoveryTx = await enclavedExpressClient.recoveryMPC({
100+
userPub: params.userPub,
101+
backupPub: params.backupPub,
102+
apiKey: params.apiKey,
103+
unsignedSweepPrebuildTx,
104+
coinSpecificParams: params.coinSpecificParams,
105+
walletContractAddress: params.walletContractAddress,
106+
});
107+
108+
return fullSignedRecoveryTx;
109+
} catch (err) {
110+
throw err;
111+
}
112+
}
113+
67114
export type UtxoCoinSpecificRecoveryParams = Pick<
68115
Parameters<AbstractUtxoCoin['recover']>[0],
69116
| 'apiKey'
@@ -113,7 +160,10 @@ export async function handleRecoveryWalletOnPrem(
113160
recoveryDestinationAddress,
114161
coinSpecificParams,
115162
apiKey,
163+
isTssRecovery,
164+
commonKeychain,
116165
} = req.decoded;
166+
console.log(req.decoded);
117167

118168
//construct a common payload for the recovery that it's repeated in any kind of recovery
119169
const commonRecoveryParams: RecoveryParams = {
@@ -126,6 +176,33 @@ export async function handleRecoveryWalletOnPrem(
126176

127177
const sdkCoin = bitgo.coin(coin);
128178

179+
if (isTssRecovery) {
180+
if (!commonKeychain) {
181+
throw new Error('Common keychain is required for TSS recovery');
182+
}
183+
if (isEddsaCoin(sdkCoin)) {
184+
const tx = await handleEddsaRecovery(
185+
req.bitgo,
186+
sdkCoin,
187+
commonRecoveryParams,
188+
enclavedExpressClient,
189+
{
190+
userPub: commonKeychain,
191+
backupPub: commonKeychain,
192+
apiKey: '',
193+
walletContractAddress: '',
194+
unsignedSweepPrebuildTx: undefined,
195+
coinSpecificParams: undefined,
196+
},
197+
);
198+
return tx;
199+
} else {
200+
throw new MethodNotImplementedError(
201+
`TSS recovery is not implemented for coin: ${coin}. Supported coins are Eddsa coins.`,
202+
);
203+
}
204+
}
205+
129206
// Check if the public key is valid
130207
if (!sdkCoin.isValidPub(userPub)) {
131208
throw new Error('Invalid user public key format');

0 commit comments

Comments
 (0)