Skip to content
Draft
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
29 changes: 5 additions & 24 deletions modules/abstract-substrate/src/abstractSubstrateCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
VerifyTransactionOptions,
EDDSAUtils,
decryptKeychainPrivateKey,
isMpcV2Keycard as sharedIsMpcV2Keycard,
EddsaSigningMaterial,
} from '@bitgo/sdk-core';
import { CoinFamily, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
import { KeyPair as SubstrateKeyPair, Transaction } from './lib';
Expand All @@ -40,12 +42,6 @@ import { ApiPromise } from '@polkadot/api';

export const DEFAULT_SCAN_FACTOR = 20;

/**
* Discriminated union carrying keycard version and decrypted V1 user key (to avoid re-decryption).
* V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares.
*/
type SubstrateSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string };

export class SubstrateCoin extends BaseCoin {
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
readonly MAX_VALIDITY_DURATION = 2400;
Expand Down Expand Up @@ -520,23 +516,8 @@ export class SubstrateCoin extends BaseCoin {
return prv;
}

/**
* Probes the key format and returns a discriminated union so callers avoid a second decrypt.
* V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares.
*/
protected async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise<SubstrateSigningMaterial> {
const normalized = userKey.replace(/\s/g, '');
let isV1: boolean;
try {
isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, this.bitgo);
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
}
if (isV1) {
const userPrv = await this.decryptKeychain(normalized, walletPassphrase, 'user');
return { version: 'v1', userPrv };
}
return { version: 'v2', encryptedUserKey: normalized };
protected async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise<EddsaSigningMaterial> {
return sharedIsMpcV2Keycard(userKey, walletPassphrase, this.bitgo);
}

// Protected so tests can stub via instance overrides without adding new test dependencies.
Expand Down Expand Up @@ -569,7 +550,7 @@ export class SubstrateCoin extends BaseCoin {
*/
protected async addSubstrateRecoverySignature(
txBuilder: NativeTransferBuilder,
signingMaterial: SubstrateSigningMaterial,
signingMaterial: EddsaSigningMaterial,
backupKey: string,
walletPassphrase: string,
unsignedTransaction: Transaction,
Expand Down
63 changes: 18 additions & 45 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ import {
DeriveAddressOptions,
DeriveAddressResult,
UnexpectedAddressError,
EDDSAUtils,
isMpcV2Keycard,
signEddsaMpcV2RecoveryTx,
} from '@bitgo/sdk-core';
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
Expand Down Expand Up @@ -1700,7 +1701,7 @@ export class Sol extends BaseCoin {
const userKey = params.userKey?.replace(/\s/g, '') ?? '';

const isMpcV2 = params.walletPassphrase
? !(await EDDSAUtils.isEddsaMpcV1SigningMaterial(userKey, params.walletPassphrase, this.bitgo))
? (await isMpcV2Keycard(userKey, params.walletPassphrase, this.bitgo)).version === 'v2'
: false;

const index = params.index || 0;
Expand Down Expand Up @@ -1819,7 +1820,7 @@ export class Sol extends BaseCoin {
// Detect once at the top to avoid decrypting the keycard on every iteration of the scan loop.
// For unsigned sweep (no passphrase), isMpcV2 is false — cold MPCv2 is out of scope.
const isMpcV2 = params.walletPassphrase
? !(await EDDSAUtils.isEddsaMpcV1SigningMaterial(userKey, params.walletPassphrase, this.bitgo))
? (await isMpcV2Keycard(userKey, params.walletPassphrase, this.bitgo)).version === 'v2'
: false;

const baseAddressIndex = 0;
Expand Down Expand Up @@ -1963,25 +1964,15 @@ export class Sol extends BaseCoin {
);
txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signatureHex);
} else {
const { userKeyShare, backupKeyShare, commonKeyChain } =
await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey(
userKey,
backupKey,
params.walletPassphrase!,
this.bitgo
);

if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) {
throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
}

const signature = await EDDSAUtils.signRecoveryEddsaMPCv2(
unsignedTransaction.signablePayload,
currPath,
userKeyShare,
backupKeyShare,
commonKeyChain
);
const signature = await signEddsaMpcV2RecoveryTx({
message: unsignedTransaction.signablePayload,
userKey,
backupKey,
walletPassphrase: params.walletPassphrase!,
bitgoKey,
derivationPath: currPath,
bitgo: this.bitgo,
});
txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signature);
}
}
Expand All @@ -1991,29 +1982,11 @@ export class Sol extends BaseCoin {
backupKey?: string,
walletPassphrase?: string
): Promise<boolean> {
let isMpcV2 = false;
if (walletPassphrase) {
if (!userKey) {
throw new Error('missing userKey');
}
if (!backupKey) {
throw new Error('missing backupKey');
}
// Detect MPCv2 keycards — will throw if decryption fails (e.g., wrong password).
// MPCv1 keycards decrypt to JSON with uShare/bitgoYShare; MPCv2 keycards are CBOR.
try {
const isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(
userKey.replace(/\s/g, ''),
walletPassphrase,
this.bitgo
);
isMpcV2 = !isV1;
} catch (e) {
// Re-wrap decryption errors with context
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
}
}
return isMpcV2;
if (!walletPassphrase) return false;
if (!userKey) throw new Error('missing userKey');
if (!backupKey) throw new Error('missing backupKey');
const material = await isMpcV2Keycard(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo);
return material.version === 'v2';
}

