v1.5.0: invalid-INS one-byte re-frame + explicit discard logging

Residual CONCAT mis-frames (4gmodem/A55_sim2/S21p_sim2) traced to a silent
5-byte drop at an invalid-INS T=0 header (0x0f 00 a4 08 04). Drop only the
stray leading byte and re-frame the remaining four via _replay so the real
command (00 a4 08 04 02 2f 00 -> 61 1c) is recovered.

- _note_discard()/_consume_first(): every consumed-but-unemitted byte is
  now logged; idle 0x00/0xFF discards are rate-limited, real data always
  gets an ANN_WARN annotation.
- All read-until-SW loops log skipped non-SW bytes.
- All traces: CONCAT 0; BAD_FCS/GARBAGE 0; test_8/test_7 RESULT OK;
  47/47 unit tests; baseline byte-identical.

History rewritten to a single root commit so no example trace is stored
in the repo (fixtures are local-only, gitignored). README keeps only
generic <trace>.sr placeholders; AGENTS.md retains the fixture names.
This commit is contained in:
catarrh committed 2026-09-09 15:51:09 +03:00
commit d3715c9385
34 files changed
+7855

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Capture baseline measurements for all traces in mid-session mode.
# Usage: ./tools/capture_baseline.sh > tests/baseline.txt
# Compare with: diff tests/baseline.txt <(./tools/capture_baseline.sh)
set -e
cd "$(dirname "$0")/.."
# Clear stale bytecode cache — prevents false results from old code.
find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true
run_test() {
local label="$1"
shift
echo "=== $label ==="
# Run decoder, capture stderr too
local output
output=$("$@" 2>&1) || true
# Extract key metrics
echo "$output" | grep -E "iso7816 decoder v|etu recovered|ENDATR|CHKSUM ERROR|INVALID Procedure|desynced APDU|CONCAT APDU|warm reset|Invalid TS|No valid TS|RST low at start|RST deasserted|expecting cold-boot" | head -20
# CHKSUM errors are a key degradation signal.
chksum_count=$(echo "$output" | grep -c "CHKSUM ERROR" || true)
echo "CHKSUM ERROR count: $chksum_count"
# CONCAT: packets with embedded exchanges (mis-frame swallowed several
# real exchanges into one "valid" APDU). Must stay 0 for clean traces.
# grep the log form (with ':'), not the annotation text, so each packet
# is counted once.
concat_count=$(echo "$output" | grep -c "CONCAT APDU:" || true)
echo "CONCAT APDU count: $concat_count"
# Check for vs_reader results if pcap exists
if [ -f /tmp/out.pcap ]; then
local reader_log=""
case "$label" in
test_8*) reader_log="examples/test_8_reader.log" ;;
test_7*) reader_log="examples/test_7_reader.log" ;;
esac
if [ -n "$reader_log" ] && [ -f "$reader_log" ]; then
python3 tools/vs_reader.py "$reader_log" /tmp/out.pcap 2>&1 || true
else
# No ground-truth reader log; run pcap-only analysis to surface
# BAD_FCS / suspicious CLA / payload issues.
python3 tools/vs_reader.py --pcap-only /tmp/out.pcap 2>&1 || true
fi
fi
echo ""
}
# Signal diagnostic: extract key metrics from .sr trace
signal_diag() {
local sr_file="$1"
local clk="$2"
local data="$3"
local rst_opt=""
local vcc_opt=""
[ -n "$4" ] && rst_opt="--rst $4"
[ -n "$5" ] && vcc_opt="--vcc $5"
python3 tools/signal_diagnostic.py "$sr_file" --clk "$clk" --data "$data" $rst_opt $vcc_opt 2>&1 \
| grep -E 'Frequency|Period|Rising|Transitions|First activity|stable|noisy' | head -10
}
echo "=== Baseline captured $(date -Iseconds) ==="
echo ""
# test_8
run_test "test_8" sigrok-cli -i examples/test_8_raw16.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:rst_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# test_7
run_test "test_7" sigrok-cli -i examples/test_7_raw16.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:rst_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# xiaomi_phone_sample
run_test "xiaomi_phone_sample" timeout 30 sigrok-cli -i examples/xiaomi_phone_sample.sr -P iso7816:clk=CLK:data=DATA:rst=RST:clock_option=native:protocol=T=0:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# xiaomi_mi_a1_coldboot_sample — capture starts BEFORE power-up (VCC low at t=0).
# Regression fixture: must arm the ATR hunt when RST is still low at start
# and start decoding the ATR once the card wakes up.
run_test "xiaomi_mi_a1_coldboot" timeout 300 sigrok-cli -i examples/xiaomi_mi_a1_coldboot_sample.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# 4gmodem_coldboot_sample — same pre-power-up capture shape, modem SIM.
run_test "4gmodem_coldboot" timeout 300 sigrok-cli -i examples/4gmodem_coldboot_sample.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# samsung_A55_coldboot_new_cable — Samsung A55 with the new short cable
# (30 mm wires, external pull resistors GND->VCC and VCC->DATA), named
# channels DATA/VCC/RST/CLK on bits 0/2/4/5.
run_test "samsung_A55_coldboot_new_cable" timeout 300 sigrok-cli -i examples/samsung_A55_coldboot_new_cable.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# samsung_A55_coldboot_new_cable_sim2 — A55 SIM slot 2, same cable; trace
# cropped to the power-on window (~21 s of pre-power idle removed).
run_test "samsung_A55_coldboot_new_cable_sim2" timeout 300 sigrok-cli -i examples/samsung_A55_coldboot_new_cable_sim2.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# samsung_S21p_coldboot_new_cable — Samsung S21+ with the new short cable.
run_test "samsung_S21p_coldboot_new_cable" timeout 300 sigrok-cli -i examples/samsung_S21p_coldboot_new_cable.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# samsung_S21p_coldboot_new_cable_sim2 — S21+ SIM slot 2, same cable; trace
# cropped to the power-on window (~17 s of pre-power idle removed).
run_test "samsung_S21p_coldboot_new_cable_sim2" timeout 300 sigrok-cli -i examples/samsung_S21p_coldboot_new_cable_sim2.sr -P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=/tmp/out.pcap -A iso7816
# signal diagnostics
echo "=== signal diagnostics ==="
echo "--- test_8 ---"
signal_diag examples/test_8_raw16.sr CLK DATA RST VCC
echo "--- test_7 ---"
signal_diag examples/test_7_raw16.sr CLK DATA RST VCC
echo "--- xiaomi_phone_sample ---"
signal_diag examples/xiaomi_phone_sample.sr CLK DATA RST
echo "--- samsung_A55_coldboot_new_cable ---"
signal_diag examples/samsung_A55_coldboot_new_cable.sr CLK DATA RST VCC
echo "--- samsung_S21p_coldboot_new_cable ---"
signal_diag examples/samsung_S21p_coldboot_new_cable.sr CLK DATA RST VCC
# unit tests
echo "=== unit tests ==="
python3 tests/test_gsmtap.py -v 2>&1
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Compare current decoder output against saved baseline.
# Usage: ./tools/compare_baseline.sh
# Prerequisites: tests/baseline.txt exists (run capture_baseline.sh first)
set -e
cd "$(dirname "$0")/.."
BASELINE="tests/baseline.txt"
if [ ! -f "$BASELINE" ]; then
echo "ERROR: No baseline found at $BASELINE"
echo "Run ./tools/capture_baseline.sh > tests/baseline.txt first"
exit 1
fi
# Capture current output
CURRENT=$(mktemp)
trap "rm -f $CURRENT" EXIT
./tools/capture_baseline.sh > "$CURRENT" 2>&1
# Compare
echo "=== Comparing against baseline ==="
echo ""
# Strip timestamps and test execution times before comparing
BASELINE_CLEAN=$(mktemp)
CURRENT_CLEAN=$(mktemp)
trap "rm -f $BASELINE_CLEAN $CURRENT_CLEAN" EXIT
grep -v "^=== Baseline captured" "$BASELINE" | grep -v "^Ran [0-9]* tests in" > "$BASELINE_CLEAN"
grep -v "^=== Baseline captured" "$CURRENT" | grep -v "^Ran [0-9]* tests in" > "$CURRENT_CLEAN"
if diff -u "$BASELINE_CLEAN" "$CURRENT_CLEAN"; then
echo "IDENTICAL - no changes detected"
exit 0
else
echo ""
echo "DIFFERENCES FOUND - review above"
echo ""
echo "Key metrics to check:"
echo " - GARBAGE count must be 0 for test_8"
echo " - RESULT must be OK for test_8"
echo " - ETU values must match for all traces"
echo " - No new INVALID Procedure Byte errors"
echo " - CHKSUM ERROR count must not increase"
echo " - BAD_FCS / payload mismatches / suspicious CLA must not increase"
echo " - CONCAT count must be 0 for test_8 / test_7 / 4gmodem"
echo " - Capture APDU count must be >0 when reader log is present"
exit 1
fi
+1086
View File
File diff suppressed because it is too large. Load diff
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Lower-level signal quality diagnostics for .sr trace files.
Reads a sigrok .sr file and reports per-signal measurements:
CLK — frequency, duration, frequency changes
RST — transitions, stability (noisy = ignore)
VCC — transitions, stability (noisy = ignore)
DATA — oscillations, correlation with CLK
Usage:
python3 tools/signal_diagnostic.py trace.sr [--clk CLK] [--data DATA] [--rst RST] [--vcc VCC]
Channel names must match the probe names in the .sr metadata
(e.g. CLK, DATA, RST, VCC or D0, D1, etc.).
"""
import argparse
import statistics
import sys
import zipfile
import numpy as np
# ---------------------------------------------------------------------------
# .sr file reading
# ---------------------------------------------------------------------------
def parse_sr_metadata(path):
"""Return dict with 'samplerate', 'probes' (probe_num -> name), 'unitsize'."""
with zipfile.ZipFile(path, 'r') as zf:
meta = zf.read('metadata').decode()
info = {'probes': {}}
for line in meta.splitlines():
line = line.strip()
if line.startswith('samplerate'):
val = line.split('=', 1)[1].strip()
if val.endswith(' MHz'):
info['samplerate'] = float(val[:-4]) * 1e6
elif val.endswith(' kHz'):
info['samplerate'] = float(val[:-4]) * 1e3
elif val.endswith(' Hz'):
info['samplerate'] = float(val[:-3])
else:
info['samplerate'] = float(val)
elif line.startswith('unitsize'):
info['unitsize'] = int(line.split('=', 1)[1].strip())
elif line.startswith('total probes'):
info['total_probes'] = int(line.split('=', 1)[1].strip())
elif line.startswith('probe'):
parts = line.split('=', 1)
num = int(parts[0][5:])
info['probes'][num] = parts[1].strip()
return info
def read_sr_all(path):
"""Read all logic samples into a numpy uint8 array."""
with zipfile.ZipFile(path, 'r') as zf:
chunk_names = [n for n in zf.namelist() if n.startswith('logic-1-')]
chunk_names.sort(key=lambda n: int(n.split('-')[-1]))
chunks = [zf.read(c) for c in chunk_names]
return np.frombuffer(b''.join(chunks), dtype=np.uint8)
def get_channel_bit(metadata, channel_name):
"""Return the bit position (0-indexed) for a channel name, or None."""
for probe_num, name in metadata['probes'].items():
if name == channel_name:
return probe_num - 1 # probe 1 = bit 0, probe 2 = bit 1, ...
return None
# ---------------------------------------------------------------------------
# Signal measurement helpers
# ---------------------------------------------------------------------------
def extract_bit(arr, bit):
"""Extract a single bit from a numpy uint8 array -> bool array."""
return (arr >> bit) & 1
def measure_clk_fast(clk_arr, sr):
"""Measure CLK frequency from rising edges using numpy."""
if len(clk_arr) == 0:
return {'ok': False, 'reason': 'no data'}
# Find rising edges: 0->1 transitions
diffs = np.diff(clk_arr.astype(np.int8))
rising = np.where(diffs == 1)[0] + 1 # +1 because diff shifts by 1
if len(rising) < 2:
return {
'ok': False,
'reason': f'only {len(rising)} rising edges (need >= 2)',
'transitions': len(rising),
}
spacings = np.diff(rising)
median_spc = float(np.median(spacings))
freq = sr / median_spc if median_spc > 0 else 0
duration_s = (rising[-1] - rising[0]) / sr
# Frequency change: compare first and last quarter
freq_change = None
if len(spacings) >= 20:
n = len(spacings)
q1 = float(np.median(spacings[:n // 4]))
q4 = float(np.median(spacings[3 * n // 4:]))
if q1 > 0 and q4 > 0:
f1 = sr / q1
f4 = sr / q4
change_pct = abs(f1 - f4) / max(f1, f4) * 100
if change_pct > 10:
freq_change = f'{change_pct:.0f}% change ({f1/1e6:.2f} -> {f4/1e6:.2f} MHz)'
return {
'ok': True,
'rising_edges': len(rising),
'frequency_mhz': freq / 1e6,
'median_period_samples': median_spc,
'duration_ms': duration_s * 1000,
'freq_change': freq_change,
}
def count_transitions(arr):
"""Count transitions in a numpy bool array."""
if len(arr) == 0:
return 0
return int(np.sum(np.diff(arr.astype(np.int8)) != 0))
def measure_data_fast(data_arr, clk_arr, sr):
"""Measure DATA signal using numpy."""
if len(data_arr) == 0:
return {'ok': False, 'reason': 'no data'}
transitions = count_transitions(data_arr)
clk_data_corr = None
if len(clk_arr) == len(data_arr) and len(clk_arr) > 1:
clk_diffs = np.diff(clk_arr.astype(np.int8))
data_diffs = np.diff(data_arr.astype(np.int8))
clk_edges = np.where(clk_diffs == 1)[0]
if len(clk_edges) > 0:
matching = int(np.sum(data_diffs[clk_edges] != 0))
clk_data_corr = f'{matching}/{len(clk_edges)} ({matching / len(clk_edges) * 100:.0f}%)'
return {
'ok': True,
'transitions': transitions,
'clk_data_correlation': clk_data_corr,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_analysis(path, clk_name, data_name, rst_name, vcc_name):
"""Run full signal analysis on a .sr file. Returns results dict."""
meta = parse_sr_metadata(path)
sr = meta['samplerate']
clk_bit = get_channel_bit(meta, clk_name)
data_bit = get_channel_bit(meta, data_name)
rst_bit = get_channel_bit(meta, rst_name)
vcc_bit = get_channel_bit(meta, vcc_name)
raw = read_sr_all(path)
total_samples = len(raw)
duration_s = total_samples / sr
# Find first CLK activity
first_clk_sample = None
if clk_bit is not None:
clk_arr = extract_bit(raw, clk_bit)
clk_edges = np.where(np.diff(clk_arr.astype(np.int8)) == 1)[0]
if len(clk_edges) > 0:
first_clk_sample = int(clk_edges[0])
results = {
'samplerate_mhz': sr / 1e6,
'total_samples': total_samples,
'duration_ms': duration_s * 1000,
'channels_found': {
'CLK': clk_name if clk_bit is not None else None,
'DATA': data_name if data_bit is not None else None,
'RST': rst_name if rst_bit is not None else None,
'VCC': vcc_name if vcc_bit is not None else None,
},
'first_activity_sample': first_clk_sample or 0,
}
# CLK
if clk_bit is not None:
results['CLK'] = measure_clk_fast(extract_bit(raw, clk_bit), sr)
else:
results['CLK'] = {'ok': False, 'reason': f'channel "{clk_name}" not found'}
# RST
if rst_bit is not None:
rst_arr = extract_bit(raw, rst_bit)
rst_t = count_transitions(rst_arr)
results['RST'] = {
'ok': True, 'transitions': rst_t,
'stable_pct': (1 - rst_t / max(total_samples, 1)) * 100,
'noisy': rst_t > total_samples * 0.01,
}
else:
results['RST'] = {'ok': False, 'reason': f'channel "{rst_name}" not found'}
# VCC
if vcc_bit is not None:
vcc_arr = extract_bit(raw, vcc_bit)
vcc_t = count_transitions(vcc_arr)
results['VCC'] = {
'ok': True, 'transitions': vcc_t,
'stable_pct': (1 - vcc_t / max(total_samples, 1)) * 100,
'noisy': vcc_t > total_samples * 0.01,
}
else:
results['VCC'] = {'ok': False, 'reason': f'channel "{vcc_name}" not found'}
# DATA
if data_bit is not None:
data_arr = extract_bit(raw, data_bit)
clk_arr = extract_bit(raw, clk_bit) if clk_bit is not None else np.array([], dtype=np.uint8)
results['DATA'] = measure_data_fast(data_arr, clk_arr, sr)
else:
results['DATA'] = {'ok': False, 'reason': f'channel "{data_name}" not found'}
return results
def print_results(results):
"""Pretty-print signal diagnostic results."""
print(f" Sample rate: {results['samplerate_mhz']:.1f} MHz")
print(f" Samples: {results['total_samples']}")
print(f" Duration: {results['duration_ms']:.1f} ms")
found = results['channels_found']
print(f" Channels: CLK={found['CLK']} DATA={found['DATA']} RST={found['RST']} VCC={found['VCC']}")
if results.get('first_activity_sample', 0) > 0:
print(f" First activity: sample {results['first_activity_sample']}")
print()
# CLK
clk = results['CLK']
print(" CLK:")
if clk.get('ok'):
print(f" Frequency: {clk['frequency_mhz']:.2f} MHz")
print(f" Period: {clk['median_period_samples']:.1f} samples")
print(f" Rising edges: {clk['rising_edges']}")
print(f" Duration: {clk['duration_ms']:.1f} ms")
if clk.get('freq_change'):
print(f" WARNING: {clk['freq_change']}")
else:
print(f" FAIL: {clk.get('reason', 'unknown')}")
# RST
rst = results['RST']
print(" RST:")
if rst.get('ok'):
print(f" Transitions: {rst['transitions']}")
print(f" Stable: {rst['stable_pct']:.1f}%")
if rst['noisy']:
print(f" WARNING: noisy (>{1}% transitions) — consider --rst=ignore")
else:
print(f" Not present: {rst.get('reason', 'n/a')}")
# VCC
vcc = results['VCC']
print(" VCC:")
if vcc.get('ok'):
print(f" Transitions: {vcc['transitions']}")
print(f" Stable: {vcc['stable_pct']:.1f}%")
if vcc['noisy']:
print(f" WARNING: noisy (>{1}% transitions) — consider --vcc=ignore")
else:
print(f" Not present: {vcc.get('reason', 'n/a')}")
# DATA
data = results['DATA']
print(" DATA:")
if data.get('ok'):
print(f" Transitions: {data['transitions']}")
if data.get('clk_data_correlation'):
print(f" CLK corr: {data['clk_data_correlation']}")
else:
print(f" FAIL: {data.get('reason', 'unknown')}")
print()
def main():
parser = argparse.ArgumentParser(description='Signal quality diagnostic for .sr traces')
parser.add_argument('sr_file', help='Path to .sr file')
parser.add_argument('--clk', default='CLK', help='CLK channel name (default: CLK)')
parser.add_argument('--data', default='DATA', help='DATA channel name (default: DATA)')
parser.add_argument('--rst', default='RST', help='RST channel name (default: RST)')
parser.add_argument('--vcc', default='VCC', help='VCC channel name (default: VCC)')
args = parser.parse_args()
results = run_analysis(args.sr_file, args.clk, args.data, args.rst, args.vcc)
print(f"Signal diagnostic: {args.sr_file}")
print_results(results)
if __name__ == '__main__':
main()
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Compare a decoded GSMTAP pcap against a PC/SC reader APDU log.
Both describe the same ISO 7816-4 exchange stream: the reader log gives
one line per exchange as `C-APDU => R-APDU`, and the decoder emits each
exchange as one GSMTAP event carrying concatenated `C-APDU + R-APDU`.
The capture is usually a TIME-SLICE of the reader session (capture may
start mid-session), and the reader SDK does NOT surface CAT/proactive
traffic (TERMINAL PROFILE, FETCH, STATUS, ENVELOPE, ...) -- so the pcap
is the reader log's exchange sequence, interspersed with extra
proactive/polling exchanges. We therefore align via longest-common-
subsequence, not greedy prefix matching.
Degradation metrics:
- BAD_FCS: decoder-flagged desynced APDUs (GSMTAP_FLAG_BAD_FCS)
- CONCAT: packets that embed complete exchanges (mis-frame swallowed
several real exchanges into one)
- payload mismatches: LCS-matched pairs where bytes differ
- suspicious CLA: APDUs with unexpected CLA values
Usage:
python3 tools/vs_reader.py <reader.log> <capture.pcap>
python3 tools/vs_reader.py --pcap-only <capture.pcap> (no reader log)
"""
import argparse
import difflib
import re
import struct
import sys
# CAT commands the PC/SC reader SDK handles internally and does not log.
CAT_INS = {
0x10, # TERMINAL PROFILE
0x12, # FETCH
0x14, # TERMINAL RESPONSE
0x24, # GET INPUT
0x26, # PROVIDE LOCAL INFO
0xC2, # ENVELOPE
0xF2, # STATUS
}
# GSMTAP_FLAG_BAD_FCS from gsmtap_stream.py
GSMTAP_FLAG_BAD_FCS = 0x01
# Valid CLA values for ISO 7816 / 3GPP. Mirror of plausible_cla in pd.py:
# interindustry structures (high nibble 0x0/0x1/0x4/0x5), which covers the
# logical-channel classes 0x01-0x03 (e.g. the command after MANAGE CHANNEL
# opens channel 1), plus the proprietary SIM/UICC CAT classes.
def _plausible_cla(cla):
if not (cla & 0x80):
return (cla & 0xF0) in (0x00, 0x10, 0x40, 0x50)
return cla in (0x80, 0x84, 0xA0, 0xB0)
VALID_CLA = frozenset(cla for cla in range(256) if _plausible_cla(cla))
# ISO 7816-3 T=0 max TPDU length: header(5) + procedure bytes + 255 data + SW(2)
MAX_TPDU_LEN = 271
# --- embedded-exchange (CONCAT) detector ---
# A byte-drop mis-frame can swallow several real T=0 exchanges into one
# structurally valid packet: each swallowed header survives verbatim in the
# data region, followed by its P3 data bytes and an interior status word.
# These constants bound detector precision (empirically tuned on all 10
# example traces: 0 hits on clean traces, every Samsung/xiaomi family hit).
EMBEDDED_CLA = VALID_CLA
# Commands that actually occur on these cards' bus (T=0 + STK/CAT).
EMBEDDED_INS = frozenset((
0x12, 0x14, 0x20, 0x44, 0x70, 0x88,
0xA2, 0xA4, 0xB0, 0xB2, 0xB6,
0xC0, 0xC2, 0xCA, 0xCB, 0xD6, 0xDC, 0xF2,
))
# Real SW1 values that are not also frequent data bytes (BER-TLV tags).
EMBEDDED_SW1 = frozenset((
0x60, 0x61, 0x62, 0x63, 0x67, 0x68, 0x69,
0x6A, 0x6B, 0x6D, 0x6E, 0x6F, 0x90, 0x91,
0x93, 0x98, 0x99,
))
EMBEDDED_SW1_SLACK = 3 # SW1 search window after the nominal data end
def embedded_exchanges(buf):
"""Return [(header_start, sw1_index), ...] for complete T=0 exchanges
embedded inside a packet's data region (index 5 onward).
Pattern: header (CLA INS P1 P2 P3) + P3 data bytes + interior SW1/SW2
before the packet's own final status word. CLA must be structurally
valid (same rule as _plausible_cla); INS must be a real command byte;
SW1 is accepted within a small slack window after the nominal data end
and must be interior (trailing bytes follow it).
"""
n = len(buf)
out = []
if n < 12:
return out
q = 5
while q <= n - 9:
s = q
cla, ins, _p1, _p2, p3 = buf[s:s + 5]
if cla not in EMBEDDED_CLA:
q += 1
continue
if ins not in EMBEDDED_INS:
q += 1
continue
if p3 == 0:
q += 1
continue
d1 = s + 5 + p3
if d1 + 3 > n:
q += 1
continue
sw1 = -1
for e in range(d1, min(n - 1, d1 + EMBEDDED_SW1_SLACK + 1)):
if buf[e] in EMBEDDED_SW1:
sw1 = e
break
if sw1 == -1 or sw1 >= n - 2:
q += 1
continue
out.append((s, sw1))
q += 1
return out
def _plausible_sw(sw1):
return (0x60 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F)
def _plausible_ins(ins):
if ins == 0x00:
return False
if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90:
return False
if (ins & 0x01) != 0:
return False
return True
def validate_t0_apdu(packet):
'''See validate_t0_apdu in pd.py; same rules, duplicated here so the
standalone vs_reader can classify pcap entries without importing the
sigrok decoder module.'''
n = len(packet)
if n < 7:
return (False, 'too short (< 7)')
if n > MAX_TPDU_LEN:
return (False, 'exceeds T=0 max length')
cla = packet[0]
ins = packet[1]
sw1 = packet[-2]
if cla not in VALID_CLA:
return (False, 'invalid CLA')
if not _plausible_ins(ins):
return (False, 'invalid INS')
if not _plausible_sw(sw1):
return (False, 'invalid SW1')
return (True, None)
def parse_reader_log(path):
"""Return list of exchange byte-strings (C-APDU + R-APDU concatenated)."""
exchanges = []
for line in open(path, errors='ignore'):
m = re.match(r'^T0: .* TPDU: ([0-9a-fA-F]+)\s*=>\s*(.*)$', line.strip())
if not m:
continue
cmd = m.group(1)
resp = m.group(2).strip()
data = ''
sw = ''
if resp.startswith('(no data)'):
sw = resp[len('(no data)'):].strip().replace(' ', '')
else:
parts = resp.split()
sw = parts[-1] if parts else ''
data = ''.join(parts[:-1])
exchanges.append((cmd + data + sw).lower())
return exchanges
def parse_pcap(path):
"""Return (apdu_list, atr_count) where apdu_list is list of (apdu_hex, flags)
tuples for APDU (sub_type 0) packets, and atr_count is the number of ATR
(sub_type 1) packets."""
data = open(path, 'rb').read()
off = 24
out = []
atr_count = 0
while off < len(data):
if off + 16 > len(data):
break
ts_s, ts_u, caplen, origlen = struct.unpack('<IIII', data[off:off + 16])
if off + 16 + caplen > len(data):
break
pkt = data[off + 16:off + 16 + caplen]
off += 16 + caplen
if caplen < 58:
continue
glen = struct.unpack('>H', pkt[38:40])[0]
g = pkt[42:42 + glen - 8]
if g[12] == 0: # APDU
flags = g[15] # GSMTAP_FLAG_BAD_FCS etc.
out.append((g[16:].hex(), flags))
elif g[12] == 1: # ATR
atr_count += 1
return out, atr_count
def is_cat(apdu_hex):
b = bytes.fromhex(apdu_hex)
if len(b) < 2:
return False
return b[0] == 0x80 and b[1] in CAT_INS
def looks_valid(apdu_hex):
"""Structural validation for an ISO 7816-4 T=0 APDU.
Delegates to validate_t0_apdu(); kept as a thin wrapper for
backwards compatibility with existing reports."""
b = bytes.fromhex(apdu_hex)
valid, _ = validate_t0_apdu(b)
return valid
def byte_diff_details(expected_hex, actual_hex):
"""Return list of (offset, expected_byte, actual_byte) for mismatched bytes."""
eb = bytes.fromhex(expected_hex)
ab = bytes.fromhex(actual_hex)
diffs = []
for i in range(min(len(eb), len(ab))):
if eb[i] != ab[i]:
diffs.append((i, eb[i], ab[i]))
for i in range(len(eb), len(ab)):
diffs.append((i, None, ab[i]))
for i in range(len(ab), len(eb)):
diffs.append((i, eb[i], None))
return diffs
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('reader_log', nargs='?', default=None,
help='PC/SC reader log')
ap.add_argument('pcap', nargs='?', default=None,
help='Decoded GSMTAP pcap')
ap.add_argument('--pcap-only', action='store_true',
help='Analyze pcap without a reader log (phone traces)')
args = ap.parse_args()
if args.pcap_only:
reader = []
if args.pcap is None:
# --pcap-only pcap (pcap is the first positional)
if args.reader_log:
pcap_path = args.reader_log
else:
ap.error('--pcap-only requires a pcap argument')
else:
pcap_path = args.pcap
else:
if args.reader_log is None or args.pcap is None:
ap.error('requires reader_log and pcap, or --pcap-only pcap')
reader = parse_reader_log(args.reader_log)
pcap_path = args.pcap
pcap_raw, atr_count = parse_pcap(pcap_path)
pcap = [h for h, _ in pcap_raw]
pcap_flags = [f for _, f in pcap_raw]
sm = difflib.SequenceMatcher(None, reader, pcap, autojunk=False)
matched = sm.get_matching_blocks()
matched = [m for m in matched if m.size > 0]
matched_reader = set()
matched_pcap = set()
for a, b, size in matched:
for k in range(size):
matched_reader.add(a + k)
matched_pcap.add(b + k)
reader_missed = [i for i in range(len(reader)) if i not in matched_reader]
pcap_unmatched = [i for i in range(len(pcap)) if i not in matched_pcap]
pcap_cat = [i for i in pcap_unmatched if is_cat(pcap[i])]
pcap_dup = [i for i in pcap_unmatched
if i not in pcap_cat and looks_valid(pcap[i])]
pcap_garbage = [i for i in pcap_unmatched
if i not in pcap_cat and i not in pcap_dup]
# --- Degradation metrics ---
# BAD_FCS: decoder-flagged desynced APDUs
bad_fcs_indices = [i for i in range(len(pcap))
if pcap_flags[i] & GSMTAP_FLAG_BAD_FCS]
bad_fcs_in_garbage = [i for i in bad_fcs_indices if i in pcap_garbage]
bad_fcs_in_dup = [i for i in bad_fcs_indices if i in pcap_dup]
bad_fcs_in_cat = [i for i in bad_fcs_indices if i in pcap_cat]
bad_fcs_in_matched = [i for i in bad_fcs_indices if i in matched_pcap]
# CONCAT: structurally valid packets that embed complete exchanges
# (a byte-drop mis-frame swallowed several real exchanges in one).
concat_indices = [i for i in range(len(pcap))
if embedded_exchanges(bytes.fromhex(pcap[i]))]
# Payload mismatches: LCS-matched pairs where bytes differ
payload_mismatches = []
for a, b, size in matched:
for k in range(size):
ri, pi = a + k, b + k
if reader[ri] != pcap[pi]:
payload_mismatches.append((ri, pi, reader[ri], pcap[pi]))
# Suspicious CLA: check all capture APDUs
suspicious_cla = []
for i, hex_str in enumerate(pcap):
b = bytes.fromhex(hex_str)
if len(b) < 1:
continue
cla = b[0]
if cla == 0x80:
continue
if cla not in VALID_CLA:
suspicious_cla.append((i, hex_str))
# --- Report ---
print('reader exchanges : %d' % len(reader))
print('capture ATRs : %d' % atr_count)
print('capture APDUs : %d' % len(pcap))
print('byte-exact matches (LCS) : %d' % len(matched_reader))
print('reader exchanges not in capture (pre-capture / dropped): %d'
% len(reader_missed))
print('capture APDUs not in reader log:')
print(' - proactive/CAT (unlogged by reader SDK): %d' % len(pcap_cat))
print(' - valid exchanges (reader retries/dupes): %d' % len(pcap_dup))
print(' - GARBAGE (mis-framed/desync): %d' % len(pcap_garbage))
for i in pcap_garbage[:15]:
print(' GARBAGE: %s' % pcap[i])
print('')
print('Degradation metrics:')
print(' - BAD_FCS (desynced by decoder): %d' % len(bad_fcs_indices))
if bad_fcs_indices:
print(' in GARBAGE: %d, in retries: %d, in CAT: %d, in matched: %d'
% (len(bad_fcs_in_garbage), len(bad_fcs_in_dup),
len(bad_fcs_in_cat), len(bad_fcs_in_matched)))
print(' - CONCAT (multi-exchange swallowed in one packet): %d'
% len(concat_indices))
for i in concat_indices[:10]:
print(' pcap[%d]: %s' % (i, pcap[i][:72]))
print(' - payload mismatches (LCS-matched, bytes differ): %d'
% len(payload_mismatches))
for ri, pi, exp, act in payload_mismatches[:10]:
diffs = byte_diff_details(exp, act)
diff_str = ', '.join(
'off%+d: %02x->%02x' % (o, e if e is not None else 0xff,
a if a is not None else 0xff)
for o, e, a in diffs[:5])
print(' reader[%d] vs pcap[%d]: %s' % (ri, pi, diff_str))
if len(payload_mismatches) > 10:
print(' ... and %d more' % (len(payload_mismatches) - 10))
print(' - suspicious CLA: %d' % len(suspicious_cla))
for i, hex_str in suspicious_cla[:10]:
b = bytes.fromhex(hex_str)
print(' pcap[%d]: CLA=0x%02x %s' % (i, b[0], hex_str[:40]))
if len(suspicious_cla) > 10:
print(' ... and %d more' % (len(suspicious_cla) - 10))
# A pcap with zero APDUs while the reader log has exchanges is a
# vacuous pass: the decoder produced nothing to validate. Flag it.
no_apdu_failure = (len(pcap) == 0 and len(reader) > 0)
if no_apdu_failure:
print('\nWARNING: capture contains 0 APDUs but reader log has %d exchange(s).'
% len(reader))
ok = (len(pcap_garbage) == 0 and not no_apdu_failure)
print('\nRESULT: %s' % ('OK - capture fully explained (reader + CAT + valid retries)'
if ok else
'UNCLEAN - %d mis-framed capture APDU(s)%s'
% (len(pcap_garbage),
', 0 capture APDUs' if no_apdu_failure else '')))
sys.exit(0 if ok else 1)
if __name__ == '__main__':
main()