a66493f9b7
The profile rule editor rendered the same "FCI hex (raw SELECT response)" field for Exact and Custom, although the custom check never reads `fciHex` (it compares the data objects against the card's live FCI) - so the field looked like an expectation it was not. - The raw-FCI field and its decoded preview are exact-mode-only now; the Custom mode shows just its data-object editor. - Switching a rule to Custom seeds the data objects from its captured FCI when the list is empty (the same all-'value' seeding a captured profile gets); the "load from the captured FCI" link refills an emptied list, and a rule without a captured FCI still starts empty for manual rows. From the v3.28.3 review: the ADM's byte-pair rule now lives in one place, presets.ADM_PATTERN, used by /api/verify-adm and the outfile import - two literals could drift and an imported ADM could silently become unverifiable. The RU help's ADM wording is "4-16 hex-цифр (чётное число)". Docs: help EN/RU, the repo AGENTS. Tests: the mode switch (seed / keep / no FCI / switch-away), the field-gating source pin, the pattern's shape and the both-users pin. 917 frontend + 927 Python; sw cache simple-v442.
685 lines
30 KiB
Python
685 lines
30 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 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).
|
|
|
|
The optional **card codes** (`pin1`/`puk1`/`pin2`/`puk2`/`adm`) are stored for
|
|
later use by the card operations (nothing consumes them yet); the PIN/PUK codes
|
|
are 4-8 decimal digits, the ADM key is hex or ASCII digits.
|
|
|
|
The optional **CAT (USAT) release** (`release`, 4-18) names the release the
|
|
card implements: the release guard hides CAT items introduced after it (the
|
|
Simulator's event parameters, see `catrelease`). Empty means unspecified and
|
|
resolves to Rel-12 (`catrelease.DEFAULT_RELEASE`) - an unset release never
|
|
filters anything away.
|
|
|
|
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, 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
|
|
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 = 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,
|
|
# each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall
|
|
# be associated to each key version"); the TAR table lives in `tars` (see
|
|
# TAR_ROLES). The optional card codes (PIN/PUK/ADM) are stored for later use.
|
|
PRESET_FIELDS = ('name', 'iccid', 'release', 'pin1', 'puk1', 'pin2', 'puk2', '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', 'pskKey')
|
|
# The card codes: PIN/PUK are 4-8 decimal digits (validated in _validate),
|
|
# ADM is hex or ASCII digits (HEX_FIELDS).
|
|
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, 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'},
|
|
'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
|
|
# The ADM's shape: 4-16 hex digits in byte pairs (the verifier pads it with 'f'
|
|
# to the 8 CHV bytes, so an odd length or a longer value can never verify).
|
|
# Shared by /api/verify-adm and the outfile import so the two cannot drift.
|
|
ADM_PATTERN = r'(?:[0-9A-F]{2}){2,8}'
|
|
|
|
|
|
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, the digits with the odd-nibble
|
|
F padding, or the raw nibble-swapped EF hex.
|
|
|
|
The trailing F (or Fs) is the odd-nibble padding: when the rest is digits
|
|
only, the value is the plain digit form (the card vendors' outfiles print
|
|
it that way); otherwise - the rest carries A-F - it is the raw form and
|
|
the swap applies. A raw EF value never ends in F (its pad nibble lands in
|
|
the high half of the last byte), so the rule is unambiguous. Empty string
|
|
means "no ICCID"."""
|
|
s = re.sub(r'[\s-]', '', str(value or '')).upper()
|
|
if not s:
|
|
return ''
|
|
stripped = re.sub(r'F+$', '', s)
|
|
if re.search(r'[A-F]', stripped):
|
|
s = _swap_nibbles(re.sub(r'[^0-9A-F]', '', s))
|
|
s = re.sub(r'F+$', '', s)
|
|
else:
|
|
s = stripped
|
|
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))
|
|
|
|
|
|
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 '')
|
|
|
|
|
|
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, ...)."""
|
|
|
|
|
|
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()
|
|
elif k in DIGIT_FIELDS:
|
|
v = re.sub(r'\s', '', v)
|
|
out[k] = v
|
|
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')
|
|
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)]
|
|
# 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
|
|
|
|
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(),
|
|
'keyset': self._normalise_keyset_ref(t.get('keyset')),
|
|
})
|
|
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': '',
|
|
'keyset': None,
|
|
})
|
|
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': '',
|
|
'keyset': (cur or {}).get('keyset')})
|
|
for e in entries:
|
|
if e['role']:
|
|
continue
|
|
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'],
|
|
'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
|
|
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 p['release'] and (not re.fullmatch(r'[0-9]{1,2}', p['release'])
|
|
or not 4 <= int(p['release']) <= 18):
|
|
raise PresetError('release must be 4-18 (or empty for the Rel-12 default)')
|
|
for k in DIGIT_FIELDS:
|
|
if p[k] and not re.fullmatch(r'[0-9]{4,8}', p[k]):
|
|
raise PresetError('%s must be 4-8 decimal digits (or empty)'
|
|
% k.upper())
|
|
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)
|
|
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.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'])
|
|
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']
|
|
if isinstance(fields, dict) and 'tars' in fields:
|
|
if not isinstance(fields['tars'], list):
|
|
raise PresetError('tars must be a list of TAR entries')
|
|
merged['tars'] = fields['tars']
|
|
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}
|