Skip to content

Commit 784bd7a

Browse files
OscarOzaineclaude
andcommitted
security: warn and self-test when CBOR_C_EXTENSION=1
PyCardano anchors transaction identity in CBOR bytes (tx.id = blake2b of the tx body's CBOR) and defaults to the pure-Python cbor2pure backend so decode->re-encode stays byte-stable. The C cbor2 extension does not guarantee preservation of map/set element order on decode (cbor2 issue #311), so enabling CBOR_C_EXTENSION=1 can silently change a re-serialized transaction's id and cause a node to reject or misattribute it. This was a latent, entirely silent risk: an operator setting the flag "for speed" got no signal. This change adds two layered guard rails when the flag is set: - Emit a RuntimeWarning and a logging.warning (package "PyCardano" logger) at import, so the hazard surfaces in both dev/test harnesses and production log pipelines, once per process. - Run a dependency-free import-time round-trip self-test against a tiny golden tag-258 set vector, mirroring PyCardano's real config (258 semantic decoder removed). If the active cbor2 build is not byte-stable it raises PyCardanoException so the process fails fast instead of silently producing wrong tx ids. Gated behind PYCARDANO_CBOR_SELFTEST (default on when the C extension is requested; opt out with 0/false/no). Default behavior (flag unset) is byte-identical to before: no warning, no self-test, no public API change. Implements docs/specs/spec-03-cbor-c-extension-warning.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 46382fd commit 784bd7a

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ For some users, the C implementation may not work properly when deserializing cb
6565

6666
To solve this problem, a fork of cbor2 is created at [cbor2pure](https://github.com/cffls/cbor2pure). This fork removes C extension and only uses pure python for cbor decoding. By default, for correctness, pycardano uses cbor2pure in decoding. However, if speed is preferred over accuracy, users can set `CBOR_C_EXTENSION=1` in their environment, and the default C extension would be used instead.
6767

68+
When `CBOR_C_EXTENSION=1` is set, PyCardano emits a `RuntimeWarning` and runs a startup round-trip self-test that fails fast (raising `PyCardanoException`) if the installed cbor2 build would change transaction ids on decode-then-reserialize. Do not enable it for signing or decode-then-reserialize workflows. The self-test can be disabled with `PYCARDANO_CBOR_SELFTEST=0` if you have verified your workflow never decodes then reserializes.
69+
6870
```bash
6971
ensure_pure_cbor2.sh
7072
```

docs/source/frequently_asked_questions.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ Or manually install it with:
102102
pip uninstall -y cbor2
103103
pip install --no-binary cbor2 cbor2
104104
105+
When ``CBOR_C_EXTENSION=1`` is set, PyCardano emits a ``RuntimeWarning`` (and a ``logging`` warning) at import time and runs a startup round-trip self-test. If the installed ``cbor2`` build is not byte-stable on decode→encode, the self-test raises a ``PyCardanoException`` so the process fails fast instead of silently producing wrong transaction ids. Do not enable the C extension for signing or decode-then-reserialize workflows.
106+
105107
**Best Practices**
106108

107109
- Always use the pure Python cbor2 implementation when working with pre-signed transactions or Plutus scripts

pycardano/cbor.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,79 @@
44
This module provides a centralized location for importing cbor2,
55
with support for both the C extension (cbor2) and pure Python (cbor2pure) versions.
66
Set the environment variable CBOR_C_EXTENSION=1 to use the C extension.
7+
8+
PyCardano anchors transaction identity in CBOR bytes (``tx.id`` is the blake2b
9+
hash of the transaction body's CBOR). The C ``cbor2`` extension does not
10+
guarantee preservation of map/set element order on decode (cbor2 issue #311),
11+
so a decode-then-reserialize flow can change a transaction's id. For that reason
12+
the pure-Python ``cbor2pure`` backend is the default, and enabling the C
13+
extension triggers a runtime warning plus a startup round-trip self-test.
714
"""
815

16+
import logging
917
import os
18+
import warnings
19+
20+
logger = logging.getLogger("PyCardano")
21+
22+
_C_EXTENSION_WARNING = (
23+
"CBOR_C_EXTENSION=1: using the C cbor2 extension. The C extension does not "
24+
"guarantee preservation of map/set element order on decode (cbor2 issue #311), "
25+
"which can change a re-serialized transaction's id (tx.id = blake2b of body CBOR) "
26+
"and cause the node to reject or misattribute the transaction. The pure-Python "
27+
"cbor2pure backend (the default) is recommended for any decode-then-reserialize or "
28+
"transaction-signing workflow. See README 'cbor2' section."
29+
)
30+
31+
# A tiny golden CBOR vector that exercises the order-preservation behaviour that
32+
# matters for transaction ids: a tag-258 (set) holding three integers in a
33+
# non-sorted order. PyCardano removes the semantic decoder for tag 258 (see
34+
# ``serialization.py``) precisely so the element order is preserved on decode.
35+
# A correct backend therefore round-trips these bytes unchanged; the C extension
36+
# collapses the tag into a Python ``set`` (losing order) and re-encodes to
37+
# different bytes.
38+
_SELFTEST_GOLDEN = bytes.fromhex("d9010283030102") # tag(258), array [3, 1, 2]
39+
40+
41+
def _run_cbor_selftest(backend) -> None:
42+
"""Run a dependency-free decode→encode round-trip check on ``backend``.
43+
44+
Mirrors PyCardano's runtime configuration by removing the tag-258 semantic
45+
decoder before the check (and restoring it afterwards so the rest of the
46+
package's setup is unaffected). Raises :class:`PyCardanoException` if the
47+
backend is not byte-stable, i.e. if decode→encode would change transaction
48+
ids.
49+
"""
50+
from pycardano.exception import PyCardanoException
51+
52+
semantic_decoders = backend._decoder.semantic_decoders
53+
had_258 = 258 in semantic_decoders
54+
original_258 = semantic_decoders.get(258)
55+
if had_258:
56+
del semantic_decoders[258]
57+
try:
58+
reencoded = backend.dumps(backend.loads(_SELFTEST_GOLDEN))
59+
finally:
60+
if had_258:
61+
semantic_decoders[258] = original_258
62+
63+
if reencoded != _SELFTEST_GOLDEN:
64+
raise PyCardanoException(
65+
"CBOR_C_EXTENSION=1 failed the CBOR round-trip self-test: decode->encode "
66+
"is not byte-stable for this build of cbor2, so transaction ids would be "
67+
"wrong. Unset CBOR_C_EXTENSION (use cbor2pure) for transaction workflows."
68+
)
69+
1070

1171
if os.getenv("CBOR_C_EXTENSION", "0") == "1":
1272
import cbor2 # noqa: F401
73+
74+
warnings.warn(_C_EXTENSION_WARNING, RuntimeWarning, stacklevel=2)
75+
logger.warning(_C_EXTENSION_WARNING)
76+
77+
# Default the self-test on when the C extension is requested; allow embedders
78+
# to opt out with an explicit falsy PYCARDANO_CBOR_SELFTEST value.
79+
if os.getenv("PYCARDANO_CBOR_SELFTEST", "1").lower() not in ("0", "false", "no"):
80+
_run_cbor_selftest(cbor2)
1381
else:
1482
import cbor2pure as cbor2 # type: ignore # noqa: F401

test/pycardano/test_cbor.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for the conditional cbor2 import and the CBOR_C_EXTENSION guard rails.
2+
3+
``pycardano.cbor`` reads ``CBOR_C_EXTENSION`` once at import time, so tests that
4+
flip the flag must reload the module (or run a subprocess) for the change to take
5+
effect. A plain ``monkeypatch.setenv`` after import has no effect.
6+
"""
7+
8+
import importlib
9+
import subprocess
10+
import sys
11+
import warnings
12+
13+
import pytest
14+
15+
import pycardano.cbor as pycardano_cbor
16+
from pycardano.exception import PyCardanoException
17+
18+
# A transaction whose body contains tag-258 sets (the structure the C extension
19+
# reorders), so the cross-impl id comparison is meaningful.
20+
# https://cardanoscan.io/transaction/941502b0aa104c850d197923259444d2b57cab7af18b63143775465aaacc84f5
21+
TX_WITH_SETS_CBOR = (
22+
"84a700d90102868258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab00"
23+
"8258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab01"
24+
"8258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab02"
25+
"8258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab03"
26+
"82582040aba0069d0dce7f801a9d16c26d469ec8ce16e1eb68379ae2774e5d28f33d5b00"
27+
"8258206ba686304126196267200c6502df4b42af898ad2fb1621561fdb0a457fd8b68b00"
28+
"0dd90102818258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab04"
29+
"0181825839013c55ef61a7fac4c7f94dc65052586f31dd659acddffc69f13d2c4364646c9e5f7484e8aeceba94566b73b8b50394eb6bfb54f67ac5885d591ab25dc1bf"
30+
"021a0004ee04031a08d0f5dc0b58204a080e29d89a598d6a3c000c9f15f4ab74a10ffdaa320f256fc7f69b75ff8a59"
31+
"14d9010281841b000000174876e800581de1646c9e5f7484e8aeceba94566b73b8b50394eb6bfb54f67ac5885d598400825820b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc500a10bd81e82010a581cfa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64827835697066733a2f2f516d634b51676763706f757568414176555947447a6f4b674d77625a536b57716945654536633637534a336b457158209b2438f0032a0c24ed62d12d6bdb79b47e2bd0c4d2dd4f4936c055ead7109caf"
32+
"a300d90102818258205d58313597871a1823742d172d738fcd1fee4800ba41859db790f981d4dae74e584089b07924734e5b9d813b43638c3e2e6f4ac1e473e454d2d5b404b7bee939d8b5046b6a5c4ba0b51096d5538feb933e802a5944442b046ef11b2381ffce70f70e0"
33+
"7d90102815908545908510101003232323232323232323232323232323232323232323232323232323232323232323232323232323232259323255333573466e1d20000011180098111bab357426ae88d55cf00104554ccd5cd19b87480100044600422c6aae74004dd51aba1357446ae88d55cf1baa3255333573466e1d200a35573a002226ae84d5d11aab9e00111637546ae84d5d11aba235573c6ea800642b26006003149a2c8a4c301f801c0052000c00e0070018016006901e4070c00e003000c00d20d00fc000c0003003800a4005801c00e003002c00d20c09a0c80e1801c006001801a4101b5881380018000600700148013003801c006005801a410100078001801c006001801a4101001f8001800060070014801b0038018096007001800600690404002600060001801c0052008c00e006025801c006001801a41209d8001800060070014802b003801c006005801a410112f501c3003800c00300348202b7881300030000c00e00290066007003800c00b003482032ad7b806038403060070014803b00380180960003003800a4021801c00e003002c00d20f40380e1801c006001801a41403f800100a0c00e0029009600f0030078040c00e002900a600f003800c00b003301a483403e01a600700180060066034904801e00060001801c0052016c01e00600f801c006001801980c2402900e30000c00e002901060070030128060c00e00290116007003800c00b003483c0ba03860070018006006906432e00040283003800a40498003003800a404d802c00e00f003800c00b003301a480cb0003003800c003003301a4802b00030001801c01e0070018016006603490605c0160006007001800600660349048276000600030000c00e0029014600b003801c00c04b003800c00300348203a2489b00030001801c00e006025801c006001801a4101b11dc2df80018000c0003003800a4055802c00e007003012c00e003000c00d2080b8b872c000c0006007003801809600700180060069040607e4155016000600030000c00e00290166007003012c00e003000c00d2080c001c000c0003003800a405d801c00e003002c00d20c80180e1801c006001801a412007800100a0c00e00290186007003013c0006007001480cb005801801e006003801800e00600500403003800a4069802c00c00f003001c00c007003803c00e003002c00c05300333023480692028c0004014c00c00b003003c00c00f003003c00e00f003800c00b00301480590052008003003800a406d801c00e003002c00d2000c00d2006c00060070018006006900a600060001801c0052038c00e007001801600690006006901260003003800c003003483281300020141801c005203ac00e006027801c006001801a403d800180006007001480f3003801804e00700180060069040404af3c4e302600060001801c005203ec00e006013801c006001801a4101416f0fd20b80018000600700148103003801c006005801a403501c3003800c0030034812b00030000c00e0029021600f003800c00a01ac00e003000c00ccc08d20d00f4800b00030000c0000000000803c00c016008401e006009801c006001801807e0060298000c000401e006007801c0060018018074020c000400e00f003800c00b003010c000802180020070018006006019801805e0003000400600580180760060138000800c00b00330134805200c400e00300080330004006005801a4001801a410112f58000801c00600901260008019806a40118002007001800600690404a75ee01e00060008018046000801801e000300c4832004c025201430094800a0030028052003002c00d2002c000300648010c0092002300748028c0312000300b48018c0292012300948008c0212066801a40018000c0192008300a2233335573e00250002801994004d55ce800cd55cf0008d5d08014c00cd5d10011263009222532900389800a4d2219002912c80344c01526910c80148964cc04cdd68010034564cc03801400626601800e0071801226601800e01518010096400a3000910c008600444002600244004a664600200244246466004460044460040064600444600200646a660080080066a00600224446600644b20051800484ccc02600244666ae68cdc3801000c00200500a91199ab9a33710004003000801488ccd5cd19b89002001800400a44666ae68cdc4801000c00a00122333573466e20008006005000912a999ab9a3371200400222002220052255333573466e2400800444008440040026eb400a42660080026eb000a4264666015001229002914801c8954ccd5cd19b8700400211333573466e1c00c006001002118011229002914801c88cc044cdc100200099b82002003245200522900391199ab9a3371066e08010004cdc1001001c002004403245200522900391199ab9a3371266e08010004cdc1001001c00a00048a400a45200722333573466e20cdc100200099b820020038014000912c99807001000c40062004912c99807001000c400a2002001199919ab9a357466ae880048cc028dd69aba1003375a6ae84008d5d1000934000dd60010a40064666ae68d5d1800c0020052225933006003357420031330050023574400318010600a444aa666ae68cdc3a400000222c22aa666ae68cdc4000a4000226600666e05200000233702900000088994004cdc2001800ccdc20010008cc010008004c01088954ccd5cd19b87480000044400844cc00c004cdc300100091119803112c800c60012219002911919806912c800c4c02401a442b26600a004019130040018c008002590028c804c8888888800d1900991111111002a244b267201722222222008001000c600518000001112a999ab9a3370e004002230001155333573466e240080044600823002229002914801c88ccd5cd19b893370400800266e0800800e00100208c8c0040048c0088cc00800800505a"
34+
"182050082a0821a0007c6d41a06a71df2f5f6"
35+
)
36+
37+
38+
@pytest.fixture
39+
def reset_cbor_module():
40+
"""Reload ``pycardano.cbor`` back to its default (cbor2pure) state on teardown."""
41+
yield
42+
importlib.reload(pycardano_cbor)
43+
44+
45+
def _reload_cbor(monkeypatch, *, c_extension, selftest=None):
46+
"""Reload ``pycardano.cbor`` with the given environment configuration."""
47+
monkeypatch.setenv("CBOR_C_EXTENSION", "1" if c_extension else "0")
48+
if selftest is None:
49+
monkeypatch.delenv("PYCARDANO_CBOR_SELFTEST", raising=False)
50+
else:
51+
monkeypatch.setenv("PYCARDANO_CBOR_SELFTEST", selftest)
52+
return importlib.reload(pycardano_cbor)
53+
54+
55+
def test_warning_emitted_when_flag_set(monkeypatch, reset_cbor_module):
56+
"""Test 1: a RuntimeWarning fires when the C extension is requested."""
57+
pytest.importorskip("cbor2", reason="C cbor2 extension not installed")
58+
with pytest.warns(RuntimeWarning, match="CBOR_C_EXTENSION"):
59+
# Disable the self-test so the warning is asserted in isolation.
60+
_reload_cbor(monkeypatch, c_extension=True, selftest="0")
61+
62+
63+
def test_no_warning_by_default(monkeypatch, reset_cbor_module):
64+
"""Test 2: no RuntimeWarning when the flag is unset (the default)."""
65+
with warnings.catch_warnings(record=True) as caught:
66+
warnings.simplefilter("always")
67+
module = _reload_cbor(monkeypatch, c_extension=False)
68+
assert module.cbor2.__name__ == "cbor2pure"
69+
assert not [w for w in caught if issubclass(w.category, RuntimeWarning)]
70+
71+
72+
def test_logger_also_emits(monkeypatch, caplog, reset_cbor_module):
73+
"""Test 3: the package logger also emits the warning text."""
74+
pytest.importorskip("cbor2", reason="C cbor2 extension not installed")
75+
with caplog.at_level("WARNING", logger="PyCardano"):
76+
with warnings.catch_warnings():
77+
warnings.simplefilter("ignore")
78+
_reload_cbor(monkeypatch, c_extension=True, selftest="0")
79+
assert any("CBOR_C_EXTENSION" in record.message for record in caplog.records)
80+
81+
82+
def _is_byte_stable(backend):
83+
"""Independently round-trip the golden bytes with the 258 decoder removed."""
84+
semantic_decoders = backend._decoder.semantic_decoders
85+
original = semantic_decoders.pop(258, None)
86+
try:
87+
reencoded = backend.dumps(backend.loads(pycardano_cbor._SELFTEST_GOLDEN))
88+
finally:
89+
if original is not None:
90+
semantic_decoders[258] = original
91+
return reencoded == pycardano_cbor._SELFTEST_GOLDEN
92+
93+
94+
def test_selftest_passes_for_pure_python():
95+
"""Test 4a: cbor2pure is byte-stable, so the self-test is a no-op."""
96+
import cbor2pure
97+
98+
assert _is_byte_stable(cbor2pure)
99+
# Must not raise.
100+
pycardano_cbor._run_cbor_selftest(cbor2pure)
101+
102+
103+
def test_selftest_contract_for_c_extension():
104+
"""Test 4b: the self-test raises iff the active backend is not byte-stable."""
105+
cbor2 = pytest.importorskip("cbor2", reason="C cbor2 extension not installed")
106+
if _is_byte_stable(cbor2):
107+
pycardano_cbor._run_cbor_selftest(cbor2) # passes for an order-stable build
108+
else:
109+
with pytest.raises(PyCardanoException, match="round-trip self-test"):
110+
pycardano_cbor._run_cbor_selftest(cbor2)
111+
112+
113+
def test_selftest_disabled_does_not_raise(monkeypatch, reset_cbor_module):
114+
"""The self-test can be opted out of with PYCARDANO_CBOR_SELFTEST=0."""
115+
pytest.importorskip("cbor2", reason="C cbor2 extension not installed")
116+
with warnings.catch_warnings():
117+
warnings.simplefilter("ignore")
118+
module = _reload_cbor(monkeypatch, c_extension=True, selftest="0")
119+
assert module.cbor2.__name__ == "cbor2"
120+
121+
122+
def _tx_id_in_subprocess(c_extension):
123+
"""Compute TX_WITH_SETS_ID in a fresh process with the given backend."""
124+
script = (
125+
"from pycardano import Transaction;"
126+
f"print(Transaction.from_cbor('{TX_WITH_SETS_CBOR}').id)"
127+
)
128+
env = {"CBOR_C_EXTENSION": "1" if c_extension else "0"}
129+
# The self-test would abort import on an order-unstable C build; disable it
130+
# here so we can observe the (wrong) id the backend would actually produce.
131+
if c_extension:
132+
env["PYCARDANO_CBOR_SELFTEST"] = "0"
133+
import os
134+
135+
proc = subprocess.run(
136+
[sys.executable, "-c", script],
137+
capture_output=True,
138+
text=True,
139+
env={**os.environ, **env},
140+
)
141+
assert proc.returncode == 0, proc.stderr
142+
return proc.stdout.strip().splitlines()[-1]
143+
144+
145+
def test_cross_impl_tx_id_stability():
146+
"""Test 5: the headline cross-impl tx-id stability check (audit §10.9 #1).
147+
148+
The pure-Python backend is deterministic. When the C extension is available,
149+
the two backends agree iff the C build is byte-stable -- which is exactly the
150+
condition the import-time self-test guards.
151+
"""
152+
pure_id = _tx_id_in_subprocess(c_extension=False)
153+
assert pure_id == _tx_id_in_subprocess(c_extension=False)
154+
155+
cbor2 = pytest.importorskip("cbor2", reason="C cbor2 extension not installed")
156+
c_id = _tx_id_in_subprocess(c_extension=True)
157+
assert (c_id == pure_id) == _is_byte_stable(cbor2)

0 commit comments

Comments
 (0)