fix hf14a sniff

This commit is contained in:
Niel Nielsen
2026-04-14 09:32:35 +02:00
parent 0ce680b5c7
commit d70a0dd63f
2 changed files with 218 additions and 288 deletions
+116 -17
View File
@@ -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) {
+102 -271
View File
@@ -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 <type> [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 <format> --cn <n> 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 <format> --cn <n> e.g. -f H10301 --fc 10 --cn 1234
ioprox --ver <n> --fc <n> --cn <n> 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 <type> [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}')