diff --git a/script/pn532_cli_unit.py b/script/pn532_cli_unit.py index 700f1f1..2145672 100644 --- a/script/pn532_cli_unit.py +++ b/script/pn532_cli_unit.py @@ -1310,132 +1310,6 @@ def get_block0(self, uid, args): return return str_to_bytes(block0) -@hf_mf.command("eSetUid") -class HfMfESetUid(DeviceRequiredUnit): - def args_parser(self) -> ArgumentParserNoExit: - parser = ArgumentParserNoExit() - parser.description = "Set UID of Mifare 1K emulator" - parser.add_argument( - "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" - ) - parser.add_argument( - "-u", - type=str, - metavar="", - required=False, - help="UID to set (4 or 7 bytes)", - ) - return parser - - def on_exec(self, args: argparse.Namespace): - if args.u is None: - print("usage: hf mf eSetUid [-h] -u ") - print("hf mf eSetUid: error: the following arguments are required: -u") - return - uid = bytes.fromhex(args.u) - if len(uid) not in [4, 7]: - print("UID length must be 4 or 7 bytes") - return - self.cmd.hf_mf_esetuid(args.slot - 1, uid) - print(f"Set Slot {args.slot} UID to {args.u} {CY}Success{C0}") - -@hf_mf.command("eload") -class HfMfEload(DeviceRequiredUnit): - def args_parser(self) -> ArgumentParserNoExit: - parser = ArgumentParserNoExit() - parser.formatter_class = argparse.RawDescriptionHelpFormatter - parser.description = "Load Mifare Dump to PN532Killer Slot" - parser.add_argument( - "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" - ) - parser.add_argument( - "--bin", - type=str, - required=False, - help="MF 1k bin dump file", - ) - parser.add_argument( - "--json", - type=str, - required=False, - help="MF 1k json dump file", - ) - return parser - - def on_exec(self, args: argparse.Namespace): - if not args.bin and not args.json: - print("Please choose either bin file or json file") - return - dump_map = {} - if args.bin: - # read bytes from bin, each block 16 bytes, map like "0":"11223344556677889900AABBCCDDEEFF" - with open(args.bin, "rb") as bin_file: - block_index = 0 - while True: - block = bin_file.read(16) - if not block: - break - dump_map[str(block_index)] = block.hex().upper() - block_index += 1 - elif args.json: - with open(args.json, "r") as json_file: - file_dump = json.load(json_file) - if "blocks" in file_dump: - dump_map = file_dump["blocks"] - - # if dump_map key count is not 64, return - if len(dump_map) != 64: - print("Invalid dump file") - return - for block_index, block_data in dump_map.items(): - if not is_hex(block_data, 32): - print(f"Invalid block {block_index}") - return - self.cmd.hf_mf_load(dump_map, args.slot) - - -@hf_mf.command("eread") -class HfMfEread(DeviceRequiredUnit): - def args_parser(self) -> ArgumentParserNoExit: - parser = ArgumentParserNoExit() - parser.description = "Get Mifare Classic dump from PN532Killer Slot" - parser.add_argument( - "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" - ) - parser.add_argument("--file", action="store_true", help="Save to json file") - parser.add_argument("--bin", action="store_true", help="Save to bin file") - return parser - - def on_exec(self, args: argparse.Namespace): - self.device_com.set_work_mode(2, 0x01, args.slot - 1) - dump_map = self.cmd.hf_mf_eread(args.slot) - # {"0": "11223344556677889900AABBCCDDEEFF", "1": "11223344556677889900AABBCCDDEEFF", ...} - if not dump_map: - print("Get dump failed") - return - file_name = "mf_dump_{args.slot}" - file_index = 0 - if args.file: - while True: - if os.path.exists(f"{file_name}_{file_index}.json"): - file_index += 1 - else: - file_name = f"{file_name}_{file_index}.json" - break - with open(file_name, "w") as json_file: - json.dump({"blocks": dump_map}, json_file) - if args.bin: - while True: - if os.path.exists(f"{file_name}_{file_index}.bin"): - file_index += 1 - else: - file_name = f"{file_name}_{file_index}.bin" - break - with open(file_name, "wb") as bin_file: - for block_index, block_data in dump_map.items(): - bin_file.write(bytes.fromhex(block_data)) - - @hf_mf.command("setuid") class HfMfSetUid(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: @@ -1981,6 +1855,131 @@ def on_exec(self, args: argparse.Namespace): else: print(f"{CR}Not MiFare Classic{C0}") +@hf_mf.command("eSetUid") +class HfMfESetUid(DeviceRequiredUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = "Set 4 bytes or 7 bytes UID of PN532Killer Mifare 1K emulator" + parser.add_argument( + "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" + ) + parser.add_argument( + "-u", + type=str, + metavar="", + required=False, + help="UID to set (4 or 7 bytes)", + ) + return parser + + def on_exec(self, args: argparse.Namespace): + if args.u is None: + print("usage: hf mf eSetUid [-h] -u ") + print("hf mf eSetUid: error: the following arguments are required: -u") + return + uid = bytes.fromhex(args.u) + if len(uid) not in [4, 7]: + print("UID length must be 4 or 7 bytes") + return + self.cmd.hf_mf_esetuid(args.slot - 1, uid) + print(f"Set Slot {args.slot} UID to {args.u} {CY}Success{C0}") + +@hf_mf.command("eLoad") +class HfMfEload(DeviceRequiredUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.formatter_class = argparse.RawDescriptionHelpFormatter + parser.description = "Load Mifare Classic Dump to PN532Killer Slot" + parser.add_argument( + "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" + ) + parser.add_argument( + "--bin", + type=str, + required=False, + help="MF 1k bin dump file", + ) + parser.add_argument( + "--json", + type=str, + required=False, + help="MF 1k json dump file", + ) + return parser + + def on_exec(self, args: argparse.Namespace): + if not args.bin and not args.json: + print("Please choose either bin file or json file") + return + dump_map = {} + if args.bin: + # read bytes from bin, each block 16 bytes, map like "0":"11223344556677889900AABBCCDDEEFF" + with open(args.bin, "rb") as bin_file: + block_index = 0 + while True: + block = bin_file.read(16) + if not block: + break + dump_map[str(block_index)] = block.hex().upper() + block_index += 1 + elif args.json: + with open(args.json, "r") as json_file: + file_dump = json.load(json_file) + if "blocks" in file_dump: + dump_map = file_dump["blocks"] + + # if dump_map key count is not 64, return + if len(dump_map) != 64: + print("Invalid dump file") + return + for block_index, block_data in dump_map.items(): + if not is_hex(block_data, 32): + print(f"Invalid block {block_index}") + return + self.cmd.hf_mf_load(dump_map, args.slot) + + +@hf_mf.command("eRead") +class HfMfEread(DeviceRequiredUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = "Get Mifare Classic dump from PN532Killer Slot" + parser.add_argument( + "-s", "--slot", default=1, type=int, help="Emulator slot(1-8)" + ) + parser.add_argument("--file", action="store_true", help="Save to json file") + parser.add_argument("--bin", action="store_true", help="Save to bin file") + return parser + + def on_exec(self, args: argparse.Namespace): + self.device_com.set_work_mode(2, 0x01, args.slot - 1) + dump_map = self.cmd.hf_mf_eread(args.slot) + # {"0": "11223344556677889900AABBCCDDEEFF", "1": "11223344556677889900AABBCCDDEEFF", ...} + if not dump_map: + print("Get dump failed") + return + file_name = "mf_dump_{args.slot}" + file_index = 0 + if args.file: + while True: + if os.path.exists(f"{file_name}_{file_index}.json"): + file_index += 1 + else: + file_name = f"{file_name}_{file_index}.json" + break + with open(file_name, "w") as json_file: + json.dump({"blocks": dump_map}, json_file) + if args.bin: + while True: + if os.path.exists(f"{file_name}_{file_index}.bin"): + file_index += 1 + else: + file_name = f"{file_name}_{file_index}.bin" + break + with open(file_name, "wb") as bin_file: + for block_index, block_data in dump_map.items(): + bin_file.write(bytes.fromhex(block_data)) + @hf_mfu.command("rdbl") class HfMfRdbl(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: @@ -2254,7 +2253,7 @@ def on_exec(self, args: argparse.Namespace): print("LF Tag no found") -@lf_em_410x.command("esetid") +@lf_em_410x.command("eSetid") class LfEm410xESetId(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() @@ -2310,3 +2309,13 @@ def args_parser(self) -> ArgumentParserNoExit: def on_exec(self, args: argparse.Namespace): self.device_com.set_normal_mode() self.cmd.ntag_emulator(url=args.uri) + +@ntag.command("reader") +class NtagReader(DeviceRequiredUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.description = "Read NTAG data and open URI in browser" + return parser + + def on_exec(self, args: argparse.Namespace): + self.cmd.ntag_reader() \ No newline at end of file diff --git a/script/pn532_cmd.py b/script/pn532_cmd.py index 73baec8..9ed7f3d 100644 --- a/script/pn532_cmd.py +++ b/script/pn532_cmd.py @@ -14,6 +14,7 @@ from pn532_enum import MfcKeyType, MfcValueBlockOperator from time import sleep from pn532_utils import CC, CB, CG, C0, CY, CR +from pn532_utils import NdefParser import os import subprocess import ndef @@ -1108,6 +1109,61 @@ def ntag_emulator(self, url: str): self.device.set_normal_mode() return resp + def ntag_reader(self): + import webbrowser + """ + Read NTAG and parse NDEF data. If the data contains a URI (e.g., link, tel, mailto), open it in the browser. + """ + try: + self.device.set_normal_mode() + self.stop_flag = False + input_thread = threading.Thread(target=self.wait_for_enter) + input_thread.start() + + while not self.stop_flag: + resp = self.hf_14a_scan() + if resp is None: + print("No tag found. Waiting for tag...") + sleep(0.5) + continue + + dump_bin_data = bytearray() + max_block = 4 + block = 0 + while block < max_block: + resp = self.mf0_read_one_block(block) + if block == 0 and resp and resp.parsed and len(resp.parsed) == 16: + max_block = resp.parsed[14] * 2 + 9 + if resp and resp.parsed and len(resp.parsed) == 16: + dump_bin_data.extend(resp.parsed) + else: + print(f"Error reading block {block}: {resp}") + break + block += 4 + print("NTAG Dump:") + for i in range(0, len(dump_bin_data), 16): + print(f"{CG}{' '.join(f'{byte:02X}' for byte in dump_bin_data[i:i + 16])} {''.join(chr(byte) if 32 <= byte <= 126 else '.' for byte in dump_bin_data[i:i + 16])}{C0}") + ndef_parser = NdefParser(dump_bin_data) + urls = ndef_parser.get_urls() + if urls: + print("NDEF URLs:") + for url in urls: + print(f"{CG}{url}{C0}") + + for url in urls: + if "http" in url or "tel" in url or "mailto" in url: + webbrowser.open(url) + + print("Remove card and place another, or press Enter to stop...") + sleep(1) + + print("Stopped NTAG reading.") + return [] + except Exception as e: + print("Error reading NTAG:", e) + self.stop_flag = True + return [] + stop_flag = False def wait_for_enter(self): print("Press Enter to stop...") diff --git a/script/pn532_enum.py b/script/pn532_enum.py index 87f6035..b4fe387 100644 --- a/script/pn532_enum.py +++ b/script/pn532_enum.py @@ -59,6 +59,7 @@ class Pn532KillerCommand(enum.IntEnum): "HfMfDump", "HfMfWipe", "NtagEmulate", + "NtagReader", "HfMfuRdbl", "HfMfuWrbl", "HfMfuDump", diff --git a/script/pn532_ntag_reader.py b/script/pn532_ntag_reader.py new file mode 100644 index 0000000..61fab13 --- /dev/null +++ b/script/pn532_ntag_reader.py @@ -0,0 +1,63 @@ +import pn532_com +from pn532_cmd import Pn532CMD + +import os +import subprocess +from platform import uname +import serial.tools.list_ports + +def test_fn(): + # connect to pn532 + dev = pn532_com.Pn532Com() + platform_name = uname().release + if "Microsoft" in platform_name: + path = os.environ["PATH"].split(os.pathsep) + path.append("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/") + powershell_path = None + for prefix in path: + fn = os.path.join(prefix, "powershell.exe") + if not os.path.isdir(fn) and os.access(fn, os.X_OK): + powershell_path = fn + break + if powershell_path: + process = subprocess.Popen( + [ + powershell_path, + "Get-PnPDevice -Class Ports -PresentOnly |" + " where {$_.DeviceID -like '*VID_6868&PID_8686*'} |" + " Select-Object -First 1 FriendlyName |" + " % FriendlyName |" + " select-string COM\\d+ |" + "% { $_.matches.value }", + ], + stdout=subprocess.PIPE, + ) + res = process.communicate()[0] + _comport = res.decode("utf-8").strip() + if _comport: + dev.open(_comport.replace("COM", "/dev/ttyS")) + else: + # loop through all ports and find pn532 + for port in serial.tools.list_ports.comports(): + if port.vid == 6790: + dev.open(port.device) + break + if "PN532Killer" in port.description: + dev.open(port.device) + break + + if dev.serial_instance is None: + print("No PN532/PN532Killer found") + return + print(f"Connected to {dev.serial_instance.port}, {dev.device_name}") + cml = Pn532CMD(dev) + + try: + cml.ntag_reader() + except Exception as e: + print("Error:", e) + dev.close() + + +if __name__ == "__main__": + test_fn() diff --git a/script/pn532_tag_scanner.py b/script/pn532_tag_scanner.py new file mode 100644 index 0000000..979a7d5 --- /dev/null +++ b/script/pn532_tag_scanner.py @@ -0,0 +1,107 @@ +import struct +import re +import ctypes +from typing import Union +import threading + +import pn532_com +from unit.calc import crc16A, crc16Ccitt +from pn532_com import Response, DEBUG +from pn532_utils import expect_response +from pn532_enum import Command, MifareCommand, ApduCommand, TagFile, NdefCommand, Status +from pn532_enum import Pn532KillerCommand +from pn532_cmd import Pn532CMD + +from pn532_enum import ButtonPressFunction, ButtonType, MifareClassicDarksideStatus +from pn532_enum import MfcKeyType, MfcValueBlockOperator +from time import sleep +from pn532_utils import CC, CB, CG, C0, CY, CR +import os +import subprocess +import ndef +from multiprocessing import Pool, cpu_count +from typing import Union +from pathlib import Path +from platform import uname +import sys +import select +import serial.tools.list_ports +# if system is Windows +if os.name == "nt": + import msvcrt +import tkinter as tk + +def test_fn(): + # connect to pn532 + dev = pn532_com.Pn532Com() + platform_name = uname().release + if "Microsoft" in platform_name: + path = os.environ["PATH"].split(os.pathsep) + path.append("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/") + powershell_path = None + for prefix in path: + fn = os.path.join(prefix, "powershell.exe") + if not os.path.isdir(fn) and os.access(fn, os.X_OK): + powershell_path = fn + break + if powershell_path: + process = subprocess.Popen( + [ + powershell_path, + "Get-PnPDevice -Class Ports -PresentOnly |" + " where {$_.DeviceID -like '*VID_6868&PID_8686*'} |" + " Select-Object -First 1 FriendlyName |" + " % FriendlyName |" + " select-string COM\\d+ |" + "% { $_.matches.value }", + ], + stdout=subprocess.PIPE, + ) + res = process.communicate()[0] + _comport = res.decode("utf-8").strip() + if _comport: + dev.open(_comport.replace("COM", "/dev/ttyS")) + else: + # loop through all ports and find pn532 + for port in serial.tools.list_ports.comports(): + if port.vid == 6790: + dev.open(port.device) + break + if "PN532Killer" in port.description: + dev.open(port.device) + break + + if dev.serial_instance is None: + print("No PN532/PN532Killer found") + return + print(f"Connected to {dev.serial_instance.port}, {dev.device_name}") + cml = Pn532CMD(dev) + + try: + def update_uid(): + scan_result = cml.hf_14a_scan() + if scan_result: + # get UID, ATQA, SAK + uid = scan_result[0]['uid'].hex().upper() + atqa = scan_result[0]["atqa"].hex().upper() + sak = scan_result[0]["sak"].hex().upper() + uid_label.config(text=f"UID: {uid}\nATS: {atqa}\nSAK: {sak}") + else: + uid_label.config(text="No card found") + root.after(1000, update_uid) + + root = tk.Tk() + root.title("TAG Scanner") + + uid_label = tk.Label(root, text="Scanning for card...", font=("Helvetica", 18), anchor="w", justify="left") + uid_label.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) + + update_uid() + root.mainloop() + except Exception as e: + print("Error:", e) + dev.close() + + +if __name__ == "__main__": + test_fn() diff --git a/script/pn532_utils.py b/script/pn532_utils.py index ae584ea..033f5dd 100644 --- a/script/pn532_utils.py +++ b/script/pn532_utils.py @@ -1,7 +1,7 @@ import argparse import colorama +import ndef from functools import wraps -# once Python3.10 is mainstream, we can replace Union[str, None] by str | None from typing import Union, Callable, Any from prompt_toolkit.completion import Completer, NestedCompleter, WordCompleter from prompt_toolkit.completion.base import Completion @@ -406,3 +406,72 @@ def print_help(self): print('\n'.join(lines)) print('') self.help_requested = True + +class NdefParser: + """ + Class for parsing binary data into NDEF records + """ + def __init__(self, bindata): + self.bindata = bindata + self.records = [] + self.urls = [] + self.parse_records() + + def parse_records(self): + """ + Parse NDEF records from binary data, specifically handling Mifare Ultralight dumps + """ + self.records = [] + self.urls = [] + i = 16 + while i < len(self.bindata): + try: + # Search for a potential NDEF record start + if self.bindata[i] & 0x07 in [0x01, 0x03]: + type_length = self.bindata[i + 1] + payload_length = self.bindata[i + 2] + type_start = i + 3 + payload_start = type_start + type_length + payload_end = payload_start + payload_length + + if payload_end > len(self.bindata): + i +=1 + continue + record_type = self.bindata[type_start:type_start + type_length] + payload = self.bindata[payload_start:payload_end] + + if record_type == b'U': # URI Record + self.records.append(payload) + decoded_uri = self._decode_uri(payload) + self.urls.append(decoded_uri) + # print(f"Decoded URI: {decoded_uri}") + i = payload_end + except Exception as e: + print(f"Error parsing record at byte {i}: {e}") + i += 1 + + def _decode_uri(self, payload): + """ + Decode a URI payload according to the NDEF URI Record specification + """ + uri_prefixes = [ + "", "http://www.", "https://www.", "http://", "https://", + "tel:", "mailto:", "ftp://anonymous:anonymous@", "ftp://ftp.", + "ftps://", "sftp://", "smb://", "nfs://", "ftp://", "dav://", + "news:", "telnet://", "imap:", "rtsp://", "urn:", "pop:", + "sip:", "sips:", "tftp:", "btspp://", "btl2cap://", "btgoep://", + "tcpobex://", "irdaobex://", "file://", "urn:epc:id:", + "urn:epc:tag:", "urn:epc:pat:", "urn:epc:raw:", "urn:epc:", + "urn:nfc:" + ] + + prefix_index = payload[0] + uri = uri_prefixes[prefix_index] + payload[1:].decode('utf-8') + return uri + + def get_urls(self): + """ + Return the list of extracted URLs + """ + return self.urls + diff --git a/script/requirements.txt b/script/requirements.txt index b373056..1cccf98 100644 --- a/script/requirements.txt +++ b/script/requirements.txt @@ -1,4 +1,5 @@ pyserial==3.5 colorama==0.4.6 prompt-toolkit==3.0.39 -ndef==0.2 \ No newline at end of file +ndef==0.2 +tk \ No newline at end of file