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

+5
View File
@@ -0,0 +1,5 @@
# Batch scripts require CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Keep shell scripts LF
*.sh text eol=lf
+6
View File
@@ -0,0 +1,6 @@
*.pcap
*.log
__pycache__/
# Trace fixtures are local-only (large, machine-specific); never tracked.
examples/
+879
View File
@@ -0,0 +1,879 @@
# AGENTS.md
## Policy: decoder changes must be validated against ALL example traces
Every change to `pd.py` must be followed by running the full test suite:
1. **Clear bytecode cache first** - stale `.pyc` files cause false results:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
find . -name "*.pyc" -delete 2>/dev/null
```
2. **Run all traces in mid-session mode** (the decoder always runs this way;
captures
all APDUs, including cold-boot ATRs):
```bash
# Run decoder for each trace
for trace in test_8_raw16 test_7_raw16 xiaomi_phone_sample \
samsung_phone_sample \
xiaomi_mi_a1_coldboot_sample 4gmodem_coldboot_sample \
samsung_A55_coldboot_new_cable samsung_A55_coldboot_new_cable_sim2 \
samsung_S21p_coldboot_new_cable samsung_S21p_coldboot_new_cable_sim2; do
# Run sigrok-cli and vs_reader as shown below
# Capture APDUs, GARBAGE count, and RESULT
done
```
3. **Acceptance criteria** (must ALL pass):
- **test_8 mid-session**: 56 APDUs, 28/37 reader exchanges (9 pre-capture), 0 GARBAGE, RESULT: OK
- **test_7 mid-session**: 65 APDUs, 37/37 reader exchanges, 0 GARBAGE, RESULT: OK
- **All smoke-test traces**: >0 APDUs in mid-session mode
- **No regressions**: GARBAGE count must not increase, no new BAD_FCS/payload mismatches
- **Unit tests**: 30/30 pass (`python3 tests/test_gsmtap.py -v`)
### Important: CLK signal behavior matters
- **Constant CLK** (test_7, test_8): Card reader keeps CLK running continuously
- Decoder can reliably sample DATA on each CLK edge
- Cold-boot re-arm handles warm resets
- **Gated CLK** (phone traces): Phone stops CLK when idle, restarts before next APDU set
- Decoder must handle CLK gaps and restart properly
- ETU auto-detection from DATA edges is critical
- This is the decoder's primary use case
- **Cold boot captured pre-power-up** (`*_coldboot_sample.sr`): VCC/RST are LOW
at t=0, capture spans the power-on (~17 s at 16 MHz), and the card emits its
ATR once RST deasserts
- Decoder must arm the ATR hunt when RST is still low at capture start
(see "Cold-boot re-arm") and not lock an ETU from pre-ATR power-up traffic
- It must decode the ATR `3b9f96801f...001e`
- `rst_detect=true`, `vcc_detect=true` for these fixtures
```bash
# --- 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 >/dev/null
python3 tools/vs_reader.py examples/test_8_reader.log /tmp/out.pcap
# --- 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 >/dev/null
python3 tools/vs_reader.py examples/test_7_reader.log /tmp/out.pcap
# --- xiaomi phone (gated CLK) ---
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 >/dev/null
# --- samsung phone 1 (gated CLK, non-default F/D) ---
sigrok-cli -i examples/samsung_phone_sample.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:protocol=T=0:gsmtap_enable=false:pcap_file=/tmp/out.pcap \
-A iso7816 >/dev/null
# --- xiaomi_mi_a1_coldboot (starts pre-power-up) ---
# rst_detect/vcc_detect=true are required for these fixtures.
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 >/dev/null
# --- 4gmodem_coldboot ---
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 >/dev/null
# --- unit tests ---
python3 tests/test_gsmtap.py -v
```
**Always clear the Python bytecode cache before testing** — stale `.pyc`
files can cause the decoder to use old code, producing false results:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
```
The acceptance criteria are:
**The decoder is NOT considered fully functional until ALL of these criteria are met.**
#### Ground truth
`test_7_reader.log` and `test_8_reader.log` are **trusted, complete APDU logs**
captured by the PC/SC reader SDK during the same recording session as the
sigrok trace. They contain every command the reader sent and every response
the card returned. They are the authoritative reference — the decoder output
must match them.
#### Success criteria (all must pass)
1. **test_8 and test_7 (reader-log traces):**
- **Every reader exchange must appear in the decoder output.**
For test_8: 28/37 reader exchanges are present in the pcap
(9 missing because they occurred before the capture started —
a trace-level limitation, not a decoder bug). For test_7: all
37/37 are present after the v1.1.10 deglitch fix (previously
only 10/37 due to ATR resets fragmenting the session).
- **0 garbage APDUs** in mid-session mode.
- **`RESULT: OK`** from `vs_reader.py`.
2. **All traces (including smoke-test):**
- **>0 APDUs** in mid-session mode for smoke-test traces.
- No increase in `CHKSUM ERROR`, `BAD_FCS`, payload mismatches, or
suspicious CLA counts.
3. **Every trace must be tested** (the decoder always runs mid-session — it
captures all APDUs, including cold-boot ATRs).
#### Known issues (must be fixed to meet criteria)
**T=0 framing produces incorrect APDUs.** The decoder's output now matches
the reader log for all reader exchanges:
- test_8: 28/37 reader exchanges are byte-identical in the pcap
(9 missing because they occurred before the capture started),
plus 17 extra CAT/proactive commands and 11 valid retries,
total 56 APDUs, 0 garbage.
- test_7: All 37/37 reader exchanges are byte-identical in the pcap,
plus 17 CAT/proactive commands, 8 ATRs, and 11 valid retries,
total 65 APDUs, 0 garbage.
Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK.
**Root cause:** `_edge_read` is initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop (lines 1808–1895) always
executes — the ATR-based procedure loop (lines 1898–1941) is dead code.
The edge-read path had two bugs:
1. **NULLs treated as command data (FIXED).** The first post-header byte
was checked for ACK; if it was NULL (0x60), it entered the case-3/4
branch and got appended as command data. Fixed by skipping NULLs
before the ACK check.
2. **"Read until SW" truncates GET RESPONSE (FIXED).** The ACK handler
read until it saw a byte in 0x6x/0x9x range, but FCP tag 0x62
matched this pattern, truncating GET RESPONSE to 1-2 bytes. Fixed
by using P3-bounded reads (read exactly P3 bytes then SW1 SW2) for
case-2 commands where P3 = Le.
#### CLA validation note
All example traces (`test_7`, `test_8`, `xiaomi`, `samsung`)
contain only CLA `0x00` (interindustry) and `0x8x` (USIM/CAT, mostly `0x80`
with occasional `0x81` for logical channel 1).
The decoder must **not** mandate specific CLA values — other CLA values
(e.g. `0x04`, `0x08`, `0x84`, `0xA0`, `0xB0`) are valid per ISO 7816-4 / 3GPP
and must be accepted. However, during test runs against the example traces,
any CLA value outside `0x00-0x0F` and `0x80-0x8F` is an error flag indicating
the decoder mis-framed the byte stream.
#### ISO 7816 APDU validation (enforced from v1.1.7)
The decoder validates every reassembled T=0 APDU with `validate_t0_apdu()`
before emitting it. Invalid APDUs are still packed into gsmtap but are
flagged with `GSMTAP_FLAG_BAD_FCS`.
Validation is purely structural (no content scanning of command/response
data), so the false-positive rate is very low. The checks are:
1. Length is at least 7 bytes (header + SW) and at most `MAX_TPDU_LEN` (271).
2. CLA byte is in the recognized set (`0x00`, `0x04`, `0x08`, `0x80`,
`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`.
The current validation deliberately does **not** enforce P3-based length
constraints, because a card may reject a command and send the status word
immediately even when P3 > 0.
#### v1.4.0: CONCAT detection (embedded exchanges, issue #1 gate)
A byte-drop mis-frame swallows 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.
Such packets pass `validate_t0_apdu()` but produce wrong APDUs downstream.
`embedded_exchanges()` (pd.py) scans the data region (`[5, n-9]`) for
embedded complete exchanges:
- header `CLA INS P1 P2 P3` where `INS ∈ EMBEDDED_INS` (commands that
actually occur on these cards: 12/14/20/44/70/88/A2/A4/B0/B2/B6/C0/C2/
CA/CB/D6/DC/F2) and `P3 ≥ 1`
- CLA valid, or the phantom byte-drop artifact `0x01` (corrupt `0x00`),
with a one-byte prefix slip tolerated when byte `0x01` precedes a real
header
- SW1 accepted in a 3-byte slack window after the nominal data end
(`d1 = s + 5 + p3`), from `EMBEDDED_SW1` (real status bytes that are not
also frequent data bytes), and the SW must be interior (`sw < n-2`, i.e.
more exchange data must follow it)
On a hit the decoder flags the packet `GSMTAP_FLAG_BAD_FCS` and logs
`CONCAT APDU: <k> embedded exchange(s) at <offsets>; packet <hex>`, so the
gate works live and offline. The detector is precision-tuned against all
10 example traces: 0 hits on clean traces (test_8/test_7/4gmodem), and it
flags every known corrupted family:
| Trace | APDUs | CONCAT hits |
|-------|-------|-------------|
| test_8 | 56 | 0 |
| test_7 | 65 | 0 |
| xiaomi_phone | 620 | 4 |
| samsung_phone | 924 | 5 |
| xiaomi_mi_a1_coldboot | 459 | 3 |
| 4gmodem_coldboot | 327 | 0 |
| A55_coldboot_new_cable | 386 | 3 |
| A55_coldboot_new_cable_sim2 | 375 | 2 |
| S21p_coldboot_new_cable | 380 | 3 |
| S21p_coldboot_new_cable_sim2 | 356 | 2 |
The concat packets are BAD_FCS-flagged on the phone traces, so their BAD_FCS
counts now equal the CONCAT counts (deliberate: those APDUs are genuinely
desynced). `vs_reader.py` reports the same detector; `capture_baseline.sh`
prints `CONCAT APDU count: N` per trace (grep of the `CONCAT APDU:` log line
only, so one line per packet). Acceptance: test_8/test_7/4gmodem must stay
0; phone-trace counts must not increase.
### v1.4.0: CONCAT gate — embedded-exchange detection (issue #1)
The decoder previously emitted byte-drop-mis-framed APDUs that swallowed
several real exchanges into one "valid" packet (the Samsung/xiaomi
"wrong APDU" family). Fragmentation is not representable in the
assembled bytes, so this is fixed as a detection gate, not a reframe:
- `embedded_exchanges()` scans the data region for embedded complete
exchanges (see "v1.4.0: CONCAT detection" under APDU validation).
- Hits are flagged `GSMTAP_FLAG_BAD_FCS` + logged `CONCAT APDU: ...`.
- `vs_reader.py` / `capture_baseline.sh` gain a `CONCAT` metric
(packet counts match the log lines exactly).
- Unit tests cover the tuned vectors (clean FCP/READ-RECORD/SELECT never
hit; U1/U4/record-burst/xiaomi swallows are caught).
- VERSION → 1.4.0. Baseline regenerated with the new CONCAT columns.
A proper *reframing* (re-splitting those packets and byte-dropping the
mis-frame at the source) is the next decoder step; until then the gate
makes the corruption visible in live and offline captures instead of
silently producing wrong APDUs.
### v1.5.0: invalid-INS one-byte re-frame (residual CONCATs → 0)
The last CONCAT family (4gmodem/A55_sim2/S21p_sim2) is now **reframed at
the source**, not just gated. All 10 traces report 0 CONCAT:
| Trace | APDUs | CONCAT hits |
|-------|-------|-------------|
| test_8 | 56 | 0 |
| test_7 | 65 | 0 |
| xiaomi_phone | 640 | 0 |
| samsung_phone | 924 | 0 |
| xiaomi_mi_a1_coldboot | 473 | 0 |
| 4gmodem_coldboot | 327 | 0 |
| A55_coldboot_new_cable | 407 | 0 |
| A55_coldboot_new_cable_sim2 | 395 | 0 |
| S21p_coldboot_new_cable | 399 | 0 |
| S21p_coldboot_new_cable_sim2 | 375 | 0 |
1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes
`plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or odd
LSB — ISO 7816-4 §12.1.1), the window is almost always shifted by one
stray leading byte (e.g. a leftover SW2 read after a PACKETEND). The old
code silently dropped all 5 bytes plus resync (`_resync = True`), and when
`resync_idle()` gave up on a busy burst the next byte became a bogus CLA
whose P3 swallowed the whole dialog into one CONCAT packet. Now the
decoder drops **only** the leading byte (explicitly logged via
`_note_discard`), re-frames the remaining four via `_replay`, and lets the
next decode step start a fresh header at the old INS. Example (4gmodem):
the stray `0f` after the READ BINARY SW1 `91` re-frames `0f 00 a4 08 04`
→ `00 a4 08 04 02`, recovering the true SELECT `00 a4 08 04 02 2f 00 →
61 1c` verbatim.
2. **No-silent-discard invariant.** Every byte the decoder consumes without
emitting is now explicitly logged via `_note_discard(value, reason,
idle=...)`. `idle=True` discards (0x00/0xFF idle stretches, NULLs, stuck
low) are rate-limited to the 1st + every 256th; `idle=False` discards
(suspicious CLA, invalid-INS re-frame byte, non-SW bytes in read-until-SW
scans) always put an `ANN_WARN` and the reason. Idle-line noise,
suspicious-CLA, stuck-low, and idle-byte-in-header consumers all go
through `_consume_first()` (which pops `_replay` before `peeked_byte`) so
re-framed bytes cannot infinite-loop.
3. **`_consume_first()` semantics.** `read_byte()` pops `_replay.popleft()`
first and only falls through to the wire; `peek_byte()` returns
`_replay[0]` first. Neither updates `peeked_samplenum`, so the re-frame
branch explicitly restores `self.peeked_samplenum = es` (the first byte's
sample) before pushing the 4 remaining bytes — the next header annotates
from a valid sample number.
4. **Counters.** `reset()` initializes `_discard_total` / `_idle_discard_total`; every `_note_discard` line carries "discards so far: N" for session-level triage.
The CONCAT detector (`embedded_exchanges()`, v1.4.0) stays as a regression
gate and now reports 0 on every trace. VERSION → 1.5.0. Baseline
regenerated.
**Phase B status (v1.5.0).** Two edge-reader hardening fixes shipped, both
verified byte-neutral on all 10 traces (stored baseline unchanged):
- `_next_start_fall()` now rejects sub-ETU phantom falls (a genuine start
fall must stay LOW for >= half an ETU) — B2.
- `_read_byte_edges()` re-anchors forward on a spurious latch instead of
dropping the byte (a drop fabricates a 0x00 / shifts the boundary) — B1.
Root cause of the Samsung/xiaomi phone-trace concatenation (the B1/B2 family)
is fixed at the source by the v1.5.0 invalid-INS re-frame — the A55 burst
cases decode clean with 0 CONCAT. The residual 4gmodem/A55_sim2/S21p_sim2
family (leftover-SW2-shifted header) is also gone. The planned B3 (strict
positional genuine-start anchor in the no-idle resync path) directly
conflicts with the documented 0.5-vs-1.5-ETU guard trade-off for fast phone
ETUs and risks the Samsung de-concatenation; it needs a dedicated design +
live validation, not an incremental patch.
### v1.3.0: `starts_with_atr` option removed
The entire ATR-included mode was redundant: its only mode-specific logic was a
start-state bootstrap (`FIND START` vs `DATA`) and an early trust hint for
`clock_skip=372`, both of which mid-session decoding reproduces via the
warm-reset / cold-boot re-arms already handled in `state == 'DATA'`
(starting the hunt instead of a fixed initial state). Every trace in the
suite produces identical APDU/GARBAGE/CHKSUM results with the option removed.
Removed:
- The `starts_with_atr` decoder option (schema + the 6 branching sites in
`pd.py`: initial state, `_clock_skip_confident` bootstrap, the
`handle_atr` samplenum save/restore, the cold-boot re-arm guard, and the
ETU re-measure condition — now `self.bit_samples is None or
self._pps_speed_changed`).
- `--starts-with-atr` from `start.sh` (now warned as obsolete).
The decoder always starts in `DATA` and re-arms the ATR hunt as needed.
### v1.2.1: cold-boot capture support
A capture that starts pre-power-up (VCC/RST low at t=0) previously locked a
bogus ETU (`bit_samples=1548`, `clock_skip=369`) from pre-ATR power-up DATA
transitions in mid-session mode, so the real ATR never synced. Fixed in
`241b7ed`:
- **Priming.** First `decode_step` reads the initial RST/VCC levels instead of
defaulting `last_rst`/`last_vcc` to 0 (which fabricated a reset edge).
- **Cold-boot re-arm.** RST low at capture start → arm the hunt at the
spec-default rate (FIND START, `clock_skip=372`, no ETU lock).
- **Guarded deassert re-arm.** The 10 ms-hysteresis-confirmed deassert lands
mid-ATR on warm resets; it only re-arms when `state == 'DATA'` so it no
longer garbles the in-flight ATR read (test_8 mid-session CHKSUM 18→0,
test_7 ATR-included 2→0).
New fixtures `xiaomi_mi_a1_coldboot_sample.sr` / `4gmodem_coldboot_sample.sr`
decode their full ATR in both modes with 0 GARBAGE.
### v1.2.0: start-bit deglitch (Samsung de-concatenation)
Samsung phone traces with gated CLK and non-default F/D (16 CLK/ETU)
produced concatenated APDUs: multiple T=0 exchanges merged into a single
GSMTAP packet. The v1.1.9 fixes (turnaround idle handling, edge-list
deglitch) improved other phone traces but the Samsung trace still showed
concatenation because the CLK-synchronous reader's start-bit detector
could not use the edge-list deglitch filter.
Fix applied (v1.2.0):
- Add a 2-sample deglitch in `wait_data_falling()`: after the 1.5-ETU
minimum-high guard passes, skip 2 additional samples and re-check DATA.
If DATA returns to idle, the start bit was a glitch → continue hunting.
This eliminates 1-sample (62.5 ns) LOW glitches in inter-byte guard
times that caused byte-level misalignment in the CLK-sync reader.
Results:
- Samsung mid-session: 52 → 924 APDUs (de-concatenated), all valid ISO 7816
commands (SELECT, GET RESPONSE, READ BINARY, STATUS, STK envelope), 0 GARBAGE.
- test_7 ATR: 10 → 65 APDUs, all 37/37 reader exchanges now captured
(was 10/37 in v1.1.9 due to ATR resets fragmenting the session).
- test_8 ATR: 54 → 56 APDUs, 28/37 reader exchanges (unchanged — 9 missing
are pre-capture, not a decoder issue).
- Xiaomi mid-session: 620 APDUs (unchanged, no regression).
**Glitch rejection measurements** (`-l 5` session log):
| Trace | Glitches rejected | Notes |
|-------|-------------------|-------|
| test_8 (reader) | 6 | Constant CLK, minor noise/turnaround artifacts |
| test_7 (reader) | 14 | Constant CLK, more activity than test_8 |
| xiaomi (phone) | **0** | Edge-list `_deglitch_edges()` filters before `wait_data_falling` |
| samsung (phone) | **1041** | CLK-sync reader has no upstream deglitch, confirms FX2LP pull-up hypothesis |
Xiaomi shows 0 because the edge-list reader's `_deglitch_edges()` (0.2×ETU
threshold) removes sub-ETU pulses before they reach `wait_data_falling()`.
The CLK-sync reader has no equivalent filter, so Samsung catches every
glitch. The 6–14 glitches on reader traces are minor noise or turnaround
artifacts in the constant-CLK captures.
The 2-sample advance in `wait_data_falling` shifts `_start_fall` by 2
samples, but at Samsung's 16 CLK/ETU (~72 samples) this is negligible
(<3% of a bit period) and does not affect downstream byte reading.
**Design note: magic number.** `_min_pulse_samples = 2` is tuned for the
primary 16 MHz sample rate (2 samples ≈ 125 ns, well within the FX2LP
pull-up rise time of 200–500 ns). At other sample rates the value would
need adjustment — but since all current traces are 16 MHz, this is left
as a hardcoded constant rather than a decoder option. If 12 MHz or
24 MHz traces are added later, consider making it configurable or
scaling it from the sample rate.
**Logging.** Each rejected start-bit glitch is logged with a running
count (`start-bit glitch rejected (#N)`) visible in the session log.
This helps diagnose whether the deglitch is too aggressive on a
particular capture — if the count is unexpectedly high, the 2-sample
window may be filtering real start bits.
### Proven data rates (all at 16 MHz sample rate)
| Trace | ETU (samples) | CLK/ETU | Mode |
|-------|---------------|---------|------|
| test_8 | ~43 | 372 (standard) | constant CLK, ATR+PPS |
| test_7 | ~43 | 372 (standard) | constant CLK, warm resets |
| xiaomi | ~53 | recovered from DATA | gated CLK, mid-session |
| samsung | ~72 | 16 (F=512/D=32) | gated CLK, non-standard F/D |
##### T=0 Procedure Bytes
| Byte | Meaning |
|------|---------|
| 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 |
| 0x9E / 0x9F | Extended-length: next byte L = number of response data bytes, then SW |
| SW1 (0x6x / 0x9x) | Status word: exchange complete |
##### CLA (Class) Byte — Interindustry Structure
```
Bit: 8 7 6 5 4 3 2 1
b8 b7 b6 b5 SM SM ch ch
```
- Bits 8–7: `00` or `01` for interindustry; `1x` reserved for proprietary
- Bit 6: `0` for interindustry; `1` for proprietary structural formats
- Bits 4–3: Secure Messaging indicator (`00` = no SM)
- Bits 2–1: Logical channel (0–3 for interindustry)
Valid interindustry CLA digits: `0x`, `1x`, `4x`, `5x`.
Examples: `0x00` (standard), `0x0C` (secure messaging).
##### INS (Instruction) Byte — Valid Ranges
- Low nibble cannot be `X0` if high nibble is even
- Low nibble cannot be `XF` under any circumstances
- `0x6x` and `0x7x` ranges are invalid (reserved for SW1 / protocol bytes)
Standard interindustry INS values: `A4` (SELECT), `B0` (READ BINARY),
`D6` (UPDATE BINARY), `B2` (READ RECORD), `E2` (APPEND RECORD),
`20` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88` (INTERNAL AUTHENTICATE).
##### Quick Validation Checklist
1. CLA high nibble is `0`, `1`, `4`, or `5` (for interindustry)
2. INS low nibble is not `F`
3. INS is outside `0x6x`–`0x7x` range
**Current smoke-test status:**
| Trace | APDUs |
|-------|-------|
| test_8 | 56 |
| test_7 | 65 |
| xiaomi_phone | 640 |
| samsung_phone | 924 |
| xiaomi_mi_a1_coldboot | 473 |
| 4gmodem_coldboot | 327 |
| A55_coldboot_new_cable | 407 |
| A55_coldboot_new_cable_sim2 | 395 |
| S21p_coldboot_new_cable | 380 |
| S21p_coldboot_new_cable_sim2 | 356 |
The baseline runs every trace through the decoder once
(intentional since v1.3.0: the `starts_with_atr` option was removed and the
decoder always runs mid-session — it captures all APDUs, including cold-boot
ATRs, and is the only mode that works on gated-CLK phone traces).
The four `samsung_*_coldboot_new_cable*_sim2` fixtures were captured 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 at 16 MHz. All four decode
their full cold-boot ATR (`3b9f9580…` on SIM1, `3b9f9680…` on SIM2) with
0 GARBAGE / 0 BAD_FCS. S21p SIM2 carries 197 CHKSUM (dirty-wire parity
noise, BAD_FCS still 0); A55 SIM2 is clean (0 CHKSUM). The `_sim2` fixtures
are cropped to the power-on window (the 17–21 s of pre-power idle was
removed).
Cold-boot fixtures start **before power-up**
(VCC/RST low at t=0); the decoder must arm the ATR hunt when RST is still low
at capture start and decode the full ATR once the card wakes (~17 s in).
`l8star_coldboot_sample.sr` was captured in the same session but had a
dirty-wire tail (GARBAGE=1 / RESULT UNCLEAN) and was dropped from the suite.
**Note on v1.1.3 fix:** The decoder measures CLK period as an average instead of using the minimum recurring period. This correctly handles traces where CLK periods alternate (e.g., 4,3,3 pattern for ~5.33MHz CLK at 16MHz sample rate). The fix changed `_measure_clock_period()` to use `sum(spacings) / len(spacings)` instead of `_robust_min(spacings)`, giving accurate `spc` values like 3.333... instead of 3. This corrected the `_wait_clk_rising()` skip calculation, fixing the post-ATR byte alignment issue.
### GSMTAP wire-format extensions
The GSMTAP-SIM output deviates from libosmocore's gsmtap.h in three
documented ways (see README.md "GSMTAP events" and the comments in
`gsmtap_stream.py`): sub_type 0x02 (`GSMTAP_SIM_PPS`) carries the PPS
request + response combined under the spec's `PPS_REQ` value (the spec
splits into `PPS_REQ` 0x02 / `PPS_RSP` 0x03; 0x03 is unused here);
0x10/0x11 (`RST_EVENT`/`VCC_EVENT`) are custom line-event subtypes; and
`GSMTAP_FLAG_BAD_FCS` writes flags into the header `res` byte, which
official gsmtap.h reserves — a simtrace2-sniff convention.
### Versioning
The decoder version is defined in `pd.py` as `VERSION = '1.5.0'`.
The version is printed to the log on decoder startup.
### Testing after decoder changes
After any change to `pd.py`:
1. Clear bytecode cache:
```bash
find . -name "__pycache__" -type d -exec rm -rf {} +
```
2. Run all tests against all traces:
```bash
./tools/capture_baseline.sh > tests/baseline.txt
```
3. Compare against saved baseline:
```bash
./tools/compare_baseline.sh
```
4. If differences found, review:
- Capture APDU count must stay >0 for any trace that previously had APDUs
- GARBAGE count must stay 0 for test_8 (mid-session)
- RESULT must stay OK for test_8 (mid-session)
- CHKSUM ERROR count must not increase
- BAD_FCS / payload mismatches / suspicious CLA must not increase
- ETU values must match for mid-session traces
- No new INVALID Procedure Byte errors
5. Run unit tests:
```bash
python3 tests/test_gsmtap.py -v
```
6. Update `tests/baseline.txt` with new baseline if changes are intentional.
## What happened: the decoder rewrite attempt
We attempted a major rewrite of the ISO 7816 decoder (`pd.py`) with these
features:
1. **Edge-counting always** — removed the `native_fast` sample-skip bit
reader, making all bit reads count CLK edges per bit instead of skipping
`clock_skip * samples_per_edge` samples. The idea was drift-immunity.
2. **Signal degradation recovery** — a `degraded` flag tracking rolling
parity errors; when ≥ 8 of 64 frames fail parity, the decoder re-arms
the ATR hunt and flags APDUs with `GSMTAP_FLAG_BAD_FCS`.
3. **Clock-drift monitor** — every 128 bytes the decoder re-measures the
CLK period and re-anchors `samples_per_edge` if the median drifts > 2%.
4. **Fatal TCK check** — the ATR's TCK checksum mismatch was made fatal
(reject the ATR) instead of being logged as a warning.
5. **Synthetic ATR fallback** — after 8 failed ATR hunts, commit a
minimal T=0 ATR `[0x3B, 0x00]` and begin decoding, for mid-session
captures.
## Outcome
The rewrite introduced a regression: **35 garbage APDUs** on `test_8`
where the baseline had **0 garbage**. The root causes:
1. **Fatal TCK check** — the ATR in the example traces has a wrong TCK
(`got=f7 expected=80`) due to a bit error in the capture. The fatal
check rejected this real ATR, preventing the decoder from ever
reaching the DATA state.
2. **Signal degradation ATR re-arm** — the `degraded` flag triggered on
the capture's natural bit errors, re-arming the ATR hunt mid-session
and fragmenting the APDU stream.
3. **Stale `.pyc` cache** — the Python bytecode cache was not
regenerated after code changes, causing the running decoder to use
old code. This masked the real regression for several test runs.
The edge-counting change itself may also have contributed subtle
bit-sampling differences, but the TCK check and signal degradation were
the primary causes.
## Resolution
We reverted to the known-good baseline (`2055d1c`, the VCC/RST hysteresis
commit) and applied only three essential, minimal fixes on top:
1. **`hasT0`/`hasT1` init in `start()`** — initialize both to `False` for
`protocol=auto`, preventing `AttributeError` when `starts_with_atr=false`
enters DATA state without an ATR.
2. **`hasT0=False` guard in `decode_step` DATA state** — when no protocol
is known, skip byte reading and wait for a warm reset to trigger the
ATR hunt.
3. **Synthetic T=0 ATR fallback** — after 8 failed ATR hunts, commit
`[0x3B, 0x00]` and set `hasT0=True` so the decoder can begin
processing APDUs in mid-session captures.
The `native_fast` sample-skip, signal degradation, clock monitor, and
fatal TCK check were all discarded. The decoder is now the original
`2055d1c` code plus 24 lines of mid-session support.
## Current architecture (v1.1.1, post-ATR desync fixes)
The decoder uses a **CLK-synchronous bit reader** for native-CLK captures and
an edge-list DATA reader for the non-native clock modes. Bit timing comes
from **counting real CLK edges**, not from a sample-count estimate.
### Two bit readers (selected per burst)
| Reader | Timing source | Used when |
|--------|---------------|-----------|
| `_read_byte_clk()` | count `clock_skip` CLK rising edges per ETU, sample DATA at bit centres | native clock mode **and** `clock_skip` known (`_clock_skip_confident`) |
| `_read_byte_edges()` | DATA-edge reconstruction from `bit_samples` | `sample_as_clock`/`detect`, or mid-session before `clock_skip` is recovered |
### Key changes
1. **CLK-synchronous reader.** `_read_byte_clk()` counts `clock_skip` CLK
rising edges per ETU and samples DATA at the bit centres (1.5 … 9.5 ETU).
Exact for `0x00`/`0xff` (too few DATA transitions for the edge reader) and
deterministic, because `clock_skip` is protocol-defined (372 for the ATR,
FI/DI after PPS). Safe across Clock Stop: CLK never stops mid-character
and resumes before the next start bit. `_wait_clk_rising()` skips most of
each bit by samples then lands on the exact edge, so it is fast.
2. **No synthetic ATR.** The old `[3B, 00]` fallback fabricated a 2-byte ATR
from a stray `0x3B`/`0x3F` byte in command traffic (e.g. the FID `3F00` of
a SELECT) and destroyed the command. The hunt is bounded and never emits
a fake ATR.
3. **Command detection during ATR hunt.** A plausible CLA + valid INS is
replayed to the DATA state and decoded as a command instead of skipped as
"invalid TS" — this recovers a SELECT when the preceding ATR was missed.
4. **RST resets `clock_skip` to 372** on RST assert (a reset makes the card
send ATR at the default rate).
5. **PPS on the native path** just updates `clock_skip` from FI/DI (CLK
period is unchanged, so the new ETU is exact). No DATA re-measurement
burst is consumed — previously that silently dropped the first post-PPS
command.
6. **Mid-session `clock_skip` recovery.** `_measure_etu()` recovers
`bit_samples`; `_measure_clock_period()` recovers `_samples_per_clock`;
`_derive_clock_skip_from_etu()` sets `clock_skip = bit_samples /
_samples_per_clock` and marks it confident. If the CLK period cannot be
measured (fully gated CLK), the edge-list reader is used instead.
7. **Data handling.** Valid APDUs are emitted clean; ambiguous frames are
emitted flagged `GSMTAP_FLAG_BAD_FCS` (never silently dropped); genuine
idle-low all-zero noise remains suppressed.
### Testing
- `test_8` / `test_7`: **NOT YET PASSING.** 0 garbage, but decoder output
does not match reader log (T=0 framing groups exchanges incorrectly).
See success criteria above.
## Mid-session phone decode (legacy note, still applies)
For a live mid-session phone capture (`protocol=T=0`),
the decoder now:
- Recovers the ETU from the live DATA line via `_measure_etu()` (edge-list,
locked once) so a gated/stopped CLK at a fast ETU (e.g. ~53 samples) is
decoded instead of mis-read at the 372-clock default.
- Decodes bytes with the edge-list reader `_read_byte_edges` (the only
reader now), which is drift-immune.
- Runs the normal T=0 framing so each command+response exchange is emitted as
one APDU.
Validation: `test_8`/`test_7` 0 garbage in both modes; 12 unit tests pass;
phone 16M/24M decode to coherent APDUs with the STK text fully readable and
no `0xff`/`0xfc` garbage.
### T=0 case 3/4 command-data (RESOLVED)
Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those
P3 bytes on the wire **before** the card's first procedure byte. The
framing now distinguishes the cases by the card's first procedure byte:
- If the first post-header byte **is the ACK (== INS)** → case 2/4: the
terminal sent no command data; read `P3` response bytes (+SW).
- Otherwise the byte is terminal command data → case 3/4: read `P3`
command-data bytes, then the card's procedure byte (ACK/SW/`9E`/`9F`).
On ACK (case 4) the response is read `P3` response bytes (+SW).
This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope
commands) into one C-APDU = header + command data, R-APDU = response + SW
APDU, instead of fragmenting them across frames. It is gated to
`_edge_read` (mid-session / gated-CLK captures) so ATR-based captures
(`test_8` / `test_7`) keep the original framing unchanged and stay at
0 garbage.
All paths use P3-bounded reads (v1.1.1 reverted the read-until-SW
experiment from v1.1.0 which mis-identified FCP `0x62` as SW1).
## Decoder methods tried — reliability registry
A running log of decoder approaches, so future work does not re-litigate
discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
### Proven reliable (kept)
- **Sample-skip bit reader (`native_fast`).** Original reader; advances bit
timing with single sample-skip waits. Reliable for ATR-based captures
(`test_8`/`test_7`, 0 garbage). Keep for those.
- **ATR TS validation + resync.** Validate TS (`0x3B`/`0x3F`); resync on the
next fall if bogus. Reliable; stops a phantom start bit shifting the ATR
off-by-one.
- **Warm-reset re-arm (guarded).** RST deassert → re-arm the ATR hunt
(`FIND START`, `clock_skip` 372, cleared ETU) **only when `state == 'DATA'`**
(hunt genuinely idle). Deasserts that arrive while `FIND START`/`ATR` is in
progress just log "RST deasserted (hunt/ATR already in progress)" and leave
state alone — an unguarded mid-ATR re-arm corrupted the in-flight ATR read
(test_8 mid-session CHKSUM 18→0, test_7 ATR-included 2→0 after the guard).
Reliable; re-ATRs parse at the default rate.
- **Cold-boot priming.** One-shot on the first `decode_step`: `wait({'skip': 0})`
reads the *initial* VCC/RST levels instead of defaulting `last_rst`/`last_vcc`
to `0`. Without this, a capture that starts pre-power-up fabricated a spurious
reset at sample 0.
- **Cold-boot re-arm.** When the capture starts with RST **low** (VCC not yet
on), arm the hunt immediately regardless of start conditions: state
`FIND START`,
`_atr_hunt_count=0`, `clock_skip=372`, `bit_samples=None` (no ETU lock), so the
pre-ATR power-up DATA transitions cannot lock a bogus ETU (`bit_samples=1548`,
`clock_skip=369` before this fix). Logs "RST low at start: expecting cold-boot
ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` /
`4gmodem_coldboot_sample.sr` (0 GARBAGE).
- **1.5-ETU start-bit guard.** Rejects the phantom `0x00` an idle glitch
inserts before an ATR. Reliable.
- **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after
≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events
from a ringing/floating sense line.
- **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the
native skip becomes F/D CLK cycles/bit. Reliable (verified live).
- **T=1 block-chain follow-up bound.** Caps chained I-blocks so garbage
cannot loop forever. Reliable.
- **Synthetic T=0 ATR fallback `[0x3B, 0x00]`.** After 8 failed ATR hunts,
commit a minimal ATR and set `hasT0=True`. Reliable for mid-session starts
(no ATR in the trace).
- **ETU auto-detect from DATA via edge counting (`_measure_etu`).** Edge-waits
to the first DATA start-bit fall, measures ETU from DATA-edge spacing, locks
it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking
once (not re-measuring) is drift-immune and avoids glitch re-anchors.
- **Edge-list bit reader (`_read_byte_edges`).** Per-byte edge reconstruction
from the DATA edge list (re-anchors the next byte via `_next_start_fall`).
Drift-immune. Reliable for mid-session/edge mode; gated by `_edge_read`.
- **`decode_step` unified dispatch.** One state machine handles
ATR/PPS/DATA + mid-session. Reliable; current architecture.
- **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte
reading until a protocol is known. Reliable.
- **T=0 case-3/4 ACK==INS discriminator.** First post-header byte == INS ⇒
case 2/4 (response bounded by `P3`); else command data (read `P3`, then
the procedure byte). Reliable for consolidating STK envelope commands.
Gated to `_edge_read`. P3-bounded reads avoid FCP tag misidentification
(v1.1.1 reverted read-until-SW from v1.1.0).
- **1.5-ETU start-bit guard in `wait_data_falling`.** `min_high = 1.5 *
bit_samples` distinguishes genuine inter-byte idle gaps (≥2 ETU per ISO
7816-3) from intra-byte HIGH periods (≤1 ETU). Prevents byte misalignment
after `_measure_etu()` consumption (v1.1.1 fix for post-ATR desync).
- **Inverse-convention ATR detection in DATA state.** `firstByte == 0x3f`
alongside `0x3b` at line 1656, so inverse-convention ATRs (TS=0x3F) are
parsed by the default state. Sets `clock_skip=372` correctly
before PPS (v1.1.1 fix).
- **`_resync = True` after PPS acceptance (native CLK path).** Forces
`resync_idle()` before the first post-PPS command, preventing the decoder
from latching onto a mid-byte transition at the new speed (v1.1.1 fix).
### Tried and rejected (do not re-add)
- **Edge-counting as the *sole* bit reader (remove `native_fast`
entirely).** Subtle per-bit sampling differences regressed `test_8` (35
garbage). Edge-counting is fine **as a second reader** (`_read_byte_edges`)
used only in mid-session mode; it is not a drop-in replacement for the whole
pipeline.
- **Signal-degradation ATR re-arm (`degraded` flag on ≥8/64 parity errors).**
Triggered on the capture's natural bit errors, re-armed the ATR hunt
mid-session, and fragmented the stream. Rejected.
- **Clock-drift monitor (re-measure CLK every 128 bytes, re-anchor
`samples_per_edge`).** Unneeded: locking the ETU once is sufficient and
safer. Rejected.
- **Fatal TCK check.** The example ATRs carry a wrong TCK (capture bit error,
`got=f7 expected=80`); fatal rejection blocked the DATA state entirely.
TCK must stay a non-fatal warning. Rejected.
- **CLA/P1P2 heuristic routing to T=1.** Misread real T=0 traffic as T=1.
Rejected.
- **`parse_packet` heuristic / APDU-boundary guessing from CLA/P1P2 + Le/ALG
tables.** Produced false splits and mis-framed exchanges. Rejected in favor
of proper T=0/T=1 transport state machines.
- **`ignore_rst` / `ignore_vcc` options.** Superseded by channel assignment +
hysteresis. Rejected.
- **Generic "read response until a `6x`/`9x` status word" for T=0 framing.**
Any `0x6x`/`0x9x` byte (e.g. command-data `0x6c`, FCP `0x6f`) was taken as
SW1, truncating/fragmenting exchanges and dropping most of the capture.
Rejected; replaced by the ACK==INS discriminator (case 2/4 bounded by
`P3`) with P3-bounded reads for all paths (v1.1.1 reverted the
read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as
SW1).
- **0.5-ETU start-bit guard in `wait_data_falling` (for `_edge_read`).**
`min_high = 0.5 * bit_samples` was too small for slow ETUs — it accepted
intra-byte HIGH periods (data bit transitions) as start bits, causing byte
misalignment after `_measure_etu()` consumption. Replaced by 1.5-ETU guard
in v1.1.1.
### Open residual limitations
- Mid-session captures that begin inside an exchange cannot frame the first
command perfectly (expected; the rest of the stream is clean).
- **Samsung phone — gated CLK, non-default F/D** (`samsung_phone_sample.sr`).
Uses F=512/D=32 (16 CLK cycles/bit). `_measure_etu` correctly measures
the ETU from DATA edge spacings (after glitch filtering). Now passes
with 0 GARBAGE and de-concatenated APDUs after v1.1.10 deglitch fix.
### v1.1.6 fix: bounded T=0 loops and explicit EOF handling
Tightened the edge-read T=0 procedure-byte handlers to prevent runaway
reads and to surface end-of-capture explicitly.
1. **Bounded all "read until SW" / turnaround-skip loops.** Replaced the
unbounded ACK-handler `while` and the open-ended `range(512)` loops with
`for _ in range(MAX_TPDU_LEN)`. Added EOF checks so the decoder returns
cleanly at end-of-capture instead of consuming zeros/turnaround bytes
indefinitely.
2. **`read_byte()` / `peek_byte()` return `None` on EOF.** Previously both
methods returned `0x00` when the sample stream ended, making it
impossible for callers to distinguish real data from padding. They now
return `None`; the T=0 DATA-state header and procedure-byte loops check
for `None` and exit the current exchange.
3. **Skip turnaround idle bytes before appending.** The P3=0 and case-4
ACK "read until SW" paths now mirror the v1.1.5 unexpected-byte fix:
non-SW bytes (including 0xFF turnaround artifacts) are skipped before
the status word is appended, rather than being added to the packet.
### v1.1.5 fix: turnaround idle bytes in edge-read path
Samsung phone traces with gated CLK had 5 garbage APDUs (BAD_FCS) in
mid-session mode. Root cause: two code paths in the edge-read T=0
procedure-byte handler did not skip turnaround idle bytes (0xFF) on the
bidirectional DATA line.
1. **ACK handler (case 2/4):** After reading P3 response bytes, the code
read 2 raw bytes as SW1 SW2. For case-3 commands (SELECT with data),
the terminal sends command data after ACK, then the DATA line idles
(0xFF) before the card sends SW. The idle 0xFF was mis-identified as
SW1. Fixed by adding a `while` loop that skips non-SW bytes before
reading SW1.
2. **"Unexpected byte" handler (procedure byte loop):** Non-SW bytes
(e.g. 0xFF turnaround) were appended to the packet before scanning for
SW, corrupting the SW position. Fixed by moving `packet.append(pb)`
inside the SW1 check so non-SW bytes are skipped.
+506
View File
@@ -0,0 +1,506 @@
# sigrok_iso7816_stream
Fork of [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) with
**live GSMTAP UDP streaming**, making a cheap FX2 logic analyzer (Saleae clone)
behave like a SIMtrace2 sniffer:
```
SIM card ⇄ reader (passive tap on the contacts)
→ FX2 logic analyzer (fx2lafw, 8ch, 24MHz max)
→ sigrok-cli + iso7816 decoder
→ GSMTAP UDP :4729 → simtrace2-pysniff / Wireshark
```
## The tap in practice
![Components](resources/components.jpg)
*Components used in the build*
![In action with phone](resources/in_action_phone.jpg)
*The rig tapping a phone SIM session*
![In action](resources/in_action.jpg)
*The rig tapping a card-reader session*
![SIM adapter](resources/sim_adapter.jpg)
*The SIM-tap adapter*
![SIM adapter pinout](resources/sim_adapter_pinout.jpg)
*Tap wiring on the SIM contacts*
![Short cable](resources/short_cable.jpg)
*The short-cable tap — reduces loop area and rise-time artifacts*
![Short cable wiring](resources/short_cable_wiring.jpg)
*Short-cable wiring detail*
![Short cable gear](resources/short_cable_gear.jpg)
*Short cable with the breakout gear*
![Short cable pull resistors](resources/short_cable_pull_resistors.jpg)
*Short cable with external pull resistors — 10 kΩ to GND on VCC (pulls low when off), 47 kΩ to VCC on DATA (faster rise to high)*
## What was added on top of upstream
| Feature | Description |
|---------|-------------|
| Live GSMTAP stream | Every decoded event is sent as a GSMTAP-SIM UDP packet (default on, mimics simtrace2-sniff) |
| Correct sub_types | ATR=0x01 and PPS=0x02 now tagged properly in PCAP output (upstream hardcoded everything to APDU=0x00) |
| ATR/PPS in PCAP | ATR and combined PPS request+response are emitted as PCAP packets |
| RST tracking (optional) | `rst_detect=true`: emits reset assert/deassert events (sub_type 0x10) |
| VCC tracking (optional) | `vcc_detect=true`: emits power on/off events (sub_type 0x11) |
| Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection |
| Start-bit deglitch | 2-sample deglitch in the start-bit detector eliminates sub-ETU LOW glitches from FX2LP pull-up artifacts, preventing byte misalignment on fast phone traces |
## Decoder options
| Option | Default | Description |
|--------|---------|-------------|
| `clock_option` | `native` | `native`, `detect`, `sample_as_clock` (see upstream FAQ) |
| `protocol` | `auto` | `auto`, `T=0`, `T=1` |
| `gsmtap_enable` | `true` | Live GSMTAP UDP streaming |
| `gsmtap_host` | `127.0.0.1` | GSMTAP destination host |
| `gsmtap_port` | `4729` | GSMTAP destination port |
| `pcap_file` | *(empty)* | Write PCAP to this file in addition to binary output |
| `rst_detect` | `false` | Track RST pin events (requires the `rst` channel to be assigned) |
| `vcc_detect` | `false` | Track VCC pin events (requires the `vcc` channel to be assigned) |
Channels: `clk`, `data` (required); `rst`, `vcc` (optional — assign only when
the corresponding wire is physically connected).
## GSMTAP events
GSMTAP header: version 2, type SIM (0x04), UDP port 4729.
| sub_type | Name | Payload |
|----------|------|---------|
| 0x00 | APDU | raw T=0/T=1 packet bytes |
| 0x01 | ATR | raw ATR bytes |
| 0x02 | PPS | PPS request + response bytes concatenated (deviation, see below) |
| 0x10 | RST event (custom) | `[direction, level, 0x00]` |
| 0x11 | VCC event (custom) | `[direction, level, 0x00]` |
Event payload byte semantics:
- RST: `direction` 1 = reset asserted (high→low), 0 = deasserted (low→high);
`level` = line level after the transition.
- VCC: `direction` 1 = power applied (low→high), 0 = removed (high→low);
`level` = line level after the transition.
0x00/0x01 match libosmocore's gsmtap.h: standard values and payloads.
0x02 reuses the spec's `GSMTAP_SIM_PPS_REQ` value but carries the request
and response bytes concatenated in one packet; the spec splits PPS into
`PPS_REQ` (0x02) / `PPS_RSP` (0x03), so Wireshark dissects these are
"PPS request" and `PPS_RSP` (0x03) is never sent. 0x10/0x11 are custom
extensions not defined in libosmocore's gsmtap.h.
Header extensions: flags such as `GSMTAP_FLAG_BAD_FCS` are written into
the header `res` byte, which official gsmtap.h marks reserved (RFU) —
flag usage there follows the simtrace2-sniff convention and is not part
of the libosmocore spec.
## Usage
Quick start after cloning (see Installation below):
```bash
./setup.sh # check sigrok-cli, register decoder symlink
./start.sh # capture + GSMTAP stream to 127.0.0.1:4729
```
`start.sh` defaults: pins D5=CLK, D0=I/O, D4=RST, D2=VCC, 16 MHz samplerate.
Override via args:
```bash
./start.sh --clk=D1 --data=D6 # different wiring
./start.sh --no-rst --no-vcc # CLK+I/O only
./start.sh --host=10.0.28.7 # stream to a remote analyser
./start.sh --pcap=session.pcap # also record decoded events to PCAP
./start.sh --debug # verbose libsigrokdecode logging
```
### Detecting the wiring (`tools/detect_pins.py`)
Not sure which FX2 channel carries which SIM signal? The probe loops
short captures until the wiring map is complete:
```bash
python3 tools/detect_pins.py # live probe (default): trigger a
# SIM reset when prompted
python3 tools/detect_pins.py capture.sr # or analyze an existing capture
```
Each segment fingerprints every channel (toggle rate, idle level, median
inter-transition gap, first-rise ordering across VCC -> RST -> ATR).
Ambiguous static lines are reported as such instead of guessed; the run
ends with the detected mapping and a ready-to-paste `start.sh` command
line -- partial mappings still print usable flags for the known channels.
Requires numpy.
> **GND is not detectable.** It carries no signal to fingerprint, so the
> tool cannot find or verify it. Plug it first and check it manually
> (continuity meter or known-good contact) -- a missing or loose ground
> masquerades as every other fault at once: floating lines, phantom
> start bits, all-zero frames.
### Robustness against noisy signals
Live captures can glitch (a phantom start bit during power-up used to
shift the whole ATR parse off-by-one). The decoder now:
- validates the ATR's TS byte (`0x3B`/`0x3F` per TS 102 221 §5.2.2) and
resyncs on the next real falling edge if it is bogus;
- never lets a decode exception kill the capture — errors are logged,
annotated, and the state machine resyncs (end-of-stream still exits
cleanly);
- bounds T=1 block-chain follow-up so garbage cannot loop forever.
After an accepted PPS the decoder **follows the speed switch**: in
native CLK mode `clock_skip` becomes F/D CLK cycles per bit (e.g.
512/16 = 32), matching both sides' new UART rate. Live capture
confirmed that keeping 372 after PPS aliases all subsequent traffic.
A **warm reset** (the reader re-asserts RST every ~1.6 s) makes the card
answer with a fresh ATR at the default 372 cycles/bit. The decoder
**re-arms the ATR hunt on RST deassert**: state back to `FIND START`,
native skip reset to 372, hunt count cleared — so the re-ATR parses as
an ATR instead of being mis-read as TPDU traffic at the PPS-negotiated
rate. A **1.5-ETU start-bit guard** additionally rejects the phantom
`0x00` an idle glitch can insert before that ATR. Requires
`rst_detect=true` (the `rst` channel assigned); without it a mid-session
reset is still mis-read.
### Troubleshooting live capture
`start.sh` runs a single `--continuous` capture until you press **Ctrl+C**
(`rc=130`). stdin is detached from sigrok-cli, so pressing keys/Enter in
the terminal does **not** stop it (sigrok's "press any key to stop
acquisition" prompt is disabled) — that keypress was previously
mis-read as a "premature abort", but there was never a device fault.
Most of the early "noise" seen in captures (parity errors, mis-framed
garbage, a bouncing VCC line) was a badly-decoded state machine, not the
hardware: re-seating the tap did nothing, while the decoder rewrite fixed
it completely (warm-reset re-arm, 1.5-ETU start-bit guard, 10 ms VCC
debounce, T=0 exchange reassembly). A clean capture now decodes to zero
garbage — see the `vs_reader.py` validation below.
If a capture *does* end on its own (`rc=0`), the device genuinely stopped
streaming; the FX2's USB bandwidth ceiling at sustained high sample rates
is the usual suspect.
```bash
./start.sh --pcap=s.pcap --log=s.log # also record decoded events, log rc=
./start.sh ... --samplerate=24M # more timing headroom if needed
./start.sh ... --klog # also record kernel journal
./start.sh ... --debug # libsigrok internals at -l 4
```
`start.sh` captures a continuous stream at the configured rate (default 16
MHz) until Ctrl+C (`rc=130`). A healthy capture never ends by itself; if the
FX2 drops the USB transfer mid-session (a flaky device, `rc=0` "device
stopped streaming"), pass `--loop` to auto-restart — it re-launches the
capture on every `rc=0` until you press Ctrl+C or hit `--max-restarts=N`
(default 20; `0` = unlimited), with a `--restart-delay=S` gap (default 2 s).
- `--loop` auto-restarts the capture when it ends on its own (`rc=0`).
- `--max-restarts=N` caps restarts (default 20; `0` = unlimited).
- `--restart-delay=S` seconds to wait between restarts (default 2).
- `--log` captures the `rc=` verdict line for the session end.
- The decoder logs `SIGNAL INTEGRITY WARNING` when >= 8 of the last
64 frames fail parity.
- Desynced TPDUs (mis-framed exchanges) are kept but flagged with
`GSMTAP_FLAG_BAD_FCS` in the GSMTAP header `res` byte — visible in
Wireshark as a BAD-FCS warning.
- A T=0 header with a structurally invalid INS (ISO 7816-4 §12.1.1:
`0x00`, `0x6x`/`0x9x`, or odd LSB) is almost always a one-byte shift from
a stray leading byte (e.g. a leftover SW2 read after a PACKETEND).
Instead of silently dropping the whole 5-byte window (which let the next
byte become a bogus CLA and swallow the remaining dialog into one CONCAT
packet), the decoder drops only the leading byte and re-frames the rest,
recovering the real command. Every consumed-but-unemitted byte is
explicitly logged (`discarded byte 0x…: reason`); idle 0x00/0xFF discards
are rate-limited, non-idle discards always produce a decoder annotation.
Performance: in native CLK mode the decoder calibrates samples per CLK
edge once from the ATR's first frame, then advances bit timing with
single sample-skip waits instead of one wait per CLK edge. The PPS speed
switch is followed via `clock_skip = F/D`; there is no post-PPS or
parity-triggered recalibration.
### Mode comparison on FX2 hardware (A/B result)
- `native` decoded real traffic (ATR, PPS switch, 100+ valid APDUs in a
12 s window); the one persistent artifact was the VCC sense line
"bouncing" — the ~1 kHz ringing the 10 ms debounce now suppresses.
- `detect` never produced a usable session on this rig: it missed the
PPS speed switch entirely (no PPS handling fired), aliasing all
post-PPS traffic; a second attempt caught a dead I/O line.
`native` stays the default. Use `--clock=detect` only for offline
experiments without PPS. A run full of all-zero frames means the tap
lost contact with a line.
Parity is **even** per TS 102 221 ("an even number of bits set to '1'
including the parity bit"); frames failing the check are flagged as
`CHKSUM ERROR` but still decoded.
Equivalent raw sigrok-cli commands:
```bash
# Minimal: CLK+I/O only, live stream to localhost:4729
sigrok-cli -d fx2lafw --config samplerate=12M --continuous \
-P iso7816:clk=D0:data=D1:clock_option=detect -A iso7816
# Full wiring: CLK + I/O + RST + VCC with line event tracking
sigrok-cli -d fx2lafw --config samplerate=12M --continuous \
-P iso7816:clk=D5:data=D0:rst=D4:vcc=D2:clock_option=native:rst_detect=true:vcc_detect=true \
-A iso7816
# Stream to a remote analyser / disable streaming / write PCAP file
... -P iso7816:...:gsmtap_host=10.0.28.7 ...
... -P iso7816:...:gsmtap_enable=false ...
... -P iso7816:...:pcap_file=trace.pcap ...
# Classic upstream mode: binary PCAP to stdout
sigrok-cli -i capture.sr -P iso7816:clk=2:data=0 -B iso7816=pcap > out.pcap
```
### Pin mapping
The FX2 clone channels are assigned via `-P iso7816:clk=N:data=N:rst=N:vcc=N`
(no re-plugging needed when wires are mixed up). Default tap wiring used by
`start.sh`:
| FX2 channel | SIM contact | Signal |
|-------------|-------------|--------|
| D0 | C7 | I/O |
| D2 | C1 | VCC (digital level only!) |
| D4 | C2 | RST |
| D5 | C3 | CLK |
| GND | C5 | GND |
FX2 input threshold is TTL-ish (~1.4V): 1.8V-class cards may read unreliably;
3V/5V classes are fine.
**Samplerate and 8-bit mode (important):** the FX2's nominal 24 MHz is only
available when sampling in 8-bit mode, i.e. when **only channels D0–D7 are
enabled**. Enabling the 9th channel (A0/analog on CWAV AX clones) or any
analog input forces 16-bit collection, which libsigrok caps at 12 MHz.
`start.sh`/`start.bat` therefore pass `-C D0,D1,D2,D3,D4,D5,D6,D7`
explicitly and default to **16 MHz** (comfortably under the 24 MHz USB
bandwidth ceiling; ~3.3 samples per 4.8 MHz clock cycle). `setup.sh`/
`setup.bat` probe and report your hardware's actual maximum under the
same restriction.
## Installation
### Linux
```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
./setup.sh
```
`setup.sh` verifies sigrok-cli and the FX2 firmware package
(`sigrok-firmware-fx2lafw`) are installed, symlinks the repo into
`~/.local/share/libsigrokdecode/decoders/iso7816` (user-local decoders shadow
system-wide ones), and probes the connected device for its maximum supported
samplerate.
### Windows
Prerequisites:
1. **Python 3.8+** from python.org ("Add to PATH" during install), then:
`pip install numpy`
2. **sigrok-cli** Windows installer (0.7.2 x86_64) from
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads).
If it fails to start with error `0xc0150002`, install the
[Visual C++ 2010 Redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=26999)
(`msvcr100.dll` is required by the bundled Python runtime).
3. fx2lafw firmware ships bundled with the installer — no separate download.
Driver setup (FX2 dongles need WinUSB, the vendor driver won't work):
- Run **Zadig**, which ships *inside* the sigrok-cli installation directory
(also reachable from the Start menu). Select the FX2 device in its dropdown
and install the **WinUSB** driver.
- After firmware upload the device re-enumerates with a different VID/PID —
if it still isn't found, run Zadig a **second time** for the re-enumerated
entry.
- Windows binds drivers per USB port when no serial number is present:
moving the dongle to another port may require repeating Zadig.
Then:
```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
setup.bat
```
`setup.bat` verifies sigrok-cli and bundled firmware, registers this repo's
decoder via a junction into `%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816`
(confirmed by `sigrok-cli -L` listing `iso7816`), checks Python/numpy for
`tools\detect_pins.py`, and probes the device's accepted samplerates.
Capture works the same as on Linux:
```bat
tools\detect_pins.py rem find the wiring (loops segments until done)
start.bat rem capture + GSMTAP stream
```
Note: batch files need CRLF line endings; the repo enforces this via
`.gitattributes`, so a fresh clone on Windows is already correct.
## RST/VCC detection notes and limitations
- Events are detected **asynchronously before the first ATR** (power-on /
reset release are captured once the level is stable) and **at packet
boundaries** between T=0/T=1 packets afterwards.
- Edges occurring entirely *within* a packet transfer are observed as a net
level change at the next boundary; short pulses inside a packet can be missed.
- VCC is digital-only: the decoder sees powered/not-powered, never the
voltage class (1.8/3/5V).
- **RST and VCC use level-stability hysteresis at 10 ms** (samplerate-scaled):
a new level is reported only once it has been continuously stable for ≥ 10 ms
— either because it persists that long, or because it ends after being stable
that long. This makes power-up and power-down *symmetric*: a VCC sense line
that **rings on the rising edge** (noisy/floating tap) no longer loses its
"VCC on" event — it fires once the line settles HIGH, instead of being eaten
by edge debounce. RST uses the same scheme, so a brief RST glitch no longer
triggers a phantom warm-reset re-arm.
- **VCC events are generally unreliable — prefer not to track VCC.** A floating
or unconnected VCC sense pin reads HIGH at all times (logic-high with no load),
so the decoder sees "powered" constantly and emits spurious power events on
every bounce. Unless your tap derives VCC from a clean, loaded source, it is
better to **leave the `vcc` channel unassigned** (do not pass `--vcc`) and keep
`vcc_detect=false`; RST tracking is usually enough to see resets. Only enable
VCC tracking on a tap with a solid VCC sense (and a solid GND — see wiring notes).
- Unassigned optional channels are ignored even if `rst_detect`/`vcc_detect`
is enabled (guarded via `has_channel()`).
## Testing
Verified against the upstream example captures:
```bash
# ATR+PPS+54 APDUs from the test_8 reader capture (~19s decode)
# GSMTAP stream verified with tshark (ATR/APDU dissected natively),
# custom 0x10/0x11 events verified with a UDP listener, and end-to-end
# into simtrace2-pysniff server (POST /api/capture/start → decode → stop).
```
A synthetic VCD generator covering RST/VCC edge cases lives in the project
notes (`projects/SIGROK_ISO7816.md` in the parent workspace).
### Validation against the reader SDK (`tools/vs_reader.py`)
On a controlled rig (PCSC reader driven by software) the reference APDU log
from the reader SDK can be diffed against the decoded PCAP:
```bash
sigrok-cli -i <trace>.sr \
-P iso7816:clk=D5:data=D0:rst=D4:vcc=D2:clock_option=native:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=out.pcap \
-A iso7816
python3 tools/vs_reader.py <reader-log>.log out.pcap
```
`vs_reader.py` aligns each reader-log exchange to the capture APDUs
(byte-exact LCS match) and classifies the leftovers as proactive/CAT
(unseen by the reader SDK), pre-capture/dropped, or garbage.
The mid-session capture whose warm resets previously produced 4 garbage
events — with the re-arm + start-bit guard it decodes to **0 garbage**
(`RESULT: OK - capture fully explained`).
test_7 (4 ATR resets fragmenting the session) now captures all 37/37 reader
exchanges (up from 10/37 before the deglitch fix). Samsung phone traces with
16 CLK/ETU (F=512/D=32) now de-concatenate to 924 individual T=0 exchanges
(previously merged into 52 oversized APDUs).
Replayable trace fixtures (kept locally, outside the repo) cover
controlled-rig reader sessions (with their reader-SDK ground-truth logs),
phone captures with gated CLK, and **cold-boot captures** that start
*before* power-up (VCC/RST low at t=0). The four Samsung fixtures are
cold boots taken with a 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; the `sim2` variants are cropped to the power-on window.
### Cold-boot captures (capture starts before power-up)
When the capture starts pre-power-up, the decoder must arm the ATR
hunt because RST is still low at sample 0: it primes the initial
VCC/RST levels on the first step (instead of defaulting them to deasserted),
and on an RST-low-at-start it enters `FIND START` with a cleared ETU, so the
pre-ATR power-up DATA transitions cannot lock a bogus bit rate. It then
decodes the full ATR (`3b9f96801fc78031e073fe2117574a3382033339001e`) with
0 garbage:
```bash
sigrok-cli -i <coldboot>.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=out.pcap \
-A iso7816
```
An RST deassert that lands while an ATR parse is already in progress no longer
re-arms the hunt mid-read — it logs `RST deasserted (hunt/ATR already in
progress)` and leaves the in-flight ATR alone (test_8/test_7 CHKSUM 18→0).
### Capture scenarios: reader (constant CLK) and phone (Clock Stop Mode)
The decoder handles the two physically different SIM sniffing setups:
- **Reader / constant-CLK capture** — the reader drives a steady CLK for the
whole session. In mid-session mode the capture
decodes to **0 garbage** (`RESULT: OK`) against the reader-SDK
ground truth (`tools/vs_reader.py <reader-log>.log out.pcap`).
Warm-reset reader captures use the synthetic-T=0-ATR fallback.
- **Phone / stopped-CLK capture** — the phone gates or stops CLK between
transfers (Clock Stop Mode), so the ETU is not the 372-clock default and
cannot be inferred from CLK. Run with `protocol=T=0`; the decoder recovers the ETU from the DATA line via edge
counting, locks it, and decodes with the drift-immune edge-list bit reader.
T=0 case 3/4 commands (e.g. `80 12`/`80 14` STK envelope) are consolidated
into a single command→response APDU. A 2-sample start-bit deglitch eliminates
sub-ETU LOW glitches caused by FX2LP internal pull-up artifacts, preventing
byte misalignment on fast phone traces.
### Proven data rates and ETUs
All traces decoded at **16 MHz sample rate**, 0 garbage, RESULT: OK.
Since v1.5.0 every example trace also reports **0 CONCAT** (the invalid-INS
re-frame eliminated the last multi-exchange swallow family on
4gmodem/A55_sim2/S21p_sim2).
| Trace | CLK mode | ETU (samples) | CLK/ETU | Convention | Notes |
|-------|----------|---------------|---------|------------|-------|
| test_8 | constant | ~43 | 372 (standard) | Direct | PC/SC reader, ATR+PPS+APDUs |
| test_7 | constant | ~43 | 372 (standard) | Direct | PC/SC reader, warm resets |
| xiaomi | gated/stopped | ~53 | recovered from DATA | Direct | Phone, mid-session |
| samsung | gated/stopped | ~72 | 16 (F=512/D=32) | Direct | Phone, non-standard F/D, STK menu |
The decoder auto-detects the ETU from the trace and selects the appropriate
bit reader (CLK-sync for constant CLK, edge-list for gated/stopped CLK).
No manual configuration is needed beyond `clock_option=native` and
`protocol=T=0` for phone captures.
Recommended invocations:
```bash
# Reader capture (controlled rig, CLK always running)
sigrok-cli -i <trace>.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:vcc=VCC:clock_option=native:rst_detect=true:vcc_detect=true:gsmtap_enable=false:pcap_file=out.pcap \
-A iso7816
python3 tools/vs_reader.py <reader-log>.log out.pcap
# Phone capture (mid-session, gated/stopped CLK)
sigrok-cli -i <phone>.sr \
-P iso7816:clk=CLK:data=DATA:rst=RST:clock_option=native:rst_detect=true:vcc_detect=false:protocol=T=0:gsmtap_enable=false:pcap_file=out.pcap \
-A iso7816
```
## License
GPL-2.0+, same as upstream.
+25
View File
@@ -0,0 +1,25 @@
##
## This file is part of the libsigrokdecode project.
##
## Copyright (C) 2012 Uwe Hermann <uwe@hermann-uwe.de>
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <http://www.gnu.org/licenses/>.
##
'''
ISO7816 is a bidirectional, multi-master
bus using two signals (CLK = serial clock line, DATA = IO data line).
'''
from .pd import Decoder
+99
View File
@@ -0,0 +1,99 @@
##
## GSMTAP live streaming for the ISO 7816 decoder.
##
## Sends decoded events as GSMTAP-SIM UDP packets, compatible with
## simtrace2-sniff, simtrace2-pysniff and the GSMTAP dissector in
## Wireshark.
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
import socket
import struct
GSMTAP_VERSION = 0x02
GSMTAP_HDR_LEN = 4 # in 32-bit words (16 bytes)
GSMTAP_TYPE_SIM = 0x04
# sub_type values. 0x00/0x01 are standard GSMTAP-SIM types with standard
# payloads (libosmocore gsmtap.h). 0x02 uses the spec's GSMTAP_SIM_PPS_REQ
# value but carries request + response bytes concatenated in one packet;
# the spec splits PPS into PPS_REQ (0x02) / PPS_RSP (0x03), so Wireshark
# dissects 0x02 as "PPS request" and GSMTAP_SIM_PPS_RSP (0x03) is
# intentionally unused. 0x10/0x11 are custom extensions (not in the spec).
GSMTAP_SIM_APDU = 0x00
GSMTAP_SIM_ATR = 0x01
GSMTAP_SIM_PPS = 0x02 # request + response combined (this decoder)
GSMTAP_SIM_RST_EVENT = 0x10 # payload: [direction, level, reserved]
GSMTAP_SIM_VCC_EVENT = 0x11 # payload: [direction, level, reserved]
# RST/VCC event payload bytes:
# byte 0 (direction):
# RST: 1 = reset asserted (high->low), 0 = reset deasserted (low->high)
# VCC: 1 = power applied (low->high), 0 = power removed (high->low)
# byte 1 (level): line level after the transition (0 = low, 1 = high)
# byte 2: reserved (0x00)
GSMTAP_UDP_PORT = 4729
# libosmocore gsmtap.h: per-packet flags live in the header `res` byte,
# which the spec marks reserved (RFU). Flag usage here is a documented
# extension following the simtrace2-sniff convention, not part of gsmtap.h.
GSMTAP_FLAG_BAD_FCS = 0x01 # "Any data checksum is wrong" -- reused here to
# mark decoder-desynced TPDUs (see README.md,
# "GSMTAP events").
_HDR_FMT = '!BBBBHBBIBBBB' # 16 bytes, big-endian
def build_packet(sub_type, data, flags=0):
'''Build a complete GSMTAP packet (header + payload) as bytes.
`flags` is written into the header `res` byte (GSMTAP_FLAG_*).'''
hdr = struct.pack(
_HDR_FMT,
GSMTAP_VERSION, # version
GSMTAP_HDR_LEN, # hdr_len (in 32-bit words)
GSMTAP_TYPE_SIM, # type
0, # timeslot
0, # arfcn
0, # noise_db
0, # signal_db
0, # frame_number
sub_type, # sub_type
0, # antenna_nr
0, # sub_slot
flags, # res (GSMTAP_FLAG_*)
)
return hdr + bytes(data)
class GsmtapStreamSender:
'''Fire-and-forget UDP sender.
Never raises on send: with no listener present, datagrams are
silently dropped by the OS (same behavior as simtrace2-sniff).
'''
def __init__(self, host='127.0.0.1', port=GSMTAP_UDP_PORT):
self._addr = (host, int(port))
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# UDP to localhost sendto is non-blocking anyway; make it explicit so
# a full socket buffer can never stall the decode loop.
self._sock.setblocking(False)
def send(self, sub_type, data, flags=0):
try:
self._sock.sendto(build_packet(sub_type, data, flags),
self._addr)
except OSError:
pass
def close(self):
try:
self._sock.close()
except OSError:
pass
+2491
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+129
View File
@@ -0,0 +1,129 @@
@echo off
rem sigrok_iso7816_stream setup (Windows): verify sigrok-cli, register decoder.
setlocal EnableExtensions EnableDelayedExpansion
set "REPO_DIR=%~dp0"
if "%REPO_DIR:~-1%"=="\" set "REPO_DIR=%REPO_DIR:~0,-1%"
set "DEC_DIR=%LOCALAPPDATA%\libsigrokdecode\decoders"
set "LINK=%DEC_DIR%\iso7816"
echo sigrok_iso7816_stream setup (Windows)
echo.
rem ---- sigrok-cli -------------------------------------------------------
echo Checking sigrok-cli...
set "SIGROK_EXE="
if not defined SIGROK_EXE if exist "%ProgramFiles%\sigrok\sigrok-cli\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles%\sigrok\sigrok-cli\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles(x86)%\sigrok\sigrok-cli\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles(x86)%\sigrok\sigrok-cli\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles%\sigrok\PulseView\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles%\sigrok\PulseView\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles(x86)%\sigrok\PulseView\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles(x86)%\sigrok\PulseView\sigrok-cli.exe"
if not defined SIGROK_EXE where sigrok-cli >nul 2>&1 && for /f "delims=" %%i in ('where sigrok-cli') do set "SIGROK_EXE=%%i"
if not defined SIGROK_EXE goto :notfound
for %%i in ("!SIGROK_EXE!") do set "SIGROK_DIR=%%~dpi"
if "!SIGROK_DIR:~-1!"=="\" set "SIGROK_DIR=!SIGROK_DIR:~0,-1!"
echo [ok] !SIGROK_EXE!
rem Add sigrok-cli dir to PATH so subsequent calls work even if only
rem discovered at the default install location.
set "PATH=!SIGROK_DIR!;!PATH!"
sigrok-cli --version 2>nul | findstr /r "^"
goto :sigrok_ok
:notfound
echo [X] sigrok-cli not found.
echo Searched:
echo %ProgramFiles%\sigrok\sigrok-cli
echo %ProgramFiles(x86)%\sigrok\sigrok-cli
echo %ProgramFiles%\sigrok\PulseView
echo %ProgramFiles(x86)%\sigrok\PulseView
echo PATH
echo Install it from https://sigrok.org/wiki/Downloads
echo ^(sigrok-cli-0.7.2-x86_64-installer.exe^), then re-open this
echo window so PATH changes take effect and re-run.
exit /b 1
:sigrok_ok
rem If sigrok-cli fails to start with error 0xc0150002, install the
rem Microsoft Visual C++ 2010 Redistributable (msvcr100.dll).
rem ---- firmware ---------------------------------------------------------
echo Checking fx2lafw firmware...
set "FW_FOUND="
if not defined FW_FOUND if exist "!SIGROK_DIR!\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=!SIGROK_DIR!\share\sigrok-firmware"
if not defined FW_FOUND if exist "!SIGROK_DIR!\..\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=!SIGROK_DIR!\..\share\sigrok-firmware"
if not defined FW_FOUND if exist "%ProgramFiles%\sigrok\sigrok-cli\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%ProgramFiles%\sigrok\sigrok-cli\share\sigrok-firmware"
if not defined FW_FOUND if exist "%ProgramFiles(x86)%\sigrok\sigrok-cli\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%ProgramFiles(x86)%\sigrok\sigrok-cli\share\sigrok-firmware"
if not defined FW_FOUND if exist "%ProgramFiles%\sigrok\PulseView\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%ProgramFiles%\sigrok\PulseView\share\sigrok-firmware"
if not defined FW_FOUND if exist "%ProgramFiles(x86)%\sigrok\PulseView\share\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%ProgramFiles(x86)%\sigrok\PulseView\share\sigrok-firmware"
if not defined FW_FOUND if exist "%LOCALAPPDATA%\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%LOCALAPPDATA%\sigrok-firmware"
if not defined FW_FOUND if exist "%ProgramData%\sigrok-firmware\fx2lafw-cypress-fx2.fw" set "FW_FOUND=%ProgramData%\sigrok-firmware"
if defined FW_FOUND (
echo [ok] !FW_FOUND!
) else (
echo [!] bundled firmware directory not found - continuing anyway;
echo the installer normally ships fx2lafw firmware, and libsigrok
echo also searches %LOCALAPPDATA%\sigrok-firmware
)
rem ---- decoder registration --------------------------------------------
echo Registering decoder...
if not exist "%DEC_DIR%" mkdir "%DEC_DIR%"
if exist "%LINK%\pd.py" goto :link_ok
if exist "%LINK%" rmdir "%LINK%"
mklink /J "%LINK%" "%REPO_DIR%" >nul 2>&1
if errorlevel 1 (
echo [X] could not create junction %LINK%
exit /b 1
)
:link_ok
sigrok-cli -L 2>nul | findstr /i "iso7816" >nul
if errorlevel 1 (
echo [X] decoder registered but NOT listed by sigrok-cli.
echo Expected junction: %LINK%
exit /b 1
)
echo [ok] %LINK% -^> listed by sigrok-cli
rem ---- python / numpy (for tools\detect_pins.py) ------------------------
echo Checking Python + numpy...
set "PY="
py -3 --version >nul 2>&1 && set "PY=py -3"
if not defined PY (
python --version >nul 2>&1 && set "PY=python"
)
if defined PY (
for /f "delims=" %%v in ('!PY! --version 2^>^&1') do echo [ok] %%v
!PY! -c "import numpy" >nul 2>&1 || echo [!] numpy missing: run '!PY! -m pip install numpy'
) else (
echo [!] no python launcher found; detect_pins.py needs Python 3.8+ and numpy
)
rem ---- hardware samplerate probe ---------------------------------------
echo Probing device samplerates...
set "MAX_RATE="
for %%r in (1M 2M 4M 8M 12M 16M 20M 24M) do (
sigrok-cli -d fx2lafw --config samplerate=%%r --continuous --time 20 ^
-P "iso7816:clk=D0:data=D1" >"%TEMP%\iso7816-probe.txt" 2>&1
type "%TEMP%\iso7816-probe.txt" | findstr /c:"Unable to claim" >nul && (
echo blocked - another program holds the device
goto :probedone
)
type "%TEMP%\iso7816-probe.txt" | findstr /c:"Failed to open" /c:"No devices found" >nul && (
echo skipped - no FX2 device connected
goto :probedone
)
type "%TEMP%\iso7816-probe.txt" | findstr /r /c:"invalid argument" /c:"Unable to sample" /c:"Could not start" >nul || set MAX_RATE=%%r
)
:probedone
if defined MAX_RATE (
echo Highest accepted samplerate: !MAX_RATE!
) else if not defined MAX_RATE if not defined DEVICE_MSG (
echo unknown - device present but no rate accepted
)
echo.
echo Done. Next steps:
echo 1. Plug GND first and verify contact manually (see README).
echo 2. tools\detect_pins.py -- find the wiring
echo 3. start.bat -- capture + GSMTAP stream
endlocal
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# sigrok_iso7816_stream setup: verify sigrok-cli and register the decoder.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
DECODERS_DIR="${HOME}/.local/share/libsigrokdecode/decoders"
LINK="${DECODERS_DIR}/iso7816"
FIRMWARE_DIRS=(
/usr/share/sigrok-firmware
/usr/share/sigrok/firmware
/usr/local/share/sigrok-firmware
)
printf 'Checking sigrok-cli... '
if ! command -v sigrok-cli >/dev/null 2>&1; then
echo "NOT FOUND"
echo " Install it first, e.g.: sudo apt install sigrok-cli"
exit 1
fi
echo "found ($(command -v sigrok-cli))"
sigrok-cli --version 2>/dev/null | sed 's/^/ /'
printf 'Checking FX2 firmware... '
FW_FOUND=""
for dir in "${FIRMWARE_DIRS[@]}"; do
if compgen -G "${dir}/fx2lafw-*.fw" >/dev/null; then
FW_FOUND="${dir}"
break
fi
done
if [ -z "${FW_FOUND}" ]; then
echo "NOT FOUND"
echo " fx2lafw firmware files are missing."
echo " Arch/CachyOS: sudo pacman -S sigrok-firmware-fx2lafw"
echo " Debian/Ubuntu: sudo apt install sigrok-firmware-fx2lafw"
exit 1
fi
echo "ok (${FW_FOUND})"
printf 'Checking decoder directory... '
mkdir -p "${DECODERS_DIR}"
echo "ok (${DECODERS_DIR})"
printf 'Installing decoder symlink... '
if [ -L "${LINK}" ]; then
rm "${LINK}"
elif [ -e "${LINK}" ]; then
echo ""
echo " ${LINK} exists and is not a symlink; refusing to overwrite." >&2
exit 1
fi
ln -s "${REPO_DIR}" "${LINK}"
echo "ok"
printf 'Verifying... '
target="$(readlink -f "${LINK}")"
if [ "${target}" = "${REPO_DIR}" ] && [ -f "${target}/pd.py" ]; then
echo "ok (${LINK} -> ${target})"
else
echo "FAILED" >&2
exit 1
fi
printf 'Probing hardware max samplerate... '
MAX_RATE=""
DEVICE_MSG=""
for rate in 1M 2M 4M 8M 12M 16M 20M 24M; do
result="$(sigrok-cli -d fx2lafw --config "samplerate=${rate}" --continuous \
--time 20 -P 'iso7816:clk=D0:data=D1' 2>&1 || true)"
if echo "${result}" | grep -q "Unable to claim"; then
DEVICE_MSG="blocked (another program holds the device; stop it and re-run)"
break
fi
if echo "${result}" | grep -q "Failed to open device\|No devices found"; then
DEVICE_MSG="skipped (no FX2 device connected)"
break
fi
if ! echo "${result}" | grep -qE "invalid argument|Unable to sample|Could not start"; then
MAX_RATE="${rate}"
fi
done
if [ -n "${DEVICE_MSG}" ]; then
echo "${DEVICE_MSG}"
elif [ -n "${MAX_RATE}" ]; then
echo "${MAX_RATE}"
if [ "${MAX_RATE}" != "24M" ]; then
echo " Device rejects rates above ${MAX_RATE}. Rates up to 24 MHz need"
echo " 8-bit sampling: only channels D0-D7 may be enabled (enabling"
echo " A0/analog forces 16-bit collection, capped at 12 MHz)."
echo " start.sh restricts channels accordingly."
fi
else
echo "unknown (device present but no rate accepted)"
fi
echo "Done. Run ./start.sh to begin capture."
+104
View File
@@ -0,0 +1,104 @@
@echo off
rem sigrok_iso7816_stream launcher (Windows): capture ISO 7816 and stream
rem GSMTAP to :4729. Flag-for-flag equivalent of start.sh.
setlocal EnableExtensions EnableDelayedExpansion
set "CLK=D5"
set "DATA=D0"
set "RST=D4"
set "VCC=D2"
set "SAMPLERATE=16M"
set "CLOCK=native"
set "HOST=127.0.0.1"
set "PORT=4729"
set "PCAP="
set "DEBUG="
:argloop
if "%~1"=="" goto :argsdone
if /i "%~1"=="--clk" ( set "CLK=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--data" ( set "DATA=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--rst" ( set "RST=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--vcc" ( set "VCC=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--samplerate" ( set "SAMPLERATE=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--clock" ( set "CLOCK=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--host" ( set "HOST=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--port" ( set "PORT=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--pcap" ( set "PCAP=%~2" & shift & shift & goto :argloop )
if /i "%~1"=="--debug" ( set "DEBUG=1" & shift & goto :argloop )
if /i "%~1"=="--no-rst" ( set "RST=" & shift & goto :argloop )
if /i "%~1"=="--no-vcc" ( set "VCC=" & shift & goto :argloop )
if /i "%~1"=="-h" ( goto :usage )
if /i "%~1"=="--help" ( goto :usage )
echo Unknown option: %~1
exit /b 1
:usage
echo Usage: %~n0 [OPTIONS]
echo --clk=CH CLK channel default: %CLK%
echo --data=CH I/O channel default: %DATA%
echo --rst=CH RST channel default: %RST%
echo --vcc=CH VCC channel default: %VCC%
echo --samplerate=SR Sample rate default: %SAMPLERATE%
echo --clock=MODE native/detect/sample_as_clock
echo --host=IP GSMTAP destination default: 127.0.0.1
echo --port=N GSMTAP port default: 4729
echo --pcap=FILE Also write decoded events to FILE (no spaces in path)
echo --debug Verbose libsigrokdecode logging (-l 4)
echo --no-rst Disable RST tracking
echo --no-vcc Disable VCC tracking
echo.
echo To log output on Windows use redirection: %~n0 ... ^> capture.log 2^>^&1
exit /b 0
:argsdone
set "SIGROK_EXE="
rem Try known install locations (order: 64-bit, 32-bit, then PATH)
if not defined SIGROK_EXE if exist "%ProgramFiles%\sigrok\sigrok-cli\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles%\sigrok\sigrok-cli\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles(x86)%\sigrok\sigrok-cli\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles(x86)%\sigrok\sigrok-cli\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles%\sigrok\PulseView\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles%\sigrok\PulseView\sigrok-cli.exe"
if not defined SIGROK_EXE if exist "%ProgramFiles(x86)%\sigrok\PulseView\sigrok-cli.exe" set "SIGROK_EXE=%ProgramFiles(x86)%\sigrok\PulseView\sigrok-cli.exe"
if not defined SIGROK_EXE where sigrok-cli >nul 2>&1 && for /f "delims=" %%i in ('where sigrok-cli') do set "SIGROK_EXE=%%i"
if not defined SIGROK_EXE goto :notfound
for %%i in ("!SIGROK_EXE!") do set "SIGROK_DIR=%%~dpi"
if "!SIGROK_DIR:~-1!"=="\" set "SIGROK_DIR=!SIGROK_DIR:~0,-1!"
set "PATH=!SIGROK_DIR!;!PATH!"
goto :found
:notfound
echo [X] sigrok-cli not found.
echo Searched:
echo %ProgramFiles%\sigrok\sigrok-cli
echo %ProgramFiles(x86)%\sigrok\sigrok-cli
echo %ProgramFiles%\sigrok\PulseView
echo %ProgramFiles(x86)%\sigrok\PulseView
echo PATH
echo Install from https://sigrok.org/wiki/Downloads
exit /b 1
:found
set "OPTS=clk=%CLK%:data=%DATA%:clock_option=%CLOCK%"
set "OPTS=%OPTS%:gsmtap_host=%HOST%:gsmtap_port=%PORT%"
if defined RST set "OPTS=%OPTS%:rst=%RST%:rst_detect=true"
if defined VCC set "OPTS=%OPTS%:vcc=%VCC%:vcc_detect=true"
if defined PCAP set "OPTS=%OPTS%:pcap_file=%PCAP%"
set "CMDLINE=sigrok-cli -d fx2lafw --config samplerate=%SAMPLERATE% --continuous -C D0,D1,D2,D3,D4,D5,D6,D7"
if defined DEBUG set "CMDLINE=%CMDLINE% -l 4"
set "CMDLINE=%CMDLINE% -P iso7816:%OPTS% -A iso7816"
echo ISO 7816 streamer - clk=%CLK% data=%DATA% clock=%CLOCK%
if defined RST echo RST tracking on channel %RST%
if defined VCC echo VCC tracking on channel %VCC%
if defined PCAP echo PCAP output: %PCAP%
echo Samplerate: %SAMPLERATE% ^| GSMTAP -^> %HOST%:%PORT%
echo Capture runs until you press Ctrl+C.
echo.
rem <NUL detaches stdin (best-effort to disable sigrok's "press any key
rem to stop acquisition" prompt); not verified on Windows, where the
rem prompt may read the console rather than stdin.
%CMDLINE% <NUL
exit /b %errorlevel%
Executable
+318
View File
@@ -0,0 +1,318 @@
#!/bin/bash
# sigrok_iso7816_stream launcher: capture ISO 7816 and stream GSMTAP to :4729.
set -euo pipefail
CLK=D5
DATA=D0
RST=D4
VCC=D2
SAMPLERATE=16M
CLOCK=native
HOST=127.0.0.1
PORT=4729
PCAP=""
DEBUG=""
LOG=""
KLOG=""
LOOP=0 # --loop: restart on rc=0 (device stopped streaming)
MAX_RESTARTS=20 # --max-restarts=N (0 = unlimited)
RESTART_DELAY=2 # --restart-delay=S seconds between restarts
PROTOCOL="T=0" # --protocol: T=0 (default; live phone captures start
# mid-session, so we recover the ETU from the live line
# and frame full command+response APDU exchanges), T=1,
# or auto
# Records which channel pins the user explicitly named via --clk/--data/
# --rst/--vcc. When ANY of these is given, defaults are NOT injected for the
# remaining channels (a default RST/VCC pin could clash with a user pin), so
# only the named channels are used. --no-rst/--no-vcc are toggles and do not
# trigger this (they keep the other defaults).
CLK_SET=""; DATA_SET=""; RST_SET=""; VCC_SET=""
usage() {
cat <<EOF
Usage: $0 [OPTIONS]
Options:
--clk=CH CLK channel (default: ${CLK})
--data=CH I/O channel (default: ${DATA})
--rst=CH RST channel (default: ${RST})
--vcc=CH VCC channel (default: ${VCC})
Note: naming any of --clk/--data/--rst/--vcc disables the other channels'
defaults -- only the channels you name are used (no injected pins to clash).
Use --no-rst/--no-vcc to drop RST/VCC while keeping the remaining defaults.
--samplerate=SR Sample rate (default: ${SAMPLERATE}). Override to
24M if you need the extra timing headroom; 16M is the
FX2's comfortable sustained USB rate.
--clock=MODE clock_option (default: ${CLOCK})
native spec timing, counts CLK edges; wants
~2+ samples per CLK period
detect measures cycles/bit from the ATR;
upstream calls it "most stable"
sample_as_clock experimental
--host=IP GSMTAP destination (default: ${HOST})
--port=N GSMTAP port (default: ${PORT})
--pcap=FILE Also write decoded events to FILE (offline analysis)
--debug Verbose libsigrokdecode logging (-l 4)
--log=FILE Tee all output into FILE (postmortem / exit diagnosis)
--klog[=FILE] Also record the kernel journal (journalctl -kf) to
FILE (default: klog.log) -- needs journal read
permission; captures xHCI/USB errors at exit moments.
--no-rst Disable RST tracking
--no-vcc Disable VCC tracking
--protocol=MODE ISO 7816 protocol for mid-session decode: T=0 (default),
T=1, or auto. Live phone captures start mid-session, so a
fixed protocol lets the decoder emit raw bursts instead of
hunting for a (non-existent) ATR.
--loop Auto-restart when the capture ends on its own (rc=0, device
stopped streaming). Loops until you press Ctrl+C (rc=130)
or a real error. Useful for a flaky FX2 that drops the USB
transfer mid-session.
--max-restarts=N Cap --loop restarts (default: 20; 0 = unlimited).
--restart-delay=S Seconds to wait between restarts (default: 2).
-h, --help Show this help
GSMTAP stream defaults to 127.0.0.1:4729 (simtrace2-pysniff compatible).
Capture runs continuously until Ctrl+C. stdin is detached from sigrok-cli
so pressing keys/Enter in the terminal does NOT stop the capture (sigrok's
"press any key to stop acquisition" prompt is disabled).
NOTE: clock_option=native counts CLK edges, so the samplerate must stay
above ~2 samples per CLK period (>= 8 MHz for a 3.579 MHz card clock).
Sustained 12 MHz capture can overrun FX2 USB buffers -> dropped sample
chunks appear as torn/fragmented packets; lower rates reduce that risk.
EOF
exit 0
}
norm_proto() {
# Normalize a --protocol value to the decoder's accepted form (T=0 / T=1 / auto).
case "$1" in
T=0|T0|t0) echo "T=0" ;;
T=1|T1|t1) echo "T=1" ;;
auto|AUTO) echo "auto" ;;
*) echo "auto" ;;
esac
}
for arg in "$@"; do
case "${arg}" in
--clk=*) CLK="${arg#*=}"; CLK_SET=1 ;;
--data=*) DATA="${arg#*=}"; DATA_SET=1 ;;
--rst=*) RST="${arg#*=}"; RST_SET=1 ;;
--vcc=*) VCC="${arg#*=}"; VCC_SET=1 ;;
--samplerate=*) SAMPLERATE="${arg#*=}" ;;
--clock=*) CLOCK="${arg#*=}" ;;
--host=*) HOST="${arg#*=}" ;;
--port=*) PORT="${arg#*=}" ;;
--pcap=*) PCAP="${arg#*=}" ;;
--debug) DEBUG=1 ;;
--log=*) LOG="${arg#*=}" ;;
--klog) KLOG="klog.log" ;;
--klog=*) KLOG="${arg#*=}" ;;
--no-rst) RST="" ;;
--no-vcc) VCC="" ;;
--protocol=*) PROTOCOL="$(norm_proto "${arg#*=}")" ;;
--starts-with-atr=*)
echo "WARNING: --starts-with-atr is obsolete (removed in v1.3.0); the decoder always runs mid-session" >&2 ;;
--loop) LOOP=1 ;;
--max-restarts=*) MAX_RESTARTS="${arg#*=}" ;;
--restart-delay=*) RESTART_DELAY="${arg#*=}" ;;
-h|--help) usage ;;
*) echo "Unknown option: ${arg} (see --help)" >&2; exit 1 ;;
esac
done
# If the user named any channel pin, do NOT inject defaults for the channels
# they didn't name -- a default RST/VCC pin (D0/D4) can clash with a user
# pin (e.g. --data=D4), and the decoder should only use the named channels.
if [ -n "${CLK_SET}${DATA_SET}${RST_SET}${VCC_SET}" ]; then
[ -z "${CLK_SET}" ] && CLK=""
[ -z "${DATA_SET}" ] && DATA=""
[ -z "${RST_SET}" ] && RST=""
[ -z "${VCC_SET}" ] && VCC=""
fi
LINK="${HOME}/.local/share/libsigrokdecode/decoders/iso7816"
if [ ! -e "${LINK}/pd.py" ]; then
echo "Decoder not installed (${LINK} missing or broken)." >&2
echo "Run ./setup.sh first." >&2
exit 1
fi
# Preflight: confirm the FX2 device is present and that every channel we
# are about to assign actually exists on it. A wrong/missing channel name
# would otherwise surface later as a confusing "device stopped streaming"
# (rc=0) or a sigrok-cli abort; fail loudly here instead.
SHOW_OUT="$(sigrok-cli -d fx2lafw --show 2>&1)" || SHOW_OUT=""
if ! echo "${SHOW_OUT}" | grep -q 'channels:'; then
echo "ERROR: FX2 device not found or firmware not loaded." >&2
echo "Connect the analyzer and run ./setup.sh (installs sigrok-firmware-fx2lafw)." >&2
exit 1
fi
DEV_CHANS="$(echo "${SHOW_OUT}" | grep -oP 'channels: \K.*' | tr -s ' ')"
validate_chan() {
# $1 = role (clk/data/rst/vcc), $2 = channel name
if [ -z "$2" ]; then
echo "ERROR: '$1' channel is empty. Use --$1=CH (e.g. --$1=D1)." >&2
exit 1
fi
if ! echo " ${DEV_CHANS} " | grep -qw "$2"; then
echo "ERROR: channel '$2' ($1) is not valid on this device." >&2
echo "Available channels: ${DEV_CHANS}" >&2
exit 1
fi
}
validate_chan clk "${CLK}"
validate_chan data "${DATA}"
[ -n "${RST}" ] && validate_chan rst "${RST}"
[ -n "${VCC}" ] && validate_chan vcc "${VCC}"
# A physical channel can carry only one signal. Assigning the same channel
# to two decoder inputs (e.g. --clk=D0 with the default --rst=D0) makes the
# decoder hang/burst after the ATR, because the shared pin drives both sample
# reads and line-event detection at once. Reject that before launching.
check_dup() {
# $1 = role A, $2 = value A, $3 = role B, $4 = value B
[ -z "$2" ] || [ -z "$4" ] && return 0
if [ "$2" = "$4" ]; then
echo "ERROR: '$1' and '$3' are both assigned to channel '$2'." >&2
echo "A channel cannot carry two signals at once. Use --no-$3 or set" >&2
echo "--$3 to a different pin (see the pinout in the README)." >&2
exit 1
fi
}
check_dup clk "${CLK}" rst "${RST}"
check_dup clk "${CLK}" vcc "${VCC}"
check_dup data "${DATA}" rst "${RST}"
check_dup data "${DATA}" vcc "${VCC}"
echo "Channels: clk=${CLK} data=${DATA}${RST:+ rst=${RST}}${VCC:+ vcc=${VCC}}"
OPTS="clk=${CLK}:data=${DATA}:clock_option=${CLOCK}"
OPTS+=":gsmtap_host=${HOST}:gsmtap_port=${PORT}"
[ -n "${RST}" ] && OPTS+=":rst=${RST}:rst_detect=true"
[ -n "${VCC}" ] && OPTS+=":vcc=${VCC}:vcc_detect=true"
OPTS+=":protocol=${PROTOCOL}"
echo "ISO 7816 streamer — clk=${CLK} data=${DATA}${RST:+ rst=${RST}}${VCC:+ vcc=${VCC}} clock=${CLOCK}"
echo "Protocol: ${PROTOCOL}"
echo "Samplerate: ${SAMPLERATE} | GSMTAP -> ${HOST}:${PORT}${PCAP:+ | pcap: ${PCAP}}${LOG:+ | log: ${LOG}}"
echo "Capture runs until you press Ctrl+C (stdin is detached, so keypresses"
echo "do NOT stop it)."
echo ""
if [ -n "${LOG}" ]; then
exec > >(tee "${LOG}") 2>&1
fi
KLOG_PID=""
if [ -n "${KLOG}" ]; then
if journalctl -kf > "${KLOG}" 2>/dev/null &
then
KLOG_PID=$!
echo "Kernel journal -> ${KLOG} (pid ${KLOG_PID})"
else
echo "journalctl -kf unavailable (permissions?); kernel log disabled" >&2
KLOG_PID=""
fi
fi
cleanup() {
[ -n "${KLOG_PID}" ] && kill "${KLOG_PID}" 2>/dev/null || true
[ -n "${ANNOT_TMP}" ] && rm -f "${ANNOT_TMP}" 2>/dev/null || true
[ -n "${FIFO_PID}" ] && kill "${FIFO_PID}" 2>/dev/null || true
[ -n "${FIFO}" ] && rm -f "${FIFO}" 2>/dev/null || true
}
trap cleanup EXIT
ANNOT_TMP="$(mktemp -t iso7816_annot.XXXXXX)" || ANNOT_TMP=""
FIFO=""
FIFO_PID=""
run_once() {
# $1 = samplerate, $2 = pcap file for this session (may be empty)
local rate="$1"
local pcap_opt=""
[ -n "$2" ] && pcap_opt=":pcap_file=$2"
# Blocking, keypress-free stdin: sigrok-cli's "press any key to stop"
# waits on stdin. /dev/null EOFs instantly and stops the capture (the
# bug introduced when detaching stdin), and a real keypress would too.
# A background `sleep` holds the pipe open without ever sending a byte,
# so the capture runs until the genuine SIGINT (Ctrl+C). We track and
# reap the sleep via a FIFO.
FIFO="$(mktemp -u -t iso7816_fifo.XXXXXX)"
mkfifo "${FIFO}"
sleep 1000000 > "${FIFO}" &
FIFO_PID=$!
sigrok-cli -d fx2lafw \
--config samplerate="${rate}" \
--continuous \
-C 'D0,D1,D2,D3,D4,D5,D6,D7' \
${DEBUG:+-l 4} \
-P "iso7816:${OPTS}${pcap_opt}" \
-A iso7816 < "${FIFO}" | tee "${ANNOT_TMP}"
local rc=${PIPESTATUS[0]}
kill "${FIFO_PID}" 2>/dev/null || true
rm -f "${FIFO}" 2>/dev/null || true
return "${rc}"
}
report_exit() {
# $1 = session number, $2 = exit code
case "$2" in
0) reason="capture ended (device stopped streaming on its own)" ;;
130) reason="SIGINT (Ctrl+C)" ;;
137) reason="SIGKILL (OOM?)" ;;
141) reason="SIGPIPE" ;;
*) reason="error" ;;
esac
echo "=== session #$1 ended rc=$2: $reason ($(date +%T))"
# rc=0 means the device itself stopped streaming -- this is distinct
# from an idle line, which with --continuous would keep running until
# Ctrl+C (rc=130). An idle capture (started before the card is
# powered) is completely normal and must not be treated as a failure.
if [ "$2" -eq 0 ]; then
local n=0
[ -n "${ANNOT_TMP}" ] && [ -f "${ANNOT_TMP}" ] \
&& n=$(wc -l < "${ANNOT_TMP}" 2>/dev/null || echo 0)
if [ "$n" -eq 0 ]; then
echo "NOTE: decoded 0 events. An idle line is normal (e.g. capture" >&2
echo "started before the SIM was powered). The device stopping on" >&2
echo "its own (rc=0) is a device-level issue, not an idle line:" >&2
echo " - FX2 unplugged / lost USB connection mid-capture" >&2
echo " - fx2lafw firmware hiccup" >&2
echo " - USB bandwidth ceiling (try a lower --samplerate)" >&2
else
echo "Decoded ${n} events before the device stopped." >&2
fi
fi
}
set +e
if [ "${LOOP}" -eq 1 ]; then
tries=0
while true; do
tries=$((tries + 1))
run_once "${SAMPLERATE}" "${PCAP}"
rc=$?
report_exit "${tries}" "$rc"
# A non-zero rc means a genuine stop: Ctrl+C (130) or an error.
# Only rc=0 (device stopped streaming on its own) triggers a restart.
if [ "$rc" -ne 0 ]; then
break
fi
if [ "${MAX_RESTARTS}" -gt 0 ] && [ "${tries}" -ge "${MAX_RESTARTS}" ]; then
echo "Reached max restarts (${MAX_RESTARTS}); stopping." >&2
break
fi
echo "Device stopped (rc=0); restarting in ${RESTART_DELAY}s ..." >&2
sleep "${RESTART_DELAY}"
done
exit "$rc"
else
run_once "${SAMPLERATE}" "${PCAP}"
rc=$?
report_exit "single" "$rc"
exit "$rc"
fi
+403
View File
@@ -0,0 +1,403 @@
=== Baseline captured 2026-09-09T14:15:00+03:00 ===
=== test_8 ===
('=== iso7816 decoder v1.5.0 ===',)
('etu recovered (samples):', 1237)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 37
capture ATRs : 3
capture APDUs : 56
byte-exact matches (LCS) : 28
reader exchanges not in capture (pre-capture / dropped): 9
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 17
- valid exchanges (reader retries/dupes): 11
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 ===
('=== iso7816 decoder v1.5.0 ===',)
('etu recovered (samples):', 1236)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
iso7816-1: RST deasserted
('RST deasserted',)
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 37
capture ATRs : 8
capture APDUs : 65
byte-exact matches (LCS) : 37
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 17
- valid exchanges (reader retries/dupes): 11
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone_sample ===
('=== iso7816 decoder v1.5.0 ===',)
('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]
(11695589, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
(11697509, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 1, 1]
(11698149, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
(11698789, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 1, 1]
(11699429, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 1, 1]
(11701349, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
(11701989, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 1, 0, 0, 1, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 1, 0, 0, 1, 0, 0]
(11709030, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
CHKSUM ERROR count: 7698
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 0
capture APDUs : 640
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 20
- valid exchanges (reader retries/dupes): 620
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_mi_a1_coldboot ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f96801fc78031e073fe2117574a3382033339001e')
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 473
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 10
- valid exchanges (reader retries/dupes): 463
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f96801fc78031e073fe2117574a3382033339001e')
(10083201, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
(10084089, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
(32207790, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 1, 1, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 1, 1, 0, 0]
(32214835, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 1, 0, 1, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 1, 0, 1, 0, 0, 0]
(32216580, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 0, 0, 0, 0, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 0, 0, 0, 0, 1, 1, 1]
(32219038, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 1, 1]
(32219857, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
CHKSUM ERROR count: 7218
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 327
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 9
- valid exchanges (reader retries/dupes): 318
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 407
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 24
- valid exchanges (reader retries/dupes): 383
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f96801fc78031e073fe2117574a3382033339001e')
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 395
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 24
- valid exchanges (reader retries/dupes): 371
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 399
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 24
- valid exchanges (reader retries/dupes): 375
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.5.0 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
('RST deasserted (hunt/ATR already in progress)',)
('ENDATR', b'3b9f96801fc78031e073fe2117574a3382033339001e')
(72333388, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 1, 0, 1, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 1, 0, 1, 0, 0, 0]
(72394723, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
(72424969, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
(72429512, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 0, 1, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 0, 1, 0, 0, 0]
(72487445, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
(72631364, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 0, 1, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
(98955516, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
CHKSUM ERROR count: 394
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 375
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 21
- valid exchanges (reader retries/dupes): 354
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- CONCAT (multi-exchange swallowed in one packet): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== signal diagnostics ===
--- test_8 ---
First activity: sample 1
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 71858170
Transitions: 6
Transitions: 168
Transitions: 7423
--- test_7 ---
First activity: sample 2
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 71428947
Transitions: 16
Transitions: 444
Transitions: 8998
--- xiaomi_phone_sample ---
First activity: sample 11497325
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 12833950
Transitions: 0
Transitions: 78488
--- samsung_A55_coldboot_new_cable ---
First activity: sample 46045778
Frequency: 4.00 MHz
Period: 4.0 samples
Rising edges: 29683152
Transitions: 1
Transitions: 1
Transitions: 46003
--- samsung_S21p_coldboot_new_cable ---
First activity: sample 44024869
Frequency: 4.00 MHz
Period: 4.0 samples
Rising edges: 30172749
Transitions: 1
Transitions: 3
Transitions: 47161
=== unit tests ===
test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok
test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok
test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok
test_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pulse_start_edge_preserved) ... ok
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_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
test_plausible_sw_accepts_valid_classes (__main__.TestDesyncHelpers.test_plausible_sw_accepts_valid_classes) ... ok
test_plausible_sw_rejects_garbage (__main__.TestDesyncHelpers.test_plausible_sw_rejects_garbage) ... ok
test_a55_u1_concat (__main__.TestEmbeddedExchanges.test_a55_u1_concat) ... ok
test_a55_u4_mega_concat (__main__.TestEmbeddedExchanges.test_a55_u4_mega_concat) ... ok
test_channel1_select_after_manage_channel_swallow (__main__.TestEmbeddedExchanges.test_channel1_select_after_manage_channel_swallow) ... ok
test_clean_atr_fragment_select (__main__.TestEmbeddedExchanges.test_clean_atr_fragment_select) ... ok
test_clean_channel1_select_after_manage_channel (__main__.TestEmbeddedExchanges.test_clean_channel1_select_after_manage_channel) ... ok
test_clean_get_response_fcp (__main__.TestEmbeddedExchanges.test_clean_get_response_fcp) ... ok
test_clean_read_record_ff_run (__main__.TestEmbeddedExchanges.test_clean_read_record_ff_run) ... ok
test_clean_select_611e (__main__.TestEmbeddedExchanges.test_clean_select_611e) ... ok
test_clean_select_6a82 (__main__.TestEmbeddedExchanges.test_clean_select_6a82) ... ok
test_concat_packets_are_not_is_desynced (__main__.TestEmbeddedExchanges.test_concat_packets_are_not_is_desynced) ... ok
test_samsung_record_burst_concat (__main__.TestEmbeddedExchanges.test_samsung_record_burst_concat) ... ok
test_xiaomi_select_getresp_swallow (__main__.TestEmbeddedExchanges.test_xiaomi_select_getresp_swallow) ... ok
test_build_packet_bad_fcs_flag (__main__.TestGsmtapFlags.test_build_packet_bad_fcs_flag) ... ok
test_build_packet_payload_passthrough (__main__.TestGsmtapFlags.test_build_packet_payload_passthrough) ... ok
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_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)
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_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok
test_invalid_cla_proprietary_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_proprietary_unseen) ... ok
test_invalid_ins_lsb_odd (__main__.TestValidateT0Apdu.test_invalid_ins_lsb_odd) ... ok
test_invalid_ins_sw_collision (__main__.TestValidateT0Apdu.test_invalid_ins_sw_collision) ... ok
test_invalid_ins_zero (__main__.TestValidateT0Apdu.test_invalid_ins_zero) ... ok
test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok
test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok
test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok
test_valid_case1_direct_sw (__main__.TestValidateT0Apdu.test_valid_case1_direct_sw) ... ok
test_valid_case2_with_response (__main__.TestValidateT0Apdu.test_valid_case2_with_response) ... ok
test_valid_case3_with_command_data (__main__.TestValidateT0Apdu.test_valid_case3_with_command_data) ... ok
test_valid_cla_channel1_select_after_manage_channel (__main__.TestValidateT0Apdu.test_valid_cla_channel1_select_after_manage_channel) ... ok
test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla_interindustry_classes) ... ok
test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok
----------------------------------------------------------------------
Ran 47 tests in 0.017s
OK
+476
View File
@@ -0,0 +1,476 @@
=== Baseline captured 2026-09-04T22:55:20+03:00 ===
=== test_8 ATR-included ===
('iso7816 decoder v1.1.3',)
('Invalid TS byte 0x90 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x90 - skipped
('Invalid TS byte 0x00 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x00 - skipped
('Invalid TS byte 0xf7 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xf7 - skipped
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
('Invalid TS byte 0x10 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x10 - skipped
('Invalid TS byte 0x95 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x95 - skipped
('Invalid TS byte 0x7a (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x7a - skipped
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
('Invalid TS byte 0x10 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x10 - skipped
('warm reset: re-arming ATR hunt',)
CHKSUM ERROR count: 28
reader exchanges : 37
capture ATRs : 2
capture APDUs : 54
byte-exact matches (LCS) : 28
reader exchanges not in capture (pre-capture / dropped): 9
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 17
- valid exchanges (reader retries/dupes): 9
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_8 mid-session ===
('iso7816 decoder v1.1.3',)
('etu recovered (samples):', 1237)
('warm reset: re-arming ATR hunt',)
('Invalid TS byte 0x95 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x95 - skipped
('Invalid TS byte 0x7a (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x7a - skipped
(382251, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xfc (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xfc - skipped
(7362370, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
(14335815, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
('Invalid TS byte 0xfb (pre-activation?), resyncing',)
CHKSUM ERROR count: 38
reader exchanges : 37
capture ATRs : 2
capture APDUs : 54
byte-exact matches (LCS) : 28
reader exchanges not in capture (pre-capture / dropped): 9
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 17
- valid exchanges (reader retries/dupes): 9
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 ATR-included ===
('iso7816 decoder v1.1.3',)
('warm reset: re-arming ATR hunt',)
('Invalid TS byte 0x07 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x07 - skipped
('Invalid TS byte 0x95 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x95 - skipped
('Invalid TS byte 0x7a (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x7a - skipped
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
('Invalid TS byte 0x10 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x10 - skipped
('Invalid TS byte 0x95 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x95 - skipped
('Invalid TS byte 0x7a (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x7a - skipped
(23123034, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
CHKSUM ERROR count: 166
reader exchanges : 37
capture ATRs : 4
capture APDUs : 10
byte-exact matches (LCS) : 10
reader exchanges not in capture (pre-capture / dropped): 27
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 0
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 mid-session ===
('iso7816 decoder v1.1.3',)
('warm reset: re-arming ATR hunt',)
('etu recovered (samples):', 1236)
('warm reset: re-arming ATR hunt',)
('ENDATR', b'3b9f95801fc78031e073fe21136364035b83079000f7')
(45481390, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
(45636295, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0x9f (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x9f - skipped
('Invalid TS byte 0x95 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x95 - skipped
('Invalid TS byte 0x80 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x80 - skipped
('Invalid TS byte 0x1f (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x1f - skipped
('Invalid TS byte 0xc7 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xc7 - skipped
('Invalid TS byte 0x80 (pre-activation?), resyncing',)
CHKSUM ERROR count: 162
reader exchanges : 37
capture ATRs : 4
capture APDUs : 10
byte-exact matches (LCS) : 10
reader exchanges not in capture (pre-capture / dropped): 27
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 0
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone ATR-included ===
('iso7816 decoder v1.1.3',)
('Invalid TS byte 0xf4 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xf4 - skipped
(11685168, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xff (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xff - skipped
(11703683, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 0, 1, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 0, 1, 0, 0, 0, 0, 0]
('Invalid TS byte 0x0b (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x0b - skipped
('Invalid TS byte 0xe8 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xe8 - skipped
('Invalid TS byte 0xbc (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xbc - skipped
(11739309, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 1, 1, 1, 0, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 1, 1, 1, 0, 1, 0]
('Invalid TS byte 0xb9 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xb9 - skipped
('Invalid TS byte 0x5c (pre-activation?), resyncing',)
CHKSUM ERROR count: 142
reader exchanges : 0
capture ATRs : 0
capture APDUs : 0
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 0
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone mid-session ===
('iso7816 decoder v1.1.3',)
('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]
(11695589, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
(11697509, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 1, 1]
(11698149, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
(11698789, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 1, 1]
(11699429, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 1, 1]
(11701349, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
(11701989, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 0, 1, 0, 0, 1, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 0, 1, 0, 0, 1, 0, 0]
(11709030, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
CHKSUM ERROR count: 10140
reader exchanges : 0
capture ATRs : 0
capture APDUs : 620
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 18
- valid exchanges (reader retries/dupes): 602
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_phone_sample ATR-included ===
('iso7816 decoder v1.1.3',)
(3125821, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
('Invalid TS byte 0xfc (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xfc - skipped
('Invalid TS byte 0x03 (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x03 - skipped
(21909783, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 1, 1, 1, 0, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 1, 1, 1, 0, 1, 0]
('Invalid TS byte 0xbc (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0xbc - skipped
(21952849, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 0, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 0, 0]
('Invalid TS byte 0x7f (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x7f - skipped
(21969301, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 0, 0, 0, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 0, 0, 0, 1, 0]
('Invalid TS byte 0x8f (pre-activation?), resyncing',)
iso7816-1: Invalid TS byte 0x8f - skipped
('Invalid TS byte 0xfe (pre-activation?), resyncing',)
CHKSUM ERROR count: 2062
reader exchanges : 0
capture ATRs : 0
capture APDUs : 2
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 2
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_phone_sample mid-session ===
('iso7816 decoder v1.1.3',)
('etu recovered (samples):', 72)
(3272987, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 1, 1, 1, 1, 1, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 1, 1, 1, 1, 1, 1, 0]
(3273839, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 1, 0, 1, 1, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 1, 0, 1, 1, 1, 0]
('etu recovered (samples):', 71)
(21919147, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 1, 1, 1, 1]
(22026940, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 1, 1, 1, 1]
(22831386, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 0, 0, 1, 1]
(22850665, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 0, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 0, 1, 0]
(22853174, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 0, 1, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 0, 1, 1, 0]
(22855060, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 1, 1, 1, 1]
(22873632, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 1, 1, 1, 1, 1, 1, 0])
CHKSUM ERROR count: 1514
reader exchanges : 0
capture ATRs : 1
capture APDUs : 49
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 44
- GARBAGE (mis-framed/desync): 5
GARBAGE: 00a41f04026f38ff61
GARBAGE: 00a40004026f07ff61
GARBAGE: 00a40004026f78ff61
GARBAGE: 00a4000402ff6fdfff
GARBAGE: 00a40004026fe3ff61
Degradation metrics:
- BAD_FCS (desynced by decoder): 6
in GARBAGE: 5, in retries: 1, in CAT: 0, in matched: 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: UNCLEAN - 5 mis-framed capture APDU(s)
=== sunrise_phone_sample ATR-included ===
('iso7816 decoder v1.1.3',)
('ENDATR', b'3b9f96801fc68031e073fe211b644127910082900037')
CHKSUM ERROR count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 1077
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 5
- valid exchanges (reader retries/dupes): 1072
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== sunrise_phone_sample mid-session ===
('iso7816 decoder v1.1.3',)
('etu recovered (samples):', 5723)
(88911990, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 0, 0, 1, 1]
(92269756, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(93979892, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(94115907, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
(95206798, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(95329967, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
(95981570, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 1, 0, 1, 1, 1, 0])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 1, 0, 1, 1, 1, 0]
(99228947, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(99366855, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
CHKSUM ERROR count: 1834
reader exchanges : 0
capture ATRs : 0
capture APDUs : 1
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 1
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== sim_turnon ATR-included ===
('iso7816 decoder v1.1.3',)
('ENDATR', b'3b9f96801fc78031e073fe211163444d2183079000e2')
CHKSUM ERROR count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 1390
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 5
- valid exchanges (reader retries/dupes): 1385
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
- BAD_FCS (desynced by decoder): 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== sim_turnon mid-session ===
('iso7816 decoder v1.1.3',)
('etu recovered (samples):', 2855)
(108117240, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 0, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 0, 0, 1, 1]
(109877662, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(113088360, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 1, 1, 1, 1]
(113308472, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
(113339895, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 1, 0, 1, 1, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 1, 0, 1, 1, 1, 1, 1, 1]
(113602225, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 0, 0, 0, 0, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 0, 0, 0, 0, 1, 1, 1]
(113954522, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 0, 0, 0, 1, 0, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 0, 0, 0, 1, 0, 1, 1]
(113983375, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 1, 1, 0, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 1, 1, 0, 0, 1, 1, 1, 1]
(114163147, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 1, 0, 1, 1, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 1, 0, 1, 1, 0, 1, 1, 1, 1]
CHKSUM ERROR count: 5044
reader exchanges : 0
capture ATRs : 0
capture APDUs : 9
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 0
- valid exchanges (reader retries/dupes): 7
- GARBAGE (mis-framed/desync): 2
GARBAGE: 00feedc2feedeefeedeafeec00feecffe797edeafeedeafeec00feecffe797edeafeecc0fefc80e7e7e7e7e7e7e767edfafeec00feecffe797edfafeecc2f9feece6edfafeec00feecffe797edfafeecc2f9feec41fbedfafeec08feecffe797edeafeecc2f9feec02feedfafeecc2f9feec83feedfefeecc2f9feecf2edf2feecc0feec3efeedfafeecc0feec4cfbedfefeecc2f9fefc4080808080808080808088808480808080feec41feedfefeecc0feecc0ecfeec02e7e6feedeefeedeafeecc0f9feece7edfafeedeafeedeafeec82f9feedfafeec08feec18e7e7fdedfefeec8af9feedfafeec0af9feedfafeec00feec18e7e7fdedfafeec02f9feedfafeec0af9
GARBAGE: 000667f9e7e7e7e7e7e7e7e7e7e767ec100083e7e7e7e7e7e7e7e7e7e7e7e797ec1820e7e7e7e7e7e7e7e7e7e7e7e727ec1800eae7e7e7e7e7e7e7e7e7e7e7e7e7cfedf6feec08feedeafeec0af9feec03ffe727edf6feec00feec52feedeafeec0af9feecffe7e7e7fbedf6feec80feedeafeec02f9feecffe7e7e7fbedf6feec88e6edfefeec00feedfafeec80e4edfafeec88e4edfafeec00feedfafeec08feedfafeec80feedfefeec80feedfefeec08feedfefeec00feedfafeec80e4ece4ece4edfafeec02e7e6feece23dccffec9d95dcf2edf6feec80e4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4edfafeecc2f9feedc2feedfefeedf6feec80e4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4edf2feecc0feedc6feedfafeecc0feedc27dedfeedf6feec88e4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4ece4
Degradation metrics:
- BAD_FCS (desynced by decoder): 5
in GARBAGE: 2, in retries: 3, in CAT: 0, in matched: 0
- payload mismatches (LCS-matched, bytes differ): 0
- suspicious CLA: 0
RESULT: UNCLEAN - 2 mis-framed capture APDU(s)
=== signal diagnostics ===
--- test_8 ---
First activity: sample 1
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 71858170
Transitions: 6
Transitions: 168
Transitions: 7423
--- test_7 ---
First activity: sample 2
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 71428947
Transitions: 16
Transitions: 444
Transitions: 8998
--- xiaomi_phone ---
First activity: sample 11497325
Frequency: 5.33 MHz
Period: 3.0 samples
Rising edges: 12833950
Transitions: 0
Transitions: 78488
--- samsung_phone_sample ---
First activity: sample 3096536
Frequency: 4.00 MHz
Period: 4.0 samples
Rising edges: 14002767
Transitions: 38
Transitions: 171694
Transitions: 117098
--- sunrise_phone_sample ---
+363
View File
@@ -0,0 +1,363 @@
"""Unit tests for GSMTAP flag plumbing + decoder desync helpers.
pd.py imports the libsigrokdecode bindings, which are not always present
in a bare Python env, so we stub a minimal `sigrokdecode` module and load
the real pd.py / gsmtap_stream.py sources under the `iso7816` package
name (mirroring how libsigrokdecode loads a decoder directory).
"""
import importlib.util
import os
import sys
import types
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_iso7816():
# Minimal sigrokdecode stub.
srd = types.ModuleType('sigrokdecode')
srd.Decoder = type('Decoder', (object,), {})
srd.SRD_CONF_SAMPLERATE = 'samplerate'
srd.OUTPUT_PYTHON = 'python'
srd.OUTPUT_ANN = 'ann'
srd.OUTPUT_BINARY = 'binary'
sys.modules.setdefault('sigrokdecode', srd)
pkg = types.ModuleType('iso7816')
pkg.__path__ = [REPO]
sys.modules.setdefault('iso7816', pkg)
def load(name, relpath):
spec = importlib.util.spec_from_file_location(
'iso7816.' + name, os.path.join(REPO, relpath))
mod = importlib.util.module_from_spec(spec)
sys.modules['iso7816.' + name] = mod
spec.loader.exec_module(mod)
return mod
return load('gsmtap_stream', 'gsmtap_stream.py'), \
load('pd', 'pd.py')
class TestGsmtapFlags(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def test_build_packet_plain_res_zero(self):
p = self.gs.build_packet(0x00, b'\x80\xf2')
self.assertEqual(p[12], 0x00)
self.assertEqual(p[15], 0x00)
def test_build_packet_bad_fcs_flag(self):
p = self.gs.build_packet(0x00, b'\x80\xf2',
flags=self.gs.GSMTAP_FLAG_BAD_FCS)
self.assertEqual(p[12], 0x00) # sub_type preserved
self.assertEqual(p[15], 0x01) # res = BAD_FCS
def test_build_packet_payload_passthrough(self):
data = b'\x00\xa4\x00\x04\x02'
p = self.gs.build_packet(0x00, data)
self.assertEqual(p[16:], data)
def test_flag_constant_value(self):
self.assertEqual(self.gs.GSMTAP_FLAG_BAD_FCS, 0x01)
class TestDesyncHelpers(unittest.TestCase):
@classmethod
def setUpClass(cls):
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)):
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):
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'))
# 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_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'))
def test_is_desynced_bad_sw(self):
self.assertTrue(self.pd.is_desynced(
b'\x00\xa4\x00\x04\x02\x3f\x00\x00\xff'))
def test_is_desynced_oversized(self):
big = bytes([0x00, 0xb2, 0x00, 0x00, 0xff]) + b'\x00' * 300
self.assertTrue(self.pd.is_desynced(big))
def test_is_desynced_all_zero_is_not_desync(self):
# all-zero is idle-low (suppressed), not desync
self.assertFalse(self.pd.is_desynced(b'\x00' * 7))
class TestValidateT0Apdu(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _valid(self, packet):
valid, reason = self.pd.validate_t0_apdu(packet)
self.assertTrue(valid, 'expected valid, got: ' + str(reason))
def _invalid(self, packet, expected_reason_substring=None):
valid, reason = self.pd.validate_t0_apdu(packet)
self.assertFalse(valid, 'expected invalid')
if expected_reason_substring:
self.assertIn(expected_reason_substring, reason)
def test_valid_case1_direct_sw(self):
# P3=0, 7 bytes, direct SW
self._valid(b'\x00\xa4\x00\x04\x00\x90\x00')
def test_valid_direct_sw_with_p3_nonzero(self):
# Card can reject a command and send SW immediately, even with P3>0
self._valid(b'\x00\xa4\x00\x04\x02\x90\x00')
def test_valid_case2_with_response(self):
# header(5) + 2 response bytes + SW(2)
self._valid(b'\x00\xb0\x00\x00\x02\x12\x34\x90\x00')
def test_valid_case3_with_command_data(self):
# header(5) + 4 command bytes + SW(2)
self._valid(b'\x00\xd6\x00\x00\x04\x11\x22\x33\x44\x90\x00')
def test_valid_cla_interindustry_classes(self):
# Interindustry CLA digits 0x/1x/4x/5x are structurally valid
# (ISO 7816-4): includes logical channels 0x01-0x03, secure
# messaging 0x0C, chaining 0x10, and the SM/structural 0x4x/0x5x.
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14, 0x44, 0x50, 0x54):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
self._valid(pkt), 'CLA=0x%02x' % cla
def test_valid_cla_channel1_select_after_manage_channel(self):
# The command that follows MANAGE CHANNEL (00 70 00 00 01) opening
# logical channel 1 legitimately uses CLA 0x01. This exact type
# (SELECT by AID) is what the A55/S21p traces carry.
self._valid(b'\x01\xa4\x00\x04\x0c'
b'\xa0\x00\x00\x00\x63\x50\x4b\x43\x53\x2d\x31\x35'
b'\x6a\x82')
def test_invalid_cla_proprietary_unseen(self):
# 0xC0 is proprietary but not a class these cards emit; stays invalid
self._invalid(b'\xc0\xa4\x00\x04\x00\x90\x00', 'invalid CLA')
def test_invalid_too_short(self):
self._invalid(b'\x00\xa4\x00\x04\x00\x90', 'too short')
def test_invalid_cla(self):
self._invalid(b'\xff\xa4\x00\x04\x00\x90\x00', 'invalid CLA')
def test_invalid_ins_zero(self):
self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_ins_lsb_odd(self):
self._invalid(b'\x00\xa5\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_ins_sw_collision(self):
self._invalid(b'\x00\x64\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_sw1(self):
self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1')
def test_invalid_oversized(self):
big = bytes([0x00, 0xb2, 0x00, 0x00, 0xff]) + b'\x00' * 300
self._invalid(big, 'exceeds T=0 max length')
class TestNextStartFall(unittest.TestCase):
def setUp(self):
_, pd = _load_iso7816()
# _next_start_fall is an instance method but does not use self.
self.inst = object.__new__(pd.Decoder)
def test_finds_fall_after_threshold(self):
edges = [(100, 0), (200, 1), (300, 0)]
self.assertEqual(self.inst._next_start_fall(edges, 250, 100), 300)
def test_finds_fall_exactly_at_threshold(self):
"""Regression test for Samsung 16 CLK/ETU concatenation.
When the next byte's start-bit fall lands exactly on the
``10 * bs`` boundary, the previous strict ``>`` test missed it.
"""
edges = [(100, 0), (200, 1), (300, 0)]
self.assertEqual(self.inst._next_start_fall(edges, 300, 100), 300)
def test_returns_none_when_no_fall(self):
edges = [(100, 0), (200, 1)]
self.assertIsNone(self.inst._next_start_fall(edges, 300, 100))
def test_rejects_phantom_sub_etu_fall(self):
# A sub-ETU low bounce in the stop/guard time (fall at 290, quick
# rise at 310) must NOT be accepted as the next start; the scan
# skips it and only accepts the genuine start fall at 400.
edges = [(100, 0), (290, 0), (310, 1), (400, 0)]
self.assertEqual(self.inst._next_start_fall(edges, 250, 100), 400)
def test_accepts_full_hold_fall(self):
# Genuine start fall: held LOW for a full ETU before the rise.
edges = [(100, 0), (300, 0), (400, 1)]
self.assertEqual(self.inst._next_start_fall(edges, 250, 100), 300)
class TestDeglitchEdges(unittest.TestCase):
def setUp(self):
_, pd = _load_iso7816()
self.inst = object.__new__(pd.Decoder)
def test_no_edges(self):
self.assertEqual(self.inst._deglitch_edges([], 72), [])
def test_glitch_low_pulse_removed(self):
# Real start fall at 0, real rise at 72, 1-sample LOW glitch
# at 73-74, real fall at 144 (next byte start). bs=72, threshold=14.
edges = [(0, 0), (72, 1), (73, 0), (74, 1), (144, 0)]
# After removing the 1-sample LOW pulse (73,0)-(74,1),
# the line stays HIGH until the next real edge.
result = self.inst._deglitch_edges(edges, 72)
self.assertEqual(result, [(0, 0), (72, 1), (144, 0)])
def test_real_pulses_preserved(self):
# 72-sample real LOW pulse; should not be touched.
edges = [(0, 0), (72, 1), (144, 0), (216, 1)]
result = self.inst._deglitch_edges(edges, 72)
self.assertEqual(result, edges)
def test_short_pulse_start_edge_preserved(self):
# Pulse starting at the first edge: start-bit fall is preserved,
# only the edge ending the short pulse is removed.
edges = [(0, 0), (1, 1), (100, 0)]
result = self.inst._deglitch_edges(edges, 72)
self.assertEqual(result, [(0, 0), (100, 0)])
class TestEmbeddedExchanges(unittest.TestCase):
"""CONCAT detection: structurally valid packets that embed complete T=0
exchanges (a byte-drop mis-frame swallowed several real exchanges into
one APDU). Vectors are exact bytes from the 10-trace suite."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _hits(self, hexstr):
return self.pd.embedded_exchanges(bytes.fromhex(hexstr))
def test_clean_get_response_fcp(self):
# FCP response with 0x62/0x6f BER-TLV tags + p1=f0 PIN template;
# classic false-positive candidate -- must NOT hit.
h = ('00c000001ec0621c8202412183026f43a503920100'
'8a01058b032f0604800200028800913e')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), [])
def test_clean_select_611e(self):
# Plain SELECT -> 611E (test_8/7 session) must not hit.
self.assertEqual(self._hits('00a4080404a47fff611e'), [])
def test_clean_select_6a82(self):
self.assertEqual(self._hits('00a4080404a47fff6a82'), [])
def test_clean_read_record_ff_run(self):
# READ RECORD whose response is an all-FF fill run + 9000. The FF
# data must not be mistaken for embedded headers.
h = '00b2020440' + 'ff' * 64 + '9000'
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), [])
def test_clean_channel1_select_after_manage_channel(self):
# A properly decoded post-MANAGE-CHANNEL exchange (CLA 0x01,
# SELECT by AID, 6A 82) is a CLEAN single exchange: it must
# validate and must NOT be flagged as CONCAT.
h = ('01a400040ca000000063504b43532d31356a82')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), [])
self.assertFalse(self.pd.is_desynced(bytes.fromhex(h)))
def test_clean_atr_fragment_select(self):
# ATR-recovery fragment (header + 3F 00 + 6125) must not hit.
self.assertEqual(self._hits('00a40004023f006125'), [])
def test_a55_u1_concat(self):
# A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix.
h = ('000402a43f00612501c0000025c062238202782183023f00a5098001'
'7183040000eec18a01058b032f0601c6069001008301019000'
'01a4000402a42f00612101c0000021621f')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), [(8, 51), (53, 61)])
def test_a55_u4_mega_concat(self):
# A55[281] (n=167): the CAT ENVELOPE mega-concat (7 embedded).
h = ('04040ca4a000000063504b43532d31356a8280f2000c009000007080'
'0100900000a4000c02a43f009000801400000c148103012500020282'
'81830100910f801200000f12d00d8103010500820281829902031290'
'0000a4000c02a43f009000801400000c148103010500020282818301'
'00910f801200000f12d00d8103010300820281828402011e90008014'
'000010148103010300020282818301000402011e90000070009000')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
hits = self._hits(h)
self.assertEqual(len(hits), 7)
self.assertEqual(hits[0], (32, 40))
def test_samsung_record_burst_concat(self):
# samsung_phone[826] (n=247): mis-framed READ RECORD chain.
h = ('0408b252f0990000fffe01900000b2020408b252f0430000fffe02900'
'000b2030408b252f0230000fffe03900000b2040408b252f0450000f'
'ffe02900000b2050408b252f0490000fffe04900000b2060408b252f'
'0690000fffe05900000b2070408b252f0790000fffe06900000b2080'
'408b252f0890000fffe07900000b2090408b2ffffffffffffffff900'
'000b20a0408b2ffffffffffffffff900000a4000402a46f40611b00c'
'000001bc0621982054221001c0283026f408a01058b036f060980020'
'0388800900000b201041cb2fffffffffbfbfbfbfbfbfbfbfbfbfbfbf'
'bfbfbfbfbfbfbfbfb830300b202041cb2ffff9000')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(len(self._hits(h)), 15)
def test_xiaomi_select_getresp_swallow(self):
# xiaomi[613] (n=118): stacked SELECT/GET RESPONSE exchanges.
h = ('04a47fff6f116a8200a4080404a47fff6fe3611e00c000001ec0621c'
'8202412183026fe3a503c001808a01058b036f0609800200128801f0'
'900000a4080404a47fff6f136a8200a4080404a47fff6fe3611e00c0'
'00001ec0621c8202412183026fe3a503c001808a01058b036f060980'
'020012889000')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(len(self._hits(h)), 4)
def test_channel1_select_after_manage_channel_swallow(self):
# Channels: MC opens channel 1 (00 70 00 00 01 -> 01 90 00: the 01
# response byte is the opened channel number), then a channel-1
# SELECT-by-AID (01 A4 00 04 0C AID -> 6A 82) that a mis-frame
# swallowed into the MC packet. The embedded exchange starts on
# CLA 0x01 -- a real channel-1 cluster byte, not a phantom: the
# detector must find it.
h = ('007000000101900001a400040ca000000063504b43532d31356a829000')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), [(8, 25)])
def test_concat_packets_are_not_is_desynced(self):
# CONCAT detection is orthogonal: these packets PASS structural
# validation (they only look wrong when interior bytes are scanned).
a55u1 = ('000402a43f00612501c0000025c062238202782183023f00a5098'
'0017183040000eec18a01058b032f0601c6069001008301019000'
'01a4000402a42f00612101c0000021621f')
self.assertFalse(self.pd.is_desynced(bytes.fromhex(a55u1)))
if __name__ == '__main__':
unittest.main()
+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()