v3.29.1: the per-row keysets in the outfile preview

The import applied one global default keyset to every TAR entry of every
imported card.  The preview's TAR table (TAR + MSL, the three role rows +
"+ TAR") now has a keyset select per row, mirroring the preset editor: an
empty "— none —" plus the keysets found in the file with their KIc/KID,
the first preselected, and the editor's amber warning when a non-zero MSL
has no keyset.  Each TAR's packets then use exactly the chosen keyset.

- PWA: the global "Default keyset for the TARs" select (and the request's
  tar_keyset) is gone; cardsOutfileTarsFromDom/FromForm carry each row's
  keyset; cardsOutfileKeysets() became cardsOutfileKeysetOptions() (the
  union with the KIc/KID bytes for the labels).
- Server: a `tars` entry's own `keyset` (1-15; an unusable value is a 400)
  wins, then the optional global `tar_keyset` (the API shorthand), then the
  card's first keyset - parse_outfile no longer clobbers per-entry keysets
  with the card-level fallback.  presets.normalise_keyset_ref() is
  module-level (one rule for the store and the parser).
- Docs: help EN/RU, api.md (the tars entry keyset + the global fallback),
  the repo AGENTS.  Tests: the per-entry/global/card fallbacks, the
  bad-keyset 400, the options helper, the row select and round-trip, the
  import body.  925 frontend + 944 Python; sw cache simple-v448.
