Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,253 @@ describe('TSS EdDSA MPCv2 Utils:', async function () {
});
});

describe('External Signing Helpers', function () {
let userGpgKeyPair: openpgp.SerializedKeyPair<string> & { revocationCertificate: string };

before(async function () {
openpgp.config.rejectCurves = new Set();
userGpgKeyPair = await openpgp.generateKey({
userIDs: [{ name: 'user', email: 'user@test.com' }],
curve: 'ed25519',
format: 'armored',
});
});

describe('getSignableHexAndDerivationPath', function () {
it('should extract signableHex and derivationPath from a valid txRequest', function () {
const txRequest = {
transactions: [
{
unsignedTx: {
signableHex: 'deadbeef',
derivationPath: 'm/0/0',
serializedTxHex: 'aabbccdd',
},
},
],
};

const result = (tssUtils as any).getSignableHexAndDerivationPath(txRequest);
assert.equal(result.signableHex, 'deadbeef');
assert.equal(result.derivationPath, 'm/0/0');
});

it('should throw when transactions field is missing', function () {
const txRequest = { messages: [{ messageEncoded: 'test' }] };

assert.throws(
() => (tssUtils as any).getSignableHexAndDerivationPath(txRequest),
/createOfflineShare requires exactly one transaction in txRequest/
);
});

it('should throw when transactions array is empty', function () {
const txRequest = { transactions: [] };

assert.throws(
() => (tssUtils as any).getSignableHexAndDerivationPath(txRequest),
/createOfflineShare requires exactly one transaction in txRequest/
);
});

it('should throw when transactions array has more than one element', function () {
const txRequest = {
transactions: [
{ unsignedTx: { signableHex: 'aaa', derivationPath: 'm/0' } },
{ unsignedTx: { signableHex: 'bbb', derivationPath: 'm/1' } },
],
};

assert.throws(
() => (tssUtils as any).getSignableHexAndDerivationPath(txRequest),
/createOfflineShare requires exactly one transaction in txRequest/
);
});

it('should throw when signableHex is missing', function () {
const txRequest = { transactions: [{ unsignedTx: { derivationPath: 'm/0' } }] };

assert.throws(
() => (tssUtils as any).getSignableHexAndDerivationPath(txRequest),
/Missing signableHex in unsignedTx/
);
});

it('should throw when derivationPath is missing', function () {
const txRequest = { transactions: [{ unsignedTx: { signableHex: 'deadbeef' } }] };

assert.throws(
() => (tssUtils as any).getSignableHexAndDerivationPath(txRequest),
/Missing derivationPath in unsignedTx/
);
});
});

describe('getBitgoAndUserGpgKeys', function () {
it('should decrypt v1 SJCL envelope and return GPG keys', async function () {
const passphrase = 'test-password';
const adata = 'test-adata';

// Encrypt user GPG private key with v1 SJCL (no adata for simplicity in v1)
const encryptedUserGpgPrvKey = bitgo.encrypt({
input: userGpgKeyPair.privateKey,
password: passphrase,
});

const result = await (tssUtils as any).getBitgoAndUserGpgKeys(
bitgoGpgKeyPair.publicKey,
encryptedUserGpgPrvKey,
passphrase,
adata
);

assert.ok(result.bitgoGpgKey);
assert.ok(result.userGpgPrvKey);
assert.ok(result.userGpgPrvKey.constructor.name === 'PrivateKey');
});

it('should decrypt v2 Argon2 envelope and return GPG keys', async function () {
this.timeout(10000); // v2 decryption with Argon2 can be slow

const passphrase = 'test-password';
const adata = 'test-adata';
const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY';

// Encrypt user GPG private key with v2 Argon2
const encryptedUserGpgPrvKey = await bitgo.encryptAsync({
input: userGpgKeyPair.privateKey,
password: passphrase,
adata: `${domainSeparator}:${adata}`,
});

const result = await (tssUtils as any).getBitgoAndUserGpgKeys(
bitgoGpgKeyPair.publicKey,
encryptedUserGpgPrvKey,
passphrase,
adata
);

assert.ok(result.bitgoGpgKey);
assert.ok(result.userGpgPrvKey);
assert.ok(result.userGpgPrvKey.constructor.name === 'PrivateKey');
});

it('should throw when adata does not match (domain-separated format)', async function () {
const passphrase = 'test-password';
const correctAdata = 'correct-adata';
const wrongAdata = 'wrong-adata';
const domainSeparator = 'MPS_DSG_SIGNING_USER_GPG_KEY';

// Encrypt with correct adata
const encryptedUserGpgPrvKey = bitgo.encrypt({
input: userGpgKeyPair.privateKey,
password: passphrase,
adata: `${domainSeparator}:${correctAdata}`,
});

// Try to decrypt with wrong adata
await assert.rejects(
(tssUtils as any).getBitgoAndUserGpgKeys(
bitgoGpgKeyPair.publicKey,
encryptedUserGpgPrvKey,
passphrase,
wrongAdata
),
/Adata does not match cyphertext adata/
);
});

it('should throw when adata does not match (non-domain-separated format)', async function () {
const passphrase = 'test-password';
const correctAdata = 'correct-adata';
const wrongAdata = 'wrong-adata';

// Encrypt with correct adata (no domain separator)
const encryptedUserGpgPrvKey = bitgo.encrypt({
input: userGpgKeyPair.privateKey,
password: passphrase,
adata: correctAdata,
});

// Try to decrypt with wrong adata
await assert.rejects(
(tssUtils as any).getBitgoAndUserGpgKeys(
bitgoGpgKeyPair.publicKey,
encryptedUserGpgPrvKey,
passphrase,
wrongAdata
),
/Adata does not match cyphertext adata/
);
});

it('should throw when cyphertext is not valid JSON', async function () {
const passphrase = 'test-password';
const adata = 'test-adata';
const invalidCyphertext = 'not-valid-json';

await assert.rejects(
(tssUtils as any).getBitgoAndUserGpgKeys(bitgoGpgKeyPair.publicKey, invalidCyphertext, passphrase, adata),
/Failed to parse cyphertext to JSON/
);
});
});

describe('validateAdata', function () {
it('should pass when adata matches with domain separator', function () {
const adata = 'test-value';
const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE';
const cyphertext = bitgo.encrypt({
input: 'secret',
password: 'password',
adata: `${domainSeparator}:${adata}`,
});

assert.doesNotThrow(() => {
(tssUtils as any).validateAdata(adata, cyphertext, domainSeparator);
});
});

it('should pass when adata matches without domain separator', function () {
const adata = 'test-value';
const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE';
const cyphertext = bitgo.encrypt({
input: 'secret',
password: 'password',
adata: adata,
});

assert.doesNotThrow(() => {
(tssUtils as any).validateAdata(adata, cyphertext, domainSeparator);
});
});

it('should throw when adata does not match', function () {
const correctAdata = 'correct-value';
const wrongAdata = 'wrong-value';
const domainSeparator = 'MPS_DSG_SIGNING_ROUND1_STATE';
const cyphertext = bitgo.encrypt({
input: 'secret',
password: 'password',
adata: `${domainSeparator}:${correctAdata}`,
});

assert.throws(
() => (tssUtils as any).validateAdata(wrongAdata, cyphertext, domainSeparator),
/Adata does not match cyphertext adata/
);
});

it('should throw when cyphertext is not valid JSON', function () {
const invalidCyphertext = 'not-json';
assert.throws(
() => (tssUtils as any).validateAdata('adata', invalidCyphertext, 'separator'),
/Failed to parse cyphertext to JSON/
);
});
});
});

