mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-05-14 12:09:35 +00:00
remove keycard-py
This commit is contained in:
parent
dd78314ca0
commit
30b96b1aaf
194
python/keycard-py/.gitignore
vendored
194
python/keycard-py/.gitignore
vendored
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
[](LICENSE) [](https://www.python.org/downloads/) [](https://codecov.io/gh/mmlado/keycard-py) [](https://pypi.org/project/keycard/) [](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml) [](https://mmlado.github.io/keycard-py/)   
|
|
||||||
|
|
||||||
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.
|
|
||||||
@ -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']
|
|
||||||
@ -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 <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_
|
|
||||||
documentation for details.
|
|
||||||
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 2
|
|
||||||
:caption: Contents:
|
|
||||||
|
|
||||||
modules
|
|
||||||
|
|
||||||
.. automodule:: keycard.keycard
|
|
||||||
:members:
|
|
||||||
@ -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:
|
|
||||||
@ -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:
|
|
||||||
@ -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:
|
|
||||||
@ -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:
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
keycard
|
|
||||||
=======
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
keycard
|
|
||||||
@ -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}.')
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
"""KeyCard Python SDK - APDU communication and cryptographic utilities."""
|
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
|
||||||
__doc__ = "Python SDK for interacting with Status Keycard."
|
|
||||||
@ -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
|
|
||||||
@ -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: ...
|
|
||||||
@ -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',
|
|
||||||
]
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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],
|
|
||||||
)
|
|
||||||
@ -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],
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
]
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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')
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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")
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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')]
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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})"
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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")
|
|
||||||
@ -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
|
|
||||||
@ -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'
|
|
||||||
)
|
|
||||||
@ -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")
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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/"
|
|
||||||
@ -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.")
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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")
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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""
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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')
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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'
|
|
||||||
@ -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]
|
|
||||||
@ -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")
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
Loading…
x
Reference in New Issue
Block a user