Skip to content
Merged
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
28 changes: 21 additions & 7 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,14 @@ function markConsumedShareClosure(

/**
* Durable single-use consumption of an encrypted offline-signing session blob
* (EdDSA MPCv2 rounds 2/3). Keys the file-backed ConsumedShareRegistry on the
* sdk-core canonical session digest so JSON reformulation of the same
* (EdDSA and ECDSA MPCv2 rounds 2/3). Keys the file-backed ConsumedShareRegistry
* on the sdk-core canonical session digest so JSON reformulation of the same
* ciphertext is still rejected, and consumption survives signer restarts.
* Returns a lazy closure that the sdk-core helper invokes only after the
* session has decrypted and authenticated (as the MuDelta/S closures do), so
* junk requests never reach the registry.
*/
function markConsumedEddsaSessionClosure(
function markConsumedOfflineSessionClosure(
registryFilePath: string,
shareType: string,
encryptedSession: unknown
Expand Down Expand Up @@ -559,7 +559,7 @@ export async function handleV2GenerateShareTSS(
case ShareType.EddsaMPCv2Round2:
return await eddsaMPCv2Utils.createOfflineRound2Share({
...req.body,
markSessionConsumed: markConsumedEddsaSessionClosure(
markSessionConsumed: markConsumedOfflineSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound1Session
Expand All @@ -568,7 +568,7 @@ export async function handleV2GenerateShareTSS(
case ShareType.EddsaMPCv2Round3:
return await eddsaMPCv2Utils.createOfflineRound3Share({
...req.body,
markSessionConsumed: markConsumedEddsaSessionClosure(
markSessionConsumed: markConsumedOfflineSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound2Session
Expand Down Expand Up @@ -607,9 +607,23 @@ export async function handleV2GenerateShareTSS(
case ShareType.MPCv2Round1:
return await ecdsaMPCv2Utils.createOfflineRound1Share(req.body);
case ShareType.MPCv2Round2:
return await ecdsaMPCv2Utils.createOfflineRound2Share(req.body);
return await ecdsaMPCv2Utils.createOfflineRound2Share({
...req.body,
markSessionConsumed: markConsumedOfflineSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound1Session
),
});
case ShareType.MPCv2Round3:
return await ecdsaMPCv2Utils.createOfflineRound3Share(req.body);
return await ecdsaMPCv2Utils.createOfflineRound3Share({
...req.body,
markSessionConsumed: markConsumedOfflineSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound2Session
),
});
default:
throw new Error(
`Share type ${req.decoded.sharetype} not supported for MPCv2, only MPCv2Round1, MPCv2Round2 and MPCv2Round3 is supported.`
Expand Down
2 changes: 1 addition & 1 deletion modules/express/src/expressApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ function checkPreconditions(config: Config) {

if (signerMode !== undefined && !envRequiresBitgoPubGpgKeyConfig(env)) {
console.warn(
`warning: environment '${env}' does not enforce a pinned BitGo GPG public key for EdDSA MPCv2 offline ` +
`warning: environment '${env}' does not enforce a pinned BitGo GPG public key for EdDSA and ECDSA MPCv2 offline ` +
'signing; run against prod, test, staging, adminProd or adminTest to enforce it.'
);
}
Expand Down
26 changes: 22 additions & 4 deletions modules/express/test/unit/clientRoutes/externalSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,11 +1129,19 @@ describe('External signer', () => {
const [userShare, backupShare, bitgoShare] = await DklsUtils.generateDKGKeyShares();
assert.ok(backupShare, 'backupShare is not defined');

const bgTest = new BitGo({ env: 'test' });
const bgTest = new BitGo({ env: 'dev' });
const userKeyShare = userShare.getKeyShare().toString('base64');
const validPrv = await bgTest.encrypt({ input: userKeyShare, password: walletPassphrase });
const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify({ [walletID]: validPrv }));
const envStub = sinon.stub(process, 'env').value({ ['WALLET_' + walletID + '_PASSPHRASE']: walletPassphrase });
// Unique temp path so the durable consumed-session registry writes to a throwaway file;
// the round 2/3 marking closures only fire inside the helper once the session has
// authenticated, and 'dev' is not a BitGo-pub-key-pinned env, so the locally generated BitGo GPG key is accepted here.
const ecdsaSignerTmpPath = path.join(
os.tmpdir(),
`bitgojs-test-ecdsa-signer-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
resetConsumedShareRegistriesForTesting();

// round 1
const reqMPCv2Round1 = {
Expand Down Expand Up @@ -1177,7 +1185,7 @@ describe('External signer', () => {
sharetype: 'MPCv2Round1',
},
config: {
signerFileSystemPath: 'signerFileSystemPath',
signerFileSystemPath: ecdsaSignerTmpPath,
},
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;
const round1Result = await handleV2GenerateShareTSS(reqMPCv2Round1);
Expand Down Expand Up @@ -1225,7 +1233,7 @@ describe('External signer', () => {
sharetype: 'MPCv2Round2',
},
config: {
signerFileSystemPath: 'signerFileSystemPath',
signerFileSystemPath: ecdsaSignerTmpPath,
},
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;
const round2Result = await handleV2GenerateShareTSS(reqMPCv2Round2);
Expand Down Expand Up @@ -1267,7 +1275,7 @@ describe('External signer', () => {
sharetype: 'MPCv2Round3',
},
config: {
signerFileSystemPath: 'signerFileSystemPath',
signerFileSystemPath: ecdsaSignerTmpPath,
},
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;
const round3Result = await handleV2GenerateShareTSS(reqMPCv2Round3);
Expand Down Expand Up @@ -1311,6 +1319,16 @@ describe('External signer', () => {
);
assert.ok(convertedSignature, 'Signature is not valid');
assert.ok(convertedSignature.split(':').length === 4, 'Signature is not valid');

// Session blobs are single-use: replaying the same encryptedRound2Session must be rejected.
// In-process the sdk-core early check fires first; across signer restarts the
// durable route-level registry (file-backed, keyed on the canonical digest) rejects.
await assert.rejects(
() => handleV2GenerateShareTSS(reqMPCv2Round3),
/Session state has already been used; replay is not allowed/
);

fs.promises.unlink(`${ecdsaSignerTmpPath}.consumedShares.json`).catch(() => undefined);
readFileStub.restore();
envStub.restore();
});
Expand Down
43 changes: 37 additions & 6 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ import { BitGoBase } from '../../../bitgoBase';
import { resolveEffectiveTxParams } from '../recipientUtils';
import type { EcdsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets';
import { parseMpcV2KeyShareEnvelope } from '../keyShareEnvelope';
import {
assertEncryptedSessionNotConsumed,
computeEncryptedSessionDigest,
markEncryptedSessionConsumed,
validateDsgSessionAdata,
} from '../encryptedSessionSingleUse';

export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
private static readonly DKLS23_SIGNING_USER_GPG_KEY = 'DKLS23_SIGNING_USER_GPG_KEY';
Expand Down Expand Up @@ -1365,7 +1371,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
try {
const encryptedRound1Session = await session.encrypt(
sessionData,
`${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE}:${adata}`
`${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE}:${Date.now()}:${adata}`
);
const encryptedUserGpgPrvKey = await session.encrypt(
userGpgKey.privateKey,
Expand All @@ -1380,7 +1386,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
const encryptedRound1Session = await this.bitgo.encrypt({
input: sessionData,
password: walletPassphrase,
adata: `${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE}:${adata}`,
adata: `${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE}:${Date.now()}:${adata}`,
encryptionVersion: 1,
});
const encryptedUserGpgPrvKey = await this.bitgo.encrypt({
Expand All @@ -1400,12 +1406,22 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
bitgoPublicGpgKey: string;
encryptedUserGpgPrvKey: string;
encryptedRound1Session: string;
/** Durable single-use consumption hook (express route registry); invoked only after the session has decrypted and authenticated. */
markSessionConsumed?: () => void;
}): Promise<{
signatureShareRound2: SignatureShareRecord;
encryptedRound2Session: string;
}> {
const { prv, walletPassphrase, encryptedUserGpgPrvKey, encryptedRound1Session, bitgoPublicGpgKey, txRequest } =
params;
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key');
}
// Early replay check; the blob is only marked consumed once the decrypted DSG
// nonce state is about to be restored, so transient failures (wrong passphrase,
// malformed peer share) can be retried but a restored nonce can never be reused.
const round1SessionDigest = computeEncryptedSessionDigest(encryptedRound1Session);
assertEncryptedSessionNotConsumed(round1SessionDigest);

const { hashBuffer, derivationPath } = this.getHashStringAndDerivationPath(txRequest);
const adata = `${hashBuffer.toString('hex')}:${derivationPath}`;
Expand Down Expand Up @@ -1438,11 +1454,15 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
bitgoGpgKey
);

validateDsgSessionAdata(encryptedRound1Session, EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE, adata);
const round1Session = await this.bitgo.decrypt({ input: encryptedRound1Session, password: walletPassphrase });
this.validateAdata(adata, encryptedRound1Session, EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND1_STATE);

const userKeyShare = Buffer.from(prv, 'base64');
const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer);
// Durable route registry first: if it rejects the blob (already consumed), the
// in-memory entry is not set and the nonce is never restored.
params.markSessionConsumed?.();
markEncryptedSessionConsumed(round1SessionDigest);
await userSigner.setSession(round1Session);

const deserializedMessages = DklsTypes.deserializeMessages(serializedBitGoToUserMessagesRound1);
Expand All @@ -1467,7 +1487,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
try {
const encryptedRound2Session = await encSession.encrypt(
sessionData,
`${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE}:${adata}`
`${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE}:${Date.now()}:${adata}`
);
return { signatureShareRound2, encryptedRound2Session };
} finally {
Expand All @@ -1478,7 +1498,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
const encryptedRound2Session = await this.bitgo.encrypt({
input: sessionData,
password: walletPassphrase,
adata: `${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE}:${adata}`,
adata: `${EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE}:${Date.now()}:${adata}`,
encryptionVersion: 1,
});

Expand All @@ -1495,11 +1515,18 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
bitgoPublicGpgKey: string;
encryptedUserGpgPrvKey: string;
encryptedRound2Session: string;
/** Durable single-use consumption hook (express route registry); invoked only after the session has decrypted and authenticated. */
markSessionConsumed?: () => void;
}): Promise<{
signatureShareRound3: SignatureShareRecord;
}> {
const { prv, walletPassphrase, encryptedUserGpgPrvKey, encryptedRound2Session, bitgoPublicGpgKey, txRequest } =
params;
if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) {
assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key');
}
const round2SessionDigest = computeEncryptedSessionDigest(encryptedRound2Session);
assertEncryptedSessionNotConsumed(round2SessionDigest);

assert(txRequest.transactions && txRequest.transactions.length === 1, 'Unable to find transactions in txRequest');
const { hashBuffer, derivationPath } = this.getHashStringAndDerivationPath(txRequest);
Expand Down Expand Up @@ -1536,11 +1563,15 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
broadcastMessages: [],
});

validateDsgSessionAdata(encryptedRound2Session, EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE, adata);
const round2Session = await this.bitgo.decrypt({ input: encryptedRound2Session, password: walletPassphrase });
this.validateAdata(adata, encryptedRound2Session, EcdsaMPCv2Utils.DKLS23_SIGNING_ROUND2_STATE);

const userKeyShare = Buffer.from(prv, 'base64');
const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer);
// Durable route registry first: if it rejects the blob (already consumed), the
// in-memory entry is not set and the nonce is never restored.
params.markSessionConsumed?.();
markEncryptedSessionConsumed(round2SessionDigest);
await userSigner.setSession(round2Session);

const userToBitGoMessagesRound4 = userSigner.handleIncomingMessages({
Expand Down
Loading
Loading