Compare commits

...
16 Commits
Author SHA1 Message Date
catarrh 3b54e130c2 README: call the tapped endpoint a device in the diagrams
The right-hand node is not necessarily a card reader: the tap works with
any device, and phone captures are the main use case.  Rename it to
"Device" / "Устройство" in both diagrams and in their alt text and
descriptions.
2026-10-28 00:33:57 +03:00
catarrh 6e663dcdcb README: draw the signal path as SVG diagrams
Replace the ASCII signal chain in both READMEs with a rendered diagram
(resources/diagram_en.svg / resources/diagram_ru.svg): SIM card <-> reader
with the passive tap on the contacts, the FX2 logic analyzer, sigrok-cli
with the iso7816 decoder, GSMTAP UDP :4729, and the SIMtrace Analyser /
Wireshark consumers.

The SVGs are hand-authored, self-contained (no scripts, no external
references), carry <title>/<desc>, and each README embeds its own language
variant with the chain repeated in the alt text.

Release packaging: both diagrams are added to the archive allowlist.  The
shared-image equality check ignores them (the READMEs embed different
files by design), while every other resources/ photo must still match
across the two READMEs.  AGENTS.md documents the exception.
2026-10-28 00:04:53 +03:00
catarrh 8214089b2d Release notes: point at the renamed README section
The README's "Installation" heading became "Install and start" in the
install-first restructure; the generated release notes still referenced
the old title.  Text-only change in the workflow.
2026-10-27 23:37:59 +03:00
catarrh 9112eafec0 README: subtitle, language switcher, related tools
- Give the H1 a one-line description ("a passive ISO 7816 sniffer" /
  "пассивный ISO 7816-сниффер").
- Replace the Russian file's top "English version" note with a symmetric
  language line under the title in both files (English | Русский /
  English | Русский).
- Add a "Related tools" / «Связанные инструменты» section before the
  license: SIMple, SIMtrace Analyser and this project, with a note that
  each is installed independently and links to the shared website and
  Telegram channel.

Docs only; no decoder change.
2026-10-27 23:37:58 +03:00
catarrh 847b49ec12 README: drop the FX2 8-bit/24 MHz samplerate explanation 2026-10-27 11:20:31 +03:00
catarrh a385648a80 README: install/start first, remove history-heavy sections
- Move installation/startup to the top (after a short intro): download
  (Linux tar.gz / Windows zip) and source checkout, each ending with the
  setup/start commands.
- Usage (start options, wiring, pin mapping, clock modes, troubleshooting,
  raw sigrok-cli) follows the install section instead of preceding it.
- Remove "Robustness against noisy signals" (decoder internals, not
  end-user information).
- Remove historical explanations (early-version failures, "used to",
  before/after fix counts) from troubleshooting, clock modes, capture
  scenarios, cold-boot, validation and data-rate notes.
- Rewrite live-capture troubleshooting: early stopping (especially on
  Windows) is commonly the wrong USB driver - the Saleae Logic driver
  works only with Saleae Logic software, sigrok needs WinUSB (Zadig) -
  with links to sigrok's Windows/FX2 documentation; keep the current
  device-not-found, USB-bandwidth and auto-restart guidance.
- Move the tap photos below Usage; features/reference/development sections
  follow.  Russian README mirrors the same structure and edits.

Docs only; no decoder or release change.
2026-10-27 10:48:42 +03:00
catarrh 8e36419143 README: add the project website next to the Telegram channel 2026-10-26 22:11:37 +03:00
catarrh 50fe1bc51b README: add the project Telegram channel link 2026-10-23 21:43:36 +03:00
catarrh 2e3a563824 Release notes: prepend both install ways to generated notes
The release page now leads with the two supported installation paths —
release package (Linux tar.gz / Windows zip, setup/start, SHA256SUMS) and
source checkout for development — before GitHub's generated changelog.
The block is written by the workflow (`--notes-file` + `--generate-notes`),
so every future v* release carries it.  The v1.9.7 release body is updated
manually to the same text; no code or version change.
2026-10-21 00:04:19 +03:00
catarrh 1cbafe715c Release packaging: archives, workflow, download-first docs, Windows notes
release / release (push) Canceled after 0s
- tools/build_release.sh: builds the Linux tar.gz and Windows zip from a
  git ref (default HEAD) using an explicit `git archive` pathspec
  allowlist: decoder (__init__.py, pd.py, gsmtap_stream.py), setup/start
  scripts, tools/vs_reader.py, READMEs, LICENSE and the README-referenced
  resources/ images.  v* tags must match pd.py VERSION.  Both archives
  are listed and extracted and checked for VERSION, py_compile, bash -n,
  .bat CRLF / .sh LF and tar exec bits; SHA256SUMS uses sha256sum or
  shasum.  Builds into a staging dir and only then replaces dist/.
- .github/workflows/release.yml: on a v* tag push run the unit tests,
  verify tag == pd.py VERSION, build, then upload into an existing
  release or create a draft and publish it; release-<tag> concurrency.
- README/RUS: Installation documents both ways (release download
  recommended; source checkout for development), SHA256SUMS verification,
  and the Windows prerequisites verified against sigrok.org (0.7.2
  release installer as the tested version, nightly caveat, VC++ 2010
  redist, bundled fx2lafw firmware and Zadig, per-port driver binding).
- start.bat: accept --opt=value alongside the original --opt value form
  (the usage text advertised the = form while the parser required a
  space); usage corrected and notes the core subset.
- .gitignore: dist/.
- AGENTS: release packaging, build verification and release process
  (commit docs/workflow first, then tag and push, auto-generated notes).

No decoder behavior change; VERSION stays 1.9.7.
2026-10-20 23:32:05 +03:00
catarrh 6e6f465841 v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling
The audit's P2 items:

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

Validation: 130/130 unit tests (13 new: 9E/9F SW1, parity BAD_FCS,
T=1 I/R/S blocks + M chain + truncations + one-block-per-step, PPS
truncations, truncated ATR TA, samplerate/ETU-floor vectors); the
10-trace suite is unchanged from v1.9.6 (identical APDU/CHKSUM/CONCAT/
BAD_FCS counts, RESULT: OK); compare_baseline.sh reports IDENTICAL.
VERSION -> 1.9.7.
2026-10-20 19:02:20 +03:00
catarrh 3e39032802 v1.9.6: ETU lock validation, hunt recovery, resync + ATR acceptance
The audit's P1 robustness findings:

- _etu_candidate() validates a measured burst: requires >=2 usable
  spacings, rejects a guard-dominated burst (all-zero data: 10-ETU LOW
  runs + a 2-5 ETU HIGH guard) and a lone 10-ETU spacing.  A failed
  measurement leaves bit_samples unset instead of committing the
  clock_skip*3 fallback.
- _confirm_etu() no longer counts a dropped frame (empty bits) as
  parity-OK, and a rejected measurement clears bit_samples,
  _etu_measured and _clock_skip_confident (was: kept sampling at the
  rejected rate).
- Hunt ETU recovery keys off _etu_measured: handle_atr pre-seeds
  bit_samples, so the old "bit_samples is None" condition never fired.
- resync_idle() waits on the DATA idle level (plus RST/VCC edges,
  bounded) instead of spinning on wait({'skip': 0}) at one sample.
- ATR acceptance: a DATA-state 0x3B/0x3F is a candidate only before the
  first APDU (_apdu_seen); a parsed ATR is ignored on any parity error
  or when it is a bare TS+T0.  A data run misread at a frame boundary
  could fabricate an ATR and force clock_skip=372 for the capture.
- Inverse convention per ISO 7816-3 7.1/8.1: idle HIGH and a falling
  start bit in both conventions, msb-first inverse data, raw moments
  5-7 TS-pattern detection (a real inverse TS reads as 0x03 through
  the direct reader), and a TS parity exemption (the inverse pattern
  is odd over moments 2-10).  Spec-verified; no inverse capture in the
  corpus.

Validation: 117/117 unit tests (18 new); the 10-trace suite is
unchanged from v1.9.5 (APDU/CHKSUM/CONCAT/BAD_FCS identical,
RESULT: OK everywhere); compare_baseline.sh reports IDENTICAL.
VERSION -> 1.9.6.
2026-10-20 18:02:14 +03:00
catarrh b574f900a1 v1.9.5: T=0 framing — immediate SW, short responses, Le=256
- Decode the first post-header byte as SW1 before the ACK/data decision:
  a case-2 rejection (6A82/6CXX) carries no ACK and used to be read as
  command data, silently consuming the exchange and the next one.
- Short responses (Lr < Le): after the P3-byte read, split the buffer at
  the first plausible SW1 followed by a plausible CLA, replay the tail and
  flag BAD_FCS; previously the SW scan discarded the real SW, merged the
  following exchanges and lost the exchange at EOF.
- P3=0 is Le=256 for an outgoing command: keep the response data (read
  until SW) instead of discarding it, including data bytes equal to INS.
- ~INS ACK: one data byte per ACK via the new helpers; case-4 inline data
  is appended (permissive; ISO 7816-3 §12.2 maps case 4 to GET RESPONSE).
- tests: four TestDecodeStep vectors (immediate SW, short read + split +
  replay, Le=256 data incl. an INS-valued byte); 99/99.
- AGENTS v1.9.5 + T=0 section, README/README_RUS bullets; baseline
  regenerated (xiaomi_mi_a1_coldboot 473 -> 475 APDUs, all else identical);
  compare_baseline: IDENTICAL.
2026-10-20 17:07:02 +03:00
catarrh 883033e712 v1.9.4: decode_step/hunt byte-replay tests + hunt EOF guard
- tests: TestDecodeStep drives decode_step() with a scripted byte queue
  (read/peek/consume stubbed over _replay + the queue; the re-align/PPS/
  ATR/annotation sinks stubbed): 'CX'/'EX' exchanges frame and emit clean,
  0x5F frames as a command (it was always eaten by the idle-noise gate
  before v1.9.3), the 0x61 text-byte boundary is a one-byte suspicious-CLA
  discard, the invalid-INS re-frame recovers a shifted SELECT, 0xFE still
  takes the idle-noise path.
- tests: TestAtrHuntDetect drives handle_atr(): 0xC4 0xA4 / 0xC4 0x89
  hand the header to DATA via _replay, CLA 0x00 stays excluded, an invalid
  INS does not hand off.
