feat: TAR probe + the SPI1 counter-tracking rule; keyless packets build again (v3.8.0)

Three things learned from probing a live card, plus the capability they enabled:

1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
   is "present, ignored, never updated", so a packet without a counter check
   must leave every counter untouched.  _counter_tracked() now guards
   /api/send-ota (no final_cntr, no persistence, a log line), the RAM step
   sender and the test runner; _preset_counter_persist() requires a non-zero
   key version (the "single keyset" fallback could advance a keyset spuriously
   for a keyless send).  The PWA advances the form counter only when the server
   reports final_cntr and says "counter not tracked" otherwise.

2. The spec's no-security form builds again: _ota_keyset() refused the valid
   KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
   SPI1.b2b1=00 for KID) because pySim wants an algorithm.  An unknown nibble
   is now refused only when the algorithm is actually used (ciphering / a
   ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
   the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
   kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
   the card accepted (a fake 15/15 was refused with 6200).  A no-security PoR
   is decodable again too, and a requested-but-missing PoR sets por_missing
   on the plain path as well.

3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
   TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
   (TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
   with the preset's keyset and SPI 16/01.  Each TAR is classified
   (registered / no_answer / no_por / refused) from the ENVELOPE verdict and
   the application's PoR, counters advance and persist normally, and the run is
   refused when SPI1 has no counter check.  Live on a card: 000000, B00000 and
   B00001 registered (compact-format applications, answering 6D00 / 6B00),
   the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
   with the keyless form the same card accepted every TAR and answered no PoR.

Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337.  Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
This commit is contained in:
2026-09-30 21:17:59 +03:00
parent 386958b261
commit 6eeb8dacb3
10 changed files with 615 additions and 31 deletions
+173 -25
View File
@@ -1176,17 +1176,44 @@ def _ota_keyset(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex):
from osmocom.utils import h2b
kic_b = int(kic, 16)
kid_b = int(kid, 16)
spi = _spi_from_bytes(int(spi1, 16), int(spi2, 16))
algo_crypt = _CRYPT_ALGO.get(kic_b & 0x0F)
algo_auth = _AUTH_ALGO.get(kid_b & 0x0F)
# TS 102 225 A.2: '00' is a valid KIc value when no ciphering is applied
# (SPI1.b3=0) and a valid KID value when no RC/CC/DS is applied
# (SPI1.b2b1=00) - a no-security packet carries no algorithm at all. The
# algorithm is only *needed* when it is actually used, so an unknown nibble
# is refused only then and a placeholder is passed to pySim otherwise (its
# key is never touched).
needs_crypt = spi['ciphering'] or spi['por_shall_be_ciphered']
needs_auth = (spi['rc_cc_ds'] != 'no_rc_cc_ds'
or spi['por_rc_cc_ds'] != 'no_rc_cc_ds')
if algo_crypt is None:
raise ValueError('Unsupported KIc algorithm nibble %02X' % (kic_b & 0x0F))
if needs_crypt or (kic_b & 0x0F):
raise ValueError('Unsupported KIc algorithm nibble %02X' % (kic_b & 0x0F))
# 'implicit' codes the nibble as 0, which is exactly the spec's value
algo_crypt = 'implicit'
if algo_auth is None:
raise ValueError('Unsupported KID algorithm nibble %02X' % (kid_b & 0x0F))
if needs_auth or (kid_b & 0x0F):
raise ValueError('Unsupported KID algorithm nibble %02X' % (kid_b & 0x0F))
algo_auth = 'implicit'
return OtaKeyset(algo_crypt=algo_crypt, kic_idx=kic_b >> 4, kic=h2b(kic_key_hex),
algo_auth=algo_auth, kid_idx=kid_b >> 4, kid=h2b(kid_key_hex),
cntr=int(cntr_hex, 16) if cntr_hex else 0)
def _counter_tracked(spi1_hex):
"""True when the packet asks the card to check the counter (TS 102 225
5.1.1, SPI1.b5b4 = 01/10/11). With b5b4 = 00 the field is "present,
ignored, never updated" - the card keeps no replay value for the packet,
so no counter may be advanced or persisted (a keyless SPI1 0x00 send must
leave every preset counter untouched)."""
try:
return ((int(spi1_hex, 16) >> 3) & 0x03) != 0
except (TypeError, ValueError):
return False
def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_hex, kid_key_hex):
from pySim.ota import OtaDialectSms
from osmocom.utils import h2b, b2h
@@ -1467,8 +1494,10 @@ def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
card state, so the server writes it with the operation that consumed it - a
closed tab, a lost response or a second browser window can no longer lose
an increment. The store itself is monotonic (a stale value never regresses
it). Best effort: a store failure must never fail the card operation."""
if not preset_id or not cntr:
it). A key version is mandatory: a packet with no key version (KIc/KID
'00', or a SPI1 without a counter check) has no counter to write. Best
effort: a store failure must never fail the card operation."""
if not preset_id or not cntr or not kvn:
return
store = getattr(server, 'card_presets', None)
if store is None:
@@ -1670,12 +1699,14 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
step_name, por_src, step.get('por_status', '?'), step.get('por_sw', '-'),
(' error=%s' % step_error) if step_error else '',
result['bytes'], result['segments']))
# Advance the counter only for an accepted packet
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
# ... and persist it right away: the server owns the counter now, so a
# crash mid-install cannot leave the preset behind the card (v3.8.0).
if step.get('por_status') in ('por_ok', 'no_por'):
# Advance the counter only for an accepted packet, and only when the
# packet asks the card to check it (TS 102 225 5.1.1 b5b4): a SPI1
# without a counter check leaves the card's replay value untouched, so
# the tool must not advance or persist anything either.
if step.get('por_status') in ('por_ok', 'no_por') and _counter_tracked(sp.get('spi1')):
state['cntr'] = _ram_next_cntr(state['cntr'], True)
# ... and persist it right away: the server owns the counter now,
# so a crash mid-install cannot leave the preset behind the card.
_preset_counter_persist(server, state.get('preset_id'),
state['cntr'], 'ram-%s' % step_name.split()[0].lower(),
state.get('kvn'))
@@ -5166,6 +5197,7 @@ _TEST_BLOCKED_PATHS = frozenset([
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
'/api/ram-install', '/api/ram-install-app', '/api/cap-compat',
'/api/tar-probe',
'/api/sp-verify', '/api/menu-select',
'/api/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
@@ -5189,6 +5221,40 @@ _CARD_FREE_GET = frozenset([
'/api/scp81/log', '/api/bip/log',
])
# TAR values allocated by ETSI (TS 101 220 V18.3.0 Annex D, Table D.1): the
# TAR probe sends one harmless command to each and reports the card's answer.
# 'B00200' is not an application - it is the start of the RFU range, kept as a
# control (a card must not answer it).
TAR_PROBE_TARS = [
('000000', 'Issuer Security Domain (compact)'),
('B20100', 'Issuer Security Domain (expanded)'),
('B00000', 'UICC shared file system RFM (compact)'),
('B00001', 'ADF RFM (compact)'),
('B00010', 'SIM file system RFM (compact)'),
('B00120', 'UICC shared file system RFM (expanded)'),
('B00130', 'SIM file system RFM (expanded)'),
('B00140', 'ADF RFM (expanded)'),
('B20000', 'USAT interpreter'),
('B20200', 'Multiplexing application'),
('B20201', 'Controlling Authority Security Domain'),
('B20202', 'OMA BCAST audience measurement'),
('B20203', 'OMA DM LWM2M application'),
('B00200', 'RFU (control)'),
]
TAR_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: harmless, applet-visible
def _tar_probe_verdict(step):
"""Classify one TAR probe step: the card's ENVELOPE verdict plus the PoR
the registered application answered with."""
pstatus = str(step.get('por_status') or '')
if pstatus == 'por_ok':
return 'registered' if (step.get('por_sw') or step.get('por_data')) else 'no_answer'
if pstatus == 'no_por':
return 'no_por'
return 'refused'
_TEST_COMMAND_TYPES = {name.upper(): code for code, name in PROACTIVE_TYPE_NAMES.items()}
@@ -5424,11 +5490,12 @@ def _test_run_scp80(server, step, ctx):
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s' % (
source, tar or '-', spi1, spi2, counter,
(' kvn=%d' % kvn) if kvn else '')
if sw:
# The card answered, so the SCP80 counter was consumed: advance the
# working value of this key version and persist it into the server-side
# preset (v3.8.0 - the server owns the counters, so the run survives a
# lost response and the PWA's counter write-back dance is gone).
if sw and _counter_tracked(spi1):
# The card answered and the packet asks for a counter check, so the
# SCP80 counter was consumed: advance the working value of this key
# version and persist it into the server-side preset (v3.8.0 - the
# server owns the counters, so the run survives a lost response and the
# PWA's counter write-back dance is gone).
if kvn:
counters[kvn] = _increment_counter_hex(counter)
ctx['counter'] = counters[kvn]
@@ -6865,6 +6932,14 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400)
self._log_resp(resp)
return
# Only a packet whose SPI1 asks the card to check the counter has a
# counter to track (TS 102 225 5.1.1 b5b4): with b5b4 = 00 (the
# keyless SPI1 00 case) the field is ignored and never updated, so
# no counter is advanced or persisted.
counter_tracked = _counter_tracked(body.get('spi1', ''))
if body.get('cntr') and not counter_tracked:
sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: '
'no counter check)\n' % body.get('spi1', ''))
include_cpi = body.get('includeCpi', True)
ram_format = None
try:
@@ -6929,7 +7004,7 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = result
if ram_format is not None:
resp['ram_format'] = ram_format
if _counter_valid(cntr):
if _counter_valid(cntr) and counter_tracked:
resp['final_cntr'] = cntr
# A warning ENVELOPE may still carry the PoR (the
# card's counter verdict): decode it so the caller
@@ -6972,10 +7047,12 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
body.get('kidKey', ''), por_hex)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
is_ram = bool(apdu)
por_required = bool(spi2_val & 0x01)
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
if is_ram and por_required and result['sw'] == '9000' and no_por_received:
if por_required and result['sw'] == '9000' and no_por_received:
# the PoR was requested and the card did not answer -
# true for pre-built packets (the keyless form) too
resp['por_missing'] = True
sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
if por:
@@ -7005,17 +7082,19 @@ class PysimHandler(BaseHTTPRequestHandler):
if ram_format is not None:
resp['ram_format'] = ram_format
# The counter to use next, reported for the RAM and the
# plain SCP80 path alike; the server persists it (v3.8.0).
# A pre-built packet may come without a counter - then
# nothing is reported (and int('') never happens).
if _counter_valid(cntr):
# plain SCP80 path alike; the server persists it
# (v3.8.0). A pre-built packet may come without a
# counter, and a packet whose SPI1 does not ask the card
# to check it has none to track - nothing is reported
# then (and int('') never happens).
if _counter_valid(cntr) and counter_tracked:
resp['final_cntr'] = _ram_next_cntr(cntr, accepted)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
_preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota',
kvn or None)
if counter_tracked:
_preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota', kvn)
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -7557,6 +7636,75 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _test_state_snapshot()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/tar-probe':
app = self.server.app
scc = self.server.scc
if not app or not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
body = self._read_body()
self._log_req(body)
kic = body.get('kic', '')
kid = body.get('kid', '')
kvn, keyset_err = self._keyset_guard(body, kic, kid)
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
# The probe sends real secured packets: it needs a counter check
# (every accepted packet advances and is persisted) and a PoR.
spi1 = str(body.get('spi1') or '16').strip().upper()
spi2 = str(body.get('spi2') or '01').strip().upper()
if not _counter_tracked(spi1):
err = {'success': False,
'error': 'the TAR probe needs a counter check - SPI1 %s has '
'none (b5b4 = 00)' % spi1}
self._send_json(err, 400)
self._log_resp(err)
return
apdu = (body.get('apdu') or TAR_PROBE_DEFAULT_APDU).replace(' ', '').upper()
labels = {tar: label for tar, label in TAR_PROBE_TARS}
wanted = body.get('tars')
if isinstance(wanted, list) and wanted:
entries = []
for t in wanted:
tar = str(t).replace(' ', '').upper()
entries.append((tar, labels.get(tar, '')))
else:
entries = list(TAR_PROBE_TARS)
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': body.get('cntr', '00000000'),
'preset_id': body.get('preset_id'), 'kvn': kvn}
sp = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, 'tar': '000000',
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)}
sys.stderr.write('TAR-PROBE: %d TAR(s), APDU %s, SPI %s/%s, kvn=%s\n'
% (len(entries), apdu, spi1, spi2, kvn))
_ram_progress_begin('tar-probe', len(entries))
try:
for index, (tar, _label) in enumerate(entries):
_ram_progress_step(index, 'TAR ' + tar)
sp['tar'] = tar
_ram_send_gp_apdu(self.server, scc, sp, state, 'TAR ' + tar, apdu)
finally:
_ram_progress_end()
results = []
for (tar, label), step in zip(entries, state['steps']):
results.append({
'tar': tar, 'label': label, 'verdict': _tar_probe_verdict(step),
'sw': step.get('sw'), 'por_status': step.get('por_status'),
'por_sw': step.get('por_sw'), 'por_data': step.get('por_data'),
'bytes': step.get('bytes'), 'segments': step.get('segments'),
})
registered = sum(1 for r in results if r['verdict'] == 'registered')
resp = {'success': True, 'results': results, 'steps': state['steps'],
'registered': registered, 'total': len(results),
'final_cntr': state['cntr'], 'kvn': kvn,
'spi1': spi1, 'spi2': spi2, 'apdu': apdu}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets':
body = self._read_body()
self._log_req(body)