Skip to content

Commit 38316c4

Browse files
committed
added documentation and mainnet warning for side-channel attacks (minerva)
1 parent 90d7d28 commit 38316c4

7 files changed

Lines changed: 167 additions & 8 deletions

File tree

README.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ The API documentation can be build with Sphinx but is also available as a PDF fo
99

1010
Complementary to this library is a CC BY-SA 4.0 licensed `Bitcoin programming book <https://github.com/karask/bitcoin-textbook>`_.
1111

12+
Security model
13+
--------------
14+
This library is intentionally pure Python and educational. Private-key operations, including ECDSA signing and Taproot/Schnorr signing, are not side-channel hardened and should not be used to protect real funds in timing-observable environments.
15+
16+
The ``python-ecdsa`` dependency has a known Minerva timing-attack advisory (CVE-2024-23342) with no patched pure-Python release. Keeping the library pure Python means this class of side-channel risk cannot be fully eliminated. Use the library for learning, tests, testnet, offline experiments, and transaction construction; use production wallets, hardware signers, or hardened native cryptographic libraries for real funds.
17+
18+
Private-key warnings are enabled by default on mainnet and can be disabled with ``bitcoinutils.setup.set_security_warnings(False)``. Testnet, testnet4, signet and regtest do not emit the warning by default, so educational examples stay quiet.
19+
1220

1321
Notes
1422
-----

TODO

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@ PRIORITY
33

44
SHORT-TERM
55
- create Address object that covers all addresses and can convert from one to another
6-
- python-ecdsa is vulnerable to minerva timing attacks
7-
. https://github.com/karask/python-bitcoin-utils/security/dependabot/2
8-
. investigate the consequences (how critical) this is for the library
9-
. no pure python implementation can go around that
10-
. investigate changing the code to use library pyca/cryptography or pycryptodome?
11-
. there are a couple of places where we extend the library for bitcoin-specific calculations; also consider them
126
- taproot's op_addchecksig simply added
137
. other tapscript changes?
148
. think: how to differentiate tapscript in code? (right now addchecksig can be used in any script)

bitcoinutils/keys.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import re
1919
import struct
2020
import hashlib
21+
import warnings
2122
from abc import ABC, abstractmethod
2223
from base64 import b64encode, b64decode
2324
from typing import Optional, List, Tuple, Union, cast
@@ -45,7 +46,7 @@
4546
NETWORK_SEGWIT_PREFIXES,
4647
TAPROOT_SIGHASH_ALL,
4748
)
48-
from bitcoinutils.setup import get_network
49+
from bitcoinutils.setup import get_network, should_warn_about_private_key_use
4950
from bitcoinutils.ripemd160 import ripemd160
5051
from bitcoinutils.schnorr import schnorr_sign
5152
from bitcoinutils.transactions import Transaction
@@ -67,6 +68,19 @@
6768
import bitcoinutils.bech32
6869

6970

71+
_PRIVATE_KEY_SECURITY_WARNING = (
72+
"Pure-Python private-key operations in bitcoinutils are for education, "
73+
"testing, and offline experimentation. They are not side-channel hardened "
74+
"and should not be used to protect real funds in timing-observable "
75+
"environments."
76+
)
77+
78+
79+
def _warn_about_private_key_use() -> None:
80+
if should_warn_about_private_key_use():
81+
warnings.warn(_PRIVATE_KEY_SECURITY_WARNING, RuntimeWarning, stacklevel=3)
82+
83+
7084
class PrivateKey:
7185
"""Represents an ECDSA private key.
7286
@@ -118,9 +132,15 @@ def __init__(
118132
used to create a specific key deterministically (default None)
119133
b : bytes, optional
120134
used to create a key from raw bytes
135+
136+
Notes
137+
-----
138+
Private-key operations are implemented in pure Python for educational
139+
readability. They are not side-channel hardened.
121140
"""
122141

