Files
simple/pysim_simple_server/outfile.py
T
catarrh 21d7f58f61 v3.27.0 review: the outfile import hardening
- The endpoint validates algorithm16 (3des2/aes, case-insensitive) and mode
  (merge/replace or omitted): a misspelled value answers 400 instead of
  silently picking 3DES2 or turning an import into a dry run.  The choices
  come from outfile.ALGORITHM16_CHOICES, derived from the algorithm map, so
  the validation and the parser cannot drift apart.
- A present but unusable PIN/PUK value is reported with its raw text: a value
  that strips to nothing (e.g. 'abcd') used to vanish without a report line,
  contradicting the "nothing is silently dropped" contract.  A column absent
  from a short row stays silent - it is not present.
- The column-line detection drops the arbitrary 8-character column-name cap:
  a candidate header must carry ICCID plus at least one column the import
  consumes (a fixed name or a numbered KIC/KID), so a long unknown column
  name next to a real one parses while 'ICCID <value>' and
  'ICCID CUSTOMER_NAME' config lines are still rejected.  Both real samples
  are unaffected.
- PWA: a chosen default keyset survives an empty or failed preview (it used
  to reset to null, and the import silently fell back to the card's first
  keyset); the select renders the preview's keysets plus a remembered value.
- PWA: the destructive Replace mode asks for confirmation first, matching the
  other store-wiping actions.
- api.md documents both validations.  Tests: the endpoint's 400s and the
  replace import, the raw-value report, the header guards, the keyset
  preservation and the declined confirmation.  906 frontend + 904 Python;
  sw cache simple-v437.
2026-10-14 13:50:30 +03:00

294 lines
13 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 ASCII string, hex-encoded - the vendors print
``3632333239463337`` for ``62329F37``), ``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)
out.append({'role': role if role in presets.TAR_ROLES else '',
'tar': tar, 'msl': msl,
'desc': str(t.get('desc') or '').strip(),
'keyset': 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 _ascii_from_hex(v):
"""The ADM column: an ASCII string hex-encoded. '' when it is not."""
try:
return bytes.fromhex(v).decode('ascii')
except (ValueError, UnicodeDecodeError):
return ''
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None):
"""Parse an outfile into preset dicts (the store's shape).
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.
"""
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)
used_max = max([idx['ICCID']] + [i for pair in pairs.values() for i in pair])
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
if len(toks) <= used_max:
skip('line %d: too few columns' % row_no)
continue
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:
skip('line %d: keyset %d has no KIC/KID' % (row_no, n))
continue
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)
keysets.append({'kic': byte, 'kid': byte,
'kicKey': kic_key, 'kidKey': kid_key})
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).
card_keyset = tar_keyset or presets.keyset_kvn(keysets[0])
card_tars = [dict(t, keyset=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]:
adm = _ascii_from_hex(_hex_value(toks[i]))
if re.fullmatch(r'[0-9A-Fa-f]{4,16}', adm or ''):
entry['adm'] = adm.upper()
else:
skip('line %d: ADM is not an ASCII-hex key - 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}