Skip to content

Commit 2906239

Browse files
Merge pull request #151 from oasisprotocol/uniyalabhishek/security/rofl-auth-token-key-sync
accounting: encrypt AuthToken key sync
2 parents 4dd85d2 + a267e9e commit 2906239

7 files changed

Lines changed: 185 additions & 16 deletions

File tree

src/auth/auth_token_service.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ def initialize(self) -> None:
7474
# key_manager should already be initialized by main.py lifespan
7575
enc_key = key_manager.enc_key
7676
self._aead = AEAD(enc_key)
77-
logger.info(
78-
"AuthTokenService initialized with key prefix: %s...",
79-
enc_key[:4].hex(),
80-
)
77+
logger.info("AuthTokenService initialized")
8178

8279
@property
8380
def aead(self) -> AEAD:

src/clients/rofl.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@
33
import base64
44
import logging
55
from dataclasses import dataclass
6+
from typing import Final
67

78
import cbor2
89
from eth_account import Account
10+
from hexbytes import HexBytes
911
from oasis_rofl_client import AsyncRoflClient
1012
from web3.types import TxParams
1113

1214
from src.abi.accounting import get_error_name
1315

1416
logger = logging.getLogger(__name__)
1517

18+
# Plaintext ROFL submissions expose calldata before Sapphire execution. Keep this
19+
# empty unless a reviewed transaction selector has a concrete need to be public.
20+
# Entries are lowercase initial 4-byte function selectors without the 0x prefix.
21+
_PLAINTEXT_TX_SELECTOR_ALLOWLIST: Final[frozenset[str]] = frozenset()
22+
1623

1724
@dataclass
1825
class RoflSubmissionResult:
@@ -67,6 +74,32 @@ def _decode_revert_reason(raw_message: str | None) -> str:
6774
return raw_message
6875

6976

77+
def _tx_selector_hex(tx: TxParams) -> str:
78+
data = tx.get("data")
79+
if data is None:
80+
raise ValueError("Transaction must include 'data' field")
81+
82+
try:
83+
data_bytes = HexBytes(data)
84+
except Exception as exc:
85+
raise ValueError("Transaction data must be valid hex") from exc
86+
87+
if len(data_bytes) < 4:
88+
raise ValueError("Plaintext transaction data must include a 4-byte selector")
89+
return bytes(data_bytes[:4]).hex()
90+
91+
92+
def _require_plaintext_allowlisted(tx: TxParams) -> str:
93+
selector = _tx_selector_hex(tx)
94+
if selector not in _PLAINTEXT_TX_SELECTOR_ALLOWLIST:
95+
raise ValueError(
96+
"Plaintext ROFL transaction submission is not allowed for selector "
97+
f"{selector}. Add a reviewed selector allow-list entry before calling "
98+
"submit_tx(..., encrypt=False)."
99+
)
100+
return selector
101+
102+
70103
# Dedicated key for authenticating signed view queries on Sapphire (onlyROFLQuery modifier).
71104
# Published on-chain at startup via setRoflSignerAddress so msg.sender checks succeed.
72105
ROFL_QUERY_SIGNER_KEY = "rofl_query_signer.key"
@@ -129,12 +162,12 @@ async def get_keypair(self, key_id: str = ROFL_QUERY_SIGNER_KEY):
129162
logger.error(f"Error generating keypair: {e}")
130163
raise
131164

