commit d3715c9385a756cc8635708fe5f534924b650f63 Author: Антон Трошин Date: Wed Sep 9 15:45:44 2026 +0300 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 .sr placeholders; AGENTS.md retains the fixture names. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0350721 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Batch scripts require CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +# Keep shell scripts LF +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23a8d51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pcap +*.log +__pycache__/ + +# Trace fixtures are local-only (large, machine-specific); never tracked. +examples/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7943378 --- /dev/null +++ b/AGENTS.md @@ -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: embedded exchange(s) at ; packet `, 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7701d2 --- /dev/null +++ b/README.md @@ -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=` 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 .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 .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 .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 .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 .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 .log out.pcap + +# Phone capture (mid-session, gated/stopped CLK) +sigrok-cli -i .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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5afc9f3 --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2012 Uwe Hermann +## +## 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 . +## + +''' +ISO7816 is a bidirectional, multi-master +bus using two signals (CLK = serial clock line, DATA = IO data line). +''' + +from .pd import Decoder diff --git a/gsmtap_stream.py b/gsmtap_stream.py new file mode 100644 index 0000000..c01e4e8 --- /dev/null +++ b/gsmtap_stream.py @@ -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 diff --git a/pd.py b/pd.py new file mode 100644 index 0000000..4ebe691 --- /dev/null +++ b/pd.py @@ -0,0 +1,2491 @@ +## +## Copyright (C) 2020 Sven Soltermann +## +## 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 . +## + +import sigrokdecode as srd +import struct +import sys +import time +import codecs +import ctypes +import traceback +from collections import deque + +from .gsmtap_stream import (GsmtapStreamSender, + GSMTAP_SIM_APDU, GSMTAP_SIM_ATR, GSMTAP_SIM_PPS, + GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT, + GSMTAP_FLAG_BAD_FCS) + +VERSION = '1.5.0' + + + +class Ann: + '''Annotation and binary output classes.''' + ( + ANN_WARN, ANN_BYTE, ANN_ATR, ANN_PPS, ANN_T0, ANN_T1, ANN_T1IBLOCK, ANN_T1RBLOCK, ANN_T1SBLOCK, ANN_APDU, + ANN_RST, ANN_VCC, + ) = range(12) + + +class pcap_udp_pkt(): + # GSM TAP + h = b'' + + # layer_2 + h += b'\x00\x00\x00\x00\x00\x00' # destination_mac + h += b'\x00\x00\x00\x00\x00\x00' # source_mac + h += b'\x08\x00' # layer_3_protocol + + # layer_3 + h += b'\x45' # version + h += b'\x00' # DiffServField + h += b'\xFF\xFF' # total_length + h += b'\x2B\x0D' # Identification + h += b'\x40\x00' # Flags + h += b'\x40' # TTL + h += b'\x11' # layer_4_protocol + h += b'\x00\x00' # header_checksum + h += b'\x7F\x00\x00\x01' # source_ip + h += b'\x7F\x00\x00\x01' # dest_ip + + # layer_4 + h += b'\xcc\x46' # source_port + h += b'\x12\x79' # dest_port + h += b'\x00\x00' # datagram_length + h += b'\x00\x00' # checksum + + def __init__(self, ts, data, sub_type=GSMTAP_SIM_APDU, flags=0): + self.header = bytearray(pcap_udp_pkt.h) + self.data = b'' + self.set_timestamp(ts) + self.set_data(data, sub_type, flags) + + def set_timestamp(self, ts): + self.timestamp = ts + + def set_data(self, data, sub_type=GSMTAP_SIM_APDU, flags=0): + gsmtap = bytearray(b'\x02\x04\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') + gsmtap[12] = sub_type + gsmtap[15] = flags + self.data = list(gsmtap + bytes(data)); + self.header[16:18] = struct.pack('>H', len(self.data) + 28) + self.header[38:40] = struct.pack('>H', len(self.data) + 8) + + def packet(self): + return bytes(self.header) + bytes(self.data) + + def record_header(self): + # See https://wiki.wireshark.org/Development/LibpcapFileFormat. + (secs, usecs) = self.timestamp + h = struct.pack(' MAX_TPDU_LEN: + return (False, 'exceeds T=0 max length') + + cla = packet[0] + ins = packet[1] + sw1 = packet[-2] + + if not plausible_cla(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 is_desynced(packet): + '''Classify an emitted T=0 packet as mis-framed. + + All-zero packets are idle-low noise (suppressed elsewhere), NOT + desync. A real TPDU must reach its status word: >= 7 bytes ending + in a plausible SW1, never exceed the T=0 maximum, and start with a + valid CLA/INS pair.''' + if not packet: + return False + if all(b == 0 for b in packet): + return False + valid, _ = validate_t0_apdu(packet) + return not valid + + +# --- 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). +# A swallowed header's CLA is validated with the same structural rule as the +# decoder itself (see plausible_cla): interindustry classes including the +# logical-channel numbers 0x01-0x03 are legitimate first bytes of a command. +EMBEDDED_CLA = frozenset(cla for cla in range(256) if plausible_cla(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 + + +MAX_TPDU_LEN = 271 # ISO 7816-3 T=0: header(5) + proc + 255 + SW(2) < 271 +IDLE_RESYNC_ETU = 4 # idle bit-periods marking an exchange boundary +MAX_RESYNC_ATTEMPTS = 1024 # bail out if no idle gap found in that many tries + + +class Decoder(srd.Decoder): + api_version = 3 + id = 'iso7816' + name = 'ISO 7816' + longname = 'Smartcard' + desc = 'ISO 7816 decoder (smartcard)' + license = 'gplv2+' + inputs = ['logic'] + outputs = ['iso7816'] + tags = ['Embedded/industrial'] + channels = ( + {'id': 'clk', 'name': 'CLK', 'desc': 'clock'}, + {'id': 'data', 'name': 'data', 'desc': 'data'}, + ) + optional_channels = ( + {'id': 'rst', 'name': 'RST', 'desc': 'reset (enable via rst_detect=true)'}, + {'id': 'vcc', 'name': 'VCC', 'desc': 'power supply, digital only (enable via vcc_detect=true)'}, + ) + options = ( + {'id': 'clock_option', 'desc': 'Clock option', + 'default': 'native', 'values': ('native', 'detect', 'sample_as_clock')}, + {'id': 'protocol', 'desc': 'Protocol', + 'default': 'auto', 'values': ('auto', 'T=0', 'T=1')}, + {'id': 'gsmtap_enable', 'desc': 'Live GSMTAP UDP stream (simtrace2-sniff compatible)', + 'default': 'true', 'values': ('true', 'false')}, + {'id': 'gsmtap_host', 'desc': 'GSMTAP destination host', 'default': '127.0.0.1'}, + {'id': 'gsmtap_port', 'desc': 'GSMTAP destination port', 'default': 4729}, + {'id': 'pcap_file', 'desc': 'Write PCAP to file in addition to binary output', 'default': ''}, + {'id': 'rst_detect', 'desc': 'Track RST pin events (needs rst channel assigned)', + 'default': 'false', 'values': ('false', 'true')}, + {'id': 'vcc_detect', 'desc': 'Track VCC pin events (needs vcc channel assigned)', + 'default': 'false', 'values': ('false', 'true')}, + ) + annotations = ( + ('warning', 'Human-readable warnings'), + ('byte', 'Byte'), + ('atr', 'ATR (Answer to Reset)'), + ('pps', 'PPS (Protocol and parameters selection)'), + ('t0', 'T=0 packet'), + ('t1', 'T=1 packet'), + ('t1-iblock', 'T=1 I-Block'), + ('t1-rblock', 'T=1 R-Block'), + ('t1-sblock', 'T=1 S-Block'), + ('apdu', 'APDU'), + ('rst', 'RST event'), + ('vcc', 'VCC event'), + ) + annotation_rows = ( + ('warnings', 'Warnings', (Ann.ANN_WARN,)), + ('bytes', 'Bytes', (Ann.ANN_BYTE,)), + ('type', 'Type', (Ann.ANN_ATR,Ann.ANN_PPS,Ann.ANN_T0,Ann.ANN_T1)), + ('t1blocks', 'T=1 Decode', (Ann.ANN_T1IBLOCK,Ann.ANN_T1RBLOCK,Ann.ANN_T1SBLOCK)), + ('apdus', 'apdu', (Ann.ANN_APDU,)), + ('lines', 'Lines', (Ann.ANN_RST, Ann.ANN_VCC)), + ) + binary = ( + ('pcap', 'PCAP format'), + ) + + clock_rate = { + 0: 372, + 1: 372, + 2: 558, + 3: 744, + 4: 1116, + 5: 1488, + 6: 1860, + 9: 512, + 10: 768, + 11: 1024, + 12: 1536, + 13: 2048 + } + baud_rate = { + 0: 1, + 1: 1, + 2: 2, + 3: 4, + 4: 8, + 5: 16, + 6: 32, + 7: 64, + 8: 12, + 9: 20 + } + + + # Channel indices within the pins tuple returned by self.wait() + # (declaration order of self.channels). + CLK_IDX = 0 + DATA_IDX = 1 + RST_IDX = 2 + VCC_IDX = 3 + + def __init__(self): + self.reset() + + def log(self, *args): + print(args, file=sys.stderr, flush=True) + + def _note_discard(self, value, reason, idle=False): + # Report bytes consumed but not emitted as part of an APDU. Idle + # line noise (0x00/0xFF on an idle period) is counted and reported + # periodically so a long idle line cannot flood the session log; + # every other discard (real data) is logged and annotated always. + if idle: + self._idle_discard_total += 1 + if self._idle_discard_total == 1 or self._idle_discard_total % 256 == 0: + self.log("discarded idle byte 0x%02x: %s (idle discards so far: %d)" + % (value, reason, self._idle_discard_total)) + return + self._discard_total += 1 + self.log("discarded byte 0x%02x: %s (discards so far: %d)" + % (value, reason, self._discard_total)) + if self.ss is not None and self.ss >= 0: + self.put(self.ss, self.ss, self.out_ann, + [Ann.ANN_WARN, ["dropped 0x%02x: %s" % (value, reason)]]) + return value + + def _consume_first(self): + '''Consume the byte the last peek_byte() returned, removing it from + whichever source it came from (the _replay buffer or the peeked + byte). The decode-step consume paths use this so re-framed bytes + queued in _replay cannot loop forever.''' + if self._replay: + return self._replay.popleft() + b = self.peeked_byte + self.peeked_byte = None + self.peeked_samplenum = -1 + return b + + def reset(self): + self.peeked_byte = None + self.peeked_samplenum = -1 + # Replay buffer for bytes read ahead but not consumed (used by the + # command-detect path in the ATR hunt to hand a command header back + # to the DATA state without dropping it). + self._replay = deque() + self.wrote_pcap_header = False + self.samplerate = None + self.sample_as_clock = False + self.detect_clock = False + self.fi = self.clock_rate[0] + self.di = self.baud_rate[0] + self.ss = self.es = -1 + self.state = 'FIND START' + self.bits = [] + self.clock_skip = 372 + # Measured bit period (samples or CLK edges) from the first frame; + # 372 = spec default until measured. Must exist before any PPS + # handling -- native mode never measures it explicitly. + self.detected_clock_skip = 372 + self.bit_samples = None + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + self._edge_spacings = deque(maxlen=128) + self._samples_per_clock = None + # True when clock_skip is known to be correct: after a parsed ATR + # (372 default), an accepted PPS (FI/DI), or a successful ETU-based + # recovery on a mid-session capture. The CLK-sync reader is only + # trusted when this is set; otherwise the edge-list reader (which + # uses bit_samples) is the safe fallback. + self._clock_skip_confident = False + self._start_fall = None + self._edge_read = True + self._last_sn = -1 + self._eof = False + self._min_pulse_samples = 2 # tuned for 16 MHz sample rate + self._glitch_rejects = 0 + self._prev_wait_sn = None + self._stall = 0 + # One-shot: at the first decode_step the initial RST/VCC levels are + # primed from the real first sample instead of the defaults below. + # Without this, last_rst=0/last_vcc=0 make "capture started while the + # card was already in reset" indistinguishable from a live session. + self._primed = False + self.lastSamplePositive = True + self.sampleOverflowCount = 0 + # Rolling parity-error monitor: warns when the wire degrades + # (e.g. GSM TX bursts coupling into the probe wiring). + self.parity_hist = deque(maxlen=64) + self.sig_warned = False + # Stuck-line watchdog: consecutive all-zero frames usually mean + # the I/O probe lost contact (floating low), not data. + self.zero_run = 0 + # Idle-low APDU suppression counter (all-zero T=0 reads). + self._idle_low = 0 + # Set after a desync so the next DATA step re-aligns on an + # inter-frame idle gap before resuming. + self._resync = False + # Discard accounting: every consumed byte that is not emitted as + # part of an APDU is reported. Non-idle discards (real data) are + # logged + annotated at every occurrence; idle 0x00/0xFF line noise + # is counted and reported periodically so a long idle line cannot + # flood the session log. + self._discard_total = 0 + self._idle_discard_total = 0 + # Set by handle_pps() when an accepted PPS changes the bit rate. + # Forces _measure_etu() on the next decode_step() cycle so the + # decoder re-locks to the new ETU from the live DATA line. + self._pps_speed_changed = False + # Count of times the ATR hunt failed to find a valid TS byte -- + # bounds the "keep hunting" loop so a mid-session sniffer (no ATR + # ever present) eventually falls back to T=0 parsing instead of + # looping forever. + self._atr_hunt_count = 0 + # PPS is only attempted once after an ATR/warm reset. Prevents + # idle-line 0xFF bytes from being parsed as an endless stream of + # PPS requests when the card does not actually perform PPS. + self._pps_attempted = False + self.gsmtap = None + self.pcap_fh = None + self.track_rst = False + self.track_vcc = False + # RST/VCC line tracking uses level-stability hysteresis (see + # _line_hyst): a level must stay continuously stable for + # vcc_min_gap samples before it is "confirmed" and reported. 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 simply fires once the line settles HIGH. + # last_rst/last_vcc hold the *confirmed* level; *_cand / *_cand_sn + # track a pending level and the sample it first appeared. + self.last_rst = 0 + self.last_vcc = 0 + self.rst_cand = None + self.rst_cand_sn = 0 + self.vcc_cand = None + self.vcc_cand_sn = 0 + # Minimum quiet window (samples) a NEW level must hold before it is + # honored. 10 ms @ 16 MHz; set from samplerate in metadata(). + self.vcc_min_gap = 0 # 0 = hysteresis disabled (no samplerate) + + def metadata(self, key, value): + if key == srd.SRD_CONF_SAMPLERATE: + self.samplerate = value + if self.samplerate: + self.secs_per_sample = float(1) / float(self.samplerate) + # 10 ms debounce window, scaled to samples. + self.vcc_min_gap = int(self.samplerate * 0.010) + + def start(self): + self.log("=== iso7816 decoder v{} ===".format(VERSION)) + self._pcap_base_time = time.time() + self.out_python = self.register(srd.OUTPUT_PYTHON) + self.out_ann = self.register(srd.OUTPUT_ANN) + self.out_binary = self.register(srd.OUTPUT_BINARY) + self.sample_as_clock = self.options['clock_option'] == "sample_as_clock" + self.detect_clock = self.options['clock_option'] == "detect" + self.state = 'DATA' + self._atr_parsed = False + # Inverse convention (TS=0x3F): logic 1 = LOW, logic 0 = HIGH. + # Set when TS=0x3F is detected; all subsequent byte reads invert bits. + self._inverse_convention = False + # clock_skip is not trusted until recovered (the card may have already + # PPS'd to a faster ETU before the capture began). + if (self.options['protocol'] == "T=0"): + self.hasT0 = True + self.hasT1 = False + elif (self.options['protocol'] == "T=1"): + self.hasT0 = False + self.hasT1 = True + else: + self.hasT0 = False + self.hasT1 = False + # True only when the user explicitly set protocol=T=0/T=1 (not 'auto'). + # Used to enable mid-session raw-burst emission (below) without + # affecting the default auto-protocol path. + self.explicit_protocol = self.options['protocol'] in ("T=0", "T=1") + + # Live GSMTAP UDP stream (default on, mimics simtrace2-sniff). + if (self.options['gsmtap_enable'] == "true" and self.options['gsmtap_host']): + try: + self.gsmtap = GsmtapStreamSender(self.options['gsmtap_host'], + int(self.options['gsmtap_port'])) + self.log("GSMTAP stream to", + "%s:%s" % (self.options['gsmtap_host'], self.options['gsmtap_port'])) + except OSError as e: + self.gsmtap = None + self.log("GSMTAP stream disabled:", str(e)) + + # Optional direct PCAP file output. + if (self.options['pcap_file']): + try: + self.pcap_fh = open(self.options['pcap_file'], 'wb') + self.pcap_fh.write(self.pcap_global_header()) + except OSError as e: + self.pcap_fh = None + self.log("PCAP file disabled:", str(e)) + + # Optional RST/VCC line tracking. + self.track_rst = self.options['rst_detect'] == "true" + self.track_vcc = self.options['vcc_detect'] == "true" + if (self.track_rst and hasattr(self, 'has_channel') + and not self.has_channel(self.RST_IDX)): + self.log("rst_detect=true but 'rst' channel not assigned - disabled") + self.track_rst = False + if (self.track_vcc and hasattr(self, 'has_channel') + and not self.has_channel(self.VCC_IDX)): + self.log("vcc_detect=true but 'vcc' channel not assigned - disabled") + self.track_vcc = False + + def finish(self): + if self.pcap_fh is not None: + try: + self.pcap_fh.close() + except OSError: + pass + self.pcap_fh = None + if self.gsmtap is not None: + self.gsmtap.close() + self.gsmtap = None + + def emit_packet(self, sub_type, payload, ss, es, flags=0): + '''Emit one event as PCAP binary stream + PCAP file + GSMTAP UDP. + + `flags` is written into the GSMTAP header `res` byte (see + GSMTAP_FLAG_BAD_FCS in gsmtap_stream).''' + ts = self.ts_from_samplenum(ss) + pkt = pcap_udp_pkt(ts, payload, sub_type, flags) + self.put(ss, es, self.out_binary, [0, pkt.record_header()]) + self.put(ss, es, self.out_binary, [0, pkt.packet()]) + if self.pcap_fh is not None: + self.pcap_fh.write(pkt.record_header()) + self.pcap_fh.write(pkt.packet()) + if self.gsmtap is not None: + self.gsmtap.send(sub_type, payload, flags) + + def _line_hyst(self, lvl, confirmed, cand, cand_sn): + '''Level-stability hysteresis for RST/VCC line tracking. + + A level different from `confirmed` becomes a *candidate*. It is + confirmed (and returned for emission) once it has been + continuously stable for >= vcc_min_gap samples -- either because it + persists that long, or because it ends (the line moves again) after + having been stable that long. This matters because the decoder is + edge-driven: the stable window between a fall and the next rise is + never re-sampled, so a candidate must be confirmable when it ENDS. + + Returns (emit_lvl, edge_sn, new_confirmed, new_cand, new_cand_sn): + emit_lvl is the level just confirmed (or None); edge_sn is the sample + the (now confirmed) level first appeared.''' + now = self.samplenum + if lvl == confirmed: + # Candidate (if any) ended by returning to the confirmed level. + # If it had been stable long enough, report it; the current line + # level (lvl) is now the state to track as a fresh candidate, so + # a genuine rise (e.g. power-up) is not lost on the same call. + if cand is not None and ((not self.vcc_min_gap) + or (now - cand_sn) >= self.vcc_min_gap): + return (cand, cand_sn, cand, lvl, now) + return (None, 0, confirmed, None, 0) + if cand is None: + # First divergence from confirmed. + if not self.vcc_min_gap: + # Hysteresis disabled: report the edge immediately. + return (lvl, now, lvl, None, 0) + return (None, 0, confirmed, lvl, now) + if cand == lvl: + # Same candidate persists: confirm once stable long enough. + if (not self.vcc_min_gap) or (now - cand_sn) >= self.vcc_min_gap: + return (lvl, cand_sn, lvl, None, 0) + return (None, 0, confirmed, cand, cand_sn) + # lvl differs from both confirmed and the current candidate: the + # candidate just ended and a new level appeared. If the candidate + # was stable, report it; then start tracking the new level. + if (not self.vcc_min_gap) or (now - cand_sn) >= self.vcc_min_gap: + return (cand, cand_sn, cand, lvl, now) + return (None, 0, confirmed, lvl, now) + + def line_events(self, pins): + '''Check RST/VCC levels via level-stability hysteresis and emit + events + annotations for confirmed level changes. + + An edge is only reported once the new level has been continuously + stable for >= vcc_min_gap samples, so a noisy/floating sense line + that rings on a transition no longer drops the event. Returns True + if any event fired.''' + fired = False + if (self.track_rst): + lvl = pins[self.RST_IDX] + emit_lvl, edge_sn, self.last_rst, self.rst_cand, self.rst_cand_sn = \ + self._line_hyst(lvl, self.last_rst, self.rst_cand, self.rst_cand_sn) + if emit_lvl is not None: + direction = 1 if emit_lvl == 0 else 0 # 1=asserted, 0=deasserted + self.emit_packet(GSMTAP_SIM_RST_EVENT, + bytes([direction, emit_lvl, 0]), edge_sn, edge_sn) + text = ('RST asserted' if emit_lvl == 0 else 'RST deasserted') + self.put(edge_sn, edge_sn, self.out_ann, [Ann.ANN_RST, [text]]) + self.log(text) + fired = True + if (emit_lvl == 0): + # RST asserted (LOW): card entering reset state. A reset + # makes the card send its ATR at the DEFAULT ETU (372 CLK + # cycles/bit), so clock_skip must be restored to 372 -- + # otherwise a post-PPS clock_skip (e.g. 16) would make the + # decoder hunt for the fresh ATR at the wrong rate and + # mis-frame every byte. Prepare for the next ATR by + # re-arming the hunt and clearing _atr_parsed so the next + # RST deassert will also re-arm (first cycle at startup). + self._atr_parsed = False + self._inverse_convention = False + self.clock_skip = 372 + self.bit_samples = None + self.state = 'FIND START' + self._atr_hunt_count = 0 + self._pps_attempted = False + if (emit_lvl == 1): + # RST deasserted (HIGH): card released from reset and + # will send a fresh ATR. However, the 10ms hysteresis + # means this event fires AFTER the ATR has already been + # parsed and the decoder entered DATA state. In that + # case, stay in DATA so the terminal's T=0 commands + # (which follow the ATR) are decoded. Only re-arm if + # _atr_parsed is False (startup / first cycle, where no + # ATR has been parsed yet). + if self._atr_parsed: + self.log("warm reset: staying in DATA (ATR already parsed)") + else: + # _atr_parsed is False: a fresh ATR is expected. The + # 10ms hysteresis confirms the deassert ~10ms after + # the physical edge -- typically in the MIDDLE of that + # very ATR. By then the hunt is already armed with + # the correct default rate (clock_skip=372, + # bit_samples=None): either by the RST assert handler + # (warm reset, edge observed) or by the "RST low at + # start" cold-boot path. Re-arming here would clear + # the in-flight ATR's timing and garble it, so only + # re-arm if we are still sitting in DATA with nothing + # armed (the one case the assert handler/cold-boot + # path never covered: a deassert without a preceding + # observed assert or hunt). + if self.state == 'DATA': + self.state = 'FIND START' + self._atr_hunt_count = 0 + self._pps_attempted = False + self.bit_samples = None + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + self.clock_skip = 372 + self._inverse_convention = False + self._clock_skip_confident = \ + not (self.sample_as_clock or self.detect_clock) + self.log("RST deasserted with no hunt armed; re-arming ATR hunt") + else: + self.log("RST deasserted (hunt/ATR already in progress)") + if (self.track_vcc): + lvl = pins[self.VCC_IDX] + emit_lvl, edge_sn, self.last_vcc, self.vcc_cand, self.vcc_cand_sn = \ + self._line_hyst(lvl, self.last_vcc, self.vcc_cand, self.vcc_cand_sn) + if emit_lvl is not None: + direction = emit_lvl # 1=power applied, 0=power removed + self.emit_packet(GSMTAP_SIM_VCC_EVENT, + bytes([direction, emit_lvl, 0]), edge_sn, edge_sn) + text = ('VCC on' if emit_lvl == 1 else 'VCC off') + self.put(edge_sn, edge_sn, self.out_ann, [Ann.ANN_VCC, [text]]) + self.log(text) + fired = True + return fired + + def pcap_global_header(self): + # See https://wiki.wireshark.org/Development/LibpcapFileFormat. + h = b'\xd4\xc3\xb2\xa1' # Magic, indicate microsecond ts resolution + h += b'\x02\x00' # Major version 2 + h += b'\x04\x00' # Minor version 4 + h += b'\x00\x00\x00\x00' # Correction vs. UTC, seconds + h += b'\x00\x00\x00\x00' # Timestamp accuracy + h += b'\xff\xff\x00\x00' # Max packet len + h += b'\x01\x00\x00\x00' # Link layer + return h + + def get_bytes(self, bits): + byte = 0 + for bit in list(reversed(bits)): + byte = (byte << 1) | bit + return byte + + def write_pcap_header(self): + if not self.wrote_pcap_header: + self.put(0, 0, self.out_binary, [0, self.pcap_global_header()]) + self.wrote_pcap_header = True + + def ts_from_samplenum(self, sample): + x = ctypes.c_uint32(sample).value; + ovrflow = ctypes.c_uint32(int(self.sampleOverflowCount * 0xFFFFFFFF * self.secs_per_sample)).value + ts = float(x) * self.secs_per_sample + ovrflow + self._pcap_base_time + return (int(ts), int((ts % 1.0) * 1e6)) + + def signal_quality(self, err): + '''Feed one frame's parity result (1 = bad) into the rolling + monitor; warn once per degradation episode when >= 8 of the last + 64 frames failed parity.''' + self.parity_hist.append(err) + if len(self.parity_hist) < 32: + return + errs = sum(self.parity_hist) + if errs >= 8 and not self.sig_warned: + self.sig_warned = True + self.log("SIGNAL INTEGRITY WARNING: {}/{} recent frames failed " + "parity (noise? RF burst? contact?)".format( + errs, len(self.parity_hist))) + try: + wss = max(self.ss, 0) + wes = max(self.es, wss + 1) + self.put(wss, wes, self.out_ann, + [Ann.ANN_WARN, ["noisy signal"]]) + except Exception: + pass + elif errs <= 2: + self.sig_warned = False + + def read_first_byte(self): + '''Find the first start-bit falling edge and read the first byte with + the standard re-anchored reader. (Mid-session ETU recovery is done by + _measure_etu, called once from the mid-session branch of decode_step + before the normal T=0/T=1 framing state machine takes over.)''' + self.wait_data_falling() + return self.read_byte_no_wait() + + def _measure_clock_period(self): + '''Measure the period of the native CLK signal in samples. + + For native CLK mode, the actual bit period is + clock_skip * samples_per_clock. Measuring it directly removes the + hard-coded 3× approximation and gives the correct ETU for the ATR and + for any post-PPS speed. Returns True if a stable period was found. + + Collects multiple CLK rising-edge spacings and picks the smallest + recurring one (like _robust_min) so a single glitch does not skew the + result.''' + if self.sample_as_clock or self.detect_clock: + return False + spacings = [] + last_sn = None + try: + # Collect up to 16 consecutive CLK rising-edge spacings. + for _ in range(16): + pins = self.wait({self.CLK_IDX: 'r'}) + if pins is None: + return False + sn = self.samplenum + if last_sn is not None: + spacings.append(sn - last_sn) + last_sn = sn + if len(spacings) < 3: + return False + # Use average period instead of robust_min to handle varying CLK periods + # (e.g., 4,3,3 alternating pattern). + period = sum(spacings) / len(spacings) + if 2 <= period <= 100000: + self._samples_per_clock = period + self.log("clock period (samples):", period) + return True + except Exception: + pass + return False + + def _compute_bit_samples(self): + '''Return the best available bit-period estimate in samples. + + Uses the measured clock period if available; otherwise falls back to + the legacy clock_skip * 3 estimate.''' + if self._samples_per_clock is not None: + return int(self.clock_skip * self._samples_per_clock) + return int(self.clock_skip * 3) + + def _derive_clock_skip_from_etu(self): + '''Derive clock_skip (CLK edges per ETU) from the recovered ETU. + + For a mid-session capture (no ATR) the card may already be PPS'd to a + fast ETU, so the default clock_skip=372 is wrong. Once _measure_etu + has recovered bit_samples (samples per ETU) and _measure_clock_period + has recovered _samples_per_clock (samples per CLK cycle), clock_skip + = bit_samples / _samples_per_clock. Sets _clock_skip_confident on + success so the CLK-sync reader is used; leaves it clear (edge-list + fallback) if the CLK period could not be measured.''' + if self._samples_per_clock and self.bit_samples: + cs = int(round(float(self.bit_samples) / float(self._samples_per_clock))) + if cs >= 1: + if cs != self.clock_skip: + self.log("clock_skip recovered from ETU:", cs, + "(bit_samples", self.bit_samples, + "/ spc", self._samples_per_clock, ")") + self.clock_skip = cs + self._clock_skip_confident = True + return True + return False + + def _measure_etu(self): + '''Recover the live bit period (ETU, in samples) from the DATA line for + a mid-session capture that has no ATR to derive timing from. + + Jumps to each DATA transition (O(edges), immune to Clock Stop Mode) + and collects edge spacings until an idle gap ends the burst. The + shortest *recurring* spacing equals one etu, so this recovers fast / + non-standard ETUs (e.g. a phone SIM already PPS'd to a short etu) that + the spec-default 372-clock etu would mis-read by ~20x. + + The result is **locked after 3 successful decodes**: bit_samples is + set after measurement, then validated by decoding subsequent bytes. + If 2 consecutive parity errors occur before validation, the + measurement is discarded and re-measured from the next burst. + Consumes one burst for the measurement; the normal framing resumes + on the next start bit (the first exchange may therefore be + mis-framed -- expected for a mid-session capture).''' + if self.bit_samples is not None and self._etu_confirm_count >= 3: + return + if self._start_fall is None: + self.wait_data_falling() + if self._eof or self._start_fall is None: + return + e0 = self._start_fall + self._start_fall = None # consumed; framing re-waits on its own + self.ss = e0 + idle_limit = int((self.bit_samples or + self._compute_bit_samples()) * 20) + if idle_limit < 3000: + idle_limit = 3000 + edges = [(e0, 0)] + last_sn = e0 + while True: + pins = self.wait({self.DATA_IDX: 'e'}) + if pins is None: + break + sn = self.samplenum + if sn <= last_sn: + break # no progress (end of capture) -> stop scan + last_sn = sn + d = pins[self.DATA_IDX] + if (sn - edges[-1][0]) > idle_limit: + # Big gap => burst ended; leave this edge for the framing. + break + edges.append((sn, d)) + if len(edges) >= 2: + spacings = [edges[i + 1][0] - edges[i][0] + for i in range(len(edges) - 1)] + # Filter glitches (< MIN_ETU) then check if the first spacing + # is an outlier (scan started in a noisy pre-burst region). + MIN_ETU = 12 + valid = [s for s in spacings if s >= MIN_ETU] + if len(valid) >= 2: + med = sorted(valid)[len(valid) // 2] + # If the first valid spacing is >10x the median, the scan + # started at a burst boundary — drop leading outliers. + while valid and valid[0] > med * 10: + valid.pop(0) + spacings = valid if valid else spacings + etu = self._robust_min(spacings) + # Sanity clamp: a real ISO 7816 etu is >= a few samples and below + # ~100k samples even at extreme F/D. Anything else is a glitch. + if 4 <= etu <= 100000: + self.bit_samples = etu + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + if self.bit_samples is None: + self.bit_samples = self._compute_bit_samples() + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + # The live line is gated/stopped CLK; use the edge-list bit reader + # (immune to the drift a fixed sample-skip cadence would suffer) for + # every byte from here on. + self._edge_read = True + self.log("etu recovered (samples):", self.bit_samples) + + def _lock_etu_from_edges(self, min_spacings=6): + '''Lock the ETU from the most recent DATA edge spacings. + + Used after the first few bytes of an ATR (which provide a clean, + contiguous burst at the default ETU) to derive the actual bit period + so the rest of the session is decoded at the correct speed.''' + if len(self._edge_spacings) < min_spacings: + return + spacings = list(self._edge_spacings) + etu = self._robust_min(spacings) + if 4 <= etu <= 100000: + self.bit_samples = etu + self._etu_confirm_count = 3 # lock immediately + self._etu_fail_count = 0 + self.log("ETU locked from ATR edges:", etu) + + def _confirm_etu(self, parity_ok): + '''Validate ETU measurement after _measure_etu. Call after each byte + decode in _read_byte_edges. After 3 successful decodes the + measurement is locked. If 2 consecutive parity errors occur before + validation, the measurement is discarded and re-measured from the + next burst.''' + if self._etu_confirm_count >= 3: + return + if parity_ok: + self._etu_confirm_count += 1 + if self._etu_confirm_count >= 3: + self.log("ETU confirmed:", self.bit_samples) + else: + self._etu_fail_count += 1 + if self._etu_fail_count >= 2: + self.log("ETU re-measure (bad initial measurement):", + self.bit_samples) + self.bit_samples = None + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + + def _robust_min(self, spacings): + '''Smallest spacing that recurs (>=3x): the true etu. Two + consecutive differing bits produce a 1-etu gap, so a real bit period + recurs often; a glitch is unique. Falls back to the raw minimum. + + Spacings below MIN_ETU (12 samples at 16 MHz) are rejected as + metastability glitches — they are too short for any valid ISO 7816 + bit period and would otherwise be picked as the "minimum recurring" + spacing by the counter.''' + MIN_ETU = 12 + from collections import Counter + c = Counter(s for s in spacings if s >= MIN_ETU) + for v in sorted(c): + if c[v] >= 3: + return v + # Fallback: accept any spacing >= MIN_ETU, or raw min if nothing qualifies. + valid = [s for s in spacings if s >= MIN_ETU] + return min(valid) if valid else min(spacings) + + def _deglitch_edges(self, edges, bs): + '''Remove pulses shorter than a fraction of an ETU from an edge list. + + Phone traces (especially Samsung at 16 CLK/ETU) contain 1-sample + (62.5 ns) LOW glitches in inter-byte guard times. These are far + shorter than the shortest real ISO 7816 pulse (about 1 ETU) and + would otherwise be accepted as false start-bit falls. + + The filter uses an iterative narrowest-first strategy: at each step + it finds the shortest pulse, and if it is shorter than 0.2 * bs it + removes the edge that *ends* the pulse. Removing the end edge lets + the previous (real) edge's value persist, re-merging the surrounding + fragments back into the original ETU-multiple pulse. The first edge + (the start-bit fall passed to _read_byte_edges) is never removed. + + Returns a new edge list; the input list is not modified.''' + if not edges: + return edges + threshold = max(1, int(0.2 * bs)) + # Work with a mutable copy. Each item is [sample, value]. + out = [[s, v] for (s, v) in edges] + changed = True + while changed: + changed = False + # Find the shortest pulse (distance between consecutive edges). + min_width = None + min_idx = None + n = len(out) + for i in range(n - 1): + width = out[i + 1][0] - out[i][0] + if width < threshold and (min_width is None + or width < min_width): + min_width = width + min_idx = i + if min_idx is not None: + # Remove the edge that ends the short pulse. This keeps the + # preceding edge (the real transition) and lets its value + # persist until the next edge. If the short pulse starts at + # the first edge, keep the first edge and remove the second. + del out[min_idx + 1] + changed = True + return [(s, v) for [s, v] in out] + + def _bits_at(self, e, edges, bs): + '''Reconstruct the 10 bits (start + 8 data + parity) of the byte whose + start-bit fall is at sample e, from the DATA edge list. Returns None + only if `e` is not a genuine start (no following edge).''' + bits = [] + for k in range(10): + m = e + round((k + 0.5) * bs) + val = 1 + for (s, v) in edges: + if s <= m: + val = v + else: + break + # Inverse convention (TS=0x3F): logic 1 = LOW, logic 0 = HIGH. + if self._inverse_convention: + val = 1 - val + bits.append(val) + return bits + + def _next_start_fall(self, edges, after, bs): + '''First GENUINE byte-start FALL (1->0) at or after sample `after`, + = the next byte start. + + A real start bit is LOW for a full ETU. A sub-ETU fall inside the + stop-bit / inter-byte guard time is a phantom (line bounce) and must + not be treated as the next byte's start -- latching onto it shifts + the whole frame. A fall is accepted only if the line stays LOW for + at least half an ETU after it (a genuine start bit's low hold is + ~1 ETU; the first data-bit transition arrives no sooner).''' + thresh = int(0.5 * bs) + for i in range(len(edges)): + s, v = edges[i] + if s < after or v != 0: + continue + nxt = edges[i + 1][0] if i + 1 < len(edges) else s + thresh + 1 + if (nxt - s) < thresh: + continue + return s + return None + + def _wait_clk_rising(self, n): + '''Wait for n CLK rising edges. Returns False on end-of-capture. + + CLK runs continuously for the whole of a character (Clock Stop only + happens between characters), so within a byte it is safe to skip most + of the way by samples and then land on the exact rising edge. This + keeps the edge-exactness of CLK-edge timing without paying one wait() + per CLK cycle. Lands within +/-1 CLK cycle of the target edge, which + is far inside the stable region of a bit (a bit is held for the whole + ETU).''' + if n <= 0: + return True + if self._samples_per_clock and n > 2: + # Skip (n - 2) cycles by samples, then take the last 2 edges + # exactly. Undershooting by a cycle is harmless (the bit is + # stable for the full ETU), and the final two edge waits re-align + # us to a genuine CLK edge. + skip = int((n - 2) * self._samples_per_clock) + if skip > 0: + self.wait({'skip': skip}) + n = 2 + for _ in range(n): + pins = self.wait({self.CLK_IDX: 'r'}) + if pins is None: + self._eof = True + return False + return True + + def _read_byte_clk(self): + '''Decode a single byte using CLK-edge counting for bit timing. + + Counts clock_skip CLK rising edges per ETU and samples DATA at the bit + centers (1.5, 2.5, ..., 9.5 ETU from the start-bit fall). Because the + bit value is held stable for the whole ETU, sampling near the centre + is exact for ANY byte -- including 0x00 / 0xff, which have too few DATA + transitions for the edge-list reader to anchor reliably. + + Safe across Clock Stop Mode: CLK is never stopped mid-character, and + it always resumes a few cycles before the next start bit (the guard + time), so once wait_data_falling() has found a start bit, CLK is + running for the whole frame. clock_skip is protocol-defined (372 for + the ATR, FI/DI after PPS), so no runtime ETU measurement is needed on + the native path -- this is what makes decoding deterministic.''' + if self._start_fall is None: + self.wait_data_falling() + if self._eof: + return 0x00 + e0 = self._start_fall + self._start_fall = None + self.ss = e0 + bs = self.clock_skip + self.bits = [0] # start bit (always low) + prev_edges = 0 + for i in range(9): + # bit centres at (1.5 + i) ETU from the start fall + total = int(round((1.5 + i) * bs)) + if not self._wait_clk_rising(total - prev_edges): + # End of capture mid-byte: pad so the frame stays 10 bits. + while len(self.bits) < 10: + self.bits.append(self.bits[-1] if self.bits else 1) + break + prev_edges = total + pins = self.wait({'skip': 0}) + if pins is None: + self._eof = True + while len(self.bits) < 10: + self.bits.append(self.bits[-1] if self.bits else 1) + break + bit = pins[self.DATA_IDX] + # Inverse convention (TS=0x3F): logic 1 = LOW, logic 0 = HIGH. + if self._inverse_convention: + bit = 1 - bit + self.bits.append(bit) + self.es = self.samplenum + self.signal_quality(1 if (self.bits.count(1) % 2 != 0) else 0) + parity_ok = (self.bits.count(1) % 2 == 0) + if (self.bits.count(1) % 2 != 0): + self.log(self.samplenum, "CHKSUM ERROR: ", 0, "bits: ", self.bits) + self.put(self.ss, self.samplenum, self.out_ann, + [0, ["CHKSUM ERROR bits={bits}".format(bits=self.bits)]]) + if self.bit_samples is not None: + self._confirm_etu(parity_ok) + byte = self.get_bytes(self.bits[1:9]) + # Stuck-line watchdog (same as the other readers). + if (sum(self.bits) == 0): + self.zero_run += 1 + if (self.zero_run == 32): + self.log("I/O line reads all-zero for", self.zero_run, + "frames - stuck low? check pogo contact") + try: + self.put(self.ss, self.samplenum, self.out_ann, + [Ann.ANN_WARN, ["I/O stuck low?"]]) + except Exception: + pass + else: + self.zero_run = 0 + self.log(self.samplenum, self.bits[1:9], " : ", "0x{:02x}".format(byte)) + self.put(self.ss, self.samplenum, self.out_ann, [1, [hex(byte)]]) + return byte + + def _read_byte_edges(self, bs): + '''Decode a single byte from the DATA line using edge-list + reconstruction (robust to Clock Stop Mode / gated CLK, where fixed + sample-skip timing drifts). Returns the byte, having set self.ss/es + and queued the next start-bit position in self._start_fall. + + Jumps straight to DATA edges around the current start fall (O(edges), + not O(samples)), collects ~11 etu of edges (one byte plus the gap to + the next start bit), reconstructs the 10 bits from their actual + positions, and leaves self._start_fall at the next byte so subsequent + reads stay re-anchored (no cross-byte drift). A large turn-around + idle (CLK stopped between command and response) simply leaves + _start_fall unset here, and the next read re-hunts via + wait_data_falling().''' + # Safety: if we were called without a queued start bit, hunt for one. + if self._start_fall is None: + self.wait_data_falling() + if self._eof: + return 0x00 + e0 = self._start_fall + self._start_fall = None + self.ss = e0 + # Collect DATA edges for this byte + the inter-byte gap. + # Use a 15 ETU window so a 2 ETU turnaround idle (observed on + # Samsung phone at 16 CLK/bit) still leaves the next start-bit fall + # inside the collected edge list. + edges = [(e0, 0)] + limit = e0 + int(15 * bs) + last_sn = e0 + while True: + pins = self.wait({self.DATA_IDX: 'e'}) + if pins is None: + break + sn = self.samplenum + if sn <= last_sn: + break + last_sn = sn + edges.append((sn, pins[self.DATA_IDX])) + if sn > limit: + break + # Remove sub-ETU glitch pulses (common on phone SIMs at fast ETUs + # like 16 CLK/bit). Without this the edge reader may latch onto a + # 1-sample bounce in an inter-byte guard time and read the wrong + # byte, desynchronising the whole T=0 frame. + edges = self._deglitch_edges(edges, bs) + bits = self._bits_at(e0, edges, bs) + # Keep the spacings for ETU locking; the first few bytes of an ATR + # provide the most reliable reference for the actual bit period. + if len(edges) >= 2: + for i in range(1, len(edges)): + self._edge_spacings.append(edges[i][0] - edges[i - 1][0]) + nxt = None + if bits is not None and bits[0] == 0: + self.bits = bits + byte = self.get_bytes(bits[1:9]) + nxt = self._next_start_fall(edges, e0 + int(10 * bs), bs) + else: + # bits[0] is the start bit; it must be 0 after _bits_at() because + # _bits_at() already inverts the value for inverse convention. + # bits[0] != 0 means we latched onto a spurious edge (phantom). + # Do NOT drop the byte: dropping replaces a real byte with a fake + # 0x00 / shifts the exchange boundary, which is exactly how a + # whole exchange gets swallowed (CONCAT). Instead slide forward + # to the next genuine start-bit fall inside this same edge window + # and re-anchor there. + for (s, v) in edges: + if s <= e0 or v != 0: + continue + cand = self._bits_at(s, edges, bs) + if cand is not None and cand[0] == 0: + e0 = s + bits = cand + self.bits = bits + byte = self.get_bytes(bits[1:9]) + nxt = self._next_start_fall(edges, e0 + int(10 * bs), bs) + break + else: + # No genuine start in the window (EOF / pathological line): + # keep the legacy drop; the next read re-hunts. + self.bits = [] + byte = 0 + self.ss = e0 + if nxt is not None: + self._start_fall = nxt + self.es = self.samplenum + self.signal_quality(1 if (self.bits.count(1) % 2 != 0) else 0) + parity_ok = (self.bits.count(1) % 2 == 0) + if (self.bits.count(1) % 2 != 0): + self.log(self.samplenum, "CHKSUM ERROR: ", 0, "bits: ", self.bits) + self.put(self.ss, self.samplenum, self.out_ann, + [0, ["CHKSUM ERROR bits={bits}".format(bits=self.bits)]]) + if self.bit_samples is not None: + self._confirm_etu(parity_ok) + if (sum(self.bits) == 0): + self.zero_run += 1 + if (self.zero_run == 32): + self.log("I/O line reads all-zero for", self.zero_run, + "frames - stuck low? check pogo contact") + try: + self.put(self.ss, self.samplenum, self.out_ann, + [Ann.ANN_WARN, ["I/O stuck low?"]]) + except Exception: + pass + else: + self.zero_run = 0 + self.log(self.samplenum, self.bits[1:9], " : ", "0x{:02x}".format(byte)) + self.put(self.ss, self.samplenum, self.out_ann, [1, [hex(byte)]]) + return byte + + def wait_data_falling(self): + '''Wait for the I/O start-bit falling edge, servicing optional + RST/VCC line events while hunting. + + The bit period is measured directly from the DATA line in SAMPLES + (not by counting CLK edges), so a frozen CLK during Clock Stop Mode + cannot desync the decoder. A start bit is accepted only after DATA + has been HIGH for >= 1.5 bit periods (inter-character guard, rejects + intra-byte edges / idle glitches).''' + min_high = 4 + if self.bit_samples: + if self._edge_read: + # DATA must be HIGH (idle) for at least half an etu before a + # fall is accepted as a start bit. This rejects intra-byte + # glitch falls yet still accepts the legitimate inter-byte gap + # (the ~1-etu stop bit), which a 1.5-etu guard would wrongly + # reject on fast ETUs (e.g. a phone SIM at ~53 samples/etu), + # causing bytes to be skipped / 0x00 inserted. + min_high = max(int(0.5 * self.bit_samples), 4) + else: + min_high = max(int(1.5 * self.bit_samples), 4) + while True: + # Wait for DATA idle, servicing RST/VCC. + # In direct convention: idle = HIGH, start bit = falling edge (HIGH→LOW). + # In inverse convention: idle = LOW, start bit = rising edge (LOW→HIGH). + if self._inverse_convention: + idle_cond = {self.DATA_IDX: 'l'} # Wait for LOW (idle in inverse) + start_edge = 'r' # Rising edge = start bit in inverse + else: + idle_cond = {self.DATA_IDX: 'h'} # Wait for HIGH (idle in direct) + start_edge = 'f' # Falling edge = start bit in direct + cond = [idle_cond] + if (self.track_rst): + cond.append({self.RST_IDX: 'e'}) + if (self.track_vcc): + cond.append({self.VCC_IDX: 'e'}) + while True: + pins = self.wait(cond) + if self._stall_check(pins): + self._eof = True + return + if (self.line_events(pins)): + continue + break + hi_start = self.samplenum + # Wait for the start-bit edge; re-anchor the high-start across + # any line events so the measured guard stays accurate. + cond2 = [{self.DATA_IDX: start_edge}] + if (self.track_rst): + cond2.append({self.RST_IDX: 'e'}) + if (self.track_vcc): + cond2.append({self.VCC_IDX: 'e'}) + while True: + pins = self.wait(cond2) + if self._stall_check(pins): + self._eof = True + return + if (self.line_events(pins)): + hi_start = self.samplenum + continue + break + if (self.samplenum - hi_start) < min_high: + continue + if self._min_pulse_samples > 0: + pins = self.wait({'skip': self._min_pulse_samples}) + if pins is not None: + bit = pins[self.DATA_IDX] + idle_val = 1 if not self._inverse_convention else 0 + if bit == idle_val: + self._glitch_rejects += 1 + self.log("start-bit glitch rejected (#%d)" + % self._glitch_rejects) + continue + self._start_fall = self.samplenum + return + + def _stall_check(self, pins): + '''Detect end-of-capture: libsigrokdecode stops advancing the sample + counter once the feed is exhausted, so a wait that returns the same + sample number repeatedly means there is no more data.''' + if pins is None: + return True + if self._prev_wait_sn is not None and self.samplenum == self._prev_wait_sn: + self._stall += 1 + else: + self._stall = 0 + self._prev_wait_sn = self.samplenum + return self._stall > 4 + + def read_byte(self): + if self._replay: + return self._replay.popleft() + if (self.peeked_byte != None): + byte = self.peeked_byte + self.peeked_byte = None + self.peeked_samplenum = -1 + return byte; + # In edge-read (mid-session, gated-CLK) mode the next start bit is + # already queued by _read_byte_edges, so re-hunting would wrongly + # reject the short inter-byte gap. Otherwise re-hunt normally. + if not (self._edge_read and self._start_fall is not None): + self.wait_data_falling() + if self._eof: + return None + return self.read_byte_no_wait() + + def peek_byte(self): + if self._replay: + return self._replay[0] + if (self.peeked_byte != None): + return self.peeked_byte + if not (self._edge_read and self._start_fall is not None): + self.wait_data_falling() + if self._eof: + return None + self.peeked_samplenum = self._start_fall + self.peeked_byte = self.read_byte_no_wait() + return self.peeked_byte + + def read_byte_no_wait(self): + self.bits = [] + if (self.sample_as_clock): + # Experimental mode: DATA doubles as the clock. Keep the + # original sample-as-clock reader (bits sampled on the DATA + # edge, with no separate timing measurement). + self.ss = self.samplenum + self.clock_skip = 0 + pins = self.wait({1: 'r'}) + self.clock_skip = self.samplenum - self.ss + 2 + self.detected_clock_skip = self.clock_skip + self.wait({'skip': int(self.clock_skip / 3)}) + self.bits.append(0) + for x in range(9): + pins = self.wait({'skip': 0}) + bit = pins[1] + # Inverse convention (TS=0x3F): logic 1 = LOW, logic 0 = HIGH. + if self._inverse_convention: + bit = 1 - bit + self.bits.append(bit) + self.wait({'skip': self.clock_skip - 4}) + self.es = self.samplenum + self.signal_quality(1 if (self.bits.count(1) % 2 != 0) else 0) + if (self.bits.count(1) % 2 != 0): + self.log(self.samplenum, "CHKSUM ERROR: ", pins[1], "bits: ", self.bits) + self.put(self.ss, self.samplenum, self.out_ann, [0, ["CHKSUM ERROR bits={bits}".format(bits=self.bits)]]) + byte = self.get_bytes(self.bits[1:9]) + if (sum(self.bits) == 0): + self.zero_run += 1 + if (self.zero_run == 32): + self.log("I/O line reads all-zero for", self.zero_run, + "frames - stuck low? check pogo contact") + try: + self.put(self.ss, self.samplenum, self.out_ann, + [Ann.ANN_WARN, ["I/O stuck low?"]]) + except Exception: + pass + else: + self.zero_run = 0 + self.log(self.samplenum, self.bits[1:9], " : ", "0x{:02x}".format(byte)) + self.put(self.ss, self.samplenum, self.out_ann, [1, [hex(byte)]]) + return byte + # Normal path. Two bit readers are available: + # - CLK-synchronous (_read_byte_clk): exact CLK-edge timing, used in + # native mode where a real CLK channel is present. Handles 0x00 / + # 0xff perfectly and is deterministic. + # - Edge-list (_read_byte_edges): DATA-edge reconstruction, used for + # the non-native clock modes (sample_as_clock / detect) and as a + # fallback when clock_skip has not been established yet. + # Callers (read_byte / peek_byte / read_first_byte) ensure + # _start_fall is set before calling here. + if self._use_clk_sync(): + return self._read_byte_clk() + return self._read_byte_edges(self.bit_samples or self._compute_bit_samples()) + + def _use_clk_sync(self): + '''True when the CLK-synchronous reader should be used. + + Only in native clock mode (a real CLK channel) AND only when + clock_skip is known to be correct (_clock_skip_confident). In + sample_as_clock / detect modes there is no usable CLK signal, and on + a mid-session capture clock_skip is unknown until recovered -- in + both cases the edge-list DATA reader (driven by bit_samples) is the + safe fallback.''' + return (not (self.sample_as_clock or self.detect_clock) + and self._clock_skip_confident) + + + def resync_idle(self): + '''Re-align framing after a desync: wait for DATA to go to idle level + so the next peek lands on a genuine start bit of a fresh exchange + instead of a mid-burst byte. Gating-immune: we poll DATA in + samples (which advance even when CLK is frozen), and bound the hunt + by a sample budget so a pathologically-busy (or stuck) line never + stalls the decoder.''' + bs = self.bit_samples or self._compute_bit_samples() + budget = int(IDLE_RESYNC_ETU * bs) * 8 + step = max(int(bs), 1) + loops = budget // step + 16 + # In direct convention: idle = HIGH (mark). + # In inverse convention: idle = LOW (mark is inverted). + idle_level = 0 if self._inverse_convention else 1 + for _ in range(loops): + pins = self.wait({'skip': 0}) + if pins[self.DATA_IDX] == idle_level: + return + # Could not find an idle gap -- resume normal decode anyway. + + def handle_atr(self, pins): + + atr_start = self.samplenum; + + self.log("START ATR:", self.samplenum); + self.state = 'ATR' + + tA = [] + tB = [] + tC = [] + tD = [] + historicalBytes = [] + self.ATR = [] + # Clear any stale edge spacings; the first few ATR bytes give us the + # most reliable ETU reference for the whole session. + self._edge_spacings.clear() + # Try to lock the ETU from the actual CLK frequency before reading the + # first ATR byte. This removes the hard-coded clock_skip*3 estimate. + if self._samples_per_clock is None: + self._measure_clock_period() + # Ensure the sample-domain ETU is available for framing + # (wait_data_falling / resync_idle) even though the CLK-sync reader + # itself only needs clock_skip. + if self.bit_samples is None: + self.bit_samples = self._compute_bit_samples() + + if (self.peeked_byte != None): + atr_start = self.peeked_samplenum + byte = self.read_byte() + else: + byte = self.read_first_byte() + atr_start = self.ss + # TS must be 0x3B (direct) or 0x3F (inverse) per TS 102 221 §5.2.2. + # Pre-activation traffic (reader card-detect polling, a stray PPS + # `ff 10 95 7a`, power-up noise) can appear on DATA before RST even + # deasserts. Resync past those instead of mis-parsing them as the + # ATR and permanently desyncing the session. + ts_attempts = 0 + while (byte not in (0x3b, 0x3f) and ts_attempts < 64): + # Command-detect: a plausible CLA immediately followed by a + # plausible INS is the start of a command, not an ATR. Hand the + # header back to the DATA state for normal T=0 framing instead of + # silently skipping it as an "invalid TS" byte (this is how a + # SELECT was being lost while the decoder hunted for an ATR that + # was not coming). The full 5-byte header is still validated in + # the DATA state; ambiguous frames are emitted flagged, not dropped. + # + # CLA=0x00 is excluded: it's technically valid for interindustry + # commands, but during ATR hunt it's overwhelmingly noise (the + # real SELECT that was lost used CLA=0x00 and appeared *after* the + # ATR, not during the hunt). Including it causes false positives + # on noise like the 0x00 0x1E seen in the live test. + if plausible_cla(byte) and byte != 0x00: + nxt = self.peek_byte() + if plausible_ins(nxt): + self._replay.appendleft(byte) + self.hasT0 = True + self.hasT1 = False + self.state = 'DATA' + self.log("Command header 0x{cla:02x} 0x{ins:02x} during ATR hunt; decoding as command".format(cla=byte, ins=nxt)) + return + self.log("Invalid TS byte 0x{byte:02x} (pre-activation?), resyncing".format(byte=byte)) + self.put(atr_start, self.samplenum, self.out_ann, + [Ann.ANN_WARN, ["Invalid TS byte 0x{byte:02x} - skipped".format(byte=byte)]]) + byte = self.read_byte() + atr_start = self.ss + ts_attempts += 1 + if (byte not in (0x3b, 0x3f)): + self._atr_hunt_count += 1 + # No valid ATR found this round -- go back to hunting for the + # next falling edge. Never fabricate a synthetic ATR: a stray + # 0x3B/0x3F byte inside command traffic (e.g. the FID '3F00' of a + # SELECT) would otherwise be mis-committed as a fake 2-byte ATR + # and destroy the surrounding command. The hunt is bounded by + # _atr_hunt_count only to avoid logging forever on a dead line. + if (self._atr_hunt_count < 8): + self.log("No valid TS byte, back to FIND START (hunt {count})".format(count=self._atr_hunt_count)) + self.state = 'FIND START' + return + if byte == 0xFF: + self.log("line appears idle (all 0xFF), resetting ATR hunt") + self._atr_hunt_count = 0 + self.state = 'FIND START' + return + # Give up hunting for an ATR here without emitting anything; + # resume T=0 framing from the next byte instead of inventing one. + self.log("No valid ATR after {count} hunts; resuming DATA without synthetic ATR".format(count=self._atr_hunt_count)) + self.hasT0 = True + self.hasT1 = False + self.state = 'DATA' + self._resync = True + if (self.options['protocol'] == "T=0"): + self.hasT0 = True + self.hasT1 = False + elif (self.options['protocol'] == "T=1"): + self.hasT0 = False + self.hasT1 = True + return + else: + self._atr_hunt_count = 0 + self.ATR.append(byte) + # TS=0x3F means inverse convention: logic 1 = LOW, logic 0 = HIGH. + # All subsequent byte reads must invert bits. + self._inverse_convention = (byte == 0x3f) + self.log("TS=0x{ts:02x} ({conv} convention)".format( + ts=byte, conv="inverse" if self._inverse_convention else "direct")) + + t0 = self.read_byte() + self.ATR.append(t0) + # Lock ETU from the first two ATR bytes (TS + T0). The ATR is sent + # at the default ETU, so its edge spacings are the authoritative + # timing reference for the rest of the session (until PPS changes it). + # Temporarily disabled: measurement is unstable on some captures. + # if self.bit_samples is None: + # self._lock_etu_from_edges(min_spacings=6) + + firstT0 = t0 + + self.hasT0 = False + self.hasT1 = False + self.hasT15 = False + while (firstT0 & 0b11110000): + # Defense-in-depth: a valid ISO 7816 ATR is at most 33 bytes. + # If the parse runs past that (e.g. a noise byte slipped in as + # TS and the TD chain never terminates), abandon the parse and + # hunt again rather than commit a giant bogus ATR. + if (len(self.ATR) > 33): + self.log("ATR exceeds 33 bytes (noise-induced?), abandoning parse") + self._atr_hunt_count += 1 + self.state = 'FIND START' + return + if (firstT0 & 0b00010000): + byte = self.read_byte() + tA.append(byte) + self.ATR.append(byte) + if (firstT0 & 0b00100000): + byte = self.read_byte() + tB.append(byte) + self.ATR.append(byte) + if (firstT0 & 0b01000000): + byte = self.read_byte() + tC.append(byte) + self.ATR.append(byte) + if (firstT0 & 0b10000000): + byte = self.read_byte() + if ((byte & 0x0F) == 0): + self.hasT0 = True + elif ((byte & 0x0F) == 1): + self.hasT1 = True + elif ((byte & 0x0F) == 15): + self.hasT15 = True + else: + self.log("Invalid Protocol in ATR: ", "T=",(byte & 0x0F)) + self.put(atr_start, self.samplenum, self.out_ann, [0, ["Invalid Protocol in ATR T={protocol}".format(protocol=(byte & 0x0F))]]) + tD.append(byte) + self.ATR.append(byte) + firstT0 = byte + self.log("TD("+str(len(tD))+"): ", hex(firstT0), "T=",(byte & 0x0F)) + else: + firstT0 = 0; + + for _ in range(0, t0 & 0x0F): + byte = self.read_byte() + self.ATR.append(byte) + + if (self.hasT1 == False and self.hasT0 == False): + self.hasT0 = True + + if (self.hasT1 == True or self.hasT15): + byte = self.read_byte() + self.ATR.append(byte) + xor = 0 + for i in range(1, len(self.ATR)): xor = xor ^ self.ATR[i] + if (xor != 0): + self.put(atr_start, self.samplenum, self.out_ann, [0, ["Invalid TCK in ATR, got={tck:02x} expected={xor:02x}".format(tck=byte,xor=xor)]]) + self.log("Invalid TCK in ATR", hex(byte), hex(xor)) + + self.put(atr_start, self.samplenum, self.out_ann, [2, ["ATR", "ATR={atr}".format(atr=codecs.encode(bytes(self.ATR), 'hex'))]]) + self.put(atr_start, self.samplenum, self.out_python, [0, self.ATR]) + self.emit_packet(GSMTAP_SIM_ATR, bytes(self.ATR), atr_start, self.samplenum) + + self.log("ENDATR", codecs.encode(bytes(self.ATR), 'hex')) + self._atr_parsed = True + # A real ATR was parsed at the default rate, so clock_skip=372 is now + # confirmed correct -- the CLK-sync reader is trusted from here on. + self._clock_skip_confident = True + # The ATR is always sent at the default rate (372 CLK/bit). + # _measure_etu() may have inflated clock_skip via inter-byte gap + # averaging; reset to 372 so PPS (also at the default rate) is + # read at the correct bit boundary. + self.clock_skip = 372 + # Also lock bit_samples so the mid-session re-measure (line 1633) + # does not re-run and overwrite clock_skip=372 with a wrong value + # derived from pre-ATR edge spacings. _confirm_etu() may have + # cleared bit_samples during ATR hunt (parity failures on noise), + # which would trigger _measure_etu() on the next decode_step. + self.bit_samples = self.clock_skip * (self._samples_per_clock or 3) + # Lock the ETU so _confirm_etu() cannot clear bit_samples after + # parity failures on post-ATR noise bytes. Without this, two + # consecutive parity errors would trigger an ETU re-measure that + # overwrites clock_skip=372 with a wrong value from pre-ATR data. + self._etu_confirm_count = 3 + self._etu_fail_count = 0 + self.state = 'DATA' + self._resync = True # Wait for inter-frame idle gap before reading commands + # PPS may be attempted once after this ATR. + self._pps_attempted = False + + if (self.options['protocol'] == "T=0"): + self.hasT0 = True + self.hasT1 = False + elif (self.options['protocol'] == "T=1"): + self.hasT0 = False + self.hasT1 = True + + def t1_parse_block(self, es): + packet = [] + lrc = 0; + nad = self.read_byte() + lrc = lrc ^ nad + self.put(es, self.samplenum, self.out_ann, [1, "NAD:" + hex(nad)]) + sad = nad & 0x70 + dad = nad & 0x07 + self.log("T=1 NAD=", hex(nad), "SAD=", hex(sad), "DAD=", hex(dad)) + + pcb = self.read_byte() + lrc = lrc ^ pcb + + isSBlock = False + isRBlock = False + isIBlock = False + if (pcb & 0b11000000 == 0b11000000): + # S-Block + isSBlock = True + self.put(es, self.samplenum, self.out_ann, [3, "PCB S " + hex(pcb)]) + self.log("PCB S-Block", hex(pcb & 0b00111111)) + elif (pcb & 0b10000000 == 0b10000000): + # R-Block + isRBlock = True + self.put(es, self.samplenum, self.out_ann, [3, "PCB R " + hex(pcb)]) + self.log("PCB R-Block", hex(pcb & 0b00111111)) + else: + # I-Block + isIBlock = True + self.put(es, self.samplenum, self.out_ann, [3, "PCB I " + hex(pcb)]) + self.log("PCB I-Block", hex(pcb)) + + bLen = self.read_byte() + lrc = lrc ^ bLen + if (bLen > 0): + for _ in range(bLen): + byte = self.read_byte() + lrc = lrc ^ byte + if (isIBlock): packet.append(byte) + + # CRC to implement + bLrc = self.read_byte() + lrc = lrc ^ bLrc + + if (lrc != 0): + self.put(es, self.samplenum, self.out_ann, [0, ["Invalid checksum on T=1 block, , got={got:02x} expected={expected:02x}".format(got=lrc,expected=bLrc)]]) + self.log("Invalid checksum on T=1 block", hex(lrc), hex(bLrc)) + + self.log("block_content", codecs.encode(bytes(packet), 'hex')) + + if (isIBlock): + self.put(es, self.samplenum, self.out_ann, [6, ["I-Block", "I-Block len={len} isMultiBlock={multi}".format(len=bLen,multi=(pcb & 0b00100000 > 0))]]) + if (isRBlock): + self.put(es, self.samplenum, self.out_ann, [7, ["R-Block", "R-Block flag={flag:02x}".format(flag=pcb & 0b00111111)]]) + if (isSBlock): + self.put(es, self.samplenum, self.out_ann, [8, ["S-Block", "S-Block flag={flag:02x}".format(flag=pcb & 0b00111111)]]) + + if (isIBlock and pcb & 0b00100000): # m-flag + self.log("T=1 Multiblock flag", hex(pcb)) + for _ in range(32): # bounded: garbage must not loop forever + isIBlock2,packet2 = self.t1_parse_block(self.samplenum) + if (isIBlock2): + packet = packet + packet2 + break + + + return isIBlock,packet; + + def handle_pps(self): + # Mark PPS attempted so idle 0xFF bytes after a failed/no-PPS + # scenario do not loop back into PPS detection indefinitely. + self._pps_attempted = True + lrc = 0 + ss = self.peeked_samplenum + pps = self.read_byte() + pps_req = [pps] + pps0 = self.read_byte() + pps_req.append(pps0) + # An idle I/O line idles HIGH (repeated 0xFF). A genuine PPS0 always + # has its reserved bits clear (bit7 and bits 0..3 == 0), so the value + # is one of 0x00/0x10/0x20/.../0x70. 0xFF (or any byte with bit7 or + # low nibble set) is not a PPS0 -- it is idle noise. Without this + # guard an idle line is mis-read as an endless stream of "PPS accepted" + # and real APDUs are never reached. + if (pps0 & 0x8F) != 0: + self.log("0xFF followed by 0x%02x is not a valid PPS0 - idle line, skipping" % pps0) + self._resync = True + return + pps1 = 0; pps2 = 0; pps3 = 0 + if (pps0 & 0b00010000): + pps1 = self.read_byte() + pps_req.append(pps1) + lrc = lrc ^ pps1 + if (pps0 & 0b00100000): + pps2 = self.read_byte() + pps_req.append(pps2) + lrc = lrc ^ pps2 + if (pps0 & 0b01000000): + pps3 = self.read_byte() + pps_req.append(pps3) + lrc = lrc ^ pps3 + pck = self.read_byte() + pps_req.append(pck) + lrc = lrc ^ pps ^ pps0 ^ pck + if (lrc != 0): + self.put(ss, self.samplenum, self.out_ann, [0, ["INVALID Checksum on PPS Request, got={got:02x} expected={expected:02x}".format(got=pck,expected=(lrc ^ pps ^ pps0))]]) + self.log("INVALID Checksum on PPS Request", hex(lrc)) + + r_lrc = 0 + r_pps = self.read_byte() + pps_rsp = [r_pps] + r_pps1 = 0; r_pps2 = 0; r_pps3 = 0 + if (r_pps != 0xFF): + self.put(ss, self.samplenum, self.out_ann, [0, ["PPS Request not confirmed"]]) + self.log("PPS Request not confirmed", r_pps) + r_pps0 = self.read_byte() + pps_rsp.append(r_pps0) + if (r_pps0 & 0b00010000): + r_pps1 = self.read_byte() + pps_rsp.append(r_pps1) + r_lrc = r_lrc ^ r_pps1 + if (r_pps0 & 0b00100000): + r_pps2 = self.read_byte() + pps_rsp.append(r_pps2) + r_lrc = r_lrc ^ r_pps2 + if (r_pps0 & 0b01000000): + r_pps3 = self.read_byte() + pps_rsp.append(r_pps3) + r_lrc = r_lrc ^ r_pps3 + r_pck = self.read_byte() + pps_rsp.append(r_pck) + r_lrc = r_lrc ^ r_pps ^ r_pps0 ^ r_pck + if (r_lrc != 0): + self.put(ss, self.samplenum, self.out_ann, [0, ["INVALID Checksum on PPS Response, got={got:02x} expected={expected:02x}".format(got=r_pck,expected=(r_lrc ^ r_pps ^ r_pps0))]]) + self.log("INVALID Checksum on PPS Response", hex(r_lrc)) + + if (pps0 == r_pps0 and pps1 == r_pps1 and pps2 == r_pps2 and pps3 == r_pps3): + if (self.detect_clock or self.sample_as_clock): + # .get() with spec defaults: unknown FI/DI codes from a + # corrupted PPS1 must not raise KeyError or divide by zero. + tmp_fi = self.clock_rate.get(int(pps1 >> 4), 372) + tmp_di = self.baud_rate.get(int(pps1 & 0x0F), 1) + if tmp_di == 0: + self.log("Corrupted PPS: DI=0 (invalid)") + return + tmp_clock_skip = int(tmp_fi / tmp_di) + self.log("Received PPS change: FI", tmp_fi, "DI", tmp_di, "clock_skip", tmp_clock_skip) + self.clock_skip = int(tmp_clock_skip * self.detected_clock_skip / 372) + self.fi = tmp_fi + self.di = tmp_di + self.log("PPS Success new settings (calculated): FI", self.fi, "DI", self.di, "clock_skip", self.clock_skip) + else: + # Native CLK mode: clock_skip counts real CLK edges per + # data bit. After an ACCEPTED PPS both sides switch the + # UART to F/D cycles per bit (e.g. 512/16 = 32 edges), + # so the decoder must follow the switch. Live capture + # proof: ATR+PPS decode cleanly at 372, everything after + # aliases into garbage if clock_skip stays at 372. + self.fi = self.clock_rate.get(int(pps1 >> 4), 372) + self.di = self.baud_rate.get(int(pps1 & 0x0F), 1) + self.clock_skip = max(int(self.fi // self.di), 1) + self.log("PPS accepted: FI", self.fi, "DI", self.di, "clock_skip", self.clock_skip) + # PPS changed the bit rate. How we follow it depends on the reader: + # + # - Native CLK-sync reader (_use_clk_sync): clock_skip is the ONLY + # timing input -- the CLK period is unchanged by PPS, so the new + # ETU is exactly clock_skip * samples_per_clock. No DATA + # re-measurement is needed (and none should happen: consuming a + # burst here would silently drop the first post-PPS command). + # Just refresh bit_samples for wait_data_falling / edge fallback. + # + # - Edge-list reader (non-native modes): there is no usable CLK + # reference, so re-measure the ETU from the live DATA line on + # the next decode cycle; that burst is emitted flagged, not + # silently consumed. + # PPS changed the bit rate. How we follow it depends on the mode: + # + # - Native CLK mode (real CLK channel): after an ACCEPTED PPS the + # card and terminal switch to FI/DI CLK cycles per bit, and the + # CLK period itself is unchanged. So clock_skip = FI/DI is + # exact -- no DATA re-measurement is needed (and none should + # happen: consuming a burst here would silently drop the first + # post-PPS command). Just refresh bit_samples for + # wait_data_falling / the edge-list fallback. + # + # - sample_as_clock / detect modes: there is no usable CLK + # reference, so re-measure the ETU from the live DATA line on + # the next decode cycle; that burst is emitted flagged, not + # silently consumed. + if not (self.sample_as_clock or self.detect_clock): + self.bit_samples = self._compute_bit_samples() + self._pps_speed_changed = False + # Re-hunt for the next start bit after the PPS response. + self._start_fall = None + else: + self._pps_speed_changed = True + self.bit_samples = None + self._start_fall = None + # clock_skip now reflects the negotiated FI/DI, so it is known to + # be correct regardless of which reader is active. + self._clock_skip_confident = True + else: + self.log("INVALID PPS. Request & Response not matching.", hex(r_lrc)) + self.put(ss, self.samplenum, self.out_ann, [0, ["INVALID PPS. Request & Response not matching"]]) + self.put(ss, self.samplenum, self.out_ann, [3, ["PPS", "PPS DI={di} FI={fi} clock_skip={clock_skip}".format(di=self.di,fi=self.fi,clock_skip=self.clock_skip)]]) + self.emit_packet(GSMTAP_SIM_PPS, bytes(pps_req + pps_rsp), ss, self.samplenum) + + + def decode_step(self): + '''Run one state-machine step. Returns False when decoding ends.''' + # End-of-capture detection: libsigrokdecode stops advancing the sample + # counter once the feed is exhausted, so a stuck sample number means + # we have consumed everything (and any pending wait is non-blocking). + if self.samplenum == self._last_sn or self._eof: + return False + self._last_sn = self.samplenum + # Prime the initial RST/VCC levels from the actual first sample (no + # event emission, no advancement). The reset() defaults are both 0, + # which is indistinguishable from a card that is genuinely in reset; + # a capture started during power-on (RST already LOW, the assertion + # edge in the past) would otherwise miss the assertion and try to + # measure the ETU while the card is inactive. With the true initial + # levels, RST-low-at-start means "expect a cold-boot ATR on deassert" + # and the DATA state waits for that deassert before measuring. + # Re-anchor the hysteresis candidates relative to the primed level so + # the first confirmed transition is measured from the true state. + if not self._primed: + self._primed = True + pins = self.wait({'skip': 0}) + if pins is not None: + if self.track_rst: + self.last_rst = pins[self.RST_IDX] + if self.track_vcc: + self.last_vcc = pins[self.VCC_IDX] + self.rst_cand = None + self.rst_cand_sn = 0 + self.vcc_cand = None + self.vcc_cand_sn = 0 + if (self.track_rst and self.last_rst == 0 and self.state == 'DATA' + and self.bit_samples is None): + # Card is sensed in reset at capture start (RST primed LOW; + # the assertion edge is in the past -- e.g. the capture began + # during power-on). The card is inactive, so measuring the + # ETU now would lock a bogus period from pre-ATR power-up + # transitions on DATA. Expect the fresh ATR instead: re-arm + # the hunt at the spec-default rate and let FIND START wait + # for the card to power on (read_first_byte/wait_data_falling + # service RST/VCC events while DATA is idle). This is the + # "a mid-session capture may still begin with a reset" case. + self.state = 'FIND START' + self._atr_hunt_count = 0 + self._pps_attempted = False + self.bit_samples = None + self._etu_confirm_count = 0 + self._etu_fail_count = 0 + self.clock_skip = 372 + self._inverse_convention = False + self._clock_skip_confident = \ + not (self.sample_as_clock or self.detect_clock) + self.log("RST low at start: expecting cold-boot ATR") + + # State machine. + if self.state == 'FIND START': + # Find the first frame (sample-based, gating-immune) and hand it + # to the ATR hunter. RST/VCC edges are still serviced inside + # wait_data_falling via track_rst/track_vcc. + while True: + byte = self.read_first_byte() + self.peeked_byte = byte + self.peeked_samplenum = self.ss + self.handle_atr(self.wait({'skip': 0})) + break + elif self.state == 'DATA': + packet = []; + + # Mid-session capture with an explicit protocol: there is no ATR to + # derive the bit period from, so recover it once from the live line + # and then fall through to the normal T=0/T=1 framing state machine + # below, which reassembles each full command+response exchange into + # a single GSMTAP APDU. (Emitting each idle-split burst verbatim + # produced fragments, because the card stops CLK during the + # turn-around and every exchange is split across idle gaps.) + # + # Also re-measure after an accepted PPS changes the bit rate: + # the old ETU no longer applies and the new one must be measured + # from the live DATA line. + if self.bit_samples is None or self._pps_speed_changed: + self._pps_speed_changed = False + # For the CLK-sync reader we also need clock_skip, which is + # unknown on a mid-session capture. Measure the CLK period + # (samples per CLK cycle) and the ETU (samples per bit), then + # derive clock_skip = ETU / CLK-period so the CLK-sync reader + # runs at the true rate. If the CLK period cannot be measured + # (fully gated CLK), clock_skip stays unconfident and the + # edge-list reader (bit_samples-driven) is used instead. + if self._samples_per_clock is None: + self._measure_clock_period() + self._measure_etu() + if self._eof: + return False + self._derive_clock_skip_from_etu() + return True + # ETU now known: run the standard framing below. + + # After a desync, re-align on an inter-frame idle gap so the + # next exchange is read from a real command boundary. + if (self._resync): + self._resync = False + self.resync_idle() + + # Opportunistic RST/VCC level check at packet boundary. + # Note: edges occurring entirely inside a packet transfer + # are only detected here as a net level change. + if (self.track_rst or self.track_vcc): + self.line_events(self.wait({'skip': 0})) + + if not self.hasT0 and not self.hasT1: + bs = self.bit_samples or self._compute_bit_samples() + self.wait({'skip': int(bs)}) + return True + + firstByte = self.peek_byte(); + if firstByte is None: + return True + if (firstByte == 0xFF): # PPS Request + # Only one PPS attempt is made after an ATR/warm reset. + # Idle 0xFF bytes after a failed/no-PPS scenario would + # otherwise trigger PPS detection in an infinite loop. + if not self._pps_attempted: + self.handle_pps(); + else: + # Idle 0xFF noise after the PPS window has closed. + # Consume it and re-sync on the next real start bit. + self.read_byte() + self._resync = True + return True + elif (firstByte == 0x3b) and not self._atr_parsed: # Probably ATR (only if no ATR yet) + self.handle_atr(self.wait({'skip': 0})) + return True + elif bin(firstByte).count('1') >= 6: + # Idle-line noise: byte has 6+ bits set (all-1s with + # 0-2 bit errors). Re-sync instead of mis-parsing as + # a T=0 header (which would pack idle noise into + # garbage APDUs). + self._note_discard(firstByte, 'idle-line noise (6+ bits set)', idle=True) + self._consume_first() + self._resync = True + return True + elif not plausible_cla(firstByte): + # Non-standard CLA: byte is not a recognized ISO 7816 + # class. Likely mis-framed data — attempt to resync. + self._note_discard(firstByte, 'suspicious CLA (not ISO 7816-4)') + self._consume_first() + self.log("Suspicious CLA 0x{:02x}, attempting resync".format(firstByte)) + self._resync = True + return True + elif firstByte == 0x00 and self.zero_run >= 32: + # Stuck-low noise: I/O held low for many consecutive frames. + # Don't start a T=0 frame with leading zeros. + self._note_discard(firstByte, 'stuck-low noise (zero_run >= 32)', idle=True) + self._consume_first() + self._resync = True + return True + + es = self.peeked_samplenum; + if (self.hasT0): + # Idle-line check before reading the command header. + # DATA idles HIGH in direct convention (0xFF) or LOW in + # inverse convention (0x00). If the first byte is idle, + # the card is not transmitting — skip it and let the RST + # handler re-arm ATR hunt. + idle_byte = 0xFF if not self._inverse_convention else 0x00 + first = self.peek_byte() + if first is None: + return True + if first == idle_byte: + self._note_discard(first, 'idle byte during header wait', idle=True) + self._consume_first() + self._resync = True + return True + bClass = self.read_byte() + if bClass is None: + return True + bIns = self.read_byte() + if bIns is None: + return True + p1 = self.read_byte() + if p1 is None: + return True + p2 = self.read_byte() + if p2 is None: + return True + p3 = self.read_byte() + if p3 is None: + return True + packet.extend((bClass, bIns, p1, p2, p3)) + # All-zero header is never a valid T=0 command. + if all(b == 0 for b in packet[:5]): + self.put(es, self.samplenum, self.out_ann, + [Ann.ANN_WARN, + ["all-zero T=0 header (stuck low?)"]]) + self._resync = True + return True + # T=0 header validation per ISO 7816-4 §12.1.1. + # Rejects noise headers that slip past the CLA filter. + # - INS=0x00 is reserved/invalid. + # - MSB nibble 0x6/0x9 conflicts with status words. + # - LSB must be 0 (all standard interindustry commands). + if (bIns == 0x00 + or (bIns & 0xF0) == 0x60 + or (bIns & 0xF0) == 0x90 + or (bIns & 0x01) != 0): + # Byte-slip re-frame: the whole 5-byte window is a valid + # size but the INS is unusable, so the window is + # probably shifted by one (a stray leading byte from the + # previous exchange, e.g. a leftover SW2). Drop ONLY the + # leading byte and re-frame the remaining four so a real + # command that would otherwise be swept into a garbage + # header (or swallowed by a bogus P3) is recovered. + self._note_discard(bClass, 'invalid INS 0x%02x in header; re-framing' + % bIns, idle=False) + self.peeked_samplenum = es + self.peeked_byte = None + self._replay.appendleft(p3) + self._replay.appendleft(p2) + self._replay.appendleft(p1) + self._replay.appendleft(bIns) + return True + # T=0 procedure-byte transport (ISO 7816-3 §10.3.3/10.3.4). + # Reassemble the exchange to ISO 7816-4 form (C-APDU = header + # + command data, R-APDU = response data + SW1 SW2) into one + # event. Procedure bytes (ACK == INS, NULL 0x60, 9E/9F) are + # framing and omitted. + got_sw = False + if self._edge_read: + # Mid-session (gated-CLK) path. + # + # T=0 case 3/4 commands carry Lc (= P3) command-data bytes + # on the wire IMMEDIATELY after the 5-byte header, BEFORE + # the card's first procedure byte. The original loop (which + # treats the first post-header byte as a procedure byte) + # therefore mis-frames them. We distinguish the cases by + # the card's first procedure byte: + # * ACK (== INS or ~INS) right after the header -> case 2/4, the + # terminal sent no command data and the card is + # acknowledging; read P3 response bytes (+SW), or, for + # Le == 0 (GET RESPONSE), read until the SW. + # * any other byte -> case 3/4, it + # is terminal command data; read P3 command-data bytes, + # then the card's procedure byte (ACK/SW/9x). + pb0 = self.read_byte() + if pb0 is None: + return True + # Skip NULL (0x60) procedure bytes and turnaround idle + # bytes (0xFF): the card may send NULLs while processing + # before the ACK, or the bus may idle at xFF during + # turnaround on the bidirectional DATA line. Without + # this the NULLs / idle bytes are mis-identified as + # case-3/4 command data. + for _ in range(MAX_TPDU_LEN): + if pb0 != 0x60 and pb0 != 0xFF: + break + self._note_discard(pb0, + 'idle/NULL byte before procedure byte', + idle=True) + pb0 = self.read_byte() + if pb0 is None: + return True + if (pb0 == bIns or pb0 == (bIns ^ 0xFF)): + # ACK (full match or ~INS): case 2/4 (response data + # follows). Read exactly P3 response bytes, then + # scan for SW1 SW2, skipping any turnaround idle + # bytes (e.g. 0xFF) between data and SW. + for _ in range(p3): + b = self.read_byte() + if b is None: + return True + packet.append(b) + sw1 = None + for _ in range(MAX_TPDU_LEN): + sw1 = self.read_byte() + if sw1 is None: + return True + if ((sw1 & 0xF0) == 0x60 + or (sw1 & 0xF0) == 0x90): + break + self._note_discard(sw1, + 'non-SW byte before status word ' + '(P3-bounded response)', + idle=(sw1 in (0x00, 0xFF))) + if sw1 is None: + return True + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(sw1) # SW1 + packet.append(sw2) # SW2 + got_sw = True + else: + # Case 2/3/4: determine data direction from P3 and + # the first post-header byte. + packet.append(pb0) + if (p3 == 0): + # P3=0 (Le=0): no command data. Card sends + # response data + SW, or SW directly (no ACK). + if ((pb0 & 0xF0) == 0x60 + or (pb0 & 0xF0) == 0x90): + # pb0 is SW1: card sent SW directly. + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(sw2) + got_sw = True + else: + # pb0 is response data: read until SW, + # skipping turnaround idle bytes. + for _ in range(MAX_TPDU_LEN): + b = self.read_byte() + if b is None: + return True + if ((b & 0xF0) == 0x60 + or (b & 0xF0) == 0x90): + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(b) + packet.append(sw2) + got_sw = True + break + self._note_discard(b, + 'non-SW byte in P3=0 ' + 'response', + idle=(b in (0x00, 0xFF))) + else: + # Case 3/4: P3 command-data bytes, then + # procedure byte. + for _ in range(max(p3 - 1, 0)): + b = self.read_byte() + if b is None: + return True + packet.append(b) + for _proc in range(32): + pb = self.read_byte() + if pb is None: + return True + if (pb == bIns or pb == (bIns ^ 0xFF)): + # ACK (full match / ~INS): case 4 -> read response until SW, + # skipping turnaround idle bytes. + for _ in range(MAX_TPDU_LEN): + b = self.read_byte() + if b is None: + return True + if ((b & 0xF0) == 0x60 + or (b & 0xF0) == 0x90): + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(b) + packet.append(sw2) + got_sw = True + break + self._note_discard(b, + 'non-SW byte in case-4 ' + 'response', + idle=(b in (0x00, 0xFF))) + break + elif (pb == (bIns ^ 0x01) + or pb == ((bIns ^ 0xFF) ^ 0x01)): + # Single-byte match (INS^0x01 / ~INS^0x01): + # read 1 byte, continue handshake. + b = self.read_byte() + if b is None: + return True + packet.append(b) + continue + elif (pb == 0x60 or pb == 0xFF): + # NULL (0x60): card busy. Turnaround + # idle (0xFF): bidirectional bus turnaround + # between terminal command data and the + # card's procedure byte. Both: consume and + # wait for next procedure byte. + continue + elif (pb == 0x9e or pb == 0x9f): + # Extended-length procedure byte: L + # response data bytes follow, then SW. + L = self.read_byte() + if L is None: + return True + for _ in range(L): + b = self.read_byte() + if b is None: + return True + packet.append(b) + continue + elif ((pb & 0xF0) == 0x60 + or (pb & 0xF0) == 0x90): + # SW1: case 3, no response data. + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(pb) + packet.append(sw2) + got_sw = True + break + else: + # Unexpected byte: scan for SW, + # skip non-SW bytes (e.g. 0xFF + # turnaround artifacts). + for _ in range(MAX_TPDU_LEN): + if ((pb & 0xF0) == 0x60 + or (pb & 0xF0) == 0x90): + sw2 = self.read_byte() + if sw2 is None: + return True + packet.append(pb) + packet.append(sw2) + got_sw = True + break + self._note_discard(pb, + 'non-SW byte during ' + 'unexpected-byte scan', + idle=(pb in (0x00, 0xFF))) + pb = self.read_byte() + if pb is None: + return True + break + else: + # ATR-based captures (test_8 / test_7): keep the original + # procedure loop unchanged. + data_read = False + for _proc in range(8): + pb = self.read_byte() + if pb is None: + break + if (pb == bIns or pb == (bIns ^ 0xFF)) and not data_read: + # ACK (full match / ~INS): the P3 bytes follow on + # the wire (Lc command data for case 3, Le response + # data for case 2). + data_read = True + for _ in range(p3): + b = self.read_byte() + if b is None: + break + packet.append(b) + elif (pb == (bIns ^ 0x01) + or pb == ((bIns ^ 0xFF) ^ 0x01)): + # Single-byte match (INS^0x01 / ~INS^0x01): + # read 1 byte, continue handshake. + b = self.read_byte() + if b is None: + break + packet.append(b) + elif (pb == 0x60): + # NULL: card busy; wait for the next procedure byte. + pass + elif (pb == 0x9e or pb == 0x9f): + # T=0 extended-length procedure byte: the next byte + # L is the number of response data bytes the card + # now sends, followed by the status word. + L = self.read_byte() + if L is None: + break + for _ in range(L): + b = self.read_byte() + if b is None: + break + packet.append(b) + elif ((pb & 0xF0) == 0x60 or (pb & 0xF0) == 0x90): + # SW1: append SW2; the exchange is complete. + sw2 = self.read_byte() + if sw2 is None: + break + packet.append(pb) + packet.append(sw2) + got_sw = True + break + else: + # Mis-framed procedure byte: stop reading. The + # partial packet is flagged desynced downstream and + # the decoder re-aligns on the next idle gap. + self.put(es, self.samplenum, self.out_ann, + [0, ["INVALID Procedure Byte"]]) + self.log("INVALID Procedure Byte", hex(pb)) + self._resync = True + break + self.put(es, self.samplenum, self.out_ann, [4, ["T=0"]]) + if (got_sw and len(packet) >= 7): + self.put(es, self.samplenum, self.out_ann, [9, ["APDU", "APDU cls={cls:02x} ins={ins:02x}".format(cls=bClass,ins=bIns), "APDU cls={cls:02x} ins={ins:02x} p1={p1:02x} p2={p2:02x} p3={p3:02x} len={len} status={sw1:02x}{sw2:02x}".format(cls=bClass,ins=bIns,p1=p1,p2=p2,p3=p3,len=p3,sw1=packet[-2],sw2=packet[-1])]]) + elif (self.hasT1): + isIBlock,packet = self.t1_parse_block(es) + if (isIBlock): + for _ in range(32): # bounded chain follow-up + isIBlock,packet2 = self.t1_parse_block(es) + if (isIBlock): + packet = packet + packet2 + break + self.put(es, self.samplenum, self.out_ann, [4, ["T=1", "T=1 (reassembled)"]]) + if (len(packet) >= 8): + self.put(es, self.samplenum, self.out_ann, [9, ["APDU", "APDU cls={cls:02x} ins={ins:02x}".format(cls=packet[0],ins=packet[1]), "APDU cls={cls:02x} ins={ins:02x} p1={p1:02x} p2={p2:02x} p3={p3:02x} len={len} status={sw1:02x}{sw2:02x}".format(cls=packet[0],ins=packet[1],p1=packet[2],p2=packet[3],p3=packet[4],len=len(packet) - 7,sw1=packet[-2],sw2=packet[-1])]]) + + + if (len(packet) > 0): + # All-zero first: an I/O-held-low stretch is idle noise, + # not a TPDU -- suppress it entirely (and re-sync later). + if (all(b == 0 for b in packet)): + self._idle_low += 1 + if self._idle_low == 1 or self._idle_low % 256 == 0: + self.put(es, self.samplenum, self.out_ann, + [Ann.ANN_WARN, + ["line idle-low (all-zero reads suppressed)"]]) + self._resync = True + elif is_desynced(packet): + # Keep the bytes but flag them via GSMTAP_FLAG_BAD_FCS + # (res byte) so downstream tools can tell desynced + # TPDUs apart; sub_type stays 0x00. Then re-align. + self.put(es, self.samplenum, self.out_ann, + [Ann.ANN_WARN, + ["desynced APDU (flagged BAD_FCS)"]]) + self.emit_packet(GSMTAP_SIM_APDU, bytes(packet), + es, self.samplenum, + flags=GSMTAP_FLAG_BAD_FCS) + self._resync = True + else: + emb = embedded_exchanges(packet) + if emb: + # CONCAT: a byte-drop mis-frame swallowed several + # real exchanges into this structurally valid + # packet (their headers survive in the data region). + # Flag BAD_FCS + log so downstream tools can + # split/reject the wrong-APDU group. + self.put(es, self.samplenum, self.out_ann, + [Ann.ANN_WARN, + ["CONCAT APDU (embedded exchange(s))"]]) + self.log("CONCAT APDU: %d embedded exchange(s) at %r; packet %s" + % (len(emb), + [(s, e) for s, e in emb], + codecs.encode(bytes(packet), 'hex'))) + self.emit_packet(GSMTAP_SIM_APDU, bytes(packet), + es, self.samplenum, + flags=GSMTAP_FLAG_BAD_FCS) + else: + self._idle_low = 0 + self.emit_packet(GSMTAP_SIM_APDU, bytes(packet), + es, self.samplenum) + + self.log("PACKETEND", codecs.encode(bytes(packet), 'hex')) + else: + return False + return True + + def decode(self): + try: + self.write_pcap_header(); + failures = 0 + while True: + # Crash protection: garbage from a noisy live signal must + # never kill the whole sigrok-cli process. Log, warn and + # resync. SystemError from wait() is the end-of-sample- + # stream signal and must propagate for a clean exit; + # KeyboardInterrupt (Ctrl+C) likewise passes through. + try: + if (not self.decode_step()): + break + failures = 0 + except SystemError: + raise + except Exception as e: + traceback.print_exc(file=sys.stderr) + self.log("DECODE ERROR:", repr(e), "- resyncing") + try: + wss = max(self.ss, 0) + wes = max(self.es, wss + 1) + self.put(wss, wes, self.out_ann, + [Ann.ANN_WARN, ["decode error, resynced"]]) + except Exception: + pass + self.state = 'DATA' + failures += 1 + if (failures >= 50): + self.log("decode failed 50 times in a row, stopping") + break + finally: + self.finish(); diff --git a/resources/components.jpg b/resources/components.jpg new file mode 100644 index 0000000..2f456fa Binary files /dev/null and b/resources/components.jpg differ diff --git a/resources/in_action.jpg b/resources/in_action.jpg new file mode 100644 index 0000000..70a8248 Binary files /dev/null and b/resources/in_action.jpg differ diff --git a/resources/in_action_phone.jpg b/resources/in_action_phone.jpg new file mode 100644 index 0000000..7856a0a Binary files /dev/null and b/resources/in_action_phone.jpg differ diff --git a/resources/mastercard_t1.PNG b/resources/mastercard_t1.PNG new file mode 100644 index 0000000..79f629d Binary files /dev/null and b/resources/mastercard_t1.PNG differ diff --git a/resources/paymentcard_t0.PNG b/resources/paymentcard_t0.PNG new file mode 100644 index 0000000..3e49aed Binary files /dev/null and b/resources/paymentcard_t0.PNG differ diff --git a/resources/paymentcard_t0_wireshark.PNG b/resources/paymentcard_t0_wireshark.PNG new file mode 100644 index 0000000..183c9c5 Binary files /dev/null and b/resources/paymentcard_t0_wireshark.PNG differ diff --git a/resources/short_cable.jpg b/resources/short_cable.jpg new file mode 100644 index 0000000..4f6cd09 Binary files /dev/null and b/resources/short_cable.jpg differ diff --git a/resources/short_cable_gear.jpg b/resources/short_cable_gear.jpg new file mode 100644 index 0000000..ac74bd1 Binary files /dev/null and b/resources/short_cable_gear.jpg differ diff --git a/resources/short_cable_pull_resistors.jpg b/resources/short_cable_pull_resistors.jpg new file mode 100644 index 0000000..7ef5d15 Binary files /dev/null and b/resources/short_cable_pull_resistors.jpg differ diff --git a/resources/short_cable_wiring.jpg b/resources/short_cable_wiring.jpg new file mode 100644 index 0000000..543b2ec Binary files /dev/null and b/resources/short_cable_wiring.jpg differ diff --git a/resources/sim_adapter.jpg b/resources/sim_adapter.jpg new file mode 100644 index 0000000..84d2189 Binary files /dev/null and b/resources/sim_adapter.jpg differ diff --git a/resources/sim_adapter_pinout.jpg b/resources/sim_adapter_pinout.jpg new file mode 100644 index 0000000..d9d928b Binary files /dev/null and b/resources/sim_adapter_pinout.jpg differ diff --git a/resources/sim_sniff.jpg b/resources/sim_sniff.jpg new file mode 100644 index 0000000..885d6f9 Binary files /dev/null and b/resources/sim_sniff.jpg differ diff --git a/resources/sim_t0.PNG b/resources/sim_t0.PNG new file mode 100644 index 0000000..2fb72ff Binary files /dev/null and b/resources/sim_t0.PNG differ diff --git a/resources/sim_t0_wireshark.PNG b/resources/sim_t0_wireshark.PNG new file mode 100644 index 0000000..4a73e46 Binary files /dev/null and b/resources/sim_t0_wireshark.PNG differ diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..c42a0a0 --- /dev/null +++ b/setup.bat @@ -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 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..75324db --- /dev/null +++ b/setup.sh @@ -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." diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..3d5ab45 --- /dev/null +++ b/start.bat @@ -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 = 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 diff --git a/tests/baseline.txt b/tests/baseline.txt new file mode 100644 index 0000000..83be604 --- /dev/null +++ b/tests/baseline.txt @@ -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 diff --git a/tests/baseline_new.txt b/tests/baseline_new.txt new file mode 100644 index 0000000..d4f100f --- /dev/null +++ b/tests/baseline_new.txt @@ -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 --- diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py new file mode 100644 index 0000000..fee5f7a --- /dev/null +++ b/tests/test_gsmtap.py @@ -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() diff --git a/tools/capture_baseline.sh b/tools/capture_baseline.sh new file mode 100755 index 0000000..90c9a8d --- /dev/null +++ b/tools/capture_baseline.sh @@ -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 diff --git a/tools/compare_baseline.sh b/tools/compare_baseline.sh new file mode 100755 index 0000000..dceea21 --- /dev/null +++ b/tools/compare_baseline.sh @@ -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 diff --git a/tools/detect_pins.py b/tools/detect_pins.py new file mode 100644 index 0000000..620b17b --- /dev/null +++ b/tools/detect_pins.py @@ -0,0 +1,1086 @@ +#!/usr/bin/env python3 +"""Auto-detect which FX2 channels carry the SIM signals (CLK / I-O / RST / VCC). + +Reads an existing sigrok .sr capture, or records a short live one, then +classifies every channel and prints the wiring plus a ready-to-paste +start.sh command line. + +Usage: + python3 tools/detect_pins.py capture.sr [--max-samples=N] + python3 tools/detect_pins.py --live [--samplerate=12M] [--seconds=2] + +Requires: python3 + numpy. The probe window should cover a phone power- +on or card reset so VCC/RST/DATA show their activation ordering; static +channels are reported honestly as ambiguous instead of guessed. + +Clock Stop Mode (phones gate or stop CLK between transactions) is handled: +CLK is detected from its *local* period and burst density, not the average +edge rate, so a gated clock is still found, and a stopped-high clock is not +mistaken for a supply line. +""" + +import argparse +import configparser +import re +import subprocess +import sys +import tempfile +import time +import zipfile + +import numpy as np + + +# Evidence-capture tunables ---------------------------------------------- +KEEP_TRANS = 512 # transition timestamps kept per channel +MAX_ONSETS = 64 # burst onsets kept per channel +ONSET_SILENCE_SEC = 0.2 # quiet time that marks a DATA burst onset +IDLE_SILENCE_SEC = 0.05 # span with NO transitions on any pin = idle + # (Clock Stop Mode); excluded from high% so a + # gated clock reads ~50% duty during bursts. +RESET_WIN_BEFORE = 0.150 # RST pulse lead vs burst onset (seconds) +RESET_WIN_AFTER = 0.020 + + +# ---------------------------------------------------------------- input -- + +def parse_rate(s): + '''Accept both .sr metadata spellings ("25 MHz") and CLI shorthand + ("12M", "400kHz", bare "1000"). Case-insensitive; 'm' means mega.''' + m = re.fullmatch(r'\s*([\d.]+)\s*([GMk])?\s*(?:Hz)?\s*', s, + re.IGNORECASE) + if not m: + raise ValueError('bad samplerate string: %r' % s) + unit = m.group(2).lower() if m.group(2) else '' + mult = {'g': 1e9, 'm': 1e6, 'k': 1e3, '': 1}[unit] + return int(float(m.group(1)) * mult) + + +def load_sr(path, max_samples=None): + """Return (samples uint8 array, samplerate, n_channels). + + Members are streamed into a growing bytearray so multi-GB captures + never double in memory; max_samples bounds the result.""" + zf = zipfile.ZipFile(path) + meta = configparser.ConfigParser() + meta.read_string(zf.read('metadata').decode()) + sec = meta['device 1'] + nch = int(sec['total probes']) + unitsize = int(sec.get('unitsize', 1)) + if unitsize != 1: + # 16-bit profiles (9/16-channel sticks) are exactly what this + # tool exists to avoid -- generic 8-channel dongles stream + # unitsize=1. + raise ValueError('unsupported unitsize %d (need an 8-channel ' + 'unitsize=1 capture)' % unitsize) + samplerate = parse_rate(sec['samplerate']) + names = sorted((n for n in zf.namelist() + if re.fullmatch(r'logic-\d+-\d+', n)), + key=lambda n: int(n.split('-')[-1])) + buf = bytearray() + limit = None if max_samples is None else max_samples * unitsize + for n in names: + buf.extend(zf.read(n)) + if limit is not None and len(buf) >= limit: + break + if limit is not None and len(buf) > limit: + del buf[limit:] + usable = len(buf) // unitsize * unitsize + arr = np.frombuffer(bytes(buf[:usable]), dtype=np.uint8) + return arr, samplerate, nch + + +# ------------------------------------------------------------ features ---- + +class ChannelAcc: + """Incremental per-channel statistics over arbitrary chunk sizes. + + Tracks: edge count, high ratio, first rise, last transition, and a + histogram of inter-transition gaps capped at GAP_CAP samples. The + capped-histogram median is what makes clock detection immune to + gating: gating adds a FEW enormous gaps which land in the overflow + bucket and never move the median away from half a clock period.""" + GAP_CAP = 4096 + + def __init__(self, samplerate): + self.sr = samplerate + self.edges = 0 + self.rises = 0 + self.high = 0 + self.total = 0 + self.first_t = None # first transition (any direction) + self.first_rise = None # in samples + self.last_t = None # in samples + self._last = None + self.fr_idx = None # global index of first rise + self.post_n = 0 # samples at/after first rise + self.post_high = 0 # high samples at/after first rise + self.hist = np.zeros(self.GAP_CAP + 1, dtype=np.int64) + self.trans_kept = [] # first transition indices (samples) + self.onsets = [] # burst onsets (samples) + self._onset_gap = int(ONSET_SILENCE_SEC * samplerate) + + def feed(self, x, base): + '''x: uint8 0/1 slice; base: global index of x[0].''' + n = len(x) + if n == 0: + return + if self._last is None: + self._last = int(x[0]) + y = np.empty(n + 1, dtype=np.uint8) + y[0] = self._last + y[1:] = x + self._last = int(y[-1]) + self.total += n + self.high += int(x.sum()) + tt = np.nonzero(y[1:] != y[:-1])[0] # offsets into y[1:] + if len(tt) == 0: + # Even without transitions the post-rise window may cover us. + if self.fr_idx is not None: + self.post_n += n + self.post_high += int(x.sum()) + return + g = base + tt # global positions + lv = y[1:][tt] # new levels + if len(self.trans_kept) < KEEP_TRANS: + need = KEEP_TRANS - len(self.trans_kept) + self.trans_kept.extend(int(v) for v in g[:need]) + if len(self.onsets) < MAX_ONSETS: + # First transition of the stream counts as an onset too: + # a capture that starts right at a burst still anchors. + if self.last_t is None or \ + (g[0] - self.last_t) >= self._onset_gap: + self.onsets.append(int(g[0])) + if len(g) > 1: + gg = np.diff(g) + for p in np.nonzero(gg >= self._onset_gap)[0]: + if len(self.onsets) < MAX_ONSETS: + self.onsets.append(int(g[p + 1])) + if self.first_t is None: + self.first_t = int(g[0]) + rise_pos = np.nonzero(lv == 1)[0] + self.rises += len(rise_pos) + if self.fr_idx is None and len(rise_pos): + self.fr_idx = int(g[rise_pos[0]]) + self.first_rise = self.fr_idx + # Gap series: chained across chunks AND within a chunk. + chain = g if self.last_t is None else \ + np.concatenate((np.array([self.last_t], dtype=g.dtype), g)) + if len(chain) >= 2: + gaps = np.minimum(np.diff(chain), self.GAP_CAP) + self.hist += np.bincount(gaps, + minlength=self.GAP_CAP + 1) + self.last_t = int(g[-1]) + self.edges += len(g) + # Post-first-rise level accounting (split at the rise sample). + if self.fr_idx is not None: + k = min(max(self.fr_idx - base, 0), n) + self.post_n += (n - k) + self.post_high += int(x[k:].sum()) + + def stats(self): + tot = int(self.hist.sum()) + med_gap = None + short_edges = None + long_edges = None + if tot: + cum = np.cumsum(self.hist) + med_gap = int(np.searchsorted(cum, (tot + 1) // 2)) + # Burst density: count of inter-transition gaps that sit at (or + # below) twice the median, i.e. inside a real clock burst. Gating + # / Clock Stop Mode only adds a FEW enormous gaps (they land in the + # GAP_CAP overflow bucket), so the short/long split cleanly + # separates a gated clock's bursts from its idle stretches. + hi = min(med_gap * 2, self.GAP_CAP) + short_edges = int(cum[hi]) + long_edges = tot - short_edges + return { + 'edges': self.edges, + 'rate': self.edges * self.sr / max(self.total, 1), + 'high': self.high / max(self.total, 1), + 'median_gap': med_gap, + 'short_edges': short_edges, + 'long_edges': long_edges, + 'rises': self.rises, + 'falls': self.edges - self.rises, + 'post_duty': (self.post_high / self.post_n + if self.post_n else None), + 'first_t': (self.first_t / self.sr + if self.first_t is not None else None), + 'first_rise': (self.first_rise / self.sr + if self.first_rise is not None else None), + 'last_act': (self.last_t / self.sr + if self.last_t is not None else None), + 'trans': list(self.trans_kept), + 'onsets': list(self.onsets), + } + + +# ----------------------------------------------------------- classifier --- + +def classify(stats_list, samplerate, duration): + '''Return dict ch -> (verdict, confidence), plus notes list.''' + verdicts = {} + notes = [] + rates = [s['rate'] for s in stats_list] + + # CLK: the channel whose *local* toggling period implies a running clock + # (>= 1 MHz) and which actually clocks for a real stretch (enough + # short-period edges to be a burst, not one isolated transition). Gating / + # Clock Stop Mode inserts long idle gaps that only lower the *average* edge + # rate, so we score on the local period and burst density (median gap -> + # local_freq, plus short_edges from the gap histogram) rather than the + # average rate. A gated CLK is still detected this way. + def _local_freq(s): + med = s['median_gap'] + if not med: + return 0.0 + return samplerate / (2 * med) + + clk = None + clock_chs = set() + for c, s in enumerate(stats_list): + if _local_freq(s) >= 1e6 and (s['short_edges'] or 0) >= 32: + clock_chs.add(c) + if clock_chs: + # The genuine CLK is the one with the most burst edges; any other + # clock-frequency channel is an alias/glitch and must not be claimed + # as VCC/RST below. + clk = max(clock_chs, + key=lambda c: stats_list[c].get('short_edges', 0)) + verdicts[clk] = ('CLK', 'high') + clock_chs.discard(clk) + if clk is None: + notes.append('no channel shows a dominant local clock (>= 1 MHz) - ' + 'is the SIM CLK running (even if gated between bursts)?') + + # Fast-toggling channels (clocks or dense data) can never be a supply or + # reset line; a stopped-high CLK would otherwise read as an always-high + # supply. Drop them from the VCC/RST candidate pools. + def _is_signal_line(c): + return c in clock_chs or c == clk or stats_list[c]['edges'] >= 200 + + rest = [c for c in range(len(stats_list)) + if c != clk and c not in clock_chs] + + def quasi(c): + s = stats_list[c] + return s['edges'] <= max(50, duration * 100) + + quiet = [c for c in rest if quasi(c)] + busy = [c for c in rest if c not in quiet] + + # DATA: busiest remaining channel, expected idle-high. Use the + # active-only high ratio so a gated clock's idle stretches do not drag + # the duty reading down. + data = None + if busy: + data = max(busy, key=lambda c: stats_list[c]['rate']) + h = stats_list[data].get('high_active', stats_list[data]['high']) + if h >= 0.80: + conf = 'high' + elif h >= 0.50: + # Heavy traffic legitimately pulls the idle ratio down. + conf = 'medium' + else: + conf = 'low' + notes.append('DATA candidate reads mostly LOW ' + '(%.0f%% high) - stuck line?' % (h * 100)) + verdicts[data] = ('DATA', conf) + + if data is None: + notes.append('no I/O traffic seen - check DATA probe contact, ' + 'or no SIM init happened inside the window') + + # VCC vs RST among quiet channels, anchored to DATA-traffic onset. + # Floating/unconnected pins pick up coupled glitches whose recorded + # rise is often EARLIER than the real activation events (noise starts + # as soon as the clock runs), so raw 'earliest rise' ordering is not + # trustworthy. Instead each transitioning candidate is fingerprinted: + # VCC -- rises once and stays high afterwards (post-duty >= 90 %, + # <= 2 falls) + # RST -- pulsed: brief excursion returning to idle, timed just + # before the DATA burst (delta in [-0.35 s, +0.05 s]) + # noise-- everything else: sparse blips scattered across the window, + # or excursions nowhere near the traffic anchor. + t_ref = stats_list[data]['first_t'] if data is not None else None + onsets = stats_list[data]['onsets'] if data is not None else [] + trans = [c for c in quiet if stats_list[c]['edges'] > 0 + and not _is_signal_line(c)] + shigh = [c for c in quiet if c not in trans and stats_list[c]['high'] >= 0.90 + and not _is_signal_line(c)] + slow = [c for c in quiet if c not in trans and c not in shigh] + pools = {'trans': list(trans), 'shigh': list(shigh), + 'slow': list(slow), 'pulsed': [], 'sticky': []} + + def mark_ambig(lst, label): + names = ', '.join(ch_name(c) for c in sorted(lst)) + notes.append('%s ambiguous (%s) - swap-test needed' % (label, names)) + for c in lst: + verdicts[c] = (label, 'ambiguous') + + def feats(c): + s = stats_list[c] + delta = None + if s['first_rise'] is not None and t_ref is not None: + delta = s['first_rise'] - t_ref + span = 0.0 + if s['first_rise'] is not None and s['last_act'] is not None: + span = max(s['last_act'] - s['first_rise'], 0.0) + return {'delta': delta, 'post': s['post_duty'], + 'falls': s['falls'], 'span': span} + + assigned_vcc = False + if t_ref is not None and trans: + pulsed, sticky, noise = [], [], [] + for c in trans: + s = stats_list[c] + f = feats(c) + d, post, fl, sp = f['delta'], f['post'], f['falls'], f['span'] + # Supply lines (and floating-high pins) are mostly-high with + # tiny dips -- never call them scatter-noise; they compete + # via pulse evidence / interactive confirmation instead. + supply_like = (post is not None and post >= 0.90) or \ + (post is None and + s.get('high_active', s['high']) >= 0.95) + corr, _hits = reset_correlations(s['trans'], onsets, + samplerate) + scatter = s['edges'] >= 6 and sp > 0.5 * duration + far = d is not None and abs(d) > 1.5 + if corr > 0: + pulsed.append((-corr, s['edges'], c)) + f['corr'] = corr + elif supply_like: + sticky.append((s['edges'], + stats_list[c]['first_rise'] or 0, c)) + f['corr'] = corr + else: + noise.append((c, dict(f, corr=corr))) + + pulsed.sort(key=lambda t: (t[0], t[1], t[2])) + pools['pulsed'] = [c for _co, _e, c in pulsed] + if pulsed: + top = pulsed[0] + # Single-segment pulse hits stay medium confidence -- + # probe_loop corroborates across segments. + verdicts[top[2]] = ('RST', 'medium') + notes.append('RST evidence: %s (%d reset-correlated ' + 'pulse(s), edges=%d)' + % (ch_name(top[2]), -top[0], top[1])) + sticky.sort(key=lambda t: (t[0], t[2])) + pools['sticky'] = [c for _e, _r, c in sticky] + if sticky: + vcc = sticky[0][2] + verdicts[vcc] = ('VCC', 'high') + assigned_vcc = True + notes.append('VCC evidence: %s (fewest edges among ' + 'always-high lines: %d)' + % (ch_name(vcc), sticky[0][0])) + noise_chs = {c for c, _f in noise} + assigned = {pulsed[0][2]} if pulsed else set() + if sticky: + assigned.add(sticky[0][2]) + for c in trans: + if c not in assigned and c not in noise_chs: + verdicts.setdefault(c, ('-', '')) + for c, f in noise[:4]: + dd = ('%+.2fs' % f['delta']) if f['delta'] is not None else '--' + p = ('%.0f%%' % (f['post'] * 100)) if f['post'] is not None else '--' + extra = ', %d reset-correlated' % f['corr'] if f['corr'] else '' + notes.append('%s looks like noise (edges=%d, Δt_ref=%s, ' + 'post-rise duty=%s%s) - ignored' + % (ch_name(c), stats_list[c]['edges'], dd, p, + extra)) + for c in shigh + slow: + verdicts.setdefault(c, ('-', '')) + elif shigh: + # No transitioning supply candidate in this window: a static-high + # line could be the (already-powered) VCC or a floating pin, and + # the two are indistinguishable without a power-on transition. + # Never assert VCC from an edge-free line alone -- mark it + # ambiguous so only transition-anchored (sticky) VCC evidence + # reaches the cross-segment consensus. + mark_ambig(shigh, 'VCC') + for c in slow: + verdicts[c] = ('-', '') + notes.append('no VCC/RST transitions seen - trigger a card reset ' + 'during the probe window for reliable separation') + else: + for c in quiet: + verdicts[c] = ('-', '') + notes.append('no pulled-up lines seen - was the SIM powered ' + 'during the probe window?') + + return verdicts, notes, pools + + +def ch_name(c): + return 'D%d' % c if c < 8 else 'A%d' % (c - 8) + + +def reset_correlations(trans, onsets, samplerate): + '''Count DATA burst onsets whose preceding pulse excursion on this + channel starts inside [RESET_WIN_BEFORE, RESET_WIN_AFTER] seconds + relative to the onset AND CLOSES near it (within +100 ms). The + closing requirement excludes supply-like lines whose single rise + precedes traffic and never returns -- those are VCC candidates, not + reset pulses. Orientation-agnostic: any consecutive transition + pair is treated as one excursion. Returns (matched_onsets, + matched_positions).''' + lo = -int(RESET_WIN_BEFORE * samplerate) + hi = int(RESET_WIN_AFTER * samplerate) + close_hi = int(0.100 * samplerate) + hits = [] + for o in onsets: + for k in range(len(trans) - 1): + d_start = trans[k] - o + if lo <= d_start <= hi: + d_end = trans[k + 1] - o + if abs(d_end) <= close_hi: + hits.append((o, trans[k])) + break + return len(hits), hits + + +# --------------------------------------------------------------- report --- + +def format_suggestion(verdicts): + '''Return (mapping_lines, suggestion_line_or_None). + + The suggestion covers whatever subset was identified; unknown + signals render as '?' and a partial start.sh command is still + emitted so capture can begin on the known channels.''' + got = {} + for c, (v, conf) in sorted(verdicts.items()): + base = v.split()[0] + if base in SIGNALS and conf != 'ambiguous' and base not in got: + got[base] = ch_name(c) + order = ['CLK', 'DATA', 'RST', 'VCC'] + missing = [s for s in order if s not in got] + lines = ['Detected pin mapping:' if got else 'No signals identified:'] + for s in order: + lines.append(' %-4s -> %s' % (s, got.get(s, '?'))) + if not missing: + cmd = './start.sh --clk=%s --data=%s --rst=%s --vcc=%s' % ( + got['CLK'], got['DATA'], got['RST'], got['VCC']) + elif got: + flags = ''.join(' --%s=%s' % (s.lower(), got[s]) + for s in order if s in got) + cmd = 'Partial: ./start.sh%s (%s unidentified)' % ( + flags, '/'.join(missing)) + else: + cmd = None + return lines, cmd + + +def report(stats_list, samplerate, verdicts, notes, show_suggestion=True): + print('CH edges rate/s idle%% high%%* first-rise verdict') + for c, st in enumerate(stats_list): + v, conf = verdicts.get(c, ('-', '')) + fr = '%7.3fs' % st['first_rise'] if st['first_rise'] is not None \ + else ' --' + h = st.get('high_active', st.get('high', 0.0)) + idle = st.get('idle_frac', 0.0) + tag = v if not conf else '%s (%s)' % (v, conf) + print('%-4s %6d %9.0f %5.1f%% %5.1f%% %s %s' + % (ch_name(c), st['edges'], st['rate'], idle * 100, h * 100, + fr, tag)) + print(' * high%% is over active (non-idle) time; idle = no transitions ' + 'on any pin') + print() + for n_ in notes: + print('note: %s' % n_) + if show_suggestion: + lines, cmd = format_suggestion(verdicts) + for ln in lines: + print(ln) + if cmd: + print('\nSuggested: %s' % cmd) + else: + print('\nAdjust probes/re-plug and re-run.') + + +CHUNK = 32_000_000 + + +def _active_mask(trans, n, samplerate): + '''Bool array marking samples that are NOT in a long idle span. + + A span with zero transitions on *any* channel longer than + IDLE_SILENCE_SEC is idle (Clock Stop Mode / no signal on any pin); + those samples are marked False so they are excluded from high% and + duty calculations. The pre-first and post-last spans are idle too.''' + active = np.ones(n, dtype=bool) + if len(trans) == 0: + return active + # Prefix before the first transition and suffix after the last. + active[:trans[0] + 1] = False + active[trans[-1]:] = False + if len(trans) < 2: + return active + gaps = np.diff(trans) + thr = int(IDLE_SILENCE_SEC * samplerate) + long = np.nonzero(gaps > thr)[0] + starts = trans[:-1] + 1 + ends = trans[1:] + for k in long: + active[starts[k]:ends[k]] = False + return active + + +def _accumulate(arr, samplerate, nch): + accs = [ChannelAcc(samplerate) for _ in range(nch)] + for s in range(0, len(arr), CHUNK): + chunk = arr[s:s + CHUNK] + for c, acc in enumerate(accs): + acc.feed((chunk >> c) & 1, s) + # Global activity: transitions on ANY channel (any bit of the bus). + n = len(arr) + changed = np.empty(n, dtype=bool) + changed[0] = False + changed[1:] = arr[1:] != arr[:-1] + trans = np.nonzero(changed)[0] + active = _active_mask(trans, n, samplerate) + active_total = int(active.sum()) + # Per-channel high count over active (non-idle) samples only. + high_active = [0] * nch + if active_total: + for s in range(0, n, CHUNK): + chunk = arr[s:s + CHUNK] + a = active[s:s + len(chunk)] + for c in range(nch): + bit = (chunk >> c) & 1 + high_active[c] += int((bit & a).sum()) + stats = [a.stats() for a in accs] + idle_frac = (1.0 - active_total / n) if n else 0.0 + for c, st in enumerate(stats): + st['high_active'] = (high_active[c] / active_total) if active_total \ + else 0.0 + st['idle_frac'] = idle_frac + return stats + + +def classify_capture(arr, samplerate, nch): + stats_list = _accumulate(arr, samplerate, nch) + verdicts, notes, pools = classify(stats_list, samplerate, + len(arr) / samplerate) + return {'verdicts': verdicts, 'notes': notes, 'pools': pools, + 'stats_list': stats_list, + 'duration': len(arr) / samplerate} + + +def analyse(arr, samplerate, nch): + res = classify_capture(arr, samplerate, nch) + report(res['stats_list'], samplerate, res['verdicts'], res['notes']) + + +# ----------------------------------------------------------------- live --- + +SIGNALS = ('CLK', 'VCC', 'RST', 'DATA') +CONF_RANK = {'low': 0, 'medium': 1, 'high': 2} + + +def mapping_score(verdicts): + '''Higher is better: one point per solid signal, confidence bonus, + ambiguity never counts.''' + score = 0 + for _c, (v, conf) in verdicts.items(): + base = v.split()[0] + if base in SIGNALS and conf != 'ambiguous': + score += 1 + 0.25 * CONF_RANK.get(conf, 0) + return score + + +def summary_line(verdicts, notes): + parts = [] + for sig in SIGNALS: + best = None + amb = False + for _c, (v, conf) in verdicts.items(): + if v.split()[0] != sig: + continue + if conf == 'ambiguous': + amb = True + elif best is None or CONF_RANK[conf] > CONF_RANK[best]: + best = conf + if best: + parts.append('%s ✓(%s)' % (sig, best)) + elif amb: + parts.append('%s ?(ambiguous)' % sig) + else: + parts.append('%s ✗' % sig) + line = ' '.join(parts) + if any('no I/O traffic' in n_ for n_ in notes): + line += ' (no traffic seen on any line)' + return line + + +def countdown(): + print('Trigger a SIM reset (or reboot) on the phone now. ' + 'Ctrl+C stops early.') + for i in reversed(range(3)): + print(' %d...' % (i + 1), flush=True) + time.sleep(1) + + +def capture_live(samplerate_str='12M', seconds=2.0): + with tempfile.NamedTemporaryFile(suffix='.sr', delete=False) as tf: + out = tf.name + # sigrok-cli 0.7.x spells the window options --time/--samples/--frames + # (there is no --limit-samples); --time matches the guided-flow + # contract directly. + cmd = ['sigrok-cli', '-d', 'fx2lafw', + '--config', 'samplerate=%s' % samplerate_str, + '--time', str(int(seconds * 1000)), + '-C', 'D0,D1,D2,D3,D4,D5,D6,D7', + '-O', 'srzip', + '-o', out] + print('$ ' + ' '.join(cmd)) + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + if e.returncode < 0: + raise KeyboardInterrupt from None # user break during capture + sys.exit('capture failed: exit status %s' % e.returncode) + except FileNotFoundError as e: + sys.exit('capture failed: %s' % e) + print('Probe saved: %s (inspect in PulseView if the mapping looks wrong)' % out) + return out + + +def resolve_signal(verdicts_seq, sig): + '''Most recent non-ambiguous channel assigned to sig, or None.''' + for verdicts in reversed(verdicts_seq): + for c, (v, conf) in verdicts.items(): + if v.split()[0] == sig and conf != 'ambiguous': + return c + return None + + +def _vcc_consensus(seg_verdicts, exclude): + '''Return the pin consistently classified as VCC across segments, or None. + + A pin must have been assigned VCC by per-segment classify() in a strict + majority of the segments that produced any VCC (and at least 2 such + segments), and must not be CLK/DATA/RST or user-rejected. A floating pin + is spurious and flaps between channels, so it never clears this bar: the + result is either the genuine rail or nothing (honest unknown). This + deliberately ignores the edge-free static-high pool -- floating pins are + the most static lines and used to win VCC precisely because they carry no + signal.''' + votes = {} + any_vcc = 0 + for vd in seg_verdicts: + picks = {c for c, (v, conf) in vd.items() + if v.split()[0] == 'VCC' and conf != 'ambiguous' + and c not in exclude} + if picks: + any_vcc += 1 + for c in picks: + votes[c] = votes.get(c, 0) + 1 + if any_vcc < 2 or not votes: + return None + best = max(votes, key=lambda c: (votes[c], -c)) + if votes[best] * 2 <= any_vcc: + return None + return best + + +def ask_confirm(sig, ch, evidence): + '''Interactive confirmation. Returns True (accepted), False + (rejected), or None when stdin is not a tty (non-interactive).''' + if not sys.stdin.isatty(): + return None + if ch is None: + return None + while True: + try: + ans = input('Confirm %s -> %s? %s[Y/n] ' + % (sig, ch_name(ch), evidence)).strip().lower() + except EOFError: + return None + if ans in ('', 'y', 'yes'): + return True + if ans in ('n', 'no'): + return False + + +def ask_confirm_map(verdicts): + '''Single Y/n confirm of the whole pin mapping. Returns True (accepted), + False (rejected), or None when stdin is not a tty (non-interactive).''' + if not sys.stdin.isatty(): + return None + while True: + try: + ans = input('Accept this pin mapping? [Y/n] ').strip().lower() + except EOFError: + return None + if ans in ('', 'y', 'yes'): + return True + if ans in ('n', 'no'): + return False + + +def probe_loop(samplerate_str='24M', seg_seconds=3.0, max_segments=20): + sr_int = parse_rate(samplerate_str) + cap = int(sr_int * seg_seconds * 1.1) + print('Probe: %s, all channels, %.1f s segments, up to %d tries.' + % (samplerate_str, seg_seconds, max_segments)) + countdown() + best = None + agg = None # ch -> {'edges', 'rst'} + seg_verdicts = [] # per-segment verdict dicts, in order + rejected = {'RST': set(), 'VCC': set()} + try: + for seg in range(1, max_segments + 1): + print('\n=== segment %d ===' % seg) + path = capture_live(samplerate_str, seg_seconds) + arr, srate, nch = load_sr(path, cap) + if len(arr) >= cap: + print('note: segment truncated at %d samples ' + '(raise --max-samples or lower --seconds)' + % len(arr)) + res = classify_capture(arr, srate, nch) + res['score'] = mapping_score(res['verdicts']) + seg_verdicts.append(res['verdicts']) + print(summary_line(res['verdicts'], res['notes'])) + if best is None or res['score'] >= best['score']: + best = dict(res, srate=srate, nsamp=len(arr), seg=seg) + + # ---- cross-segment evidence aggregation ---- + if agg is None: + agg = {c: {'edges': 0, 'rst': 0} for c in range(nch)} + pools = res['pools'] + for c in agg: + agg[c]['edges'] += res['stats_list'][c]['edges'] + for c in pools.get('pulsed', []): + agg[c]['rst'] += 1 + + clk_ch = resolve_signal(seg_verdicts, 'CLK') + data_ch = resolve_signal(seg_verdicts, 'DATA') + taken = {clk_ch, data_ch} + + # Ranked candidates from aggregated evidence. + rst_ranked = sorted( + (c for c, a in agg.items() + if a['rst'] > 0 and c not in taken + and c not in rejected['RST']), + key=lambda c: (-agg[c]['rst'], agg[c]['edges'])) + # Early finalize: once CLK and DATA are both resolved we stop + # probing. RST/VCC are optional for decoding (RST only enables + # warm-reset re-arm; VCC is informational), so we do not keep + # hunting for them -- we finalize with whichever were found. + if clk_ch is not None and data_ch is not None: + rst_top = rst_ranked[0] if rst_ranked else None + vcc_top = _vcc_consensus( + seg_verdicts, + (taken | rejected['VCC'] + | ({rst_top} if rst_top is not None else set()))) + final = {} + final[clk_ch] = ('CLK', 'high') + final[data_ch] = ('DATA', 'high') + if rst_top is not None: + final[rst_top] = ('RST', 'medium') + if vcc_top is not None: + final[vcc_top] = ('VCC', 'low') + lines, cmd = format_suggestion(final) + for ln in lines: + print(ln) + if cmd: + print('\nSuggested: %s' % cmd) + if not sys.stdin.isatty(): + print('CLK+DATA resolved (headless) - stopping.') + return final + ok = ask_confirm_map(final) + if ok is False: + print('Mapping rejected - re-probing...', flush=True) + continue + print('\nMapping accepted at segment %d.' % seg) + return final + if seg < max_segments: + print('Trigger reset again if needed...', flush=True) + time.sleep(1) + except KeyboardInterrupt: + print('\n(stopped by user)') + if best is None or agg is None: + sys.exit('no usable segments captured') + + # ---- final resolution ------------------------------------------------- + final = {} + open_q = [] + clk_ch = resolve_signal(seg_verdicts, 'CLK') + data_ch = resolve_signal(seg_verdicts, 'DATA') + if clk_ch is not None: + final[clk_ch] = ('CLK', 'high') + else: + open_q.append('CLK unidentified') + if data_ch is not None: + final[data_ch] = ('DATA', 'high') + else: + open_q.append('DATA unidentified') + + taken = {clk_ch, data_ch} + rst_ranked = sorted((c for c, a in agg.items() + if a['rst'] > 0 and c not in taken + and c not in rejected['RST']), + key=lambda c: (-agg[c]['rst'], agg[c]['edges'])) + if rst_ranked: + final[rst_ranked[0]] = ('RST', 'medium') + open_q.append('RST candidate %s (%d pulse segment(s)) - verify' + % (ch_name(rst_ranked[0]), agg[rst_ranked[0]]['rst'])) + else: + open_q.append('RST never pulsed - trigger resets inside segments') + + vcc_top = _vcc_consensus( + seg_verdicts, + (taken | rejected['VCC'] + | ({rst_ranked[0]} if rst_ranked else set()))) + if vcc_top is not None: + final[vcc_top] = ('VCC', 'low') + open_q.append('VCC candidate %s (consistent across segments) - verify' + % ch_name(vcc_top)) + else: + open_q.append('VCC unresolved - no consistent VCC evidence; ' + 'static-high lines are not positively identifiable ' + '(may be floating)') + + print('\nBest result table: segment %d\n' % best['seg']) + report(best['stats_list'], best['srate'], + best['verdicts'], best['notes'], show_suggestion=False) + lines, _cmd = format_suggestion(final) + for ln in lines: + print(ln) + for q in open_q: + print('open: %s' % q) + + +# -------------------------------------------------------------- selftest -- + +def synth(n, sr, clk_ch=None, data_ch=None, rst_ch=None, vcc_ch=None, + gate_rst=True, pre_high=False, gate_clk=False): + '''Build an 8-channel packed stream with known signal placement.''' + t = np.arange(n) / sr + chans = {} + if gate_clk: + # Clock Stop Mode: CLK runs only inside the transaction window and + # is held HIGH (stop-high) the rest of the time -- exactly the case + # that used to confuse high/low-time based detection. + clk = np.ones(n, dtype=np.uint8) + window = (t >= 0.10) & (t < 0.35) + clk[window] = (((t[window] * 2e6).astype(np.int64)) % 2) + else: + clk = (((t * 2e6).astype(np.int64)) % 2).astype(np.uint8) + chans['clk'] = clk + vcc = (t >= 0.05).astype(np.uint8) + rst = ((t >= 0.10) & (t < 0.15)).astype(np.uint8) + if pre_high: + rst = np.ones(n, dtype=np.uint8) # no transitions at all + vcc = np.ones(n, dtype=np.uint8) + data = np.ones(n, dtype=np.uint8) + burst = (t >= 0.15) & (t < 0.30) + data[burst] = (((t[burst] * 1e5).astype(np.int64)) % 2) + chans['data'] = data + chans['rst'] = rst + chans['vcc'] = vcc + mapping = {'clk': clk_ch, 'data': data_ch, 'rst': rst_ch, 'vcc': vcc_ch} + arr = np.zeros(n, dtype=np.uint8) + for sig, ch in mapping.items(): + if ch is not None: + arr |= chans[sig] << ch + return arr + + +def _classify_arr(arr, sr): + stats = _accumulate(arr, sr, 8) + v, notes, _pools = classify(stats, sr, len(arr) / sr) + return v, notes + + +def selftest(): + failures = [] + + # parse_rate: CLI shorthand and .sr metadata spellings alike. + for s, want in [('12M', 12_000_000), ('12MHz', 12_000_000), + ('25 MHz', 25_000_000), ('400kHz', 400_000), + ('400 kHz', 400_000), ('1000', 1000)]: + got = parse_rate(s) + ok = got == want + print('selftest parse_rate %-9s: %s (%d)' + % (repr(s), 'PASS' if ok else 'FAIL', got)) + if not ok: + failures.append('parse_rate %r' % s) + + sr = 12_000_000 + n = sr // 2 # 0.5 s window keeps it fast + + # Case 1: scrambled but complete mapping. + arr = synth(n, sr, clk_ch=5, data_ch=3, rst_ch=0, vcc_ch=4) + v, notes = _classify_arr(arr, sr) + got = {verdict.split()[0]: ch_name(c) for c, (verdict, _) in v.items()} + want = {'CLK': 'D5', 'DATA': 'D3', 'RST': 'D0', 'VCC': 'D4'} + ok = all(got.get(k) == want[k] for k in want) + print('selftest scrambled-mapping: %s (got %s)' % ('PASS' if ok else 'FAIL', got)) + if not ok: + failures.append('scrambled mapping') + + # Case 1b: gated / stopped CLK (Clock Stop Mode) must still detect CLK + # -- the prior average-rate heuristic used to miss it and misread the + # stopped-high clock as a supply line. + arr = synth(n, sr, clk_ch=5, data_ch=3, rst_ch=0, vcc_ch=4, gate_clk=True) + v, notes = _classify_arr(arr, sr) + got = {verdict.split()[0]: ch_name(c) for c, (verdict, _) in v.items()} + ok = (got.get('CLK') == 'D5' and got.get('DATA') == 'D3' + and got.get('RST') == 'D0' and got.get('VCC') == 'D4') + # Idle-aware metrics: the gated CLK must read ~50% duty over its active + # (non-idle) windows, and most of the capture must be idle. + res = classify_capture(arr, sr, 8) + clk_c = next(c for c, (vv, _) in res['verdicts'].items() + if vv.split()[0] == 'CLK') + st = res['stats_list'][clk_c] + ha = st['high_active'] + idle = st['idle_frac'] + ok = ok and (0.40 <= ha <= 0.60) and idle > 0.2 + print('selftest gated-clk (stop timer): %s (got %s, high_active=%.2f, ' + 'idle_frac=%.2f)' % ('PASS' if ok else 'FAIL', got, ha, idle)) + if not ok: + failures.append('gated-clk case') + + # Case 2: VCC/RST static-high whole window -> ambiguity must be flagged. + arr = synth(n, sr, clk_ch=2, data_ch=6, rst_ch=0, vcc_ch=4, pre_high=True) + v, notes = _classify_arr(arr, sr) + amb = [c for c, (verdict, conf) in v.items() if conf == 'ambiguous'] + ok = len(amb) == 2 and v[2][0] == 'CLK' and v[6][0].startswith('DATA') + print('selftest static-pair ambiguity: %s (ambiguous=%s)' + % ('PASS' if ok else 'FAIL', [ch_name(c) for c in amb])) + if not ok: + failures.append('ambiguity case') + + # Case 3: report() must emit the mapping block AND the suggested + # command for a complete detection. (A lowercase/uppercase key + # mismatch once made the Suggested line unreachable -- invisible to + # headless tests because they stubbed report() out.) + import io + import contextlib + + stats = [dict(edges=10, rate=10, high=1.0, median_gap=3, + first_rise=0.01, last_act=0.02) for _ in range(8)] + complete = {2: ('CLK', 'high'), 3: ('DATA', 'high'), + 4: ('RST', 'high'), 5: ('VCC', 'high')} + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + report(stats, 12_000_000, complete, []) + out = buf.getvalue() + ok = ('Suggested:' in out and '--clk=D2' in out and '--vcc=D5' in out + and 'Detected pin mapping:' in out) + print('selftest report-suggestion : %s' % ('PASS' if ok else 'FAIL')) + if not ok: + failures.append('report suggestion') + + # Case 4: partial mapping still yields a usable partial command. + partial = {2: ('CLK', 'high'), 3: ('DATA', 'high')} + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + report(stats, 12_000_000, partial, []) + out = buf.getvalue() + ok = ('Partial: ./start.sh --clk=D2 --data=D3' in out + and 'RST/VCC unidentified' in out) + print('selftest report-partial : %s' % ('PASS' if ok else 'FAIL')) + if not ok: + failures.append('report partial') + + # Case 5: floating/unconnected pins emit sparse coupled blips that + # START as early as the clock -- raw 'earliest rise' ordering used + # to hand VCC/RST to those ghosts (observed on real hardware). + arr = np.zeros(n, dtype=np.uint8) + t = np.arange(n) / sr + arr |= ((((t * 2e6).astype(np.int64)) % 2)).astype(np.uint8) << 5 # CLK D5 + d = np.ones(n, dtype=np.uint8) + burst = (t >= 0.15) & (t < 0.30) + d[burst] = (((t[burst] * 1e5).astype(np.int64)) % 2) + arr |= d << 0 # DATA D0 + arr |= (((t >= 0.10) & (t < 0.15)).astype(np.uint8)) << 4 # RST D4 + arr |= ((t >= 0.05).astype(np.uint8)) << 2 # VCC D2 + # two floating pins: thin blips scattered across the whole window + for ch, offs in ((1, [0.02, 0.09, 0.21, 0.33, 0.41, 0.47]), + (7, [0.01, 0.06, 0.18, 0.27, 0.38, 0.49])): + for o in offs: + i0 = int(o * n) + arr[i0:i0 + 40] |= (1 << ch) + v, notes = _classify_arr(arr, sr) + got = {verdict.split()[0]: ch_name(c) for c, (verdict, conf) + in v.items() if conf != 'ambiguous'} + want = {'CLK': 'D5', 'DATA': 'D0', 'RST': 'D4', 'VCC': 'D2'} + ok = all(got.get(k) == want[k] for k in want) + noisy_flagged = sum(1 for _c, (_vv, cf) in v.items() if cf != 'ambiguous' + ) >= 4 + print('selftest floating-noise : %s (got %s)' + % ('PASS' if ok else 'FAIL', got)) + if not ok or not noisy_flagged: + failures.append('floating-noise case') + + # Case 6: _vcc_consensus gating (the multi-segment VCC vote). + # Floating pins flap between channels across segments and must never + # clear the bar; only a pin consistently classified VCC in a strict + # majority (and >= 2 segments) is accepted. + taken = {1, 6, 0} # CLK, DATA, RST + base = {1: ('CLK', 'high'), 6: ('DATA', 'high'), 0: ('RST', 'medium')} + seg_d2 = {**base, 2: ('VCC', 'low')} + seg_d5 = {**base, 5: ('VCC', 'low')} + seg_d4 = {**base, 4: ('VCC', 'high')} + ok = (_vcc_consensus([base, base], taken) is None # no VCC ever + and _vcc_consensus([seg_d2], taken) is None # single segment + and _vcc_consensus([seg_d2, seg_d5], taken) is None # flapping D2/D5 + and _vcc_consensus([seg_d4, seg_d4], taken) == 4 # consistent + and _vcc_consensus([seg_d4, seg_d4, seg_d2], taken) == 4) # 2/3 + print('selftest vcc-consensus : %s' % ('PASS' if ok else 'FAIL')) + if not ok: + failures.append('vcc-consensus case') + + if failures: + sys.exit('SELFTEST FAILED: %s' % ', '.join(failures)) + print('selftest OK') + + +# ------------------------------------------------------------------ main -- + +def main(): + # Windows consoles default to legacy codepages that cannot render + # the ✓/✗ markers used in summaries; degrade gracefully instead of + # raising UnicodeEncodeError mid-report. + try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + except Exception: + pass + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('capture', nargs='?', help='.sr capture file') + ap.add_argument('--live', action='store_true', + help='probe live (default when no capture file is given)') + ap.add_argument('--samplerate', default='24M') + ap.add_argument('--seconds', type=float, default=3.0, + help='segment length in seconds (default: %(default)s)') + ap.add_argument('--max-segments', type=int, default=20, + help='stop after this many segments (default: %(default)s)') + ap.add_argument('--max-samples', type=int, default=400_000_000, + help='cap for offline captures (default: %(default)s)') + ap.add_argument('--selftest', action='store_true') + args = ap.parse_args() + + if args.selftest: + selftest() + return + if args.capture and args.live: + ap.error('pass either a capture file or --live, not both') + if args.capture: + arr, samplerate, nch = load_sr(path=args.capture, + max_samples=args.max_samples) + print('loaded %s: %d samples @ %s, %d channels\n' + % (args.capture, len(arr), samplerate, nch)) + analyse(arr, samplerate, nch) + else: + # Live probing is the primary use -- bare invocation suffices. + probe_loop(args.samplerate, args.seconds, args.max_segments) + + +if __name__ == '__main__': + main() diff --git a/tools/signal_diagnostic.py b/tools/signal_diagnostic.py new file mode 100644 index 0000000..0a2e12f --- /dev/null +++ b/tools/signal_diagnostic.py @@ -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() diff --git a/tools/vs_reader.py b/tools/vs_reader.py new file mode 100644 index 0000000..ccbdcfe --- /dev/null +++ b/tools/vs_reader.py @@ -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 + python3 tools/vs_reader.py --pcap-only (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(' 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()