353 lines
11 KiB
Python
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())
|