// ---------------------------------------------------------------------------
// Nock helpers
// ---------------------------------------------------------------------------
Expand Down
91 changes: 90 additions & 1 deletion modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,23 @@ import {
} from '../../../tss/eddsa/eddsaMPCv2';
import { generateGPGKeyPair } from '../../opengpgUtils';
import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2';
import { RequestType, SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, TxRequest } from '../baseTypes';
import {
RequestType,
SignatureShareType,
TSSParamsForMessageWithPrv,
TSSParamsWithPrv,
TxRequest,
isV2Envelope,
} from '../baseTypes';
import { BaseEddsaUtils } from './base';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';

export class EddsaMPCv2Utils extends BaseEddsaUtils {
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
// TODO(WCI-378): call the MPS_DSG_SIGNING_ROUND1/2_STATE in createOfflineRoundShare handlers
// private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
// private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE';

/** @inheritdoc */
async createKeychains(params: {
passphrase: string;
Expand Down Expand Up @@ -515,4 +527,81 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
}

// #endregion

// #region private utils
/**
* Get the signable hex and derivation path from the transaction request.
* @param {TxRequest} txRequest - the transaction request object
* @returns {{ signableHex: string; derivationPath: string }} - the signable hex and derivation path
*/
private getSignableHexAndDerivationPath(txRequest: TxRequest): {
signableHex: string;
derivationPath: string;
} {
assert(
txRequest.transactions && txRequest.transactions.length === 1,
'createOfflineShare requires exactly one transaction in txRequest'
);
const unsignedTx = txRequest.transactions[0].unsignedTx;
assert(unsignedTx.signableHex, 'Missing signableHex in unsignedTx');
assert(unsignedTx.derivationPath, 'Missing derivationPath in unsignedTx');
return { signableHex: unsignedTx.signableHex, derivationPath: unsignedTx.derivationPath };
}

/**
* Gets the BitGo and user GPG keys from the BitGo public GPG key and the encrypted user GPG private key.
* @param {string} bitgoPublicGpgKey - the BitGo public GPG key
* @param {string} encryptedUserGpgPrvKey - the encrypted user GPG private key
* @param {string} walletPassphrase - the wallet passphrase
* @param {string} adata - the additional data to validate the GPG keys
* @returns {Promise<{ bitgoGpgKey: pgp.Key; userGpgPrvKey: pgp.PrivateKey }>} - the BitGo and user GPG keys
*/
private async getBitgoAndUserGpgKeys(
bitgoPublicGpgKey: string,
encryptedUserGpgPrvKey: string,
walletPassphrase: string,
adata: string
): Promise<{
bitgoGpgKey: pgp.Key;
userGpgPrvKey: pgp.PrivateKey;
}> {
const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey });

let decryptedGpgPrvKey: string;
if (isV2Envelope(encryptedUserGpgPrvKey)) {
decryptedGpgPrvKey = await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
} else {
decryptedGpgPrvKey = this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase });
}

this.validateAdata(adata, encryptedUserGpgPrvKey, EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY);

const userGpgPrvKey = await pgp.readPrivateKey({ armoredKey: decryptedGpgPrvKey });
return { bitgoGpgKey, userGpgPrvKey };
}

/**
* Validates the adata and cyphertext.
* @param adata string
* @param cyphertext string
* @param roundDomainSeparator string
* @returns void
* @throws {Error} if the adata or cyphertext is invalid
*/
private validateAdata(adata: string, cyphertext: string, roundDomainSeparator: string): void {
let cypherJson;
try {
cypherJson = JSON.parse(cyphertext);
} catch (e) {
throw new Error('Failed to parse cyphertext to JSON, got: ' + cyphertext);
}
// using decodeURIComponent to handle special characters
if (
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(`${roundDomainSeparator}:${adata}`) &&
decodeURIComponent(cypherJson.adata) !== decodeURIComponent(adata)
) {
throw new Error('Adata does not match cyphertext adata');
}
}
// #endregion
}
Loading