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
132 changes: 131 additions & 1 deletion modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Ed25519BIP32,
EDDSAUtils,
Eddsa,
EncryptedSignerShareRecord,
EncryptedSignerShareType,
ExchangeCommitmentResponse,
InvalidTransactionError,
Expand Down Expand Up @@ -716,14 +717,143 @@ describe('TSS Utils:', async function () {
rShareEnvelope.should.have.property('hkdfSalt');

// 3. Round-trip: decrypt the v2 R-share
const { rShare } = await tssUtils.createRShareFromTxRequest({
const { rShare, encryptedUserToBitgoRShare } = await tssUtils.createRShareFromTxRequest({
txRequest: signingTxRequest,
walletPassphrase: passphrase,
encryptedUserToBitgoRShare: commitResult.encryptedUserToBitgoRShare,
bitgoToUserCommitment: {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: validBitgoToUserSignShare.rShares[1].commitment,
type: CommitmentType.COMMITMENT,
},
});

should.exist(rShare.xShare);
should.exist(rShare.rShares);
JSON.parse(encryptedUserToBitgoRShare.share).v.should.equal(2);
});
});

describe('EdDSA MPCv1 external signer signing state binding', function () {
const walletPassphrase = 'test-passphrase';
const prv = JSON.stringify(validUserSigningMaterial);
const signingTxRequest: TxRequest = {
txRequestId: 'mpcv1-binding-test',
transactions: [],
unsignedTxs: [{ serializedTxHex: solTssSerializedTxHex, signableHex: solTssSignableHex, derivationPath: 'm/0' }],
date: new Date().toISOString(),
intent: { intentType: 'payment' },
latest: true,
state: 'pendingUserSignature',
walletType: 'hot',
walletId: 'walletId',
policiesChecked: true,
version: 1,
userId: 'userId',
};
const bitgoToUserCommitment: CommitmentShareRecord = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: validBitgoToUserSignShare.rShares[1].commitment,
type: CommitmentType.COMMITMENT,
};
const bitgoToUserRShare: SignatureShareRecord = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: validBitgoToUserSignShare.rShares[1].r + validBitgoToUserSignShare.rShares[1].R,
};

async function createRShare(txRequest: TxRequest = signingTxRequest) {
const { encryptedUserToBitgoRShare } = await tssUtils.createCommitmentShareFromTxRequest({
txRequest: signingTxRequest,
prv,
walletPassphrase,
bitgoGpgPubKey: bitgoGpgKey.publicKey,
});
return tssUtils.createRShareFromTxRequest({
txRequest,
walletPassphrase,
encryptedUserToBitgoRShare,
bitgoToUserCommitment,
});
}

function createGShare(
encryptedUserToBitgoRShare: EncryptedSignerShareRecord,
overrides: { txRequest?: TxRequest; bitgoToUserRShare?: SignatureShareRecord } = {}
) {
return tssUtils.createGShareFromTxRequest({
txRequest: overrides.txRequest ?? signingTxRequest,
prv,
walletPassphrase,
bitgoToUserRShare: overrides.bitgoToUserRShare ?? bitgoToUserRShare,
encryptedUserToBitgoRShare,
bitgoToUserCommitment,
});
}

it('rejects commitment state replayed against a different txRequest', async function () {
await createRShare({ ...signingTxRequest, txRequestId: 'other-tx-request' }).should.be.rejectedWith(
'Adata does not match cyphertext adata'
);
await createRShare({
...signingTxRequest,
unsignedTxs: [{ ...signingTxRequest.unsignedTxs[0], signableHex: 'deadbeef' }],
}).should.be.rejectedWith('Adata does not match cyphertext adata');
});

it('requires the BitGo commitment before revealing the R share', async function () {
const { encryptedUserToBitgoRShare } = await tssUtils.createCommitmentShareFromTxRequest({
txRequest: signingTxRequest,
prv,
walletPassphrase,
bitgoGpgPubKey: bitgoGpgKey.publicKey,
});
await tssUtils
.createRShareFromTxRequest({
txRequest: signingTxRequest,
walletPassphrase,
encryptedUserToBitgoRShare,
bitgoToUserCommitment: { ...bitgoToUserCommitment, share: '' },
})
.should.be.rejectedWith('Missing BitGo to user commitment');
});

it('rejects G share generation against a different BitGo commitment or txRequest', async function () {
const { encryptedUserToBitgoRShare } = await createRShare();
await tssUtils
.createGShareFromTxRequest({
txRequest: signingTxRequest,
prv,
walletPassphrase,
bitgoToUserRShare,
encryptedUserToBitgoRShare,
bitgoToUserCommitment: { ...bitgoToUserCommitment, share: validUserSignShare.rShares[3].commitment },
})
.should.be.rejectedWith('Adata does not match cyphertext adata');
await createGShare(encryptedUserToBitgoRShare, {
txRequest: { ...signingTxRequest, walletId: 'otherWalletId' },
}).should.be.rejectedWith('Adata does not match cyphertext adata');
});

