Compare commits

...
7 Commits
Author SHA1 Message Date
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 2469 additions and 549 deletions

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
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: 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)
# 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.
if ! gh release upload "$GITHUB_REF_NAME" dist/* --clobber; then
gh release create "$GITHUB_REF_NAME" dist/* --draft \
--title "sigrok_iso7816_stream $version" --generate-notes
fi
# Publish a new draft and re-publish an existing one.
gh release edit "$GITHUB_REF_NAME" --draft=false
+3
View File
@@ -4,3 +4,6 @@ __pycache__/
# Trace fixtures are local-only (large, machine-specific); never tracked. # Trace fixtures are local-only (large, machine-specific); never tracked.
examples/ examples/
# Release archives built by tools/build_release.sh.
dist/
+410 -86
View File
@@ -142,10 +142,10 @@ the reader log for all reader exchanges:
Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK. Both traces show 0 garbage APDUs, 0 payload mismatches, and RESULT: OK.
**Root cause:** `_edge_read` is initialized to `True` and never set to **Root cause:** `_edge_read` was initialized to `True` and never set to
`False`, so the edge-read T=0 procedure loop (lines 1808–1895) always `False`, so the edge-read T=0 procedure loop always executed and the
executes — the ATR-based procedure loop (lines 1898–1941) is dead code. ATR-based procedure loop was dead code. **FIXED (v1.9.7):** the flag and
The edge-read path had two bugs: the dead loop are removed. The edge-read path had two bugs:
1. **NULLs treated as command data (FIXED).** The first post-header byte 1. **NULLs treated as command data (FIXED).** The first post-header byte
was checked for ACK; if it was NULL (0x60), it entered the case-3/4 was checked for ACK; if it was NULL (0x60), it entered the case-3/4
@@ -160,14 +160,30 @@ The edge-read path had two bugs:
#### CLA validation note #### CLA validation note
All example traces (`test_7`, `test_8`, `xiaomi`, `samsung`) `plausible_cla()` (v1.9.3) accepts the higher logical channels:
contain only CLA `0x00` (interindustry) and `0x8x` (USIM/CAT, mostly `0x80`
with occasional `0x81` for logical channel 1). - interindustry `0x00`-`0x1F` (channels 0-3 in b2b1) and `0x40`-`0x5F`
The decoder must **not** mandate specific CLA values — other CLA values (`01xx xxxx`: channels 4-19 in b4..b1, no CLA-level SM; `0x50`-`0x5F`
(e.g. `0x04`, `0x08`, `0x84`, `0xA0`, `0xB0`) are valid per ISO 7816-4 / 3GPP are the legacy chaining variants);
and must be accepted. However, during test runs against the example traces, - the UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`,
any CLA value outside `0x00-0x0F` and `0x80-0x8F` is an error flag indicating channels 4-19, SM in b6 -- TS 102 221 Table 10.4a);
the decoder mis-framed the byte stream. - `0x80`/`0x84`/`0xA0`/`0xB0` (0xB0 has no pinned-spec basis; kept for
backward compatibility).
Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (the
SM-indicating extended classes -- `0x60`-`0x6F` overlaps printable ASCII
and the text byte `0x61` started a bogus frame on xiaomi_mi_a1_coldboot
when it was accepted); `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (the
`'8X'`/`'AX'` standard-channel variants for channels 0-3 other than the
legacy `0x80`/`0x84`/`0xA0` -- the FCP byte `0xA5` did the
same); `0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`. They are spec-coded
but cost frame-start precision; add any of them back when a real trace
requires it.
During test runs such a CLA is an error flag indicating the decoder
mis-framed the byte stream. The example traces carry `0x00`
(interindustry), `0x01` (logical channel 1 -- the SELECT-by-AID after
MANAGE CHANNEL opens it) and `0x80` (USIM/CAT); no trace uses channels 2-3
or the higher channels yet.
#### ISO 7816 APDU validation (enforced from v1.1.7) #### ISO 7816 APDU validation (enforced from v1.1.7)
@@ -179,10 +195,18 @@ Validation is purely structural (no content scanning of command/response
data), so the false-positive rate is very low. The checks are: data), so the false-positive rate is very low. The checks are:
1. Length is at least 7 bytes (header + SW) and at most `MAX_TPDU_LEN` (271). 1. Length is at least 7 bytes (header + SW) and at most `MAX_TPDU_LEN` (271).
2. CLA byte is in the recognized set (`0x00`, `0x04`, `0x08`, `0x80`, 2. CLA byte is a structurally valid ISO 7816-4 / UICC class — see
`0x84`, `0xA0`, `0xB0`). `plausible_cla()` (v1.9.3): `0x00`-`0x1F`, `0x40`-`0x5F` (logical
channels 4-19), `0xC0`-`0xCF`/`0xE0`-`0xEF` (`'CX'`/`'EX'`), plus
`0x80`/`0x84`/`0xA0`/`0xB0`.
3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word 3. INS byte is plausible: not `0x00`, not in `0x6x`/`0x9x` (status-word
collision), and the least-significant bit is `0`. collision). Odd INS codes are accepted only as the defined alternate
variants (ISO 7816-4 Table 4 pairs: `'0F'`, `'21'`, `'87'`, `'A1'`,
`'B1'`, `'B3'`, `'C3'`, `'CB'`, `'D1'`, `'D7'`, `'DB'`, `'DD'`;
TS 102 221 AUTHENTICATE `'89'`). Other odd values stay rejected: a
mis-framed boundary exposes data bytes, and the text run `49 4D`
("IM") started a bogus frame on xiaomi_phone_sample when every odd INS
was accepted.
4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`. 4. The penultimate byte (SW1) is in `0x61`–`0x6F` or `0x90`–`0x9F`.
`0x60` is explicitly invalid: it is the T=0 NULL procedure byte `0x60` is explicitly invalid: it is the T=0 NULL procedure byte
(ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**; (ISO 7816-3 §10.3.3 Table 11 marks SW1 as `'6X'` **except `'60'`**;
@@ -309,6 +333,232 @@ Known limitation: no on-disk fixture reproduces the exact live failure (all
real captures contain a 372 ATR), so end-to-end proof needs a re-captured real captures contain a 372 ATR), so end-to-end proof needs a re-captured
`.sr` from the failing phone; the unit tests cover the loop-escape logic. `.sr` from the failing phone; the unit tests cover the loop-escape logic.
### v1.9.7: spec fixes, T=1/parity, EOF guards, dead code + rate scaling
The audit's P2 items:
- **Odd INS set completed.** `0x73` MANAGE SECURE CHANNEL and `0x75`
TRANSACT DATA (TS 102 221 Table 10.5) join `ODD_INS`; the mirrored set in
`tools/vs_reader.py` and the pinned test are updated.
- **9E/9F are SW1.** ISO 7816-3 §10.3.3 Table 11: every `9X` value is
SW1. The fabricated "extended-length procedure byte" (it consumed the
next byte as a length, eating SW2 and losing the status word) is gone.
The second copy lived in the dead ATR-based loop removed below.
- **T=1 blocks.** `t1_parse_block()` EOF-guards every read (a truncated
block returns no half INF instead of raising) and bounds the M-bit chain
with `T1_CHAIN_MAX` instead of unbounded recursion. The caller's
duplicate "chain follow-up" loop -- it read the *next* block after every
I-block, usually the peer's R-block, and merged the following I-block's
INF into the packet -- is gone. R/S blocks emit nothing; the (possibly
chained) I-block INF is emitted as the APDU payload. No T=1 trace
exists in the corpus, so this is unit-tested only.
- **Parity flag.** A packet containing a parity-error byte is now emitted
with `GSMTAP_FLAG_BAD_FCS` (the CHKSUM annotation alone was invisible to
downstream tooling), with a log note when the exchange is otherwise
clean.
- **EOF guards.** `handle_atr()` (TA/TB/TC/TD, historical bytes, TCK) and
`handle_pps()` (request and response fields, including the optional
PPS1-3) return cleanly on a truncated capture instead of raising on a
None byte; the T=1 block parser likewise.
- **Dead code removed.** `_edge_read` was initialized `True` and never
cleared, so the "ATR-based" T=0 procedure loop (58 lines) never ran and
the 1.5-ETU guard branch was unreachable. The flag and both dead paths
are removed; the edge reader is the only T=0 framing reader (the
CLK-sync reader is selected by `_clock_skip_confident`). The live loop
was dedented, not rewritten -- the trace metrics are unchanged.
- **Samplerate scaling.** `_samples_for_us()` replaces the hard-coded
16 MHz thresholds: the start-bit deglitch (`_min_pulse_samples`; `None`
= 2 samples @ 16 MHz), the `min_high` floor (0.25 us) and the ETU glitch
floor in `_etu_candidate` (0.75 us). At 16 MHz the values are
unchanged; another capture rate now scales instead of breaking the
filters.
Validation: 130/130 unit tests (13 new: 9E/9F SW1 for both values, the
parity BAD_FCS flag, T=1 I/R/S blocks + M chain + two truncation cases
+ a one-block-per-step caller test + two PPS truncations, the truncated
ATR TA, the samplerate/ETU-floor vectors); the 10-trace suite is
unchanged from v1.9.6 (identical
APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.7.
### v1.9.6: ETU lock validation, hunt recovery, resync + ATR acceptance
The audit's P1 findings. The validation helpers are unit-tested; the trace
suite stays at the v1.9.5 metrics.
- **ETU measurement validation (`_etu_candidate`).** The mid-session
estimator used to lock the inter-character guard as the ETU on all-zero
data: a 0x00 run gives 10-ETU LOW runs plus a short HIGH guard, so the
smallest recurring spacing is the guard (2x-3x the true ETU), and a lone
0x00 character's single 10-ETU spacing is 10x. A burst now needs at
least two usable spacings, and a burst whose minimum recurrence is a HIGH
guard with every other spacing >= 2x it is rejected -- the decoder waits
for a burst with bit transitions (this covers the 2-5 ETU guard range).
A failed measurement leaves `bit_samples` unset instead of committing the
old `clock_skip*3` guess.
- **Invalidation.** `_confirm_etu()` no longer counts a dropped frame (no
genuine start -> empty bits) as parity-OK, and a rejected measurement now
clears `bit_samples`, `_etu_measured` and `_clock_skip_confident` (the old
code kept sampling at the rejected rate).
- **Hunt ETU recovery.** `handle_atr()` pre-seeds `bit_samples` with the
default-rate guess while the recovery condition tested `bit_samples is
None`, so the v1.6.0 live-ETU recovery could never fire from the hunt.
The condition now keys off `_etu_measured` (a real DATA measurement); a
measured rate still suppresses the recovery.
- **`resync_idle()`** now actually waits: it used `wait({'skip': 0})` in a
loop (the same sample 48 times -> a no-op). It now waits on the DATA idle
level (plus RST/VCC edges, bounded) before the next peek.
- **ATR acceptance.** A DATA-state `0x3B`/`0x3F` is an ATR candidate only
before any APDU has been emitted (`_apdu_seen`); a parsed ATR is rejected
when any of its bytes had a parity error or when it is a bare TS+T0
(`3B 00`). A data run misread at a frame boundary used to be committed
as an ATR (a fabricated `3b80900000` appeared while developing this
change), forcing `clock_skip=372` for the rest of the capture. Real
warm-reset ATRs are parity-clean and commit as before.
- **Inverse convention (TS=0x3F).** The readers assumed an idle-LOW line
and a rising start bit; ISO 7816-3 7.1 keeps the line idle HIGH and the
start bit LOW in both conventions -- only the data polarity and order
change. `wait_data_falling`/`resync_idle` now use the HIGH idle for
inverse too, `get_bytes()` assembles the inverse data msb-first, and the
DATA state accepts `0x3F` alongside `0x3B`. The data readers already
inverted the bit polarity. A real inverse TS decodes as `0x03` through
the direct reader, so `ts_from_bits()` matches the raw ISO 7816-3 8.1
pattern (moments 1-4 LHHL, 5-7 LLL/HHH, 8-10 LLH) in both the hunt and
the DATA state instead of trusting the decoded byte. The readers exempt
a matching TS from the parity check: its moment 10 is fixed by the
pattern, and the inverse TS is legitimately odd over moments 2-10, so it
must not log a CHKSUM or reject the ATR. No inverse-convention capture
exists in the corpus, so this is spec-verified, not trace-verified.
Validation: 117/117 unit tests (18 new: the ETU candidate vectors, the
hunt-recovery seeded/measured cases, the resync level wait, the bare-ATR
and parity-ATR guards, the `_apdu_seen` gate both ways, the inverse
bit-order/start-edge/TS-pattern vectors); the 10-trace suite is unchanged
from v1.9.5 (identical APDU/CHKSUM/CONCAT/BAD_FCS counts, `RESULT: OK`
everywhere);
`compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.6.
### v1.9.5: T=0 framing — immediate SW, short responses, Le=256
The audit's P0 framing fixes, each pinned by a `TestDecodeStep` vector:
- **Immediate SW.** The first post-header byte is classified as a status
word before the ACK/data decision. ISO 7816-3 §10.3.3 lets the card
finish at the first procedure byte; a case-2 rejection (`6A82`, `6CXX`,
...) carries no ACK and used to be read as command data (the exchange
and the following one were silently consumed).
- **Short responses (Lr < Le).** The ACK path still reads the P3 bytes,
but if the next byte is not SW1 the buffer is split at the first
plausible SW1 followed by a plausible CLA, the tail is replayed into
`_replay`, and the packet is emitted with `BAD_FCS`. Previously the SW
scan discarded the real SW, merged the following exchanges into a
clean-looking packet, and at EOF lost the exchange silently. A short
read with no plausible split is emitted flagged and resynced.
- **P3=0 = Le=256.** For an outgoing command P3=0 requests 256 bytes
(§10.3.2). The response data used to be discarded; it is now read until
SW and kept. The read-until-SW ambiguity on data bytes that look like
SW1 remains (inherent to T=0 sniffing).
- **~INS ACK.** One data byte per ACK (Table 11) via the new
`_t0_read_response_data()` helper, instead of "all remaining".
- **Case 4.** ISO 7816-3 §12.2/Table 14 map case 4 to a separate GET
RESPONSE; the ACK path now appends any inline data instead of dropping
it (permissive extension; conforming cards send SW/61XY there).
Validation: 99/99 unit tests; the 10-trace suite is unchanged except
`xiaomi_mi_a1_coldboot` 473 -> 475 APDUs (two previously lost exchanges
recovered), 0 CHKSUM/CONCAT/BAD_FCS/GARBAGE, `RESULT: OK`; all other traces
identical; `compare_baseline.sh` reports IDENTICAL. VERSION -> 1.9.5.
### v1.9.4: decode_step/hunt unit coverage + hunt EOF guard
The v1.9.3 review's residual risk -- the new acceptance classes were only
proven by "no regression" on the example traces -- is closed with a
byte-replay harness, and the new coverage surfaced one latent defect:
- **`TestDecodeStep`** drives the real `decode_step()` DATA-state gate chain
with a scripted byte queue (`read_byte`/`peek_byte`/`_consume_first`
stubbed over `_replay` + the queue; the re-align/PPS/ATR/annotation sinks
stubbed; `reset()` provides the state). Pinned: `'CX'`/`'EX'` exchanges
frame and emit clean; `0x5F` (always eaten by the idle-noise gate before
v1.9.3) frames as a command; the `0x61` menu-text boundary byte is a
suspicious CLA with exactly one byte consumed; the invalid-INS one-byte
re-frame recovers a shifted SELECT into the intended packet; `0xFE` still
takes the idle-noise path.
- **`TestAtrHuntDetect`** drives `handle_atr()` the same way: `0xC4 0xA4`
and `0xC4 0x89` (the odd-INS acceptance) hand the header to DATA via
`_replay`; CLA `0x00` stays excluded; an invalid INS does not hand off.
- **Bug found and fixed:** the hunt's command-detect called
`plausible_ins(nxt)` with no None check -- a capture ending right after a
plausible CLA byte made `peek_byte()` return None and raised TypeError
(previously seen only by `decode()`'s crash protection).
`test_eof_after_plausible_cla_is_clean` pins the clean end-of-capture
return.
Validation: 95/95 unit tests; the new framing/hunt tests fail against
v1.9.2 for the right reasons (the acceptance is missing) and the EOF test
fails against v1.9.3 (the TypeError); full trace suite unchanged
(`compare_baseline.sh` IDENTICAL, baseline regenerated for the version +
test listing). VERSION -> 1.9.4.
### v1.9.3: higher logical channels in the CLA gate + defined odd INS codes
A trace with higher logical channels (4-19) or an odd-INS command could not
decode: the acceptance gates knew neither. Both are now spec-derived, with
the empirical precision of the mis-frame filter preserved.
- **Higher logical channels (4-19).** TS 102 221 §10.1.1 Table 10.4a codes
them `'01x0 xxxx'` (`0x40`-`0x4F` without SM, `0x60`-`0x6F` with SM in b6)
and `'11x0 xxxx'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`, the `'CX'`/`'EX'`
classes) -- ISO 7816-4 §5.1.1 Table 3 is the same with b5 = chaining.
`plausible_cla()` now accepts the `'CX'`/`'EX'` classes alongside the
previous `0x40`-`0x5F`, so extended-channel commands (e.g. STATUS on
channel 4 = `0xC0`) and higher channels in the no-SM `'4X'` form decode.
Rejected after testing: `0x60`-`0x6F`/`0x70`-`0x7F` (the SM-indicating
interindustry classes) and the `'8X'`/`'AX'` channel variants
`0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF`. They are spec-coded, but
they cost
frame-start precision on the mis-framed traces: `0x60`-`0x6F` overlaps
printable ASCII, and the menu-text byte `0x61` ('a') started a bogus
frame on xiaomi_mi_a1_coldboot whose P3 swallowed the following exchanges
(a CONCAT regression); the FCP byte `0xA5` did the same for `'AX'`.
Previously such stray bytes were discarded as "suspicious CLA" and the
decoder resynced; the wider classes turned them into headers. Add the
classes back when a real trace needs them.
- **Odd INS.** The gates rejected every odd INS code, so TS 102 221
AUTHENTICATE `'89'` (chunked authentication data) and the ISO 7816-4
Table 4 alternate variants mis-framed. `plausible_ins()` now accepts
exactly the 13 defined odd codes (`ODD_INS`: `0F 21 87 89 A1 B1 B3 C3
CB D1 D7 DB DD`) and still rejects the rest: accepting every odd value
let the text run `49 4D` ("IM") start a bogus frame on
xiaomi_phone_sample. The header check calls the same `plausible_ins()`
now (the duplicate inline condition is gone) and the stale ISO `§12.1.1`
citation was corrected to `§5.1.2`.
- **Idle-noise gate.** The 6+ set-bits discard runs before the CLA check,
so it would swallow the legitimately bit-dense classes (`0xCF`, `0xE7`,
`0xEB`, `0xED`-`0xEF`) and kept `0x5F` dead (`plausible_cla()` accepted
it, the gate always discarded it). The gate is now
`is_idle_noise_byte()`: only bytes no spec allows are idle noise; valid
CLAs take the normal header path.
- **CONCAT detector unchanged by design.** `EMBEDDED_CLA` no longer
derives from `plausible_cla()`; it is pinned to the empirically tuned 36
values (`0x00`-`0x1F` + `0x80`/`0x84`/`0xA0`/`0xB0`) in both `pd.py` and
`tools/vs_reader.py`, with a membership-pin test and a `pd.py` ↔
`vs_reader.py` mirror test -- the CONCAT scan runs
across payload bytes where the extended classes false-hit (the Alcor
`"40 70 70 61 15"` run).
- **Tests.** CLA accept/reject vectors (including the two regressing bytes
`0x61`/`0xA5` and the reserved ranges), extended-channel
`validate_t0_apdu`/`is_desynced` vectors, the odd-INS vectors, the
idle-noise vectors, the EMBEDDED_CLA membership pin, the `ODD_INS` set
pin and the `pd.py` ↔ `vs_reader.py` mirror test (75 -> 84 tests);
`tools/vs_reader.py` mirrors the predicate, `ODD_INS` and the pinned set.
Validation: all 10 traces identical to the v1.9.2 baseline (APDU counts,
CHKSUM/CONCAT/BAD_FCS/suspicious-CLA counts, `RESULT: OK` everywhere);
`compare_baseline.sh` reports IDENTICAL. The xiaomi CONCAT regressions the
wider sets produced were found by this loop and narrowed away before the
baseline was regenerated (version + test listing only). VERSION -> 1.9.3.
### v1.9.2: PPS CLK upshift + exact CLK-edge projection (new reader support) ### v1.9.2: PPS CLK upshift + exact CLK-edge projection (new reader support)
Two timing bugs surfaced by `examples/alcor_reader_sample.sr`, a PC/SC reader Two timing bugs surfaced by `examples/alcor_reader_sample.sr`, a PC/SC reader
@@ -503,8 +753,9 @@ the source**, not just gated. All 10 traces report 0 CONCAT:
| S21p_coldboot_new_cable_sim2 | 375 | 0 | | S21p_coldboot_new_cable_sim2 | 375 | 0 |
1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes 1. **One-byte re-frame on invalid INS.** When a 5-byte header window passes
`plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or odd `plausible_cla()` but the INS is unusable (`0x00`, `0x6x`/`0x9x`, or an
LSB — ISO 7816-4 §12.1.1), the window is almost always shifted by one odd code outside the defined variants -- see the v1.9.3 note), the
window is almost always shifted by one
stray leading byte (e.g. a leftover SW2 read after a PACKETEND). The old stray leading byte (e.g. a leftover SW2 read after a PACKETEND). The old
code silently dropped all 5 bytes plus resync (`_resync = True`), and when code silently dropped all 5 bytes plus resync (`_resync = True`), and when
`resync_idle()` gave up on a busy burst the next byte became a bogus CLA `resync_idle()` gave up on a busy burst the next byte became a bogus CLA
@@ -635,13 +886,11 @@ The 2-sample advance in `wait_data_falling` shifts `_start_fall` by 2
samples, but at Samsung's 16 CLK/ETU (~72 samples) this is negligible samples, but at Samsung's 16 CLK/ETU (~72 samples) this is negligible
(<3% of a bit period) and does not affect downstream byte reading. (<3% of a bit period) and does not affect downstream byte reading.
**Design note: magic number.** `_min_pulse_samples = 2` is tuned for the **Design note: pulse threshold.** `_min_pulse_samples` used to be a
primary 16 MHz sample rate (2 samples ≈ 125 ns, well within the FX2LP hard-coded 2 samples, tuned for the 16 MHz corpus (≈125 ns, within the
pull-up rise time of 200–500 ns). At other sample rates the value would FX2LP pull-up rise time of 200–500 ns). Since v1.9.7 it is `None` by
need adjustment — but since all current traces are 16 MHz, this is left default and scales from `samplerate` (`_samples_for_us(0.125)` → 2 at
as a hardcoded constant rather than a decoder option. If 12 MHz or 16 MHz); a test or option may still override it (0 disables the check).
24 MHz traces are added later, consider making it configurable or
scaling it from the sample rate.
**Logging.** Each rejected start-bit glitch is logged with a running **Logging.** Each rejected start-bit glitch is logged with a running
count (`start-bit glitch rejected (#N)`) visible in the session log. count (`start-bit glitch rejected (#N)`) visible in the session log.
@@ -665,40 +914,54 @@ window may be filtering real start bits.
| ACK (== INS or == ~INS) | Full data match: card acknowledges and expects all P3 data bytes | | ACK (== INS or == ~INS) | Full data match: card acknowledges and expects all P3 data bytes |
| INS^0x01 or ~INS^0x01 | Single-byte match: card validates syntax but processes data one byte at a time | | INS^0x01 or ~INS^0x01 | Single-byte match: card validates syntax but processes data one byte at a time |
| 0x60 | NULL: card busy, wait for next procedure byte (never an SW1) | | 0x60 | NULL: card busy, wait for next procedure byte (never an SW1) |
| 0x9E / 0x9F | Extended-length: next byte L = number of response data bytes, then SW | | SW1 (`0x61`–`0x6F` / `0x90`–`0x9F`) | Status word: exchange complete. ISO 7816-3 §10.3.3 Table 11 has no exception for `9X`: `9E`/`9F` are SW1 too (the v1.9.7 fix) |
| SW1 (`0x61`–`0x6F` / `0x9X`) | Status word: exchange complete |
##### CLA (Class) Byte — Interindustry Structure ##### CLA (Class) Byte — Coding and Logical Channels
``` Accepted (v1.9.3):
Bit: 8 7 6 5 4 3 2 1
b8 b7 b6 b5 SM SM ch ch
```
- Bits 8–7: `00` or `01` for interindustry; `1x` reserved for proprietary - `000x xxxx` (`0x00`-`0x1F`): first interindustry — SM in b4b3, chaining in
- Bit 6: `0` for interindustry; `1` for proprietary structural formats b5, logical channel **0–3** in b2b1.
- Bits 4–3: Secure Messaging indicator (`00` = no SM) - `01xx xxxx`: further interindustry — logical channel **4–19**
- Bits 2–1: Logical channel (0–3 for interindustry) (b4..b1 = channel − 4). Accepted: `0x40`-`0x4F` plus the legacy
`0x50`-`0x5F` chaining variants (no CLA-level SM).
- UICC extended classes `'CX'`/`'EX'` (`0xC0`-`0xCF`/`0xE0`-`0xEF`):
channels 4–19 in b4..b1, SM in b6.
- Legacy `0x80`/`0x84`/`0xA0`/`0xB0`.
Valid interindustry CLA digits: `0x`, `1x`, `4x`, `5x`. Rejected: `0x20`-`0x3F` (ISO-reserved); `0x60`-`0x6F`/`0x70`-`0x7F` (SM in
b6 — `0x60`-`0x6F` overlaps printable ASCII, and text bytes started bogus
frames on the mis-framed phone traces); the `'8X'`/`'AX'` standard-channel
variants `0x81`-`0x83`/`0x85`-`0x8F`/`0xA1`-`0xAF` (channels 0–3; same
trade-off);
`0x90`-`0x9F`, `0xD0`-`0xDF`, `0xF0`-`0xFF`.
Examples: `0x00` (standard), `0x0C` (secure messaging). Examples: `0x00` (standard), `0x01` (channel 1), `0x44` (channel 8),
`0x4F` (channel 19), `0xC0` (STATUS on channel 4, `'CX'` class),
`0x0C` (secure messaging).
##### INS (Instruction) Byte — Valid Ranges ##### INS (Instruction) Byte — Valid Ranges
- Low nibble cannot be `X0` if high nibble is even - `0x00` is reserved/invalid.
- Low nibble cannot be `XF` under any circumstances - `0x6x`/`0x9x` collide with status words and are invalid.
- `0x6x` and `0x7x` ranges are invalid (reserved for SW1 / protocol bytes) - Odd INS codes are valid only as the defined alternate variants
(`0F` ERASE BINARY, `21` VERIFY, `87` GENERAL AUTHENTICATE,
`A1` SEARCH BINARY, `B1` READ BINARY, `B3` READ RECORD, `C3` ENVELOPE,
`CB` GET DATA, `D1` WRITE BINARY, `D7` UPDATE BINARY, `DB` PUT DATA,
`DD` UPDATE RECORD; TS 102 221 AUTHENTICATE `89`).
Standard interindustry INS values: `A4` (SELECT), `B0` (READ BINARY), Common INS values: `A4` (SELECT), `B0`/`B1` (READ BINARY),
`D6` (UPDATE BINARY), `B2` (READ RECORD), `E2` (APPEND RECORD), `D6`/`D7` (UPDATE BINARY), `B2`/`B3` (READ RECORD), `E2` (APPEND RECORD),
`20` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88` (INTERNAL AUTHENTICATE). `20`/`21` (VERIFY), `82` (EXTERNAL AUTHENTICATE), `88`/`89` (AUTHENTICATE),
`C2`/`C3` (ENVELOPE: CAT), `C0` (GET RESPONSE).
##### Quick Validation Checklist ##### Quick Validation Checklist
1. CLA high nibble is `0`, `1`, `4`, or `5` (for interindustry) 1. CLA is a valid class: `0x00`–`0x1F`/`0x40`–`0x5F` (interindustry, channels
2. INS low nibble is not `F` 0–3/4–19) or `0xC0`–`0xCF`/`0xE0`–`0xEF` (UICC extended) or
3. INS is outside `0x6x`–`0x7x` range `0x80`/`0x84`/`0xA0`/`0xB0`
2. INS is not `0x00`
3. INS is outside the `0x6x`/`0x9x` status-word ranges
**Current smoke-test status:** **Current smoke-test status:**
@@ -753,9 +1016,47 @@ append the measured CLK frequency when known
### Versioning ### Versioning
The decoder version is defined in `pd.py` as `VERSION = '1.9.2'`. The decoder version is defined in `pd.py` as `VERSION = '1.9.7'`.
The version is printed to the log on decoder startup. The version is printed to the log on decoder startup.
### Release packaging (v1.9.7)
The GitHub release archives let users run the decoder without cloning the
repo. `./tools/build_release.sh [REF]` (REF defaults to `HEAD`) builds
`dist/sigrok_iso7816_stream-<VERSION>.tar.gz` (Linux) and
`dist/sigrok_iso7816_stream-<VERSION>.zip` (Windows) plus
`dist/SHA256SUMS`. `VERSION` comes from the ref's `pd.py`; a `v*` REF must
equal it.
The release file set is an explicit allowlist passed to `git archive` as its
pathspec, and also compared exactly against both archive listings (missing
*and* unexpected entries fail): the decoder (`__init__.py`, `pd.py`,
`gsmtap_stream.py`), the scripts (`setup.sh`/`start.sh`,
`setup.bat`/`start.bat`), `tools/vs_reader.py`, `README.md`, `README_RUS.md`,
`LICENSE`, 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. **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. Release notes are GitHub's auto-generated notes from the
commits; `AGENTS.md` and `tests/` are not in the archive. Packaging/docs
only - no decoder behavior change, and `VERSION` is unchanged.
### Testing after decoder changes ### Testing after decoder changes
After any change to `pd.py`: After any change to `pd.py`:
@@ -934,23 +1235,35 @@ no `0xff`/`0xfc` garbage.
Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those Commands that carry command-data (e.g. `80 12 … d0`, P3=208) send those
P3 bytes on the wire **before** the card's first procedure byte. The P3 bytes on the wire **before** the card's first procedure byte. The
framing now distinguishes the cases by the card's first procedure byte: framing classifies the first post-header byte in this order (v1.9.5):
- If the first post-header byte **is the ACK (== INS)** → case 2/4: the - **SW1** (`0x61-0x6F`/`0x90-0x9F`) → the card finished with SW1 SW2 at the
terminal sent no command data; read `P3` response bytes (+SW). first procedure byte (ISO 7816-3 §10.3.3; a case-2 rejection such as
- Otherwise the byte is terminal command data → case 3/4: read `P3` `6A82`/`6CXX` carries no ACK).
command-data bytes, then the card's procedure byte (ACK/SW/`9E`/`9F`). - **ACK (== INS)** → case 2/4: the terminal sent no command data. Read the
On ACK (case 4) the response is read `P3` response bytes (+SW). `P3` response bytes, then the SW. `P3=0` is Le=256 (§10.3.2): read the
response data until the SW. If the card returns **fewer** bytes than Le
(`6282` end of file), the buffer is split at the first plausible SW1
followed by a plausible CLA, the tail is replayed and the packet is
flagged BAD_FCS (v1.9.5; previously the SW scan discarded the real SW and
the following exchanges could be merged silently).
- **~INS** → byte-by-byte transfer: one data byte per ACK, read until SW.
- Otherwise → case 3/4: the byte is terminal command data; read `P3`
command-data bytes, then the card's procedure byte (ACK/SW; `9E`/`9F`
are SW1, not an extended length).
On ACK, append any inline response data until SW (ISO 7816-3 §12.2 maps
case 4 to a separate GET RESPONSE, so conforming cards send SW/61XY
here; the append is a permissive extension).
This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope This consolidates case-3/4 exchanges (e.g. the `80 12`/`80 14` STK envelope
commands) into one C-APDU = header + command data, R-APDU = response + SW commands) into one C-APDU = header + command data, R-APDU = response + SW
APDU, instead of fragmenting them across frames. It is gated to APDU, instead of fragmenting them across frames.
`_edge_read` (mid-session / gated-CLK captures) so ATR-based captures
(`test_8` / `test_7`) keep the original framing unchanged and stay at
0 garbage.
All paths use P3-bounded reads (v1.1.1 reverted the read-until-SW P3-bounded reads are kept for case 2 (`Lr == Le`): the read-until-SW
experiment from v1.1.0 which mis-identified FCP `0x62` as SW1). experiment from v1.1.0 mis-identified FCP `0x62` as SW1 and was reverted.
Where the length is unknown (P3=0, case-4 inline data) the read-until-SW
ambiguity remains — a data byte that looks like SW1 is indistinguishable
on the wire.
## Decoder methods tried — reliability registry ## Decoder methods tried — reliability registry
@@ -984,42 +1297,53 @@ discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
`clock_skip=369` before this fix). Logs "RST low at start: expecting cold-boot `clock_skip=369` before this fix). Logs "RST low at start: expecting cold-boot
ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` / ATR". Decodes the full cold-boot ATR on `xiaomi_mi_a1_coldboot_sample.sr` /
`4gmodem_coldboot_sample.sr` (0 GARBAGE). `4gmodem_coldboot_sample.sr` (0 GARBAGE).
- **1.5-ETU start-bit guard.** Rejects the phantom `0x00` an idle glitch - **Half-ETU start-bit guard (edge reader).** Rejects the phantom `0x00`
inserts before an ATR. Reliable. an idle glitch inserts before an ATR while still accepting the ~1-ETU
inter-byte gap on fast ETUs (v1.9.7 removed the unreachable 1.5-ETU
CLK-sync branch). Reliable.
- **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after - **VCC/RST 10 ms level-stability hysteresis.** Reports a level only after
≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events ≥10 ms stable. Reliable; symmetric power-up/down, kills spurious events
from a ringing/floating sense line. from a ringing/floating sense line.
- **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the - **PPS speed-switch follow (`clock_skip = F/D`).** After an accepted PPS the
native skip becomes F/D CLK cycles/bit. Reliable (verified live). native skip becomes F/D CLK cycles/bit. Reliable (verified live).
- **T=1 block-chain follow-up bound.** Caps chained I-blocks so garbage - **T=1 block decode (v1.9.7).** NAD/PCB/LEN/INF/LRC with the M-bit chain
cannot loop forever. Reliable. followed inside `t1_parse_block()` (bounded by `T1_CHAIN_MAX`) and every
- **Synthetic T=0 ATR fallback `[0x3B, 0x00]`.** After 8 failed ATR hunts, read EOF-guarded; R/S blocks carry no INF and are not emitted. Unit
commit a minimal ATR and set `hasT0=True`. Reliable for mid-session starts tested only (no T=1 capture in the corpus). Reliable by construction.
(no ATR in the trace). - **Hunt give-up (no synthetic ATR).** After `ATR_HUNT_FAIL_LIMIT` failed
hunts the decoder resumes DATA without fabricating an ATR (v1.6.0 removed
the old `[0x3B, 0x00]` fallback); the mid-session ETU estimator then
recovers the rate from DATA. Reliable.
- **ETU auto-detect from DATA via edge counting (`_measure_etu`).** Edge-waits - **ETU auto-detect from DATA via edge counting (`_measure_etu`).** Edge-waits
to the first DATA start-bit fall, measures ETU from DATA-edge spacing, locks to the first DATA start-bit fall, measures ETU from DATA-edge spacing, locks
it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking it **once**. Reliable for gated/stopped CLK (phone, ~53 samples). Locking
once (not re-measuring) is drift-immune and avoids glitch re-anchors. once (not re-measuring) is drift-immune and avoids glitch re-anchors. A
burst must yield >=2 usable spacings, and a guard-dominated burst (all-zero
data: long LOW runs + short HIGH guard) is rejected (`_etu_candidate`,
v1.9.6); a rate that fails parity is invalidated and re-measured.
- **Edge-list bit reader (`_read_byte_edges`).** Per-byte edge reconstruction - **Edge-list bit reader (`_read_byte_edges`).** Per-byte edge reconstruction
from the DATA edge list (re-anchors the next byte via `_next_start_fall`). from the DATA edge list (re-anchors the next byte via `_next_start_fall`).
Drift-immune. Reliable for mid-session/edge mode; gated by `_edge_read`. Drift-immune. The T=0 framing reader; the CLK-sync reader is selected
when `_clock_skip_confident` is set (`_edge_read` removed in v1.9.7).
- **`decode_step` unified dispatch.** One state machine handles - **`decode_step` unified dispatch.** One state machine handles
ATR/PPS/DATA + mid-session. Reliable; current architecture. ATR/PPS/DATA + mid-session. Reliable; current architecture.
- **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte - **`hasT0`/`hasT1` guards + init.** Prevents `AttributeError` and skips byte
reading until a protocol is known. Reliable. reading until a protocol is known. Reliable.
- **T=0 case-3/4 ACK==INS discriminator.** First post-header byte == INS ⇒ - **T=0 procedure-byte classification (v1.9.5).** The first post-header
case 2/4 (response bounded by `P3`); else command data (read `P3`, then byte is SW1 ⇒ immediate status word; ACK (== INS) ⇒ case 2/4 (P3
the procedure byte). Reliable for consolidating STK envelope commands. response bytes, short-read split + BAD_FCS); ~INS ⇒ one byte per ACK;
Gated to `_edge_read`. P3-bounded reads avoid FCP tag misidentification else command data (case 3/4). Reliable for consolidating STK envelope
(v1.1.1 reverted read-until-SW from v1.1.0). commands. P3-bounded reads avoid FCP tag misidentification (v1.1.1
- **1.5-ETU start-bit guard in `wait_data_falling`.** `min_high = 1.5 * reverted read-until-SW from v1.1.0).
bit_samples` distinguishes genuine inter-byte idle gaps (≥2 ETU per ISO - **Half-ETU start-bit guard in `wait_data_falling` (v1.9.7).** `min_high
7816-3) from intra-byte HIGH periods (≤1 ETU). Prevents byte misalignment = 0.5 * bit_samples` (with a rate-scaled floor) rejects intra-byte glitch
after `_measure_etu()` consumption (v1.1.1 fix for post-ATR desync). falls while accepting the ~1-ETU inter-byte gap; the next byte's start is
- **Inverse-convention ATR detection in DATA state.** `firstByte == 0x3f` queued by the edge reader (`_next_start_fall`), so no 1.5-ETU guard is
alongside `0x3b` at line 1656, so inverse-convention ATRs (TS=0x3F) are needed on this path.
parsed by the default state. Sets `clock_skip=372` correctly - **Inverse-convention support (TS=0x3F, v1.9.6).** The hunt already read
before PPS (v1.1.1 fix). `0x3F`; the DATA state now accepts it too, the start bit stays a falling
edge from an idle-HIGH line (ISO 7816-3 7.1), and `get_bytes()` assembles
inverse data msb-first. Spec-verified; no inverse capture in the corpus.
- **`_resync = True` after PPS acceptance (native CLK path).** Forces - **`_resync = True` after PPS acceptance (native CLK path).** Forces
`resync_idle()` before the first post-PPS command, preventing the decoder `resync_idle()` before the first post-PPS command, preventing the decoder
from latching onto a mid-byte transition at the new speed (v1.1.1 fix). from latching onto a mid-byte transition at the new speed (v1.1.1 fix).
@@ -1054,11 +1378,11 @@ discarded ideas. "Proven" = kept in `pd.py`; "Rejected" = do not re-add.
`P3`) with P3-bounded reads for all paths (v1.1.1 reverted the `P3`) with P3-bounded reads for all paths (v1.1.1 reverted the
read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as read-until-SW experiment from v1.1.0 which mis-identified FCP `0x62` as
SW1). SW1).
- **0.5-ETU start-bit guard in `wait_data_falling` (for `_edge_read`).** - **0.5-ETU start-bit guard for the ATR-based CLK-sync loop.** It was
`min_high = 0.5 * bit_samples` was too small for slow ETUs — it accepted rejected in v1.1.1 for that path (it accepted intra-byte HIGH periods on
intra-byte HIGH periods (data bit transitions) as start bits, causing byte slow ETUs). The path was dead (`_edge_read` never False) and is removed
misalignment after `_measure_etu()` consumption. Replaced by 1.5-ETU guard in v1.9.7; the surviving edge reader uses the 0.5 guard because its next
in v1.1.1. start bit is queued by `_next_start_fall` (no re-hunt mid-burst).
### Open residual limitations ### Open residual limitations
+110 -20
View File
@@ -55,7 +55,13 @@ this decoder's `pcap_file=<path>` option).
| RST tracking (optional) | `rst_detect=true`: emits reset assert/deassert events (sub_type 0x10) | | RST tracking (optional) | `rst_detect=true`: emits reset assert/deassert events (sub_type 0x10) |
| VCC tracking (optional) | `vcc_detect=true`: emits power on/off events (sub_type 0x11) | | VCC tracking (optional) | `vcc_detect=true`: emits power on/off events (sub_type 0x11) |
| Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection | | Direct PCAP file | `pcap_file=<path>` writes PCAP without shell redirection |
| Start-bit deglitch | 2-sample deglitch in the start-bit detector eliminates sub-ETU LOW glitches from FX2LP pull-up artifacts, preventing byte misalignment on fast phone traces | | Start-bit deglitch | 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 ## Decoder options
@@ -111,13 +117,17 @@ data flags and are not part of the libosmocore spec.
## Usage ## Usage
Quick start after cloning (see Installation below): Quick start (after installing — release download or source checkout; see
Installation below):
```bash ```bash
./setup.sh # check sigrok-cli, register decoder symlink ./setup.sh # check sigrok-cli, register decoder symlink
./start.sh # capture + GSMTAP stream to 127.0.0.1:4729 ./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. `start.sh` defaults: pins D5=CLK, D0=I/O, D4=RST, D2=VCC, 16 MHz samplerate.
Override via args: Override via args:
@@ -155,7 +165,8 @@ shift the whole ATR parse off-by-one). The decoder now:
- never lets a decode exception kill the capture — errors are logged, - never lets a decode exception kill the capture — errors are logged,
annotated, and the state machine resyncs (end-of-stream still exits annotated, and the state machine resyncs (end-of-stream still exits
cleanly); 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 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. native CLK mode `clock_skip` becomes F/D CLK cycles per bit (e.g.
@@ -167,7 +178,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`, **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 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 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 `0x00` an idle glitch can insert before that ATR. Requires
`rst_detect=true` (the `rst` channel assigned); without it a mid-session `rst_detect=true` (the `rst` channel assigned); without it a mid-session
reset is still mis-read. reset is still mis-read.
@@ -183,7 +194,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 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 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 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 debounce, T=0 exchange reassembly). A clean capture now decodes to zero
garbage — see the `vs_reader.py` validation below. garbage — see the `vs_reader.py` validation below.
@@ -221,8 +232,8 @@ filtered from stderr.
- Desynced TPDUs (mis-framed exchanges) are kept but flagged with - Desynced TPDUs (mis-framed exchanges) are kept but flagged with
`GSMTAP_FLAG_BAD_FCS` in the GSMTAP header `res` byte — visible in `GSMTAP_FLAG_BAD_FCS` in the GSMTAP header `res` byte — visible in
Wireshark as a BAD-FCS warning. Wireshark as a BAD-FCS warning.
- A T=0 header with a structurally invalid INS (ISO 7816-4 §12.1.1: - A T=0 header with a structurally invalid INS (ISO 7816-4 §5.1.2:
`0x00`, `0x6x`/`0x9x`, or odd LSB) is almost always a one-byte shift from `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). 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 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 byte become a bogus CLA and swallow the remaining dialog into one CONCAT
@@ -230,6 +241,12 @@ filtered from stderr.
recovering the real command. Every consumed-but-unemitted byte is recovering the real command. Every consumed-but-unemitted byte is
explicitly logged (`discarded byte 0x…: reason`); idle 0x00/0xFF discards explicitly logged (`discarded byte 0x…: reason`); idle 0x00/0xFF discards
are rate-limited, non-idle discards always produce a decoder annotation. 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 Performance: in native CLK mode the decoder calibrates samples per CLK
edge once from the ATR's first frame, then advances bit timing with edge once from the ATR's first frame, then advances bit timing with
@@ -305,27 +322,61 @@ same restriction.
## Installation ## 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 ```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git sha256sum -c --ignore-missing SHA256SUMS # GNU coreutils (Linux)
cd sigrok_iso7816_stream ```
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 ./setup.sh
./start.sh
``` ```
`setup.sh` verifies sigrok-cli and the FX2 firmware package `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 `~/.local/share/libsigrokdecode/decoders/iso7816` (user-local decoders shadow
system-wide ones), and probes the connected device for its maximum supported system-wide ones), and probes the connected device for its maximum supported
samplerate. samplerate.
### Windows #### Windows (download)
Prerequisites: Prerequisites:
1. **sigrok-cli** Windows installer (0.7.2 x86_64) from 1. **sigrok-cli** Windows installer (0.7.2 x86_64) from
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads). [sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads) — the tested
If it fails to start with error `0xc0150002`, install the 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) [Visual C++ 2010 Redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=26999)
(`msvcr100.dll` is required by the bundled Python runtime). (`msvcr100.dll` is required by the bundled Python runtime).
2. fx2lafw firmware ships bundled with the installer — no separate download. 2. fx2lafw firmware ships bundled with the installer — no separate download.
@@ -341,7 +392,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: - Windows binds drivers per USB port when no serial number is present:
moving the dongle to another port may require repeating Zadig. 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 ```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git git clone git@github.com:anttro/sigrok_iso7816_stream.git
@@ -349,10 +433,15 @@ cd sigrok_iso7816_stream
setup.bat setup.bat
``` ```
`setup.bat` verifies sigrok-cli and bundled firmware, registers this repo's `setup.bat` performs the same checks and junction registration as above.
decoder via a junction into `%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816` `start.bat` supports the core option subset (channels, samplerate, clock,
(confirmed by `sigrok-cli -L` listing `iso7816`), and probes the device's host/port, pcap, protocol, debug; `start.sh` has the full set) — the space
accepted samplerates. 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: Capture works the same as on Linux:
@@ -361,7 +450,8 @@ start.bat rem capture + GSMTAP stream
``` ```
Note: batch files need CRLF line endings; the repo enforces this via 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 ## RST/VCC detection notes and limitations
+111 -22
View File
@@ -58,7 +58,13 @@ GSMTAP pcap-файлы (включая PCAP, который этот декод
| Отслеживание RST (опционально) | `rst_detect=true`: события assert/deassert сброса (sub_type 0x10) | | Отслеживание RST (опционально) | `rst_detect=true`: события assert/deassert сброса (sub_type 0x10) |
| Отслеживание VCC (опционально) | `vcc_detect=true`: события включения/выключения питания (sub_type 0x11) | | Отслеживание VCC (опционально) | `vcc_detect=true`: события включения/выключения питания (sub_type 0x11) |
| PCAP сразу в файл | `pcap_file=<path>` пишет PCAP без перенаправления вывода оболочки | | PCAP сразу в файл | `pcap_file=<path>` пишет PCAP без перенаправления вывода оболочки |
| Деглитч стартового бита | Деглитч в 2 отсчёта в детекторе стартового бита убирает LOW-глитчи короче ETU от артефактов подтяжки FX2LP и предотвращает смещение байтов на быстрых телефонных трассах | | Деглитч стартового бита | Деглитч в детекторе стартового бита (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 +121,17 @@ flags) USB-протокола simtrace2 и не является частью с
## Использование ## Использование
Быстрый старт после клонирования (см. «Установка» ниже): Быстрый старт (после установки — скачанный архив или клон репозитория;
см. «Установка» ниже):
```bash ```bash
./setup.sh # проверить sigrok-cli, зарегистрировать симлинк декодера ./setup.sh # проверить sigrok-cli, зарегистрировать симлинк декодера
./start.sh # запись + поток GSMTAP на 127.0.0.1:4729 ./start.sh # запись + поток GSMTAP на 127.0.0.1:4729
``` ```
В Windows те же два шага — `setup.bat`, затем `start.bat` (см. «Установка»
ниже).
Умолчания `start.sh`: выводы D5=CLK, D0=I/O, D4=RST, D2=VCC, частота Умолчания `start.sh`: выводы D5=CLK, D0=I/O, D4=RST, D2=VCC, частота
дискретизации 16 МГц. Переопределяются аргументами: дискретизации 16 МГц. Переопределяются аргументами:
@@ -160,8 +170,8 @@ flags) USB-протокола simtrace2 и не является частью с
- никогда не даёт исключению при декодировании убить запись — ошибки - никогда не даёт исключению при декодировании убить запись — ошибки
логируются, аннотируются, а конечный автомат ресинхронизируется логируются, аннотируются, а конечный автомат ресинхронизируется
(конец потока по-прежнему завершается чисто); (конец потока по-прежнему завершается чисто);
- ограничивает продолжение цепочки T=1-блоков, чтобы мусор не мог - разбирает блоки T=1 (NAD/PCB/LEN/INF/LRC) и ограничивает цепочку по биту M,
зациклиться навсегда. чтобы мусор не мог зациклиться навсегда.
После принятого PPS декодер **следует переключению скорости**: в режиме После принятого PPS декодер **следует переключению скорости**: в режиме
native CLK `clock_skip` становится равным F/D тактов CLK на бит (например, native CLK `clock_skip` становится равным F/D тактов CLK на бит (например,
@@ -175,7 +185,7 @@ native CLK `clock_skip` становится равным F/D тактов CLK
`FIND START`, native-пропуск сбрасывается в 372, счётчик охоты очищается — `FIND START`, native-пропуск сбрасывается в 372, счётчик охоты очищается —
поэтому повторный ATR разбирается как ATR, а не ошибочно читается как поэтому повторный ATR разбирается как ATR, а не ошибочно читается как
TPDU-трафик на скорости, согласованной PPS. **Защита стартового бита в TPDU-трафик на скорости, согласованной PPS. **Защита стартового бита в
1.5 ETU** дополнительно отбрасывает фантомный `0x00`, который глитч на 0.5 ETU** (edge-ридер) дополнительно отбрасывает фантомный `0x00`, который глитч на
холостом ходу может вставить перед этим ATR. Требуется `rst_detect=true` холостом ходу может вставить перед этим ATR. Требуется `rst_detect=true`
(назначенный канал `rst`); без него сброс в середине сессии всё равно (назначенный канал `rst`); без него сброс в середине сессии всё равно
будет прочитан неверно. будет прочитан неверно.
@@ -193,7 +203,7 @@ TPDU-трафик на скорости, согласованной PPS. **За
неверного кадрирования, дребезг линии VCC) была следствием плохо неверного кадрирования, дребезг линии VCC) была следствием плохо
декодированного конечного автомата, а не железа: переподключение съёма декодированного конечного автомата, а не железа: переподключение съёма
ничего не давало, а переписанный декодер полностью это вылечил (re-arm по ничего не давало, а переписанный декодер полностью это вылечил (re-arm по
тёплому сбросу, защита стартового бита 1.5 ETU, антидребезг VCC 10 мс, тёплому сбросу, защита стартового бита 0.5 ETU, антидребезг VCC 10 мс,
сборка обмена T=0). Чистая запись теперь декодируется с нулём мусора — сборка обмена T=0). Чистая запись теперь декодируется с нулём мусора —
см. проверку `vs_reader.py` ниже. см. проверку `vs_reader.py` ниже.
@@ -232,8 +242,8 @@ USB у FX2 при длительной высокой частоте дискр
- Десинхронизированные TPDU (обмены с ошибкой кадрирования) сохраняются, - Десинхронизированные TPDU (обмены с ошибкой кадрирования) сохраняются,
но помечаются `GSMTAP_FLAG_BAD_FCS` в байте `res` заголовка GSMTAP — но помечаются `GSMTAP_FLAG_BAD_FCS` в байте `res` заголовка GSMTAP —
видно в Wireshark как предупреждение BAD-FCS. видно в Wireshark как предупреждение BAD-FCS.
- Заголовок T=0 со структурно некорректным INS (ISO 7816-4 §12.1.1: - Заголовок T=0 со структурно некорректным INS (ISO 7816-4 §5.1.2:
`0x00`, `0x6x`/`0x9x` или нечётный младший бит) почти всегда сдвинут на `0x00` или `0x6x`/`0x9x`) почти всегда сдвинут на
один байт из-за лишнего ведущего байта (например, дочитанного остатка один байт из-за лишнего ведущего байта (например, дочитанного остатка
SW2 после PACKETEND). Вместо молчаливого отбрасывания всего 5-байтового SW2 после PACKETEND). Вместо молчаливого отбрасывания всего 5-байтового
окна (из-за чего следующий байт становился фиктивным CLA и проглатывал окна (из-за чего следующий байт становился фиктивным CLA и проглатывал
@@ -243,6 +253,12 @@ USB у FX2 при длительной высокой частоте дискр
(`discarded byte 0x…: reason`); отбрасывания холостых 0x00/0xFF (`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 декодер один раз калибрует число Производительность: в режиме native CLK декодер один раз калибрует число
отсчётов на фронт CLK по первому кадру ATR, а затем продвигает битовую отсчётов на фронт CLK по первому кадру ATR, а затем продвигает битовую
@@ -319,27 +335,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 ```bash
git clone git@github.com:anttro/sigrok_iso7816_stream.git sha256sum -c --ignore-missing SHA256SUMS # GNU coreutils (Linux)
cd sigrok_iso7816_stream ```
В 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 ./setup.sh
./start.sh
``` ```
`setup.sh` проверяет, что установлены sigrok-cli и пакет прошивки FX2 `setup.sh` проверяет, что установлены sigrok-cli и пакет прошивки FX2
(`sigrok-firmware-fx2lafw`), создаёт симлинк репозитория в (`sigrok-firmware-fx2lafw`), создаёт симлинк каталога проекта в
`~/.local/share/libsigrokdecode/decoders/iso7816` (пользовательские `~/.local/share/libsigrokdecode/decoders/iso7816` (пользовательские
декодеры перекрывают системные) и опрашивает подключённое устройство на декодеры перекрывают системные) и опрашивает подключённое устройство на
предмет максимальной поддерживаемой частоты дискретизации. предмет максимальной поддерживаемой частоты дискретизации.
### Windows #### Windows (скачать)
Предварительные требования: Предварительные требования:
1. **sigrok-cli** — установщик для Windows (0.7.2 x86_64) с 1. **sigrok-cli** — установщик для Windows (0.7.2 x86_64) с
[sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads). [sigrok.org/wiki/Downloads](https://sigrok.org/wiki/Downloads) —
Если он не запускается с ошибкой `0xc0150002`, установите протестированная версия. На этой же странице публикуются ночные сборки
для 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) [Visual C++ 2010 Redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=26999)
(`msvcr100.dll` требуется встроенному Python-рантайму). (`msvcr100.dll` требуется встроенному Python-рантайму).
2. Прошивка fx2lafw поставляется вместе с установщиком — отдельно скачивать не нужно. 2. Прошивка fx2lafw поставляется вместе с установщиком — отдельно скачивать не нужно.
@@ -355,7 +404,42 @@ cd sigrok_iso7816_stream
- Windows привязывает драйверы к USB-порту, когда нет серийного номера: - Windows привязывает драйверы к USB-порту, когда нет серийного номера:
перенос донгла в другой порт может потребовать повторить Zadig. перенос донгла в другой порт может потребовать повторить 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 ```bat
git clone git@github.com:anttro/sigrok_iso7816_stream.git git clone git@github.com:anttro/sigrok_iso7816_stream.git
@@ -363,11 +447,15 @@ cd sigrok_iso7816_stream
setup.bat setup.bat
``` ```
`setup.bat` проверяет sigrok-cli и встроенную прошивку, регистрирует декодер `setup.bat` выполняет те же проверки и регистрацию junction, что и выше.
этого репозитория через junction в `start.bat` поддерживает основное подмножество опций (каналы, частота
`%LOCALAPPDATA%\libsigrokdecode\decoders\iso7816` (подтверждается тем, что дискретизации, clock, host/port, pcap, protocol, debug; полный набор — в
`sigrok-cli -L` показывает `iso7816`), и опрашивает устройство на предмет `start.sh`) — основной формат с пробелом, а `--clk=CH` тоже работает:
поддерживаемых частот дискретизации.
```bat
start.bat --clk D1 --data D6
start.bat --help
```
Запись работает так же, как в Linux: Запись работает так же, как в Linux:
@@ -376,7 +464,8 @@ start.bat rem запись + поток GSMTAP
``` ```
Примечание: bat-файлы требуют переводов строк CRLF; репозиторий следит за Примечание: bat-файлы требуют переводов строк CRLF; репозиторий следит за
этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен. этим через `.gitattributes`, поэтому свежий клон в Windows уже корректен
(zip-архив релиза содержит те же файлы с CRLF).
## Заметки и ограничения по детектированию RST/VCC ## Заметки и ограничения по детектированию 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"=="--no-vcc" ( set "VCC=" & shift & goto :argloop )
if /i "%~1"=="-h" ( goto :usage ) if /i "%~1"=="-h" ( goto :usage )
if /i "%~1"=="--help" ( goto :usage ) if /i "%~1"=="--help" ( goto :usage )
rem Value options also accept the --opt=value form (usage shows the space form).
for /f "tokens=1,* delims==" %%A in ("%~1") do (
if /i "%%A"=="--clk" ( set "CLK=%%B" & shift & goto :argloop )
if /i "%%A"=="--data" ( set "DATA=%%B" & shift & goto :argloop )
if /i "%%A"=="--rst" ( set "RST=%%B" & shift & goto :argloop )
if /i "%%A"=="--vcc" ( set "VCC=%%B" & shift & goto :argloop )
if /i "%%A"=="--samplerate" ( set "SAMPLERATE=%%B" & shift & goto :argloop )
if /i "%%A"=="--clock" ( set "CLOCK=%%B" & shift & goto :argloop )
if /i "%%A"=="--host" ( set "HOST=%%B" & shift & goto :argloop )
if /i "%%A"=="--port" ( set "PORT=%%B" & shift & goto :argloop )
if /i "%%A"=="--pcap" ( set "PCAP=%%B" & shift & goto :argloop )
if /i "%%A"=="--protocol" ( set "PROTOCOL=%%B" & shift & goto :argloop )
)
echo Unknown option: %~1 echo Unknown option: %~1
exit /b 1 exit /b 1
:usage :usage
echo Usage: %~n0 [OPTIONS] echo Usage: %~n0 [OPTIONS]
echo --clk=CH CLK channel default: %CLK% echo --clk CH CLK channel default: %CLK%
echo --data=CH I/O channel default: %DATA% echo --data CH I/O channel default: %DATA%
echo --rst=CH RST channel default: %RST% echo --rst CH RST channel default: %RST%
echo --vcc=CH VCC channel default: %VCC% echo --vcc CH VCC channel default: %VCC%
echo --samplerate=SR Sample rate default: %SAMPLERATE% echo --samplerate SR Sample rate default: %SAMPLERATE%
echo --clock=MODE native/detect/sample_as_clock echo --clock MODE native/detect/sample_as_clock
echo --host=IP GSMTAP destination default: 127.0.0.1 echo --host IP GSMTAP destination default: 127.0.0.1
echo --port=N GSMTAP port default: 4729 echo --port N GSMTAP port default: 4729
echo --pcap=FILE Also write decoded events to FILE (no spaces in path) echo --pcap FILE Also write decoded events to FILE (no spaces in path)
echo --debug Full decoder + sigrok output (all annotation rows + -l 4). echo --debug Full decoder + sigrok output (all annotation rows + -l 4).
echo echo
echo By default only decoded APDUs and decoder warnings/errors are shown. echo By default only decoded APDUs and decoder warnings/errors are shown.
echo --protocol=MODE T=0 (default), T=1, or auto (mid-session decode) echo --protocol MODE T=0 (default), T=1, or auto (mid-session decode)
echo --no-rst Disable RST tracking echo --no-rst Disable RST tracking
echo --no-vcc Disable VCC tracking echo --no-vcc Disable VCC tracking
echo. echo.
echo The --clk=CH form also works. start.bat supports this core subset;
echo start.sh has the full option set.
echo.
echo To log output on Windows use redirection: %~n0 ... ^> capture.log 2^>^&1 echo To log output on Windows use redirection: %~n0 ... ^> capture.log 2^>^&1
exit /b 0 exit /b 0
+71 -16
View File
@@ -1,7 +1,7 @@
=== Baseline captured 2026-09-27T23:45:58+03:00 === === Baseline captured 2026-10-20T18:55:32+03:00 ===
=== test_8 === === test_8 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1237) ('etu recovered (samples):', 1237)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -36,7 +36,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== test_7 === === test_7 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 1236) ('etu recovered (samples):', 1236)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -77,7 +77,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_phone_sample === === xiaomi_phone_sample ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('etu recovered (samples):', 53) ('etu recovered (samples):', 53)
(11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1]) (11674007, 'CHKSUM ERROR: ', 0, 'bits: ', [0, 0, 0, 1, 0, 0, 1, 1, 1, 1])
iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1] iso7816-1: CHKSUM ERROR bits=[0, 0, 0, 1, 0, 0, 1, 1, 1, 1]
@@ -102,7 +102,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== xiaomi_mi_a1_coldboot === === xiaomi_mi_a1_coldboot ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -112,12 +112,12 @@ CHKSUM ERROR count: 0
CONCAT APDU count: 0 CONCAT APDU count: 0
reader exchanges : 0 reader exchanges : 0
capture ATRs : 1 capture ATRs : 1
capture APDUs : 473 capture APDUs : 475
byte-exact matches (LCS) : 0 byte-exact matches (LCS) : 0
reader exchanges not in capture (pre-capture / dropped): 0 reader exchanges not in capture (pre-capture / dropped): 0
capture APDUs not in reader log: capture APDUs not in reader log:
- proactive/CAT (unlogged by reader SDK): 10 - proactive/CAT (unlogged by reader SDK): 10
- valid exchanges (reader retries/dupes): 463 - valid exchanges (reader retries/dupes): 465
- GARBAGE (mis-framed/desync): 0 - GARBAGE (mis-framed/desync): 0
Degradation metrics: Degradation metrics:
@@ -129,7 +129,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== 4gmodem_coldboot === === 4gmodem_coldboot ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -156,7 +156,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== alcor_reader_sample === === alcor_reader_sample ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -189,7 +189,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable === === samsung_A55_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -216,7 +216,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_A55_coldboot_new_cable_sim2 === === samsung_A55_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -243,7 +243,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable === === samsung_S21p_coldboot_new_cable ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -270,7 +270,7 @@ Degradation metrics:
RESULT: OK - capture fully explained (reader + CAT + valid retries) RESULT: OK - capture fully explained (reader + CAT + valid retries)
=== samsung_S21p_coldboot_new_cable_sim2 === === samsung_S21p_coldboot_new_cable_sim2 ===
('=== iso7816 decoder v1.9.2 ===',) ('=== iso7816 decoder v1.9.7 ===',)
('RST low at start: expecting cold-boot ATR',) ('RST low at start: expecting cold-boot ATR',)
iso7816-1: RST deasserted iso7816-1: RST deasserted
('RST deasserted',) ('RST deasserted',)
@@ -342,27 +342,57 @@ test_explicit_eof_wins (__main__.TestAtEof.test_explicit_eof_wins) ... ok
test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok test_pending_replay_is_bounded (__main__.TestAtEof.test_pending_replay_is_bounded) ... ok
test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok test_pending_replay_is_not_eof (__main__.TestAtEof.test_pending_replay_is_not_eof) ... ok
test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok test_stuck_with_nothing_queued_is_eof (__main__.TestAtEof.test_stuck_with_nothing_queued_is_eof) ... ok
test_atr_with_parity_error_is_ignored (__main__.TestAtrHuntDetect.test_atr_with_parity_error_is_ignored) ... ok
test_bare_ts_t0_is_not_committed_as_an_atr (__main__.TestAtrHuntDetect.test_bare_ts_t0_is_not_committed_as_an_atr) ... ok
test_eof_after_plausible_cla_is_clean (__main__.TestAtrHuntDetect.test_eof_after_plausible_cla_is_clean) ... ok
test_handoff_on_extended_channel_cla (__main__.TestAtrHuntDetect.test_handoff_on_extended_channel_cla) ... ok
test_handoff_on_odd_ins_89 (__main__.TestAtrHuntDetect.test_handoff_on_odd_ins_89) ... ok
test_inverse_ts_pattern_is_parsed (__main__.TestAtrHuntDetect.test_inverse_ts_pattern_is_parsed) ... ok
test_no_handoff_on_basic_channel_cla (__main__.TestAtrHuntDetect.test_no_handoff_on_basic_channel_cla) ... ok
test_no_handoff_on_invalid_ins (__main__.TestAtrHuntDetect.test_no_handoff_on_invalid_ins) ... ok
test_truncated_atr_ta_is_clean (__main__.TestAtrHuntDetect.test_truncated_atr_ta_is_clean) ... ok
test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok test_all_ff_idle_loop_is_bounded (__main__.TestAtrHuntEscape.test_all_ff_idle_loop_is_bounded) ... ok
test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok test_idle_ff_retries_before_cap (__main__.TestAtrHuntEscape.test_idle_ff_retries_before_cap) ... ok
test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok test_idle_resets_capped_then_resume (__main__.TestAtrHuntEscape.test_idle_resets_capped_then_resume) ... ok
test_measured_etu_skips_recovery (__main__.TestAtrHuntEscape.test_measured_etu_skips_recovery) ... ok
test_no_recovery_when_etu_known (__main__.TestAtrHuntEscape.test_no_recovery_when_etu_known) ... ok test_no_recovery_when_etu_known (__main__.TestAtrHuntEscape.test_no_recovery_when_etu_known) ... ok
test_non_ff_resumes_when_count_high (__main__.TestAtrHuntEscape.test_non_ff_resumes_when_count_high) ... ok test_non_ff_resumes_when_count_high (__main__.TestAtrHuntEscape.test_non_ff_resumes_when_count_high) ... ok
test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok test_recover_etu_in_hunt_runs_measurement (__main__.TestAtrHuntEscape.test_recover_etu_in_hunt_runs_measurement) ... ok
test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok test_recovery_attempts_capped_then_hard_limit (__main__.TestAtrHuntEscape.test_recovery_attempts_capped_then_hard_limit) ... ok
test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok test_retry_then_recover_after_two_failures (__main__.TestAtrHuntEscape.test_retry_then_recover_after_two_failures) ... ok
test_seeded_bit_samples_does_not_block_recovery (__main__.TestAtrHuntEscape.test_seeded_bit_samples_does_not_block_recovery) ... ok
test_9e_9f_are_status_words (__main__.TestDecodeStep.test_9e_9f_are_status_words) ... ok
test_bit_dense_valid_cla_is_not_idle_noise (__main__.TestDecodeStep.test_bit_dense_valid_cla_is_not_idle_noise) ... ok
test_data_0x3b_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3b_before_traffic_is_atr_candidate) ... ok
test_data_0x3b_is_not_an_atr_after_traffic (__main__.TestDecodeStep.test_data_0x3b_is_not_an_atr_after_traffic) ... ok
test_data_0x3f_before_traffic_is_atr_candidate (__main__.TestDecodeStep.test_data_0x3f_before_traffic_is_atr_candidate) ... ok
test_ex_class_exchange_frames (__main__.TestDecodeStep.test_ex_class_exchange_frames) ... ok
test_extended_channel_exchange_frames (__main__.TestDecodeStep.test_extended_channel_exchange_frames) ... ok
test_idle_noise_still_discarded (__main__.TestDecodeStep.test_idle_noise_still_discarded) ... ok
test_immediate_sw_rejection_frames (__main__.TestDecodeStep.test_immediate_sw_rejection_frames) ... ok
test_invalid_ins_re_frame_recovers_command (__main__.TestDecodeStep.test_invalid_ins_re_frame_recovers_command) ... ok
test_inverse_ts_raw_pattern_is_an_atr_candidate (__main__.TestDecodeStep.test_inverse_ts_raw_pattern_is_an_atr_candidate) ... ok
test_le_256_response_data_is_kept (__main__.TestDecodeStep.test_le_256_response_data_is_kept) ... ok
test_parity_error_flags_bad_fcs (__main__.TestDecodeStep.test_parity_error_flags_bad_fcs) ... ok
test_short_response_is_split_and_flagged (__main__.TestDecodeStep.test_short_response_is_split_and_flagged) ... ok
test_short_response_replays_following_exchange (__main__.TestDecodeStep.test_short_response_replays_following_exchange) ... ok
test_text_byte_boundary_is_discarded_not_framed (__main__.TestDecodeStep.test_text_byte_boundary_is_discarded_not_framed) ... ok
test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok test_glitch_low_pulse_removed (__main__.TestDeglitchEdges.test_glitch_low_pulse_removed) ... ok
test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok test_no_edges (__main__.TestDeglitchEdges.test_no_edges) ... ok
test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok test_real_pulses_preserved (__main__.TestDeglitchEdges.test_real_pulses_preserved) ... ok
test_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pulse_start_edge_preserved) ... ok test_short_pulse_start_edge_preserved (__main__.TestDeglitchEdges.test_short_pulse_start_edge_preserved) ... ok
test_idle_noise_byte (__main__.TestDesyncHelpers.test_idle_noise_byte) ... ok
test_is_desynced_all_zero_is_not_desync (__main__.TestDesyncHelpers.test_is_desynced_all_zero_is_not_desync) ... ok test_is_desynced_all_zero_is_not_desync (__main__.TestDesyncHelpers.test_is_desynced_all_zero_is_not_desync) ... ok
test_is_desynced_bad_sw (__main__.TestDesyncHelpers.test_is_desynced_bad_sw) ... ok test_is_desynced_bad_sw (__main__.TestDesyncHelpers.test_is_desynced_bad_sw) ... ok
test_is_desynced_clean_packets (__main__.TestDesyncHelpers.test_is_desynced_clean_packets) ... ok test_is_desynced_clean_packets (__main__.TestDesyncHelpers.test_is_desynced_clean_packets) ... ok
test_is_desynced_extended_channel_valid (__main__.TestDesyncHelpers.test_is_desynced_extended_channel_valid) ... ok
test_is_desynced_null_sw1_is_invalid (__main__.TestDesyncHelpers.test_is_desynced_null_sw1_is_invalid) ... ok test_is_desynced_null_sw1_is_invalid (__main__.TestDesyncHelpers.test_is_desynced_null_sw1_is_invalid) ... ok
test_is_desynced_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok test_is_desynced_oversized (__main__.TestDesyncHelpers.test_is_desynced_oversized) ... ok
test_is_desynced_short_fragment (__main__.TestDesyncHelpers.test_is_desynced_short_fragment) ... ok test_is_desynced_short_fragment (__main__.TestDesyncHelpers.test_is_desynced_short_fragment) ... ok
test_max_tpdu_len (__main__.TestDesyncHelpers.test_max_tpdu_len) ... ok test_max_tpdu_len (__main__.TestDesyncHelpers.test_max_tpdu_len) ... ok
test_plausible_sw_accepts_valid_classes (__main__.TestDesyncHelpers.test_plausible_sw_accepts_valid_classes) ... ok test_plausible_sw_accepts_valid_classes (__main__.TestDesyncHelpers.test_plausible_sw_accepts_valid_classes) ... ok
test_plausible_sw_rejects_garbage (__main__.TestDesyncHelpers.test_plausible_sw_rejects_garbage) ... ok test_plausible_sw_rejects_garbage (__main__.TestDesyncHelpers.test_plausible_sw_rejects_garbage) ... ok
test_resync_idle_waits_on_a_level_condition (__main__.TestDesyncHelpers.test_resync_idle_waits_on_a_level_condition) ... ok
test_a55_u1_concat (__main__.TestEmbeddedExchanges.test_a55_u1_concat) ... ok test_a55_u1_concat (__main__.TestEmbeddedExchanges.test_a55_u1_concat) ... ok
test_a55_u4_mega_concat (__main__.TestEmbeddedExchanges.test_a55_u4_mega_concat) ... ok test_a55_u4_mega_concat (__main__.TestEmbeddedExchanges.test_a55_u4_mega_concat) ... ok
test_channel1_select_after_manage_channel_swallow (__main__.TestEmbeddedExchanges.test_channel1_select_after_manage_channel_swallow) ... ok test_channel1_select_after_manage_channel_swallow (__main__.TestEmbeddedExchanges.test_channel1_select_after_manage_channel_swallow) ... ok
@@ -374,15 +404,24 @@ test_clean_secured_packet_payload_alcor (__main__.TestEmbeddedExchanges.test_cle
test_clean_select_611e (__main__.TestEmbeddedExchanges.test_clean_select_611e) ... ok test_clean_select_611e (__main__.TestEmbeddedExchanges.test_clean_select_611e) ... ok
test_clean_select_6a82 (__main__.TestEmbeddedExchanges.test_clean_select_6a82) ... ok test_clean_select_6a82 (__main__.TestEmbeddedExchanges.test_clean_select_6a82) ... ok
test_concat_packets_are_not_is_desynced (__main__.TestEmbeddedExchanges.test_concat_packets_are_not_is_desynced) ... ok test_concat_packets_are_not_is_desynced (__main__.TestEmbeddedExchanges.test_concat_packets_are_not_is_desynced) ... ok
test_embedded_cla_membership_pinned (__main__.TestEmbeddedExchanges.test_embedded_cla_membership_pinned) ... ok
test_samsung_record_burst_concat (__main__.TestEmbeddedExchanges.test_samsung_record_burst_concat) ... ok test_samsung_record_burst_concat (__main__.TestEmbeddedExchanges.test_samsung_record_burst_concat) ... ok
test_xiaomi_select_getresp_swallow (__main__.TestEmbeddedExchanges.test_xiaomi_select_getresp_swallow) ... ok test_xiaomi_select_getresp_swallow (__main__.TestEmbeddedExchanges.test_xiaomi_select_getresp_swallow) ... ok
test_eoferror_propagates (__main__.TestEndOfStream.test_eoferror_propagates) ... ok test_eoferror_propagates (__main__.TestEndOfStream.test_eoferror_propagates) ... ok
test_systemerror_propagates (__main__.TestEndOfStream.test_systemerror_propagates) ... ok test_systemerror_propagates (__main__.TestEndOfStream.test_systemerror_propagates) ... ok
test_ff_burst_is_accepted (__main__.TestEtuCandidate.test_ff_burst_is_accepted) ... ok
test_normal_burst_is_accepted (__main__.TestEtuCandidate.test_normal_burst_is_accepted) ... ok
test_single_spacing_is_rejected (__main__.TestEtuCandidate.test_single_spacing_is_rejected) ... ok
test_zero_run_guard_is_rejected (__main__.TestEtuCandidate.test_zero_run_guard_is_rejected) ... ok
test_build_packet_bad_fcs_flag (__main__.TestGsmtapFlags.test_build_packet_bad_fcs_flag) ... ok test_build_packet_bad_fcs_flag (__main__.TestGsmtapFlags.test_build_packet_bad_fcs_flag) ... ok
test_build_packet_payload_passthrough (__main__.TestGsmtapFlags.test_build_packet_payload_passthrough) ... ok test_build_packet_payload_passthrough (__main__.TestGsmtapFlags.test_build_packet_payload_passthrough) ... ok
test_build_packet_plain_res_zero (__main__.TestGsmtapFlags.test_build_packet_plain_res_zero) ... ok test_build_packet_plain_res_zero (__main__.TestGsmtapFlags.test_build_packet_plain_res_zero) ... ok
test_flag_constant_value (__main__.TestGsmtapFlags.test_flag_constant_value) ... ok test_flag_constant_value (__main__.TestGsmtapFlags.test_flag_constant_value) ... ok
test_pps_subtypes_are_standard (__main__.TestGsmtapFlags.test_pps_subtypes_are_standard) ... ok test_pps_subtypes_are_standard (__main__.TestGsmtapFlags.test_pps_subtypes_are_standard) ... ok
test_get_bytes_direct_lsb_first (__main__.TestInverseConvention.test_get_bytes_direct_lsb_first) ... ok
test_get_bytes_inverse_msb_first (__main__.TestInverseConvention.test_get_bytes_inverse_msb_first) ... ok
test_ts_from_bits_direct_inverse_and_garbage (__main__.TestInverseConvention.test_ts_from_bits_direct_inverse_and_garbage) ... ok
test_wait_data_falling_uses_high_idle_and_falling_start (__main__.TestInverseConvention.test_wait_data_falling_uses_high_idle_and_falling_start) ... ok
test_base_payload_without_rate (__main__.TestLineEventPayload.test_base_payload_without_rate) ... ok test_base_payload_without_rate (__main__.TestLineEventPayload.test_base_payload_without_rate) ... ok
test_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... ok test_flag_constant (__main__.TestLineEventPayload.test_flag_constant) ... ok
test_legacy_prefix_preserved (__main__.TestLineEventPayload.test_legacy_prefix_preserved) ... ok test_legacy_prefix_preserved (__main__.TestLineEventPayload.test_legacy_prefix_preserved) ... ok
@@ -394,27 +433,43 @@ test_finds_fall_exactly_at_threshold (__main__.TestNextStartFall.test_finds_fall
Regression test for Samsung 16 CLK/ETU concatenation. ... ok Regression test for Samsung 16 CLK/ETU concatenation. ... ok
test_rejects_phantom_sub_etu_fall (__main__.TestNextStartFall.test_rejects_phantom_sub_etu_fall) ... ok test_rejects_phantom_sub_etu_fall (__main__.TestNextStartFall.test_rejects_phantom_sub_etu_fall) ... ok
test_returns_none_when_no_fall (__main__.TestNextStartFall.test_returns_none_when_no_fall) ... ok test_returns_none_when_no_fall (__main__.TestNextStartFall.test_returns_none_when_no_fall) ... ok
test_etu_glitch_floor_scales (__main__.TestSamplerateScaling.test_etu_glitch_floor_scales) ... ok
test_samples_for_us (__main__.TestSamplerateScaling.test_samples_for_us) ... ok
test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok test_advancing_sample_is_not_stall (__main__.TestStallCheck.test_advancing_sample_is_not_stall) ... ok
test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok test_none_pins_is_eof (__main__.TestStallCheck.test_none_pins_is_eof) ... ok
test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok test_repeated_same_sample_is_bounded_stall (__main__.TestStallCheck.test_repeated_same_sample_is_bounded_stall) ... ok
test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok test_untracked_level_wait_never_stalls (__main__.TestStallCheck.test_untracked_level_wait_never_stalls) ... ok
test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok test_untracked_resets_stall_window (__main__.TestStallCheck.test_untracked_resets_stall_window) ... ok
test_decode_step_reads_exactly_one_block (__main__.TestT1Blocks.test_decode_step_reads_exactly_one_block) ... ok
test_handle_pps_truncated_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_is_clean) ... ok
test_handle_pps_truncated_optional_is_clean (__main__.TestT1Blocks.test_handle_pps_truncated_optional_is_clean) ... ok
test_i_block (__main__.TestT1Blocks.test_i_block) ... ok
test_m_chain_concatenates_inf (__main__.TestT1Blocks.test_m_chain_concatenates_inf) ... ok
test_r_and_s_blocks_have_no_inf (__main__.TestT1Blocks.test_r_and_s_blocks_have_no_inf) ... ok
test_truncated_block_before_lrc_is_clean (__main__.TestT1Blocks.test_truncated_block_before_lrc_is_clean) ... ok
test_truncated_inf_is_clean (__main__.TestT1Blocks.test_truncated_inf_is_clean) ... ok
test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok test_invalid_cla (__main__.TestValidateT0Apdu.test_invalid_cla) ... ok
test_invalid_cla_proprietary_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_proprietary_unseen) ... ok test_invalid_cla_reserved_and_unseen (__main__.TestValidateT0Apdu.test_invalid_cla_reserved_and_unseen) ... ok
test_invalid_ins_lsb_odd (__main__.TestValidateT0Apdu.test_invalid_ins_lsb_odd) ... ok test_invalid_cla_untraced_variants (__main__.TestValidateT0Apdu.test_invalid_cla_untraced_variants) ... ok
test_invalid_ins_odd_undefined (__main__.TestValidateT0Apdu.test_invalid_ins_odd_undefined) ... ok
test_invalid_ins_sw_collision (__main__.TestValidateT0Apdu.test_invalid_ins_sw_collision) ... ok test_invalid_ins_sw_collision (__main__.TestValidateT0Apdu.test_invalid_ins_sw_collision) ... ok
test_invalid_ins_zero (__main__.TestValidateT0Apdu.test_invalid_ins_zero) ... ok test_invalid_ins_zero (__main__.TestValidateT0Apdu.test_invalid_ins_zero) ... ok
test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok test_invalid_oversized (__main__.TestValidateT0Apdu.test_invalid_oversized) ... ok
test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok test_invalid_sw1 (__main__.TestValidateT0Apdu.test_invalid_sw1) ... ok
test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok test_invalid_too_short (__main__.TestValidateT0Apdu.test_invalid_too_short) ... ok
test_odd_ins_set_pinned (__main__.TestValidateT0Apdu.test_odd_ins_set_pinned) ... ok
test_valid_case1_direct_sw (__main__.TestValidateT0Apdu.test_valid_case1_direct_sw) ... ok test_valid_case1_direct_sw (__main__.TestValidateT0Apdu.test_valid_case1_direct_sw) ... ok
test_valid_case2_with_response (__main__.TestValidateT0Apdu.test_valid_case2_with_response) ... ok test_valid_case2_with_response (__main__.TestValidateT0Apdu.test_valid_case2_with_response) ... ok
test_valid_case3_with_command_data (__main__.TestValidateT0Apdu.test_valid_case3_with_command_data) ... ok test_valid_case3_with_command_data (__main__.TestValidateT0Apdu.test_valid_case3_with_command_data) ... ok
test_valid_cla_channel1_select_after_manage_channel (__main__.TestValidateT0Apdu.test_valid_cla_channel1_select_after_manage_channel) ... ok test_valid_cla_channel1_select_after_manage_channel (__main__.TestValidateT0Apdu.test_valid_cla_channel1_select_after_manage_channel) ... ok
test_valid_cla_extended_channel_exchanges (__main__.TestValidateT0Apdu.test_valid_cla_extended_channel_exchanges) ... ok
test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla_interindustry_classes) ... ok test_valid_cla_interindustry_classes (__main__.TestValidateT0Apdu.test_valid_cla_interindustry_classes) ... ok
test_valid_cla_uicc_classes (__main__.TestValidateT0Apdu.test_valid_cla_uicc_classes) ... ok
test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok test_valid_direct_sw_with_p3_nonzero (__main__.TestValidateT0Apdu.test_valid_direct_sw_with_p3_nonzero) ... ok
test_valid_ins_odd_variants (__main__.TestValidateT0Apdu.test_valid_ins_odd_variants) ... ok
test_vs_reader_mirror (__main__.TestValidateT0Apdu.test_vs_reader_mirror) ... ok
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 75 tests in 0.024s Ran 130 tests in 0.029s
OK OK
+804 -11
View File
@@ -98,6 +98,31 @@ class TestDesyncHelpers(unittest.TestCase):
self.assertFalse(self.pd.is_desynced( self.assertFalse(self.pd.is_desynced(
b'\x00\xc0\x00\x00\x25\xc0\x62\x23\x90\x00')) b'\x00\xc0\x00\x00\x25\xc0\x62\x23\x90\x00'))
def test_is_desynced_extended_channel_valid(self):
# v1.9.3: higher logical channels and the UICC extended classes
# are clean traffic, not desync. 0x44 = SELECT on channel 8,
# 0x4F = SELECT on channel 19, 0xC0 = STATUS on channel 4
# ('CX'), 0xE2 = STATUS on channel 6 with SM ('EX').
for h in ('44a40004009000', '4fa40004009000',
'c0f20000009000', 'e2f20000009000'):
with self.subTest(h=h):
self.assertFalse(self.pd.is_desynced(bytes.fromhex(h)))
def test_idle_noise_byte(self):
# All-1s artifacts with dropped bits are idle noise (including
# 0x6F/0x7F/0xAF, which are not valid classes) ...
for b in (0x6f, 0x7f, 0xaf, 0xbf, 0xf7, 0xfc, 0xfd, 0xfe):
with self.subTest(b=b):
self.assertTrue(self.pd.is_idle_noise_byte(b))
# ... but valid, bit-dense CLAs are command bytes: 0x5F/0xCF/
# 0xE7/0xEB/0xED-0xEF must not be swallowed by the noise resync.
for b in (0x5f, 0xcf, 0xe7, 0xeb, 0xed, 0xee, 0xef):
with self.subTest(b=b):
self.assertFalse(self.pd.is_idle_noise_byte(b))
# A sparse invalid class is not "idle noise" either (it takes the
# suspicious-CLA path).
self.assertFalse(self.pd.is_idle_noise_byte(0x20))
def test_is_desynced_null_sw1_is_invalid(self): def test_is_desynced_null_sw1_is_invalid(self):
# Regression (v1.9.1): the T=0 NULL byte 0x60 is not SW1. The # Regression (v1.9.1): the T=0 NULL byte 0x60 is not SW1. The
# wire "NULL 0x60 + real SW1 0x61" was mis-framed as the status # wire "NULL 0x60 + real SW1 0x61" was mis-framed as the status
@@ -126,6 +151,262 @@ class TestDesyncHelpers(unittest.TestCase):
self.assertFalse(self.pd.is_desynced(b'\x00' * 7)) self.assertFalse(self.pd.is_desynced(b'\x00' * 7))
def test_resync_idle_waits_on_a_level_condition(self):
# The re-align must WAIT for DATA (a level condition that advances
# the sample counter). It used to call wait({'skip': 0}) in a loop,
# which never advanced -- a no-op repeated on the same sample.
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.bit_samples = 100
inst._inverse_convention = False # normally set by start()
inst._start_fall = None
inst.samplenum = 0
levels = [0, 0, 1]
calls = []
def wait(cond):
calls.append(cond)
want = None
if isinstance(cond, list):
for c in cond:
if self.pd.Decoder.DATA_IDX in c:
want = c[self.pd.Decoder.DATA_IDX]
while levels:
inst.samplenum += 10
lvl = levels.pop(0)
if (want == 'h' and lvl == 1) or (want == 'l' and lvl == 0):
return [lvl] * 8
inst.samplenum += 10
return [1] * 8
inst.wait = wait
inst.resync_idle()
self.assertEqual(len(calls), 1)
self.assertIsInstance(calls[0], list)
self.assertIn(self.pd.Decoder.DATA_IDX, calls[0][0])
# it advanced through the two LOW samples to the HIGH one
self.assertEqual(inst.samplenum, 30)
class TestEtuCandidate(unittest.TestCase):
"""The mid-session ETU estimator's burst validation (_etu_candidate)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def setUp(self):
self.inst = object.__new__(self.pd.Decoder)
def test_zero_run_guard_is_rejected(self):
# All-zero characters: 10-ETU LOW runs + a short HIGH inter-byte
# guard. The smallest recurring spacing is the guard, not the ETU.
pairs = [(1000, 0), (200, 1)] * 8
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 200)
self.assertFalse(ok)
def test_single_spacing_is_rejected(self):
# A lone 0x00 character yields one 10-ETU run (10x the true ETU).
self.assertEqual(self.inst._etu_candidate([(1000, 0)]),
(None, False))
self.assertEqual(self.inst._etu_candidate([(8, 0)]),
(None, False))
def test_normal_burst_is_accepted(self):
# A 1-ETU LOW spacing (start bit / data run) alongside 2- and
# 3-ETU runs: the recurring minimum is the true ETU.
pairs = [(100, 0), (100, 1), (200, 0), (300, 1),
(100, 0), (100, 1), (200, 0)]
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 100)
self.assertTrue(ok)
def test_ff_burst_is_accepted(self):
# 0xFF: 1-ETU LOW start pulses + long HIGH runs. The minimum is a
# LOW run (the start bit), so the guard heuristic must not fire.
pairs = [(100, 0), (900, 1), (100, 0), (900, 1), (100, 0)]
etu, ok = self.inst._etu_candidate(pairs)
self.assertEqual(etu, 100)
self.assertTrue(ok)
class TestInverseConvention(unittest.TestCase):
"""ISO 7816-3 7.1: inverse = inverted data, msb first; the start bit
stays a falling edge from an idle-HIGH line (the old code assumed an
idle-LOW line / rising start)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def setUp(self):
self.inst = object.__new__(self.pd.Decoder)
self.inst.reset()
def test_get_bytes_direct_lsb_first(self):
self.inst._inverse_convention = False
self.assertEqual(self.inst.get_bytes([1, 0, 0, 0, 0, 0, 0, 0]), 0x01)
self.assertEqual(self.inst.get_bytes([1, 1, 0, 0, 0, 0, 0, 0]), 0x03)
def test_get_bytes_inverse_msb_first(self):
self.inst._inverse_convention = True
self.assertEqual(self.inst.get_bytes([1, 0, 0, 0, 0, 0, 0, 0]), 0x80)
self.assertEqual(self.inst.get_bytes([1, 1, 0, 0, 0, 0, 0, 0]), 0xC0)
def test_ts_from_bits_direct_inverse_and_garbage(self):
self.assertEqual(
self.pd.ts_from_bits([0, 1, 1, 0, 1, 1, 1, 0, 0, 1]), 0x3b)
self.assertEqual(
self.pd.ts_from_bits([0, 1, 1, 0, 0, 0, 0, 0, 0, 1]), 0x3f)
self.assertIsNone(
self.pd.ts_from_bits([0, 1, 1, 0, 1, 0, 1, 0, 0, 1]))
self.assertIsNone(self.pd.ts_from_bits([0, 1, 1, 0]))
def test_wait_data_falling_uses_high_idle_and_falling_start(self):
self.inst._inverse_convention = True
self.inst.bit_samples = 100
self.inst._min_pulse_samples = 0
self.inst.samplenum = 0
conds = []
def wait(cond):
conds.append(cond)
self.inst.samplenum += 200
return [1] * 8 if len(conds) == 1 else [0] * 8
self.inst.wait = wait
self.inst.wait_data_falling()
self.assertFalse(self.inst._eof)
self.assertEqual(conds[0][0][self.pd.Decoder.DATA_IDX], 'h')
self.assertEqual(conds[1][0][self.pd.Decoder.DATA_IDX], 'f')
self.assertEqual(self.inst._start_fall, self.inst.samplenum)
class TestT1Blocks(unittest.TestCase):
"""T=1 block parsing: NAD/PCB/LEN/INF/LRC, the M-bit chain, and the
EOF guards (a truncated block never raises or emits a half INF)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.out_ann = 0
inst.log = lambda *a, **k: None
inst.put = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
inst._bytes = collections.deque(byte_seq)
inst.read_byte = lambda: (inst._bytes.popleft()
if inst._bytes else None)
return inst
def _block(self, nad, pcb, inf):
body = [nad, pcb, len(inf)] + list(inf)
lrc = 0
for b in body:
lrc ^= b
return body + [lrc]
def test_i_block(self):
inst = self._make(self._block(0x00, 0x00, [0x6f, 0x00]))
self.assertEqual(inst.t1_parse_block(0), (True, [0x6f, 0x00]))
def test_r_and_s_blocks_have_no_inf(self):
inst = self._make(self._block(0x00, 0x80, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
inst = self._make(self._block(0x00, 0xc0, []))
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_m_chain_concatenates_inf(self):
seq = (self._block(0x00, 0x20, [0x01])
+ self._block(0x00, 0x00, [0x02]))
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (True, [0x01, 0x02]))
def test_truncated_block_before_lrc_is_clean(self):
seq = self._block(0x00, 0x00, [0x6f, 0x00])[:5] # no LRC
inst = self._make(seq)
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_truncated_inf_is_clean(self):
inst = self._make([0x00, 0x00, 0x05, 0x01, 0x02])
self.assertEqual(inst.t1_parse_block(0), (False, []))
def test_decode_step_reads_exactly_one_block(self):
# The T=1 caller used to run an extra chain loop after every
# I-block: it consumed the peer's R-block and merged the following
# I-block's INF into this packet. One step must parse one block.
apdu = [0x00, 0xa4, 0x00, 0x00, 0x00]
rblock = self._block(0x00, 0x80, [])
inst = self._make(self._block(0x00, 0x00, apdu) + rblock)
inst.state = 'DATA'
inst.hasT0 = False
inst.hasT1 = True
inst.bit_samples = 100
inst._pps_speed_changed = False
inst._resync = False
inst._at_eof = lambda: False
inst.out_ann = 0
inst.emitted = []
inst.emit_packet = lambda st, pl, ss, es, flags=0: \
inst.emitted.append((bytes(pl), flags))
inst._note_discard = lambda *a, **k: None
inst._note_pre_sw = lambda *a, **k: None
inst._consume_first = lambda: (inst._bytes.popleft()
if inst._bytes else None)
inst.peek_byte = lambda: inst._bytes[0] if inst._bytes else None
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertEqual(inst.emitted[0][0], bytes(apdu))
# The peer's R-block is left for the next step.
self.assertEqual(list(inst._bytes), rblock)
def test_handle_pps_truncated_is_clean(self):
inst = self._make([0x55])
inst.peeked_samplenum = 0
inst.handle_pps() # pps0 missing: must not raise
def test_handle_pps_truncated_optional_is_clean(self):
inst = self._make([0x55, 0x70]) # PPS0 asks for PPS1..3, then EOF
inst.peeked_samplenum = 0
inst.handle_pps() # PPS1 missing: must not raise
class TestSamplerateScaling(unittest.TestCase):
"""The pulse/glitch thresholds scale with the capture rate instead of
assuming 16 MHz."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def test_samples_for_us(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 16000000
self.assertEqual(inst._samples_for_us(0.125), 2)
self.assertEqual(inst._samples_for_us(0.25), 4)
self.assertEqual(inst._samples_for_us(0.75), 12)
inst.samplerate = 96000000
self.assertEqual(inst._samples_for_us(0.125), 12)
self.assertEqual(inst._samples_for_us(0.75), 72)
inst.samplerate = None
self.assertEqual(inst._samples_for_us(0.75), 12) # 16 MHz fallback
def test_etu_glitch_floor_scales(self):
inst = object.__new__(self.pd.Decoder)
inst.samplerate = 96000000
# 12-sample spacings are sub-ETU glitches at 96 MHz (floor 72).
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(None, False))
inst.samplerate = 16000000
self.assertEqual(inst._etu_candidate([(12, 0), (12, 1)]),
(12, True))
class TestValidateT0Apdu(unittest.TestCase): class TestValidateT0Apdu(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -158,14 +439,39 @@ class TestValidateT0Apdu(unittest.TestCase):
self._valid(b'\x00\xd6\x00\x00\x04\x11\x22\x33\x44\x90\x00') self._valid(b'\x00\xd6\x00\x00\x04\x11\x22\x33\x44\x90\x00')
def test_valid_cla_interindustry_classes(self): def test_valid_cla_interindustry_classes(self):
# Interindustry CLA digits 0x/1x/4x/5x are structurally valid # Interindustry CLA (ISO 7816-4 §5.1.1): 000x xxxx 0x00-0x1F
# (ISO 7816-4): includes logical channels 0x01-0x03, secure # (channels 0-3 in b2b1, SM 0x0C, chaining 0x10-0x1F) and
# messaging 0x0C, chaining 0x10, and the SM/structural 0x4x/0x5x. # 01xx xxxx 0x40-0x5F (channels 4-19 without CLA-level SM:
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14, 0x44, 0x50, 0x54): # b4..b1 = channel-4). 0x20-0x3F is reserved by ISO; the
# SM-indicating 0x60-0x6F/0x70-0x7F stay rejected -- see
# test_invalid_cla_untraced_variants.
for cla in (0x01, 0x02, 0x03, 0x0C, 0x10, 0x14,
0x40, 0x44, 0x4F, 0x50, 0x54):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00]) pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla): with self.subTest(cla=cla):
self._valid(pkt) self._valid(pkt)
def test_valid_cla_uicc_classes(self):
# UICC extended classes (TS 102 221 §10.1.1 Table 10.4a): 'CX'/'EX'
# (0xC0-0xCF/0xE0-0xEF) carry the extended channels 4-19 in b4..b1
# (SM in b6). The legacy SIM/UICC values and 0xB0 (no pinned-spec
# basis, kept for compatibility) are unchanged.
for cla in (0x80, 0x84, 0xA0, 0xB0,
0xC0, 0xC4, 0xCF, 0xE0, 0xE4, 0xEF):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._valid(pkt)
def test_valid_cla_extended_channel_exchanges(self):
# Real higher-channel shapes from TS 102 221 Tables 10.4a/10.5:
# SELECT on channel 8 ('4X'), SELECT on channel 19 ('4X'),
# STATUS on channel 4 ('CX'), STATUS on channel 6 with SM
# ('EX' -- the UICC extended class accepts b6 = SM).
self._valid(b'\x44\xa4\x00\x04\x00\x90\x00')
self._valid(b'\x4f\xa4\x00\x04\x00\x90\x00')
self._valid(b'\xc0\xf2\x00\x00\x00\x90\x00')
self._valid(b'\xe2\xf2\x00\x00\x00\x90\x00')
def test_valid_cla_channel1_select_after_manage_channel(self): def test_valid_cla_channel1_select_after_manage_channel(self):
# The command that follows MANAGE CHANNEL (00 70 00 00 01) opening # The command that follows MANAGE CHANNEL (00 70 00 00 01) opening
# logical channel 1 legitimately uses CLA 0x01. This exact type # logical channel 1 legitimately uses CLA 0x01. This exact type
@@ -174,9 +480,59 @@ class TestValidateT0Apdu(unittest.TestCase):
b'\xa0\x00\x00\x00\x63\x50\x4b\x43\x53\x2d\x31\x35' b'\xa0\x00\x00\x00\x63\x50\x4b\x43\x53\x2d\x31\x35'
b'\x6a\x82') b'\x6a\x82')
def test_invalid_cla_proprietary_unseen(self): def test_invalid_cla_reserved_and_unseen(self):
# 0xC0 is proprietary but not a class these cards emit; stays invalid # 0x20-0x3F (001x xxxx) is reserved by ISO 7816-4; 0x90-0x9F,
self._invalid(b'\xc0\xa4\x00\x04\x00\x90\x00', 'invalid CLA') # 0xD0-0xDF and 0xF0-0xFF are not SIM/UICC classes ('11x0' needs
# b5 = 0). All stay invalid.
for cla in (0x20, 0x2F, 0x3F, 0x90, 0x9F, 0xD0, 0xDF, 0xF0, 0xFE):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._invalid(pkt, 'invalid CLA')
def test_invalid_cla_untraced_variants(self):
# Accepted-set precision (v1.9.3): the higher-channel codings that
# cost frame-start precision stay rejected. 0x60-0x6F/0x70-0x7F
# are the SM-indicating extended classes -- 0x60-0x6F overlaps
# printable ASCII ('a'-'o'), and the text byte 0x61 started a bogus
# frame on xiaomi_mi_a1_coldboot; 0x81-0x83/0x85-0x8F/0xA1-0xAF
# are the '8X'/'AX' standard-channel variants (channels 0-3) other
# than the legacy 0x80/0x84/0xA0, where the FCP byte 0xA5 did the
# same.
for cla in (0x60, 0x61, 0x6F, 0x70, 0x73, 0x7F,
0x81, 0x85, 0x8F, 0xA1, 0xA5, 0xAF):
pkt = bytes([cla, 0xa4, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(cla=cla):
self._invalid(pkt, 'invalid CLA')
def test_odd_ins_set_pinned(self):
# Exactly the odd codes the pinned specs define: the ISO 7816-4
# Table 4 pairs (0F ERASE BINARY, 21 VERIFY, 87 GENERAL
# AUTHENTICATE, A1 SEARCH BINARY, B1 READ BINARY, B3 READ RECORD,
# C3 ENVELOPE, CB GET DATA, D1 WRITE BINARY, D7 UPDATE BINARY,
# DB PUT DATA, DD UPDATE RECORD), TS 102 221 AUTHENTICATE 89, and
# TS 102 221 Table 10.5 MANAGE SECURE CHANNEL 73 / TRANSACT DATA 75
# (the v1.9.6 audit found these two missing).
self.assertEqual(self.pd.ODD_INS, frozenset((
0x0F, 0x21, 0x73, 0x75, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xDB, 0xD1, 0xD7, 0xDD)))
for ins in (0x73, 0x75):
with self.subTest(ins=ins):
self.assertTrue(self.pd.plausible_ins(ins))
def test_vs_reader_mirror(self):
# tools/vs_reader.py duplicates the acceptance predicates for its
# standalone pcap analysis; the copies must not drift from pd.py
# (a drift would make its "suspicious CLA" / validity metrics and
# the --pcap-only verdict disagree with the decoder).
path = os.path.join(REPO, 'tools', 'vs_reader.py')
spec = importlib.util.spec_from_file_location('vs_reader', path)
vs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(vs)
self.assertEqual(
vs.VALID_CLA,
frozenset(c for c in range(256) if self.pd.plausible_cla(c)))
self.assertEqual(vs.ODD_INS, self.pd.ODD_INS)
self.assertEqual(vs.EMBEDDED_CLA, self.pd.EMBEDDED_CLA)
def test_invalid_too_short(self): def test_invalid_too_short(self):
self._invalid(b'\x00\xa4\x00\x04\x00\x90', 'too short') self._invalid(b'\x00\xa4\x00\x04\x00\x90', 'too short')
@@ -187,11 +543,31 @@ class TestValidateT0Apdu(unittest.TestCase):
def test_invalid_ins_zero(self): def test_invalid_ins_zero(self):
self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS') self._invalid(b'\x00\x00\x00\x04\x00\x90\x00', 'invalid INS')
def test_invalid_ins_lsb_odd(self): def test_valid_ins_odd_variants(self):
self._invalid(b'\x00\xa5\x00\x04\x00\x90\x00', 'invalid INS') # Odd INS codes are accepted as the defined alternate variants:
# ISO 7816-4 Table 4 pairs (READ BINARY 'B1', ENVELOPE 'C3',
# UPDATE RECORD 'DD', ...) and TS 102 221 AUTHENTICATE '89'.
for ins in (0x0F, 0x21, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xD1, 0xD7, 0xDB, 0xDD):
pkt = bytes([0x00, ins, 0x00, 0x00, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._valid(pkt)
def test_invalid_ins_odd_undefined(self):
# Odd INS values with no defined command stay rejected: a
# mis-framed boundary exposes data bytes, and the text run
# "I M" (0x49 0x4D) started a bogus frame on xiaomi_phone_sample
# when every odd INS was accepted.
for ins in (0x03, 0x4D, 0x4F, 0xA5, 0xD3):
pkt = bytes([0x00, ins, 0x00, 0x00, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._invalid(pkt, 'invalid INS')
def test_invalid_ins_sw_collision(self): def test_invalid_ins_sw_collision(self):
self._invalid(b'\x00\x64\x00\x04\x00\x90\x00', 'invalid INS') for ins in (0x64, 0x9F):
pkt = bytes([0x00, ins, 0x00, 0x04, 0x00, 0x90, 0x00])
with self.subTest(ins=ins):
self._invalid(pkt, 'invalid INS')
def test_invalid_sw1(self): def test_invalid_sw1(self):
self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1') self._invalid(b'\x00\xa4\x00\x04\x00\xff\x00', 'invalid SW1')
@@ -278,6 +654,7 @@ class TestAtrHuntEscape(unittest.TestCase):
self.pd = pd self.pd = pd
self.inst = object.__new__(pd.Decoder) self.inst = object.__new__(pd.Decoder)
self.inst.bit_samples = None self.inst.bit_samples = None
self.inst._etu_measured = False
self.inst._atr_hunt_count = 0 self.inst._atr_hunt_count = 0
self.inst._atr_fail_total = 0 self.inst._atr_fail_total = 0
self.inst._hunt_etu_attempts = 0 self.inst._hunt_etu_attempts = 0
@@ -299,6 +676,22 @@ class TestAtrHuntEscape(unittest.TestCase):
self.assertEqual(self._hunt_round(0x00), 'recover') self.assertEqual(self._hunt_round(0x00), 'recover')
self.assertEqual(self.inst._hunt_etu_attempts, 1) self.assertEqual(self.inst._hunt_etu_attempts, 1)
def test_seeded_bit_samples_does_not_block_recovery(self):
# handle_atr pre-seeds bit_samples with the default-rate guess; the
# recovery must key off a real DATA measurement, not on bit_samples
# being None (the pre-seed used to defeat the v1.6.0 recovery).
self.inst.bit_samples = 1488
self.inst._etu_measured = False
self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self._hunt_round(0x00), 'recover')
def test_measured_etu_skips_recovery(self):
# A real measurement means the rate is known: no re-measurement.
self.inst.bit_samples = 1488
self.inst._etu_measured = True
actions = [self._hunt_round(0x00) for _ in range(6)]
self.assertNotIn('recover', actions)
def test_recovery_attempts_capped_then_hard_limit(self): def test_recovery_attempts_capped_then_hard_limit(self):
actions = [self._hunt_round(0x00) for _ in range(200)] actions = [self._hunt_round(0x00) for _ in range(200)]
actions = actions[:actions.index('resume_data') + 1] actions = actions[:actions.index('resume_data') + 1]
@@ -337,6 +730,7 @@ class TestAtrHuntEscape(unittest.TestCase):
def test_no_recovery_when_etu_known(self): def test_no_recovery_when_etu_known(self):
self.inst.bit_samples = 100 self.inst.bit_samples = 100
self.inst._etu_measured = True
for _ in range(6): for _ in range(6):
self.assertEqual(self._hunt_round(0x00), 'retry') self.assertEqual(self._hunt_round(0x00), 'retry')
self.assertEqual(self.inst._hunt_etu_attempts, 0) self.assertEqual(self.inst._hunt_etu_attempts, 0)
@@ -406,13 +800,24 @@ class TestEmbeddedExchanges(unittest.TestCase):
# alcor_reader[46] (n=87): GET RESPONSE whose 80-byte payload holds a # alcor_reader[46] (n=87): GET RESPONSE whose 80-byte payload holds a
# secured-packet run "40 70 70 61 15" 21 bytes before an SW-like byte; # secured-packet run "40 70 70 61 15" 21 bytes before an SW-like byte;
# the reader follows with 00 c0 00 00 19 -- a clean single exchange. # the reader follows with 00 c0 00 00 19 -- a clean single exchange.
# The structural 0x4x CLA must not turn payload bytes into a hit. # The extended-channel 0x4x CLA must not turn payload bytes into a
# hit: the CONCAT detector is deliberately blind to those classes.
h = ('80c2000050d14e82028381060291978b4440048111227ff64070706115' h = ('80c2000050d14e82028381060291978b4440048111227ff64070706115'
'3500350270000030151601252500000018ed15e8476126aa9cacbbe8f4' '3500350270000030151601252500000018ed15e8476126aa9cacbbe8f4'
'735f962016e3c6e30a26af20c02bad2a34bdf49f152e263bc7390a6119') '735f962016e3c6e30a26af20c02bad2a34bdf49f152e263bc7390a6119')
self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0]) self.assertTrue(self.pd.validate_t0_apdu(bytes.fromhex(h))[0])
self.assertEqual(self._hits(h), []) self.assertEqual(self._hits(h), [])
def test_embedded_cla_membership_pinned(self):
# CONCAT precision (v1.9.3): the detector's CLA set is deliberately
# NARROWER than plausible_cla() -- it must not follow the widened
# acceptance into the extended-channel classes (0x40-0x7F,
# 0xC0-0xEF) that false-hit on payload bytes (the Alcor vector
# above). Pin the exact membership.
expected = frozenset(list(range(0x00, 0x20)) +
[0x80, 0x84, 0xA0, 0xB0])
self.assertEqual(self.pd.EMBEDDED_CLA, expected)
def test_a55_u1_concat(self): def test_a55_u1_concat(self):
# A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix. # A55[296] (n=70): phantom-00 (buffer {01}) CLA + slip prefix.
h = ('000402a43f00612501c0000025c062238202782183023f00a5098001' h = ('000402a43f00612501c0000025c062238202782183023f00a5098001'
@@ -626,5 +1031,393 @@ class TestLineEventPayload(unittest.TestCase):
self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01) self.assertEqual(self.pd.LINE_EVENT_FLAG_CLK_HZ, 0x01)
class TestDecodeStep(unittest.TestCase):
"""Byte-replay harness for the DATA-state gate chain in decode_step().
The rest of the suite exercises only pure functions and leaf methods;
these tests drive the real state machine with a scripted byte queue so
the CLA/INS acceptance rules are pinned on the framing path (gate
ordering, header read, one-byte re-frame, exchange assembly), not just
in the predicates.
"""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst._inverse_convention = False # normally set by start()
inst._atr_parsed = True # keep 0x3B on the DATA path
inst._primed = True # skip the first-sample RST/VCC priming
inst._at_eof = lambda: False
inst.state = 'DATA'
inst.hasT0 = True
inst.hasT1 = False
inst.bit_samples = 100 # ETU known: skip _measure_etu()
inst._pps_speed_changed = False
inst._resync = False
inst.out_ann = 0
inst.emitted = []
inst.discarded = []
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
# The re-align / PPS / ATR / annotation paths are not under test.
inst.resync_idle = lambda: None
inst.handle_pps = lambda: None
inst.handle_atr = lambda pins: None
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst._note_discard = lambda value, reason, idle=False: \
inst.discarded.append((value, reason, idle))
inst._note_pre_sw = lambda value, reason: \
inst.discarded.append((value, reason, True))
inst.emit_packet = lambda sub_type, payload, ss, es, flags=0: \
inst.emitted.append((bytes(payload), flags))
# The vectors never depend on sample positions; a benign wait()
# keeps an unrelated wait call from crashing the harness.
inst.wait = lambda cond=None: [1] * 8
return inst
def test_9e_9f_are_status_words(self):
# ISO 7816-3 10.3.3 Table 11: '9X' is SW1. The old code treated
# 9E/9F as an "extended-length" procedure byte and consumed SW2 as
# a bogus length, losing the status word.
for sw1 in (0x9e, 0x9f):
with self.subTest(sw1=sw1):
inst = self._make(bytes.fromhex('00a40000023f00')
+ bytes([sw1, 0x00]))
self.assertTrue(inst.decode_step())
self.assertEqual(
inst.emitted,
[(bytes.fromhex('00a40000023f00') + bytes([sw1, 0x00]), 0)])
def test_parity_error_flags_bad_fcs(self):
# A parity error is annotated and must also reach downstream as
# GSMTAP_FLAG_BAD_FCS on the emitted packet.
inst = self._make(bytes.fromhex('00a40000023f009000'))
orig = inst.read_byte
def read():
b = orig()
if b == 0x3f:
inst._parity_errors += 1
return b
inst.read_byte = read
self.assertTrue(inst.decode_step())
self.assertEqual(len(inst.emitted), 1)
self.assertTrue(inst.emitted[0][1] & self.pd.GSMTAP_FLAG_BAD_FCS)
def test_extended_channel_exchange_frames(self):
# 'CX' STATUS on channel 4 (v1.9.3): the whole exchange must be
# read and emitted clean.
inst = self._make(bytes.fromhex('c0f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('c0f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_ex_class_exchange_frames(self):
# 'EX' STATUS on channel 6 with SM (b6 = 1): same.
inst = self._make(bytes.fromhex('e2f20000009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('e2f20000009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_bit_dense_valid_cla_is_not_idle_noise(self):
# 0x5F was accepted by plausible_cla() all along but always
# discarded by the idle-noise gate before v1.9.3 (dead code): the
# framing path must consume it as a command byte.
inst = self._make(bytes.fromhex('5fa40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('5fa40004009000'), 0)])
self.assertEqual(inst.discarded, [])
def test_text_byte_boundary_is_discarded_not_framed(self):
# xiaomi_mi_a1_coldboot regression byte: the menu-text 'a' (0x61)
# at a mis-framed boundary is a suspicious CLA (not a frame start
# and not idle noise) -- exactly one byte is consumed.
inst = self._make(bytes.fromhex('612000c000621e'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0x61)
self.assertIn('suspicious CLA', inst.discarded[0][1])
self.assertEqual(bytes(inst._bytes), bytes.fromhex('2000c000621e'))
def test_invalid_ins_re_frame_recovers_command(self):
# A stray leading byte (0x1C) shifts the header; the invalid-INS
# window (INS 0x00) drops only that byte and re-frames the real
# command, which is emitted intact on the next step.
inst = self._make(bytes.fromhex('1c00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.discarded[0][0], 0x1c)
self.assertIn('invalid INS', inst.discarded[0][1])
self.assertEqual(list(inst._replay), [0x00, 0xA4, 0x00, 0x04])
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00a40004009000'), 0)])
def test_data_0x3b_is_not_an_atr_after_traffic(self):
# Once an APDU has been emitted, a DATA-state 0x3B is data misread
# at a frame boundary, not an ATR: it falls through to the
# suspicious-CLA discard instead of calling handle_atr.
inst = self._make(bytes.fromhex('3b80900000'))
inst._atr_parsed = False
inst._apdu_seen = True
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(called, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0x3B)
def test_data_0x3b_before_traffic_is_atr_candidate(self):
# Before any APDU the byte is still a legitimate ATR candidate
# (a capture starting between RST deassert and the ATR).
inst = self._make(bytes.fromhex('3b80900000'))
inst._atr_parsed = False
inst._apdu_seen = False
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_data_0x3f_before_traffic_is_atr_candidate(self):
# Inverse-convention ATRs (TS=0x3F) are candidates too.
inst = self._make(bytes.fromhex('3f100000'))
inst._atr_parsed = False
inst._apdu_seen = False
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_inverse_ts_raw_pattern_is_an_atr_candidate(self):
# A real inverse TS decodes as 0x03 through the direct reader; the
# raw moments 5-7 (LLL) mark it as the 0x3F candidate.
inst = self._make(bytes.fromhex('03100000'))
inst._atr_parsed = False
inst._apdu_seen = False
inst.bits = [0, 1, 1, 0, 0, 0, 0, 0, 0, 1]
called = []
inst.handle_atr = lambda pins: called.append(pins)
inst.decode_step()
self.assertEqual(len(called), 1)
def test_idle_noise_still_discarded(self):
# 0xFE (all-1s minus one bit) is not a valid class: unchanged
# idle-noise discard.
inst = self._make(bytes.fromhex('fea40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted, [])
self.assertEqual(len(inst.discarded), 1)
self.assertEqual(inst.discarded[0][0], 0xfe)
self.assertIn('idle-line noise', inst.discarded[0][1])
def test_immediate_sw_rejection_frames(self):
# A command rejected with SW1 SW2 instead of an ACK (ISO 7816-3
# §10.3.3) must not be read as command data; the exchange and the
# following one must frame separately.
inst = self._make(bytes.fromhex('00b00000106a82' +
'00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b00000106a82'), 0)])
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[1],
(bytes.fromhex('00a40004009000'), 0))
def test_short_response_is_split_and_flagged(self):
# Lr < Le (6282 end-of-file): the SW inside the P3-byte read ends
# the response, and the packet is flagged BAD_FCS (it used to be a
# silent total loss).
inst = self._make(bytes.fromhex('00b0000010b012349000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b000001012349000'),
self.gs.GSMTAP_FLAG_BAD_FCS)])
def test_short_response_replays_following_exchange(self):
# A short response followed by another command: the split must
# replay the tail so the next exchange is framed intact.
inst = self._make(bytes.fromhex('00b0000010b012349000' +
'00a40004009000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[0],
(bytes.fromhex('00b000001012349000'),
self.gs.GSMTAP_FLAG_BAD_FCS))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted[1],
(bytes.fromhex('00a40004009000'), 0))
def test_le_256_response_data_is_kept(self):
# P3=0 in an outgoing command is Le=256 (ISO 7816-3 §10.3.2): the
# data after the ACK must be appended, not discarded — including a
# data byte that happens to equal INS.
payload = 'a5' * 8 + 'b0' + 'a5' * 7
inst = self._make(bytes.fromhex('00b0000000b0' + payload + '9000'))
self.assertTrue(inst.decode_step())
self.assertEqual(inst.emitted,
[(bytes.fromhex('00b0000000' + payload + '9000'), 0)])
class TestAtrHuntDetect(unittest.TestCase):
"""The ATR-hunt command-detect handoff in handle_atr(), driven by a
scripted byte queue (the hunt logic itself is not otherwise covered)."""
@classmethod
def setUpClass(cls):
cls.gs, cls.pd = _load_iso7816()
def _make(self, byte_seq):
inst = object.__new__(self.pd.Decoder)
inst.reset()
inst.samplenum = 0
inst.ss = 0
inst._inverse_convention = False # normally set by start()
inst._samples_per_clock = 4.0 # skip _measure_clock_period()
inst.bit_samples = 100 # skip _compute_bit_samples()
inst.out_ann = 0
inst.out_python = 0
inst.options = {'protocol': 'T=0'}
inst._atr_parsed = False
inst.emitted = []
inst.emit_packet = lambda st, pl, ss, es, flags=0: \
inst.emitted.append((st, bytes(pl), flags))
inst.hasT0 = False # start() defaults; a handoff flips it
inst.hasT1 = False
inst._bytes = collections.deque(byte_seq)
def pop_byte():
if inst._replay:
return inst._replay.popleft()
return inst._bytes.popleft() if inst._bytes else None
def peek_byte():
b = inst._replay[0] if inst._replay else (
inst._bytes[0] if inst._bytes else None)
inst.peeked_samplenum = inst.samplenum
return b
inst.read_byte = pop_byte
inst.read_first_byte = pop_byte
inst._consume_first = pop_byte
inst.peek_byte = peek_byte
inst.put = lambda *a, **k: None
inst.log = lambda *a, **k: None
inst.wait = lambda cond=None: [1] * 8
return inst
def test_handoff_on_extended_channel_cla(self):
# 0xC4 (channel 8, no SM) + 0xA4 (SELECT): a command header, not a
# non-TS byte; it is replayed to the DATA state.
inst = self._make([0xC4, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
self.assertTrue(inst.hasT0)
def test_handoff_on_odd_ins_89(self):
# The TS 102 221 AUTHENTICATE '89' form (v1.9.3): the second byte
# only qualifies because ODD_INS accepts it.
inst = self._make([0xC4, 0x89])
inst.handle_atr(None)
self.assertEqual(inst.state, 'DATA')
self.assertEqual(list(inst._replay), [0xC4])
def test_no_handoff_on_basic_channel_cla(self):
# CLA 0x00 is excluded by design (pre-activation noise).
inst = self._make([0x00, 0xA4])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_no_handoff_on_invalid_ins(self):
# 0x61 is not a valid INS: the pair stays on the hunt path.
inst = self._make([0xC4, 0x61])
inst.handle_atr(None)
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_eof_after_plausible_cla_is_clean(self):
# Capture ends right after a plausible CLA byte: the hunt must
# return cleanly. It used to call plausible_ins(None) -> TypeError
# (seen only by decode()'s crash protection).
inst = self._make([0xC4])
inst.handle_atr(None) # must not raise
self.assertEqual(inst.state, 'ATR')
self.assertEqual(list(inst._replay), [])
def test_bare_ts_t0_is_not_committed_as_an_atr(self):
# A data byte 0x3B followed by 0x00 looks like a bare TS+T0 ATR.
# It must not be emitted or force clock_skip=372 (a mid-session
# data 0x3B used to fabricate an ATR).
inst = self._make([0x3B, 0x00])
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.state, 'DATA')
def test_truncated_atr_ta_is_clean(self):
# T0 says TA1 present but the capture ends: the parse must return
# without committing an ATR (or raising on the None byte).
inst = self._make([0x3B, 0x10])
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
def test_inverse_ts_pattern_is_parsed(self):
# 0x03 + the raw inverse TS pattern must be committed as 0x3F and
# switch the readers to the inverse convention.
inst = self._make([0x03, 0x10, 0x95])
inst.bits = [0, 1, 1, 0, 0, 0, 0, 0, 0, 1]
inst.handle_atr(None)
self.assertTrue(inst._atr_parsed)
self.assertTrue(inst._inverse_convention)
self.assertEqual(inst.ATR, [0x3f, 0x10, 0x95])
self.assertEqual(len(inst.emitted), 1)
def test_atr_with_parity_error_is_ignored(self):
# A real ATR is parity-clean. A data run that parses as an ATR but
# has a parity error must not commit the rate (e.g. test_8's
# fabricated 3b80900000).
inst = self._make([0x3B, 0x10, 0x95])
orig = inst.read_byte
def read():
b = orig()
if b == 0x10:
inst._parity_errors += 1
return b
inst.read_byte = read
inst.handle_atr(None)
self.assertFalse(inst._atr_parsed)
self.assertEqual(inst.emitted, [])
self.assertEqual(inst.state, 'DATA')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+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 from gsmtap_stream.py
GSMTAP_FLAG_BAD_FCS = 0x01 GSMTAP_FLAG_BAD_FCS = 0x01
# Valid CLA values for ISO 7816 / 3GPP. Mirror of plausible_cla in pd.py: # Valid CLA values for ISO 7816-4 / TS 102 221. Mirror of plausible_cla in
# interindustry structures (high nibble 0x0/0x1/0x4/0x5), which covers the # pd.py: interindustry 0x00-0x1F (channels 0-3) and 0x40-0x5F (channels
# logical-channel classes 0x01-0x03 (e.g. the command after MANAGE CHANNEL # 4-19 without CLA-level SM), the UICC extended classes 'CX'/'EX'
# opens channel 1), plus the proprietary SIM/UICC CAT classes. # (0xC0-0xCF/0xE0-0xEF, channels 4-19), plus 0x80/0x84/0xA0/0xB0 (0xB0 has
# no pinned-spec basis; kept for backward compatibility). 0x60-0x6F
# ('6X': SM; overlaps printable ASCII 'a'-'o'), 0x70-0x7F and the '8X'/'AX'
# standard-channel variants stay rejected: stray data bytes with those
# classes started bogus frames on the mis-framed phone traces.
def _plausible_cla(cla): def _plausible_cla(cla):
if not (cla & 0x80): if not (cla & 0x80):
return (cla & 0xF0) in (0x00, 0x10, 0x40, 0x50) return (cla & 0xF0) in (0x00, 0x10, 0x40, 0x50)
return cla in (0x80, 0x84, 0xA0, 0xB0) return (cla & 0xF0) in (0xC0, 0xE0) or cla in (0x80, 0x84, 0xA0, 0xB0)
VALID_CLA = frozenset(cla for cla in range(256) if _plausible_cla(cla)) VALID_CLA = frozenset(cla for cla in range(256) if _plausible_cla(cla))
@@ -65,10 +69,12 @@ MAX_TPDU_LEN = 271
# data region, followed by its P3 data bytes and an interior status word. # data region, followed by its P3 data bytes and an interior status word.
# These constants bound detector precision (empirically tuned on all 10 # These constants bound detector precision (empirically tuned on all 10
# example traces: 0 hits on clean traces, every Samsung/xiaomi family hit). # example traces: 0 hits on clean traces, every Samsung/xiaomi family hit).
# Mirror of pd.py: real command classes only -- the structural 0x4x/0x5x # Pinned in both pd.py and here -- deliberately narrower than VALID_CLA:
# forms that _plausible_cla() tolerates only add payload false hits. # the extended-channel classes false-hit on payload bytes (the Alcor
# "40 70 70 61 15" secured-packet run). Only the command classes that
# really occur on these wires stay covered.
EMBEDDED_CLA = frozenset( EMBEDDED_CLA = frozenset(
cla for cla in VALID_CLA if (cla & 0xF0) not in (0x40, 0x50)) list(range(0x00, 0x20)) + [0x80, 0x84, 0xA0, 0xB0])
# Commands that actually occur on these cards' bus (T=0 + STK/CAT). # Commands that actually occur on these cards' bus (T=0 + STK/CAT).
EMBEDDED_INS = frozenset(( EMBEDDED_INS = frozenset((
0x12, 0x14, 0x20, 0x44, 0x70, 0x88, 0x12, 0x14, 0x20, 0x44, 0x70, 0x88,
@@ -89,8 +95,9 @@ def embedded_exchanges(buf):
embedded inside a packet's data region (index 5 onward). embedded inside a packet's data region (index 5 onward).
Pattern: header (CLA INS P1 P2 P3) + P3 data bytes + interior SW1/SW2 Pattern: header (CLA INS P1 P2 P3) + P3 data bytes + interior SW1/SW2
before the packet's own final status word. CLA must be structurally before the packet's own final status word. CLA must be one of the
valid (same rule as _plausible_cla); INS must be a real command byte; pinned EMBEDDED_CLA classes (deliberately narrower than
_plausible_cla()); INS must be a real command byte;
SW1 is accepted within a small slack window after the nominal data end SW1 is accepted within a small slack window after the nominal data end
and must be interior (trailing bytes follow it). and must be interior (trailing bytes follow it).
""" """
@@ -134,12 +141,26 @@ def _plausible_sw(sw1):
return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F) return (0x61 <= sw1 <= 0x6F) or (0x90 <= sw1 <= 0x9F)
# Odd INS codes defined by the pinned specs: the alternate variants of the
# ISO 7816-4 Table 4 pairs plus TS 102 221 AUTHENTICATE '89'. Other odd
# values stay rejected (no command uses them; mis-framed boundary data
# bytes with odd INS started bogus frames on the phone traces).
ODD_INS = frozenset((
0x0F, 0x21, 0x73, 0x75, 0x87, 0x89, 0xA1, 0xB1, 0xB3,
0xC3, 0xCB, 0xDB, 0xD1, 0xD7, 0xDD,
))
def _plausible_ins(ins): def _plausible_ins(ins):
# Mirror of plausible_ins in pd.py: 0x00 is invalid and 0x6x/0x9x
# collide with status words; odd INS values are accepted only as the
# defined alternate command variants (READ BINARY 'B1', AUTHENTICATE
# '89', ...).
if ins == 0x00: if ins == 0x00:
return False return False
if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90: if (ins & 0xF0) == 0x60 or (ins & 0xF0) == 0x90:
return False return False
if (ins & 0x01) != 0: if (ins & 0x01) != 0 and ins not in ODD_INS:
return False return False
return True return True