Files
simple/pysim_simple_server/presets.py
T
catarrh 9f7876b5fa docs: help/README catch up with v3.8.0; keyset-number wording; RU "пресет"
Documentation:
- help (§1/§3): the SCP80 tab is switched by three pills - the tab list and the
  section intro now name the TAR probe (RU "TAR-сканер"); §3.1/§3.2 describe the
  Keyset selector next to the preset selector (and that a successful PoR
  advances the counter only when SPI1 carries a counter check); §4 describes
  the keyset editor rows, the derived number and the per-keyset counters; the
  probe section keeps its counter rule.
- README/README_RUS: the SCP80 tab says three pills and gains a "TAR probe" /
  "TAR-сканер" section (standard list, verdicts, counter persistence); the
  Cards section is rewritten for the server-side store - keysets, server-owned
  per-keyset counters with the audit trail, JSON export/import as the migration
  path, the keyset editor, the equipped-card mismatch banner - and the field
  table, "Add a card" and "Edit / remove" follow.

Terminology:
- "key version" is no longer used for SCP80 keysets in the UI, the server error
  strings and the docs: the selector is "Keyset", its options "Keyset 1 - ...",
  the messages say "keyset N ...".  The TS 102 225 A.1/A.2 quotes and the GP
  PUT KEY P1 field keep the spec's own wording.
- RU keeps the word "keyset" untranslated ('Добавить keyset', 'Номер keyset',
  "не является keyset пресета", ...) and uses "пресет" instead of
  "предустановка" throughout the dictionary, the help and README_RUS.

sw cache simple-v340.  Tests: 742 Node / 619 Python.
2026-09-30 23:48:32 +03:00

493 lines
20 KiB
Python

"""Server-side card presets: SCP80/SCP81 key material, counters and per-card
parameters, kept in one JSON file.
The presets used to live in the browser's localStorage and the PWA wrote the
SCP80 counter back after every operation. The counter is card state (the card
rejects a packet whose counter is not above its own), so the server - which
performs every card operation and already computes the accepted counter - now
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 **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"
(TS 102 225 Annex A.1, the spec's wording). The two bytes of a keyset must
carry the same number (A.2: the card rejects a mismatch with "Unidentified
security error"); number '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)::
~/.pysim-simple-server/card_presets.json
~/.pysim-simple-server/card_presets.json.audit.jsonl (counter changes)
Writes are atomic (temp file + fsync + ``os.replace``) and every mutation
holds an ``RLock``, so request handlers and the operation threads can share one
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). 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
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 2
# 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
# 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')
# 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 # keyset number '00' is reserved: no key set, no counter
def default_path():
"""The default store: ``~/.pysim-simple-server/card_presets.json``."""
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
def keyset_kvn(keyset):
"""The keyset number: 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)."""
out = []
for i in range(0, len(s), 2):
out.append(s[i + 1] if i + 1 < len(s) else 'F')
out.append(s[i])
return ''.join(out).upper()
def normalize_iccid(value):
"""ICCID -> plain digits, accepting every stored form (the PWA's
``cardsNormIccid``): digits, spaced digits, or the raw nibble-swapped EF
hex - the swap applies only when the value carries A-F, because a raw
value without hex letters is indistinguishable from digits. Empty string
means "no ICCID"."""
s = re.sub(r'[\s-]', '', str(value or '')).upper()
if not s:
return ''
if re.search(r'[A-F]', s):
s = _swap_nibbles(re.sub(r'[^0-9A-F]', '', s))
s = re.sub(r'F+$', '', s)
return re.sub(r'^0+', '', re.sub(r'\D', '', s))
def counter_value(cntr):
"""The counter as an int, or None when it is not hex."""
try:
return int(re.sub(r'[^0-9A-Fa-f]', '', str(cntr or '')), 16)
except ValueError:
return None
def counter_hex(value):
"""The fixed-width 10-hex-digit counter form the card protocol uses."""
v = counter_value(value)
if v is None:
return str(value or '').strip().upper()
return '%0*X' % (COUNTER_WIDTH, v % (1 << COUNTER_BITS))
def counter_ahead(old, new):
"""True when ``new`` is a plausible forward step from ``old``: the counter
only moves forward, and a value that wrapped around the 40-bit field
(top of the range -> small value) counts as forward."""
o, n = counter_value(old), counter_value(new)
if o is None or n is None:
return False
if n > o:
return True
return (o - n) > (1 << (COUNTER_BITS - 1))
class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...)."""
class PresetStore:
"""The card preset store. All public methods are thread-safe and return
copies - callers can never mutate the store's own state."""
def __init__(self, path=None):
self.path = Path(path) if path else default_path()
self.audit_path = self.path.with_name(self.path.name + '.audit.jsonl')
self._lock = threading.RLock()
self._presets = []
self._load()
# ------------------------------------------------------------------ disk
def _load(self):
try:
raw = self.path.read_text(encoding='utf-8')
except FileNotFoundError:
self._presets = []
return
except OSError as e:
sys.stderr.write('PRESETS: cannot read %s: %s\n' % (self.path, e))
self._presets = []
return
try:
data = json.loads(raw)
except ValueError as e:
# Never silently overwrite an unreadable store: keep the file for
# the user, start empty (the next save would destroy it).
bad = self.path.with_name(self.path.name + '.bad-%s'
% time.strftime('%Y%m%d-%H%M%S'))
try:
self.path.replace(bad)
sys.stderr.write('PRESETS: %s is not valid JSON (%s) - kept as %s, '
'starting empty\n' % (self.path, e, bad))
except OSError:
sys.stderr.write('PRESETS: %s is not valid JSON (%s)\n' % (self.path, e))
self._presets = []
return
items = data.get('presets') if isinstance(data, dict) else None
if not isinstance(items, list):
items = []
self._presets = [self._normalise(p) for p in items if isinstance(p, dict)]
def _save(self):
data = {'version': SCHEMA_VERSION, 'presets': self._presets}
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_name = tempfile.mkstemp(prefix=self.path.name + '.',
dir=str(self.path.parent))
try:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write('\n')
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_name, self.path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
try:
# Make the rename durable. Best effort: opening a directory is not
# portable (Windows) - a failure here never fails the mutation.
dir_fd = os.open(str(self.path.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def _audit(self, pid, name, old, new, source, kvn=None):
"""One JSONL line per counter change (the keyset number 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, '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:
fh.write(line + '\n')
except OSError as e:
sys.stderr.write('PRESETS: audit write failed: %s\n' % e)
# ------------------------------------------------------------- shaping
def _normalise(self, fields):
"""The stored shape: the known fields, cleaned; unknown keys dropped."""
out = {}
for k in PRESET_FIELDS:
v = fields.get(k)
v = '' if v is None else str(v).strip()
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
# 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')
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 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 keyset number %02X in this preset' % kvn)
seen.add(kvn)
norm = normalize_iccid(p['iccid'])
if norm:
for other in self._presets:
if other['id'] == skip_id:
continue
if normalize_iccid(other['iccid']) == norm:
raise PresetError('card with this ICCID already exists: %s'
% (other['name'] or other['id']))
return p
def _validate_keyset(self, ks):
"""One keyset: KIc/KID carry its number in b8..b5 and must agree
(TS 102 225 A.2 - a mismatch is rejected by the card), the number 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 keyset, 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 keyset number '
'(TS 102 225 A.2): %s / %s' % (ks['kic'], ks['kid']))
if not 1 <= kic_v <= KVN_MAX:
raise PresetError('keyset number 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:
return None
for p in self._presets:
if p['id'] == want:
return p
return None
# -------------------------------------------------------------- public
def list(self):
with self._lock:
return [copy.deepcopy(p) for p in self._presets]
def get(self, pid):
with self._lock:
p = self._find(pid)
return copy.deepcopy(p) if p else None
def find_by_iccid(self, iccid):
norm = normalize_iccid(iccid)
if not norm:
return None
with self._lock:
for p in self._presets:
if normalize_iccid(p['iccid']) == norm:
return copy.deepcopy(p)
return None
def add(self, fields):
with self._lock:
p = self._normalise(fields or {})
if self._find(p['id']):
# a caller-supplied id that is already taken never wins: ids
# must stay unique (the same guard the import uses)
p['id'] = uuid.uuid4().hex
self._validate(p)
self._presets.append(p)
self._save()
sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id']))
return copy.deepcopy(p)
def update(self, pid, fields):
"""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 = 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'])
self._presets[self._presets.index(cur)] = new
self._save()
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:
p = self._find(pid)
if p is None:
return False
self._presets.remove(p)
self._save()
sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id']))
return True
def find_keyset(self, pid, kvn):
"""The keyset of a preset with the given number (the b8..b5 nibble of
KIc/KID), or None when the preset or the number 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 it used
(each keyset 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 keyset %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 keysets - 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 copy.deepcopy(p)
new = counter_hex(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, 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
one by one; a duplicate ICCID or an already-used id is skipped, invalid
entries are reported instead of failing the whole import. ``replace``
wipes the store first."""
added, skipped, errors = 0, 0, []
with self._lock:
if mode == 'replace':
self._presets = []
for i, entry in enumerate(items or []):
if not isinstance(entry, dict):
skipped += 1
continue
try:
p = self._normalise(entry)
if self._find(p['id']):
p['id'] = uuid.uuid4().hex
self._validate(p)
except PresetError as e:
skipped += 1
errors.append('%s: %s' % (entry.get('name') or ('#' + str(i + 1)), e))
continue
self._presets.append(p)
added += 1
if added or mode == 'replace':
self._save()
sys.stderr.write('PRESETS: import %s: %d added, %d skipped\n'
% (mode, added, skipped))
return {'added': added, 'skipped': skipped, 'errors': errors[:10]}
def info(self):
with self._lock:
return {'path': str(self.path), 'count': len(self._presets),
'version': SCHEMA_VERSION}