v1.9.0: CLK rate in RST events; v1.9.1: T=0 NULL is not an SW1

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.
This commit is contained in:
catarrh committed 2026-09-19 00:55:33 +03:00
1 parent e5a2b7c83b
commit 250c09c9b7
7 files changed
+233 -55

No files matched your search

+63 -5
View File
@@ -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
+8 -1
View File
@@ -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
+8 -1
View File
@@ -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
+83 -32
View File
@@ -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,
self._note_pre_sw(b,
'non-SW byte in P3=0 '
'response',
idle=(b in (0x00, 0xFF)))
'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,
self._note_pre_sw(b,
'non-SW byte in case-4 '
'response',
idle=(b in (0x00, 0xFF)))
'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,
self._note_pre_sw(pb,
'non-SW byte during '
'unexpected-byte scan',
idle=(pb in (0x00, 0xFF)))
'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:
+17 -11
View File
@@ -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
+51 -4
View File
@@ -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()
+3 -1
View File
@@ -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):