v3.27.0: the outfile import

Import many card presets at once from a card vendor's plain-text dump.
The new pure pysim_simple_server/outfile.py (parse_outfile) plus
POST /api/presets/import-outfile: without `mode` the call is a dry-run
preview returning the parsed presets and a report (nothing is stored),
with `merge`/`replace` the same parse runs and the presets go through
the preset store.

The parser: the data starts at the first line whose whitespace tokens
all look like column names and include ICCID (a leading `word:` prefix
is stripped, so a `var_out:IMSI ICCID ...` header line matches while a
config line naming a value - `ICCID:897...` or `ICCID 897...` - cannot);
one row per card follows.  Recognised columns (case-insensitive): ICCID,
PIN1/PIN2, PUK1/PUK2, ADM (the ASCII string, hex-encoded), PSK (32 hex;
an optional PSKID/PSK_ID names the identity, defaulting to the card's
ICCID) and KICn/KIDn - one keyset per numbered pair, up to 15;
IMSI*/EKI/KIKn are ignored.  The KIc/KID bytes are inferred: the keyset
number is the column number, the algorithm nibble follows the key size
(8 B DES, 16 B 3DES2 or AES - the preview's choice, 3DES2 default,
24 B 3DES3; anything else skips the keyset with a note), both bytes
carrying the same number (TS 102 225 A.2).  The TARs never come from the
file: the caller's table (default the three role entries) is applied
with one global default keyset written into every entry (the card's
first keyset when unset).  An invalid optional code is dropped with a
report line instead of failing the card; the report names the columns,
the rows read, the parsed keysets and every skip.

The ICCID normaliser fix (both sides, drift-guarded): the trailing F
(or Fs) is the odd-nibble padding - when the rest is digits only it is
the plain digit form (the vendors print the ICCID as digits+F),
otherwise the value is the raw nibble-swapped EF hex; a raw value never
ends in F, so the rule is unambiguous.

PWA: the Import outfile button in the Cards list, the hidden file input
and the preview panel (the common name, the 16-byte algorithm select,
the TAR table with the role defaults editable and + TAR / remove for
extras, the global default keyset select over the parsed keysets, the
merge/replace mode, the parsed cards table, the report, Refresh
preview, Import).  The panel joins refreshDynamicI18n for the language
switch.

