Files
simtrace-analyser/simtrace_analyser/database.py
T
catarrh 5dea6bceb8 Files view: the full DF chain in the file path (v2.7.6)
- the tracker kept only the immediate parent DF, so a phonebook file read as
  "DF_PHONEBOOK/4FA1" — the trace's own selection path (00a4080406
  7F105F3A4FA1) spells out the whole chain; the channel state now carries it
  (selected_df_state() returns (df, path), e.g. ('5f3a', ['7f10', '5f3a'])),
  the tracker stores it per file and _file_path() renders it:
  "DF_TELECOM/DF_PHONEBOOK/4FA1"
- chain rules: a child DF selected by FID appends to the current chain, MF and
  the ADF restart it, an AID is its own root, a path selection uses the DFs
  in the path (an explicit '7fff' collapsing to the ADF), and an EF selects
  keeps it.  A leading MF is implicit in a longer path; MF-only files keep
  "MF/…", ADF files "ADF/…" (the previous labels unchanged)
- the chain is learned from the FCP sighting too and extends a shorter path
  recorded earlier, so entries created from a GET RESPONSE still get the full
  path
- verified on the live traces (sessions 47/193): 5f3a/4fa1 ->
  DF_TELECOM/DF_PHONEBOOK/4FA1, 5f3a/4f30 -> DF_TELECOM/DF_PHONEBOOK/EF_PBR
  (4F30), while 3f00/2fe2 -> MF/EF_ICCID (2FE2) and 7fff/6f07 ->
  ADF/EF_IMSI (6F07) stay as before
- tests: selected_df_state (path/FID/MF/ADF/AID/EF cases) and a tracker flow
  with a path selection asserting the full path; 529 Python + 76 Node tests
- README/README_RUS show the chain; PWA_VERSION/pyproject/version.py 2.7.6,
  SW cache simtrace-analyser-v86
2026-10-18 01:05:55 +03:00

467 lines
20 KiB
Python

"""SQLite database for session and APDU storage."""
import os
import sqlite3
import time
from contextlib import contextmanager
from datetime import datetime, timedelta
DEFAULT_DB_DIR = os.path.expanduser('~/.simtrace-analyser')
DEFAULT_DB_PATH = os.path.join(DEFAULT_DB_DIR, 'sessions.db')
# SW1 values that mean a SELECT succeeded (normal, warnings, response pending).
# Everything else (0x64-0x6F: execution/checking/security errors) leaves the
# current file unchanged.
_SUCCESS_SW1 = {'61', '62', '63', '90', '91', '92', '9e', '9f'}
_SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
started TEXT NOT NULL,
ended TEXT,
mode TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
elapsed REAL NOT NULL,
type TEXT NOT NULL,
data BLOB NOT NULL,
flags INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(type);
CREATE INDEX IF NOT EXISTS idx_messages_elapsed ON messages(session_id, elapsed);
"""
def _iso_now():
return time.strftime('%Y-%m-%dT%H:%M:%S.', time.localtime()) + \
f'{time.time() % 1 * 1000:03.0f}'
def _iso_from_ts(ts):
return time.strftime('%Y-%m-%dT%H:%M:%S.', time.localtime(ts)) + \
f'{ts % 1 * 1000:03.0f}'
class Database:
def __init__(self, db_path=None):
if db_path is None:
db_path = DEFAULT_DB_PATH
self._db_path = db_path
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.execute('PRAGMA journal_mode=WAL')
self._conn.execute('PRAGMA foreign_keys=ON')
self._conn.executescript(_SCHEMA)
self._conn.commit()
# --- Sessions ---
def create_session(self, mode):
cur = self._conn.execute(
'INSERT INTO sessions (started, mode) VALUES (?, ?)',
(_iso_now(), mode))
self._conn.commit()
return cur.lastrowid
def close_session(self, session_id, duration_secs=None):
if duration_secs is not None and duration_secs > 0:
started = self._conn.execute(
'SELECT started FROM sessions WHERE id=?', (session_id,)).fetchone()[0]
ended = (datetime.fromisoformat(started) + timedelta(seconds=duration_secs)).isoformat()
else:
ended = _iso_now()
self._conn.execute(
'UPDATE sessions SET ended=? WHERE id=? AND ended IS NULL',
(ended, session_id))
self._conn.commit()
def set_session_times_from_ts(self, session_id, first_ts, last_ts):
self._conn.execute(
'UPDATE sessions SET started=?, ended=? WHERE id=?',
(_iso_from_ts(first_ts), _iso_from_ts(last_ts), session_id))
self._conn.commit()
def rename_session(self, session_id, name):
self._conn.execute(
'UPDATE sessions SET name=? WHERE id=?',
(name, session_id))
self._conn.commit()
def delete_session(self, session_id):
self._conn.execute('DELETE FROM messages WHERE session_id=?', (session_id,))
self._conn.execute('DELETE FROM sessions WHERE id=?', (session_id,))
self._conn.commit()
def count_sessions(self):
row = self._conn.execute('SELECT COUNT(*) FROM sessions').fetchone()
return row[0]
def db_size(self):
"""Total on-disk size of the SQLite database (db + WAL + SHM)."""
total = 0
for suffix in ('', '-wal', '-shm'):
p = self._db_path + suffix
if os.path.exists(p):
total += os.path.getsize(p)
return total
def vacuum(self):
self._conn.execute('VACUUM')
self._conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
self._conn.commit()
def get_session(self, session_id):
row = self._conn.execute(
'SELECT id, name, started, ended, mode FROM sessions WHERE id=?',
(session_id,)).fetchone()
if row is None:
return None
return self._row_to_session(row)
def list_sessions(self):
rows = self._conn.execute("""
SELECT s.id, s.name, s.started, s.ended, s.mode,
COUNT(m.id) AS msg_count
FROM sessions s LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY s.started DESC
""").fetchall()
return [self._row_to_session(r) for r in rows]
def get_active_session(self):
row = self._conn.execute(
'SELECT id, name, started, ended, mode FROM sessions WHERE ended IS NULL ORDER BY id DESC LIMIT 1'
).fetchone()
if row is None:
return None
return self._row_to_session(row)
def _row_to_session(self, row):
d = {'id': row[0], 'name': row[1], 'started': row[2], 'ended': row[3], 'mode': row[4]}
if len(row) > 5:
d['message_count'] = row[5]
return d
# --- Messages ---
def insert_message(self, session_id, elapsed, msg_type, data, flags=0):
cur = self._conn.execute(
'INSERT INTO messages (session_id, elapsed, type, data, flags) VALUES (?,?,?,?,?)',
(session_id, elapsed, msg_type, data, flags))
self._conn.commit()
return cur.lastrowid
def insert_messages(self, session_id, rows):
"""Bulk-insert messages; *rows* is an iterable of
(elapsed, msg_type, data, flags) tuples. Returns the last rowid."""
self._conn.executemany(
'INSERT INTO messages (session_id, elapsed, type, data, flags) VALUES (?,?,?,?,?)',
[(session_id, elapsed, msg_type, data, flags) for elapsed, msg_type, data, flags in rows])
self._conn.commit()
def get_message(self, msg_id):
row = self._conn.execute(
'SELECT id, session_id, elapsed, type, data, flags FROM messages WHERE id=?',
(msg_id,)).fetchone()
if row is None:
return None
return self._row_to_message(row)
def get_messages(self, session_id, offset=0, limit=200):
rows = self._conn.execute(
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
'WHERE session_id=? ORDER BY elapsed, id LIMIT ? OFFSET ?',
(session_id, limit, offset)).fetchall()
initial_states = None
clk_hz = None
if offset > 0 and rows:
initial_states, clk_hz = self._replay_context(session_id, rows[0][0])
return self._decode_rows(rows, initial_states=initial_states, clk_hz=clk_hz)
def get_messages_after(self, session_id, after_id):
rows = self._conn.execute(
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
'WHERE session_id=? AND id > ? ORDER BY elapsed, id',
(session_id, after_id)).fetchall()
initial_states, clk_hz = self._replay_context(session_id, after_id + 1)
return self._decode_rows(rows, initial_states=initial_states, clk_hz=clk_hz)
def track_files(self, session_id):
"""Track file state over the whole session (see filetrack.py).
Returns the tracker's result dict: the operation timeline, per-file
counters/images/changes, the SFI relations and the AID labels.
"""
tracker = self._file_tracker(session_id)
return tracker.result()
def change_images(self, session_id, key, idx):
"""Before/after images of the unit affected by change *idx* of *key*
(the Files view's Details modal), or None for an unknown file/index."""
return self._file_tracker(session_id).change_images(key, idx)
def _file_tracker(self, session_id):
from .filetrack import FileTracker
rows = self._conn.execute(
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
'WHERE session_id=? ORDER BY elapsed, id', (session_id,)).fetchall()
tracker = FileTracker()
self._decode_rows(rows, on_message=tracker.observe)
return tracker
def get_messages_raw(self, session_id):
rows = self._conn.execute(
'SELECT elapsed, type, data FROM messages WHERE session_id=? ORDER BY elapsed, id',
(session_id,)).fetchall()
return [{'elapsed': r[0], 'type': r[1], 'data': r[2]} for r in rows]
def count_messages(self, session_id):
row = self._conn.execute(
'SELECT COUNT(*) FROM messages WHERE session_id=?',
(session_id,)).fetchone()
return row[0]
def search_messages(self, session_id, query):
hex_q = ''.join(c for c in query if c in '0123456789abcdefABCDEF')
if not hex_q:
return []
rows = self._conn.execute(
"SELECT id, session_id, elapsed, type, data, flags FROM messages "
"WHERE session_id=? AND hex(data) LIKE ? ORDER BY elapsed, id",
(session_id, f'%{hex_q.lower()}%')).fetchall()
return [self._row_to_message(r) for r in rows]
def filter_messages(self, session_id, msg_type):
rows = self._conn.execute(
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
'WHERE session_id=? AND type=? ORDER BY elapsed, id',
(session_id, msg_type)).fetchall()
return [self._row_to_message(r) for r in rows]
def get_type_counts(self, session_id):
rows = self._conn.execute(
'SELECT type, COUNT(*) FROM messages WHERE session_id=? GROUP BY type ORDER BY COUNT(*) DESC',
(session_id,)).fetchall()
return [{'type': r[0], 'count': r[1]} for r in rows]
def _row_to_message(self, row, prev=None, clk_hz=None):
data_blob = row[4]
flags = row[5]
msg = {
'id': row[0],
'session_id': row[1],
'elapsed': row[2],
'type': row[3],
'data': data_blob.hex(),
'flags': flags,
}
if row[3] in ('tpdu', 'change', 'fidi', 'atr', 'pps', 'rst', 'vcc'):
try:
from .decode import decode_sniff_msg
msg['decoded'] = decode_sniff_msg(data_blob, row[3], flags,
prev=prev, clk_hz=clk_hz)
except Exception as e:
import sys
print(f'Decode error for msg {row[0]} ({row[3]}, {len(data_blob)} bytes): {e}',
file=sys.stderr)
return msg
def _new_channel_state(self):
from .decode import sfi_table
return {'prev': None, 'sel': None, 'df': '3f00', 'df_path': ['3f00'],
'sfi_map': dict(sfi_table('3f00'))}
def _channel_state(self, states, channel):
st = states.get(channel)
if st is None:
st = self._new_channel_state()
states[channel] = st
return st
def _replay_context(self, session_id, before_id):
"""Replay (bounded) messages before *before_id* to recover per-channel
selection, DF, SFI map, and the last RST-reported CLK frequency.
Returns ``(states, clk_hz)``."""
from .decode import cla_channel, decode_sniff_msg
rows = self._conn.execute(
"SELECT data, type FROM messages WHERE session_id=? AND id < ? "
"AND type IN ('tpdu','atr','change','gap','rst','vcc') "
"ORDER BY id DESC LIMIT 500",
(session_id, before_id)).fetchall()
states = {}
clk_hz = None
for data, typ in reversed(rows):
if typ in ('atr', 'change', 'gap'):
states = {}
# An ATR consumes the CLK rate; a replay starts fresh after it.
clk_hz = None
continue
if typ in ('rst', 'vcc'):
d = decode_sniff_msg(data, typ, 0)
if d and d.get('clk_hz'):
clk_hz = d['clk_hz']
continue
channel = cla_channel(data[0]) if data else 0
st = self._channel_state(states, channel)
ctx = dict(st['prev']) if st['prev'] else {}
ctx['sel'] = st['sel']
ctx['df'] = st['df']
ctx['df_path'] = st['df_path']
ctx['sfi_map'] = st['sfi_map']
d = decode_sniff_msg(data, 'tpdu', 0, prev=ctx)
if not d or not d.get('ins_hex'):
continue
(st['sel'], st['df'], st['sfi_map'],
st['df_path']) = self._advance_state(d, st['sel'], st['df'],
st['sfi_map'], st['df_path'])
st['prev'] = self._context_from_decoded(d)
return states, clk_hz
def _advance_state(self, d, sel, df, sfi_map, df_path=None):
"""Update (sel, df, sfi_map, df_path) for one decoded TPDU."""
from .decode import selected_df_state, sfi_table, KNOWN_FIDS
new_df, new_path = selected_df_state(d, df, df_path)
if new_df is not None and new_df != df:
# Only a successful SELECT changes the current DF.
if (d.get('sw') or {}).get('sw1') in _SUCCESS_SW1:
df = new_df
df_path = new_path
sfi_map = dict(sfi_table(new_df))
# Learn SFI from the FCP of the last SELECT (GET RESPONSE).
if d.get('ins_hex') == 'c0' and d.get('response_for') == 'SELECT' and sel:
sfi = (d.get('response') or {}).get('sfi')
if sfi is not None:
sfi_map = dict(sfi_map)
sfi_map[sfi] = sel['fid']
sel = self._selection_after(d, sel)
# A record/SEARCH command with a valid SFI sets the file as current EF
# (TS 102 221 §11.1.2).
sfi = (d.get('p2') or {}).get('sfi')
if d.get('ins_hex') in ('b2', 'dc', 'a2') and sfi:
fid = sfi_map.get(sfi)
if fid:
sel = {'fid': fid, 'name': KNOWN_FIDS.get(fid), 'structure': None}
return sel, df, sfi_map, (df_path or [df])
def _context_from_decoded(self, d):
ins_hex = d.get('ins_hex')
ins = int(ins_hex, 16) if ins_hex else None
file_ok = False
if ins in (0xB2, 0xDC, 0xA2):
file_ok = True
elif ins in (0xB0, 0xD6):
file_ok = (d.get('p1p2') or {}).get('value', 0) == 0
sec = ((d.get('cmd') or {}).get('tpdu') or {}).get('secured') or {}
return {
'ins': ins,
'ins_name': d.get('ins_name'),
'sw1': (d.get('sw') or {}).get('sw1'),
'file_ok': file_ok,
'por_ciphered': bool((sec.get('spi') or {}).get('por_ciphered')),
}
def _selection_after(self, d, sel):
ins_hex = d.get('ins_hex')
if ins_hex == 'a4':
from .decode import select_target_fid, KNOWN_FIDS
sw1 = (d.get('sw') or {}).get('sw1')
# Only a successful SELECT changes the current file. On an error
# status word (e.g. 6A82 file-not-found) the file is unchanged.
if sw1 not in _SUCCESS_SW1:
return sel
fid = select_target_fid(d)
if fid:
return {'fid': fid, 'name': KNOWN_FIDS.get(fid), 'structure': None}
if (d.get('p1') or {}).get('raw') in ('03', '04'):
return None
elif ins_hex == 'c0' and d.get('response_for') == 'SELECT' and sel:
# GET RESPONSE carrying the FCP of the last SELECT → capture the
# file structure so we can sanity-check record operations.
fd = (d.get('response') or {}).get('file_descriptor')
if fd:
new = dict(sel)
new['structure'] = fd.get('structure')
return new
return sel
def _decode_rows(self, rows, initial_states=None, clk_hz=None, on_message=None):
from .decode import atr_rate_fields, cla_channel
msgs = []
states = dict(initial_states or {})
last_atr_i = None # index of the most recent ATR in *msgs*
last_atr_rated = False # whether that ATR already carries a data rate
for row in rows:
channel = 0
if row[3] == 'tpdu' and row[4] and len(row[4]) >= 5:
channel = cla_channel(row[4][0])
st = self._channel_state(states, channel)
ctx = dict(st['prev']) if st['prev'] else {}
ctx['sel'] = st['sel']
ctx['df'] = st['df']
ctx['df_path'] = st['df_path']
ctx['sfi_map'] = st['sfi_map']
msg = self._row_to_message(row, prev=ctx, clk_hz=clk_hz)
d = msg.get('decoded')
if on_message is not None:
# Observers (the per-file tracker) see the message with the
# channel state as it was *before* this message.
on_message(msg, st, channel)
if msg['type'] == 'tpdu' and d and d.get('ins_hex'):
(st['sel'], st['df'], st['sfi_map'],
st['df_path']) = self._advance_state(d, st['sel'], st['df'],
st['sfi_map'], st['df_path'])
st['prev'] = self._context_from_decoded(d)
elif msg['type'] == 'atr':
states = {}
# The CLK rate is consumed by the ATR it precedes: an ATR
# without a preceding RST event must not inherit a stale rate.
last_atr_i = len(msgs)
last_atr_rated = bool(d and d.get('data_rate'))
clk_hz = None
elif msg['type'] == 'gap':
# Device disconnected and reconnected — messages may have been
# missed, so the selection is uncertain.
states = {}
elif msg['type'] == 'change':
# Only card-level changes invalidate the selection. A
# waiting-time timeout interrupts a single TPDU but leaves the
# card selected, so the phone usually just retries.
from simtrace2_pysniff.protocol import (
CHANGE_FLAG_CARD_INSERT, CHANGE_FLAG_CARD_EJECT,
CHANGE_FLAG_RESET_ASSERT, CHANGE_FLAG_RESET_DEASSERT,
)
if msg['flags'] & (CHANGE_FLAG_CARD_INSERT | CHANGE_FLAG_CARD_EJECT |
CHANGE_FLAG_RESET_ASSERT | CHANGE_FLAG_RESET_DEASSERT):
states = {}
elif msg['type'] in ('rst', 'vcc'):
# sigrok-iso7816-stream line events: a reset assert or power
# removal destroys the card's selection state. De-assert /
# power-on are markers only (the following ATR resets state).
data = row[4] if len(row) > 4 and row[4] else b''
if d and d.get('clk_hz'):
if (last_atr_i is not None and not last_atr_rated
and msgs[last_atr_i].get('decoded')):
# Ordering caveat: a short ATR can be emitted before
# the RST event that measured its CLK rate — backfill
# the rate onto that ATR.
msgs[last_atr_i]['decoded'].update(
atr_rate_fields(d['clk_hz'], msgs[last_atr_i]['decoded']))
last_atr_rated = True
else:
clk_hz = d['clk_hz']
if msg['type'] == 'rst':
if data and data[0] == 1: # asserted
states = {}
else: # vcc
if data and data[0] == 0: # power removed
states = {}
msgs.append(msg)
return msgs