Merge branch 'marvin/keycard-commands' into marvin/keycard-privacy-commands

This commit is contained in:
jonesmarvin8 2026-04-28 18:11:47 -04:00
commit d031b10e45
112 changed files with 266 additions and 6547 deletions

18
Cargo.lock generated
View File

@ -629,9 +629,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "astral-tokio-tar"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9"
checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693"
dependencies = [
"filetime",
"futures-core",
@ -1959,7 +1959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@ -2108,7 +2108,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2409,7 +2409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -5415,7 +5415,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7164,7 +7164,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -8103,7 +8103,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -9394,7 +9394,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]

View File

@ -1174,7 +1174,8 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
// This command breaks (Marvin)
println!("TEST5");
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
@ -1182,6 +1183,7 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
println!("TEST6");
// Verify commitment exists
let recipient_commitment = ctx
.wallet()

View File

@ -9,6 +9,8 @@ use sha2::{Digest as _, Sha256};
use crate::{AccountId, error::NssaError};
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00";
pub type ViewTag = u8;
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
@ -121,8 +123,6 @@ impl Message {
#[must_use]
pub fn hash_message(&self) -> [u8; 32] {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00";
let mut bytes = Vec::with_capacity(
PREFIX
.len()
@ -140,16 +140,13 @@ impl Message {
pub mod tests {
use nssa_core::{
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, SharedSecretKey,
account::Account,
account::{Account, AccountId, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{BlockValidityWindow, TimestampValidityWindow},
};
use sha2::{Digest as _, Sha256};
use crate::{
AccountId,
privacy_preserving_transaction::message::{EncryptedAccountData, Message},
};
use super::{EncryptedAccountData, Message, PREFIX};
#[must_use]
pub fn message_for_tests() -> Message {
@ -190,6 +187,58 @@ pub mod tests {
}
}
#[test]
fn hash_message_privacy_pinned() {
let msg = Message {
public_account_ids: vec![AccountId::new([42_u8; 32])],
nonces: vec![Nonce(5)],
public_post_states: vec![],
encrypted_private_post_states: vec![],
new_commitments: vec![],
new_nullifiers: vec![],
block_validity_window: BlockValidityWindow::new_unbounded(),
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
};
let public_account_ids_bytes: &[u8] = &[42_u8; 32];
let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
// all remaining vec fields are empty: u32 len=0
let empty_vec_bytes: &[u8] = &[0_u8; 4];
// validity windows: unbounded = {from: None (0u8), to: None (0u8)}
let unbounded_window_bytes: &[u8] = &[0_u8; 2];
let expected_borsh_vec: Vec<u8> = [
&[1_u8, 0, 0, 0], // public_account_ids
public_account_ids_bytes,
nonces_bytes,
empty_vec_bytes, // public_post_state
empty_vec_bytes, // encrypted_private_post_states
empty_vec_bytes, // new_commitments
empty_vec_bytes, // new_nullifiers
unbounded_window_bytes, // block_validity_window
unbounded_window_bytes, // timestamp_validity_window
]
.concat();
let expected_borsh: &[u8] = &expected_borsh_vec;
assert_eq!(
borsh::to_vec(&msg).unwrap(),
expected_borsh,
"`privacy_preserving_transaction::hash_message()`: expected borsh order has changed"
);
let mut preimage = Vec::with_capacity(PREFIX.len() + expected_borsh.len());
preimage.extend_from_slice(PREFIX);
preimage.extend_from_slice(expected_borsh);
let expected_hash: [u8; 32] = Sha256::digest(&preimage).into();
assert_eq!(
msg.hash_message(),
expected_hash,
"`privacy_preserving_transaction::hash_message()`: serialization has changed"
);
}
#[test]
fn encrypted_account_data_constructor() {
let npk = NullifierPublicKey::from(&[1; 32]);

View File

@ -8,6 +8,8 @@ use sha2::{Digest as _, Sha256};
use crate::{AccountId, error::NssaError, program::Program};
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00";
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub program_id: ProgramId,
@ -67,8 +69,6 @@ impl Message {
#[must_use]
pub fn hash_message(&self) -> [u8; 32] {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00";
let mut bytes = Vec::with_capacity(
PREFIX
.len()
@ -81,3 +81,59 @@ impl Message {
Sha256::digest(bytes).into()
}
}
#[cfg(test)]
mod tests {
use nssa_core::account::{AccountId, Nonce};
use sha2::{Digest as _, Sha256};
use super::{Message, PREFIX};
#[test]
fn hash_message_public_pinned() {
let msg = Message::new_preserialized(
[1_u32; 8],
vec![AccountId::new([42_u8; 32])],
vec![Nonce(5)],
vec![],
);
// program_id: [1_u32; 8], each word as LE u32
let program_id_bytes: &[u8] = &[
1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,
0, 0, 0,
];
// account_ids: AccountId([42_u8; 32])
let account_ids_bytes: &[u8] = &[42_u8; 32];
// nonces: u32 len=1, then Nonce(5) as LE u128
let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let instruction_data_bytes: &[u8] = &[0_u8; 4];
let expected_borsh_vec: Vec<u8> = [
program_id_bytes,
&[1_u8, 0, 0, 0], // account_ids len=1
account_ids_bytes,
nonces_bytes,
instruction_data_bytes,
]
.concat();
let expected_borsh: &[u8] = &expected_borsh_vec;
assert_eq!(
borsh::to_vec(&msg).unwrap(),
expected_borsh,
"`public_transaction::hash_message()`: expected borsh order has changed"
);
let mut preimage = Vec::with_capacity(PREFIX.len() + expected_borsh.len());
preimage.extend_from_slice(PREFIX);
preimage.extend_from_slice(expected_borsh);
let expected_hash: [u8; 32] = Sha256::digest(&preimage).into();
assert_eq!(
msg.hash_message(),
expected_hash,
"`public_transaction::hash_message()`: serialization has changed"
);
}
}

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -1,27 +0,0 @@
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![codecov](https://codecov.io/gh/mmlado/keycard-py/branch/main/graph/badge.svg)](https://codecov.io/gh/mmlado/keycard-py) [![PyPI version](https://img.shields.io/pypi/v/keycard.svg)](https://pypi.org/project/keycard/) [![Build status](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml/badge.svg)](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml) [![Documentation](https://img.shields.io/badge/docs-gh--pages-blue.svg)](https://mmlado.github.io/keycard-py/) ![stars](https://img.shields.io/github/stars/mmlado/keycard-py.svg?style=social) ![lastcommit](https://img.shields.io/github/last-commit/mmlado/keycard-py.svg) ![numcontributors](https://img.shields.io/github/contributors-anon/mmlado/keycard-py.svg)
A minimal, clean, fully native Python SDK for communicating with [Keycard](https://keycard.tech) smart cards.
## Requirements
- Python 3.10 or higher
- The library is tested on Python 3.10, 3.11, 3.12, and 3.13
## Installation
```bash
git clone https://github.com/mmlado/keycard-py.git
cd keycard-py
python -m venv venv
source venv/bin/activate
pip install -e .
pytest
```
## License
MIT
## Contributions
Contributions are welcome as this SDK grows.

View File

@ -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']

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -1,7 +0,0 @@
keycard
=======
.. toctree::
:maxdepth: 4
keycard

View File

@ -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}.')

View File

@ -1,4 +0,0 @@
"""KeyCard Python SDK - APDU communication and cryptographic utilities."""
__version__ = "0.3.0"
__doc__ = "Python SDK for interacting with Status Keycard."

View File

@ -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

View File

@ -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: ...

View File

@ -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',
]

View File

@ -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
)

View File

@ -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
)

View File

@ -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],
)

View File

@ -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],
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)
]

View File

@ -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
)

View File

@ -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
}

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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')

View File

@ -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,
)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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")

View File

@ -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
)

View File

@ -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
)

View File

@ -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 (015) 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,
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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')]

View File

@ -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)

View File

@ -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 cards 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

View File

@ -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})"
)

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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'
)

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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/"

View File

@ -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.")

View File

@ -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)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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()

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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""
)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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'

View File

@ -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]

View File

@ -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")

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More