123142
if not secret_exponent and not wif and not b:
143+
_warn_about_private_key_use()
124144
self.key = SigningKey.generate(curve=SECP256k1)
125145
else:
126146
if wif:
@@ -241,7 +261,12 @@ def sign_message(self, message: str, compressed: bool = True) -> Optional[str]:
241261
respectively)
242262
243263
Returns a Bitcoin compact signature in Base64
264+
265+
Notes
266+
-----
267+
This pure-Python signing path is not side-channel hardened.
244268
"""
269+
_warn_about_private_key_use()
245270

246271
# All bitcoin signatures include the magic prefix. It is just a string
247272
# added to the message to distinguish Bitcoin-specific messages.
@@ -278,6 +303,7 @@ def sign_message(self, message: str, compressed: bool = True) -> Optional[str]:
278303
def sign_input(
279304
self, tx: Transaction, txin_index: int, script: Script, sighash: int = SIGHASH_ALL
280305
) -> str:
306+
_warn_about_private_key_use()
281307
# get the digest from the transaction object and sign
282308
tx_digest = tx.get_transaction_digest(txin_index, script, sighash)
283309
return self._sign_input(tx_digest, sighash)
@@ -290,6 +316,7 @@ def sign_segwit_input(
290316
amount: int,
291317
sighash: int = SIGHASH_ALL,
292318
) -> str:
319+
_warn_about_private_key_use()
293320
# get the digest from the transaction object and sign
294321
tx_digest = tx.get_transaction_segwit_digest(
295322
txin_index, script, amount, sighash
@@ -308,6 +335,7 @@ def sign_taproot_input(
308335
sighash: int = TAPROOT_SIGHASH_ALL,
309336
tweak: bool = True,
310337
) -> str:
338+
_warn_about_private_key_use()
311339
# get the digest from the transaction object and sign
312340
# note that when signing a tapleaf we typically won't use tweaked
313341
# keys - so tweak should be set to False
@@ -336,7 +364,12 @@ def _sign_input(self, tx_digest: bytes, sighash: int = SIGHASH_ALL) -> str:
336364
is what is actually signed!)
337365
338366
Returns a signature for that input
367+
368+
Notes
369+
-----
370+
This pure-Python signing path is not side-channel hardened.
339371
"""
372+
_warn_about_private_key_use()
340373

