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);