From 7495f3b8cd013f63bada381f6dec1dced0ace9ec Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Tue, 10 Nov 2026 19:41:50 +0530 Subject: [PATCH] fix: validate BitGo GPG key and session state in ECDSA MPCv2 offline signing The DKLS23 offline round helpers accepted the BitGo public GPG key from the request and processed the same encrypted session blob repeatedly, so a malicious online relay could replay round-2 or round-3 state, obtain two partial signatures under one nonce, and recover the signer's key share (CWE-323). Round 2 and round 3 now validate the BitGo public GPG key against the known BitGo key table in the same environments that already enforce this for keygen, and each encrypted session blob is consumed exactly once: rejected early through an in-memory digest registry in sdk-core and durably through the express consumed-shares registry via a lazy markSessionConsumed hook, mirroring the EdDSA MPCv2 rounds. Session envelopes are minted with a creation stamp in the adata and validated against the registry retention window, bounding the replay window the same way. The session digest, stamped-adata validation, and registry are hoisted from the EdDSA MPCv2 module into a shared TSS module so both DSG protocols enforce an identical single-use contract. References: WAL-2457 --- modules/express/src/clientRoutes.ts | 28 ++- modules/express/src/expressApp.ts | 2 +- .../test/unit/clientRoutes/externalSign.ts | 26 ++- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 43 +++- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 169 +------------- .../utils/tss/encryptedSessionSingleUse.ts | 163 ++++++++++++++ modules/sdk-core/src/index.ts | 2 +- .../test/unit/bitgo/utils/tss/baseTSSUtils.ts | 11 +- .../unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 211 +++++++++++++++++- 9 files changed, 465 insertions(+), 190 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/tss/encryptedSessionSingleUse.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index fbf977e6b1..6638ca9a03 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -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 @@ -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 @@ -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 @@ -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.` diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index f2cb1e4605..f02eccadaf 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -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.' ); } diff --git a/modules/express/test/unit/clientRoutes/externalSign.ts b/modules/express/test/unit/clientRoutes/externalSign.ts index 069d4e633a..29df5f443e 100644 --- a/modules/express/test/unit/clientRoutes/externalSign.ts +++ b/modules/express/test/unit/clientRoutes/externalSign.ts @@ -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 = { @@ -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); @@ -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); @@ -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); @@ -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(); }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index d7bfc2b6ef..fdd83d5da3 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -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'; @@ -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, @@ -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({ @@ -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}`; @@ -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); @@ -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 { @@ -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, }); @@ -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); @@ -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({ diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 854940260c..c88f319d2c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,5 +1,4 @@ import assert from 'assert'; -import { createHash } from 'crypto'; import * as sjcl from '@bitgo/sjcl'; import * as pgp from 'openpgp'; import { NonEmptyString } from 'io-ts-types'; @@ -61,168 +60,12 @@ import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2K import { EddsaMPCv2RecoveryKeyShares } from './types'; import { parseMpcV2KeyShareEnvelope } from '../keyShareEnvelope'; import { SigningMaterial } from '../../../tss'; - -/** Entries older than this are dropped once the map exceeds the size cap; mirrors the express ConsumedShareRegistry retention. */ -const CONSUMED_OFFLINE_SIGNING_SESSION_MAX_AGE_MS = 30 * 24 * 3600 * 1000; -/** Hard size cap: beyond this, the oldest entries are evicted. */ -const CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES = 100_000; - -/** - * Encrypted offline-signing session blobs that have already been consumed by a - * round-2/round-3 helper. Single-use enforcement: replaying a round-1/round-2 - * session blob restores the same DSG nonce, and two partial signatures under one - * nonce leak the signer's key share (CWE-323, WAL-2455). Keyed by a formatting- - * independent digest of the ciphertext (see computeEncryptedSessionDigest) with a - * consumption timestamp; the map is bounded by a hard size cap, with expired - * entries dropped lazily only when the cap is exceeded. Module-level because the - * express route constructs a fresh EddsaMPCv2Utils instance per request. - */ -const consumedEncryptedOfflineSigningSessions = new Map(); - -function isJsonObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isCanonicalBase64(value: string): boolean { - return Buffer.from(value, 'base64').toString('base64') === value; -} - -/** Session blobs expire with the durable registry's retention window (keep in sync with CONSUMED_SHARES_MAX_AGE_MS). */ -const DSG_SESSION_MAX_AGE_MS = 30 * 24 * 3600 * 1000; -/** Tolerance for clock skew when comparing the minted stamp against the local clock. */ -const DSG_SESSION_CLOCK_SKEW_MS = 60_000; - -/** - * Validates an encrypted DSG session blob's adata: `STATE:::`. - * The created stamp bounds the replay window to the registry retention (the durable registry prunes - * entries at the same age, so pruning never drops an entry that could still be accepted). - */ -function validateDsgSessionAdata(encryptedSession: string, state: string, expectedAdata: string): void { - let cypherJson: { adata?: unknown }; - try { - cypherJson = JSON.parse(encryptedSession); - } catch { - throw new Error('Failed to parse cyphertext to JSON, got: ' + encryptedSession); - } - if (typeof cypherJson.adata !== 'string') { - throw new Error('Adata does not match cyphertext adata'); - } - const stored = decodeURIComponent(cypherJson.adata); - const [storedState, created, ...rest] = stored.split(':'); - if (storedState !== state || !/^\d+$/.test(created ?? '') || rest.join(':') !== expectedAdata) { - throw new Error('Adata does not match cyphertext adata'); - } - const createdMs = Number(created); - const now = Date.now(); - if (createdMs > now + DSG_SESSION_CLOCK_SKEW_MS || now - createdMs >= DSG_SESSION_MAX_AGE_MS) { - throw new Error('Encrypted session envelope has expired; restart the signing session'); - } -} - -/** - * Digest of an encrypted session blob that is invariant under every - * decrypt-equivalent reformulation: hashing the raw string would let a relay - * re-serialize the same ciphertext into a "fresh" blob and replay the same DSG - * nonce. The digest covers the decoded bytes that decryption actually consumes - * (base64-decoded iv and ct); for v1 (SJCL/CCM) the iv is truncated to the - * effective nonce length, mirroring @bitgo/sdk-api decryptV1.ts ccmNonceLength - * (keep in sync), because trailing iv bytes do not affect decryption. The salt - * is deliberately omitted: altering it changes the derived key, so decryption - * fails and no nonce is restored (merging such blobs is fail-closed). - * Exported so the express route's durable ConsumedShareRegistry keys consumed - * EdDSA sessions identically. - */ -export function computeEncryptedSessionDigest(encryptedSession: string): string { - let envelope: Record; - try { - envelope = JSON.parse(encryptedSession); - } catch { - throw new Error('Session state is not a valid encrypted session envelope'); - } - if (!isJsonObject(envelope) || typeof envelope.iv !== 'string' || typeof envelope.ct !== 'string') { - throw new Error('Session state is not a valid encrypted session envelope'); - } - // Strict canonical base64 only: Node's decoder stops at the first '=' while the - // SJCL fallback decoder strips every '=' (and whitespace), so an envelope with a - // '=' inserted inside iv/ct decrypts identically but would otherwise digest to a - // different prefix and evade the single-use registry. Legitimate envelopes are - // canonical base64 and pass this round-trip unchanged. - if (!isCanonicalBase64(envelope.iv) || !isCanonicalBase64(envelope.ct)) { - throw new Error('Session state is not a valid encrypted session envelope'); - } - const iv = Buffer.from(envelope.iv, 'base64'); - const ct = Buffer.from(envelope.ct, 'base64'); - // v2 (GCM) authenticates the full iv. v1 (CCM) truncates the iv to the nonce length - // (real SJCL envelopes carry a 16-byte iv for a 13-byte nonce), so hash only the - // effective nonce bytes. - let effectiveIv = iv; - if (envelope.v !== 2) { - const tagBytes = typeof envelope.ts === 'number' ? envelope.ts / 8 : 0; - const nonceLen = ccmNonceLength(ct.length - tagBytes); - if (nonceLen <= 0 || iv.length < nonceLen) { - // decryption would reject this envelope anyway - throw new Error('Session state is not a valid encrypted session envelope'); - } - effectiveIv = iv.subarray(0, nonceLen); - } - return createHash('sha256').update(effectiveIv).update(ct).digest('hex'); -} - -/** - * Mirrors @bitgo/sdk-api decryptV1.ts ccmNonceLength: v1 (SJCL/CCM) decryption - * truncates the iv to the nonce length derived from the plaintext size, so - * trailing iv bytes do not affect decryption. Keep in sync with decryptV1.ts. - */ -function ccmNonceLength(plaintextLen: number): number { - let l = 2; - while (l < 4 && plaintextLen >= 2 ** (8 * l)) { - l++; - } - return 15 - l; -} - -function assertEncryptedSessionNotConsumed(digest: string): void { - if (consumedEncryptedOfflineSigningSessions.has(digest)) { - throw new Error('Session state has already been used; replay is not allowed'); - } -} - -/** - * Marks a session blob consumed. Called only right before the decrypted DSG - * nonce state is restored: nothing before this point uses the nonce, and the - * first call to reach here consumes the blob, so a concurrent replay cannot - * reach restoreSession twice (the check-then-set is synchronous). - */ -function markEncryptedSessionConsumed(digest: string): void { - if (consumedEncryptedOfflineSigningSessions.has(digest)) { - throw new Error('Session state has already been used; replay is not allowed'); - } - const now = Date.now(); - consumedEncryptedOfflineSigningSessions.set(digest, now); - pruneConsumedEncryptedOfflineSigningSessions(now); -} - -function pruneConsumedEncryptedOfflineSigningSessions(now: number): void { - if (consumedEncryptedOfflineSigningSessions.size <= CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES) { - return; - } - for (const [key, consumedAt] of consumedEncryptedOfflineSigningSessions) { - if (now - consumedAt >= CONSUMED_OFFLINE_SIGNING_SESSION_MAX_AGE_MS) { - consumedEncryptedOfflineSigningSessions.delete(key); - } else { - // Insertion order is chronological under a monotonic clock; a backwards - // clock adjustment only delays pruning, and the size cap below still holds. - break; - } - } - while (consumedEncryptedOfflineSigningSessions.size > CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES) { - const oldest = consumedEncryptedOfflineSigningSessions.keys().next(); - if (oldest.done) { - break; - } - consumedEncryptedOfflineSigningSessions.delete(oldest.value); - } -} +import { + assertEncryptedSessionNotConsumed, + computeEncryptedSessionDigest, + markEncryptedSessionConsumed, + validateDsgSessionAdata, +} from '../encryptedSessionSingleUse'; export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/encryptedSessionSingleUse.ts b/modules/sdk-core/src/bitgo/utils/tss/encryptedSessionSingleUse.ts new file mode 100644 index 0000000000..8004298464 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/encryptedSessionSingleUse.ts @@ -0,0 +1,163 @@ +import { createHash } from 'crypto'; +import { OFFLINE_SIGNING_SHARE_MAX_AGE_MS } from './ecdsa/ecdsa'; + +/** Entries older than this are dropped once the map exceeds the size cap; mirrors the express ConsumedShareRegistry retention. */ +const CONSUMED_OFFLINE_SIGNING_SESSION_MAX_AGE_MS = 30 * 24 * 3600 * 1000; +/** Hard size cap: beyond this, the oldest entries are evicted. */ +const CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES = 100_000; + +/** + * Encrypted offline-signing session blobs that have already been consumed by a + * round-2/round-3 helper. Single-use enforcement: replaying a round-1/round-2 + * session blob restores the same DSG nonce, and two partial signatures under one + * nonce leak the signer's key share (CWE-323, WAL-2455/WAL-2457). Keyed by a + * formatting-independent digest of the ciphertext (see computeEncryptedSessionDigest) + * with a consumption timestamp; the map is bounded by a hard size cap, with expired + * entries dropped lazily only when the cap is exceeded. Module-level because the + * express route constructs a fresh per-request utils instance. + */ +const consumedEncryptedOfflineSigningSessions = new Map(); + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCanonicalBase64(value: string): boolean { + return Buffer.from(value, 'base64').toString('base64') === value; +} +/** Session blobs expire with the GG18 offline-signing share retention window; the express durable registry prunes at the same age. */ +const DSG_SESSION_MAX_AGE_MS = OFFLINE_SIGNING_SHARE_MAX_AGE_MS; +/** Tolerance for clock skew when comparing the minted stamp against the local clock. */ +const DSG_SESSION_CLOCK_SKEW_MS = 60_000; + +/** + * Validates an encrypted DSG session blob's adata: `STATE:::`. + * The created stamp bounds the replay window to the registry retention (the durable registry prunes + * entries at the same age, so pruning never drops an entry that could still be accepted). + */ +export function validateDsgSessionAdata(encryptedSession: string, state: string, expectedAdata: string): void { + let cypherJson: { adata?: unknown }; + try { + cypherJson = JSON.parse(encryptedSession); + } catch { + throw new Error('Failed to parse cyphertext to JSON, got: ' + encryptedSession); + } + if (typeof cypherJson.adata !== 'string') { + throw new Error('Adata does not match cyphertext adata'); + } + const stored = decodeURIComponent(cypherJson.adata); + const [storedState, created, ...rest] = stored.split(':'); + if (storedState !== state || !/^\d+$/.test(created ?? '') || rest.join(':') !== expectedAdata) { + throw new Error('Adata does not match cyphertext adata'); + } + const createdMs = Number(created); + const now = Date.now(); + if (createdMs > now + DSG_SESSION_CLOCK_SKEW_MS || now - createdMs >= DSG_SESSION_MAX_AGE_MS) { + throw new Error('Encrypted session envelope has expired; restart the signing session'); + } +} + +/** + * Digest of an encrypted session blob that is invariant under every + * decrypt-equivalent reformulation: hashing the raw string would let a relay + * re-serialize the same ciphertext into a "fresh" blob and replay the same DSG + * nonce. The digest covers the decoded bytes that decryption actually consumes + * (base64-decoded iv and ct); for v1 (SJCL/CCM) the iv is truncated to the + * effective nonce length, mirroring @bitgo/sdk-api decryptV1.ts ccmNonceLength + * (keep in sync), because trailing iv bytes do not affect decryption. The salt + * is deliberately omitted: altering it changes the derived key, so decryption + * fails and no nonce is restored (merging such blobs is fail-closed). + * Exported so the express route's durable ConsumedShareRegistry keys consumed + * offline signing sessions identically. + */ +export function computeEncryptedSessionDigest(encryptedSession: string): string { + let envelope: Record; + try { + envelope = JSON.parse(encryptedSession); + } catch { + throw new Error('Session state is not a valid encrypted session envelope'); + } + if (!isJsonObject(envelope) || typeof envelope.iv !== 'string' || typeof envelope.ct !== 'string') { + throw new Error('Session state is not a valid encrypted session envelope'); + } + // Strict canonical base64 only: Node's decoder stops at the first '=' while the + // SJCL fallback decoder strips every '=' (and whitespace), so an envelope with a + // '=' inserted inside iv/ct decrypts identically but would otherwise digest to a + // different prefix and evade the single-use registry. Legitimate envelopes are + // canonical base64 and pass this round-trip unchanged. + if (!isCanonicalBase64(envelope.iv) || !isCanonicalBase64(envelope.ct)) { + throw new Error('Session state is not a valid encrypted session envelope'); + } + const iv = Buffer.from(envelope.iv, 'base64'); + const ct = Buffer.from(envelope.ct, 'base64'); + // v2 (GCM) authenticates the full iv. v1 (CCM) truncates the iv to the nonce length + // (real SJCL envelopes carry a 16-byte iv for a 13-byte nonce), so hash only the + // effective nonce bytes. + let effectiveIv = iv; + if (envelope.v !== 2) { + const tagBytes = typeof envelope.ts === 'number' ? envelope.ts / 8 : 0; + const nonceLen = ccmNonceLength(ct.length - tagBytes); + if (nonceLen <= 0 || iv.length < nonceLen) { + // decryption would reject this envelope anyway + throw new Error('Session state is not a valid encrypted session envelope'); + } + effectiveIv = iv.subarray(0, nonceLen); + } + return createHash('sha256').update(effectiveIv).update(ct).digest('hex'); +} + +/** + * Mirrors @bitgo/sdk-api decryptV1.ts ccmNonceLength: v1 (SJCL/CCM) decryption + * truncates the iv to the nonce length derived from the plaintext size, so + * trailing iv bytes do not affect decryption. Keep in sync with decryptV1.ts. + */ +function ccmNonceLength(plaintextLen: number): number { + let l = 2; + while (l < 4 && plaintextLen >= 2 ** (8 * l)) { + l++; + } + return 15 - l; +} + +export function assertEncryptedSessionNotConsumed(digest: string): void { + if (consumedEncryptedOfflineSigningSessions.has(digest)) { + throw new Error('Session state has already been used; replay is not allowed'); + } +} + +/** + * Marks a session blob consumed. Called only right before the decrypted DSG + * nonce state is restored: nothing before this point uses the nonce, and the + * first call to reach here consumes the blob, so a concurrent replay cannot + * restore the same nonce twice (the check-then-set is synchronous). + */ +export function markEncryptedSessionConsumed(digest: string): void { + if (consumedEncryptedOfflineSigningSessions.has(digest)) { + throw new Error('Session state has already been used; replay is not allowed'); + } + const now = Date.now(); + consumedEncryptedOfflineSigningSessions.set(digest, now); + pruneConsumedEncryptedOfflineSigningSessions(now); +} + +function pruneConsumedEncryptedOfflineSigningSessions(now: number): void { + if (consumedEncryptedOfflineSigningSessions.size <= CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES) { + return; + } + for (const [key, consumedAt] of consumedEncryptedOfflineSigningSessions) { + if (now - consumedAt >= CONSUMED_OFFLINE_SIGNING_SESSION_MAX_AGE_MS) { + consumedEncryptedOfflineSigningSessions.delete(key); + } else { + // Insertion order is chronological under a monotonic clock; a backwards + // clock adjustment only delays pruning, and the size cap below still holds. + break; + } + } + while (consumedEncryptedOfflineSigningSessions.size > CONSUMED_OFFLINE_SIGNING_SESSION_MAX_ENTRIES) { + const oldest = consumedEncryptedOfflineSigningSessions.keys().next(); + if (oldest.done) { + break; + } + consumedEncryptedOfflineSigningSessions.delete(oldest.value); + } +} diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index ec7e55f11a..f3db4f647e 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -33,7 +33,7 @@ export { EcdsaMPCv2Utils }; import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; export { EddsaMPCv2Utils }; export type { EddsaSigningMaterial } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; -export { computeEncryptedSessionDigest } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; +export { computeEncryptedSessionDigest } from './bitgo/utils/tss/encryptedSessionSingleUse'; export { envRequiresBitgoPubGpgKeyConfig } from './bitgo/tss/bitgoPubKeys'; export { getEddsaSigningMaterial, diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts index c250898f29..f5398a3abf 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts @@ -567,12 +567,13 @@ describe('Base TSS Utils', function () { walletPassphrase, }); - // The round-1 session is encrypted with adata = "DKLS23_SIGNING_ROUND1_STATE::", - // which proves getHashStringAndDerivationPath produced the right values after the delegation refactor. + // The round-1 session is encrypted with adata = "DKLS23_SIGNING_ROUND1_STATE:::"; + // the creation stamp bounds the session's replay window. Matching the stamp loosely keeps the + // assertion on the values getHashStringAndDerivationPath actually produced. const { adata } = JSON.parse(encryptedRound1Session) as { adata?: string }; - assert.equal( - adata === undefined ? adata : decodeURIComponent(adata), - `DKLS23_SIGNING_ROUND1_STATE:${expectedHashBuffer.toString('hex')}:${derivationPath}` + assert.match( + adata === undefined ? '' : decodeURIComponent(adata), + new RegExp(`^DKLS23_SIGNING_ROUND1_STATE:\\d+:${expectedHashBuffer.toString('hex')}:${derivationPath}$`) ); }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 3360946ce8..07d04be91f 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -45,7 +45,7 @@ describe('ECDSA MPC v2', async () => { before('initialize EcdsaMPCv2Utils', async () => { const mockBg = {} as BitGoBase; - mockBg.getEnv = sinon.stub().returns('test'); + mockBg.getEnv = sinon.stub().returns('mock'); const encryptImpl = (params: { password: string; input: string; adata?: string }) => { const salt = randomBytes(8); const iv = randomBytes(16); @@ -286,11 +286,14 @@ describe('ECDSA MPC v2', async () => { const encryptedRound2Session = resMPCv2SigningRound2.encryptedRound2Session; // A bogus round 3 signing request containing encrypted session from round 1 instead of round 2 should fail. + // The session blob is single-use, so mint a fresh round-1 session: the already-consumed + // round-1 blob would be rejected by the replay check before the adata check runs. + const freshRound1Session = await ecdsaMPCv2Utils.createOfflineRound1Share(reqMPCv2SigningRound1 as any); const bogusReqMPCv2SigningRound3 = { ...reqMPCv2SigningRound2, txRequest: txRequestRound2, encryptedRound1Session: null, // not needed for round 3 - encryptedRound2Session: encryptedRound1Session, // instaed of encryptedRound2Session + encryptedRound2Session: freshRound1Session.encryptedRound1Session, // instaed of encryptedRound2Session }; await ecdsaMPCv2Utils @@ -974,13 +977,215 @@ describe('ECDSA MPC v2', async () => { assert.strictEqual(sigParts[2].length, 64, 'Signature S must be 32 bytes hex'); }); + describe('createOfflineRound2Share and createOfflineRound3Share replay protection and GPG pinning', () => { + const tMessage = 'testMessage'; + const derivationPath = 'm/3'; + // partial TxRequest fixture: the offline round helpers only read these fields + const baseTxRequest = { + txRequestId: '123456', + apiVersion: 'full', + walletId: walletID, + transactions: [ + { + unsignedTx: { + derivationPath, + signableHex: tMessage, + }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + + async function createRound2Flow() { + const resMPCv2SigningRound1 = await ecdsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userShare.toString('base64'), + walletPassphrase, + }); + + const hashBuffer = createKeccakHash('keccak256').update(Buffer.from(tMessage, 'hex')).digest(); + const bitgoSession = new DklsDsg.Dsg(bitgoShare, 2, derivationPath, hashBuffer); + + const txRequestRound1 = await signBitgoMPCv2Round1( + bitgoSession, + baseTxRequest, + resMPCv2SigningRound1.signatureShareRound1, + resMPCv2SigningRound1.userGpgPubKey + ); + + const reqMPCv2SigningRound2 = { + txRequest: txRequestRound1, + prv: userShare.toString('base64'), + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKey.public, + encryptedUserGpgPrvKey: resMPCv2SigningRound1.encryptedUserGpgPrvKey, + encryptedRound1Session: resMPCv2SigningRound1.encryptedRound1Session, + }; + return { bitgoSession, reqMPCv2SigningRound2, resMPCv2SigningRound1 }; + } + + it('should reject a replayed encryptedRound1Session', async () => { + const { reqMPCv2SigningRound2 } = await createRound2Flow(); + await ecdsaMPCv2Utils.createOfflineRound2Share(reqMPCv2SigningRound2); + + await assert.rejects( + () => ecdsaMPCv2Utils.createOfflineRound2Share(reqMPCv2SigningRound2), + /Session state has already been used; replay is not allowed/ + ); + + // Reformatting the envelope JSON (whitespace, key order, extra field) must not mint + // a fresh digest for the same ciphertext: the digest covers the inner iv/ct only. + const reformatted = JSON.stringify( + { extraField: 'ignored', ...JSON.parse(reqMPCv2SigningRound2.encryptedRound1Session) }, + null, + 2 + ); + await assert.rejects( + () => + ecdsaMPCv2Utils.createOfflineRound2Share({ + ...reqMPCv2SigningRound2, + encryptedRound1Session: reformatted, + }), + /Session state has already been used; replay is not allowed/ + ); + }); + + it('should reject a replayed encryptedRound2Session', async () => { + const { bitgoSession, reqMPCv2SigningRound2, resMPCv2SigningRound1 } = await createRound2Flow(); + const resMPCv2SigningRound2 = await ecdsaMPCv2Utils.createOfflineRound2Share(reqMPCv2SigningRound2); + const { txRequest: txRequestRound2 } = await signBitgoMPCv2Round2( + bitgoSession, + reqMPCv2SigningRound2.txRequest, + resMPCv2SigningRound2.signatureShareRound2, + resMPCv2SigningRound1.userGpgPubKey + ); + + const reqMPCv2SigningRound3 = { + txRequest: txRequestRound2, + prv: userShare.toString('base64'), + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKey.public, + encryptedUserGpgPrvKey: resMPCv2SigningRound1.encryptedUserGpgPrvKey, + encryptedRound2Session: resMPCv2SigningRound2.encryptedRound2Session, + }; + await ecdsaMPCv2Utils.createOfflineRound3Share(reqMPCv2SigningRound3); + + await assert.rejects( + () => ecdsaMPCv2Utils.createOfflineRound3Share(reqMPCv2SigningRound3), + /Session state has already been used; replay is not allowed/ + ); + + const reformatted = JSON.stringify( + { extraField: 'ignored', ...JSON.parse(reqMPCv2SigningRound3.encryptedRound2Session) }, + null, + 2 + ); + await assert.rejects( + () => + ecdsaMPCv2Utils.createOfflineRound3Share({ + ...reqMPCv2SigningRound3, + encryptedRound2Session: reformatted, + }), + /Session state has already been used; replay is not allowed/ + ); + }); + + it('should reject a non-BitGo GPG key when env requires BitGo pub key config', async () => { + const mockBg = {} as BitGoBase; + mockBg.getEnv = sinon.stub().returns('prod'); + mockBg.encrypt = sinon.stub().callsFake(async (params: { input: string }) => params.input); + mockBg.decrypt = sinon.stub().callsFake(async (params: { input: string }) => params.input); + const mockCoin = {} as IBaseCoin; + mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash); + const prodUtils = new EcdsaMPCv2Utils(mockBg, mockCoin); + + const baseParams = { + txRequest: baseTxRequest, + prv: userShare.toString('base64'), + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKey.public, + encryptedUserGpgPrvKey: 'unused', + }; + + await assert.rejects( + () => prodUtils.createOfflineRound2Share({ ...baseParams, encryptedRound1Session: 'unused' }), + /Invalid BitGo GPG public key/ + ); + await assert.rejects( + () => prodUtils.createOfflineRound3Share({ ...baseParams, encryptedRound2Session: 'unused' }), + /Invalid BitGo GPG public key/ + ); + }); + + it('should reject BitGo round-1 messages minted for a different signing session', async () => { + // Two genuine round-1 sessions for the same signable, simulating what a relay can + // assemble: session A's WaitMsg1 blob is restored while the incoming BitGo round-1 + // output comes from session B, re-wrapped under session A's user GPG key. The DSG + // session transcript binds the restored state to the original session, so the wasm + // must reject the cross-session mix before the nonce advances. + const flowA = await createRound2Flow(); + + const resMPCv2SigningRound1B = await ecdsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userShare.toString('base64'), + walletPassphrase, + }); + const hashBuffer = createKeccakHash('keccak256').update(Buffer.from(tMessage, 'hex')).digest(); + const bitgoSessionB = new DklsDsg.Dsg(bitgoShare, 2, derivationPath, hashBuffer); + const txRequestRound1B = await signBitgoMPCv2Round1( + bitgoSessionB, + baseTxRequest, + resMPCv2SigningRound1B.signatureShareRound1, + flowA.resMPCv2SigningRound1.userGpgPubKey + ); + + await assert.rejects( + () => + ecdsaMPCv2Utils.createOfflineRound2Share({ + ...flowA.reqMPCv2SigningRound2, + txRequest: txRequestRound1B, + }), + /Error while creating messages from party 0|Signing aborted/ + ); + }); + + it('should reject an expired or future-stamped encryptedRound1Session', async () => { + const { reqMPCv2SigningRound2 } = await createRound2Flow(); + + // The stamp validator runs before the nonce is restored, so rewriting the stamped + // adata in the envelope is enough to exercise the expiry path (mirrors the EdDSA suite). + const envelope = JSON.parse(reqMPCv2SigningRound2.encryptedRound1Session) as { adata: string }; + const [state, , ...rest] = decodeURIComponent(envelope.adata).split(':'); + const expiredAdata = [state, String(Date.now() - 31 * 24 * 3600 * 1000), ...rest].join(':'); + await assert.rejects( + () => + ecdsaMPCv2Utils.createOfflineRound2Share({ + ...reqMPCv2SigningRound2, + encryptedRound1Session: JSON.stringify({ ...envelope, adata: expiredAdata }), + }), + /Encrypted session envelope has expired; restart the signing session/ + ); + + // A minted stamp beyond the clock-skew tolerance is rejected by the same branch. + const futureAdata = [state, String(Date.now() + 61_000), ...rest].join(':'); + await assert.rejects( + () => + ecdsaMPCv2Utils.createOfflineRound2Share({ + ...reqMPCv2SigningRound2, + encryptedRound1Session: JSON.stringify({ ...envelope, adata: futureAdata }), + }), + /Encrypted session envelope has expired; restart the signing session/ + ); + }); + }); + describe('createOfflineRound1Share and createOfflineRound2Share - encryptionVersion: 1 in v1 path', async () => { let ecdsaMPCv2UtilsWithSpy: EcdsaMPCv2Utils; let encryptAsyncSpy: sinon.SinonStub; before(async () => { const mockBg = {} as BitGoBase; - mockBg.getEnv = sinon.stub().returns('test'); + mockBg.getEnv = sinon.stub().returns('mock'); const encryptImpl = (params: { password: string; input: string; adata?: string }) => { const salt = randomBytes(8); const iv = randomBytes(16);