v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling

The audit's P2 items:

- ODD_INS: add 0x73 MANAGE SECURE CHANNEL and 0x75 TRANSACT DATA
  (TS 102 221 Table 10.5); the tools/vs_reader.py mirror and the pinned
  test are updated.
- 9E/9F are SW1: ISO 7816-3 10.3.3 Table 11 makes every 9X value SW1.
  Remove the fabricated "extended-length procedure byte" that consumed
  SW2 as a length and lost the status word.
- T=1: EOF-guard every block read, bound the M-bit chain with
  T1_CHAIN_MAX, and drop the caller's duplicate chain loop -- it read the
  next block after every I-block (usually the peer's R-block) and merged
  the following I-block's INF into the packet.  R/S blocks emit nothing;
  the (possibly chained) I-block INF is the APDU payload.
- Parity: a packet containing a parity-error byte is emitted with
  GSMTAP_FLAG_BAD_FCS (the CHKSUM annotation was invisible downstream),
  with a log note when the exchange is otherwise clean.
- EOF guards: handle_atr (TA/TB/TC/TD, historical, TCK), handle_pps
  (request/response fields incl. PPS1-3) and the T=1 block parser return
  cleanly on a truncated capture instead of raising on None.
- Dead code: _edge_read was initialized True and never cleared, so the
  ATR-based T=0 procedure loop (58 lines) and the 1.5-ETU guard branch
  were unreachable.  Remove the flag, both dead paths and the branch;
  dedent the live loop (behavior unchanged).
- Samplerate scaling: _samples_for_us() replaces the hard-coded 16 MHz
  thresholds -- start-bit deglitch (0.125 us), min_high floor (0.25 us)
  and the ETU glitch floor (0.75 us).  At 16 MHz the values are
  unchanged.

Validation: 130/130 unit tests (13 new: 9E/9F SW1, parity BAD_FCS,
T=1 I/R/S blocks + M chain + truncations + one-block-per-step, PPS
truncations, truncated ATR TA, samplerate/ETU-floor vectors); the
10-trace suite is unchanged from v1.9.6 (identical APDU/CHKSUM/CONCAT/
BAD_FCS counts, RESULT: OK); compare_baseline.sh reports IDENTICAL.
VERSION -> 1.9.7.
This commit is contained in:
catarrh committed 2026-10-20 19:02:20 +03:00
1 parent 3e39032802
commit 6e6f465841
7 files changed
+593 -359

No files matched your search

+25 -12
View File
@@ -1,7 +1,7 @@
=== Baseline captured 2026-10-20T17:56:26+03:00 ===
=== Baseline captured 2026-10-20T18:55:32+03:00 ===
=== test_8 ===
('=== iso7816 decoder v1.9.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -129,7 +129,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot ===
('=== iso7816 decoder v1.9.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('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.6 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -350,6 +350,7 @@ test_handoff_on_odd_ins_89 (__main__.TestAtrHuntDetect.test_handoff_on_odd_ins_8
test_inverse_ts_pattern_is_parsed (__main__.TestAtrHuntDetect.test_inverse_ts_pattern_is_parsed) ... ok
test_no_handoff_on_basic_channel_cla (__main__.TestAtrHuntDetect.test_no_handoff_on_basic_channel_cla) ... ok
test_no_handoff_on_invalid_ins (__main__.TestAtrHuntDetect.test_no_handoff_on_invalid_ins) ... ok
test_truncated_atr_ta_is_clean (__main__.TestAtrHuntDetect.test_truncated_atr_ta_is_clean) ... ok
test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok
test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok
test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok
@@ -360,6 +361,7 @@ test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recov
test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok
test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok
test_seeded_bit_samples_does_not_block_recovery (__main__.TestAtrHuntEscape.test_seeded_bit_samples_does_not_block_recovery) ... ok
test_9e_9f_are_status_words (__main__.TestDecodeStep.test_9e_9f_are_status_words) ... ok
test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_dense_valid_cla_is_not_idle_noise) ... ok
test_data_0x3b_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3b_before_traffic_is_atr_candidate) ... ok
test_data_0x3b_is_not_an_atr_after_traffic (__main__.TestDecodeStep.test_data_0x3b_is_not_an_atr_after_traffic) ... ok
@@ -371,6 +373,7 @@ test_immediate_sw_rejection_frames (__main__.TestDecodeStep.test_immediate_sw_re
test_invalid_ins_re_frame_recovers_command (__main__.TestDecodeStep.test_invalid_ins_re_frame_recovers_command) ... ok
test_inverse_ts_raw_pattern_is_an_atr_candidate (__main__.TestDecodeStep.test_inverse_ts_raw_pattern_is_an_atr_candidate) ... ok
test_le_256_response_data_is_kept (__main__.TestDecodeStep.test_le_256_response_data_is_kept) ... ok
test_parity_error_flags_bad_fcs (__main__.TestDecodeStep.test_parity_error_flags_bad_fcs) ... 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
@@ -430,11 +433,21 @@ test_finds_fall_exactly_at_threshold (__main__.TestNextStartFall.test_finds_fall
Regression test for Samsung 16 CLK/ETU concatenation. ... ok
test_rejects_phantom_sub_etu_fall (__main__.TestNextStartFall.test_rejects_phantom_sub_etu_fall) ... ok
test_returns_none_when_no_fall (__main__.TestNextStartFall.test_returns_none_when_no_fall) ... ok
test_etu_glitch_floor_scales (__main__.TestSamplerateScaling.test_etu_glitch_floor_scales) ... ok
test_samples_for_us (__main__.TestSamplerateScaling.test_samples_for_us) ... ok
test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok
test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok
test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok
test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok
test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok
test_decode_step_reads_exactly_one_block (__main__.TestT1Blocks.test_decode_step_reads_exactly_one_block) ... ok
test_handle_pps_truncated_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_is_clean) ... ok
test_handle_pps_truncated_optional_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_optional_is_clean) ... ok
test_i_block (__main__.TestT1Blocks.test_i_block) ... ok
test_m_chain_concatenates_inf (__main__.TestT1Blocks.test_m_chain_concatenates_inf) ... ok
test_r_and_s_blocks_have_no_inf (__main__.TestT1Blocks.test_r_and_s_blocks_have_no_inf) ... ok
test_truncated_block_before_lrc_is_clean (__main__.TestT1Blocks.test_truncated_block_before_lrc_is_clean) ... ok
test_truncated_inf_is_clean (__main__.TestT1Blocks.test_truncated_inf_is_clean) ... ok
test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok
test_invalid_cla_reserved_and_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_reserved_and_unseen) ... ok
test_invalid_cla_untraced_variants (__main__.TestValidateT0Apdu.test_invalid_cla_untraced_variants) ... ok
@@ -457,6 +470,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 117 tests in 0.026s
Ran 130 tests in 0.029s
OK
+169 -3
View File
@@ -266,7 +266,6 @@ class TestInverseConvention(unittest.TestCase):
def test_wait_data_falling_uses_high_idle_and_falling_start(self):
self.inst._inverse_convention = True
self.inst.bit_samples = 100
self.inst._edge_read = True
self.inst._min_pulse_samples = 0
self.inst.samplenum = 0
conds = []
@@ -284,6 +283,130 @@ class TestInverseConvention(unittest.TestCase):
self.assertEqual(self.inst._start_fall, self.inst.samplenum)
class TestT1Blocks(unittest.TestCase):
"""T=1 block parsing: NAD/PCB/LEN/INF/LRC, the M-bit chain, and the
EOF guards (a truncated block never raises or emits a half INF)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.out_ann = 0
inst.log = lambda *a, **k: None
inst.put = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
inst._bytes = collections.deque(byte_seq)
inst.read_byte = lambda: (inst._bytes.popleft()
if inst._bytes else None)
return inst
def _block(self, nad, pcb, inf):
body = [nad, pcb, len(inf)] + list(inf)
lrc = 0
for b in body:
lrc ^= b
return body + [lrc]
def test_i_block(self):
inst = self._make(self._block(0x00, 0x00, [0x6f, 0x00]))
self.assertEqual(inst.t1_parse_block(0), (True, [0x6f, 0x00]))
def test_r_and_s_blocks_have_no_inf(self):
inst = self._make(self._block(0x00, 0x80, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
inst = self._make(self._block(0x00, 0xc0, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_m_chain_concatenates_inf(self):
seq = (self._block(0x00, 0x20, [0x01])
+ self._block(0x00, 0x00, [0x02]))
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (True, [0x01, 0x02]))
def test_truncated_block_before_lrc_is_clean(self):
seq = self._block(0x00, 0x00, [0x6f, 0x00])[:5] # no LRC
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_truncated_inf_is_clean(self):
inst = self._make([0x00, 0x00, 0x05, 0x01, 0x02])
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_decode_step_reads_exactly_one_block(self):
# The T=1 caller used to run an extra chain loop after every
# I-block: it consumed the peer's R-block and merged the following
# I-block's INF into this packet. One step must parse one block.
apdu = [0x00, 0xa4, 0x00, 0x00, 0x00]
rblock = self._block(0x00, 0x80, [])
inst = self._make(self._block(0x00, 0x00, apdu) + rblock)
inst.state = 'DATA'
inst.hasT0 = False
inst.hasT1 = True
inst.bit_samples = 100
inst._pps_speed_changed = False
inst._resync = False
inst._at_eof = lambda: False
inst.out_ann = 0
inst.emitted = []
inst.emit_packet = lambda st, pl, ss, es, flags=0: \
inst.emitted.append((bytes(pl), flags))
inst._note_discard = lambda *a, **k: None
inst._note_pre_sw = lambda *a, **k: None
inst._consume_first = lambda: (inst._bytes.popleft()
if inst._bytes else None)
inst.peek_byte = lambda: inst._bytes[0] if inst._bytes else None
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertEqual(inst.emitted[0][0], bytes(apdu))
# The peer's R-block is left for the next step.
self.assertEqual(list(inst._bytes), rblock)
def test_handle_pps_truncated_is_clean(self):
inst = self._make([0x55])
inst.peeked_samplenum = 0
inst.handle_pps() # pps0 missing: must not raise
def test_handle_pps_truncated_optional_is_clean(self):
inst = self._make([0x55, 0x70]) # PPS0 asks for PPS1..3, then EOF
inst.peeked_samplenum = 0
inst.handle_pps() # PPS1 missing: must not raise
class TestSamplerateScaling(unittest.TestCase):
"""The pulse/glitch thresholds scale with the capture rate instead of
assuming 16 MHz."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def test_samples_for_us(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 16000000
self.assertEqual(inst._samples_for_us(0.125), 2)
self.assertEqual(inst._samples_for_us(0.25), 4)
self.assertEqual(inst._samples_for_us(0.75), 12)
inst.samplerate = 96000000
self.assertEqual(inst._samples_for_us(0.125), 12)
self.assertEqual(inst._samples_for_us(0.75), 72)
inst.samplerate = None
self.assertEqual(inst._samples_for_us(0.75), 12) # 16 MHz fallback
def test_etu_glitch_floor_scales(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 96000000
# 12-sample spacings are sub-ETU glitches at 96 MHz (floor 72).
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(None, False))
inst.samplerate = 16000000
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(12, True))
class TestValidateT0Apdu(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -386,10 +509,15 @@ class TestValidateT0Apdu(unittest.TestCase):
# Table 4 pairs (0F ERASE BINARY, 21 VERIFY, 87 GENERAL
# AUTHENTICATE, A1 SEARCH BINARY, B1 READ BINARY, B3 READ RECORD,
# C3 ENVELOPE, CB GET DATA, D1 WRITE BINARY, D7 UPDATE BINARY,
# DB PUT DATA, DD UPDATE RECORD) plus TS 102 221 AUTHENTICATE 89.
# DB PUT DATA, DD UPDATE RECORD), TS 102 221 AUTHENTICATE 89, and
# TS 102 221 Table 10.5 MANAGE SECURE CHANNEL 73 / TRANSACT DATA 75
# (the v1.9.6 audit found these two missing).
self.assertEqual(self.pd.ODD_INS, frozenset((
0x0F, 0x21, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0x0F, 0x21, 0x73, 0x75, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xDB, 0xD1, 0xD7, 0xDD)))
for ins in (0x73, 0x75):
with self.subTest(ins=ins):
self.assertTrue(self.pd.plausible_ins(ins))
def test_vs_reader_mirror(self):
# tools/vs_reader.py duplicates the acceptance predicates for its
@@ -968,6 +1096,36 @@ class TestDecodeStep(unittest.TestCase):
inst.wait = lambda cond=None: [1] * 8
return inst
def test_9e_9f_are_status_words(self):
# ISO 7816-3 10.3.3 Table 11: '9X' is SW1. The old code treated
# 9E/9F as an "extended-length" procedure byte and consumed SW2 as
# a bogus length, losing the status word.
for sw1 in (0x9e, 0x9f):
with self.subTest(sw1=sw1):
inst = self._make(bytes.fromhex('00a40000023f00')
+ bytes([sw1, 0x00]))
self.assertTrue(inst.decode_step())
self.assertEqual(
inst.emitted,
[(bytes.fromhex('00a40000023f00') + bytes([sw1, 0x00]), 0)])
def test_parity_error_flags_bad_fcs(self):
# A parity error is annotated and must also reach downstream as
# GSMTAP_FLAG_BAD_FCS on the emitted packet.
inst = self._make(bytes.fromhex('00a40000023f009000'))
orig = inst.read_byte
def read():
b = orig()
if b == 0x3f:
inst._parity_errors += 1
return b
inst.read_byte = read
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertTrue(inst.emitted[0][1] & self.pd.GSMTAP_FLAG_BAD_FCS)
def test_extended_channel_exchange_frames(self):
# 'CX' STATUS on channel 4 (v1.9.3): the whole exchange must be
# read and emitted clean.
@@ -1222,6 +1380,14 @@ class TestAtrHuntDetect(unittest.TestCase):
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.state, 'DATA')
def test_truncated_atr_ta_is_clean(self):
# T0 says TA1 present but the capture ends: the parse must return
# without committing an ATR (or raising on the None byte).
inst = self._make([0x3B, 0x10])
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
def test_inverse_ts_pattern_is_parsed(self):
# 0x03 + the raw inverse TS pattern must be committed as 0x3F and
# switch the readers to the inverse convention.