Files
mykad-reader-api/mykad.py
T
ISMAIL MASSERAN 1e6cebc163 first init
2026-09-01 12:22:43 +08:00

353 lines
11 KiB
Python

"""
MyKad reader built on API.py (PC/SC APDUs).
Works on macOS and Windows with any CCID reader (including ACR39U).
Reads the JPN application: personal details, address, and JPEG photo.
Usage:
python mykad.py
python mykad.py --reader "ACS ACR39U ICC Reader"
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from datetime import date
from typing import Optional
from API import connect_to_reader, disconnect_reader, list_readers, send_command
JPN_SELECT = "00 A4 04 00 0A A0 00 00 00 74 4A 50 4E 00 10"
CHUNK_SIZE = 0xF0 # 240 bytes — safe T=0 buffer for photo and long files
FILE_HEADER = 3 # each JPN file starts with a 3-byte header
# JPN file 1 (personal) and file 4 (address), measured from byte 0 of the file.
# Layout is the community-documented JPN surface-data map (Xenon / JPN field table).
PERSONAL_FILE = 0x01
PERSONAL_LENGTH = 0x01A5 # from offset 0x0003 through religion / card extras
PHOTO_FILE = 0x02
PHOTO_LENGTH = 0x0FA0 # 4000-byte JPEG slot
ADDRESS_FILE = 0x04
ADDRESS_LENGTH = 0x0094
SOCSO_FILE = 0x05
SOCSO_LENGTH = 0x09
LOCALITY_FILE = 0x06
LOCALITY_LENGTH = 0x0A
class MyKadError(Exception):
"""Raised when the card, reader, or APDU sequence fails."""
@dataclass
class MyKadData:
name: str
gmpc_name: str
kpt_name: str
nric: str
gender: str
old_ic: str
dob: str
age: Optional[int]
birth_place: str
date_issued: str
citizenship: str
race: str
religion: str
address1: str
address2: str
address3: str
postcode: str
city: str
state: str
socso: str
locality: str
photo_jpeg: bytes
def as_dict(self, include_photo: bool = False) -> dict:
payload = asdict(self)
if include_photo:
payload["photo_jpeg_bytes"] = len(self.photo_jpeg)
payload.pop("photo_jpeg", None)
return payload
def save_photo(self, path: str = "photo.jpg") -> str:
if not self.photo_jpeg:
raise MyKadError("No JPEG photo was read from the card.")
with open(path, "wb") as handle:
handle.write(self.photo_jpeg)
return path
def _text(raw: bytes) -> str:
cleaned = raw.decode("latin-1", "replace").replace("\x00", " ")
return " ".join(cleaned.split())
def _bcd_date(raw: bytes) -> str:
if len(raw) < 4 or raw == b"\x00\x00\x00\x00":
return ""
hex_str = raw.hex()
return f"{hex_str[0:4]}-{hex_str[4:6]}-{hex_str[6:8]}"
def _bcd_postcode(raw: bytes) -> str:
if len(raw) < 3:
return ""
return raw.hex()[0:5]
def _age_from_dob(dob: str) -> Optional[int]:
try:
born = date.fromisoformat(dob)
except ValueError:
return None
today = date.today()
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
def _gender_label(raw: bytes) -> str:
marker = raw[:1]
if marker == b"L":
return "Male"
if marker == b"P":
return "Female"
text = _text(raw)
return text or "Unknown"
def transmit(connection, command_hex: str):
"""
Send one APDU and follow T=0 GET RESPONSE / wrong-length retries.
MyKad uses proprietary status words:
91 08 after Set Length
94 xx after Select Info
90 00 after a successful Read Info
"""
success, data, sw1, sw2, error = send_command(connection, command_hex, mode="apdu")
if not success:
raise MyKadError(error or "APDU failed")
if sw1 == 0x6C:
parts = command_hex.replace(" ", "")
if len(parts) >= 10:
retried = parts[:-2] + f"{sw2:02X}"
success, data, sw1, sw2, error = send_command(connection, retried, mode="apdu")
if not success:
raise MyKadError(error or "APDU failed after length retry")
if sw1 == 0x61:
success, data, sw1, sw2, error = send_command(
connection, f"00 C0 00 00 {sw2:02X}", mode="apdu"
)
if not success:
raise MyKadError(error or "GET RESPONSE failed")
return data, sw1, sw2
def select_jpn(connection) -> None:
_data, sw1, sw2 = transmit(connection, JPN_SELECT)
if sw1 not in (0x90, 0x61):
raise MyKadError(
f"Not a readable MyKad JPN application (SW={sw1:02X} {sw2:02X}). "
"Insert a MyKad contact-side down."
)
def read_jpn_file(connection, file_no: int, offset: int, length: int) -> bytes:
"""Read a JPN file with Set Length + Select Info + Read Info, in 240-byte chunks."""
result = bytearray()
remaining = length
current = offset
while remaining > 0:
chunk = min(remaining, CHUNK_SIZE)
len_lo, len_hi = chunk & 0xFF, (chunk >> 8) & 0xFF
off_lo, off_hi = current & 0xFF, (current >> 8) & 0xFF
_, sw1, sw2 = transmit(
connection,
f"C8 32 00 00 05 08 00 00 {len_lo:02X} {len_hi:02X}",
)
if sw1 not in (0x91, 0x90, 0x61):
raise MyKadError(
f"Set Length failed at offset {current:#06x} (SW={sw1:02X} {sw2:02X})"
)
_, sw1, sw2 = transmit(
connection,
f"CC 00 00 00 08 {file_no:02X} 00 01 00 {off_lo:02X} {off_hi:02X} {len_lo:02X} {len_hi:02X}",
)
if sw1 not in (0x94, 0x90, 0x61):
raise MyKadError(
f"Select Info failed at offset {current:#06x} (SW={sw1:02X} {sw2:02X})"
)
data, sw1, sw2 = transmit(connection, f"CC 06 00 00 {chunk:02X}")
if not data:
raise MyKadError(
f"Read Info returned no data at offset {current:#06x} "
f"(SW={sw1:02X} {sw2:02X})"
)
result.extend(data)
remaining -= len(data)
current += len(data)
return bytes(result)
def _extract_jpeg(raw: bytes) -> bytes:
start = raw.find(b"\xff\xd8")
if start < 0:
return b""
end = raw.rfind(b"\xff\xd9")
if end < start:
return raw[start:]
return raw[start:end + 2]
def parse_personal(blob: bytes) -> dict:
"""blob starts at file offset 0x0003 (header skipped)."""
dob = _bcd_date(blob[0x124:0x128])
return {
"name": _text(blob[0x00:0x96]),
"gmpc_name": _text(blob[0x96:0xE6]),
"kpt_name": _text(blob[0xE6:0x10E]),
"nric": _text(blob[0x10E:0x11B]),
"gender": _gender_label(blob[0x11B:0x11C]),
"old_ic": _text(blob[0x11C:0x124]),
"dob": dob,
"age": _age_from_dob(dob),
"birth_place": _text(blob[0x128:0x141]),
"date_issued": _bcd_date(blob[0x141:0x145]),
"citizenship": _text(blob[0x145:0x157]),
"race": _text(blob[0x157:0x170]),
"religion": _text(blob[0x170:0x17B]),
}
def parse_address(blob: bytes) -> dict:
return {
"address1": _text(blob[0x00:0x1E]),
"address2": _text(blob[0x1E:0x3C]),
"address3": _text(blob[0x3C:0x5A]),
"postcode": _bcd_postcode(blob[0x5A:0x5D]),
"city": _text(blob[0x5D:0x76]),
"state": _text(blob[0x76:0x94]),
}
def read_mykad(connection) -> MyKadData:
"""Read identity, address, photo, SOCSO, and locality from an open connection."""
select_jpn(connection)
personal = parse_personal(
read_jpn_file(connection, PERSONAL_FILE, FILE_HEADER, PERSONAL_LENGTH)
)
photo = _extract_jpeg(
read_jpn_file(connection, PHOTO_FILE, FILE_HEADER, PHOTO_LENGTH)
)
address = parse_address(
read_jpn_file(connection, ADDRESS_FILE, FILE_HEADER, ADDRESS_LENGTH)
)
try:
socso = _text(read_jpn_file(connection, SOCSO_FILE, FILE_HEADER, SOCSO_LENGTH))
except MyKadError:
socso = ""
try:
locality = _text(
read_jpn_file(connection, LOCALITY_FILE, FILE_HEADER, LOCALITY_LENGTH)
)
except MyKadError:
locality = ""
return MyKadData(
photo_jpeg=photo,
socso=socso,
locality=locality,
**personal,
**address,
)
def resolve_reader(reader_name: Optional[str] = None):
readers = list_readers()
if not readers:
raise MyKadError("No smart card readers found. Plug in the ACR39U and try again.")
if reader_name:
for reader in readers:
if str(reader) == reader_name:
return reader
names = ", ".join(str(item) for item in readers)
raise MyKadError(f'Reader "{reader_name}" not found. Available: {names}')
return readers[0]
def connect_mykad(reader=None, reader_name: Optional[str] = None):
"""
Open an APDU session. Uses T=0/T=1 on both macOS and Windows.
Caller must disconnect_reader(connection).
"""
selected = reader if reader is not None else resolve_reader(reader_name)
connection, error, _ = connect_to_reader(selected, mode="share", protocol="t0t1")
if not connection:
raise MyKadError(error or "Could not connect. Insert a MyKad and try again.")
return connection
def read_mykad_from_reader(reader=None, reader_name: Optional[str] = None) -> MyKadData:
connection = connect_mykad(reader=reader, reader_name=reader_name)
try:
return read_mykad(connection)
finally:
disconnect_reader(connection)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Read a Malaysian MyKad over PC/SC.")
parser.add_argument("--reader", help="Exact PC/SC reader name. Default: first reader.")
parser.add_argument("--photo", default="photo.jpg", help="JPEG output path.")
parser.add_argument("--list-readers", action="store_true", help="List readers and exit.")
args = parser.parse_args(argv)
if args.list_readers:
found = list_readers()
if not found:
print("No smart card readers found.")
return 1
for index, reader in enumerate(found, start=1):
print(f"{index}: {reader}")
return 0
try:
data = read_mykad_from_reader(reader_name=args.reader)
except MyKadError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(json.dumps(data.as_dict(), indent=2, ensure_ascii=False))
if data.photo_jpeg:
saved = data.save_photo(args.photo)
print(f"Photo saved: {saved}", file=sys.stderr)
else:
print("No JPEG photo found on this card.", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())