- pd.py: the hunt command-detect guards peek_byte() returning None (a
  capture ending right after a plausible CLA byte raised TypeError, seen
  only by decode()'s crash protection);
  test_eof_after_plausible_cla_is_clean pins the clean return.
- AGENTS v1.9.4 section; baseline regenerated (version + test listing;
  compare_baseline: IDENTICAL); 95/95 unit tests.
2026-10-18 02:22:15 +03:00
catarrh 54dfc6586d v1.9.3 review fixes: CLA doc ranges, README_RUS, mirror/ODD_INS pins
- pd.py/AGENTS: the rejected '8X' range now reads 0x81-0x83/0x85-0x8F
  (0x84 stays accepted); the 0x40-0x4F vs 0x50-0x5F citations are split
  (chaining is ISO-only, TS 102 221 keeps b5=0); the bit-dense CLA list
  gains 0xE7 (exact set verified).
- README_RUS.md: the invalid-INS note mirrored (the removed odd-LSB rule,
  citation corrected to 5.1.2).
- tests: test_vs_reader_mirror (pd.py <-> tools/vs_reader.py drift guard),
  test_odd_ins_set_pinned, the 0xE7 noise vector, and the 'EX' vector now
  uses STATUS (e2 f2) as TS 102 221 Table 10.5 defines.
- tests/baseline.txt regenerated (test listing only); compare_baseline:
  IDENTICAL.
2026-10-18 00:00:08 +03:00
catarrh 9f42a88c01 v1.9.3: extended-channel CLA classes + defined odd INS in the acceptance gates 2026-10-17 23:29:09 +03:00
13 changed files with 2999 additions and 1218 deletions

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Unit tests
run: python3 tests/test_gsmtap.py
- name: Verify tag matches pd.py VERSION
shell: bash
run: |
set -euo pipefail
version=$(sed -n "s/^VERSION = '\([0-9][0-9.]*\)'.*/\1/p" pd.py)
if [ -z "$version" ]; then
echo "::error::cannot read VERSION from pd.py"
exit 1
fi
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match pd.py VERSION '$version'"
exit 1
fi
echo "tag $GITHUB_REF_NAME matches pd.py VERSION $version"
- name: Build release archives
run: ./tools/build_release.sh "$GITHUB_REF_NAME"
- name: Write the install notes
shell: bash
run: |
set -euo pipefail
version=$(sed -n "s/^VERSION = '\([0-9][0-9.]*\)'.*/\1/p" pd.py)
notes="$RUNNER_TEMP/release-notes.md"
cat > "$notes" <<'EOF'
Runtime release - the decoder, setup/start scripts, docs and README
images; no git history and no development files.
**Install - release package (recommended)**
- Linux: `sigrok_iso7816_stream-<VERSION>.tar.gz`
- Windows: `sigrok_iso7816_stream-<VERSION>.zip`
Extract the archive, then run `./setup.sh` + `./start.sh` (Linux) or
`setup.bat` + `start.bat` (Windows). `SHA256SUMS` (attached) verifies
both downloads. Requires sigrok-cli and the FX2 firmware; see the
README *Install and start* section for the full prerequisites.
**Install - source checkout (development)**
`git clone https://github.com/anttro/sigrok_iso7816_stream.git`, then
the same setup/start scripts. The clone adds `tests/`, the baseline
tools and the (local-only) example traces; the release package is the
runtime tree.
EOF
sed -i "s/<VERSION>/$version/g" "$notes"
- name: Create or update the GitHub release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version=$(sed -n "s/^VERSION = '\([0-9][0-9.]*\)'.*/\1/p" pd.py)
notes="$RUNNER_TEMP/release-notes.md"
# Upload into an existing release first. If the release does not
# exist yet, create it as a draft so the published release never
# appears with a partial asset set. The install notes are prepended
# to GitHub's generated changelog.
if ! gh release upload "$GITHUB_REF_NAME" dist/* --clobber; then
gh release create "$GITHUB_REF_NAME" dist/* --draft \
--title "sigrok_iso7816_stream $version" \
--notes-file "$notes" --generate-notes
fi
# Publish a new draft and re-publish an existing one.
gh release edit "$GITHUB_REF_NAME" --draft=false
+3
View File
@@ -4,3 +4,6 @@ __pycache__/
# Trace fixtures are local-only (large, machine-specific); never tracked. # Trace fixtures are local-only (large, machine-specific); never tracked.
examples/ examples/
# Release archives built by tools/build_release.sh.
dist/
+415 -86
View File
@@ -142,10 +142,10 @@ the reader log for all reader exchanges:
Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK. Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK.
**Root cause:** `_edge_read` is initialized to `True` and never set to **Root cause:** `_edge_read` was initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop (lines 1808–1895) always `False`, so the edge-read T=0 procedure loop always executed and the
executes — the ATR-based procedure loop (lines 1898–1941) is dead code. ATR-based procedure loop was dead code. **FIXED (v1.9.7):** the flag and
The edge-read path had two bugs: the dead loop are removed. The edge-read path had two bugs:
1. **NULLs treated as command data (FIXED).** The first post-header byte 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 was checked for ACK; if it was NULL (0x60), it entered the case-3/4
@@ -160,14 +160,30 @@ The edge-read path had two bugs:
#### CLA validation note #### CLA validation note
All example traces (`test_7`, `test_8`, `xiaomi`, `samsung`) `plausible_cla()` (v1.9.3) accepts the higher logical channels:
contain only CLA `0x00` (interindustry) and `0x8x` (USIM/CAT, mostly `0x80`
with occasional `0x81` for logical channel 1). - interindustry `0x00`-`0x1F` (channels 0-3 in b2b1) and `0x40`-`0x5F`
The decoder must **not** mandate specific CLA values — other CLA values (`01xx xxxx`: channels 4-19 in b4..b1, no CLA-level SM; `0x50`-`0x5F`
(e.g. `0x04`, `0x08`, `0x84`, `0xA0`, `0xB0`) are valid per ISO 7816-4 / 3GPP are the legacy chaining variants);
and must be accepted. However, during test runs against the example traces, - the UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`,
any CLA value outside `0x00-0x0F` and `0x80-0x8F` is an error flag indicating channels 4-19, SM in b6 -- TS 102 221 Table 10.4a);
the decoder mis-framed the byte stream. - `0x80`/`0x84`/`0xA0`/`0xB0` (0xB0 has no pinned-spec basis; kept for
backward compatibility).
Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (the
SM-indicating extended classes -- `0x60`-`0x6F` overlaps printable ASCII
and the text byte `0x61` started a bogus frame on xiaomi_mi_a1_coldboot
when it was accepted); `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (the
`'8X'`/`'AX'` standard-channel variants for channels 0-3 other than the
legacy `0x80`/`0x84`/`0xA0` -- the FCP byte `0xA5` did the
same); `0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`. They are spec-coded
but cost frame-start precision; add any of them back when a real trace
requires it.
During test runs such a CLA is an error flag indicating the decoder
mis-framed the byte stream. The example traces carry `0x00`
(interindustry), `0x01` (logical channel 1 -- the SELECT-by-AID after
MANAGE CHANNEL opens it) and `0x80` (USIM/CAT); no trace uses channels 2-3
or the higher channels yet.
#### ISO 7816 APDU validation (enforced from v1.1.7) #### ISO 7816 APDU validation (enforced from v1.1.7)
@@ -179,10 +195,18 @@ Validation is purely structural (no content scanning of command/response
data), so the false-positive rate is very low. The checks are: 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). 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`, 2. CLA byte is a structurally valid ISO 7816-4 / UICC class — see
`0x84`, `0xA0`, `0xB0`). `plausible_cla()` (v1.9.3): `0x00`-`0x1F`, `0x40`-`0x5F` (logical
channels 4-19), `0xC0`-`0xCF`/`0xE0`-`0xEF` (`'CX'`/`'EX'`), plus
`0x80`/`0x84`/`0xA0`/`0xB0`.
3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word 3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word
collision), and the least-significant bit is `0`. collision). Odd INS codes are accepted only as the defined alternate
variants (ISO 7816-4 Table 4 pairs: `'0F'`, `'21'`, `'87'`, `'A1'`,
`'B1'`, `'B3'`, `'C3'`, `'CB'`, `'D1'`, `'D7'`, `'DB'`, `'DD'`;
TS 102 221 AUTHENTICATE `'89'`). Other odd values stay rejected: a
mis-framed boundary exposes data bytes, and the text run `49 4D`
("IM") started a bogus frame on xiaomi_phone_sample when every odd INS
was accepted.
4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`. 4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`.
`0x60` is explicitly invalid: it is the T=0 NULL procedure byte `0x60` is explicitly invalid: it is the T=0 NULL procedure byte
(ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**; (ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**;
@@ -309,6 +333,232 @@ Known limitation: no on-disk fixture reproduces the exact live failure (all
real captures contain a 372 ATR), so end-to-end proof needs a re-captured real captures contain a 372 ATR), so end-to-end proof needs a re-captured
`.sr` from the failing phone; the unit tests cover the loop-escape logic. `.sr` from the failing phone; the unit tests cover the loop-escape logic.
### v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling
The audit's P2 items:
- **Odd INS set completed.** `0x73` MANAGE SECURE CHANNEL and `0x75`
TRANSACT DATA (TS 102 221 Table 10.5) join `ODD_INS`; the mirrored set in
`tools/vs_reader.py` and the pinned test are updated.
- **9E/9F are SW1.** ISO 7816-3 §10.3.3 Table 11: every `9X` value is
SW1. The fabricated "extended-length procedure byte" (it consumed the
next byte as a length, eating SW2 and losing the status word) is gone.
The second copy lived in the dead ATR-based loop removed below.
- **T=1 blocks.** `t1_parse_block()` EOF-guards every read (a truncated
block returns no half INF instead of raising) and bounds the M-bit chain
with `T1_CHAIN_MAX` instead of unbounded recursion. The caller's
duplicate "chain follow-up" loop -- it read the *next* block after every
I-block, usually the peer's R-block, and merged the following I-block's
INF into the packet -- is gone. R/S blocks emit nothing; the (possibly
chained) I-block INF is emitted as the APDU payload. No T=1 trace
exists in the corpus, so this is unit-tested only.
- **Parity flag.** A packet containing a parity-error byte is now emitted
with `GSMTAP_FLAG_BAD_FCS` (the CHKSUM annotation alone was invisible to
downstream tooling), with a log note when the exchange is otherwise
clean.
- **EOF guards.** `handle_atr()` (TA/TB/TC/TD, historical bytes, TCK) and
`handle_pps()` (request and response fields, including the optional
PPS1-3) return cleanly on a truncated capture instead of raising on a
None byte; the T=1 block parser likewise.
- **Dead code removed.** `_edge_read` was initialized `True` and never
cleared, so the "ATR-based" T=0 procedure loop (58 lines) never ran and
the 1.5-ETU guard branch was unreachable. The flag and both dead paths
are removed; the edge reader is the only T=0 framing reader (the
CLK-sync reader is selected by `_clock_skip_confident`). The live loop
was dedented, not rewritten -- the trace metrics are unchanged.
- **Samplerate scaling.** `_samples_for_us()` replaces the hard-coded
16 MHz thresholds: the start-bit deglitch (`_min_pulse_samples`; `None`
= 2 samples @ 16 MHz), the `min_high` floor (0.25 us) and the ETU glitch
floor in `_etu_candidate` (0.75 us). At 16 MHz the values are
unchanged; another capture rate now scales instead of breaking the
filters.
Validation: 130/130 unit tests (13 new: 9E/9F SW1 for both values, the
parity BAD_FCS flag, T=1 I/R/S blocks + M chain + two truncation cases
+ a one-block-per-step caller test + two PPS truncations, the truncated
ATR TA, the samplerate/ETU-floor vectors); the 10-trace suite is
unchanged from v1.9.6 (identical
APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.7.
### v1.9.6: ETU lock validation, hunt recovery, resync + ATR acceptance
The audit's P1 findings. The validation helpers are unit-tested; the trace
suite stays at the v1.9.5 metrics.
- **ETU measurement validation (`_etu_candidate`).** The mid-session
estimator used to lock the inter-character guard as the ETU on all-zero
data: a 0x00 run gives 10-ETU LOW runs plus a short HIGH guard, so the
smallest recurring spacing is the guard (2x-3x the true ETU), and a lone
0x00 character's single 10-ETU spacing is 10x. A burst now needs at
least two usable spacings, and a burst whose minimum recurrence is a HIGH
guard with every other spacing >= 2x it is rejected -- the decoder waits
for a burst with bit transitions (this covers the 2-5 ETU guard range).
A failed measurement leaves `bit_samples` unset instead of committing the
old `clock_skip*3` guess.
- **Invalidation.** `_confirm_etu()` no longer counts a dropped frame (no
genuine start -> empty bits) as parity-OK, and a rejected measurement now
clears `bit_samples`, `_etu_measured` and `_clock_skip_confident` (the old
code kept sampling at the rejected rate).
- **Hunt ETU recovery.** `handle_atr()` pre-seeds `bit_samples` with the
default-rate guess while the recovery condition tested `bit_samples is
None`, so the v1.6.0 live-ETU recovery could never fire from the hunt.
The condition now keys off `_etu_measured` (a real DATA measurement); a
measured rate still suppresses the recovery.
- **`resync_idle()`** now actually waits: it used `wait({'skip': 0})` in a
loop (the same sample 48 times -> a no-op). It now waits on the DATA idle
level (plus RST/VCC edges, bounded) before the next peek.
- **ATR acceptance.** A DATA-state `0x3B`/`0x3F` is an ATR candidate only
before any APDU has been emitted (`_apdu_seen`); a parsed ATR is rejected
when any of its bytes had a parity error or when it is a bare TS+T0
(`3B 00`). A data run misread at a frame boundary used to be committed
as an ATR (a fabricated `3b80900000` appeared while developing this
change), forcing `clock_skip=372` for the rest of the capture. Real
warm-reset ATRs are parity-clean and commit as before.
- **Inverse convention (TS=0x3F).** The readers assumed an idle-LOW line
and a rising start bit; ISO 7816-3 7.1 keeps the line idle HIGH and the
start bit LOW in both conventions -- only the data polarity and order
change. `wait_data_falling`/`resync_idle` now use the HIGH idle for
inverse too, `get_bytes()` assembles the inverse data msb-first, and the
DATA state accepts `0x3F` alongside `0x3B`. The data readers already
inverted the bit polarity. A real inverse TS decodes as `0x03` through
the direct reader, so `ts_from_bits()` matches the raw ISO 7816-3 8.1
pattern (moments 1-4 LHHL, 5-7 LLL/HHH, 8-10 LLH) in both the hunt and
the DATA state instead of trusting the decoded byte. The readers exempt
a matching TS from the parity check: its moment 10 is fixed by the
pattern, and the inverse TS is legitimately odd over moments 2-10, so it
must not log a CHKSUM or reject the ATR. No inverse-convention capture
exists in the corpus, so this is spec-verified, not trace-verified.
Validation: 117/117 unit tests (18 new: the ETU candidate vectors, the
hunt-recovery seeded/measured cases, the resync level wait, the bare-ATR
and parity-ATR guards, the `_apdu_seen` gate both ways, the inverse
bit-order/start-edge/TS-pattern vectors); the 10-trace suite is unchanged
from v1.9.5 (identical APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK`
everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.6.
### v1.9.5: T=0 framing — immediate SW, short responses, Le=256
The audit's P0 framing fixes, each pinned by a `TestDecodeStep` vector:
- **Immediate SW.** The first post-header byte is classified as a status
word before the ACK/data decision. ISO 7816-3 §10.3.3 lets the card
finish at the first procedure byte; a case-2 rejection (`6A82`, `6CXX`,
...) carries no ACK and used to be read as command data (the exchange
and the following one were silently consumed).
- **Short responses (Lr < Le).** The ACK path still reads the P3 bytes,
but if the next byte is not SW1 the buffer is split at the first
plausible SW1 followed by a plausible CLA, the tail is replayed into
`_replay`, and the packet is emitted with `BAD_FCS`. Previously the SW
scan discarded the real SW, merged the following exchanges into a
clean-looking packet, and at EOF lost the exchange silently. A short
read with no plausible split is emitted flagged and resynced.
- **P3=0 = Le=256.** For an outgoing command P3=0 requests 256 bytes
(§10.3.2). The response data used to be discarded; it is now read until
SW and kept. The read-until-SW ambiguity on data bytes that look like
SW1 remains (inherent to T=0 sniffing).
- **~INS ACK.** One data byte per ACK (Table 11) via the new
`_t0_read_response_data()` helper, instead of "all remaining".
- **Case 4.** ISO 7816-3 §12.2/Table 14 map case 4 to a separate GET
RESPONSE; the ACK path now appends any inline data instead of dropping
it (permissive extension; conforming cards send SW/61XY there).
Validation: 99/99 unit tests; the 10-trace suite is unchanged except
`xiaomi_mi_a1_coldboot` 473 -> 475 APDUs (two previously lost exchanges
recovered), 0 CHKSUM/CONCAT/BAD_FCS/GARBAGE, `RESULT: OK`; all other traces
identical; `compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.5.
### v1.9.4: decode_step/hunt unit coverage + hunt EOF guard
The v1.9.3 review's residual risk -- the new acceptance classes were only
proven by "no regression" on the example traces -- is closed with a
byte-replay harness, and the new coverage surfaced one latent defect:
- **`TestDecodeStep`** drives the real `decode_step()` DATA-state gate chain
with a scripted byte queue (`read_byte`/`peek_byte`/`_consume_first`
stubbed over `_replay` + the queue; the re-align/PPS/ATR/annotation sinks
stubbed; `reset()` provides the state). Pinned: `'CX'`/`'EX'` exchanges
frame and emit clean; `0x5F` (always eaten by the idle-noise gate before
v1.9.3) frames as a command; the `0x61` menu-text boundary byte is a
suspicious CLA with exactly one byte consumed; the invalid-INS one-byte
re-frame recovers a shifted SELECT into the intended packet; `0xFE` still
takes the idle-noise path.
- **`TestAtrHuntDetect`** drives `handle_atr()` the same way: `0xC4 0xA4`
and `0xC4 0x89` (the odd-INS acceptance) hand the header to DATA via
`_replay`; CLA `0x00` stays excluded; an invalid INS does not hand off.
- **Bug found and fixed:** the hunt's command-detect called
`plausible_ins(nxt)` with no None check -- a capture ending right after a
plausible CLA byte made `peek_byte()` return None and raised TypeError
(previously seen only by `decode()`'s crash protection).
`test_eof_after_plausible_cla_is_clean` pins the clean end-of-capture
return.
Validation: 95/95 unit tests; the new framing/hunt tests fail against
v1.9.2 for the right reasons (the acceptance is missing) and the EOF test
fails against v1.9.3 (the TypeError); full trace suite unchanged
(`compare_baseline.sh` IDENTICAL, baseline regenerated for the version +
test listing). VERSION -> 1.9.4.
### v1.9.3: higher logical channels in the CLA gate + defined odd INS codes
A trace with higher logical channels (4-19) or an odd-INS command could not
decode: the acceptance gates knew neither. Both are now spec-derived, with
the empirical precision of the mis-frame filter preserved.
- **Higher logical channels (4-19).** TS 102 221 §10.1.1 Table 10.4a codes
them `'01x0 xxxx'` (`0x40`-`0x4F` without SM, `0x60`-`0x6F` with SM in b6)
and `'11x0 xxxx'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`, the `'CX'`/`'EX'`
classes) -- ISO 7816-4 §5.1.1 Table 3 is the same with b5 = chaining.
`plausible_cla()` now accepts the `'CX'`/`'EX'` classes alongside the
previous `0x40`-`0x5F`, so extended-channel commands (e.g. STATUS on
channel 4 = `0xC0`) and higher channels in the no-SM `'4X'` form decode.
Rejected after testing: `0x60`-`0x6F`/`0x70`-`0x7F` (the SM-indicating
interindustry classes) and the `'8X'`/`'AX'` channel variants
`0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF`. They are spec-coded, but
they cost
frame-start precision on the mis-framed traces: `0x60`-`0x6F` overlaps
printable ASCII, and the menu-text byte `0x61` ('a') started a bogus
frame on xiaomi_mi_a1_coldboot whose P3 swallowed the following exchanges
(a CONCAT regression); the FCP byte `0xA5` did the same for `'AX'`.
Previously such stray bytes were discarded as "suspicious CLA" and the
decoder resynced; the wider classes turned them into headers. Add the
classes back when a real trace needs them.
- **Odd INS.** The gates rejected every odd INS code, so TS 102 221
AUTHENTICATE `'89'` (chunked authentication data) and the ISO 7816-4
Table 4 alternate variants mis-framed. `plausible_ins()` now accepts
exactly the 13 defined odd codes (`ODD_INS`: `0F 21 87 89 A1 B1 B3 C3
CB D1 D7 DB DD`) and still rejects the rest: accepting every odd value
let the text run `49 4D` ("IM") start a bogus frame on
xiaomi_phone_sample. The header check calls the same `plausible_ins()`
now (the duplicate inline condition is gone) and the stale ISO `§12.1.1`
citation was corrected to `§5.1.2`.
- **Idle-noise gate.** The 6+ set-bits discard runs before the CLA check,
so it would swallow the legitimately bit-dense classes (`0xCF`, `0xE7`,
`0xEB`, `0xED`-`0xEF`) and kept `0x5F` dead (`plausible_cla()` accepted
it, the gate always discarded it). The gate is now
`is_idle_noise_byte()`: only bytes no spec allows are idle noise; valid
CLAs take the normal header path.
- **CONCAT detector unchanged by design.** `EMBEDDED_CLA` no longer
derives from `plausible_cla()`; it is pinned to the empirically tuned 36
values (`0x00`-`0x1F` + `0x80`/`0x84`/`0xA0`/`0xB0`) in both `pd.py` and
`tools/vs_reader.py`, with a membership-pin test and a `pd.py` ↔
`vs_reader.py` mirror test -- the CONCAT scan runs
across payload bytes where the extended classes false-hit (the Alcor
`"40 70 70 61 15"` run).
- **Tests.** CLA accept/reject vectors (including the two regressing bytes
`0x61`/`0xA5` and the reserved ranges), extended-channel
`validate_t0_apdu`/`is_desynced` vectors, the odd-INS vectors, the
idle-noise vectors, the EMBEDDED_CLA membership pin, the `ODD_INS` set
pin and the `pd.py` ↔ `vs_reader.py` mirror test (75 -> 84 tests);
`tools/vs_reader.py` mirrors the predicate, `ODD_INS` and the pinned set.
Validation: all 10 traces identical to the v1.9.2 baseline (APDU counts,
CHKSUM/CONCAT/BAD_FCS/suspicious-CLA counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. The xiaomi CONCAT regressions the
wider sets produced were found by this loop and narrowed away before the
baseline was regenerated (version + test listing only). VERSION -> 1.9.3.
### v1.9.2: PPS CLK upshift + exact CLK-edge projection (new reader support) ### v1.9.2: PPS CLK upshift + exact CLK-edge projection (new reader support)
Two timing bugs surfaced by `examples/alcor_reader_sample.sr`, a PC/SC reader Two timing bugs surfaced by `examples/alcor_reader_sample.sr`, a PC/SC reader
@@ -503,8 +753,9 @@ the source**, not just gated. All 10 traces report 0 CONCAT:
| S21p_coldboot_new_cable_sim2 | 375 | 0 | | S21p_coldboot_new_cable_sim2 | 375 | 0 |
1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes 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 `plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or an
LSB — ISO 7816-4 §12.1.1), the window is almost always shifted by one odd code outside the defined variants -- see the v1.9.3 note), the
window is almost always shifted by one
stray leading byte (e.g. a leftover SW2 read after a PACKETEND). The old 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 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 `resync_idle()` gave up on a busy burst the next byte became a bogus CLA
@@ -635,13 +886,11 @@ 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 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. (<3% of a bit period) and does not affect downstream byte reading.
**Design note: magic number.** `_min_pulse_samples = 2` is tuned for the **Design note: pulse threshold.** `_min_pulse_samples` used to be a
primary 16 MHz sample rate (2 samples ≈ 125 ns, well within the FX2LP hard-coded 2 samples, tuned for the 16 MHz corpus (≈125 ns, within the
pull-up rise time of 200–500 ns). At other sample rates the value would FX2LP pull-up rise time of 200–500 ns). Since v1.9.7 it is `None` by
need adjustment — but since all current traces are 16 MHz, this is left default and scales from `samplerate` (`_samples_for_us(0.125)` → 2 at
as a hardcoded constant rather than a decoder option. If 12 MHz or 16 MHz); a test or option may still override it (0 disables the check).
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 **Logging.** Each rejected start-bit glitch is logged with a running
count (`start-bit glitch rejected (#N)`) visible in the session log. count (`start-bit glitch rejected (#N)`) visible in the session log.
@@ -665,40 +914,54 @@ window may be filtering real start bits.
| ACK (== INS or == ~INS) | Full data match: card acknowledges and expects all P3 data bytes | | 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 | | 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 (never an SW1) | | 0x60 | NULL: card busy, wait for next procedure byte (never an SW1) |
| 0x9E / 0x9F | Extended-length: next byte L = number of response data bytes, then SW | | SW1 (`0x61`–`0x6F` / `0x90`–`0x9F`) | Status word: exchange complete. ISO 7816-3 §10.3.3 Table 11 has no exception for `9X`: `9E`/`9F` are SW1 too (the v1.9.7 fix) |
| SW1 (`0x61`–`0x6F` / `0x9X`) | Status word: exchange complete |
##### CLA (Class) Byte — Interindustry Structure ##### CLA (Class) Byte — Coding and Logical Channels
``` Accepted (v1.9.3):
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 - `000x xxxx` (`0x00`-`0x1F`): first interindustry — SM in b4b3, chaining in
- Bit 6: `0` for interindustry; `1` for proprietary structural formats b5, logical channel **0–3** in b2b1.
- Bits 4–3: Secure Messaging indicator (`00` = no SM) - `01xx xxxx`: further interindustry — logical channel **4–19**
- Bits 2–1: Logical channel (0–3 for interindustry) (b4..b1 = channel − 4). Accepted: `0x40`-`0x4F` plus the legacy
`0x50`-`0x5F` chaining variants (no CLA-level SM).
- UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`):
channels 4–19 in b4..b1, SM in b6.
- Legacy `0x80`/`0x84`/`0xA0`/`0xB0`.
Valid interindustry CLA digits: `0x`, `1x`, `4x`, `5x`. Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (SM in
b6 — `0x60`-`0x6F` overlaps printable ASCII, and text bytes started bogus
frames on the mis-framed phone traces); the `'8X'`/`'AX'` standard-channel
variants `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (channels 0–3; same
trade-off);
`0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`.
Examples: `0x00` (standard), `0x0C` (secure messaging). Examples: `0x00` (standard), `0x01` (channel 1), `0x44` (channel 8),
`0x4F` (channel 19), `0xC0` (STATUS on channel 4, `'CX'` class),
`0x0C` (secure messaging).
##### INS (Instruction) Byte — Valid Ranges ##### INS (Instruction) Byte — Valid Ranges
- Low nibble cannot be `X0` if high nibble is even - `0x00` is reserved/invalid.
- Low nibble cannot be `XF` under any circumstances - `0x6x`/`0x9x` collide with status words and are invalid.
- `0x6x` and `0x7x` ranges are invalid (reserved for SW1 / protocol bytes) - Odd INS codes are valid only as the defined alternate variants
(`0F` ERASE BINARY, `21` VERIFY, `87` GENERAL AUTHENTICATE,
`A1` SEARCH BINARY, `B1` READ BINARY, `B3` READ RECORD, `C3` ENVELOPE,
`CB` GET DATA, `D1` WRITE BINARY, `D7` UPDATE BINARY, `DB` PUT DATA,
`DD` UPDATE RECORD; TS 102 221 AUTHENTICATE `89`).
Standard interindustry INS values: `A4` (SELECT), `B0` (READ BINARY), Common INS values: `A4` (SELECT), `B0`/`B1` (READ BINARY),
`D6` (UPDATE BINARY), `B2` (READ RECORD), `E2` (APPEND RECORD), `D6`/`D7` (UPDATE BINARY), `B2`/`B3` (READ RECORD), `E2` (APPEND RECORD),
`20` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88` (INTERNAL AUTHENTICATE). `20`/`21` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88`/`89` (AUTHENTICATE),
`C2`/`C3` (ENVELOPE: CAT), `C0` (GET RESPONSE).
##### Quick Validation Checklist ##### Quick Validation Checklist
1. CLA high nibble is `0`, `1`, `4`, or `5` (for interindustry) 1. CLA is a valid class: `0x00`–`0x1F`/`0x40`–`0x5F` (interindustry, channels
2. INS low nibble is not `F` 0–3/4–19) or `0xC0`–`0xCF`/`0xE0`–`0xEF` (UICC extended) or
3. INS is outside `0x6x`–`0x7x` range `0x80`/`0x84`/`0xA0`/`0xB0`
2. INS is not `0x00`
3. INS is outside the `0x6x`/`0x9x` status-word ranges
**Current smoke-test status:** **Current smoke-test status:**
@@ -753,9 +1016,52 @@ append the measured CLK frequency when known
### Versioning ### Versioning
The decoder version is defined in `pd.py` as `VERSION = '1.9.2'`. The decoder version is defined in `pd.py` as `VERSION = '1.9.7'`.
The version is printed to the log on decoder startup. The version is printed to the log on decoder startup.
### Release packaging (v1.9.7)
The GitHub release archives let users run the decoder without cloning the
repo. `./tools/build_release.sh [REF]` (REF defaults to `HEAD`) builds
`dist/sigrok_iso7816_stream-<VERSION>.tar.gz` (Linux) and
`dist/sigrok_iso7816_stream-<VERSION>.zip` (Windows) plus
`dist/SHA256SUMS`. `VERSION` comes from the ref's `pd.py`; a `v*` REF must
equal it.
The release file set is an explicit allowlist passed to `git archive` as its
pathspec, and also compared exactly against both archive listings (missing
*and* unexpected entries fail): the decoder (`__init__.py`, `pd.py`,
`gsmtap_stream.py`), the scripts (`setup.sh`/`start.sh`,
`setup.bat`/`start.bat`), `tools/vs_reader.py`, `README.md`, `README_RUS.md`,
`LICENSE`, every `resources/` photo referenced from both READMEs, and the two
language variants of the pipeline diagram (`resources/diagram_en.svg`,
`resources/diagram_ru.svg`, added explicitly because each README embeds its
own). Tests, `AGENTS.md`, the CI config, the development tools and the
unreferenced `resources/` images never enter the release. No `export-ignore`
rules are used, so GitHub's own source archives still contain the full tree.
`dist/` is gitignored.
Both archives are unpacked and verified: `pd.py` VERSION, `py_compile` of the
three modules, `bash -n` of the shell scripts, the tar exec bits, CRLF on the
`.bat` files and no CR in the `.sh` files. The archives are built in a temp
staging dir; only after every check passes are stale `dist/` archives removed
and the new tar.gz/zip/`SHA256SUMS` moved in (a failed build leaves `dist/`
untouched, and `gh release upload dist/*` can never attach a stale version).
`.github/workflows/release.yml` runs on a `v*` tag push: unit tests
(`tests/test_gsmtap.py`), tag == `pd.py` VERSION, the build script, then
`gh release upload --clobber` (or `gh release create --draft` when there is
no release yet) followed by `gh release edit --draft=false`, with
`release-<tag>` concurrency. The workflow prepends a standard install block
to the generated notes: release package (recommended; Linux tar.gz / Windows
zip, setup/start, SHA256SUMS) and source checkout (development). **Release
process:** commit the docs, workflow and scripts first, then tag `v1.9.7` and
push the tag (with user approval) - the workflow must exist in the tagged
commit, and the archive ships the committed READMEs. The changelog itself is
GitHub's auto-generated notes from the commits; `AGENTS.md` and `tests/` are
not in the archive. Packaging/docs
only - no decoder behavior change, and `VERSION` is unchanged.
### Testing after decoder changes ### Testing after decoder changes
After any change to `pd.py`: After any change to `pd.py`:
@@ -934,23 +1240,35 @@ no `0xff`/`0xfc` garbage.
Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those 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 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: framing classifies the first post-header byte in this order (v1.9.5):
- If the first post-header byte **is the ACK (== INS)** → case 2/4: the - **SW1** (`0x61-0x6F`/`0x90-0x9F`) → the card finished with SW1 SW2 at the
terminal sent no command data; read `P3` response bytes (+SW). first procedure byte (ISO 7816-3 §10.3.3; a case-2 rejection such as
- Otherwise the byte is terminal command data → case 3/4: read `P3` `6A82`/`6CXX` carries no ACK).
command-data bytes, then the card's procedure byte (ACK/SW/`9E`/`9F`). - **ACK (== INS)** → case 2/4: the terminal sent no command data. Read the
On ACK (case 4) the response is read `P3` response bytes (+SW). `P3` response bytes, then the SW. `P3=0` is Le=256 (§10.3.2): read the
response data until the SW. If the card returns **fewer** bytes than Le
(`6282` end of file), the buffer is split at the first plausible SW1
followed by a plausible CLA, the tail is replayed and the packet is
flagged BAD_FCS (v1.9.5; previously the SW scan discarded the real SW and
the following exchanges could be merged silently).
- **~INS** → byte-by-byte transfer: one data byte per ACK, read until SW.
- Otherwise → case 3/4: the byte is terminal command data; read `P3`
command-data bytes, then the card's procedure byte (ACK/SW; `9E`/`9F`
are SW1, not an extended length).
On ACK, append any inline response data until SW (ISO 7816-3 §12.2 maps
case 4 to a separate GET RESPONSE, so conforming cards send SW/61XY
here; the append is a permissive extension).
This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope 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 commands) into one C-APDU = header + command data, R-APDU = response + SW
APDU, instead of fragmenting them across frames. It is gated to APDU, instead of fragmenting them across frames.
`_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 P3-bounded reads are kept for case 2 (`Lr == Le`): the read-until-SW
experiment from v1.1.0 which mis-identified FCP `0x62` as SW1). experiment from v1.1.0 mis-identified FCP `0x62` as SW1 and was reverted.
Where the length is unknown (P3=0, case-4 inline data) the read-until-SW
ambiguity remains — a data byte that looks like SW1 is indistinguishable
on the wire.
## Decoder methods tried — reliability registry ## Decoder methods tried — reliability registry
@@ -984,42 +1302,53 @@ discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
`clock_skip=369` before this fix). Logs "RST low at start: expecting cold-boot `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` / ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` /
`4gmodem_coldboot_sample.sr` (0 GARBAGE). `4gmodem_coldboot_sample.sr` (0 GARBAGE).
- **1.5-ETU start-bit guard.** Rejects the phantom `0x00` an idle glitch - **Half-ETU start-bit guard (edge reader).** Rejects the phantom `0x00`
inserts before an ATR. Reliable. an idle glitch inserts before an ATR while still accepting the ~1-ETU
inter-byte gap on fast ETUs (v1.9.7 removed the unreachable 1.5-ETU
CLK-sync branch). Reliable.
- **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after - **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after
≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events ≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events
from a ringing/floating sense line. from a ringing/floating sense line.
- **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the - **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the
native skip becomes F/D CLK cycles/bit. Reliable (verified live). native skip becomes F/D CLK cycles/bit. Reliable (verified live).
- **T=1 block-chain follow-up bound.** Caps chained I-blocks so garbage - **T=1 block decode (v1.9.7).** NAD/PCB/LEN/INF/LRC with the M-bit chain
cannot loop forever. Reliable. followed inside `t1_parse_block()` (bounded by `T1_CHAIN_MAX`) and every
- **Synthetic T=0 ATR fallback `[0x3B, 0x00]`.** After 8 failed ATR hunts, read EOF-guarded; R/S blocks carry no INF and are not emitted. Unit
commit a minimal ATR and set `hasT0=True`. Reliable for mid-session starts tested only (no T=1 capture in the corpus). Reliable by construction.
(no ATR in the trace). - **Hunt give-up (no synthetic ATR).** After `ATR_HUNT_FAIL_LIMIT` failed
hunts the decoder resumes DATA without fabricating an ATR (v1.6.0 removed
the old `[0x3B, 0x00]` fallback); the mid-session ETU estimator then
recovers the rate from DATA. Reliable.
- **ETU auto-detect from DATA via edge counting (`_measure_etu`).** Edge-waits - **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 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 it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking
once (not re-measuring) is drift-immune and avoids glitch re-anchors. once (not re-measuring) is drift-immune and avoids glitch re-anchors. A
burst must yield >=2 usable spacings, and a guard-dominated burst (all-zero
data: long LOW runs + short HIGH guard) is rejected (`_etu_candidate`,
v1.9.6); a rate that fails parity is invalidated and re-measured.
- **Edge-list bit reader (`_read_byte_edges`).** Per-byte edge reconstruction - **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`). 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`. Drift-immune. The T=0 framing reader; the CLK-sync reader is selected
when `_clock_skip_confident` is set (`_edge_read` removed in v1.9.7).
- **`decode_step` unified dispatch.** One state machine handles - **`decode_step` unified dispatch.** One state machine handles
ATR/PPS/DATA + mid-session. Reliable; current architecture. ATR/PPS/DATA + mid-session. Reliable; current architecture.
- **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte - **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte
reading until a protocol is known. Reliable. reading until a protocol is known. Reliable.
- **T=0 case-3/4 ACK==INS discriminator.** First post-header byte == INS ⇒ - **T=0 procedure-byte classification (v1.9.5).** The first post-header
case 2/4 (response bounded by `P3`); else command data (read `P3`, then byte is SW1 ⇒ immediate status word; ACK (== INS) ⇒ case 2/4 (P3
the procedure byte). Reliable for consolidating STK envelope commands. response bytes, short-read split + BAD_FCS); ~INS ⇒ one byte per ACK;
Gated to `_edge_read`. P3-bounded reads avoid FCP tag misidentification else command data (case 3/4). Reliable for consolidating STK envelope
(v1.1.1 reverted read-until-SW from v1.1.0). commands. P3-bounded reads avoid FCP tag misidentification (v1.1.1
- **1.5-ETU start-bit guard in `wait_data_falling`.** `min_high = 1.5 * reverted read-until-SW from v1.1.0).
bit_samples` distinguishes genuine inter-byte idle gaps (≥2 ETU per ISO - **Half-ETU start-bit guard in `wait_data_falling` (v1.9.7).** `min_high
7816-3) from intra-byte HIGH periods (≤1 ETU). Prevents byte misalignment = 0.5 * bit_samples` (with a rate-scaled floor) rejects intra-byte glitch
after `_measure_etu()` consumption (v1.1.1 fix for post-ATR desync). falls while accepting the ~1-ETU inter-byte gap; the next byte's start is
- **Inverse-convention ATR detection in DATA state.** `firstByte == 0x3f` queued by the edge reader (`_next_start_fall`), so no 1.5-ETU guard is
alongside `0x3b` at line 1656, so inverse-convention ATRs (TS=0x3F) are needed on this path.
parsed by the default state. Sets `clock_skip=372` correctly - **Inverse-convention support (TS=0x3F, v1.9.6).** The hunt already read
before PPS (v1.1.1 fix). `0x3F`; the DATA state now accepts it too, the start bit stays a falling
edge from an idle-HIGH line (ISO 7816-3 7.1), and `get_bytes()` assembles
inverse data msb-first. Spec-verified; no inverse capture in the corpus.
- **`_resync = True` after PPS acceptance (native CLK path).** Forces - **`_resync = True` after PPS acceptance (native CLK path).** Forces
`resync_idle()` before the first post-PPS command, preventing the decoder `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). from latching onto a mid-byte transition at the new speed (v1.1.1 fix).
@@ -1054,11 +1383,11 @@ discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
`P3`) with P3-bounded reads for all paths (v1.1.1 reverted the `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 read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as
SW1). SW1).
- **0.5-ETU start-bit guard in `wait_data_falling` (for `_edge_read`).** - **0.5-ETU start-bit guard for the ATR-based CLK-sync loop.** It was
`min_high = 0.5 * bit_samples` was too small for slow ETUs — it accepted rejected in v1.1.1 for that path (it accepted intra-byte HIGH periods on
intra-byte HIGH periods (data bit transitions) as start bits, causing byte slow ETUs). The path was dead (`_edge_read` never False) and is removed
misalignment after `_measure_etu()` consumption. Replaced by 1.5-ETU guard in v1.9.7; the surviving edge reader uses the 0.5 guard because its next
in v1.1.1. start bit is queued by `_next_start_fall` (no re-hunt mid-burst).
### Open residual limitations ### Open residual limitations
+312 -346
View File
@@ -1,21 +1,266 @@
# sigrok_iso7816_stream # sigrok_iso7816_stream — a passive ISO 7816 sniffer
English | [Русский](README_RUS.md)
📢 Project news and updates: [website](https://atroshin.ru) · [Telegram channel](https://t.me/youeyeseesee)
Fork of [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) with Fork of [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) with
**live GSMTAP UDP streaming**, making a cheap FX2 logic analyzer (Saleae clone) **live GSMTAP UDP streaming**, making a cheap FX2 logic analyzer (Saleae clone)
behave like a SIMtrace2 sniffer: behave like a SIMtrace2 sniffer:
``` ![Signal path: SIM card and device with a passive tap on the contacts, FX2 logic analyzer (fx2lafw, 8 channels, up to 24 MHz), sigrok-cli with the iso7816 decoder, GSMTAP UDP :4729, SIMtrace Analyser and Wireshark](resources/diagram_en.svg)
SIM card ⇄ reader (passive tap on the contacts)
→ FX2 logic analyzer (fx2lafw, 8ch, 24MHz max)
→ sigrok-cli + iso7816 decoder
→ GSMTAP UDP :4729 → [SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) / Wireshark
```
[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) is the [SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) is the
recommended tool for visualising live traces and viewing saved captures — recommended tool for visualising live traces and viewing saved captures —
it can also import/export GSMTAP pcap files (including the PCAP written by it can also import/export GSMTAP pcap files (including the PCAP written by
this decoder's `pcap_file=<path>` option). this decoder's `pcap_file=<path>` option).
## Install and start
Both ways install the same decoder — pick one:
- **Download (recommended)** — a release archive. Nothing to clone and no git
history: extract it, run `setup`, then `start`. Best if you only want to
capture.
- **Source checkout** — a `git clone`. The same runtime plus the development
material: `tests/` and the trace/baseline tools (`tools/capture_baseline.sh`,
`tools/compare_baseline.sh`, `tools/vs_reader.py`). Example traces
(`examples/`) are gitignored and stay local in either case. Release archives
do not contain `tests/`, the dev tools or `examples/`.
### Download (recommended)
Grab the latest archive from
[Releases](https://github.com/anttro/sigrok_iso7816_stream/releases/latest):
- `sigrok_iso7816_stream-<version>.tar.gz` — Linux
- `sigrok_iso7816_stream-<version>.zip` — Windows
Each release also publishes `SHA256SUMS`, which lists **both** archives; keep
it next to the file you downloaded and verify:
```bash
sha256sum -c --ignore-missing SHA256SUMS # GNU coreutils (Linux)
```
On Windows run `certutil -hashfile sigrok_iso7816_stream-<version>.zip SHA256`
and compare the output with the `.zip` line in `SHA256SUMS`.
#### Linux (download)
```bash
tar xzf sigrok_iso7816_stream-<version>.tar.gz
cd sigrok_iso7816_stream-<version>
./setup.sh
./start.sh
```
`setup.sh` verifies sigrok-cli and the FX2 firmware package
(`sigrok-firmware-fx2lafw`) are installed, symlinks the project directory into
`~/.local/share/libsigrokdecode/decoders/iso7816` (user-local decoders shadow
system-wide ones), and probes the connected device for its maximum supported
samplerate.
#### Windows (download)
Prerequisites:
1. **sigrok-cli** Windows installer (0.7.2 x86_64) from
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads) — the tested
release. Nightly Windows installers are also published on that page; they
may change the sigrok-cli syntax or the decoder API, so the 0.7.2 release
installer remains the supported version.
If sigrok-cli 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).
2. 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 extract the release `.zip` (right-click → Extract All in Explorer) and
run, inside the extracted folder:
```bat
setup.bat
start.bat
```
`setup.bat` verifies sigrok-cli and bundled firmware, registers this project's
decoder via a junction into `%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816`
(confirmed by `sigrok-cli -L` listing `iso7816`), and probes the device's
accepted samplerates. The release zip ships the batch files with CRLF line
endings, so no conversion is needed. `start.bat --help` lists its options
(the core subset of `start.sh`: channels, samplerate, clock, host/port, pcap,
protocol, debug).
### Source checkout
#### Linux (source checkout)
```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
./setup.sh
./start.sh
```
`setup.sh` performs the same checks and symlink as above.
#### Windows (source checkout)
The Windows prerequisites (sigrok-cli + the Visual C++ 2010 Redistributable,
the bundled fx2lafw firmware, and the Zadig/WinUSB driver setup) are the same
as in [Windows (download)](#windows-download) above.
```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
setup.bat
start.bat
```
`setup.bat` performs the same checks and junction registration as above.
`start.bat` supports the core option subset; the space form is the primary
one, and `--clk=CH` also works (`start.bat --help` lists them):
```bat
start.bat --clk D1 --data D6
```
Note: batch files need CRLF line endings; the repo enforces this via
`.gitattributes`, so a fresh clone on Windows is already correct (the release
zip ships the same CRLF files).
## Usage
`start.sh` captures continuously at 16 MHz until Ctrl+C (`rc=130`) and streams
GSMTAP to 127.0.0.1:4729. Defaults: pins D5=CLK, D0=I/O, D4=RST, D2=VCC.
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 # full decoder + sigrok output (all
# annotation rows + libsigrok -l 4);
# by default only decoded APDUs and
# decoder warnings/errors are shown
```
On Windows, `start.bat` takes the same core options (space form; the
`--opt=value` form also works) — see the Windows install notes above.
### Wiring
The recommended tap is the short cable with external pull resistors (see
*The tap in practice* below): 10 kΩ to GND on VCC (pulls low when off) and
47 kΩ to VCC on DATA (faster rise to high). Its pin mapping is **fixed and
known in advance** — see the "Pin mapping" table below — and is exactly what
`start.sh` defaults to (`D5`=CLK, `D0`=I/O, `D4`=RST, `D2`=VCC). No wiring
auto-detection is needed; the pins never move.
> **GND first.** Plug GND first and verify 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.
### Pin mapping
The FX2 clone channels are assigned via `-P iso7816:clk=N:data=N:rst=N:vcc=N`.
The recommended short-cable tap (external pull resistors) uses fixed pins that
are exactly `start.sh`'s defaults; `--clk`/`--data`/`--rst`/`--vcc` remap them
if you wire differently:
| 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.
### Clock modes
- `native` (default) — live captures. The decoder follows the PPS speed
switch and re-arms on RST, so warm resets and post-PPS traffic decode
correctly.
- `detect` — for offline experiments or captures without PPS. It does not
follow the PPS switch, so post-PPS traffic can be misread.
- `sample_as_clock` — experimental upstream mode; not recommended for SIM
captures.
### Troubleshooting live capture
**The capture stops early (especially on Windows).** A common cause is the
wrong USB driver. The Saleae Logic driver works only with Saleae Logic
software; sigrok needs the **WinUSB** driver. Install it with **Zadig**
(bundled with the sigrok-cli installer) and repeat the assignment after the
device re-enumerates with the firmware's VID/PID. See the sigrok documentation:
- https://sigrok.org/wiki/Windows#Drivers
- https://sigrok.org/wiki/Fx2lafw
**Device not found / no signal.** Check the WinUSB driver (Windows), the
fx2lafw firmware, the wiring and GND (see Wiring), and try another USB port or
a known-good cable. Cheap USB cables and hubs are common causes of stalls.
**The capture ends by itself (`rc=0`).** The FX2 is at its USB bandwidth limit
at high samplerates. Lower `--samplerate`, avoid hubs, and close other USB
traffic. `start.sh` automatically restarts the capture on `rc=0` (default 20
restarts, 2 s apart; `--no-loop`, `--max-restarts=N`, `--restart-delay=S`).
With `--pcap=FILE`, each restart writes `FILE.<N>.<ext>`.
`start.sh` runs until Ctrl+C; keystrokes do not stop it (the "press any key"
prompt is disabled). Useful options:
```bash
./start.sh --pcap=s.pcap --log=s.log # record events and the rc= verdict
./start.sh ... --samplerate=24M # more timing headroom if needed
./start.sh ... --klog # also record the kernel journal
./start.sh ... --debug # full decoder + sigrok output
```
- `--log` records the session-end `rc=` line.
- The decoder logs `SIGNAL INTEGRITY WARNING` when ≥ 8 of the last 64 frames
fail parity.
- Desynced packets are kept and flagged `GSMTAP_FLAG_BAD_FCS` in the GSMTAP
header `res` byte — visible in Wireshark as a BAD-FCS warning.
### Raw sigrok-cli
Equivalent commands without the wrapper scripts:
```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
```
## The tap in practice ## The tap in practice
![Components](resources/components.jpg) ![Components](resources/components.jpg)
@@ -45,17 +290,23 @@ this decoder's `pcap_file=<path>` option).
![Short cable pull resistors](resources/short_cable_pull_resistors.jpg) ![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)* *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 ## Features
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| Live GSMTAP stream | Every decoded event is sent as a GSMTAP-SIM UDP packet (default on, mimics simtrace2-sniff) | | 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) | | Correct sub_types | ATR=0x01 and PPS=0x02 are tagged properly in PCAP output |
| ATR/PPS in PCAP | ATR and combined PPS request+response are emitted as PCAP packets | | 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) | | 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) | | VCC tracking (optional) | `vcc_detect=true`: emits power on/off events (sub_type 0x11) |
| Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection | | Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection |
| Start-bit deglitch | 2-sample deglitch in the start-bit detector eliminates sub-ETU LOW glitches from FX2LP pull-up artifacts, preventing byte misalignment on fast phone traces | | Start-bit deglitch | Suppresses sub-ETU LOW glitches (2 samples at 16 MHz, scaled to the samplerate) |
| Mid-session ETU lock | Recovers the ETU from DATA for captures without a usable CLK; a rate that fails parity is re-measured |
| ATR / resync guards | Validates the ATR, ignores mis-framed ATR candidates, and re-aligns after a desync |
| Inverse convention | Supports direct (TS=0x3B) and inverse (TS=0x3F) cards |
| T=1 blocks | Decodes NAD/PCB/LEN/INF/LRC with M-bit chaining; the I-block INF is emitted as the APDU |
| Parity flag | Packets containing a parity-error byte are flagged `GSMTAP_FLAG_BAD_FCS` |
| Rate-aware thresholds | Deglitch, guard and ETU floors scale with the capture samplerate |
## Decoder options ## Decoder options
@@ -109,260 +360,6 @@ This carriage is this decoder's own extension (upstream simtrace2-sniff
never sets `res`); the flag semantics mirror simtrace2's per-message USB never sets `res`); the flag semantics mirror simtrace2's per-message USB
data flags and are not part of the libosmocore spec. data flags and are 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 # full decoder + sigrok output (all
# annotation rows + libsigrok -l 4);
# by default only decoded APDUs and
# decoder warnings/errors are shown
```
### Wiring
The recommended tap is the short cable with external pull resistors (see
the photos above): 10 kΩ to GND on VCC (pulls low when off) and 47 kΩ to
VCC on DATA (faster rise to high). Its pin mapping is **fixed and known
in advance** — see the "Pin mapping" table below — and is exactly what
`start.sh` defaults to (`D5`=CLK, `D0`=I/O, `D4`=RST, `D2`=VCC). No
wiring auto-detection is needed; the pins never move.
> **GND first.** Plug GND first and verify 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 # full output: all annotation rows
# + libsigrok internals at -l 4
# (default: APDUs + warnings only)
```
`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"), the capture auto-restarts so the session is not lost —
it re-launches 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). When restarting with `--pcap=FILE`, each session's capture
is written to `FILE.<N>.<ext>` so a restart never overwrites the previous one,
and the repetitive `sr_session_stop: session was NULL` teardown line is
filtered from stderr.
- `--no-loop` disables auto-restart (single capture only).
- `--loop` forces auto-restart on (this is the default).
- `--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`.
The recommended short-cable tap (external pull resistors) uses fixed pins that
are exactly `start.sh`'s defaults; `--clk`/`--data`/`--rst`/`--vcc` remap them
if you wire differently:
| 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. **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).
2. 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`), and probes the device's
accepted samplerates.
Capture works the same as on Linux:
```bat
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 ## RST/VCC detection notes and limitations
- Events are detected **asynchronously before the first ATR** (power-on / - Events are detected **asynchronously before the first ATR** (power-on /
@@ -373,13 +370,9 @@ Note: batch files need CRLF line endings; the repo enforces this via
- VCC is digital-only: the decoder sees powered/not-powered, never the - VCC is digital-only: the decoder sees powered/not-powered, never the
voltage class (1.8/3/5V). voltage class (1.8/3/5V).
- **RST and VCC use level-stability hysteresis at 10 ms** (samplerate-scaled): - **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 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 This makes power-up and power-down symmetric and suppresses ringing and
that long. This makes power-up and power-down *symmetric*: a VCC sense line short glitches.
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 - **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), 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 so the decoder sees "powered" constantly and emits spurious power events on
@@ -390,23 +383,14 @@ Note: batch files need CRLF line endings; the repo enforces this via
- Unassigned optional channels are ignored even if `rst_detect`/`vcc_detect` - Unassigned optional channels are ignored even if `rst_detect`/`vcc_detect`
is enabled (guarded via `has_channel()`). is enabled (guarded via `has_channel()`).
## Testing ## Development and testing
Verified against the upstream example captures: The decoder is validated against example captures with `tools/vs_reader.py`.
Trace fixtures are kept locally and are not part of the repository.
```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 the SIMtrace Analyser 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`) ### Validation against the reader SDK (`tools/vs_reader.py`)
On a controlled rig (PCSC reader driven by software) the reference APDU log On a controlled rig (PC/SC reader driven by software) the reference APDU log
from the reader SDK can be diffed against the decoded PCAP: from the reader SDK can be diffed against the decoded PCAP:
```bash ```bash
@@ -419,32 +403,12 @@ python3 tools/vs_reader.py <reader-log>.log out.pcap
`vs_reader.py` aligns each reader-log exchange to the capture APDUs `vs_reader.py` aligns each reader-log exchange to the capture APDUs
(byte-exact LCS match) and classifies the leftovers as proactive/CAT (byte-exact LCS match) and classifies the leftovers as proactive/CAT
(unseen by the reader SDK), pre-capture/dropped, or garbage. (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) ### Cold-boot captures (capture starts before power-up)
When the capture starts pre-power-up, the decoder must arm the ATR When the capture starts before power-up, RST is still low at sample 0. Use
hunt because RST is still low at sample 0: it primes the initial `rst_detect=true` and `protocol=T=0`; the decoder arms the ATR hunt on the
VCC/RST levels on the first step (instead of defaulting them to deasserted), initial RST level and decodes the full ATR. Example:
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 ```bash
sigrok-cli -i <coldboot>.sr \ sigrok-cli -i <coldboot>.sr \
@@ -452,48 +416,16 @@ sigrok-cli -i <coldboot>.sr \
-A iso7816 -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) ### 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 - **Reader / constant-CLK capture** — the reader drives a steady CLK for the
whole session. In mid-session mode the capture whole session. Validate it against the reader SDK with
decodes to **0 garbage** (`RESULT: OK`) against the reader-SDK `tools/vs_reader.py <reader-log>.log out.pcap`.
ground truth (`tools/vs_reader.py <reader-log>.log out.pcap`). - **Phone / gated or stopped-CLK capture** — the phone gates or stops CLK
Warm-reset reader captures use the synthetic-T=0-ATR fallback. between transfers (Clock Stop Mode), so the ETU cannot be derived from CLK.
Run with `protocol=T=0`; the decoder recovers the ETU from the DATA line and
- **Phone / stopped-CLK capture** — the phone gates or stops CLK between decodes with the edge-list reader. T=0 case 3/4 commands (e.g. `80 12`/`80 14`
transfers (Clock Stop Mode), so the ETU is not the 372-clock default and STK envelopes) are consolidated into one command→response APDU.
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 |
| alcor_reader | constant | ~53 | 16 (F=512/D=32) | Direct | PC/SC reader, CLK 4.0 MHz at ATR -> 4.8 MHz after PPS |
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: Recommended invocations:
@@ -510,6 +442,40 @@ sigrok-cli -i <phone>.sr \
-A iso7816 -A iso7816
``` ```
### Data rates and ETUs
Reference values observed on the example captures at a 16 MHz samplerate:
| 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 |
| alcor_reader | constant | ~53 | 16 (F=512/D=32) | Direct | PC/SC reader, CLK 4.0 MHz at ATR -> 4.8 MHz after PPS |
The decoder auto-detects the ETU and selects the 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.
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.
## Related tools
SIMple, SIMtrace Analyser and sigrok_iso7816_stream are three standalone
tools for working with SIM/UICC cards, published together on the same
[website](https://atroshin.ru) and [news channel](https://t.me/youeyeseesee).
Each one is fully independent — install only the tools you need:
- **[SIMple](https://github.com/anttro/simple)** — a UICC workbench: APDU
construction, SCP80/SCP81 OTA, SIM Toolkit simulation and card profiling.
- **[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser)** — an
APDU trace analyser for GSMTAP-SIM captures.
- **[sigrok_iso7816_stream](https://github.com/anttro/sigrok_iso7816_stream)** —
a passive ISO 7816 sniffer built on a cheap FX2 logic analyser.
## License ## License
GPL-2.0+, same as upstream. GPL-2.0+, same as upstream.
+325 -365
View File
@@ -1,17 +1,14 @@
> English version: [README.md](README.md) # sigrok_iso7816_stream — пассивный ISO 7816-сниффер
# sigrok_iso7816_stream [English](README.md) | Русский
📢 Новости и обновления проекта: [сайт](https://atroshin.ru) · [Telegram-канал](https://t.me/youeyeseesee)
Форк [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) с Форк [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) с
**живым GSMTAP-UDP-стримингом**: дешёвый логический анализатор FX2 (клон **живым GSMTAP-UDP-стримингом**: дешёвый логический анализатор FX2 (клон
Saleae) ведёт себя как сниффер SIMtrace2: Saleae) ведёт себя как сниффер SIMtrace2:
``` ![Путь сигнала: SIM-карта и устройство с пассивным съёмом с контактов, логический анализатор FX2 (fx2lafw, 8 каналов, до 24 МГц), sigrok-cli с декодером iso7816, GSMTAP UDP :4729, SIMtrace Analyser и Wireshark](resources/diagram_ru.svg)
SIM-карта ⇄ ридер (пассивный съём с контактов)
→ логический анализатор FX2 (fx2lafw, 8 каналов, до 24 МГц)
→ sigrok-cli + декодер iso7816
→ GSMTAP UDP :4729 → [SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) / Wireshark
```
[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) — [SIMtrace Analyser](https://github.com/anttro/simtrace-analyser) —
рекомендуемый инструмент для визуализации живых трасс и просмотра рекомендуемый инструмент для визуализации живых трасс и просмотра
@@ -19,6 +16,262 @@ SIM-карта ⇄ ридер (пассивный съём с контактов
GSMTAP pcap-файлы (включая PCAP, который этот декодер пишет через опцию GSMTAP pcap-файлы (включая PCAP, который этот декодер пишет через опцию
`pcap_file=<path>`). `pcap_file=<path>`).
## Установка и запуск
Оба способа ставят один и тот же декодер — выберите один:
- **Скачать (рекомендуется)** — архив релиза. Не нужно ничего клонировать и
нет истории git: распакуйте, запустите `setup`, затем `start`. Лучший
вариант, если нужен только съём.
- **Из исходников** — `git clone`. Тот же рантайм плюс материал для
разработки: `tests/` и инструменты трасс и базовой линии
(`tools/capture_baseline.sh`, `tools/compare_baseline.sh`,
`tools/vs_reader.py`). Трассы примеров (`examples/`) исключены из git и
остаются локальными. В архивах релиза нет `tests/`, dev-инструментов и
`examples/`.
### Скачать (рекомендуется)
Скачайте последний архив со страницы
[Releases](https://github.com/anttro/sigrok_iso7816_stream/releases/latest):
- `sigrok_iso7816_stream-<version>.tar.gz` — Linux
- `sigrok_iso7816_stream-<version>.zip` — Windows
К каждому релизу прилагается `SHA256SUMS` — в нём **оба** архива.
Положите файл рядом со скачанным архивом и проверьте:
```bash
sha256sum -c --ignore-missing SHA256SUMS # GNU coreutils (Linux)
```
В Windows выполните `certutil -hashfile sigrok_iso7816_stream-<version>.zip SHA256`
и сравните вывод со строкой `.zip` в `SHA256SUMS`.
#### Linux (скачать)
```bash
tar xzf sigrok_iso7816_stream-<version>.tar.gz
cd sigrok_iso7816_stream-<version>
./setup.sh
./start.sh
```
`setup.sh` проверяет, что установлены sigrok-cli и пакет прошивки FX2
(`sigrok-firmware-fx2lafw`), создаёт симлинк каталога проекта в
`~/.local/share/libsigrokdecode/decoders/iso7816` (пользовательские
декодеры перекрывают системные) и опрашивает подключённое устройство на
предмет максимальной поддерживаемой частоты дискретизации.
#### Windows (скачать)
Предварительные требования:
1. **sigrok-cli** — установщик для Windows (0.7.2 x86_64) с
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads) —
протестированная версия. На этой же странице публикуются ночные сборки
для Windows; они могут менять синтаксис sigrok-cli или API декодеров,
поэтому поддерживаемой остаётся версия из релизного установщика 0.7.2.
Если sigrok-cli не запускается с ошибкой `0xc0150002`, установите
[Visual C++ 2010 Redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=26999)
(`msvcr100.dll` требуется встроенному Python-рантайму).
2. Прошивка fx2lafw поставляется вместе с установщиком — отдельно скачивать не нужно.
Настройка драйвера (донглам FX2 нужен WinUSB, вендорский драйвер не подойдёт):
- Запустите **Zadig**, который лежит *внутри* каталога установки sigrok-cli
(также доступен из меню «Пуск»). Выберите устройство FX2 в выпадающем
списке и установите драйвер **WinUSB**.
- После загрузки прошивки устройство переподключается с другим VID/PID —
если оно всё ещё не находится, запустите Zadig **второй раз** для
переподключившегося устройства.
- Windows привязывает драйверы к USB-порту, когда нет серийного номера:
перенос донгла в другой порт может потребовать повторить Zadig.
Затем распакуйте архив `.zip` (в Проводнике: правой кнопкой → «Извлечь все»)
и запустите в распакованном каталоге:
```bat
setup.bat
start.bat
```
`setup.bat` проверяет sigrok-cli и встроенную прошивку, регистрирует декодер
этого проекта через junction в
`%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816` (подтверждается тем, что
`sigrok-cli -L` показывает `iso7816`), и опрашивает устройство на предмет
поддерживаемых частот дискретизации. В zip-архиве релиза bat-файлы уже с
переводами строк CRLF, поэтому преобразование не требуется.
`start.bat --help` перечисляет его опции (основное подмножество `start.sh`:
каналы, частота дискретизации, clock, host/port, pcap, protocol, debug).
### Из исходников
#### Linux (из исходников)
```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
./setup.sh
./start.sh
```
`setup.sh` выполняет те же проверки и создаёт тот же симлинк, что и выше.
#### Windows (из исходников)
Предварительные требования Windows (sigrok-cli и Visual C++ 2010
Redistributable, встроенная прошивка fx2lafw и настройка драйвера
Zadig/WinUSB) — те же, что в разделе
[Windows (скачать)](#windows-скачать) выше.
```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
setup.bat
start.bat
```
`setup.bat` выполняет те же проверки и регистрацию junction, что и выше.
`start.bat` поддерживает основное подмножество опций; основной формат — с
пробелом, `--clk=CH` тоже работает (`start.bat --help` перечисляет опции):
```bat
start.bat --clk D1 --data D6
```
Примечание: bat-файлы требуют переводов строк CRLF; репозиторий следит за
этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен
(zip-архив релиза содержит те же файлы с CRLF).
## Использование
`start.sh` пишет непрерывный поток на частоте 16 МГц до Ctrl+C (`rc=130`) и
стримит GSMTAP на 127.0.0.1:4729. Умолчания: выводы D5=CLK, D0=I/O,
D4=RST, D2=VCC. Переопределяются аргументами:
```bash
./start.sh --clk=D1 --data=D6 # другое подключение
./start.sh --no-rst --no-vcc # только CLK+I/O
./start.sh --host=10.0.28.7 # стримить на удалённый анализатор
./start.sh --pcap=session.pcap # также записать декодированные события в PCAP
./start.sh --debug # полный вывод декодера и sigrok (все
# строки аннотаций + libsigrok -l 4);
# по умолчанию показываются только
# декодированные APDU и предупреждения/ошибки
```
В Windows `start.bat` принимает то же основное подмножество опций (форма с
пробелом; форма `--opt=value` тоже работает) — см. заметки по установке
Windows выше.
### Подключение
Рекомендуемый способ съёма — короткий кабель с внешними подтягивающими
резисторами (см. раздел «Съём на практике» ниже): 10 кОм к GND на VCC
(тянет вниз без питания) и 47 кОм к VCC на DATA (быстрее фронт вверх).
Его распиновка **фиксирована и известна заранее** — см. таблицу
«Распиновка» ниже — и в точности совпадает с умолчаниями `start.sh`
(`D5`=CLK, `D0`=I/O, `D4`=RST, `D2`=VCC). Автодетект подключения не нужен;
выводы не меняются.
> **Сначала GND.** Сначала подключите GND и проверьте его вручную
> (мультиметром или заведомо исправным контактом) — отсутствующий или
> плохой контакт земли маскируется под все остальные неисправности сразу:
> плавающие линии, фантомные стартовые биты, кадры из одних нулей.
### Распиновка
Каналы клона FX2 назначаются через `-P iso7816:clk=N:data=N:rst=N:vcc=N`.
Рекомендуемый съём коротким кабелем (с внешними подтягивающими резисторами)
использует фиксированные выводы, которые в точности совпадают с умолчаниями
`start.sh`; `--clk`/`--data`/`--rst`/`--vcc` переназначают их, если вы
подключили иначе:
| Канал FX2 | Контакт SIM | Сигнал |
|-------------|-------------|--------|
| D0 | C7 | I/O |
| D2 | C1 | VCC (только цифровой уровень!) |
| D4 | C2 | RST |
| D5 | C3 | CLK |
| GND | C5 | GND |
Входной порог FX2 близок к TTL (~1.4 В): карты класса 1.8 В могут читаться
нестабильно; классы 3 В/5 В — нормально.
### Режимы CLK
- `native` (по умолчанию) — живые записи. Декодер отслеживает переключение
скорости по PPS и заново взводится по RST, поэтому тёплые сбросы и трафик
после PPS разбираются корректно.
- `detect` — для офлайн-экспериментов или записей без PPS. Он не отслеживает
переключение по PPS, поэтому трафик после PPS может читаться неверно.
- `sample_as_clock` — экспериментальный режим upstream; для съёма SIM не
рекомендуется.
### Решение проблем при живой записи
**Запись обрывается рано (особенно в Windows).** Частая причина —
неподходящий драйвер USB. Драйвер Saleae Logic работает только с Saleae
Logic software; sigrok нужен драйвер **WinUSB**. Установите его через
**Zadig** (входит в установщик sigrok-cli) и повторите назначение после
переподключения устройства с VID/PID прошивки. Подробности в документации
sigrok:
- https://sigrok.org/wiki/Windows#Drivers
- https://sigrok.org/wiki/Fx2lafw
**Устройство не найдено / нет сигнала.** Проверьте драйвер WinUSB (Windows),
прошивку fx2lafw, подключение и GND (см. «Подключение»), попробуйте другой
USB-порт и заведомо исправный кабель. Дешёвые USB-кабели и хабы — частая
причина остановок.
**Запись завершается сама (`rc=0`).** FX2 упирается в потолок пропускной
способности USB на высокой частоте дискретизации. Снизьте `--samplerate`,
избегайте хабов, закройте другой USB-трафик. `start.sh` автоматически
перезапускает запись при `rc=0` (по умолчанию 20 перезапусков с паузой 2 с;
`--no-loop`, `--max-restarts=N`, `--restart-delay=S`). При `--pcap=FILE`
каждый перезапуск пишет `FILE.<N>.<ext>`.
`start.sh` работает до Ctrl+C; нажатия клавиш его не останавливают
(приглашение «нажмите любую клавишу» отключено). Полезные опции:
```bash
./start.sh --pcap=s.pcap --log=s.log # записать события и вердикт rc=
./start.sh ... --samplerate=24M # больше запаса по таймингу при необходимости
./start.sh ... --klog # также записать журнал ядра
./start.sh ... --debug # полный вывод декодера и sigrok
```
- `--log` сохраняет строку вердикта `rc=` в конце сессии.
- Декодер пишет `SIGNAL INTEGRITY WARNING`, когда ≥ 8 из последних 64 кадров
не проходят проверку чётности.
- Десинхронизированные пакеты сохраняются с флагом `GSMTAP_FLAG_BAD_FCS` в
байте `res` заголовка GSMTAP — видно в Wireshark как предупреждение BAD-FCS.
### Сырые команды sigrok-cli
Эквивалентные команды без скриптов-обёрток:
```bash
# Минимум: только CLK+I/O, живой поток на localhost:4729
sigrok-cli -d fx2lafw --config samplerate=12M --continuous \
-P iso7816:clk=D0:data=D1:clock_option=detect -A iso7816
# Полное подключение: CLK + I/O + RST + VCC с отслеживанием событий линий
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
# Поток на удалённый анализатор / отключить стриминг / писать PCAP-файл
... -P iso7816:...:gsmtap_host=10.0.28.7 ...
... -P iso7816:...:gsmtap_enable=false ...
... -P iso7816:...:pcap_file=trace.pcap ...
# Классический режим upstream: бинарный PCAP в stdout
sigrok-cli -i capture.sr -P iso7816:clk=2:data=0 -B iso7816=pcap > out.pcap
```
## Съём на практике ## Съём на практике
![Компоненты](resources/components.jpg) ![Компоненты](resources/components.jpg)
@@ -48,17 +301,23 @@ GSMTAP pcap-файлы (включая PCAP, который этот декод
![Подтягивающие резисторы](resources/short_cable_pull_resistors.jpg) ![Подтягивающие резисторы](resources/short_cable_pull_resistors.jpg)
*Короткий кабель с внешними подтягивающими резисторами — 10 кОм к GND на VCC (тянет вниз, когда питания нет), 47 кОм к VCC на DATA (быстрее фронт вверх)* *Короткий кабель с внешними подтягивающими резисторами — 10 кОм к GND на VCC (тянет вниз, когда питания нет), 47 кОм к VCC на DATA (быстрее фронт вверх)*
## Что добавлено поверх upstream ## Возможности
| Возможность | Описание | | Возможность | Описание |
|---------|-------------| |---------|-------------|
| Живой поток GSMTAP | Каждое декодированное событие отправляется как GSMTAP-SIM UDP-пакет (включено по умолчанию, повторяет поведение simtrace2-sniff) | | Живой поток GSMTAP | Каждое декодированное событие отправляется как GSMTAP-SIM UDP-пакет (включено по умолчанию, повторяет поведение simtrace2-sniff) |
| Правильные sub_types | ATR=0x01 и PPS=0x02 теперь корректно помечаются в PCAP (в upstream всё писалось как APDU=0x00) | | Правильные sub_types | ATR=0x01 и PPS=0x02 корректно помечаются в PCAP |
| ATR/PPS в PCAP | ATR и объединённые запрос+ответ PPS выводятся как PCAP-пакеты | | ATR/PPS в PCAP | ATR и объединённые запрос+ответ PPS выводятся как PCAP-пакеты |
| Отслеживание RST (опционально) | `rst_detect=true`: события assert/deassert сброса (sub_type 0x10) | | Отслеживание RST (опционально) | `rst_detect=true`: события assert/deassert сброса (sub_type 0x10) |
| Отслеживание VCC (опционально) | `vcc_detect=true`: события включения/выключения питания (sub_type 0x11) | | Отслеживание VCC (опционально) | `vcc_detect=true`: события включения/выключения питания (sub_type 0x11) |
| PCAP сразу в файл | `pcap_file=<path>` пишет PCAP без перенаправления вывода оболочки | | PCAP сразу в файл | `pcap_file=<path>` пишет PCAP без перенаправления вывода оболочки |
| Деглитч стартового бита | Деглитч в 2 отсчёта в детекторе стартового бита убирает LOW-глитчи короче ETU от артефактов подтяжки FX2LP и предотвращает смещение байтов на быстрых телефонных трассах | | Деглитч стартового бита | Подавляет LOW-глитчи короче ETU (2 отсчёта при 16 МГц, масштабируется от частоты дискретизации) |
| Захват ETU в середине сессии | Восстанавливает ETU по линии DATA, когда CLK непригоден; скорость, не прошедшая проверку чётности, измеряется заново |
| Защита ATR / ресинхронизации | Проверяет ATR, игнорирует неверно кадрированные кандидаты и выравнивается после десинхронизации |
| Инверсная конвенция | Поддерживает прямую (TS=0x3B) и инверсную (TS=0x3F) карты |
| Блоки T=1 | Разбирает NAD/PCB/LEN/INF/LRC с цепочкой по биту M; INF I-блока выдаётся как APDU |
| Флаг чётности | Пакеты с ошибкой чётности помечаются `GSMTAP_FLAG_BAD_FCS` |
| Пороги от частоты | Пороги деглитча, защиты и ETU масштабируются от частоты дискретизации |
## Опции декодера ## Опции декодера
@@ -113,271 +372,6 @@ uint32 в герцах (полезная нагрузка 7 байт). Пока
никогда не заполняет `res`); семантика флагов повторяет флаги данных (data никогда не заполняет `res`); семантика флагов повторяет флаги данных (data
flags) USB-протокола simtrace2 и не является частью спецификации libosmocore. flags) USB-протокола simtrace2 и не является частью спецификации libosmocore.
## Использование
Быстрый старт после клонирования (см. «Установка» ниже):
```bash
./setup.sh # проверить sigrok-cli, зарегистрировать симлинк декодера
./start.sh # запись + поток GSMTAP на 127.0.0.1:4729
```
Умолчания `start.sh`: выводы D5=CLK, D0=I/O, D4=RST, D2=VCC, частота
дискретизации 16 МГц. Переопределяются аргументами:
```bash
./start.sh --clk=D1 --data=D6 # другое подключение
./start.sh --no-rst --no-vcc # только CLK+I/O
./start.sh --host=10.0.28.7 # стримить на удалённый анализатор
./start.sh --pcap=session.pcap # также записать декодированные события в PCAP
./start.sh --debug # полный вывод декодера и sigrok (все
# строки аннотаций + libsigrok -l 4);
# по умолчанию показываются только
# декодированные APDU и предупреждения/ошибки
```
### Подключение
Рекомендуемый способ съёма — короткий кабель с внешними подтягивающими
резисторами (см. фото выше): 10 кОм к GND на VCC (тянет вниз без питания)
и 47 кОм к VCC на DATA (быстрее фронт вверх). Его распиновка **фиксирована
и известна заранее** — см. таблицу «Распиновка» ниже — и в точности
совпадает с умолчаниями `start.sh` (`D5`=CLK, `D0`=I/O, `D4`=RST,
`D2`=VCC). Автодетект подключения не нужен; выводы не меняются.
> **Сначала GND.** Сначала подключите GND и проверьте его вручную
> (мультиметром или заведомо исправным контактом) — отсутствующий или
> плохой контакт земли маскируется под все остальные неисправности сразу:
> плавающие линии, фантомные стартовые биты, кадры из одних нулей.
### Устойчивость к шумным сигналам
Живые записи могут глитчить (фантомный стартовый бит при включении питания
раньше сдвигал весь разбор ATR на единицу). Теперь декодер:
- проверяет байт TS в ATR (`0x3B`/`0x3F` по TS 102 221 §5.2.2) и
ресинхронизируется на следующем настоящем спаде, если он неверен;
- никогда не даёт исключению при декодировании убить запись — ошибки
логируются, аннотируются, а конечный автомат ресинхронизируется
(конец потока по-прежнему завершается чисто);
- ограничивает продолжение цепочки T=1-блоков, чтобы мусор не мог
зациклиться навсегда.
После принятого PPS декодер **следует переключению скорости**: в режиме
native CLK `clock_skip` становится равным F/D тактов CLK на бит (например,
512/16 = 32), что соответствует новой скорости UART у обеих сторон. Живая
запись подтвердила, что сохранение 372 после PPS алиасит весь последующий
трафик.
**Тёплый сброс** (ридер заново подаёт RST примерно каждые ~1.6 с) заставляет
карту ответить свежим ATR на стандартных 372 тактах/бит. Декодер
**заново взводит охоту за ATR по deassert RST**: состояние возвращается в
`FIND START`, native-пропуск сбрасывается в 372, счётчик охоты очищается —
поэтому повторный ATR разбирается как ATR, а не ошибочно читается как
TPDU-трафик на скорости, согласованной PPS. **Защита стартового бита в
1.5 ETU** дополнительно отбрасывает фантомный `0x00`, который глитч на
холостом ходу может вставить перед этим ATR. Требуется `rst_detect=true`
(назначенный канал `rst`); без него сброс в середине сессии всё равно
будет прочитан неверно.
### Решение проблем при живой записи
`start.sh` запускает одну `--continuous` запись, пока вы не нажмёте
**Ctrl+C** (`rc=130`). stdin отвязан от sigrok-cli, поэтому нажатия
клавиш/Enter в терминале **не** останавливают запись (приглашение sigrok
«нажмите любую клавишу для остановки» отключено) — раньше это нажатие
ошибочно трактовалось как «преждевременный обрыв», хотя никакой
неисправности устройства не было.
Большая часть раннего «шума» в записях (ошибки чётности, мусор из-за
неверного кадрирования, дребезг линии VCC) была следствием плохо
декодированного конечного автомата, а не железа: переподключение съёма
ничего не давало, а переписанный декодер полностью это вылечил (re-arm по
тёплому сбросу, защита стартового бита 1.5 ETU, антидребезг VCC 10 мс,
сборка обмена T=0). Чистая запись теперь декодируется с нулём мусора —
см. проверку `vs_reader.py` ниже.
Если запись *всё-таки* завершается сама (`rc=0`), устройство действительно
перестало стримить; обычный подозреваемый — потолок пропускной способности
USB у FX2 при длительной высокой частоте дискретизации.
```bash
./start.sh --pcap=s.pcap --log=s.log # также записать события и лог rc=
./start.sh ... --samplerate=24M # больше запаса по таймингу при необходимости
./start.sh ... --klog # также записать журнал ядра
./start.sh ... --debug # полный вывод: все строки аннотаций
# + внутренности libsigrok на -l 4
# (по умолчанию: только APDU + предупреждения)
```
`start.sh` пишет непрерывный поток на настроенной частоте (по умолчанию
16 МГц) до Ctrl+C (`rc=130`). Здоровая запись никогда не завершается
сама; если FX2 роняет USB-передачу в середине сессии (нестабильное
устройство, `rc=0` «device stopped streaming»), запись автоматически
перезапускается, чтобы не потерять сессию — она перезапускается при каждом
`rc=0`, пока вы не нажмёте Ctrl+C или не сработает `--max-restarts=N`
(по умолчанию 20; `0` = без ограничения), с паузой `--restart-delay=S`
(по умолчанию 2 с). При перезапуске с `--pcap=FILE` запись каждой сессии
пишется в `FILE.<N>.<ext>`, поэтому перезапуск никогда не перезаписывает
предыдущую, а повторяющаяся строка завершения
`sr_session_stop: session was NULL` фильтруется из stderr.
- `--no-loop` отключает авто-перезапуск (только одна запись).
- `--loop` принудительно включает авто-перезапуск (это поведение по умолчанию).
- `--max-restarts=N` ограничивает число перезапусков (по умолчанию 20; `0` = без ограничения).
- `--restart-delay=S` — секунды ожидания между перезапусками (по умолчанию 2).
- `--log` сохраняет строку вердикта `rc=` для завершения сессии.
- Декодер пишет `SIGNAL INTEGRITY WARNING`, когда ≥ 8 из последних
64 кадров не проходят проверку чётности.
- Десинхронизированные TPDU (обмены с ошибкой кадрирования) сохраняются,
но помечаются `GSMTAP_FLAG_BAD_FCS` в байте `res` заголовка GSMTAP —
видно в Wireshark как предупреждение BAD-FCS.
- Заголовок T=0 со структурно некорректным INS (ISO 7816-4 §12.1.1:
`0x00`, `0x6x`/`0x9x` или нечётный младший бит) почти всегда сдвинут на
один байт из-за лишнего ведущего байта (например, дочитанного остатка
SW2 после PACKETEND). Вместо молчаливого отбрасывания всего 5-байтового
окна (из-за чего следующий байт становился фиктивным CLA и проглатывал
остаток диалога в один CONCAT-пакет) декодер отбрасывает только ведущий
байт и перекадрирует остальные, восстанавливая настоящую команду.
Каждый потреблённый, но не выведенный байт явно логируется
(`discarded byte 0x…: reason`); отбрасывания холостых 0x00/0xFF
ограничены по частоте, отбрасывания не-холостых байтов всегда дают
аннотацию декодера.
Производительность: в режиме native CLK декодер один раз калибрует число
отсчётов на фронт CLK по первому кадру ATR, а затем продвигает битовую
синхронизацию одиночными пропусками отсчётов вместо ожидания на каждый
фронт CLK. Переключение скорости по PPS отслеживается через
`clock_skip = F/D`; никакой перекалибровки после PPS или по чётности нет.
### Сравнение режимов на железе FX2 (результат A/B)
- `native` декодировал реальный трафик (ATR, переключение PPS, 100+ валидных
APDU за 12-секундное окно); единственным устойчивым артефактом был
«дребезг» линии VCC — звон ~1 кГц, который теперь подавляет антидребезг 10 мс.
- `detect` ни разу не дал пригодной сессии на этом стенде: он полностью
пропускал переключение скорости по PPS (обработка PPS не срабатывала) и
алиасил весь трафик после PPS; вторая попытка поймала мёртвую линию I/O.
`native` остаётся режимом по умолчанию. Используйте `--clock=detect`
только для офлайн-экспериментов без PPS. Запись, целиком состоящая из
нулевых кадров, означает, что съём потерял контакт с линией.
Чётность — **чётная** по TS 102 221 («чётное число битов, установленных в
'1', включая бит чётности»); кадры, не прошедшие проверку, помечаются
`CHKSUM ERROR`, но всё равно декодируются.
Эквивалентные «сырые» команды sigrok-cli:
```bash
# Минимум: только CLK+I/O, живой поток на localhost:4729
sigrok-cli -d fx2lafw --config samplerate=12M --continuous \
-P iso7816:clk=D0:data=D1:clock_option=detect -A iso7816
# Полное подключение: CLK + I/O + RST + VCC с отслеживанием событий линий
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
# Поток на удалённый анализатор / отключить стриминг / писать PCAP-файл
... -P iso7816:...:gsmtap_host=10.0.28.7 ...
... -P iso7816:...:gsmtap_enable=false ...
... -P iso7816:...:pcap_file=trace.pcap ...
# Классический режим upstream: бинарный PCAP в stdout
sigrok-cli -i capture.sr -P iso7816:clk=2:data=0 -B iso7816=pcap > out.pcap
```
### Распиновка
Каналы клона FX2 назначаются через `-P iso7816:clk=N:data=N:rst=N:vcc=N`.
Рекомендуемый съём коротким кабелем (с внешними подтягивающими резисторами)
использует фиксированные выводы, которые в точности совпадают с умолчаниями
`start.sh`; `--clk`/`--data`/`--rst`/`--vcc` переназначают их, если вы
подключили иначе:
| Канал FX2 | Контакт SIM | Сигнал |
|-------------|-------------|--------|
| D0 | C7 | I/O |
| D2 | C1 | VCC (только цифровой уровень!) |
| D4 | C2 | RST |
| D5 | C3 | CLK |
| GND | C5 | GND |
Входной порог FX2 близок к TTL (~1.4 В): карты класса 1.8 В могут читаться
нестабильно; классы 3 В/5 В — нормально.
**Частота дискретизации и 8-битный режим (важно):** номинальные 24 МГц FX2
доступны только при дискретизации в 8-битном режиме, т.е. когда **включены
только каналы D0–D7**. Включение 9-го канала (A0/аналоговый на клонах CWAV
AX) или любого аналогового входа переводит сбор в 16-битный, который
libsigrok ограничивает 12 МГц. Поэтому `start.sh`/`start.bat` явно
передают `-C D0,D1,D2,D3,D4,D5,D6,D7` и по умолчанию используют **16 МГц**
(с комфортным запасом под потолком USB 24 МГц; ~3.3 отсчёта на такт CLK
4.8 МГц). `setup.sh`/`setup.bat` проверяют и сообщают фактический максимум
вашего железа при том же ограничении.
## Установка
### Linux
```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
./setup.sh
```
`setup.sh` проверяет, что установлены sigrok-cli и пакет прошивки FX2
(`sigrok-firmware-fx2lafw`), создаёт симлинк репозитория в
`~/.local/share/libsigrokdecode/decoders/iso7816` (пользовательские
декодеры перекрывают системные) и опрашивает подключённое устройство на
предмет максимальной поддерживаемой частоты дискретизации.
### Windows
Предварительные требования:
1. **sigrok-cli** — установщик для Windows (0.7.2 x86_64) с
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads).
Если он не запускается с ошибкой `0xc0150002`, установите
[Visual C++ 2010 Redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=26999)
(`msvcr100.dll` требуется встроенному Python-рантайму).
2. Прошивка fx2lafw поставляется вместе с установщиком — отдельно скачивать не нужно.
Настройка драйвера (донглам FX2 нужен WinUSB, вендорский драйвер не подойдёт):
- Запустите **Zadig**, который лежит *внутри* каталога установки sigrok-cli
(также доступен из меню «Пуск»). Выберите устройство FX2 в выпадающем
списке и установите драйвер **WinUSB**.
- После загрузки прошивки устройство переподключается с другим VID/PID —
если оно всё ещё не находится, запустите Zadig **второй раз** для
переподключившегося устройства.
- Windows привязывает драйверы к USB-порту, когда нет серийного номера:
перенос донгла в другой порт может потребовать повторить Zadig.
Затем:
```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
setup.bat
```
`setup.bat` проверяет sigrok-cli и встроенную прошивку, регистрирует декодер
этого репозитория через junction в
`%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816` (подтверждается тем, что
`sigrok-cli -L` показывает `iso7816`), и опрашивает устройство на предмет
поддерживаемых частот дискретизации.
Запись работает так же, как в Linux:
```bat
start.bat rem запись + поток GSMTAP
```
Примечание: bat-файлы требуют переводов строк CRLF; репозиторий следит за
этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен.
## Заметки и ограничения по детектированию RST/VCC ## Заметки и ограничения по детектированию RST/VCC
- События детектируются **асинхронно до первого ATR** (включение питания / - События детектируются **асинхронно до первого ATR** (включение питания /
@@ -388,16 +382,11 @@ start.bat rem запись + поток GSMTAP
пакета могут быть пропущены. пакета могут быть пропущены.
- VCC — только цифровой: декодер видит «питание есть/нет», но никогда - VCC — только цифровой: декодер видит «питание есть/нет», но никогда
не класс напряжения (1.8/3/5 В). не класс напряжения (1.8/3/5 В).
- **RST и VCC используют гистерезис стабильности уровня в 10 мс** - **RST и VCC используют гистерезис стабильности уровня 10 мс**
(масштабируется от частоты дискретизации): новый уровень сообщается (масштабируется от частоты дискретизации): новый уровень сообщается
только после того, как он непрерывно стабилен ≥ 10 мс — либо потому, что только после того, как он непрерывно стабилен ≥ 10 мс. Это делает
он держится так долго, либо потому, что он закончился, продержавшись включение и выключение питания *симметричными* и подавляет звон и
столько. Это делает включение и выключение питания *симметричными*: короткие глитчи.
линия VCC, которая **звенит на фронте** (шумный/плавающий съём), больше
не теряет событие «VCC on» — оно срабатывает, как только линия
устанавливается в HIGH, вместо того чтобы быть съеденным дребезгом
фронта. RST использует ту же схему, поэтому короткий глитч RST больше
не вызывает фантомный re-arm по тёплому сбросу.
- **События VCC в целом ненадёжны — лучше не отслеживать VCC.** Плавающий - **События VCC в целом ненадёжны — лучше не отслеживать VCC.** Плавающий
или неподключённый пин съёма VCC всегда читается как HIGH (логическая или неподключённый пин съёма VCC всегда читается как HIGH (логическая
единица без нагрузки), поэтому декодер постоянно видит «питание подано» единица без нагрузки), поэтому декодер постоянно видит «питание подано»
@@ -410,20 +399,10 @@ start.bat rem запись + поток GSMTAP
- Неназначенные опциональные каналы игнорируются, даже если - Неназначенные опциональные каналы игнорируются, даже если
`rst_detect`/`vcc_detect` включены (защита через `has_channel()`). `rst_detect`/`vcc_detect` включены (защита через `has_channel()`).
## Тестирование ## Разработка и тестирование
Проверено на примерах записей из upstream: Декодер проверяется на примерах записей с помощью `tools/vs_reader.py`.
Фикстуры трасс хранятся локально и не входят в репозиторий.
```bash
# ATR+PPS+54 APDU из записи ридера test_8 (~19 с декодирования)
# Поток GSMTAP проверен через tshark (ATR/APDU разбираются нативно),
# кастомные события 0x10/0x11 — через UDP-слушатель, и сквозным путём
# в сервер SIMtrace Analyser (POST /api/capture/start → decode → stop).
```
Синтетический генератор VCD, покрывающий краевые случаи RST/VCC, живёт в
заметках проекта (`projects/SIGROK_ISO7816.md` в родительском рабочем
пространстве).
### Проверка по SDK ридера (`tools/vs_reader.py`) ### Проверка по SDK ридера (`tools/vs_reader.py`)
@@ -440,33 +419,12 @@ python3 tools/vs_reader.py <reader-log>.log out.pcap
`vs_reader.py` выравнивает каждый обмен из журнала ридера с APDU из записи `vs_reader.py` выравнивает каждый обмен из журнала ридера с APDU из записи
(побайтовое LCS-совпадение) и классифицирует остатки как proactive/CAT (побайтовое LCS-совпадение) и классифицирует остатки как proactive/CAT
(невидимые для SDK ридера), pre-capture/потерянные или мусор. (невидимые для SDK ридера), pre-capture/потерянные или мусор.
Запись, начатая в середине сессии, тёплые сбросы которой раньше давали
4 мусорных события, с re-arm и защитой стартового бита декодируется в
**0 мусора** (`RESULT: OK - capture fully explained`).
test_7 (4 сброса ATR, фрагментирующих сессию) теперь захватывает все 37/37
обменов ридера (было 10/37 до исправления деглитча). Телефонные трассы
Samsung с 16 CLK/ETU (F=512/D=32) теперь разбираются на 924 отдельных
обмена T=0 (раньше сливались в 52 APDU завышенного размера).
Воспроизводимые фикстуры трасс (хранятся локально, вне репозитория)
покрывают сессии ридера с контролируемого стенда (с эталонными журналами
SDK ридера), телефонные записи с гейтированным CLK и **записи холодного
старта**, начинающиеся *до* включения питания (VCC/RST низкие при t=0).
Четыре фикстуры Samsung — холодные старты, снятые новым коротким кабелем
(провода 30 мм, внешние подтягивающие резисторы GND→VCC и VCC→DATA), с
каналами `DATA/VCC/RST/CLK` на битах 0/2/4/5; варианты `sim2` обрезаны до
окна включения питания.
### Записи холодного старта (запись начинается до включения питания) ### Записи холодного старта (запись начинается до включения питания)
Когда запись начинается до включения питания, декодер должен взвести охоту Когда запись начинается до включения питания, RST всё ещё низкий на
за ATR, потому что RST всё ещё низкий на отсчёте 0: он инициализирует отсчёте 0. Используйте `rst_detect=true` и `protocol=T=0`; декодер взводит
начальные уровни VCC/RST на первом шаге (вместо того чтобы по умолчанию охоту за ATR по начальному уровню RST и разбирает полный ATR. Пример:
считать их снятыми) и при низком RST на старте входит в `FIND START` с
очищенным ETU, поэтому переходы DATA при включении питания до ATR не могут
зафиксировать фиктивную битовую скорость. Затем он декодирует полный ATR
(`3b9f96801fc78031e073fe2117574a3382033339001e`) с 0 мусора:
```bash ```bash
sigrok-cli -i <coldboot>.sr \ sigrok-cli -i <coldboot>.sr \
@@ -474,52 +432,17 @@ sigrok-cli -i <coldboot>.sr \
-A iso7816 -A iso7816
``` ```
Снятие RST (deassert), которое приходит, пока разбор ATR уже идёт, больше не
перевзводит охоту посреди чтения — оно логирует `RST deasserted (hunt/ATR
already in progress)` и не трогает разбираемый ATR (test_8/test_7
CHKSUM 18→0).
### Сценарии записи: ридер (постоянный CLK) и телефон (Clock Stop Mode) ### Сценарии записи: ридер (постоянный CLK) и телефон (Clock Stop Mode)
Декодер обрабатывает две физически разные схемы съёма SIM:
- **Запись ридера / постоянный CLK** — ридер ведёт ровный CLK всю сессию. - **Запись ридера / постоянный CLK** — ридер ведёт ровный CLK всю сессию.
В режиме mid-session запись декодируется в **0 мусора** Проверяйте её по SDK ридера через
(`RESULT: OK`) относительно эталона из SDK ридера `tools/vs_reader.py <reader-log>.log out.pcap`.
(`tools/vs_reader.py <reader-log>.log out.pcap`). - **Запись телефона / гейтированный или остановленный CLK** — телефон
Записи ридера с тёплыми сбросами используют fallback с синтетическим гейтирует или останавливает CLK между передачами (Clock Stop Mode),
T=0-ATR. поэтому ETU нельзя вывести из CLK. Запускайте с `protocol=T=0`; декодер
восстанавливает ETU из линии DATA и декодирует edge-list читателем.
- **Запись телефона / остановленный CLK** — телефон гейтирует или Команды T=0 case 3/4 (например, `80 12`/`80 14` STK envelope) объединяются
останавливает CLK между передачами (Clock Stop Mode), поэтому ETU не в один APDU команда→ответ.
равен стандартным 372 тактам и не может быть выведен из CLK. Запускайте
с `protocol=T=0`; декодер восстанавливает ETU из линии DATA подсчётом
фронтов, фиксирует его и декодирует устойчивым к дрейфу edge-list
читателем битов. Команды T=0 case 3/4 (например, `80 12`/`80 14`
STK envelope) объединяются в один APDU команда→ответ. Деглитч стартового
бита в 2 отсчёта убирает LOW-глитчи короче ETU, вызванные артефактами
внутренней подтяжки FX2LP, и предотвращает смещение байтов на быстрых
телефонных трассах.
### Проверенные скорости данных и ETU
Все трассы декодированы на **частоте дискретизации 16 МГц**, 0 мусора,
RESULT: OK. Начиная с v1.5.0 каждая трасса из набора примеров также
сообщает **0 CONCAT** (перекадрирование по некорректному INS устранило
последнее семейство проглатывания нескольких обменов на
4gmodem/A55_sim2/S21p_sim2).
| Трасса | Режим CLK | ETU (отсчёты) | CLK/ETU | Конвенция | Примечания |
|-------|----------|---------------|---------|------------|-------|
| test_8 | постоянный | ~43 | 372 (стандарт) | Direct | PC/SC ридер, ATR+PPS+APDU |
| test_7 | постоянный | ~43 | 372 (стандарт) | Direct | PC/SC ридер, тёплые сбросы |
| xiaomi | гейтированный/остановленный | ~53 | восстановлен из DATA | Direct | Телефон, середина сессии |
| samsung | гейтированный/остановленный | ~72 | 16 (F=512/D=32) | Direct | Телефон, нестандартный F/D, меню STK |
Декодер автоопределяет ETU по трассе и выбирает подходящий читатель битов
(CLK-синхронный для постоянного CLK, edge-list для гейтированного/
остановленного CLK). Никакой ручной настройки не требуется, кроме
`clock_option=native` и `protocol=T=0` для телефонных записей.
Рекомендуемые вызовы: Рекомендуемые вызовы:
@@ -536,6 +459,43 @@ sigrok-cli -i <phone>.sr \
-A iso7816 -A iso7816
``` ```
### Скорости данных и ETU
Справочные значения, наблюдавшиеся на примерах записей при частоте
дискретизации 16 МГц:
| Трасса | Режим CLK | ETU (отсчёты) | CLK/ETU | Конвенция | Примечания |
|-------|----------|---------------|---------|------------|-------|
| test_8 | постоянный | ~43 | 372 (стандарт) | Direct | PC/SC ридер, ATR+PPS+APDU |
| test_7 | постоянный | ~43 | 372 (стандарт) | Direct | PC/SC ридер, тёплые сбросы |
| xiaomi | гейтированный/остановленный | ~53 | восстановлен из DATA | Direct | Телефон, середина сессии |
| samsung | гейтированный/остановленный | ~72 | 16 (F=512/D=32) | Direct | Телефон, нестандартный F/D, меню STK |
| alcor_reader | постоянный | ~53 | 16 (F=512/D=32) | Direct | PC/SC ридер, CLK 4.0 МГц на ATR -> 4.8 МГц после PPS |
Декодер автоопределяет ETU и выбирает подходящий читатель битов
(CLK-синхронный для постоянного CLK, edge-list для гейтированного/
остановленного CLK). Никакой ручной настройки не требуется, кроме
`clock_option=native` и `protocol=T=0` для телефонных записей.
Чётность — **чётная** по TS 102 221 («чётное число битов, установленных в
'1', включая бит чётности»); кадры, не прошедшие проверку, помечаются
`CHKSUM ERROR`, но всё равно декодируются.
## Связанные инструменты
SIMple, SIMtrace Analyser и sigrok_iso7816_stream — три автономных
инструмента для работы с SIM/UICC-картами, представленные на одном
[сайте](https://atroshin.ru) и в одном [Telegram-канале](https://t.me/youeyeseesee).
Каждый полностью независим — устанавливайте только нужные вам:
- **[SIMple](https://github.com/anttro/simple)** — мастерская для UICC/SIM:
конструкторы APDU, OTA SCP80/SCP81, симуляция SIM Toolkit и профилирование
карт.
- **[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser)** —
анализатор APDU-трассировок GSMTAP-SIM.
- **[sigrok_iso7816_stream](https://github.com/anttro/sigrok_iso7816_stream)** —
пассивный ISO 7816-сниффер на дешёвом логическом анализаторе FX2.
## Лицензия ## Лицензия
GPL-2.0+, как и upstream. GPL-2.0+, как и upstream.
+634 -373
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="690" viewBox="0 0 960 690" role="img" aria-labelledby="diagram-title diagram-desc" font-family="'Segoe UI', 'Helvetica Neue', Arial, 'DejaVu Sans', sans-serif">
<title id="diagram-title">sigrok_iso7816_stream: signal path</title>
<desc id="diagram-desc">SIM card and device with a passive tap on the contacts, FX2 logic analyzer (fx2lafw, 8 channels, up to 24 MHz), sigrok-cli with the iso7816 decoder, GSMTAP UDP :4729, SIMtrace Analyser and Wireshark</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/>
</marker>
</defs>
<rect x="1" y="1" width="958" height="688" rx="18" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.5"/>
<text x="480" y="30" text-anchor="middle" font-size="14" fill="#475569">passive tap on the contacts</text>
<rect x="150" y="48" width="210" height="72" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
<text x="255" y="90" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">SIM card</text>
<rect x="600" y="48" width="210" height="72" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
<text x="705" y="90" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Device</text>
<line x1="372" y1="84" x2="588" y2="84" stroke="#64748b" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<circle cx="480" cy="84" r="5" fill="#2563eb"/>
<line x1="480" y1="89" x2="480" y2="196" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="200" width="420" height="92" rx="12" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1.5"/>
<text x="480" y="236" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">FX2 logic analyzer</text>
<text x="480" y="265" text-anchor="middle" font-size="15" fill="#475569">fx2lafw · 8 channels · up to 24 MHz</text>
<line x1="480" y1="292" x2="480" y2="332" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="336" width="420" height="80" rx="12" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1.5"/>
<text x="480" y="382" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" xml:space="preserve"><tspan font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">sigrok-cli</tspan><tspan> + </tspan><tspan font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">iso7816</tspan><tspan> decoder</tspan></text>
<line x1="480" y1="416" x2="480" y2="456" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="320" y="460" width="320" height="72" rx="12" fill="#fffbeb" stroke="#fcd34d" stroke-width="1.5"/>
<text x="480" y="502" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">GSMTAP UDP :4729</text>
<path d="M480 532 L480 548 Q480 558 470 558 L255 558 Q245 558 245 568 L245 584" fill="none" stroke="#64748b" stroke-width="2" stroke-linejoin="round" marker-end="url(#arrow)"/>
<path d="M480 532 L480 548 Q480 558 490 558 L705 558 Q715 558 715 568 L715 584" fill="none" stroke="#64748b" stroke-width="2" stroke-linejoin="round" marker-end="url(#arrow)"/>
<rect x="110" y="588" width="270" height="72" rx="12" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5"/>
<text x="245" y="630" text-anchor="middle" font-size="18" font-weight="600" fill="#0f172a">SIMtrace Analyser</text>
<rect x="580" y="588" width="270" height="72" rx="12" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5"/>
<text x="715" y="630" text-anchor="middle" font-size="18" font-weight="600" fill="#0f172a">Wireshark</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="690" viewBox="0 0 960 690" role="img" aria-labelledby="diagram-title diagram-desc" font-family="'Segoe UI', 'Helvetica Neue', Arial, 'DejaVu Sans', sans-serif">
<title id="diagram-title">sigrok_iso7816_stream: путь сигнала</title>
<desc id="diagram-desc">SIM-карта и устройство с пассивным съёмом с контактов, логический анализатор FX2 (fx2lafw, 8 каналов, до 24 МГц), sigrok-cli с декодером iso7816, GSMTAP UDP :4729, SIMtrace Analyser и Wireshark</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/>
</marker>
</defs>
<rect x="1" y="1" width="958" height="688" rx="18" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.5"/>
<text x="480" y="30" text-anchor="middle" font-size="14" fill="#475569">пассивный съём с контактов</text>
<rect x="150" y="48" width="210" height="72" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
<text x="255" y="90" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">SIM-карта</text>
<rect x="600" y="48" width="210" height="72" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
<text x="705" y="90" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Устройство</text>
<line x1="372" y1="84" x2="588" y2="84" stroke="#64748b" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<circle cx="480" cy="84" r="5" fill="#2563eb"/>
<line x1="480" y1="89" x2="480" y2="196" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="200" width="420" height="92" rx="12" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1.5"/>
<text x="480" y="236" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Логический анализатор FX2</text>
<text x="480" y="265" text-anchor="middle" font-size="15" fill="#475569">fx2lafw · 8 каналов · до 24 МГц</text>
<line x1="480" y1="292" x2="480" y2="332" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="336" width="420" height="80" rx="12" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1.5"/>
<text x="480" y="382" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" xml:space="preserve"><tspan font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">sigrok-cli</tspan><tspan> + декодер </tspan><tspan font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">iso7816</tspan></text>
<line x1="480" y1="416" x2="480" y2="456" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="320" y="460" width="320" height="72" rx="12" fill="#fffbeb" stroke="#fcd34d" stroke-width="1.5"/>
<text x="480" y="502" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">GSMTAP UDP :4729</text>
<path d="M480 532 L480 548 Q480 558 470 558 L255 558 Q245 558 245 568 L245 584" fill="none" stroke="#64748b" stroke-width="2" stroke-linejoin="round" marker-end="url(#arrow)"/>
<path d="M480 532 L480 548 Q480 558 490 558 L705 558 Q715 558 715 568 L715 584" fill="none" stroke="#64748b" stroke-width="2" stroke-linejoin="round" marker-end="url(#arrow)"/>
<rect x="110" y="588" width="270" height="72" rx="12" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5"/>
<text x="245" y="630" text-anchor="middle" font-size="18" font-weight="600" fill="#0f172a">SIMtrace Analyser</text>
<rect x="580" y="588" width="270" height="72" rx="12" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5"/>
<text x="715" y="630" text-anchor="middle" font-size="18" font-weight="600" fill="#0f172a">Wireshark</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+26 -10
View File
@@ -36,27 +36,43 @@ if /i "%~1"=="--no-rst" ( set "RST=" & shift & goto :argloop )
if /i "%~1"=="--no-vcc" ( set "VCC=" & shift & goto :argloop ) if /i "%~1"=="--no-vcc" ( set "VCC=" & shift & goto :argloop )
if /i "%~1"=="-h" ( goto :usage ) if /i "%~1"=="-h" ( goto :usage )
if /i "%~1"=="--help" ( goto :usage ) if /i "%~1"=="--help" ( goto :usage )
rem Value options also accept the --opt=value form (usage shows the space form).
for /f "tokens=1,* delims==" %%A in ("%~1") do (
if /i "%%A"=="--clk" ( set "CLK=%%B" & shift & goto :argloop )
if /i "%%A"=="--data" ( set "DATA=%%B" & shift & goto :argloop )
if /i "%%A"=="--rst" ( set "RST=%%B" & shift & goto :argloop )
if /i "%%A"=="--vcc" ( set "VCC=%%B" & shift & goto :argloop )
if /i "%%A"=="--samplerate" ( set "SAMPLERATE=%%B" & shift & goto :argloop )
if /i "%%A"=="--clock" ( set "CLOCK=%%B" & shift & goto :argloop )
if /i "%%A"=="--host" ( set "HOST=%%B" & shift & goto :argloop )
if /i "%%A"=="--port" ( set "PORT=%%B" & shift & goto :argloop )
if /i "%%A"=="--pcap" ( set "PCAP=%%B" & shift & goto :argloop )
if /i "%%A"=="--protocol" ( set "PROTOCOL=%%B" & shift & goto :argloop )
)
echo Unknown option: %~1 echo Unknown option: %~1
exit /b 1 exit /b 1
:usage :usage
echo Usage: %~n0 [OPTIONS] echo Usage: %~n0 [OPTIONS]
echo --clk=CH CLK channel default: %CLK% echo --clk CH CLK channel default: %CLK%
echo --data=CH I/O channel default: %DATA% echo --data CH I/O channel default: %DATA%
echo --rst=CH RST channel default: %RST% echo --rst CH RST channel default: %RST%
echo --vcc=CH VCC channel default: %VCC% echo --vcc CH VCC channel default: %VCC%
echo --samplerate=SR Sample rate default: %SAMPLERATE% echo --samplerate SR Sample rate default: %SAMPLERATE%
echo --clock=MODE native/detect/sample_as_clock echo --clock MODE native/detect/sample_as_clock
echo --host=IP GSMTAP destination default: 127.0.0.1 echo --host IP GSMTAP destination default: 127.0.0.1
echo --port=N GSMTAP port default: 4729 echo --port N GSMTAP port default: 4729
echo --pcap=FILE Also write decoded events to FILE (no spaces in path) echo --pcap FILE Also write decoded events to FILE (no spaces in path)
echo --debug Full decoder + sigrok output (all annotation rows + -l 4). echo --debug Full decoder + sigrok output (all annotation rows + -l 4).
echo echo
echo By default only decoded APDUs and decoder warnings/errors are shown. echo By default only decoded APDUs and decoder warnings/errors are shown.
echo --protocol=MODE T=0 (default), T=1, or auto (mid-session decode) echo --protocol MODE T=0 (default), T=1, or auto (mid-session decode)
echo --no-rst Disable RST tracking echo --no-rst Disable RST tracking
echo --no-vcc Disable VCC tracking echo --no-vcc Disable VCC tracking
echo. echo.
echo The --clk=CH form also works. start.bat supports this core subset;
echo start.sh has the full option set.
echo.
echo To log output on Windows use redirection: %~n0 ... ^> capture.log 2^>^&1 echo To log output on Windows use redirection: %~n0 ... ^> capture.log 2^>^&1
exit /b 0 exit /b 0
+71 -16
View File
@@ -1,7 +1,7 @@
=== Baseline captured 2026-09-27T23:45:58+03:00 === === Baseline captured 2026-10-20T18:55:32+03:00 ===
=== test_8 === === test_8 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1237) ('etu recovered (samples):', 1237)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -36,7 +36,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 === === test_7 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1236) ('etu recovered (samples):', 1236)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -77,7 +77,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone_sample === === xiaomi_phone_sample ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 53) ('etu recovered (samples):', 53)
(11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1]) (11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1] iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1]
@@ -102,7 +102,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_mi_a1_coldboot === === xiaomi_mi_a1_coldboot ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -112,12 +112,12 @@ CHKSUM ERROR count: 0
CONCAT APDU count: 0 CONCAT APDU count: 0
reader exchanges : 0 reader exchanges : 0
capture ATRs : 1 capture ATRs : 1
capture APDUs : 473 capture APDUs : 475
byte-exact matches (LCS) : 0 byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0 reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log: capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 10 - proactive/CAT (unlogged by reader SDK): 10
- valid exchanges (reader retries/dupes): 463 - valid exchanges (reader retries/dupes): 465
- GARBAGE (mis-framed/desync): 0 - GARBAGE (mis-framed/desync): 0
Degradation metrics: Degradation metrics:
@@ -129,7 +129,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot === === 4gmodem_coldboot ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -156,7 +156,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== alcor_reader_sample === === alcor_reader_sample ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -189,7 +189,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable === === samsung_A55_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -216,7 +216,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable_sim2 === === samsung_A55_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -243,7 +243,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable === === samsung_S21p_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -270,7 +270,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable_sim2 === === samsung_S21p_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -342,27 +342,57 @@ test_explicit_eof_wins (__main__.TestAtEof.test_explicit_eof_wins) ... ok
test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok
test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok
test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok
test_atr_with_parity_error_is_ignored (__main__.TestAtrHuntDetect.test_atr_with_parity_error_is_ignored) ... ok
test_bare_ts_t0_is_not_committed_as_an_atr (__main__.TestAtrHuntDetect.test_bare_ts_t0_is_not_committed_as_an_atr) ... ok
test_eof_after_plausible_cla_is_clean (__main__.TestAtrHuntDetect.test_eof_after_plausible_cla_is_clean) ... ok
test_handoff_on_extended_channel_cla (__main__.TestAtrHuntDetect.test_handoff_on_extended_channel_cla) ... ok
test_handoff_on_odd_ins_89 (__main__.TestAtrHuntDetect.test_handoff_on_odd_ins_89) ... ok
test_inverse_ts_pattern_is_parsed (__main__.TestAtrHuntDetect.test_inverse_ts_pattern_is_parsed) ... ok
test_no_handoff_on_basic_channel_cla (__main__.TestAtrHuntDetect.test_no_handoff_on_basic_channel_cla) ... ok
test_no_handoff_on_invalid_ins (__main__.TestAtrHuntDetect.test_no_handoff_on_invalid_ins) ... ok
test_truncated_atr_ta_is_clean (__main__.TestAtrHuntDetect.test_truncated_atr_ta_is_clean) ... ok
test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok
test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok
test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok
test_measured_etu_skips_recovery (__main__.TestAtrHuntEscape.test_measured_etu_skips_recovery) ... ok
test_no_recovery_when_etu_known (__main__.TestAtrHuntEscape.test_no_recovery_when_etu_known) ... ok test_no_recovery_when_etu_known (__main__.TestAtrHuntEscape.test_no_recovery_when_etu_known) ... ok
test_non_ff_resumes_when_count_high (__main__.TestAtrHuntEscape.test_non_ff_resumes_when_count_high) ... ok test_non_ff_resumes_when_count_high (__main__.TestAtrHuntEscape.test_non_ff_resumes_when_count_high) ... ok
test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok
test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok
test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok
test_seeded_bit_samples_does_not_block_recovery (__main__.TestAtrHuntEscape.test_seeded_bit_samples_does_not_block_recovery) ... ok
test_9e_9f_are_status_words (__main__.TestDecodeStep.test_9e_9f_are_status_words) ... ok
test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_dense_valid_cla_is_not_idle_noise) ... ok
test_data_0x3b_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3b_before_traffic_is_atr_candidate) ... ok
test_data_0x3b_is_not_an_atr_after_traffic (__main__.TestDecodeStep.test_data_0x3b_is_not_an_atr_after_traffic) ... ok
test_data_0x3f_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3f_before_traffic_is_atr_candidate) ... ok
test_ex_class_exchange_frames (__main__.TestDecodeStep.test_ex_class_exchange_frames) ... ok
test_extended_channel_exchange_frames (__main__.TestDecodeStep.test_extended_channel_exchange_frames) ... ok
test_idle_noise_still_discarded (__main__.TestDecodeStep.test_idle_noise_still_discarded) ... ok
test_immediate_sw_rejection_frames (__main__.TestDecodeStep.test_immediate_sw_rejection_frames) ... ok
test_invalid_ins_re_frame_recovers_command (__main__.TestDecodeStep.test_invalid_ins_re_frame_recovers_command) ... ok
test_inverse_ts_raw_pattern_is_an_atr_candidate (__main__.TestDecodeStep.test_inverse_ts_raw_pattern_is_an_atr_candidate) ... ok
test_le_256_response_data_is_kept (__main__.TestDecodeStep.test_le_256_response_data_is_kept) ... ok
test_parity_error_flags_bad_fcs (__main__.TestDecodeStep.test_parity_error_flags_bad_fcs) ... ok
test_short_response_is_split_and_flagged (__main__.TestDecodeStep.test_short_response_is_split_and_flagged) ... ok
test_short_response_replays_following_exchange (__main__.TestDecodeStep.test_short_response_replays_following_exchange) ... ok
test_text_byte_boundary_is_discarded_not_framed (__main__.TestDecodeStep.test_text_byte_boundary_is_discarded_not_framed) ... ok
test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok
test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok
test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... 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_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pulse_start_edge_preserved) ... ok
test_idle_noise_byte (__main__.TestDesyncHelpers.test_idle_noise_byte) ... ok
test_is_desynced_all_zero_is_not_desync (__main__.TestDesyncHelpers.test_is_desynced_all_zero_is_not_desync) ... 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_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_clean_packets (__main__.TestDesyncHelpers.test_is_desynced_clean_packets) ... ok
test_is_desynced_extended_channel_valid (__main__.TestDesyncHelpers.test_is_desynced_extended_channel_valid) ... ok
test_is_desynced_null_sw1_is_invalid (__main__.TestDesyncHelpers.test_is_desynced_null_sw1_is_invalid) ... ok test_is_desynced_null_sw1_is_invalid (__main__.TestDesyncHelpers.test_is_desynced_null_sw1_is_invalid) ... ok
test_is_desynced_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok test_is_desynced_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok
test_is_desynced_short_fragment (__main__.TestDesyncHelpers.test_is_desynced_short_fragment) ... 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_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_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_plausible_sw_rejects_garbage (__main__.TestDesyncHelpers.test_plausible_sw_rejects_garbage) ... ok
test_resync_idle_waits_on_a_level_condition (__main__.TestDesyncHelpers.test_resync_idle_waits_on_a_level_condition) ... ok
test_a55_u1_concat (__main__.TestEmbeddedExchanges.test_a55_u1_concat) ... 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_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_channel1_select_after_manage_channel_swallow (__main__.TestEmbeddedExchanges.test_channel1_select_after_manage_channel_swallow) ... ok
@@ -374,15 +404,24 @@ test_clean_secured_packet_payload_alcor (__main__.TestEmbeddedExchanges.test_cle
test_clean_select_611e (__main__.TestEmbeddedExchanges.test_clean_select_611e) ... ok test_clean_select_611e (__main__.TestEmbeddedExchanges.test_clean_select_611e) ... ok
test_clean_select_6a82 (__main__.TestEmbeddedExchanges.test_clean_select_6a82) ... 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_concat_packets_are_not_is_desynced (__main__.TestEmbeddedExchanges.test_concat_packets_are_not_is_desynced) ... ok
test_embedded_cla_membership_pinned (__main__.TestEmbeddedExchanges.test_embedded_cla_membership_pinned) ... ok
test_samsung_record_burst_concat (__main__.TestEmbeddedExchanges.test_samsung_record_burst_concat) ... 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_xiaomi_select_getresp_swallow (__main__.TestEmbeddedExchanges.test_xiaomi_select_getresp_swallow) ... ok
test_eoferror_propagates (__main__.TestEndOfStream.test_eoferror_propagates) ... ok test_eoferror_propagates (__main__.TestEndOfStream.test_eoferror_propagates) ... ok
test_systemerror_propagates (__main__.TestEndOfStream.test_systemerror_propagates) ... ok test_systemerror_propagates (__main__.TestEndOfStream.test_systemerror_propagates) ... ok
test_ff_burst_is_accepted (__main__.TestEtuCandidate.test_ff_burst_is_accepted) ... ok
test_normal_burst_is_accepted (__main__.TestEtuCandidate.test_normal_burst_is_accepted) ... ok
test_single_spacing_is_rejected (__main__.TestEtuCandidate.test_single_spacing_is_rejected) ... ok
test_zero_run_guard_is_rejected (__main__.TestEtuCandidate.test_zero_run_guard_is_rejected) ... ok
test_build_packet_bad_fcs_flag (__main__.TestGsmtapFlags.test_build_packet_bad_fcs_flag) ... 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_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_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_flag_constant_value (__main__.TestGsmtapFlags.test_flag_constant_value) ... ok
test_pps_subtypes_are_standard (__main__.TestGsmtapFlags.test_pps_subtypes_are_standard) ... ok test_pps_subtypes_are_standard (__main__.TestGsmtapFlags.test_pps_subtypes_are_standard) ... ok
test_get_bytes_direct_lsb_first (__main__.TestInverseConvention.test_get_bytes_direct_lsb_first) ... ok
test_get_bytes_inverse_msb_first (__main__.TestInverseConvention.test_get_bytes_inverse_msb_first) ... ok
test_ts_from_bits_direct_inverse_and_garbage (__main__.TestInverseConvention.test_ts_from_bits_direct_inverse_and_garbage) ... ok
test_wait_data_falling_uses_high_idle_and_falling_start (__main__.TestInverseConvention.test_wait_data_falling_uses_high_idle_and_falling_start) ... ok
test_base_payload_without_rate (__main__.TestLineEventPayload.test_base_payload_without_rate) ... ok test_base_payload_without_rate (__main__.TestLineEventPayload.test_base_payload_without_rate) ... ok
test_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... ok test_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... ok
test_legacy_prefix_preserved (__main__.TestLineEventPayload.test_legacy_prefix_preserved) ... ok test_legacy_prefix_preserved (__main__.TestLineEventPayload.test_legacy_prefix_preserved) ... ok
@@ -394,27 +433,43 @@ test_finds_fall_exactly_at_threshold (__main__.TestNextStartFall.test_finds_fall
Regression test for Samsung 16 CLK/ETU concatenation. ... ok 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_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_returns_none_when_no_fall (__main__.TestNextStartFall.test_returns_none_when_no_fall) ... ok
test_etu_glitch_floor_scales (__main__.TestSamplerateScaling.test_etu_glitch_floor_scales) ... ok
test_samples_for_us (__main__.TestSamplerateScaling.test_samples_for_us) ... ok
test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok
test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok
test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok
test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok
test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok
test_decode_step_reads_exactly_one_block (__main__.TestT1Blocks.test_decode_step_reads_exactly_one_block) ... ok
test_handle_pps_truncated_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_is_clean) ... ok
test_handle_pps_truncated_optional_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_optional_is_clean) ... ok
test_i_block (__main__.TestT1Blocks.test_i_block) ... ok
test_m_chain_concatenates_inf (__main__.TestT1Blocks.test_m_chain_concatenates_inf) ... ok
test_r_and_s_blocks_have_no_inf (__main__.TestT1Blocks.test_r_and_s_blocks_have_no_inf) ... ok
test_truncated_block_before_lrc_is_clean (__main__.TestT1Blocks.test_truncated_block_before_lrc_is_clean) ... ok
test_truncated_inf_is_clean (__main__.TestT1Blocks.test_truncated_inf_is_clean) ... ok
test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok
test_invalid_cla_proprietary_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_proprietary_unseen) ... ok test_invalid_cla_reserved_and_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_reserved_and_unseen) ... ok
test_invalid_ins_lsb_odd (__main__.TestValidateT0Apdu.test_invalid_ins_lsb_odd) ... ok test_invalid_cla_untraced_variants (__main__.TestValidateT0Apdu.test_invalid_cla_untraced_variants) ... ok
test_invalid_ins_odd_undefined (__main__.TestValidateT0Apdu.test_invalid_ins_odd_undefined) ... ok
test_invalid_ins_sw_collision (__main__.TestValidateT0Apdu.test_invalid_ins_sw_collision) ... 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_ins_zero (__main__.TestValidateT0Apdu.test_invalid_ins_zero) ... ok
test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok
test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok
test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok
test_odd_ins_set_pinned (__main__.TestValidateT0Apdu.test_odd_ins_set_pinned) ... ok
test_valid_case1_direct_sw (__main__.TestValidateT0Apdu.test_valid_case1_direct_sw) ... 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_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_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_channel1_select_after_manage_channel (__main__.TestValidateT0Apdu.test_valid_cla_channel1_select_after_manage_channel) ... ok
test_valid_cla_extended_channel_exchanges (__main__.TestValidateT0Apdu.test_valid_cla_extended_channel_exchanges) ... ok
test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla_interindustry_classes) ... ok test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla_interindustry_classes) ... ok
test_valid_cla_uicc_classes (__main__.TestValidateT0Apdu.test_valid_cla_uicc_classes) ... ok
test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok
test_valid_ins_odd_variants (__main__.TestValidateT0Apdu.test_valid_ins_odd_variants) ... ok
test_vs_reader_mirror (__main__.TestValidateT0Apdu.test_vs_reader_mirror) ... ok
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 75 tests in 0.024s Ran 130 tests in 0.029s
OK OK
+804 -11
View File
@@ -98,6 +98,31 @@ class TestDesyncHelpers(unittest.TestCase):
self.assertFalse(self.pd.is_desynced( self.assertFalse(self.pd.is_desynced(
b'\x00\xc0\x00\x00\x25\xc0\x62\x23\x90\x00')) b'\x00\xc0\x00\x00\x25\xc0\x62\x23\x90\x00'))
def test_is_desynced_extended_channel_valid(self):
# v1.9.3: higher logical channels and the UICC extended classes
# are clean traffic, not desync. 0x44 = SELECT on channel 8,
# 0x4F = SELECT on channel 19, 0xC0 = STATUS on channel 4
# ('CX'), 0xE2 = STATUS on channel 6 with SM ('EX').
for h in ('44a40004009000', '4fa40004009000',
'c0f20000009000', 'e2f20000009000'):
with self.subTest(h=h):
self.assertFalse(self.pd.is_desynced(bytes.fromhex(h)))
def test_idle_noise_byte(self):
# All-1s artifacts with dropped bits are idle noise (including
# 0x6F/0x7F/0xAF, which are not valid classes) ...
for b in (0x6f, 0x7f, 0xaf, 0xbf, 0xf7, 0xfc, 0xfd, 0xfe):
with self.subTest(b=b):
self.assertTrue(self.pd.is_idle_noise_byte(b))
# ... but valid, bit-dense CLAs are command bytes: 0x5F/0xCF/
# 0xE7/0xEB/0xED-0xEF must not be swallowed by the noise resync.
for b in (0x5f, 0xcf, 0xe7, 0xeb, 0xed, 0xee, 0xef):
with self.subTest(b=b):
self.assertFalse(self.pd.is_idle_noise_byte(b))
# A sparse invalid class is not "idle noise" either (it takes the
# suspicious-CLA path).
self.assertFalse(self.pd.is_idle_noise_byte(0x20))
def test_is_desynced_null_sw1_is_invalid(self): def test_is_desynced_null_sw1_is_invalid(self):
# Regression (v1.9.1): the T=0 NULL byte 0x60 is not SW1. The # Regression (v1.9.1): the T=0 NULL byte 0x60 is not SW1. The
# wire "NULL 0x60 + real SW1 0x61" was mis-framed as the status # wire "NULL 0x60 + real SW1 0x61" was mis-framed as the status
@@ -126,6 +151,262 @@ class TestDesyncHelpers(unittest.TestCase):
self.assertFalse(self.pd.is_desynced(b'\x00' * 7)) self.assertFalse(self.pd.is_desynced(b'\x00' * 7))
def test_resync_idle_waits_on_a_level_condition(self):
# The re-align must WAIT for DATA (a level condition that advances
# the sample counter). It used to call wait({'skip': 0}) in a loop,
# which never advanced -- a no-op repeated on the same sample.
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.bit_samples = 100
inst._inverse_convention = False # normally set by start()
inst._start_fall = None
inst.samplenum = 0
levels = [0, 0, 1]
calls = []
def wait(cond):
calls.append(cond)
want = None
if isinstance(cond, list):
for c in cond:
if self.pd.Decoder.DATA_IDX in c:
want = c[self.pd.Decoder.DATA_IDX]
while levels:
inst.samplenum += 10
lvl = levels.pop(0)
if (want == 'h' and lvl == 1) or (want == 'l' and lvl == 0):
return [lvl] * 8
inst.samplenum += 10
return [1] * 8
inst.wait = wait
inst.resync_idle()
self.assertEqual(len(calls), 1)
self.assertIsInstance(calls[0], list)
self.assertIn(self.pd.Decoder.DATA_IDX, calls[0][0])
# it advanced through the two LOW samples to the HIGH one
self.assertEqual(inst.samplenum, 30)
class TestEtuCandidate(unittest.TestCase):
"""The mid-session ETU estimator's burst validation (_etu_candidate)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def setUp(self):
self.inst = object.__new__(self.pd.Decoder)
def test_zero_run_guard_is_rejected(self):
# All-zero characters: 10-ETU LOW runs + a short HIGH inter-byte
# guard. The smallest recurring spacing is the guard, not the ETU.
pairs = [(1000, 0), (200, 1)] * 8
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 200)
self.assertFalse(ok)
def test_single_spacing_is_rejected(self):
# A lone 0x00 character yields one 10-ETU run (10x the true ETU).
self.assertEqual(self.inst._etu_candidate([(1000, 0)]),
(None, False))
self.assertEqual(self.inst._etu_candidate([(8, 0)]),
(None, False))
def test_normal_burst_is_accepted(self):
# A 1-ETU LOW spacing (start bit / data run) alongside 2- and
# 3-ETU runs: the recurring minimum is the true ETU.
pairs = [(100, 0), (100, 1), (200, 0), (300, 1),
(100, 0), (100, 1), (200, 0)]
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 100)
self.assertTrue(ok)
def test_ff_burst_is_accepted(self):
# 0xFF: 1-ETU LOW start pulses + long HIGH runs. The minimum is a
# LOW run (the start bit), so the guard heuristic must not fire.
pairs = [(100, 0), (900, 1), (100, 0), (900, 1), (100, 0)]
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 100)
self.assertTrue(ok)
class TestInverseConvention(unittest.TestCase):
"""ISO 7816-3 7.1: inverse = inverted data, msb first; the start bit
stays a falling edge from an idle-HIGH line (the old code assumed an
idle-LOW line / rising start)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def setUp(self):
self.inst = object.__new__(self.pd.Decoder)
self.inst.reset()
def test_get_bytes_direct_lsb_first(self):
self.inst._inverse_convention = False
self.assertEqual(self.inst.get_bytes([1, 0, 0, 0, 0, 0, 0, 0]), 0x01)
self.assertEqual(self.inst.get_bytes([1, 1, 0, 0, 0, 0, 0, 0]), 0x03)
def test_get_bytes_inverse_msb_first(self):
self.inst._inverse_convention = True
self.assertEqual(self.inst.get_bytes([1, 0, 0, 0, 0, 0, 0, 0]), 0x80)
self.assertEqual(self.inst.get_bytes([1, 1, 0, 0, 0, 0, 0, 0]), 0xC0)
def test_ts_from_bits_direct_inverse_and_garbage(self):
self.assertEqual(
self.pd.ts_from_bits([0, 1, 1, 0, 1, 1, 1, 0, 0, 1]), 0x3b)
self.assertEqual(
self.pd.ts_from_bits([0, 1, 1, 0, 0, 0, 0, 0, 0, 1]), 0x3f)
self.assertIsNone(
self.pd.ts_from_bits([0, 1, 1, 0, 1, 0, 1, 0, 0, 1]))
self.assertIsNone(self.pd.ts_from_bits([0, 1, 1, 0]))
def test_wait_data_falling_uses_high_idle_and_falling_start(self):
self.inst._inverse_convention = True
self.inst.bit_samples = 100
self.inst._min_pulse_samples = 0
self.inst.samplenum = 0
conds = []
def wait(cond):
conds.append(cond)
self.inst.samplenum += 200
return [1] * 8 if len(conds) == 1 else [0] * 8
self.inst.wait = wait
self.inst.wait_data_falling()
self.assertFalse(self.inst._eof)
self.assertEqual(conds[0][0][self.pd.Decoder.DATA_IDX], 'h')
self.assertEqual(conds[1][0][self.pd.Decoder.DATA_IDX], 'f')
self.assertEqual(self.inst._start_fall, self.inst.samplenum)
class TestT1Blocks(unittest.TestCase):
"""T=1 block parsing: NAD/PCB/LEN/INF/LRC, the M-bit chain, and the
EOF guards (a truncated block never raises or emits a half INF)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.out_ann = 0
inst.log = lambda *a, **k: None
inst.put = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
inst._bytes = collections.deque(byte_seq)
inst.read_byte = lambda: (inst._bytes.popleft()
if inst._bytes else None)
return inst
def _block(self, nad, pcb, inf):
body = [nad, pcb, len(inf)] + list(inf)
lrc = 0
for b in body:
lrc ^= b
return body + [lrc]
def test_i_block(self):
inst = self._make(self._block(0x00, 0x00, [0x6f, 0x00]))
self.assertEqual(inst.t1_parse_block(0), (True, [0x6f, 0x00]))
def test_r_and_s_blocks_have_no_inf(self):
inst = self._make(self._block(0x00, 0x80, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
inst = self._make(self._block(0x00, 0xc0, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_m_chain_concatenates_inf(self):
seq = (self._block(0x00, 0x20, [0x01])
+ self._block(0x00, 0x00, [0x02]))
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (True, [0x01, 0x02]))
def test_truncated_block_before_lrc_is_clean(self):
seq = self._block(0x00, 0x00, [0x6f, 0x00])[:5] # no LRC
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_truncated_inf_is_clean(self):
inst = self._make([0x00, 0x00, 0x05, 0x01, 0x02])
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_decode_step_reads_exactly_one_block(self):
# The T=1 caller used to run an extra chain loop after every
# I-block: it consumed the peer's R-block and merged the following
# I-block's INF into this packet. One step must parse one block.
apdu = [0x00, 0xa4, 0x00, 0x00, 0x00]
rblock = self._block(0x00, 0x80, [])
inst = self._make(self._block(0x00, 0x00, apdu) + rblock)
inst.state = 'DATA'
inst.hasT0 = False
inst.hasT1 = True
inst.bit_samples = 100
inst._pps_speed_changed = False
inst._resync = False
inst._at_eof = lambda: False
inst.out_ann = 0
inst.emitted = []
inst.emit_packet = lambda st, pl, ss, es, flags=0: \
inst.emitted.append((bytes(pl), flags))
inst._note_discard = lambda *a, **k: None
inst._note_pre_sw = lambda *a, **k: None
inst._consume_first = lambda: (inst._bytes.popleft()
if inst._bytes else None)
inst.peek_byte = lambda: inst._bytes[0] if inst._bytes else None
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertEqual(inst.emitted[0][0], bytes(apdu))
# The peer's R-block is left for the next step.
self.assertEqual(list(inst._bytes), rblock)
def test_handle_pps_truncated_is_clean(self):
inst = self._make([0x55])
inst.peeked_samplenum = 0
inst.handle_pps() # pps0 missing: must not raise
def test_handle_pps_truncated_optional_is_clean(self):
inst = self._make([0x55, 0x70]) # PPS0 asks for PPS1..3, then EOF
inst.peeked_samplenum = 0
inst.handle_pps() # PPS1 missing: must not raise
class TestSamplerateScaling(unittest.TestCase):
"""The pulse/glitch thresholds scale with the capture rate instead of
assuming 16 MHz."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def test_samples_for_us(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 16000000
self.assertEqual(inst._samples_for_us(0.125), 2)
self.assertEqual(inst._samples_for_us(0.25), 4)
self.assertEqual(inst._samples_for_us(0.75), 12)
inst.samplerate = 96000000
self.assertEqual(inst._samples_for_us(0.125), 12)
self.assertEqual(inst._samples_for_us(0.75), 72)
inst.samplerate = None
self.assertEqual(inst._samples_for_us(0.75), 12) # 16 MHz fallback
def test_etu_glitch_floor_scales(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 96000000
# 12-sample spacings are sub-ETU glitches at 96 MHz (floor 72).
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(None, False))
inst.samplerate = 16000000
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(12, True))
class TestValidateT0Apdu(unittest.TestCase): class TestValidateT0Apdu(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -158,14 +439,39 @@ class TestValidateT0Apdu(unittest.TestCase):
self._valid(b'\x00\xd6\x00\x00\x04\x11\x22\x33\x44\x90\x00') self._valid(b'\x00\xd6\x00\x00\x04\x11\x22\x33\x44\x90\x00')
def test_valid_cla_interindustry_classes(self): def test_valid_cla_interindustry_classes(self):
# Interindustry CLA digits 0x/1x/4x/5x are structurally valid # Interindustry CLA (ISO 7816-4 §5.1.1): 000x xxxx 0x00-0x1F
# (ISO 7816-4): includes logical channels 0x01-0x03, secure # (channels 0-3 in b2b1, SM 0x0C, chaining 0x10-0x1F) and
# messaging 0x0C, chaining 0x10, and the SM/structural 0x4x/0x5x. # 01xx xxxx 0x40-0x5F (channels 4-19 without CLA-level SM:
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14, 0x44, 0x50, 0x54): # b4..b1 = channel-4). 0x20-0x3F is reserved by ISO; the
# SM-indicating 0x60-0x6F/0x70-0x7F stay rejected -- see
# test_invalid_cla_untraced_variants.
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14,
0x40, 0x44, 0x4F, 0x50, 0x54):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00]) pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla): with self.subTest(cla=cla):
self._valid(pkt) self._valid(pkt)
def test_valid_cla_uicc_classes(self):
# UICC extended classes (TS 102 221 §10.1.1 Table 10.4a): 'CX'/'EX'
# (0xC0-0xCF/0xE0-0xEF) carry the extended channels 4-19 in b4..b1
# (SM in b6). The legacy SIM/UICC values and 0xB0 (no pinned-spec
# basis, kept for compatibility) are unchanged.
for cla in (0x80, 0x84, 0xA0, 0xB0,
0xC0, 0xC4, 0xCF, 0xE0, 0xE4, 0xEF):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._valid(pkt)
def test_valid_cla_extended_channel_exchanges(self):
# Real higher-channel shapes from TS 102 221 Tables 10.4a/10.5:
# SELECT on channel 8 ('4X'), SELECT on channel 19 ('4X'),
# STATUS on channel 4 ('CX'), STATUS on channel 6 with SM
# ('EX' -- the UICC extended class accepts b6 = SM).
self._valid(b'\x44\xa4\x00\x04\x00\x90\x00')
self._valid(b'\x4f\xa4\x00\x04\x00\x90\x00')
self._valid(b'\xc0\xf2\x00\x00\x00\x90\x00')
self._valid(b'\xe2\xf2\x00\x00\x00\x90\x00')
def test_valid_cla_channel1_select_after_manage_channel(self): def test_valid_cla_channel1_select_after_manage_channel(self):
# The command that follows MANAGE CHANNEL (00 70 00 00 01) opening # The command that follows MANAGE CHANNEL (00 70 00 00 01) opening
# logical channel 1 legitimately uses CLA 0x01. This exact type # logical channel 1 legitimately uses CLA 0x01. This exact type
@@ -174,9 +480,59 @@ class TestValidateT0Apdu(unittest.TestCase):
b'\xa0\x00\x00\x00\x63\x50\x4b\x43\x53\x2d\x31\x35' b'\xa0\x00\x00\x00\x63\x50\x4b\x43\x53\x2d\x31\x35'
b'\x6a\x82') b'\x6a\x82')
def test_invalid_cla_proprietary_unseen(self): def test_invalid_cla_reserved_and_unseen(self):
# 0xC0 is proprietary but not a class these cards emit; stays invalid # 0x20-0x3F (001x xxxx) is reserved by ISO 7816-4; 0x90-0x9F,
self._invalid(b'\xc0\xa4\x00\x04\x00\x90\x00', 'invalid CLA') # 0xD0-0xDF and 0xF0-0xFF are not SIM/UICC classes ('11x0' needs
# b5 = 0). All stay invalid.
for cla in (0x20, 0x2F, 0x3F, 0x90, 0x9F, 0xD0, 0xDF, 0xF0, 0xFE):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._invalid(pkt, 'invalid CLA')
def test_invalid_cla_untraced_variants(self):
# Accepted-set precision (v1.9.3): the higher-channel codings that
# cost frame-start precision stay rejected. 0x60-0x6F/0x70-0x7F
# are the SM-indicating extended classes -- 0x60-0x6F overlaps
# printable ASCII ('a'-'o'), and the text byte 0x61 started a bogus
# frame on xiaomi_mi_a1_coldboot; 0x81-0x83/0x85-0x8F/0xA1-0xAF
# are the '8X'/'AX' standard-channel variants (channels 0-3) other
# than the legacy 0x80/0x84/0xA0, where the FCP byte 0xA5 did the
# same.
for cla in (0x60, 0x61, 0x6F, 0x70, 0x73, 0x7F,
0x81, 0x85, 0x8F, 0xA1, 0xA5, 0xAF):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._invalid(pkt, 'invalid CLA')
def test_odd_ins_set_pinned(self):
# Exactly the odd codes the pinned specs define: the ISO 7816-4
# Table 4 pairs (0F ERASE BINARY, 21 VERIFY, 87 GENERAL
# AUTHENTICATE, A1 SEARCH BINARY, B1 READ BINARY, B3 READ RECORD,
# C3 ENVELOPE, CB GET DATA, D1 WRITE BINARY, D7 UPDATE BINARY,
# DB PUT DATA, DD UPDATE RECORD), TS 102 221 AUTHENTICATE 89, and
# TS 102 221 Table 10.5 MANAGE SECURE CHANNEL 73 / TRANSACT DATA 75
# (the v1.9.6 audit found these two missing).
self.assertEqual(self.pd.ODD_INS, frozenset((
0x0F, 0x21, 0x73, 0x75, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xDB, 0xD1, 0xD7, 0xDD)))
for ins in (0x73, 0x75):
with self.subTest(ins=ins):
self.assertTrue(self.pd.plausible_ins(ins))
def test_vs_reader_mirror(self):
# tools/vs_reader.py duplicates the acceptance predicates for its
# standalone pcap analysis; the copies must not drift from pd.py
# (a drift would make its "suspicious CLA" / validity metrics and
# the --pcap-only verdict disagree with the decoder).
path = os.path.join(REPO, 'tools', 'vs_reader.py')
spec = importlib.util.spec_from_file_location('vs_reader', path)
vs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(vs)
self.assertEqual(
vs.VALID_CLA,
frozenset(c for c in range(256) if self.pd.plausible_cla(c)))
self.assertEqual(vs.ODD_INS, self.pd.ODD_INS)
self.assertEqual(vs.EMBEDDED_CLA, self.pd.EMBEDDED_CLA)
def test_invalid_too_short(self): def test_invalid_too_short(self):
self._invalid(b'\x00\xa4\x00\x04\x00\x90', 'too short') self._invalid(b'\x00\xa4\x00\x04\x00\x90', 'too short')
@@ -187,11 +543,31 @@ class TestValidateT0Apdu(unittest.TestCase):
def test_invalid_ins_zero(self): def test_invalid_ins_zero(self):
self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS') self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_ins_lsb_odd(self): def test_valid_ins_odd_variants(self):
self._invalid(b'\x00\xa5\x00\x04\x00\x90\x00', 'invalid INS') # Odd INS codes are accepted as the defined alternate variants:
# ISO 7816-4 Table 4 pairs (READ BINARY 'B1', ENVELOPE 'C3',
# UPDATE RECORD 'DD', ...) and TS 102 221 AUTHENTICATE '89'.
for ins in (0x0F, 0x21, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xD1, 0xD7, 0xDB, 0xDD):
pkt = bytes([0x00, ins, 0x00, 0x00, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._valid(pkt)
def test_invalid_ins_odd_undefined(self):
# Odd INS values with no defined command stay rejected: a
# mis-framed boundary exposes data bytes, and the text run
# "I M" (0x49 0x4D) started a bogus frame on xiaomi_phone_sample
# when every odd INS was accepted.
for ins in (0x03, 0x4D, 0x4F, 0xA5, 0xD3):
pkt = bytes([0x00, ins, 0x00, 0x00, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._invalid(pkt, 'invalid INS')
def test_invalid_ins_sw_collision(self): def test_invalid_ins_sw_collision(self):
self._invalid(b'\x00\x64\x00\x04\x00\x90\x00', 'invalid INS') for ins in (0x64, 0x9F):
pkt = bytes([0x00, ins, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._invalid(pkt, 'invalid INS')
def test_invalid_sw1(self): def test_invalid_sw1(self):
self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1') self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1')
@@ -278,6 +654,7 @@ class TestAtrHuntEscape(unittest.TestCase):
self.pd = pd self.pd = pd
self.inst = object.__new__(pd.Decoder) self.inst = object.__new__(pd.Decoder)
self.inst.bit_samples = None self.inst.bit_samples = None
self.inst._etu_measured = False
self.inst._atr_hunt_count = 0 self.inst._atr_hunt_count = 0
self.inst._atr_fail_total = 0 self.inst._atr_fail_total = 0
self.inst._hunt_etu_attempts = 0 self.inst._hunt_etu_attempts = 0
@@ -299,6 +676,22 @@ class TestAtrHuntEscape(unittest.TestCase):
self.assertEqual(self._hunt_round(0x00), 'recover') self.assertEqual(self._hunt_round(0x00), 'recover')
self.assertEqual(self.inst._hunt_etu_attempts, 1) self.assertEqual(self.inst._hunt_etu_attempts, 1)
def test_seeded_bit_samples_does_not_block_recovery(self):
# handle_atr pre-seeds bit_samples with the default-rate guess; the
# recovery must key off a real DATA measurement, not on bit_samples
# being None (the pre-seed used to defeat the v1.6.0 recovery).
self.inst.bit_samples = 1488
self.inst._etu_measured = False
self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self._hunt_round(0x00), 'recover')
def test_measured_etu_skips_recovery(self):
# A real measurement means the rate is known: no re-measurement.
self.inst.bit_samples = 1488
self.inst._etu_measured = True
actions = [self._hunt_round(0x00) for _ in range(6)]
self.assertNotIn('recover', actions)
def test_recovery_attempts_capped_then_hard_limit(self): def test_recovery_attempts_capped_then_hard_limit(self):
actions = [self._hunt_round(0x00) for _ in range(200)] actions = [self._hunt_round(0x00) for _ in range(200)]
actions = actions[:actions.index('resume_data') + 1] actions = actions[:actions.index('resume_data') + 1]
@@ -337,6 +730,7 @@ class TestAtrHuntEscape(unittest.TestCase):
def test_no_recovery_when_etu_known(self): def test_no_recovery_when_etu_known(self):
self.inst.bit_samples = 100 self.inst.bit_samples = 100
self.inst._etu_measured = True
for _ in range(6): for _ in range(6):
self.assertEqual(self._hunt_round(0x00), 'retry') self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self.inst._hunt_etu_attempts, 0) self.assertEqual(self.inst._hunt_etu_attempts, 0)
@@ -406,13 +800,24 @@ class TestEmbeddedExchanges(unittest.TestCase):
# alcor_reader[46] (n=87): GET RESPONSE whose 80-byte payload holds a # alcor_reader[46] (n=87): GET RESPONSE whose 80-byte payload holds a
# secured-packet run "40 70 70 61 15" 21 bytes before an SW-like byte; # secured-packet run "40 70 70 61 15" 21 bytes before an SW-like byte;
# the reader follows with 00 c0 00 00 19 -- a clean single exchange. # the reader follows with 00 c0 00 00 19 -- a clean single exchange.
# The structural 0x4x CLA must not turn payload bytes into a hit. # The extended-channel 0x4x CLA must not turn payload bytes into a
# hit: the CONCAT detector is deliberately blind to those classes.
h = ('80c2000050d14e82028381060291978b4440048111227ff64070706115' h = ('80c2000050d14e82028381060291978b4440048111227ff64070706115'
'3500350270000030151601252500000018ed15e8476126aa9cacbbe8f4' '3500350270000030151601252500000018ed15e8476126aa9cacbbe8f4'
'735f962016e3c6e30a26af20c02bad2a34bdf49f152e263bc7390a6119') '735f962016e3c6e30a26af20c02bad2a34bdf49f152e263bc7390a6119')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0]) self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), []) self.assertEqual(self._hits(h), [])
def test_embedded_cla_membership_pinned(self):
# CONCAT precision (v1.9.3): the detector's CLA set is deliberately
# NARROWER than plausible_cla() -- it must not follow the widened
# acceptance into the extended-channel classes (0x40-0x7F,
# 0xC0-0xEF) that false-hit on payload bytes (the Alcor vector
# above). Pin the exact membership.
expected = frozenset(list(range(0x00, 0x20)) +
[0x80, 0x84, 0xA0, 0xB0])
self.assertEqual(self.pd.EMBEDDED_CLA, expected)
def test_a55_u1_concat(self): def test_a55_u1_concat(self):
# A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix. # A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix.
h = ('000402a43f00612501c0000025c062238202782183023f00a5098001' h = ('000402a43f00612501c0000025c062238202782183023f00a5098001'
@@ -626,5 +1031,393 @@ class TestLineEventPayload(unittest.TestCase):
self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01) self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01)
class TestDecodeStep(unittest.TestCase):
"""Byte-replay harness for the DATA-state gate chain in decode_step().
The rest of the suite exercises only pure functions and leaf methods;
these tests drive the real state machine with a scripted byte queue so
the CLA/INS acceptance rules are pinned on the framing path (gate
ordering, header read, one-byte re-frame, exchange assembly), not just
in the predicates.
"""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst._inverse_convention = False # normally set by start()
inst._atr_parsed = True # keep 0x3B on the DATA path
inst._primed = True # skip the first-sample RST/VCC priming
inst._at_eof = lambda: False
inst.state = 'DATA'
inst.hasT0 = True
inst.hasT1 = False
inst.bit_samples = 100 # ETU known: skip _measure_etu()
inst._pps_speed_changed = False
inst._resync = False
inst.out_ann = 0
inst.emitted = []
inst.discarded = []
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
# The re-align / PPS / ATR / annotation paths are not under test.
inst.resync_idle = lambda: None
inst.handle_pps = lambda: None
inst.handle_atr = lambda pins: None
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst._note_discard = lambda value, reason, idle=False: \
inst.discarded.append((value, reason, idle))
inst._note_pre_sw = lambda value, reason: \
inst.discarded.append((value, reason, True))
inst.emit_packet = lambda sub_type, payload, ss, es, flags=0: \
inst.emitted.append((bytes(payload), flags))
# The vectors never depend on sample positions; a benign wait()
# keeps an unrelated wait call from crashing the harness.
inst.wait = lambda cond=None: [1] * 8
return inst
def test_9e_9f_are_status_words(self):
# ISO 7816-3 10.3.3 Table 11: '9X' is SW1. The old code treated
# 9E/9F as an "extended-length" procedure byte and consumed SW2 as
# a bogus length, losing the status word.
for sw1 in (0x9e, 0x9f):
with self.subTest(sw1=sw1):
inst = self._make(bytes.fromhex('00a40000023f00')
+ bytes([sw1, 0x00]))
self.assertTrue(inst.decode_step())
self.assertEqual(
inst.emitted,
[(bytes.fromhex('00a40000023f00') + bytes([sw1, 0x00]), 0)])
def test_parity_error_flags_bad_fcs(self):
# A parity error is annotated and must also reach downstream as
# GSMTAP_FLAG_BAD_FCS on the emitted packet.
inst = self._make(bytes.fromhex('00a40000023f009000'))
orig = inst.read_byte
def read():
b = orig()
if b == 0x3f:
inst._parity_errors += 1
return b
inst.read_byte = read
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertTrue(inst.emitted[0][1] & self.pd.GSMTAP_FLAG_BAD_FCS)
def test_extended_channel_exchange_frames(self):
# 'CX' STATUS on channel 4 (v1.9.3): the whole exchange must be
# read and emitted clean.
inst = self._make(bytes.fromhex('c0f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('c0f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_ex_class_exchange_frames(self):
# 'EX' STATUS on channel 6 with SM (b6 = 1): same.
inst = self._make(bytes.fromhex('e2f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('e2f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_bit_dense_valid_cla_is_not_idle_noise(self):
# 0x5F was accepted by plausible_cla() all along but always
# discarded by the idle-noise gate before v1.9.3 (dead code): the
# framing path must consume it as a command byte.
inst = self._make(bytes.fromhex('5fa40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('5fa40004009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_text_byte_boundary_is_discarded_not_framed(self):
# xiaomi_mi_a1_coldboot regression byte: the menu-text 'a' (0x61)
# at a mis-framed boundary is a suspicious CLA (not a frame start
# and not idle noise) -- exactly one byte is consumed.
inst = self._make(bytes.fromhex('612000c000621e'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0x61)
self.assertIn('suspicious CLA', inst.discarded[0][1])
self.assertEqual(bytes(inst._bytes), bytes.fromhex('2000c000621e'))
def test_invalid_ins_re_frame_recovers_command(self):
# A stray leading byte (0x1C) shifts the header; the invalid-INS
# window (INS 0x00) drops only that byte and re-frames the real
# command, which is emitted intact on the next step.
inst = self._make(bytes.fromhex('1c00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.discarded[0][0], 0x1c)
self.assertIn('invalid INS', inst.discarded[0][1])
self.assertEqual(list(inst._replay), [0x00, 0xA4, 0x00, 0x04])
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00a40004009000'), 0)])
def test_data_0x3b_is_not_an_atr_after_traffic(self):
# Once an APDU has been emitted, a DATA-state 0x3B is data misread
# at a frame boundary, not an ATR: it falls through to the
# suspicious-CLA discard instead of calling handle_atr.
inst = self._make(bytes.fromhex('3b80900000'))
inst._atr_parsed = False
inst._apdu_seen = True
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(called, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0x3B)
def test_data_0x3b_before_traffic_is_atr_candidate(self):
# Before any APDU the byte is still a legitimate ATR candidate
# (a capture starting between RST deassert and the ATR).
inst = self._make(bytes.fromhex('3b80900000'))
inst._atr_parsed = False
inst._apdu_seen = False
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_data_0x3f_before_traffic_is_atr_candidate(self):
# Inverse-convention ATRs (TS=0x3F) are candidates too.
inst = self._make(bytes.fromhex('3f100000'))
inst._atr_parsed = False
inst._apdu_seen = False
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_inverse_ts_raw_pattern_is_an_atr_candidate(self):
# A real inverse TS decodes as 0x03 through the direct reader; the
# raw moments 5-7 (LLL) mark it as the 0x3F candidate.
inst = self._make(bytes.fromhex('03100000'))
inst._atr_parsed = False
inst._apdu_seen = False
inst.bits = [0, 1, 1, 0, 0, 0, 0, 0, 0, 1]
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_idle_noise_still_discarded(self):
# 0xFE (all-1s minus one bit) is not a valid class: unchanged
# idle-noise discard.
inst = self._make(bytes.fromhex('fea40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0xfe)
self.assertIn('idle-line noise', inst.discarded[0][1])
def test_immediate_sw_rejection_frames(self):
# A command rejected with SW1 SW2 instead of an ACK (ISO 7816-3
# §10.3.3) must not be read as command data; the exchange and the
# following one must frame separately.
inst = self._make(bytes.fromhex('00b00000106a82' +
'00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b00000106a82'), 0)])
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[1],
(bytes.fromhex('00a40004009000'), 0))
def test_short_response_is_split_and_flagged(self):
# Lr < Le (6282 end-of-file): the SW inside the P3-byte read ends
# the response, and the packet is flagged BAD_FCS (it used to be a
# silent total loss).
inst = self._make(bytes.fromhex('00b0000010b012349000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b000001012349000'),
self.gs.GSMTAP_FLAG_BAD_FCS)])
def test_short_response_replays_following_exchange(self):
# A short response followed by another command: the split must
# replay the tail so the next exchange is framed intact.
inst = self._make(bytes.fromhex('00b0000010b012349000' +
'00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[0],
(bytes.fromhex('00b000001012349000'),
self.gs.GSMTAP_FLAG_BAD_FCS))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[1],
(bytes.fromhex('00a40004009000'), 0))
def test_le_256_response_data_is_kept(self):
# P3=0 in an outgoing command is Le=256 (ISO 7816-3 §10.3.2): the
# data after the ACK must be appended, not discarded — including a
# data byte that happens to equal INS.
payload = 'a5' * 8 + 'b0' + 'a5' * 7
inst = self._make(bytes.fromhex('00b0000000b0' + payload + '9000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b0000000' + payload + '9000'), 0)])
class TestAtrHuntDetect(unittest.TestCase):
"""The ATR-hunt command-detect handoff in handle_atr(), driven by a
scripted byte queue (the hunt logic itself is not otherwise covered)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.ss = 0
inst._inverse_convention = False # normally set by start()
inst._samples_per_clock = 4.0 # skip _measure_clock_period()
inst.bit_samples = 100 # skip _compute_bit_samples()
inst.out_ann = 0
inst.out_python = 0
inst.options = {'protocol': 'T=0'}
inst._atr_parsed = False
inst.emitted = []
inst.emit_packet = lambda st, pl, ss, es, flags=0: \
inst.emitted.append((st, bytes(pl), flags))
inst.hasT0 = False # start() defaults; a handoff flips it
inst.hasT1 = False
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
return inst
def test_handoff_on_extended_channel_cla(self):
# 0xC4 (channel 8, no SM) + 0xA4 (SELECT): a command header, not a
# non-TS byte; it is replayed to the DATA state.
inst = self._make([0xC4, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
self.assertTrue(inst.hasT0)
def test_handoff_on_odd_ins_89(self):
# The TS 102 221 AUTHENTICATE '89' form (v1.9.3): the second byte
# only qualifies because ODD_INS accepts it.
inst = self._make([0xC4, 0x89])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
def test_no_handoff_on_basic_channel_cla(self):
# CLA 0x00 is excluded by design (pre-activation noise).
inst = self._make([0x00, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_no_handoff_on_invalid_ins(self):
# 0x61 is not a valid INS: the pair stays on the hunt path.
inst = self._make([0xC4, 0x61])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_eof_after_plausible_cla_is_clean(self):
# Capture ends right after a plausible CLA byte: the hunt must
# return cleanly. It used to call plausible_ins(None) -> TypeError
# (seen only by decode()'s crash protection).
inst = self._make([0xC4])
inst.handle_atr(None) # must not raise
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_bare_ts_t0_is_not_committed_as_an_atr(self):
# A data byte 0x3B followed by 0x00 looks like a bare TS+T0 ATR.
# It must not be emitted or force clock_skip=372 (a mid-session
# data 0x3B used to fabricate an ATR).
inst = self._make([0x3B, 0x00])
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.state, 'DATA')
def test_truncated_atr_ta_is_clean(self):
# T0 says TA1 present but the capture ends: the parse must return
# without committing an ATR (or raising on the None byte).
inst = self._make([0x3B, 0x10])
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
def test_inverse_ts_pattern_is_parsed(self):
# 0x03 + the raw inverse TS pattern must be committed as 0x3F and
# switch the readers to the inverse convention.
inst = self._make([0x03, 0x10, 0x95])
inst.bits = [0, 1, 1, 0, 0, 0, 0, 0, 0, 1]
inst.handle_atr(None)
self.assertTrue(inst._atr_parsed)
self.assertTrue(inst._inverse_convention)
self.assertEqual(inst.ATR, [0x3f, 0x10, 0x95])
self.assertEqual(len(inst.emitted), 1)
def test_atr_with_parity_error_is_ignored(self):
# A real ATR is parity-clean. A data run that parses as an ATR but
# has a parity error must not commit the rate (e.g. test_8's
# fabricated 3b80900000).
inst = self._make([0x3B, 0x10, 0x95])
orig = inst.read_byte
def read():
b = orig()
if b == 0x10:
inst._parity_errors += 1
return b
inst.read_byte = read
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.state, 'DATA')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+216
View File
@@ -0,0 +1,216 @@
#!/bin/bash
# Build the GitHub release archives from a git ref.
# Usage: ./tools/build_release.sh [REF]
#
# REF defaults to HEAD. The archives carry an explicit path allowlist: the
# decoder, the setup/start scripts, tools/vs_reader.py, the two READMEs, the
# license and the images the READMEs reference. The allowlist is passed to
# `git archive` as its pathspec, so tests/, AGENTS.md, the CI config and the
# development tools never enter the release — and the repository's own
# (auto-generated) source archives are not affected.
#
# A v* tag must match the VERSION line in that ref's pd.py.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
die() { echo "build_release: $*" >&2; exit 1; }
REF="${1:-HEAD}"
git rev-parse --verify "$REF^{commit}" >/dev/null 2>&1 || die "unknown ref: $REF"
VERSION="$(git show "$REF:pd.py" | sed -n "s/^VERSION = '\([0-9][0-9.]*\)'.*/\1/p")"
[ -n "$VERSION" ] || die "cannot read VERSION from $REF:pd.py"
case "$REF" in
v*)
[ "${REF#v}" = "$VERSION" ] || \
die "tag $REF does not match pd.py VERSION '$VERSION'"
;;
esac
PREFIX="sigrok_iso7816_stream-${VERSION}"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/sigrok_release.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
# --- expected release contents (allowlist) -------------------------------
# The base runtime files plus every image referenced from the READMEs.
# Each README embeds its own language variant of the pipeline diagram, so the
# two diagrams are added explicitly instead of joining the shared-photo check.
LANG_RES="resources/diagram_en.svg resources/diagram_ru.svg"
README_RES="$(git show "$REF:README.md" \
| grep -oE '\]\(resources/[^)]+' | sed 's/](//' \
| grep -v -e '^resources/diagram_en\.svg$' -e '^resources/diagram_ru\.svg$' \
| LC_ALL=C sort -u || true)"
[ -n "$README_RES" ] || die "no resources/ images referenced from $REF:README.md"
README_RUS_RES="$(git show "$REF:README_RUS.md" \
| grep -oE '\]\(resources/[^)]+' | sed 's/](//' \
| grep -v -e '^resources/diagram_en\.svg$' -e '^resources/diagram_ru\.svg$' \
| LC_ALL=C sort -u || true)"
[ "$README_RES" = "$README_RUS_RES" ] || \
die "README.md and README_RUS.md reference different resources/ images"
for res in $LANG_RES; do
git cat-file -e "$REF:$res" 2>/dev/null || die "missing $res at $REF"
done
EXPECTED="$STAGE/expected.list"
{
printf '%s\n' \
__init__.py pd.py gsmtap_stream.py \
setup.sh start.sh setup.bat start.bat \
tools/vs_reader.py README.md README_RUS.md LICENSE
printf '%s\n' "$README_RES"
printf '%s\n' resources/diagram_en.svg resources/diagram_ru.svg
} | LC_ALL=C sort -u > "$EXPECTED"
PATHS=()
while IFS= read -r path; do
PATHS+=("$path")
done < "$EXPECTED"
# Build into the staging directory; dist/ is only touched after every check
# passes, so a failed build never leaves unverified archives behind.
git archive --format=tar.gz --prefix="${PREFIX}/" "$REF" \
-o "$STAGE/${PREFIX}.tar.gz" -- "${PATHS[@]}"
git archive --format=zip --prefix="${PREFIX}/" "$REF" \
-o "$STAGE/${PREFIX}.zip" -- "${PATHS[@]}"
# --- archive listings ----------------------------------------------------
list_archive() {
case "$1" in
*.zip)
if command -v unzip >/dev/null 2>&1; then
unzip -Z1 "$1"
else
python3 - "$1" <<'PY'
import sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z:
for name in z.namelist():
print(name)
PY
fi
;;
*)
tar tzf "$1"
;;
esac
}
normalize_listing() {
# Strip the top-level prefix, drop directory entries, sort.
awk -v p="$PREFIX/" '
index($0, p) == 1 {
name = substr($0, length(p) + 1)
if (name != "" && name !~ /\/$/) print name
}' | LC_ALL=C sort
}
DEV_PATHS=(
tests/
AGENTS.md
.github/
.gitignore
.gitattributes
examples/
tools/build_release.sh
tools/capture_baseline.sh
tools/compare_baseline.sh
tools/signal_diagnostic.py
)
check_archive_listing() {
local archive="$1" listing="$2" actual="$3" path
# Defense in depth: the pathspec already excludes these, but a future
# refactor must not silently ship them.
for path in "${DEV_PATHS[@]}"; do
if grep -qF "${PREFIX}/${path}" "$listing"; then
die "$archive unexpectedly contains dev-only path ${path}"
fi
done
normalize_listing < "$listing" > "$actual"
if ! diff -u "$EXPECTED" "$actual" > "$STAGE/list.diff"; then
cat "$STAGE/list.diff" >&2
die "$archive contents do not match the release allowlist (expected vs actual above)"
fi
}
list_archive "$STAGE/${PREFIX}.tar.gz" > "$STAGE/tar.list"
list_archive "$STAGE/${PREFIX}.zip" > "$STAGE/zip.list"
check_archive_listing "$STAGE/${PREFIX}.tar.gz" "$STAGE/tar.list" "$STAGE/tar.files"
check_archive_listing "$STAGE/${PREFIX}.zip" "$STAGE/zip.list" "$STAGE/zip.files"
# --- unpack and verify both archives -------------------------------------
mkdir -p "$STAGE/tar" "$STAGE/zip"
tar xzf "$STAGE/${PREFIX}.tar.gz" -C "$STAGE/tar"
TAR_DIR="$STAGE/tar/$PREFIX"
[ -f "$TAR_DIR/pd.py" ] || die "tar.gz did not unpack ${PREFIX}/pd.py"
if command -v unzip >/dev/null 2>&1; then
unzip -q "$STAGE/${PREFIX}.zip" -d "$STAGE/zip"
else
python3 - "$STAGE/${PREFIX}.zip" "$STAGE/zip" <<'PY'
import sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z:
z.extractall(sys.argv[2])
PY
fi
ZIP_DIR="$STAGE/zip/$PREFIX"
[ -f "$ZIP_DIR/pd.py" ] || die "zip did not unpack ${PREFIX}/pd.py"
check_tree() {
local src="$1" label="$2" file found
found="$(sed -n "s/^VERSION = '\([0-9][0-9.]*\)'.*/\1/p" "$src/pd.py")"
[ "$found" = "$VERSION" ] || \
die "$label: pd.py VERSION '$found', expected '$VERSION'"
python3 -m py_compile "$src/pd.py" "$src/gsmtap_stream.py" "$src/__init__.py" || \
die "$label: py_compile failed"
for file in setup.sh start.sh; do
bash -n "$src/$file" || die "$label: bash -n $file failed"
done
for file in setup.bat start.bat; do
python3 - "$src/$file" <<'PY' || die "$label: $file is not CRLF-terminated"
import sys
data = open(sys.argv[1], 'rb').read()
if b'\n' not in data or data.count(b'\n') != data.count(b'\r\n'):
sys.exit(1)
PY
done
for file in setup.sh start.sh; do
if grep -q $'\r' "$src/$file"; then
die "$label: $file contains CR (expected LF only)"
fi
done
}
check_tree "$TAR_DIR" "tar.gz"
check_tree "$ZIP_DIR" "zip"
[ -x "$TAR_DIR/setup.sh" ] || die "setup.sh is not executable in the tar.gz"
[ -x "$TAR_DIR/start.sh" ] || die "start.sh is not executable in the tar.gz"
# --- checksums and publish ----------------------------------------------
if command -v sha256sum >/dev/null 2>&1; then
SHA256=(sha256sum)
elif command -v shasum >/dev/null 2>&1; then
SHA256=(shasum -a 256)
else
die "neither sha256sum nor shasum is available"
fi
(cd "$STAGE" && "${SHA256[@]}" "${PREFIX}.tar.gz" "${PREFIX}.zip" > SHA256SUMS)
# Verified: replace any previous release files so a later `gh release upload
# dist/*` can never attach a stale archive.
mkdir -p dist
rm -f dist/sigrok_iso7816_stream-*.tar.gz \
dist/sigrok_iso7816_stream-*.zip \
dist/SHA256SUMS
mv "$STAGE/${PREFIX}.tar.gz" "$STAGE/${PREFIX}.zip" "$STAGE/SHA256SUMS" dist/
echo "Built release archives (VERSION $VERSION):"
du -h "dist/${PREFIX}.tar.gz" "dist/${PREFIX}.zip"
echo ""
echo "dist/SHA256SUMS:"
cat dist/SHA256SUMS
+32 -11
View File
@@ -44,14 +44,18 @@ CAT_INS = {
# GSMTAP_FLAG_BAD_FCS from gsmtap_stream.py # GSMTAP_FLAG_BAD_FCS from gsmtap_stream.py
GSMTAP_FLAG_BAD_FCS = 0x01 GSMTAP_FLAG_BAD_FCS = 0x01
# Valid CLA values for ISO 7816 / 3GPP. Mirror of plausible_cla in pd.py: # Valid CLA values for ISO 7816-4 / TS 102 221. Mirror of plausible_cla in
# interindustry structures (high nibble 0x0/0x1/0x4/0x5), which covers the # pd.py: interindustry 0x00-0x1F (channels 0-3) and 0x40-0x5F (channels
# logical-channel classes 0x01-0x03 (e.g. the command after MANAGE CHANNEL # 4-19 without CLA-level SM), the UICC extended classes 'CX'/'EX'
# opens channel 1), plus the proprietary SIM/UICC CAT classes. # (0xC0-0xCF/0xE0-0xEF, channels 4-19), plus 0x80/0x84/0xA0/0xB0 (0xB0 has
# no pinned-spec basis; kept for backward compatibility). 0x60-0x6F
# ('6X': SM; overlaps printable ASCII 'a'-'o'), 0x70-0x7F and the '8X'/'AX'
# standard-channel variants stay rejected: stray data bytes with those
# classes started bogus frames on the mis-framed phone traces.
def _plausible_cla(cla): def _plausible_cla(cla):
if not (cla & 0x80): if not (cla & 0x80):
return (cla & 0xF0) in (0x00, 0x10, 0x40, 0x50) return (cla & 0xF0) in (0x00, 0x10, 0x40, 0x50)
return cla in (0x80, 0x84, 0xA0, 0xB0) return (cla & 0xF0) in (0xC0, 0xE0) or cla in (0x80, 0x84, 0xA0, 0xB0)
VALID_CLA = frozenset(cla for cla in range(256) if _plausible_cla(cla)) VALID_CLA = frozenset(cla for cla in range(256) if _plausible_cla(cla))
@@ -65,10 +69,12 @@ MAX_TPDU_LEN = 271
# data region, followed by its P3 data bytes and an interior status word. # data region, followed by its P3 data bytes and an interior status word.
# These constants bound detector precision (empirically tuned on all 10 # These constants bound detector precision (empirically tuned on all 10
# example traces: 0 hits on clean traces, every Samsung/xiaomi family hit). # example traces: 0 hits on clean traces, every Samsung/xiaomi family hit).
# Mirror of pd.py: real command classes only -- the structural 0x4x/0x5x # Pinned in both pd.py and here -- deliberately narrower than VALID_CLA:
# forms that _plausible_cla() tolerates only add payload false hits. # the extended-channel classes false-hit on payload bytes (the Alcor
# "40 70 70 61 15" secured-packet run). Only the command classes that
# really occur on these wires stay covered.
EMBEDDED_CLA = frozenset( EMBEDDED_CLA = frozenset(
cla for cla in VALID_CLA if (cla & 0xF0) not in (0x40, 0x50)) list(range(0x00, 0x20)) + [0x80, 0x84, 0xA0, 0xB0])
# Commands that actually occur on these cards' bus (T=0 + STK/CAT). # Commands that actually occur on these cards' bus (T=0 + STK/CAT).
EMBEDDED_INS = frozenset(( EMBEDDED_INS = frozenset((
0x12, 0x14, 0x20, 0x44, 0x70, 0x88, 0x12, 0x14, 0x20, 0x44, 0x70, 0x88,
@@ -89,8 +95,9 @@ def embedded_exchanges(buf):
embedded inside a packet's data region (index 5 onward). embedded inside a packet's data region (index 5 onward).
Pattern: header (CLA INS P1 P2 P3) + P3 data bytes + interior SW1/SW2 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 before the packet's own final status word. CLA must be one of the
valid (same rule as _plausible_cla); INS must be a real command byte; pinned EMBEDDED_CLA classes (deliberately narrower than
_plausible_cla()); INS must be a real command byte;
SW1 is accepted within a small slack window after the nominal data end SW1 is accepted within a small slack window after the nominal data end
and must be interior (trailing bytes follow it). and must be interior (trailing bytes follow it).
""" """
@@ -134,12 +141,26 @@ def _plausible_sw(sw1):
return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F)
# Odd INS codes defined by the pinned specs: the alternate variants of the
# ISO 7816-4 Table 4 pairs plus TS 102 221 AUTHENTICATE '89'. Other odd
# values stay rejected (no command uses them; mis-framed boundary data
# bytes with odd INS started bogus frames on the phone traces).
ODD_INS = frozenset((
0x0F, 0x21, 0x73, 0x75, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xDB, 0xD1, 0xD7, 0xDD,
))
def _plausible_ins(ins): def _plausible_ins(ins):
# Mirror of plausible_ins in pd.py: 0x00 is invalid and 0x6x/0x9x
# collide with status words; odd INS values are accepted only as the
# defined alternate command variants (READ BINARY 'B1', AUTHENTICATE
# '89', ...).
if ins == 0x00: if ins == 0x00:
return False return False
if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90: if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90:
return False return False
if (ins & 0x01) != 0: if (ins & 0x01) != 0 and ins not in ODD_INS:
return False return False
return True return True