Compare commits

...
10 Commits
Author SHA1 Message Date
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
11 changed files with 2506 additions and 549 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 *Installation* 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.
examples/
# Release archives built by tools/build_release.sh.
dist/
+413 -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.
**Root cause:** `_edge_read` is initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop (lines 1808–1895) always
executes — the ATR-based procedure loop (lines 1898–1941) is dead code.
The edge-read path had two bugs:
**Root cause:** `_edge_read` was initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop always executed and the
ATR-based procedure loop was dead code. **FIXED (v1.9.7):** the flag and
the dead loop are removed. The edge-read path had two bugs:
1. **NULLs treated as command data (FIXED).** The first post-header byte
was checked for ACK; if it was NULL (0x60), it entered the case-3/4
@@ -160,14 +160,30 @@ The edge-read path had two bugs:
#### CLA validation note
All example traces (`test_7`, `test_8`, `xiaomi`, `samsung`)
contain only CLA `0x00` (interindustry) and `0x8x` (USIM/CAT, mostly `0x80`
with occasional `0x81` for logical channel 1).
The decoder must **not** mandate specific CLA values — other CLA values
(e.g. `0x04`, `0x08`, `0x84`, `0xA0`, `0xB0`) are valid per ISO 7816-4 / 3GPP
and must be accepted. However, during test runs against the example traces,
any CLA value outside `0x00-0x0F` and `0x80-0x8F` is an error flag indicating
the decoder mis-framed the byte stream.
`plausible_cla()` (v1.9.3) accepts the higher logical channels:
- interindustry `0x00`-`0x1F` (channels 0-3 in b2b1) and `0x40`-`0x5F`
(`01xx xxxx`: channels 4-19 in b4..b1, no CLA-level SM; `0x50`-`0x5F`
are the legacy chaining variants);
- the UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`,
channels 4-19, SM in b6 -- TS 102 221 Table 10.4a);
- `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)
@@ -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:
1. Length is at least 7 bytes (header + SW) and at most `MAX_TPDU_LEN` (271).
2. CLA byte is in the recognized set (`0x00`, `0x04`, `0x08`, `0x80`,
`0x84`, `0xA0`, `0xB0`).
2. CLA byte is a structurally valid ISO 7816-4 / UICC class — see
`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
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`.
`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'`**;
@@ -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
`.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)
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 |
1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes
`plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or odd
LSB — ISO 7816-4 §12.1.1), the window is almost always shifted by one
`plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or an
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
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
@@ -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
(<3% of a bit period) and does not affect downstream byte reading.
**Design note: magic number.** `_min_pulse_samples = 2` is tuned for the
primary 16 MHz sample rate (2 samples ≈ 125 ns, well within the FX2LP
pull-up rise time of 200–500 ns). At other sample rates the value would
need adjustment — but since all current traces are 16 MHz, this is left
as a hardcoded constant rather than a decoder option. If 12 MHz or
24 MHz traces are added later, consider making it configurable or
scaling it from the sample rate.
**Design note: pulse threshold.** `_min_pulse_samples` used to be a
hard-coded 2 samples, tuned for the 16 MHz corpus (≈125 ns, within the
FX2LP pull-up rise time of 200–500 ns). Since v1.9.7 it is `None` by
default and scales from `samplerate` (`_samples_for_us(0.125)` → 2 at
16 MHz); a test or option may still override it (0 disables the check).
**Logging.** Each rejected start-bit glitch is logged with a running
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 |
| 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) |
| 0x9E / 0x9F | Extended-length: next byte L = number of response data bytes, then SW |
| SW1 (`0x61`–`0x6F` / `0x9X`) | Status word: exchange complete |
| 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) |
##### CLA (Class) Byte — Interindustry Structure
##### CLA (Class) Byte — Coding and Logical Channels
```
Bit: 8 7 6 5 4 3 2 1
b8 b7 b6 b5 SM SM ch ch
```
Accepted (v1.9.3):
- Bits 8–7: `00` or `01` for interindustry; `1x` reserved for proprietary
- Bit 6: `0` for interindustry; `1` for proprietary structural formats
- Bits 4–3: Secure Messaging indicator (`00` = no SM)
- Bits 2–1: Logical channel (0–3 for interindustry)
- `000x xxxx` (`0x00`-`0x1F`): first interindustry — SM in b4b3, chaining in
b5, logical channel **0–3** in b2b1.
- `01xx xxxx`: further interindustry — logical channel **4–19**
(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
- Low nibble cannot be `X0` if high nibble is even
- Low nibble cannot be `XF` under any circumstances
- `0x6x` and `0x7x` ranges are invalid (reserved for SW1 / protocol bytes)
- `0x00` is reserved/invalid.
- `0x6x`/`0x9x` collide with status words and are invalid.
- 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),
`D6` (UPDATE BINARY), `B2` (READ RECORD), `E2` (APPEND RECORD),
`20` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88` (INTERNAL AUTHENTICATE).
Common INS values: `A4` (SELECT), `B0`/`B1` (READ BINARY),
`D6`/`D7` (UPDATE BINARY), `B2`/`B3` (READ RECORD), `E2` (APPEND RECORD),
`20`/`21` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88`/`89` (AUTHENTICATE),
`C2`/`C3` (ENVELOPE: CAT), `C0` (GET RESPONSE).
##### Quick Validation Checklist
1. CLA high nibble is `0`, `1`, `4`, or `5` (for interindustry)
2. INS low nibble is not `F`
3. INS is outside `0x6x`–`0x7x` range
1. CLA is a valid class: `0x00`–`0x1F`/`0x40`–`0x5F` (interindustry, channels
0–3/4–19) or `0xC0`–`0xCF`/`0xE0`–`0xEF` (UICC extended) or
`0x80`/`0x84`/`0xA0`/`0xB0`
2. INS is not `0x00`
3. INS is outside the `0x6x`/`0x9x` status-word ranges
**Current smoke-test status:**
@@ -753,9 +1016,50 @@ append the measured CLK frequency when known
### 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.
### 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`, and every `resources/` image referenced from `README.md`. 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
After any change to `pd.py`:
@@ -934,23 +1238,35 @@ no `0xff`/`0xfc` garbage.
Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those
P3 bytes on the wire **before** the card's first procedure byte. The
framing now distinguishes the cases by the card's first procedure byte:
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
terminal sent no command data; read `P3` response bytes (+SW).
- Otherwise the byte is terminal command data → case 3/4: read `P3`
command-data bytes, then the card's procedure byte (ACK/SW/`9E`/`9F`).
On ACK (case 4) the response is read `P3` response bytes (+SW).
- **SW1** (`0x61-0x6F`/`0x90-0x9F`) → the card finished with SW1 SW2 at the
first procedure byte (ISO 7816-3 §10.3.3; a case-2 rejection such as
`6A82`/`6CXX` carries no ACK).
- **ACK (== INS)** → case 2/4: the terminal sent no command data. Read the
`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
commands) into one C-APDU = header + command data, R-APDU = response + SW
APDU, instead of fragmenting them across frames. It is gated to
`_edge_read` (mid-session / gated-CLK captures) so ATR-based captures
(`test_8` / `test_7`) keep the original framing unchanged and stay at
0 garbage.
APDU, instead of fragmenting them across frames.
All paths use P3-bounded reads (v1.1.1 reverted the read-until-SW
experiment from v1.1.0 which mis-identified FCP `0x62` as SW1).
P3-bounded reads are kept for case 2 (`Lr == Le`): the read-until-SW
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
@@ -984,42 +1300,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
ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` /
`4gmodem_coldboot_sample.sr` (0 GARBAGE).
- **1.5-ETU start-bit guard.** Rejects the phantom `0x00` an idle glitch
inserts before an ATR. Reliable.
- **Half-ETU start-bit guard (edge reader).** Rejects the phantom `0x00`
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
≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events
from a ringing/floating sense line.
- **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the
native skip becomes F/D CLK cycles/bit. Reliable (verified live).
- **T=1 block-chain follow-up bound.** Caps chained I-blocks so garbage
cannot loop forever. Reliable.
- **Synthetic T=0 ATR fallback `[0x3B, 0x00]`.** After 8 failed ATR hunts,
commit a minimal ATR and set `hasT0=True`. Reliable for mid-session starts
(no ATR in the trace).
- **T=1 block decode (v1.9.7).** NAD/PCB/LEN/INF/LRC with the M-bit chain
followed inside `t1_parse_block()` (bounded by `T1_CHAIN_MAX`) and every
read EOF-guarded; R/S blocks carry no INF and are not emitted. Unit
tested only (no T=1 capture in the corpus). Reliable by construction.
- **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
to the first DATA start-bit fall, measures ETU from DATA-edge spacing, locks
it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking
once (not re-measuring) is drift-immune and avoids glitch re-anchors.
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
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
ATR/PPS/DATA + mid-session. Reliable; current architecture.
- **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte
reading until a protocol is known. Reliable.
- **T=0 case-3/4 ACK==INS discriminator.** First post-header byte == INS ⇒
case 2/4 (response bounded by `P3`); else command data (read `P3`, then
the procedure byte). Reliable for consolidating STK envelope commands.
Gated to `_edge_read`. P3-bounded reads avoid FCP tag misidentification
(v1.1.1 reverted read-until-SW from v1.1.0).
- **1.5-ETU start-bit guard in `wait_data_falling`.** `min_high = 1.5 *
bit_samples` distinguishes genuine inter-byte idle gaps (≥2 ETU per ISO
7816-3) from intra-byte HIGH periods (≤1 ETU). Prevents byte misalignment
after `_measure_etu()` consumption (v1.1.1 fix for post-ATR desync).
- **Inverse-convention ATR detection in DATA state.** `firstByte == 0x3f`
alongside `0x3b` at line 1656, so inverse-convention ATRs (TS=0x3F) are
parsed by the default state. Sets `clock_skip=372` correctly
before PPS (v1.1.1 fix).
- **T=0 procedure-byte classification (v1.9.5).** The first post-header
byte is SW1 ⇒ immediate status word; ACK (== INS) ⇒ case 2/4 (P3
response bytes, short-read split + BAD_FCS); ~INS ⇒ one byte per ACK;
else command data (case 3/4). Reliable for consolidating STK envelope
commands. P3-bounded reads avoid FCP tag misidentification (v1.1.1
reverted read-until-SW from v1.1.0).
- **Half-ETU start-bit guard in `wait_data_falling` (v1.9.7).** `min_high
= 0.5 * bit_samples` (with a rate-scaled floor) rejects intra-byte glitch
falls while accepting the ~1-ETU inter-byte gap; the next byte's start is
queued by the edge reader (`_next_start_fall`), so no 1.5-ETU guard is
needed on this path.
- **Inverse-convention support (TS=0x3F, v1.9.6).** The hunt already read
`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_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).
@@ -1054,11 +1381,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
read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as
SW1).
- **0.5-ETU start-bit guard in `wait_data_falling` (for `_edge_read`).**
`min_high = 0.5 * bit_samples` was too small for slow ETUs — it accepted
intra-byte HIGH periods (data bit transitions) as start bits, causing byte
misalignment after `_measure_etu()` consumption. Replaced by 1.5-ETU guard
in v1.1.1.
- **0.5-ETU start-bit guard for the ATR-based CLK-sync loop.** It was
rejected in v1.1.1 for that path (it accepted intra-byte HIGH periods on
slow ETUs). The path was dead (`_edge_read` never False) and is removed
in v1.9.7; the surviving edge reader uses the 0.5 guard because its next
start bit is queued by `_next_start_fall` (no re-hunt mid-burst).
### Open residual limitations
+112 -20
View File
@@ -1,5 +1,7 @@
# sigrok_iso7816_stream
📢 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
**live GSMTAP UDP streaming**, making a cheap FX2 logic analyzer (Saleae clone)
behave like a SIMtrace2 sniffer:
@@ -55,7 +57,13 @@ this decoder's `pcap_file=<path>` option).
| RST tracking (optional) | `rst_detect=true`: emits reset assert/deassert events (sub_type 0x10) |
| VCC tracking (optional) | `vcc_detect=true`: emits power on/off events (sub_type 0x11) |
| Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection |
| Start-bit deglitch | 2-sample deglitch in the start-bit detector eliminates sub-ETU LOW glitches from FX2LP pull-up artifacts, preventing byte misalignment on fast phone traces |
| Start-bit deglitch | Deglitch in the start-bit detector (2 samples at 16 MHz, scaled from the sample rate since v1.9.7) eliminates sub-ETU LOW glitches from FX2LP pull-up artifacts, preventing byte misalignment on fast phone traces |
| Mid-session ETU lock | The DATA-edge ETU estimator rejects guard-only (all-zero) and single-spacing bursts and invalidates a rate that fails parity, so a capture at a non-default F/D locks safely |
| ATR / resync guards | A DATA-state `0x3B`/`0x3F` is an ATR candidate only before the first APDU; parity-dirty or bare (TS+T0) frames are ignored, and `resync_idle` really waits for the idle level |
| Inverse convention | TS=0x3F ATRs follow ISO 7816-3 7.1: inverted data, msb first, start bit still LOW from an idle-HIGH line |
| T=1 blocks | NAD/PCB/LEN/INF/LRC decode with M-bit chaining; the I-block INF is emitted as the APDU, R/S blocks are not; a truncated capture ends cleanly |
| Parity flag | A packet containing a parity-error byte is emitted with the GSMTAP BAD_FCS flag (not just an annotation) |
| Rate-aware thresholds | Start-bit deglitch, guard floors and the ETU glitch floor scale from the capture sample rate instead of assuming 16 MHz |
## Decoder options
@@ -111,13 +119,17 @@ data flags and are not part of the libosmocore spec.
## Usage
Quick start after cloning (see Installation below):
Quick start (after installing — release download or source checkout; see
Installation below):
```bash
./setup.sh # check sigrok-cli, register decoder symlink
./start.sh # capture + GSMTAP stream to 127.0.0.1:4729
```
On Windows the same two steps are `setup.bat` then `start.bat` (see
Installation below).
`start.sh` defaults: pins D5=CLK, D0=I/O, D4=RST, D2=VCC, 16 MHz samplerate.
Override via args:
@@ -155,7 +167,8 @@ shift the whole ATR parse off-by-one). The decoder now:
- 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.
- decodes T=1 blocks (NAD/PCB/LEN/INF/LRC) and bounds the M-bit chain 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.
@@ -167,7 +180,7 @@ 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
rate. A **half-ETU start-bit guard** (edge reader) 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.
@@ -183,7 +196,7 @@ 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
it completely (warm-reset re-arm, half-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.
@@ -221,8 +234,8 @@ filtered from stderr.
- 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 T=0 header with a structurally invalid INS (ISO 7816-4 §5.1.2:
`0x00` or `0x6x`/`0x9x`) 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
@@ -230,6 +243,12 @@ filtered from stderr.
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.
- A card may finish a command with SW1 SW2 at the first procedure byte
(ISO 7816-3 §10.3.3), including a case-2 command with P3 > 0: the SW is
framed as the status word, not read as command data. A response
**shorter than Le** is split at the status word and the packet is
flagged `BAD_FCS` (the following exchanges stay intact), and `P3=0`
(Le=256) response data is kept.
Performance: in native CLK mode the decoder calibrates samples per CLK
edge once from the ATR's first frame, then advances bit timing with
@@ -305,27 +324,61 @@ same restriction.
## Installation
### Linux
Both ways install the same decoder — pick one:
- **Download (recommended)** — a release archive. Nothing to clone and no git
history: extract it, run `setup`, run `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`). The example traces
(`examples/`) are gitignored and stay local in either case. The release
archives intentionally 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
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
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 repo into
(`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
#### Windows (download)
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
[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.
@@ -341,7 +394,40 @@ Driver setup (FX2 dongles need WinUSB, the vendor driver won't work):
- Windows binds drivers per USB port when no serial number is present:
moving the dongle to another port may require repeating Zadig.
Then:
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).
### Linux (source checkout)
```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
./setup.sh
```
A fresh clone needs the same `./setup.sh` checks and symlink as above: it
verifies sigrok-cli and the FX2 firmware package, symlinks the project
directory into `~/.local/share/libsigrokdecode/decoders/iso7816` and probes
the device's maximum samplerate.
### 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
@@ -349,10 +435,15 @@ 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.
`setup.bat` performs the same checks and junction registration as above.
`start.bat` supports the core option subset (channels, samplerate, clock,
host/port, pcap, protocol, debug; `start.sh` has the full set) — the space
form is the primary one, and `--clk=CH` also works:
```bat
start.bat --clk D1 --data D6
start.bat --help
```
Capture works the same as on Linux:
@@ -361,7 +452,8 @@ 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.
`.gitattributes`, so a fresh clone on Windows is already correct (the release
zip ships the same CRLF files).
## RST/VCC detection notes and limitations
+113 -22
View File
@@ -2,6 +2,8 @@
# sigrok_iso7816_stream
📢 Новости и обновления проекта: [Сайт](https://atroshin.ru) · [Telegram-канал](https://t.me/youeyeseesee)
Форк [svenso/sigrok_iso7816](https://github.com/svenso/sigrok_iso7816) с
**живым GSMTAP-UDP-стримингом**: дешёвый логический анализатор FX2 (клон
Saleae) ведёт себя как сниффер SIMtrace2:
@@ -58,7 +60,13 @@ GSMTAP pcap-файлы (включая PCAP, который этот декод
| Отслеживание RST (опционально) | `rst_detect=true`: события assert/deassert сброса (sub_type 0x10) |
| Отслеживание VCC (опционально) | `vcc_detect=true`: события включения/выключения питания (sub_type 0x11) |
| PCAP сразу в файл | `pcap_file=<path>` пишет PCAP без перенаправления вывода оболочки |
| Деглитч стартового бита | Деглитч в 2 отсчёта в детекторе стартового бита убирает LOW-глитчи короче ETU от артефактов подтяжки FX2LP и предотвращает смещение байтов на быстрых телефонных трассах |
| Деглитч стартового бита | Деглитч в детекторе стартового бита (2 отсчёта при 16 МГц, с v1.9.7 масштабируется от частоты дискретизации) убирает LOW-глитчи короче ETU от артефактов подтяжки FX2LP и предотвращает смещение байтов на быстрых телефонных трассах |
| Захват ETU в середине сессии | Оценщик ETU по фронтам DATA отбрасывает пачки только со сторожевым интервалом (нулевые данные) и одиночные интервалы, а также сбрасывает скорость, не прошедшую проверку чётности, поэтому трасса с нестандартным F/D захватывается надёжно |
| Защита ATR / ресинхронизации | `0x3B`/`0x3F` в состоянии DATA считается кандидатом в ATR только до первого APDU; кадры с ошибкой чётности или голый TS+T0 игнорируются, а `resync_idle` действительно ждёт уровень простоя |
| Инверсная конвенция | ATR с TS=0x3F разбирается по ISO 7816-3 7.1: инверсные данные, msb первым, стартовый бит по-прежнему LOW при простое линии HIGH |
| Блоки T=1 | Разбор NAD/PCB/LEN/INF/LRC с цепочкой по биту M; INF I-блока выдаётся как APDU, R/S-блоки не выдаются; обрезанная запись завершается чисто |
| Флаг чётности | Пакет с байтом с ошибкой чётности выдаётся с флагом GSMTAP BAD_FCS (не только аннотацией) |
| Пороги от частоты | Деглитч стартового бита, пороги защиты и порог глитча ETU масштабируются от частоты дискретизации, а не считаются от 16 МГц |
## Опции декодера
@@ -115,13 +123,17 @@ flags) USB-протокола simtrace2 и не является частью с
## Использование
Быстрый старт после клонирования (см. «Установка» ниже):
Быстрый старт (после установки — скачанный архив или клон репозитория;
см. «Установка» ниже):
```bash
./setup.sh # проверить sigrok-cli, зарегистрировать симлинк декодера
./start.sh # запись + поток GSMTAP на 127.0.0.1:4729
```
В Windows те же два шага — `setup.bat`, затем `start.bat` (см. «Установка»
ниже).
Умолчания `start.sh`: выводы D5=CLK, D0=I/O, D4=RST, D2=VCC, частота
дискретизации 16 МГц. Переопределяются аргументами:
@@ -160,8 +172,8 @@ flags) USB-протокола simtrace2 и не является частью с
- никогда не даёт исключению при декодировании убить запись — ошибки
логируются, аннотируются, а конечный автомат ресинхронизируется
(конец потока по-прежнему завершается чисто);
- ограничивает продолжение цепочки T=1-блоков, чтобы мусор не мог
зациклиться навсегда.
- разбирает блоки T=1 (NAD/PCB/LEN/INF/LRC) и ограничивает цепочку по биту M,
чтобы мусор не мог зациклиться навсегда.
После принятого PPS декодер **следует переключению скорости**: в режиме
native CLK `clock_skip` становится равным F/D тактов CLK на бит (например,
@@ -175,7 +187,7 @@ native CLK `clock_skip` становится равным F/D тактов CLK
`FIND START`, native-пропуск сбрасывается в 372, счётчик охоты очищается —
поэтому повторный ATR разбирается как ATR, а не ошибочно читается как
TPDU-трафик на скорости, согласованной PPS. **Защита стартового бита в
1.5 ETU** дополнительно отбрасывает фантомный `0x00`, который глитч на
0.5 ETU** (edge-ридер) дополнительно отбрасывает фантомный `0x00`, который глитч на
холостом ходу может вставить перед этим ATR. Требуется `rst_detect=true`
(назначенный канал `rst`); без него сброс в середине сессии всё равно
будет прочитан неверно.
@@ -193,7 +205,7 @@ TPDU-трафик на скорости, согласованной PPS. **За
неверного кадрирования, дребезг линии VCC) была следствием плохо
декодированного конечного автомата, а не железа: переподключение съёма
ничего не давало, а переписанный декодер полностью это вылечил (re-arm по
тёплому сбросу, защита стартового бита 1.5 ETU, антидребезг VCC 10 мс,
тёплому сбросу, защита стартового бита 0.5 ETU, антидребезг VCC 10 мс,
сборка обмена T=0). Чистая запись теперь декодируется с нулём мусора —
см. проверку `vs_reader.py` ниже.
@@ -232,8 +244,8 @@ USB у FX2 при длительной высокой частоте дискр
- Десинхронизированные TPDU (обмены с ошибкой кадрирования) сохраняются,
но помечаются `GSMTAP_FLAG_BAD_FCS` в байте `res` заголовка GSMTAP —
видно в Wireshark как предупреждение BAD-FCS.
- Заголовок T=0 со структурно некорректным INS (ISO 7816-4 §12.1.1:
`0x00`, `0x6x`/`0x9x` или нечётный младший бит) почти всегда сдвинут на
- Заголовок T=0 со структурно некорректным INS (ISO 7816-4 §5.1.2:
`0x00` или `0x6x`/`0x9x`) почти всегда сдвинут на
один байт из-за лишнего ведущего байта (например, дочитанного остатка
SW2 после PACKETEND). Вместо молчаливого отбрасывания всего 5-байтового
окна (из-за чего следующий байт становился фиктивным CLA и проглатывал
@@ -243,6 +255,12 @@ USB у FX2 при длительной высокой частоте дискр
(`discarded byte 0x…: reason`); отбрасывания холостых 0x00/0xFF
ограничены по частоте, отбрасывания не-холостых байтов всегда дают
аннотацию декодера.
- Карта может завершить команду словами SW1 SW2 на первом процедурном
байте (ISO 7816-3 §10.3.3), в том числе для case-2 команды с P3 > 0:
SW оформляется как статусное слово, а не читается как данные. Ответ
**короче Le** разделяется по статусному слову и пакет помечается
`BAD_FCS` (следующие обмены сохраняются); данные ответа при `P3=0`
(Le=256) сохраняются.
Производительность: в режиме native CLK декодер один раз калибрует число
отсчётов на фронт CLK по первому кадру ATR, а затем продвигает битовую
@@ -319,27 +337,60 @@ libsigrok ограничивает 12 МГц. Поэтому `start.sh`/`start.
## Установка
### Linux
Оба способа ставят один и тот же декодер — выберите один:
- **Скачать (рекомендуется)** — архив релиза. Не нужно ничего клонировать и
нет истории 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
git clone git@github.com:anttro/sigrok_iso7816_stream.git
cd sigrok_iso7816_stream
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`), создаёт симлинк репозитория в
(`sigrok-firmware-fx2lafw`), создаёт симлинк каталога проекта в
`~/.local/share/libsigrokdecode/decoders/iso7816` (пользовательские
декодеры перекрывают системные) и опрашивает подключённое устройство на
предмет максимальной поддерживаемой частоты дискретизации.
### Windows
#### Windows (скачать)
Предварительные требования:
1. **sigrok-cli** — установщик для Windows (0.7.2 x86_64) с
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads).
Если он не запускается с ошибкой `0xc0150002`, установите
[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 поставляется вместе с установщиком — отдельно скачивать не нужно.
@@ -355,7 +406,42 @@ cd sigrok_iso7816_stream
- 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
```
Свежему клону нужны те же проверки и симлинк `./setup.sh`, что и выше: он
проверяет sigrok-cli и пакет прошивки FX2, создаёт симлинк каталога проекта
в `~/.local/share/libsigrokdecode/decoders/iso7816` и опрашивает устройство
на предмет максимальной частоты дискретизации.
### Windows (из исходников)
Предварительные требования Windows (sigrok-cli и Visual C++ 2010
Redistributable, встроенная прошивка fx2lafw и настройка драйвера
Zadig/WinUSB) — те же, что в разделе
[Windows (скачать)](#windows-скачать) выше.
```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git
@@ -363,11 +449,15 @@ cd sigrok_iso7816_stream
setup.bat
```
`setup.bat` проверяет sigrok-cli и встроенную прошивку, регистрирует декодер
этого репозитория через junction в
`%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816` (подтверждается тем, что
`sigrok-cli -L` показывает `iso7816`), и опрашивает устройство на предмет
поддерживаемых частот дискретизации.
`setup.bat` выполняет те же проверки и регистрацию junction, что и выше.
`start.bat` поддерживает основное подмножество опций (каналы, частота
дискретизации, clock, host/port, pcap, protocol, debug; полный набор — в
`start.sh`) — основной формат с пробелом, а `--clk=CH` тоже работает:
```bat
start.bat --clk D1 --data D6
start.bat --help
```
Запись работает так же, как в Linux:
@@ -376,7 +466,8 @@ start.bat rem запись + поток GSMTAP
```
Примечание: bat-файлы требуют переводов строк CRLF; репозиторий следит за
этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен.
этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен
(zip-архив релиза содержит те же файлы с CRLF).
## Заметки и ограничения по детектированию RST/VCC
+634 -373
View File
File diff suppressed because it is too large. Load diff
+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"=="-h" ( 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
exit /b 1
:usage
echo Usage: %~n0 [OPTIONS]
echo --clk=CH CLK channel default: %CLK%
echo --data=CH I/O channel default: %DATA%
echo --rst=CH RST channel default: %RST%
echo --vcc=CH VCC channel default: %VCC%
echo --samplerate=SR Sample rate default: %SAMPLERATE%
echo --clock=MODE native/detect/sample_as_clock
echo --host=IP GSMTAP destination default: 127.0.0.1
echo --port=N GSMTAP port default: 4729
echo --pcap=FILE Also write decoded events to FILE (no spaces in path)
echo --clk CH CLK channel default: %CLK%
echo --data CH I/O channel default: %DATA%
echo --rst CH RST channel default: %RST%
echo --vcc CH VCC channel default: %VCC%
echo --samplerate SR Sample rate default: %SAMPLERATE%
echo --clock MODE native/detect/sample_as_clock
echo --host IP GSMTAP destination default: 127.0.0.1
echo --port N GSMTAP port default: 4729
echo --pcap FILE Also write decoded events to FILE (no spaces in path)
echo --debug Full decoder + sigrok output (all annotation rows + -l 4).
echo
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-vcc Disable VCC tracking
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
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 ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1237)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -36,7 +36,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1236)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -77,7 +77,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone_sample ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 53)
(11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1]
@@ -102,7 +102,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_mi_a1_coldboot ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -112,12 +112,12 @@ CHKSUM ERROR count: 0
CONCAT APDU count: 0
reader exchanges : 0
capture ATRs : 1
capture APDUs : 473
capture APDUs : 475
byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 10
- valid exchanges (reader retries/dupes): 463
- valid exchanges (reader retries/dupes): 465
- GARBAGE (mis-framed/desync): 0
Degradation metrics:
@@ -129,7 +129,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -156,7 +156,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== alcor_reader_sample ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -189,7 +189,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -216,7 +216,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -243,7 +243,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted
('RST deasserted',)
@@ -270,7 +270,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',)
('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',)
iso7816-1: 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_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_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_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_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_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_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok
test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok
test_seeded_bit_samples_does_not_block_recovery (__main__.TestAtrHuntEscape.test_seeded_bit_samples_does_not_block_recovery) ... ok
test_9e_9f_are_status_words (__main__.TestDecodeStep.test_9e_9f_are_status_words) ... ok
test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_dense_valid_cla_is_not_idle_noise) ... ok
test_data_0x3b_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3b_before_traffic_is_atr_candidate) ... ok
test_data_0x3b_is_not_an_atr_after_traffic (__main__.TestDecodeStep.test_data_0x3b_is_not_an_atr_after_traffic) ... ok
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_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok
test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok
test_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pulse_start_edge_preserved) ... ok
test_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_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_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_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok
test_is_desynced_short_fragment (__main__.TestDesyncHelpers.test_is_desynced_short_fragment) ... ok
test_max_tpdu_len (__main__.TestDesyncHelpers.test_max_tpdu_len) ... ok
test_plausible_sw_accepts_valid_classes (__main__.TestDesyncHelpers.test_plausible_sw_accepts_valid_classes) ... ok
test_plausible_sw_rejects_garbage (__main__.TestDesyncHelpers.test_plausible_sw_rejects_garbage) ... ok
test_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_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
@@ -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_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_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_xiaomi_select_getresp_swallow (__main__.TestEmbeddedExchanges.test_xiaomi_select_getresp_swallow) ... ok
test_eoferror_propagates (__main__.TestEndOfStream.test_eoferror_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_payload_passthrough (__main__.TestGsmtapFlags.test_build_packet_payload_passthrough) ... ok
test_build_packet_plain_res_zero (__main__.TestGsmtapFlags.test_build_packet_plain_res_zero) ... ok
test_flag_constant_value (__main__.TestGsmtapFlags.test_flag_constant_value) ... ok
test_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_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... 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
test_rejects_phantom_sub_etu_fall (__main__.TestNextStartFall.test_rejects_phantom_sub_etu_fall) ... ok
test_returns_none_when_no_fall (__main__.TestNextStartFall.test_returns_none_when_no_fall) ... ok
test_etu_glitch_floor_scales (__main__.TestSamplerateScaling.test_etu_glitch_floor_scales) ... ok
test_samples_for_us (__main__.TestSamplerateScaling.test_samples_for_us) ... ok
test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok
test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok
test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok
test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok
test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok
test_decode_step_reads_exactly_one_block (__main__.TestT1Blocks.test_decode_step_reads_exactly_one_block) ... ok
test_handle_pps_truncated_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_is_clean) ... ok
test_handle_pps_truncated_optional_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_optional_is_clean) ... ok
test_i_block (__main__.TestT1Blocks.test_i_block) ... ok
test_m_chain_concatenates_inf (__main__.TestT1Blocks.test_m_chain_concatenates_inf) ... ok
test_r_and_s_blocks_have_no_inf (__main__.TestT1Blocks.test_r_and_s_blocks_have_no_inf) ... ok
test_truncated_block_before_lrc_is_clean (__main__.TestT1Blocks.test_truncated_block_before_lrc_is_clean) ... ok
test_truncated_inf_is_clean (__main__.TestT1Blocks.test_truncated_inf_is_clean) ... ok
test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok
test_invalid_cla_proprietary_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_proprietary_unseen) ... ok
test_invalid_ins_lsb_odd (__main__.TestValidateT0Apdu.test_invalid_ins_lsb_odd) ... ok
test_invalid_cla_reserved_and_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_reserved_and_unseen) ... ok
test_invalid_cla_untraced_variants (__main__.TestValidateT0Apdu.test_invalid_cla_untraced_variants) ... ok
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_zero (__main__.TestValidateT0Apdu.test_invalid_ins_zero) ... ok
test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok
test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok
test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok
test_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_case2_with_response (__main__.TestValidateT0Apdu.test_valid_case2_with_response) ... ok
test_valid_case3_with_command_data (__main__.TestValidateT0Apdu.test_valid_case3_with_command_data) ... ok
test_valid_cla_channel1_select_after_manage_channel (__main__.TestValidateT0Apdu.test_valid_cla_channel1_select_after_manage_channel) ... ok
test_valid_cla_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_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_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
+804 -11
View File
@@ -98,6 +98,31 @@ class TestDesyncHelpers(unittest.TestCase):
self.assertFalse(self.pd.is_desynced(
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):
# 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
@@ -126,6 +151,262 @@ class TestDesyncHelpers(unittest.TestCase):
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):
@classmethod
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')
def test_valid_cla_interindustry_classes(self):
# Interindustry CLA digits 0x/1x/4x/5x are structurally valid
# (ISO 7816-4): includes logical channels 0x01-0x03, secure
# messaging 0x0C, chaining 0x10, and the SM/structural 0x4x/0x5x.
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14, 0x44, 0x50, 0x54):
# Interindustry CLA (ISO 7816-4 §5.1.1): 000x xxxx 0x00-0x1F
# (channels 0-3 in b2b1, SM 0x0C, chaining 0x10-0x1F) and
# 01xx xxxx 0x40-0x5F (channels 4-19 without CLA-level SM:
# 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])
with self.subTest(cla=cla):
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):
# The command that follows MANAGE CHANNEL (00 70 00 00 01) opening
# 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'\x6a\x82')
def test_invalid_cla_proprietary_unseen(self):
# 0xC0 is proprietary but not a class these cards emit; stays invalid
self._invalid(b'\xc0\xa4\x00\x04\x00\x90\x00', 'invalid CLA')
def test_invalid_cla_reserved_and_unseen(self):
# 0x20-0x3F (001x xxxx) is reserved by ISO 7816-4; 0x90-0x9F,
# 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):
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):
self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_ins_lsb_odd(self):
self._invalid(b'\x00\xa5\x00\x04\x00\x90\x00', 'invalid INS')
def test_valid_ins_odd_variants(self):
# 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):
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):
self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1')
@@ -278,6 +654,7 @@ class TestAtrHuntEscape(unittest.TestCase):
self.pd = pd
self.inst = object.__new__(pd.Decoder)
self.inst.bit_samples = None
self.inst._etu_measured = False
self.inst._atr_hunt_count = 0
self.inst._atr_fail_total = 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.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):
actions = [self._hunt_round(0x00) for _ in range(200)]
actions = actions[:actions.index('resume_data') + 1]
@@ -337,6 +730,7 @@ class TestAtrHuntEscape(unittest.TestCase):
def test_no_recovery_when_etu_known(self):
self.inst.bit_samples = 100
self.inst._etu_measured = True
for _ in range(6):
self.assertEqual(self._hunt_round(0x00), 'retry')
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
# 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 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'
'3500350270000030151601252500000018ed15e8476126aa9cacbbe8f4'
'735f962016e3c6e30a26af20c02bad2a34bdf49f152e263bc7390a6119')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
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):
# A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix.
h = ('000402a43f00612501c0000025c062238202782183023f00a5098001'
@@ -626,5 +1031,393 @@ class TestLineEventPayload(unittest.TestCase):
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__':
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
#!/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.
README_RES="$(git show "$REF:README.md" \
| grep -oE '\]\(resources/[^)]+' | sed 's/](//' | 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/](//' | LC_ALL=C sort -u || true)"
[ "$README_RES" = "$README_RUS_RES" ] || \
die "README.md and README_RUS.md reference different resources/ images"
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"
} | 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 = 0x01
# Valid CLA values for ISO 7816 / 3GPP. Mirror of plausible_cla in pd.py:
# interindustry structures (high nibble 0x0/0x1/0x4/0x5), which covers the
# logical-channel classes 0x01-0x03 (e.g. the command after MANAGE CHANNEL
# opens channel 1), plus the proprietary SIM/UICC CAT classes.
# Valid CLA values for ISO 7816-4 / TS 102 221. Mirror of plausible_cla in
# pd.py: interindustry 0x00-0x1F (channels 0-3) and 0x40-0x5F (channels
# 4-19 without CLA-level SM), the UICC extended classes 'CX'/'EX'
# (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):
if not (cla & 0x80):
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))
@@ -65,10 +69,12 @@ MAX_TPDU_LEN = 271
# data region, followed by its P3 data bytes and an interior status word.
# These constants bound detector precision (empirically tuned on all 10
# example traces: 0 hits on clean traces, every Samsung/xiaomi family hit).
# Mirror of pd.py: real command classes only -- the structural 0x4x/0x5x
# forms that _plausible_cla() tolerates only add payload false hits.
# Pinned in both pd.py and here -- deliberately narrower than VALID_CLA:
# 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(
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).
EMBEDDED_INS = frozenset((
0x12, 0x14, 0x20, 0x44, 0x70, 0x88,
@@ -89,8 +95,9 @@ def embedded_exchanges(buf):
embedded inside a packet's data region (index 5 onward).
Pattern: header (CLA INS P1 P2 P3) + P3 data bytes + interior SW1/SW2
before the packet's own final status word. CLA must be structurally
valid (same rule as _plausible_cla); INS must be a real command byte;
before the packet's own final status word. CLA must be one of the
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
and must be interior (trailing bytes follow it).
"""
@@ -134,12 +141,26 @@ def _plausible_sw(sw1):
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):
# 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:
return False
if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90:
return False
if (ins & 0x01) != 0:
if (ins & 0x01) != 0 and ins not in ODD_INS:
return False
return True