diff --git a/firmware/application/src/app_cmd.c b/firmware/application/src/app_cmd.c index d4f42e1..59bc76a 100644 --- a/firmware/application/src/app_cmd.c +++ b/firmware/application/src/app_cmd.c @@ -19,6 +19,8 @@ #include "lf_reader_generic.h" #include "lf_em4x05_data.h" #include "rc522.h" +#include "mf1_crapto1.h" +#include "parity.h" #endif #include "nfc_14a.h" /* Forward declarations for functions added to nfc_14a.c/h in this PR. @@ -1872,6 +1874,261 @@ static data_frame_tx_t *cmd_processor_lf_sniff(uint16_t cmd, uint16_t status, ui } return data_frame_make(cmd, STATUS_LF_TAG_OK, (uint16_t)outlen, sniff_buf); } +/* ======================================================================== + * HF14A AUTH TRACE — full anticoll + Crypto1 auth flow, every frame returned. + * + * Performs a complete reader-side auth sequence against a real MIFARE Classic + * tag and packs every wire frame (synthesized anticoll + actual auth) into the + * same trace-buffer format used by `hf 14a sniff`, so the existing host-side + * decoder can render the full exchange. + * + * Buffer format (identical to hf 14a sniff): [bits_be16][data...] per frame. + * Bit15 of the bit-count header: 0 = reader→card, 1 = card→reader. + * + * Request payload: [type:1][block:1][key:6] — 8 bytes total + * type: PICC_AUTHENT1A (0x60) or PICC_AUTHENT1B (0x61) + * block: target block number + * key: 6-byte sector key + * + * Response payload: packed frame buffer, decoded host-side. + * Status: STATUS_HF_TAG_OK if the auth completed (NT, NR||AR, AT all captured), + * STATUS_HF_TAG_NO if no card was found, + * STATUS_MF_ERR_AUTH if AT was wrong or missing (partial trace returned), + * STATUS_PAR_ERR on bad payload length. + * ======================================================================== */ +#define HF_AUTH_TRACE_BUF_SIZE 256 + +static uint8_t m_auth_trace_buf[HF_AUTH_TRACE_BUF_SIZE]; +static uint16_t m_auth_trace_len = 0; + +/* Append one frame to the trace buffer, mirroring hf14a_sniff_store(). */ +static void auth_trace_store(const uint8_t *data, uint16_t szBits, bool is_tx) { + uint16_t szBytes = (szBits + 7) / 8; + if (m_auth_trace_len + 2 + szBytes > HF_AUTH_TRACE_BUF_SIZE) return; + uint16_t hdr = szBits | (is_tx ? 0x8000u : 0x0000u); + m_auth_trace_buf[m_auth_trace_len++] = (hdr >> 8) & 0xFF; + m_auth_trace_buf[m_auth_trace_len++] = hdr & 0xFF; + memcpy(&m_auth_trace_buf[m_auth_trace_len], data, szBytes); + m_auth_trace_len += szBytes; +} + +/* Synthesize the anticoll/SELECT/SAK/RATS/ATS frames from a populated tag + * struct. The wire frames during scan_auto are perfectly determined by the + * tag descriptor, so we reconstruct them rather than tap rc522.c. + * + * For 4-byte UID (cascade=1): CL1 with full UID. + * For 7-byte UID (cascade=2): CL1 with CT||UID0..2, CL2 with UID3..6. + * For 10-byte UID (cascade=3): CL1, CL2, CL3. + * + * The "first SAK" for an incomplete UID has bit 2 set (cascade flag); we + * synthesize it as 0x04 (cascade required, no further info), which matches + * what every real cascading tag returns. Final SAK is the captured tag->sak. + */ +static void auth_trace_emit_anticoll(const picc_14a_tag_t *tag) { + /* 1. REQA — 7 bits, reader→card */ + uint8_t reqa = PICC_REQIDL; + auth_trace_store(&reqa, 7, false); + + /* 2. ATQA — 16 bits, card→reader */ + auth_trace_store(tag->atqa, 16, true); + + /* 3. Anticoll/SELECT cycles — one per cascade level */ + const uint8_t cascade_anticoll[3] = { PICC_ANTICOLL1, PICC_ANTICOLL2, PICC_ANTICOLL3 }; + uint8_t uid_pos = 0; + for (uint8_t cl = 0; cl < tag->cascade; cl++) { + bool is_last = (cl == (uint8_t)(tag->cascade - 1)); + + /* Anticoll request: 0x20 — 16 bits, reader→card */ + uint8_t anticoll[2] = { cascade_anticoll[cl], 0x20 }; + auth_trace_store(anticoll, 16, false); + + /* Anticoll response: CT||UID3 + BCC for non-last, or UID4 + BCC for last + * with full UID inline. 5 bytes / 40 bits, card→reader. */ + uint8_t resp[5]; + if (is_last) { + /* Last level — 4 bytes of UID at the end of tag->uid */ + memcpy(resp, &tag->uid[uid_pos], 4); + } else { + /* Cascading — CT (0x88) + 3 bytes UID */ + resp[0] = 0x88; + memcpy(&resp[1], &tag->uid[uid_pos], 3); + uid_pos += 3; + } + resp[4] = resp[0] ^ resp[1] ^ resp[2] ^ resp[3]; /* BCC */ + auth_trace_store(resp, 40, true); + + /* SELECT: 0x70 + 5-byte CT/UID/BCC + 2-byte CRC = 9 bytes / 72 bits */ + uint8_t sel[9] = { cascade_anticoll[cl], 0x70 }; + memcpy(&sel[2], resp, 5); + crc_14a_calculate(sel, 7, &sel[7]); + auth_trace_store(sel, 72, false); + + /* SAK: 1 byte SAK + 2-byte CRC = 24 bits, card→reader. + * Intermediate cascades: synthesize SAK=0x04 (cascade bit set, generic). + * Last cascade: real tag->sak. */ + uint8_t sak_frame[3]; + sak_frame[0] = is_last ? tag->sak : 0x04; + crc_14a_calculate(sak_frame, 1, &sak_frame[1]); + auth_trace_store(sak_frame, 24, true); + } + + /* 4. RATS / ATS — only if scan_auto did RATS and the tag responded */ + if (tag->ats_len > 0) { + /* RATS request: 0xE0 0x40 + 2-byte CRC = 4 bytes / 32 bits, reader→card. + * 0x40 = FSDI=4 (FSD=48), CID=0 — same as pcd_14a_reader_ats_request(). */ + uint8_t rats[4] = { PICC_RATS, 0x40 }; + crc_14a_calculate(rats, 2, &rats[2]); + auth_trace_store(rats, 32, false); + + /* ATS response: ats_len bytes (CRC was stripped by rc522 layer) + + * recomputed 2-byte CRC, card→reader. */ + if ((uint16_t)tag->ats_len + 2 <= 64) { + uint8_t ats_frame[64]; + memcpy(ats_frame, tag->ats, tag->ats_len); + crc_14a_calculate(ats_frame, tag->ats_len, &ats_frame[tag->ats_len]); + auth_trace_store(ats_frame, (tag->ats_len + 2) * 8, true); + } + } +} + +/* Software-side MIFARE Classic auth tap. Mirrors mf1_toolbox.c::authex() but + * stores every TX/RX frame into the trace buffer instead of just returning a + * status. Fixed reader nonce 12345678 is used (same as authex). */ +static uint8_t auth_trace_do_auth(picc_14a_tag_t *tag, uint8_t type, uint8_t blockNo, const uint8_t *key6) { + struct Crypto1State pcs = { 0, 0 }; + static const uint8_t nr[4] = { 0x12, 0x34, 0x56, 0x78 }; /* fixed reader nonce */ + uint8_t par[8] = { 0 }; + uint8_t mf_nr_ar[8] = { 0 }; + uint8_t answer[8] = { 0 }; + uint8_t parity_resp[8] = { 0 }; + uint16_t len = 0; + uint8_t status; + + /* AUTH command frame: [type, blockNo, CRC, CRC] — 32 bits, reader→card */ + uint8_t auth_cmd[4] = { type, blockNo }; + crc_14a_calculate(auth_cmd, 2, &auth_cmd[2]); + auth_trace_store(auth_cmd, 32, false); + + /* Send AUTH, expect 4-byte NT (plaintext on first auth). We use the same + * primitive as authex's send_cmd() under the non-encrypted path. */ + pcd_14a_reader_bytes_transfer(PCD_TRANSCEIVE, auth_cmd, 4, answer, &len, U8ARR_BIT_LEN(answer)); + if (len != 32) { + return STATUS_HF_ERR_STAT; /* no NT — partial trace already in buffer */ + } + + /* Capture NT — 32 bits, card→reader */ + auth_trace_store(answer, 32, true); + uint32_t nt = ((uint32_t)answer[0] << 24) | ((uint32_t)answer[1] << 16) + | ((uint32_t)answer[2] << 8) | (uint32_t)answer[3]; + + /* Initialise Crypto1 with key (LSB-first, MIFARE convention) */ + uint64_t ui64Key = 0; + for (int i = 0; i < 6; i++) ui64Key |= (uint64_t)key6[i] << ((5 - i) * 8); + crypto1_init(&pcs, ui64Key); + + /* First auth: feed (nt ^ uid) as plaintext into the cipher */ + uint32_t uid32 = get_u32_tag_uid(tag); + crypto1_word(&pcs, nt ^ uid32, 0); + + /* Encrypt NR + parity */ + for (int pos = 0; pos < 4; pos++) { + mf_nr_ar[pos] = crypto1_byte(&pcs, nr[pos], 0) ^ nr[pos]; + par[pos] = filter(pcs.odd) ^ oddparity8(nr[pos]); + } + /* Compute AR = prng_successor(nt, 32) and encrypt byte-by-byte */ + uint32_t nt_succ = prng_successor(nt, 32); + for (int pos = 4; pos < 8; pos++) { + nt_succ = prng_successor(nt_succ, 8); + mf_nr_ar[pos] = crypto1_byte(&pcs, 0x00, 0) ^ (nt_succ & 0xFF); + par[pos] = filter(pcs.odd) ^ oddparity8(nt_succ & 0xFF); + } + + /* Capture NR||AR (encrypted) — 64 bits, reader→card. Stored before the + * actual transfer so we still have the trace if the transfer fails. */ + auth_trace_store(mf_nr_ar, 64, false); + + /* Send NR||AR with manual parity, expect 4-byte AT */ + pcd_14a_reader_bits_transfer(mf_nr_ar, 64, par, answer, parity_resp, &len, U8ARR_BIT_LEN(answer)); + if (len != 32) { + return STATUS_MF_ERR_AUTH; /* no AT — auth was rejected, partial trace */ + } + + /* Capture AT — 32 bits, card→reader (still encrypted on the wire) */ + auth_trace_store(answer, 32, true); + + /* Verify AT == prng_successor(nt, 64) ^ ks3 */ + uint32_t at_recv = ((uint32_t)answer[0] << 24) | ((uint32_t)answer[1] << 16) + | ((uint32_t)answer[2] << 8) | (uint32_t)answer[3]; + uint32_t ntpp = prng_successor(nt_succ, 32) ^ crypto1_word(&pcs, 0, 0); + status = (ntpp == at_recv) ? STATUS_HF_TAG_OK : STATUS_MF_ERR_AUTH; + + /* Auth state may be left armed in the RC522 — clear it for safety so + * subsequent reader operations start clean. */ + pcd_14a_reader_mf1_unauth(); + return status; +} + +static data_frame_tx_t *cmd_processor_hf14a_auth_trace(uint16_t cmd, uint16_t status, uint16_t length, uint8_t *data) { + /* Payload: [type:1][block:1][key:6][timeout_ms_be16:2] = 10 bytes. + * Backward-compatible short form [type:1][block:1][key:6] = 8 bytes still + * accepted; default 5000ms. */ + uint32_t timeout_ms = 5000; + if (length == 10) { + timeout_ms = ((uint32_t)data[8] << 8) | data[9]; + if (timeout_ms == 0 || timeout_ms > 30000) timeout_ms = 5000; + } else if (length != 8) { + return data_frame_make(cmd, STATUS_PAR_ERR, 0, NULL); + } + uint8_t type = data[0]; + uint8_t block = data[1]; + uint8_t *key = &data[2]; + if (type != PICC_AUTHENT1A && type != PICC_AUTHENT1B) { + return data_frame_make(cmd, STATUS_PAR_ERR, 0, NULL); + } + + m_auth_trace_len = 0; + + /* Step 1: poll for a tag in the field until present or timeout. + * scan_auto internally tries twice with REQA; if no card is present + * each attempt fails fast (~3-5 ms) and we back off briefly between + * iterations to keep the WDT happy and avoid hammering the RC522. + * The antenna is already on (before_hf_reader_run hook) and stays on + * for the whole polling window — same behaviour as a desktop reader. */ + picc_14a_tag_t tag; + uint8_t scan_status = STATUS_HF_TAG_NO; + autotimer *p_at = bsp_obtain_timer(0); + while (NO_TIMEOUT_1MS(p_at, timeout_ms)) { + scan_status = pcd_14a_reader_scan_auto(&tag); + if (scan_status == STATUS_HF_TAG_OK) { + break; + } + /* 50 ms back-off between attempts — yields to USB/BLE main loop + * and keeps the WDT fed (timeout is 5000 ms). */ + for (int i = 0; i < 50; i++) { + bsp_delay_ms(1); + bsp_wdt_feed(); + } + } + bsp_return_timer(p_at); + + if (scan_status != STATUS_HF_TAG_OK) { + return data_frame_make(cmd, STATUS_HF_TAG_NO, 0, NULL); + } + + /* Step 2: synthesize the wire frames that scan_auto just produced */ + auth_trace_emit_anticoll(&tag); + + /* Step 3: perform the auth, tapping every TX/RX into the same buffer */ + uint8_t auth_status = auth_trace_do_auth(&tag, type, block, key); + + /* Always return the buffer — even on auth failure, the partial trace is + * useful for diagnosing why (wrong key vs. wrong block vs. no NT etc.). */ + if (m_auth_trace_len == 0) { + return data_frame_make(cmd, auth_status, 0, NULL); + } + return data_frame_make(cmd, auth_status, m_auth_trace_len, m_auth_trace_buf); +} + #define HF_SNIFF_BUF_SIZE 3800 /* leave room for USB framing */ #define HF_SNIFF_MAX_FRAMES 200 @@ -2676,6 +2933,7 @@ static cmd_data_map_t m_data_cmd_map[] = { { DATA_CMD_EM4X05_SCAN, before_reader_run, cmd_processor_em4x05_scan, NULL }, { DATA_CMD_LF_SNIFF, before_reader_run, cmd_processor_lf_sniff, NULL }, { DATA_CMD_HF14A_SNIFF, NULL, cmd_processor_hf14a_sniff, NULL }, + { DATA_CMD_HF14A_AUTH_TRACE, before_hf_reader_run, cmd_processor_hf14a_auth_trace, after_hf_reader_run }, #endif diff --git a/firmware/application/src/data_cmd.h b/firmware/application/src/data_cmd.h index 22ae409..c3cb282 100644 --- a/firmware/application/src/data_cmd.h +++ b/firmware/application/src/data_cmd.h @@ -70,6 +70,7 @@ #define DATA_CMD_MF1_WRITE_ONE_BLOCK (2009) #define DATA_CMD_HF14A_RAW (2010) #define DATA_CMD_HF14A_SCAN_KEEP (2016) /* scan+RATS, keep field alive for APDU exchange */ +#define DATA_CMD_HF14A_AUTH_TRACE (2017) /* full anticoll + Crypto1 auth, every frame returned for inspection */ #define DATA_CMD_MF1_MANIPULATE_VALUE_BLOCK (2011) #define DATA_CMD_MF1_CHECK_KEYS_OF_SECTORS (2012) #define DATA_CMD_MF1_HARDNESTED_ACQUIRE (2013) diff --git a/firmware/application/src/rfid/nfctag/lf/lf_tag_em.c b/firmware/application/src/rfid/nfctag/lf/lf_tag_em.c index 2a83193..3f5ab03 100644 --- a/firmware/application/src/rfid/nfctag/lf/lf_tag_em.c +++ b/firmware/application/src/rfid/nfctag/lf/lf_tag_em.c @@ -24,7 +24,6 @@ NRF_LOG_MODULE_REGISTER(); #define ANT_NO_MOD() nrf_gpio_pin_clear(LF_MOD) -#define LF_125KHZ_BROADCAST_MAX (10) // Whether the USB light effect is allowed to enable extern bool g_usb_led_marquee_enable; @@ -43,7 +42,8 @@ static void lf_field_lost(void) { g_is_tag_emulating = false; // Reset the flag in the emulation m_is_lf_emulating = false; TAG_FIELD_LED_OFF() // Make sure the indicator light of the LF field status - NRF_LPCOMP->INTENSET = LPCOMP_INTENCLR_CROSS_Msk | LPCOMP_INTENCLR_UP_Msk | LPCOMP_INTENCLR_DOWN_Msk | LPCOMP_INTENCLR_READY_Msk; + // Re-arm LPCOMP so the next field appearance triggers lpcomp_event_handler. + NRF_LPCOMP->INTENSET = LPCOMP_INTENSET_UP_Msk; // call sleep_timer_start *after* unsetting g_is_tag_emulating sleep_timer_start(SLEEP_DELAY_MS_FIELD_125KHZ_LOST); // Start the timer to enter the sleep NRF_LOG_INFO("LF FIELD LOST"); @@ -68,12 +68,15 @@ bool is_lf_field_exists(void) { * priority is set to APP_IRQ_PRIORITY_HIGH). */ static void lpcomp_event_handler(nrf_lpcomp_event_t event) { - // Only when the lf -frequency emulation is not launched, and the analog card is started + // Only when the lf-frequency emulation is not launched, and the analog card is started if (m_is_lf_emulating || event != NRF_LPCOMP_EVENT_UP) { return; } sleep_timer_stop(); // turn off dormant delay + // Disable LPCOMP during emulation — LF_RSSI fluctuates during load + // modulation and would trigger spurious DOWN events with DETECT_CROSS. + // Field-loss is checked periodically via EVT_END_SEQ0 in pwm_handler. nrfx_lpcomp_disable(); // set the emulation status logo bit @@ -86,8 +89,9 @@ static void lpcomp_event_handler(nrf_lpcomp_event_t event) { set_slot_light_color(RGB_BLUE); TAG_FIELD_LED_ON() - // use precise hardware timer to broadcast card id - nrfx_pwm_simple_playback(&m_broadcast, m_pwm_seq, LF_125KHZ_BROADCAST_MAX, NRFX_PWM_FLAG_STOP); + // Loop continuously — no stop/restart gaps between sequence plays. + // Field-loss is detected in pwm_handler via EVT_END_SEQ0. + nrfx_pwm_simple_playback(&m_broadcast, m_pwm_seq, 1, NRFX_PWM_FLAG_LOOP); NRF_LOG_INFO("LF FIELD DETECTED"); } @@ -104,21 +108,23 @@ static void lpcomp_init(void) { } static void pwm_handler(nrfx_pwm_evt_type_t event_type) { + if (event_type == NRFX_PWM_EVT_END_SEQ0) { + // Fired at end of each loop iteration — check field without stopping PWM. + // Mask UP interrupt while sampling to prevent re-entrancy. + NRF_LPCOMP->INTENCLR = LPCOMP_INTENCLR_UP_Msk; + if (!is_lf_field_exists()) { + // Field gone — stop the loop; pwm_handler will get EVT_STOPPED next. + nrfx_pwm_stop(&m_broadcast, false); + } + // Re-enable will happen either in lf_field_lost (via INTENSET) or stays + // suppressed while PWM keeps looping (we only need it after field_lost). + return; + } if (event_type != NRFX_PWM_EVT_STOPPED) { return; } - - // after last broadcast, force NO_MOD on antenna to measure field. ANT_NO_MOD(); - bsp_delay_ms(1); - // We don't need any events, but only need to detect the state of the field - NRF_LPCOMP->INTENCLR = LPCOMP_INTENCLR_CROSS_Msk | LPCOMP_INTENCLR_UP_Msk | LPCOMP_INTENCLR_DOWN_Msk | LPCOMP_INTENCLR_READY_Msk; - if (is_lf_field_exists()) { - nrfx_lpcomp_disable(); - nrfx_pwm_simple_playback(&m_broadcast, m_pwm_seq, LF_125KHZ_BROADCAST_MAX, NRFX_PWM_FLAG_STOP); - } else { - lf_field_lost(); - } + lf_field_lost(); } static void pwm_init(void) { diff --git a/software/script/chameleon_cli_unit.py b/software/script/chameleon_cli_unit.py index a810929..ae9de80 100644 --- a/software/script/chameleon_cli_unit.py +++ b/software/script/chameleon_cli_unit.py @@ -7560,6 +7560,225 @@ class HF14ASniff(BaseCLIUnit): +@hf_14a.command("auth-trace") +class HF14AAuthTrace(ReaderRequiredUnit): + def args_parser(self) -> ArgumentParserNoExit: + parser = ArgumentParserNoExit() + parser.formatter_class = argparse.RawDescriptionHelpFormatter + parser.description = ( + "Run a full reader-side ISO14443A + MIFARE Classic Crypto1 auth " + "against a real card and print every wire frame: REQA → ATQA → " + "anticoll/SELECT → SAK → (RATS/ATS) → AUTH(0x60/0x61) → NT → " + "NR||AR (enc) → AT (enc), with host-side Crypto1 decryption of " + "the auth sub-frames for verification." + ) + parser.add_argument( + "--blk", "--block", type=int, required=True, metavar="", + help="Target block number" + ) + keytype_group = parser.add_mutually_exclusive_group() + keytype_group.add_argument("-a", "-A", action="store_true", help="Use Key A (default)") + keytype_group.add_argument("-b", "-B", action="store_true", help="Use Key B") + parser.add_argument( + "-k", "--key", type=str, required=True, metavar="", + help="6-byte sector key (12 hex chars)" + ) + parser.add_argument( + "-t", "--timeout", type=int, default=5000, metavar="", + help="Tag-presence polling timeout in ms (1-30000, default 5000)" + ) + parser.epilog = """ +examples: + hf 14a auth-trace --blk 0 -k FFFFFFFFFFFF + hf 14a auth-trace --blk 4 -b -k A0A1A2A3A4A5 + hf 14a auth-trace --blk 0 -k FFFFFFFFFFFF -t 10000 # wait up to 10s for tag +""" + return parser + + def on_exec(self, args: argparse.Namespace): + # Validate key + key_hex = args.key.replace(" ", "") + if not re.match(r"^[0-9a-fA-F]{12}$", key_hex): + print(f" [!] {color_string((CR, 'Key must be exactly 12 hex characters'))}") + return + key_bytes = bytes.fromhex(key_hex) + key_type = 0x61 if args.b else 0x60 + block = args.blk + timeout_ms = max(1, min(30000, int(args.timeout))) + + print(f" Running auth trace: block={block} keyType={'B' if args.b else 'A'} key={key_hex.upper()}") + print(f" Waiting up to {timeout_ms} ms for a MIFARE Classic card... " + f"({CY}place CU on a card now{C0})") + print() + + try: + resp = self.cmd.hf14a_auth_trace(block, key_type, key_bytes, timeout_ms=timeout_ms) + except Exception as e: + if 'CMDInvalid' in type(e).__name__ or '2017' in str(e): + print(f"{CR}Command not supported — reflash firmware to enable hf 14a auth-trace{C0}") + else: + print(f"{CR}{e}{C0}") + return + + # Status legend: + # HF_TAG_OK — auth succeeded, full trace + # HF_TAG_NO — no card / scan failed + # MF_ERR_AUTH — auth failed (wrong key / wrong block), partial trace returned + # HF_ERR_STAT — no NT received, partial trace returned + if resp.status == Status.HF_TAG_NO: + print(f"{CR} No 14443A tag in field — auth aborted{C0}") + return + + if not resp.data: + print(f"{CR} No frames returned (status={Status(resp.status).name}){C0}") + return + + # Parse packed frame buffer — same format as hf 14a sniff. + # [bits_be16][data...]; bit15 of bits = direction (1 = card→reader). + buf = bytes(resp.data) + frames = [] # (szBits, data, is_tx) + i = 0 + while i + 2 <= len(buf): + 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 + if i + szBytes > len(buf): + break + raw = buf[i:i + szBytes] + i += szBytes + # Auth-trace stores parity-stripped data (firmware byte/bits + # transfer primitives strip parity automatically), so szBits is + # always a multiple of 8 — no unwrap needed. + frames.append((szBits, raw, is_tx)) + + if not frames: + print(f"{CR}No frames decoded{C0}") + return + + # Header + rx_count = sum(1 for _, _, tx in frames if not tx) + tx_count = sum(1 for _, _, tx in frames if tx) + status_label = { + Status.HF_TAG_OK: f"{CG}auth OK{C0}", + Status.MF_ERR_AUTH: f"{CR}auth FAILED{C0}", + Status.HF_ERR_STAT: f"{CR}no NT received{C0}", + }.get(Status(resp.status), f"{CY}status={Status(resp.status).name}{C0}") + print(f" Captured : {CG}{len(frames)}{C0} frame(s) " + f"({CY}{rx_count}{C0} reader→card {CG}{tx_count}{C0} card→reader) " + f"{status_label}") + print() + print(f" {'#':>3} {'dir':<3} {'bits':>4} {'hex data':<42} decoded") + print(f" {'---':>3} {'---':<3} {'----':>4} {'-' * 42} {'-' * 35}") + + # Auth-state tracker — annotate AUTH cmd, NT, NR||AR, AT specifically. + auth_state = 'idle' # idle → cmd_seen → nt_seen → nr_ar_seen → done + last_auth_keytype = None + last_auth_block = None + nt_int = None + nr_ar_enc = None + at_enc = None + uid_bytes = b'' + + for n, (szBits, data, is_tx) in enumerate(frames): + hex_str = ' '.join(f'{b:02x}' for b in data) + decoded_ctx = None + col_ctx = None + + # Capture UID from the first anticoll RX (CL1 response): 5 bytes. + # If first byte is 0x88 it's a cascading 7-byte UID — second segment + # gives bytes 3..6. For 4-byte UID, all four bytes are here. + if is_tx and szBits == 40 and len(data) == 5 and not uid_bytes: + if data[0] == 0x88: + uid_bytes = data[1:4] # CT|UID0|UID1|UID2|BCC + else: + uid_bytes = data[0:4] + elif is_tx and szBits == 40 and len(data) == 5 and len(uid_bytes) == 3: + uid_bytes = uid_bytes + data[0:4] # 7-byte UID complete + + # AUTH cmd: 0x60/0x61 + block + 2 CRC bytes, reader→card, 32 bits + if (not is_tx) and szBits == 32 and len(data) == 4 and data[0] in (0x60, 0x61): + last_auth_keytype = 'A' if data[0] == 0x60 else 'B' + last_auth_block = data[1] + auth_state = 'cmd_seen' + decoded_ctx = f"AUTH Key{last_auth_keytype} block=0x{last_auth_block:02X} ({last_auth_block}) +CRC" + col_ctx = CG + + # NT: 4 bytes, card→reader, immediately after AUTH cmd + elif is_tx and auth_state == 'cmd_seen' and szBits == 32 and len(data) == 4: + nt_int = int.from_bytes(data, 'big') + auth_state = 'nt_seen' + decoded_ctx = f"NT (card nonce, plaintext) = {data.hex().upper()}" + col_ctx = CG + + # NR||AR encrypted: 8 bytes, reader→card, after NT + elif (not is_tx) and auth_state == 'nt_seen' and szBits == 64 and len(data) == 8: + nr_ar_enc = bytes(data) + auth_state = 'nr_ar_seen' + decoded_ctx = f"NR||AR (enc) NR={data[:4].hex().upper()} AR={data[4:].hex().upper()}" + col_ctx = CG + + # AT encrypted: 4 bytes, card→reader, after NR||AR + elif is_tx and auth_state == 'nr_ar_seen' and szBits == 32 and len(data) == 4: + at_enc = bytes(data) + auth_state = 'done' + decoded_ctx = f"AT (enc) = {data.hex().upper()}" + col_ctx = CG + + decoded, col = _decode_14a_frame_col(data, szBits) + if decoded_ctx is not None: + decoded, col = decoded_ctx, col_ctx + + 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}") + + # Crypto1 verification block — if we have NT + NR||AR, decrypt AR/AT + # and confirm they match prng_successor(NT, 32) / prng_successor(NT, 64). + print() + if nt_int is not None and nr_ar_enc is not None and len(uid_bytes) >= 4: + uid32 = int.from_bytes(uid_bytes[-4:], 'big') # last 4 bytes for cascade≥2 + print(f" {CC}Crypto1 analysis:{C0}") + print(f" UID (low 4 bytes) : {uid_bytes[-4:].hex().upper()}") + print(f" NT (plaintext) : {nt_int:08X}") + print(f" NR (fixed in fw) : 12345678 (encrypted on wire: {nr_ar_enc[:4].hex().upper()})") + ar_expected = Crypto1.prng_next(nt_int, 32) + at_expected = Crypto1.prng_next(nt_int, 64) + print(f" AR expected : {ar_expected:08X} = prng_successor(NT, 32)") + print(f" AR (encrypted) : {nr_ar_enc[4:].hex().upper()}") + if at_enc is not None: + # Re-run Crypto1 forward to recover ks2 (keystream over AT). + state = Crypto1() + state.key = key_hex + state.lfsr48_u32(uid32 ^ nt_int, False) # ks0 + state.lfsr48_u32(int.from_bytes(nr_ar_enc[:4], 'big'), True) # ks1 + state.lfsr48_u32(0, False) # consume ks2 (AR keystream) + ks_at = state.lfsr48_u32(0, False) # ks3 (AT keystream) + at_int = int.from_bytes(at_enc, 'big') + at_decoded = at_int ^ ks_at + ok = (at_decoded == at_expected) + colour = CG if ok else CR + mark = '✓' if ok else '✗' + print(f" AT expected : {at_expected:08X} = prng_successor(NT, 64)") + print(f" AT (encrypted) : {at_enc.hex().upper()}") + print(f" AT decrypted : {colour}{at_decoded:08X}{C0} {colour}{mark} " + f"{'MATCH — auth verified' if ok else 'MISMATCH — wrong key or replay'}{C0}") + # Cross-check against mfkey32 prediction too. + nr_enc_int = int.from_bytes(nr_ar_enc[:4], 'big') + ar_enc_int = int.from_bytes(nr_ar_enc[4:], 'big') + key_match = Crypto1.mfkey32_is_reader_has_key(uid32, nt_int, nr_enc_int, ar_enc_int, key_hex) + if key_match: + print(f" mfkey32 forward : {CG}key {key_hex.upper()} verified against NT/NR/AR{C0}") + else: + print(f" {CR}AT not received — auth was rejected by the card{C0}") + elif nt_int is not None: + print(f" {CY}Auth aborted before NR||AR — NT={nt_int:08X}, no further analysis{C0}") + + + + def _decode_sw(sw1: int, sw2: int) -> str: """Decode an ISO 7816-4 status word pair.""" diff --git a/software/script/chameleon_cmd.py b/software/script/chameleon_cmd.py index 1f3c320..a5c3e84 100644 --- a/software/script/chameleon_cmd.py +++ b/software/script/chameleon_cmd.py @@ -535,6 +535,39 @@ class ChameleonCMD: timeout_s = (timeout_ms // 1000) + 5 return self.device.send_cmd_sync(Command.HF14A_SNIFF, payload, timeout=timeout_s) + def hf14a_auth_trace(self, block: int, key_type: int, key: bytes, timeout_ms: int = 5000): + """ + Run a full reader-side ISO14443A + MIFARE Classic Crypto1 auth flow + against a real card and return every wire frame for inspection. + + The firmware polls for a tag in the field for up to `timeout_ms` + milliseconds, then performs anticoll + SELECT + (optional RATS) + + AUTH and packs all frames — synthesized anticoll plus the live + AUTH/NT/NR||AR/AT — into the same buffer format used by hf14a_sniff: + [2 bytes: bit count, big-endian] [N bytes: frame data, ceil(bits/8)] ... + Bit 15 of the bit-count header: 0 = reader→card, 1 = card→reader. + + :param block: target block number (0-255) + :param key_type: 0x60 (Key A) or 0x61 (Key B) + :param key: 6-byte sector key + :param timeout_ms: tag-presence polling timeout in ms (1-30000) + :return: Raw response — check .status and .data + """ + if key_type not in (0x60, 0x61): + raise ValueError("key_type must be 0x60 (Key A) or 0x61 (Key B)") + if len(key) != 6: + raise ValueError("key must be exactly 6 bytes") + timeout_ms = max(1, min(30000, int(timeout_ms))) + payload = ( + bytes([key_type, block & 0xFF]) + + bytes(key) + + bytes([(timeout_ms >> 8) & 0xFF, timeout_ms & 0xFF]) + ) + # Add a couple of seconds of slack on top of the device-side polling + # window so the USB/BLE round-trip doesn't time out before firmware + # gives up on its own. + return self.device.send_cmd_sync(Command.HF14A_AUTH_TRACE, payload, timeout=(timeout_ms // 1000) + 3) + @expect_response(Status.SUCCESS) def hf14a_get_config(self): """ diff --git a/software/script/chameleon_enum.py b/software/script/chameleon_enum.py index 05bf75f..0dff83c 100644 --- a/software/script/chameleon_enum.py +++ b/software/script/chameleon_enum.py @@ -72,6 +72,7 @@ class Command(enum.IntEnum): MF1_WRITE_ONE_BLOCK = 2009 HF14A_RAW = 2010 HF14A_SCAN_KEEP = 2016 + HF14A_AUTH_TRACE = 2017 MF1_MANIPULATE_VALUE_BLOCK = 2011 MF1_CHECK_KEYS_OF_SECTORS = 2012 MF1_HARDNESTED_ACQUIRE = 2013