Files
simple/pysim_simple_server/outfile.py
T
catarrh cdd3820064
release / release (push) Waiting to run
v3.32.1: "Start counter value" in the outfile import
The import created presets with the store's default counter (1).  Cards
whose counters are already past it had to be edited preset by preset
afterwards.

- The outfile preview form gains a "Start counter value" field (default
  1, the store's value).  The value is written into every created keyset
  of every imported card - parse_outfile's `start_counter`, 1-10 hex
  digits, whitespace stripped and uppercased; a bad value raises and the
  endpoint answers 400.  Without it the store's default still applies
  (the field is omitted), so the API keeps its old behaviour.
- The preview shows the counter; an import stores the protocol's
  10-digit form (counter_hex).
- Docs: api.md (the field), help EN/RU (the form).
- Version strings + the PWA cache bumped together (simple-v459).

Tests: the parser (every keyset, normalisation, unset, the bad values),
the endpoint (the preview's keysets, the 400, the stored padded form),
the PWA (the field's default, the controls' normalisation, both bodies).
1008 Python + 953 frontend green.
2026-10-21 13:20:28 +03:00

325 lines
15 KiB
Python

"""Import card presets from a card vendor's **outfile** (v3.27.0).
An outfile is the vendor personalization tool's plain-text dump: a long
banner/configuration header (customer, batch, profile, ...) followed by the
actual data. The data starts at a **column-name line** - whitespace-separated
tokens that include ``ICCID`` (the first token may carry a ``var_out:``-style
prefix) - and continues with one whitespace-separated row per card. The
header and every column the presets do not use are ignored.
Recognised columns (case-insensitive): ``ICCID``, ``PIN1``/``PIN2``,
``PUK1``/``PUK2``, ``ADM`` (the key's hex value - 4-16 hex digits in **byte
pairs**, the rule ``/api/verify-adm`` applies; this is the form the preset store
uses, where pySim pads it with 'f' to the 8 CHV bytes), ``PSK`` (the SCP81 key, 16 bytes; an
optional ``PSKID``/``PSK_ID`` column names its identity, defaulting to the
card's ICCID) and ``KICn``/``KIDn`` - one keyset per numbered pair, up to 15.
Everything else (``IMSI*``, ``EKI``, ``KIKn`` - the key-management key, no
use yet) is skipped.
The **KIc/KID bytes are never in the file**: the keyset number is the column
number (``KIC2`` -> keyset 2) and the algorithm nibble follows the key size -
8 bytes DES (1), 16 bytes 3DES2 (5) or AES (2, the caller's choice), 24 bytes
3DES3 (9); any other size skips that keyset with a report line. The KIc and
KID bytes carry the same number (TS 102 225 A.2).
The **TARs** never come from the file either: the caller passes the TAR table
(the three mandatory role entries with their spec defaults by default) and one
default keyset applied to every entry of every imported card; without one the
card's first keyset is used (a multi-keyset card would otherwise fail the
store's "a non-zero MSL needs a default keyset" rule).
``parse_outfile()`` is pure: it returns the preset dicts (the store's shape)
and a report. Nothing is validated here - the preset store's normalisation
and validation own that, and an entry it refuses is reported by the import.
"""
import re
from pysim_simple_server import presets
# The algorithm nibble of KIc/KID by key size (TS 102 225 A.2; the project's
# _CRYPT_ALGO): 8 bytes = DES, 16 = 3DES2 or AES (the caller picks), 24 =
# 3DES3. Anything else is refused with a report line.
_KEY_ALGO = {8: 0x01, 24: 0x09}
_KEY_ALGO_16 = {'3des2': 0x05, 'aes': 0x02}
# The 16-byte algorithm choices as the API accepts them (derived from the map
# above, so the endpoint's validation and the parser cannot drift apart).
ALGORITHM16_CHOICES = tuple(_KEY_ALGO_16)
KEY_SIZES = (8, 16, 24)
PSK_KEY_HEX_LEN = 32 # the store's PSK contract: 32 hex chars (16 bytes)
MAX_KEYSET = presets.KVN_MAX
SKIP_REPORT_MAX = 20 # the report names the first N skips, then counts
def _tokens(line):
"""The whitespace tokens of a line, with a leading ``word:`` prefix
stripped from the first token (``var_out:IMSI ICCID ...`` -> IMSI, ICCID).
A config line like ``ICCID:897...`` therefore yields a single value token
and can never be mistaken for the column line."""
parts = str(line or '').split()
if parts and ':' in parts[0]:
parts[0] = parts[0].split(':')[-1]
return [p for p in parts if p]
# A column name: an identifier (IMSI, ICCID, KIC1, PSK_ID, ...). The shape
# check keeps a configuration line that names a value next to a column
# (``ICCID 897...`` - the value is not an identifier) from being read as the
# column line.
_COLUMN_NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]*')
# The columns the presets use, beyond ICCID itself. A candidate header must
# carry at least one of them: a line with ICCID and nothing usable cannot
# yield a card (every keyset needs a KICn/KIDn pair), so it is not the header.
_FIXED_COLUMNS = ('ADM', 'PIN1', 'PIN2', 'PUK1', 'PUK2', 'PSK', 'PSKID', 'PSK_ID')
def _is_known_column(name):
"""A column the import consumes: a fixed name or a numbered KIC/KID."""
if name in _FIXED_COLUMNS:
return True
m = re.fullmatch(r'(?:KIC|KID)([0-9]{1,2})', name)
return bool(m) and 1 <= int(m.group(1)) <= MAX_KEYSET
def find_columns(text):
"""The column-name line of an outfile: the first line whose tokens all
look like column names, include ``ICCID`` and carry at least one other
column the import consumes (at least two tokens in total). Returns
``(columns, line_index)`` with the column names uppercased, or
``(None, -1)``."""
for i, line in enumerate(str(text or '').splitlines()):
toks = _tokens(line)
if len(toks) < 2 or not all(_COLUMN_NAME_RE.fullmatch(t) for t in toks):
continue
names = [t.upper() for t in toks]
if 'ICCID' not in names:
continue
if not any(n != 'ICCID' and _is_known_column(n) for n in names):
continue
return names, i
return None, -1
def _column_index(columns, name):
try:
return columns.index(name)
except ValueError:
return -1
def _keyset_columns(columns):
"""The numbered KIC/KID column pairs: ``{n: (kic_index, kid_index)}`` for
the numbers both sides carry."""
kic, kid = {}, {}
for i, c in enumerate(columns):
m = re.fullmatch(r'KIC([0-9]{1,2})', c)
if m and 1 <= int(m.group(1)) <= MAX_KEYSET:
kic[int(m.group(1))] = i
m = re.fullmatch(r'KID([0-9]{1,2})', c)
if m and 1 <= int(m.group(1)) <= MAX_KEYSET:
kid[int(m.group(1))] = i
return {n: (kic[n], kid[n]) for n in sorted(set(kic) & set(kid))}
def default_tars(tar_keyset=None):
"""The TAR table for the imported cards: the three mandatory role entries
with their spec defaults and MSL 16, each with the default keyset (None
leaves it to the store's single-keyset fill)."""
out = []
for role in presets.TAR_ROLES:
d = presets.TAR_ROLE_DEFAULTS[role]
out.append({'role': role, 'tar': d['tar'], 'msl': d['msl'],
'desc': '', 'keyset': tar_keyset})
return out
def normalise_tars(tars, tar_keyset=None):
"""The caller's TAR table (or the role defaults) cleaned: the roles kept,
the TAR/MSL uppercased, the global default keyset applied to every
entry. Raises ValueError on a malformed table (the caller answers 400)."""
entries = tars if isinstance(tars, list) and tars else default_tars()
out = []
for t in entries:
if not isinstance(t, dict):
raise ValueError('invalid TAR entry')
role = str(t.get('role') or '').strip()
tar = re.sub(r'\s', '', str(t.get('tar') or '')).upper()
msl = re.sub(r'\s', '', str(t.get('msl') or '')).upper()
if not re.fullmatch(r'[0-9A-F]{6}', tar):
raise ValueError('TAR must be three hex bytes: %r' % tar)
if not re.fullmatch(r'[0-9A-F]{2}', msl):
raise ValueError('MSL must be one hex byte: %r' % msl)
ks_raw = t.get('keyset')
if ks_raw in (None, ''):
keyset = None
else:
keyset = presets.normalise_keyset_ref(ks_raw)
if keyset is None:
raise ValueError('keyset must be 1-%02X: %r'
% (presets.KVN_MAX, ks_raw))
out.append({'role': role if role in presets.TAR_ROLES else '',
'tar': tar, 'msl': msl,
'desc': str(t.get('desc') or '').strip(),
'keyset': keyset or (tar_keyset or None)})
seen = set()
for t in out:
if t['tar'] in seen:
raise ValueError('duplicate TAR %s' % t['tar'])
seen.add(t['tar'])
return out
def _key_algo(size_bytes, algorithm16):
if size_bytes in _KEY_ALGO:
return _KEY_ALGO[size_bytes]
if size_bytes == 16:
return _KEY_ALGO_16.get(str(algorithm16 or '3des2').strip().lower(), 0x05)
return None
def _hex_value(tok):
"""A row token as an even-length uppercase hex string, or ''."""
v = re.sub(r'[^0-9A-Fa-f]', '', str(tok or '')).upper()
return v if v and len(v) % 2 == 0 else ''
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None,
start_counter=None):
"""Parse an outfile into preset dicts (the store's shape).
``start_counter`` (1-10 hex digits) is written into every created keyset -
the counter an operation against that keyset starts from; without it the
store's default ('1') applies. Returns
``{'presets': [...], 'report': {...}}``. Every skipped row or key is named
in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) - nothing is
silently dropped. Raises ValueError on a malformed TAR table or counter.
"""
counter = None
if start_counter not in (None, ''):
counter = re.sub(r'\s', '', str(start_counter)).upper()
if not re.fullmatch(r'[0-9A-F]{1,10}', counter):
raise ValueError('start_counter must be 1-10 hex digits: %r'
% (start_counter,))
columns, line_index = find_columns(text)
report = {'columns': columns or [], 'header_lines': max(line_index, 0),
'rows': 0, 'cards': 0, 'keysets': [], 'tars': 0,
'skipped': [], 'skipped_more': 0}
if columns is None:
report['error'] = ('no column-name line found - the data must start '
'with a whitespace-separated header containing ICCID')
return {'presets': [], 'report': report}
idx = {c: _column_index(columns, c)
for c in ('ICCID', 'ADM', 'PIN1', 'PIN2', 'PUK1', 'PUK2',
'PSK', 'PSKID', 'PSK_ID')}
if idx['PSKID'] < 0:
idx['PSKID'] = idx['PSK_ID']
pairs = _keyset_columns(columns)
report['keysets'] = ['KIC%d/KID%d' % (n, n) for n in pairs]
tars_base = normalise_tars(tars, tar_keyset)
report['tars'] = len(tars_base)
def skip(msg):
if len(report['skipped']) < SKIP_REPORT_MAX:
report['skipped'].append(msg)
else:
report['skipped_more'] += 1
presets_out = []
lines = str(text or '').splitlines()
for row_no, line in enumerate(lines[line_index + 1:], start=line_index + 2):
# The data rows split plainly: the ``word:`` prefix strip belongs to
# the header detection and must not touch a value.
toks = str(line).split()
if not toks:
continue
# The file is positional: an empty value drops its token, so a row whose
# token count differs from the header's column count carries an empty
# (or extra) value somewhere. The file is invalid then - it is never
# partially imported (v3.29.2).
if len(toks) != len(columns):
report['error'] = ('line %d has %d values, the header declares %d '
'columns - the file is invalid'
% (row_no, len(toks), len(columns)))
return {'presets': [], 'report': report}
report['rows'] += 1
iccid = presets.normalize_iccid(toks[idx['ICCID']]) if idx['ICCID'] >= 0 else ''
if not iccid:
skip('line %d: no ICCID' % row_no)
continue
keysets = []
for n, (ki, di) in pairs.items():
kic_key = _hex_value(toks[ki])
kid_key = _hex_value(toks[di])
if not kic_key or not kid_key:
# The header declares this keyset: every row must carry a usable
# value for it (v3.29.2)
report['error'] = ('line %d: keyset %d has no usable KIC/KID '
'value - the file is invalid' % (row_no, n))
return {'presets': [], 'report': report}
if len(kic_key) != len(kid_key):
skip('line %d: keyset %d KIC/KID sizes differ' % (row_no, n))
continue
algo = _key_algo(len(kic_key) // 2, algorithm16)
if algo is None:
skip('line %d: keyset %d has an unsupported key size (%d bytes)'
% (row_no, n, len(kic_key) // 2))
continue
byte = '%02X' % ((n << 4) | algo)
keyset = {'kic': byte, 'kid': byte,
'kicKey': kic_key, 'kidKey': kid_key}
if counter is not None:
keyset['cntr'] = counter
keysets.append(keyset)
if not keysets:
skip('line %d: no usable keyset' % row_no)
continue
# Every TAR gets the caller's global default keyset; without one the
# card's first keyset is used (a multi-keyset card would otherwise
# fail the store's "a non-zero MSL needs a default keyset" rule).
# A per-entry keyset (chosen in the preview) wins; the caller's global
# default - else the card's first keyset - fills the rest.
card_keyset = tar_keyset or presets.keyset_kvn(keysets[0])
card_tars = [dict(t, keyset=t.get('keyset') or card_keyset)
for t in tars_base]
entry = {'name': str(name or '').strip() or iccid, 'iccid': iccid,
'keysets': keysets, 'tars': card_tars}
for field, col in (('pin1', 'PIN1'), ('pin2', 'PIN2'),
('puk1', 'PUK1'), ('puk2', 'PUK2')):
i = idx[col]
raw = str(toks[i]).strip() if 0 <= i < len(toks) else ''
if not raw:
continue # the column is simply absent from this row
v = re.sub(r'\D', '', raw)
if re.fullmatch(r'[0-9]{4,8}', v):
entry[field] = v
else:
skip('line %d: %s %r is not 4-8 digits - dropped'
% (row_no, col, raw))
i = idx['ADM']
if 0 <= i < len(toks) and toks[i]:
# The ADM column carries the key's hex value itself (the length
# varies) - the form the preset store and /api/verify-adm use.
adm = re.sub(r'[^0-9A-Fa-f]', '', str(toks[i])).upper()
# Byte pairs, exactly like /api/verify-adm: an odd length is a half
# byte and the verifier would always refuse the stored value
# (v3.28.3).
if re.fullmatch(presets.ADM_PATTERN, adm):
entry['adm'] = adm
else:
skip('line %d: ADM is not 4-16 hex digits in byte pairs - dropped'
% row_no)
i = idx['PSK']
if 0 <= i < len(toks) and toks[i]:
psk = _hex_value(toks[i])
if len(psk) == PSK_KEY_HEX_LEN:
entry['pskKey'] = psk
pi = idx['PSKID']
ident = str(toks[pi]).strip() if 0 <= pi < len(toks) else ''
entry['pskIdentity'] = ident or iccid
else:
skip('line %d: PSK is not 16 bytes (32 hex) - dropped' % row_no)
presets_out.append(entry)
report['cards'] = len(presets_out)
return {'presets': presets_out, 'report': report}