feat: one keyset per key version - indexed keys and counters (v3.8.0)

A card usually holds several OTA key sets, and the SCP80 counter is tracked
per key version on the card, so a preset now carries one keyset per
GlobalPlatform key version - the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2), the version is implicit in the bytes.  The spec is explicit:
"a dedicated counter shall be associated to each key version" (Annex A.1) and
"the key version number indicated in the KIc and KID (bits b8..b5) fields
shall be identical when different from 0" - a mismatch is rejected by the card
with "Unidentified security error" (A.2).

Server (presets.py, schema 2):
- a preset carries `keysets: [{kic, kid, kicKey, kidKey, cntr}]`; validation
  refuses a KIc/KID version mismatch (A.2), a version outside 01-0F ('00'
  means "no security" and is a packet-level choice, not a keyset), duplicate
  versions and missing keys/counters; a v3.8.0 flat preset converts into one
  keyset on load and on import;
- `set_counter(..., kvn)` writes the counter of that keyset (monotonic per
  keyset), the audit line carries the kvn, and `find_keyset()` serves the
  lookups;
- the operations derive the key version from the request's KIc byte and
  persist into that keyset: /api/send-ota, the RAM install/install-app/compat
  steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (which may name
  a `kvn` per step and keep one working counter per keyset);
- a non-zero key version the named preset does not define, or a KIc/KID
  mismatch, is refused before anything is built (_preset_keyset_check /
  _keyset_guard: 400, or a step error in the test runner).  Without a
  preset_id only the mismatch rule applies.

PWA:
- the Cards form's KIc/KID/counter/keys inputs become a keyset editor (one row
  per version: KIc, KID, both keys, that version's counter, Add/Remove; the
  version is shown derived from the bytes and a mismatch is flagged);
- the SCP80 and RAM views get a Key-version selector beside the preset
  selector, driving the KIc/KID bytes and loading that keyset's keys and
  counter; the packet builder refuses a version the preset does not define
  (spKeysetCheck/spKeysetGuard, pure and unit-tested);
- the counter syncs are per keyset (spCntrLocalSync/ramSaveCntr), the SCP80
  form's counter edit posts the keysets, and the test-run write-back syncs
  every counter the server reports (scp80_counters).

