Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 13 additions & 37 deletions backend/protocol_rpc/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
NotFoundError,
QueueDepthExceeded,
)
from backend.protocol_rpc.redaction import (
sanitize_rpc_private_keys as _sanitize_rpc_private_keys,
)
from backend.protocol_rpc.contract_storage_quota import (
enforce_contract_storage_quota,
live_state_column_size,
Expand Down Expand Up @@ -167,40 +170,6 @@ def __init__(
_gen_call_singleflight_lock = asyncio.Lock()


def _show_validator_private_keys_in_rpc() -> bool:
return os.getenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}


def _is_private_key_field(key: Any) -> bool:
if not isinstance(key, str):
return False
normalized = key.replace("_", "").replace("-", "").lower()
return normalized == "privatekey" or normalized.endswith("privatekey")


def _sanitize_rpc_private_keys(value: Any) -> Any:
"""Return RPC data with private-key fields removed unless explicitly enabled."""
if _show_validator_private_keys_in_rpc():
return value

if isinstance(value, dict):
return {
key: _sanitize_rpc_private_keys(item)
for key, item in value.items()
if not _is_private_key_field(key)
}
if isinstance(value, list):
return [_sanitize_rpc_private_keys(item) for item in value]
if isinstance(value, tuple):
return tuple(_sanitize_rpc_private_keys(item) for item in value)
return value


def _check_rate_limit(address: str) -> None:
"""Reject if address exceeds rate limit. Prunes old entries."""
now = time.monotonic()
Expand Down Expand Up @@ -1616,7 +1585,9 @@ async def sim_call(
genvm_manager,
params,
)
return TransactionsProcessor._json_safe_numbers(receipt.to_dict())
return TransactionsProcessor._json_safe_numbers(
_sanitize_rpc_private_keys(receipt.to_dict())
)


async def sim_estimate_transaction_fees(
Expand Down Expand Up @@ -1920,7 +1891,10 @@ async def execute(active_fee_accounting):
raise JSONRPCError(
code=-32000,
message="execution failed",
data={"receipt": receipt.to_dict(), "params": params},
data={
"receipt": _sanitize_rpc_private_keys(receipt.to_dict()),
"params": params,
},
)

return receipt
Expand Down Expand Up @@ -2474,7 +2448,9 @@ async def eth_call(

if receipt.execution_result != ExecutionResultStatus.SUCCESS:
raise JSONRPCError(
code=-32000, message="execution failed", data={"receipt": receipt.to_dict()}
code=-32000,
message="execution failed",
data={"receipt": _sanitize_rpc_private_keys(receipt.to_dict())},
)
return eth_utils.hexadecimal.encode_hex(receipt.result[1:])

Expand Down
9 changes: 6 additions & 3 deletions backend/protocol_rpc/explorer/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
TransactionStatus,
Validators,
)
from backend.protocol_rpc.redaction import sanitize_rpc_private_keys


def _serialize_tx(
Expand All @@ -30,7 +31,7 @@ def _serialize_tx(
"to_address": tx.to_address,
"input_data": tx.input_data,
"data": tx.data,
"consensus_data": tx.consensus_data,
"consensus_data": sanitize_rpc_private_keys(tx.consensus_data),
"nonce": tx.nonce,
"value": tx.value,
"type": tx.type,
Expand All @@ -42,14 +43,16 @@ def _serialize_tx(
"s": tx.s,
"v": tx.v,
"appeal_failed": tx.appeal_failed,
"consensus_history": tx.consensus_history,
"consensus_history": sanitize_rpc_private_keys(tx.consensus_history),
"timestamp_appeal": tx.timestamp_appeal,
"appeal_processing_time": tx.appeal_processing_time,
"config_rotation_rounds": tx.config_rotation_rounds,
"num_of_initial_validators": tx.num_of_initial_validators,
"last_vote_timestamp": tx.last_vote_timestamp,
"rotation_count": tx.rotation_count,
"leader_timeout_validators": tx.leader_timeout_validators,
"leader_timeout_validators": sanitize_rpc_private_keys(
tx.leader_timeout_validators
),
"sim_config": tx.sim_config,
"triggered_by_hash": tx.triggered_by_hash,
"triggered_on": tx.triggered_on,
Expand Down
44 changes: 44 additions & 0 deletions backend/protocol_rpc/redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Redaction of validator private keys from RPC-facing payloads.

Receipts carry the executing validator's ``node_config`` (including its
``private_key``); anything that returns a receipt to a client must pass
through :func:`sanitize_rpc_private_keys`. Shared by the JSON-RPC endpoints and
the explorer REST API.
"""

import os
from typing import Any


def show_validator_private_keys_in_rpc() -> bool:
return os.getenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}


def is_private_key_field(key: Any) -> bool:
if not isinstance(key, str):
return False
normalized = key.replace("_", "").replace("-", "").lower()
return normalized == "privatekey" or normalized.endswith("privatekey")


def sanitize_rpc_private_keys(value: Any) -> Any:
"""Return RPC data with private-key fields removed unless explicitly enabled."""
if show_validator_private_keys_in_rpc():
return value

if isinstance(value, dict):
return {
key: sanitize_rpc_private_keys(item)
for key, item in value.items()
if not is_private_key_field(key)
}
if isinstance(value, list):
return [sanitize_rpc_private_keys(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_rpc_private_keys(item) for item in value)
return value
232 changes: 232 additions & 0 deletions tests/unit/protocol_rpc/test_receipt_private_key_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
"""Receipts returned or raised by the call endpoints and the explorer API must
not expose the executing validator's private key (see the redaction added for
the transaction/block getters in #1731)."""

from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from backend.domain.types import LLMProvider, Validator
from backend.node.genvm import base as genvmbase
from backend.protocol_rpc import endpoints
from backend.protocol_rpc.exceptions import JSONRPCError
from backend.protocol_rpc.explorer import queries
from backend.validators import SingleValidatorSnapshot, Snapshot

SECRET = "0x" + "ab" * 32
CONTRACT = "0x8ba1f109551bD432803012645Ac136ddd64DBA72"
SENDER = "0x9F0e84243496AcFB3Cd99D02eA59673c05901501"


def _provider() -> LLMProvider:
return LLMProvider(
provider="openai",
model="gpt-4o",
config={},
plugin="openai-compatible",
plugin_config={
"api_key_env_var": "OPENAIKEY",
"api_url": "https://api.openai.com",
},
)


class _FakeContractSnapshot:
contract_address = CONTRACT
balance = 0
states = {"accepted": {}, "finalized": {}}
genvm_executor_selector = None

def __init__(self, *_args, **_kwargs):
pass


def _fake_run_genvm_host(result):
async def run(host_supplier, **_kwargs):
return genvmbase.ExecutionResult(
result=result,
eq_outputs={},
pending_transactions=[],
stdout="",
stderr="",
genvm_log=[],
state=host_supplier.keywords["state_proxy"],
processing_time=1,
nondet_disagree=None,
)

return run


def _call_dependencies(monkeypatch, result):
monkeypatch.delenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", raising=False)
monkeypatch.setattr(genvmbase, "run_genvm_host", _fake_run_genvm_host(result))
monkeypatch.setattr(endpoints, "ContractSnapshot", _FakeContractSnapshot)
monkeypatch.setattr(endpoints, "_check_rate_limit", lambda _address: None)

validator = Validator(
address=SENDER, stake=1, llmprovider=_provider(), private_key=SECRET
)
snapshot = Snapshot(
nodes=[SingleValidatorSnapshot(validator, {"node_address": SENDER})]
)

@asynccontextmanager
async def _snapshot():
yield snapshot

validators_manager = MagicMock()
validators_manager.snapshot = _snapshot

accounts_manager = MagicMock()
accounts_manager.is_valid_address.return_value = True

transactions_parser = MagicMock()
transactions_parser.decode_method_call_data.return_value = MagicMock(
calldata=b"\x00"
)

msg_handler = MagicMock()
msg_handler.with_client_session.return_value = MagicMock(client_session_id=None)

return dict(
session=MagicMock(),
accounts_manager=accounts_manager,
msg_handler=msg_handler,
transactions_parser=transactions_parser,
validators_manager=validators_manager,
genvm_manager=MagicMock(url="http://127.0.0.1:1"),
)


@pytest.mark.asyncio
async def test_sim_call_result_redacts_validator_private_key(monkeypatch):
deps = _call_dependencies(monkeypatch, genvmbase.ExecutionReturn(ret=b"\x00"))

result = await endpoints.sim_call(
**deps,
params={"type": "read", "data": "0x00", "to": CONTRACT, "from": SENDER},
)

assert result["node_config"]["address"] == SENDER
assert "private_key" not in result["node_config"]


@pytest.mark.asyncio
async def test_gen_call_execution_failed_error_redacts_validator_private_key(
monkeypatch,
):
deps = _call_dependencies(
monkeypatch,
genvmbase.ExecutionError(message="boom", kind=genvmbase.ResultCode.USER_ERROR),
)

with pytest.raises(JSONRPCError) as exc:
await endpoints.gen_call(
**deps,
params={"type": "read", "data": "0x00", "to": CONTRACT, "from": SENDER},
)

receipt = exc.value.to_dict()["data"]["receipt"]
assert receipt["node_config"]["address"] == SENDER
assert "private_key" not in receipt["node_config"]


@pytest.mark.asyncio
async def test_eth_call_execution_failed_error_redacts_validator_private_key(
monkeypatch,
):
deps = _call_dependencies(
monkeypatch,
genvmbase.ExecutionError(message="boom", kind=genvmbase.ResultCode.USER_ERROR),
)
monkeypatch.setattr(endpoints, "handle_consensus_data_call", lambda *_a: None)

with pytest.raises(JSONRPCError) as exc:
await endpoints.eth_call(
**deps,
transactions_processor=MagicMock(),
params={"to": CONTRACT, "from": SENDER, "data": "0x00"},
)

receipt = exc.value.to_dict()["data"]["receipt"]
assert "private_key" not in receipt["node_config"]


def _explorer_transaction():
node_config = {"address": SENDER, "private_key": SECRET}
return SimpleNamespace(
hash="0xabc",
status=SimpleNamespace(value="FINALIZED"),
from_address=SENDER,
to_address=CONTRACT,
input_data=None,
data=None,
consensus_data={
"leader_receipt": [{"node_config": dict(node_config)}],
"validators": [{"node_config": dict(node_config)}],
},
nonce=0,
value=0,
type=2,
gaslimit=None,
created_at=None,
leader_only=False,
execution_mode=None,
r=None,
s=None,
v=None,
appeal_failed=0,
consensus_history={
"consensus_results": [
{"leader_result": [{"node_config": dict(node_config)}]}
]
},
timestamp_appeal=None,
appeal_processing_time=0,
config_rotation_rounds=None,
num_of_initial_validators=None,
last_vote_timestamp=None,
rotation_count=0,
leader_timeout_validators=[dict(node_config)],
sim_config=None,
triggered_by_hash=None,
triggered_on=None,
appealed=False,
appeal_undetermined=False,
appeal_leader_timeout=False,
appeal_validators_timeout=False,
timestamp_awaiting_finalization=None,
blocked_at=None,
worker_id=None,
)


def test_explorer_serialize_tx_redacts_private_keys(monkeypatch):
monkeypatch.delenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", raising=False)

serialized = queries._serialize_tx(_explorer_transaction())

assert serialized["consensus_data"]["leader_receipt"][0]["node_config"] == {
"address": SENDER
}
assert serialized["consensus_data"]["validators"][0]["node_config"] == {
"address": SENDER
}
assert serialized["consensus_history"]["consensus_results"][0]["leader_result"][0][
"node_config"
] == {"address": SENDER}
assert serialized["leader_timeout_validators"] == [{"address": SENDER}]


def test_explorer_serialize_tx_can_show_private_keys_for_local_debug(monkeypatch):
monkeypatch.setenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", "true")

serialized = queries._serialize_tx(_explorer_transaction())

assert (
serialized["consensus_data"]["leader_receipt"][0]["node_config"]["private_key"]
== SECRET
)
Loading