it('rejects G share generation from the commitment state directly', async function () {
const { encryptedUserToBitgoRShare } = await tssUtils.createCommitmentShareFromTxRequest({
txRequest: signingTxRequest,
prv,
walletPassphrase,
bitgoGpgPubKey: bitgoGpgKey.publicKey,
});
await createGShare(encryptedUserToBitgoRShare).should.be.rejectedWith('Adata does not match cyphertext adata');
});

it('allows an identical retry but rejects reusing the nonce with a different BitGo R share', async function () {
const { encryptedUserToBitgoRShare } = await createRShare();
const gShare = await createGShare(encryptedUserToBitgoRShare);
(await createGShare(encryptedUserToBitgoRShare)).should.deepEqual(gShare);
await createGShare(encryptedUserToBitgoRShare, {
bitgoToUserRShare: { ...bitgoToUserRShare, share: validBitgoToUserSignShare.rShares[1].r + gShare.R },
}).should.be.rejectedWith('User signing nonce has already been used');
});
});

Expand Down
1 change: 1 addition & 0 deletions modules/express/EXTERNAL_SIGNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This may be preferable for users who would like to apply their signature to thei
To set up BitGo Express with an external signer, a url to the external signer instance of BitGo Express must be provided using the `externalSignerUrl` configuration option.
The corresponding external signer instance of BitGo Express must have `signerMode` set, and `signerFileSystemPath` set to the path of a json containing the private key.
Note that if BitGo Express encounters an `ECONNREFUSED` error when requesting the external signer for a signature, it will retry the request for up to 15 seconds.
The external signer instance and the BitGo Express instance calling it should run the same BitGo Express version.

### Encrypted private key format

Expand Down
4 changes: 3 additions & 1 deletion modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1957,7 +1957,9 @@ export function createCustomCommitmentGenerator(
}

