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
45 changes: 43 additions & 2 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
ShareType,
SignShare,
SShare,
computeEncryptedSessionDigest,
TssEcdsaStep1ReturnMessage,
TssEcdsaStep2ReturnMessage,
UnsupportedCoinError,
Expand Down Expand Up @@ -472,6 +473,32 @@ function markConsumedShareClosure(
getConsumedShareRegistry(registryFilePath).markConsumed(computeConsumedShareKey(shareType, adata));
}

/**
* 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
* 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(
registryFilePath: string,
shareType: string,
encryptedSession: unknown
): () => void {
if (typeof encryptedSession !== 'string' || !encryptedSession) {
throw new ApiResponseError('Missing encrypted session state in request', 400);
}
let digest: string;
try {
digest = computeEncryptedSessionDigest(encryptedSession);
} catch {
throw new ApiResponseError('Invalid encrypted session envelope', 400);
}
return () => getConsumedShareRegistry(registryFilePath).markConsumed(computeConsumedShareKey(shareType, digest));
}

async function decryptPrivKey(bg: BitGo, encryptedPrivKey: string, walletPw: string): Promise<string> {
try {
return await bg.decrypt({ password: walletPw, input: encryptedPrivKey });
Expand Down Expand Up @@ -528,9 +555,23 @@ export async function handleV2GenerateShareTSS(
case ShareType.EddsaMPCv2Round1:
return await eddsaMPCv2Utils.createOfflineRound1Share(req.body);
case ShareType.EddsaMPCv2Round2:
return await eddsaMPCv2Utils.createOfflineRound2Share(req.body);
return await eddsaMPCv2Utils.createOfflineRound2Share({
...req.body,
markSessionConsumed: markConsumedEddsaSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound1Session
),
});
case ShareType.EddsaMPCv2Round3:
return await eddsaMPCv2Utils.createOfflineRound3Share(req.body);
return await eddsaMPCv2Utils.createOfflineRound3Share({
...req.body,
markSessionConsumed: markConsumedEddsaSessionClosure(
registryFilePath,
req.decoded.sharetype,
req.body.encryptedRound2Session
),
});
default:
throw new Error(
`Share type ${req.decoded.sharetype} not supported for EdDSA MPCv2, only EddsaMPCv2Round1, EddsaMPCv2Round2 and EddsaMPCv2Round3 is supported.`
Expand Down
8 changes: 8 additions & 0 deletions modules/express/src/expressApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './errors';

import { Environments } from 'bitgo';
import { envRequiresBitgoPubGpgKeyConfig } from '@bitgo/sdk-core';
import * as clientRoutes from './clientRoutes';
import { isSameFilePath } from './consumedSharesRegistry';
import { getBitGoTlsPolicy } from './tlsPolicy';
Expand Down Expand Up @@ -263,6 +264,13 @@ 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 ` +
'signing; run against prod, test, staging, adminProd or adminTest to enforce it.'
);
}

if (
signerFileSystemPath !== undefined &&
signerRegistryFilePath !== undefined &&
Expand Down
77 changes: 63 additions & 14 deletions modules/express/test/unit/clientRoutes/externalSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
ShareKeyPosition,
TxRequest,
SignatureShareRecord,
generateGPGKeyPair,
BitgoMpcGpgPubKeys,
} from '@bitgo/sdk-core';
import { Hash, createPublicKey, verify as cryptoVerify } from 'crypto';
import { Hash, createPublicKey, randomBytes, verify as cryptoVerify } from 'crypto';
import { logger } from '@bitgo/logger';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import * as should from 'should';
Expand Down Expand Up @@ -901,6 +901,18 @@ describe('External signer', () => {
const round1Stub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound1Share').resolves(round1Response);
const round2Stub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound2Share').resolves(round2Response);
const round3Stub = sinon.stub(EddsaMPCv2Utils.prototype, 'createOfflineRound3Share').resolves(round3Response);
// Round 2/3 now durably mark the encrypted session consumed before the generator runs;
// supply parseable fake envelopes and a throwaway registry path.
const stubTestSignerPath = path.join(
os.tmpdir(),
`bitgojs-test-eddsa-routing-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
resetConsumedShareRegistriesForTesting();
// The digest requires a v1 iv at least as long as the CCM nonce (13 bytes).
const fakeEncryptedSession = JSON.stringify({
iv: randomBytes(16).toString('base64'),
ct: Buffer.from('ct').toString('base64'),
});

try {
const round1Result = await handleV2GenerateShareTSS(
Expand All @@ -912,13 +924,29 @@ describe('External signer', () => {
round1Stub.firstCall.args[0].walletPassphrase.should.equal(walletPassword);

const round2Result = await handleV2GenerateShareTSS(
createEddsaMPCv2GenerateShareRequest(bgTest, walletId, ShareType.EddsaMPCv2Round2)
createEddsaMPCv2GenerateShareRequest(
bgTest,
walletId,
ShareType.EddsaMPCv2Round2,
{
encryptedRound1Session: fakeEncryptedSession,
},
stubTestSignerPath
)
);
round2Result.should.eql(round2Response);
round2Stub.calledOnce.should.be.true();

const round3Result = await handleV2GenerateShareTSS(
createEddsaMPCv2GenerateShareRequest(bgTest, walletId, ShareType.EddsaMPCv2Round3)
createEddsaMPCv2GenerateShareRequest(
bgTest,
walletId,
ShareType.EddsaMPCv2Round3,
{
encryptedRound2Session: fakeEncryptedSession,
},
stubTestSignerPath
)
);
round3Result.should.eql(round3Response);
round3Stub.calledOnce.should.be.true();
Expand All @@ -928,6 +956,7 @@ describe('External signer', () => {
round1Stub.restore();
round2Stub.restore();
round3Stub.restore();
fs.promises.unlink(`${stubTestSignerPath}.consumedShares.json`).catch(() => undefined);
}
});

Expand Down Expand Up @@ -1304,11 +1333,19 @@ describe('External signer', () => {
const validPrvEdDSA = await bgTest.encrypt({ input: userKeyShareB64, password: walletPassphrase });
const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify({ [walletID]: validPrvEdDSA }));
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
const eddsaSignerTmpPath = path.join(
os.tmpdir(),
`bitgojs-test-eddsa-signer-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
resetConsumedShareRegistriesForTesting();

// EdDSA MPCv2 requires ed25519 GPG keys (not secp256k1) for PGP-authenticated message exchange.
// GPG auth is tested by the sdk-core unit tests; here we stub the comms layer so the test
// exercises only the WASM DSG protocol without needing openpgp as a direct express dep.
const bitgoEddsaGpgKey = await generateGPGKeyPair('ed25519');
// Round 2/3 pin the BitGo GPG key in envs that require BitGo pub key config ('test' here),
// so the request must carry the hardcoded BitGo onprem EdDSA MPCv2 public key.
const pinnedBitgoGpgKey = BitgoMpcGpgPubKeys.getBitgoMpcGpgPubKey('test', 'onprem', 'eddsaMpcv2');
const detachSignStub = sinon
.stub(MPSComms, 'detachSignMpsMessage')
.callsFake(async (rawBytes) => ({ message: Buffer.from(rawBytes).toString('base64'), signature: '' }));
Expand Down Expand Up @@ -1339,7 +1376,7 @@ describe('External signer', () => {
body: { txRequest: baseTxRequest },
decoded: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1', txRequest: baseTxRequest },
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
config: { signerFileSystemPath: eddsaSignerTmpPath },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round1Result = await handleV2GenerateShareTSS(reqEdDSARound1);
Expand Down Expand Up @@ -1368,18 +1405,18 @@ describe('External signer', () => {
txRequest: txRequestRound1,
encryptedRound1Session: round1Result.encryptedRound1Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
bitgoPublicGpgKey: pinnedBitgoGpgKey,
},
decoded: {
coin: 'tsol',
sharetype: 'EddsaMPCv2Round2',
txRequest: txRequestRound1,
encryptedRound1Session: round1Result.encryptedRound1Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
bitgoPublicGpgKey: pinnedBitgoGpgKey,
},
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round2' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
config: { signerFileSystemPath: eddsaSignerTmpPath },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round2Result = await handleV2GenerateShareTSS(reqEdDSARound2);
Expand Down Expand Up @@ -1407,18 +1444,18 @@ describe('External signer', () => {
txRequest: txRequestRound2,
encryptedRound2Session: round2Result.encryptedRound2Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
bitgoPublicGpgKey: pinnedBitgoGpgKey,
},
decoded: {
coin: 'tsol',
sharetype: 'EddsaMPCv2Round3',
txRequest: txRequestRound2,
encryptedRound2Session: round2Result.encryptedRound2Session,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey,
bitgoPublicGpgKey: pinnedBitgoGpgKey,
},
params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round3' },
config: { signerFileSystemPath: 'signerFileSystemPath' },
config: { signerFileSystemPath: eddsaSignerTmpPath },
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;

const round3Result = await handleV2GenerateShareTSS(reqEdDSARound3);
Expand All @@ -1440,6 +1477,15 @@ describe('External signer', () => {
'Ed25519 signature must verify under the derived public key'
);

// 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(reqEdDSARound3),
/Session state has already been used; replay is not allowed/
);

fs.promises.unlink(`${eddsaSignerTmpPath}.consumedShares.json`).catch(() => undefined);
detachSignStub.restore();
verifyMsgStub.restore();
readFileStub.restore();
Expand Down Expand Up @@ -1481,11 +1527,14 @@ describe('External signer', () => {
function createEddsaMPCv2GenerateShareRequest(
bitgo: BitGo,
walletId: string,
sharetype: string
sharetype: string,
extraBody: Record<string, unknown> = {},
signerFileSystemPath = 'signerFileSystemPath'
): ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'> {
return {
bitgo,
body: {
...extraBody,
txRequest: {
txRequestId: '123456',
apiVersion: 'full',
Expand All @@ -1510,7 +1559,7 @@ function createEddsaMPCv2GenerateShareRequest(
sharetype,
},
config: {
signerFileSystemPath: 'signerFileSystemPath',
signerFileSystemPath,
},
} as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>;
}
Expand Down
Loading
Loading