Skip to content

Commit 3e562a4

Browse files
committed
refactor(abstract-substrate): extract MPCv2 helpers into SubstrateCoin
Add protected isMpcv2SigningMaterial() and addSubstrateRecoverySignature() to SubstrateCoin so DOT and POLYX can call shared methods rather than duplicating ~40 lines of identical MPCv2 signing logic. Refactors recover() to delegate to these helpers; MPCv1 path unchanged. Ticket: WCI-1239
1 parent 25f6c27 commit 3e562a4

2 files changed

Lines changed: 288 additions & 34 deletions

File tree

modules/abstract-substrate/src/abstractSubstrateCoin.ts

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
UnexpectedAddressError,
2626
verifyEddsaTssWalletAddress,
2727
VerifyTransactionOptions,
28+
EDDSAUtils,
29+
decryptKeychainPrivateKey,
2830
} from '@bitgo/sdk-core';
2931
import { CoinFamily, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
3032
import { KeyPair as SubstrateKeyPair, Transaction } from './lib';
@@ -38,6 +40,12 @@ import { ApiPromise } from '@polkadot/api';
3840

3941
export const DEFAULT_SCAN_FACTOR = 20;
4042

43+
/**
44+
* Discriminated union carrying keycard version and decrypted V1 user key (to avoid re-decryption).
45+
* V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares.
46+
*/
47+
type SubstrateSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string };
48+
4149
export class SubstrateCoin extends BaseCoin {
4250
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
4351
readonly MAX_VALIDITY_DURATION = 2400;
@@ -356,42 +364,17 @@ export class SubstrateCoin extends BaseCoin {
356364
throw new Error('missing wallet passphrase');
357365
}
358366

359-
const userKey = params.userKey.replace(/\s/g, '');
360-
const backupKey = params.backupKey.replace(/\s/g, '');
361-
362-
// Decrypt private keys from KeyCard values
363-
let userPrv;
364-
try {
365-
userPrv = await this.bitgo.decrypt({
366-
input: userKey,
367-
password: params.walletPassphrase,
368-
});
369-
} catch (e) {
370-
throw new Error(`Error decrypting user keychain: ${e.message}`);
371-
}
372-
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;
373-
374-
let backupPrv;
375-
try {
376-
backupPrv = await this.bitgo.decrypt({
377-
input: backupKey,
378-
password: params.walletPassphrase,
379-
});
380-
} catch (e) {
381-
throw new Error(`Error decrypting backup keychain: ${e.message}`);
382-
}
383-
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
384-
385-
// add signature
386-
const signatureHex = await EDDSAMethods.getTSSSignature(
387-
userSigningMaterial,
388-
backupSigningMaterial,
367+
const signingMaterial = await this.isMpcV2Keycard(params.userKey!, params.walletPassphrase!);
368+
await this.addSubstrateRecoverySignature(
369+
txBuilder,
370+
signingMaterial,
371+
params.backupKey!.replace(/\s/g, ''),
372+
params.walletPassphrase!,
373+
unsignedTransaction,
389374
currPath,
390-
unsignedTransaction
375+
bitgoKey,
376+
accountId
391377
);
392-
393-
const substrateKeyPair = new SubstrateKeyPair({ pub: accountId });
394-
txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex);
395378
const signedTransaction = await txBuilder.build();
396379
serializedTx = signedTransaction.toBroadcastFormat();
397380
} else {
@@ -526,6 +509,110 @@ export class SubstrateCoin extends BaseCoin {
526509
return { transactions: consolidationTransactions, lastScanIndex };
527510
}
528511

512+
/**
513+
* Decrypts an encrypted keychain value, wrapping errors with a descriptive message.
514+
*/
515+
private async decryptKeychain(encryptedKey: string, passphrase: string, label: string): Promise<string> {
516+
const prv = await decryptKeychainPrivateKey(this.bitgo, { encryptedPrv: encryptedKey }, passphrase);
517+
if (!prv) {
518+
throw new Error(`Error decrypting ${label} keychain: invalid password or corrupted key`);
519+
}
520+
return prv;
521+
}
522+
523+
/**
524+
* Probes the key format and returns a discriminated union so callers avoid a second decrypt.
525+
* V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares.
526+
*/
527+
protected async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise<SubstrateSigningMaterial> {
528+
const normalized = userKey.replace(/\s/g, '');
529+
let isV1: boolean;
530+
try {
531+
isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, this.bitgo);
532+
} catch (e) {
533+
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
534+
}
535+
if (isV1) {
536+
const userPrv = await this.decryptKeychain(normalized, walletPassphrase, 'user');
537+
return { version: 'v1', userPrv };
538+
}
539+
return { version: 'v2', encryptedUserKey: normalized };
540+
}
541+
542+
// Protected so tests can stub via instance overrides without adding new test dependencies.
543+
protected async getEddsaMpcV2RecoveryKeyShares(
544+
encryptedUserKey: string,
545+
encryptedBackupKey: string,
546+
walletPassphrase: string
547+
): ReturnType<typeof EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey> {
548+
return EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
549+
encryptedUserKey,
550+
encryptedBackupKey,
551+
walletPassphrase,
552+
this.bitgo
553+
);
554+
}
555+
556+
// Protected so tests can stub via instance overrides without adding new test dependencies.
557+
protected async signEddsaMpcV2Recovery(
558+
signablePayload: Buffer,
559+
currPath: string,
560+
...args: Parameters<typeof EDDSAUtils.signRecoveryEddsaMPCv2> extends [Buffer, string, ...infer R] ? R : never
561+
): Promise<Buffer> {
562+
return EDDSAUtils.signRecoveryEddsaMPCv2(signablePayload, currPath, ...args);
563+
}
564+
565+
/**
566+
* Adds an MPCv1 or MPCv2 signature to a Substrate transaction builder.
567+
* MPCv2 signatures are prefixed with ED25519_MULTI_SIGNATURE_PREFIX (Ed25519 discriminant
568+
* in the Substrate MultiSignature enum).
569+
*/
570+
protected async addSubstrateRecoverySignature(
571+
txBuilder: NativeTransferBuilder,
572+
signingMaterial: SubstrateSigningMaterial,
573+
backupKey: string,
574+
walletPassphrase: string,
575+
unsignedTransaction: Transaction,
576+
currPath: string,
577+
bitgoKey: string,
578+
accountId: string
579+
): Promise<void> {
580+
const ED25519_MULTI_SIGNATURE_PREFIX = 0x00;
581+
const substrateKeyPair = new SubstrateKeyPair({ pub: accountId });
582+
583+
if (signingMaterial.version === 'v2') {
584+
const { userKeyShare, backupKeyShare, commonKeyChain } = await this.getEddsaMpcV2RecoveryKeyShares(
585+
signingMaterial.encryptedUserKey,
586+
backupKey,
587+
walletPassphrase
588+
);
589+
if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) {
590+
throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
591+
}
592+
const rawSig = await this.signEddsaMpcV2Recovery(
593+
unsignedTransaction.signablePayload,
594+
currPath,
595+
userKeyShare,
596+
backupKeyShare,
597+
commonKeyChain
598+
);
599+
const substrateSig = Buffer.concat([Buffer.from([ED25519_MULTI_SIGNATURE_PREFIX]), rawSig]);
600+
txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, substrateSig);
601+
} else {
602+
const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial;
603+
const backupPrv = await this.decryptKeychain(backupKey, walletPassphrase, 'backup');
604+
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
605+
606+
const signatureHex = await EDDSAMethods.getTSSSignature(
607+
userSigningMaterial,
608+
backupSigningMaterial,
609+
currPath,
610+
unsignedTransaction
611+
);
612+
txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex);
613+
}
614+
}
615+
529616
/** inherited doc */
530617
async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise<MPCTxs> {
531618
const req = params.signatureShares;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as sinon from 'sinon';
2+
import * as sdkCore from '@bitgo/sdk-core';
3+
// Cross-module relative import: tsx resolves TypeScript source directly in the monorepo,
4+
// avoiding a circular devDependency (sdk-coin-tao depends on abstract-substrate at runtime).
5+
import { Ttao } from '../../../sdk-coin-tao/src';
6+
7+
interface SubstrateCoinTestAccessor {
8+
isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise<{ version: 'v1' | 'v2' }>;
9+
addSubstrateRecoverySignature(
10+
txBuilder: unknown,
11+
signingMaterial: unknown,
12+
backupKey: string,
13+
walletPassphrase: string,
14+
unsignedTransaction: unknown,
15+
currPath: string,
16+
bitgoKey: string,
17+
accountId: string
18+
): Promise<void>;
19+
}
20+
21+
// 32-byte all-zeros hex: minimal valid Ed25519 public key for SubstrateKeyPair construction.
22+
const MOCK_ACCOUNT_ID = '0'.repeat(64);
23+
const MOCK_BITGO_KEY = 'aa'.repeat(32);
24+
const MOCK_UNSIGNED_TX = { signablePayload: Buffer.from('deadbeef', 'hex') };
25+
26+
describe('SubstrateCoin MPCv2 recovery helpers:', function () {
27+
const sandBox = sinon.createSandbox();
28+
// Bypass the constructor (which requires a full BitGoBase) — we only need the
29+
// prototype methods.
30+
const basecoin = Object.create(Ttao.prototype) as Ttao & SubstrateCoinTestAccessor;
31+
// isEddsaMpcV1SigningMaterial is gated behind non-configurable namespace getters that
32+
// sinon cannot replace. Provide bitgo.decrypt so it uses that path instead of sjcl,
33+
// then control V1 vs V2 detection by returning JSON (V1) or non-JSON (V2).
34+
let decryptStub: sinon.SinonStub;
35+
36+
beforeEach(function () {
37+
decryptStub = sinon.stub();
38+
(basecoin as unknown as { bitgo: unknown }).bitgo = { decrypt: decryptStub };
39+
});
40+
41+
afterEach(function () {
42+
sandBox.restore();
43+
});
44+
45+
describe('isMpcV2Keycard()', function () {
46+
it('should return version v2 for a CBOR (MPCv2) keycard', async function () {
47+
// Non-JSON decrypted value → V2 CBOR keycard
48+
decryptStub.resolves('not-json-cbor-bytes');
49+
const result = await basecoin.isMpcV2Keycard('encryptedKey', 'passphrase');
50+
result.version.should.equal('v2');
51+
});
52+
53+
it('should return version v1 for a JSON (MPCv1) keycard', async function () {
54+
// isMpcV2Keycard checks uShare.seed + bitgoYShare.u + backupYShare.u
55+
decryptStub.resolves(
56+
JSON.stringify({
57+
uShare: { seed: 'deadbeef' },
58+
bitgoYShare: { u: 'aabbcc' },
59+
backupYShare: { u: 'ddeeff' },
60+
})
61+
);
62+
const result = await basecoin.isMpcV2Keycard('encryptedKey', 'passphrase');
63+
result.version.should.equal('v1');
64+
});
65+
66+
it('should throw with a descriptive message when decryption fails', async function () {
67+
decryptStub.rejects(new Error('bad password'));
68+
await basecoin
69+
.isMpcV2Keycard('encryptedKey', 'wrong-passphrase')
70+
.should.be.rejectedWith(/Error decrypting user keychain/);
71+
});
72+
});
73+
74+
describe('addSubstrateRecoverySignature()', function () {
75+
// EDDSAUtils.* are exported via `export * as Namespace`, compiling to non-configurable
76+
// property getters — sinon cannot replace them. Instead, SubstrateCoin exposes
77+
// getEddsaMpcV2RecoveryKeyShares() and signEddsaMpcV2Recovery() as protected methods
78+
// so they can be stubbed on the instance (own property shadows the prototype).
79+
// EDDSAMethods.getTSSSignature is a regular writable property — sinon can stub it directly.
80+
let addSignatureStub: sinon.SinonStub;
81+
let coin: SubstrateCoinTestAccessor;
82+
83+
beforeEach(function () {
84+
addSignatureStub = sinon.stub();
85+
const instanceDecryptStub = sinon.stub();
86+
coin = Object.create(Ttao.prototype) as Ttao & SubstrateCoinTestAccessor;
87+
(coin as unknown as { bitgo: unknown }).bitgo = { decrypt: instanceDecryptStub };
88+
});
89+
90+
it('should prepend ED25519 0x00 discriminant on MPCv2 path', async function () {
91+
const rawSig = Buffer.alloc(64, 0xab);
92+
sinon.stub(coin as unknown, 'getEddsaMpcV2RecoveryKeyShares').resolves({
93+
userKeyShare: 'ks1',
94+
backupKeyShare: 'ks2',
95+
commonKeyChain: MOCK_BITGO_KEY,
96+
});
97+
sinon.stub(coin as unknown, 'signEddsaMpcV2Recovery').resolves(rawSig);
98+
99+
await coin.addSubstrateRecoverySignature(
100+
{ addSignature: addSignatureStub },
101+
{ version: 'v2', encryptedUserKey: 'encKey' },
102+
'encBackupKey',
103+
'passphrase',
104+
MOCK_UNSIGNED_TX,
105+
'm/0',
106+
MOCK_BITGO_KEY,
107+
MOCK_ACCOUNT_ID
108+
);
109+
110+
addSignatureStub.calledOnce.should.be.true();
111+
const sig: Buffer = addSignatureStub.firstCall.args[1];
112+
sig[0].should.equal(0x00);
113+
sig.slice(1).should.deepEqual(rawSig);
114+
});
115+
116+
it('should throw when commonKeyChain does not match bitgoKey on MPCv2 path', async function () {
117+
sinon.stub(coin as unknown, 'getEddsaMpcV2RecoveryKeyShares').resolves({
118+
userKeyShare: 'ks1',
119+
backupKeyShare: 'ks2',
120+
commonKeyChain: 'mismatch',
121+
});
122+
123+
await coin
124+
.addSubstrateRecoverySignature(
125+
{ addSignature: addSignatureStub },
126+
{ version: 'v2', encryptedUserKey: 'encKey' },
127+
'encBackupKey',
128+
'passphrase',
129+
MOCK_UNSIGNED_TX,
130+
'm/0',
131+
MOCK_BITGO_KEY,
132+
MOCK_ACCOUNT_ID
133+
)
134+
.should.be.rejectedWith(/commonKeyChain from keycard does not match bitgoKey/);
135+
});
136+
137+
it('should call getTSSSignature and pass result to addSignature on MPCv1 path', async function () {
138+
const mockSig = 'ff'.repeat(64);
139+
sandBox.stub(sdkCore.EDDSAMethods, 'getTSSSignature').resolves(mockSig);
140+
// decryptKeychain calls decryptKeychainPrivateKey → bitgo.decrypt for the backup key
141+
(coin as unknown as { bitgo: { decrypt: sinon.SinonStub } }).bitgo.decrypt.resolves(
142+
JSON.stringify({ yShares: {} })
143+
);
144+
145+
const userPrv = JSON.stringify({
146+
uShare: { seed: 'deadbeef' },
147+
bitgoYShare: { u: 'aabbcc' },
148+
backupYShare: { u: 'ddeeff' },
149+
});
150+
151+
await coin.addSubstrateRecoverySignature(
152+
{ addSignature: addSignatureStub },
153+
{ version: 'v1', userPrv },
154+
'encBackupKey',
155+
'passphrase',
156+
MOCK_UNSIGNED_TX,
157+
'm/0',
158+
MOCK_BITGO_KEY,
159+
MOCK_ACCOUNT_ID
160+
);
161+
162+
(sdkCore.EDDSAMethods.getTSSSignature as sinon.SinonStub).calledOnce.should.be.true();
163+
addSignatureStub.calledOnce.should.be.true();
164+
addSignatureStub.firstCall.args[1].should.equal(mockSig);
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)