Files
catarrh 64433f9739 Extended logical channels: CLA 0-19, ATR card capabilities, MANAGE CHANNEL (v2.6.0)
- CLA decode (TS 102 221 §10.1.1/§10.3): standard channels 0-3 from b2-b1
  and the extended channels 4-19 from b4-b1.  The old further-format rule
  read only b2-b1 (channel 8 read as 6, 19 as 7) and the ETSI '11x0 xxxx'
  coding (0xC0-0xEF) was not handled at all; the class ranges now follow the
  specs (0x80-0x8F UICC, 0xA0-0xAF SIM, 0xC0-0xEF ETSI extended, 0x20-0x3F
  reserved, 0xFF invalid) and SM/chain are decoded per family — the SM
  extraction and the chaining bit (b5, not b6) were off by one bit
- per-channel state: database._decode_rows/_replay_context use the shared
  cla_channel() helper — before, CLA & 0x03 with no +4, so a channel-5
  SELECT clobbered channel 1's selection and a channel-8 one channel 0's
  (the local traces already open channels: 29 MANAGE CHANNEL opens, 1-2)
- COMPRION T=0 import: the synthesized GET RESPONSE keeps the channel —
  a plain CLA for 0-3 (as real terminals do) and 01x0 for 4-19
- ATR historical bytes: the UICC's COMPACT-TLV objects (TS 102 221 §6.3.1)
  with card service data, the card capabilities (ISO 7816-4 §8.1.1.2.7
  Table 88: assignment methods and max logical channels — the workspace
  cards announce 4 and "8 or more") and the status indicator ('00' trailing
  bytes / the '8Y' TLV); a truncated ATR no longer raises
- MANAGE CHANNEL success replies decode the assigned channel byte
- capture/import: a 'tpdu' packet that is not APDU-shaped (shorter than the
  header, CLA FF, ISO-reserved class) is skipped and counted, so a
  character-level or mis-sampled stream no longer pollutes the decode
  (the local DB's outlier sessions are exactly that)
- tests: CLA table vectors, per-channel state isolation on channels 8/12,
  COMPRION GR channels, filetrack extended channels, ATR compact-TLV and
  card-capability cases, the capture skip counter, the PWA rows; 527 Python
  + 47 Node tests
- README/README_RUS document the extended channels, card capabilities and
  the skip counter; PWA_VERSION/pyproject/version.py 2.6.0, SW v72
2026-10-17 22:42:11 +03:00

600 lines
23 KiB
Python

"""COMPRION trace import (the tool's 'ISO Protocol Layer' text export).
The COMPRION Device Manager exports a recorded session in two tab-separated
text files (CRLF, CP1252):
* **ISO Protocol Layer** — the byte-accurate protocol view (ATR, PPS, APDUs
with their raw hex and return codes). This is the import source.
* **ISO Application Layer** — a decoded view (no raw response bytes); it is
rejected with a pointer to the Protocol Layer export.
Each protocol element carries a *Delay* in etu at the negotiated F/D (the F/D
half is exact — it comes from the ATR's TA1 and the PPS). The card interface
clock is not part of the export, so a Delay-derived timeline measures only
**card/line activity time**: ``CLK_HZ`` merely fixes its unit (it is not a
measurement of the card clock), long host pauses between APDU bursts are
absent, and the result is therefore much shorter than the real session. When
the matching **ISO Application Layer** export is supplied as well, its
per-exchange wall-clock times (``Time:`` rows, ms precision) are used
instead: the two files are first verified to belong to the same session
(``Session Name``, falling back to ``Working directory``) and their exchange
counts must align.
Exchanges are re-encoded into the wire-shaped TPDUs the analyser stores (the
shape the GSMTAP/sigrok sources produce): a case-4 exchange becomes the
command TPDU (data, no Le) plus a ``61 <len>`` status word and a GET RESPONSE
TPDU carrying the response data and the final SW — so command/response
pairing, FCP decoding and the GET RESPONSE filters all work as usual.
"""
import re
import time
from .decode import _fidi_dict, cla_channel
# Unit scale for the Delay-derived timeline: the COMPRION export states no
# clock, so this only converts the Delay column's etu into seconds (card/line
# activity time — it is NOT a measurement, and it is deliberately not fed
# into the sigrok CLK / RST line-event annotation). Real-phone clocks
# measured in our own captures sit at 3.58-3.81 MHz (colorburst / Xiaomi).
CLK_HZ = 3_800_000
# Before the ATR/PPS the card runs at the ISO default Fi/Di.
_DEFAULT_F_DIV_D = 372.0
_TABLE_HEADER = ['#', 'Sequence', 'Dir', 'Description', 'Value', 'Delay', 'Meaning']
_LAYER_TITLES = ('ISO Protocol Layer', 'ISO Application Layer')
# '16.10.2026 15:10' (the export timestamp in the header block)
_DATE_RE = re.compile(r'^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2})$')
# '6795,06 etu' / '6795.06 etu' (the decimal separator follows the exporting
# machine's locale) / '12 etu' / '1 234,56 etu'
_DELAY_RE = re.compile(r'^(\d[\d ]*)(?:[,.](\d+))?\s*([A-Za-z]+)$')
# '42.443 s' / '1 180.559 s' (space thousands separator, millisecond precision)
_SECONDS_RE = re.compile(r'^(\d[\d ]*)(?:[,.](\d+))?\s*s$')
# '2026-10-16 12h15m53s_AM_Android'
_SESSION_RE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})[ _T](\d{2})h(\d{2})m(\d{2})s')
# The exchanges the Application Layer export carries a block (and a Time) for:
# reset/power events and APDUs. The ATR and the PPS exchange are protocol
# only, so the time base reuses the previous timestamp for them.
_TIME_BASE_SEQUENCES = ('Cold reset', 'Power off', 'APDU')
ERROR_MESSAGES = {
'app_layer': "This is the 'ISO Application Layer' export — select the "
"'ISO Protocol Layer' file (it contains the raw APDU bytes).",
'not_comprion': "Not a COMPRION export — expected the 'ISO Protocol Layer' "
"text file.",
'unsupported_layout': "Unsupported COMPRION export layout (expected the "
"'ISO Protocol Layer' table).",
'no_data': 'No APDU exchanges found in the COMPRION export.',
'times_unparsable': 'No usable Time entries found in the ISO Application '
'Layer export.',
'session_mismatch': 'The two files belong to different sessions — select '
'the ISO Application Layer export of the same session.',
'time_base_unverified': 'Cannot verify that the two files belong to the '
'same session (no Session Name in either file).',
'time_base_mismatch': 'The ISO Application Layer file does not match this '
'trace (different exchange count).',
}
class ComprionError(ValueError):
"""A COMPRION import failure carrying a stable machine code."""
def __init__(self, code, detail=None):
base = ERROR_MESSAGES.get(code, code)
self.message = f'{base} ({detail})' if detail else base
super().__init__(self.message)
self.code = code
def decode_text(raw):
"""Decode raw file bytes to text.
The exports are CP1252 (the banner's (c) is the only non-ASCII byte in a
Protocol Layer file); UTF-8 is tried first so a re-encoded export still
decodes, with CP1252 as the fallback.
"""
if isinstance(raw, str):
return raw
try:
return raw.decode('utf-8-sig')
except UnicodeDecodeError:
return raw.decode('cp1252', errors='replace')
def _cells(line):
return [p.strip() for p in line.split('\t')]
def _layer_title(lines, limit=20):
"""The export's layer title ('ISO Protocol Layer'/'ISO Application
Layer') from the header block, or None."""
for line in lines[:limit]:
cells = _cells(line)
if cells and cells[0] in _LAYER_TITLES:
return cells[0]
return None
def _find_header(lines):
"""Index of the trace table's header row, or None."""
for i, line in enumerate(lines):
if _cells(line)[:7] == _TABLE_HEADER:
return i
return None
def check_comprion_text(text):
"""Classify a text file for the import validation ladder.
Returns a stable code: '' when the file looks like an ISO Protocol Layer
export, otherwise 'app_layer' / 'unsupported_layout' / 'not_comprion'.
"""
lines = text.splitlines()
title = _layer_title(lines)
header = _find_header(lines)
if title == 'ISO Application Layer':
return 'app_layer'
if header is not None:
return ''
if title == 'ISO Protocol Layer':
return 'unsupported_layout'
return 'not_comprion'
def looks_like_comprion(raw):
"""True when a raw file looks like a COMPRION export (either layer).
Used for the 'wrong button' hint when a COMPRION text file is dropped on
the PCAP import path.
"""
if isinstance(raw, str):
head = raw[:8192]
else:
head = decode_text(bytes(raw[:8192]))
return check_comprion_text(head) in ('', 'app_layer', 'unsupported_layout')
def session_start_epoch(name):
"""Epoch seconds of a session name like '2026-10-16 12h15m53s_AM_Android'.
Returns None when the name does not carry a parseable timestamp.
"""
m = _SESSION_RE.match(name or '')
if not m:
return None
year, month, day, hour, minute, sec = (int(x) for x in m.groups())
try:
return time.mktime((year, month, day, hour, minute, sec, 0, 0, -1))
except (ValueError, OverflowError):
return None
def parse_application_times(text):
"""Extract the per-exchange timestamps of an ISO Application Layer export.
Returns ``([(block_name, seconds)], metadata)`` in file order. Each
``Command:``/``Event:`` block carries exactly one ``Time:`` row (the
session-relative wall-clock time of that exchange). Raises
:class:`ComprionError` ('times_unparsable') when no usable entries are
found.
"""
lines = text.splitlines()
cut = len(lines)
for i, line in enumerate(lines):
cells = _cells(line)
if 'Command' in cells and 'Field' in cells and 'Value' in cells:
cut = i
break
meta = _parse_metadata(lines[:cut])
blocks = []
for line in lines[cut + 1:]:
nonempty = [c for c in _cells(line) if c]
if not nonempty:
continue
if len(nonempty) >= 2 and nonempty[1] in ('Command:', 'Event:'):
blocks.append([nonempty[0], None])
continue
if (nonempty[0] == 'Time:' and blocks and blocks[-1][1] is None
and len(nonempty) > 1):
blocks[-1][1] = _parse_seconds(nonempty[1])
missing = [name for name, t in blocks if t is None]
if not blocks or missing:
raise ComprionError(
'times_unparsable',
f'{len(blocks) - len(missing)}/{len(blocks)} blocks carry a Time')
times = [t for _name, t in blocks]
if times != sorted(times):
raise ComprionError('times_unparsable', 'timestamps are not monotonic')
return [(name, t) for name, t in blocks], meta
def _verify_time_base(meta, times_meta):
"""Refuse an Application Layer file that is not from the same session.
The two exports of one session share the *Session Name* (and the working
directory); the export timestamps differ and are not compared.
"""
proto_name = (meta.get('session_name') or '').strip()
app_name = (times_meta.get('session_name') or '').strip()
if proto_name and app_name:
if proto_name != app_name:
raise ComprionError('session_mismatch',
f"'{proto_name}' vs '{app_name}'")
return
proto_dir = (meta.get('working_directory') or '').strip()
app_dir = (times_meta.get('working_directory') or '').strip()
if proto_dir and app_dir:
if proto_dir != app_dir:
raise ComprionError('session_mismatch',
f"'{proto_dir}' vs '{app_dir}'")
return
raise ComprionError('time_base_unverified')
# Session Name fields become the session's display name, so control
# characters are normalised away (quotes are legitimate and are escaped by
# the PWA when rendering).
_CONTROL_RE = re.compile(r'[\x00-\x1f\x7f]+')
def _clean_text(value, limit=200):
text = _CONTROL_RE.sub(' ', value or '')
text = re.sub(r' {2,}', ' ', text).strip()
return text[:limit]
def _parse_metadata(lines):
meta = {
'title': '', 'user': '', 'session_name': '', 'working_directory': '',
'serial_number': '', 'comment': '', 'exported': '',
}
key_map = {
'User': 'user',
'Session Name': 'session_name',
'Working directory': 'working_directory',
'Serial Number': 'serial_number',
'Comment': 'comment',
}
for line in lines:
cells = _cells(line)
# The config rows indent the key into a later column ('', 'Configuration',
# '', ...); values follow the key.
nonempty = [c for c in cells if c]
if not nonempty:
continue
first = nonempty[0]
if not meta['title'] and first in _LAYER_TITLES:
meta['title'] = first
if not meta['exported']:
for cell in cells:
if _DATE_RE.match(cell):
meta['exported'] = cell
break
key = first.rstrip(':')
if key in key_map and len(nonempty) > 1:
meta[key_map[key]] = _clean_text(nonempty[1])
return meta
def _hex_bytes(value):
"""Hex cell content -> bytes ('' -> b''). Raises ValueError."""
text = re.sub(r'\s+', '', value or '')
if not text:
return b''
return bytes.fromhex(text)
def _parse_delay(value):
"""A Delay cell ('6795,06 etu') -> (amount, unit), or None."""
m = _DELAY_RE.match(value.strip())
if not m:
return None
whole = m.group(1).replace(' ', '')
frac = m.group(2) or '0'
return float(whole + '.' + frac), m.group(3).lower()
def _parse_seconds(value):
"""A Time cell ('1 180.559 s') -> seconds, or None."""
m = _SECONDS_RE.match(value.strip())
if not m:
return None
whole = re.sub(r'[\s\u00a0]', '', m.group(1))
return float(whole + '.' + (m.group(2) or '0'))
def _warn_once(bucket, warnings, note):
if note not in bucket:
bucket.add(note)
warnings.append(note)
def _f_div_d(fi, di, fallback):
entry = _fidi_dict(fi, di)
value = entry.get('f_div_d')
if isinstance(value, (int, float)) and value > 0:
return float(value)
return fallback
def _rate_change(exchange, f_div_d):
"""The F/D after this exchange (TA1 of an ATR, PPS1 of a PPS exchange)."""
if exchange['seq'] == 'ATR':
desc = 'TA1'
elif exchange['seq'] in ('PPS request', 'PPS response'):
desc = 'PPS1'
else:
return f_div_d
for item in exchange['items']:
if item['desc'] != desc or not item['value']:
continue
try:
b = _hex_bytes(item['value'])
except ValueError:
return f_div_d
if len(b) != 1:
return f_div_d
return _f_div_d(b[0] >> 4, b[0] & 0x0F, f_div_d)
return f_div_d
def _item_value(items, prefix):
"""Concatenated bytes of the first item whose description starts with
*prefix* (continuation rows were folded into it), or None when absent."""
for item in items:
if item['desc'].startswith(prefix):
return _hex_bytes(item['value'])
return None
def _push(messages, t, type_, data):
"""Append a message, clamping the timestamp so the timeline stays
monotonic (the DB is ordered by (elapsed, id))."""
last = messages[-1][0] if messages else 0.0
messages.append((max(t, last), type_, data))
def _emit_exchange(exchange, messages, skipped, warn_bucket, warnings):
seq = exchange['seq']
items = exchange['items']
if seq == 'Cold reset':
# Reset card interface: RST asserted (low) then de-asserted (high),
# the ATR follows. Payloads per sigrok-iso7816-stream line events —
# deliberately without the optional CLK extension: the export carries
# no clock measurement (see CLK_HZ).
t = items[0]['t'] if items else 0.0
_push(messages, t, 'rst', bytes([1, 0, 0]))
_push(messages, t, 'rst', bytes([0, 1, 0]))
return
if seq == 'Power off':
t = items[0]['t'] if items else 0.0
_push(messages, t, 'vcc', bytes([0, 0, 0]))
return
if seq in ('ATR', 'PPS request', 'PPS response'):
chunks = []
for item in items:
if not item['value']:
continue
try:
chunks.append(_hex_bytes(item['value']))
except ValueError:
_warn_once(warn_bucket, warnings,
f'{seq}: unparsed hex value {item["value"]!r} (ignored)')
if chunks:
_push(messages, items[0]['t'], 'atr' if seq == 'ATR' else 'pps',
b''.join(chunks))
return
if seq == 'APDU':
_emit_apdu(exchange, messages, skipped, warn_bucket, warnings)
return
_warn_once(warn_bucket, warnings,
f'unknown exchange type {seq!r} (skipped)')
def _emit_apdu(exchange, messages, skipped, warn_bucket, warnings):
items = exchange['items']
head_item = next((i for i in items if i['desc'].startswith('Header')), None)
if head_item is None or not head_item['value']:
skipped.append({'sequence': 'APDU', 'reason': 'no command header row'})
return
try:
head = _hex_bytes(head_item['value'])
except ValueError:
skipped.append({'sequence': 'APDU',
'reason': f'unparsed header {head_item["value"]!r}'})
return
if len(head) not in (4, 5):
skipped.append({'sequence': 'APDU',
'reason': f'unexpected header length {len(head)}'})
return
try:
incoming = _item_value(items, 'Incoming data') or b''
outgoing = _item_value(items, 'Outgoing data') or b''
sw = _item_value(items, 'Return code') or b''
except ValueError as e:
skipped.append({'sequence': 'APDU', 'reason': f'unparsed hex value ({e})'})
return
if not sw:
_warn_once(warn_bucket, warnings,
f'APDU {head.hex().upper()}: no return code row')
elif len(sw) != 2:
_warn_once(warn_bucket, warnings,
f'APDU {head.hex().upper()}: unexpected return code length '
f'{len(sw)}')
if len(incoming) > 0xFF or len(outgoing) > 0xFF:
# Extended-length APDUs are not part of the stored wire shape.
skipped.append({'sequence': 'APDU',
'reason': 'extended-length APDU not supported'})
return
t1 = head_item['t']
if len(head) == 4:
# The export shows CLA INS P1 P2; synthesise the TPDU's P3: Lc for a
# command with data, otherwise the response length the terminal read
# (Le), which keeps the body/status-word split unambiguous.
p3 = len(incoming) if incoming else (len(outgoing) if outgoing else 0)
head = head + bytes([p3])
if incoming and outgoing:
# Case 4 on the wire (T=0): the command carries no Le, the card
# answers 61 <len> and the terminal fetches with GET RESPONSE.
# The GET RESPONSE keeps the command's logical channel: the terminal
# answers with a plain first-interindustry CLA for channels 0-3 and
# the further-interindustry coding for the extended channels 4-19
# (real traces drop the class/SM bits, TS 102 221 §10.3).
_push(messages, t1, 'tpdu',
head + incoming + bytes([0x61, len(outgoing)]))
t2 = max(items[-1]['t'], t1)
channel = cla_channel(head[0])
gr_cla = channel if channel < 4 else 0x40 | (channel - 4)
cmd = bytes([gr_cla, 0xC0, 0x00, 0x00, len(outgoing)])
_push(messages, t2, 'tpdu', cmd + outgoing + sw)
elif incoming:
_push(messages, t1, 'tpdu', head + incoming + sw)
elif outgoing:
_push(messages, t1, 'tpdu', head + outgoing + sw)
else:
_push(messages, t1, 'tpdu', head + sw)
def parse_comprion(text, times_text=None):
"""Parse an ISO Protocol Layer export.
*times_text* is the matching ISO Application Layer export: when given, its
per-exchange wall-clock times replace the delay-derived timestamps (after
verifying that both files are from the same session and that the exchange
counts align).
Returns a dict with the re-encoded ``messages`` (elapsed, type, data),
the parsed metadata, the session start (epoch), ``time_base`` ('delays' or
'application-layer') and a parse report. Raises :class:`ComprionError`
for a wrong/invalid file or an unusable time base.
"""
code = check_comprion_text(text)
if code:
raise ComprionError(code)
lines = text.splitlines()
header_idx = _find_header(lines)
meta = _parse_metadata(lines[:header_idx])
times = None
if times_text is not None:
if not isinstance(times_text, str):
raise ComprionError('times_unparsable')
blocks, times_meta = parse_application_times(times_text)
_verify_time_base(meta, times_meta)
times = [t for _name, t in blocks]
messages = []
skipped = []
warnings = []
warn_bucket = set()
f_div_d = _DEFAULT_F_DIV_D
now = 0.0
exchange = None
exchange_count = 0
apdu_count = 0
timed_index = 0
last_time = times[0] if times else 0.0
def finish(ex):
nonlocal exchange_count, apdu_count, timed_index, last_time
exchange_count += 1
if ex['seq'] == 'APDU':
apdu_count += 1
start = len(messages)
force_t = None
if times is not None:
if ex['seq'] in _TIME_BASE_SEQUENCES:
if timed_index >= len(times):
raise ComprionError(
'time_base_mismatch',
f'{len(times)} time entries vs more exchanges')
last_time = times[timed_index]
timed_index += 1
force_t = last_time
_emit_exchange(ex, messages, skipped, warn_bucket, warnings)
if force_t is not None:
# Every message of the exchange carries the exchange's wall-clock
# time (an ATR/PPS has no Application Layer block: it keeps the
# previous timestamp).
for i in range(start, len(messages)):
elapsed, msg_type, data = messages[i]
messages[i] = (force_t, msg_type, data)
return _rate_change(ex, f_div_d)
for line in lines[header_idx + 1:]:
cells = (_cells(line) + [''] * 7)[:7]
_num, seq, direction, desc, value, delay, _meaning = cells
if not any(cells):
continue
if seq:
if exchange is not None:
f_div_d = finish(exchange)
exchange = {'seq': seq, 'items': []}
if exchange is None:
continue
if delay and times is None:
# With an Application Layer time base the Delay column is unused
# (and its warnings would only be noise).
parsed = _parse_delay(delay)
if parsed is None:
_warn_once(warn_bucket, warnings,
f'unparsed delay value {delay!r} (ignored)')
else:
amount, unit = parsed
if unit != 'etu':
_warn_once(warn_bucket, warnings,
f'delay unit {unit!r} is not etu (ignored)')
else:
now += amount * f_div_d / CLK_HZ
if desc:
exchange['items'].append({
'dir': direction, 'desc': desc, 'value': value, 't': now})
elif value:
if exchange['items']:
last = exchange['items'][-1]
last['value'] = (last['value'] + ' ' + value).strip()
else:
skipped.append({'sequence': exchange['seq'],
'reason': 'continuation row without a base row'})
if exchange is not None:
finish(exchange)
if not messages:
raise ComprionError('no_data')
if times is not None:
if timed_index != len(times):
raise ComprionError(
'time_base_mismatch',
f'{len(times)} time entries vs {timed_index} exchanges')
# Rebase the wall-clock times: the first entry is the trace's start.
base = times[0]
messages = [(round(t - base, 6), msg_type, data)
for t, msg_type, data in messages]
session_name = meta['session_name']
started = session_start_epoch(session_name)
if times is not None and started is not None:
started += times[0]
return {
'format': 'comprion',
'layer': meta['title'] or 'ISO Protocol Layer',
'metadata': meta,
'messages': messages,
'message_count': len(messages),
'exchange_count': exchange_count,
'apdu_count': apdu_count,
'duration': messages[-1][0],
'started': started,
'time_base': 'application-layer' if times is not None else 'delays',
'skipped': skipped,
'warnings': warnings,
}