132-
async def submit_tx(self, tx: TxParams, encrypt: bool = False) -> RoflSubmissionResult:
165+
async def submit_tx(self, tx: TxParams, encrypt: bool = True) -> RoflSubmissionResult:
133166
"""Submit a transaction to the ROFL daemon for signing and relay.
134167
135168
Args:
136169
tx: Transaction parameters (must include 'to', 'data', 'gas', 'value')
137-
encrypt: Whether to encrypt the transaction (default: False)
170+
encrypt: Whether to encrypt the transaction (default: True)
138171
139172
Returns:
140173
RoflSubmissionResult: Submission ID and optional ok payload (ABI-encoded return value)
@@ -147,11 +180,20 @@ async def submit_tx(self, tx: TxParams, encrypt: bool = False) -> RoflSubmission
147180
if "to" not in tx or "data" not in tx:
148181
raise ValueError("Transaction must include 'to' and 'data' fields")
149182

183+
plaintext_selector = None
184+
if not encrypt:
185+
plaintext_selector = _require_plaintext_allowlisted(tx)
186+
150187
logger.info(
151188
"Submitting transaction via ROFL to %s with gas %s",
152189
tx["to"],
153190
tx.get("gas"),
154191
)
192+
if plaintext_selector is not None:
193+
logger.warning(
194+
"Submitting allow-listed plaintext ROFL transaction selector %s",
195+
plaintext_selector,
196+
)
155197

156198
try:
157199
# AsyncRoflClient.sign_submit returns decoded CBOR directly

src/main.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
settings = load_settings()
3333

3434
logging.getLogger("httpx").setLevel(logging.WARNING)
35+
# WARNING: oasis-rofl-client DEBUG logs include full tx payloads before appd encryption!
3536
logging.getLogger().setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
3637

3738

@@ -88,13 +89,10 @@ async def lifespan(_app: FastAPI):
8889
if not os.getenv("DISABLE_ROFL_KEYS"):
8990
try:
9091
await auth_token_key_manager.sync_key_to_contract()
91-
logger.info("AuthToken encryption key synced to contract")
92-
except Exception as e:
93-
logger.warning(
94-
f"Failed to sync AuthToken encryption key to contract: {e}. "
95-
"Continuing startup - on fresh deployments the key will be set later, "
96-
"on restarts the key may already be set."
97-
)
92+
except Exception:
93+
logger.exception("Failed to sync AuthToken encryption key to contract")
94+
raise
95+
logger.info("AuthToken encryption key synced to contract")
9896

9997
await bootstrap_rofl_signer_address(get_accounting_contract_service())
10098

src/services/accounting_contract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,7 @@ async def set_auth_token_enc_key(self, enc_key: bytes) -> None:
11671167
"gas": self.gas_limit,
11681168
"data": Web3.to_hex(data),
11691169
}
1170-
await self.rofl_client.submit_tx(tx)
1170+
await self.rofl_client.submit_tx(tx, encrypt=True)
11711171

11721172
async def get_siwe_domain(self) -> Dict[str, Any]:
11731173
if self._siwe_domain is None:

test/py/test_accounting_contract_service.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,41 @@ async def test_set_rofl_signer_address_rejects_invalid_address() -> None:
494494
await service.set_rofl_signer_address("not-an-address")
495495

496496

497+
@pytest.mark.asyncio
498+
async def test_set_auth_token_enc_key_submits_encrypted_tx() -> None:
499+
auth_address = "0x2222222222222222222222222222222222222222"
500+
enc_key = bytes.fromhex("11" * 32)
501+
502+
rofl_client = MagicMock()
503+
rofl_client.submit_tx = AsyncMock(
504+
return_value=RoflSubmissionResult(submission_id="sub-1", ok_payload=None)
505+
)
506+
507+
service = AccountingContractService.__new__(AccountingContractService)
508+
service.gas_limit = 500_000
509+
service.rofl_client = rofl_client
510+
service._get_siwe_auth_address = AsyncMock(return_value=auth_address)
511+
512+
await service.set_auth_token_enc_key(enc_key)
513+
514+
selector = Web3.keccak(text="setAuthTokenEncKey(bytes32)")[:4]
515+
expected_tx = {
516+
"to": auth_address,
517+
"value": 0,
518+
"gas": 500_000,
519+
"data": Web3.to_hex(selector + enc_key),
520+
}
521+
rofl_client.submit_tx.assert_awaited_once_with(expected_tx, encrypt=True)
522+
523+
524+
@pytest.mark.asyncio
525+
async def test_set_auth_token_enc_key_rejects_wrong_key_length() -> None:
526+
service = AccountingContractService.__new__(AccountingContractService)
527+
528+
with pytest.raises(ValueError, match="Encryption key must be 32 bytes"):
529+
await service.set_auth_token_enc_key(b"\x11" * 31)
530+
531+
497532
def _make_service_with_confidential_reader(contract: MagicMock) -> AccountingContractService:
498533
service = AccountingContractService.__new__(AccountingContractService)
499534
service._get_confidential_reader_contract = AsyncMock(return_value=contract)

test/py/test_main_lifespan.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Tests for application startup security invariants."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
8+
import src.main as main
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_lifespan_aborts_when_auth_token_key_sync_fails(monkeypatch) -> None:
13+
monkeypatch.delenv("DISABLE_ROFL_KEYS", raising=False)
14+
15+
jwt_key_manager = SimpleNamespace(initialize=AsyncMock())
16+
auth_token_key_manager = SimpleNamespace(
17+
initialize=AsyncMock(),
18+
sync_key_to_contract=AsyncMock(side_effect=RuntimeError("sync failed")),
19+
)
20+
bootstrap_rofl_signer_address = AsyncMock()
21+
22+
monkeypatch.setattr(main, "get_jwt_key_manager", lambda: jwt_key_manager)
23+
monkeypatch.setattr(main, "get_auth_token_key_manager", lambda: auth_token_key_manager)
24+
monkeypatch.setattr(main, "bootstrap_rofl_signer_address", bootstrap_rofl_signer_address)
25+
26+
with pytest.raises(RuntimeError, match="sync failed"):
27+
async with main.lifespan(None):
28+
pass
29+
30+
bootstrap_rofl_signer_address.assert_not_awaited()

test/test_rofl_client.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,34 @@ async def test_submit_tx_success(self, mock_async_client_class):
101101
"gas": 100000,
102102
"value": 0,
103103
}
104-
result = await client.submit_tx(tx, encrypt=False)
104+
result = await client.submit_tx(tx)
105105

106106
# Verify - returns RoflSubmissionResult with hex-encoded CBOR as submission_id
107107
expected = cbor2.dumps({"ok": b""}).hex()
108108
self.assertEqual(result.submission_id, expected)
109109
self.assertEqual(result.ok_payload, b"")
110+
mock_client.sign_submit.assert_awaited_once_with(tx, True)
111+
112+
@patch("src.clients.rofl.AsyncRoflClient")
113+
async def test_submit_tx_encrypts_by_default(self, mock_async_client_class):
114+
"""Test that submit_tx encrypts transactions unless explicitly disabled."""
115+
from src.clients.rofl import RoflAppdClient
116+
117+
mock_client = MagicMock()
118+
mock_client.sign_submit = AsyncMock(return_value={"ok": b""})
119+
mock_async_client_class.return_value = mock_client
120+
121+
client = RoflAppdClient()
122+
tx: TxParams = {
123+
"to": "0x0987654321098765432109876543210987654321",
124+
"data": "0xabcdef",
125+
"gas": 100000,
126+
"value": 0,
127+
}
128+
129+
await client.submit_tx(tx)
130+
131+
mock_client.sign_submit.assert_awaited_once_with(tx, True)
110132

111133
@patch("src.clients.rofl.AsyncRoflClient")
112134
async def test_submit_tx_reverted_raises_error(self, mock_async_client_class):
@@ -135,13 +157,58 @@ async def test_submit_tx_reverted_raises_error(self, mock_async_client_class):
135157

136158
# Should raise TransactionRevertedError
137159
with self.assertRaises(TransactionRevertedError) as ctx:
138-
await client.submit_tx(tx, encrypt=False)
160+
await client.submit_tx(tx)
139161

140162
error = ctx.exception
141163
self.assertEqual(error.code, 8)
142164
self.assertEqual(error.module, "evm")
143165
self.assertIn("InvalidSignature", str(error))
144166

167+
@patch("src.clients.rofl.AsyncRoflClient")
168+
async def test_submit_tx_rejects_unallowlisted_plaintext(self, mock_async_client_class):
169+
"""Test that plaintext submission is blocked unless allow-listed."""
170+
from src.clients.rofl import RoflAppdClient
171+
172+
mock_client = MagicMock()
173+
mock_client.sign_submit = AsyncMock(return_value={"ok": b""})
174+
mock_async_client_class.return_value = mock_client
175+
176+
client = RoflAppdClient()
177+
tx: TxParams = {
178+
"to": "0x0987654321098765432109876543210987654321",
179+
"data": "0xabcdef01",
180+
"gas": 100000,
181+
"value": 0,
182+
}
183+
184+
with self.assertRaises(ValueError) as ctx:
185+
await client.submit_tx(tx, encrypt=False)
186+
187+
self.assertIn("Plaintext ROFL transaction submission is not allowed", str(ctx.exception))
188+
mock_client.sign_submit.assert_not_awaited()
189+
190+
@patch("src.clients.rofl._PLAINTEXT_TX_SELECTOR_ALLOWLIST", frozenset({"abcdef01"}))
191+
@patch("src.clients.rofl.AsyncRoflClient")
192+
async def test_submit_tx_allows_allowlisted_plaintext_selector(self, mock_async_client_class):
193+
"""Test that plaintext submission requires a reviewed selector allow-list entry."""
194+
from src.clients.rofl import RoflAppdClient
195+
196+
mock_client = MagicMock()
197+
mock_client.sign_submit = AsyncMock(return_value={"ok": b""})
198+
mock_async_client_class.return_value = mock_client
199+
200+
client = RoflAppdClient()
201+
tx: TxParams = {
202+
"to": "0x0987654321098765432109876543210987654321",
203+
"data": "0xabcdef0100000000",
204+
"gas": 100000,
205+
"value": 0,
206+
}
207+
208+
await client.submit_tx(tx, encrypt=False)
209+
210+
mock_client.sign_submit.assert_awaited_once_with(tx, False)
211+
145212
@patch("src.clients.rofl.AsyncRoflClient")
146213
async def test_submit_tx_requires_to_and_data(self, mock_async_client_class):
147214
"""Test that submit_tx raises ValueError without 'to' or 'data'."""

0 commit comments

Comments
 (0)