diff --git a/.readthedocs.yml b/.readthedocs.yml index b5ce89964..94a175e6e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,11 +3,11 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3" + python: "3.10" python: install: - requirements: docs/requirements.txt sphinx: - configuration: docs/conf.py + configuration: docs/conf.py \ No newline at end of file diff --git a/README.md b/README.md index c80374b18..df826cd20 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ sudo yum -y install python3-devel libusbx-devel systemd-devel For macOS: ``` brew install libusb + +``` + +For Windows: +``` +# Install Visual Studio Build Tools with Windows 10 SDK and C++ CMake tools +# Install OpenSSL development libraries ``` ## Install @@ -77,6 +84,26 @@ All output will be in JSON form and sent to `stdout`. Additional information or prompts will be sent to `stderr` and will not necessarily be in JSON. This additional information is for debugging purposes. +### PKCS11 Token Support + +HWI supports PKCS11 tokens (HSMs) with secp256k1 curve support. To use a PKCS11 token: + +1. Set the required environment variables: +```bash +# Windows +$env:PKCS11_LIB_PATH = "C:\path\to\your\pkcs11\library.dll" +$env:PKCS11_TOKEN_LABEL = "YourTokenLabel" + +# Unix-like +export PKCS11_LIB_PATH=/path/to/your/pkcs11/library.so +export PKCS11_TOKEN_LABEL=YourTokenLabel +``` + +2. Use the token with HWI: +```bash +hwi --device-type pkcs11 --path /path/to/library.so getmasterxpub +``` + To see a complete list of available commands and global parameters, run `./hwi.py --help`. To see options specific to a particular command, pass the `--help` parameter after the command name; for example: diff --git a/docs/conf.py b/docs/conf.py index 5956eda79..e14e1dff4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,5 +71,18 @@ # Show both class and init docstring autoclass_content = "both" -# Mock these imports -autodoc_mock_imports = ["hid", "ecdsa", "pyaes", "mnemonic", "typing_extensions", "usb1", "PySide2"] +autodoc_mock_imports = [ + "hid", + "hidapi", + "libusb1", + "usb", + "usb.core", + "usb.util", + "usb1", + "btchip", + "trezorlib", + "ledgerblue", + "jadepy", + "PySide2", + "hwilib.udevinstaller", +] \ No newline at end of file diff --git a/docs/devices/pkcs11.rst b/docs/devices/pkcs11.rst new file mode 100644 index 000000000..6213c9935 --- /dev/null +++ b/docs/devices/pkcs11.rst @@ -0,0 +1,101 @@ +PKCS#11 Token +============= + +The PKCS#11 Token device implementation allows HWI to interact with PKCS#11-compliant Hardware Security Modules (HSMs) that support the secp256k1 curve. + +Requirements +------------ + +- A PKCS#11-compliant HSM with secp256k1 curve support. +- The PKCS#11 library for your HSM. +- The ``python-pkcs11`` Python package. + +Windows-specific Requirements +----------------------------- + +On Windows, you'll need: + +1. **Visual Studio Build Tools with C++ support** + - Download from: https://visualstudio.microsoft.com/visual-cpp-build-tools/ + - Select "Desktop development with C++". + - Make sure to include the Windows 10 SDK. + +2. **OpenSSL development headers** + - Download from: https://slproweb.com/products/Win32OpenSSL.html + - Choose the "Win64 OpenSSL" version. + - Ensure the OpenSSL bin directory is on PATH. + +3. **The PKCS#11 library for your HSM** (usually a ``.dll`` file) + - Prefer specifying its absolute path via ``PKCS11_LIB_PATH`` or placing it alongside the application. + - Avoid copying into ``C:\Windows\System32`` to reduce DLL hijacking risks. + +Installation Steps for Windows: + +1. Install the prerequisites in the order listed above. +2. Install ``python-pkcs11``: + + .. code-block:: shell + + pip install python-pkcs11 + + If you get a "Failed building wheel" error, ensure prerequisites are installed correctly and try running the command in a new terminal. + +Configuration +------------- + +The device can be configured using environment variables. Command-line flags will override these variables if provided. + +- ``PKCS11_LIB_PATH``: **(Required)** Path to the PKCS#11 library. +- ``PKCS11_TOKEN_LABEL``: Label of the token to use (default: "Bitcoin"). +- ``PKCS11_PIN``: User PIN for token login. For security, it is better to rely on the interactive prompt than to set this variable. + +Example environment variable setup: + +.. code-block:: powershell + + # On Windows (PowerShell) + $env:PKCS11_LIB_PATH = "C:\path\to\your\pkcs11\library.dll" + $env:PKCS11_TOKEN_LABEL = "YourTokenLabel" + +.. code-block:: shell + + # On Linux/macOS + export PKCS11_LIB_PATH=/path/to/your/pkcs11/library.so + export PKCS11_TOKEN_LABEL=YourTokenLabel + +Usage +----- + +1. **Initialize your HSM** with a master key labeled ``MASTER_KEY`` using the secp256k1 curve. +2. **Use HWI** with your PKCS#11 token: + + .. code-block:: shell + + # List available devices + hwi enumerate + + # Get the master public key + hwi --device-type pkcs11 --path /path/to/library.so getmasterxpub + +Security Considerations +----------------------- + +- The PKCS#11 token must be properly configured with appropriate access controls. +- The master key should be protected with a strong PIN/password. +- The PKCS#11 library should be from a trusted source. +- The token should be physically secured. + +Limitations +----------- + +- Only supports the secp256k1 curve. +- Requires the token to be pre-initialized with a master key. +- May not support all HWI features depending on the token's capabilities. + +Troubleshooting +--------------- + +- Verify your PKCS#11 library is properly installed and the path is correct. +- Check that your token supports the secp256k1 curve. +- Ensure the ``MASTER_KEY`` exists and is accessible. +- Check the token's logs for any error messages. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 4b05f4d65..e6ab1beed 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,13 @@ sphinxcontrib-autoprogram>=0.1.5 sphinx>=3.2.1 sphinx_rtd_theme>=1.0.0 +python-pkcs11>=0.7.0 +cbor2>=5.4.6,<6.0.0 +pyserial>=3.5 +ecdsa>=0 +mnemonic>=0 +noiseprotocol>=0.3.1,<0.4.0 +protobuf>=4.23.3,<5.0.0 +pyaes>=1.6 +semver>=2.13.0,<3.0.0 +typing-extensions>=4.4 \ No newline at end of file diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..30c3ef143 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -66,6 +66,8 @@ Union, ) +import pkcs11 +from pkcs11 import Mechanism, ObjectClass, KeyType py_enumerate = enumerate @@ -590,3 +592,67 @@ def install_udev_rules(source: str, location: str) -> Dict[str, bool]: from .udevinstaller import UDevInstaller return {"success": UDevInstaller.install(source, location)} raise NotImplementedError("udev rules are not needed on your platform") + +from .key import ExtendedKey +from .errors import HWWError + +class PKCS11Client(HardwareWalletClient): + def __init__( + self, + path: str, + password: Optional[str] = None, + expert: bool = False, + chain: Chain = Chain.MAIN, + token_label: str = "Bitcoin", + master_key_label: str = "MASTER_KEY" + ) -> None: + super(PKCS11Client, self).__init__(path, password, expert, chain) + + try: + # Initialize PKCS11 library and token + self.lib = pkcs11.lib(path) + self.token = self.lib.get_token(token_label=token_label) + self.session = self.token.open(user_pin=password) + + # Find the master key + self.master_key = self.session.get_key( + object_class=ObjectClass.PRIVATE_KEY, + key_type=KeyType.EC, + label=master_key_label + ) + except Exception as e: + if hasattr(self, 'session'): + self.session.close() + raise HWWError(f"Failed to initialize PKCS11 client: {e}") + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + # Implement BIP32 path derivation and get public key + # You'll need to implement BIP32 path derivation logic + # and use the PKCS11 token to get the public key + pass + + def sign_tx(self, psbt: PSBT) -> PSBT: + # Implement PSBT signing using the PKCS11 token + # You'll need to: + # 1. Parse the PSBT + # 2. For each input that needs signing: + # - Get the appropriate key from the token + # - Sign the transaction + # 3. Return the signed PSBT + pass + + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: + # Implement message signing using the PKCS11 token + # You'll need to: + # 1. Get the key at the specified path + # 2. Sign the message + # 3. Return the signature + pass + + def get_master_fingerprint(self) -> bytes: + # Get the master key's fingerprint + # This is typically the first 4 bytes of the hash160 of the master public key + pass + + def close(self) -> None: + # Close the PKCS11 session + self.session.close() \ No newline at end of file diff --git a/hwilib/devices/__init__.py b/hwilib/devices/__init__.py index 77fa0ffba..1ff037266 100644 --- a/hwilib/devices/__init__.py +++ b/hwilib/devices/__init__.py @@ -1,3 +1,18 @@ +""" +Devices +******* +This module contains all of the device implementations. +Each device implementation is a subclass of :class:`~hwilib.hwwclient.HardwareWalletClient`. +""" + +from .trezor import TrezorClient +from .ledger import LedgerClient +from .keepkey import KeepkeyClient +from .jade import JadeClient +from .coldcard import ColdcardClient +from .digitalbitbox import DigitalbitboxClient +from .bitbox02 import Bitbox02Client +from .pkcs11 import PKCS11Client __all__ = [ 'trezor', 'ledger', @@ -5,5 +20,6 @@ 'digitalbitbox', 'coldcard', 'bitbox02', - 'jade' + 'jade', + 'pkcs11' ] diff --git a/hwilib/devices/pkcs11.py b/hwilib/devices/pkcs11.py new file mode 100644 index 000000000..2c94e9dd7 --- /dev/null +++ b/hwilib/devices/pkcs11.py @@ -0,0 +1,254 @@ +""" +PKCS#11 Token Support +******************** +This module implements support for PKCS#11 tokens (HSMs) with secp256k1 curve support. +""" + +import logging +import os +import platform +import struct +from typing import Dict, List, Optional, Set, Tuple, Union, Any + +import pkcs11 +from pkcs11 import Mechanism, ObjectClass, KeyType, Attribute + +from ..hwwclient import HardwareWalletClient +from ..common import AddressType, Chain +from ..key import ExtendedKey, parse_path +from ..psbt import PSBT +from ..errors import ( + BadArgumentError, + DeviceConnectionError, + DeviceNotReadyError, + UnavailableActionError, +) +from ..descriptor import MultisigDescriptor + +# Constants for PKCS#11 +PKCS11_LIB_PATH = os.environ.get('PKCS11_LIB_PATH', '') +TOKEN_LABEL = os.environ.get('PKCS11_TOKEN_LABEL', 'Bitcoin') +MASTER_KEY_LABEL = 'MASTER_KEY' + +# Windows-specific paths +if platform.system() == 'Windows': + DEFAULT_PKCS11_PATHS = [ + r'C:\Windows\System32\*.dll', # System PKCS#11 libraries + r'C:\Program Files\*.dll', # Program Files PKCS#11 libraries + r'C:\Program Files (x86)\*.dll' # 32-bit Program Files PKCS#11 libraries + ] +else: + DEFAULT_PKCS11_PATHS = [ + '/usr/lib/*.so', # System libraries + '/usr/local/lib/*.so', # Local libraries + '/usr/lib/x86_64-linux-gnu/*.so', # Debian/Ubuntu + '/usr/lib64/*.so', # Fedora/RHEL + ] + +class PKCS11Client(HardwareWalletClient): + """Create a client for a PKCS#11 token that has already been opened.""" + + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None: + """ + Initialize the PKCS#11 client. + :param path: Path to the PKCS#11 library + :param password: The PIN/password to use with the token + :param expert: Whether to return additional information intended for experts + :param chain: The chain to use (mainnet/testnet) + """ + super(PKCS11Client, self).__init__(path, password, expert, chain) + + if not path: + # Try to find the PKCS#11 library + for pattern in DEFAULT_PKCS11_PATHS: + try: + import glob + libs = glob.glob(pattern) + if libs: + path = libs[0] + break + except: + continue + + if not path: + raise DeviceConnectionError("PKCS#11 library path not specified and no default library found") + + try: + # Initialize PKCS#11 library + self.lib = pkcs11.lib(path) + self.token = self.lib.get_token(token_label=TOKEN_LABEL) + self.session = self.token.open(user_pin=password) + + # Find the master key + self.master_key = self.session.get_key( + object_class=ObjectClass.PRIVATE_KEY, + key_type=KeyType.EC, + label=MASTER_KEY_LABEL + ) + + # Verify secp256k1 curve support + curve = self.master_key.get_attribute(Attribute.EC_PARAMS) + if curve != b'\x06\x05\x2b\x81\x04\x00\x0a': # OID for secp256k1 + raise DeviceNotReadyError("Token does not support secp256k1 curve") + + except Exception as e: + raise DeviceConnectionError(f"Failed to connect to PKCS#11 token: {str(e)}") + + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + """ + Get the public key at the BIP32 derivation path. + :param bip32_path: The BIP32 derivation path + :return: The extended public key + """ + try: + # Parse BIP32 path + path = parse_path(bip32_path) + + # Get the key at this path + key = self.session.get_key( + object_class=ObjectClass.PRIVATE_KEY, + key_type=KeyType.EC, + label=f"KEY_{bip32_path}" + ) + + # Get public key attributes + pubkey = key.get_attribute(Attribute.EC_POINT) + chain_code = key.get_attribute(Attribute.EC_PARAMS) + + # Create ExtendedKey + return ExtendedKey( + version=ExtendedKey.MAINNET_PUBLIC if self.chain == Chain.MAIN else ExtendedKey.TESTNET_PUBLIC, + depth=len(path), + parent_fingerprint=self.get_master_fingerprint(), + child_num=path[-1] if path else 0, + chain_code=chain_code, + key_data=pubkey + ) + except Exception as e: + raise BadArgumentError(f"Failed to get public key at path {bip32_path}: {str(e)}") + + def sign_tx(self, psbt: PSBT) -> PSBT: + """ + Sign a PSBT using the PKCS#11 token. + :param psbt: The PSBT to sign + :return: The signed PSBT + """ + try: + # Get master fingerprint + master_fp = self.get_master_fingerprint() + + # For each input that needs signing + for input_num, psbt_in in enumerate(psbt.inputs): + # Check if this input needs our signature + for pubkey, origin in psbt_in.hd_keypaths.items(): + if origin.fingerprint == master_fp: + # Get the key for this path + key = self.session.get_key( + object_class=ObjectClass.PRIVATE_KEY, + key_type=KeyType.EC, + label=f"KEY_{origin.path}" + ) + + # Sign the input + signature = key.sign( + psbt_in.sighash, + mechanism=Mechanism.ECDSA + ) + + # Add signature to PSBT + psbt_in.partial_sigs[pubkey] = signature + b'\x01' # SIGHASH_ALL + + return psbt + except Exception as e: + raise BadArgumentError(f"Failed to sign transaction: {str(e)}") + + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: + """ + Sign a message using the key at the specified path. + :param message: The message to sign + :param keypath: The BIP32 path of the key to sign with + :return: The signature in base64 format + """ + try: + # Get the key + key = self.session.get_key( + object_class=ObjectClass.PRIVATE_KEY, + key_type=KeyType.EC, + label=f"KEY_{keypath}" + ) + + # Sign the message + signature = key.sign( + message if isinstance(message, bytes) else message.encode(), + mechanism=Mechanism.ECDSA + ) + + return signature.hex() + except Exception as e: + raise BadArgumentError(f"Failed to sign message: {str(e)}") + + def get_master_fingerprint(self) -> bytes: + """ + Get the master key's fingerprint. + :return: The master key fingerprint + """ + try: + # Get the master public key + pubkey = self.master_key.get_attribute(Attribute.EC_POINT) + + # Calculate fingerprint (first 4 bytes of hash160) + from hashlib import sha256, ripemd160 + h = ripemd160.new(sha256(pubkey).digest()).digest() + return h[:4] + except Exception as e: + raise DeviceNotReadyError(f"Failed to get master fingerprint: {str(e)}") + + def close(self) -> None: + """Close the PKCS#11 session.""" + try: + self.session.close() + except: + pass + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: + """ + Enumerate all connected PKCS#11 tokens. + :param password: The PIN/password to use with the token + :param expert: Whether to return additional information intended for experts + :param chain: The chain to use (mainnet/testnet) + :param allow_emulators: Whether to allow emulator devices + :return: A list of dictionaries describing the found tokens + """ + result = [] + + # Try all possible PKCS#11 library paths + paths_to_try = [PKCS11_LIB_PATH] if PKCS11_LIB_PATH else [] + paths_to_try.extend(DEFAULT_PKCS11_PATHS) + + for path_pattern in paths_to_try: + try: + import glob + for path in glob.glob(path_pattern): + try: + # Try to load the PKCS#11 library + lib = pkcs11.lib(path) + + # Get all tokens + for token in lib.get_tokens(): + if token.label == TOKEN_LABEL: + result.append({ + 'type': 'pkcs11', + 'path': path, + 'model': 'PKCS#11 Token', + 'label': token.label, + 'expert': expert, + 'chain': chain, + }) + except Exception as e: + logging.debug(f"Failed to load PKCS#11 library at {path}: {str(e)}") + continue + except Exception as e: + logging.debug(f"Failed to glob path pattern {path_pattern}: {str(e)}") + continue + + return result \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 76d8f09ce..7c48b1f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dataclasses = {version = "^0.8", python = ">=3.6,<3.7"} semver = "^3.0.1" noiseprotocol = "^0.3.1" protobuf = "^4.23.3" +python-pkcs11 = ">=0.7.0" [tool.poetry.extras] qt = ["pyside2"] diff --git a/test/run_tests.py b/test/run_tests.py index 80300db1f..eaa265044 100755 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -18,6 +18,12 @@ from test_jade import jade_test_suite from test_bitbox02 import bitbox02_test_suite from test_udevrules import TestUdevRulesInstaller +try: + from test_pkcs11 import TestPKCS11Client + HAS_PKCS11_TESTS = True +except ImportError: + TestPKCS11Client = None + HAS_PKCS11_TESTS = False parser = argparse.ArgumentParser(description='Setup the testing environment and run automated tests') trezor_group = parser.add_mutually_exclusive_group() @@ -70,7 +76,8 @@ parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist', 'stdin'], default='library') parser.add_argument("--device-only", help="Only run device tests", action="store_true") - +parser.add_argument('--no-pkcs11', dest='pkcs11', help='Do not run PKCS11 tests', action='store_false') +parser.add_argument('--pkcs11', dest='pkcs11', help='Run PKCS11 tests', action='store_true') parser.set_defaults(trezor_1=None, trezor_t=None, coldcard=None, keepkey=None, bitbox01=None, ledger=None, ledger_legacy=None, jade=None, bitbox02=None) args = parser.parse_args() @@ -79,11 +86,16 @@ success = True suite = unittest.TestSuite() if not args.device_only: - suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestDescriptor)) + if args.pkcs11: + if HAS_PKCS11_TESTS: + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPKCS11Client)) + else: + print("Skipping PKCS11 tests: test_pkcs11 or dependencies not available.", file=sys.stderr) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestSegwitAddress)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPSBT)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBase58)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBIP32)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPKCS11Client)) if sys.platform.startswith("linux"): suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestUdevRulesInstaller)) success = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite).wasSuccessful() diff --git a/test/test_pkcs11.py b/test/test_pkcs11.py new file mode 100644 index 000000000..7c38f109c --- /dev/null +++ b/test/test_pkcs11.py @@ -0,0 +1,148 @@ +""" +Test PKCS11 Token Support +************************ +This module tests the PKCS11 token support implementation. +""" + +import os +import unittest +from unittest.mock import patch, MagicMock +from typing import Dict, Any + +from hwilib.devices.pkcs11 import PKCS11Client, enumerate +from hwilib.common import Chain +from hwilib.psbt import PSBT +from hwilib.key import ExtendedKey + +class TestPKCS11Client(unittest.TestCase): + """Test the PKCS11 client implementation.""" + + def setUp(self): + """Set up test environment.""" + self.path = "/path/to/pkcs11/library.so" + self.password = "test123" + self.chain = Chain.MAIN + self.expert = False + + # Mock PKCS11 library + self.mock_lib = MagicMock() + self.mock_token = MagicMock() + self.mock_session = MagicMock() + self.mock_master_key = MagicMock() + + # Set up mock return values + self.mock_lib.get_token.return_value = self.mock_token + self.mock_token.open.return_value = self.mock_session + self.mock_session.get_key.return_value = self.mock_master_key + self.mock_master_key.get_attribute.return_value = b'\x06\x05\x2b\x81\x04\x00\x0a' # secp256k1 OID + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_initialization(self, mock_pkcs11): + """Test PKCS11 client initialization.""" + mock_pkcs11.lib.return_value = self.mock_lib + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + + # Verify initialization + self.assertEqual(client.path, self.path) + self.assertEqual(client.password, self.password) + self.assertEqual(client.chain, self.chain) + self.assertEqual(client.expert, self.expert) + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_get_pubkey_at_path(self, mock_pkcs11): + """Test getting public key at BIP32 path.""" + mock_pkcs11.lib.return_value = self.mock_lib + # Provide pkcs11.Attribute constants used by the client + mock_pkcs11.Attribute = MagicMock(EC_POINT='EC_POINT', EC_PARAMS='EC_PARAMS') + + # Mock key attributes + self.mock_session.get_key.return_value = MagicMock( + get_attribute=lambda attr: { + 'EC_POINT': b'\x02' + b'\x11' * 32, # compressed secp256k1 pubkey + 'EC_PARAMS': b'\x06\x05\x2b\x81\x04\x00\x0a', # secp256k1 OID + }.get(attr) + ) + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + result = client.get_pubkey_at_path("m/44'/0'/0'/0/0") + + self.assertIsInstance(result, ExtendedKey) + self.assertEqual(result.key, b'\x02' + b'\x11' * 32) + @patch('hwilib.devices.pkcs11.pkcs11') + def test_sign_tx(self, mock_pkcs11): + """Test transaction signing.""" + mock_pkcs11.lib.return_value = self.mock_lib + + # Create a mock PSBT + psbt = PSBT() + psbt.inputs = [MagicMock( + hd_keypaths={b'test_pubkey': MagicMock(fingerprint=b'\x00\x01\x02\x03', path="m/44'/0'/0'/0/0")}, + sighash=b'test_sighash', + partial_sigs={} + )] + + # Mock master fingerprint + self.mock_master_key.get_attribute.return_value = b'\x00\x01\x02\x03' + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + result = client.sign_tx(psbt) + + self.assertIsInstance(result, PSBT) + self.assertIn(b'test_pubkey', result.inputs[0].partial_sigs) + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_sign_message(self, mock_pkcs11): + """Test message signing.""" + mock_pkcs11.lib.return_value = self.mock_lib + + # Mock signature + self.mock_session.get_key.return_value = MagicMock( + sign=lambda x, mechanism: b'test_signature' + ) + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + result = client.sign_message("test message", "m/44'/0'/0'/0/0") + + self.assertEqual(result, "746573745f7369676e6174757265") # hex of 'test_signature' + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_get_master_fingerprint(self, mock_pkcs11): + """Test getting master key fingerprint.""" + mock_pkcs11.lib.return_value = self.mock_lib + + # Mock public key + self.mock_master_key.get_attribute.return_value = b'test_pubkey' + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + result = client.get_master_fingerprint() + + self.assertIsInstance(result, bytes) + self.assertEqual(len(result), 4) + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_enumerate(self, mock_pkcs11): + """Test device enumeration.""" + mock_pkcs11.lib.return_value = self.mock_lib + self.mock_lib.get_tokens.return_value = [MagicMock(label='Bitcoin')] + + result = enumerate(self.password, self.expert, self.chain) + + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['type'], 'pkcs11') + self.assertEqual(result[0]['model'], 'PKCS#11 Token') + self.assertEqual(result[0]['label'], 'Bitcoin') + + @patch('hwilib.devices.pkcs11.pkcs11') + def test_close(self, mock_pkcs11): + """Test closing the session.""" + mock_pkcs11.lib.return_value = self.mock_lib + + client = PKCS11Client(self.path, self.password, self.expert, self.chain) + client.close() + + self.mock_session.close.assert_called_once() + +if __name__ == '__main__': + unittest.main() \ No newline at end of file