async broadcastTransaction({
Expand Down
64 changes: 63 additions & 1 deletion modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'assert';
import * as pgp from 'openpgp';
import * as sjcl from '@bitgo/sjcl';
import * as pgp from 'openpgp';
import { NonEmptyString } from 'io-ts-types';
import {
EddsaMPCv2KeyGenRound1Request,
Expand Down Expand Up @@ -1208,8 +1208,70 @@ export async function signRecoveryEddsaMPCv2(
return signature;
}

/**
* Discriminated union representing EdDSA signing material detected from a keycard.
* v1: MPCv1 JSON keycard — userPrv is the decrypted plaintext.
* v2: MPCv2 CBOR keycard — encryptedUserKey is returned as-is for MPS DSG.
*/
export type EddsaSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string };

/**
* Detects MPCv1 vs MPCv2 keycard format and returns typed signing material.
* For v1: decrypts the userKey and returns the plaintext.
* For v2: returns the encrypted key as-is for use with signEddsaMpcV2RecoveryTx.
* Identical logic across all EdDSA coin recovery implementations.
*/
export async function isMpcV2Keycard(
userKey: string,
walletPassphrase: string,
bitgo?: BitGoBase
): Promise<EddsaSigningMaterial> {
const normalized = userKey.replace(/\s/g, '');
let isV1: boolean;
try {
isV1 = await isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, bitgo);
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`);
}
if (isV1) {
if (!bitgo) throw new Error('bitgo instance required for MPCv1 keycard decryption');
const userPrv = await bitgo.decrypt({ input: normalized, password: walletPassphrase });
return { version: 'v1', userPrv };
}
return { version: 'v2', encryptedUserKey: normalized };
}

/**
* Full MPCv2 recovery signing flow: decrypt key shares → validate commonKeyChain → MPS DSG.
* Returns raw 64-byte Ed25519 signature Buffer.
* Caller is responsible for any coin-specific envelope
* (e.g. 0x00 Substrate prefix, SUI flag+pubkey wrapper, or raw for NEAR/ADA/TON).
*/
export async function signEddsaMpcV2RecoveryTx(params: {
message: Buffer;
userKey: string;
backupKey: string;
walletPassphrase: string;
bitgoKey: string;
derivationPath: string;
bitgo?: BitGoBase;
}): Promise<Buffer> {
const { userKeyShare, backupKeyShare, commonKeyChain } = await getEddsaMpcV2RecoveryKeySharesFromReducedKey(
params.userKey,
params.backupKey,
params.walletPassphrase,
params.bitgo
);
if (commonKeyChain.toLowerCase() !== params.bitgoKey.toLowerCase()) {
throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
}
return signRecoveryEddsaMPCv2(params.message, params.derivationPath, userKeyShare, backupKeyShare, commonKeyChain);
}

export const EddsaMPCv2RecoveryFunctions = {
isEddsaMpcV1SigningMaterial,
getEddsaMpcV2RecoveryKeySharesFromReducedKey,
signRecoveryEddsaMPCv2,
isMpcV2Keycard,
signEddsaMpcV2RecoveryTx,
};
9 changes: 9 additions & 0 deletions modules/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2';
export { EcdsaMPCv2Utils };
import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
export { EddsaMPCv2Utils };
export type { EddsaSigningMaterial } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
export {
isMpcV2Keycard,
signEddsaMpcV2RecoveryTx,
isEddsaMpcV1SigningMaterial,
getEddsaMpcV2RecoveryKeySharesFromReducedKey,
signRecoveryEddsaMPCv2,
EddsaMPCv2RecoveryFunctions,
} from './bitgo/utils/tss/eddsa/eddsaMPCv2';
export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification';
export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types';
export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types';
Expand Down
134 changes: 134 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 @@ -27,6 +27,8 @@ import {
SignatureShareRecord,
SignatureShareType,
TxRequest,
isMpcV2Keycard,
signEddsaMpcV2RecoveryTx,
} from '../../../../../../src';
import {
getSignatureShareRoundOne,
Expand Down Expand Up @@ -1983,6 +1985,138 @@ describe('signRecoveryEddsaMPCv2', () => {
});
});

describe('isMpcV2Keycard', () => {
const PASSPHRASE = 'test-passphrase';

const MPCv1_MATERIAL = {
uShare: { i: 1, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' },
bitgoYShare: { i: 3, j: 1, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' },
backupYShare: { i: 2, j: 1, y: 'aabbcc', u: 'backup-u-value', chaincode: '00' },
};

it('returns { version: v1, userPrv } for MPCv1 JSON keycard', async () => {
const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL));
const mockBitgo = {
decrypt: sinon.stub().resolves(JSON.stringify(MPCv1_MATERIAL)),
} as unknown as BitGoBase;

const result = await isMpcV2Keycard(encrypted, PASSPHRASE, mockBitgo);

assert.strictEqual(result.version, 'v1');
assert.ok('userPrv' in result);
});

it('returns { version: v2, encryptedUserKey } for MPCv2 CBOR keycard', async () => {
const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64');
const encrypted = sjcl.encrypt(PASSPHRASE, MPCv2_CBOR_BYTES);
const normalizedKey = encrypted.replace(/\s/g, '');

const result = await isMpcV2Keycard(encrypted, PASSPHRASE);

assert.strictEqual(result.version, 'v2');
assert.ok('encryptedUserKey' in result);
assert.strictEqual((result as { version: 'v2'; encryptedUserKey: string }).encryptedUserKey, normalizedKey);
});

it('throws with context message when decryption fails', async () => {
const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL));
await assert.rejects(() => isMpcV2Keycard(encrypted, 'wrong-passphrase'), /Error decrypting user keychain/);
});

it('throws when bitgo instance is missing for v1 keycard decryption', async () => {
const encrypted = sjcl.encrypt(PASSPHRASE, JSON.stringify(MPCv1_MATERIAL));
await assert.rejects(
() => isMpcV2Keycard(encrypted, PASSPHRASE),
/bitgo instance required for MPCv1 keycard decryption/
);
});
});

describe('signEddsaMpcV2RecoveryTx', () => {
const derivationPath = 'm/0/0';
const walletPassphrase = 'testPass';

const makeDecryptBitgo = (userKeyBase64: string, backupKeyBase64: string): BitGoBase =>
({
decrypt: sinon.stub().onFirstCall().resolves(userKeyBase64).onSecondCall().resolves(backupKeyBase64),
} as unknown as BitGoBase);

it('returns 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 mockBitgo = makeDecryptBitgo(
userDkg.getReducedKeyShare().toString('base64'),
backupDkg.getReducedKeyShare().toString('base64')
);

const result = await signEddsaMpcV2RecoveryTx({
message,
userKey: 'encrypted-user-key',
backupKey: 'encrypted-backup-key',
walletPassphrase,
bitgoKey: commonKeyChain,
derivationPath,
bitgo: mockBitgo,
});

assert.strictEqual(result.length, 64);
const mpc = await getInitializedMpcInstance();
const derivedKeychain = mpc.deriveUnhardened(commonKeyChain, derivationPath);
const publicKeyBytes = Buffer.from(derivedKeychain.slice(0, 64), 'hex');
const ok = ed25519.verify(new Uint8Array(result), new Uint8Array(message), new Uint8Array(publicKeyBytes));
assert.strictEqual(ok, true);
});

it('throws when commonKeyChain does not match bitgoKey', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const message = Buffer.from('deadbeef', 'hex');
const mockBitgo = makeDecryptBitgo(
userDkg.getReducedKeyShare().toString('base64'),
backupDkg.getReducedKeyShare().toString('base64')
);

await assert.rejects(
() =>
signEddsaMpcV2RecoveryTx({
message,
userKey: 'encrypted-user-key',
backupKey: 'encrypted-backup-key',
walletPassphrase,
bitgoKey: 'bbbb'.repeat(32),
derivationPath,
bitgo: mockBitgo,
}),
/commonKeyChain from keycard does not match bitgoKey/
);
});

it('passes userKey, backupKey, passphrase, and bitgo to getEddsaMpcV2RecoveryKeySharesFromReducedKey', async () => {
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const message = Buffer.from('deadbeef', 'hex');
const decryptStub = sinon
.stub()
.onFirstCall()
.resolves(userDkg.getReducedKeyShare().toString('base64'))
.onSecondCall()
.resolves(backupDkg.getReducedKeyShare().toString('base64'));
const mockBitgo = { decrypt: decryptStub } as unknown as BitGoBase;

await signEddsaMpcV2RecoveryTx({
message,
userKey: 'u-key',
backupKey: 'b-key',
walletPassphrase,
bitgoKey: userDkg.getCommonKeychain(),
derivationPath,
bitgo: mockBitgo,
});

sinon.assert.calledWith(decryptStub.firstCall, { input: 'u-key', password: walletPassphrase });
sinon.assert.calledWith(decryptStub.secondCall, { input: 'b-key', password: walletPassphrase });
});
});

describe('EddsaMPCv2Utils.createKeychainsWithExternalSigner', function () {
let utils: EddsaMPCv2Utils;
let callbacks: EddsaMPCv2KeyGenCallbacks;
Expand Down