from types import TracebackType from typing import Optional, Union from . import constants from . import commands from .apdu import APDUResponse from .constants import DerivationOption, PairingMode from .card_interface import CardInterface from .exceptions import APDUError from .parsing.application_info import ApplicationInfo from .parsing.exported_key import ExportedKey from .parsing.signature_result import SignatureResult from .transport import Transport from .secure_channel import SecureChannel class KeyCard(CardInterface): ''' High-level interface for interacting with a Keycard device. This class provides convenient methods to manage pairing, secure channels, and card operations. ''' def __init__(self, transport: Optional[Transport] = None): ''' Initializes the KeyCard interface. Args: transport (Transport): Instance used for APDU communication. Raises: ValueError: If transport is None. ''' self.transport = transport if transport else Transport() self.card_public_key: Optional[bytes] = None self.session: Optional[SecureChannel] = None self._is_pin_verified: bool = False def __enter__(self) -> 'KeyCard': self.transport.connect() return self def __exit__( self, type_: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None ) -> None: if self.transport: self.transport.disconnect() @property def is_selected(self) -> bool: ''' Checks if a card is selected and has a public key. Returns: bool: True if a card is selected, False otherwise. ''' return self.card_public_key is not None @property def is_session_open(self) -> bool: ''' Checks if a secure session is currently open. Returns: bool: True if a secure session is established, False otherwise. ''' return self.session is not None @property def is_secure_channel_open(self) -> bool: ''' Checks if a secure channel is currently open. Returns: bool: True if a secure channel is established, False otherwise. ''' return self.session is not None and self.session.authenticated @property def is_initialized(self) -> bool: ''' Checks if the Keycard is initialized. Returns: bool: True if the Keycard is initialized, False otherwise. ''' return self._is_initialized @property def is_pin_verified(self) -> bool: ''' Checks if the user PIN has been verified. Returns: bool: True if the PIN is verified, False otherwise. ''' return self._is_pin_verified def select(self) -> 'ApplicationInfo': ''' Selects the Keycard applet and retrieves application metadata. Returns: ApplicationInfo: Object containing ECC public key and card info. ''' info = commands.select(self) self.card_public_key = info.ecc_public_key self._is_initialized = info.is_initialized return info def init(self, pin: str, puk: str, pairing_secret: str) -> None: ''' Initializes the card with security credentials. Args: pin (bytes): The PIN code in bytes. puk (bytes): The PUK code in bytes. pairing_secret (bytes): The shared secret for pairing. ''' commands.init( self, pin, puk, pairing_secret, ) def ident(self, challenge: Optional[bytes] = None) -> bytes: ''' Sends an identity challenge to the card. Args: challenge (bytes): A challenge (nonce or data) to send to the card. If None, a random 32-byte challenge is generated. Returns: bytes: The public key extracted from the card's identity response. ''' return commands.ident(self, challenge) def open_secure_channel( self, pairing_index: int, pairing_key: bytes, mutually_authenticate: Optional[bool] = True ) -> None: ''' Opens a secure session with the card. Args: pairing_index (int): Index of the pairing slot to use. pairing_key (bytes): The shared pairing key (32 bytes). mutually_authenticate (bool): Execute mutually authenticate when a secure channel has been opened ''' self.session = commands.open_secure_channel( self, pairing_index, pairing_key, ) if mutually_authenticate: self.mutually_authenticate() def mutually_authenticate(self) -> None: ''' Performs mutual authentication between host and card. Raises: APDUError: If the authentication fails. ''' commands.mutually_authenticate(self) def pair( self, shared_secret: bytes, pairing_mode: Optional[PairingMode] = PairingMode.ANY ) -> tuple[int, bytes]: ''' Pairs with the card using an ECDH-derived shared secret. Args: shared_secret (bytes): 32-byte ECDH shared secret. Returns: tuple[int, bytes]: The pairing index and client cryptogram. ''' return commands.pair(self, shared_secret, pairing_mode) def verify_pin(self, pin: str) -> bool: ''' Verifies the user PIN with the card. Args: pin (str): The user-entered PIN. Returns: bool: True if PIN is valid, otherwise False. ''' result = commands.verify_pin(self, pin.encode('utf-8')) self._is_pin_verified = True return result @property def status(self) -> dict[str, int | bool] | list[int]: ''' Retrieves the application status using the secure session. Returns: dict: A dictionary with: - pin_retry_count (int) - puk_retry_count (int) - initialized (bool) Raises: RuntimeError: If the secure session is not open. ''' if self.session is None: raise RuntimeError('Secure session not established') return commands.get_status(self) @property def get_key_path(self) -> dict[str, int | bool] | list[int]: ''' Returns the current key derivation path from the card. Returns: list of int: List of 32-bit integers representing the key path. Raises: RuntimeError: If the secure session is not open. ''' if self.session is None: raise RuntimeError('Secure session not established') return commands.get_status(self, key_path=True) def unpair(self, index: int) -> None: ''' Removes a pairing slot from the card. Args: index (int): Index of the pairing slot to remove. ''' commands.unpair(self, index) def factory_reset(self) -> None: ''' Sends the FACTORY_RESET command to the card. Raises: APDUError: If the card returns a failure status word. ''' commands.factory_reset(self) def generate_key(self) -> bytes: ''' Generates a new key on the card and returns the key UID. Returns: bytes: Key UID (SHA-256 of the public key) Raises: APDUError: If the response status word is not 0x9000 ''' return commands.generate_key(self) def change_pin(self, new_value: str) -> None: ''' Changes the user PIN on the card. Args: new_value (str): The new PIN value to set. Raises: ValueError: If input format is invalid. APDUError: If the response status word is not 0x9000. ''' commands.change_secret(self, new_value, constants.PinType.USER) def change_puk(self, new_value: str) -> None: ''' Changes the PUK on the card. Args: new_value (str): The new PUK value to set. Raises: ValueError: If input format is invalid. APDUError: If the response status word is not 0x9000. ''' commands.change_secret(self, new_value, constants.PinType.PUK) def change_pairing_secret(self, new_value: str | bytes) -> None: ''' Changes the pairing secret on the card. Args: new_value (str): The new pairing secret value to set. Raises: ValueError: If input format is invalid. APDUError: If the response status word is not 0x9000. ''' commands.change_secret(self, new_value, constants.PinType.PAIRING) def unblock_pin(self, puk: str | bytes, new_pin: str | bytes) -> None: ''' Unblocks the user PIN using the provided PUK and sets a new PIN. Args: puk_and_pin (str | bytes): Concatenation of PUK (12 digits) + new PIN (6 digits) Raises: ValueError: If the format is invalid. APDUError: If the card returns an error. ''' if isinstance(puk, str): puk = puk.encode("utf-8") if isinstance(new_pin, str): new_pin = new_pin.encode("utf-8") commands.unblock_pin(self, puk + new_pin) def remove_key(self) -> None: ''' Removes the current key from the card. Raises: APDUError: If the response status word is not 0x9000. ''' commands.remove_key(self) def store_data( self, data: bytes, slot: constants.StorageSlot = constants.StorageSlot.PUBLIC ) -> None: """ Stores data on the card in the specified slot. Args: data (bytes): The data to store (max 127 bytes). slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH) Raises: ValueError: If slot is invalid or data is too long. """ commands.store_data(self, data, slot) def get_data( self, slot: constants.StorageSlot = constants.StorageSlot.PUBLIC ) -> bytes: """ Gets the data on the card previously stored with the store data command in the specified slot. Args: slot (StorageSlot): Where to retrieve the data (PUBLIC, NDEF, CASH) Raises: ValueError: If slot is invalid or data is too long. """ return commands.get_data(self, slot) def export_key( self, derivation_option: constants.DerivationOption, public_only: bool, keypath: Optional[Union[str, bytes, bytearray]] = None, make_current: bool = False, source: constants.DerivationSource = constants.DerivationSource.MASTER ) -> ExportedKey: """ Export a key from the card. This is a proxy for :func:`keycard.commands.export_key`, provided here for convenience. Args: derivation_option: One of the derivation options (CURRENT, DERIVE, DERIVE_AND_MAKE_CURRENT). public_only: If True, only the public key will be returned. keypath: BIP32-style string (e.g. "m/44'/60'/0'/0/0") or packed bytes. If derivation_option is CURRENT, this can be omitted. make_current: If True, updates the card’s current derivation path. source: Which node to derive from: MASTER, PARENT, or CURRENT. Returns: ExportedKey: An object containing the public key, and optionally the private key and chain code. See Also: - :func:`keycard.commands.export_key` - for the lower-level implementation - :class:`keycard.types.ExportedKey` - return value structure """ return commands.export_key( self, derivation_option=derivation_option, public_only=public_only, keypath=keypath, make_current=make_current, source=source ) def export_current_key(self, public_only: bool = False) -> ExportedKey: """ Exports the current key from the card. This is a convenience method that uses the CURRENT derivation option and does not require a keypath. Args: public_only (bool): If True, only the public key will be returned. Returns: ExportedKey: An object containing the public key, and optionally the private key and chain code. """ return self.export_key( derivation_option=constants.DerivationOption.CURRENT, public_only=public_only ) def sign( self, digest: bytes, algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, ) -> SignatureResult: """ Sign using the currently loaded keypair. Requires PIN verification and secure channel. Args: digest (bytes): 32-byte hash to sign algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to ECDSA_SECP256K1. Other options include SCHNORR_BIP340. Returns: SignatureResult: Parsed signature result, including the signature (DER or raw), algorithm, and optional recovery ID or public key. """ return commands.sign(self, digest, DerivationOption.CURRENT, algorithm) def sign_with_path( self, digest: bytes, path: str, make_current: bool = False, algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, ) -> SignatureResult: """ Sign using a derived keypath. Optionally updates the current path. Args: digest (bytes): 32-byte hash to sign path (str): BIP-32-style path (e.g. "m/44'/60'/0'/0/0") make_current (bool): whether to update current path on card algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to ECDSA_SECP256K1. Other options include SCHNORR_BIP340. Returns: SignatureResult: Parsed signature result, including the signature (DER or raw), algorithm, and optional recovery ID or public key. """ p1 = ( DerivationOption.DERIVE_AND_MAKE_CURRENT if make_current else DerivationOption.DERIVE ) return commands.sign(self, digest, p1, algorithm, derivation_path=path) def sign_pinless( self, digest: bytes, algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1, ) -> SignatureResult: """ Sign using the predefined PIN-less path. Does not require secure channel or PIN. Args: digest (bytes): 32-byte hash to sign algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to ECDSA_SECP256K1. Other options include SCHNORR_BIP340. Returns: SignatureResult: Parsed signature result, including the signature (DER or raw), algorithm, and optional recovery ID or public key. Raises: APDUError: if no PIN-less path is set """ return commands.sign(self, digest, DerivationOption.PINLESS, algorithm) def load_key( self, key_type: constants.LoadKeyType, public_key: Optional[bytes] = None, private_key: Optional[bytes] = None, chain_code: Optional[bytes] = None, bip39_seed: Optional[bytes] = None, lee_seed: Optional[bytes] = None ) -> bytes: """ Load a key into the card for signing purposes. Args: key_type: Key type public_key: Optional ECC public key (tag 0x80). private_key: ECC private key (tag 0x81). chain_code: Optional chain code (tag 0x82, only for extended key). bip39_seed: 16 to 64-byte BIP39 seed (only for key_type=BIP39_SEED). lee_seed: 16 to 64-byte LEE seed (only for key_type=BIP39_SEED). Returns: UID of the loaded key (SHA-256 of public key). """ return commands.load_key( self, key_type=key_type, public_key=public_key, private_key=private_key, chain_code=chain_code, bip39_seed=bip39_seed, lee_seed=lee_seed ) def set_pinless_path(self, path: str) -> None: """ Set a PIN-less path on the card. Allows signing without PIN/auth if the current derived key matches this path. Args: path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0"). An empty string disables the pinless path. """ commands.set_pinless_path(self, path) def generate_mnemonic(self, checksum_size: int = 6) -> list[int]: """ Generate a BIP39 mnemonic using the card's RNG. Args: checksum_size (int): Number of checksum bits (between 4 and 8 inclusive). Returns: List[int]: List of integers (0-2047) corresponding to wordlist indexes. """ return commands.generate_mnemonic(self, checksum_size) def derive_key(self, path: str = '') -> None: """ Set the derivation path for subsequent SIGN and EXPORT KEY commands. Args: path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0") or "../0/1" (parent) or "./0" (current). """ commands.derive_key(self, path) def send_apdu( self, ins: int, p1: int = 0x00, p2: int = 0x00, data: bytes = b'', cla: Optional[int] = None ) -> bytes: if cla is None: cla = constants.CLA_PROPRIETARY response: APDUResponse = self.transport.send_apdu( bytes([cla, ins, p1, p2, len(data)]) + data ) if response.status_word != constants.SW_SUCCESS: raise APDUError(response.status_word) return bytes(response.data) def send_secure_apdu( self, ins: int, p1: int = 0x00, p2: int = 0x00, data: bytes = b'' ) -> bytes: if not self.session or not self.session.authenticated: raise RuntimeError('Secure channel not established') encrypted = self.session.wrap_apdu( cla=constants.CLA_PROPRIETARY, ins=ins, p1=p1, p2=p2, data=data ) response: APDUResponse = self.transport.send_apdu( bytes([ constants.CLA_PROPRIETARY, ins, p1, p2, len(encrypted) ]) + encrypted ) if response.status_word != 0x9000: raise APDUError(response.status_word) plaintext, sw = self.session.unwrap_response(response) if sw != 0x9000: raise APDUError(sw) return plaintext