This commit is contained in:
2026-10-15 00:40:16 +03:00
parent cf7ee77a9e
commit 8a66ea5a0a
12 changed files with 168 additions and 95 deletions
+8 -4
View File
@@ -336,10 +336,14 @@ into a preview.
- `tars` — the TAR table every imported card gets (defaults to the three role - `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 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, a non-1-byte MSL, a duplicate) is refused with `400`.
- `tar_keyset` — one **global default keyset** (1–15) written into every TAR - `tars` entries may carry their own `keyset` (1–15 — the preview's per-row
entry of every imported card. Without it the card's first keyset is used choice, v3.29.1): it wins; the entries without one fall back to
(a multi-keyset card would otherwise fail the store's "a non-zero MSL needs `tar_keyset`, and without that to the card's first keyset (a multi-keyset
a default keyset" rule). card would otherwise fail the store's "a non-zero MSL needs a default
keyset" rule). A keyset outside 1–15 is refused with `400`.
- `tar_keyset` — an optional **global default keyset** (1–15) for the TAR
entries that do not name one (the API's shorthand; the PWA sends per-row
keysets instead).
Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F
pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all
+1 -1
View File
@@ -371,7 +371,7 @@
</tbody> </tbody>
</table> </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>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> (hex-значение ключа, 4&ndash;16 hex-цифр (чётное число) &mdash; например <code class="font-mono text-sm">3632333239463337</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> <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> (hex-значение ключа, 4&ndash;16 hex-цифр (чётное число) &mdash; например <code class="font-mono text-sm">3632333239463337</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> &mdash; список из найденных в файле keysets (первый выбран заранее; при ненулевом MSL keyset обязателен и помечается жёлтым), поэтому пакеты к каждому TAR используют именно выбранный keyset. <strong>Общее имя</strong> применяется ко всем импортируемым картам (пустое &mdash; берётся ICCID); <strong>режим импорта</strong> выбирает <strong>Дополнить</strong> (по умолчанию &mdash; существующие пресеты сохраняются, дубли ICCID пропускаются и перечисляются) или <strong>Заменить</strong> (хранилище сначала очищается). Предпросмотр перечисляет разобранные карты (ICCID, keysets, найденные коды) и каждую пропущенную строку/ключ с причиной &mdash; до того, как что-либо сохранено.</p>
</section> </section>
<section class="mb-10"> <section class="mb-10">
+1 -1
View File
@@ -370,7 +370,7 @@
</tbody> </tbody>
</table> </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">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 key's hex value, 4&ndash;16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</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> <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 key's hex value, 4&ndash;16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</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 <strong>every row carries its own keyset</strong> — 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 <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>
<section class="mb-10"> <section class="mb-10">
+34 -29
View File
@@ -1928,7 +1928,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.29.0'; const SIMPLE_VERSION = '3.29.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) ===== // ===== view history (the browser Back/Forward buttons) =====
@@ -14856,7 +14856,7 @@ function cardsImportFile(input) {
// shows the preview plus the controls: the common name, the 16-byte key // 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 // algorithm, the TAR table and its one global default keyset - the final call
// imports with a mode. // imports with a mode.
let _cardsOutfile = null; // {text, file, name, algorithm16, tarKeyset, mode, preview} let _cardsOutfile = null; // {text, file, name, algorithm16, mode, preview}
let _cardsOutfileTars = 0; // the free TAR rows of the panel let _cardsOutfileTars = 0; // the free TAR rows of the panel
function cardsOutfilePick() { function cardsOutfilePick() {
@@ -14879,7 +14879,7 @@ async function cardsOutfileFile(input) {
} }
_cardsOutfile = { text: text, file: String(file.name || ''), _cardsOutfile = { text: text, file: String(file.name || ''),
name: String(file.name || '').replace(/\.[^.]+$/, ''), name: String(file.name || '').replace(/\.[^.]+$/, ''),
algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null }; algorithm16: '3des2', mode: 'merge', preview: null };
_cardsOutfileTars = 0; _cardsOutfileTars = 0;
cardsOutfileRender(); cardsOutfileRender();
await cardsOutfilePreview(); await cardsOutfilePreview();
@@ -14905,8 +14905,6 @@ function cardsOutfileControls() {
if (document.getElementById('cards-of-mode')) { if (document.getElementById('cards-of-mode')) {
o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge'; 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 // The TAR table as the panel shows it: the three role rows (their defaults
@@ -14917,17 +14915,21 @@ function cardsOutfileTarsFromDom() {
const el = document.getElementById(id); const el = document.getElementById(id);
return el ? String(el.value).trim() : ''; return el ? String(el.value).trim() : '';
}; };
const ks = i => {
const v = parseInt(get('cards-of-tar-' + i + '-keyset'), 10);
return Number.isInteger(v) && v > 0 ? v : null;
};
const out = CARDS_TAR_ROLES.map((role, i) => ({ const out = CARDS_TAR_ROLES.map((role, i) => ({
role: role, role: role,
tar: get('cards-of-tar-' + i + '-tar').replace(/\s/g, '').toUpperCase(), tar: get('cards-of-tar-' + i + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + i + '-msl').replace(/\s/g, '').toUpperCase(), msl: get('cards-of-tar-' + i + '-msl').replace(/\s/g, '').toUpperCase(),
desc: '', desc: '', keyset: ks(i),
})); }));
for (let i = 0; i < _cardsOutfileTars; i++) { for (let i = 0; i < _cardsOutfileTars; i++) {
const idx = CARDS_TAR_ROLES.length + i; const idx = CARDS_TAR_ROLES.length + i;
out.push({ tar: get('cards-of-tar-' + idx + '-tar').replace(/\s/g, '').toUpperCase(), out.push({ tar: get('cards-of-tar-' + idx + '-tar').replace(/\s/g, '').toUpperCase(),
msl: get('cards-of-tar-' + idx + '-msl').replace(/\s/g, '').toUpperCase(), msl: get('cards-of-tar-' + idx + '-msl').replace(/\s/g, '').toUpperCase(),
desc: get('cards-of-tar-' + idx + '-desc') }); desc: get('cards-of-tar-' + idx + '-desc'), keyset: ks(idx) });
} }
return out; return out;
} }
@@ -14942,7 +14944,7 @@ function cardsOutfileTarsFromForm() {
const e = rows[i]; const e = rows[i];
if (e.role) { if (e.role) {
out.push({ role: e.role, tar: e.tar || cardsRoleDefault(e.role), out.push({ role: e.role, tar: e.tar || cardsRoleDefault(e.role),
msl: e.msl || CARDS_MSL_DEFAULT, desc: '' }); msl: e.msl || CARDS_MSL_DEFAULT, desc: '', keyset: e.keyset || null });
} else if (e.tar || e.msl || e.desc) { } else if (e.tar || e.msl || e.desc) {
out.push(e); out.push(e);
} }
@@ -14953,9 +14955,24 @@ function cardsOutfileTarsFromForm() {
function cardsOutfileTarRowHtml(e, i) { 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 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] || ''; const role = CARDS_TAR_ROLES[i] || '';
// The keyset this TAR's packets use: one select per row, like the preset
// editor (v3.29.1); it defaults to the first parsed keyset, and a non-zero
// MSL without one gets the amber marker.
const opts = cardsOutfileKeysetOptions();
const sel = e.keyset ? Number(e.keyset) : (opts.length ? opts[0].v : null);
const msl = String(e.msl || '').toUpperCase();
let html = '<div class="flex flex-wrap items-center gap-1.5 mb-1">'; 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 + '-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 || '') + '">'; html += '<input id="cards-of-tar-' + i + '-msl" class="w-14 ' + cls + '" maxlength="2" placeholder="MSL" value="' + escHtml(e.msl || '') + '">';
html += '<select id="cards-of-tar-' + i + '-keyset" class="' + cls + '" title="' + esc(t('The keyset packets to this TAR use - a non-zero MSL requires one')) + '">' +
'<option value=""' + (sel ? '' : ' selected') + '>' + esc(t('— none —')) + '</option>' +
opts.map(o => '<option value="' + o.v + '"' + (o.v === sel ? ' selected' : '') + '>' +
esc(t('Keyset') + ' ' + o.v + ' \u2014 ' + o.kic + '/' + o.kid) + '</option>').join('') +
'</select>';
if (!sel && msl && msl !== '00') {
html += '<span class="text-amber-500" title="' +
esc(t('This TAR needs a keyset (its MSL requires security)')) + '">\u26a0</span>';
}
if (role) { if (role) {
html += '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(CARDS_TAR_ROLE_DESC[role] || role) + '</span>'; html += '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(CARDS_TAR_ROLE_DESC[role] || role) + '</span>';
} else { } else {
@@ -15006,15 +15023,19 @@ function cardsOutfileTarRender(rows) {
} }
// The keyset numbers the parsed cards carry (the global default's options). // The keyset numbers the parsed cards carry (the global default's options).
function cardsOutfileKeysets() { // The parsed keysets as the TAR rows' options: the union across the parsed
const out = []; // cards, with their KIc/KID bytes for the labels (v3.29.1).
function cardsOutfileKeysetOptions() {
const seen = {};
(((_cardsOutfile || {}).preview || {}).presets || []).forEach(c => { (((_cardsOutfile || {}).preview || {}).presets || []).forEach(c => {
(c.keysets || []).forEach(ks => { (c.keysets || []).forEach(ks => {
const v = spKeysetKvnOf(ks.kic); const v = spKeysetKvnOf(ks.kic);
if (v !== null && !out.includes(v)) out.push(v); if (v !== null && !seen[v]) {
seen[v] = { v: v, kic: ks.kic || '??', kid: ks.kid || '??' };
}
}); });
}); });
return out.sort((a, b) => a - b); return Object.keys(seen).map(k => seen[k]).sort((a, b) => a.v - b.v);
} }
function cardsOutfileResultHtml() { function cardsOutfileResultHtml() {
@@ -15059,15 +15080,6 @@ function cardsOutfileRender() {
const panel = document.getElementById('cards-outfile-panel'); const panel = document.getElementById('cards-outfile-panel');
const o = _cardsOutfile; const o = _cardsOutfile;
if (!panel || !o) return; if (!panel || !o) return;
const keysets = cardsOutfileKeysets();
// An empty or failed preview must not drop a chosen keyset: the value is
// only re-selected against a list that has keysets, and the option list is
// the preview's keysets plus a remembered value (so it stays visible).
if (keysets.length && !keysets.includes(o.tarKeyset)) o.tarKeyset = keysets[0];
const options = keysets.slice();
if (o.tarKeyset && !options.includes(o.tarKeyset)) options.push(o.tarKeyset);
options.sort((a, b) => a - b);
const selected = o.tarKeyset;
let html = '<div class="flex items-center justify-between mb-2">'; let html = '<div class="flex items-center justify-between mb-2">';
html += '<span class="text-sm font-semibold">' + esc(t('outfile import')) + html += '<span class="text-sm font-semibold">' + esc(t('outfile import')) +
': <span class="font-mono text-xs">' + esc(o.file || '') + '</span></span>'; ': <span class="font-mono text-xs">' + esc(o.file || '') + '</span></span>';
@@ -15080,11 +15092,6 @@ function cardsOutfileRender() {
'<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">' + '<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="3des2"' + (o.algorithm16 === 'aes' ? '' : ' selected') + '>3DES2</option>' +
'<option value="aes"' + (o.algorithm16 === 'aes' ? ' selected' : '') + '>AES</option></select></label>'; '<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">' +
(options.length ? options.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')) + 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">' + '<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="merge"' + (o.mode === 'replace' ? '' : ' selected') + '>' + esc(t('Merge')) + '</option>' +
@@ -15110,7 +15117,6 @@ async function cardsOutfilePreview() {
text: o.text, name: o.name, algorithm16: o.algorithm16, text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), tars: cardsOutfileTarsFromForm(),
}; };
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try { try {
o.preview = await pysimFetchOk('/api/presets/import-outfile', body); o.preview = await pysimFetchOk('/api/presets/import-outfile', body);
} catch (e) { } catch (e) {
@@ -15131,7 +15137,6 @@ async function cardsOutfileImport() {
text: o.text, name: o.name, algorithm16: o.algorithm16, text: o.text, name: o.name, algorithm16: o.algorithm16,
tars: cardsOutfileTarsFromForm(), mode: o.mode, tars: cardsOutfileTarsFromForm(), mode: o.mode,
}; };
if (o.tarKeyset) body.tar_keyset = o.tarKeyset;
try { try {
const res = await pysimFetchOk('/api/presets/import-outfile', body); const res = await pysimFetchOk('/api/presets/import-outfile', body);
await cardsFetch(); await cardsFetch();
@@ -23632,7 +23637,7 @@ const LANG_RU = {
'outfile import': 'Импорт outfile', 'outfile import': 'Импорт outfile',
'Common name': 'Общее имя', 'Common name': 'Общее имя',
'16-byte keys': '16-байтные ключи', '16-byte keys': '16-байтные ключи',
'Default keyset for the TARs': 'Keyset по умолчанию для TAR', 'This TAR needs a keyset (its MSL requires security)': 'Этому TAR нужен keyset (его MSL требует защиты)',
'Import mode': 'Режим импорта', 'Import mode': 'Режим импорта',
'Merge': 'Дополнить', 'Merge': 'Дополнить',
'Replace': 'Заменить', 'Replace': 'Заменить',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v447'; const CACHE = 'simple-v448';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+53 -35
View File
@@ -31,7 +31,7 @@ const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImpor
let code = ''; let code = '';
for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm', for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm',
'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd', 'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd',
'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysets', 'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysetOptions',
'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose', 'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose',
'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview',
'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) { 'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) {
@@ -71,7 +71,7 @@ const app = new Function('els', `
sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd; sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd;
sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove; sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove;
sandbox.cardsOutfileTarRender = cardsOutfileTarRender; sandbox.cardsOutfileTarRender = cardsOutfileTarRender;
sandbox.cardsOutfileKeysets = cardsOutfileKeysets; sandbox.cardsOutfileKeysetOptions = cardsOutfileKeysetOptions;
sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml; sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml;
sandbox.cardsOutfileControls = cardsOutfileControls; sandbox.cardsOutfileControls = cardsOutfileControls;
sandbox.cardsOutfileClose = cardsOutfileClose; sandbox.cardsOutfileClose = cardsOutfileClose;
@@ -107,9 +107,11 @@ test('the preview and the import both post the outfile endpoint', () => {
assert.match(preview, /'\/api\/presets\/import-outfile'/); assert.match(preview, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /'\/api\/presets\/import-outfile'/); assert.match(imp, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /mode: o\.mode/); assert.match(imp, /mode: o\.mode/);
// the keyset is only sent when one was resolved // the keysets travel per TAR row (v3.29.1): no global tar_keyset
assert.match(preview, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/); assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
assert.match(imp, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/); assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/);
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/);
}); });
test('the 16-byte algorithm values match the server map', () => { test('the 16-byte algorithm values match the server map', () => {
@@ -159,20 +161,33 @@ test('the TAR rows render the role ids and the free rows with a remove button',
assert.match(rows2, /id="cards-of-tar-3-tar"/); assert.match(rows2, /id="cards-of-tar-3-tar"/);
assert.match(rows2, /id="cards-of-tar-3-desc"/); assert.match(rows2, /id="cards-of-tar-3-desc"/);
assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/); assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/);
// every row has a keyset select, fed by the parsed keysets (v3.29.1)
assert.match(rows2, /id="cards-of-tar-0-keyset"/);
assert.match(rows2, /id="cards-of-tar-3-keyset"/);
app.state.preview = { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] };
const rows3 = app.cardsOutfileTarRowsHtml();
assert.match(rows3, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(rows3, /<option value="2">Keyset 2 — 25\/25<\/option>/);
// a non-zero MSL without a keyset gets the amber marker
app.state.preview = { presets: [] };
const rows4 = app.cardsOutfileTarRowsHtml();
assert.match(rows4, /This TAR needs a keyset/);
}); });
test('cardsOutfileKeysets collects the parsed keyset numbers', () => { test('cardsOutfileKeysetOptions collects the parsed keysets with their bytes', () => {
clearEls(); clearEls();
app.state = { app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [ preview: { presets: [
{ keysets: [{ kic: '25' }, { kic: '15' }] }, { keysets: [{ kic: '25', kid: '25' }, { kic: '15', kid: '15' }] },
{ keysets: [{ kic: '15' }, { kic: '31' }] }, { keysets: [{ kic: '15', kid: '15' }, { kic: '31', kid: '31' }] },
] }, ] },
}; };
assert.deepStrictEqual(app.cardsOutfileKeysets(), [1, 2, 3]); assert.deepStrictEqual(app.cardsOutfileKeysetOptions(),
[{ v: 1, kic: '15', kid: '15' }, { v: 2, kic: '25', kid: '25' },
{ v: 3, kic: '31', kid: '31' }]);
app.state.preview = { presets: [] }; app.state.preview = { presets: [] };
assert.deepStrictEqual(app.cardsOutfileKeysets(), []); assert.deepStrictEqual(app.cardsOutfileKeysetOptions(), []);
}); });
test('cardsOutfileResultHtml renders the report and the parsed cards', () => { test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
@@ -203,16 +218,14 @@ test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
test('cardsOutfileControls reads the panel controls back into the state', () => { test('cardsOutfileControls reads the panel controls back into the state', () => {
clearEls(); clearEls();
app.state = { text: TEXT, name: 'old', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null }; app.state = { text: TEXT, name: 'old', algorithm16: '3des2', mode: 'merge', preview: null };
setEl('cards-of-name', ' Batch 7 '); setEl('cards-of-name', ' Batch 7 ');
setEl('cards-of-algo', 'aes'); setEl('cards-of-algo', 'aes');
setEl('cards-of-mode', 'replace'); setEl('cards-of-mode', 'replace');
setEl('cards-of-keyset', '2');
app.cardsOutfileControls(); app.cardsOutfileControls();
assert.strictEqual(app.state.name, 'Batch 7'); assert.strictEqual(app.state.name, 'Batch 7');
assert.strictEqual(app.state.algorithm16, 'aes'); assert.strictEqual(app.state.algorithm16, 'aes');
assert.strictEqual(app.state.mode, 'replace'); 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 () => { test('picking a file previews it with the file name and the TAR defaults', async () => {
@@ -231,9 +244,12 @@ test('picking a file previews it with the file name and the TAR defaults', async
assert.strictEqual(body.name, 'batch'); // the file base name assert.strictEqual(body.name, 'batch'); // the file base name
assert.strictEqual(body.algorithm16, '3des2'); assert.strictEqual(body.algorithm16, '3des2');
assert.strictEqual(body.mode, undefined); // the preview is a dry run assert.strictEqual(body.mode, undefined); // the preview is a dry run
assert.strictEqual(body.tar_keyset, undefined); // none resolved yet assert.strictEqual(body.tar_keyset, undefined); // the keysets are per row now
assert.strictEqual(body.tars.length, 3); assert.strictEqual(body.tars.length, 3);
assert.strictEqual(app.state.tarKeyset, 1); // the first parsed keyset // the rows' keyset selects are filled from the parsed keysets (the first one
// is the default)
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/); assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
}); });
@@ -259,11 +275,13 @@ test('the import posts the chosen mode and refreshes the list', async () => {
setEl('cards-of-mode', 'replace'); setEl('cards-of-mode', 'replace');
setEl('cards-of-name', 'Batch 7'); setEl('cards-of-name', 'Batch 7');
app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2', app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2',
tarKeyset: 2, mode: 'merge', preview: { presets: [], report: {} } }; mode: 'merge', preview: { presets: [], report: {} } };
setEl('cards-of-tar-0-keyset', '2'); // the ISD row's chosen keyset
await app.cardsOutfileImport(); await app.cardsOutfileImport();
assert.strictEqual(app.calls.length, 1); assert.strictEqual(app.calls.length, 1);
assert.strictEqual(app.calls[0].body.mode, 'replace'); assert.strictEqual(app.calls[0].body.mode, 'replace');
assert.strictEqual(app.calls[0].body.tar_keyset, 2); assert.strictEqual(app.calls[0].body.tar_keyset, undefined); // per row, not global
assert.strictEqual(app.calls[0].body.tars[0].keyset, 2);
assert.strictEqual(app.calls[0].body.name, 'Batch 7'); assert.strictEqual(app.calls[0].body.name, 'Batch 7');
assert.strictEqual(app.fetched, 1); assert.strictEqual(app.fetched, 1);
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]); assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
@@ -292,22 +310,22 @@ test('a declined replace confirmation aborts the import', async () => {
assert.strictEqual(app.calls.length, 1); assert.strictEqual(app.calls.length, 1);
}); });
test('a chosen keyset survives an empty (failed) preview', () => { test('a row keeps its chosen keyset through the DOM round-trip', () => {
clearEls(); clearEls();
setEl('cards-outfile-panel', ''); app.state = { text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2', preview: { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] } };
tarKeyset: 2, mode: 'merge', app.tars = 0;
preview: { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15' }, { kic: '25' }] }], report: {} } }; setEl('cards-of-tar-0-keyset', '2'); // the user picked keyset 2 for the ISD row
app.cardsOutfileRender(); setEl('cards-of-tar-1-keyset', '');
assert.strictEqual(app.state.tarKeyset, 2); const tars = app.cardsOutfileTarsFromForm();
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="2" selected>/); assert.strictEqual(tars[0].keyset, 2);
// a failed re-preview has no keysets - the choice must not be dropped assert.strictEqual(tars[1].keyset, null);
app.state.preview = { report: { error: 'TAR must be three hex bytes: ZZ' } }; // a free row carries its own keyset too
app.cardsOutfileRender(); app.tars = 1;
assert.strictEqual(app.state.tarKeyset, 2); setEl('cards-of-tar-3-tar', 'b00011');
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="2" selected>/); setEl('cards-of-tar-3-msl', '1a');
// a keyset the new preview does not define falls back to its first one setEl('cards-of-tar-3-keyset', '1');
app.state.preview = { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15' }] }], report: {} }; const tars2 = app.cardsOutfileTarsFromForm();
app.cardsOutfileRender(); assert.strictEqual(tars2.length, 4);
assert.strictEqual(app.state.tarKeyset, 1); assert.strictEqual(tars2[3].keyset, 1);
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.29.0" version = "3.29.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+13 -2
View File
@@ -150,10 +150,18 @@ def normalise_tars(tars, tar_keyset=None):
raise ValueError('TAR must be three hex bytes: %r' % tar) raise ValueError('TAR must be three hex bytes: %r' % tar)
if not re.fullmatch(r'[0-9A-F]{2}', msl): if not re.fullmatch(r'[0-9A-F]{2}', msl):
raise ValueError('MSL must be one hex byte: %r' % msl) raise ValueError('MSL must be one hex byte: %r' % msl)
ks_raw = t.get('keyset')
if ks_raw in (None, ''):
keyset = None
else:
keyset = presets.normalise_keyset_ref(ks_raw)
if keyset is None:
raise ValueError('keyset must be 1-%02X: %r'
% (presets.KVN_MAX, ks_raw))
out.append({'role': role if role in presets.TAR_ROLES else '', out.append({'role': role if role in presets.TAR_ROLES else '',
'tar': tar, 'msl': msl, 'tar': tar, 'msl': msl,
'desc': str(t.get('desc') or '').strip(), 'desc': str(t.get('desc') or '').strip(),
'keyset': tar_keyset or None}) 'keyset': keyset or (tar_keyset or None)})
seen = set() seen = set()
for t in out: for t in out:
if t['tar'] in seen: if t['tar'] in seen:
@@ -248,8 +256,11 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None
# Every TAR gets the caller's global default keyset; without one the # Every TAR gets the caller's global default keyset; without one the
# card's first keyset is used (a multi-keyset card would otherwise # 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). # fail the store's "a non-zero MSL needs a default keyset" rule).
# A per-entry keyset (chosen in the preview) wins; the caller's global
# default - else the card's first keyset - fills the rest.
card_keyset = tar_keyset or presets.keyset_kvn(keysets[0]) card_keyset = tar_keyset or presets.keyset_kvn(keysets[0])
card_tars = [dict(t, keyset=card_keyset) for t in tars_base] card_tars = [dict(t, keyset=t.get('keyset') or card_keyset)
for t in tars_base]
entry = {'name': str(name or '').strip() or iccid, 'iccid': iccid, entry = {'name': str(name or '').strip() or iccid, 'iccid': iccid,
'keysets': keysets, 'tars': card_tars} 'keysets': keysets, 'tars': card_tars}
for field, col in (('pin1', 'PIN1'), ('pin2', 'PIN2'), for field, col in (('pin1', 'PIN1'), ('pin2', 'PIN2'),
+20 -20
View File
@@ -218,6 +218,25 @@ def tar_keyset(preset, tar_hex):
return (tar_entry(preset, tar_hex) or {}).get('keyset') or None return (tar_entry(preset, tar_hex) or {}).get('keyset') or None
def normalise_keyset_ref(v):
"""A TAR's default keyset number (int 1-15) or None. Accepts an int and
the decimal ('1'..'15') or hex-ish ('01'..'0F') string forms; an unusable
value becomes None - the validation reports a missing keyset where one is
mandatory. Shared by the store and the outfile import (v3.29.1)."""
if v is None or v == '':
return None
s = str(v).strip().upper()
if re.fullmatch(r'[0-9]{1,2}', s):
n = int(s, 10)
if 1 <= n <= KVN_MAX:
return n
if re.fullmatch(r'[0-9A-F]{1,2}', s):
n = int(s, 16)
if 1 <= n <= KVN_MAX:
return n
return None
class PresetError(Exception): class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...).""" """A refused mutation (validation, duplicate ICCID, ...)."""
@@ -360,7 +379,7 @@ class PresetStore:
'tar': re.sub(r'\s', '', str(t.get('tar') or '')).upper(), 'tar': re.sub(r'\s', '', str(t.get('tar') or '')).upper(),
'msl': re.sub(r'\s', '', str(t.get('msl') or '')).upper(), 'msl': re.sub(r'\s', '', str(t.get('msl') or '')).upper(),
'desc': str(t.get('desc') or '').strip(), 'desc': str(t.get('desc') or '').strip(),
'keyset': self._normalise_keyset_ref(t.get('keyset')), 'keyset': normalise_keyset_ref(t.get('keyset')),
}) })
else: else:
msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper() msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper()
@@ -392,25 +411,6 @@ class PresetStore:
'keyset': e.get('keyset')}) 'keyset': e.get('keyset')})
return out return out
@staticmethod
def _normalise_keyset_ref(v):
"""A TAR's default keyset number (int 1-15) or None. Accepts an int
and the decimal ('1'..'15') or hex-ish ('01'..'0F') string forms; an
unusable value becomes None - the validation reports a missing keyset
where one is mandatory (v3.26.0)."""
if v is None or v == '':
return None
s = str(v).strip().upper()
if re.fullmatch(r'[0-9]{1,2}', s):
n = int(s, 10)
if 1 <= n <= KVN_MAX:
return n
if re.fullmatch(r'[0-9A-F]{1,2}', s):
n = int(s, 16)
if 1 <= n <= KVN_MAX:
return n
return None
def _normalise_keyset(self, ks): def _normalise_keyset(self, ks):
"""One keyset, cleaned. The counter is kept as typed here and """One keyset, cleaned. The counter is kept as typed here and
validated + padded in _validate_keyset() - a bad value must be validated + padded in _validate_keyset() - a bad value must be
+1 -1
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.29.0' VERSION = '3.29.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
+11
View File
@@ -955,6 +955,17 @@ class PresetStoreHttpTests(unittest.TestCase):
status, resp = self._post('/api/presets/import-outfile', { status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tar_keyset': 99}) 'text': text, 'tar_keyset': 99})
self.assertEqual(status, 400, resp) self.assertEqual(status, 400, resp)
# the per-row keysets travel in `tars` (v3.29.1)
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tars': [{'role': 'isd', 'tar': '000000', 'msl': '16', 'keyset': 2},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16', 'keyset': 1},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16', 'keyset': 1}]})
self.assertEqual(status, 200, resp)
self.assertEqual([t['keyset'] for t in resp['presets'][0]['tars']], [2, 1, 1])
status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tars': [{'tar': '000000', 'msl': '16', 'keyset': 99}]})
self.assertEqual(status, 400, resp)
self.assertIn('keyset', resp['error'])
# a non-list TAR table is the caller's error, never a silent fallback # a non-list TAR table is the caller's error, never a silent fallback
status, resp = self._post('/api/presets/import-outfile', { status, resp = self._post('/api/presets/import-outfile', {
'text': text, 'tars': 'nope'}) 'text': text, 'tars': 'nope'})
+24
View File
@@ -195,6 +195,30 @@ class OptionTests(unittest.TestCase):
self.assertTrue(all(t['keyset'] == 2 for t in p['tars'])) self.assertTrue(all(t['keyset'] == 2 for t in p['tars']))
self.assertEqual(res['report']['tars'], 4) self.assertEqual(res['report']['tars'], 4)
def test_the_per_row_keysets_win(self):
"""A TAR entry's own keyset (chosen in the preview) is used; the global
default fills the entries without one, then the card's first keyset
(v3.29.1)."""
text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ' + K16C + ' ' + K16A + '\n')
tars = [{'role': 'isd', 'tar': '000000', 'msl': '16', 'keyset': 2},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'tar': 'B00011', 'msl': '1A', 'desc': 'push', 'keyset': 1}]
res = outfile.parse_outfile(text, tars=tars)
got = [(t['tar'], t['keyset']) for t in res['presets'][0]['tars']]
self.assertEqual(got[0], ('000000', 2)) # its own keyset
self.assertEqual(got[-1], ('B00011', 1)) # its own keyset
self.assertEqual(got[1], ('B00000', 1)) # the card's first keyset
# the global default fills the entries without one; an entry's own
# keyset still wins over it
res = outfile.parse_outfile(text, tars=tars, tar_keyset=2)
self.assertEqual([t['keyset'] for t in res['presets'][0]['tars']], [2, 2, 1])
# an unusable keyset is the caller's error
with self.assertRaises(ValueError):
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 99}])
with self.assertRaises(ValueError):
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 'x'}])
def test_a_malformed_tar_table_raises(self): def test_a_malformed_tar_table_raises(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}]) outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}])