Last active
June 15, 2025 14:21
-
-
Save bouroo/8b34daf5b7deed57ea54819ff7aeef6e to your computer and use it in GitHub Desktop.
Thai National ID Card reader in python
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# Kawin Viriyaprasopsook<[email protected]> | |
# 2025-06-15 | |
# sudo apt-get -y install pcscd python-pyscard python-pil | |
from dataclasses import dataclass, field | |
from pathlib import Path | |
from smartcard.System import readers | |
from smartcard.util import toHexString | |
import sys | |
from typing import Callable, List, Optional, Tuple | |
# Common APDU constants | |
APDU_SELECT_COMMAND = [0x00, 0xA4, 0x04, 0x00, 0x08] | |
APDU_APPLET_ID = [0xA0, 0x00, 0x00, 0x00, 0x54, 0x48, 0x00, 0x01] | |
SW_SUCCESS = [0x90, 0x00] | |
# Decoder function | |
def thai2unicode(data: List[int]) -> str: | |
"""Decodes TIS-620 bytes to Unicode, replacing '#' with spaces and stripping whitespace.""" | |
return ( | |
bytes(data) | |
.decode('tis-620', errors='replace') | |
.replace('#', ' ') | |
.strip() | |
) | |
@dataclass(frozen=True) | |
class APDUCommand: | |
"""Represents an APDU command for reading a specific field from the card.""" | |
ins: List[int] | |
label: str | |
decoder: Callable[[List[int]], str] = thai2unicode | |
class SmartCardError(Exception): | |
"""Custom exception for smart card related errors.""" | |
pass | |
class SmartCardConnection: | |
"""Manages the low-level connection and communication with the smart card.""" | |
def __init__(self, connection): | |
self.conn = connection | |
self.get_response_apdu_prefix: List[int] = [] # Prefix for GET RESPONSE command | |
def connect(self) -> None: | |
"""Establishes connection to the card and determines the GET RESPONSE prefix.""" | |
self.conn.connect() | |
atr = self.conn.getATR() | |
print("ATR:", toHexString(atr)) | |
# Determine GET RESPONSE command based on ATR (e.g., for specific card types) | |
# 0x3B 0x67 is a common prefix for some Thai ID cards | |
self.get_response_apdu_prefix = [0x00, 0xC0, 0x00, 0x01] if atr[:2] == [0x3B, 0x67] else [0x00, 0xC0, 0x00, 0x00] | |
def transmit(self, apdu: List[int]) -> Tuple[List[int], int, int]: | |
"""Transmits an APDU command and returns (data, sw1, sw2).""" | |
return self.conn.transmit(apdu) | |
class SmartCard: | |
"""High-level interface for interacting with a Thai National ID card.""" | |
def __init__(self, connection: SmartCardConnection): | |
self.conn = connection | |
def initialize(self) -> None: | |
"""Selects the Thai ID card applet.""" | |
apdu = APDU_SELECT_COMMAND + APDU_APPLET_ID | |
_, sw1, sw2 = self.conn.transmit(apdu) | |
if [sw1, sw2] != SW_SUCCESS: | |
raise SmartCardError(f"Failed to select applet: {sw1:02X} {sw2:02X}") | |
print(f"Select Applet: {sw1:02X} {sw2:02X}") | |
def _get_data_with_get_response(self, command_apdu: List[int]) -> List[int]: | |
""" | |
Sends an APDU command and then issues a GET RESPONSE command to retrieve data. | |
This is common for commands that return SW1/SW2 first, indicating data is available. | |
""" | |
# Send the initial command | |
_, sw1, sw2 = self.conn.transmit(command_apdu) | |
if [sw1, sw2] != SW_SUCCESS: | |
raise SmartCardError(f"Command failed ({toHexString(command_apdu)}): {sw1:02X} {sw2:02X}") | |
# Request the actual data using GET RESPONSE | |
# The Le byte (last byte of the original command) indicates the expected length | |
get_response_apdu = self.conn.get_response_apdu_prefix + [command_apdu[-1]] | |
data, sw1, sw2 = self.conn.transmit(get_response_apdu) | |
if [sw1, sw2] != SW_SUCCESS: | |
raise SmartCardError(f"GET RESPONSE failed ({toHexString(get_response_apdu)}): {sw1:02X} {sw2:02X}") | |
return data | |
def read_field(self, cmd: APDUCommand) -> str: | |
"""Reads a specific field from the card using the provided APDUCommand.""" | |
data = self._get_data_with_get_response(cmd.ins) | |
result = cmd.decoder(data) | |
print(f"{cmd.label}: {result}") | |
return result | |
def read_photo(self, cid: str, segments: int = 20) -> None: | |
"""Reads the photo from the card and saves it as a JPEG file.""" | |
# Base command for reading photo segments. P2 (cmd[4]) is the segment index. | |
# Le (cmd[-1]) is the expected length of each segment. | |
base_photo_cmd = [0x80, 0xB0, 0x00, 0x78, 0x00, 0x00, 0xFF] | |
photo_data = bytearray() | |
for i in range(1, segments + 1): | |
current_cmd = base_photo_cmd.copy() | |
current_cmd[4] = i # Set P2 to the current segment index | |
try: | |
segment_data = self._get_data_with_get_response(current_cmd) | |
photo_data.extend(segment_data) | |
except SmartCardError as e: | |
print(f"Warning: Could not read photo segment {i}: {e}") | |
# Continue to next segment, or break if critical | |
break # Break if a segment fails, as subsequent ones might also fail | |
if photo_data: | |
filename = Path(f"{cid}.jpg") | |
try: | |
filename.write_bytes(photo_data) | |
print(f"Photo saved as {filename}") | |
except IOError as e: | |
print(f"Error saving photo to {filename}: {e}") | |
else: | |
print("No photo data retrieved.") | |
def select_reader() -> Optional[object]: | |
"""Prompts the user to select a smartcard reader.""" | |
rlist = readers() | |
if not rlist: | |
print("No smartcard readers found.") | |
return None | |
print("Available readers:") | |
for i, r in enumerate(rlist): | |
print(f" [{i}] {r}") | |
try: | |
choice_str = input("Select reader [0]: ") | |
choice = int(choice_str) if choice_str else 0 | |
except ValueError: | |
print("Invalid input. Defaulting to reader 0.") | |
choice = 0 | |
if not (0 <= choice < len(rlist)): | |
print(f"Invalid reader choice '{choice}'. Selecting default reader 0.") | |
choice = 0 | |
return rlist[choice] | |
def main(): | |
reader = select_reader() | |
if reader is None: | |
sys.exit(1) | |
conn = None # Initialize conn to None for finally block | |
try: | |
conn = SmartCardConnection(reader.createConnection()) | |
conn.connect() # Establish connection and determine GET RESPONSE prefix | |
card = SmartCard(conn) | |
card.initialize() # Select the applet | |
commands = [ | |
APDUCommand([0x80, 0xB0, 0x00, 0x04, 0x02, 0x00, 0x0D], "CID"), | |
APDUCommand([0x80, 0xB0, 0x00, 0x11, 0x02, 0x00, 0x64], "TH Fullname"), | |
APDUCommand([0x80, 0xB0, 0x00, 0x75, 0x02, 0x00, 0x64], "EN Fullname"), | |
APDUCommand([0x80, 0xB0, 0x00, 0xD9, 0x02, 0x00, 0x08], "Date of birth"), | |
APDUCommand([0x80, 0xB0, 0x00, 0xE1, 0x02, 0x00, 0x01], "Gender"), | |
APDUCommand([0x80, 0xB0, 0x00, 0xF6, 0x02, 0x00, 0x64], "Card Issuer"), | |
APDUCommand([0x80, 0xB0, 0x01, 0x67, 0x02, 0x00, 0x08], "Issue Date"), | |
APDUCommand([0x80, 0xB0, 0x01, 0x6F, 0x02, 0x00, 0x08], "Expire Date"), | |
APDUCommand([0x80, 0xB0, 0x15, 0x79, 0x02, 0x00, 0x64], "Address"), | |
] | |
cid = "" | |
for cmd in commands: | |
try: | |
result = card.read_field(cmd) | |
if cmd.label == "CID": | |
cid = result | |
except SmartCardError as e: | |
print(f"Error reading {cmd.label}: {e}") | |
except Exception as e: | |
print(f"Unexpected error reading {cmd.label}: {e}") | |
if cid: | |
card.read_photo(cid) | |
else: | |
print("CID not found; skipping photo extraction.") | |
except SmartCardError as e: | |
print(f"Smart Card Error: {e}") | |
sys.exit(1) | |
except Exception as e: | |
print(f"An unexpected error occurred: {e}") | |
sys.exit(1) | |
finally: | |
# Disconnect from the card if connection was established | |
if conn and conn.conn: | |
try: | |
conn.conn.disconnect() | |
print("Disconnected from smart card.") | |
except Exception as e: | |
print(f"Error during disconnect: {e}") | |
if __name__ == "__main__": | |
main() |
For python3 you can just do the following to get the photo writing correctly
Update the thai2unicode method
def thai2unicode(data):
if isinstance(data, list):
return bytes(data).decode('tis-620').strip().replace('#', ' ')
else :
return data
Replace the line with HexListToBinString with the following
data = bytes(photo)
@bouroo Thanks for this, it has been very helpful.
Thanks for your code.
I tried this on python3. it doesn't work on reading profile picture.
I changed some code of you then it works.
My code
https://github.com/pstudiodev1/lab-python3-th-idcard
Thanks
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I'm having the same problem with you, how to fix it?