Docs: help EN/RU (the term outfile stays untranslated in RU), api.md's
new section, the repo AGENTS.  Tests: the parser (both fixture shapes,
the column detection, the key-size inference, the options/TAR table,
the PSK identity, the skips, the store round-trip), the endpoint (the
preview, the import, the duplicate skip, the 400s), the normaliser on
both sides, the PWA helpers/wiring and the algorithm16 drift guard
against outfile.py.  904 frontend + 903 Python; sw cache simple-v436.
This commit is contained in:
2026-10-14 13:37:27 +03:00
parent 8dfbb02c93
commit 212bfc24ac
14 changed files with 1298 additions and 11 deletions
+68 -2
View File
@@ -36,6 +36,7 @@ a 3.x PWA).
| `/api/presets/update` | POST | Update a preset (`{id, fields}`; the counter may be raised) |
| `/api/presets/delete` | POST | Delete a preset (`{id}`) |
| `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) |
| `/api/presets/import-outfile` | POST | Import a card vendor's **outfile** (`{text, name?, algorithm16?, tars?, tar_keyset?, mode?}`; without `mode` a dry-run preview) |
| `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) |
| `/api/counter-probe` | POST | Bounded counter sync: probe increasing counter values (start+1, +2, +4, …) with a read-only command until the card accepts one (ceiling/attempt-capped), then persist accepted+1 |
| `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) |
@@ -278,8 +279,9 @@ import also accepts presets exported by the older localStorage-based builds.
- `GET /api/presets` — `{"path": "…", "count": 2, "version": 5, "presets": [{…}]}`
(card-free: it answers while a long card operation runs).
- `POST /api/presets` — body = the preset fields (`keysets` included); returns
`{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF
hex are normalised to one form) is refused with
`{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced digits,
digits with the odd-nibble F pad, or raw EF hex — all normalised to one
form) is refused with
`400 {"error": "card with this ICCID already exists: …"}`.
- `POST /api/presets/update` — `{"id": "…", "fields": {…}}` (partial update;
a plain `{id, name, …}` body works too, `keysets` replaces the list and
@@ -291,6 +293,70 @@ import also accepts presets exported by the older localStorage-based builds.
`"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`;
invalid entries and duplicates are skipped and reported, never fatal.
### `POST /api/presets/import-outfile`
Import many cards at once from a card vendor's **outfile** (v3.27.0). An
outfile is the vendor personalization tool's plain-text dump: a banner and a
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;
everything before the column line and every column the presets do not use is
ignored.
Without a `mode` the call is a **dry run**: the parsed presets and the report
come back for the preview (nothing is stored). With `mode: "merge"` /
`"replace"` the same parse runs and the presets are imported through the
store (`replace` wipes the store first, like `/api/presets/import`).
```json
{"text": "…", "name": "Batch 0002", "algorithm16": "3des2",
"tars": [{"role": "isd", "tar": "000000", "msl": "16", "desc": ""}, …],
"tar_keyset": 1, "mode": "merge"}
```
- `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).
- `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"`
(default, KIc/KID nibble `5`) or `"aes"` (nibble `2`). The key size alone
decides the others: 8 bytes → DES (`1`), 24 bytes → 3DES3 (`9`); any other
size skips that keyset with a report line.
- `tars` — the TAR table every imported card gets (defaults to the three role
entries with their spec TARs and MSL 16). A malformed table (a non-3-byte
TAR, a non-1-byte MSL, a duplicate) is refused with `400`.
- `tar_keyset` — one **global default keyset** (1–15) written into every TAR
entry of every imported card. Without it 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).
Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F
pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all
normalise to the same value), `PIN1`/`PIN2`, `PUK1`/`PUK2` (4–8 digits; an
invalid value is dropped and reported, the card is kept), `ADM` (the ASCII
string, hex-encoded — `3632333239463337` → `62329F37`), `PSK` (the SCP81 key,
exactly 32 hex chars) and `KICn`/`KIDn` — one keyset per numbered 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, both bytes carrying the same number (TS 102 225 §A.2). A `PSKID` /
`PSK_ID` column names the PSK identity; without one the card's ICCID is used
(the identity the card sends in the ClientKeyExchange is ICCID-like). `IMSI*`,
`EKI` and `KIKn` (the key-management key, unused) are ignored.
The response carries the parsed presets (a dry run) or the import result
(`added`/`skipped`/`errors`) plus the `report`:
```json
{"ok": true, "preview": false, "added": 5, "skipped": 0, "errors": [],
"report": {"columns": ["IMSI", "ICCID", "KIC1", …], "header_lines": 11,
"rows": 5, "cards": 5, "keysets": ["KIC1/KID1", …], "tars": 3,
"skipped": ["line 4: keyset 2 has an unsupported key size (10 bytes)"],
"skipped_more": 0}}
```
### `POST /api/counter-probe`
Sync a preset's SCP80 counter with the card. The card accepts only a counter
+1
View File
@@ -368,6 +368,7 @@
</tbody>
</table>
<p class="text-sm mb-3">Столбец <strong>SCP81</strong> показывает, задана ли в пресете рабочая пара PSK: <strong>&#10003;</strong> (зелёный) — идентификатор и ключ заданы; <strong>&#9888;</strong> (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; <strong>&mdash;</strong> — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка <strong>Добавить пресет</strong> открывает редактор с новым пресетом; <strong>Редактировать</strong> загружает пресет строки в редактор, поэтому поля можно менять без повторного ввода карты; <strong>Сохранить</strong> сохраняет и возвращает к списку, <strong>Отмена</strong> отменяет правки; <strong>Удалить</strong> удаляет строку (только в списке). Обмен пресетами: <strong>Экспорт в файл</strong> для выгрузки и <strong>Импорт из файла</strong> для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как <code class="font-mono text-sm">warning</code>); изменения сразу передаются работающему слушателю SCP81.</p>
<p class="text-sm mb-3"><strong>Импорт outfile</strong> загружает сразу много карт из <em>outfile</em> поставщика карт &mdash; текстового дампа инструмента персонализации: шапка с баннером и параметрами, затем разделённая пробелами <strong>строка колонок</strong> (среди токенов есть <code class="font-mono text-sm">ICCID</code>; первый токен может нести префикс вида <code class="font-mono text-sm">var_out:</code>) и по одной строке на карту. Всё до строки колонок и все неиспользуемые колонки игнорируются. Разбираются <code class="font-mono text-sm">ICCID</code> (цифры, цифры с добивочным <code class="font-mono text-sm">F</code> до чётного числа нибблов, цифры с разделителями или сырой hex EF &mdash; всё приводится к одной форме), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (строка ASCII, закодированная hex &mdash; <code class="font-mono text-sm">3632333239463337</code> это <code class="font-mono text-sm">62329F37</code>), <code class="font-mono text-sm">PSK</code> (16 байт; необязательная колонка <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> задаёт её идентификатор, по умолчанию берётся ICCID карты) и нумерованные пары ключей <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> &mdash; по одному keyset на пару, до 15. <strong>Байтов KIc/KID в файле нет</strong>: номер keyset &mdash; это номер колонки (<code class="font-mono text-sm">KIC2</code> &rarr; keyset 2), а ниббл алгоритма определяется размером ключа &mdash; 8 байт DES, 24 байта 3DES3, а для <strong>16 байт</strong> &mdash; выбором в предпросмотре: <strong>3DES2</strong> (по умолчанию) или <strong>AES</strong>; любой другой размер пропускает этот keyset с пометкой. TAR также не берутся из файла: предпросмотр показывает таблицу TAR, которую получат импортируемые карты (по умолчанию три ролевые записи с TAR по спецификации и MSL 16; их можно править, кнопка <strong>+ TAR</strong> добавляет записи), и один <strong>keyset по умолчанию</strong>, применяемый ко всем записям TAR &mdash; он обязателен для карты с несколькими keysets (ненулевой MSL должен называть keyset). <strong>Общее имя</strong> применяется ко всем импортируемым картам (пустое &mdash; берётся ICCID); <strong>режим импорта</strong> выбирает <strong>Дополнить</strong> (по умолчанию &mdash; существующие пресеты сохраняются, дубли ICCID пропускаются и перечисляются) или <strong>Заменить</strong> (хранилище сначала очищается). Предпросмотр перечисляет разобранные карты (ICCID, keysets, найденные коды) и каждую пропущенную строку/ключ с причиной &mdash; до того, как что-либо сохранено.</p>
</section>
<section class="mb-10">
+1
View File
@@ -367,6 +367,7 @@
</tbody>
</table>
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>&#10003;</strong> (green) — identity and key set; <strong>&#9888;</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>&mdash;</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP &rarr; ISD TAR, SIM RFM &rarr; UICC RFM TAR, USIM RFM &rarr; 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 <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
<p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the ASCII string, hex-encoded — <code class="font-mono text-sm">3632333239463337</code> is <code class="font-mono text-sm">62329F37</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> &rarr; keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; 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 <strong>+ TAR</strong> for extra entries) and one <strong>default keyset</strong> applied to every TAR entry — required for a card with several keysets (a non-zero MSL must name a keyset). A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (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.</p>
</section>
<section class="mb-10">
+314 -2
View File
@@ -1613,6 +1613,7 @@
<button id="cards-new-btn" onclick="cardsNew()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add preset">Add preset</button>
<button onclick="cardsExportFile()" class="px-3 py-2 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Export to file">Export to file</button>
<button onclick="document.getElementById('cards-file').click()" class="px-3 py-2 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Import from file">Import from file</button>
<button onclick="cardsOutfilePick()" class="px-3 py-2 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Import outfile">Import outfile</button>
</div>
<div id="cards-list" class="overflow-x-auto">
<table class="w-full text-sm">
@@ -1628,7 +1629,9 @@
</table>
</div>
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
<input type="file" id="cards-outfile" accept=".out,.txt,text/plain" class="hidden" onchange="cardsOutfileFile(this)">
<div id="cards-io" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400"></div>
<div id="cards-outfile-panel" class="hidden mt-3 p-3 border border-gray-200 dark:border-slate-700 rounded"></div>
</div>
<!-- ===== preset editor ===== -->
@@ -1892,7 +1895,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.26.0';
const SIMPLE_VERSION = '3.27.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -14155,7 +14158,13 @@ function cardsRebuildSelect() {
function cardsNormIccid(v) {
let s = String(v == null ? '' : v).trim().toUpperCase().replace(/[\s-]/g, '');
if (!s) return '';
if (/[A-F]/.test(s)) s = decIccid(s); // raw EF content (nibble-swapped)
// The trailing F (or Fs) is the odd-nibble padding: when the rest is
// digits only the value is the plain digit form (the vendors' outfiles
// print it that way); otherwise it is the raw nibble-swapped EF content.
// A raw value never ends in F, so the rule is unambiguous (v3.27.0).
const stripped = s.replace(/F+$/, '');
if (/[A-F]/.test(stripped)) s = decIccid(s); // raw EF content (nibble-swapped)
else s = stripped;
return String(s || '').replace(/\D/g, '').replace(/^0+/, '');
}
@@ -14512,6 +14521,292 @@ function cardsImportFile(input) {
reader.readAsText(file);
}
// ===== outfile import (v3.27.0) =====
// A card vendor's outfile carries many cards (ICCID + KIc/KID keys + optional
// PIN/PUK/ADM/PSK). The server parses it (a dry run) and the panel below
// shows the preview plus the controls: the common name, the 16-byte key
// algorithm, the TAR table and its one global default keyset - the final call
// imports with a mode.
let _cardsOutfile = null; // {text, file, name, algorithm16, tarKeyset, mode, preview}
let _cardsOutfileTars = 0; // the free TAR rows of the panel
function cardsOutfilePick() {
const el = document.getElementById('cards-outfile');
if (!el) return;
el.value = '';
el.click();
}
async function cardsOutfileFile(input) {
const file = input && input.files && input.files[0];
if (input) input.value = '';
if (!file) return;
let text = '';
try {
text = await file.text();
} catch (e) {
alert(t('Cannot read the file') + ': ' + e.message);
return;
}
_cardsOutfile = { text: text, file: String(file.name || ''),
name: String(file.name || '').replace(/\.[^.]+$/, ''),
algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
_cardsOutfileTars = 0;
cardsOutfileRender();
await cardsOutfilePreview();
}
function cardsOutfileClose() {
_cardsOutfile = null;
_cardsOutfileTars = 0;
const panel = document.getElementById('cards-outfile-panel');
if (panel) {
panel.innerHTML = '';
panel.classList.add('hidden');
}
}
// Read the panel's controls back into the state (called before any re-render).
function cardsOutfileControls() {
const o = _cardsOutfile;
if (!o) return;
const val = id => { const el = document.getElementById(id); return el ? String(el.value) : ''; };
if (document.getElementById('cards-of-name')) o.name = val('cards-of-name').trim();
if (document.getElementById('cards-of-algo')) o.algorithm16 = val('cards-of-algo') || '3des2';
if (document.getElementById('cards-of-mode')) {
o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge';
}
const ks = parseInt(val('cards-of-keyset'), 10);
if (Number.isInteger(ks) && ks > 0) o.tarKeyset = ks;
}
// The TAR table as the panel shows it: the three role rows (their defaults
// when a field is empty) plus the free rows - the re-render source for
// Add/Remove, so a typed value is never lost.
function cardsOutfileTarsFromDom() {
const get = id => {
const el = document.getElementById(id);
return el ? String(el.value).trim() : '';
};
const out = CARDS_TAR_ROLES.map((role, i) => ({
role: role,
tar: get('cards-of-tar-' + i + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + i + '-msl').replace(/\s/g, '').toUpperCase(),
desc: '',
}));
for (let i = 0; i < _cardsOutfileTars; i++) {
const idx = CARDS_TAR_ROLES.length + i;
out.push({ tar: get('cards-of-tar-' + idx + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + idx + '-msl').replace(/\s/g, '').toUpperCase(),
desc: get('cards-of-tar-' + idx + '-desc') });
}
return out;
}
// The TAR table as the preview sends it: the role defaults fill an emptied
// field, a fully empty free row is dropped; every entry gets the global
// default keyset server-side.
function cardsOutfileTarsFromForm() {
const rows = cardsOutfileTarsFromDom();
const out = [];
for (let i = 0; i < rows.length; i++) {
const e = rows[i];
if (e.role) {
out.push({ role: e.role, tar: e.tar || cardsRoleDefault(e.role),
msl: e.msl || CARDS_MSL_DEFAULT, desc: '' });
} else if (e.tar || e.msl || e.desc) {
out.push(e);
}
}
return out;
}
function cardsOutfileTarRowHtml(e, i) {
const cls = 'border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-0.5 dark:bg-slate-800 font-mono';
const role = CARDS_TAR_ROLES[i] || '';
let html = '<div class="flex flex-wrap items-center gap-1.5 mb-1">';
html += '<input id="cards-of-tar-' + i + '-tar" class="w-24 ' + cls + '" maxlength="6" placeholder="TAR" value="' + escHtml(e.tar || '') + '">';
html += '<input id="cards-of-tar-' + i + '-msl" class="w-14 ' + cls + '" maxlength="2" placeholder="MSL" value="' + escHtml(e.msl || '') + '">';
if (role) {
html += '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(CARDS_TAR_ROLE_DESC[role] || role) + '</span>';
} else {
html += '<input id="cards-of-tar-' + i + '-desc" class="flex-1 min-w-24 ' + cls + '" placeholder="' + esc(t('Description (optional)')) + '" value="' + escHtml(e.desc || '') + '">';
html += '<button type="button" onclick="cardsOutfileTarRemove(' + i + ')" class="text-red-400 hover:text-red-600 text-xs px-1">\u2715</button>';
}
return html + '</div>';
}
function cardsOutfileTarRowsHtml() {
const rows = cardsOutfileTarsFromDom();
_cardsOutfileTars = Math.max(rows.length - CARDS_TAR_ROLES.length, 0);
const shown = CARDS_TAR_ROLES.map((role, i) => ({
role: role,
tar: rows[i].tar || cardsRoleDefault(role),
msl: rows[i].msl || CARDS_MSL_DEFAULT,
}));
for (let i = 0; i < _cardsOutfileTars; i++) shown.push(rows[CARDS_TAR_ROLES.length + i]);
return shown.map((e, i) => cardsOutfileTarRowHtml(e, i)).join('');
}
function cardsOutfileTarAdd() {
const rows = cardsOutfileTarsFromDom();
rows.push({ tar: '', msl: '', desc: '' });
cardsOutfileTarRender(rows);
}
function cardsOutfileTarRemove(i) {
if (i < CARDS_TAR_ROLES.length) return;
const rows = cardsOutfileTarsFromDom();
rows.splice(i, 1);
cardsOutfileTarRender(rows);
}
// A local re-render of the TAR rows only (the controls around them keep
// their values, so the preview is not re-run on every Add/Remove).
function cardsOutfileTarRender(rows) {
const box = document.getElementById('cards-of-tars');
if (!box) return;
_cardsOutfileTars = Math.max((rows || []).length - CARDS_TAR_ROLES.length, 0);
const shown = CARDS_TAR_ROLES.map((role, i) => ({
role: role,
tar: (rows && rows[i] && rows[i].tar) || cardsRoleDefault(role),
msl: (rows && rows[i] && rows[i].msl) || CARDS_MSL_DEFAULT,
}));
for (let i = 0; i < _cardsOutfileTars; i++) shown.push(rows[CARDS_TAR_ROLES.length + i]);
box.innerHTML = shown.map((e, i) => cardsOutfileTarRowHtml(e, i)).join('');
}
// The keyset numbers the parsed cards carry (the global default's options).
function cardsOutfileKeysets() {
const out = [];
(((_cardsOutfile || {}).preview || {}).presets || []).forEach(c => {
(c.keysets || []).forEach(ks => {
const v = spKeysetKvnOf(ks.kic);
if (v !== null && !out.includes(v)) out.push(v);
});
});
return out.sort((a, b) => a - b);
}
function cardsOutfileResultHtml() {
const p = (_cardsOutfile || {}).preview;
if (!p) return '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Parsing...')) + '</div>';
const rep = p.report || {};
let html = '<div class="mt-2 text-xs text-gray-600 dark:text-slate-400">';
if ((rep.columns || []).length) {
html += esc(t('Columns')) + ': <span class="font-mono">' + esc(rep.columns.join(' ')) + '</span><br>';
}
html += esc(t('Cards')) + ': ' + (rep.cards || 0) + ' / ' + esc(t('rows read')) + ': ' + (rep.rows || 0);
if ((rep.keysets || []).length) {
html += ' &middot; ' + esc(t('Keysets')) + ': <span class="font-mono">' + esc(rep.keysets.join(', ')) + '</span>';
}
html += '<br>';
if (rep.error) {
html += '<span class="text-red-600 dark:text-red-400">' + esc(rep.error) + '</span><br>';
}
if ((rep.skipped || []).length || rep.skipped_more) {
html += '<span class="text-amber-600 dark:text-amber-400">' + esc(t('skipped')) + ': ' +
esc((rep.skipped || []).join('; ')) +
(rep.skipped_more ? ' (+' + rep.skipped_more + ')' : '') + '</span>';
}
html += '</div>';
if ((p.presets || []).length) {
html += '<div class="mt-1 max-h-48 overflow-y-auto border border-gray-200 dark:border-slate-700 rounded"><table class="w-full text-xs">';
for (const c of p.presets) {
const ks = (c.keysets || []).map(k => esc(k.kic || '?') + '/' + esc(k.kid || '?')).join(' ');
const codes = [c.pin1 ? 'PIN1' : '', c.puk1 ? 'PUK1' : '',
c.adm ? 'ADM' : '', c.pskKey ? 'PSK' : ''].filter(Boolean).join(' ');
html += '<tr class="border-b border-gray-100 dark:border-slate-800">' +
'<td class="py-0.5 px-2 font-mono">' + esc(c.iccid) + '</td>' +
'<td class="py-0.5 px-2 font-mono text-gray-500 dark:text-slate-400">' + ks + '</td>' +
'<td class="py-0.5 px-2 text-gray-500 dark:text-slate-400">' + esc(codes) + '</td></tr>';
}
html += '</table></div>';
}
return html;
}
function cardsOutfileRender() {
const panel = document.getElementById('cards-outfile-panel');
const o = _cardsOutfile;
if (!panel || !o) return;
const keysets = cardsOutfileKeysets();
const selected = keysets.includes(o.tarKeyset) ? o.tarKeyset : (keysets[0] || null);
o.tarKeyset = selected;
let html = '<div class="flex items-center justify-between mb-2">';
html += '<span class="text-sm font-semibold">' + esc(t('outfile import')) +
': <span class="font-mono text-xs">' + esc(o.file || '') + '</span></span>';
html += '<button type="button" onclick="cardsOutfileClose()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">' + esc(t('Close')) + '</button>';
html += '</div>';
html += '<div class="flex flex-wrap items-end gap-3 mb-2">';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Common name')) +
'<input id="cards-of-name" class="block mt-0.5 w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" value="' + escHtml(o.name || '') + '"></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('16-byte keys')) +
'<select id="cards-of-algo" onchange="cardsOutfilePreview()" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="3des2"' + (o.algorithm16 === 'aes' ? '' : ' selected') + '>3DES2</option>' +
'<option value="aes"' + (o.algorithm16 === 'aes' ? ' selected' : '') + '>AES</option></select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Default keyset for the TARs')) +
'<select id="cards-of-keyset" onchange="cardsOutfilePreview()" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
(keysets.length ? keysets.map(v => '<option value="' + v + '"' + (v === selected ? ' selected' : '') + '>' + esc(t('Keyset') + ' ' + v) + '</option>').join('')
: '<option value="">\u2014</option>') +
'</select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Import mode')) +
'<select id="cards-of-mode" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
'<option value="merge"' + (o.mode === 'replace' ? '' : ' selected') + '>' + esc(t('Merge')) + '</option>' +
'<option value="replace"' + (o.mode === 'replace' ? ' selected' : '') + '>' + esc(t('Replace')) + '</option></select></label>';
html += '</div>';
html += '<div class="text-xs text-gray-600 dark:text-slate-400 mb-1">' + esc(t('TAR table')) + '</div>';
html += '<div id="cards-of-tars">' + cardsOutfileTarRowsHtml() + '</div>';
html += '<button type="button" onclick="cardsOutfileTarAdd()" class="mb-1 text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300">+ ' + esc(t('TAR')) + '</button>';
html += cardsOutfileResultHtml();
html += '<div class="flex gap-2 mt-2">';
html += '<button type="button" id="cards-of-import" onclick="cardsOutfileImport()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40">' + esc(t('Import')) + '</button>';
html += '<button type="button" onclick="cardsOutfilePreview()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">' + esc(t('Refresh preview')) + '</button>';
html += '</div>';
panel.innerHTML = html;
panel.classList.remove('hidden');
}
async function cardsOutfilePreview() {
const o = _cardsOutfile;
if (!o) return;
cardsOutfileControls();
const body = {
text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(),
};
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try {
o.preview = await pysimFetchOk('/api/presets/import-outfile', body);
} catch (e) {
o.preview = { report: { error: e.message } };
}
cardsOutfileRender();
}
async function cardsOutfileImport() {
const o = _cardsOutfile;
if (!o) return;
cardsOutfileControls();
const btn = document.getElementById('cards-of-import');
if (btn) btn.disabled = true;
const body = {
text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), mode: o.mode,
};
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try {
const res = await pysimFetchOk('/api/presets/import-outfile', body);
await cardsFetch();
ioStatus('cards-io', 'Imported ' + res.added + ' cards'
+ (res.skipped ? ' (' + res.skipped + ' skipped)' : '') + '.');
cardsOutfileClose();
} catch (e) {
alert('Import error: ' + e.message);
if (btn) btn.disabled = false;
}
}
// ===== pysim sub-tabs =====
let pysimCmdHistory = [];
let pysimCmdHistIdx = -1;
@@ -22975,6 +23270,21 @@ const LANG_RU = {
'Add': 'Добавить',
'Export to file': 'Экспорт в файл',
'Import from file': 'Импорт из файла',
'Import outfile': 'Импорт outfile',
'Cannot read the file': 'Не удалось прочитать файл',
'Parsing...': 'Разбор…',
'Columns': 'Колонки',
'rows read': 'прочитано строк',
'Keysets': 'Keysets',
'outfile import': 'Импорт outfile',
'Common name': 'Общее имя',
'16-byte keys': '16-байтные ключи',
'Default keyset for the TARs': 'Keyset по умолчанию для TAR',
'Import mode': 'Режим импорта',
'Merge': 'Дополнить',
'Replace': 'Заменить',
'TAR table': 'Таблица TAR',
'Refresh preview': 'Обновить предпросмотр',
'Name': 'Имя',
'ICCID': 'ICCID',
'Cntr': 'Сч',
@@ -24234,6 +24544,8 @@ function refreshDynamicI18n() {
if (isViewVisible('tab-cards')) {
cardsRender();
cardsSetFormMode();
// the outfile preview panel is built with t() strings too (v3.27.0)
if (_cardsOutfile) { cardsOutfileControls(); cardsOutfileRender(); }
// the editor's dynamic rows carry t() strings (placeholders, titles):
// rebuild them from what is typed, not from the defaults
if (isViewVisible('cards-editor-view')) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v435';
const CACHE = 'simple-v436';
const URLS = [
'index.html',
'help.html',
+9
View File
@@ -73,6 +73,15 @@ test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
assert.strictEqual(cardsNormIccid(null), '');
});
test('cardsNormIccid accepts the outfile digits+F form (v3.27.0)', () => {
// the vendors' outfiles print the ICCID as digits + an odd-nibble F pad -
// not the raw EF form (which never ends in F)
assert.strictEqual(cardsNormIccid(DIGITS + 'F'), DIGITS);
assert.strictEqual(cardsNormIccid(DIGITS + 'FF'), DIGITS);
assert.strictEqual(cardsNormIccid(DIGITS + 'f'), DIGITS);
assert.strictEqual(cardsNormIccid('FFFF'), '');
});
test('cardsFindByIccid matches regardless of the stored form', () => {
globalThis.cards = [
{ name: 'A', iccid: '1111111111111111111' },
+269
View File
@@ -0,0 +1,269 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImport'];
let code = '';
for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm',
'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd',
'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysets',
'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose',
'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview',
'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC')
+ 'var _cardsOutfile = null;\nvar _cardsOutfileTars = 0;\n' + code;
// A minimal DOM: the tests set the elements they need, everything else is
// absent (the code guards every getElementById result).
const els = {};
const app = new Function('els', `
var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var alert = m => { sandbox.alerts.push(String(m)); };
var ioStatus = (id, msg) => { sandbox.io.push([id, msg]); };
var pysimFetchOk = async (path, body) => {
sandbox.calls.push({ path: path, body: body });
if (sandbox.replyError) throw new Error(sandbox.replyError);
return sandbox.reply;
};
var cardsFetch = async () => { sandbox.fetched++; };
${code}
sandbox.getState = () => _cardsOutfile;
sandbox.setState = v => { _cardsOutfile = v; };
sandbox.getTars = () => _cardsOutfileTars;
sandbox.setTars = v => { _cardsOutfileTars = v; };
Object.defineProperty(sandbox, 'state', { get: () => _cardsOutfile, set: v => { _cardsOutfile = v; } });
Object.defineProperty(sandbox, 'tars', { get: () => _cardsOutfileTars, set: v => { _cardsOutfileTars = v; } });
sandbox.cardsOutfileTarsFromDom = cardsOutfileTarsFromDom;
sandbox.cardsOutfileTarsFromForm = cardsOutfileTarsFromForm;
sandbox.cardsOutfileTarRowHtml = cardsOutfileTarRowHtml;
sandbox.cardsOutfileTarRowsHtml = cardsOutfileTarRowsHtml;
sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd;
sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove;
sandbox.cardsOutfileTarRender = cardsOutfileTarRender;
sandbox.cardsOutfileKeysets = cardsOutfileKeysets;
sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml;
sandbox.cardsOutfileControls = cardsOutfileControls;
sandbox.cardsOutfileClose = cardsOutfileClose;
sandbox.cardsOutfileRender = cardsOutfileRender;
sandbox.cardsOutfileFile = cardsOutfileFile;
sandbox.cardsOutfilePreview = cardsOutfilePreview;
sandbox.cardsOutfileImport = cardsOutfileImport;
return sandbox;
`)(els);
app.state = null;
app.tars = 0;
function setEl(id, value) {
els[id] = { value: value, innerHTML: '', classList: { add() {}, remove() {} } };
}
function clearEls() {
for (const k of Object.keys(els)) delete els[k];
}
const TEXT = 'var_out:IMSI ICCID KIC1 KID1\n250197700000000 8970119000004600098F ' +
'00112233445566778899AABBCCDDEEFF FFEEDDCCBBAA99887766554433221100\n';
test('the Cards view wires the outfile button, the input and the panel', () => {
assert.match(html, /onclick="cardsOutfilePick\(\)"[^>]*data-l10n="Import outfile"/);
assert.match(html, /<input type="file" id="cards-outfile"[^>]*onchange="cardsOutfileFile\(this\)"/);
assert.match(html, /<div id="cards-outfile-panel"/);
assert.match(html, /'Import outfile': 'Импорт outfile'/); // the term stays untranslated
});
test('the preview and the import both post the outfile endpoint', () => {
const preview = extractFunc(html, 'cardsOutfilePreview', true);
const imp = extractFunc(html, 'cardsOutfileImport', true);
assert.match(preview, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /mode: o\.mode/);
// the keyset is only sent when one was resolved
assert.match(preview, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/);
assert.match(imp, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/);
});
test('the 16-byte algorithm values match the server map', () => {
const py = fs.readFileSync(
path.join(__dirname, '..', '..', 'pysim_simple_server', 'outfile.py'), 'utf8');
const m = /_KEY_ALGO_16\s*=\s*\{([^}]*)\}/.exec(py);
assert.ok(m, '_KEY_ALGO_16 not found in outfile.py');
const server = [...m[1].matchAll(/'([a-z0-9]+)'\s*:/g)].map(x => x[1]).sort();
const ui = [...html.matchAll(/<option value="(3des2|aes)"/g)].map(x => x[1]).sort();
assert.deepStrictEqual(ui, server);
});
test('cardsOutfileTarsFromForm fills the role defaults and keeps the free rows', () => {
clearEls();
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
app.tars = 0;
const tars = app.cardsOutfileTarsFromForm();
assert.deepStrictEqual(tars.map(e => e.role), ['isd', 'uiccRfm', 'usimRfm']);
assert.deepStrictEqual(tars.map(e => e.tar), ['000000', 'B00000', 'B00001']);
assert.deepStrictEqual(tars.map(e => e.msl), ['16', '16', '16']);
// a typed role value wins, a typed free row is kept, an empty one dropped
setEl('cards-of-tar-0-tar', 'af4d01');
setEl('cards-of-tar-0-msl', '0a');
app.tars = 2;
setEl('cards-of-tar-3-tar', 'b00011');
setEl('cards-of-tar-3-msl', '16');
setEl('cards-of-tar-3-desc', 'push');
const tars2 = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars2.length, 4);
assert.deepStrictEqual([tars2[0].tar, tars2[0].msl], ['AF4D01', '0A']);
assert.deepStrictEqual([tars2[3].tar, tars2[3].msl, tars2[3].desc], ['B00011', '16', 'push']);
assert.strictEqual(tars2[3].role, undefined);
});
test('the TAR rows render the role ids and the free rows with a remove button', () => {
clearEls();
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
app.tars = 0;
const rows = app.cardsOutfileTarRowsHtml();
assert.match(rows, /id="cards-of-tar-0-tar"[^>]*value="000000"/);
assert.match(rows, /id="cards-of-tar-2-msl"[^>]*value="16"/);
assert.match(rows, /ISD compact format/);
assert.ok(!rows.includes('cards-of-tar-3-tar'));
// a free row carries the description input and the remove action
app.tars = 1;
const rows2 = app.cardsOutfileTarRowsHtml();
assert.match(rows2, /id="cards-of-tar-3-tar"/);
assert.match(rows2, /id="cards-of-tar-3-desc"/);
assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/);
});
test('cardsOutfileKeysets collects the parsed keyset numbers', () => {
clearEls();
app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
preview: { presets: [
{ keysets: [{ kic: '25' }, { kic: '15' }] },
{ keysets: [{ kic: '15' }, { kic: '31' }] },
] },
};
assert.deepStrictEqual(app.cardsOutfileKeysets(), [1, 2, 3]);
app.state.preview = { presets: [] };
assert.deepStrictEqual(app.cardsOutfileKeysets(), []);
});
test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
clearEls();
app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
preview: {
presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }], adm: '62329F37' }],
report: { columns: ['IMSI', 'ICCID', 'KIC1', 'KID1'], rows: 2, cards: 1,
keysets: ['KIC1/KID1'], skipped: ['line 3: no ICCID'], skipped_more: 1 },
},
};
const out = app.cardsOutfileResultHtml();
assert.match(out, /Columns: <span class="font-mono">IMSI ICCID KIC1 KID1<\/span>/);
assert.match(out, /Cards: 1 \/ rows read: 2/);
assert.match(out, /Keysets: <span class="font-mono">KIC1\/KID1<\/span>/);
assert.match(out, /line 3: no ICCID/);
assert.match(out, /\(\+1\)/);
assert.match(out, /8970119000004600098/);
assert.match(out, /15\/15/);
assert.match(out, /ADM/);
// an empty preview says so, an error is shown
app.state.preview = null;
assert.match(app.cardsOutfileResultHtml(), /Parsing\.\.\./);
app.state.preview = { report: { error: 'no column-name line found' } };
assert.match(app.cardsOutfileResultHtml(), /no column-name line found/);
});
test('cardsOutfileControls reads the panel controls back into the state', () => {
clearEls();
app.state = { text: TEXT, name: 'old', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
setEl('cards-of-name', ' Batch 7 ');
setEl('cards-of-algo', 'aes');
setEl('cards-of-mode', 'replace');
setEl('cards-of-keyset', '2');
app.cardsOutfileControls();
assert.strictEqual(app.state.name, 'Batch 7');
assert.strictEqual(app.state.algorithm16, 'aes');
assert.strictEqual(app.state.mode, 'replace');
assert.strictEqual(app.state.tarKeyset, 2);
});
test('picking a file previews it with the file name and the TAR defaults', async () => {
clearEls();
app.calls = [];
app.reply = { preview: true, presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
report: { columns: ['ICCID'], rows: 1, cards: 1 } };
setEl('cards-outfile-panel', '');
const input = { value: 'C:\\fakepath\\batch.out', files: [{ name: 'batch.out', text: async () => TEXT }] };
await app.cardsOutfileFile(input);
assert.strictEqual(input.value, '');
assert.strictEqual(app.calls.length, 1);
assert.strictEqual(app.calls[0].path, '/api/presets/import-outfile');
const body = app.calls[0].body;
assert.strictEqual(body.text, TEXT);
assert.strictEqual(body.name, 'batch'); // the file base name
assert.strictEqual(body.algorithm16, '3des2');
assert.strictEqual(body.mode, undefined); // the preview is a dry run
assert.strictEqual(body.tar_keyset, undefined); // none resolved yet
assert.strictEqual(body.tars.length, 3);
assert.strictEqual(app.state.tarKeyset, 1); // the first parsed keyset
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
});
test('a failing preview shows the server message in the panel', async () => {
clearEls();
app.calls = [];
app.replyError = 'TAR must be three hex bytes: ZZ';
setEl('cards-outfile-panel', '');
const input = { value: '', files: [{ name: 'bad.out', text: async () => TEXT }] };
await app.cardsOutfileFile(input);
app.replyError = null;
assert.match(app.cardsOutfileResultHtml(), /TAR must be three hex bytes/);
});
test('the import posts the chosen mode and refreshes the list', async () => {
clearEls();
app.calls = [];
app.fetched = 0;
app.io = [];
app.reply = { ok: true, preview: false, added: 2, skipped: 1, errors: ['dup'] };
setEl('cards-outfile-panel', '');
setEl('cards-of-mode', 'replace');
setEl('cards-of-name', 'Batch 7');
app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2',
tarKeyset: 2, mode: 'merge', preview: { presets: [], report: {} } };
await app.cardsOutfileImport();
assert.strictEqual(app.calls.length, 1);
assert.strictEqual(app.calls[0].body.mode, 'replace');
assert.strictEqual(app.calls[0].body.tar_keyset, 2);
assert.strictEqual(app.calls[0].body.name, 'Batch 7');
assert.strictEqual(app.fetched, 1);
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
assert.strictEqual(app.state, null); // the panel closed
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.26.0"
version = "3.27.0"
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.
+270
View File
@@ -0,0 +1,270 @@
"""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}
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: a short identifier (IMSI, ICCID, KIC1, PSK_ID, ...). The
# check keeps a configuration line that names a value next to a column
# (``ICCID 897...`` - the value token is not an identifier) from being read as
# the column line.
_COLUMN_NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,7}')
def find_columns(text):
"""The column-name line of an outfile: the first line whose tokens all
look like column names and include ``ICCID`` (at least two tokens).
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 and any(t.upper() == 'ICCID' for t in toks)
and all(_COLUMN_NAME_RE.fullmatch(t) for t in toks)):
return [t.upper() for t in toks], 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]
v = re.sub(r'\D', '', str(toks[i])) if 0 <= i < len(toks) else ''
if not v:
continue
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, v))
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}
+12 -4
View File
@@ -132,16 +132,24 @@ def _swap_nibbles(s):
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
``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 ''
if re.search(r'[A-F]', s):
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))
+60 -1
View File
@@ -23,6 +23,7 @@ from pysim_simple_server import esim
from pysim_simple_server import capmem
from pysim_simple_server import testscript
from pysim_simple_server import presets
from pysim_simple_server import outfile
from pysim_simple_server import test_scripts
from pysim_simple_server import test_suites
from pysim_simple_server import events
@@ -36,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.26.0'
VERSION = '3.27.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -10844,6 +10845,64 @@ class PysimHandler(BaseHTTPRequestHandler):
resp['ok'] = True
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets/import-outfile':
# Card vendors' outfile import (v3.27.0): without `mode` this is a
# dry run - the parsed presets and the report come back for the
# preview (the UI picks the common name, the 16-byte algorithm,
# the TAR table and its global default keyset first); with `mode`
# the same parse runs and the presets are imported.
body = self._read_body()
self._log_req(body)
store = self._preset_store_or_503()
if store is None:
return
text = body.get('text')
if not isinstance(text, str) or not text.strip():
resp = {'error': 'text is required (the outfile contents)'}
self._send_json(resp, 400)
self._log_resp(resp)
return
tar_keyset = body.get('tar_keyset')
if tar_keyset in (None, ''):
tar_keyset = None
else:
try:
tar_keyset = int(tar_keyset)
except (TypeError, ValueError):
tar_keyset = -1
if not 1 <= tar_keyset <= presets.KVN_MAX:
resp = {'error': 'tar_keyset must be 1-%02X' % presets.KVN_MAX}
self._send_json(resp, 400)
self._log_resp(resp)
return
try:
parsed = outfile.parse_outfile(
text,
name=str(body.get('name') or ''),
algorithm16=str(body.get('algorithm16') or '3des2'),
tars=body.get('tars'),
tar_keyset=tar_keyset)
except ValueError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
mode = body.get('mode')
if mode not in ('merge', 'replace'):
resp = {'ok': True, 'preview': True,
'presets': parsed['presets'], 'report': parsed['report']}
self._send_json(resp)
self._log_resp(resp)
return
try:
result = store.import_presets(parsed['presets'], mode)
except OSError as e:
self._preset_store_error(e)
return
resp = {'ok': True, 'preview': False, 'report': parsed['report']}
resp.update(result)
self._send_json(resp)
self._log_resp(resp)
else:
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})
+53
View File
@@ -903,6 +903,59 @@ class PresetStoreHttpTests(unittest.TestCase):
status, resp = self._post('/api/presets/import', {'presets': 'nope'})
self.assertEqual(status, 400, resp)
def test_the_outfile_import_previews_and_imports(self):
text = ('var_out:IMSI ICCID KIC1 KID1\n'
'250197700000000 8970119000004600098F 00112233445566778899AABBCCDDEEFF '
'FFEEDDCCBBAA99887766554433221100\n')
# the dry run parses and saves nothing
status, resp = self._post('/api/presets/import-outfile', {'text': text})
self.assertEqual(status, 200, resp)
self.assertTrue(resp['preview'])
self.assertEqual(len(resp['presets']), 1)
self.assertEqual(resp['presets'][0]['iccid'], '8970119000004600098')
self.assertEqual(self.store.list(), [])
# the options: the common name, the 16-byte algorithm, the TAR table
# and its global default keyset
tars = [{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'B00011', 'msl': '0A', 'desc': 'push'}]
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'name': 'Batch 1', 'algorithm16': 'aes',
'tars': tars, 'tar_keyset': 1})
self.assertEqual(status, 200, resp)
p = resp['presets'][0]
self.assertEqual(p['name'], 'Batch 1')
self.assertEqual(p['keysets'][0]['kic'], '12') # the AES nibble
self.assertEqual(len(p['tars']), 4)
self.assertTrue(all(t['keyset'] == 1 for t in p['tars']))
# the import itself, then a duplicate ICCID is skipped and reported
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'name': 'Batch 1', 'mode': 'merge'})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['added'], 1)
self.assertFalse(resp['preview'])
self.assertEqual(len(self.store.list()), 1)
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'name': 'Batch 1', 'mode': 'merge'})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['added'], 0)
self.assertEqual(resp['skipped'], 1)
self.assertTrue(resp['errors'])
# bad input: no text, a file without the column line, a malformed TAR
# table and an out-of-range keyset number
status, resp = self._post('/api/presets/import-outfile', {})
self.assertEqual(status, 400, resp)
status, resp = self._post('/api/presets/import-outfile', {'text': 'banner only'})
self.assertEqual(status, 200, resp)
self.assertIn('column-name line', resp['report']['error'])
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tars': [{'tar': 'ZZ', 'msl': '16'}]})
self.assertEqual(status, 400, resp)
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tar_keyset': 99})
self.assertEqual(status, 400, resp)
def test_the_counter_an_operation_accepted_is_persisted(self):
p = self.store.add(self._preset(iccid='8970119000004600098'))
patches = [
+231
View File
@@ -0,0 +1,231 @@
"""Outfile import tests (v3.27.0).
The vendors' outfiles carry real key material, so the fixtures here are
**synthetic** files shaped like the two observed layouts: the ALFA style
(a ``var_out:`` column line, three keysets, no codes) and the BAP style (a
long banner/configuration header, the PIN/PUK/ADM columns and the EKI/KIK
columns that must be ignored).
"""
import pathlib
import tempfile
import unittest
from pysim_simple_server import outfile, presets
DIGITS = '8970119000004600098'
DIGITS_F = DIGITS + 'F' # the vendors' odd-nibble F padding
# 16-byte synthetic keys (3DES2 by default)
K16A = '00112233445566778899AABBCCDDEEFF'
K16B = 'FFEEDDCCBBAA99887766554433221100'
K16C = '0123456789ABCDEFFEDCBA9876543210'
def alfa_text():
return '\n'.join([
'var_out:IMSI ICCID KIC1 KID1 KIK1 KIC2 KID2 KIK2',
'250197700000000 ' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' ' + K16A +
' ' + K16C + ' ' + K16B + ' ' + K16A,
'250197700000001 8970119000004600106F ' + K16B + ' ' + K16A + ' ' + K16B +
' ' + K16A + ' ' + K16C + ' ' + K16B,
]) + '\n'
def bap_text():
return '\n'.join([
'********************************',
'* HEADER DESCRIPTION *',
'********************************',
'Customer: Synthetic',
'Batch: 0002',
'TransportKey: 004',
'ICCID:897011900000460005',
'',
'*******************************',
'* OUTPUT VARIABLES *',
'*******************************',
'IMSI1 IMSI2 ICCID EKI PUK1 PUK2 PIN1 PIN2 ADM KIC1 KID1 KIK1',
'250199800390005 FFFFFFFFFFFFFFF ' + DIGITS_F + ' ' + K16A +
' 12345678 87654321 1234 4321 3632333239463337 ' +
K16A + ' ' + K16B + ' ' + K16C,
]) + '\n'
class ColumnDetectionTests(unittest.TestCase):
def test_find_columns_skips_the_header(self):
cols, i = outfile.find_columns(alfa_text())
self.assertEqual(i, 0)
self.assertEqual(cols[:2], ['IMSI', 'ICCID'])
cols, i = outfile.find_columns(bap_text())
self.assertEqual(cols[0], 'IMSI1')
self.assertEqual(cols.index('ICCID'), 2)
# the config line ICCID:897... is a single token and never matches:
# the column line sits after it (line 6 in the fixture)
config_line = bap_text().splitlines().index('ICCID:897011900000460005')
self.assertGreater(i, config_line)
def test_a_file_without_the_column_line_reports_it(self):
res = outfile.parse_outfile('just a banner\nno columns here\n')
self.assertEqual(res['presets'], [])
self.assertIn('column-name line', res['report']['error'])
def test_a_config_line_naming_a_value_is_not_the_column_line(self):
# 'ICCID <value>' names a value, not the column header: the value
# token is not a column name, so the real column line is found
text = ('ICCID 897011900000460005\n'
'ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + '\n')
cols, i = outfile.find_columns(text)
self.assertEqual(i, 1)
self.assertEqual(cols, ['ICCID', 'KIC1', 'KID1'])
res = outfile.parse_outfile(text)
self.assertEqual(res['report']['cards'], 1)
self.assertEqual(res['presets'][0]['iccid'], DIGITS)
class AlfaShapeTests(unittest.TestCase):
def test_the_rows_become_presets_with_three_keysets(self):
res = outfile.parse_outfile(alfa_text())
self.assertEqual(res['report']['cards'], 2)
self.assertEqual(res['report']['rows'], 2)
self.assertEqual(res['report']['keysets'], ['KIC1/KID1', 'KIC2/KID2'])
p = res['presets'][0]
self.assertEqual(p['iccid'], DIGITS)
self.assertEqual(p['name'], DIGITS) # no name given: the ICCID
self.assertEqual([ks['kic'] for ks in p['keysets']], ['15', '25'])
self.assertEqual([ks['kid'] for ks in p['keysets']], ['15', '25'])
self.assertEqual(p['keysets'][0]['kicKey'], K16A)
self.assertEqual(p['keysets'][1]['kicKey'], K16C)
# the TARs are the three role defaults, all on the card's first keyset
self.assertEqual([t['role'] for t in p['tars']],
['isd', 'uiccRfm', 'usimRfm'])
self.assertEqual([t['tar'] for t in p['tars']],
['000000', 'B00000', 'B00001'])
self.assertTrue(all(t['keyset'] == 1 for t in p['tars']))
self.assertNotIn('pskKey', p)
self.assertNotIn('adm', p)
def test_the_parsed_presets_validate_through_the_store(self):
res = outfile.parse_outfile(alfa_text())
with tempfile.TemporaryDirectory() as tmp:
store = presets.PresetStore(pathlib.Path(tmp) / 'card_presets.json')
for entry in res['presets']:
p = store.add(entry)
self.assertEqual(p['iccid'], entry['iccid'])
class BapShapeTests(unittest.TestCase):
def test_the_codes_and_the_adm_are_read(self):
res = outfile.parse_outfile(bap_text())
self.assertEqual(res['report']['cards'], 1)
p = res['presets'][0]
self.assertEqual(p['iccid'], DIGITS)
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['pin2'], '4321')
self.assertEqual(p['puk1'], '12345678')
self.assertEqual(p['puk2'], '87654321')
self.assertEqual(p['adm'], '62329F37') # the ASCII-hex decode
# EKI, KIK and the IMSI columns are ignored - one keyset only
self.assertEqual(len(p['keysets']), 1)
self.assertEqual(p['keysets'][0]['kicKey'], K16A)
self.assertNotIn(K16C, [p['keysets'][0]['kicKey'], p['keysets'][0]['kidKey']])
class KeyInferenceTests(unittest.TestCase):
def _one(self, kic_key, algorithm16='3des2', kic='KIC1', kid='KID1'):
text = ('ICCID ' + kic + ' ' + kid + '\n' +
DIGITS_F + ' ' + kic_key + ' ' + kic_key + '\n')
return outfile.parse_outfile(text, algorithm16=algorithm16)
def test_key_size_picks_the_algorithm_nibble(self):
# 8 bytes -> DES (1), 24 -> 3DES3 (9), 16 -> 3DES2 (5) or AES (2)
p = self._one('0011223344556677')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '11')
p = self._one('00112233445566778899AABBCCDDEEFF0011223344556677')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '19')
p = self._one(K16A)['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '15')
p = self._one(K16A, algorithm16='aes')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '12')
def test_an_unsupported_key_size_skips_the_keyset(self):
res = self._one('00112233445566778899') # 10 bytes
self.assertEqual(res['presets'], [])
self.assertTrue(any('unsupported key size' in s for s in res['report']['skipped']))
def test_the_keyset_number_is_the_column_number(self):
text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' +
K16A + ' ' + K16B + ' ' + K16C + ' ' + K16A + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual([ks['kic'] for ks in p['keysets']], ['15', '25'])
def test_a_keyset_missing_one_side_is_skipped(self):
text = 'ICCID KIC1 KID1 KIC2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' ' + K16C + '\n'
res = outfile.parse_outfile(text)
self.assertEqual(len(res['presets'][0]['keysets']), 1)
class OptionTests(unittest.TestCase):
def test_the_common_name_applies_to_every_card(self):
res = outfile.parse_outfile(alfa_text(), name='Alfa DSA 0002')
self.assertEqual([p['name'] for p in res['presets']],
['Alfa DSA 0002', 'Alfa DSA 0002'])
def test_the_tar_table_and_the_global_keyset(self):
tars = outfile.default_tars() + [{'tar': 'B00011', 'msl': '0A', 'desc': 'push'}]
res = outfile.parse_outfile(alfa_text(), tars=tars, tar_keyset=2)
p = res['presets'][0]
self.assertEqual(len(p['tars']), 4)
self.assertEqual(p['tars'][3]['tar'], 'B00011')
self.assertTrue(all(t['keyset'] == 2 for t in p['tars']))
self.assertEqual(res['report']['tars'], 4)
def test_a_malformed_tar_table_raises(self):
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}])
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[
{'tar': '000000', 'msl': '16'}, {'tar': '000000', 'msl': '16'}])
def test_the_psk_column_sets_the_key_and_defaults_the_identity(self):
psk = 'AB' * 16
text = ('ICCID KIC1 KID1 PSK\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ' + psk + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual(p['pskKey'], psk)
self.assertEqual(p['pskIdentity'], DIGITS)
# an explicit PSKID column wins
text = ('ICCID PSKID KIC1 KID1 PSK\n' + DIGITS_F + ' 89390100000129506903 ' +
K16A + ' ' + K16B + ' ' + psk + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual(p['pskIdentity'], '89390100000129506903')
# a wrong length is dropped with a report line
res = outfile.parse_outfile('ICCID KIC1 KID1 PSK\n' + DIGITS_F + ' ' +
K16A + ' ' + K16B + ' AB\n')
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
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
text = ('ICCID KIC1 KID1 PIN1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 1234\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])
# a present ADM is decoded from its ASCII-hex form
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 3632333239463337\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'][0]['adm'], '62329F37')
# a present but invalid PIN is dropped, the card kept
text = ('ICCID KIC1 KID1 PIN1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' 12\n')
res = outfile.parse_outfile(text)
self.assertEqual(len(res['presets']), 1)
self.assertNotIn('pin1', res['presets'][0])
self.assertTrue(any('PIN1' in s for s in res['report']['skipped']))
+8
View File
@@ -66,6 +66,14 @@ class IccidNormalisationTests(unittest.TestCase):
self.assertEqual(presets.normalize_iccid('980711090000640090F8'), DIGITS)
self.assertEqual(presets._swap_nibbles('123'), '21F3')
def test_the_outfile_digits_f_form_normalises(self):
# the vendors' outfiles print the ICCID as digits + an odd-nibble F
# pad (v3.27.0) - not the raw EF form (which never ends in F)
self.assertEqual(presets.normalize_iccid(DIGITS + 'F'), DIGITS)
self.assertEqual(presets.normalize_iccid(DIGITS + 'FF'), DIGITS)
self.assertEqual(presets.normalize_iccid(DIGITS.lower() + 'f'), DIGITS)
self.assertEqual(presets.normalize_iccid('FFFF'), '')
class CounterTests(unittest.TestCase):
def test_counter_hex_is_the_fixed_width_protocol_form(self):