Docs/help/AGENTS updated; sw cache simple-v335.  Tests: 610 Python (store
keysets, endpoint refusals, the counter landing in the right keyset) and 726
Node (keysets.test.js plus the updated harnesses).
This commit is contained in:
2026-09-30 15:15:21 +03:00
parent 4ab133c266
commit cc2edb3d8a
16 changed files with 1240 additions and 249 deletions
+164 -53
View File
@@ -9,6 +9,14 @@ 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 one **keyset per GlobalPlatform key version** (the b8..b5 nibble
of KIc/KID, TS 102 225 5.1.2/A.2), each with its own KIc/KID keys and its own
counter - "a dedicated counter shall be associated to each key version"
(TS 102 225 Annex A.1). The two bytes of a keyset must carry the same key
version (A.2: the card rejects a mismatch with "Unidentified security error");
key version '00' is reserved and not a keyset (it means "no security" in a
packet, which is a form-level choice, not a preset).
Layout (``--card-presets`` overrides the file; ``Path.home()`` resolves the
same way on Linux, macOS and Windows)::
@@ -21,9 +29,11 @@ store. Presets carry a stable uuid - never array indices. The JSON is
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).
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.
"""
import copy
import json
import os
import re
@@ -36,18 +46,24 @@ from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
# The fields a preset carries: the localStorage shape plus the id. Defaults
# mirror the Cards form (spi1/spi2/counter and the per-target TARs).
PRESET_FIELDS = ('name', 'iccid', 'adm', 'kic', 'kid', 'spi1', 'spi2',
'tar', 'uiccTar', 'usimTar', 'cntr', 'kicKey', 'kidKey',
'pskIdentity', 'pskKey')
HEX_FIELDS = ('adm', 'kic', 'kid', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar',
'cntr', 'kicKey', 'kidKey', 'pskKey')
# mirror the Cards form (spi1/spi2 and the per-target TARs). The OTA key
# material lives in `keysets` - one entry per GlobalPlatform key version (the
# b8..b5 nibble of KIc/KID), 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')
# 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')
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'}
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
COUNTER_WIDTH = 10 # ... shown as 10 hex digits
KVN_MAX = 0x0F # key version '00' is reserved: no key set, no counter
def default_path():
@@ -55,6 +71,16 @@ def default_path():
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
def keyset_kvn(keyset):
"""The key version of a keyset: the high nibble of KIc (bits b8..b5 of
KIc/KID, TS 102 225 5.1.2/A.2 - both bytes must agree, so KIc is enough).
None when the byte is unusable."""
kic = str((keyset or {}).get('kic') or '').strip().upper()
if not re.fullmatch(r'[0-9A-F]{2}', kic):
return None
return int(kic, 16) >> 4
def _swap_nibbles(s):
"""Nibble-swap a hex string; an odd trailing nibble becomes 'F' (the JS
``swapNibbles`` the PWA uses)."""
@@ -183,12 +209,14 @@ class PresetStore:
except OSError:
pass
def _audit(self, pid, name, old, new, source):
"""One JSONL line per counter change: the forensic trail for "did an
increment get lost?" questions."""
def _audit(self, pid, name, old, new, source, kvn=None):
"""One JSONL line per counter change (the key version included - each
keyset has its own counter): the forensic trail for "did an increment
get lost?" questions."""
try:
line = json.dumps({'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
'id': pid, 'name': name, 'old': old, 'new': new,
'id': pid, 'name': name, 'kvn': kvn,
'old': old, 'new': new,
'source': source}, ensure_ascii=False)
self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.audit_path, 'a', encoding='utf-8') as fh:
@@ -208,29 +236,51 @@ class PresetStore:
out[k] = v
out['spi1'] = out['spi1'] or '16'
out['spi2'] = out['spi2'] or '01'
# the counter is kept as typed here and validated + padded in
# _validate(): a bad value must be refused, never silently masked
out['cntr'] = out['cntr'] or '1'
for k, d in TAR_DEFAULTS.items():
out[k] = out[k] or d
# 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')
if not isinstance(raw, list):
raw = []
if any(str(fields.get(k) or '').strip() for k in LEGACY_KEY_FIELDS):
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)]
pid = str(fields.get('id') or '').strip().lower()
out['id'] = pid or uuid.uuid4().hex
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
refused, never silently masked."""
out = {}
for k in KEYSET_FIELDS:
v = ks.get(k)
v = '' if v is None else str(v).strip()
if k in KEYSET_HEX_FIELDS:
v = re.sub(r'\s', '', v).upper()
out[k] = v
out['cntr'] = out['cntr'] or '1'
return out
def _validate(self, p, skip_id=None):
if not p['name']:
raise PresetError('name is required')
for key in ('kic', 'kid', 'kicKey', 'kidKey'):
if not p[key]:
raise PresetError('%s is required' % key)
if bool(p['pskIdentity']) != bool(p['pskKey']):
raise PresetError('PSK identity and PSK key must be set together')
if p['pskKey'] and not re.fullmatch(r'[0-9A-F]{32}', p['pskKey']):
raise PresetError('PSK key must be 32 hex characters')
if not re.fullmatch(r'[0-9A-F]{1,10}', p['cntr']):
raise PresetError('counter must be 1-10 hex digits')
# validated: store the protocol's fixed-width form
p['cntr'] = counter_hex(p['cntr'])
if not p['keysets']:
raise PresetError('at least one keyset is required')
seen = set()
for ks in p['keysets']:
self._validate_keyset(ks)
kvn = keyset_kvn(ks)
if kvn in seen:
raise PresetError('duplicate key version %02X in this preset' % kvn)
seen.add(kvn)
norm = normalize_iccid(p['iccid'])
if norm:
for other in self._presets:
@@ -241,6 +291,30 @@ class PresetStore:
% (other['name'] or other['id']))
return p
def _validate_keyset(self, ks):
"""One keyset: KIc/KID carry the key version in b8..b5 and must agree
(TS 102 225 A.2 - a mismatch is rejected by the card), the version is
01-0F ('00' means "no security" and has no keys to define, so it is
not a preset keyset), both keys are present and the counter is
1-10 hex digits (the dedicated counter of that key version, A.1)."""
for key in ('kic', 'kid'):
if not re.fullmatch(r'[0-9A-F]{2}', ks[key]):
raise PresetError('%s must be one hex byte' % key)
kic_v, kid_v = int(ks['kic'], 16) >> 4, int(ks['kid'], 16) >> 4
if kic_v != kid_v:
raise PresetError('KIc and KID must carry the same key version '
'(TS 102 225 A.2): %s / %s' % (ks['kic'], ks['kid']))
if not 1 <= kic_v <= KVN_MAX:
raise PresetError('key version must be 01-0F: %s' % ks['kic'])
for key in ('kicKey', 'kidKey'):
if not ks[key]:
raise PresetError('%s is required' % key)
if not re.fullmatch(r'[0-9A-F]{1,10}', ks['cntr']):
raise PresetError('counter must be 1-10 hex digits')
# validated: store the protocol's fixed-width form
ks['cntr'] = counter_hex(ks['cntr'])
return ks
def _find(self, pid):
want = str(pid or '').strip().lower()
if not want:
@@ -253,12 +327,12 @@ class PresetStore:
# -------------------------------------------------------------- public
def list(self):
with self._lock:
return [dict(p) for p in self._presets]
return [copy.deepcopy(p) for p in self._presets]
def get(self, pid):
with self._lock:
p = self._find(pid)
return dict(p) if p else None
return copy.deepcopy(p) if p else None
def find_by_iccid(self, iccid):
norm = normalize_iccid(iccid)
@@ -267,7 +341,7 @@ class PresetStore:
with self._lock:
for p in self._presets:
if normalize_iccid(p['iccid']) == norm:
return dict(p)
return copy.deepcopy(p)
return None
def add(self, fields):
@@ -281,31 +355,36 @@ class PresetStore:
self._presets.append(p)
self._save()
sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id']))
return dict(p)
return copy.deepcopy(p)
def update(self, pid, fields):
"""Partial or full update (the human edit path): the counter is written
as given - the Cards tab is the place to resync it deliberately. The
counter an *operation* accepted goes through set_counter()."""
"""Partial or full update (the human edit path): a counter in a keyset
is written as given - the Cards tab is the place to resync it
deliberately. The counter an *operation* accepted goes through
set_counter()."""
with self._lock:
cur = self._find(pid)
if cur is None:
return None
merged = dict(cur)
merged = copy.deepcopy(cur)
for k in PRESET_FIELDS:
if isinstance(fields, dict) and k in fields:
merged[k] = fields[k]
if isinstance(fields, dict) and 'keysets' in fields:
merged['keysets'] = fields['keysets']
new = self._normalise(merged)
new['id'] = cur['id']
self._validate(new, skip_id=cur['id'])
old_cntr = cur['cntr']
self._presets[self._presets.index(cur)] = new
self._save()
if new['cntr'] != old_cntr:
self._audit(new['id'], new['name'], old_cntr, new['cntr'], 'edit')
sys.stderr.write('PRESETS: counter %s %s -> %s (edit)\n'
% (new['name'], old_cntr, new['cntr']))
return dict(new)
for old_ks, new_ks in zip(cur['keysets'], new['keysets']):
if old_ks['cntr'] != new_ks['cntr']:
kvn = keyset_kvn(new_ks)
self._audit(new['id'], new['name'], old_ks['cntr'],
new_ks['cntr'], 'edit', kvn)
sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (edit)\n'
% (new['name'], kvn, old_ks['cntr'], new_ks['cntr']))
return copy.deepcopy(new)
def remove(self, pid):
with self._lock:
@@ -317,33 +396,65 @@ class PresetStore:
sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id']))
return True
def set_counter(self, pid, cntr, source='operation'):
"""Persist the counter an operation accepted. Monotonic: the counter
only moves forward, so a stale caller can never regress it. Returns
the stored preset (or None when the id is unknown)."""
def find_keyset(self, pid, kvn):
"""The keyset of a preset for a key version (the b8..b5 nibble of
KIc/KID), or None when the preset or the key version is unknown."""
with self._lock:
p = self._find(pid)
if p is None:
return None
for ks in p['keysets']:
if keyset_kvn(ks) == kvn:
return copy.deepcopy(ks)
return None
def set_counter(self, pid, cntr, source='operation', kvn=None):
"""Persist the counter an operation accepted, into the keyset of the
key version it used (each key version has its own dedicated counter,
TS 102 225 A.1). Monotonic per keyset: the counter only moves
forward, so a stale caller can never regress it. `kvn=None` picks the
preset's only keyset (a convenience for single-keyset callers).
Returns the stored preset (or None when the id is unknown)."""
with self._lock:
p = self._find(pid)
if p is None:
return None
ks = None
if kvn is not None:
for cand in p['keysets']:
if keyset_kvn(cand) == kvn:
ks = cand
break
if ks is None:
sys.stderr.write('PRESETS: no key version %s in %s (%s)\n'
% (kvn, p['name'], source))
return copy.deepcopy(p)
elif len(p['keysets']) == 1:
ks = p['keysets'][0]
else:
sys.stderr.write('PRESETS: %s has several key versions - the '
'counter needs one (%s)\n' % (p['name'], source))
return copy.deepcopy(p)
new = str(cntr or '').strip().upper()
if not re.fullmatch(r'[0-9A-F]{1,10}', new):
sys.stderr.write('PRESETS: ignoring malformed counter %r (%s)\n'
% (cntr, source))
return dict(p)
return copy.deepcopy(p)
new = counter_hex(new)
if new == p['cntr']:
return dict(p)
if not counter_ahead(p['cntr'], new):
sys.stderr.write('PRESETS: ignoring backwards counter %s -> %s (%s)\n'
% (p['cntr'], new, source))
return dict(p)
old = p['cntr']
p['cntr'] = new
if new == ks['cntr']:
return copy.deepcopy(p)
if not counter_ahead(ks['cntr'], new):
sys.stderr.write('PRESETS: ignoring backwards counter kvn=%02X '
'%s -> %s (%s)\n'
% (keyset_kvn(ks), ks['cntr'], new, source))
return copy.deepcopy(p)
old = ks['cntr']
ks['cntr'] = new
self._save()
self._audit(p['id'], p['name'], old, new, source)
sys.stderr.write('PRESETS: counter %s %s -> %s (%s)\n'
% (p['name'], old, new, source))
return dict(p)
self._audit(p['id'], p['name'], old, new, source, keyset_kvn(ks))
sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (%s)\n'
% (p['name'], keyset_kvn(ks), old, new, source))
return copy.deepcopy(p)
def import_presets(self, items, mode='merge'):
"""Import exported (or old localStorage) presets: entries are validated
+198 -35
View File
@@ -1419,20 +1419,62 @@ def _counter_valid(cntr):
return bool(re.fullmatch(r'[0-9A-Fa-f]{1,10}', str(cntr or '')))
def _preset_counter_persist(server, preset_id, cntr, source):
def _kvn_of(kic_hex, kid_hex):
"""The key version a packet uses: the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2). Returns ``(kvn, error)``: kvn is 0 when neither byte carries
one ('00' = no security, legal per A.2), error names a mismatch - A.2: the
versions shall be identical when different from 0, else the card rejects
the message - or a malformed byte."""
kic = str(kic_hex or '').strip().upper()
kid = str(kid_hex or '').strip().upper()
for name, value in (('KIc', kic), ('KID', kid)):
if value and not re.fullmatch(r'[0-9A-F]{2}', value):
return None, '%s must be one hex byte: %r' % (name, value)
kic_v = int(kic, 16) >> 4 if kic else 0
kid_v = int(kid, 16) >> 4 if kid else 0
if kic_v and kid_v and kic_v != kid_v:
return None, ('KIc and KID must carry the same key version '
'(TS 102 225 A.2): %s / %s' % (kic, kid))
return (kic_v or kid_v), None
def _preset_keyset_check(server, preset_id, kic, kid):
"""The "the key version must be defined in the preset" rule: an error
string when the packet's (non-zero) key version has no keyset in the named
preset, else None. Without a preset_id (a raw API caller) there is
nothing to check; '00'/'00' (no security) needs no keyset either."""
if not preset_id:
return None
kvn, err = _kvn_of(kic, kid)
if err:
return err
if not kvn:
return None
store = getattr(server, 'card_presets', None)
if store is None:
return None
if store.find_keyset(preset_id, kvn) is None:
preset = store.get(preset_id) or {}
return ('key version %d is not defined in preset %s - add it in the '
'Cards tab' % (kvn, preset.get('name') or preset_id))
return None
def _preset_counter_persist(server, preset_id, cntr, source, kvn=None):
"""Persist the counter an operation accepted into the server-side preset
store (v3.8.0). The counter is 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."""
store (v3.8.0), into the keyset of the key version it used (each key
version has its own dedicated counter, TS 102 225 A.1). The counter is
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:
return
store = getattr(server, 'card_presets', None)
if store is None:
return
try:
store.set_counter(preset_id, cntr, source)
store.set_counter(preset_id, cntr, source, kvn)
except Exception as e:
sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e))
@@ -1635,7 +1677,8 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
# crash mid-install cannot leave the preset behind the card (v3.8.0).
if step.get('por_status') in ('por_ok', 'no_por'):
_preset_counter_persist(server, state.get('preset_id'),
state['cntr'], 'ram-%s' % step_name.split()[0].lower())
state['cntr'], 'ram-%s' % step_name.split()[0].lower(),
state.get('kvn'))
if step_error:
if not silent:
state['failure']['error'] = '%s: %s' % (step_name, step_error)
@@ -5108,7 +5151,7 @@ _TEST_RUNNING = False
_TEST_RUN = {
'running': False, 'stop': False, 'name': None, 'status': None,
'session': None, 'index': 0, 'total': 0, 'steps': [],
'preset': None, 'scp80_counter': None,
'preset': None, 'scp80_counter': None, 'scp80_counters': {},
'started': None, 'finished': None, 'error': None,
}
_TEST_LOCK = threading.Lock()
@@ -5173,19 +5216,41 @@ def _increment_counter_hex(counter_hex):
return '%0*X' % (width, (int(c, 16) + 1) & ((1 << (4 * width)) - 1))
def _test_preset_error(script, preset):
"""The SCP80 steps need a complete card preset; steps may override
TAR/SPI1/SPI2 only (KIc/KID and the counter stay preset-owned)."""
def _preset_keysets(preset):
"""The keysets of a preset dict - a v3.8.0 flat preset (kic/kid/kicKey/
kidKey/counter) counts as a single keyset, so old data keeps working."""
preset = preset or {}
for key in ('kic', 'kid', 'kicKey', 'kidKey'):
if not preset.get(key):
return 'SCP80 preset is incomplete: %s is missing' % key
if not (preset.get('counter') or preset.get('cntr')):
return 'SCP80 preset has no counter'
keysets = [ks for ks in (preset.get('keysets') or []) if isinstance(ks, dict)]
if not keysets and (preset.get('kic') or preset.get('kid')):
keysets = [{'kic': preset.get('kic'), 'kid': preset.get('kid'),
'kicKey': preset.get('kicKey'), 'kidKey': preset.get('kidKey'),
'cntr': preset.get('counter') or preset.get('cntr') or ''}]
return keysets
def _test_preset_error(script, preset):
"""The SCP80 steps need at least one complete keyset in the card preset;
a step may override the keyset (kvn), the TAR, SPI1 and SPI2 only - the
KIc/KID keys and the counters stay preset-owned."""
preset = preset or {}
keysets = _preset_keysets(preset)
if not keysets:
return 'SCP80 preset is incomplete: no keyset is defined'
if not [ks for ks in keysets if _keyset_complete(ks)]:
return ('SCP80 preset is incomplete: a keyset needs KIc, KID, both keys '
'and a counter')
for i, step in enumerate(script['steps']):
if step['type'] != 'action' or step['kind'] != 'scp80':
continue
p = step['params']
if p.get('kvn') is not None:
want = int(p['kvn'])
ks = next((k for k in keysets if presets.keyset_kvn(k) == want), None)
if ks is None:
return 'step %d: key version %d is not defined in the preset' % (i + 1, want)
if not _keyset_complete(ks):
return ('step %d: key version %d is incomplete (KIc, KID, both '
'keys and a counter)' % (i + 1, want))
if not (p.get('tar') or preset.get('tar')):
return 'step %d: no TAR (neither in the step nor in the preset)' % (i + 1)
if not (p.get('spi1') or preset.get('spi1')) or not (p.get('spi2') or preset.get('spi2')):
@@ -5193,6 +5258,12 @@ def _test_preset_error(script, preset):
return None
def _keyset_complete(ks):
"""A usable keyset: both key bytes, both keys and a counter."""
return bool(ks.get('kic') and ks.get('kid') and ks.get('kicKey')
and ks.get('kidKey') and ks.get('cntr'))
def _test_check_result(label, ok, expected, actual, level, detail=None):
res = {'label': label, 'ok': bool(ok), 'expected': str(expected),
'actual': str(actual), 'level': level}
@@ -5308,17 +5379,40 @@ def _test_run_scp80(server, step, ctx):
scc = server.scc
preset = ctx['preset']
p = step['params']
# The keyset: a step may name a key version (`kvn`), otherwise the preset's
# first keyset is used (a preset carries one keyset per key version, each
# with its own keys and counter - TS 102 225 Annex A.1).
keysets = _preset_keysets(preset)
keyset = None
if p.get('kvn') is not None:
want = int(p['kvn'])
keyset = next((ks for ks in keysets if presets.keyset_kvn(ks) == want), None)
if keyset is None:
raise testscript.ScriptError('key version %d is not defined in the '
'selected card preset' % want)
elif keysets:
keyset = keysets[0]
kic = p.get('kic') or (keyset or {}).get('kic') or ''
kid = p.get('kid') or (keyset or {}).get('kid') or ''
kic_key = p.get('kicKey') or (keyset or {}).get('kicKey') or ''
kid_key = p.get('kidKey') or (keyset or {}).get('kidKey') or ''
kvn, kvn_err = _kvn_of(kic, kid)
if kvn_err:
raise testscript.ScriptError(kvn_err)
spi1 = p.get('spi1') or preset.get('spi1') or '16'
spi2 = p.get('spi2') or preset.get('spi2') or '01'
tar = p.get('tar') or preset.get('tar') or ''
counter = ctx['counter'] or '00000000'
counters = ctx.setdefault('counters', {})
if kvn and kvn not in counters:
counters[kvn] = (keyset or {}).get('cntr') or '00000000'
counter = counters.get(kvn) if kvn else (ctx.get('counter') or '00000000')
counter = counter or '00000000'
if p.get('sp'):
sp_hex = p['sp']
source = 'sp'
else:
sp_hex, _ = _build_secured_packet(spi1, spi2, preset.get('kic', ''), preset.get('kid', ''),
tar, counter, p['apdu'],
preset.get('kicKey', ''), preset.get('kidKey', ''))
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
p['apdu'], kic_key, kid_key)
source = 'apdu'
result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc,
handle_proactive=False)
@@ -5326,18 +5420,23 @@ def _test_run_scp80(server, step, ctx):
data = result.get('response_data') or ''
por = None
if data:
por = _decode_por(spi1, spi2, preset.get('kic', ''), preset.get('kid', ''),
counter, preset.get('kicKey', ''), preset.get('kidKey', ''), data)
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s' % (
source, tar or '-', spi1, spi2, counter)
por = _decode_por(spi1, spi2, kic, kid, counter, kic_key, kid_key, data)
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 and persist it into the server-side preset (v3.8.0 -
# the server owns the counter, so the run survives a lost response and
# the PWA's counter write-back dance is gone).
ctx['counter'] = _increment_counter_hex(counter)
_preset_counter_persist(server, (preset or {}).get('id'),
ctx['counter'], 'test-run')
# 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]
_preset_counter_persist(server, preset.get('id'), counters[kvn],
'test-run', kvn)
else:
ctx['counter'] = _increment_counter_hex(counter)
_preset_counter_persist(server, preset.get('id'), ctx['counter'], 'test-run')
return data, sw, sent, por, counter
@@ -5475,6 +5574,7 @@ def _test_run_execute(server, script, preset):
scc = server.scc
ctx = {'preset': dict(preset or {}),
'counter': str((preset or {}).get('counter') or (preset or {}).get('cntr') or '').upper(),
'counters': {},
'uses_scp80': any(s['type'] == 'action' and s['kind'] == 'scp80'
for s in script['steps'])}
if ctx['uses_scp80']:
@@ -5546,6 +5646,7 @@ def _test_run_execute(server, script, preset):
_TEST_RUN['finished'] = time.time()
if ctx['uses_scp80']:
_TEST_RUN['scp80_counter'] = ctx['counter']
_TEST_RUN['scp80_counters'] = dict(ctx.get('counters') or {})
if _TEST_RUN['status'] is None:
levels = [s.get('status') for s in _TEST_RUN['steps']]
if stopped:
@@ -5588,6 +5689,7 @@ def _test_run_start(server, script, preset):
'session': getattr(server, 'card_session', 0), 'index': 0,
'total': len(script['steps']), 'steps': [], 'started': time.time(),
'finished': None, 'error': None, 'scp80_counter': None,
'scp80_counters': {},
'preset': (preset or {}).get('name') or (preset or {}).get('iccid'),
})
_TEST_RUNNING = True
@@ -5653,6 +5755,19 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 500)
self._log_resp(resp)
def _keyset_guard(self, body, kic, kid):
"""The key-version checks shared by the RAM operations: the KIc/KID
versions must agree (TS 102 225 A.2 - the card rejects a mismatch) and a
named preset must define the key version ("the keyset must exist").
Returns ``(kvn, error)``: kvn is None when the packet carries no key
version ('00'/'00' = no security, legal per A.2), error an error string
the caller answers with 400."""
kvn, err = _kvn_of(kic, kid)
if err:
return None, err
err = _preset_keyset_check(self.server, body.get('preset_id'), kic, kid)
return (kvn or None), err
def _log_req(self, body=None):
if self.server.log_requests:
if body is not None:
@@ -6732,6 +6847,24 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
# The packet's key version (b8..b5 of KIc/KID, TS 102 225 A.2) must
# be consistent - a mismatch is invalid input (the card rejects it
# with "Unidentified security error") - and, when a preset is named,
# defined in it. Both are refused before anything is built (and
# before the format probe consumes a counter).
kvn, kvn_err = _kvn_of(body.get('kic', ''), body.get('kid', ''))
if kvn_err:
resp = {'error': kvn_err}
self._send_json(resp, 400)
self._log_resp(resp)
return
keyset_err = _preset_keyset_check(self.server, body.get('preset_id'),
body.get('kic', ''), body.get('kid', ''))
if keyset_err:
resp = {'error': keyset_err}
self._send_json(resp, 400)
self._log_resp(resp)
return
include_cpi = body.get('includeCpi', True)
ram_format = None
try:
@@ -6881,7 +7014,8 @@ class PysimHandler(BaseHTTPRequestHandler):
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')
resp.get('final_cntr'), 'send-ota',
kvn or None)
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -6947,6 +7081,16 @@ class PysimHandler(BaseHTTPRequestHandler):
make_selectable = body.get('make_selectable', True)
privileges_hex = body.get('privileges', '').replace(' ', '') or '00'
# The key version must be consistent and defined in the preset
# (TS 102 225 A.2 / the "keyset must exist" rule) - checked
# before the format probe consumes a counter.
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
# LOAD block size: the default 240-byte payload cannot be sent
# over SCP80 (pySim refuses a secured packet above one SMS).
# Fit it automatically, or honour an explicit override capped
@@ -6973,7 +7117,8 @@ class PysimHandler(BaseHTTPRequestHandler):
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id')}
'cntr': cntr, 'preset_id': body.get('preset_id'),
'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
@@ -7106,8 +7251,18 @@ class PysimHandler(BaseHTTPRequestHandler):
'tar': body.get('tar', '000000'), 'cntr': body.get('cntr', '00000000'),
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)}
# The key version must be consistent and defined in the preset
# (TS 102 225 A.2 / the "keyset must exist" rule) - checked
# before the format probe consumes a counter.
kvn, keyset_err = self._keyset_guard(body, sp_state['kic'], sp_state['kid'])
if keyset_err:
err = {'success': False, 'error': keyset_err}
self._send_json(err, 400)
self._log_resp(err)
return
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id')}
'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id'),
'kvn': kvn}
# Live progress for the PWA's operation modal (see /api/ram-install)
_ram_progress_begin('install-app', 2)
try:
@@ -7212,8 +7367,15 @@ class PysimHandler(BaseHTTPRequestHandler):
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
steps = []
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
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr, 'preset_id': body.get('preset_id')}
'cntr': cntr, 'preset_id': body.get('preset_id'),
'kvn': kvn}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
@@ -7385,6 +7547,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'running': False, 'stop': False, 'name': None, 'status': None,
'session': None, 'index': 0, 'total': 0, 'steps': [],
'preset': None, 'scp80_counter': None,
'scp80_counters': {},
'started': None, 'finished': None, 'error': None,
})
if busy: