v3.26.0: per-TAR default keysets (the preset/SCP80/RAM rework)

Every preset TAR entry carries `keyset` (int 1-15) - the keyset every
operation addressed to that TAR uses: mandatory for any non-zero MSL
(the counters are keyset-linked), optional for MSL 00 (allowed: some
cards reject the no-security 00/00 KIc/KID form).  The store fills it
automatically when the preset has a single keyset, the validation
refuses an entry naming an undefined keyset, and the schema is 6 (a v5
file loads leniently; editing it forces the fix).

The keyset selectors are gone: the SCP80 TAR field became a select of
the preset's entries (applying the TAR's keyset to the form and seeding
the SPI1 from its MSL); the RAM operations use the preset's ISD role
entry (the PWA sends only the preset id, the ISD TAR, SPI and the
counter override - the server resolves the keyset and the keys:
presets.tar_keyset, _preset_tar_keyset, _resolve_operation_keyset,
_op_keyset, used by send-ota's APDU path, ram-install, ram-install-app
and cap-compat); the counter probe and the test runner's scp80 steps
default to their TAR's keyset (an explicit kvn stays an override).  A
security TAR without a keyset refuses the operation ("set it in the
Cards tab") - never a silent keyless send - and the start buttons wait
for a usable operation-TAR keyset.

The TAR probe checks the preset's TAR list only (the standard
TAR_PROBE_TARS list and the custom-TAR localStorage are gone; a `tars`
list selects a subset, a TAR outside the preset is refused), each row
using its own MSL and default keyset.  The Cards editor's TAR table
gained a Keyset column (the only keyset preselected, an amber marker
when a security MSL lacks one).

Docs: help EN/RU, api.md's tar-probe contract, both AGENTS.  Tests: the
store (the field, the auto-fill, the schema, the string forms), the
server resolution (send-ota + the refusal, the probe's per-row keysets
and refusals), the PWA (the TAR select, the RAM ISD resolution, the
keyset-free requests) - several fixtures updated for the new schema.
891 frontend + 885 Python; sw cache simple-v434.
This commit is contained in:
2026-10-14 12:21:16 +03:00
parent 6e4494195c
commit cb40662e4e
18 changed files with 955 additions and 611 deletions
+57 -5
View File
@@ -69,7 +69,7 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 5
SCHEMA_VERSION = 6
# 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,
@@ -88,7 +88,12 @@ DIGIT_FIELDS = ('pin1', 'puk1', 'pin2', 'puk2')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
# 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).
# entry carries the TAR, its MSL (Minimum SPI1, one hex byte) and its **default
# keyset** (v3.26.0): the keyset every operation addressed to that TAR uses.
# The keyset is mandatory for any non-zero MSL (the counters are keyset-linked,
# TS 102 225 A.1) and may be omitted only for MSL 00 - where setting one is
# still allowed, to pass its KIc/KID in the packet (some cards reject the
# no-security '00/00' form even when SPI declares no security).
TAR_ROLES = ('isd', 'uiccRfm', 'usimRfm')
TAR_ROLE_DEFAULTS = {
'isd': {'tar': '000000', 'msl': '16'},
@@ -194,6 +199,13 @@ def tar_msl(preset, tar_hex):
return str((tar_entry(preset, tar_hex) or {}).get('msl') or '')
def tar_keyset(preset, tar_hex):
"""The default keyset number (int 1-15) of a TAR, or None when the preset
has no such entry or the entry carries no keyset (allowed for an MSL 00
entry - the packet is then built keyless; v3.26.0)."""
return (tar_entry(preset, tar_hex) or {}).get('keyset') or None
class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...)."""
@@ -306,6 +318,14 @@ class PresetStore:
raw = [{k: fields.get(k) for k in LEGACY_KEY_FIELDS}]
out['keysets'] = [self._normalise_keyset(ks) for ks in raw
if isinstance(ks, dict)]
# v3.26.0: a preset with a single keyset fills every TAR entry that has
# no default keyset with it - the TARs' keysets drive the operations.
nums = [keyset_kvn(ks) for ks in out['keysets']]
nums = [n for n in nums if n]
if len(nums) == 1:
for t in out['tars']:
if not t.get('keyset'):
t['keyset'] = nums[0]
pid = str(fields.get('id') or '').strip().lower()
out['id'] = pid or uuid.uuid4().hex
return out
@@ -328,6 +348,7 @@ class PresetStore:
'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(),
'keyset': self._normalise_keyset_ref(t.get('keyset')),
})
else:
msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper()
@@ -338,6 +359,7 @@ class PresetStore:
'tar': re.sub(r'\s', '', str(fields.get(src) or '')).upper(),
'msl': msl,
'desc': '',
'keyset': None,
})
out = []
for role in TAR_ROLES:
@@ -346,16 +368,37 @@ class PresetStore:
out.append({'role': role,
'tar': (cur or {}).get('tar') or d['tar'],
'msl': (cur or {}).get('msl') or d['msl'],
'desc': ''})
'desc': '',
'keyset': (cur or {}).get('keyset')})
for e in entries:
if e['role']:
continue
if not (e['tar'] or e['msl'] or e['desc']):
if not (e['tar'] or e['msl'] or e['desc'] or e.get('keyset')):
continue # a blank editor row
# free entries carry no `role`: the three roles are the fixed ones
out.append({'tar': e['tar'], 'msl': e['msl'], 'desc': e['desc']})
out.append({'tar': e['tar'], 'msl': e['msl'], 'desc': e['desc'],
'keyset': e.get('keyset')})
return out
@staticmethod
def _normalise_keyset_ref(v):
"""A TAR's default keyset number (int 1-15) or None. Accepts an int
and the decimal ('1'..'15') or hex-ish ('01'..'0F') string forms; an
unusable value becomes None - the validation reports a missing keyset
where one is mandatory (v3.26.0)."""
if v is None or v == '':
return None
s = str(v).strip().upper()
if re.fullmatch(r'[0-9]{1,2}', s):
n = int(s, 10)
if 1 <= n <= KVN_MAX:
return n
if re.fullmatch(r'[0-9A-F]{1,2}', s):
n = int(s, 16)
if 1 <= n <= KVN_MAX:
return n
return None
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
@@ -402,6 +445,15 @@ class PresetStore:
'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.get('keyset'):
if not any(keyset_kvn(ks) == t['keyset'] for ks in p['keysets']):
raise PresetError('TAR %s names keyset %02X, which this '
'preset does not define'
% (t['tar'], t['keyset']))
elif t['msl'] != '00':
raise PresetError('TAR %s needs a default keyset (its MSL %s '
'requires security) - set it in the Cards tab'
% (t['tar'], t['msl']))
if t['tar'] in seen_tars:
raise PresetError('duplicate TAR %s in this preset' % t['tar'])
seen_tars.add(t['tar'])