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