diff --git a/docs/api.md b/docs/api.md index 452e584..12577f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -324,7 +324,13 @@ into a preview. - `text` (required) — the outfile contents. A file without a column line answers `{"ok": true, "preview": true, "presets": [], "report": {"error": "no column-name line found - …"}}` (a preview, not a `400`: the panel - shows the message). + shows the message). The same report error covers a structurally invalid + file (v3.29.2): a data row whose token count differs from the header's column + count (the format is positional, so an empty value drops its token - including + in the middle, which would otherwise shift the columns), or a + `KICn`/`KIDn` pair declared in the header without a usable hex value in some + row. An import (`mode`) of such a file answers `400` with that error - it is + never partially imported. - `name` — one common name applied to every imported card; empty falls back to the card's ICCID. - `algorithm16` — the algorithm nibble for a **16-byte** key: `"3des2"` diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 53e602c..4018589 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -371,7 +371,7 @@

Столбец SCP81 показывает, задана ли в пресете рабочая пара PSK: ✓ (зелёный) — идентификатор и ключ заданы; ⚠ (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; — — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка Добавить пресет открывает редактор с новым пресетом; Редактировать загружает пресет строки в редактор, поэтому поля можно менять без повторного ввода карты; Сохранить сохраняет и возвращает к списку, Отмена отменяет правки; Удалить удаляет строку (только в списке). Обмен пресетами: Экспорт в файл для выгрузки и Импорт из файла для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как warning); изменения сразу передаются работающему слушателю SCP81.

-

Импорт outfile загружает сразу много карт из outfile поставщика карт — текстового дампа инструмента персонализации: шапка с баннером и параметрами, затем разделённая пробелами строка колонок (среди токенов есть ICCID; первый токен может нести префикс вида var_out:) и по одной строке на карту. Всё до строки колонок и все неиспользуемые колонки игнорируются. Разбираются ICCID (цифры, цифры с добивочным F до чётного числа нибблов, цифры с разделителями или сырой hex EF — всё приводится к одной форме), PIN1/PIN2/PUK1/PUK2, ADM (hex-значение ключа, 4–16 hex-цифр (чётное число) — например 3632333239463337), PSK (16 байт; необязательная колонка PSKID/PSK_ID задаёт её идентификатор, по умолчанию берётся ICCID карты) и нумерованные пары ключей KICn/KIDn — по одному keyset на пару, до 15. Байтов KIc/KID в файле нет: номер keyset — это номер колонки (KIC2 → keyset 2), а ниббл алгоритма определяется размером ключа — 8 байт DES, 24 байта 3DES3, а для 16 байт — выбором в предпросмотре: 3DES2 (по умолчанию) или AES; любой другой размер пропускает этот keyset с пометкой. TAR также не берутся из файла: предпросмотр показывает таблицу TAR, которую получат импортируемые карты (по умолчанию три ролевые записи с TAR по спецификации и MSL 16; их можно править, кнопка + TAR добавляет записи), и у каждой строки свой keyset — список из найденных в файле keysets (первый выбран заранее; при ненулевом MSL keyset обязателен и помечается жёлтым), поэтому пакеты к каждому TAR используют именно выбранный keyset. Общее имя применяется ко всем импортируемым картам (пустое — берётся ICCID); режим импорта выбирает Дополнить (по умолчанию — существующие пресеты сохраняются, дубли ICCID пропускаются и перечисляются) или Заменить (хранилище сначала очищается). Предпросмотр перечисляет разобранные карты (ICCID, keysets, найденные коды) и каждую пропущенную строку/ключ с причиной — до того, как что-либо сохранено.

+