341374
# Both R ans S cannot start with 0x00 (be signed as negative) unless
342375
# they are higher than 2^128 or start with 0x80.
@@ -452,7 +485,12 @@ def _sign_taproot_input(
452485
use tweaking so tweak should be set to False
453486
454487
Returns a signature for that input
488+
489+
Notes
490+
-----
491+
This pure-Python Schnorr signing path is not side-channel hardened.
455492
"""
493+
_warn_about_private_key_use()
456494

457495
byte_key = b""
458496

bitcoinutils/setup.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# LICENSE file.
1111

1212
NETWORK = "testnet"
13+
SECURITY_WARNINGS = True
14+
_SECURITY_WARNING_EMITTED = False
1315

1416
networks = {"mainnet", "testnet", "testnet4", "signet", "regtest"}
1517

@@ -30,6 +32,32 @@ def get_network() -> str:
3032
return NETWORK
3133

3234

35+
def set_security_warnings(enabled: bool) -> None:
36+
"""Enable or disable warnings for pure-Python private-key operations."""
37+
38+
global SECURITY_WARNINGS
39+
global _SECURITY_WARNING_EMITTED
40+
SECURITY_WARNINGS = enabled
41+
if enabled:
42+
_SECURITY_WARNING_EMITTED = False
43+
44+
45+
def get_security_warnings() -> bool:
46+
"""Return whether pure-Python private-key operation warnings are enabled."""
47+
48+
return SECURITY_WARNINGS
49+
50+
51+
def should_warn_about_private_key_use() -> bool:
52+
"""Return True the first time a private-key operation should warn."""
53+
54+
global _SECURITY_WARNING_EMITTED
55+
if NETWORK != "mainnet" or not SECURITY_WARNINGS or _SECURITY_WARNING_EMITTED:
56+
return False
57+
_SECURITY_WARNING_EMITTED = True
58+
return True
59+
60+
3361
def is_mainnet() -> bool:
3462
return NETWORK == "mainnet"
3563

docs/usage/keys.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
Keys and Addresses module
22
-------------------------
33

4+
Security note
5+
~~~~~~~~~~~~~
6+
7+
The key APIs are pure Python and intended for education, tests, testnet, and
8+
offline experimentation. Private-key operations, including ECDSA signing and
9+
Taproot/Schnorr signing, are not side-channel hardened. They should not be used
10+
to protect real funds in timing-observable environments.
11+
12+
This mirrors Bitcoin Core's Python test framework: readable Python secp256k1
13+
code is useful for tests and teaching, while production Bitcoin software uses
14+
hardened native implementations or external signers for real keys.
15+
16+
Warnings for private-key operations are enabled by default on mainnet. They can
17+
be disabled with ``bitcoinutils.setup.set_security_warnings(False)``. Testnet,
18+
testnet4, signet and regtest do not emit the warning by default, so educational
19+
examples stay quiet.
20+
421
.. automodule:: keys
522
:members:

tests/test_keys.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111

1212

1313
import unittest
14+
import warnings
1415

15-
from bitcoinutils.setup import setup
16+
from bitcoinutils.setup import setup, set_security_warnings
1617
from bitcoinutils.keys import (
1718
PrivateKey,
1819
PublicKey,
@@ -27,6 +28,10 @@
2728

2829

2930
class TestPrivateKeys(unittest.TestCase):
31+
def tearDown(self):
32+
set_security_warnings(True)
33+
setup("testnet")
34+
3035
def setUp(self):
3136
setup("mainnet")
3237
self.key_wifc = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"
@@ -56,6 +61,27 @@ def test_public_key(self):
5661
p = PrivateKey(secret_exponent=1)
5762
self.assertEqual(p.get_public_key().to_bytes(), self.public_key_bytes)
5863

64+
def test_private_key_generation_warns_by_default(self):
65+
set_security_warnings(True)
66+
with self.assertWarnsRegex(RuntimeWarning, "not side-channel hardened"):
67+
PrivateKey()
68+
69+
def test_private_key_warning_can_be_disabled(self):
70+
set_security_warnings(False)
71+
with warnings.catch_warnings(record=True) as caught:
72+
warnings.simplefilter("always")
73+
PrivateKey()
74+
self.assertEqual(caught, [])
75+
76+
def test_private_key_generation_does_not_warn_on_test_networks(self):
77+
for network in ["testnet", "testnet4", "signet", "regtest"]:
78+
setup(network)
79+
set_security_warnings(True)
80+
with warnings.catch_warnings(record=True) as caught:
81+
warnings.simplefilter("always")
82+
PrivateKey()
83+
self.assertEqual(caught, [])
84+
5985

6086
class TestPublicKeys(unittest.TestCase):
6187
def setUp(self):
@@ -176,6 +202,10 @@ def test_creation_address(self):
176202

177203

178204
class TestSignAndVerify(unittest.TestCase):
205+
def tearDown(self):
206+
set_security_warnings(True)
207+
setup("testnet")
208+
179209
def setUp(self):
180210
setup("mainnet")
181211
self.message = "The test!"
@@ -199,6 +229,39 @@ def test_sign_and_verify(self):
199229
self.assertEqual(signature, self.deterministic_signature)
200230
self.assertTrue(PublicKey.verify_message(self.address, signature, self.message))
201231

232+
def test_sign_message_warns_by_default(self):
233+
set_security_warnings(True)
234+
with self.assertWarnsRegex(RuntimeWarning, "not side-channel hardened"):
235+
self.priv.sign_message(self.message)
236+
237+
def test_sign_message_warning_can_be_disabled(self):
238+
set_security_warnings(False)
239+
with warnings.catch_warnings(record=True) as caught:
240+
warnings.simplefilter("always")
241+
self.priv.sign_message(self.message)
242+
self.assertEqual(caught, [])
243+
244+
def test_sign_message_does_not_warn_on_test_networks(self):
245+
for network in ["testnet", "testnet4", "signet", "regtest"]:
246+
setup(network)
247+
set_security_warnings(True)
248+
with warnings.catch_warnings(record=True) as caught:
249+
warnings.simplefilter("always")
250+
self.priv.sign_message(self.message)
251+
self.assertEqual(caught, [])
252+
253+
def test_transaction_signing_helper_warns_by_default(self):
254+
set_security_warnings(True)
255+
digest = bytes.fromhex("00" * 32)
256+
with self.assertWarnsRegex(RuntimeWarning, "not side-channel hardened"):
257+
self.priv._sign_input(digest)
258+
259+
def test_taproot_signing_helper_warns_by_default(self):
260+
set_security_warnings(True)
261+
digest = bytes.fromhex("00" * 32)
262+
with self.assertWarnsRegex(RuntimeWarning, "not side-channel hardened"):
263+
self.priv._sign_taproot_input(digest)
264+
202265
def test_verify_external(self):
203266
self.assertTrue(
204267
PublicKey.verify_message(

tests/test_setup.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
is_testnet4,
88
is_signet,
99
is_regtest,
10+
set_security_warnings,
11+
get_security_warnings,
1012
)
1113

1214

@@ -34,6 +36,15 @@ def test_invalid_network(self):
3436

3537
setup("testnet")
3638

39+
def test_security_warning_setting(self):
40+
set_security_warnings(False)
41+
self.assertFalse(get_security_warnings())
42+
43+
set_security_warnings(True)
44+
self.assertTrue(get_security_warnings())
45+
46+
setup("testnet")
47+
3748

3849
if __name__ == "__main__":
3950
unittest.main()

0 commit comments

Comments
 (0)