From 90596a422975ba4c5f1a22afc28f05cade13cdb1 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Tue, 10 Nov 2026 12:05:30 +0530 Subject: [PATCH] fix: validate BitGo GPG key and session state in EdDSA MPCv2 offline signing Aligns the EdDSA MPCv2 offline round-2/round-3 helpers with the existing keygen helpers: the BitGo GPG public key is validated against the known BitGo key table in the envs that already enforce it for keygen, and round session state is tracked so each encrypted session blob is processed at most once, sharing the express ConsumedShareRegistry with the ECDSA MuDelta/S shares. No public API or wire-format changes. Ticket: WAL-2455 --- modules/express/src/clientRoutes.ts | 45 ++- modules/express/src/expressApp.ts | 8 + .../test/unit/clientRoutes/externalSign.ts | 77 ++++- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 200 ++++++++++- modules/sdk-core/src/index.ts | 2 + .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 315 +++++++++++++++++- 6 files changed, 613 insertions(+), 34 deletions(-) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 8e3c082ba7..29679a4d56 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -34,6 +34,7 @@ import { ShareType, SignShare, SShare, + computeEncryptedSessionDigest, TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage, UnsupportedCoinError, @@ -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 { try { return await bg.decrypt({ password: walletPw, input: encryptedPrivKey }); @@ -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.` diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index 90e0bce48e..f2cb1e4605 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -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'; @@ -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 && diff --git a/modules/express/test/unit/clientRoutes/externalSign.ts b/modules/express/test/unit/clientRoutes/externalSign.ts index fc9e6a1ed3..069d4e633a 100644 --- a/modules/express/test/unit/clientRoutes/externalSign.ts +++ b/modules/express/test/unit/clientRoutes/externalSign.ts @@ -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'; @@ -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( @@ -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(); @@ -928,6 +956,7 @@ describe('External signer', () => { round1Stub.restore(); round2Stub.restore(); round3Stub.restore(); + fs.promises.unlink(`${stubTestSignerPath}.consumedShares.json`).catch(() => undefined); } }); @@ -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: '' })); @@ -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); @@ -1368,7 +1405,7 @@ describe('External signer', () => { txRequest: txRequestRound1, encryptedRound1Session: round1Result.encryptedRound1Session, encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, - bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + bitgoPublicGpgKey: pinnedBitgoGpgKey, }, decoded: { coin: 'tsol', @@ -1376,10 +1413,10 @@ describe('External signer', () => { 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); @@ -1407,7 +1444,7 @@ describe('External signer', () => { txRequest: txRequestRound2, encryptedRound2Session: round2Result.encryptedRound2Session, encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, - bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + bitgoPublicGpgKey: pinnedBitgoGpgKey, }, decoded: { coin: 'tsol', @@ -1415,10 +1452,10 @@ describe('External signer', () => { 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); @@ -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(); @@ -1481,11 +1527,14 @@ describe('External signer', () => { function createEddsaMPCv2GenerateShareRequest( bitgo: BitGo, walletId: string, - sharetype: string + sharetype: string, + extraBody: Record = {}, + signerFileSystemPath = 'signerFileSystemPath' ): ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'> { return { bitgo, body: { + ...extraBody, txRequest: { txRequestId: '123456', apiVersion: 'full', @@ -1510,7 +1559,7 @@ function createEddsaMPCv2GenerateShareRequest( sharetype, }, config: { - signerFileSystemPath: 'signerFileSystemPath', + signerFileSystemPath, }, } as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>; } 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 0114ef0b86..0c519dccaa 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,4 +1,5 @@ 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'; @@ -60,6 +61,168 @@ 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); + } +} + export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; @@ -981,7 +1144,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { try { const encryptedRound1Session = await session.encrypt( sessionPayload, - `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}` + `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${Date.now()}:${adata}` ); const encryptedUserGpgPrvKey = await session.encrypt( userGpgKey.privateKey, @@ -996,7 +1159,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { const encryptedRound1Session = await this.bitgo.encrypt({ input: sessionPayload, password: walletPassphrase, - adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${adata}`, + adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE}:${Date.now()}:${adata}`, encryptionVersion: 1, }); const encryptedUserGpgPrvKey = await this.bitgo.encrypt({ @@ -1017,12 +1180,23 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { 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 { walletPassphrase, encryptedUserGpgPrvKey, encryptedRound1Session, bitgoPublicGpgKey, txRequest } = params; + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoEddsaMpcv2PubKey(bitgoPublicGpgKey), 'Invalid BitGo GPG public key'); + } + // Early replay check; the blob is only marked consumed after 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 { signableHex, derivationPath } = this.getSignableHexAndDerivationPath( txRequest, 'Unable to find transactions in txRequest' @@ -1057,7 +1231,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { const bitgoDeserializedMsg1 = await verifyPeerMessageRoundOne(parsedBitGoToUserSigShareRoundOne, bitgoGpgKey); - this.validateAdata(adata, encryptedRound1Session, EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE); + validateDsgSessionAdata(encryptedRound1Session, EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE, adata); const decryptedRound1Session = await this.bitgo.decrypt({ input: encryptedRound1Session, @@ -1070,6 +1244,10 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { }; const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + // 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 userDsg.restoreSession(dsgSession); const userMsg1: MPSTypes.DeserializedMessage = { from: MPCv2PartiesEnum.USER, @@ -1090,7 +1268,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { try { const encryptedRound2Session = await session.encrypt( sessionPayload, - `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${adata}` + `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${Date.now()}:${adata}` ); return { signatureShareRound2, encryptedRound2Session }; } finally { @@ -1101,7 +1279,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { const encryptedRound2Session = await this.bitgo.encrypt({ input: sessionPayload, password: walletPassphrase, - adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${adata}`, + adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${Date.now()}:${adata}`, encryptionVersion: 1, }); @@ -1116,11 +1294,19 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { 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 { walletPassphrase, encryptedUserGpgPrvKey, encryptedRound2Session, bitgoPublicGpgKey, txRequest } = params; + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoEddsaMpcv2PubKey(bitgoPublicGpgKey), 'Invalid BitGo GPG public key'); + } + const round2SessionDigest = computeEncryptedSessionDigest(encryptedRound2Session); + assertEncryptedSessionNotConsumed(round2SessionDigest); + const { signableHex, derivationPath } = this.getSignableHexAndDerivationPath( txRequest, 'Unable to find transactions in txRequest' @@ -1154,7 +1340,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { const bitgoDeserializedMsg2 = await verifyPeerMessageRoundTwo(parsedBitGoToUserSigShareRoundTwo, bitgoGpgKey); - this.validateAdata(adata, encryptedRound2Session, EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE); + validateDsgSessionAdata(encryptedRound2Session, EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE, adata); const decryptedRound2Session = await this.bitgo.decrypt({ input: encryptedRound2Session, @@ -1167,6 +1353,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { }; const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + params.markSessionConsumed?.(); + markEncryptedSessionConsumed(round2SessionDigest); await userDsg.restoreSession(dsgSession); const userMsg2: MPSTypes.DeserializedMessage = { from: MPCv2PartiesEnum.USER, diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index 2d1766558a..ec7e55f11a 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -33,6 +33,8 @@ 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 { envRequiresBitgoPubGpgKeyConfig } from './bitgo/tss/bitgoPubKeys'; export { getEddsaSigningMaterial, isEddsaSigningMaterial, diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index e05fe33146..9117cc9e43 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -604,9 +604,9 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { const encryptedRound1Session = JSON.parse(result.encryptedRound1Session); const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey); - assert.strictEqual( + assert.match( decodeURIComponent(encryptedRound1Session.adata), - `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`, + new RegExp(`^MPS_DSG_SIGNING_ROUND1_STATE:\\d+:${expectedAdata}$`), 'round-1 session adata should bind the signing context' ); assert.strictEqual( @@ -644,7 +644,7 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { const encryptedRound1Session = JSON.parse(result.encryptedRound1Session); const encryptedUserGpgPrvKey = JSON.parse(result.encryptedUserGpgPrvKey); assert.strictEqual(encryptedRound1Session.v, 2); - assert.strictEqual(encryptedRound1Session.adata, `MPS_DSG_SIGNING_ROUND1_STATE:${expectedAdata}`); + assert.match(encryptedRound1Session.adata, new RegExp(`^MPS_DSG_SIGNING_ROUND1_STATE:\\d+:${expectedAdata}$`)); assert.strictEqual(encryptedUserGpgPrvKey.v, 2); assert.strictEqual(encryptedUserGpgPrvKey.adata, `MPS_DSG_SIGNING_USER_GPG_KEY:${expectedAdata}`); @@ -1455,6 +1455,7 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { }); }), decrypt: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)), + getEnv: sinon.stub().returns('mock'), } as unknown as BitGoBase; const mockCoin = { @@ -1464,6 +1465,181 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); }); + it('should reject a replayed encryptedRound1Session', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + await bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + const params = { + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }; + await eddsaMPCv2Utils.createOfflineRound2Share(params); + + await assert.rejects( + () => eddsaMPCv2Utils.createOfflineRound2Share(params), + /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 iv/salt/ct only. + const reformatted = JSON.stringify( + { extraField: 'ignored', ...JSON.parse(params.encryptedRound1Session) }, + null, + 2 + ); + await assert.rejects( + () => eddsaMPCv2Utils.createOfflineRound2Share({ ...params, encryptedRound1Session: reformatted }), + /Session state has already been used; replay is not allowed/ + ); + }); + + it('should reject session envelopes with non-canonical base64 iv or ct', async () => { + // A canonical v1 envelope: valid base64 iv/ct that would pass the digest gate. + const envelope = { + v: 1, + ts: 64, + salt: randomBytes(8).toString('base64'), + iv: randomBytes(16).toString('base64'), + ct: randomBytes(32).toString('base64'), + adata: 'MPS_DSG_SIGNING_ROUND1_STATE:deadbeef:m/0/0', + }; + // '=' inserted inside ct: Node's decoder stops at it while the SJCL fallback + // decoder strips it, so such a blob decrypts identically and must be rejected. + const embeddedEquals = { ...envelope, ct: envelope.ct.slice(0, 8) + '=' + envelope.ct.slice(8) }; + // extra trailing bytes on the iv (iv-extension) and internal whitespace are + // likewise decrypt-equivalent mutations that must fail the canonical check. + const ivExtended = { ...envelope, iv: envelope.iv + 'AA==' }; + const whitespace = { ...envelope, iv: envelope.iv.slice(0, 4) + ' ' + envelope.iv.slice(4) }; + for (const mutated of [embeddedEquals, ivExtended, whitespace]) { + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: baseTxRequest, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: 'unused', + encryptedRound1Session: JSON.stringify(mutated), + }), + /Session state is not a valid encrypted session envelope/ + ); + } + }); + + it('should reject an encryptedRound1Session that does not parse', async () => { + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: baseTxRequest, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: 'unused', + encryptedRound1Session: 'not-json', + }), + /Session state is not a valid encrypted session envelope/ + ); + }); + + it('should reject non-BitGo GPG key when env requires BitGo pub key config', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + await bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + const prodMockBitgo = { + ...mockBitgo, + getEnv: sinon.stub().returns('prod'), + } as unknown as BitGoBase; + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('eddsa') } as unknown as IBaseCoin; + const prodUtils = new EddsaMPCv2Utils(prodMockBitgo, mockCoin); + + await assert.rejects( + () => + prodUtils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }), + /Invalid BitGo GPG public key/ + ); + }); + + it('should invoke markSessionConsumed only after the session authenticates', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + await bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + const markConsumed = sinon.spy(); + // A failed round (wrong passphrase) must not reach the durable registry. + await assert.rejects(() => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase: 'wrongPassphrase', + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + markSessionConsumed: markConsumed, + }) + ); + assert.strictEqual(markConsumed.callCount, 0); + + await eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + markSessionConsumed: markConsumed, + }); + assert.strictEqual(markConsumed.callCount, 1); + }); + it('should create a round-2 share from offline round 1', async () => { const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ txRequest: baseTxRequest, @@ -1504,9 +1680,9 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { assert.ok(parsedShare.data.msg2.signature, 'msg2.signature should be set'); const encryptedRound2Session = JSON.parse(round2.encryptedRound2Session); - assert.strictEqual( + assert.match( decodeURIComponent(encryptedRound2Session.adata), - `MPS_DSG_SIGNING_ROUND2_STATE:${expectedAdata}` + new RegExp(`^MPS_DSG_SIGNING_ROUND2_STATE:\\d+:${expectedAdata}$`) ); const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, round2.encryptedRound2Session)); @@ -1515,9 +1691,17 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { }); it('should use v2 decryption when encryptedRound1Session is a v2 envelope', async () => { - const encrypt = sinon - .stub() - .callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata }))); + const encrypt = sinon.stub().callsFake((input: string, adata: string) => + Promise.resolve( + JSON.stringify({ + v: 2, + iv: randomBytes(12).toString('base64'), + hkdfSalt: randomBytes(16).toString('base64'), + ct: Buffer.from(input, 'utf8').toString('base64'), + adata, + }) + ) + ); const destroy = sinon.stub(); const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy }); mockBitgo.createEncryptionSession = createEncryptionSession; @@ -1543,6 +1727,9 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { const decrypt = sinon.stub().callsFake(async (params: { input: string }) => { const envelope = JSON.parse(params.input); + if (envelope.v === 2) { + return Buffer.from(envelope.ct, 'base64').toString('utf8'); + } return envelope.input; }); mockBitgo.decrypt = decrypt; @@ -1560,7 +1747,46 @@ describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { const encryptedRound2Session = JSON.parse(round2.encryptedRound2Session); assert.strictEqual(encryptedRound2Session.v, 2); - assert.strictEqual(encryptedRound2Session.adata, `MPS_DSG_SIGNING_ROUND2_STATE:${expectedAdata}`); + assert.match(encryptedRound2Session.adata, new RegExp(`^MPS_DSG_SIGNING_ROUND2_STATE:\\d+:${expectedAdata}$`)); + }); + + it('should reject an expired encryptedRound1Session', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + await bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + // The validator runs before decryption, so rewriting the stamped adata in the + // envelope is enough to exercise the expiry path. + const expiredAdata = `MPS_DSG_SIGNING_ROUND1_STATE:${ + Date.now() - 31 * 24 * 3600 * 1000 + }:${signableHex}:${derivationPath}`; + const expiredEnvelope = { ...JSON.parse(round1.encryptedRound1Session), adata: expiredAdata }; + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: JSON.stringify(expiredEnvelope), + }), + /Encrypted session envelope has expired; restart the signing session/ + ); }); it('should reject tampered encryptedRound1Session adata', async () => { @@ -1796,6 +2022,7 @@ describe('EddsaMPCv2Utils.createOfflineRound3Share', () => { }); }), decrypt: sinon.stub().callsFake(async (params) => sjcl.decrypt(params.password, params.input)), + getEnv: sinon.stub().returns('mock'), } as unknown as BitGoBase; const mockCoin = { @@ -1805,6 +2032,59 @@ describe('EddsaMPCv2Utils.createOfflineRound3Share', () => { eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); }); + it('should reject a replayed encryptedRound2Session', async () => { + const { round1, round2, txRequestRound2 } = await createRound2Flow(baseTxRequest); + + const params = { + txRequest: txRequestRound2, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound2Session: round2.encryptedRound2Session, + }; + await eddsaMPCv2Utils.createOfflineRound3Share(params); + + await assert.rejects( + () => eddsaMPCv2Utils.createOfflineRound3Share(params), + /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/salt/ct only. + const reformatted = JSON.stringify( + { extraField: 'ignored', ...JSON.parse(params.encryptedRound2Session) }, + null, + 2 + ); + await assert.rejects( + () => eddsaMPCv2Utils.createOfflineRound3Share({ ...params, encryptedRound2Session: reformatted }), + /Session state has already been used; replay is not allowed/ + ); + }); + + it('should reject non-BitGo GPG key when env requires BitGo pub key config', async () => { + const { round1, round2, txRequestRound2 } = await createRound2Flow(baseTxRequest); + + const prodMockBitgo = { + ...mockBitgo, + getEnv: sinon.stub().returns('prod'), + } as unknown as BitGoBase; + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('eddsa') } as unknown as IBaseCoin; + const prodUtils = new EddsaMPCv2Utils(prodMockBitgo, mockCoin); + + await assert.rejects( + () => + prodUtils.createOfflineRound3Share({ + txRequest: txRequestRound2, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound2Session: round2.encryptedRound2Session, + }), + /Invalid BitGo GPG public key/ + ); + }); + async function createRound2Flow( txRequest: TxRequest, encryptedPrv?: string @@ -1896,15 +2176,26 @@ describe('EddsaMPCv2Utils.createOfflineRound3Share', () => { }); it('should use v2 decryption when encryptedRound2Session is a v2 envelope', async () => { - const encrypt = sinon - .stub() - .callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata }))); + const encrypt = sinon.stub().callsFake((input: string, adata: string) => + Promise.resolve( + JSON.stringify({ + v: 2, + iv: randomBytes(12).toString('base64'), + hkdfSalt: randomBytes(16).toString('base64'), + ct: Buffer.from(input, 'utf8').toString('base64'), + adata, + }) + ) + ); const destroy = sinon.stub(); const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy }); mockBitgo.createEncryptionSession = createEncryptionSession; const decrypt = sinon.stub().callsFake(async (params: { input: string }) => { const envelope = JSON.parse(params.input); + if (envelope.v === 2) { + return Buffer.from(envelope.ct, 'base64').toString('utf8'); + } return envelope.input; }); mockBitgo.decrypt = decrypt;