From 3bf005619e286f8ecc7d0184ece3fe982dedf49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 10 Oct 2026 23:09:47 +0300 Subject: [PATCH] v3.25.0: the access-rules check in profiles A profile file rule may carry accessRules {expected, enabled?, ignoreFciRef?}: the check resolves the file FCI's '8B' security-attribute reference against the card's (or the snapshot's) EF.ARR copies and compares the decoded rules as a set - record-number independent, so cards with a different EF.ARR layout still verify. Missing/extra lines are listed in the report; a file whose FCI lost the reference fails; a reference that cannot be resolved (no cached EF.ARR / record out of range) is an unverifiable error. Capture fills the lines when a profile is created from a card (pysimArrEnsure) or a snapshot (profilerSnapshotArr), before the contents ignore list - the rules are the meaningful per-file check, and EF.ARR's own contents stay ignored by default as before. The editor shows the captured lines read-only with a None/Check toggle; with exact FCI an optional "Ignore the EF.ARR reference in the exact FCI" checkbox compares the FCI without the '8B' value (every other DO stays byte-exact; fcpWithoutArrRef), so a different record number no longer fails while the rules check owns its meaning. The snapshot comparison is unchanged (byte-exact EF.ARR + FCI catch record renumbering). New 'Access rules' aspect in the report/summary with a matching/missing/extra line diff. Tests: capture, the four check outcomes, the ignore-FCI path, the aspect/report, validation. 877 frontend + 857 Python; sw cache simple-v424. --- frontend/help-ru.html | 3 +- frontend/help.html | 3 +- frontend/index.html | 210 +++++++++++++++++++++++++++++--- frontend/sw.js | 2 +- frontend/tests/profiler.test.js | 132 +++++++++++++++++++- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- 7 files changed, 331 insertions(+), 23 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 89fce4f..2e46d5b 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -466,10 +466,11 @@ -

Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; расхождения содержимого дополнительно показывают для файлов с декодером декодированное сравнение по полям (только отличающиеся поля, с заголовками колонок «ожидаемое/фактическое» над значениями; если декодированные значения совпадают, вместо таблицы выводится пометка об этом, а сторона, чья запись целиком из FF, показывает в своих ячейках Пусто (только FF)), а для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны ожидалось (имя профиля) и фактически (ICCID карты), а в заголовке отчёта выводится Результаты проверки профиля: <профиль> → <ICCID карты>. Опция «Только расхождения» в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.

+

Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; расхождения содержимого дополнительно показывают для файлов с декодером декодированное сравнение по полям (только отличающиеся поля, с заголовками колонок «ожидаемое/фактическое» над значениями; если декодированные значения совпадают, вместо таблицы выводится пометка об этом, а сторона, чья запись целиком из FF, показывает в своих ячейках Пусто (только FF)), а для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Расхождения правил доступа выводят декодированные строки правил (✓ совпадает, − отсутствует, + лишнее). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны ожидалось (имя профиля) и фактически (ICCID карты), а в заголовке отчёта выводится Результаты проверки профиля: <профиль> → <ICCID карты>. Опция «Только расхождения» в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.

Опции сканирования «Профиль с карты»

Диалог сканирования запрашивает имя профиля и предлагает селектор «Проверка FCP/FCI» (те же три режима, по умолчанию Тип файла + размер (FCP)), применяемый ко всем создаваемым правилам, а также список «Игнорировать содержимое файлов» (все отмечены по умолчанию, кроме EF.ARR; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS, EF.ACC, EF.EPSNSC, EF.START-HFN, EF.ARR. Ещё две отмеченные по умолчанию опции «Сравнивать первые 4 байта для» EF.IMSI и EF.ICCID захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает N / всего файлов с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки «Пользовательские файлы» включаются с той же проверкой существования. Тот же диалог и опции использует «Профиль из снимка»: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).

diff --git a/frontend/help.html b/frontend/help.html index adcd0c3..a6ce0be 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -465,10 +465,11 @@ -

Check card 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. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) 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; content mismatches additionally show, for files with a decoder, a decoded per-field comparison (only the differing fields, with expected/actual column headers above the side-by-side values; when the decoded values match, a note says so instead of the table, and a side whose record is all-FF shows Empty (all FF) in its cells), and 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 matching records: 1-5, 7-10 note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled expected (profile name) and actual (card ICCID), and the results header reads Profile verification results for: <profile> → <card ICCID>. An Only mismatches option in the results header hides all passing files and keeps only failures and errors.

