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;