diff --git a/python/keycard-py/.gitignore b/python/keycard-py/.gitignore deleted file mode 100644 index fea70884..00000000 --- a/python/keycard-py/.gitignore +++ /dev/null @@ -1,194 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv* -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the enitre vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore \ No newline at end of file diff --git a/python/keycard-py/CHANGELOG.md b/python/keycard-py/CHANGELOG.md deleted file mode 100644 index 3a76f92b..00000000 --- a/python/keycard-py/CHANGELOG.md +++ /dev/null @@ -1,61 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Support Pairing Mode for Keycard version 3.2 - -### Fixed - -- Older versions not supported. Python 3.10+ now supported - -## [0.3.0] - 2025-08-24 - -### Changed - -- Open Secure Channel also mutually authenticates unless specified otherwise. -- SignatureResult object returned by sign methods -- Identity returns public key. - -## [0.2.0] - 2025-08-06 - -### Added - -- LOAD KEY command -- SET PINLESS PATH command -- GENERATE MNEMONIC command -- DERIVE KEY command - -## [0.1.0] - 2025-08-05 - -### Added - -- INIT command -- IDENT command -- OPEN SECURE CHANNEL command -- MUTUALLY AUTHENTICATE command -- PAIR command -- UNPAIR command -- GET STATUS command -- VERIFY PIN command -- CHANGE PIN command -- UNBLOCK PIN command -- REMOVE KEY command -- GENERATE KEY command -- SIGN command -- EXPORT KEY command -- GET_DATA command -- STORE DATA command -- FACTORY RESET command - - -[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.3.0...HEAD -[0.3.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.2.0...v0.3.0 -[0.2.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.1.0...v0.2.0 -[0.1.0]: https://github.com/mmlado/keycard-py/releases/tag/v0.1.0 \ No newline at end of file diff --git a/python/keycard-py/LICENSE b/python/keycard-py/LICENSE deleted file mode 100644 index a49f04cf..00000000 --- a/python/keycard-py/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 mmlado - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/python/keycard-py/README.md b/python/keycard-py/README.md deleted file mode 100644 index 2ad7df90..00000000 --- a/python/keycard-py/README.md +++ /dev/null @@ -1,27 +0,0 @@ -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![codecov](https://codecov.io/gh/mmlado/keycard-py/branch/main/graph/badge.svg)](https://codecov.io/gh/mmlado/keycard-py) [![PyPI version](https://img.shields.io/pypi/v/keycard.svg)](https://pypi.org/project/keycard/) [![Build status](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml/badge.svg)](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml) [![Documentation](https://img.shields.io/badge/docs-gh--pages-blue.svg)](https://mmlado.github.io/keycard-py/) ![stars](https://img.shields.io/github/stars/mmlado/keycard-py.svg?style=social) ![lastcommit](https://img.shields.io/github/last-commit/mmlado/keycard-py.svg) ![numcontributors](https://img.shields.io/github/contributors-anon/mmlado/keycard-py.svg) - -A minimal, clean, fully native Python SDK for communicating with [Keycard](https://keycard.tech) smart cards. - -## Requirements - -- Python 3.10 or higher -- The library is tested on Python 3.10, 3.11, 3.12, and 3.13 - -## Installation - -```bash -git clone https://github.com/mmlado/keycard-py.git -cd keycard-py -python -m venv venv -source venv/bin/activate -pip install -e . -pytest -``` - -## License - -MIT - -## Contributions - -Contributions are welcome as this SDK grows. \ No newline at end of file diff --git a/python/keycard-py/docs/conf.py b/python/keycard-py/docs/conf.py deleted file mode 100644 index c11d3d65..00000000 --- a/python/keycard-py/docs/conf.py +++ /dev/null @@ -1,36 +0,0 @@ - -import os -import sys -sys.path.insert(0, os.path.abspath('../../')) - -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'KeyCard.py' -copyright = '2025, mmlado' -author = 'mmlado' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx_autodoc_typehints', - 'sphinx.ext.napoleon', # Google/NumPy style docstrings -] - -templates_path = ['_templates'] -exclude_patterns = [] - - - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'alabaster' -html_static_path = ['_static'] diff --git a/python/keycard-py/docs/index.rst b/python/keycard-py/docs/index.rst deleted file mode 100644 index cd2e7345..00000000 --- a/python/keycard-py/docs/index.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. KeyCard.py documentation master file, created by - sphinx-quickstart on Thu Jun 26 13:32:43 2025. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -KeyCard.py documentation -======================== - -Add your content using ``reStructuredText`` syntax. See the -`reStructuredText `_ -documentation for details. - - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - modules - -.. automodule:: keycard.keycard - :members: \ No newline at end of file diff --git a/python/keycard-py/docs/keycard.commands.rst b/python/keycard-py/docs/keycard.commands.rst deleted file mode 100644 index d99d666c..00000000 --- a/python/keycard-py/docs/keycard.commands.rst +++ /dev/null @@ -1,77 +0,0 @@ -keycard.commands package -======================== - -Submodules ----------- - -keycard.commands.ident module ------------------------------ - -.. automodule:: keycard.commands.ident - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.init module ------------------------------ - -.. automodule:: keycard.commands.init - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.mutually_authenticate module ------------------------------ - -.. automodule:: keycard.commands.mutually_authenticate - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.open_secure_channel module ------------------------------ - -.. automodule:: keycard.commands.open_secure_channel - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.pair module ------------------------------ - -.. automodule:: keycard.commands.pair - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.select module ------------------------------ - -.. automodule:: keycard.commands.select - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.unpair module ------------------------------ - -.. automodule:: keycard.commands.unpair - :members: - :undoc-members: - :show-inheritance: - -keycard.commands.verify_pin module ------------------------------ - -.. automodule:: keycard.commands.verify_pin - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: keycard.commands - :members: - :show-inheritance: - :undoc-members: diff --git a/python/keycard-py/docs/keycard.crypto.rst b/python/keycard-py/docs/keycard.crypto.rst deleted file mode 100644 index 1725b670..00000000 --- a/python/keycard-py/docs/keycard.crypto.rst +++ /dev/null @@ -1,29 +0,0 @@ -keycard.crypto package -====================== - -Submodules ----------- - -keycard.crypto.aes module -------------------------- - -.. automodule:: keycard.crypto.aes - :members: - :show-inheritance: - :undoc-members: - -keycard.crypto.padding module ------------------------------ - -.. automodule:: keycard.crypto.padding - :members: - :show-inheritance: - :undoc-members: - -Module contents ---------------- - -.. automodule:: keycard.crypto - :members: - :show-inheritance: - :undoc-members: diff --git a/python/keycard-py/docs/keycard.parsing.rst b/python/keycard-py/docs/keycard.parsing.rst deleted file mode 100644 index 2252c043..00000000 --- a/python/keycard-py/docs/keycard.parsing.rst +++ /dev/null @@ -1,45 +0,0 @@ -keycard.parsing package -======================= - -Submodules ----------- - -keycard.parsing.application\_info module ----------------------------------------- - -.. automodule:: keycard.parsing.application_info - :members: - :show-inheritance: - :undoc-members: - -keycard.parsing.capabilities module ------------------------------------ - -.. automodule:: keycard.parsing.capabilities - :members: - :show-inheritance: - :undoc-members: - -keycard.parsing.identity module -------------------------------- - -.. automodule:: keycard.parsing.identity - :members: - :show-inheritance: - :undoc-members: - -keycard.parsing.tlv module --------------------------- - -.. automodule:: keycard.parsing.tlv - :members: - :show-inheritance: - :undoc-members: - -Module contents ---------------- - -.. automodule:: keycard.parsing - :members: - :show-inheritance: - :undoc-members: diff --git a/python/keycard-py/docs/keycard.rst b/python/keycard-py/docs/keycard.rst deleted file mode 100644 index 2d743859..00000000 --- a/python/keycard-py/docs/keycard.rst +++ /dev/null @@ -1,63 +0,0 @@ -keycard package -=============== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - keycard.commands - keycard.crypto - keycard.parsing - -Submodules ----------- - -keycard.apdu module -------------------- - -.. automodule:: keycard.apdu - :members: - :show-inheritance: - :undoc-members: - -keycard.constants module ------------------------- - -.. automodule:: keycard.constants - :members: - :show-inheritance: - :undoc-members: - -keycard.exceptions module -------------------------- - -.. automodule:: keycard.exceptions - :members: - :show-inheritance: - :undoc-members: - -keycard.keycard module ----------------------- - -.. automodule:: keycard.keycard - :members: - :show-inheritance: - :undoc-members: - -keycard.transport module ------------------------- - -.. automodule:: keycard.transport - :members: - :show-inheritance: - :undoc-members: - -Module contents ---------------- - -.. automodule:: keycard - :members: - :show-inheritance: - :undoc-members: diff --git a/python/keycard-py/docs/modules.rst b/python/keycard-py/docs/modules.rst deleted file mode 100644 index e50e4a79..00000000 --- a/python/keycard-py/docs/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -keycard -======= - -.. toctree:: - :maxdepth: 4 - - keycard diff --git a/python/keycard-py/example/example.py b/python/keycard-py/example/example.py deleted file mode 100644 index e3237df4..00000000 --- a/python/keycard-py/example/example.py +++ /dev/null @@ -1,196 +0,0 @@ -import hashlib -import hmac -import os - -from ecdsa import SigningKey, VerifyingKey, SECP256k1, util -from hashlib import sha256 -from mnemonic import Mnemonic - -from keycard import constants -from keycard.exceptions import APDUError -from keycard.keycard import KeyCard - -PIN = '123456' -PUK = '123456123456' -PAIRING_PASSWORD = 'KeycardTest' - -def bip32_master_key(seed: bytes): - I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest() - master_priv_key = I[:32] - master_chain_code = I[32:] - return master_priv_key, master_chain_code - - -def get_uncompressed_pubkey(priv_key_bytes: bytes): - sk = SigningKey.from_string(priv_key_bytes, curve=SECP256k1) - vk = sk.verifying_key - return b'\x04' + vk.to_string() - - -with KeyCard() as card: - card.select() - print('Retrieving data...') - retrieved_data = card.get_data(slot=constants.StorageSlot.PUBLIC) - print(f'Retrieved data: {retrieved_data}') - try: - print('Factory resetting card...') - card.factory_reset() - except APDUError as e: - print(f'Factory reset failed: {e}') - else: - print(card.select()) - - card.init(PIN, PUK, PAIRING_PASSWORD) - print('Card initialized.') - print(card.select()) - - print('Identifying...') - ident_public_key = card.ident() - print(f'Identity public key: {ident_public_key.hex()}') - - print('Pairing...') - pairing_index, pairing_key = card.pair(PAIRING_PASSWORD) - print(f'Paired. Index: {pairing_index}') - print(f'{pairing_key.hex()=}') - - card.open_secure_channel(pairing_index, pairing_key) - print('Secure channel established.') - - print(card.status) - - print("Generating mnemonic") - indexes = card.generate_mnemonic() - print("Generated list: ", ", ".join(str(m) for m in indexes)) - mnemo = Mnemonic("english") - words = [mnemo.wordlist[i] for i in indexes] - print("Mnemonic: ", " ".join(words)) - - print('Unblocking PIN...') - card.verify_pin('654321') - card.verify_pin('654321') - try: - card.verify_pin('654321') - except RuntimeError as e: - print(f'PIN verification failed: {e}') - card.unblock_pin(PUK, PIN) - print('PIN unblocked.') - - card.verify_pin(PIN) - print('PIN verified.') - - print('Generating key...') - key = b'0x04' + card.generate_key() - print(f'Generated key: {key.hex()}') - - print('Exporting key...') - exported_key = card.export_current_key(True) - print(f'Exported key: {exported_key.public_key.hex()}') - if exported_key.private_key: - print(f'Private key: {exported_key.private_key.hex()}') - if exported_key.chain_code: - print(f'Chain code: {exported_key.chain_code.hex()}') - - digest = sha256(b'This is a test message.').digest() - print(f'Digest: {digest.hex()}') - signature = card.sign(digest) - print(f'Signature: {signature}') - - vk = VerifyingKey.from_string(exported_key.public_key, curve=SECP256k1) - try: - vk.verify_digest( - signature.signature_der, digest, sigdecode=util.sigdecode_der) - print('Signature verified successfully.') - except Exception as e: - print(f"Signature verification failed: {e}") - - print("Set pinless path...") - card.set_pinless_path("m/44'/60'/0'/0/0") - - print("Sign with pinless path...") - print(f'Digest: {digest.hex()}') - signature = card.sign_pinless(digest) - print(f'Signature: {signature}') - - exported_key = card.export_key( - derivation_option=constants.DerivationOption.DERIVE, - public_only=True, - keypath="m/44'/60'/0'/0/0" - ) - - vk = VerifyingKey.from_string(exported_key.public_key, curve=SECP256k1) - try: - vk.verify_digest( - signature.signature_der, digest, sigdecode=util.sigdecode_der) - print('Signature verified successfully.') - except Exception as e: - print(f"Signature verification failed: {e}") - - - print("Load key...") - sk = SigningKey.generate(curve=SECP256k1) - vk = sk.verifying_key - public_key = b'\x04' + vk.to_string() - - result = card.load_key( - key_type=constants.LoadKeyType.ECC, - public_key=public_key, - private_key=sk.to_string() - ) - - uid = sha256(public_key).digest() - if (result == uid): - print("Received public key hash is the same") - else: - print("Received public key hash is not the same") - - print("Loading key from mnemonic...") - mnemonic = ( - "gravity machine north sort system female " - "filter attitude volume fold club stay" - ) - passphrase = "" - mnemo = Mnemonic("english") - seed = mnemo.to_seed(mnemonic, passphrase) - - master_priv_key, master_chain_code = bip32_master_key(seed) - pubkey = get_uncompressed_pubkey(master_priv_key) - uid = hashlib.sha256(pubkey).digest() - - result = card.load_key( - key_type=constants.LoadKeyType.BIP39_SEED, - bip39_seed=seed - ) - - if (result == uid): - print("Received public key hash is the same") - else: - print("Received public key hash is not the same") - - print("Deriving key...") - card.derive_key("m/44'/60'/0'/0/0") - - card.change_pin(PIN) - print('PIN changed.') - - card.change_puk(PUK) - print('PUK changed.') - - card.change_pairing_secret(PAIRING_PASSWORD) - print('Pairing secret changed.') - - print('Storing data...') - data = b'This is some test data.' - card.store_data(data, slot=constants.StorageSlot.PUBLIC) - print('Data stored.') - - print('Retrieving data...') - retrieved_data = card.get_data(slot=constants.StorageSlot.PUBLIC) - print(f'Retrieved data: {retrieved_data}') - - print('Removing key...') - card.remove_key() - print('Key removed.') - - print('Unpairing...') - card.unpair(pairing_index) - print(f'Unpaired index {pairing_index}.') diff --git a/python/keycard-py/keycard/__init__.py b/python/keycard-py/keycard/__init__.py deleted file mode 100644 index e20f382a..00000000 --- a/python/keycard-py/keycard/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""KeyCard Python SDK - APDU communication and cryptographic utilities.""" - -__version__ = "0.3.0" -__doc__ = "Python SDK for interacting with Status Keycard." diff --git a/python/keycard-py/keycard/apdu.py b/python/keycard-py/keycard/apdu.py deleted file mode 100644 index 05a9d8ff..00000000 --- a/python/keycard-py/keycard/apdu.py +++ /dev/null @@ -1,48 +0,0 @@ -''' -This module provides classes and functions for handling APDU (Application -Protocol Data Unit) responses and encoding data in LV (Length-Value) format. -''' - -from dataclasses import dataclass - - -@dataclass -class APDUResponse: - ''' - Represents a response to an APDU (Application Protocol Data Unit) command. - - Attributes: - data (bytes): The response data returned from the APDU command. - status_word (int): The status word indicating the result of the APDU - command. - ''' - data: bytes - status_word: int - - def __str__(self) -> str: - return ( - f'APDUResponse(data={bytes(self.data).hex()}, ' - f'status_word={hex(self.status_word)})' - ) - - -def encode_lv(value: bytes) -> bytes: - ''' - Encodes the given bytes using LV (Length-Value) encoding. - - The function prepends the length of the input bytes as a single byte, - followed by the value itself. The maximum supported length is 255 bytes. - - Args: - value (bytes): The data to encode. - - Returns: - bytes: The LV-encoded bytes. - - Raises: - ValueError: If the input exceeds 255 bytes in length. - ''' - if len(value) > 255: - raise ValueError('LV encoding supports up to 255 bytes') - - return bytes([len(value)]) + value diff --git a/python/keycard-py/keycard/card_interface.py b/python/keycard-py/keycard/card_interface.py deleted file mode 100644 index f699d8f3..00000000 --- a/python/keycard-py/keycard/card_interface.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Optional, Protocol, runtime_checkable - - -@runtime_checkable -class CardInterface(Protocol): - ''' - Abstract base class representing a Keycard interface for command functions. - ''' - card_public_key: Optional[bytes] - - @property - def is_initialized(self) -> bool: ... - - @property - def is_secure_channel_open(self) -> bool: ... - - @property - def is_pin_verified(self) -> bool: ... - - @property - def is_selected(self) -> bool: ... - - def send_apdu( - self, - ins: int, - p1: int = 0x00, - p2: int = 0x00, - data: bytes = b'', - cla: Optional[int] = None - ) -> bytes: ... - - def send_secure_apdu( - self, - ins: int, - p1: int = 0x00, - p2: int = 0x00, - data: bytes = b'' - ) -> bytes: ... diff --git a/python/keycard-py/keycard/commands/__init__.py b/python/keycard-py/keycard/commands/__init__.py deleted file mode 100644 index 359388d6..00000000 --- a/python/keycard-py/keycard/commands/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from .change_secret import change_secret -from .derive_key import derive_key -from .export_key import export_key -from .export_lee_key import export_lee_key -from .factory_reset import factory_reset -from .generate_key import generate_key -from .generate_mnemonic import generate_mnemonic -from .get_data import get_data -from .ident import ident -from .init import init -from .get_status import get_status -from .load_key import load_key -from .mutually_authenticate import mutually_authenticate -from .open_secure_channel import open_secure_channel -from .pair import pair -from .remove_key import remove_key -from .select import select -from .set_pinless_path import set_pinless_path -from .sign import sign -from .store_data import store_data -from .unblock_pin import unblock_pin -from .unpair import unpair -from .verify_pin import verify_pin - -__all__ = [ - 'change_secret', - 'derive_key', - 'export_key', - 'export_lee_key', - 'factory_reset', - 'generate_key', - 'generate_mnemonic', - 'get_data', - 'ident', - 'init', - 'get_status', - 'load_key', - 'mutually_authenticate', - 'open_secure_channel', - 'pair', - 'remove_key', - 'select', - 'set_pinless_path', - 'sign', - 'store_data', - 'unblock_pin', - 'unpair', - 'verify_pin', -] diff --git a/python/keycard-py/keycard/commands/change_secret.py b/python/keycard-py/keycard/commands/change_secret.py deleted file mode 100644 index 97cd3799..00000000 --- a/python/keycard-py/keycard/commands/change_secret.py +++ /dev/null @@ -1,46 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_pin_verified -from ..crypto.generate_pairing_token import generate_pairing_token - - -@require_pin_verified -def change_secret( - card: CardInterface, - new_value: bytes | str, - pin_type: constants.PinType -) -> None: - """ - Changes the specified secret (PIN, PUK, PAIRING) or secret on the card. - - Preconditions: - - Secure Channel must be opened - - User PIN must be verified - - Args: - card: The card session object. - new_value (bytes | str): The new PIN/PUK/secret. - pin_type (PinType): Type of PIN (USER, PUK, or PAIRING) - - Raises: - ValueError: If input format is invalid. - APDUError: If the card returns an error status word. - """ - if pin_type == constants.PinType.PAIRING: - if isinstance(new_value, str): - new_value = generate_pairing_token(new_value) - elif len(new_value) != 32: - raise ValueError("Pairing secret must be 32 bytes.") - elif isinstance(new_value, str): - new_value = new_value.encode("utf-8") - - if pin_type == constants.PinType.USER and len(new_value) != 6: - raise ValueError("User PIN must be exactly 6 digits.") - elif pin_type == constants.PinType.PUK and len(new_value) != 12: - raise ValueError("PUK must be exactly 12 digits.") - - card.send_secure_apdu( - ins=constants.INS_CHANGE_SECRET, - p1=pin_type.value, - data=new_value - ) diff --git a/python/keycard-py/keycard/commands/derive_key.py b/python/keycard-py/keycard/commands/derive_key.py deleted file mode 100644 index ba4ef93a..00000000 --- a/python/keycard-py/keycard/commands/derive_key.py +++ /dev/null @@ -1,25 +0,0 @@ -from ..card_interface import CardInterface -from ..constants import INS_DERIVE_KEY -from ..parsing.keypath import KeyPath -from ..preconditions import require_pin_verified - - -@require_pin_verified -def derive_key(card: CardInterface, path: str = '') -> None: - """ - Set the derivation path for subsequent SIGN and EXPORT KEY commands. - - Args: - card (CardInterface): The card interface. - path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0") or - "../0/1" (parent) or "./0" (current). - - Raises: - APDUError: if the derivation fails or the format is invalid. - """ - keypath = KeyPath(path) - card.send_secure_apdu( - ins=INS_DERIVE_KEY, - p1=keypath.source, - data=keypath.data - ) diff --git a/python/keycard-py/keycard/commands/export_key.py b/python/keycard-py/keycard/commands/export_key.py deleted file mode 100644 index 3a72f572..00000000 --- a/python/keycard-py/keycard/commands/export_key.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Optional, Union - -from .. import constants -from ..card_interface import CardInterface -from ..constants import DerivationOption, KeyExportOption, DerivationSource -from ..parsing import tlv -from ..parsing.exported_key import ExportedKey -from ..parsing.keypath import KeyPath -from ..preconditions import require_pin_verified - - -@require_pin_verified -def export_key( - card: CardInterface, - derivation_option: constants.DerivationOption, - public_only: bool, - keypath: Optional[Union[str, bytes, bytearray]] = None, - make_current: bool = False, - source: DerivationSource = DerivationSource.MASTER -) -> ExportedKey: - """ - Export a key (public or private) from the card using an optional keypath. - - If derivation_option == CURRENT, keypath can be omitted or empty. - - Args: - card: The card object - derivation_option: e.g. DERIVE, CURRENT, DERIVE_AND_MAKE_CURRENT - public_only: If True, export only public key - keypath: BIP32-style string or packed bytes, or None if CURRENT - make_current: Whether to update the card's current path - source: MASTER (0x00), PARENT (0x40), CURRENT (0x80) - - Returns: - dict with optional 'public_key', 'private_key', 'chain_code' - """ - if keypath is None: - if derivation_option != constants.DerivationOption.CURRENT: - raise ValueError( - "Keypath required unless using CURRENT derivation") - data = b"" - elif isinstance(keypath, str): - data = KeyPath(keypath).data - elif isinstance(keypath, (bytes, bytearray)): - if len(keypath) % 4 != 0: - raise ValueError("Byte keypath must be a multiple of 4") - data = bytes(keypath) - else: - raise TypeError("Keypath must be a string or bytes") - - if make_current: - p1 = DerivationOption.DERIVE_AND_MAKE_CURRENT - else: - p1 = derivation_option - p1 |= source - - if public_only: - p2 = KeyExportOption.PUBLIC_ONLY - else: - p2 = KeyExportOption.PRIVATE_AND_PUBLIC - - response = card.send_secure_apdu( - ins=constants.INS_EXPORT_KEY, - p1=p1, - p2=p2, - data=data - ) - - outer = tlv.parse_tlv(response) - tpl = outer.get(0xA1) - if not tpl: - raise ValueError("Missing keypair template (tag 0xA1)") - - inner = tlv.parse_tlv(tpl[0]) - - return ExportedKey( - public_key=inner.get(0x80, [None])[0], - private_key=inner.get(0x81, [None])[0], - chain_code=inner.get(0x82, [None])[0], - ) diff --git a/python/keycard-py/keycard/commands/export_lee_key.py b/python/keycard-py/keycard/commands/export_lee_key.py deleted file mode 100644 index 4d532360..00000000 --- a/python/keycard-py/keycard/commands/export_lee_key.py +++ /dev/null @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union - -from .. import constants -from ..card_interface import CardInterface -from ..constants import DerivationOption, DerivationSource -from ..parsing import tlv -from ..preconditions import require_pin_verified - - -@dataclass -class ExportedLeeKey: - """Represents a LEE key template containing LEE_NSK and LEE_VSK.""" - lee_nsk: Optional[bytes] = None - lee_vsk: Optional[bytes] = None - - -@require_pin_verified -def export_lee_key( - card: CardInterface, - derivation_option: constants.DerivationOption, - keypath: Optional[Union[str, bytes, bytearray]] = None, - make_current: bool = False, - source: DerivationSource = DerivationSource.MASTER -) -> ExportedLeeKey: - """ - Export a LEE key template from the card. - - The output is a key template (tag 0xA1) containing LEE_NSK (tag 0x83) - and LEE_VSK (tag 0x84). - - If derivation_option == CURRENT, keypath can be omitted or empty. - - Args: - card: The card object - derivation_option: e.g. DERIVE, CURRENT, DERIVE_AND_MAKE_CURRENT - keypath: BIP32-style string or packed bytes, or None if CURRENT - make_current: Whether to update the card's current path - source: MASTER (0x00), PARENT (0x40), CURRENT (0x80) - - Returns: - ExportedLeeKey with lee_nsk and lee_vsk fields - """ - if keypath is None: - if derivation_option != constants.DerivationOption.CURRENT: - raise ValueError( - "Keypath required unless using CURRENT derivation") - data = b"" - elif isinstance(keypath, str): - from ..parsing.keypath import KeyPath - data = KeyPath(keypath).data - elif isinstance(keypath, (bytes, bytearray)): - if len(keypath) % 4 != 0: - raise ValueError("Byte keypath must be a multiple of 4") - data = bytes(keypath) - else: - raise TypeError("Keypath must be a string or bytes") - - if make_current: - p1 = DerivationOption.DERIVE_AND_MAKE_CURRENT - else: - p1 = derivation_option - p1 |= source - - response = card.send_secure_apdu( - ins=constants.INS_EXPORT_LEE_KEY, - p1=p1, - p2=0x00, - data=data - ) - - outer = tlv.parse_tlv(response) - tpl = outer.get(0xA1) - if not tpl: - raise ValueError("Missing keypair template (tag 0xA1)") - - inner = tlv.parse_tlv(tpl[0]) - - return ExportedLeeKey( - lee_nsk=inner.get(0x83, [None])[0], - lee_vsk=inner.get(0x84, [None])[0], - ) diff --git a/python/keycard-py/keycard/commands/factory_reset.py b/python/keycard-py/keycard/commands/factory_reset.py deleted file mode 100644 index 8a4a296a..00000000 --- a/python/keycard-py/keycard/commands/factory_reset.py +++ /dev/null @@ -1,15 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_selected - - -@require_selected -def factory_reset(card: CardInterface) -> None: - ''' - Sends the FACTORY_RESET command to the card. - ''' - card.send_apdu( - ins=constants.INS_FACTORY_RESET, - p1=0xAA, - p2=0x55 - ) diff --git a/python/keycard-py/keycard/commands/generate_key.py b/python/keycard-py/keycard/commands/generate_key.py deleted file mode 100644 index 8afbe295..00000000 --- a/python/keycard-py/keycard/commands/generate_key.py +++ /dev/null @@ -1,27 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_secure_channel - - -@require_secure_channel -def generate_key(card: CardInterface) -> bytes: - ''' - Generates a new key on the card and returns the key UID. - - Preconditions: - - Secure Channel must be opened - - PIN must be verified - - Args: - transport: Transport instance for APDU communication - session: SecureChannel instance for wrapping/unwrapping - - Returns: - bytes: Key UID (SHA-256 of the public key) - - Raises: - APDUError: If the response status word is not 0x9000 - ''' - return card.send_secure_apdu( - ins=constants.INS_GENERATE_KEY - ) diff --git a/python/keycard-py/keycard/commands/generate_mnemonic.py b/python/keycard-py/keycard/commands/generate_mnemonic.py deleted file mode 100644 index 1d00210c..00000000 --- a/python/keycard-py/keycard/commands/generate_mnemonic.py +++ /dev/null @@ -1,41 +0,0 @@ -from ..card_interface import CardInterface -from ..constants import INS_GENERATE_MNEMONIC -from ..preconditions import require_secure_channel - - -@require_secure_channel -def generate_mnemonic( - card: CardInterface, - checksum_size: int = 6 -) -> list[int]: - """ - Generate a BIP39 mnemonic using the card's RNG. - - Args: - card (CardInterface): The card interface. - checksum_size (int): Number of checksum bits - (between 4 and 8 inclusive). - - Returns: - List[int]: List of integers (0-2047) corresponding to wordlist - indexes. - - Raises: - ValueError: If checksum size is outside the allowed range. - APDUError: If the card rejects the request. - """ - if not (4 <= checksum_size <= 8): - raise ValueError("Checksum size must be between 4 and 8") - - response = card.send_secure_apdu( - ins=INS_GENERATE_MNEMONIC, - p1=checksum_size - ) - - if len(response) % 2 != 0: - raise ValueError("Response must contain an even number of bytes") - - return [ - (response[i] << 8) | response[i + 1] - for i in range(0, len(response), 2) - ] diff --git a/python/keycard-py/keycard/commands/get_data.py b/python/keycard-py/keycard/commands/get_data.py deleted file mode 100644 index 6478773c..00000000 --- a/python/keycard-py/keycard/commands/get_data.py +++ /dev/null @@ -1,35 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_selected - - -@require_selected -def get_data( - card: CardInterface, - slot: constants.StorageSlot = constants.StorageSlot.PUBLIC -) -> bytes: - """ - Gets the data on the card previously stored with the store data command - in the specified slot. - - If the secure channel is open, it uses the secure APDU command. - Otherwise, it uses the proprietary APDU command. - - Args: - card: The card session object. - slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH) - - Raises: - ValueError: If slot is invalid or data is too long. - """ - if card.is_secure_channel_open: - return card.send_secure_apdu( - ins=constants.INS_GET_DATA, - p1=slot.value - ) - - return card.send_apdu( - cla=constants.CLA_PROPRIETARY, - ins=constants.INS_GET_DATA, - p1=slot.value - ) diff --git a/python/keycard-py/keycard/commands/get_status.py b/python/keycard-py/keycard/commands/get_status.py deleted file mode 100644 index e887a604..00000000 --- a/python/keycard-py/keycard/commands/get_status.py +++ /dev/null @@ -1,63 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..parsing import tlv -from ..preconditions import require_secure_channel - - -@require_secure_channel -def get_status( - card: CardInterface, - key_path: bool = False -) -> dict[str, int | bool] | list[int]: - ''' - Query the application status or key path from the Keycard. - - Requires an open Secure Channel. - - Args: - transport: Transport instance used to send APDU bytes. - session: An established SecureChannel instance. - key_path (bool): If True, returns the current key path. - If False (default), returns application status. - - Returns: - If key_path is False: - dict with keys: - - pin_retry_count (int) - - puk_retry_count (int) - - initialized (bool) - - If key_path is True: - List of 32-bit integers representing the current key path. - - Raises: - APDUError: If the response status word is not 0x9000. - ValueError: If the application status template (tag 0xA3) is missing. - ''' - response: bytes = card.send_secure_apdu( - ins=constants.INS_GET_STATUS, - p1=0x01 if key_path else 0x00, - ) - - if key_path: - return [ - int.from_bytes(response[i:i+4], 'big') - for i in range(0, len(response), 4) - ] - - outer = tlv.parse_tlv(response) - - if 0xA3 not in outer: - raise ValueError('Missing tag 0xA3 (Application Status Template)') - - inner = tlv.parse_tlv(outer[0xA3][0]) - - pin_retry = inner[0x02][0] or b'\xff' - puk_retry = inner[0x02][1] or b'\xff' - initialized = inner[0x01][0] != b'\x00' - - return { - 'pin_retry_count': pin_retry[0] if pin_retry else 0xff, - 'puk_retry_count': puk_retry[0] if puk_retry else 0xff, - 'initialized': initialized - } diff --git a/python/keycard-py/keycard/commands/ident.py b/python/keycard-py/keycard/commands/ident.py deleted file mode 100644 index aaa72d33..00000000 --- a/python/keycard-py/keycard/commands/ident.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -from typing import Optional - -from .. import constants -from ..card_interface import CardInterface -from ..parsing.identity import parse -from ..preconditions import require_selected - - -@require_selected -def ident(card: CardInterface, challenge: Optional[bytes]) -> bytes: - ''' - Sends a challenge to the card to receive a signed identity response. - - Args: - transport: An instance of the Transport class to communicate with - the card. - challenge (bytes): A challenge (nonce or data) to send to the card. - If None, a random 32-byte challenge is generated. - - Returns: - bytes: The public key extracted from the card's identity response. - - Raises: - APDUError: If the response status word is not successful (0x9000). - ''' - challenge = challenge or os.urandom(32) - - response: bytes = card.send_apdu( - ins=constants.INS_IDENT, - data=challenge - ) - - return parse(challenge, response) diff --git a/python/keycard-py/keycard/commands/init.py b/python/keycard-py/keycard/commands/init.py deleted file mode 100644 index 921d0cb3..00000000 --- a/python/keycard-py/keycard/commands/init.py +++ /dev/null @@ -1,79 +0,0 @@ -from os import urandom -from ecdsa import SigningKey, VerifyingKey, ECDH, SECP256k1 - -from .. import constants -from ..card_interface import CardInterface -from ..crypto.aes import aes_cbc_encrypt -from ..crypto.generate_pairing_token import generate_pairing_token -from ..exceptions import NotSelectedError -from ..preconditions import require_selected - - -@require_selected -def init( - card: CardInterface, - pin: str | bytes, - puk: str | bytes, - pairing_secret: str | bytes -) -> None: - ''' - Initializes a Keycard device with PIN, PUK, and pairing secret. - - Establishes an ephemeral ECDH key exchange and sends encrypted - credentials to the card. - - Args: - transport: The transport used to send APDU commands to the card. - card_public_key (bytes): The card's ECC public key, usually - retrieved via select(). - pin (bytes): The personal identification number (PIN) as bytes. - puk (bytes): The personal unblocking key (PUK) as bytes. - pairing_secret (bytes): A 32-byte shared secret or a passphrase that - will be converted into one. - - Raises: - NotSelectedError: If no card public key is provided. - ValueError: If the encrypted data exceeds a single APDU length. - APDUError: If the card returns a failure status word. - ''' - if card.card_public_key is None: - raise NotSelectedError('Card not selected. Call select() first.') - - if not isinstance(pin, bytes): - pin = pin.encode('ascii') - if not isinstance(puk, bytes): - puk = puk.encode('ascii') - if not isinstance(pairing_secret, bytes): - pairing_secret = generate_pairing_token(pairing_secret) - - ephemeral_key = SigningKey.generate(curve=SECP256k1) - our_pubkey_bytes: bytes = \ - ephemeral_key.verifying_key.to_string('uncompressed') - card_pubkey = VerifyingKey.from_string( - card.card_public_key, - curve=SECP256k1 - ) - ecdh = ECDH( - curve=SECP256k1, - private_key=ephemeral_key, - public_key=card_pubkey - ) - shared_secret = ecdh.generate_sharedsecret_bytes() - - plaintext: bytes = pin + puk + pairing_secret - iv: bytes = urandom(16) - ciphertext: bytes = aes_cbc_encrypt(shared_secret, iv, plaintext) - data: bytes = ( - bytes([len(our_pubkey_bytes)]) - + our_pubkey_bytes - + iv - + ciphertext - ) - - if len(data) > 255: - raise ValueError('Data too long for single APDU') - - card.send_apdu( - ins=constants.INS_INIT, - data=data - ) diff --git a/python/keycard-py/keycard/commands/load_key.py b/python/keycard-py/keycard/commands/load_key.py deleted file mode 100644 index 8e09a255..00000000 --- a/python/keycard-py/keycard/commands/load_key.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional - -from .. import constants -from ..card_interface import CardInterface -from ..parsing import tlv -from ..preconditions import require_pin_verified - - -@require_pin_verified -def load_key( - card: CardInterface, - key_type: constants.LoadKeyType, - public_key: Optional[bytes] = None, - private_key: Optional[bytes] = None, - chain_code: Optional[bytes] = None, - bip39_seed: Optional[bytes] = None, - lee_seed: Optional[bytes] = None -) -> bytes: - """ - Load a key into the card for signing purposes. - - Args: - card: The card interface. - key_type: Key type - public_key: Optional ECC public key (tag 0x80). - private_key: ECC private key (tag 0x81). - chain_code: Optional chain code (tag 0x82, only for extended key). - bip39_seed: 64-byte BIP39 seed (only for key_type=BIP39_SEED). - lee_seed: 64-byte LEE seed (only for key_type=BIP39_SEED). - - Returns: - UID of the loaded key (SHA-256 of public key). - """ - if key_type == constants.LoadKeyType.BIP39_SEED: - if bip39_seed is None and lee_seed is None: - raise ValueError( - "Either bip39_seed or lee_seed must be provided for key_type = BIP39_SEED") - data = bip39_seed if bip39_seed is not None else lee_seed - - if data is not None and len(data) > 64 or len(data) < 16: - raise ValueError( - "BIP39/LEE seed must be 16-64 bytes") - else: - inner_tlv = [] - if public_key is not None: - inner_tlv.append(tlv.encode_tlv(0x80, public_key)) - if private_key is None: - raise ValueError("Private key (tag 0x81) is required") - inner_tlv.append(tlv.encode_tlv(0x81, private_key)) - if ( - key_type == constants.LoadKeyType.EXTENDED_ECC and - chain_code is not None - ): - inner_tlv.append(tlv.encode_tlv(0x82, chain_code)) - tpl = tlv.encode_tlv(0xA1, b''.join(inner_tlv)) - data = tpl - - response = card.send_secure_apdu( - ins=constants.INS_LOAD_KEY, - p1=key_type, - p2=1 if lee_seed is not None else 0, - data=data - ) - - return response diff --git a/python/keycard-py/keycard/commands/mutually_authenticate.py b/python/keycard-py/keycard/commands/mutually_authenticate.py deleted file mode 100644 index 84379d86..00000000 --- a/python/keycard-py/keycard/commands/mutually_authenticate.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -from ..card_interface import CardInterface -from .. import constants -from ..preconditions import require_secure_channel -from typing import Optional - - -@require_secure_channel -def mutually_authenticate( - card: CardInterface, - client_challenge: Optional[bytes] = None -) -> None: - ''' - Performs mutual authentication between the client and the Keycard. - - Preconditions: - - Secure Channel must be opened - - The card will respond with a cryptographic challenge. The secure - session will verify the response. If the response is not exactly - 32 bytes, or if the response has an unexpected status word, the - function raises an error. - - Args: - transport: A Transport instance for sending APDUs. - session: A SecureChannel instance used for wrapping/unwrapping. - client_challenge (bytes, optional): Optional challenge bytes. - If not provided, a random 32-byte value will be generated. - - Raises: - APDUError: If the response status word is not 0x9000. - ValueError: If the decrypted response is not exactly 32 bytes. - ''' - client_challenge = client_challenge or os.urandom(32) - - response: bytes = card.send_secure_apdu( - ins=constants.INS_MUTUALLY_AUTHENTICATE, - data=client_challenge - ) - - if len(response) != 32: - raise ValueError( - 'Response to MUTUALLY AUTHENTICATE is not 32 bytes') diff --git a/python/keycard-py/keycard/commands/open_secure_channel.py b/python/keycard-py/keycard/commands/open_secure_channel.py deleted file mode 100644 index 6ce0fdee..00000000 --- a/python/keycard-py/keycard/commands/open_secure_channel.py +++ /dev/null @@ -1,68 +0,0 @@ -from ecdsa import SigningKey, VerifyingKey, SECP256k1, ECDH - -from .. import constants -from ..card_interface import CardInterface -from ..exceptions import NotSelectedError -from ..secure_channel import SecureChannel -from ..preconditions import require_initialized - - -@require_initialized -def open_secure_channel( - card: CardInterface, - pairing_index: int, - pairing_key: bytes -) -> SecureChannel: - ''' - Opens a secure session with the Keycard using ECDH and a pairing key. - - This function performs an ephemeral ECDH key exchange with the card, - sends the ephemeral public key, and receives cryptographic material - from the card to derive a secure session. - - Args: - transport: The transport used to communicate with the card. - card_public_key (bytes): The ECC public key of the card, retrieved - via select(). - pairing_index (int): The index of the previously established - pairing slot. - pairing_key (bytes): The shared 32-byte pairing key. - - Returns: - SecureChannel: A newly established secure session with the card. - - Raises: - NotSelectedError: If no card public key is provided. - APDUError: If the card returns a failure status word. - ''' - if not card.card_public_key: - raise NotSelectedError('Card not selected or missing public key') - - ephemeral_key = SigningKey.generate(curve=SECP256k1) - eph_pub_bytes = ephemeral_key.verifying_key.to_string('uncompressed') - response: bytes = card.send_apdu( - ins=constants.INS_OPEN_SECURE_CHANNEL, - p1=pairing_index, - data=eph_pub_bytes - ) - - salt = bytes(response[:32]) - seed_iv = bytes(response[32:]) - - public_key = VerifyingKey.from_string( - card.card_public_key, - curve=SECP256k1 - ) - ecdh = ECDH( - curve=SECP256k1, - private_key=ephemeral_key, - public_key=public_key - ) - shared_secret = ecdh.generate_sharedsecret_bytes() - - return SecureChannel.open( - shared_secret, - pairing_key, - salt, - seed_iv, - ) diff --git a/python/keycard-py/keycard/commands/pair.py b/python/keycard-py/keycard/commands/pair.py deleted file mode 100644 index 5f3dec4f..00000000 --- a/python/keycard-py/keycard/commands/pair.py +++ /dev/null @@ -1,75 +0,0 @@ -import hashlib -from os import urandom -from typing import Optional - -from .. import constants -from ..card_interface import CardInterface -from ..crypto.generate_pairing_token import generate_pairing_token -from ..exceptions import InvalidResponseError -from ..preconditions import require_initialized - - -@require_initialized -def pair( - card: CardInterface, - shared_secret: str | bytes, - pairing_mode: Optional[constants.PairingMode] = constants.PairingMode.ANY -) -> tuple[int, bytes]: - ''' - Performs an ECDH-based pairing handshake with the card. - - Args: - card: The keycard interface. - shared_secret: A 32-byte secret or a passphrase convertible to one. - pairing_mode: Mode for pairing: ANY, EPHEMERAL, PERSISTENT - - Returns: - tuple[int, bytes]: Pairing index and derived 32-byte pairing key. - - Raises: - ValueError: If the shared secret is not 32 bytes. - APDUError: If the card returns a non-success status word. - InvalidResponseError: If response lengths or values are unexpected. - ''' - if isinstance(shared_secret, str): - shared_secret = generate_pairing_token(shared_secret) - - if len(shared_secret) != 32: - raise ValueError('Shared secret must be 32 bytes') - - client_challenge = urandom(32) - - response = card.send_apdu( - ins=constants.INS_PAIR, - p2=pairing_mode, - data=client_challenge - ) - - if len(response) != 64: - raise InvalidResponseError('Unexpected response length') - - card_cryptogram = response[:32] - card_challenge = response[32:] - - expected = hashlib.sha256(shared_secret + client_challenge).digest() - - if card_cryptogram != expected: - raise InvalidResponseError('Card cryptogram mismatch') - - client_cryptogram = hashlib.sha256(shared_secret + card_challenge).digest() - - response = card.send_apdu( - ins=constants.INS_PAIR, - p1=0x01, - data=client_cryptogram - ) - - if len(response) != 33: - raise InvalidResponseError('Unexpected response length') - - pairing_index = response[0] - salt = response[1:] - - pairing_key = hashlib.sha256(shared_secret + salt).digest() - - return pairing_index, pairing_key diff --git a/python/keycard-py/keycard/commands/remove_key.py b/python/keycard-py/keycard/commands/remove_key.py deleted file mode 100644 index fb08698d..00000000 --- a/python/keycard-py/keycard/commands/remove_key.py +++ /dev/null @@ -1,11 +0,0 @@ -from ..card_interface import CardInterface -from ..preconditions import require_pin_verified - - -@require_pin_verified -def remove_key(card: CardInterface) -> None: - ''' - Removes the key from the card, returning it to an uninitialized state. - - ''' - card.send_secure_apdu(ins=0xD3) diff --git a/python/keycard-py/keycard/commands/select.py b/python/keycard-py/keycard/commands/select.py deleted file mode 100644 index 17b64125..00000000 --- a/python/keycard-py/keycard/commands/select.py +++ /dev/null @@ -1,33 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..parsing.application_info import ApplicationInfo - - -def select(card: CardInterface) -> ApplicationInfo: - ''' - Selects the Keycard application on the smart card and retrieves - application information. - - Sends a SELECT APDU command using the Keycard AID, checks for a - successful response, parses the returned application information, - and returns it. - - Args: - transport: The transport instance used to send the APDU command. - - Returns: - ApplicationInfo: Parsed information about the selected Keycard - application. - - Raises: - APDUError: If the card returns a status word indicating failure. - ''' - result = card.send_apdu( - cla=constants.CLAISO7816, - ins=constants.INS_SELECT, - p1=0x04, - p2=0x00, - data=constants.KEYCARD_AID - ) - - return ApplicationInfo.parse(result) diff --git a/python/keycard-py/keycard/commands/set_pinless_path.py b/python/keycard-py/keycard/commands/set_pinless_path.py deleted file mode 100644 index 729afdf3..00000000 --- a/python/keycard-py/keycard/commands/set_pinless_path.py +++ /dev/null @@ -1,25 +0,0 @@ -from ..card_interface import CardInterface -from ..constants import INS_SET_PINLESS_PATH -from ..parsing.keypath import KeyPath -from ..preconditions import require_pin_verified - - -@require_pin_verified -def set_pinless_path(card: CardInterface, path: str) -> None: - """ - Set a PIN-less path on the card. Allows signing without PIN/auth if the - current derived key matches this path. - - Args: - card (CardInterface): The card interface. - path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0"). An empty - string disables the pinless path. - - Raises: - APDUError: if the card rejects the input (invalid path) - """ - keypath = KeyPath(path).data if path else b"" - card.send_secure_apdu( - ins=INS_SET_PINLESS_PATH, - data=keypath - ) diff --git a/python/keycard-py/keycard/commands/sign.py b/python/keycard-py/keycard/commands/sign.py deleted file mode 100644 index 17993612..00000000 --- a/python/keycard-py/keycard/commands/sign.py +++ /dev/null @@ -1,136 +0,0 @@ -from typing import Optional - -from ecdsa.util import sigdecode_der - - -from .. import constants -from ..constants import DerivationOption, DerivationSource, SigningAlgorithm -from ..card_interface import CardInterface -from ..exceptions import InvalidStateError -from ..parsing import tlv -from ..parsing.keypath import KeyPath -from ..parsing.signature_result import SignatureResult - - -def sign( - card: CardInterface, - digest: bytes, - p1: DerivationOption = DerivationOption.CURRENT, - p2: SigningAlgorithm = SigningAlgorithm.ECDSA_SECP256K1, - derivation_path: Optional[str] = None -) -> SignatureResult: - """ - Sign a 32-byte digest using the specified key and signing algorithm. - - This command sends the SIGN APDU to the Keycard and parses the response, - returning a structured `SignatureResult` object. The signature may be - returned as a DER-encoded structure, a raw 65-byte format including - the recovery ID, or an ECDSA template depending on card behavior. - - Preconditions: - - Secure Channel must be opened (unless using PINLESS) - - PIN must be verified (unless using PINLESS) - - A valid keypair must be loaded on the card - - If P1=PINLESS, a PIN-less path must be configured - - Args: - card (CardInterface): Active Keycard transport session. - digest (bytes): 32-byte hash to be signed. - p1 (DerivationOption): Key derivation option. One of: - - CURRENT: Sign with the currently loaded key - - DERIVE: Derive key for signing without changing current - - DERIVE_AND_MAKE_CURRENT: Derive and load for future use - - PINLESS: Use pre-defined PIN-less key without SC/PIN - p2 (SigningAlgorithm): Signing algorithm. Defaults to - ECDSA_SECP256K1. Other options include SCHNORR_BIP340. - derivation_path (Optional[str]): String-formatted BIP32 path - (e.g. "m/44'/60'/0'/0/0"). Required if `p1` uses derivation. - The source (master/parent/current) is inferred from the path - prefix. - - Returns: - SignatureResult: Parsed signature result, including the signature - (DER or raw), algorithm, and optional recovery ID or public key. - - Raises: - ValueError: If the digest is not 32 bytes or path is invalid. - InvalidStateError: If preconditions (PIN, SC) are not met. - APDUError: If the card returns an error (e.g., SW=0x6985). - """ - if p2 not in ( - SigningAlgorithm.ECDSA_SECP256K1, - SigningAlgorithm.SCHNORR_BIP340 - ): - raise NotImplementedError( - f"Signature algorithm {p2} not supported" - ) - - if len(digest) != 32: - raise ValueError("Digest must be exactly 32 bytes") - - if p1 != DerivationOption.PINLESS and not card.is_pin_verified: - raise InvalidStateError( - "PIN must be verified to sign with this derivation option") - - data = digest - source = DerivationSource.MASTER - if p1 in ( - DerivationOption.DERIVE, - DerivationOption.DERIVE_AND_MAKE_CURRENT - ): - if not derivation_path: - raise ValueError("Derivation path cannot be empty") - key_path = KeyPath(derivation_path) - data += key_path.data - source = key_path.source - - response = card.send_secure_apdu( - ins=constants.INS_SIGN, - p1=p1 | source, - p2=p2, - data=data - ) - - if response.startswith(b'\xA0'): - outer = tlv.parse_tlv(response) - inner = tlv.parse_tlv(outer[0xA0][0]) - pub = inner.get(0x80, [None])[0] - - if len(inner.get(0x80, [])) > 1: - return SignatureResult( - algo=p2, - digest=digest, - r=int.from_bytes(inner[0x80][1][:32], "big"), - s=int.from_bytes(inner[0x80][1][32:64], "big"), - recovery_id=-1, - public_key=pub - ) - else: - der_bytes = ( - b'\x30' + - len(inner[0x30][0]).to_bytes(1, 'big') + - inner[0x30][0] - ) - signature = sigdecode_der(der_bytes, 0) - r, s = signature - return SignatureResult( - algo=p2, - digest=digest, - r=r, - s=s, - public_key=pub - ) - elif response.startswith(b'\x80'): - outer = tlv.parse_tlv(response) - raw = outer[0x80][0] - if len(raw) != 65: - raise ValueError("Expected 65-byte raw signature (r||s||recId)") - return SignatureResult( - algo=p2, - digest=digest, - r=int.from_bytes(raw[:32], "big"), - s=int.from_bytes(raw[32:64], "big"), - recovery_id=int(raw[64]) - ) - - raise ValueError("Unexpected SIGN response format") diff --git a/python/keycard-py/keycard/commands/store_data.py b/python/keycard-py/keycard/commands/store_data.py deleted file mode 100644 index 672fe7a6..00000000 --- a/python/keycard-py/keycard/commands/store_data.py +++ /dev/null @@ -1,30 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_pin_verified - - -@require_pin_verified -def store_data( - card: CardInterface, - data: bytes, - slot: constants.StorageSlot = constants.StorageSlot.PUBLIC -) -> None: - """ - Stores data on the card in the specified slot. - - Args: - card: The card session object. - data (bytes): The data to store (max 127 bytes). - slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH) - - Raises: - ValueError: If slot is invalid or data is too long. - """ - if len(data) > 127: - raise ValueError("Data too long. Maximum allowed is 127 bytes.") - - card.send_secure_apdu( - ins=constants.INS_STORE_DATA, - p1=slot.value, - data=data - ) diff --git a/python/keycard-py/keycard/commands/unblock_pin.py b/python/keycard-py/keycard/commands/unblock_pin.py deleted file mode 100644 index c3707410..00000000 --- a/python/keycard-py/keycard/commands/unblock_pin.py +++ /dev/null @@ -1,32 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_secure_channel - - -@require_secure_channel -def unblock_pin(card: CardInterface, puk_and_pin: bytes | str) -> None: - """ - Unblocks the user PIN using the provided PUK and sets a new PIN. - - Args: - card: The card session object. - puk_and_pin (bytes | str): Concatenation of PUK (12 digits) + new PIN - (6 digits) - - Raises: - ValueError: If the format is invalid. - APDUError: If the card returns an error. - """ - if isinstance(puk_and_pin, str): - if not puk_and_pin.isdigit(): - raise ValueError("PUK and PIN must be numeric digits.") - puk_and_pin = puk_and_pin.encode("utf-8") - - if len(puk_and_pin) != 18: - raise ValueError( - "Data must be exactly 18 digits (12-digit PUK + 6-digit PIN).") - - card.send_secure_apdu( - ins=constants.INS_UNBLOCK_PIN, - data=puk_and_pin - ) diff --git a/python/keycard-py/keycard/commands/unpair.py b/python/keycard-py/keycard/commands/unpair.py deleted file mode 100644 index 9266eef1..00000000 --- a/python/keycard-py/keycard/commands/unpair.py +++ /dev/null @@ -1,31 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..preconditions import require_pin_verified - - -@require_pin_verified -def unpair(card: CardInterface, index: int) -> None: - ''' - Sends the UNPAIR command to remove a pairing index from the card. - - Preconditions: - - Secure Channel must be opened - - PIN must be verified - - This function securely communicates with the card using the established - session to instruct it to forget a specific pairing index. - - Args: - transport: The transport interface used to send APDUs. - secure_session: The active SecureChannel object used to wrap APDUs. - index (int): The pairing index (0–15) to unpair from the card. - - Raises: - ValueError: If transport or secure_session is not provided, or if - the session is not authenticated. - APDUError: If the response status word indicates an error. - ''' - card.send_secure_apdu( - ins=constants.INS_UNPAIR, - p1=index, - ) diff --git a/python/keycard-py/keycard/commands/verify_pin.py b/python/keycard-py/keycard/commands/verify_pin.py deleted file mode 100644 index 4a977300..00000000 --- a/python/keycard-py/keycard/commands/verify_pin.py +++ /dev/null @@ -1,49 +0,0 @@ -from .. import constants -from ..card_interface import CardInterface -from ..exceptions import APDUError -from ..preconditions import require_secure_channel - - -@require_secure_channel -def verify_pin(card: CardInterface, pin: str | bytes) -> bool: - ''' - Verifies the user PIN with the card using a secure session. - - Preconditions: - - Secure Channel must be opened - - PIN must be verified - - Sends the VERIFY PIN APDU command through the secure session. Returns - True if the PIN is correct, False if incorrect with remaining attempts, - and raises an error if blocked or another APDU error occurs. - - Args: - transport: The transport instance used to send the command. - session: An established SecureChannel object. - pin (str): The PIN string to be verified. - - Returns: - bool: True if the PIN is correct, False if incorrect but still allowed. - - Raises: - ValueError: If no secure session is provided. - RuntimeError: If the PIN is blocked (no attempts remaining). - APDUError: For other status word errors returned by the card. - ''' - if not isinstance(pin, bytes): - pin = pin.encode('ascii') - - try: - card.send_secure_apdu( - ins=constants.INS_VERIFY_PIN, - data=pin - ) - except APDUError as e: - if (e.sw & 0xFFF0) == 0x63C0: - attempts = e.sw & 0x000F - if attempts == 0: - raise RuntimeError('PIN is blocked') - return False - raise e - - return True diff --git a/python/keycard-py/keycard/constants.py b/python/keycard-py/keycard/constants.py deleted file mode 100644 index f2867358..00000000 --- a/python/keycard-py/keycard/constants.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -This module defines constants used for communication with the Keycard applet -via APDU commands. -""" - -from enum import IntEnum - - -# Applet AID -KEYCARD_AID: bytes = bytes.fromhex('A000000804000101') - -CLAISO7816: int = 0x00 -CLA_PROPRIETARY: int = 0x80 - -# APDU instructions -INS_SELECT: int = 0xA4 -INS_INIT: int = 0xFE -INS_IDENT: int = 0x14 -INS_OPEN_SECURE_CHANNEL: int = 0x10 -INS_MUTUALLY_AUTHENTICATE: int = 0x11 -INS_PAIR: int = 0x12 -INS_UNPAIR: int = 0x13 -INS_VERIFY_PIN: int = 0x20 -INS_GET_STATUS: int = 0xF2 -INS_FACTORY_RESET: int = 0xFD -INS_GENERATE_KEY: int = 0xD4 -INS_CHANGE_SECRET: int = 0x21 -INS_UNBLOCK_PIN: int = 0x22 -INS_STORE_DATA: int = 0xE2 -INS_GET_DATA: int = 0xCA -INS_SIGN: int = 0xC0 -INS_SET_PINLESS_PATH = 0xC1 -INS_EXPORT_KEY: int = 0xC2 -INS_LOAD_KEY: int = 0xD0 -INS_DERIVE_KEY = 0xD1 -INS_GENERATE_MNEMONIC = 0xD2 -INS_EXPORT_LEE_KEY = 0xC3 - -# Status words -SW_SUCCESS: int = 0x9000 - - -class PinType(IntEnum): - USER = 0x00 - PUK = 0x01 - PAIRING = 0x02 - - -class StorageSlot(IntEnum): - PUBLIC = 0x00 - NDEF = 0x01 - CASH = 0x02 - - -class DerivationOption(IntEnum): - CURRENT = 0x00 - DERIVE = 0x01 - DERIVE_AND_MAKE_CURRENT = 0x02 - PINLESS = 0x03 - - -class KeyExportOption(IntEnum): - PRIVATE_AND_PUBLIC = 0x00 - PUBLIC_ONLY = 0x01 - EXTENDED_PUBLIC = 0x02 - - -class DerivationSource(IntEnum): - MASTER = 0x00 - PARENT = 0x40 - CURRENT = 0x80 - - -class SigningAlgorithm(IntEnum): - ECDSA_SECP256K1 = 0x00 - EDDSA_ED25519 = 0x01 - BLS12_381 = 0x02 - SCHNORR_BIP340 = 0x03 - - -class LoadKeyType(IntEnum): - ECC = 0x01 - EXTENDED_ECC = 0x02 - BIP39_SEED = 0x03 - - -class PairingMode(IntEnum): - ANY = 0x00 - EPHEMERAL = 0x01 - PERSISTENT = 0x02 diff --git a/python/keycard-py/keycard/crypto/__init__.py b/python/keycard-py/keycard/crypto/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/keycard/crypto/aes.py b/python/keycard-py/keycard/crypto/aes.py deleted file mode 100644 index c78d4029..00000000 --- a/python/keycard-py/keycard/crypto/aes.py +++ /dev/null @@ -1,32 +0,0 @@ -from .padding import iso7816_pad, iso7816_unpad - -import pyaes - - -def aes_cbc_encrypt( - key: bytes, - iv: bytes, - data: bytes, - padding: bool = True -) -> bytes: - if padding: - data = iso7816_pad(data, 16) - aes = pyaes.AESModeOfOperationCBC(key, iv=iv) - - ciphertext = b'' - for i in range(0, len(data), 16): - block = data[i:i+16] - ciphertext += aes.encrypt(block) - - return ciphertext - - -def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: - aes = pyaes.AESModeOfOperationCBC(key, iv=iv) - - decrypted = b'' - for i in range(0, len(ciphertext), 16): - block = ciphertext[i:i+16] - decrypted += aes.decrypt(block) - - return iso7816_unpad(decrypted) diff --git a/python/keycard-py/keycard/crypto/generate_pairing_token.py b/python/keycard-py/keycard/crypto/generate_pairing_token.py deleted file mode 100644 index 3afca8e0..00000000 --- a/python/keycard-py/keycard/crypto/generate_pairing_token.py +++ /dev/null @@ -1,15 +0,0 @@ - -import hashlib -import unicodedata - - -SALT = 'Keycard Pairing Password Salt' -NUMBER_OF_ITERATIONS = 50000 -DKLEN = 32 - - -def generate_pairing_token(passphrase: str) -> bytes: - norm_pass = unicodedata.normalize('NFKD', passphrase).encode('utf-8') - salt = unicodedata.normalize('NFKD', SALT).encode('utf-8') - return hashlib.pbkdf2_hmac( - 'sha256', norm_pass, salt, NUMBER_OF_ITERATIONS, dklen=DKLEN) diff --git a/python/keycard-py/keycard/crypto/padding.py b/python/keycard-py/keycard/crypto/padding.py deleted file mode 100644 index ffdf3d09..00000000 --- a/python/keycard-py/keycard/crypto/padding.py +++ /dev/null @@ -1,9 +0,0 @@ -def iso7816_pad(data: bytes, block_size: int) -> bytes: - pad_len = block_size - (len(data) % block_size) - return data + b'\x80' + b'\x00' * (pad_len - 1) - - -def iso7816_unpad(padded: bytes) -> bytes: - if b'\x80' not in padded: - raise ValueError("Invalid ISO7816 padding") - return padded[:padded.rindex(b'\x80')] diff --git a/python/keycard-py/keycard/exceptions.py b/python/keycard-py/keycard/exceptions.py deleted file mode 100644 index 494d815f..00000000 --- a/python/keycard-py/keycard/exceptions.py +++ /dev/null @@ -1,41 +0,0 @@ -class KeyCardError(Exception): - """Base exception for Keycard SDK""" - - pass - - -class APDUError(KeyCardError): - """Raised when APDU returns non-success status word.""" - - def __init__(self, sw: int): - self.sw = sw - super().__init__(f"APDU failed with SW={sw:04X}") - - -class InvalidResponseError(KeyCardError): - """Raised when response parsing fails.""" - - pass - - -class NotInitializedError(KeyCardError): - """Raised when trying to use card public key before select().""" - - pass - - -class NotSelectedError(KeyCardError): - """Raised when trying to use card before select().""" - - pass - - -class TransportError(KeyCardError): - """Raised there are no readers""" - pass - - -class InvalidStateError(KeyCardError): - """Raised when a precondition is not met.""" - def __init__(self, message: str): - super().__init__(message) diff --git a/python/keycard-py/keycard/keycard.py b/python/keycard-py/keycard/keycard.py deleted file mode 100644 index 5d7f23ac..00000000 --- a/python/keycard-py/keycard/keycard.py +++ /dev/null @@ -1,630 +0,0 @@ -from types import TracebackType -from typing import Optional, Union - -from . import constants -from . import commands -from .apdu import APDUResponse -from .constants import DerivationOption, PairingMode -from .card_interface import CardInterface -from .exceptions import APDUError -from .parsing.application_info import ApplicationInfo -from .parsing.exported_key import ExportedKey -from .parsing.signature_result import SignatureResult -from .transport import Transport -from .secure_channel import SecureChannel - - -class KeyCard(CardInterface): - ''' - High-level interface for interacting with a Keycard device. - - This class provides convenient methods to manage pairing, secure channels, - and card operations. - ''' - - def __init__(self, transport: Optional[Transport] = None): - ''' - Initializes the KeyCard interface. - - Args: - transport (Transport): Instance used for APDU communication. - - Raises: - ValueError: If transport is None. - ''' - self.transport = transport if transport else Transport() - self.card_public_key: Optional[bytes] = None - self.session: Optional[SecureChannel] = None - self._is_pin_verified: bool = False - - def __enter__(self) -> 'KeyCard': - self.transport.connect() - return self - - def __exit__( - self, - type_: type[BaseException] | None, - value: BaseException | None, - traceback: TracebackType | None - ) -> None: - if self.transport: - self.transport.disconnect() - - @property - def is_selected(self) -> bool: - ''' - Checks if a card is selected and has a public key. - - Returns: - bool: True if a card is selected, False otherwise. - ''' - return self.card_public_key is not None - - @property - def is_session_open(self) -> bool: - ''' - Checks if a secure session is currently open. - - Returns: - bool: True if a secure session is established, False otherwise. - ''' - return self.session is not None - - @property - def is_secure_channel_open(self) -> bool: - ''' - Checks if a secure channel is currently open. - - Returns: - bool: True if a secure channel is established, False otherwise. - ''' - return self.session is not None and self.session.authenticated - - @property - def is_initialized(self) -> bool: - ''' - Checks if the Keycard is initialized. - - Returns: - bool: True if the Keycard is initialized, False otherwise. - ''' - return self._is_initialized - - @property - def is_pin_verified(self) -> bool: - ''' - Checks if the user PIN has been verified. - - Returns: - bool: True if the PIN is verified, False otherwise. - ''' - return self._is_pin_verified - - def select(self) -> 'ApplicationInfo': - ''' - Selects the Keycard applet and retrieves application metadata. - - Returns: - ApplicationInfo: Object containing ECC public key and card info. - ''' - info = commands.select(self) - self.card_public_key = info.ecc_public_key - self._is_initialized = info.is_initialized - return info - - def init(self, pin: str, puk: str, pairing_secret: str) -> None: - ''' - Initializes the card with security credentials. - - Args: - pin (bytes): The PIN code in bytes. - puk (bytes): The PUK code in bytes. - pairing_secret (bytes): The shared secret for pairing. - ''' - commands.init( - self, - pin, - puk, - pairing_secret, - ) - - def ident(self, challenge: Optional[bytes] = None) -> bytes: - ''' - Sends an identity challenge to the card. - - Args: - challenge (bytes): A challenge (nonce or data) to send to the - card. If None, a random 32-byte challenge is generated. - - Returns: - bytes: The public key extracted from the card's identity response. - ''' - return commands.ident(self, challenge) - - def open_secure_channel( - self, - pairing_index: int, - pairing_key: bytes, - mutually_authenticate: Optional[bool] = True - ) -> None: - ''' - Opens a secure session with the card. - - Args: - pairing_index (int): Index of the pairing slot to use. - pairing_key (bytes): The shared pairing key (32 bytes). - mutually_authenticate (bool): Execute mutually authenticate when - a secure channel has been opened - ''' - self.session = commands.open_secure_channel( - self, - pairing_index, - pairing_key, - ) - - if mutually_authenticate: - self.mutually_authenticate() - - def mutually_authenticate(self) -> None: - ''' - Performs mutual authentication between host and card. - - Raises: - APDUError: If the authentication fails. - ''' - commands.mutually_authenticate(self) - - def pair( - self, - shared_secret: bytes, - pairing_mode: Optional[PairingMode] = PairingMode.ANY - ) -> tuple[int, bytes]: - ''' - Pairs with the card using an ECDH-derived shared secret. - - Args: - shared_secret (bytes): 32-byte ECDH shared secret. - - Returns: - tuple[int, bytes]: The pairing index and client cryptogram. - ''' - return commands.pair(self, shared_secret, pairing_mode) - - def verify_pin(self, pin: str) -> bool: - ''' - Verifies the user PIN with the card. - - Args: - pin (str): The user-entered PIN. - - Returns: - bool: True if PIN is valid, otherwise False. - ''' - result = commands.verify_pin(self, pin.encode('utf-8')) - self._is_pin_verified = True - return result - - @property - def status(self) -> dict[str, int | bool] | list[int]: - ''' - Retrieves the application status using the secure session. - - Returns: - dict: A dictionary with: - - pin_retry_count (int) - - puk_retry_count (int) - - initialized (bool) - - Raises: - RuntimeError: If the secure session is not open. - ''' - if self.session is None: - raise RuntimeError('Secure session not established') - - return commands.get_status(self) - - @property - def get_key_path(self) -> dict[str, int | bool] | list[int]: - ''' - Returns the current key derivation path from the card. - - Returns: - list of int: List of 32-bit integers representing the key path. - - Raises: - RuntimeError: If the secure session is not open. - ''' - if self.session is None: - raise RuntimeError('Secure session not established') - - return commands.get_status(self, key_path=True) - - def unpair(self, index: int) -> None: - ''' - Removes a pairing slot from the card. - - Args: - index (int): Index of the pairing slot to remove. - ''' - commands.unpair(self, index) - - def factory_reset(self) -> None: - ''' - Sends the FACTORY_RESET command to the card. - - Raises: - APDUError: If the card returns a failure status word. - ''' - commands.factory_reset(self) - - def generate_key(self) -> bytes: - ''' - Generates a new key on the card and returns the key UID. - - Returns: - bytes: Key UID (SHA-256 of the public key) - - Raises: - APDUError: If the response status word is not 0x9000 - ''' - return commands.generate_key(self) - - def change_pin(self, new_value: str) -> None: - ''' - Changes the user PIN on the card. - - Args: - new_value (str): The new PIN value to set. - - Raises: - ValueError: If input format is invalid. - APDUError: If the response status word is not 0x9000. - ''' - commands.change_secret(self, new_value, constants.PinType.USER) - - def change_puk(self, new_value: str) -> None: - ''' - Changes the PUK on the card. - - Args: - new_value (str): The new PUK value to set. - - Raises: - ValueError: If input format is invalid. - APDUError: If the response status word is not 0x9000. - ''' - commands.change_secret(self, new_value, constants.PinType.PUK) - - def change_pairing_secret(self, new_value: str | bytes) -> None: - ''' - Changes the pairing secret on the card. - - Args: - new_value (str): The new pairing secret value to set. - - Raises: - ValueError: If input format is invalid. - APDUError: If the response status word is not 0x9000. - ''' - commands.change_secret(self, new_value, constants.PinType.PAIRING) - - def unblock_pin(self, puk: str | bytes, new_pin: str | bytes) -> None: - ''' - Unblocks the user PIN using the provided PUK and sets a new PIN. - - Args: - puk_and_pin (str | bytes): Concatenation of PUK (12 digits) + - new PIN (6 digits) - - Raises: - ValueError: If the format is invalid. - APDUError: If the card returns an error. - ''' - if isinstance(puk, str): - puk = puk.encode("utf-8") - if isinstance(new_pin, str): - new_pin = new_pin.encode("utf-8") - - commands.unblock_pin(self, puk + new_pin) - - def remove_key(self) -> None: - ''' - Removes the current key from the card. - - Raises: - APDUError: If the response status word is not 0x9000. - ''' - commands.remove_key(self) - - def store_data( - self, - data: bytes, - slot: constants.StorageSlot = constants.StorageSlot.PUBLIC - ) -> None: - """ - Stores data on the card in the specified slot. - - Args: - data (bytes): The data to store (max 127 bytes). - slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH) - - Raises: - ValueError: If slot is invalid or data is too long. - """ - commands.store_data(self, data, slot) - - def get_data( - self, - slot: constants.StorageSlot = constants.StorageSlot.PUBLIC - ) -> bytes: - """ - Gets the data on the card previously stored with the store data command - in the specified slot. - - Args: - slot (StorageSlot): Where to retrieve the data (PUBLIC, NDEF, CASH) - - Raises: - ValueError: If slot is invalid or data is too long. - """ - return commands.get_data(self, slot) - - def export_key( - self, - derivation_option: constants.DerivationOption, - public_only: bool, - keypath: Optional[Union[str, bytes, bytearray]] = None, - make_current: bool = False, - source: constants.DerivationSource = constants.DerivationSource.MASTER - ) -> ExportedKey: - """ - Export a key from the card. - - This is a proxy for :func:`keycard.commands.export_key`, provided here - for convenience. - - Args: - derivation_option: One of the derivation options - (CURRENT, DERIVE, DERIVE_AND_MAKE_CURRENT). - public_only: If True, only the public key will be returned. - keypath: BIP32-style string (e.g. "m/44'/60'/0'/0/0") or packed - bytes. If derivation_option is CURRENT, this can be omitted. - make_current: If True, updates the card’s current derivation path. - source: Which node to derive from: MASTER, PARENT, or CURRENT. - - Returns: - ExportedKey: An object containing the public key, and optionally - the private key and chain code. - - See Also: - - :func:`keycard.commands.export_key` - for the lower-level - implementation - - :class:`keycard.types.ExportedKey` - return value - structure - """ - return commands.export_key( - self, - derivation_option=derivation_option, - public_only=public_only, - keypath=keypath, - make_current=make_current, - source=source - ) - - def export_current_key(self, public_only: bool = False) -> ExportedKey: - """ - Exports the current key from the card. - - This is a convenience method that uses the CURRENT derivation option - and does not require a keypath. - - Args: - public_only (bool): If True, only the public key will be returned. - - Returns: - ExportedKey: An object containing the public key, and optionally - the private key and chain code. - """ - return self.export_key( - derivation_option=constants.DerivationOption.CURRENT, - public_only=public_only - ) - - def sign( - self, - digest: bytes, - algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, - ) -> SignatureResult: - """ - Sign using the currently loaded keypair. - Requires PIN verification and secure channel. - - Args: - digest (bytes): 32-byte hash to sign - algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to - ECDSA_SECP256K1. Other options include SCHNORR_BIP340. - - Returns: - SignatureResult: Parsed signature result, including the signature - (DER or raw), algorithm, and optional recovery ID or - public key. - """ - return commands.sign(self, digest, DerivationOption.CURRENT, algorithm) - - def sign_with_path( - self, - digest: bytes, - path: str, - make_current: bool = False, - algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, - ) -> SignatureResult: - """ - Sign using a derived keypath. Optionally updates the current path. - - Args: - digest (bytes): 32-byte hash to sign - path (str): BIP-32-style path (e.g. "m/44'/60'/0'/0/0") - make_current (bool): whether to update current path on card - algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to - ECDSA_SECP256K1. Other options include SCHNORR_BIP340. - - Returns: - SignatureResult: Parsed signature result, including the signature - (DER or raw), algorithm, and optional recovery ID or - public key. - """ - p1 = ( - DerivationOption.DERIVE_AND_MAKE_CURRENT - if make_current else DerivationOption.DERIVE - ) - return commands.sign(self, digest, p1, algorithm, derivation_path=path) - - def sign_pinless( - self, - digest: bytes, - algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, - ) -> SignatureResult: - """ - Sign using the predefined PIN-less path. - Does not require secure channel or PIN. - - Args: - digest (bytes): 32-byte hash to sign - algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to - ECDSA_SECP256K1. Other options include SCHNORR_BIP340. - - Returns: - SignatureResult: Parsed signature result, including the signature - (DER or raw), algorithm, and optional recovery ID or - public key. - - Raises: - APDUError: if no PIN-less path is set - """ - return commands.sign(self, digest, DerivationOption.PINLESS, algorithm) - - def load_key( - self, - key_type: constants.LoadKeyType, - public_key: Optional[bytes] = None, - private_key: Optional[bytes] = None, - chain_code: Optional[bytes] = None, - bip39_seed: Optional[bytes] = None, - lee_seed: Optional[bytes] = None - ) -> bytes: - """ - Load a key into the card for signing purposes. - - Args: - key_type: Key type - public_key: Optional ECC public key (tag 0x80). - private_key: ECC private key (tag 0x81). - chain_code: Optional chain code (tag 0x82, only for extended key). - bip39_seed: 16 to 64-byte BIP39 seed (only for key_type=BIP39_SEED). - lee_seed: 16 to 64-byte LEE seed (only for key_type=BIP39_SEED). - - Returns: - UID of the loaded key (SHA-256 of public key). - """ - return commands.load_key( - self, - key_type=key_type, - public_key=public_key, - private_key=private_key, - chain_code=chain_code, - bip39_seed=bip39_seed, - lee_seed=lee_seed - ) - - def set_pinless_path(self, path: str) -> None: - """ - Set a PIN-less path on the card. Allows signing without PIN/auth if the - current derived key matches this path. - - Args: - path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0"). An empty - string disables the pinless path. - """ - commands.set_pinless_path(self, path) - - def generate_mnemonic(self, checksum_size: int = 6) -> list[int]: - """ - Generate a BIP39 mnemonic using the card's RNG. - - Args: - checksum_size (int): Number of checksum bits - (between 4 and 8 inclusive). - - Returns: - List[int]: List of integers (0-2047) corresponding to wordlist - indexes. - """ - return commands.generate_mnemonic(self, checksum_size) - - def derive_key(self, path: str = '') -> None: - """ - Set the derivation path for subsequent SIGN and EXPORT KEY commands. - - Args: - path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0") or - "../0/1" (parent) or "./0" (current). - """ - commands.derive_key(self, path) - - def send_apdu( - self, - ins: int, - p1: int = 0x00, - p2: int = 0x00, - data: bytes = b'', - cla: Optional[int] = None - ) -> bytes: - if cla is None: - cla = constants.CLA_PROPRIETARY - - response: APDUResponse = self.transport.send_apdu( - bytes([cla, ins, p1, p2, len(data)]) + data - ) - - if response.status_word != constants.SW_SUCCESS: - raise APDUError(response.status_word) - - return bytes(response.data) - - def send_secure_apdu( - self, - ins: int, - p1: int = 0x00, - p2: int = 0x00, - data: bytes = b'' - ) -> bytes: - if not self.session or not self.session.authenticated: - raise RuntimeError('Secure channel not established') - - encrypted = self.session.wrap_apdu( - cla=constants.CLA_PROPRIETARY, - ins=ins, - p1=p1, - p2=p2, - data=data - ) - - response: APDUResponse = self.transport.send_apdu( - bytes([ - constants.CLA_PROPRIETARY, - ins, - p1, - p2, - len(encrypted) - ]) + encrypted - ) - - if response.status_word != 0x9000: - raise APDUError(response.status_word) - - plaintext, sw = self.session.unwrap_response(response) - - if sw != 0x9000: - raise APDUError(sw) - - return plaintext diff --git a/python/keycard-py/keycard/parsing/__init__.py b/python/keycard-py/keycard/parsing/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/keycard/parsing/application_info.py b/python/keycard-py/keycard/parsing/application_info.py deleted file mode 100644 index cc21aefd..00000000 --- a/python/keycard-py/keycard/parsing/application_info.py +++ /dev/null @@ -1,119 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from ..exceptions import InvalidResponseError -from .capabilities import Capabilities -from .tlv import parse_tlv - - -@dataclass -class ApplicationInfo: - """ - Represents parsed application information from a TLV-encoded response. - - Attributes: - capabilities (Optional[int]): Parsed capabilities value, if present. - ecc_public_key (Optional[bytes]): ECC public key bytes, if present. - instance_uid (Optional[bytes]): Unique identifier for the application - instance, if present. - key_uid (Optional[bytes]): Unique identifier for the key, if present. - version_major (int): Major version number of the application. - version_minor (int): Minor version number of the application. - """ - capabilities: Optional[int] - ecc_public_key: Optional[bytes] - instance_uid: Optional[bytes] - key_uid: Optional[bytes] - version_major: int - version_minor: int - - @property - def is_initialized(self) -> bool: - """ - Checks if the application is initialized based on the presence of - the key_uid. - - Returns: - bool: True if the key_uid is present, False otherwise. - """ - return self.key_uid is not None - - @staticmethod - def parse(data: bytes) -> "ApplicationInfo": - """ - Parses a byte sequence containing TLV-encoded application information - and returns an ApplicationInfo instance. - - Args: - data (bytes): The TLV-encoded response data to parse. - - Returns: - ApplicationInfo: An instance populated with the parsed application - information fields. - - The function extracts the following fields from the TLV data: - - version_major (int): Major version number (from tag 0x02). - - version_minor (int): Minor version number (from tag 0x02). - - instance_uid (bytes or None): Instance UID (from tag 0x8F). - - key_uid (bytes or None): Key UID (from tag 0x8E). - - ecc_public_key (bytes or None): ECC public key (from tag 0x80). - - capabilities (Capabilities or None): Capabilities object - (from tag 0x8D). - - Raises: - Any exceptions raised by ApplicationInfo._parse_response or - Capabilities.parse. - """ - version_major = version_minor = 0 - instance_uid = None - key_uid = None - ecc_public_key = None - capabilities = 0 - - if data[0] == 0x80: - length = data[1] - pubkey = data[2:2+length] - ecc_public_key = bytes(pubkey) - capabilities += Capabilities.CREDENTIALS_MANAGEMENT - - if pubkey: - capabilities += Capabilities.SECURE_CHANNEL - capabilities = Capabilities.parse(capabilities) - else: - tlv = parse_tlv(data) - if 0xA4 not in tlv: - raise InvalidResponseError( - "Invalid top-level tag, expected 0xA4") - - inner_tlv = parse_tlv(tlv[0xA4][0]) - - instance_uid = bytes(inner_tlv[0x8F][0]) - ecc_public_key = bytes(inner_tlv[0x80][0]) - key_uid = inner_tlv[0x8E][0] - capabilities = Capabilities.parse(inner_tlv[0x8D][0][0]) - for value in inner_tlv[0x02]: - if len(value) == 2: - version_major, version_minor = value[0], value[1] - - return ApplicationInfo( - capabilities=capabilities, - ecc_public_key=ecc_public_key, - instance_uid=instance_uid, - key_uid=key_uid, - version_major=version_major, - version_minor=version_minor, - ) - - def __str__(self) -> str: - return ( - f"ApplicationInfo(version=" - f"{self.version_major}.{self.version_minor}, " - f"instance_uid=" - f"{self.instance_uid.hex() if self.instance_uid else None}, " - f"key_uid=" - f"{self.key_uid.hex() if self.key_uid else None}, " - f"ecc_public_key=" - f"{self.ecc_public_key.hex() if self.ecc_public_key else None}, " - f"capabilities=" - f"{self.capabilities})" - ) diff --git a/python/keycard-py/keycard/parsing/capabilities.py b/python/keycard-py/keycard/parsing/capabilities.py deleted file mode 100644 index 9fc8122e..00000000 --- a/python/keycard-py/keycard/parsing/capabilities.py +++ /dev/null @@ -1,44 +0,0 @@ -from enum import IntFlag - - -class Capabilities(IntFlag): - """ - An enumeration representing the various capabilities supported by a device - or application. - - Attributes: - SECURE_CHANNEL (int): Indicates support for secure channel - communication (0x01). - KEY_MANAGEMENT (int): Indicates support for key management operations - (0x02). - CREDENTIALS_MANAGEMENT (int): Indicates support for credentials - management (0x04). - NDEF (int): Indicates support for NDEF (NFC Data Exchange Format) - operations (0x08). - """ - SECURE_CHANNEL = 0x01 - KEY_MANAGEMENT = 0x02 - CREDENTIALS_MANAGEMENT = 0x04 - NDEF = 0x08 - - @classmethod - def parse(cls, value: int) -> "Capabilities": - """ - Parses an integer value and returns a corresponding Capabilities - instance. - - Args: - value (int): The integer value representing the capabilities. - - Returns: - Capabilities: An instance of the Capabilities class corresponding - to the given value. - """ - return cls(value) - - def __str__(self) -> str: - return " | ".join( - name - for name, member in self.__class__.__members__.items() - if member in self - ) diff --git a/python/keycard-py/keycard/parsing/exported_key.py b/python/keycard-py/keycard/parsing/exported_key.py deleted file mode 100644 index 059ced4f..00000000 --- a/python/keycard-py/keycard/parsing/exported_key.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class ExportedKey: - public_key: Optional[bytes] = None - private_key: Optional[bytes] = None - chain_code: Optional[bytes] = None - - @property - def is_extended(self) -> bool: - return self.chain_code is not None - - @property - def has_private(self) -> bool: - return self.private_key is not None diff --git a/python/keycard-py/keycard/parsing/identity.py b/python/keycard-py/keycard/parsing/identity.py deleted file mode 100644 index c67a0f88..00000000 --- a/python/keycard-py/keycard/parsing/identity.py +++ /dev/null @@ -1,43 +0,0 @@ -from hashlib import sha256 -from ecdsa import VerifyingKey, SECP256k1, util - -from ..exceptions import InvalidResponseError -from ..parsing.tlv import parse_tlv - - -def parse(challenge: bytes, data: bytes) -> bytes: - tlvs = parse_tlv(data) - - inner_tlvs = parse_tlv(tlvs[0xA0][0]) - - try: - certificate = inner_tlvs[0x8A][0] - signature = inner_tlvs[0x30][0] - except (IndexError, KeyError): - raise InvalidResponseError('Malformed identity response') - - signature = b'\x30' + len(signature).to_bytes(1, 'big') + signature - if len(certificate) < 95 or len(signature) < 65: - raise InvalidResponseError('Malformed identity response') - - _verify(certificate, signature, challenge) - return _recover_public_key(certificate) - - -def _verify(certificate: bytes, signature: bytes, challenge: bytes) -> None: - pub_key = certificate[:33] - vk = VerifyingKey.from_string(pub_key, curve=SECP256k1) - vk.verify_digest(signature, challenge, sigdecode=util.sigdecode_der) - - -def _recover_public_key(certificate: bytes) -> bytes: - signature = certificate[33:] - v = signature[-1] - digest = sha256(certificate).digest() - - vk = VerifyingKey.from_public_key_recovery_with_digest( - signature[:-1], digest, SECP256k1) - - public_key = vk[v] if isinstance(vk, list) else vk - der: bytes = public_key.to_der('compressed') - return der diff --git a/python/keycard-py/keycard/parsing/keypath.py b/python/keycard-py/keycard/parsing/keypath.py deleted file mode 100644 index a3ff528b..00000000 --- a/python/keycard-py/keycard/parsing/keypath.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import Union - -from ..constants import DerivationSource - - -class KeyPath: - MAX_COMPONENTS = 10 - - def __init__( - self, - path: Union[str, bytes, bytearray], - source: DerivationSource = DerivationSource.MASTER - ): - if isinstance(path, str): - if path == '': - raise ValueError("Empty path") - self.source, components = self._parse_path_string(path) - if len(components) > self.MAX_COMPONENTS: - raise ValueError("Too many components in derivation path") - self.data = self._encode_components(components) - elif isinstance(path, (bytes, bytearray)): - if len(path) % 4 != 0: - raise ValueError("Byte path must be a multiple of 4") - self.source = source - self.data = bytes(path) - else: - raise TypeError("Path must be a string or bytes") - - def _parse_path_string( - self, - path: str - ) -> tuple[DerivationSource, list[int]]: - tokens = path.split('/') - if not tokens: - raise ValueError("Empty path") - - first = tokens[0] - if first == "m": - source = DerivationSource.MASTER - tokens = tokens[1:] - elif first == "..": - source = DerivationSource.PARENT - tokens = tokens[1:] - elif first == ".": - source = DerivationSource.CURRENT - tokens = tokens[1:] - else: - source = DerivationSource.CURRENT - - components = [self._parse_component(token) for token in tokens] - return source, components - - def _parse_component(self, token: str) -> int: - if token.endswith("'"): - token = token[:-1] - hardened = True - else: - hardened = False - - if not token.isdigit(): - raise ValueError(f"Invalid component: {token}") - - value = int(token) - if hardened: - value |= 0x80000000 - return value - - def _encode_components(self, components: list[int]) -> bytes: - return b''.join(comp.to_bytes(4, 'big') for comp in components) - - def to_string(self) -> str: - prefix = { - DerivationSource.MASTER: 'm', - DerivationSource.PARENT: '..', - DerivationSource.CURRENT: '.' - }.get(self.source, '.') - - components = [] - for i in range(0, len(self.data), 4): - chunk = self.data[i:i+4] - val = int.from_bytes(chunk, 'big') - if val & 0x80000000: - components.append(f"{val & 0x7FFFFFFF}'") - else: - components.append(str(val)) - - return '/'.join([prefix] + components) diff --git a/python/keycard-py/keycard/parsing/signature_result.py b/python/keycard-py/keycard/parsing/signature_result.py deleted file mode 100644 index db5ddfb9..00000000 --- a/python/keycard-py/keycard/parsing/signature_result.py +++ /dev/null @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from ecdsa import VerifyingKey, util, SECP256k1 -from typing import Optional - -from ..constants import SigningAlgorithm - - -@dataclass -class SignatureResult: - algo: SigningAlgorithm - r: bytes - s: bytes - recovery_id: Optional[int] = None - public_key: Optional[bytes] = None - - def __init__( - self, - digest: bytes, - algo: SigningAlgorithm, - r: int, - s: int, - recovery_id: Optional[int] = None, - public_key: Optional[bytes] = None - ) -> None: - self.algo = algo - self.r = r.to_bytes((r.bit_length() + 7) // 8, 'big') - self.s = s.to_bytes((s.bit_length() + 7) // 8, 'big') - if public_key is None and recovery_id is None: - raise ValueError( - "Public key and recovery id not returned from card") - - self.public_key = ( - public_key - if public_key is not None - else self._recover_public_key(digest) - ) - - self.recovery_id = ( - recovery_id - if recovery_id is not None - else self._recover_v(digest) - ) - - @property - def signature(self) -> bytes: - return self.r + self.s - - @property - def signature_der(self) -> bytes: - signature: bytes = util.sigencode_der( - int.from_bytes(self.r, 'big'), - int.from_bytes(self.s, 'big'), - self.recovery_id - ) - return signature - - def _recover_public_key(self, digest: bytes) -> bytes: - if self.recovery_id is None: - raise ValueError("Recovery ID is required for public key recovery") - - public_key = VerifyingKey.from_public_key_recovery_with_digest( - self.signature_der, - digest, - SECP256k1, - sigdecode=util.sigdecode_der) - public_key_bytes: bytes = public_key.to_string() - return public_key_bytes - - def _recover_v(self, digest: bytes) -> int: - if self.public_key is None: - raise ValueError("Public key is required for recovery ID") - - public_keys = VerifyingKey.from_public_key_recovery_with_digest( - self.signature, digest, SECP256k1) - - index = 0 - for public_key in public_keys: - if self.public_key[1:] == public_key.to_string(): - return index - index += 1 - - raise RuntimeError("Recovery ID not found") diff --git a/python/keycard-py/keycard/parsing/tlv.py b/python/keycard-py/keycard/parsing/tlv.py deleted file mode 100644 index 93b97b95..00000000 --- a/python/keycard-py/keycard/parsing/tlv.py +++ /dev/null @@ -1,102 +0,0 @@ -from collections import defaultdict - -from keycard.exceptions import InvalidResponseError - - -def _parse_ber_length(data: bytes, index: int) -> tuple[int, int]: - """ - Parses a BER-encoded length field from a byte sequence starting at the - given index. - - Args: - data (bytes): The byte sequence containing the BER-encoded length. - index (int): The starting index in the byte sequence to parse the - length from. - - Returns: - tuple[int, int]: A tuple containing the parsed length (int) and the - total number of bytes consumed (int). - - Raises: - InvalidResponseError: If the length encoding is unsupported or exceeds - the remaining buffer. - """ - first = data[index] - index += 1 - - if first < 0x80: - return first, 1 - - num_bytes = first & 0x7F - if num_bytes > 4: - raise InvalidResponseError("Unsupported length encoding") - - if index + num_bytes > len(data): - raise InvalidResponseError("Length exceeds remaining buffer") - - length = int.from_bytes(data[index:index+num_bytes], "big") - return length, 1 + num_bytes - - -def parse_tlv(data: bytes) -> defaultdict[int, list[bytes]]: - """ - Parses a byte sequence containing TLV (Tag-Length-Value) encoded data. - - Args: - data (bytes): The byte sequence to parse. - - Returns: - List[Tuple[int, bytes]]: A list of tuples, each containing the tag - (as an int) and the value (as bytes). - - Raises: - InvalidResponseError: If the TLV header is incomplete or the declared - length exceeds the available data. - """ - index = 0 - result = defaultdict(list) - - while index < len(data): - tag = data[index] - index += 1 - - length, length_size = _parse_ber_length(data, index) - index += length_size - - value = data[index:index+length] - - if len(value) < length: - raise InvalidResponseError("Not enough bytes for value") - - index += length - - result[tag].append(value) - - return result - - -def encode_tlv(tag: int, value: bytes) -> bytes: - """ - Encode a tag-length-value (TLV) structure using BER-TLV rules. - - Args: - tag (int): A single-byte tag (0x00 - 0xFF). - value (bytes): Value to encode. - - Returns: - bytes: Encoded TLV. - """ - if not (0 <= tag <= 0xFF): - raise ValueError("Tag must fit in a single byte") - - length = len(value) - - if length < 0x80: - length_bytes = bytes([length]) - else: - len_len = (length.bit_length() + 7) // 8 - length_bytes = ( - bytes([0x80 | len_len]) + length.to_bytes(len_len, 'big') - ) - - return bytes([tag]) + length_bytes + value diff --git a/python/keycard-py/keycard/preconditions.py b/python/keycard-py/keycard/preconditions.py deleted file mode 100644 index edadaa9e..00000000 --- a/python/keycard-py/keycard/preconditions.py +++ /dev/null @@ -1,48 +0,0 @@ -from functools import wraps -from typing import Callable, TypeVar, ParamSpec, cast - -from .exceptions import InvalidStateError -from .card_interface import CardInterface - -P = ParamSpec("P") -R = TypeVar("R") - - -def make_precondition( - attribute_name: str, - display_name: str | None = None -) -> Callable[[Callable[P, R]], Callable[P, R]]: - def decorator(func: Callable[P, R]) -> Callable[P, R]: - @wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - card = args[0] - if not isinstance(card, CardInterface): - raise TypeError("First argument must be a CardInterface") - if not getattr(card, attribute_name, False): - readable = ( - display_name - if display_name is not None - else attribute_name.replace('_', ' ').title() - ) - raise InvalidStateError(f"{readable} must be satisfied.") - return func(*args, **kwargs) - return cast(Callable[P, R], wrapper) - return decorator - - -require_selected = make_precondition( - 'is_selected', - 'Card Selection' -) -require_initialized = make_precondition( - 'is_initialized', - 'Card Initialization' -) -require_secure_channel = make_precondition( - 'is_secure_channel_open', - 'Secure Channel' -) -require_pin_verified = make_precondition( - 'is_pin_verified', - 'PIN verification' -) diff --git a/python/keycard-py/keycard/secure_channel.py b/python/keycard-py/keycard/secure_channel.py deleted file mode 100644 index 2cc9e33a..00000000 --- a/python/keycard-py/keycard/secure_channel.py +++ /dev/null @@ -1,143 +0,0 @@ -# keycard/secure_channel.py - -from hashlib import sha512 - -from .apdu import APDUResponse - -from .crypto.aes import aes_cbc_encrypt, aes_cbc_decrypt - -from dataclasses import dataclass - - -@dataclass -class SecureChannel: - """ - SecureChannel manages a secure communication channel using AES encryption - and MAC authentication. - - Attributes: - enc_key (bytes): The AES encryption key for the session. - mac_key (bytes): The AES MAC key for message authentication. - iv (bytes): The initialization vector for AES operations. - authenticated (bool): Indicates if the session is authenticated. - """ - enc_key: bytes - mac_key: bytes - iv: bytes - authenticated: bool = False - - @classmethod - def open( - cls, - shared_secret: bytes, - pairing_key: bytes, - salt: bytes, - seed_iv: bytes - ) -> "SecureChannel": - """ - Opens a new SecureChannel using the provided cryptographic parameters. - - Args: - shared_secret (bytes): The shared secret used for key derivation. - pairing_key (bytes): The pairing key used for key derivation. - salt (bytes): The salt value used in the key derivation process. - seed_iv (bytes): The initialization vector (IV) to seed the - session. - - Returns: - SecureChannel: An instance of SecureChannel initialized with - derived encryption and MAC keys, and the provided IV. - """ - digest = sha512(shared_secret + pairing_key + salt).digest() - enc_key, mac_key = digest[:32], digest[32:] - return cls( - enc_key=enc_key, - mac_key=mac_key, - iv=seed_iv, - authenticated=True - ) - - def wrap_apdu( - self, - cla: int, - ins: int, - p1: int, - p2: int, - data: bytes - ) -> bytes: - """ - Wraps an APDU command with secure channel encryption and MAC. - - Args: - cla (int): The APDU class byte. - ins (int): The APDU instruction byte. - p1 (int): The APDU parameter 1 byte. - p2 (int): The APDU parameter 2 byte. - data (bytes): The APDU data field to be encrypted. - - Returns: - tuple[int, int, int, int, bytes]: The wrapped APDU as a tuple - containing the class, instruction, parameter 1, parameter 2, - and the concatenated MAC and encrypted data. - - Raises: - ValueError: If the secure channel is not authenticated and the - instruction is not 0x11. - """ - if not self.authenticated and ins != 0x11: - raise ValueError("Secure channel not authenticated") - - encrypted = aes_cbc_encrypt(self.enc_key, self.iv, data) - - lc = 16 + len(encrypted) - mac_input = bytes([cla, ins, p1, p2, lc]) + bytes(11) + encrypted - - enc_data = aes_cbc_encrypt( - self.mac_key, bytes(16), mac_input, padding=False) - - self.iv = enc_data[-16:] - - return self.iv + encrypted - - def unwrap_response(self, response: APDUResponse) -> tuple[bytes, int]: - """ - Unwraps and verifies a secure channel response. - - Args: - response (bytes): The encrypted response bytes to unwrap. - - Returns: - tuple[bytes, int]: A tuple containing the decrypted plaintext - (excluding the status word) and the status word as an integer. - - Raises: - ValueError: If the secure channel is not authenticated. - ValueError: If the response length is invalid. - ValueError: If the MAC verification fails. - ValueError: If the decrypted plaintext is too short to contain a - status word. - """ - if not self.authenticated: - raise ValueError("Secure channel not authenticated") - - if len(response.data) < 18: - raise ValueError("Invalid secure response length") - - received_mac = bytes(response.data[:16]) - encrypted = bytes(response.data[16:]) - - lr = len(response.data) - mac_input = bytes([lr]) + bytes(15) + bytes(encrypted) - expected_mac = aes_cbc_encrypt( - self.mac_key, bytes(16), mac_input, padding=False)[-16:] - if received_mac != expected_mac: - raise ValueError("Invalid MAC") - - plaintext = aes_cbc_decrypt(self.enc_key, self.iv, encrypted) - - self.iv = received_mac - - if len(plaintext) < 2: - raise ValueError("Missing status word in response") - - return plaintext[:-2], int.from_bytes(plaintext[-2:], "big") diff --git a/python/keycard-py/keycard/transport.py b/python/keycard-py/keycard/transport.py deleted file mode 100644 index 9a56e599..00000000 --- a/python/keycard-py/keycard/transport.py +++ /dev/null @@ -1,46 +0,0 @@ -from types import TracebackType -from smartcard.System import readers -from smartcard.pcsc.PCSCReader import PCSCReader - -from .apdu import APDUResponse -from .exceptions import TransportError - - -class Transport: - def __init__(self) -> None: - self.connection: PCSCReader = None - - def __enter__(self) -> 'Transport': - self.connect() - return self - - def __exit__( - self, - type_: type[BaseException] | None, - value: BaseException | None, - traceback: TracebackType | None - ) -> None: - self.disconnect() - - def connect(self, index: int = 0) -> None: - r = readers() - if not r: - raise TransportError('No smart card readers found') - self.connection = r[index].createConnection() - self.connection.connect() - - def disconnect(self) -> None: - if self.connection: - self.connection.disconnect() - self.connection = None - - def send_apdu(self, apdu: bytes) -> APDUResponse: - if not self.connection: - self.connect() - - apdu_list = list(apdu) - - response, sw1, sw2 = self.connection.transmit(apdu_list) - - sw = (sw1 << 8) | sw2 - return APDUResponse(response, sw) diff --git a/python/keycard-py/mypy.ini b/python/keycard-py/mypy.ini deleted file mode 100644 index 462cb50b..00000000 --- a/python/keycard-py/mypy.ini +++ /dev/null @@ -1,12 +0,0 @@ -[mypy] -ignore_missing_imports = True -strict = True - -[mypy-ecdsa.*] -ignore_missing_imports = True - -[mypy-pyaes.*] -ignore_missing_imports = True - -[mypy-smartcard.*] -ignore_missing_imports = True \ No newline at end of file diff --git a/python/keycard-py/pyproject.toml b/python/keycard-py/pyproject.toml deleted file mode 100644 index 8a45f79a..00000000 --- a/python/keycard-py/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[build-system] -requires = ["flit_core >=3.11,<4"] -build-backend = "flit_core.buildapi" - -[project] -name = "keycard" -authors = [{name = "mmlado", email = "developer@mmlado.com"}] -readme = "README.md" -license = "MIT" -license-files = ["LICENSE"] -dynamic = ["version", "description"] -requires-python = ">=3.10" - -dependencies = [ - "pyscard", - "ecdsa", - "pyaes" -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-cov", - "coverage", - "sphinx", - "sphinx-autodoc-typehints", - "flake8", - "mypy", - "mnemonic", - "tox" -] - -[project.urls] -Homepage = "https://github.com/mmlado/keycard-py" -Documentation = "https://mmlado.github.io/keycard-py/" diff --git a/python/keycard-py/tasks.py b/python/keycard-py/tasks.py deleted file mode 100644 index c2168110..00000000 --- a/python/keycard-py/tasks.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -from pathlib import Path -import shutil - -from invoke import task - - -@task -def venv(c): - if not os.path.exists("venv"): - c.run("python -m venv venv") - print("venv created.") - else: - print("venv already exists.") - -@task -def install(c, dev=False): - """Install dependencies with Flit.""" - pip = "venv/bin/pip" - c.run(f"{pip} install flit") - if dev: - c.run("venv/bin/flit install --symlink --deps develop") - else: - c.run("venv/bin/flit install --deps production") - - -@task -def test(c): - """Run pytest with coverage""" - c.run("coverage run -m pytest", pty=True) - - -@task -def coverage(c): - """ - Runs the coverage report using the coverage tool. - """ - c.run("coverage report", pty=True) - - -@task -def htmlcov(c): - """ - Generates an HTML coverage report using the 'coverage' tool in html - format. - """ - c.run("coverage html", pty=True) - print("Open htmlcov/index.html in your browser") - - -@task -def lint(c): - """Run flake8 linting""" - c.run("flake8 keycard tests", pty=True) - - -@task -def typecheck(c): - """Run mypy type checking.""" - c.run("mypy keycard") - - -@task -def docs(ctx, clean=False, open=False): - """ - Build Sphinx documentation. - - Args: - clean (bool): If True, removes the build directory before building. - open (bool): If True, opens the built docs in a browser. - """ - docs_dir = "docs" - build_dir = os.path.join(docs_dir, "_build") - - if clean and os.path.exists(build_dir): - ctx.run(f"rm -rf {build_dir}") - - ctx.run(f"sphinx-build -b html {docs_dir} {build_dir}/html") - - if open: - index_path = os.path.join(build_dir, "html", "index.html") - ctx.run(f"xdg-open {index_path} || open {index_path}", warn=True) - - -@task -def clean(c): - """Clean artifacts""" - for pycache in Path(".").rglob("__pycache__"): - shutil.rmtree(pycache, ignore_errors=True) - - build_path = Path("docs") / "_build" - if build_path.exists(): - shutil.rmtree(build_path, ignore_errors=True) - - c.run("rm -rf .pytest_cache htmlcov .coverage", warn=True) - - -@task -def cleanall(c): - """Thorough cleanup of all build, cache, and pycache files.""" - patterns = [ - "__pycache__", - ".pytest_cache", - ".coverage", - "htmlcov", - "dist", - "build", - "*.egg-info", - "venv" - ] - - for pattern in patterns: - for path in Path(".").rglob(pattern): - if path.is_dir(): - shutil.rmtree(path, ignore_errors=True) - elif path.is_file(): - path.unlink(missing_ok=True) - - # Sphinx docs - docs_build = Path("docs") / "_build" - if docs_build.exists(): - shutil.rmtree(docs_build, ignore_errors=True) - print("All artifacts cleaned up.") diff --git a/python/keycard-py/tests/__init__.py b/python/keycard-py/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/tests/commands/__init__.py b/python/keycard-py/tests/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/tests/commands/test_change_secret.py b/python/keycard-py/tests/commands/test_change_secret.py deleted file mode 100644 index 2503ded2..00000000 --- a/python/keycard-py/tests/commands/test_change_secret.py +++ /dev/null @@ -1,95 +0,0 @@ -import sys -import pytest - -from unittest.mock import Mock, patch -from keycard import constants -from keycard.card_interface import CardInterface -from keycard.exceptions import APDUError -from keycard.commands.change_secret import change_secret - - -@pytest.fixture -def mock_card(): - card = Mock(spec=CardInterface) - card.send_secure_apdu = Mock() - return card - - -def test_change_secret_pairing_str_success(mock_card): - change_secret_module = sys.modules['keycard.commands.change_secret'] - with patch.object( - change_secret_module, 'generate_pairing_token' - ) as mock_generate: - mock_generate.return_value = bytes(32) - change_secret(mock_card, 'pairingtoken', constants.PinType.PAIRING) - mock_generate.assert_called_once_with('pairingtoken') - mock_card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_CHANGE_SECRET, - p1=constants.PinType.PAIRING.value, - data=mock_generate.return_value - ) - - -def test_change_secret_user_pin_str_success(mock_card): - pin = '123456' - change_secret(mock_card, pin, constants.PinType.USER) - mock_card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_CHANGE_SECRET, - p1=constants.PinType.USER.value, - data=pin.encode('utf-8') - ) - - -def test_change_secret_user_pin_invalid_length(mock_card): - with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."): - change_secret(mock_card, b'12345', constants.PinType.USER) - with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."): - change_secret(mock_card, '12345', constants.PinType.USER) - - -def test_change_secret_puk_success(mock_card): - puk = b'123456789012' - change_secret(mock_card, puk, constants.PinType.PUK) - mock_card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_CHANGE_SECRET, - p1=constants.PinType.PUK.value, - data=puk - ) - - -def test_change_secret_puk_str_success(mock_card): - puk = '123456789012' - change_secret(mock_card, puk, constants.PinType.PUK) - mock_card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_CHANGE_SECRET, - p1=constants.PinType.PUK.value, - data=puk.encode('utf-8') - ) - - -def test_change_secret_puk_invalid_length(mock_card): - with pytest.raises(ValueError, match="PUK must be exactly 12 digits."): - change_secret(mock_card, b'1234567890', constants.PinType.PUK) - with pytest.raises(ValueError, match="PUK must be exactly 12 digits."): - change_secret(mock_card, '1234567890', constants.PinType.PUK) - - -def test_change_secret_pairing_bytes_success(mock_card): - secret = b'a' * 32 - change_secret(mock_card, secret, constants.PinType.PAIRING) - mock_card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_CHANGE_SECRET, - p1=constants.PinType.PAIRING.value, - data=secret - ) - - -def test_change_secret_pairing_bytes_invalid_length(mock_card): - with pytest.raises(ValueError, match="Pairing secret must be 32 bytes."): - change_secret(mock_card, b'a' * 31, constants.PinType.PAIRING) - - -def test_change_secret_raises_apdu_error(mock_card): - mock_card.send_secure_apdu.side_effect = APDUError(0x6A80) - with pytest.raises(APDUError): - change_secret(mock_card, b'123456', constants.PinType.USER) diff --git a/python/keycard-py/tests/commands/test_derive_key.py b/python/keycard-py/tests/commands/test_derive_key.py deleted file mode 100644 index 85e2646b..00000000 --- a/python/keycard-py/tests/commands/test_derive_key.py +++ /dev/null @@ -1,14 +0,0 @@ -from keycard.commands import derive_key -from keycard.constants import INS_DERIVE_KEY, DerivationSource -from keycard.parsing.keypath import KeyPath - - -def test_derive_key_valid_master(card): - key_path = KeyPath("m/44'/60'/0'/0/0") - derive_key(card, key_path.to_string()) - - card.send_secure_apdu.assert_called_once_with( - ins=INS_DERIVE_KEY, - p1=DerivationSource.MASTER, - data=key_path.data - ) diff --git a/python/keycard-py/tests/commands/test_export_key.py b/python/keycard-py/tests/commands/test_export_key.py deleted file mode 100644 index cf9db5f7..00000000 --- a/python/keycard-py/tests/commands/test_export_key.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest -from keycard.commands.export_key import export_key -from keycard.constants import DerivationOption, DerivationSource -from keycard.parsing.exported_key import ExportedKey - - -def test_export_key_success_public_only(card): - public_key = b'\x04' + b'\x01' * 64 - inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key - outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv - card.send_secure_apdu.return_value = outer_tlv - - result = export_key( - card, - derivation_option=DerivationOption.CURRENT, - public_only=True, - keypath=None, - make_current=False, - source=DerivationSource.MASTER - ) - - assert isinstance(result, ExportedKey) - assert result.public_key == public_key - assert result.private_key is None - assert result.chain_code is None - - -def test_export_key_with_path_string(card): - public_key = b'\x04' + b'\x02' * 64 - inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key - outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv - card.send_secure_apdu.return_value = outer_tlv - - result = export_key( - card, - derivation_option=DerivationOption.DERIVE, - public_only=True, - keypath="m/44'/60'/0'/0/0", - make_current=True, - source=DerivationSource.MASTER - ) - - assert isinstance(result, ExportedKey) - assert result.public_key == public_key - - -def test_export_key_invalid_keypath_length_bytes(card): - with pytest.raises( - ValueError, - match="Byte keypath must be a multiple of 4" - ): - export_key( - card, - derivation_option=DerivationOption.DERIVE, - public_only=True, - keypath=b'\x01\x02\x03', - make_current=False, - source=DerivationSource.PARENT - ) - - -def test_export_key_requires_keypath_if_not_current(card): - with pytest.raises( - ValueError, - match="Keypath required unless using CURRENT derivation" - ): - export_key( - card, - derivation_option=DerivationOption.DERIVE, - public_only=True, - keypath=None, - make_current=False, - source=DerivationSource.CURRENT - ) - - -def test_export_key_invalid_keypath_type(card): - with pytest.raises(TypeError, match="Keypath must be a string or bytes"): - export_key( - card, - derivation_option=DerivationOption.DERIVE, - public_only=True, - keypath=123, - make_current=False, - source=DerivationSource.CURRENT - ) - - -def test_export_key_missing_keypair_template(card): - card.send_secure_apdu.return_value = b'\xA0\x00' - - with pytest.raises(ValueError, match="Missing keypair template"): - export_key( - card, - derivation_option=DerivationOption.CURRENT, - public_only=True, - keypath=None, - make_current=False, - source=DerivationSource.MASTER - ) diff --git a/python/keycard-py/tests/commands/test_factory_reset.py b/python/keycard-py/tests/commands/test_factory_reset.py deleted file mode 100644 index 31d1e530..00000000 --- a/python/keycard-py/tests/commands/test_factory_reset.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from unittest.mock import Mock -from keycard import constants -from keycard.commands.factory_reset import factory_reset -from keycard.exceptions import APDUError - - -def test_factory_reset_success(card): - mock_response = Mock() - mock_response.status_word = 0x9000 - card.send_apdu.return_value = mock_response - - factory_reset(card) - card.send_apdu.assert_called_once_with( - ins=constants.INS_FACTORY_RESET, - p1=0xAA, - p2=0x55 - ) - - -def test_factory_reset_failure(card): - card.send_apdu.side_effect = APDUError(0x6A80) - - with pytest.raises(APDUError): - factory_reset(card) diff --git a/python/keycard-py/tests/commands/test_generate_key.py b/python/keycard-py/tests/commands/test_generate_key.py deleted file mode 100644 index ef2c820e..00000000 --- a/python/keycard-py/tests/commands/test_generate_key.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from keycard import constants -from keycard.commands.generate_key import generate_key -from keycard.exceptions import APDUError - - -def test_generate_key_success(card): - mock_id = b'\x01' * 32 - card.send_secure_apdu.return_value = mock_id - result = generate_key(card) - assert result == mock_id - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_GENERATE_KEY) - - -def test_generate_key_apdu_error(card): - card.send_secure_apdu.side_effect = APDUError(0x6A80) - - with pytest.raises(APDUError): - generate_key(card) diff --git a/python/keycard-py/tests/commands/test_generate_mnemonic.py b/python/keycard-py/tests/commands/test_generate_mnemonic.py deleted file mode 100644 index 54160cc4..00000000 --- a/python/keycard-py/tests/commands/test_generate_mnemonic.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from keycard.constants import INS_GENERATE_MNEMONIC -from keycard.commands.generate_mnemonic import generate_mnemonic - - -def test_generate_mnemonic_valid(card): - card.send_secure_apdu.return_value = bytes([ - 0x00, 0x00, - 0x07, 0xFF, - 0x05, 0x39, - 0x00, 0x2A - ]) - - result = generate_mnemonic(card, checksum_size=6) - - card.send_secure_apdu.assert_called_once_with( - ins=INS_GENERATE_MNEMONIC, - p1=6 - ) - - assert result == [0, 2047, 1337, 42] - - -def test_generate_mnemonic_invalid_checksum(card): - with pytest.raises( - ValueError, - match="Checksum size must be between 4 and 8" - ): - generate_mnemonic(card, checksum_size=2) - - -def test_generate_mnemonic_odd_length_response(card): - # Simulate invalid odd-length byte response - card.send_secure_apdu.return_value = b'\x00\x01\x02' - - with pytest.raises( - ValueError, - match="Response must contain an even number of bytes" - ): - generate_mnemonic(card, checksum_size=6) diff --git a/python/keycard-py/tests/commands/test_get_data.py b/python/keycard-py/tests/commands/test_get_data.py deleted file mode 100644 index 2faf5127..00000000 --- a/python/keycard-py/tests/commands/test_get_data.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from keycard.commands.get_data import get_data -from keycard import constants - - -def test_get_data_secure_channel(card): - card.is_secure_channel_open = True - card.send_secure_apdu.return_value = b"secure_data" - result = get_data(card, slot=constants.StorageSlot.PUBLIC) - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_GET_DATA, - p1=constants.StorageSlot.PUBLIC, - ) - assert result == card.send_secure_apdu.return_value - - -def test_get_data_proprietary_channel(card): - card.is_secure_channel_open = False - card.send_apdu.return_value = b"proprietary_data" - result = get_data(card, slot=constants.StorageSlot.NDEF) - card.send_apdu.assert_called_once_with( - ins=constants.INS_GET_DATA, - p1=constants.StorageSlot.NDEF.value, - cla=constants.CLA_PROPRIETARY - ) - assert result == card.send_apdu.return_value - - -def test_get_data_invalid_slot(card): - with pytest.raises(AttributeError): - get_data(card, slot="INVALID_SLOT") diff --git a/python/keycard-py/tests/commands/test_get_status.py b/python/keycard-py/tests/commands/test_get_status.py deleted file mode 100644 index 0464b905..00000000 --- a/python/keycard-py/tests/commands/test_get_status.py +++ /dev/null @@ -1,24 +0,0 @@ -from keycard.commands.get_status import get_status - - -def test_get_application_status(card): - card.send_secure_apdu.return_value = bytes.fromhex( - 'A309020103020102010101') - - result = get_status(card) - - assert result['pin_retry_count'] == 3 - assert result['puk_retry_count'] == 2 - assert result['initialized'] is True - - -def test_get_key_path_status(card): - key_path = [0x8000002C, 0x8000003C] - - card.send_secure_apdu.return_value = b''.join( - i.to_bytes(4, 'big') for i in key_path - ) - - result = get_status(card, key_path=True) - - assert result == key_path diff --git a/python/keycard-py/tests/commands/test_init.py b/python/keycard-py/tests/commands/test_init.py deleted file mode 100644 index d24dd33b..00000000 --- a/python/keycard-py/tests/commands/test_init.py +++ /dev/null @@ -1,85 +0,0 @@ -import sys -import pytest -from unittest.mock import MagicMock, patch -from keycard.commands.init import init -from keycard.exceptions import APDUError -from keycard import constants - - -PIN = b'1234' -PUK = b'5678' -PAIRING_SECRET = b'abcdefgh' -CARD_PUBLIC_KEY = b'\x04' + b'\x00' * 64 # Valid uncompressed pubkey format - - -@pytest.fixture -def ecc_patches(): - init_module = sys.modules['keycard.commands.init'] - with ( - patch.object(init_module, 'urandom', return_value=b'\x00' * 16), - patch.object( - init_module, - 'aes_cbc_encrypt', - side_effect=lambda k, iv, - pt: b'\xAA' * len(pt) - ), - patch.object(init_module, 'SigningKey') as mock_signing_key_cls, - patch.object(init_module, 'VerifyingKey') as mock_verifying_key_cls, - patch.object(init_module, 'ECDH') as mock_ecdh_cls, - ): - mock_gen = mock_signing_key_cls.generate - fake_privkey = MagicMock() - fake_privkey.verifying_key.to_string.return_value = b'\x01' * 65 - mock_gen.return_value = fake_privkey - - mock_parse = mock_verifying_key_cls.from_string - mock_parse.return_value = 'parsed-pubkey' - - ecdh_instance = MagicMock() - ecdh_instance.generate_sharedsecret_bytes.return_value = b'\xBB' * 32 - mock_ecdh_cls.return_value = ecdh_instance - - yield - - -def test_init_success(card, ecc_patches): - card.send_apdu.return_value = b'' - card.card_public_key = CARD_PUBLIC_KEY - - init(card, PIN, PUK, PAIRING_SECRET) - - card.send_apdu.assert_called_once_with( - ins=constants.INS_INIT, - data=bytes.fromhex( - '4101010101010101010101010101010101010101010101010101010' - '1010101010101010101010101010101010101010101010101010101' - '010101010101010101010100000000000000000000000000000000' - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') - ) - - -@pytest.mark.parametrize('secret_length', [10, 240]) -def test_init_data_length(card, ecc_patches, secret_length): - card.send_apdu.return_value = b'' - card.card_public_key = CARD_PUBLIC_KEY - - pairing_secret = b'x' * secret_length - plaintext = PIN + PUK + pairing_secret - total_data_len = 1 + 65 + 16 + len(plaintext) - - if total_data_len > 255: - with pytest.raises(ValueError, match='Data too long'): - init(card, PIN, PUK, pairing_secret) - else: - init(card, PIN, PUK, pairing_secret) - assert card.send_apdu.call_count == 1 - - -def test_init_apdu_error(card, ecc_patches): - card.send_apdu.side_effect = APDUError(0x6A84) - card.card_public_key = CARD_PUBLIC_KEY - - with pytest.raises(APDUError) as excinfo: - init(card, PIN, PUK, PAIRING_SECRET) - - assert excinfo.value.sw == 0x6A84 diff --git a/python/keycard-py/tests/commands/test_keypath.py b/python/keycard-py/tests/commands/test_keypath.py deleted file mode 100644 index a1177493..00000000 --- a/python/keycard-py/tests/commands/test_keypath.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest -from keycard.parsing.keypath import KeyPath -from keycard.constants import DerivationSource - - -def test_keypath_from_string_master(): - path = KeyPath("m/44'/60'/0'/0/0") - assert path.source == DerivationSource.MASTER - assert path.data == bytes.fromhex( - '8000002c8000003c800000000000000000000000') - assert path.to_string() == "m/44'/60'/0'/0/0" - - -def test_keypath_from_string_parent(): - path = KeyPath('../1/2/3') - assert path.source == DerivationSource.PARENT - assert path.to_string() == '../1/2/3' - - -def test_keypath_from_string_current_default(): - path = KeyPath('1/2/3') - assert path.source == DerivationSource.CURRENT - assert path.to_string() == './1/2/3' - - -def test_keypath_from_bytes(): - data = bytes.fromhex('8000002c00000001') - path = KeyPath(data, source=DerivationSource.PARENT) - assert path.source == DerivationSource.PARENT - assert path.data == data - assert path.to_string() == "../44'/1" - - -def test_keypath_empty_string_raises(): - with pytest.raises(ValueError, match="Empty path"): - KeyPath('') - - -def test_keypath_invalid_component(): - with pytest.raises(ValueError, match="Invalid component: abc"): - KeyPath('m/abc') - - -def test_keypath_too_many_components(): - long_path = 'm/' + '/'.join('0' for _ in range(11)) - with pytest.raises(ValueError, match="Too many components"): - KeyPath(long_path) - - -def test_keypath_invalid_byte_length(): - with pytest.raises(ValueError, match="Byte path must be a multiple of 4"): - KeyPath(b'\x00\x01') - - -def test_keypath_invalid_type(): - with pytest.raises(TypeError, match="Path must be a string or bytes"): - KeyPath(123) diff --git a/python/keycard-py/tests/commands/test_load_key.py b/python/keycard-py/tests/commands/test_load_key.py deleted file mode 100644 index 5f92a196..00000000 --- a/python/keycard-py/tests/commands/test_load_key.py +++ /dev/null @@ -1,89 +0,0 @@ -import pytest -from keycard.commands.load_key import load_key -from keycard import constants -from hashlib import sha256 -from keycard.parsing import tlv - - -def test_load_key_bip39(card): - seed = b"\xAA" * 64 - fake_uid = b"\xBB" * 32 - card.send_secure_apdu.return_value = fake_uid - - result = load_key( - card, - key_type=constants.LoadKeyType.BIP39_SEED, - bip39_seed=seed - ) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_LOAD_KEY, - p1=constants.LoadKeyType.BIP39_SEED, - p2=0, - data=seed - ) - assert result == fake_uid - - -def test_load_key_pair(card): - public_key = b'\x04' + b'\x01' * 64 - private_key = b'\x02' * 32 - uid = sha256(public_key).digest() - card.send_secure_apdu.return_value = uid - - encoded = tlv.encode_tlv( - 0xA1, - tlv.encode_tlv(0x80, public_key) + - tlv.encode_tlv(0x81, private_key) - ) - - result = load_key( - card, - key_type=constants.LoadKeyType.ECC, - public_key=public_key, - private_key=private_key - ) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_LOAD_KEY, - p1=constants.LoadKeyType.ECC, - p2=0, - data=encoded - ) - assert result == uid - - -def test_bip39_seed_too_short(card): - with pytest.raises(ValueError, match="BIP39/LEE seed must be 16-64 bytes"): - load_key( - card, - key_type=constants.LoadKeyType.BIP39_SEED, - bip39_seed=b"\xAA" * 8 - ) - - -def test_bip39_seed_missing(card): - with pytest.raises(ValueError, match="Either bip39_seed or lee_seed must be provided for key_type = BIP39_SEED"): - load_key( - card, - key_type=constants.LoadKeyType.BIP39_SEED - ) - - -def test_ecc_missing_private_key(card): - with pytest.raises(ValueError, match="Private key.*required"): - load_key( - card, - key_type=constants.LoadKeyType.ECC, - public_key=b"\x04" + b"\x01" * 64 - ) - - -def test_extended_ecc_missing_private_key(card): - with pytest.raises(ValueError, match="Private key.*required"): - load_key( - card, - key_type=constants.LoadKeyType.EXTENDED_ECC, - public_key=b"\x04" + b"\x02" * 64, - chain_code=b"\x00" * 32 - ) diff --git a/python/keycard-py/tests/commands/test_mutually_authenticate.py b/python/keycard-py/tests/commands/test_mutually_authenticate.py deleted file mode 100644 index dec88a24..00000000 --- a/python/keycard-py/tests/commands/test_mutually_authenticate.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from keycard import constants -from keycard.commands.mutually_authenticate import mutually_authenticate -from keycard.exceptions import APDUError - - -def test_mutually_authenticate_success(card): - client_challenge = bytes(32) - card.send_secure_apdu.return_value = bytes(32) - - mutually_authenticate(card, client_challenge) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_MUTUALLY_AUTHENTICATE, - data=client_challenge - ) - - -def test_mutually_authenticate_invalid_status_word(card): - card.send_secure_apdu.side_effect = APDUError(0x6F00) - - with pytest.raises(APDUError, match='APDU failed with SW=6F00'): - mutually_authenticate(card, bytes(32)) - - -def test_mutually_authenticate_invalid_response_length(card): - client_challenge = b'\xAA' * 32 - response = b'\xBB' * 16 # Invalid length - - card.send_secure_apdu.return_value = response - - with pytest.raises( - ValueError, - match='Response to MUTUALLY AUTHENTICATE is not 32 bytes' - ): - mutually_authenticate(card, client_challenge) - - -def test_mutually_authenticate_auto_challenge(card, monkeypatch): - fake_challenge = b'\xCC' * 32 - monkeypatch.setattr('os.urandom', lambda n: fake_challenge) - - card.send_secure_apdu.return_value = fake_challenge - - mutually_authenticate(card) - - card.send_secure_apdu.assert_called_once() diff --git a/python/keycard-py/tests/commands/test_open_secure_channel.py b/python/keycard-py/tests/commands/test_open_secure_channel.py deleted file mode 100644 index c6adab33..00000000 --- a/python/keycard-py/tests/commands/test_open_secure_channel.py +++ /dev/null @@ -1,109 +0,0 @@ -import sys -import pytest -from unittest.mock import MagicMock, patch -from ecdsa import SECP256k1 - -from keycard.card_interface import CardInterface -from keycard.commands.open_secure_channel import open_secure_channel -from keycard.exceptions import APDUError - - -@pytest.fixture -def mock_ecdsa(): - open_secure_channel_module = sys.modules[ - 'keycard.commands.open_secure_channel' - ] - with ( - patch.object( - open_secure_channel_module, - 'SecureChannel' - ) as mock_secure_channel, - patch.object( - open_secure_channel_module, - 'VerifyingKey' - ) as mock_verifying_key, - patch.object( - open_secure_channel_module, - 'ECDH' - ) as mock_ecdh, - patch.object( - open_secure_channel_module, - 'SigningKey' - ) as mock_signing_key, - ): - yield { - 'secure_channel': mock_secure_channel, - 'verifying_key': mock_verifying_key, - 'ecdh': mock_ecdh, - 'signing_key': mock_signing_key, - } - - -def test_open_secure_channel_success(mock_ecdsa): - mock_verifying_key = mock_ecdsa['verifying_key'] - mock_ecdh = mock_ecdsa['ecdh'] - mock_signing_key = mock_ecdsa['signing_key'] - mock_secure_channel = mock_ecdsa['secure_channel'] - - pairing_index = 1 - pairing_key = b'pairing_key' - card = MagicMock(spec=CardInterface) - card.card_public_key = b'\x04' + b'\x01' * 64 - - salt = b'A' * 32 - seed_iv = b'B' * 16 - response_data = salt + seed_iv - card.send_apdu.return_value = response_data - - # Mock SigningKey.generate - mock_signing_key_instance = MagicMock() - mock_signing_key_instance.verifying_key.to_string.return_value = \ - b'\x04' + b'\x02' * 64 - mock_signing_key.generate.return_value = mock_signing_key_instance - - mock_verifying_key.from_string.return_value = MagicMock() - mock_ecdh_instance = MagicMock() - mock_ecdh.return_value = mock_ecdh_instance - mock_ecdh_instance.generate_sharedsecret_bytes.return_value = ( - b'shared_secret' - ) - mock_secure_channel.open.return_value = 'secure_session' - - result = open_secure_channel( - card, - pairing_index, - pairing_key - ) - - card.send_apdu.assert_called_once() - mock_verifying_key.from_string.assert_called_once_with( - card.card_public_key, curve=SECP256k1 - ) - mock_ecdh.assert_called_once() - mock_ecdh_instance.generate_sharedsecret_bytes.assert_called_once() - mock_secure_channel.open.assert_called_once_with( - b'shared_secret', pairing_key, salt, seed_iv - ) - assert result == 'secure_session' - - -def test_open_secure_channel_raises_apdu_error(card, mock_ecdsa): - mock_signing_key = mock_ecdsa['signing_key'] - - # Mock SigningKey.generate - mock_signing_key_instance = MagicMock() - mock_signing_key_instance.verifying_key.to_string.return_value = \ - b'\x04' + b'\x02' * 64 - mock_signing_key.generate.return_value = mock_signing_key_instance - - pairing_index = 1 - pairing_key = b'pairing_key' - card.card_public_key = b'\x04' + b'\x01' * 64 - card.send_apdu.side_effect = APDUError(0x6A80) - - with pytest.raises(APDUError): - open_secure_channel( - card, - pairing_index, - pairing_key - ) diff --git a/python/keycard-py/tests/commands/test_pair.py b/python/keycard-py/tests/commands/test_pair.py deleted file mode 100644 index b43b9269..00000000 --- a/python/keycard-py/tests/commands/test_pair.py +++ /dev/null @@ -1,106 +0,0 @@ -import sys -import pytest -import hashlib - -from unittest.mock import patch - -from keycard.constants import INS_PAIR, PairingMode -from keycard.commands.pair import pair -from keycard.exceptions import APDUError, InvalidResponseError - - -@pytest.fixture -def mock_urandom(): - pair_module = sys.modules['keycard.commands.pair'] - with patch.object(pair_module, 'urandom', return_value=b'\x01' * 32): - yield - - -def test_pair_success(card, mock_urandom): - shared_secret = b'\xAA' * 32 - client_challenge = b'\x01' * 32 - card_challenge = b'\x02' * 32 - expected_card_cryptogram = hashlib.sha256( - shared_secret + client_challenge).digest() - expected_client_cryptogram = hashlib.sha256( - shared_secret + card_challenge).digest() - - first_response = expected_card_cryptogram + card_challenge - second_response = b'\x05' + card_challenge - - card.send_apdu.side_effect = [first_response, second_response] - - result = pair(card, shared_secret) - - assert result == (5, expected_client_cryptogram) - assert card.send_apdu.call_count == 2 - - -def test_pairing_mode(card, mock_urandom): - shared_secret = b'\xAA' * 32 - client_challenge = b'\x01' * 32 - card_challenge = b'\x02' * 32 - expected_card_cryptogram = hashlib.sha256( - shared_secret + client_challenge).digest() - first_response = expected_card_cryptogram + card_challenge - second_response = b'\x05' + card_challenge - - card.send_apdu.side_effect = [first_response, second_response] - - pair(card, shared_secret, PairingMode.EPHEMERAL) - card.send_apdu.assert_any_call( - ins=INS_PAIR, - p2=PairingMode.EPHEMERAL, - data=client_challenge - ) - - -def test_pair_invalid_shared_secret(card, mock_urandom): - with pytest.raises(ValueError, match='Shared secret must be 32 bytes'): - pair(card, b'short') - - -def test_pair_apdu_error_on_first(card, mock_urandom): - card.send_apdu.side_effect = APDUError(0x6A82) - - with pytest.raises(APDUError): - pair(card, b'\x00' * 32) - - -def test_pair_invalid_response_length_first(card, mock_urandom): - card.send_apdu.return_value = bytes(10) - - with pytest.raises( - InvalidResponseError, - match='Unexpected response length' - ): - pair(card, b'\x00' * 32) - - -def test_pair_cryptogram_mismatch(card, mock_urandom): - wrong_card_cryptogram = b'\xAB' * 32 - card_challenge = b'\x02' * 32 - response = wrong_card_cryptogram + card_challenge - - card.send_apdu.side_effect = [response] - - with pytest.raises(InvalidResponseError, match='Card cryptogram mismatch'): - pair(card, b'\xAA' * 32) - - -def test_pair_invalid_response_second_apdu(card, mock_urandom): - shared_secret = b'\xAA' * 32 - client_challenge = b'\x01' * 32 - card_challenge = b'\x02' * 32 - card_cryptogram = hashlib.sha256(shared_secret + client_challenge).digest() - - first_response = card_cryptogram + card_challenge - second_response = b'\x00' * 10 - - card.send_apdu.side_effect = [first_response, second_response] - - with pytest.raises( - InvalidResponseError, - match='Unexpected response length' - ): - pair(card, shared_secret) diff --git a/python/keycard-py/tests/commands/test_remove_key.py b/python/keycard-py/tests/commands/test_remove_key.py deleted file mode 100644 index 20c76d04..00000000 --- a/python/keycard-py/tests/commands/test_remove_key.py +++ /dev/null @@ -1,11 +0,0 @@ -from keycard.commands.remove_key import remove_key - - -def test_remove_key_calls_send_secure_apdu_with_correct_ins(card): - remove_key(card) - card.send_secure_apdu.assert_called_once_with(ins=0xD3) - - -def test_remove_key_returns_none(card): - result = remove_key(card) - assert result is None diff --git a/python/keycard-py/tests/commands/test_select.py b/python/keycard-py/tests/commands/test_select.py deleted file mode 100644 index 75a040b9..00000000 --- a/python/keycard-py/tests/commands/test_select.py +++ /dev/null @@ -1,41 +0,0 @@ -import sys -import pytest - - -from unittest.mock import MagicMock, patch -from keycard.commands.select import select -from keycard.exceptions import APDUError -from keycard import constants - - -def test_select_success(): - select_module = sys.modules['keycard.commands.select'] - dummy_info = MagicMock() - response_data = b'\x01\x02\x03\x04' - - card = MagicMock() - card.send_apdu.return_value = response_data - - with patch.object(select_module, 'ApplicationInfo') as mock_app_info: - mock_app_info.parse.return_value = dummy_info - result = select(card) - - card.send_apdu.assert_called_once_with( - cla=constants.CLAISO7816, - ins=constants.INS_SELECT, - p1=0x04, - p2=0x00, - data=constants.KEYCARD_AID - ) - mock_app_info.parse.assert_called_once_with(response_data) - assert result == dummy_info - - -def test_select_apdu_error(): - card = MagicMock() - card.send_apdu.side_effect = APDUError(0x6A82) - - with pytest.raises(APDUError) as excinfo: - select(card) - - assert excinfo.value.sw == 0x6A82 diff --git a/python/keycard-py/tests/commands/test_set_pinless_path.py b/python/keycard-py/tests/commands/test_set_pinless_path.py deleted file mode 100644 index ff8be399..00000000 --- a/python/keycard-py/tests/commands/test_set_pinless_path.py +++ /dev/null @@ -1,24 +0,0 @@ -from keycard.commands.set_pinless_path import set_pinless_path -from keycard.constants import INS_SET_PINLESS_PATH -from keycard.parsing.keypath import KeyPath - - -def test_set_pinless_path(card): - path = "m/44'/60'/0'/0/0" - expected_data = KeyPath(path).data - - set_pinless_path(card, path) - - card.send_secure_apdu.assert_called_once_with( - ins=INS_SET_PINLESS_PATH, - data=expected_data - ) - - -def test_set_pinless_path_empty(card): - set_pinless_path(card, "") - - card.send_secure_apdu.assert_called_once_with( - ins=INS_SET_PINLESS_PATH, - data=b"" - ) diff --git a/python/keycard-py/tests/commands/test_sign.py b/python/keycard-py/tests/commands/test_sign.py deleted file mode 100644 index 40ae3ee9..00000000 --- a/python/keycard-py/tests/commands/test_sign.py +++ /dev/null @@ -1,101 +0,0 @@ -import sys -import pytest - -from unittest import mock - -from keycard.commands.sign import sign -from keycard import constants -from keycard.exceptions import InvalidStateError -from keycard.parsing.keypath import KeyPath - - -def test_sign_current_key(card): - sign_module = sys.modules['keycard.commands.sign'] - digest = b'\xAA' * 32 - raw = b'\x01' * 64 + b'\x1f' - encoded = b'\x80' + bytes([len(raw)]) + raw - card.send_secure_apdu.return_value = encoded - with mock.patch.object(sign_module, "SignatureResult"): - sign(card, digest) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_SIGN, - p1=constants.DerivationOption.CURRENT, - p2=constants.SigningAlgorithm.ECDSA_SECP256K1, - data=digest, - ) - - -def test_sign_with_derivation_path(card): - sign_module = sys.modules['keycard.commands.sign'] - digest = bytes(32) - raw = bytes(65) - encoded = b'\x80' + bytes([len(raw)]) + raw - card.send_secure_apdu.return_value = encoded - key_path = KeyPath("m/44'/60'/0'/0/0") - expected_data = digest + key_path.data - - with mock.patch.object(sign_module, "SignatureResult"): - sign( - card, - digest, - p1=constants.DerivationOption.DERIVE, - derivation_path=key_path.to_string() - ) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_SIGN, - p1=constants.DerivationOption.DERIVE, - p2=constants.SigningAlgorithm.ECDSA_SECP256K1, - data=expected_data, - ) - - -def test_sign_requires_pin(card): - card.is_pin_verified = False - digest = b'\xCC' * 32 - - with pytest.raises( - InvalidStateError, - match="PIN must be verified to sign with this derivation option" - ): - sign(card, digest) - - -def test_sign_short_digest(card): - short_digest = b'\xDD' * 10 - - with pytest.raises(ValueError, match="Digest must be exactly 32 bytes"): - sign(card, short_digest) - - -def test_sign_missing_path(card): - digest = b'\xEE' * 32 - - with pytest.raises(ValueError, match="Derivation path cannot be empty"): - sign( - card, - digest, - p1=constants.DerivationOption.DERIVE, - derivation_path=None - ) - - -def test_sign_not_implemented_algo(card): - digest = b'\xAB' * 32 - - with pytest.raises( - NotImplementedError, - match="Signature algorithm 255 not supported" - ): - sign(card, digest, p2=0xFF) - - -def test_sign_raw_signature_wrong_length(card): - digest = b'\xCC' * 32 - raw = b'\x01' * 64 # Should be 65 bytes - encoded = b'\x80' + bytes([len(raw)]) + raw - card.send_secure_apdu.return_value = encoded - card.is_pin_verified = True - with pytest.raises(ValueError, match="Expected 65-byte raw signature"): - sign(card, digest) diff --git a/python/keycard-py/tests/commands/test_store_data.py b/python/keycard-py/tests/commands/test_store_data.py deleted file mode 100644 index f24c8f75..00000000 --- a/python/keycard-py/tests/commands/test_store_data.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from keycard.commands import store_data -from keycard import constants - - -def test_store_data_calls_send_secure_apdu_with_correct_args(card): - store_data(card, b"hello", constants.StorageSlot.PUBLIC) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_STORE_DATA, - p1=constants.StorageSlot.PUBLIC.value, - data=b'hello' - ) - - -def test_store_data_uses_default_slot(card): - store_data(card, b'world') - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_STORE_DATA, - p1=constants.StorageSlot.PUBLIC, - data=b'world' - ) - - -def test_store_data_raises_value_error_on_too_long_data(card): - with pytest.raises(ValueError, match="Data too long"): - store_data(card, b'a' * 128, constants.StorageSlot.PUBLIC) diff --git a/python/keycard-py/tests/commands/test_unblock_pin.py b/python/keycard-py/tests/commands/test_unblock_pin.py deleted file mode 100644 index 8eea2a1d..00000000 --- a/python/keycard-py/tests/commands/test_unblock_pin.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from keycard.commands.unblock_pin import unblock_pin -from keycard import constants - - -def test_unblock_pin_with_valid_str(card): - puk = '123456789012' - pin = '123456' - unblock_pin(card, puk + pin) - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_UNBLOCK_PIN, - data=(puk + pin).encode('utf-8') - ) - - -def test_unblock_pin_with_valid_bytes(card): - data = b'123456789012123456' - unblock_pin(card, data) - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_UNBLOCK_PIN, - data=data - ) - - -@pytest.mark.parametrize('bad_input', [ - '12345678901212345', # Too short - '1234567890121234567', # Too long - b'12345678901212345', # Too short (bytes) - b'1234567890121234567', # Too long (bytes) -]) -def test_unblock_pin_invalid_length(card, bad_input): - with pytest.raises(ValueError, match='exactly 18 digits'): - unblock_pin(card, bad_input) - - -@pytest.mark.parametrize('bad_input', [ - '12345678901A123456', # Non-digit in PUK - '12345678901212345A', # Non-digit in PIN - 'ABCDEFGHIJKL123456', # All non-digits in PUK -]) -def test_unblock_pin_invalid_digits(card, bad_input): - with pytest.raises(ValueError, match='must be numeric digits'): - unblock_pin(card, bad_input) diff --git a/python/keycard-py/tests/commands/test_unpair.py b/python/keycard-py/tests/commands/test_unpair.py deleted file mode 100644 index e65c80cd..00000000 --- a/python/keycard-py/tests/commands/test_unpair.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from keycard.commands.unpair import unpair -from keycard.apdu import APDUResponse -from keycard.exceptions import APDUError -from keycard import constants - - -def test_unpair_success(card): - card.send_secure_apdu.return_value = APDUResponse(b'', 0x9000) - - unpair(card, 1) - - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_UNPAIR, - p1=0x01, - ) - - -def test_unpair_apdu_error(card): - card.send_secure_apdu.side_effect = APDUError(0x6A84) - - with pytest.raises(APDUError) as excinfo: - unpair(card, 1) - - assert excinfo.value.sw == 0x6A84 diff --git a/python/keycard-py/tests/commands/test_verify_pin.py b/python/keycard-py/tests/commands/test_verify_pin.py deleted file mode 100644 index 910b7fea..00000000 --- a/python/keycard-py/tests/commands/test_verify_pin.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from keycard.commands.verify_pin import verify_pin -from keycard.exceptions import APDUError -from keycard import constants - - -def test_verify_pin_success(card): - assert verify_pin(card, '1234') is True - card.send_secure_apdu.assert_called_once_with( - ins=constants.INS_VERIFY_PIN, - data=b'1234' - ) - - -def test_verify_pin_incorrect_but_allowed(card): - card.send_secure_apdu.side_effect = APDUError(0x63C2) - assert verify_pin(card, '0000') is False - - -def test_verify_pin_blocked(card): - card.send_secure_apdu.side_effect = APDUError(0x63C0) - with pytest.raises(RuntimeError, match='PIN is blocked'): - verify_pin(card, '0000') - - -def test_verify_pin_other_apdu_error(card): - card.send_secure_apdu.side_effect = APDUError(0x6A80) - with pytest.raises(APDUError): - verify_pin(card, '0000') diff --git a/python/keycard-py/tests/conftest.py b/python/keycard-py/tests/conftest.py deleted file mode 100644 index 81c5a6b1..00000000 --- a/python/keycard-py/tests/conftest.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest -from unittest.mock import Mock -from keycard.card_interface import CardInterface - - -@pytest.fixture -def card(): - mock = Mock(spec=CardInterface) - return mock diff --git a/python/keycard-py/tests/crypto/__init__.py b/python/keycard-py/tests/crypto/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/tests/crypto/test_aes.py b/python/keycard-py/tests/crypto/test_aes.py deleted file mode 100644 index c1b6b70d..00000000 --- a/python/keycard-py/tests/crypto/test_aes.py +++ /dev/null @@ -1,55 +0,0 @@ -import pytest -from keycard.crypto import aes - - -def test_aes_cbc_encrypt_decrypt_roundtrip(): - key = b'0123456789abcdef' - iv = b'abcdef9876543210' - data = b'hello world 1234' - ciphertext = aes.aes_cbc_encrypt(key, iv, data) - decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) - assert decrypted == data - - -def test_aes_cbc_encrypt_padding(): - key = b'0123456789abcdef' - iv = b'abcdef9876543210' - data = b'abc' - ciphertext = aes.aes_cbc_encrypt(key, iv, data) - assert len(ciphertext) % 16 == 0 - decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) - assert decrypted == data - - -def test_aes_cbc_encrypt_no_padding(): - key = b'0123456789abcdef' - iv = b'abcdef9876543210' - data = b'1234567890abcdef' - ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False) - assert len(ciphertext) == 16 - with pytest.raises(ValueError): - aes.aes_cbc_decrypt(key, iv, ciphertext) - - -def test_aes_cbc_decrypt_invalid_padding(): - key = b'0123456789abcdef' - iv = b'abcdef9876543210' - data = b'1234567890abcdef' - ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False) - with pytest.raises(ValueError): - aes.aes_cbc_decrypt(key, iv, ciphertext) - - -@pytest.mark.parametrize('data', [ - b'', - b'a', - b'short', - b'exactly16bytes!!', - b'longer data that is not a multiple of block size', -]) -def test_various_lengths(data): - key = b'0123456789abcdef' - iv = b'abcdef9876543210' - ciphertext = aes.aes_cbc_encrypt(key, iv, data) - decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) - assert decrypted == data diff --git a/python/keycard-py/tests/crypto/test_generate_pairing_token.py b/python/keycard-py/tests/crypto/test_generate_pairing_token.py deleted file mode 100644 index 67aec706..00000000 --- a/python/keycard-py/tests/crypto/test_generate_pairing_token.py +++ /dev/null @@ -1,28 +0,0 @@ -import hashlib -import unicodedata -from keycard.crypto.generate_pairing_token import generate_pairing_token - - -def test_generate_pairing_token_deterministic(): - passphrase = "correct horse battery staple" - expected = hashlib.pbkdf2_hmac( - 'sha256', - unicodedata.normalize('NFKD', passphrase).encode('utf-8'), - unicodedata.normalize( - 'NFKD', 'Keycard Pairing Password Salt').encode('utf-8'), - 50000, - dklen=32 - ) - assert generate_pairing_token(passphrase) == expected - - -def test_generate_pairing_token_unicode_normalization(): - token_plain = generate_pairing_token("é") - token_composed = generate_pairing_token("é") - assert token_plain == token_composed - - -def test_generate_pairing_token_output_length(): - token = generate_pairing_token("whatever") - assert isinstance(token, bytes) - assert len(token) == 32 diff --git a/python/keycard-py/tests/parsing/__init__.py b/python/keycard-py/tests/parsing/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/python/keycard-py/tests/parsing/test_application_info.py b/python/keycard-py/tests/parsing/test_application_info.py deleted file mode 100644 index 3b666a61..00000000 --- a/python/keycard-py/tests/parsing/test_application_info.py +++ /dev/null @@ -1,162 +0,0 @@ -import pytest -from keycard.exceptions import InvalidResponseError -from keycard.parsing.application_info import ApplicationInfo - - -class DummyCapabilities: - CREDENTIALS_MANAGEMENT = 1 - SECURE_CHANNEL = 2 - - @staticmethod - def parse(val): - return val - - -def test_parse_simple_pubkey(monkeypatch): - monkeypatch.setattr( - 'keycard.parsing.application_info.Capabilities', - DummyCapabilities - ) - data = bytes([0x80, 0x04, 0x01, 0x02, 0x03, 0x04]) - info = ApplicationInfo.parse(data) - - assert info.ecc_public_key == b'\x01\x02\x03\x04' - assert info.capabilities == 3 - assert info.instance_uid is None - assert info.key_uid is None - assert info.version_major == 0 - assert info.version_minor == 0 - - -def test_str_method(): - info = ApplicationInfo( - capabilities=7, - ecc_public_key=b'\x01\x02', - instance_uid=b'\xAA\xBB', - key_uid=b'\xCC\xDD', - version_major=2, - version_minor=5, - ) - s = str(info) - - assert '2.5' in s - assert 'aabb' in s - assert 'ccdd' in s - assert '0102' in s - assert '7' in s - - -def test_parse_tlv_success(monkeypatch): - def dummy_parse_tlv(data): - if data == b'\xA4\x0C' + b'\x01'*12: - return { - 0xA4: [ - b'\x8F\x02\xAA\xBB' - b'\x80\x02\x01\x02' - b'\x8E\x02\xCC\xDD' - b'\x8D\x01\x07' - b'\x02\x02\x02\x05' - ] - } - return { - 0x8F: [b'\xAA\xBB'], - 0x80: [b'\x01\x02'], - 0x8E: [b'\xCC\xDD'], - 0x8D: [b'\x07'], - 0x02: [b'\x02\x05'] - } - - class DummyCapabilities: - @staticmethod - def parse(val): - return val - - monkeypatch.setattr( - 'keycard.parsing.application_info.parse_tlv', - dummy_parse_tlv - ) - monkeypatch.setattr( - 'keycard.parsing.application_info.Capabilities', - DummyCapabilities - ) - - # Simulate TLV-encoded data - data = b'\xA4\x0C' + b'\x01'*12 - info = ApplicationInfo.parse(data) - - assert info.instance_uid == b'\xAA\xBB' - assert info.ecc_public_key == b'\x01\x02' - assert info.key_uid == b'\xCC\xDD' - assert info.capabilities == 7 - assert info.version_major == 2 - assert info.version_minor == 5 - - -def test_parse_tlv_missing_a4(monkeypatch): - def dummy_parse_tlv(data): - # No 0xA4 tag present - return {} - - monkeypatch.setattr( - 'keycard.parsing.application_info.parse_tlv', - dummy_parse_tlv - ) - with pytest.raises(InvalidResponseError): - ApplicationInfo.parse(b'\x00\x01\x02') - - -def test_parse_tlv_missing_fields(monkeypatch): - def dummy_parse_tlv(data): - # Missing some tags - return { - 0xA4: [b''] - } - - class DummyCapabilities: - @staticmethod - def parse(val): - return val - - monkeypatch.setattr( - 'keycard.parsing.application_info.parse_tlv', - dummy_parse_tlv - ) - monkeypatch.setattr( - 'keycard.parsing.application_info.Capabilities', - DummyCapabilities - ) - - # Should raise KeyError due to missing tags in inner_tlv - with pytest.raises(KeyError): - ApplicationInfo.parse(b'\xA4\x01\x00') - - -def test_parse_pubkey_empty(monkeypatch): - monkeypatch.setattr( - 'keycard.parsing.application_info.Capabilities', - DummyCapabilities - ) - # No pubkey bytes - data = bytes([0x80, 0x00]) - info = ApplicationInfo.parse(data) - assert info.ecc_public_key == b'' - assert info.capabilities == 1 # Only CREDENTIALS_MANAGEMENT - assert info.instance_uid is None - assert info.key_uid is None - assert info.version_major == 0 - assert info.version_minor == 0 - - -def test_is_initialized_property(): - info = ApplicationInfo( - capabilities=1, - ecc_public_key=None, - instance_uid=None, - key_uid=None, - version_major=0, - version_minor=0, - ) - assert not info.is_initialized - - info.key_uid = b'\x01' - assert info.is_initialized diff --git a/python/keycard-py/tests/parsing/test_identity.py b/python/keycard-py/tests/parsing/test_identity.py deleted file mode 100644 index f8b16d02..00000000 --- a/python/keycard-py/tests/parsing/test_identity.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -from keycard.parsing.identity import parse, InvalidResponseError -from keycard.parsing import identity - - -def make_tlv(tag, value): - return bytes([tag, len(value)]) + value - - -def fake_parse_tlv(data): - if data == b'outer': - return {0xA0: [b'inner']} - elif data == b'inner': - return {0x8A: [b'cert'], 0x30: [b'sig']} - return {} - - -def test_parse_success(monkeypatch): - monkeypatch.setattr( - identity, - "parse_tlv", - lambda data: - {0xA0: [b'inner']} if data == b'data' - else {0x8A: [b'c'*95], 0x30: [b's'*64]}) - monkeypatch.setattr( - identity, - "_verify", - lambda certificate, signature, challenge: None - ) - monkeypatch.setattr( - identity, - "_recover_public_key", - lambda certificate: b'pubkey' - ) - - challenge = b'challenge' - data = b'data' - result = parse(challenge, data) - assert result == b'pubkey' - - -def test_parse_malformed_index(monkeypatch): - monkeypatch.setattr( - identity, - "parse_tlv", - lambda data: {0xA0: [b'inner']} if data == b'data' else {} - ) - challenge = b'challenge' - data = b'data' - with pytest.raises( - InvalidResponseError, - match="Malformed identity response" - ): - parse(challenge, data) - - -def test_parse_certificate_too_short(monkeypatch): - monkeypatch.setattr( - identity, - "parse_tlv", - lambda data: - {0xA0: [b'inner']} if data == b'data' - else {0x8A: [b'c'*10], 0x30: [b's'*64]} - ) - challenge = b'challenge' - data = b'data' - with pytest.raises( - InvalidResponseError, - match="Malformed identity response" - ): - parse(challenge, data) - - -def test_parse_signature_too_short(monkeypatch): - monkeypatch.setattr( - identity, - "parse_tlv", - lambda data: - {0xA0: [b'inner']} if data == b'data' - else {0x8A: [b'c'*95], 0x30: [b's'*10]}) - challenge = b'challenge' - data = b'data' - with pytest.raises( - InvalidResponseError, - match="Malformed identity response" - ): - parse(challenge, data) diff --git a/python/keycard-py/tests/parsing/test_signature_result.py b/python/keycard-py/tests/parsing/test_signature_result.py deleted file mode 100644 index 4f8d4a3c..00000000 --- a/python/keycard-py/tests/parsing/test_signature_result.py +++ /dev/null @@ -1,117 +0,0 @@ -from keycard.parsing.signature_result import SignatureResult -from keycard.constants import SigningAlgorithm -from unittest import mock - - -def test_signature_result_with_minimal_r_s(): - r = 1 - s = 1 - digest = b'\x01' * 32 - public_key = b'\x02' + b'\x01' * 32 - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=public_key, - recovery_id=0 - ) - assert sig.r == b'\x01' - assert sig.s == b'\x01' - assert sig.signature == b'\x01\x01' - - -def test_signature_result_with_large_r_s(): - r = 2**255 - s = 2**255 - 1 - digest = b'\x01' * 32 - public_key = b'\x02' + b'\x01' * 32 - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=public_key, - recovery_id=2 - ) - assert sig.r == r.to_bytes((r.bit_length() + 7) // 8, 'big') - assert sig.s == s.to_bytes((s.bit_length() + 7) // 8, 'big') - assert sig.recovery_id == 2 - - -def test_signature_result_signature_der_property(): - r = 123 - s = 456 - digest = b'\x01' * 32 - public_key = b'\x02' + b'\x01' * 32 - - with mock.patch( - 'keycard.parsing.signature_result.util.sigencode_der' - ) as mock_sigencode_der: - mock_sigencode_der.return_value = b'der' - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=public_key, - recovery_id=3 - ) - der = sig.signature_der - assert der == b'der' - mock_sigencode_der.assert_called_once_with(r, s, 3) - - -def test_signature_result_repr_exists(): - r = int.from_bytes(b'\x01' * 32, 'big') - s = int.from_bytes(b'\x01' * 32, 'big') - digest = b'\x01' * 32 - public_key = b'\x02' + b'\x01' * 32 - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=public_key, - recovery_id=0 - ) - assert isinstance(repr(sig), str) - - -def test_signature_result_public_key_and_recovery_id_priority(): - r = 5 - s = 6 - digest = b'\x01' * 32 - public_key = b'\x02' + b'\x01' * 32 - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=public_key, - recovery_id=7 - ) - assert sig.public_key == public_key - assert sig.recovery_id == 7 - - -def test_signature_result_missing_public_key_calls_recover(): - r = 10 - s = 20 - digest = b'\x01' * 32 - with mock.patch.object( - SignatureResult, - "_recover_public_key", - return_value=b'\x02' + b'\x02' * 32 - ) as mock_pubkey: - sig = SignatureResult( - digest=digest, - algo=SigningAlgorithm.ECDSA_SECP256K1, - r=r, - s=s, - public_key=None, - recovery_id=9 - ) - assert sig.public_key == b'\x02' + b'\x02' * 32 - assert sig.recovery_id == 9 - mock_pubkey.assert_called_once_with(digest) diff --git a/python/keycard-py/tests/parsing/test_tlv.py b/python/keycard-py/tests/parsing/test_tlv.py deleted file mode 100644 index f6e44c80..00000000 --- a/python/keycard-py/tests/parsing/test_tlv.py +++ /dev/null @@ -1,122 +0,0 @@ -import pytest - -from keycard.exceptions import InvalidResponseError -from keycard.parsing import tlv - - -def test_parse_ber_length_short_form(): - data = bytes([0x05]) - length, consumed = tlv._parse_ber_length(data, 0) - assert length == 5 - assert consumed == 1 - - -def test_parse_ber_length_long_form_1byte(): - data = bytes([0x81, 0x10]) - length, consumed = tlv._parse_ber_length(data, 0) - assert length == 0x10 - assert consumed == 2 - - -def test_parse_ber_length_long_form_2bytes(): - data = bytes([0x82, 0x01, 0xF4]) - length, consumed = tlv._parse_ber_length(data, 0) - assert length == 500 - assert consumed == 3 - - -def test_parse_ber_length_unsupported_length(): - data = bytes([0x85, 0, 0, 0, 0, 0]) - with pytest.raises(InvalidResponseError): - tlv._parse_ber_length(data, 0) - - -def test_parse_ber_length_exceeds_buffer(): - data = bytes([0x82, 0x01]) - with pytest.raises(InvalidResponseError): - tlv._parse_ber_length(data, 0) - - -def test_parse_tlv_single(): - data = bytes([0x01, 0x03, ord('a'), ord('b'), ord('c')]) - result = tlv.parse_tlv(data) - assert 0x01 in result - assert result[0x01][0] == b'abc' - - -def test_parse_tlv_multiple_tags(): - data = bytes([ - 0x01, 0x02, ord('h'), ord('i'), - 0x02, 0x01, ord('x')]) - result = tlv.parse_tlv(data) - assert result[0x01][0] == b'hi' - assert result[0x02][0] == b'x' - - -def test_parse_tlv_repeated_tag(): - data = bytes([ - 0x01, 0x01, ord('a'), - 0x01, 0x02, ord('b'), ord('c') - ]) - result = tlv.parse_tlv(data) - assert result[0x01][0] == b'a' - assert result[0x01][1] == b'bc' - - -def test_parse_tlv_long_length(): - data = bytes([0x10, 0x82, 0x01, 0x01]) + b'a' * 257 - result = tlv.parse_tlv(data) - assert result[0x10][0] == b'a' * 257 - - -def test_parse_tlv_incomplete_value(): - data = bytes([0x01, 0x05, ord('a'), ord('b'), ord('c')]) - with pytest.raises(InvalidResponseError): - tlv.parse_tlv(data) - - -def test_encode_tlv_short(): - tag = 0x01 - value = b'\xAB\xCD' - encoded = tlv.encode_tlv(tag, value) - assert encoded == b'\x01\x02\xAB\xCD' - - -def test_encode_tlv_1byte_long_form(): - tag = 0x02 - value = b'\x00' * 130 # >127 triggers long form - encoded = tlv.encode_tlv(tag, value) - assert encoded[:2] == b'\x02\x81' - assert encoded[2] == 130 - assert encoded[3:] == value - - -def test_encode_tlv_2byte_long_form(): - tag = 0x03 - value = b'\xFF' * 300 # >255 triggers 2-byte length - encoded = tlv.encode_tlv(tag, value) - assert encoded[:3] == b'\x03\x82\x01' - assert encoded[3] == 0x2C # 300 = 0x012C - assert encoded[4:] == value - - -def test_encode_tlv_empty_value(): - tag = 0x04 - value = b"" - encoded = tlv.encode_tlv(tag, value) - assert encoded == b'\x04\x00' - - -def test_encode_tlv_max_short_length(): - tag = 0x10 - value = b"A" * 127 - encoded = tlv.encode_tlv(tag, value) - assert encoded[1] == 127 - assert len(encoded) == 2 + 127 - - -def test_encode_tlv_max_1byte_long_form(): - tag = 0x20 - value = b"A" * 255 - encoded = tlv.encode_tlv(tag, value) - assert encoded[1:3] == b'\x81\xFF' diff --git a/python/keycard-py/tests/test_apdu.py b/python/keycard-py/tests/test_apdu.py deleted file mode 100644 index 0d4c83f4..00000000 --- a/python/keycard-py/tests/test_apdu.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest -from keycard.apdu import encode_lv, APDUResponse - - -def test_encode_lv_valid(): - value = bytes(10) - result = encode_lv(value) - assert result == b"\x0A" + value - - -def test_encode_lv_too_long(): - value = bytes(256) - with pytest.raises(ValueError): - encode_lv(value) - - -def test_encode_lv_empty(): - value = bytes() - result = encode_lv(value) - assert result == b"\x00" - - -def test_encode_lv_single_byte(): - value = bytes([0xFF]) - result = encode_lv(value) - assert result == b"\x01\xFF" - - -def test_encode_lv_max_length(): - value = bytes(255) - result = encode_lv(value) - assert result == b"\xFF" + value - - -def test_apdu_response_success(): - r = APDUResponse([0x01, 0x02], 0x9000) - assert r.data == [0x01, 0x02] - assert r.status_word == 0x9000 - - -def test_apdu_response_error_status(): - r = APDUResponse([], 0x6A82) - assert r.status_word == 0x6A82 - assert isinstance(r.status_word, int) - - -def test_apdu_response_all_status_range(): - for sw in [0x9000, 0x6A80, 0x6A84, 0x6982]: - r = APDUResponse([0x00], sw) - assert r.status_word == sw - assert r.data == [0x00] diff --git a/python/keycard-py/tests/test_keycard.py b/python/keycard-py/tests/test_keycard.py deleted file mode 100644 index d84dcead..00000000 --- a/python/keycard-py/tests/test_keycard.py +++ /dev/null @@ -1,531 +0,0 @@ -import pytest -from unittest.mock import MagicMock, patch - -from keycard import constants -from keycard.apdu import APDUResponse -from keycard.exceptions import APDUError -from keycard.parsing.exported_key import ExportedKey -from keycard.keycard import KeyCard -from keycard.transport import Transport - - -def test_keycard_init_with_transport(): - transport = MagicMock(spec=Transport) - kc = KeyCard(transport) - assert kc.transport == transport - assert kc.card_public_key is None - assert kc.session is None - - -def test_select_sets_card_pubkey(): - mock_info = MagicMock() - mock_info.ecc_public_key = b'pubkey' - with patch('keycard.keycard.commands.select', return_value=mock_info): - kc = KeyCard(MagicMock()) - result = kc.select() - assert kc.card_public_key == b'pubkey' - assert result == mock_info - - -def test_init_calls_command(): - transport = MagicMock() - with patch('keycard.keycard.commands.init') as mock_init: - kc = KeyCard(transport) - kc.card_public_key = b'pub' - kc.init(b'pin', b'puk', b'secret') - mock_init.assert_called_once_with(kc, b'pin', b'puk', b'secret') - - -def test_ident_calls_command(): - with patch('keycard.keycard.commands.ident', return_value='identity') as m: - kc = KeyCard(MagicMock()) - result = kc.ident(b'challenge') - m.assert_called_once() - assert result == 'identity' - - -def test_open_secure_channel_with_mutual_authentication(): - with patch( - 'keycard.keycard.commands.open_secure_channel' - ) as mock_osc: - with patch( - 'keycard.keycard.commands.mutually_authenticate' - ) as mock_ma: - mock_osc.return_value = 'session' - kc = KeyCard(MagicMock()) - kc._card_public_key = b'pub' - kc.open_secure_channel(1, b'pairing_key') - mock_osc.assert_called_once_with(kc, 1, b'pairing_key') - mock_ma.assert_called_once_with(kc) - assert kc.session == 'session' - - -def test_open_secure_channel_without_mutual_authentication(): - with patch( - 'keycard.keycard.commands.open_secure_channel' - ) as mock_osc: - with patch( - 'keycard.keycard.commands.mutually_authenticate' - ) as mock_ma: - mock_osc.return_value = 'session' - kc = KeyCard(MagicMock()) - kc._card_public_key = b'pub' - kc.open_secure_channel(1, b'pairing_key', False) - mock_osc.assert_called_once_with(kc, 1, b'pairing_key') - mock_ma.assert_not_called() - assert kc.session == 'session' - - -def test_mutually_authenticate_calls_command(): - with patch('keycard.keycard.commands.mutually_authenticate') as mock_auth: - kc = KeyCard(MagicMock()) - kc.secure_session = 'sess' - kc.mutually_authenticate() - mock_auth.assert_called_once() - - -def test_pair_returns_expected_tuple(): - with patch('keycard.keycard.commands.pair', return_value=(1, b'crypt')): - kc = KeyCard(MagicMock()) - result = kc.pair(b'shared') - assert result == (1, b'crypt') - - -def test_verify_pin_delegates_call_and_returns_result(): - with patch( - 'keycard.keycard.commands.verify_pin', - return_value=True - ) as mock_cmd: - kc = KeyCard(MagicMock()) - kc.secure_session = 'sess' - result = kc.verify_pin('1234') - mock_cmd.assert_called_once_with(kc, b'1234') - assert result is True - - -def test_unpair_delegates_call(): - transport = MagicMock() - with patch('keycard.keycard.commands.unpair') as mock_unpair: - kc = KeyCard(transport) - kc.secure_session = 'sess' - kc.unpair(2) - mock_unpair.assert_called_once_with(kc, 2) - - -def test_send_secure_apdu_success(): - mock_session = MagicMock() - mock_session.wrap_apdu.return_value = b'encrypted' - mock_session.unwrap_response.return_value = (b'plaintext', 0x9000) - mock_transport = MagicMock() - mock_response = MagicMock() - mock_response.status_word = 0x9000 - mock_response.data = b'ciphertext' - mock_transport.send_apdu.return_value = mock_response - - kc = KeyCard(mock_transport) - kc.session = mock_session - - result = kc.send_secure_apdu(0xA4, 0x01, 0x02, b'data') - - mock_session.wrap_apdu.assert_called_once_with( - cla=kc.transport.send_apdu.call_args[0][0][0], - ins=0xA4, - p1=0x01, - p2=0x02, - data=b'data' - ) - mock_transport.send_apdu.assert_called_once() - mock_session.unwrap_response.assert_called_once_with(mock_response) - assert result == b'plaintext' - - -def test_send_secure_apdu_raises_on_transport_status_word(): - mock_session = MagicMock() - mock_session.wrap_apdu.return_value = b'encrypted' - mock_transport = MagicMock() - mock_transport.send_apdu.return_value = APDUResponse( - b'', status_word=0x6A82) - - kc = KeyCard(mock_transport) - kc.session = mock_session - - with pytest.raises(APDUError) as exc: - kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data') - assert exc.value.args[0] == 'APDU failed with SW=6A82' - - -def test_send_secure_apdu_raises_on_unwrap_status_word(): - mock_session = MagicMock() - mock_session.wrap_apdu.return_value = b'encrypted' - mock_session.unwrap_response.return_value = (b'plaintext', 0x6A84) - mock_transport = MagicMock() - mock_transport.send_apdu.return_value = APDUResponse( - b'', status_word=0x9000) - - kc = KeyCard(mock_transport) - kc.session = mock_session - - with pytest.raises(APDUError) as exc: - kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data') - assert exc.value.args[0] == 'APDU failed with SW=6A84' - - -def test_send_apdu_success(monkeypatch): - mock_transport = MagicMock() - mock_response = MagicMock() - mock_response.status_word = 0x9000 - mock_response.data = b'response' - mock_transport.send_apdu.return_value = mock_response - - kc = KeyCard(mock_transport) - - result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data') - expected_apdu = bytes([0x80, 0xA4, 0x01, 0x02, 4]) + b'data' - mock_transport.send_apdu.assert_called_once_with(expected_apdu) - assert result == b'response' - - -def test_send_apdu_raises_on_non_success_status(monkeypatch): - mock_transport = MagicMock() - mock_transport.send_apdu.return_value = APDUResponse(b'', 0x6A82) - - kc = KeyCard(mock_transport) - - with pytest.raises(APDUError) as exc: - kc.send_apdu(ins=0xA4, p1=0x00, p2=0x00, data=b'') - assert exc.value.args[0] == 'APDU failed with SW=6A82' - - -def test_send_apdu_with_custom_cla(monkeypatch): - mock_transport = MagicMock() - mock_response = MagicMock() - mock_response.status_word = 0x9000 - mock_response.data = b'abc' - mock_transport.send_apdu.return_value = mock_response - - kc = KeyCard(mock_transport) - - result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data', cla=0x90) - expected_apdu = bytes([0x90, 0xA4, 0x01, 0x02, 4]) + b'data' - mock_transport.send_apdu.assert_called_once_with(expected_apdu) - assert result == b'abc' - - -def test_unblock_pin_calls_command_with_bytes(): - with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: - kc = KeyCard(MagicMock()) - puk = b'123456789012' - new_pin = b'654321' - kc.unblock_pin(puk, new_pin) - mock_unblock.assert_called_once_with(kc, puk + new_pin) - - -def test_unblock_pin_calls_command_with_str(): - with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: - kc = KeyCard(MagicMock()) - puk = '123456789012' - new_pin = '654321' - kc.unblock_pin(puk, new_pin) - mock_unblock.assert_called_once_with( - kc, - (puk + new_pin).encode('utf-8') - ) - - -def test_unblock_pin_calls_command_with_mixed_types(): - with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: - kc = KeyCard(MagicMock()) - puk = '123456789012' - new_pin = b'654321' - kc.unblock_pin(puk, new_pin) - mock_unblock.assert_called_once_with(kc, puk.encode('utf-8') + new_pin) - - -def test_remove_key_calls_command(): - with patch('keycard.keycard.commands.remove_key') as mock_remove_key: - kc = KeyCard(MagicMock()) - kc.remove_key() - mock_remove_key.assert_called_once_with(kc) - - -def test_store_data_calls_command_with_default_slot(): - with patch('keycard.keycard.commands.store_data') as mock_store_data: - kc = KeyCard(MagicMock()) - data = b'testdata' - kc.store_data(data) - mock_store_data.assert_called_once_with( - kc, data, constants.StorageSlot.PUBLIC - ) - - -def test_store_data_calls_command_with_custom_slot(): - with patch('keycard.keycard.commands.store_data') as mock_store_data: - kc = KeyCard(MagicMock()) - data = b'testdata' - slot = MagicMock() - kc.store_data(data, slot) - mock_store_data.assert_called_once_with(kc, data, slot) - - -def test_store_data_raises_value_error_on_invalid_slot(): - with patch( - 'keycard.keycard.commands.store_data', - side_effect=ValueError("Invalid slot") - ): - kc = KeyCard(MagicMock()) - with pytest.raises(ValueError, match="Invalid slot"): - kc.store_data(b'testdata', slot="INVALID") - - -def test_store_data_raises_value_error_on_data_too_long(): - with patch( - 'keycard.keycard.commands.store_data', - side_effect=ValueError("data is too long") - ): - kc = KeyCard(MagicMock()) - long_data = b'a' * 128 - with pytest.raises(ValueError, match="data is too long"): - kc.store_data(long_data) - - -def test_get_data_calls_command_with_default_slot(): - with patch( - 'keycard.keycard.commands.get_data', - return_value=b'data' - ) as mock_get_data: - kc = KeyCard(MagicMock()) - result = kc.get_data() - mock_get_data.assert_called_once_with(kc, constants.StorageSlot.PUBLIC) - assert result == b'data' - - -def test_get_data_calls_command_with_custom_slot(): - with patch( - 'keycard.keycard.commands.get_data', - return_value=b'data' - ) as mock_get_data: - kc = KeyCard(MagicMock()) - slot = MagicMock() - result = kc.get_data(slot) - mock_get_data.assert_called_once_with(kc, slot) - assert result == b'data' - - -def test_export_key_delegates_and_returns_result(): - mock_exported = MagicMock(spec=ExportedKey) - with patch( - 'keycard.keycard.commands.export_key', - return_value=mock_exported - ) as mock_cmd: - kc = KeyCard(MagicMock()) - result = kc.export_key( - derivation_option=constants.DerivationOption.DERIVE, - public_only=True, - keypath="m/44'/60'/0'/0/0", - make_current=True, - source=constants.DerivationSource.PARENT - ) - - mock_cmd.assert_called_once_with( - kc, - derivation_option=constants.DerivationOption.DERIVE, - public_only=True, - keypath="m/44'/60'/0'/0/0", - make_current=True, - source=constants.DerivationSource.PARENT - ) - assert result is mock_exported - - -def test_export_current_key_delegates_and_returns_result(): - mock_exported = MagicMock(spec=ExportedKey) - with patch( - 'keycard.keycard.commands.export_key', - return_value=mock_exported - ) as mock_cmd: - kc = KeyCard(MagicMock()) - result = kc.export_current_key(public_only=False) - - mock_cmd.assert_called_once_with( - kc, - derivation_option=constants.DerivationOption.CURRENT, - public_only=False, - keypath=None, - make_current=False, - source=constants.DerivationSource.MASTER - ) - assert result is mock_exported - - -def test_sign_current_key(): - with patch("keycard.keycard.commands.sign") as mock_sign: - card = KeyCard(MagicMock()) - digest = b"\xAA" * 32 - mock_sign.return_value = "signed" - - result = card.sign(digest) - - mock_sign.assert_called_once_with( - card, - digest, - constants.DerivationOption.CURRENT, - constants.SigningAlgorithm.ECDSA_SECP256K1 - ) - assert result == "signed" - - -def test_sign_with_path(): - with patch("keycard.keycard.commands.sign") as mock_sign: - card = KeyCard(MagicMock()) - digest = b"\xBB" * 32 - path = [0x8000002C, 0x8000003C, 0, 0, 0] # m/44'/60'/0'/0/0 - mock_sign.return_value = "sig" - - result = card.sign_with_path(digest, path) - - mock_sign.assert_called_once_with( - card, - digest, - constants.DerivationOption.DERIVE, - constants.SigningAlgorithm.ECDSA_SECP256K1, - derivation_path=path - ) - assert result == "sig" - - -def test_sign_with_path_make_current(): - with patch("keycard.keycard.commands.sign") as mock_sign: - card = KeyCard(MagicMock()) - digest = b"\xCC" * 32 - path = [0x8000002C, 0x8000003C, 0, 0, 0] - mock_sign.return_value = "sig" - - result = card.sign_with_path(digest, path, make_current=True) - - mock_sign.assert_called_once_with( - card, - digest, - constants.DerivationOption.DERIVE_AND_MAKE_CURRENT, - constants.SigningAlgorithm.ECDSA_SECP256K1, - derivation_path=path - ) - assert result == "sig" - - -def test_sign_pinless(): - with patch("keycard.keycard.commands.sign") as mock_sign: - card = KeyCard(MagicMock()) - digest = b"\xDD" * 32 - mock_sign.return_value = "sig" - - result = card.sign_pinless(digest) - - mock_sign.assert_called_once_with( - card, - digest, - constants.DerivationOption.PINLESS, - constants.SigningAlgorithm.ECDSA_SECP256K1 - ) - assert result == "sig" - - -def test_load_key_bip39_seed(): - with patch("keycard.keycard.commands.load_key") as mock_load_key: - card = KeyCard(MagicMock()) - seed = b"\xAB" * 64 - mock_load_key.return_value = b"uid" - - result = card.load_key( - key_type=constants.LoadKeyType.BIP39_SEED, - bip39_seed=seed - ) - - mock_load_key.assert_called_once_with( - card, - key_type=constants.LoadKeyType.BIP39_SEED, - public_key=None, - private_key=None, - chain_code=None, - bip39_seed=seed, - lee_seed=None - ) - assert result == b"uid" - - -def test_load_key_ecc_pair(): - with patch("keycard.keycard.commands.load_key") as mock_load_key: - card = KeyCard(MagicMock()) - pub = b"\x04" + b"\x01" * 64 - priv = b"\x02" * 32 - mock_load_key.return_value = b"uid" - - result = card.load_key( - key_type=constants.LoadKeyType.ECC, - public_key=pub, - private_key=priv - ) - - mock_load_key.assert_called_once_with( - card, - key_type=constants.LoadKeyType.ECC, - public_key=pub, - private_key=priv, - chain_code=None, - bip39_seed=None, - lee_seed=None - ) - assert result == b"uid" - - -def test_load_key_extended(): - with patch("keycard.keycard.commands.load_key") as mock_load_key: - card = KeyCard(MagicMock()) - pub = b"\x04" + b"\x01" * 64 - priv = b"\x02" * 32 - chain = b"\x00" * 32 - mock_load_key.return_value = b"uid" - - result = card.load_key( - key_type=constants.LoadKeyType.EXTENDED_ECC, - public_key=pub, - private_key=priv, - chain_code=chain - ) - - mock_load_key.assert_called_once_with( - card, - key_type=constants.LoadKeyType.EXTENDED_ECC, - public_key=pub, - private_key=priv, - chain_code=chain, - bip39_seed=None, - lee_seed=None - ) - assert result == b"uid" - - -def test_keycard_set_pinless_path(): - with patch("keycard.keycard.commands.set_pinless_path") as mock_cmd: - card = KeyCard(MagicMock()) - card.set_pinless_path("m/44'/60'/0'/0/0") - - mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0") - - -def test_keycard_generate_mnemonic(): - with patch("keycard.keycard.commands.generate_mnemonic") as mock_cmd: - card = KeyCard(None) - mock_cmd.return_value = [0, 2047, 1337, 42] - - result = card.generate_mnemonic(checksum_size=6) - - mock_cmd.assert_called_once_with(card, 6) - assert result == [0, 2047, 1337, 42] - - -def test_keycard_derive_key(): - with patch("keycard.keycard.commands.derive_key") as mock_cmd: - card = KeyCard(MagicMock()) - card.derive_key("m/44'/60'/0'/0/0") - - mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0") diff --git a/python/keycard-py/tests/test_preconditions.py b/python/keycard-py/tests/test_preconditions.py deleted file mode 100644 index 1c46bd15..00000000 --- a/python/keycard-py/tests/test_preconditions.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from keycard.card_interface import CardInterface -from keycard.preconditions import make_precondition -from keycard.exceptions import InvalidStateError - - -class DummyCard(CardInterface): - def __init__(self, **attrs): - for k, v in attrs.items(): - setattr(self, k, v) - - -def test_precondition_passes_when_attribute_true(): - @make_precondition('is_ready') - def do_something(card): - return "success" - card = DummyCard(is_ready=True) - assert do_something(card) == "success" - - -def test_precondition_raises_when_attribute_false(): - @make_precondition('is_ready') - def do_something(card): - return "should not reach" - card = DummyCard(is_ready=False) - with pytest.raises(InvalidStateError) as exc: - do_something(card) - assert "Is Ready must be satisfied." in str(exc.value) - - -def test_precondition_raises_when_attribute_missing(): - @make_precondition('is_ready') - def do_something(card): - return "should not reach" - card = DummyCard() - with pytest.raises(InvalidStateError) as exc: - do_something(card) - assert "Is Ready must be satisfied." in str(exc.value) - - -def test_precondition_custom_display_name(): - @make_precondition('is_ready', display_name="Custom Name") - def do_something(card): - return "success" - card = DummyCard(is_ready=False) - with pytest.raises(InvalidStateError) as exc: - do_something(card) - assert "Custom Name must be satisfied." in str(exc.value) - - -def test_precondition_passes_args_kwargs(): - @make_precondition('is_ready') - def do_something(card, x, y=2): - return x + y - card = DummyCard(is_ready=True) - assert do_something(card, 3, y=4) == 7 diff --git a/python/keycard-py/tests/test_secure_channel.py b/python/keycard-py/tests/test_secure_channel.py deleted file mode 100644 index 2622f9e8..00000000 --- a/python/keycard-py/tests/test_secure_channel.py +++ /dev/null @@ -1,132 +0,0 @@ -import pytest - -from keycard.apdu import APDUResponse -from keycard.secure_channel import SecureChannel - - -@pytest.fixture -def session_params(): - return { - "shared_secret": bytes(32), - "pairing_key": bytes(32), - "salt": bytes(16), - "seed_iv": bytes(16), - } - - -def test_open_sets_authenticated_and_keys(session_params): - session = SecureChannel.open(**session_params) - assert session.authenticated is True - assert isinstance(session.enc_key, bytes) and len(session.enc_key) == 32 - assert isinstance(session.mac_key, bytes) and len(session.mac_key) == 32 - assert session.iv == session_params['seed_iv'] - - -def test_wrap_apdu_authenticated(session_params): - session = SecureChannel.open(**session_params) - wrapped = session.wrap_apdu( - 0x80, - 0xCA, - 0x00, - 0x00, - b'testdata' - ) - assert isinstance(wrapped, bytes) - assert len(wrapped) > 16 # IV + encrypted data - - -@pytest.mark.parametrize("ins,should_raise", [ - (0x11, False), - (0xCA, True), -]) -def test_wrap_apdu_auth_check(ins, should_raise): - session = SecureChannel( - b'\x01' * 32, - b'\x02' * 32, - bytes(16), - authenticated=False - ) - if should_raise: - with pytest.raises(ValueError, match="not authenticated"): - session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test') - else: - session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test') - - -def test_unwrap_response_authenticated_and_mac(monkeypatch, session_params): - # Patch aes_cbc_encrypt and aes_cbc_decrypt to simulate expected behavior - session = SecureChannel.open(**session_params) - plaintext = b"hello world" + b'\x90\x00' # status word 0x9000 - - # Simulate encryption and MAC - - def fake_decrypt(key, iv, data): - return plaintext - - def fake_encrypt(key, iv, data, padding=True): - # Return 16 bytes MAC for mac_key, else just return dummy - if key == session.mac_key: - return b'Y' * 16 - return b'Z' * (len(data) // 16 * 16) - - monkeypatch.setattr('keycard.secure_channel.aes_cbc_decrypt', fake_decrypt) - monkeypatch.setattr('keycard.secure_channel.aes_cbc_encrypt', fake_encrypt) - - # Compose response: 16 bytes MAC + encrypted data - response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900) - out, sw = session.unwrap_response(response) - assert out == plaintext[:-2] - assert sw == 0x9000 - - -def test_unwrap_response_not_authenticated_raises(session_params): - session = SecureChannel.open(**session_params) - session.authenticated = False - response = APDUResponse(bytes(32), 0x900) - with pytest.raises(ValueError, match="not authenticated"): - session.unwrap_response(response) - - -def test_unwrap_response_invalid_length_raises(session_params): - session = SecureChannel.open(**session_params) - session.authenticated = True - response = APDUResponse(bytes(10), 0x900) - with pytest.raises(ValueError, match="Invalid secure response length"): - session.unwrap_response(response) - - -def test_unwrap_response_invalid_mac_raises(monkeypatch, session_params): - session = SecureChannel.open(**session_params) - # Patch aes_cbc_encrypt to return a different MAC - - def fake_encrypt(key, iv, data, padding=True): - return b'X' * 16 - - monkeypatch.setattr( - 'keycard.secure_channel.aes_cbc_encrypt', - fake_encrypt - ) - - response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900) - - with pytest.raises(ValueError, match="Invalid MAC"): - session.unwrap_response(response) - - -def test_unwrap_response_missing_status_word(monkeypatch, session_params): - session = SecureChannel.open(**session_params) - - def fake_decrypt(key, iv, data): - return b'\x01' - - monkeypatch.setattr( - 'keycard.secure_channel.aes_cbc_decrypt', - fake_decrypt) - monkeypatch.setattr( - 'keycard.secure_channel.aes_cbc_encrypt', - lambda *a, **k: b'Y' * 16) - - response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x9000) - - with pytest.raises(ValueError, match="Missing status word"): - session.unwrap_response(response) diff --git a/python/keycard-py/tests/test_transport.py b/python/keycard-py/tests/test_transport.py deleted file mode 100644 index f3ecf6fc..00000000 --- a/python/keycard-py/tests/test_transport.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -from unittest.mock import MagicMock, patch -from keycard.transport import Transport -from keycard.apdu import APDUResponse -from keycard.exceptions import TransportError - - -@patch("keycard.transport.readers") -def test_transport_connect_success(mock_readers): - mock_connection = MagicMock() - mock_reader = MagicMock() - mock_reader.createConnection.return_value = mock_connection - mock_readers.return_value = [mock_reader] - - transport = Transport() - transport.connect() - - mock_readers.assert_called_once() - mock_connection.connect.assert_called_once() - assert transport.connection == mock_connection - - -@patch("keycard.transport.readers", return_value=[]) -def test_transport_connect_no_reader(mock_readers): - transport = Transport() - with pytest.raises(TransportError, match="No smart card readers found"): - transport.connect() - - -@patch("keycard.transport.readers") -def test_send_apdu_success(mock_readers): - mock_connection = MagicMock() - mock_connection.transmit.return_value = ([1, 2, 3], 0x90, 0x00) - - mock_reader = MagicMock() - mock_reader.createConnection.return_value = mock_connection - mock_readers.return_value = [mock_reader] - - transport = Transport() - transport.connection = mock_connection - - apdu = b"\x00\xA4\x04\x00" - response = transport.send_apdu(apdu) - - mock_connection.transmit.assert_called_once_with(list(apdu)) - assert isinstance(response, APDUResponse) - assert response.data == [1, 2, 3] - assert response.status_word == 0x9000 - - -@patch("keycard.transport.readers") -def test_send_apdu_auto_connect(mock_readers): - mock_connection = MagicMock() - mock_connection.transmit.return_value = ([0x90], 0x90, 0x00) - - mock_reader = MagicMock() - mock_reader.createConnection.return_value = mock_connection - mock_readers.return_value = [mock_reader] - - transport = Transport() - response = transport.send_apdu(b"\x00") - - assert isinstance(response, APDUResponse) - assert response.status_word == 0x9000 - assert mock_connection.connect.called - - -@patch("keycard.transport.readers") -def test_transport_context_manager(mock_readers): - mock_connection = MagicMock() - mock_reader = MagicMock() - mock_reader.createConnection.return_value = mock_connection - mock_readers.return_value = [mock_reader] - - with Transport() as transport: - assert transport.connection == mock_connection - - mock_connection.disconnect.assert_called_once() - assert transport.connection is None - - -def test_exit_without_connection(): - transport = Transport() - transport.connection = None - - transport.__exit__(None, None, None) - assert transport.connection is None diff --git a/python/keycard-py/tests/test_vectors.py b/python/keycard-py/tests/test_vectors.py deleted file mode 100644 index de390d0a..00000000 --- a/python/keycard-py/tests/test_vectors.py +++ /dev/null @@ -1,50 +0,0 @@ -from ecdsa import ECDH, SigningKey, SECP256k1, VerifyingKey -from keycard.crypto.aes import aes_cbc_encrypt - - -def test_full_crypto_vector(): - card_pubkey_bytes = bytes.fromhex( - '04525481c70263f79c29092e95cfc972e0eb427ea31fe6cc6c96787eb12205737' - 'd431929f0837c66a4ee514578a7d5eb78087927851b15b691a79cdea431bd63d9' - ) - ephemeral_private_bytes = bytes.fromhex( - 'e3b9a83efa7b113bac4562a77c496de21a9f91a17fa8dcb2384ed7154bb43c5c' - ) - iv = bytes.fromhex('d2c5feedf4bdb935057f8c78cf92395e') - expected_ciphertext = bytes.fromhex( - '4707ca7edf4218c416f252967da55f1b6e2e65f0ffa0305f71501f53aa283fd5' - 'aaa8b049e75288c01034f25893db43d4db4bd6dfc4a6546658dd22227082aa58' - ) - - ephemeral_key = SigningKey.from_string( - ephemeral_private_bytes, - curve=SECP256k1 - ) - card_pubkey = VerifyingKey.from_string( - card_pubkey_bytes, - curve=SECP256k1 - ) - - ecdh = ECDH( - curve=SECP256k1, - private_key=ephemeral_key, - public_key=card_pubkey - ) - shared_secret = ecdh.generate_sharedsecret_bytes() - - pin = b'123456' - puk = b'123456789012' - pairing_secret = b'A' * 32 - plaintext = pin + puk + pairing_secret - - ciphertext: bytes = aes_cbc_encrypt(shared_secret, iv, plaintext) - - assert ciphertext == expected_ciphertext, ( - "Ciphertext does not match expected test vector" - ) - - -def test_crypto_vector_fails_on_mismatch(): - bogus = b"\x00" * 48 - actual = b"\x01" * 48 - assert bogus != actual, "Test vector should intentionally fail mismatch" diff --git a/python/keycard-py/tox.ini b/python/keycard-py/tox.ini deleted file mode 100644 index e2a96ef0..00000000 --- a/python/keycard-py/tox.ini +++ /dev/null @@ -1,48 +0,0 @@ -[tox] -envlist = py310,py311,py312,py313,py314 -isolated_build = True -skip_missing_interpreters = True - -[testenv] -description = Run tests with pytest -basepython = - py310: python3.10 - py311: python3.11 - py312: python3.12 - py313: python3.13 - py314: python3.14 -deps = - pytest - pytest-cov - coverage - mnemonic -extras = dev -commands = - pytest --maxfail=1 --disable-warnings {posargs} - -[testenv:lint] -description = Run linting checks -deps = - flake8 - mypy -commands = - flake8 keycard tests - mypy keycard - -[testenv:coverage] -description = Run tests with coverage report -deps = - pytest - pytest-cov - coverage - mnemonic -commands = - pytest --cov=keycard --cov-report=term-missing --cov-report=xml - -[gh-actions] -python = - 3.10: py310 - 3.11: py311 - 3.12: py312 - 3.13: py313 - 3.14: py314