Импорт outfile загружает сразу много карт из outfile поставщика карт — текстового дампа инструмента персонализации: шапка с баннером и параметрами, затем разделённая пробелами строка колонок (среди токенов есть ICCID; первый токен может нести префикс вида var_out:) и по одной строке на карту. Всё до строки колонок и все неиспользуемые колонки игнорируются. Каждая строка данных должна содержать значение для каждой объявленной колонки — формат позиционный, поэтому пустое значение неотличимо от отсутствующего — а пара KICn/KIDn, объявленная в заголовке, должна иметь пригодное значение в каждой строке; нарушение делает весь файл некорректным (предпросмотр называет строку, импорт отклоняется). Разбираются ICCID (цифры, цифры с добивочным F до чётного числа нибблов, цифры с разделителями или сырой hex EF — всё приводится к одной форме), PIN1/PIN2/PUK1/PUK2, ADM (hex-значение ключа, 4–16 hex-цифр (чётное число) — например 3632333239463337), PSK (16 байт; необязательная колонка PSKID/PSK_ID задаёт её идентификатор, по умолчанию берётся ICCID карты) и нумерованные пары ключей KICn/KIDn — по одному keyset на пару, до 15. Байтов KIc/KID в файле нет: номер keyset — это номер колонки (KIC2 → keyset 2), а ниббл алгоритма определяется размером ключа — 8 байт DES, 24 байта 3DES3, а для 16 байт — выбором в предпросмотре: 3DES2 (по умолчанию) или AES; любой другой размер пропускает этот keyset с пометкой. TAR также не берутся из файла: предпросмотр показывает таблицу TAR, которую получат импортируемые карты (по умолчанию три ролевые записи с TAR по спецификации и MSL 16; их можно править, кнопка + TAR добавляет записи), и у каждой строки свой keyset — список из найденных в файле keysets (первый выбран заранее; при ненулевом MSL keyset обязателен и помечается жёлтым), поэтому пакеты к каждому TAR используют именно выбранный keyset. Общее имя применяется ко всем импортируемым картам (пустое — берётся ICCID); режим импорта выбирает Дополнить (по умолчанию — существующие пресеты сохраняются, дубли ICCID пропускаются и перечисляются) или Заменить (хранилище сначала очищается). Предпросмотр перечисляет разобранные карты (ICCID, keysets, найденные коды) и каждую пропущенную строку/ключ с причиной — до того, как что-либо сохранено.

diff --git a/frontend/help.html b/frontend/help.html index 6ae8742..6c7a32b 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -370,7 +370,7 @@

The SCP81 column shows whether the preset supplies a usable PSK pair: ✓ (green) — identity and key set; ⚠ (amber) — only one of the two, so the listener ignores the preset; — — no PSK. PSK identity and key must be set together. Add preset opens the editor with a fresh preset; Edit loads the row's preset into it so fields can be changed without re-entering the card; Save stores it and returns to the list, Cancel discards the edits; Remove deletes the row (list only). Presets can be shared with Export to file and restored with Import from file. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a warning too); edits are pushed into a running SCP81 listener automatically.

-

