diff --git a/firmware/application/src/app_cmd.c b/firmware/application/src/app_cmd.c index 1f2b28d..322f928 100644 --- a/firmware/application/src/app_cmd.c +++ b/firmware/application/src/app_cmd.c @@ -2159,7 +2159,7 @@ static bool tcl_apdu_( pcd_14a_reader_timeout_set(600); uint16_t rbits = 0; uint8_t st = pcd_14a_reader_bytes_transfer( - PCD_TRANSCEIVE, abuf, frame_len, rbuf, &rbits, 70u * 8u); + PCD_TRANSCEIVE, abuf, frame_len, rbuf, &rbits, 270u * 8u); if (st != STATUS_HF_TAG_OK || rbits < 24u) return false; uint16_t rb = rbits / 8u; @@ -2177,8 +2177,39 @@ static bool tcl_apdu_( } /* Handle card-side chaining ---------------------------------------- */ + uint16_t chain_rbits = 0; /* hoisted: used in both WTX and R(ACK) paths */ + uint8_t chain_st = STATUS_HF_TAG_OK; while (resp_pcb & 0x20u) { - if ((resp_pcb & 0xC0u) != 0x00u) break; /* not an I-block */ + if ((resp_pcb & 0xC0u) != 0x00u) { + /* S-block: handle S(WTX), reject others. + * Some Visa/MC cards send WTX (PCB=0xF2) before their FCI, + * requesting more processing time. We must echo it back. + * The WTXM byte was spuriously added to chain_buf — undo it. */ + if ((resp_pcb & 0xF0u) == 0xF0u) { + chain_len -= dlen; /* remove spurious WTXM byte(s) */ + uint8_t wtx_r[4]; + wtx_r[0] = resp_pcb; /* mirror the S(WTX) PCB */ + wtx_r[1] = rbuf[1]; /* WTXM from last received frame */ + crc_14a_append(wtx_r, 2); + write_register_single(ComIrqReg, 0x7F); + pcd_14a_reader_timeout_set(600); + chain_rbits = 0; + chain_st = pcd_14a_reader_bytes_transfer( + PCD_TRANSCEIVE, wtx_r, 4, rbuf, &chain_rbits, 270u * 8u); + if (chain_st != STATUS_HF_TAG_OK || chain_rbits < 24u) break; + rb = chain_rbits / 8u; + crc_14a_calculate(rbuf, rb - 2u, crc); + if (rbuf[rb-2] != crc[0] || rbuf[rb-1] != crc[1]) break; + resp_pcb = rbuf[0]; + dlen = (uint8_t)(rb - 3u); + if (dlen > 0u && chain_len + dlen < 512u) { + memcpy(&chain_buf[chain_len], &rbuf[1], dlen); + chain_len += dlen; + } + continue; /* re-check while with new resp_pcb */ + } + break; /* other S-blocks (DESELECT etc.): stop */ + } /* R(ACK) block_num must match the received I-block's block_num */ uint8_t rf[3]; @@ -2188,9 +2219,9 @@ static bool tcl_apdu_( /* Use bytes_transfer for chain R(ACK) — clear stale RxIRq first */ write_register_single(ComIrqReg, 0x7F); pcd_14a_reader_timeout_set(600); - uint16_t chain_rbits = 0; - uint8_t chain_st = pcd_14a_reader_bytes_transfer( - PCD_TRANSCEIVE, rf, 3, rbuf, &chain_rbits, 70u * 8u); + chain_rbits = 0; + chain_st = pcd_14a_reader_bytes_transfer( + PCD_TRANSCEIVE, rf, 3, rbuf, &chain_rbits, 270u * 8u); if (chain_st != STATUS_HF_TAG_OK || chain_rbits < 24u) break; rb = chain_rbits / 8u; /* bytes_transfer returns BIT count */ @@ -2232,7 +2263,7 @@ static data_frame_tx_t *cmd_processor_hf14a_4_emv_scan(uint16_t cmd, uint16_t st /* ---- helpers -------------------------------------------------- */ static uint8_t abuf[64]; /* TX frame: PCB + APDU + CRC */ - static uint8_t rbuf[70]; /* single-frame receive buffer: FIFO(64) + PCB(1) + CRC(2) + slack */ + static uint8_t rbuf[270]; /* single-frame receive buffer: up to 256 bytes data + PCB + CRC + slack (FSDI=8 → FSD=256) */ static uint8_t chain_buf[512]; /* reassembled chained response */ uint16_t rbits; uint8_t blk = 0; /* alternating block number */ @@ -2359,27 +2390,95 @@ static data_frame_tx_t *cmd_processor_hf14a_4_emv_scan(uint16_t cmd, uint16_t st APPEND_PAIR(sel_cmd, sel_len, sel_resp, sel_rlen); num_apdus++; - /* ---- Step 4: GPO -------------------------------------------- */ - static const uint8_t gpo_cmd[] = {0x80, 0xA8, 0x00, 0x00, 0x02, 0x83, 0x00, 0x00}; - uint8_t *gpo_resp; uint16_t gpo_rlen; - if (!SEND_APDU(gpo_cmd, sizeof(gpo_cmd), &gpo_resp, &gpo_rlen)) goto done; - APPEND_PAIR(gpo_cmd, sizeof(gpo_cmd), gpo_resp, gpo_rlen); + /* ---- Step 4: GPO — parse PDOL from SELECT AID FCI, fill zeros ------- */ + /* PDOL is tag 9F38 in the FCI (sel_resp). Parse it to know how many + * bytes the card expects. Fill all fields with zeros (offline scan). */ + uint8_t pdol_len = 0; + for (uint8_t pi = 0; pi + 2 < sel_rlen; pi++) { + /* 2-byte tag detection: first byte has bits[4:0] == 0x1F */ + uint8_t ptag1 = sel_resp[pi]; + uint8_t ptag2 = (((ptag1 & 0x1F) == 0x1F) && pi+1 < sel_rlen) ? sel_resp[pi+1] : 0; + uint16_t ftag = ((ptag1 & 0x1F) == 0x1F) ? (((uint16_t)ptag1<<8)|ptag2) : ptag1; + uint8_t flen_off = (((ptag1 & 0x1F) == 0x1F)) ? 2 : 1; + if (pi + flen_off >= sel_rlen) break; + uint8_t flen = sel_resp[pi + flen_off]; + if (ftag == 0x9F38) { + /* Sum DOL field lengths to get total PDOL data size */ + uint8_t di = pi + flen_off + 1; + uint8_t dend = di + flen; + while (di < dend && di + 1 < sel_rlen) { + uint8_t dol_tl = ((sel_resp[di] & 0x1F) == 0x1F) ? 2 : 1; + if (di + dol_tl >= sel_rlen) break; + pdol_len += sel_resp[di + dol_tl]; + di += dol_tl + 1; + } + break; + } + if (flen_off + flen < 255) pi += flen_off + flen - 1; else break; + } + /* ---- Step 5: GPO with PDOL retry -------------------------------- + * When the FCI is truncated (45b for long cards), we can't read the + * full PDOL. Try progressively larger PDOL sizes (all zeros) until the + * card accepts. pdol_len from FCI parsing is tried first. Common sizes + * cover most Mastercard/Visa variants. */ + static const uint8_t gpo_try_pl[] = {0, 4, 8, 12, 18, 22, 26, 29, 34, 38, 44}; + uint8_t gpo_buf[8 + 44]; /* PCB(1)+header(7)+max_PDOL(44)+Le(1)+CRC(2) fits abuf[64] */ + uint8_t gpo_len = 0; + uint8_t *gpo_resp = NULL; uint16_t gpo_rlen = 0; + if (pdol_len > 44) pdol_len = 0; /* clamp to abuf-safe size */ + { + bool gpo_ok = false; + for (uint8_t ti = 0; ti <= sizeof(gpo_try_pl) && !gpo_ok; ti++) { + uint8_t pl = (ti == 0) ? pdol_len + : gpo_try_pl[ti - 1]; + /* skip sizes we already tried */ + bool dup = false; + for (uint8_t si = 0; si < ti && !dup; si++) + dup = ((si == 0 ? pdol_len : gpo_try_pl[si-1]) == pl); + if (dup) continue; + gpo_len = 0; + gpo_buf[gpo_len++]=0x80; gpo_buf[gpo_len++]=0xA8; + gpo_buf[gpo_len++]=0x00; gpo_buf[gpo_len++]=0x00; + gpo_buf[gpo_len++]=(uint8_t)(pl + 2); /* Lc */ + gpo_buf[gpo_len++]=0x83; + gpo_buf[gpo_len++]=pl; + memset(&gpo_buf[gpo_len], 0x00, pl); gpo_len += pl; + gpo_buf[gpo_len++]=0x00; /* Le */ + if (!SEND_APDU(gpo_buf, gpo_len, &gpo_resp, &gpo_rlen)) continue; + /* Accept if response template (0x77/0x80) or SW 9000 */ + if (gpo_rlen >= 2) { + uint8_t s1 = gpo_resp[gpo_rlen-2]; + uint8_t s2 = gpo_resp[gpo_rlen-1]; + if (gpo_resp[0] == 0x77 || gpo_resp[0] == 0x80 || + (s1 == 0x90 && s2 == 0x00)) + gpo_ok = true; + } + } + if (!gpo_ok) goto done; + } + APPEND_PAIR(gpo_buf, gpo_len, gpo_resp, gpo_rlen); num_apdus++; - - /* ---- Step 5: parse AFL and READ RECORDs --------------------- */ - /* Find AFL in GPO response (tag 0x94 in format 2, or bytes 3+ in format 1) */ uint8_t *afl = NULL; uint8_t afl_len = 0; if (gpo_rlen > 0 && gpo_resp[0] == 0x77) { - /* Format 2: search for tag 94 */ + /* Format 2: find tag 94 */ for (uint8_t i = 2; i + 1 < gpo_rlen; ) { uint8_t t = gpo_resp[i]; uint8_t l = gpo_resp[i+1]; if (t == 0x94) { afl = &gpo_resp[i+2]; afl_len = l; break; } i += 2 + l; } } else if (gpo_rlen > 3 && gpo_resp[0] == 0x80) { - /* Format 1: skip tag(1)+len(1)+AIP(2) */ - afl = &gpo_resp[3]; afl_len = gpo_rlen - 3 - 2; /* -2 for SW */ + afl = &gpo_resp[3]; afl_len = gpo_rlen - 3 - 2; } + if (afl == NULL || afl_len == 0) goto done; + + /* Copy AFL to local buffer before READ RECORDs. + * afl points into chain_buf which is overwritten by each SEND_APDU call. + * Without this copy, the 2nd+ AFL entries become garbage after the first + * READ RECORD, causing last=0xFF and up to 255 timeout loops per entry. */ + static uint8_t afl_buf[32]; /* max 8 AFL entries × 4 bytes */ + if (afl_len > sizeof(afl_buf)) afl_len = (uint8_t)sizeof(afl_buf); + memcpy(afl_buf, afl, afl_len); + afl = afl_buf; /* READ each record */ for (uint8_t a = 0; a + 3 < afl_len; a += 4) { diff --git a/software/script/chameleon_cli_unit.py b/software/script/chameleon_cli_unit.py index 5a43673..75113c6 100644 --- a/software/script/chameleon_cli_unit.py +++ b/software/script/chameleon_cli_unit.py @@ -105,8 +105,8 @@ def check_tools(): if missing_tools: missing_tool_str = ", ".join(missing_tools) - warn_str = f"Warning, {missing_tool_str} not found. Corresponding commands will not work as intended." - print(color_string((CR, warn_str))) + warn_str = f"Note: optional Mifare tools not found: {missing_tool_str}. Mifare attack commands will not work." + print(color_string((CY, warn_str))) class BaseCLIUnit: @@ -5556,242 +5556,7 @@ class HFMFUEDetect(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): print(f"{actual_index:3d}: {color_string((CY, password.upper()))}") -@hf_mfu.command('nfcimport') -class HFMFUNfcImport(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): - # Mapping from Flipper Zero device type strings to CU TagSpecificType - FLIPPER_TYPE_MAP = { - 'NTAG203': TagSpecificType.NTAG_215, # best-effort: no native NTAG203 support - 'NTAG210': TagSpecificType.NTAG_210, - 'NTAG212': TagSpecificType.NTAG_212, - 'NTAG213': TagSpecificType.NTAG_213, - 'NTAG215': TagSpecificType.NTAG_215, - 'NTAG216': TagSpecificType.NTAG_216, - 'NTAGI2C1K': TagSpecificType.NTAG_216, # best-effort - 'NTAGI2C2K': TagSpecificType.NTAG_216, # best-effort - 'NTAGI2CPlus1K': TagSpecificType.NTAG_216, # best-effort - 'NTAGI2CPlus2K': TagSpecificType.NTAG_216, # best-effort - 'Mifare Ultralight': TagSpecificType.MF0ICU1, - 'Mifare Ultralight C': TagSpecificType.MF0ICU2, - 'Mifare Ultralight 11': TagSpecificType.MF0UL11, - 'Mifare Ultralight 21': TagSpecificType.MF0UL21, - # "Mifare Ultralight EV1" is disambiguated by page count in on_exec - } - - def args_parser(self) -> ArgumentParserNoExit: - parser = ArgumentParserNoExit() - parser.description = 'Import a Flipper Zero .nfc file into a MIFARE Ultralight / NTAG emulator slot' - self.add_slot_args(parser) - parser.add_argument('-f', '--file', required=True, type=str, help="Path to Flipper Zero .nfc file") - parser.add_argument('--amiibo', action='store_true', default=False, - help="Derive and write correct PWD/PACK for amiibo (NTAG215)") - return parser - - def on_exec(self, args: argparse.Namespace): - file_path = args.file - file_name = os.path.basename(file_path) - - # --- Parse the .nfc file --- - try: - with open(file_path, 'r') as f: - lines = f.readlines() - except FileNotFoundError: - print(color_string((CR, f"File not found: {file_path}"))) - return - except OSError as e: - print(color_string((CR, f"Error reading file: {e}"))) - return - - device_type = None - uid = None - atqa = None - sak = None - signature = None - version = None - counters = {} - tearing = {} - pages_total = None - pages = {} - - for line in lines: - line = line.strip() - if line.startswith('#') or not line: - continue - - if line.startswith('Device type:'): - device_type = line.split(':', 1)[1].strip() - elif line.startswith('UID:'): - uid = bytes.fromhex(line.split(':', 1)[1].strip().replace(' ', '')) - elif line.startswith('ATQA:'): - atqa = bytes.fromhex(line.split(':', 1)[1].strip().replace(' ', '')) - elif line.startswith('SAK:'): - sak = bytes.fromhex(line.split(':', 1)[1].strip().replace(' ', '')) - elif line.startswith('Signature:'): - signature = bytes.fromhex(line.split(':', 1)[1].strip().replace(' ', '')) - elif line.startswith('Mifare version:'): - version = bytes.fromhex(line.split(':', 1)[1].strip().replace(' ', '')) - elif line.startswith('Counter '): - match = re.match(r'Counter\s+(\d+):\s+(\d+)', line) - if match: - counters[int(match.group(1))] = int(match.group(2)) - elif line.startswith('Tearing '): - match = re.match(r'Tearing\s+(\d+):\s+([0-9A-Fa-f]+)', line) - if match: - tearing[int(match.group(1))] = int(match.group(2), 16) - elif line.startswith('Pages total:'): - pages_total = int(line.split(':', 1)[1].strip()) - elif line.startswith('Page '): - match = re.match(r'Page\s+(\d+):\s+(.*)', line) - if match: - page_num = int(match.group(1)) - page_data = bytes.fromhex(match.group(2).strip().replace(' ', '')) - pages[page_num] = page_data - - # --- Validate required fields --- - if device_type is None: - print(color_string((CR, "No 'Device type' found in .nfc file."))) - return - if uid is None: - print(color_string((CR, "No 'UID' found in .nfc file."))) - return - if atqa is None: - print(color_string((CR, "No 'ATQA' found in .nfc file."))) - return - if sak is None: - print(color_string((CR, "No 'SAK' found in .nfc file."))) - return - - # --- Map device type to TagSpecificType --- - tag_type = self.FLIPPER_TYPE_MAP.get(device_type) - - if tag_type is None and device_type.startswith('Mifare Ultralight EV1'): - # Disambiguate EV1 by page count - nr = pages_total if pages_total else len(pages) - tag_type = TagSpecificType.MF0UL11 if nr <= 20 else TagSpecificType.MF0UL21 - - if tag_type is None: - print(color_string((CR, f"Unsupported Flipper device type: '{device_type}'"))) - print(f" Supported types: {', '.join(sorted(self.FLIPPER_TYPE_MAP.keys()))}, Mifare Ultralight EV1") - return - - # --- Print summary --- - print(f"Importing Flipper NFC file: {file_name}") - print(f" Device type: {device_type} -> {tag_type}") - print(f" UID: {uid.hex(' ').upper()}") - print(f" ATQA: {atqa.hex(' ').upper()} SAK: {sak.hex().upper()}") - if version: - print(f" Version: {version.hex(' ').upper()}") - if signature: - print(f" Signature: {signature.hex(' ').upper()}") - if counters: - print(f" Counters: {', '.join(str(counters.get(i, 0)) for i in range(max(counters.keys()) + 1))}") - nr_pages = pages_total if pages_total else len(pages) - print(f" Pages: {nr_pages}") - print() - - # --- Step 1: Set slot tag type --- - print(f"Setting slot {self.slot_num} tag type to {tag_type}...") - self.cmd.set_slot_tag_type(self.slot_num, tag_type) - self.cmd.set_slot_data_default(self.slot_num, tag_type) - # Must re-activate slot after changing type so subsequent commands target the new type - self.cmd.set_active_slot(self.slot_num) - - # --- Step 2: Set anti-collision data --- - print("Setting anti-collision data...") - self.cmd.hf14a_set_anti_coll_data(uid, atqa, sak) - - # --- Step 3: Set version data --- - if version and len(version) == 8: - print("Setting version data...") - try: - self.cmd.mf0_ntag_set_version_data(version) - except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): - print(color_string((CY, " Warning: tag type does not support GET_VERSION."))) - - # --- Step 4: Set signature data --- - if signature and len(signature) == 32: - print("Setting signature data...") - try: - self.cmd.mf0_ntag_set_signature_data(signature) - except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): - print(color_string((CY, " Warning: tag type does not support READ_SIG."))) - - # --- Step 5: Set counter and tearing data --- - if counters: - print("Setting counter data...") - # NTAG types have a single counter accessed via NFC at index 2, - # but stored at firmware internal index 0 - ntag_types = { - TagSpecificType.NTAG_210, TagSpecificType.NTAG_212, - TagSpecificType.NTAG_213, TagSpecificType.NTAG_215, - TagSpecificType.NTAG_216, - } - for i in sorted(counters.keys()): - value = counters[i] - if value > 0xFFFFFF: - print(color_string((CY, f" Warning: counter {i} value {value:#x} exceeds 24-bit, skipping."))) - continue - # Map Flipper counter index to firmware internal index - if tag_type in ntag_types: - if i != 2: - continue # NTAG only has counter at NFC index 2 - fw_index = 0 - else: - fw_index = i - # Reset tearing flag if tearing byte is BD (default / no tearing) - tearing_val = tearing.get(i, 0x00) - reset_tearing = (tearing_val == 0xBD or tearing_val == 0x00) - try: - self.cmd.mfu_write_emu_counter_data(fw_index, value, reset_tearing) - except (ValueError, chameleon_com.CMDInvalidException, UnexpectedResponseError, TimeoutError): - print(color_string((CY, f" Warning: could not set counter {i}."))) - - # --- Step 6: Write page data --- - if pages: - # Get total pages for the configured slot - slot_pages = self.cmd.mfu_get_emu_pages_count() - - # Build contiguous data from parsed pages - max_page = max(pages.keys()) - write_pages = min(max_page + 1, slot_pages) - - print(f"Writing {write_pages} pages...", end=' ', flush=True) - - page = 0 - while page < write_pages: - cur_count = min(16, write_pages - page) - batch = bytearray() - for p in range(page, page + cur_count): - batch.extend(pages.get(p, b'\x00\x00\x00\x00')) - self.cmd.mfu_write_emu_page_data(page, bytes(batch)) - page += cur_count - - print("done") - - # --- Step 7: Derive and write amiibo PWD/PACK --- - if args.amiibo: - if tag_type != TagSpecificType.NTAG_215: - print(color_string((CY, f" Warning: --amiibo flag ignored (tag type is {tag_type}, not NTAG 215)."))) - elif uid is None or len(uid) != 7: - print(color_string((CY, " Warning: --amiibo flag ignored (UID is not 7 bytes)."))) - else: - pwd = bytes([ - 0xAA ^ uid[1] ^ uid[3], - 0x55 ^ uid[2] ^ uid[4], - 0xAA ^ uid[3] ^ uid[5], - 0x55 ^ uid[4] ^ uid[6], - ]) - pack = bytes([0x80, 0x80, 0x00, 0x00]) - print(f"Setting amiibo PWD: {pwd.hex(' ').upper()}, PACK: {pack[:2].hex(' ').upper()}...") - self.cmd.mfu_write_emu_page_data(133, pwd) - self.cmd.mfu_write_emu_page_data(134, pack) - - self.cmd.set_slot_enable(self.slot_num, TagSenseType.HF, True) - - print() - print(f" - Import complete. Slot {self.slot_num} is now emulating {device_type} ({file_name})") - - -@lf_em_410x.command('read') +@lf_em_410x.command("read") class LFEMRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() @@ -6244,20 +6009,18 @@ class LFVikingWriteT55xx(LFVikingIdArgsUnit, ReaderRequiredUnit): print(f" - Viking ID(8H): {id_hex} write done.") -@lf.command("clone") +@lf_t55xx.command("clone") class LFT55xxClone(ReaderRequiredUnit): """ - Clone a LF card ID onto a blank T55xx tag. - - Usage: lf clone -t [args] + Clone a scanned or manually-specified LF card ID onto a blank T55xx tag. Supported types and their required arguments: - em410x --id <10 hex> e.g. lf clone -t em410x --id DEADBEEF88 - electra --id <26 hex> e.g. lf clone -t electra --id DEADBEEF880102030405060708 - hid -f --cn e.g. lf clone -t hid -f H10301 --fc 10 --cn 1234 + em410x --id <10 hex> e.g. --id DEADBEEF88 + electra --id <26 hex> e.g. --id DEADBEEF880102030405060708 + hid -f --cn e.g. -f H10301 --fc 10 --cn 1234 ioprox --ver --fc --cn OR --raw8 <16 hex> - viking --id <8 hex> e.g. lf clone -t viking --id DEADBEEF + viking --id <8 hex> e.g. --id DEADBEEF Only supported on Chameleon Ultra (Lite has no LF writer). """ @@ -6268,7 +6031,6 @@ class LFT55xxClone(ReaderRequiredUnit): parser = ArgumentParserNoExit() parser.description = ( "Clone a LF card ID onto a blank T55xx tag.\n" - "Usage: lf clone -t [args]\n" "Supported types: em410x, electra, hid, ioprox, viking.\n" "Only supported on Chameleon Ultra (Lite has no LF writer)." ) @@ -6388,7 +6150,7 @@ class LFT55xxClone(ReaderRequiredUnit): elif t == "ioprox": ver = args.ver if args.ver is not None else 1 - fc = args.fc if args.fc is not None else 0 + fc = int(args.fc, 0) if args.fc is not None else 0 cn = args.cn if args.cn is not None else 0 if args.raw8 is not None: raw8 = LFIOProxIdArgsUnit.parse_raw8(args.raw8) @@ -7552,12 +7314,16 @@ class HF14ASniff(BaseCLIUnit): return # Parse packed frame buffer: [2 bytes bits BE][N bytes data] ... + # Bit 15 of szBits: 0 = reader→card, 1 = card→reader (new firmware). + # Old firmware always sends bit15=0; parser is backward compatible. buf = bytes(resp.data) - frames = [] + frames = [] # (szBits, data, is_tx) i = 0 while i + 2 <= len(buf): - szBits = (buf[i] << 8) | buf[i+1] + hdr = (buf[i] << 8) | buf[i+1] i += 2 + is_tx = bool(hdr & 0x8000) + szBits = hdr & 0x7FFF if szBits == 0: break szBytes = (szBits + 7) // 8 @@ -7586,29 +7352,83 @@ class HF14ASniff(BaseCLIUnit): else: data = raw - frames.append((szBits, data)) + frames.append((szBits, data, is_tx)) if not frames: print(f"{CR}No frames decoded{C0}") return - print(f" Captured : {CG}{len(frames)}{C0} frame(s)") + rx_count = sum(1 for _, _, tx in frames if not tx) + tx_count = sum(1 for _, _, tx in frames if tx) + if tx_count > 0: + print(f" Captured : {CG}{len(frames)}{C0} frame(s) " + f"({CY}{rx_count}{C0} reader→card {CG}{tx_count}{C0} card→reader)") + else: + print(f" Captured : {CG}{len(frames)}{C0} frame(s) " + f"{CY}(reader→card only — reflash for both directions){C0}") print() - print(f" {'#':>3} {'bits':>4} {'hex data':<42} decoded") - print(f" {'---':>3} {'----':>4} {'-'*42} {'-'*35}") + print(f" {'#':>3} {'dir':<3} {'bits':>4} {'hex data':<42} decoded") + print(f" {'---':>3} {'---':<3} {'----':>4} {'-'*42} {'-'*35}") - for n, (szBits, data) in enumerate(frames): - hex_str = ' '.join(f'{b:02x}' for b in data) - decoded, col = _decode_14a_frame_col(data, szBits) - print(f" {CY}{n+1:>3}{C0} {szBits:>4} {hex_str:<42} {col}{decoded}{C0}") + for n, (szBits, data, is_tx) in enumerate(frames): + hex_str = ' '.join(f'{b:02x}' for b in data) + decoded, col = _decode_14a_frame_col(data, szBits) + dir_str = f'{CG}<<<{C0}' if is_tx else f'{CY}>>>{C0}' + print(f" {CY}{n+1:>3}{C0} {dir_str} {szBits:>4} {hex_str:<42} {col}{decoded}{C0}") - # Summary block + # Summary block (pass only reader→card frames for protocol decode) print() - _print_14a_sniff_summary(frames) + _print_14a_sniff_summary([(s, d) for s, d, tx in frames if not tx]) + +def _decode_sw(sw1: int, sw2: int) -> str: + """Decode an ISO 7816-4 status word pair.""" + exact = { + 0x9000: 'OK', + 0x6100: 'Response bytes available', + 0x6283: 'File deactivated', + 0x6300: 'Auth failed', + 0x6400: 'No changes', + 0x6581: 'Memory failure', + 0x6700: 'Wrong length', + 0x6881: 'Logical channel not supported', + 0x6882: 'Secure messaging not supported', + 0x6900: 'Command not allowed', + 0x6981: 'Command incompatible with file structure', + 0x6982: 'Security status not satisfied', + 0x6983: 'Auth method blocked', + 0x6984: 'Referenced data invalidated', + 0x6985: 'Conditions of use not satisfied', + 0x6986: 'Command not allowed — no EF selected', + 0x6A00: 'Wrong parameters P1-P2', + 0x6A80: 'Incorrect data in command', + 0x6A81: 'Function not supported', + 0x6A82: 'File not found', + 0x6A83: 'Record not found', + 0x6A84: 'Not enough memory', + 0x6A85: 'Lc inconsistent with TLV', + 0x6A86: 'Incorrect parameters P1-P2', + 0x6A87: 'Lc inconsistent with P1-P2', + 0x6A88: 'Referenced data not found', + 0x6B00: 'Wrong parameters P1-P2', + 0x6D00: 'Instruction not supported', + 0x6E00: 'Class not supported', + 0x6F00: 'Unknown error', + } + key = (sw1 << 8) | sw2 + if key in exact: + return exact[key] + if sw1 == 0x61: return f'Response bytes available: {sw2}' + if sw1 == 0x62: return f'Warning — no info change: {sw2:02X}' + if sw1 == 0x63: return f'Warning — state changed: {sw2:02X}' + if sw1 == 0x6C: return f'Wrong Le — use {sw2}' + if sw1 == 0x90: return 'OK' + if sw1 == 0x91: return 'Proprietary OK' + return '' + def _decode_14a_frame_col(data: bytes, szBits: int): """Return (description, colour) for a 14A frame.""" if not data: @@ -7731,6 +7551,18 @@ def _decode_14a_frame_col(data: bytes, szBits: int): return 'MANAGE CHANNEL', CC return f'APDU CLA={cla:02x} INS={ins:02x} P1={p1:02x} P2={p2:02x}', CY + # ISO 7816-4 status word — scan last 2 bytes (and last 4 if CRC present) + sw_label = '' + for sw_offset in (-2, -4): + if len(data) >= abs(sw_offset): + s1, s2 = data[sw_offset], data[sw_offset + 1] + lbl = _decode_sw(s1, s2) + if lbl: + sw_label = f'SW {s1:02X} {s2:02X} {lbl}' + break + if sw_label: + return sw_label, CY + # Unknown — show first byte return f'unknown (0x{b0:02x})', CC @@ -8633,11 +8465,13 @@ class EMVScan(DeviceRequiredUnit): print(f'') print(f' {CG}── Card Details ──────────────────────{C0}') - # App label - for v in tags.get(0x50, []): + # App label — show first unique label only + seen_labels = set() + for v in tags.get(0x50, []) + app_tags.get(0x50, []): try: lbl = v.decode('ascii', errors='replace').strip() - if lbl: + if lbl and lbl not in seen_labels: + seen_labels.add(lbl) print(f' {CG}App Label :{C0} {CY}{lbl}{C0}') except Exception: pass @@ -8706,15 +8540,12 @@ class EMVScan(DeviceRequiredUnit): print(f' {CG}Issuer Country:{C0} {CY}{country}{C0}') result.setdefault('Decoded', {})['IssuerCountry'] = country - # Application Preferred Name / Label + # Application Preferred Name (9F12) — only if different from label for v in tags[0x9F12]: try: - print(f' {CG}App Name :{C0} {CY}{v.decode("ascii", errors="replace").strip()}{C0}') - except Exception: - pass - for v in tags[0x50]: - try: - print(f' {CG}App Label :{C0} {CY}{v.decode("ascii", errors="replace").strip()}{C0}') + name = v.decode('ascii', errors='replace').strip() + if name and name not in seen_labels: + print(f' {CG}App Name :{C0} {CY}{name}{C0}') except Exception: pass @@ -9069,4 +8900,4 @@ class EMVApdu(DeviceRequiredUnit): print(f' {CR}Error sending response: {e}{C0}') break - print(f'\n {C0}Relay ended. {exchange_count} APDU exchange(s) completed.{C0}') + print(f'\n {C0}Relay ended. {exchange_count} APDU exchange(s) completed.{C0}') \ No newline at end of file