From b80352e512c2328764ec8d7fa74afd672ff4e7f4 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 9 Apr 2026 14:43:30 +0700 Subject: [PATCH] fix(export): verify multisign signatures at ingestion time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3: Cryptographically verify each export signature blob against the inner transaction's signing data before storing in the collector. Looks up the ttEXPORT tx from the open ledger, reconstructs the signing data via buildMultiSigningData, and calls verify(). If the tx isn't in our open ledger yet (timing/relay), the sig is stored unverified as a fallback — it can be verified later at the SHAMap merge path or will be rejected at Export::doApply if invalid. This runs on the jtPROPOSAL_t job queue thread (not the IO strand or transactor), so the verify() cost has no impact on consensus critical path performance. --- .../app/consensus/ConsensusExtensions.cpp | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index a156aafc4..5de26d02b 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -1518,7 +1518,27 @@ ConsensusExtensions::onTrustedPeerMessage( } } - // Pass 2: commit validated sigs. + // Pass 2: verify multisign signatures and commit. + // Look up each export tx from the open ledger to reconstruct the + // signing data. This runs on a jtPROPOSAL_t job queue thread + // (post-C1 fix), so the verify() cost doesn't block the IO strand + // or the single-threaded transactor. + auto const openLedger = app_.openLedger().current(); + + // Build a txHash -> STTx lookup for ttEXPORT txns in the open ledger. + // Avoids O(N*M) iteration when processing multiple export sig blobs. + std::unordered_map> exportTxns; + if (openLedger) + { + for (auto const& [stx, meta] : openLedger->txs) + { + if (stx && stx->getTxnType() == ttEXPORT) + exportTxns.emplace(stx->getTransactionID(), stx); + } + } + + auto const signerAcctID = calcAccountID(senderPK); + for (int i = 0; i < wireMsg.exportsignatures_size(); ++i) { auto const& blob = wireMsg.exportsignatures(i); @@ -1529,17 +1549,55 @@ ConsensusExtensions::onTrustedPeerMessage( uint256 txHash; std::memcpy(txHash.data(), blob.data(), 32); - if (blob.size() > 65) - { - auto const fullSlice = makeSlice(blob); - auto const sigSlice = fullSlice.substr(65); - Buffer sigBuf(sigSlice.data(), sigSlice.size()); - exportSigCollector_.addSignature(txHash, senderPK, sigBuf); - } - else + if (blob.size() <= 65) { + // Pubkey-only entry (no real signature). exportSigCollector_.addSignature(txHash, senderPK); + continue; } + + auto const fullSlice = makeSlice(blob); + auto const sigSlice = fullSlice.substr(65); + Buffer sigBuf(sigSlice.data(), sigSlice.size()); + + // Verify the multisign signature against the inner tx if + // we can find it in the open ledger. If the tx isn't in + // our open ledger yet (timing / relay order), store the sig + // unverified — it will be verified at the SHAMap merge path + // or rejected at Export::doApply if invalid. + auto const txIt = exportTxns.find(txHash); + if (txIt != exportTxns.end() && + txIt->second->isFieldPresent(sfExportedTxn)) + { + try + { + auto const& exportedObj = const_cast(*txIt->second) + .peekAtField(sfExportedTxn) + .downcast(); + + Serializer innerSer; + exportedObj.add(innerSer); + SerialIter sit(innerSer.slice()); + STTx innerTx(std::ref(sit)); + + auto const sigData = + buildMultiSigningData(innerTx, signerAcctID); + if (!verify(senderPK, sigData.slice(), sigSlice)) + { + JLOG(j_.warn()) << "Export: invalid multisign sig for tx " + << txHash << " — rejected"; + continue; + } + } + catch (std::exception const& e) + { + JLOG(j_.warn()) << "Export: failed to verify sig for tx " + << txHash << ": " << e.what(); + continue; + } + } + + exportSigCollector_.addSignature(txHash, senderPK, sigBuf); } } //@@end peer-harvest-export-sigs