diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts index 0602b58e3b..67c11d9112 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts @@ -19,6 +19,7 @@ import { Ed25519BIP32, EDDSAUtils, Eddsa, + EncryptedSignerShareRecord, EncryptedSignerShareType, ExchangeCommitmentResponse, InvalidTransactionError, @@ -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'); }); }); diff --git a/modules/express/EXTERNAL_SIGNER.md b/modules/express/EXTERNAL_SIGNER.md index af2539e268..1b971bc831 100644 --- a/modules/express/EXTERNAL_SIGNER.md +++ b/modules/express/EXTERNAL_SIGNER.md @@ -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 diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 3afd7a5efa..ccf6b737ba 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -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) => { diff --git a/modules/express/src/typedRoutes/api/v2/generateShareTSS.ts b/modules/express/src/typedRoutes/api/v2/generateShareTSS.ts index 3e35233950..2688d9f6d3 100644 --- a/modules/express/src/typedRoutes/api/v2/generateShareTSS.ts +++ b/modules/express/src/typedRoutes/api/v2/generateShareTSS.ts @@ -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 */ @@ -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( - 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 */ @@ -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 */ diff --git a/modules/express/test/unit/clientRoutes/externalSign.ts b/modules/express/test/unit/clientRoutes/externalSign.ts index bdebf72471..5faac8dd89 100644 --- a/modules/express/test/unit/clientRoutes/externalSign.ts +++ b/modules/express/test/unit/clientRoutes/externalSign.ts @@ -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: { @@ -555,6 +576,7 @@ describe('External signer', () => { ], }, encryptedUserToBitgoRShare, + bitgoToUserCommitment: bitgoToUserCommitmentShare, }, decoded: { coin: 'tsol', @@ -572,6 +594,7 @@ describe('External signer', () => { ], }, encryptedUserToBitgoRShare, + bitgoToUserCommitment: bitgoToUserCommitmentShare, }, params: { coin: 'tsol', @@ -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: { @@ -620,7 +623,7 @@ describe('External signer', () => { }, ], }, - userToBitgoRShare: rResult.rShare, + encryptedUserToBitgoRShare: rResult.encryptedUserToBitgoRShare, bitgoToUserRShare: signatureShareRec, bitgoToUserCommitment: bitgoToUserCommitmentShare, }, @@ -639,7 +642,7 @@ describe('External signer', () => { }, ], }, - userToBitgoRShare: rResult.rShare, + encryptedUserToBitgoRShare: rResult.encryptedUserToBitgoRShare, bitgoToUserRShare: signatureShareRec, bitgoToUserCommitment: bitgoToUserCommitmentShare, }, diff --git a/modules/express/test/unit/typedRoutes/generateShareTSS.ts b/modules/express/test/unit/typedRoutes/generateShareTSS.ts index 98e7dd29d7..ec459440af 100644 --- a/modules/express/test/unit/typedRoutes/generateShareTSS.ts +++ b/modules/express/test/unit/typedRoutes/generateShareTSS.ts @@ -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); @@ -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 = { @@ -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 @@ -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', diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index ba91ab4e0b..2d8f7f226a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -359,13 +359,15 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil * @param {string} params.prv - user signing material * @param {string} [params.walletPassphrase] - wallet passphrase * @param {EncryptedSignerShareRecord} [params.encryptedUserToBitgoRShare] - encrypted user to bitgo R share generated in the commitment phase - * @returns {Promise<{ rShare: SignShare }>} - R Share to BitGo + * @param {CommitmentShareRecord} params.bitgoToUserCommitment - BitGo to User Commitment + * @returns {Promise<{ rShare: SignShare, encryptedUserToBitgoRShare: EncryptedSignerShareRecord }>} - R Share to BitGo and the encrypted R share bound to the BitGo commitment */ createRShareFromTxRequest(params: { txRequest: TxRequest; walletPassphrase: string; encryptedUserToBitgoRShare: EncryptedSignerShareRecord; - }): Promise<{ rShare: SignShare }> { + bitgoToUserCommitment: CommitmentShareRecord; + }): Promise<{ rShare: SignShare; encryptedUserToBitgoRShare: EncryptedSignerShareRecord }> { throw new Error('Method not implemented.'); } @@ -375,16 +377,18 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil * @param {Object} params - params object * @param {TxRequest} params.txRequest - transaction request with unsigned transaction * @param {string} params.prv - user signing material + * @param {string} params.walletPassphrase - wallet passphrase * @param {SignatureShareRecord} params.bitgoToUserRShare - BitGo to User R Share - * @param {SignShare} params.userToBitgoRShare - User to BitGo R Share + * @param {EncryptedSignerShareRecord} params.encryptedUserToBitgoRShare - encrypted user to bitgo R share generated in the R share phase * @param {CommitmentShareRecord} params.bitgoToUserCommitment - BitGo to User Commitment * @returns {Promise} - GShare from User to BitGo */ createGShareFromTxRequest(params: { txRequest: TxRequest; prv: string; + walletPassphrase: string; bitgoToUserRShare: SignatureShareRecord; - userToBitgoRShare: SignShare; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; bitgoToUserCommitment: CommitmentShareRecord; }): Promise { throw new Error('Method not implemented.'); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index df64526fa4..9504a77c08 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -190,15 +190,20 @@ export interface CustomCommitmentGeneratingFunction { } export interface CustomRShareGeneratingFunction { - (params: { txRequest: TxRequest; encryptedUserToBitgoRShare: EncryptedSignerShareRecord }): Promise<{ + (params: { + txRequest: TxRequest; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; + bitgoToUserCommitment: CommitmentShareRecord; + }): Promise<{ rShare: SignShare; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; }>; } export interface CustomGShareGeneratingFunction { (params: { txRequest: TxRequest; - userToBitgoRShare: SignShare; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; bitgoToUserRShare: SignatureShareRecord; bitgoToUserCommitment: CommitmentShareRecord; }): Promise; @@ -960,12 +965,14 @@ export interface ITssUtils { txRequest: TxRequest; walletPassphrase: string; encryptedUserToBitgoRShare: EncryptedSignerShareRecord; - }): Promise<{ rShare: SignShare }>; + bitgoToUserCommitment: CommitmentShareRecord; + }): Promise<{ rShare: SignShare; encryptedUserToBitgoRShare: EncryptedSignerShareRecord }>; createGShareFromTxRequest(params: { txRequest: TxRequest; prv: string; + walletPassphrase: string; bitgoToUserRShare: SignatureShareRecord; - userToBitgoRShare: SignShare; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; bitgoToUserCommitment: CommitmentShareRecord; }): Promise; prebuildTxWithIntent( diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index 94247da5ba..ebf0188f3e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -37,7 +37,8 @@ import { isV2Envelope, } from '../baseTypes'; import { InvalidTransactionError } from '../../../errors'; -import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types'; +import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, SignShareCodec, YShare } from './types'; +import { decodeWithCodec } from '../../codecs'; import baseTSSUtils from '../baseTSSUtils'; import { BaseEddsaUtils } from './base'; import { KeychainsTriplet, TransactionParams } from '../../../baseCoin'; @@ -55,6 +56,11 @@ import { resolveEffectiveTxParams } from '../recipientUtils'; */ export class EddsaUtils extends baseTSSUtils { + private static readonly MPCV1_SIGNING_COMMITMENT_STATE = 'MPCV1_SIGNING_COMMITMENT_STATE'; + private static readonly MPCV1_SIGNING_R_SHARE_STATE = 'MPCV1_SIGNING_R_SHARE_STATE'; + /** User signing nonce (R) -> the BitGo R share it was used with */ + private static readonly usedSigningNonces = new Map(); + async verifyWalletSignatures( userGpgPub: string, backupGpgPub: string, @@ -536,7 +542,6 @@ export class EddsaUtils extends baseTSSUtils { }> { const bitgoIndex = ShareKeyPosition.BITGO; const { txRequest, prv } = params; - const txRequestResolved: TxRequest = txRequest; const hdTree = await Ed25519Bip32HdTree.initialize(); const MPC = await Eddsa.initialize(hdTree); @@ -546,11 +551,7 @@ export class EddsaUtils extends baseTSSUtils { throw new Error('Invalid user key - missing backupYShare'); } - assert(txRequestResolved.transactions || txRequestResolved.unsignedTxs, 'Unable to find transactions in txRequest'); - const unsignedTx = - txRequestResolved.apiVersion === 'full' - ? txRequestResolved.transactions![0].unsignedTx - : txRequestResolved.unsignedTxs[0]; + const unsignedTx = this.getUnsignedTx(txRequest); const signingKey = MPC.keyDerive( userSigningMaterial.uShare, @@ -573,23 +574,12 @@ export class EddsaUtils extends baseTSSUtils { ); const encryptedSignerShare = this.createUserToBitgoEncryptedSignerShare(userToBitgoEncryptedSignerShare); - const stringifiedRShare = JSON.stringify(userSignShare); - let encryptedRShare: string; - if (params.encryptedPrv && isV2Envelope(params.encryptedPrv)) { - const session = await this.bitgo.createEncryptionSession(params.walletPassphrase); - try { - encryptedRShare = await session.encrypt(stringifiedRShare); - } finally { - session.destroy(); - } - } else { - encryptedRShare = await this.bitgo.encrypt({ - input: stringifiedRShare, - password: params.walletPassphrase, - encryptionVersion: 1, - }); - } - const encryptedUserToBitgoRShare = this.createUserToBitgoEncryptedRShare(encryptedRShare); + const encryptedUserToBitgoRShare = await this.encryptSignShare( + userSignShare, + params.walletPassphrase, + `${EddsaUtils.MPCV1_SIGNING_COMMITMENT_STATE}:${this.getSigningAdata(txRequest)}`, + params.encryptedPrv !== undefined && isV2Envelope(params.encryptedPrv) + ); return { userToBitgoCommitment, encryptedSignerShare, encryptedUserToBitgoRShare }; } @@ -598,30 +588,40 @@ export class EddsaUtils extends baseTSSUtils { txRequest: TxRequest; walletPassphrase: string; encryptedUserToBitgoRShare: EncryptedSignerShareRecord; - }): Promise<{ rShare: SignShare }> { - const { walletPassphrase, encryptedUserToBitgoRShare } = params; + bitgoToUserCommitment: CommitmentShareRecord; + }): Promise<{ rShare: SignShare; encryptedUserToBitgoRShare: EncryptedSignerShareRecord }> { + const { txRequest, walletPassphrase, encryptedUserToBitgoRShare, bitgoToUserCommitment } = params; + assert(bitgoToUserCommitment?.share, 'Missing BitGo to user commitment'); + const rShare = await this.decryptSignShare( + encryptedUserToBitgoRShare, + walletPassphrase, + this.getSigningAdata(txRequest), + EddsaUtils.MPCV1_SIGNING_COMMITMENT_STATE + ); - const decryptedRShare = await this.bitgo.decrypt({ - input: encryptedUserToBitgoRShare.share, - password: walletPassphrase, - }); - const rShare = JSON.parse(decryptedRShare); - assert(rShare.xShare, 'Unable to find xShare in decryptedRShare'); - assert(rShare.rShares, 'Unable to find rShares in decryptedRShare'); + // Commit-before-reveal: the G share may only be produced against the BitGo commitment presented before our R share is revealed + const encryptedRShareWithBitgoCommitment = await this.encryptSignShare( + rShare, + walletPassphrase, + `${EddsaUtils.MPCV1_SIGNING_R_SHARE_STATE}:${this.getSigningAdata(txRequest, bitgoToUserCommitment)}`, + isV2Envelope(encryptedUserToBitgoRShare.share) + ); - return { rShare }; + return { rShare, encryptedUserToBitgoRShare: encryptedRShareWithBitgoCommitment }; } async createGShareFromTxRequest(params: { txRequest: string | TxRequest; prv: string; + walletPassphrase: string; bitgoToUserRShare: SignatureShareRecord; - userToBitgoRShare: SignShare; + encryptedUserToBitgoRShare: EncryptedSignerShareRecord; bitgoToUserCommitment: CommitmentShareRecord; }): Promise { let txRequestResolved: TxRequest; - const { txRequest, prv, bitgoToUserCommitment, bitgoToUserRShare, userToBitgoRShare } = params; + const { txRequest, prv, walletPassphrase, bitgoToUserCommitment, bitgoToUserRShare, encryptedUserToBitgoRShare } = + params; if (typeof txRequest === 'string') { txRequestResolved = await getTxRequest(this.bitgo, this.wallet.id(), txRequest); @@ -634,13 +634,15 @@ export class EddsaUtils extends baseTSSUtils { throw new Error('Invalid user key - missing backupYShare'); } - assert(txRequestResolved.transactions || txRequestResolved.unsignedTxs, 'Unable to find transactions in txRequest'); - const unsignedTx = - txRequestResolved.apiVersion === 'full' - ? txRequestResolved.transactions![0].unsignedTx - : txRequestResolved.unsignedTxs[0]; + const userToBitgoRShare = await this.decryptSignShare( + encryptedUserToBitgoRShare, + walletPassphrase, + this.getSigningAdata(txRequestResolved, bitgoToUserCommitment), + EddsaUtils.MPCV1_SIGNING_R_SHARE_STATE + ); + EddsaUtils.consumeSigningNonce(userToBitgoRShare.xShare.R, bitgoToUserRShare.share); - const signablePayload = Buffer.from(unsignedTx.signableHex, 'hex'); + const signablePayload = Buffer.from(this.getUnsignedTx(txRequestResolved).signableHex, 'hex'); const userToBitGoGShare = await createUserToBitGoGShare( userToBitgoRShare, @@ -653,6 +655,68 @@ export class EddsaUtils extends baseTSSUtils { return userToBitGoGShare; } + private getUnsignedTx(txRequest: TxRequest): UnsignedTransactionTss { + assert(txRequest.transactions || txRequest.unsignedTxs, 'Unable to find transactions in txRequest'); + return txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + } + + /** + * Binds persisted signing state to the wallet, txRequest, derivation path, signable payload and, once known, the + * BitGo commitment. JSON-encoded so server-supplied fields cannot be shifted across separators. + */ + private getSigningAdata(txRequest: TxRequest, bitgoToUserCommitment?: CommitmentShareRecord): string { + const { derivationPath, signableHex } = this.getUnsignedTx(txRequest); + const fields = [txRequest.walletId, txRequest.txRequestId, derivationPath, signableHex]; + return JSON.stringify(bitgoToUserCommitment ? [...fields, bitgoToUserCommitment.share] : fields); + } + + private async encryptSignShare( + signShare: SignShare, + walletPassphrase: string, + adata: string, + useV2: boolean + ): Promise { + const input = JSON.stringify(signShare); + let encryptedRShare: string; + if (useV2) { + const session = await this.bitgo.createEncryptionSession(walletPassphrase); + try { + encryptedRShare = await session.encrypt(input, adata); + } finally { + session.destroy(); + } + } else { + encryptedRShare = await this.bitgo.encrypt({ input, password: walletPassphrase, adata, encryptionVersion: 1 }); + } + return this.createUserToBitgoEncryptedRShare(encryptedRShare); + } + + private async decryptSignShare( + encryptedSignShare: EncryptedSignerShareRecord, + walletPassphrase: string, + adata: string, + roundDomainSeparator: string + ): Promise { + this.validateAdata(adata, encryptedSignShare.share, roundDomainSeparator); + const decryptedSignShare = await this.bitgo.decrypt({ + input: encryptedSignShare.share, + password: walletPassphrase, + }); + return decodeWithCodec(SignShareCodec, JSON.parse(decryptedSignShare), 'Invalid decrypted SignShare'); + } + + /** + * Enforces that a user signing nonce is only ever used against a single BitGo R share. Signing twice under the + * same nonce with different challenges leaks the user's signing share. Identical retries are allowed. + */ + private static consumeSigningNonce(userNonce: string, bitgoToUserRShare: string): void { + const usedWith = EddsaUtils.usedSigningNonces.get(userNonce); + if (usedWith !== undefined && usedWith !== bitgoToUserRShare) { + throw new Error('User signing nonce has already been used'); + } + EddsaUtils.usedSigningNonces.set(userNonce, bitgoToUserRShare); + } + async signEddsaTssUsingExternalSigner( txRequest: string | TxRequest, externalSignerCommitmentGenerator: CustomCommitmentGeneratingFunction, @@ -689,10 +753,12 @@ export class EddsaUtils extends baseTSSUtils { reqId ); - const { rShare } = await externalSignerRShareGenerator({ - txRequest: txRequestResolved, - encryptedUserToBitgoRShare, - }); + const { rShare, encryptedUserToBitgoRShare: encryptedRShareWithBitgoCommitment } = + await externalSignerRShareGenerator({ + txRequest: txRequestResolved, + encryptedUserToBitgoRShare, + bitgoToUserCommitment, + }); await offerUserToBitgoRShare( this.bitgo, @@ -707,7 +773,7 @@ export class EddsaUtils extends baseTSSUtils { const gSignShareTransactionParams = { txRequest: txRequestResolved, bitgoToUserRShare: bitgoToUserRShare, - userToBitgoRShare: rShare, + encryptedUserToBitgoRShare: encryptedRShareWithBitgoCommitment, bitgoToUserCommitment, }; const gShare = await externalSignerGShareGenerator(gSignShareTransactionParams); diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts index ce851abaf9..008741400a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts @@ -1,3 +1,4 @@ +import * as t from 'io-ts'; import { EDDSA } from '../../../../account-lib/mpc/tss'; import BaseTSSUtils from '../baseTSSUtils'; import { CreateKeychainParamsBase, UnsignedTransactionTss } from '../baseTypes'; @@ -27,6 +28,18 @@ export interface EddsaMPCv2RecoveryKeyShares { backupVrfKeyShare?: Buffer; } +/** Codec for an MPCv1 SignShare decrypted from the external signer's persisted signing state */ +export const SignShareCodec = t.type({ + xShare: t.type({ i: t.number, y: t.string, u: t.string, r: t.string, R: t.string }), + rShares: t.record( + t.string, + t.intersection([ + t.type({ i: t.number, j: t.number, u: t.string, r: t.string, R: t.string, commitment: t.string }), + t.partial({ v: t.string }), + ]) + ), +}); + export type CreateEddsaBitGoKeychainParams = Omit; // For backward compatibility