From 250c09c9b7796e0e38470d7cd05abb05d408981f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 19 Sep 2026 00:55:33 +0300 Subject: [PATCH] v1.9.0: CLK rate in RST events; v1.9.1: T=0 NULL is not an SW1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.9.0 -- CLK frequency in RST events: - RST events (sub_type 0x10) append the measured CLK frequency when known: [direction, level, flags, clk_hz_be32], flags bit 0 = rate present; the 3-byte form is kept when the rate is not yet measured. Value is samplerate / _samples_per_clock from the existing 16-edge measurement (PPS changes F/D, not the CLK rate). VCC events unchanged. Backward compatible (old consumers read the first two bytes only); consumers can compute data_rate = clk_hz x D / F from the ATR/PPS Fi/Di. - Unit tests for the payload codec; README/README_RUS/AGENTS updated. v1.9.1 -- T=0 NULL is not an SW1: - The 'scan for SW1' loops accepted any 0x6X byte, including the NULL procedure byte 0x60 (card busy), so 'NULL + real SW1' was mis-framed as the status word: the phone traces' SELECT MF came out as ...3f00 6061 — real SW1 0x61 eaten as SW2, real SW2 0x29 left on the wire and discarded as a suspicious CLA (desync cascade). - plausible_sw() now accepts 0x61-0x6F and 0x90-0x9F only, per ISO 7816-3:2006 10.3.3 Table 11 ('6X' or '9X', EXCEPT '60'; ISO 7816-4 enforces '60' as an invalid SW1) and UICC_SPECS.md 1.8. All seven SW1-detection sites use plausible_sw(); NULLs met while scanning are logged as rate-limited wait extensions (_note_pre_sw) instead of suspicious bytes. - tools/vs_reader.py::_plausible_sw() mirrors the fix; regression vector for the 00a40004023f006061 artifact. Validation: 74/74 unit tests; baseline regenerated and IDENTICAL (the SW fix is count-neutral -- only the SW bytes change); all 9 example pcaps regenerated -- vs_reader OK, zero APDUs ending in 60 61, SELECT MF now 61 29 / 61 25. --- AGENTS.md | 68 ++++++++++++++++++++++-- README.md | 9 +++- README_RUS.md | 9 +++- pd.py | 121 ++++++++++++++++++++++++++++++------------- tests/baseline.txt | 28 ++++++---- tests/test_gsmtap.py | 55 ++++++++++++++++++-- tools/vs_reader.py | 4 +- 7 files changed, 236 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad1d8a0..7f6548d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,7 +182,11 @@ data), so the false-positive rate is very low. The checks are: `0x84`, `0xA0`, `0xB0`). 3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word collision), and the least-significant bit is `0`. -4. The penultimate byte (SW1) is in `0x6x` or `0x9x`. +4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`. + `0x60` is explicitly invalid: it is the T=0 NULL procedure byte + (ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**; + ISO 7816-4 enforces `'60'` as an invalid SW1), so a "NULL + real SW1" + pair must never be accepted as a status word. The current validation deliberately does **not** enforce P3-based length constraints, because a card may reject a command and send the status word @@ -304,6 +308,58 @@ 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.1: T=0 NULL is not an SW1 (fixes bogus "60 61" APDU endings) + +The "scan for SW1" loops accepted any byte with high nibble `0x6` as a +status word, including the T=0 **NULL procedure byte `0x60`** (card busy / +waiting-time extension). A card that holds the terminal with NULLs while +busy was therefore mis-framed: on the phone/cold-boot traces the SELECT MF +wire flow is `00 a4 00 04 02` → ACK `a4` → command data `3f 00` → ~1.4 s of +NULL `60` → real SW `61 29`. The decoder took `60` as SW1 and the real +`0x61` as SW2, emitting `…3f00 6061` and leaving the real SW2 (`0x29`) on +the wire, where it was discarded as a suspicious CLA — and the bogus packet +even passed `validate_t0_apdu()` because `plausible_sw()` accepted `0x60`. + +- `plausible_sw()` now accepts `0x61`–`0x6F` and `0x90`–`0x9F` only. + Authority: ISO 7816-3:2006 §10.3.3 Table 11 — "If the value is `'6X'` + or `'9X'`, **except for `'60'`**, it is a SW1 byte"; the note adds that + ISO 7816-4 enforces `'60'` as an invalid SW1. (UICC_SPECS.md §1.8 + classes: normal `'9000'`/`'61XX'`; warning `'62XX'`/`'63XX'`; execution + error `'64XX'`–`'66XX'`; checking error `'67XX'`–`'6FXX'`.) +- Every SW1-detection site now uses `plausible_sw()`: the edge ACK scan, + the P3=0 direct-SW check and response scan, the case-4 response scan, + the case-3 SW1 check, the unexpected-byte scan, and the ATR path. +- NULLs met while scanning for the status word are logged as rate-limited + wait extensions via the new `_note_pre_sw()` (cards emit them in bursts, + so they must not flood the log or raise warnings); other non-SW bytes + keep their call-site reason. +- `tools/vs_reader.py::_plausible_sw()` mirrors the fix. +- Tests: `plausible_sw` vectors updated (`0x60` now rejected; accepts + start at `0x61`), plus a regression vector for the + `00 a4 00 04 02 3f 00 60 61` artifact (invalid SW1 / desynced). + +The fix is count-neutral (the same bytes are consumed; only the SW bytes +change), so `tests/baseline.txt` differs only by the version line and the +new test listing. 74/74 unit tests. VERSION -> 1.9.1. + +### v1.9.0: CLK frequency in RST events + +RST line events (sub_type 0x10) now append the measured CLK frequency when +it is known: `[direction, level, flags, clk_hz_be32]`, with `flags` bit 0 +(`LINE_EVENT_FLAG_CLK_HZ`) set and the frequency as a big-endian uint32 in +Hz (7-byte payload). An unknown rate keeps the 3-byte payload, so +consumers that read only the first two bytes (e.g. pysniff's +`decode_line_event`) are unaffected. The value is the existing 16-edge +average (`samplerate / _samples_per_clock`, see `_measure_clock_period`); +PPS changes F/D, not the CLK rate, so one measurement per session is +enough. VCC events (0x11) are unchanged. Purpose: consumers can compute +the actual data rate as `clk_hz × D / F` from the Fi/Di negotiated in the +ATR/PPS. Ordering: the RST deassert event usually precedes the ATR event +(the 10 ms hysteresis lands mid-ATR for full ATRs), but a short ATR can +invert that, so consumers take the rate from any RST event. Wire +extension is backward compatible; 73/73 unit tests; baseline regenerated +and IDENTICAL (it tracks no RST payload metrics). VERSION -> 1.9.0. + ### v1.8.0: PPS emitted as standard request + response packets The PPS exchange was previously emitted as a single packet under sub_type @@ -559,9 +615,9 @@ window may be filtering real start bits. |------|---------| | ACK (== INS or == ~INS) | Full data match: card acknowledges and expects all P3 data bytes | | INS^0x01 or ~INS^0x01 | Single-byte match: card validates syntax but processes data one byte at a time | -| 0x60 | NULL: card busy, wait for next procedure byte | +| 0x60 | NULL: card busy, wait for next procedure byte (never an SW1) | | 0x9E / 0x9F | Extended-length: next byte L = number of response data bytes, then SW | -| SW1 (0x6x / 0x9x) | Status word: exchange complete | +| SW1 (`0x61`–`0x6F` / `0x9X`) | Status word: exchange complete | ##### CLA (Class) Byte — Interindustry Structure @@ -642,11 +698,13 @@ comments in `gsmtap_stream.py`). It deviates in two documented ways: `GSMTAP_FLAG_BAD_FCS` writes flags into the header `res` byte, which official gsmtap.h reserves. The `res`-byte carriage is this decoder's own extension (upstream simtrace2 never sets `res`); the semantics mirror -simtrace2's per-message USB data flags. +simtrace2's per-message USB data flags. 0x10 RST events additionally +append the measured CLK frequency when known +(`[direction, level, flags, clk_hz_be32]`; see "v1.9.0" below). ### Versioning -The decoder version is defined in `pd.py` as `VERSION = '1.8.0'`. +The decoder version is defined in `pd.py` as `VERSION = '1.9.1'`. The version is printed to the log on decoder startup. ### Testing after decoder changes diff --git a/README.md b/README.md index 1773b86..3acbf28 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ GSMTAP header: version 2, type SIM (0x04), UDP port 4729. | 0x01 | ATR | raw ATR bytes | | 0x02 | PPS request | raw PPS request bytes | | 0x03 | PPS response | raw PPS response bytes | -| 0x10 | RST event (custom) | `[direction, level, 0x00]` | +| 0x10 | RST event (custom) | `[direction, level, flags, clk_hz_be32]` — CLK frequency appended when known | | 0x11 | VCC event (custom) | `[direction, level, 0x00]` | Event payload byte semantics: @@ -96,6 +96,13 @@ Event payload byte semantics: (PPS request and response are emitted as separate packets). 0x10/0x11 are custom extensions not defined in libosmocore's gsmtap.h. +RST events carry the measured CLK frequency when it is known: `flags` +bit 0 is then set and the frequency follows as a big-endian uint32 in Hz +(7-byte payload). When the rate is not yet measured the payload stays +3 bytes, so consumers that read only the first two bytes are unaffected. +Combined with the F/D negotiated in the ATR/PPS, the data rate is +`clk_hz × D / F` bits/s. + Header extensions: flags such as `GSMTAP_FLAG_BAD_FCS` are written into the header `res` byte, which official gsmtap.h marks reserved (RFU). This carriage is this decoder's own extension (upstream simtrace2-sniff diff --git a/README_RUS.md b/README_RUS.md index 2bd8c61..26383ff 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -86,7 +86,7 @@ GSMTAP pcap-файлы (включая PCAP, который этот декод | 0x01 | ATR | сырые байты ATR | | 0x02 | Запрос PPS | сырые байты запроса PPS | | 0x03 | Ответ PPS | сырые байты ответа PPS | -| 0x10 | Событие RST (кастомное) | `[direction, level, 0x00]` | +| 0x10 | Событие RST (кастомное) | `[direction, level, flags, clk_hz_be32]` — частота CLK добавляется, когда известна | | 0x11 | Событие VCC (кастомное) | `[direction, level, 0x00]` | Семантика байтов полезной нагрузки: @@ -100,6 +100,13 @@ GSMTAP pcap-файлы (включая PCAP, который этот декод 0x10/0x11 — кастомные расширения, не определённые в gsmtap.h из libosmocore. +События RST несут измеренную частоту CLK, когда она известна: тогда +устанавливается бит 0 байта `flags`, а следом идёт частота как big-endian +uint32 в герцах (полезная нагрузка 7 байт). Пока частота не измерена, +нагрузка остаётся 3 байта, поэтому потребители, читающие только первые два +байта, не затрагиваются. Вместе с F/D, согласованными в ATR/PPS, скорость +передачи данных равна `clk_hz × D / F` бит/с. + Расширения заголовка: флаги вроде `GSMTAP_FLAG_BAD_FCS` пишутся в байт `res` заголовка, который официальный gsmtap.h помечает как зарезервированный (RFU). Это собственное расширение данного декодера (upstream simtrace2-sniff diff --git a/pd.py b/pd.py index 1fbbc46..06ffffb 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.8.0' +VERSION = '1.9.1' @@ -106,10 +106,17 @@ class pcap_udp_pkt(): return len(self.h) + len(self.data) def plausible_sw(sw1): - '''T=0 status word first byte sanity check. SW1 is 0x9x (normal - processing) or 0x6x (warning/error classes). Anything else means - the byte stream was almost certainly mis-framed.''' - return (0x60 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) + '''T=0 status word first byte sanity check. + + Per ISO 7816-3:2006 §10.3.3 Table 11, an SW1 byte is '6X' or '9X' + EXCEPT '60': that value is the NULL procedure byte (card busy / + waiting-time extension), and ISO 7816-4 enforces '60' as an invalid + SW1. Accepting it mis-frames "NULL + real SW1" as the status word -- + the bogus "60 61" APDU ending, where the real SW1 (0x61) is eaten as + SW2. The 6X classes are '61'..'6F' (normal '61XX'; warning '62XX'/ + '63XX'; execution error '64XX'-'66XX'; checking error '67XX'-'6FXX'), + plus '9X' for normal processing.''' + return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) def plausible_cla(cla): @@ -272,6 +279,29 @@ def embedded_exchanges(buf): return out +# --- line-event payload extension (RST/VCC, sub_type 0x10/0x11) --- +# The base payload is [direction, level, flags]; byte 2 used to be reserved +# and always 0. Bit 0 of it now marks an appended CLK frequency (big-endian +# uint32 Hz), so consumers that only read the first two bytes keep working. +LINE_EVENT_FLAG_CLK_HZ = 0x01 + + +def line_event_payload(direction, level, clk_hz=None): + '''Build a RST/VCC line-event payload (GSMTAP sub_type 0x10/0x11). + + Base form: [direction, level, flags] (3 bytes). When `clk_hz` is + known, flags bit 0 is set and the frequency follows as a big-endian + uint32 in Hz, giving a 7-byte payload. Old consumers read only the + first two bytes and ignore the rest, so the extension is backward + compatible.''' + flags = 0 + extra = b'' + if clk_hz: + flags |= LINE_EVENT_FLAG_CLK_HZ + extra = struct.pack('>I', int(clk_hz)) + return bytes([direction & 0xFF, level & 0xFF, flags]) + extra + + MAX_TPDU_LEN = 271 # ISO 7816-3 T=0: header(5) + proc + 255 + SW(2) < 271 IDLE_RESYNC_ETU = 4 # idle bit-periods marking an exchange boundary MAX_RESYNC_ATTEMPTS = 1024 # bail out if no idle gap found in that many tries @@ -405,6 +435,20 @@ class Decoder(srd.Decoder): [Ann.ANN_WARN, ["dropped 0x%02x: %s" % (value, reason)]]) return value + def _note_pre_sw(self, value, reason): + '''Log a byte consumed while scanning for the status word. + + A T=0 NULL (0x60) is a legitimate wait extension -- cards emit them + in bursts while busy -- so it is reported like idle noise + (rate-limited) with its own reason instead of raising a warning. + Other non-SW bytes keep the call-site reason.''' + if value == 0x60: + self._note_discard(value, + 'NULL procedure byte (card busy) before ' + 'status word', idle=True) + else: + self._note_discard(value, reason, idle=(value in (0x00, 0xFF))) + def _consume_first(self): '''Consume the byte the last peek_byte() returned, removing it from whichever source it came from (the _replay buffer or the peeked @@ -668,6 +712,21 @@ class Decoder(srd.Decoder): return (cand, cand_sn, cand, lvl, now) return (None, 0, confirmed, lvl, now) + def _clk_hz(self): + '''Return the measured CLK frequency in Hz, or None when unknown. + + Uses the 16-edge average from _measure_clock_period(). The value is + a session constant: PPS changes F/D (CLK cycles per bit), not the + physical CLK rate. Native mode only (the measurement is skipped in + sample_as_clock / detect modes).''' + spc = self._samples_per_clock + sr = self.samplerate + if spc and sr: + hz = int(round(float(sr) / float(spc))) + if 1 <= hz <= 100000000: + return hz + return None + def line_events(self, pins): '''Check RST/VCC levels via level-stability hysteresis and emit events + annotations for confirmed level changes. @@ -684,7 +743,9 @@ class Decoder(srd.Decoder): if emit_lvl is not None: direction = 1 if emit_lvl == 0 else 0 # 1=asserted, 0=deasserted self.emit_packet(GSMTAP_SIM_RST_EVENT, - bytes([direction, emit_lvl, 0]), edge_sn, edge_sn) + line_event_payload(direction, emit_lvl, + self._clk_hz()), + edge_sn, edge_sn) text = ('RST asserted' if emit_lvl == 0 else 'RST deasserted') self.put(edge_sn, edge_sn, self.out_ann, [Ann.ANN_RST, [text]]) self.log(text) @@ -2319,13 +2380,11 @@ class Decoder(srd.Decoder): sw1 = self.read_byte() if sw1 is None: return True - if ((sw1 & 0xF0) == 0x60 - or (sw1 & 0xF0) == 0x90): + if plausible_sw(sw1): break - self._note_discard(sw1, - 'non-SW byte before status word ' - '(P3-bounded response)', - idle=(sw1 in (0x00, 0xFF))) + self._note_pre_sw( + sw1, 'non-SW byte before status word ' + '(P3-bounded response)') if sw1 is None: return True sw2 = self.read_byte() @@ -2341,8 +2400,7 @@ class Decoder(srd.Decoder): if (p3 == 0): # P3=0 (Le=0): no command data. Card sends # response data + SW, or SW directly (no ACK). - if ((pb0 & 0xF0) == 0x60 - or (pb0 & 0xF0) == 0x90): + if plausible_sw(pb0): # pb0 is SW1: card sent SW directly. sw2 = self.read_byte() if sw2 is None: @@ -2356,8 +2414,7 @@ class Decoder(srd.Decoder): b = self.read_byte() if b is None: return True - if ((b & 0xF0) == 0x60 - or (b & 0xF0) == 0x90): + if plausible_sw(b): sw2 = self.read_byte() if sw2 is None: return True @@ -2365,10 +2422,9 @@ class Decoder(srd.Decoder): packet.append(sw2) got_sw = True break - self._note_discard(b, - 'non-SW byte in P3=0 ' - 'response', - idle=(b in (0x00, 0xFF))) + self._note_pre_sw(b, + 'non-SW byte in P3=0 ' + 'response') else: # Case 3/4: P3 command-data bytes, then # procedure byte. @@ -2388,8 +2444,7 @@ class Decoder(srd.Decoder): b = self.read_byte() if b is None: return True - if ((b & 0xF0) == 0x60 - or (b & 0xF0) == 0x90): + if plausible_sw(b): sw2 = self.read_byte() if sw2 is None: return True @@ -2397,10 +2452,9 @@ class Decoder(srd.Decoder): packet.append(sw2) got_sw = True break - self._note_discard(b, - 'non-SW byte in case-4 ' - 'response', - idle=(b in (0x00, 0xFF))) + self._note_pre_sw(b, + 'non-SW byte in case-4 ' + 'response') break elif (pb == (bIns ^ 0x01) or pb == ((bIns ^ 0xFF) ^ 0x01)): @@ -2430,8 +2484,7 @@ class Decoder(srd.Decoder): return True packet.append(b) continue - elif ((pb & 0xF0) == 0x60 - or (pb & 0xF0) == 0x90): + elif plausible_sw(pb): # SW1: case 3, no response data. sw2 = self.read_byte() if sw2 is None: @@ -2445,8 +2498,7 @@ class Decoder(srd.Decoder): # skip non-SW bytes (e.g. 0xFF # turnaround artifacts). for _ in range(MAX_TPDU_LEN): - if ((pb & 0xF0) == 0x60 - or (pb & 0xF0) == 0x90): + if plausible_sw(pb): sw2 = self.read_byte() if sw2 is None: return True @@ -2454,10 +2506,9 @@ class Decoder(srd.Decoder): packet.append(sw2) got_sw = True break - self._note_discard(pb, - 'non-SW byte during ' - 'unexpected-byte scan', - idle=(pb in (0x00, 0xFF))) + self._note_pre_sw(pb, + 'non-SW byte during ' + 'unexpected-byte scan') pb = self.read_byte() if pb is None: return True @@ -2503,7 +2554,7 @@ class Decoder(srd.Decoder): if b is None: break packet.append(b) - elif ((pb & 0xF0) == 0x60 or (pb & 0xF0) == 0x90): + elif plausible_sw(pb): # SW1: append SW2; the exchange is complete. sw2 = self.read_byte() if sw2 is None: diff --git a/tests/baseline.txt b/tests/baseline.txt index bdd3665..07292f0 100644 --- a/tests/baseline.txt +++ b/tests/baseline.txt @@ -1,7 +1,7 @@ -=== Baseline captured 2026-09-17T21:58:59+03:00 === +=== Baseline captured 2026-09-19T00:47:38+03:00 === === test_8 === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('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.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('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.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('etu recovered (samples):', 53) (11673977, '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] @@ -118,7 +118,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === xiaomi_mi_a1_coldboot === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -145,7 +145,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === 4gmodem_coldboot === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -186,7 +186,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -213,7 +213,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_A55_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -240,7 +240,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -267,7 +267,7 @@ Degradation metrics: RESULT: OK - capture fully explained (reader + CAT + valid retries) === samsung_S21p_coldboot_new_cable_sim2 === -('=== iso7816 decoder v1.8.0 ===',) +('=== iso7816 decoder v1.9.1 ===',) ('RST low at start: expecting cold-boot ATR',) iso7816-1: RST deasserted ('RST deasserted',) @@ -368,6 +368,7 @@ test_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pul test_is_desynced_all_zero_is_not_desync (__main__.TestDesyncHelpers.test_is_desynced_all_zero_is_not_desync) ... ok test_is_desynced_bad_sw (__main__.TestDesyncHelpers.test_is_desynced_bad_sw) ... ok test_is_desynced_clean_packets (__main__.TestDesyncHelpers.test_is_desynced_clean_packets) ... ok +test_is_desynced_null_sw1_is_invalid (__main__.TestDesyncHelpers.test_is_desynced_null_sw1_is_invalid) ... ok test_is_desynced_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok test_is_desynced_short_fragment (__main__.TestDesyncHelpers.test_is_desynced_short_fragment) ... ok test_max_tpdu_len (__main__.TestDesyncHelpers.test_max_tpdu_len) ... ok @@ -392,6 +393,11 @@ test_build_packet_payload_passthrough (__main__.TestGsmtapFlags.test_build_packe test_build_packet_plain_res_zero (__main__.TestGsmtapFlags.test_build_packet_plain_res_zero) ... ok test_flag_constant_value (__main__.TestGsmtapFlags.test_flag_constant_value) ... ok test_pps_subtypes_are_standard (__main__.TestGsmtapFlags.test_pps_subtypes_are_standard) ... ok +test_base_payload_without_rate (__main__.TestLineEventPayload.test_base_payload_without_rate) ... ok +test_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... ok +test_legacy_prefix_preserved (__main__.TestLineEventPayload.test_legacy_prefix_preserved) ... ok +test_payload_with_clk_rate (__main__.TestLineEventPayload.test_payload_with_clk_rate) ... ok +test_rate_absent_when_unknown (__main__.TestLineEventPayload.test_rate_absent_when_unknown) ... ok test_accepts_full_hold_fall (__main__.TestNextStartFall.test_accepts_full_hold_fall) ... ok test_finds_fall_after_threshold (__main__.TestNextStartFall.test_finds_fall_after_threshold) ... ok test_finds_fall_exactly_at_threshold (__main__.TestNextStartFall.test_finds_fall_exactly_at_threshold) @@ -419,6 +425,6 @@ test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok ---------------------------------------------------------------------- -Ran 68 tests in 0.027s +Ran 74 tests in 0.027s OK diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py index 119532c..4bf5ce3 100644 --- a/tests/test_gsmtap.py +++ b/tests/test_gsmtap.py @@ -79,23 +79,36 @@ class TestDesyncHelpers(unittest.TestCase): cls.gs, cls.pd = _load_iso7816() def test_plausible_sw_accepts_valid_classes(self): - for sw1 in list(range(0x60, 0x70)) + list(range(0x90, 0xA0)): + # ISO 7816-3 §10.3.3 Table 11: SW1 is '6X' or '9X', except '60' + # (the NULL procedure byte). + for sw1 in list(range(0x61, 0x70)) + list(range(0x90, 0xA0)): self.assertTrue(self.pd.plausible_sw(sw1), hex(sw1)) def test_plausible_sw_rejects_garbage(self): - for sw1 in (0x00, 0x01, 0x3B, 0x3F, 0xA0, 0xFF, 0x88, 0x5F): + for sw1 in (0x00, 0x01, 0x3B, 0x3F, 0x5F, 0x60, 0x88, 0xA0, 0xFF): self.assertFalse(self.pd.plausible_sw(sw1), hex(sw1)) def test_max_tpdu_len(self): self.assertEqual(self.pd.MAX_TPDU_LEN, 271) def test_is_desynced_clean_packets(self): - # 7-byte: header(5) + proc(1) + SW2(1) ending in a 9x SW1 - self.assertFalse(self.pd.is_desynced(b'\x00\xa4\x00\x04\x02\x60\x90')) + # header(5) + SW1 SW2 (P3=0 direct status word) + self.assertFalse(self.pd.is_desynced(b'\x00\xa4\x00\x04\x00\x90\x00')) # ACK-style GET RESPONSE (header + proc + payload + SW 9000) self.assertFalse(self.pd.is_desynced( b'\x00\xc0\x00\x00\x25\xc0\x62\x23\x90\x00')) + def test_is_desynced_null_sw1_is_invalid(self): + # Regression (v1.9.1): the T=0 NULL byte 0x60 is not SW1. The + # wire "NULL 0x60 + real SW1 0x61" was mis-framed as the status + # word "60 61" (real SW1 eaten as SW2). It must be flagged, not + # accepted (trace: SELECT MF -> NULL wait -> 61 29). + pkt = b'\x00\xa4\x00\x04\x02\x3f\x00\x60\x61' + valid, reason = self.pd.validate_t0_apdu(pkt) + self.assertFalse(valid, 'NULL must not pass as SW1') + self.assertIn('invalid SW1', reason) + self.assertTrue(self.pd.is_desynced(pkt)) + def test_is_desynced_short_fragment(self): # 5-byte header-only (INVALID-procedure residue) -- no SW read self.assertTrue(self.pd.is_desynced(b'\xa4\x3f\x00\x61\x25')) @@ -568,5 +581,39 @@ class TestEndOfStream(unittest.TestCase): self._decode_raising(SystemError).decode() +class TestLineEventPayload(unittest.TestCase): + """RST/VCC line-event payloads (GSMTAP sub_type 0x10/0x11), including the + CLK-frequency extension (v1.9.0).""" + + @classmethod + def setUpClass(cls): + cls.gs, cls.pd = _load_iso7816() + + def test_base_payload_without_rate(self): + self.assertEqual(self.pd.line_event_payload(0, 1), b'\x00\x01\x00') + self.assertEqual(self.pd.line_event_payload(1, 0, None), b'\x01\x00\x00') + + def test_payload_with_clk_rate(self): + p = self.pd.line_event_payload(0, 1, 4800000) + self.assertEqual(len(p), 7) + self.assertEqual(p[0], 0) # deasserted + self.assertEqual(p[1], 1) # resulting level: high + self.assertEqual(p[2], self.pd.LINE_EVENT_FLAG_CLK_HZ) + self.assertEqual(int.from_bytes(p[3:7], 'big'), 4800000) + + def test_rate_absent_when_unknown(self): + self.assertEqual(len(self.pd.line_event_payload(1, 0, None)), 3) + self.assertEqual(len(self.pd.line_event_payload(1, 0, 0)), 3) + + def test_legacy_prefix_preserved(self): + # Consumers that read only bytes 0-1 (pysniff decode_line_event) + # must see the same direction/level as before. + p = self.pd.line_event_payload(1, 0, 3579000) + self.assertEqual(p[:2], b'\x01\x00') + + def test_flag_constant(self): + self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01) + + if __name__ == '__main__': unittest.main() diff --git a/tools/vs_reader.py b/tools/vs_reader.py index d40644d..122b3b1 100644 --- a/tools/vs_reader.py +++ b/tools/vs_reader.py @@ -126,7 +126,9 @@ def embedded_exchanges(buf): def _plausible_sw(sw1): - return (0x60 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) + # Mirror of plausible_sw in pd.py: SW1 is '6X'/'9X' except '60' + # (the T=0 NULL procedure byte; ISO 7816-4 makes it an invalid SW1). + return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) def _plausible_ins(ins):