v3.10.0: preset TAR table with per-TAR MSL; derive the SPI1 from the TAR

Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar
fields: a preset now carries `tars` - the three mandatory role entries
(isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an
optional description, each with its MSL (Minimum SPI1, TS 102 226
8.2.1.3.2.4).  v3.9.1 presets convert on load/import (the spi1 seeds all
three MSLs); duplicate TARs and empty TAR/MSL values are refused; the
store schema version is 3.

Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the
RAM ops, the test runner (per-step overrides still win), the counter probe
and each TAR-probe row; the plain send keeps an explicit SPI1 with a
`warning` when it is below the MSL, and a TAR outside the preset without an
explicit SPI1 is refused with a hint.  The TAR probe resolves per checked
TAR before sending and reports the effective spi1 per row.  The secured
packet form's SPI2 default is 00 (the RAM/probe operations pin their own).

PWA: the Cards form edits the TAR table (role rows with MSL inputs plus
Add/Remove free rows with a description), the list shows a TARs (MSL)
column, the SCP80 TAR field suggests the preset's entries (datalist) and a
preset apply seeds the SPI1 from the operation TAR's MSL with an amber
warning below it; the preset-wide SPI1/SPI2 fields and the header marker's
SPI requirement are gone (completeness = a keyset + the three role entries
with their MSLs).  Help EN/RU, docs/api.md and AGENTS updated; sw cache
simple-v358.

Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter
advanced); a send without spi1 derived 16 (por_ok, counter advanced); a
send with spi1 0A returned the warning and the card refused it (6200, no
counter consumed); an unknown TAR without spi1 was refused with the hint.
This commit is contained in:
2026-10-02 08:28:46 +03:00
parent f059d05040
commit 6360dc5bae
19 changed files with 1187 additions and 269 deletions
+115 -13
View File
@@ -9,6 +9,17 @@ owns the store: an accepted counter is persisted in the same operation, and a
closed tab, a lost response or a second browser window can no longer lose an
increment.
A preset holds a **TAR table**: every Remote Management application the card
exposes is addressed by its TAR, and each carries its own **Minimum Security
Level** (MSL) - the "Minimum SPI1" the card checks before it processes a
secured packet (TS 102 226 6.1/8.2.1.3.2.4; a too-low SPI1 is answered with
response status 0A "Insufficient security level"). The three role entries
(`isd`, `uiccRfm`, `usimRfm`) are mandatory - the Cards form pre-settles their
values - and further TARs can be added with an optional description. A packet
for a TAR derives its SPI1 from that TAR's MSL; the preset no longer carries a
card-wide SPI1/SPI2 (SPI2 is an operation property: whether and how a Proof of
Receipt is requested).
A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them
(TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own
counter - "a dedicated counter shall be associated to each key version"
@@ -30,7 +41,9 @@ deliberately human-readable: hand-editing a counter or moving the file between
machines is part of the workbench workflow, and the Cards tab's export/import
uses the same shape (which is also how old localStorage presets move over -
there is no automatic migration). v3.8.0's flat ``kic/kid/kicKey/kidKey/cntr``
shape is converted into a single keyset on load and on import.
shape is converted into a single keyset, and a v3.9.1 preset's card-wide
``spi1``/``tar``/``uiccTar``/``usimTar`` become the three role entries (the
spi1 as their MSL) - on load and on import alike.
"""
import copy
@@ -46,21 +59,28 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
# The fields a preset carries: the localStorage shape plus the id. Defaults
# mirror the Cards form (spi1/spi2 and the per-target TARs). The OTA key
# The fields a preset carries: the localStorage shape plus the id. The OTA key
# material lives in `keysets` - the b8..b5 nibble of KIc/KID numbers them,
# each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall
# be associated to each key version").
PRESET_FIELDS = ('name', 'iccid', 'adm', 'spi1', 'spi2',
'tar', 'uiccTar', 'usimTar', 'pskIdentity', 'pskKey')
# be associated to each key version"); the TAR table lives in `tars` (see
# TAR_ROLES).
PRESET_FIELDS = ('name', 'iccid', 'adm', 'pskIdentity', 'pskKey')
# The v3.8.0 flat key fields: converted into a single keyset on load/import.
LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
HEX_FIELDS = ('adm', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar', 'pskKey')
HEX_FIELDS = ('adm', 'pskKey')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'}
# The TAR table: the three mandatory role entries (their values are pre-settled
# in the Cards form) and free entries with an optional description. Every
# entry carries the TAR and its MSL (Minimum SPI1, one hex byte).
TAR_ROLES = ('isd', 'uiccRfm', 'usimRfm')
TAR_ROLE_DEFAULTS = {
'isd': {'tar': '000000', 'msl': '16'},
'uiccRfm': {'tar': 'B00000', 'msl': '16'},
'usimRfm': {'tar': 'B00001', 'msl': '16'},
}
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
COUNTER_WIDTH = 10 # ... shown as 10 hex digits
KVN_MAX = 0x0F # keyset number '00' is reserved: no key set, no counter
@@ -134,6 +154,32 @@ def counter_ahead(old, new):
return (o - n) > (1 << (COUNTER_BITS - 1))
def tar_entry(preset, tar_hex):
"""The preset's TAR entry whose TAR matches `tar_hex` (case-insensitive),
or None."""
want = re.sub(r'\s', '', str(tar_hex or '')).upper()
if not want:
return None
for t in (preset or {}).get('tars') or []:
if str(t.get('tar') or '').upper() == want:
return dict(t)
return None
def role_tar(preset, role):
"""The TAR of a role entry ('isd' | 'uiccRfm' | 'usimRfm'), or ''."""
for t in (preset or {}).get('tars') or []:
if t.get('role') == role:
return str(t.get('tar') or '')
return ''
def tar_msl(preset, tar_hex):
"""The MSL (Minimum SPI1) of a TAR, or '' when the preset has no such
entry or no MSL for it."""
return str((tar_entry(preset, tar_hex) or {}).get('msl') or '')
class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...)."""
@@ -234,10 +280,7 @@ class PresetStore:
if k in HEX_FIELDS:
v = re.sub(r'\s', '', v).upper()
out[k] = v
out['spi1'] = out['spi1'] or '16'
out['spi2'] = out['spi2'] or '01'
for k, d in TAR_DEFAULTS.items():
out[k] = out[k] or d
out['tars'] = self._normalise_tars(fields)
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
# into a single keyset, so old files and exports keep working.
raw = fields.get('keysets')
@@ -251,6 +294,51 @@ class PresetStore:
out['id'] = pid or uuid.uuid4().hex
return out
def _normalise_tars(self, fields):
"""The preset's TAR table: the three mandatory role entries (values
filled from the defaults when empty, so a partial API call and the
pre-settled form both work) followed by the free entries in input
order. A v3.9.1 preset (tar/uiccTar/usimTar + the card-wide spi1)
converts: the spi1 becomes the MSL of all three roles."""
raw = fields.get('tars')
entries = []
if isinstance(raw, list):
for t in raw:
if not isinstance(t, dict):
continue
role = str(t.get('role') or '').strip()
entries.append({
'role': role if role in TAR_ROLES else '',
'tar': re.sub(r'\s', '', str(t.get('tar') or '')).upper(),
'msl': re.sub(r'\s', '', str(t.get('msl') or '')).upper(),
'desc': str(t.get('desc') or '').strip(),
})
else:
msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper()
for role, src in (('isd', 'tar'), ('uiccRfm', 'uiccTar'),
('usimRfm', 'usimTar')):
entries.append({
'role': role,
'tar': re.sub(r'\s', '', str(fields.get(src) or '')).upper(),
'msl': msl,
'desc': '',
})
out = []
for role in TAR_ROLES:
cur = next((e for e in entries if e['role'] == role), None)
d = TAR_ROLE_DEFAULTS[role]
out.append({'role': role,
'tar': (cur or {}).get('tar') or d['tar'],
'msl': (cur or {}).get('msl') or d['msl'],
'desc': ''})
for e in entries:
if e['role']:
continue
if not (e['tar'] or e['msl'] or e['desc']):
continue # a blank editor row
out.append(e)
return out
def _normalise_keyset(self, ks):
"""One keyset, cleaned. The counter is kept as typed here and
validated + padded in _validate_keyset() - a bad value must be
@@ -281,6 +369,18 @@ class PresetStore:
if kvn in seen:
raise PresetError('duplicate keyset number %02X in this preset' % kvn)
seen.add(kvn)
seen_tars = set()
for t in p['tars']:
if not re.fullmatch(r'[0-9A-F]{6}', t['tar']):
raise PresetError('TAR must be three hex bytes: %r' % t['tar'])
if not t['msl']:
raise PresetError('TAR %s needs its MSL (Minimum SPI1, one hex '
'byte, TS 102 226 8.2.1.3.2.4)' % t['tar'])
if not re.fullmatch(r'[0-9A-F]{2}', t['msl']):
raise PresetError('MSL must be one hex byte: %r' % t['msl'])
if t['tar'] in seen_tars:
raise PresetError('duplicate TAR %s in this preset' % t['tar'])
seen_tars.add(t['tar'])
norm = normalize_iccid(p['iccid'])
if norm:
for other in self._presets:
@@ -372,6 +472,8 @@ class PresetStore:
merged[k] = fields[k]
if isinstance(fields, dict) and 'keysets' in fields:
merged['keysets'] = fields['keysets']
if isinstance(fields, dict) and isinstance(fields.get('tars'), list):
merged['tars'] = fields['tars']
new = self._normalise(merged)
new['id'] = cur['id']
self._validate(new, skip_id=cur['id'])