+

Check card 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. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) 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; content mismatches additionally show, for files with a decoder, a decoded per-field comparison (only the differing fields, with expected/actual column headers above the side-by-side values; when the decoded values match, a note says so instead of the table, and a side whose record is all-FF shows Empty (all FF) in its cells), and FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). Access-rule mismatches list the decoded rule lines (✓ matching, − missing, + extra). 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 matching records: 1-5, 7-10 note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled expected (profile name) and actual (card ICCID), and the results header reads Profile verification results for: <profile> → <card ICCID>. An Only mismatches option in the results header hides all passing files and keeps only failures and errors.

“Profile from card” scan options

The scan dialog asks for a profile name and offers a “FCP/FCI check” selector (the same three modes above, default Filetype + size) applied to every generated rule, plus an “Ignore contents of files” checklist (all checked by default except EF.ARR; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS, EF.ACC, EF.EPSNSC, EF.START-HFN, EF.ARR. Two further checked-by-default options “Match first 4 bytes for” EF.IMSI and EF.ICCID capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows N / total files 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 Custom files sub-tab are included under the same existence check. The same dialog and options are used by Profile from snapshot, which walks the selected snapshot’s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).

diff --git a/frontend/index.html b/frontend/index.html index 376a81a..4fe5027 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1905,7 +1905,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.24.0'; +const SIMPLE_VERSION = '3.25.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -18939,6 +18939,11 @@ function profilerValidateProfile(obj) { if (r.type !== 'file') return 'Unsupported rule type: ' + r.type; if (!r.path) return 'Rule missing path'; if (r.fciMode && !['type', 'type_size', 'exact'].includes(r.fciMode)) return 'Invalid FCP/FCI mode: ' + r.fciMode; + if (r.accessRules !== undefined && r.accessRules !== null) { + const ar = r.accessRules; + if (typeof ar !== 'object' || !Array.isArray(ar.expected) || + ar.expected.some(x => typeof x !== 'string')) return 'Invalid access rules: ' + r.path; + } } return null; } @@ -19287,7 +19292,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma let _scanSnapshot = null; -function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, maskFids) { +function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, maskFids, arrs) { const isRecord = profilerContentKindForFileType(f.fileType) === 'record'; const rule = { type: 'file', @@ -19301,6 +19306,12 @@ function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, fciHex: f.fciHex || null, content: null, }; + // Access rules from the snapshot's own EF.ARR copies (v3.25.0) + const ref = fcpArrRefFromFci(rule.fciHex); + if (ref) { + const got = fcpArrRules(ref, arrs || [], f.path); + if (got.rules.length) rule.accessRules = { expected: got.rules }; + } const seg = (f.path || '').toUpperCase().split('/').pop(); const fid = /^[0-9A-F]{4}$/.test(seg) ? seg : ''; const name = (f.name || '').toUpperCase(); @@ -19324,11 +19335,13 @@ function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, async function profilerScanSnapshot(snapshot, ignoreFids, ignoreNames, fciMode, maskFids, onProgress) { const files = (snapshot && snapshot.files) ? snapshot.files : []; const total = files.length; + // the snapshot's own EF.ARR copies feed every rule's access-rules capture + const arrs = profilerSnapshotArr(snapshot); if (onProgress) onProgress(0, total, ''); const rules = []; for (let i = 0; i < files.length; i++) { if (onProgress) onProgress(i + 1, total, files[i].path); - rules.push(profilerBuildFileRuleFromSnapshot(files[i], ignoreFids, ignoreNames, fciMode, maskFids)); + rules.push(profilerBuildFileRuleFromSnapshot(files[i], ignoreFids, ignoreNames, fciMode, maskFids, arrs)); if (i % 20 === 19) await new Promise(r => setTimeout(r, 0)); } return rules; @@ -19353,6 +19366,16 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, fciHex: sel.fci_hex || null, content: null, }; + // Access rules (v3.25.0): resolve the FCP's '8B' reference against the + // card's cached EF.ARR copies. Captured before the ignore list - the + // rules are the meaningful per-file check (contents may be ignored). + try { + const ref = fcpArrRefFromFci(rule.fciHex); + if (ref) { + const got = fcpArrRules(ref, await pysimArrEnsure(), path); + if (got.rules.length) rule.accessRules = { expected: got.rules }; + } + } catch (e) {} const fid = (c.fid || sel.fid || '').toUpperCase(); const name = (c.name || sel.name || '').toUpperCase(); if (ignoreFids.has(fid) || (ignoreNames && ignoreNames.has(name))) return rule; @@ -19779,10 +19802,53 @@ function profilerRenderRule(i, r) { if (r.fileType !== 'df') { html += profilerRenderContentEditor(i, r); } + html += profilerRenderAccessRulesEditor(i, r); html += ''; return html; } +// Access-rules check editor (v3.25.0): the captured rule lines are read-only +// (capture fills them from the card/snapshot); the radio toggles the check. +function profilerRenderAccessRulesEditor(i, r) { + const ar = r.accessRules || null; + const lines = (ar && ar.expected) || []; + const on = !!(ar && ar.enabled !== false && lines.length); + let html = '
'; + html += '' + esc(t('Check access rules')) + ''; + html += '
'; + html += ''; + html += ''; + html += '
'; + if (r.fciMode === 'exact') { + html += ''; + } + if (lines.length) { + html += '
'; + for (const x of lines) { + html += '
' + esc('\u21b3 ' + x) + '
'; + } + html += '
'; + } else { + html += '
' + esc(t('Captured when the profile is created from a card or a snapshot')) + '
'; + } + return html; +} + +function profilerSetAccessRules(i, on) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) r.accessRules = { expected: [] }; + r.accessRules.enabled = on; + profilerRenderEditor(); +} + +function profilerSetAccessRulesIgnore(i, on) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) r.accessRules = { expected: [] }; + r.accessRules.ignoreFciRef = on; +} + function profilerRenderContentEditor(i, r) { const c = r.content; const mode = c ? c.mode : 'exact'; @@ -20091,11 +20157,15 @@ function profilerLiveSource() { return { select: path => pysimFetch('/api/select', { path: path }), read: path => pysimFetch('/api/read', { path: path, mode: 'raw' }), + // the card's ARR cache (read once per session at equip) - fetched + // lazily, only when a rule checks access rules + arrs: () => pysimArrEnsure(), }; } function profilerSnapshotSource(snapshot) { const find = path => (snapshot.files || []).find(f => (f.path || '').toUpperCase() === (path || '').toUpperCase()); + let arrCache = null; return { select: async path => { const f = find(path); @@ -20117,6 +20187,8 @@ function profilerSnapshotSource(snapshot) { if (f.content.kind === 'record') return { success: true, records: f.content.records }; return { success: true, data: f.content.data }; }, + // the snapshot's own EF.ARR copies (memoized per source) + arrs: () => arrCache || (arrCache = profilerSnapshotArr(snapshot)), }; } @@ -20164,11 +20236,50 @@ async function profilerRunRule(r, source) { } } if (fciMode === 'exact' && r.fciHex) { - const expected = profilerNormHexStrict(r.fciHex); - const actual = profilerNormHexStrict(sel.fci_hex); + let expected = profilerNormHexStrict(r.fciHex); + let actual = profilerNormHexStrict(sel.fci_hex); + let ignoredRef = false; + // the EF.ARR reference's record number may differ across cards while + // the access rules match - the rules check owns its meaning then + if (r.accessRules && r.accessRules.enabled !== false && r.accessRules.ignoreFciRef && + (r.accessRules.expected || []).length) { + const e2 = fcpWithoutArrRef(expected); + const a2 = fcpWithoutArrRef(actual); + if (e2 !== null && a2 !== null) { expected = e2; actual = a2; ignoredRef = true; } + } const ok = !!expected && expected === actual; if (!ok) res.status = 'fail'; - res.checks.push({ label: 'fci', expected: r.fciHex, actual: sel.fci_hex || '', ok: ok }); + res.checks.push({ label: 'fci', expected: r.fciHex, actual: sel.fci_hex || '', ok: ok, ignoredRef: ignoredRef }); + } + // Access rules (v3.25.0): the decoded conditions of the EF.ARR record the + // FCI's '8B' reference points at - record-number independent, so cards + // with a different EF.ARR layout still verify. + if (r.accessRules && r.accessRules.enabled !== false && (r.accessRules.expected || []).length) { + const ref = fcpArrRefFromFci(sel.fci_hex); + if (!ref) { + res.status = 'fail'; + res.checks.push({ label: 'accessRules', expected: r.accessRules.expected, actual: null, ok: false, + missing: r.accessRules.expected.slice(), extra: [] }); + } else { + let arrs = []; + try { + arrs = !source.arrs ? [] : (typeof source.arrs === 'function' ? await source.arrs() : source.arrs); + } catch (e) { arrs = []; } + const got = fcpArrRules(ref, arrs, r.path); + if (!got.found) { + if (res.status === 'pass') res.status = 'error'; + res.checks.push({ label: 'accessRules', expected: r.accessRules.expected, actual: null, ok: false, + unresolved: true, missing: [], extra: [] }); + } else { + const exp = r.accessRules.expected; + const missing = exp.filter(x => !got.rules.includes(x)); + const extra = got.rules.filter(x => !exp.includes(x)); + const ok = !missing.length && !extra.length; + if (!ok) res.status = 'fail'; + res.checks.push({ label: 'accessRules', expected: exp, actual: got.rules, ok: ok, + missing: missing, extra: extra }); + } + } } if (r.content) { let rd; @@ -20233,7 +20344,7 @@ function profilerCustomNameForPath(path) { // that actually ran. 'Exact FCI' subsumes filetype/size/records; 'exists' is // implicit and omitted. function profilerResultAspects(res) { - const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [] }; + const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [], accessRules: [] }; for (const c of (res.checks || [])) { const l = c.label; if (l === 'fileType') groups.filetype.push(c); @@ -20241,6 +20352,7 @@ function profilerResultAspects(res) { else if (l === 'recordLen' || l === 'numRecords') groups.records.push(c); else if (l === 'fci') groups['Exact FCI'].push(c); else if (l === 'content' || l.startsWith('content.')) groups.contents.push(c); + else if (l === 'accessRules') groups.accessRules.push(c); } const out = []; if (groups['Exact FCI'].length) { @@ -20251,6 +20363,7 @@ function profilerResultAspects(res) { if (groups.records.length) out.push({ key: 'records', ok: groups.records.every(c => c.ok) }); } if (groups.contents.length) out.push({ key: 'contents', ok: groups.contents.every(c => c.ok) }); + if (groups.accessRules.length) out.push({ key: 'Access rules', ok: groups.accessRules.every(c => c.ok) }); return out; } @@ -20269,6 +20382,7 @@ function profilerAspectSummary(aspects, marked, tr) { const parts = []; if (fcp) parts.push(fcp); if (has('contents')) parts.push(tr('contents')); + if (has('Access rules')) parts.push(tr('Access rules')); return parts.join(', '); } @@ -20409,14 +20523,35 @@ function fcpSeidName(seid) { return 'SE' + (Number(seid) || 0).toString(16).toUpperCase().padStart(2, '0'); } +// The FCP's '8B' referencing DO of an FCI hex, parsed (or null). +function fcpArrRefFromFci(hex) { + const item = fcpDecode(hex).items.find(i => i.key === '8B'); + return item ? fcpArrRef(item.value) : null; +} + +// The FCI without its top-level '8B' referencing DO: the exact-FCI comparison +// with `ignoreFciRef` compares these, so a different EF.ARR record number no +// longer fails while every other DO is still compared byte-exactly (the +// reference's meaning is verified by the access-rules check). Returns null +// when the FCI does not parse - the caller falls back to the strict compare. +function fcpWithoutArrRef(hex) { + const d = fcpDecode(hex); + if (!d.items.length || d.error) return null; + const parts = d.items.filter(i => i.key.indexOf('/') < 0 && i.key !== '8B').map(i => i.raw || ''); + if (parts.some(p => !p)) return null; + return (d.template || '') + ':' + parts.join(''); +} + // Access rules of the record(s) an FCP '8B' reference points at, resolved // against a set of EF.ARR copies ({fid, path, records[]} - the server's // /api/arr cache or a snapshot's own EF.ARR files). `path` is the tree path // of the file whose FCI is shown: it picks the ARR copy of that DF (the // ISO 7816-4 5.4.3.3 lookup order - ADF files use the ADF's 6F06 copy). -// Returns [] when nothing resolves - the caller stays silent (v3.24.0). +// Returns {found, rules}: found = at least one referenced record exists; +// rules = the decoded rule lines (empty when nothing resolves - the caller +// stays silent, v3.24.0). function fcpArrRules(ref, arrs, path) { - if (!ref || !arrs || !arrs.length) return []; + if (!ref || !arrs || !arrs.length) return { found: false, rules: [] }; const df = (((path || '').split('/')[0]) || 'MF').toUpperCase(); const pick = fid => { const hits = arrs.filter(a => String(a.fid || '').toUpperCase() === fid); @@ -20424,10 +20559,12 @@ function fcpArrRules(ref, arrs, path) { return hits.find(a => String(a.path || '').split('/')[0].toUpperCase() === df) || hits[0]; }; const out = []; + let found = false; const add = (label, fid, record) => { const arr = pick(fid); const recs = (arr && arr.records) || []; if (!(record >= 1) || record > recs.length) return; + found = true; const dec = efDecArr(efBytes(recs[record - 1] || '')); for (const rule of ((dec && dec.rules) || [])) { out.push((label ? label + ': ' : '') + rule); @@ -20440,7 +20577,7 @@ function fcpArrRules(ref, arrs, path) { } else { add('', ref.fid, ref.record); } - return out; + return { found: found, rules: out }; } function fcpDo(key, value) { @@ -20598,13 +20735,13 @@ function fcpDecode(hex) { 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 }); + items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded, raw: t.raw }); 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 }); + items.push({ key: t.tag + '/' + s.tag, tag: s.tag, name: sd.name, value: s.value, decoded: sd.decoded, raw: s.raw }); } } } @@ -20616,18 +20753,19 @@ function profilerLabelText(labels, which) { return labels.prefix ? t(which) + ' (' + labels[which] + ')' : labels[which]; } -function fcpDiffHtml(expectedHex, actualHex, labels) { +function fcpDiffHtml(expectedHex, actualHex, labels, opts) { const expLabel = profilerLabelText(labels, 'expected'); const actLabel = profilerLabelText(labels, 'actual'); 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 skipKey = k => !!(opts && opts.ignoreArrRef && k === '8B'); 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); + const keys = e.items.map(it => it.key).filter(k => !skipKey(k)); + for (const it of a.items) if (!eMap[it.key] && !skipKey(it.key)) keys.push(it.key); let html = ''; if (keys.length) { html += '
' + esc(t('FCI parameters')) + '
'; @@ -20651,6 +20789,10 @@ function fcpDiffHtml(expectedHex, actualHex, labels) { } if (eBad) html += '
' + esc(expLabel) + ': ' + esc(e.error) + '
'; if (aBad) html += '
' + esc(actLabel) + ': ' + esc(a.error) + '
'; + if (opts && opts.ignoreArrRef) { + html += '
' + + esc(t('EF.ARR reference ignored (verified by the access-rules check)')) + '
'; + } return html; } @@ -20689,7 +20831,7 @@ function profilerFciPreviewItems(hex, opts) { // ARR content (the file manager's live cache, or a snapshot's copy) const ref = it.key === '8B' ? fcpArrRef(it.value) : null; rows.push({ key: it.key, name: it.name, text: it.decoded || it.value, - sub: ref ? fcpArrRules(ref, opts.arr, opts.path) : [] }); + sub: ref ? fcpArrRules(ref, opts.arr, opts.path).rules : [] }); } const fi = rows.find(r => r.key === '83'); const sfi = rows.find(r => r.key === '88'); @@ -20720,6 +20862,30 @@ function profilerFciInput(i, value) { profilerUpdateFciPreview(i, value); } +// A failed access-rules check: the decoded rules of the referenced EF.ARR +// record, expected vs actual (set diff; v3.25.0). +function profilerAccessRulesDiffHtml(c) { + let html = '
'; + html += '
' + esc(t('Access rules')) + '
'; + if (c.unresolved) { + html += '
' + + esc(t('the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)')) + '
'; + return html + '
'; + } + if (!c.actual) { + html += '
' + esc(t('the FCI has no EF.ARR reference')) + '
'; + } + const line = (mark, cls, text) => '
' + esc(mark + ' ' + text) + '
'; + for (const x of (c.expected || [])) { + const miss = (c.missing || []).includes(x); + html += line(miss ? '\u2212' : '\u2713', miss ? 'text-red-600' : 'text-gray-500 dark:text-slate-400', x); + } + for (const x of (c.extra || [])) { + html += line('+', 'text-red-600', x); + } + return html + ''; +} + function profilerRenderReport(results, labels) { const expLabel = profilerLabelText(labels, 'expected'); const actLabel = profilerLabelText(labels, 'actual'); @@ -20743,6 +20909,8 @@ function profilerRenderReport(results, labels) { if (c.ok) continue; if (c.label === 'extra file') { html += '
' + esc(t('extra file')) + '
'; + } else if (c.label === 'accessRules') { + html += profilerAccessRulesDiffHtml(c); } else if (profilerRawDataCheck(c)) { const isContent = c.label === 'content' || /^content\.rec\d+$/.test(c.label); const dd = isContent && typeof efContentDiff === 'function' @@ -20776,7 +20944,7 @@ function profilerRenderReport(results, labels) { } else if (dd && dd.sameDecoded) { html += '
' + esc(t('Decoded values match; raw bytes differ')) + '
'; } - if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels); + if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels, c.ignoredRef ? { ignoreArrRef: true } : null); html += ''; } else { html += '
' + esc(c.label) + ': ' + esc(expLabel) + ' ' + esc(String(c.expected)) + ', ' + esc(actLabel) + ' ' + esc(String(c.actual)) + '
'; @@ -22925,6 +23093,14 @@ const LANG_RU = { 'of': 'из', 'None': 'Нет', 'Exact': 'Точное', + 'Check': 'Проверять', + 'Access rules': 'Правила доступа', + 'Check access rules': 'Проверять правила доступа', + 'Ignore the EF.ARR reference in the exact FCI': 'Игнорировать ссылку на EF.ARR в точном FCI', + 'the FCI has no EF.ARR reference': 'в FCI нет ссылки на EF.ARR', + 'the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)': 'ссылку на EF.ARR не удалось разрешить (нет кэша EF.ARR или запись вне диапазона)', + 'Captured when the profile is created from a card or a snapshot': 'Заполняется при создании профиля с карты или из снимка', + 'EF.ARR reference ignored (verified by the access-rules check)': 'Ссылка на EF.ARR игнорируется (проверяется по правилам доступа)', 'Mask': 'Маска', 'Checking': 'Проверка', 'passed': 'пройдено', diff --git a/frontend/sw.js b/frontend/sw.js index d13ae0e..8c6867d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v423'; +const CACHE = 'simple-v424'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index aaeb4e6..774a208 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name, asyncFn) { } const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', - 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath', + 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'fcpArrRefFromFci', 'fcpWithoutArrRef', 'profilerAccessRulesDiffHtml', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath', 'pinKeyRefName', 'fcpPsTemplate', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; @@ -930,6 +930,136 @@ test('pysimArrEnsure does not cache a transient failure', async () => { _pysimCardSession = null; }); +test('fcpWithoutArrRef drops only the 8B referencing DO', () => { + const a = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'; + const b = '621A82054221000F0583026F4F8A01058B032F06098802004B8801B0'; + const wa = fcpWithoutArrRef(a), wb = fcpWithoutArrRef(b); + assert.ok(wa && wb, 'parseable'); + assert.strictEqual(wa, wb); // only the record number differed + assert.ok(!wa.includes('8B03'), wa); // the reference is gone + assert.ok(wa.includes('82054221000F05'), wa); // every other DO is kept + assert.strictEqual(fcpWithoutArrRef('garbage'), null); +}); + +test('fcpArrRefFromFci reads the 8B reference of an FCI', () => { + assert.deepStrictEqual(fcpArrRefFromFci('621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'), + { fid: '2F06', record: 2 }); + assert.strictEqual(fcpArrRefFromFci('621582054221000F0583026F4F8A01058002004B8801B0'), null); +}); + +test('access rules are captured from a snapshot rule', () => { + const snap = { files: [ + { path: 'MF/2F06', name: 'EF.ARR', fileType: 'linear_fixed', recordLen: 6, numRecords: 2, + content: { kind: 'record', records: [{ num: 1, data: 'FFFF' }, + { num: 2, data: '800101A40683010A950108' }] } }, + { path: 'MF/7F20/6F07', name: 'EF.IMSI', + fciHex: '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0' }, + ]}; + const arrs = profilerSnapshotArr(snap); + const rule = profilerBuildFileRuleFromSnapshot(snap.files[1], new Set(), null, 'type_size', null, arrs); + assert.deepStrictEqual(rule.accessRules, + { expected: ['AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'] }); + // without the ARR copies nothing is captured + const bare = profilerBuildFileRuleFromSnapshot(snap.files[1], new Set(), null, 'type_size', null, []); + assert.strictEqual(bare.accessRules, undefined); +}); + +test('profilerRunRule checks the access rules against the ARR copies', async () => { + const FCI2 = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'; + const ADM = 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'; + const ALW = 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'; + const arrs = [{ fid: '2F06', path: 'MF/2F06', records: ['FFFF', '800101A40683010A950108'] }]; + const src = (fci, a) => ({ + select: async () => ({ name: 'EF.SAMPLE', file_type: 'linear_fixed', file_size: null, + record_len: 6, num_of_rec: 2, fci_hex: fci, exists: true }), + read: async () => ({ success: false }), + arrs: () => a, + }); + const rule = fciHex => ({ type: 'file', path: 'MF/7F20/6F07', name: 'EF.SAMPLE', + fileType: 'linear_fixed', fciMode: 'type_size', fciHex: fciHex, accessRules: { expected: [ADM] } }); + // pass + const ok = await profilerRunRule(rule(FCI2), src(FCI2, arrs)); + assert.strictEqual(ok.status, 'pass', JSON.stringify(ok.checks)); + // mismatch: missing + extra + const bad = await profilerRunRule( + { ...rule(FCI2), accessRules: { expected: [ALW] } }, src(FCI2, arrs)); + assert.strictEqual(bad.status, 'fail'); + const c1 = bad.checks.find(c => c.label === 'accessRules'); + assert.deepStrictEqual(c1.missing, [ALW]); + assert.deepStrictEqual(c1.extra, [ADM]); + // no EF.ARR reference on the card + const FCI0 = '621582054221000F0583026F4F8A01058002004B8801B0'; + const noref = await profilerRunRule(rule(FCI2), src(FCI0, arrs)); + assert.strictEqual(noref.status, 'fail'); + assert.strictEqual(noref.checks.find(c => c.label === 'accessRules').actual, null); + // unresolvable (no cached ARR / record out of range) + const unres = await profilerRunRule(rule(FCI2), src(FCI2, [])); + assert.strictEqual(unres.status, 'error'); + assert.ok(unres.checks.find(c => c.label === 'accessRules').unresolved); +}); + +test('ignoreFciRef compares the FCI without the EF.ARR reference', async () => { + const FCI2 = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'; + const FCI9 = '621A82054221000F0583026F4F8A01058B032F06098802004B8801B0'; + const ALW = 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'; + // record 2 and record 9 carry the same rule + const arrs = [{ fid: '2F06', path: 'MF/2F06', + records: ['', '8001019000', '', '', '', '', '', '', '8001019000'] }]; + const src = fci => ({ + select: async () => ({ name: 'EF.SAMPLE', file_type: 'linear_fixed', fci_hex: fci, exists: true }), + read: async () => ({ success: false }), + arrs: () => arrs, + }); + const rule = { type: 'file', path: 'MF/7F20/6F07', fileType: 'linear_fixed', fciMode: 'exact', + fciHex: FCI2, accessRules: { expected: [ALW], ignoreFciRef: true } }; + const res = await profilerRunRule(rule, src(FCI9)); + assert.strictEqual(res.status, 'pass', JSON.stringify(res.checks)); + assert.strictEqual(res.checks.find(c => c.label === 'fci').ignoredRef, true); + // without the flag the same setup fails on the FCI + const strict = await profilerRunRule({ ...rule, accessRules: { expected: [ALW] } }, src(FCI9)); + assert.strictEqual(strict.status, 'fail'); + assert.strictEqual(strict.checks.find(c => c.label === 'fci').ok, false); +}); + +test('profilerResultAspects and the summary include Access rules', () => { + const res = { checks: [ + { label: 'exists', ok: true }, + { label: 'fileType', ok: true }, + { label: 'accessRules', ok: false, expected: ['x'], actual: ['y'], missing: ['x'], extra: ['y'] }, + ]}; + const aspects = profilerResultAspects(res); + assert.ok(aspects.some(a => a.key === 'Access rules' && !a.ok), JSON.stringify(aspects)); + const sum = profilerAspectSummary(aspects, true, s => s); + assert.ok(sum.includes('Access rules'), sum); +}); + +test('profile validation accepts the access-rules field', () => { + const base = { name: 'P', rules: [{ type: 'file', path: 'MF/7F20/6F07' }] }; + assert.strictEqual(profilerValidateProfile(base), null); + assert.strictEqual(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: ['AM 0x01: always'] } }] }), null); + assert.ok(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: 'nope' } }] })); + assert.ok(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: [1] } }] })); +}); + +test('the access-rules report block marks missing and extra lines', () => { + global.t = s => s; + const html = profilerAccessRulesDiffHtml({ label: 'accessRules', ok: false, + expected: ['A', 'B'], actual: ['B', 'C'], missing: ['A'], extra: ['C'] }); + assert.ok(html.includes('\u2212 A'), html); + assert.ok(html.includes('\u2713 B'), html); + assert.ok(html.includes('+ C'), html); + const unres = profilerAccessRulesDiffHtml({ label: 'accessRules', ok: false, unresolved: true, + expected: ['A'], actual: null, missing: [], extra: [] }); + assert.ok(unres.includes('could not be resolved'), unres); + const noref = profilerAccessRulesDiffHtml({ label: 'accessRules', ok: false, + expected: ['A'], actual: null, missing: ['A'], extra: [] }); + assert.ok(noref.includes('no EF.ARR reference'), noref); + delete global.t; +}); + test('pysimFsInfoHtml embeds the symbolic name in the FCI border', () => { global.t = s => s; const hex = '6212800200098202412183026F078A0105880110'; diff --git a/pyproject.toml b/pyproject.toml index c291c9a..ab4ebf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.24.0" +version = "3.25.0" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index dad4957..fcdb3b8 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.24.0' +VERSION = '3.25.0' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE