diff --git a/AGENTS.md b/AGENTS.md index 6a7d858..813d6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,6 +333,37 @@ Known limitation: no on-disk fixture reproduces the exact live failure (all real captures contain a 372 ATR), so end-to-end proof needs a re-captured `.sr` from the failing phone; the unit tests cover the loop-escape logic. +### v1.9.5: T=0 framing — immediate SW, short responses, Le=256 + +The audit's P0 framing fixes, each pinned by a `TestDecodeStep` vector: + +- **Immediate SW.** The first post-header byte is classified as a status + word before the ACK/data decision. ISO 7816-3 §10.3.3 lets the card + finish at the first procedure byte; a case-2 rejection (`6A82`, `6CXX`, + ...) carries no ACK and used to be read as command data (the exchange + and the following one were silently consumed). +- **Short responses (Lr < Le).** The ACK path still reads the P3 bytes, + but if the next byte is not SW1 the buffer is split at the first + plausible SW1 followed by a plausible CLA, the tail is replayed into + `_replay`, and the packet is emitted with `BAD_FCS`. Previously the SW + scan discarded the real SW, merged the following exchanges into a + clean-looking packet, and at EOF lost the exchange silently. A short + read with no plausible split is emitted flagged and resynced. +- **P3=0 = Le=256.** For an outgoing command P3=0 requests 256 bytes + (§10.3.2). The response data used to be discarded; it is now read until + SW and kept. The read-until-SW ambiguity on data bytes that look like + SW1 remains (inherent to T=0 sniffing). +- **~INS ACK.** One data byte per ACK (Table 11) via the new + `_t0_read_response_data()` helper, instead of "all remaining". +- **Case 4.** ISO 7816-3 §12.2/Table 14 map case 4 to a separate GET + RESPONSE; the ACK path now appends any inline data instead of dropping + it (permissive extension; conforming cards send SW/61XY there). + +Validation: 99/99 unit tests; the 10-trace suite is unchanged except +`xiaomi_mi_a1_coldboot` 473 -> 475 APDUs (two previously lost exchanges +recovered), 0 CHKSUM/CONCAT/BAD_FCS/GARBAGE, `RESULT: OK`; all other traces +identical; `compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.5. + ### v1.9.4: decode_step/hunt unit coverage + hunt EOF guard The v1.9.3 review's residual risk -- the new acceptance classes were only @@ -883,7 +914,7 @@ append the measured CLK frequency when known ### Versioning -The decoder version is defined in `pd.py` as `VERSION = '1.9.4'`. +The decoder version is defined in `pd.py` as `VERSION = '1.9.5'`. The version is printed to the log on decoder startup. ### Testing after decoder changes @@ -1064,23 +1095,34 @@ no `0xff`/`0xfc` garbage. Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those P3 bytes on the wire **before** the card's first procedure byte. The -framing now distinguishes the cases by the card's first procedure byte: +framing classifies the first post-header byte in this order (v1.9.5): -- If the first post-header byte **is the ACK (== INS)** → case 2/4: the - terminal sent no command data; read `P3` response bytes (+SW). -- Otherwise the byte is terminal command data → case 3/4: read `P3` +- **SW1** (`0x61-0x6F`/`0x90-0x9F`) → the card finished with SW1 SW2 at the + first procedure byte (ISO 7816-3 §10.3.3; a case-2 rejection such as + `6A82`/`6CXX` carries no ACK). +- **ACK (== INS)** → case 2/4: the terminal sent no command data. Read the + `P3` response bytes, then the SW. `P3=0` is Le=256 (§10.3.2): read the + response data until the SW. If the card returns **fewer** bytes than Le + (`6282` end of file), the buffer is split at the first plausible SW1 + followed by a plausible CLA, the tail is replayed and the packet is + flagged BAD_FCS (v1.9.5; previously the SW scan discarded the real SW and + the following exchanges could be merged silently). +- **~INS** → byte-by-byte transfer: one data byte per ACK, read until SW. +- Otherwise → case 3/4: the byte is terminal command data; read `P3` command-data bytes, then the card's procedure byte (ACK/SW/`9E`/`9F`). - On ACK (case 4) the response is read `P3` response bytes (+SW). + On ACK, append any inline response data until SW (ISO 7816-3 §12.2 maps + case 4 to a separate GET RESPONSE, so conforming cards send SW/61XY + here; the append is a permissive extension). This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope commands) into one C-APDU = header + command data, R-APDU = response + SW -APDU, instead of fragmenting them across frames. It is gated to -`_edge_read` (mid-session / gated-CLK captures) so ATR-based captures -(`test_8` / `test_7`) keep the original framing unchanged and stay at -0 garbage. +APDU, instead of fragmenting them across frames. -All paths use P3-bounded reads (v1.1.1 reverted the read-until-SW -experiment from v1.1.0 which mis-identified FCP `0x62` as SW1). +P3-bounded reads are kept for case 2 (`Lr == Le`): the read-until-SW +experiment from v1.1.0 mis-identified FCP `0x62` as SW1 and was reverted. +Where the length is unknown (P3=0, case-4 inline data) the read-until-SW +ambiguity remains — a data byte that looks like SW1 is indistinguishable +on the wire. ## Decoder methods tried — reliability registry @@ -1137,11 +1179,12 @@ discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add. ATR/PPS/DATA + mid-session. Reliable; current architecture. - **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte reading until a protocol is known. Reliable. -- **T=0 case-3/4 ACK==INS discriminator.** First post-header byte == INS ⇒ - case 2/4 (response bounded by `P3`); else command data (read `P3`, then - the procedure byte). Reliable for consolidating STK envelope commands. - Gated to `_edge_read`. P3-bounded reads avoid FCP tag misidentification - (v1.1.1 reverted read-until-SW from v1.1.0). +- **T=0 procedure-byte classification (v1.9.5).** The first post-header + byte is SW1 ⇒ immediate status word; ACK (== INS) ⇒ case 2/4 (P3 + response bytes, short-read split + BAD_FCS); ~INS ⇒ one byte per ACK; + else command data (case 3/4). Reliable for consolidating STK envelope + commands. P3-bounded reads avoid FCP tag misidentification (v1.1.1 + reverted read-until-SW from v1.1.0). - **1.5-ETU start-bit guard in `wait_data_falling`.** `min_high = 1.5 * bit_samples` distinguishes genuine inter-byte idle gaps (≥2 ETU per ISO 7816-3) from intra-byte HIGH periods (≤1 ETU). Prevents byte misalignment diff --git a/README.md b/README.md index d9003a5..7d4a42a 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,12 @@ filtered from stderr. recovering the real command. Every consumed-but-unemitted byte is explicitly logged (`discarded byte 0x…: reason`); idle 0x00/0xFF discards are rate-limited, non-idle discards always produce a decoder annotation. +- A card may finish a command with SW1 SW2 at the first procedure byte + (ISO 7816-3 §10.3.3), including a case-2 command with P3 > 0: the SW is + framed as the status word, not read as command data. A response + **shorter than Le** is split at the status word and the packet is + flagged `BAD_FCS` (the following exchanges stay intact), and `P3=0` + (Le=256) response data is kept. Performance: in native CLK mode the decoder calibrates samples per CLK edge once from the ATR's first frame, then advances bit timing with diff --git a/README_RUS.md b/README_RUS.md index 93fd7a7..2770da9 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -243,6 +243,12 @@ USB у FX2 при длительной высокой частоте дискр (`discarded byte 0x…: reason`); отбрасывания холостых 0x00/0xFF ограничены по частоте, отбрасывания не-холостых байтов всегда дают аннотацию декодера. +- Карта может завершить команду словами SW1 SW2 на первом процедурном + байте (ISO 7816-3 §10.3.3), в том числе для case-2 команды с P3 > 0: + SW оформляется как статусное слово, а не читается как данные. Ответ + **короче Le** разделяется по статусному слову и пакет помечается + `BAD_FCS` (следующие обмены сохраняются); данные ответа при `P3=0` + (Le=256) сохраняются. Производительность: в режиме native CLK декодер один раз калибрует число отсчётов на фронт CLK по первому кадру ATR, а затем продвигает битовую diff --git a/pd.py b/pd.py index bc2730c..7a9d877 100644 --- a/pd.py +++ b/pd.py @@ -28,7 +28,7 @@ from .gsmtap_stream import (GsmtapStreamSender, GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT, GSMTAP_FLAG_BAD_FCS) -VERSION = '1.9.4' +VERSION = '1.9.5' @@ -2177,6 +2177,69 @@ class Decoder(srd.Decoder): return False return True + def _t0_read_data_until_sw(self, packet): + '''Append every card byte until SW1 SW2, keeping the data. + + Used after an INS ACK ("all remaining data follows", ISO 7816-3 + Table 11) and for P3=0 / case-4 response data: from this point on + the card sends data bytes, then the status word. A plausible SW1 + ends the response; data bytes are appended verbatim. Returns True + when the status word was read.''' + for _ in range(MAX_TPDU_LEN): + pb = self.read_byte() + if pb is None: + return False + if plausible_sw(pb): + sw2 = self.read_byte() + if sw2 is None: + return False + packet.extend((pb, sw2)) + return True + packet.append(pb) + return False + + + def _t0_read_response_data(self, packet, ins): + '''Append the card's T=0 response data bytes until SW1 SW2. + + Used for the per-byte (~INS) ACK form, where every data byte is + followed by another procedure byte. Procedure bytes are consumed + and never stored: NULL 0x60 / turnaround 0xFF are skipped, ~INS + means "the next data byte only", and the deprecated INS^0x01 forms + consume one data byte. An INS ACK ("all remaining data follows") + switches to _t0_read_data_until_sw(). Any other byte is response + data. Returns True when the status word was read.''' + for _ in range(MAX_TPDU_LEN): + pb = self.read_byte() + if pb is None: + return False + if pb in (0x60, 0xFF): + self._note_pre_sw(pb, 'NULL/turnaround before response data') + continue + if pb == ins: + return self._t0_read_data_until_sw(packet) + if pb == (ins ^ 0xFF): + d = self.read_byte() + if d is None: + return False + packet.append(d) + continue + if pb == (ins ^ 0x01) or pb == ((ins ^ 0xFF) ^ 0x01): + d = self.read_byte() + if d is None: + return False + packet.append(d) + continue + if plausible_sw(pb): + sw2 = self.read_byte() + if sw2 is None: + return False + packet.extend((pb, sw2)) + return True + packet.append(pb) + return False + + def decode_step(self): '''Run one state-machine step. Returns False when decoding ends.''' if self._at_eof(): @@ -2238,6 +2301,7 @@ class Decoder(srd.Decoder): self.handle_atr(self.wait({'skip': 0})) elif self.state == 'DATA': packet = []; + forced_bad = False # Mid-session capture with an explicit protocol: there is no ATR to # derive the bit period from, so recover it once from the live line @@ -2432,66 +2496,100 @@ class Decoder(srd.Decoder): pb0 = self.read_byte() if pb0 is None: return True - if (pb0 == bIns or pb0 == (bIns ^ 0xFF)): - # ACK (full match or ~INS): case 2/4 (response data - # follows). Read exactly P3 response bytes, then - # scan for SW1 SW2, skipping any turnaround idle - # bytes (e.g. 0xFF) between data and SW. - for _ in range(p3): - b = self.read_byte() - if b is None: - return True - packet.append(b) - sw1 = None - for _ in range(MAX_TPDU_LEN): - sw1 = self.read_byte() - if sw1 is None: - return True - if plausible_sw(sw1): - break - self._note_pre_sw( - sw1, 'non-SW byte before status word ' - '(P3-bounded response)') - if sw1 is None: - return True + if plausible_sw(pb0): + # Immediate status word: ISO 7816-3 §10.3.3 lets the + # card finish the command at the first procedure + # byte; a case-2 rejection ('6A82', '6CXX', ...) + # carries no ACK and must not be read as data. sw2 = self.read_byte() if sw2 is None: return True - packet.append(sw1) # SW1 - packet.append(sw2) # SW2 - got_sw = True - else: - # Case 2/3/4: determine data direction from P3 and - # the first post-header byte. packet.append(pb0) + packet.append(sw2) + got_sw = True + elif pb0 == bIns: if (p3 == 0): - # P3=0 (Le=0): no command data. Card sends - # response data + SW, or SW directly (no ACK). - if plausible_sw(pb0): - # pb0 is SW1: card sent SW directly. + # P3=0 in an outgoing command is Le=256 + # (ISO 7816-3 §10.3.2): the card ACKs and sends + # response data until the status word. + if self._t0_read_data_until_sw(packet): + got_sw = True + else: + # ACK: read the P3 response bytes, then the SW. + # The card may return FEWER bytes than Le + # (6282 end-of-file; Lr <= Le, TS 102 221 + # §10.1.6): if the byte after the read is not + # SW1, split the buffer at the first plausible + # SW1 followed by a plausible CLA and replay the + # tail (flagged BAD_FCS). + resp = [] + eof = False + for _ in range(p3): + b = self.read_byte() + if b is None: + eof = True + break + resp.append(b) + s1 = None if eof else self.read_byte() + if s1 is None: + eof = True + while s1 in (0x60, 0xFF): + s1 = self.read_byte() + if s1 is None: + eof = True + break + if s1 is not None and plausible_sw(s1): sw2 = self.read_byte() if sw2 is None: return True - packet.append(sw2) + packet.extend(resp) + packet.extend((s1, sw2)) got_sw = True else: - # pb0 is response data: read until SW, - # skipping turnaround idle bytes. - for _ in range(MAX_TPDU_LEN): - b = self.read_byte() - if b is None: - return True - if plausible_sw(b): - sw2 = self.read_byte() - if sw2 is None: - return True - packet.append(b) - packet.append(sw2) - got_sw = True + buf = resp + ([s1] if s1 is not None else []) + split = None + for j in range(len(buf) - 1): + if not plausible_sw(buf[j]): + continue + k = j + 2 + if k >= len(buf) or plausible_cla(buf[k]): + split = j break - self._note_pre_sw(b, - 'non-SW byte in P3=0 ' - 'response') + if split is not None: + packet.extend(buf[:split]) + packet.extend(buf[split:split + 2]) + for t in reversed(buf[split + 2:]): + self._replay.appendleft(t) + got_sw = True + forced_bad = True + self.log( + "SHORT RESPONSE: split %d data byte(s) " + "before SW1 0x%02x (Le %d)" + % (split, buf[split], p3)) + else: + packet.extend(buf) + forced_bad = True + self._resync = True + self.log( + "SHORT RESPONSE: no status word in " + "%d byte(s) read for Le %d" + % (len(buf), p3)) + elif pb0 == (bIns ^ 0xFF): + # ~INS ACK: one data byte at a time. Read data + # until the SW, consuming the procedure bytes in + # between (ISO 7816-3 Table 11). + if self._t0_read_response_data(packet, bIns): + got_sw = True + else: + # Case 3/4: the first post-header byte is command + # data (P3 = Lc). + packet.append(pb0) + if (p3 == 0): + # P3=0 and no procedure byte: treat the byte as + # response data (Le=256, permissive) and read + # until the status word. + if self._t0_read_data_until_sw(packet): + got_sw = True else: # Case 3/4: P3 command-data bytes, then # procedure byte. @@ -2505,23 +2603,15 @@ class Decoder(srd.Decoder): if pb is None: return True if (pb == bIns or pb == (bIns ^ 0xFF)): - # ACK (full match / ~INS): case 4 -> read response until SW, - # skipping turnaround idle bytes. - for _ in range(MAX_TPDU_LEN): - b = self.read_byte() - if b is None: - return True - if plausible_sw(b): - sw2 = self.read_byte() - if sw2 is None: - return True - packet.append(b) - packet.append(sw2) + # ACK: case-4 response data follows. + # Append it until the status word (it + # used to be discarded). INS means all + # remaining data; ~INS is per byte. + if pb == bIns: + if self._t0_read_data_until_sw(packet): got_sw = True - break - self._note_pre_sw(b, - 'non-SW byte in case-4 ' - 'response') + elif self._t0_read_response_data(packet, bIns): + got_sw = True break elif (pb == (bIns ^ 0x01) or pb == ((bIns ^ 0xFF) ^ 0x01)): @@ -2697,7 +2787,8 @@ class Decoder(srd.Decoder): else: self._idle_low = 0 self.emit_packet(GSMTAP_SIM_APDU, bytes(packet), - es, self.samplenum) + es, self.samplenum, + flags=GSMTAP_FLAG_BAD_FCS if forced_bad else 0) self.log("PACKETEND", codecs.encode(bytes(packet), 'hex')) else: diff --git a/tests/baseline.txt b/tests/baseline.txt index b4821d1..00feccc 100644 --- a/tests/baseline.txt +++ b/tests/baseline.txt @@ -1,7 +1,7 @@ -=== Baseline captured 2026-10-18T02:13:20+03:00 === +=== Baseline captured 2026-10-20T16:56:58+03:00 === === test_8 === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('etu recovered (samples):', 1237) iso7816-1: RST deasserted ('RST deasserted',) @@ -36,7 +36,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === test_7 === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('etu recovered (samples):', 1236) iso7816-1: RST deasserted ('RST deasserted',) @@ -77,7 +77,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === xiaomi_phone_sample === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('etu recovered (samples):', 53) (11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1]) iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1] @@ -102,7 +102,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === xiaomi_mi_a1_coldboot === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -112,12 +112,12 @@ CHKSUM ERROR count: 0 CONCAT APDU count: 0 reader exchanges : 0 capture ATRs : 1 -capture APDUs : 473 +capture APDUs : 475 byte-exact matches (LCS) : 0 reader exchanges not in capture (pre-capture / dropped): 0 capture APDUs not in reader log: - proactive/CAT (unlogged by reader SDK): 10 - - valid exchanges (reader retries/dupes): 463 + - valid exchanges (reader retries/dupes): 465 - GARBAGE (mis-framed/desync): 0 Degradation metrics: @@ -129,7 +129,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === 4gmodem_coldboot === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -156,7 +156,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === alcor_reader_sample === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -189,7 +189,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -216,7 +216,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -243,7 +243,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -270,7 +270,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.9.4 ===',) +('=== iso7816 decoder v1.9.5 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -359,7 +359,11 @@ test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_den test_ex_class_exchange_frames (__main__.TestDecodeStep.test_ex_class_exchange_frames) ... ok test_extended_channel_exchange_frames (__main__.TestDecodeStep.test_extended_channel_exchange_frames) ... ok test_idle_noise_still_discarded (__main__.TestDecodeStep.test_idle_noise_still_discarded) ... ok +test_immediate_sw_rejection_frames (__main__.TestDecodeStep.test_immediate_sw_rejection_frames) ... ok test_invalid_ins_re_frame_recovers_command (__main__.TestDecodeStep.test_invalid_ins_re_frame_recovers_command) ... ok +test_le_256_response_data_is_kept (__main__.TestDecodeStep.test_le_256_response_data_is_kept) ... ok +test_short_response_is_split_and_flagged (__main__.TestDecodeStep.test_short_response_is_split_and_flagged) ... ok +test_short_response_replays_following_exchange (__main__.TestDecodeStep.test_short_response_replays_following_exchange) ... ok test_text_byte_boundary_is_discarded_not_framed (__main__.TestDecodeStep.test_text_byte_boundary_is_discarded_not_framed) ... ok test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok @@ -435,6 +439,6 @@ test_valid_ins_odd_variants (__main__.TestValidateT0Apdu.test_valid_ins_odd_vari test_vs_reader_mirror (__main__.TestValidateT0Apdu.test_vs_reader_mirror) ... ok ---------------------------------------------------------------------- -Ran 95 tests in 0.028s +Ran 99 tests in 0.032s OK diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py index 27a3aab..2fe9ea4 100644 --- a/tests/test_gsmtap.py +++ b/tests/test_gsmtap.py @@ -880,6 +880,52 @@ class TestDecodeStep(unittest.TestCase): self.assertEqual(inst.discarded[0][0], 0xfe) self.assertIn('idle-line noise', inst.discarded[0][1]) + def test_immediate_sw_rejection_frames(self): + # A command rejected with SW1 SW2 instead of an ACK (ISO 7816-3 + # §10.3.3) must not be read as command data; the exchange and the + # following one must frame separately. + inst = self._make(bytes.fromhex('00b00000106a82' + + '00a40004009000')) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted, + [(bytes.fromhex('00b00000106a82'), 0)]) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted[1], + (bytes.fromhex('00a40004009000'), 0)) + + def test_short_response_is_split_and_flagged(self): + # Lr < Le (6282 end-of-file): the SW inside the P3-byte read ends + # the response, and the packet is flagged BAD_FCS (it used to be a + # silent total loss). + inst = self._make(bytes.fromhex('00b0000010b012349000')) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted, + [(bytes.fromhex('00b000001012349000'), + self.gs.GSMTAP_FLAG_BAD_FCS)]) + + def test_short_response_replays_following_exchange(self): + # A short response followed by another command: the split must + # replay the tail so the next exchange is framed intact. + inst = self._make(bytes.fromhex('00b0000010b012349000' + + '00a40004009000')) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted[0], + (bytes.fromhex('00b000001012349000'), + self.gs.GSMTAP_FLAG_BAD_FCS)) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted[1], + (bytes.fromhex('00a40004009000'), 0)) + + def test_le_256_response_data_is_kept(self): + # P3=0 in an outgoing command is Le=256 (ISO 7816-3 §10.3.2): the + # data after the ACK must be appended, not discarded — including a + # data byte that happens to equal INS. + payload = 'a5' * 8 + 'b0' + 'a5' * 7 + inst = self._make(bytes.fromhex('00b0000000b0' + payload + '9000')) + self.assertTrue(inst.decode_step()) + self.assertEqual(inst.emitted, + [(bytes.fromhex('00b0000000' + payload + '9000'), 0)]) + class TestAtrHuntDetect(unittest.TestCase): """The ATR-hunt command-detect handoff in handle_atr(), driven by a