feat(consensus): add UNL enforcement for RNG commit-reveal pipeline

Cache active UNL NodeIDs per round from UNL Report (in-ledger),
falling back to getTrustedMasterKeys() on fresh testnets.
Reject non-UNL validators at all entry points: harvestRngData,
buildCommitSet, buildEntropySet, and handleAcquiredRngSet.
This commit is contained in:
Nicholas Dudfield
2026-02-06 11:12:03 +07:00
parent 893f8d5a10
commit 34ff53f65d
2 changed files with 89 additions and 0 deletions

View File

@@ -421,6 +421,7 @@ RCLConsensus::Adaptor::onClose(
// in our very first proposal so peers can collect it during consensus.
if (proposing && prevLedger->rules().enabled(featureConsensusEntropy))
{
cacheActiveUNL();
generateEntropySecret();
pos.myCommitment = sha512Half(
myEntropySecret_,
@@ -1160,6 +1161,9 @@ RCLConsensus::Adaptor::buildCommitSet(LedgerIndex seq)
for (auto const& [nodeId, commit] : pendingCommits_)
{
if (!isActiveUNLMember(nodeId))
continue;
auto kit = nodeIdToKey_.find(nodeId);
if (kit == nodeIdToKey_.end())
continue;
@@ -1206,6 +1210,9 @@ RCLConsensus::Adaptor::buildEntropySet(LedgerIndex seq)
for (auto const& [nodeId, reveal] : pendingReveals_)
{
if (!isActiveUNLMember(nodeId))
continue;
auto kit = nodeIdToKey_.find(nodeId);
if (kit == nodeIdToKey_.end())
continue;
@@ -1278,6 +1285,53 @@ RCLConsensus::Adaptor::clearRngState()
commitSetMap_.reset();
entropySetMap_.reset();
pendingRngFetches_.clear();
activeUNLNodeIds_.clear();
}
void
RCLConsensus::Adaptor::cacheActiveUNL()
{
activeUNLNodeIds_.clear();
// Try UNL Report from the validated ledger
if (auto const prevLedger = ledgerMaster_.getValidatedLedger())
{
if (auto const sle = prevLedger->read(keylet::UNLReport()))
{
if (sle->isFieldPresent(sfActiveValidators))
{
for (auto const& obj : sle->getFieldArray(sfActiveValidators))
{
auto const pk = obj.getFieldVL(sfPublicKey);
if (publicKeyType(makeSlice(pk)))
{
activeUNLNodeIds_.insert(
calcNodeID(PublicKey(makeSlice(pk))));
}
}
}
}
}
// Fallback to normal UNL if no report or empty
if (activeUNLNodeIds_.empty())
{
for (auto const& masterKey : app_.validators().getTrustedMasterKeys())
{
activeUNLNodeIds_.insert(calcNodeID(masterKey));
}
}
// Always include ourselves
activeUNLNodeIds_.insert(validatorKeys_.nodeID);
JLOG(j_.debug()) << "RNG: cacheActiveUNL size=" << activeUNLNodeIds_.size();
}
bool
RCLConsensus::Adaptor::isActiveUNLMember(NodeID const& nodeId) const
{
return activeUNLNodeIds_.count(nodeId) > 0;
}
bool
@@ -1360,6 +1414,13 @@ RCLConsensus::Adaptor::handleAcquiredRngSet(std::shared_ptr<SHAMap> const& map)
NodeID nodeId;
std::memcpy(nodeId.data(), acctId.data(), nodeId.size());
if (!isActiveUNLMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting non-UNL entry from "
<< nodeId << " in acquired set";
continue;
}
pendingData[nodeId] = digest;
nodeIdToKey_[nodeId] = pubKey;
++merged;
@@ -1395,6 +1456,13 @@ RCLConsensus::Adaptor::handleAcquiredRngSet(std::shared_ptr<SHAMap> const& map)
NodeID nodeId;
std::memcpy(nodeId.data(), acctId.data(), nodeId.size());
if (!isActiveUNLMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting non-UNL entry from "
<< nodeId << " in acquired set";
return;
}
pendingData[nodeId] = digest;
nodeIdToKey_[nodeId] = pubKey;
++merged;
@@ -1528,6 +1596,14 @@ RCLConsensus::Adaptor::harvestRngData(
<< " commit=" << (position.myCommitment ? "yes" : "no")
<< " reveal=" << (position.myReveal ? "yes" : "no");
// Reject data from validators not in the active UNL
if (!isActiveUNLMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting data from non-UNL validator "
<< nodeId;
return;
}
// Store nodeId -> publicKey mapping for deterministic ordering
nodeIdToKey_[nodeId] = publicKey;

View File

@@ -104,6 +104,9 @@ class RCLConsensus
// Track pending RNG set hashes we've triggered fetches for
hash_set<uint256> pendingRngFetches_;
// Cached set of NodeIDs from UNL Report (or fallback UNL)
hash_set<NodeID> activeUNLNodeIds_;
public:
using Ledger_t = RCLCxLedger;
using NodeID_t = NodeID;
@@ -240,6 +243,16 @@ class RCLConsensus
void
fetchRngSetIfNeeded(std::optional<uint256> const& hash);
/** Cache the active UNL NodeIDs for this round.
Reads from UNL Report (in-ledger), falls back to normal UNL.
*/
void
cacheActiveUNL();
/** Check if a NodeID is in the active UNL for this round */
bool
isActiveUNLMember(NodeID const& nodeId) const;
/** Generate new entropy secret for this round */
void
generateEntropySecret();