export function createCustomRShareGenerator(externalSignerUrl: string, coin: string): CustomRShareGeneratingFunction {
return async function (params): Promise<{ rShare: SignShare }> {
return async function (
params
): Promise<{ rShare: SignShare; encryptedUserToBitgoRShare: EncryptedSignerShareRecord }> {
const { body: rShare } = await retryPromise(
() => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/R`).type('json').send(params),
(err, tryCount) => {
Expand Down
15 changes: 4 additions & 11 deletions modules/express/src/typedRoutes/api/v2/generateShareTSS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export const GenerateShareTSSBody = {
txParams: Json,
})
),
/** Encrypted user-to-BitGo R share for EDDSA signing protocol */
/** Encrypted user-to-BitGo R share for EDDSA signing protocol (from the commitment phase for R, from the R phase for G) */
encryptedUserToBitgoRShare: optional(
t.partial({
/** Source participant identifier */
Expand All @@ -216,16 +216,7 @@ export const GenerateShareTSSBody = {
share: t.string,
})
),
/** User's R share sent to BitGo containing cryptographic commitments for EDDSA G share generation */
userToBitgoRShare: optional(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we are removing it? it would break MPCv1 wallets?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is replaced by encryptedUserToBitgoRShare but it's a breaking change

t.partial({
/** Participant index in the signing protocol */
i: t.number,
/** Mapping of participant indices to their R share structures (commitment, u, v, r, R values) */
rShares: t.record(t.string, RShareStructure),
})
),
/** BitGo's commitment share sent to user for EDDSA G share generation */
/** BitGo's commitment share sent to user for EDDSA R and G share generation */
bitgoToUserCommitment: optional(
t.partial({
/** Source participant identifier */
Expand Down Expand Up @@ -432,6 +423,8 @@ export const EddsaCommitmentShareResponse = t.type({
export const EddsaRShareResponse = t.type({
/** R share containing participant index and share commitments */
rShare: SignShare,
/** Encrypted R share bound to the BitGo commitment, required for G share generation */
encryptedUserToBitgoRShare: EncryptedSignerShareRecord,
});

/** EDDSA G share generation response with final signature share components */
Expand Down
49 changes: 26 additions & 23 deletions modules/express/test/unit/clientRoutes/externalSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,27 @@ describe('External signer', () => {
cResult.should.have.property('encryptedSignerShare');
cResult.should.have.property('encryptedUserToBitgoRShare');
const encryptedUserToBitgoRShare = cResult.encryptedUserToBitgoRShare;
const signingKey = MPC.keyDerive(
userSigningMaterial.uShare,
[userSigningMaterial.bitgoYShare, userSigningMaterial.backupYShare],
derivationPath
);

const bitgoCombine = MPC.keyCombine(bitgo.uShare, [signingKey.yShares[3], backup.yShares[3]]);
const bitgoSignShare = await MPC.signShare(Buffer.from(tMessage, 'hex'), bitgoCombine.pShare, [
bitgoCombine.jShares[1],
]);
const signatureShareRec = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: bitgoSignShare.rShares[1].r + bitgoSignShare.rShares[1].R,
};
const bitgoToUserCommitmentShare = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: bitgoSignShare.rShares[1].commitment,
type: 'commitment',
};
const reqR = {
bitgo: bgTest,
body: {
Expand All @@ -555,6 +576,7 @@ describe('External signer', () => {
],
},
encryptedUserToBitgoRShare,
bitgoToUserCommitment: bitgoToUserCommitmentShare,
},
decoded: {
coin: 'tsol',
Expand All @@ -572,6 +594,7 @@ describe('External signer', () => {
],
},
encryptedUserToBitgoRShare,
bitgoToUserCommitment: bitgoToUserCommitmentShare,
},
params: {
coin: 'tsol',
Expand All @@ -583,28 +606,8 @@ describe('External signer', () => {
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;
const rResult = await handleV2GenerateShareTSS(reqR);
rResult.should.have.property('rShare');
rResult.should.have.property('encryptedUserToBitgoRShare');

const signingKey = MPC.keyDerive(
userSigningMaterial.uShare,
[userSigningMaterial.bitgoYShare, userSigningMaterial.backupYShare],
derivationPath
);

const bitgoCombine = MPC.keyCombine(bitgo.uShare, [signingKey.yShares[3], backup.yShares[3]]);
const bitgoSignShare = await MPC.signShare(Buffer.from(tMessage, 'hex'), bitgoCombine.pShare, [
bitgoCombine.jShares[1],
]);
const signatureShareRec = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: bitgoSignShare.rShares[1].r + bitgoSignShare.rShares[1].R,
};
const bitgoToUserCommitmentShare = {
from: SignatureShareType.BITGO,
to: SignatureShareType.USER,
share: bitgoSignShare.rShares[1].commitment,
type: 'commitment',
};
const reqG = {
bitgo: bgTest,
body: {
Expand All @@ -620,7 +623,7 @@ describe('External signer', () => {
},
],
},
userToBitgoRShare: rResult.rShare,
encryptedUserToBitgoRShare: rResult.encryptedUserToBitgoRShare,
bitgoToUserRShare: signatureShareRec,
bitgoToUserCommitment: bitgoToUserCommitmentShare,
},
Expand All @@ -639,7 +642,7 @@ describe('External signer', () => {
},
],
},
userToBitgoRShare: rResult.rShare,
encryptedUserToBitgoRShare: rResult.encryptedUserToBitgoRShare,
bitgoToUserRShare: signatureShareRec,
bitgoToUserCommitment: bitgoToUserCommitmentShare,
},
Expand Down
36 changes: 23 additions & 13 deletions modules/express/test/unit/typedRoutes/generateShareTSS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ describe('GenerateShareTSS codec tests (External Signer Mode)', function () {
},
},
},
encryptedUserToBitgoRShare: {
from: 'user',
to: 'bitgo',
share: 'encrypted-r-share',
type: 'encryptedRShare',
},
};
const decoded = assertDecode(EddsaRShareResponse, validResponse);
assert.strictEqual(decoded.rShare.i, 1);
Expand Down Expand Up @@ -510,6 +516,12 @@ describe('GenerateShareTSS codec tests (External Signer Mode)', function () {
share: 'encrypted-r-share',
type: 'encryptedRShare',
},
bitgoToUserCommitment: {
from: 'bitgo',
to: 'user',
share: 'bitgo-commitment',
type: 'commitment',
},
};

const mockRShareResponse = {
Expand All @@ -527,6 +539,12 @@ describe('GenerateShareTSS codec tests (External Signer Mode)', function () {
},
},
},
encryptedUserToBitgoRShare: {
from: 'user',
to: 'bitgo',
share: 'encrypted-r-share-with-bitgo-commitment',
type: 'encryptedRShare',
},
};

// Mock filesystem and Eddsa utils
Expand Down Expand Up @@ -576,19 +594,11 @@ describe('GenerateShareTSS codec tests (External Signer Mode)', function () {
to: 'user',
share: 'bitgo-r-share',
},
userToBitgoRShare: {
i: 1,
rShares: {
2: {
i: 1,
j: 2,
u: 'u-value',
v: 'v-value',
r: 'r-value',
R: 'R-value',
commitment: 'commitment-value',
},
},
encryptedUserToBitgoRShare: {
from: 'user',
to: 'bitgo',
share: 'encrypted-r-share-with-bitgo-commitment',
type: 'encryptedRShare',
},
bitgoToUserCommitment: {
from: 'bitgo',
Expand Down
Loading
Loading