631 lines
19 KiB
Python
Raw Normal View History

2026-04-26 20:27:22 -04:00
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