Import outfile imports many cards at once from a card vendor's outfile — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated column line (the tokens include ICCID; the first token may carry a var_out:-style prefix) and one row per card. Everything before the column line and every unused column is ignored. The parser reads ICCID (digits, the digits with an odd-nibble F pad, spaced digits or the raw EF hex — all normalised to one form), PIN1/PIN2/PUK1/PUK2, ADM (the key's hex value, 4–16 hex digits in byte pairs — e.g. 3632333239463337), PSK (16 bytes; an optional PSKID/PSK_ID names its identity, defaulting to the card's ICCID) and the numbered key pairs KICn/KIDn — one keyset per pair, up to 15. 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, 24 bytes 3DES3, and for 16 bytes the preview's choice, 3DES2 (default) or AES; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with + TAR for extra entries) and every row carries its own keyset — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. A common name is applied to every imported card (empty falls back to the ICCID); Import mode chooses Merge (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or Replace (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.

+

Import outfile imports many cards at once from a card vendor's outfile — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated column line (the tokens include ICCID; the first token may carry a var_out:-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for every declared column — the format is positional, so an empty value is indistinguishable from a missing one — and a KICn/KIDn pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads ICCID (digits, the digits with an odd-nibble F pad, spaced digits or the raw EF hex — all normalised to one form), PIN1/PIN2/PUK1/PUK2, ADM (the key's hex value, 4–16 hex digits in byte pairs — e.g. 3632333239463337), PSK (16 bytes; an optional PSKID/PSK_ID names its identity, defaulting to the card's ICCID) and the numbered key pairs KICn/KIDn — one keyset per pair, up to 15. 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, 24 bytes 3DES3, and for 16 bytes the preview's choice, 3DES2 (default) or AES; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with + TAR for extra entries) and every row carries its own keyset — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. A common name is applied to every imported card (empty falls back to the ICCID); Import mode chooses Merge (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or Replace (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.

diff --git a/frontend/index.html b/frontend/index.html index ed292bc..4060cd0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1928,7 +1928,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.29.1'; +const SIMPLE_VERSION = '3.29.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -15101,8 +15101,11 @@ function cardsOutfileRender() { html += '
' + cardsOutfileTarRowsHtml() + '
'; html += ''; html += cardsOutfileResultHtml(); + // A structurally invalid file cannot be imported (v3.29.2): the server + // refuses it, so the button stays disabled while the report has an error. + const fileErr = !!(o.preview && o.preview.report && o.preview.report.error); html += '
'; - html += ''; + html += ''; html += ''; html += '
'; panel.innerHTML = html; @@ -15129,6 +15132,11 @@ async function cardsOutfileImport() { const o = _cardsOutfile; if (!o) return; cardsOutfileControls(); + // A structurally invalid file is refused by the server (v3.29.2) + if (o.preview && o.preview.report && o.preview.report.error) { + alert(t('The file is invalid - fix it and refresh the preview')); + return; + } // Replace wipes the whole store - the destructive choice asks first if (o.mode === 'replace' && !confirm(t('Replace mode wipes the whole preset store. Continue?'))) return; const btn = document.getElementById('cards-of-import'); @@ -23638,6 +23646,7 @@ const LANG_RU = { 'Common name': 'Общее имя', '16-byte keys': '16-байтные ключи', 'This TAR needs a keyset (its MSL requires security)': 'Этому TAR нужен keyset (его MSL требует защиты)', + 'The file is invalid - fix it and refresh the preview': 'Файл некорректен — исправьте его и обновите предпросмотр', 'Import mode': 'Режим импорта', 'Merge': 'Дополнить', 'Replace': 'Заменить', diff --git a/frontend/sw.js b/frontend/sw.js index 8800981..12b24d4 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v448'; +const CACHE = 'simple-v449'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/cards_outfile.test.js b/frontend/tests/cards_outfile.test.js index 9faf8ff..132252b 100644 --- a/frontend/tests/cards_outfile.test.js +++ b/frontend/tests/cards_outfile.test.js @@ -310,6 +310,22 @@ test('a declined replace confirmation aborts the import', async () => { assert.strictEqual(app.calls.length, 1); }); +test('the Import button is disabled while the report has a file error (v3.29.2)', () => { + clearEls(); + setEl('cards-outfile-panel', ''); + app.state = { text: TEXT, file: 'bad.out', name: 'x', algorithm16: '3des2', mode: 'merge', + preview: { presets: [], report: { error: 'line 2 has 2 values, the header declares 3 columns - the file is invalid' } } }; + app.tars = 0; + app.cardsOutfileRender(); + assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" disabled/); + assert.match(els['cards-outfile-panel'].innerHTML, /the header declares 3 columns/); + // a valid report keeps it enabled + app.state.preview = { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }], + report: { columns: ['ICCID', 'KIC1', 'KID1'], rows: 1, cards: 1 } }; + app.cardsOutfileRender(); + assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" onclick/); +}); + test('a row keeps its chosen keyset through the DOM round-trip', () => { clearEls(); app.state = { text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge', diff --git a/pyproject.toml b/pyproject.toml index 930b14c..baea7d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.29.1" +version = "3.29.2" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/outfile.py b/pysim_simple_server/outfile.py index d99510e..8957d83 100644 --- a/pysim_simple_server/outfile.py +++ b/pysim_simple_server/outfile.py @@ -208,7 +208,6 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None 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: @@ -224,9 +223,15 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None toks = str(line).split() if not toks: continue - if len(toks) <= used_max: - skip('line %d: too few columns' % row_no) - 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: @@ -237,8 +242,11 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None 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 + # 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 diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 1a8bbf4..4befae5 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.29.1' +VERSION = '3.29.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -11360,6 +11360,13 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(resp) self._log_resp(resp) return + if parsed['report'].get('error'): + # A structurally invalid file (v3.29.2) is never partially + # imported - the preview shows the error, the import refuses. + resp = {'error': parsed['report']['error']} + self._send_json(resp, 400) + self._log_resp(resp) + return try: result = store.import_presets(parsed['presets'], mode) except OSError as e: diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index ee18964..f29a575 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -966,6 +966,19 @@ class PresetStoreHttpTests(unittest.TestCase): 'text': text, 'tars': [{'tar': '000000', 'msl': '16', 'keyset': 99}]}) self.assertEqual(status, 400, resp) self.assertIn('keyset', resp['error']) + # an invalid file (a row with a missing value, v3.29.2): the preview + # shows the error, the import refuses it - never a partial import + bad = ('ICCID KIC1 KID1\n' + '8970119000004600098F 00112233445566778899AABBCCDDEEFF\n') + status, resp = self._post('/api/presets/import-outfile', {'text': bad}) + self.assertEqual(status, 200, resp) + self.assertEqual(resp['presets'], []) + self.assertIn('the header declares 3 columns', resp['report']['error']) + before = len(self.store.list()) + status, resp = self._post('/api/presets/import-outfile', { + 'text': bad, 'mode': 'merge'}) + self.assertEqual(status, 400, resp) + self.assertIn('the file is invalid', resp['error']) + self.assertEqual(len(self.store.list()), before) # a non-list TAR table is the caller's error, never a silent fallback status, resp = self._post('/api/presets/import-outfile', { 'text': text, 'tars': 'nope'}) diff --git a/tests/test_outfile.py b/tests/test_outfile.py index 6458da4..f142704 100644 --- a/tests/test_outfile.py +++ b/tests/test_outfile.py @@ -244,19 +244,41 @@ class OptionTests(unittest.TestCase): self.assertNotIn('pskKey', res['presets'][0]) self.assertTrue(any('PSK is not 16 bytes' in s for s in res['report']['skipped'])) - def test_short_rows_and_bad_optional_codes_are_reported(self): - # a row shorter than the *used* columns (the keyset) is skipped + def test_a_row_with_a_missing_or_extra_value_is_a_file_error(self): + """The file is positional: an empty value drops its token, so a token + count other than the header's column count means an empty (or extra) + value - the whole file is refused (v3.29.2).""" + # a missing trailing value text = ('ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + '\n') res = outfile.parse_outfile(text) self.assertEqual(res['presets'], []) - self.assertTrue(any('too few columns' in s for s in res['report']['skipped'])) - # a missing optional column is simply absent, the card kept + self.assertIn('line 2 has 2 values, the header declares 3 columns', + res['report']['error']) + # ...a missing middle value (which used to shift the columns silently) + text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + '\n') + res = outfile.parse_outfile(text) + self.assertEqual(res['presets'], []) + self.assertIn('the file is invalid', res['report']['error']) + # ...and an extra value + text = ('ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' EXTRA\n') + res = outfile.parse_outfile(text) + self.assertEqual(res['presets'], []) + self.assertIn('4 values', res['report']['error']) + # a declared keyset without a usable value is a file error too + text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + + ' - ' + K16C + '\n') + res = outfile.parse_outfile(text) + self.assertEqual(res['presets'], []) + self.assertIn('keyset 2 has no usable KIC/KID value', res['report']['error']) + # a valid file keeps parsing (an exact count everywhere) text = ('ICCID KIC1 KID1 PIN1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + - ' 1234\n') + ' 1234 3632333239463337\n') res = outfile.parse_outfile(text) self.assertEqual(len(res['presets']), 1) self.assertEqual(res['presets'][0]['pin1'], '1234') - self.assertNotIn('adm', res['presets'][0]) + self.assertEqual(res['presets'][0]['adm'], '3632333239463337') + + def test_bad_optional_codes_are_reported_per_row(self): # the ADM column is the key's hex value, stored as-is (v3.28.2): the # length varies, and the value is never ASCII-decoded text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + @@ -314,9 +336,9 @@ class OptionTests(unittest.TestCase): res = outfile.parse_outfile(text) self.assertNotIn('puk1', res['presets'][0]) self.assertTrue(any("PUK1 'abcd'" in s for s in res['report']['skipped'])) - # ...while a column absent from a short row stays silent (not present) + # ...while an *empty* PUK1 token cannot exist in a whitespace-separated + # row: the count would change, which is a file error (v3.29.2) text = ('ICCID KIC1 KID1 PIN1 PUK1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' 1234\n') res = outfile.parse_outfile(text) - self.assertEqual(res['presets'][0]['pin1'], '1234') - self.assertNotIn('puk1', res['presets'][0]) - self.assertEqual([s for s in res['report']['skipped'] if 'PUK1' in s], []) + self.assertEqual(res['presets'], []) + self.assertIn('the file is invalid', res['report']['error'])