Compare commits

...

6 Commits

Author SHA1 Message Date
catarrh 994075920d i18n: rename 'Decoded FCP' -> 'Decoded FCI' (RU: 'Декодированный FCI')
Label in the rule editor, LANG_RU value and both help files.
SW cache v75 -> v76.
2026-09-11 21:41:33 +03:00
catarrh 130b9d7e3b profiler: explicit decode-failure display for corrupt FCI, keep partial results
fcpDecode now parses with a partial-aware walker (fcpParseTlvs) that keeps
every complete TLV it encounters and reports why it stopped:
- 'TLV 62 declares N bytes, only M available' (truncation)
- 'Truncated length field at offset X' / 'incomplete TLV header'
- 'Invalid length form' / 'Trailing data after TLV 62'
- inner A5/C6 errors prefixed with their context ('In A5: ...')

The editor preview shows the decoded parameters plus a red
'Decode failed: <reason>' line (RU: 'Ошибка декодирования'), and the check
report's decoded FCI diff appends per-side error notes (expected/actual)
while still showing whatever decoded on either side.

SW cache v74 -> v75.
2026-09-11 21:38:32 +03:00
catarrh 1e067dc883 fix BER long-form lengths: parseBerLen read b length bytes instead of b & 0x7F
parseBerLen treated the first long-form length byte (0x81/0x82/...) as the
count of length bytes, so any TLV with a long-form length parsed as
garbage: the profiler FCI decoder returned ok:false (decoded preview
disappeared and never came back after editing an FCP with a '62 81 xx'
outer length or a long-form inner TLV), and the same bug hit parseTlvList
(C-APDU parser, INSTALL param walker), parseBerScript (expanded script
rows >= 128 bytes) and readLvField.

Per ISO 7816-4 5.2 / UICC_SPECS.md 1.7 the long form is 81-84 followed by
(b & 0x7F) length bytes. Fixed; short-form behavior unchanged.

New tests: parseBerLen short/81/82 forms, parseTlvList long-form outer and
inner TLVs, fcpDecode long-form regression (81/82, nested A5, >=128-byte
FCP) incl. the editor preview path. SW cache v73 -> v74.
2026-09-11 21:32:23 +03:00
catarrh 6bfbb00994 profiler editor: decoded FCP panel beside the raw FCI field
The exact-FCI row is now a two-column flex layout: the editable raw FCI
textarea on the left, the decoded FCP list on the right (50/50, textarea
4 rows). Preview rendering split into profilerFciPreviewItems; a new
profilerFciInput hook updates the rule and re-decodes the panel on every
keystroke (no editor re-render, no focus loss), showing an empty body
while the hex is incomplete/invalid. Docs wording updated. SW cache
v72 -> v73.
2026-09-11 21:22:18 +03:00
catarrh 944fccf67b profiler: decode FCP/FCI and show per-parameter diffs
Adds a spec-verified FCP/FCI decoder (ISO 7816-4 5.3.3 Tables 12-14,
TS 102 221 11.1.1.4; cross-checked against pySim ts_102_221.py — spec wins
on the data coding byte and termination mask). fcpDecode accepts the FCP
template '62', an FCI '6F' wrapper and bare FCP content, and decodes:
file size/total size, file descriptor (access/shareable, file type,
structure incl. BER-TLV/SIMPLE-TLV, data coding byte, record length/count),
FID, DF name, SFI, life cycle status, security attributes, the A5
proprietary sub-DOs (UICC characteristics, power, clock, memory, file
details, sizes, commands, environmental conditions, test config) and the C6
PIN status template DO. Unknown TLVs are preserved raw.

Usage:
- check report: an 'Exact FCI' mismatch now renders a decoded
  expected/actual table under the raw fields, highlighting differing
  parameters and showing missing ones as '—'
- rule editor: the FCI hex textarea shows a live decoded FCP preview

10 new tests (vectors incl. pySim linear-fixed, malformed inputs, diff
highlighting, report integration). Help docs updated. SW cache v71 -> v72.
2026-09-11 21:15:42 +03:00
catarrh 758603a9ad profiler: merge rule attributes into a single row
Path, FCP/FCI check, file type and size (or record length + count) now
share one flex-wrap row; path column narrowed from w-72 to w-48. The FCI
hex textarea (Exact FCI mode) keeps its own full-width row and the
contents check stays on the following row. SW cache v70 -> v71.
2026-09-11 21:04:49 +03:00
6 changed files with 519 additions and 15 deletions
+1 -1
View File
@@ -381,7 +381,7 @@
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
</ul>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали.</p>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали.</p>
<h4 class="font-medium mb-1">Опции сканирования &laquo;Профиль с карты&raquo;</h4>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p>
+1 -1
View File
@@ -381,7 +381,7 @@
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
</ul>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
<h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
+283 -11
View File
@@ -3127,8 +3127,8 @@ const PARSE_INS = {
function parseBerLen(hex, i) {
const b = parseInt(hex.substr(i, 2), 16);
if (b < 128) return {len: b, consumed: 2};
const n = parseInt(hex.substr(i + 2, b * 2), 16);
return {len: n, consumed: 2 + b * 2};
const n = b & 0x7F;
return {len: parseInt(hex.substr(i + 2, n * 2), 16), consumed: 2 + n * 2};
}
function parseTlvList(hex) {
@@ -7383,8 +7383,11 @@ function profilerRenderRule(i, r) {
html += '<button onclick="profilerDeleteRule(' + i + ')" class="ml-auto px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
html += '</div>';
const sname = profilerCustomNameForPath(r.path) || r.name || '';
html += '<div class="flex gap-2 mb-2">';
html += '<div class="w-72 shrink-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) +
const vis = profilerFileFields(r.fileType);
const showSize = vis.size && fciMode !== 'type';
const showRecords = vis.records && fciMode !== 'type';
html += '<div class="flex flex-wrap gap-2 mb-2">';
html += '<div class="w-48 shrink-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) +
' <span class="profiler-rule-name font-normal text-gray-400 dark:text-slate-500">' + esc(sname) + '</span></label>' +
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRulePath(' + i + ',this.value,this)" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="MF/7F10/6F3A"></div>';
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCP/FCI check')) + '</label>' +
@@ -7393,11 +7396,6 @@ function profilerRenderRule(i, r) {
'<option value="type_size"' + (fciMode === 'type_size' ? ' selected' : '') + '>' + esc(t('Filetype + size (FCP)')) + '</option>' +
'<option value="exact"' + (fciMode === 'exact' ? ' selected' : '') + '>' + esc(t('Exact FCI')) + '</option>' +
'</select></div>';
html += '</div>';
const vis = profilerFileFields(r.fileType);
const showSize = vis.size && fciMode !== 'type';
const showRecords = vis.records && fciMode !== 'type';
html += '<div class="flex gap-2 mb-2">';
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
'<select onchange="profilerUpdateRuleFileType(' + i + ',this.value||null)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' + ftOpts + '</select></div>';
if (showSize) {
@@ -7412,8 +7410,12 @@ function profilerRenderRule(i, r) {
}
html += '</div>';
if (fciMode === 'exact') {
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</label>' +
'<textarea oninput="profilerUpdateRule(' + i + ',\'fciHex\',this.value)" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="62...">' + escHtml(r.fciHex || '') + '</textarea></div>';
html += '<div class="mb-2 flex gap-3 items-start">';
html += '<div class="flex-1 min-w-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</label>' +
'<textarea oninput="profilerFciInput(' + i + ',this.value)" rows="4" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="62...">' + escHtml(r.fciHex || '') + '</textarea></div>';
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded FCI')) + '</div>' +
'<div id="profiler-fci-preview-' + i + '" class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(r.fciHex || '') + '</div></div>';
html += '</div>';
}
if (r.fileType !== 'df') {
html += profilerRenderContentEditor(i, r);
@@ -7747,6 +7749,271 @@ function profilerRawDataCheck(c) {
return false;
}
// ===== FCP/FCI decoding (ISO 7816-4 5.3.3 tables 12-14, TS 102 221 11.1.1.4) =====
function fcpInt(hex) { return hex ? parseInt(hex, 16) : 0; }
function fcpFileDescriptor(v) {
if (!v) return '';
const fd = parseInt(v.substr(0, 2), 16);
const parts = [(fd & 0x80) ? 'shareable' : 'not shareable'];
const cat = (fd >> 3) & 0x07;
const st = fd & 0x07;
if (cat === 0x07) {
parts.push(st === 0x01 ? 'BER-TLV EF' : st === 0x02 ? 'SIMPLE-TLV EF' : st === 0x00 ? 'DF/ADF' : 'proprietary file (' + v.substr(0, 2) + ')');
} else {
parts.push(cat === 0 ? 'working EF' : cat === 1 ? 'internal EF' : 'proprietary EF (' + cat + ')');
parts.push(['no info', 'transparent', 'linear fixed', 'linear fixed TLV', 'linear variable', 'linear variable TLV', 'cyclic', 'cyclic TLV'][st] || ('structure ' + st));
}
if (v.length >= 4) parts.push('data coding ' + v.substr(2, 2));
if (v.length >= 10) parts.push('record length ' + fcpInt(v.substr(4, 4)) + ', ' + fcpInt(v.substr(8, 2)) + ' records');
else if (v.length === 8) parts.push('record length ' + fcpInt(v.substr(4, 4)));
else if (v.length === 6) parts.push('record length ' + fcpInt(v.substr(4, 2)));
return parts.join(', ');
}
// ISO 7816-4 table 13 / TS 102 221 table 11.7b.
function fcpLifeCycle(v) {
if (v.length !== 2) return v;
const b = parseInt(v, 16);
if (b === 0x00) return 'no information';
if (b === 0x01) return 'creation';
if (b === 0x03) return 'initialization';
if ((b & 0x0C) === 0x0C) return 'termination';
if ((b & 0x05) === 0x05) return 'operational, activated';
if ((b & 0x05) === 0x04) return 'operational, deactivated';
return 'proprietary (' + v + ')';
}
// TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id.
function fcpSfi(v) {
if (!v) return 'not supported';
if (v.length === 2 && (parseInt(v, 16) & 0x07) === 0) return String(parseInt(v, 16) >> 3);
return v;
}
function fcpDo(key, value) {
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
switch (key) {
case '80': return { name: 'File size', decoded: fcpInt(value) + ' bytes' };
case '81': return { name: 'Total file size', decoded: fcpInt(value) + ' bytes' };
case '82': return { name: 'File descriptor', decoded: fcpFileDescriptor(value) };
case '83': return { name: 'File identifier', decoded: value };
case '84': return { name: 'DF name (AID)', decoded: value };
case '85': return { name: 'Proprietary information', decoded: value };
case '86': return { name: 'Security attribute (proprietary)', decoded: value };
case '87': return { name: 'FCI extension EF', decoded: value };
case '88': return { name: 'Short file identifier', decoded: fcpSfi(value) };
case '8A': return { name: 'Life cycle status', decoded: fcpLifeCycle(value) };
case '8B': return { name: 'Security attributes (referenced)', decoded: value.length >= 6 ? 'EF_ARR ' + value.substr(0, 4) + ', record ' + fcpInt(value.substr(4, 2)) : value };
case '8C': return { name: 'Security attributes (compact)', decoded: value };
case '8D': return { name: 'Security env. template EF', decoded: value };
case '8E': return { name: 'Channel security attribute', decoded: value };
case 'A0': return { name: 'Security attributes (data objects)', decoded: value };
case 'A5': return { name: 'Proprietary information', decoded: null };
case 'AB': return { name: 'Security attributes (expanded)', decoded: value };
case 'C6': return { name: 'PIN status template DO', decoded: null };
case 'A5/80': {
const b = fcpInt(value);
const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed'];
const cls = [];
if (b & 0x10) cls.push('A');
if (b & 0x20) cls.push('B');
if (b & 0x40) cls.push('C');
if (b & 0x80) cls.push('D');
if (cls.length) parts.push('class ' + cls.join('/'));
return { name: 'UICC characteristics', decoded: parts.join(', ') };
}
case 'A5/81': {
if (value.length < 6) return { name: 'Application power consumption', decoded: value };
const vc = fcpInt(value.substr(0, 2));
const cls = [];
if (vc & 0x01) cls.push('A');
if (vc & 0x02) cls.push('B');
if (vc & 0x04) cls.push('C');
if (vc & 0x08) cls.push('D');
if (vc & 0x10) cls.push('E');
const ma = fcpInt(value.substr(2, 2)) & 0x3F;
const f = fcpInt(value.substr(4, 2));
const parts = [];
if (cls.length) parts.push('class ' + cls.join('/'));
if (ma) parts.push(ma + ' mA');
if (f === 0xFF) parts.push('no reference frequency');
else if (f) parts.push((f / 10) + ' MHz');
return { name: 'Application power consumption', decoded: parts.join(', ') };
}
case 'A5/82': {
const f = fcpInt(value);
return { name: 'Minimum application clock frequency', decoded: f === 0xFF ? 'none' : (f / 10) + ' MHz' };
}
case 'A5/83': return { name: 'Available memory', decoded: fcpInt(value) + ' bytes' };
case 'A5/84': return { name: 'File details', decoded: (fcpInt(value) & 0x01) ? 'DER coding only' : 'no details' };
case 'A5/85': return { name: 'Reserved file size', decoded: fcpInt(value) + ' bytes' };
case 'A5/86': return { name: 'Maximum file size', decoded: fcpInt(value) + ' bytes' };
case 'A5/87': return { name: 'Supported system commands', decoded: (fcpInt(value) & 0x01) ? 'TERMINAL CAPABILITY' : 'none' };
case 'A5/88': {
const b = fcpInt(value);
const tc = ['standard temperature range', 'temperature class A', 'temperature class B', 'temperature class C'][b & 0x07] || 'RFU';
return { name: 'Specific UICC environmental conditions', decoded: tc + ((b & 0x08) ? ', high humidity' : '') };
}
case 'A5/89': return { name: 'Platform-to-platform CAT secured APDU', decoded: value };
case 'A5/8A': return { name: 'Test configuration state', decoded: (fcpInt(value) & 0x01) ? 'active' : 'inactive' };
case 'A5/8B': return { name: 'Test configuration criterion type', decoded: value };
case 'A5/C0': return { name: 'Special file info', decoded: value };
case 'A5/C1': return { name: 'Filling pattern', decoded: value };
case 'A5/C2': return { name: 'Repeat pattern', decoded: value };
case 'C6/90': return { name: 'PS_DO', decoded: value };
case 'C6/95': return { name: 'Usage qualifier', decoded: value };
case 'C6/83': return { name: 'Key reference', decoded: value };
default: return { name: 'Unknown TLV ' + key, decoded: value };
}
}
// Decode a SELECT response (FCI): accepts the FCP template '62', an FCI template
// '6F' wrapping FCP/FMD, or bare FCP content. Returns { ok, template, items }.
// Partial-aware BER-TLV walk: parses as many complete TLVs as possible and
// reports where/why it stopped (truncated header/length/value, trailing data).
function fcpParseTlvs(hex) {
const tlvs = [];
let i = 0;
let error = '';
while (i < hex.length) {
const remaining = hex.length - i;
if (remaining < 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete tag'; break; }
const tag = hex.substr(i, 2);
let tagBytes = 2;
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
tagBytes = 4;
if (remaining < 4) { error = 'Truncated at offset ' + (i / 2) + ': incomplete 2-byte tag'; break; }
}
if (remaining < tagBytes + 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete TLV header'; break; }
const lb = parseInt(hex.substr(i + tagBytes, 2), 16);
const nLen = lb < 128 ? 0 : (lb & 0x7F);
if (i + tagBytes + 2 + nLen * 2 > hex.length) { error = 'Truncated length field at offset ' + ((i + tagBytes) / 2); break; }
const r = parseBerLen(hex, i + tagBytes);
if (!(r.len >= 0)) { error = 'Invalid length form at offset ' + ((i + tagBytes) / 2); break; }
const consumed = tagBytes + r.consumed;
const total = consumed + r.len * 2;
if (remaining < total) {
error = 'TLV ' + tag + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(remaining - consumed, 0) / 2) + ' available';
break;
}
tlvs.push({ tag, length: r.len, value: hex.substr(i + consumed, r.len * 2), raw: hex.substr(i, total) });
i += total;
}
return { tlvs, error, consumed: i };
}
function fcpDecode(hex) {
const h = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (!h) return { ok: false, template: null, items: [], error: '' };
let template = null;
let body = h;
let error = '';
const first = h.substr(0, 2);
if ((first === '62' || first === '64' || first === '6F') && h.length >= 4) {
const lb = parseInt(h.substr(2, 2), 16);
const nLen = lb < 128 ? 0 : (lb & 0x7F);
if (4 + nLen * 2 > h.length) {
error = 'TLV ' + first + ' has a truncated length field';
body = '';
} else {
const r = parseBerLen(h, 2);
const consumed = 2 + r.consumed;
const avail = h.length - consumed;
template = first;
body = h.substr(consumed, Math.min(r.len * 2, Math.max(avail, 0)));
if (r.len * 2 > avail) error = 'TLV ' + first + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(avail, 0) / 2) + ' available';
else if (consumed + r.len * 2 < h.length) error = 'Trailing data after TLV ' + first;
}
if (template === '6F' && body) {
const inner = fcpParseTlvs(body);
const fcp = inner.tlvs.find(t => t.tag === '62');
if (fcp) {
template = '62';
body = fcp.value;
if (!error && inner.error) error = inner.error;
}
}
}
const parsed = fcpParseTlvs(body);
if (!error && parsed.error) error = parsed.error;
const items = [];
for (const t of parsed.tlvs) {
const d = fcpDo(t.tag, t.value);
items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded });
if (t.tag === 'A5' || t.tag === 'C6') {
const sub = fcpParseTlvs(t.value);
if (!error && sub.error) error = 'In ' + t.tag + ': ' + sub.error;
for (const s of sub.tlvs) {
const sd = fcpDo(t.tag + '/' + s.tag, s.value);
items.push({ key: t.tag + '/' + s.tag, tag: s.tag, name: sd.name, value: s.value, decoded: sd.decoded });
}
}
}
return { ok: !error && items.length > 0, template, items, error };
}
function fcpDiffHtml(expectedHex, actualHex) {
const e = fcpDecode(expectedHex);
const a = fcpDecode(actualHex);
const eBad = !!e.error, aBad = !!a.error;
if (!eBad && !aBad && (!e.items.length || !a.items.length)) return '';
const eMap = {}, aMap = {};
e.items.forEach(it => { eMap[it.key] = it; });
a.items.forEach(it => { aMap[it.key] = it; });
const keys = e.items.map(it => it.key);
for (const it of a.items) if (!eMap[it.key]) keys.push(it.key);
let html = '';
if (keys.length) {
html += '<div class="mt-1 text-xs text-gray-500 dark:text-slate-400">' + esc(t('FCP parameters')) + '</div>';
html += '<table class="w-full text-xs border-collapse">';
html += '<thead><tr class="border-b border-gray-200 dark:border-slate-700">' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(t('Parameter')) + '</th>' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(t('expected')) + '</th>' +
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(t('actual')) + '</th></tr></thead><tbody>';
for (const k of keys) {
const ev = eMap[k], av = aMap[k];
const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—';
const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—';
const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value;
const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium';
html += '<tr class="border-b border-gray-100 dark:border-slate-800">' +
'<td class="py-0.5 px-1 ' + color + '">' + esc((ev || av).name) + '</td>' +
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ed) + '</td>' +
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ad) + '</td></tr>';
}
html += '</tbody></table>';
}
if (eBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('expected')) + ': ' + esc(e.error) + '</div>';
if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('actual')) + ': ' + esc(a.error) + '</div>';
return html;
}
function profilerFciPreviewItems(hex) {
const d = fcpDecode(hex);
if (!d.items.length && !d.error) return '';
let html = '';
for (const it of d.items) {
if (it.decoded === null) continue;
html += '<div>' + esc(it.name) + ': <span class="text-gray-800 dark:text-slate-200">' + esc(it.decoded || it.value) + '</span></div>';
}
if (d.error) {
html += '<div class="text-red-600">' + esc(t('Decode failed')) + ': ' + esc(d.error) + '</div>';
}
return html;
}
function profilerUpdateFciPreview(i, value) {
const el = document.getElementById('profiler-fci-preview-' + i);
if (el) el.innerHTML = profilerFciPreviewItems(value);
}
function profilerFciInput(i, value) {
profilerUpdateRule(i, 'fciHex', value);
profilerUpdateFciPreview(i, value);
}
function profilerRenderReport(results) {
let html = '';
for (const r of results) {
@@ -7774,6 +8041,7 @@ function profilerRenderReport(results) {
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
'<span class="text-xs text-gray-500 dark:text-slate-400 w-24 text-right shrink-0">' + esc(t('actual')) + '</span>' +
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual));
html += '</div>';
} else {
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(t('expected')) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(t('actual')) + ' <b>' + esc(String(c.actual)) + '</b></div>';
@@ -8002,6 +8270,10 @@ const LANG_RU = {
'Filetype + size (FCP)': 'Тип файла + размер (FCP)',
'Exact FCI': 'Полный FCI',
'FCI hex (raw SELECT response)': 'FCI hex (сырой ответ SELECT)',
'FCP parameters': 'Параметры FCP',
'Parameter': 'Параметр',
'Decoded FCI': 'Декодированный FCI',
'Decode failed': 'Ошибка декодирования',
'No profiles defined.': 'Профили не заданы.',
'rules': 'правил',
'Check card': 'Проверить карту',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v70';
const CACHE = 'otaman-v76';
const URLS = [
'index.html',
'help.html',
+33
View File
@@ -165,6 +165,39 @@ test('parseTlvList handles BER-TLVs', () => {
assert.strictEqual(tlvs[1].tag, '82');
});
test('parseBerLen handles short and long form lengths (ISO 7816-4 5.2)', () => {
assert.deepStrictEqual(parseBerLen('1200', 0), { len: 18, consumed: 2 });
assert.deepStrictEqual(parseBerLen('8112', 0), { len: 18, consumed: 4 });
assert.deepStrictEqual(parseBerLen('820100', 0), { len: 256, consumed: 6 });
assert.deepStrictEqual(parseBerLen('820182' + '0102030405060708', 0), { len: 386, consumed: 6 });
});
test('parseTlvList parses long-form lengths (81/82)', () => {
const short = parseTlvList('6212' + '8202412183026F078A010580020009880110');
assert.strictEqual(short.length, 1);
assert.strictEqual(short[0].tag, '62');
assert.strictEqual(short[0].length, 18);
// same content with a long-form outer length
const long81 = parseTlvList('628112' + '8202412183026F078A010580020009880110');
assert.strictEqual(long81.length, 1);
assert.strictEqual(long81[0].length, 18);
assert.strictEqual(long81[0].value, short[0].value);
// 2-byte length form
const long82 = parseTlvList('62820004' + '80020009');
assert.strictEqual(long82.length, 1);
assert.strictEqual(long82[0].length, 4);
assert.strictEqual(long82[0].value, '80020009');
// inner TLV with a long-form length (A5 81 05 85 03 00 00 00)
const inner = parseTlvList('A581058503000000');
assert.strictEqual(inner.length, 1);
assert.strictEqual(inner[0].tag, 'A5');
assert.strictEqual(inner[0].length, 5);
assert.strictEqual(inner[0].value, '8503000000');
});
test('gsm7Decode unpacks "HI" from C824', () => {
const bytes = new Uint8Array([0xC8, 0x24]);
assert.strictEqual(gsm7Decode(bytes), 'HI');
+200 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -576,3 +576,202 @@ test('profilerRenderReport renders raw-data mismatches as aligned readonly field
assert.ok(!fciBlock.includes(': expected'));
delete global.t;
});
// --- FCP/FCI decoding (ISO 7816-4 5.3.3, TS 102 221 11.1.1.4) ---
const FCP_TRANSPARENT = '6212800200098202412183026F078A0105880110';
const FCP_LINFIX = '620E82054221006E0583026F3A8A0105';
const FCP_DF = '620B8202782183027F208A0105';
const FCP_BERTLV = '620B8202792183026F018A0105';
test('fcpDecode decodes a transparent EF FCP template', () => {
const d = fcpDecode(FCP_TRANSPARENT);
assert.strictEqual(d.ok, true);
assert.strictEqual(d.template, '62');
const by = {};
d.items.forEach(it => { by[it.key] = it; });
assert.strictEqual(by['80'].decoded, '9 bytes');
assert.strictEqual(by['82'].decoded, 'not shareable, working EF, transparent, data coding 21');
assert.strictEqual(by['83'].decoded, '6F07');
assert.strictEqual(by['8A'].decoded, 'operational, activated');
assert.strictEqual(by['88'].decoded, '2');
});
test('fcpDecode decodes linear fixed file descriptor (record length/count)', () => {
const d = fcpDecode(FCP_LINFIX);
assert.strictEqual(d.ok, true);
const fd = d.items.find(it => it.key === '82');
assert.strictEqual(fd.decoded, 'not shareable, working EF, linear fixed, data coding 21, record length 110, 5 records');
});
test('fcpDecode decodes DF and BER-TLV structures', () => {
assert.strictEqual(fcpDecode(FCP_DF).items.find(it => it.key === '82').decoded, 'not shareable, DF/ADF, data coding 21');
assert.strictEqual(fcpDecode(FCP_BERTLV).items.find(it => it.key === '82').decoded, 'not shareable, BER-TLV EF, data coding 21');
});
test('fcpDecode maps life cycle status per table 11.7b', () => {
const lcs = v => fcpDecode('6207820278218A01' + v).items.find(it => it.key === '8A').decoded;
assert.strictEqual(lcs('00'), 'no information');
assert.strictEqual(lcs('01'), 'creation');
assert.strictEqual(lcs('03'), 'initialization');
assert.strictEqual(lcs('05'), 'operational, activated');
assert.strictEqual(lcs('04'), 'operational, deactivated');
assert.strictEqual(lcs('0C'), 'termination');
assert.strictEqual(lcs('0D'), 'termination');
assert.strictEqual(lcs('80'), 'proprietary (80)');
});
test('fcpDecode decodes A5 proprietary sub-TLVs', () => {
const d = fcpDecode('62128202412183026F078A0105A5058503000000');
const by = {};
d.items.forEach(it => { by[it.key] = it; });
assert.strictEqual(by['A5'].decoded, null);
assert.strictEqual(by['A5/85'].decoded, '0 bytes');
});
test('fcpDecode unwraps an FCI 6F template and rejects malformed input', () => {
const d = fcpDecode('6F146212800200098202412183026F078A0105880110');
assert.strictEqual(d.ok, true);
assert.strictEqual(d.template, '62');
assert.strictEqual(d.items.find(it => it.key === '80').decoded, '9 bytes');
assert.strictEqual(fcpDecode('').ok, false);
assert.strictEqual(fcpDecode('ZZZZ').ok, false);
assert.strictEqual(fcpDecode('6214800200098202412183').ok, false); // truncated
assert.strictEqual(fcpDecode('6213' + FCP_TRANSPARENT.slice(4)).ok, false); // wrong outer length
});
test('fcpDecode accepts long-form BER lengths (81/82) — editor preview regression', () => {
// 62 12 <18B> vs 62 81 12 <same content>
const long81 = '628112' + FCP_TRANSPARENT.slice(4);
const d1 = fcpDecode(long81);
assert.strictEqual(d1.ok, true);
assert.strictEqual(d1.items.find(it => it.key === '80').decoded, '9 bytes');
// 2-byte length form
const d2 = fcpDecode('62820004' + '80020009');
assert.strictEqual(d2.ok, true);
assert.strictEqual(d2.items.find(it => it.key === '80').decoded, '9 bytes');
// nested A5 with a long-form length (A5 81 05 …)
const d3 = fcpDecode('62138202412183026F078A0105A581058503000000');
assert.strictEqual(d3.ok, true);
assert.strictEqual(d3.items.find(it => it.key === 'A5/85').decoded, '0 bytes');
// FCP content >= 128 bytes uses a long-form outer length
let big = '';
for (let i = 0; i < 13; i++) big += '8808' + '0102030405060708'; // 13 x 10B = 130B
const d4 = fcpDecode('628182' + big);
assert.strictEqual(d4.ok, true);
assert.strictEqual(d4.items.filter(it => it.key === '88').length, 13);
global.t = s => s;
assert.ok(profilerFciPreviewItems(long81).includes('File size: '));
delete global.t;
});
test('fcpDiffHtml highlights differing FCP parameters', () => {
global.t = s => s;
const same = fcpDiffHtml(FCP_TRANSPARENT, FCP_TRANSPARENT);
assert.ok(same.includes('File size'));
assert.ok(!same.includes('text-red-600'));
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110');
assert.ok(diff.includes('9 bytes'));
assert.ok(diff.includes('10 bytes'));
assert.ok(diff.includes('text-red-600'));
assert.ok(fcpDiffHtml('garbage', FCP_TRANSPARENT).includes('Truncated length field'));
assert.strictEqual(fcpDiffHtml('', ''), '');
delete global.t;
});
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
global.t = s => s;
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed'));
assert.strictEqual(profilerFciPreviewItems(''), '');
delete global.t;
});
test('profilerFciInput stores the edited FCI and re-decodes the preview', () => {
global.t = s => s;
global.profilerDraft = { rules: [{ fciHex: '', fciMode: 'exact' }] };
const el = { innerHTML: '' };
global.document = { getElementById: id => (id === 'profiler-fci-preview-0' ? el : null) };
profilerFciInput(0, FCP_TRANSPARENT);
assert.strictEqual(global.profilerDraft.rules[0].fciHex, FCP_TRANSPARENT);
assert.ok(el.innerHTML.includes('File size: '));
profilerFciInput(0, 'zz');
assert.strictEqual(el.innerHTML, '');
global.profilerDraft = null;
delete global.document;
delete global.t;
});
test('profilerRenderReport shows the decoded FCI diff for a raw fci mismatch', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([
{ path: 'MF/6F07', name: 'EF.IMSI', status: 'fail', checks: [
{ label: 'fci', ok: false, expected: FCP_TRANSPARENT, actual: '62128002000A8202412183026F078A0105880110' },
] },
]);
assert.ok(html.includes('FCP parameters'));
assert.ok(html.includes('9 bytes'));
assert.ok(html.includes('10 bytes'));
assert.ok(html.includes('text-red-600'));
delete global.t;
});
// --- partial/corrupt FCI decoding ---
test('fcpDecode reports truncation but keeps the TLVs parsed before it', () => {
// outer declares 22 bytes (0x16) but only the 18-byte body is present
const truncOuter = '6216' + FCP_TRANSPARENT.slice(4);
const d1 = fcpDecode(truncOuter);
assert.strictEqual(d1.ok, false);
assert.match(d1.error, /TLV 62 declares 22 bytes, only 18 available/);
assert.strictEqual(d1.items.find(it => it.key === '80').decoded, '9 bytes');
// correct outer length, truncated inner TLV (82 declares 5 bytes, 2 present)
const d2 = fcpDecode('620880020009820541 21'.replace(/ /g, ''));
assert.strictEqual(d2.ok, false);
assert.match(d2.error, /TLV 82 declares 5 bytes, only 2 available/);
assert.strictEqual(d2.items.find(it => it.key === '80').decoded, '9 bytes');
assert.ok(!d2.items.some(it => it.key === '82'));
// truncated length field
const d3 = fcpDecode('6281');
assert.strictEqual(d3.ok, false);
assert.match(d3.error, /truncated length field/);
assert.strictEqual(d3.items.length, 0);
// truncated inner A5 sub-TLV reports the context
const d4 = fcpDecode('620A82024121A50488050102');
assert.strictEqual(d4.ok, false);
assert.match(d4.error, /In A5: TLV 88 declares 5 bytes, only 2 available/);
});
test('profilerFciPreviewItems shows decoded data plus an explicit failure note', () => {
global.t = s => s;
const trunc = '6216' + FCP_TRANSPARENT.slice(4);
const html = profilerFciPreviewItems(trunc);
assert.ok(html.includes('File size: '), html);
assert.ok(html.includes('Decode failed'), html);
assert.ok(html.includes('declares 22 bytes'), html);
// empty input renders nothing; garbage reports the failure
assert.strictEqual(profilerFciPreviewItems(''), '');
assert.ok(profilerFciPreviewItems('not hex').includes('Decode failed'));
delete global.t;
});
test('fcpDiffHtml appends decode-failure notes for corrupt sides', () => {
global.t = s => s;
const trunc = '6216' + FCP_TRANSPARENT.slice(4);
const html = fcpDiffHtml(trunc, FCP_TRANSPARENT);
assert.ok(html.includes('FCP parameters'), html);
assert.ok(html.includes('9 bytes'));
assert.ok(html.includes('expected: ') && html.includes('TLV 62 declares 22 bytes'));
// both sides empty -> nothing rendered
assert.strictEqual(fcpDiffHtml('', ''), '');
delete global.t;
});