v3.24.0: the EF.ARR access rules in the FCP view

The file manager's decoded FCI shows the '8B' security attribute
reference (EF_ARR 2F06, record 23).  The referenced record's access
rules are now rendered as indented muted lines under that row, decoded
with the existing EF.ARR record decoder (AM_DO/SC_DO conditions).

The server reads the card's ARR copies (MF/2F06 and ADF.USIM/6F06,
best effort) once per card session in _apply_equipped_card and keeps
them as server.arr; GET /api/arr serves the cache without the card
lock, a disconnect clears it, and a write to an ARR copy through
/api/write refreshes it.  The PWA fetches it lazily per card session
(pysimArrEnsure, keyed by card_session) and resolves the reference with
the selected file's DF context: an ADF.USIM file uses the ADF's 6F06
copy, the implicit reference form too (ISO 7816-4 5.4.3.3 lookup
order).  Unresolvable references stay silent.

The '8B' decode now covers all three ISO 7816-4 Table 25 forms:
implicit ARR byte / FID + record / FID + (SEID, ARR) pairs - the pairs
form was mis-decoded as a record before.  A snapshot resolves the same
references offline from its own captured EF.ARR copy.

Tests: the three forms, the resolution (DF preference, out-of-range
silence), the snapshot path, the server read/cache/route/write-refresh.
868 frontend + 856 Python; sw cache simple-v423.
This commit is contained in:
2026-10-10 00:30:45 +03:00
parent 9b4114012b
commit b6537bd54b
10 changed files with 521 additions and 14 deletions
+22
View File
@@ -74,6 +74,7 @@ a 3.x PWA).
| `/api/net-sim` | POST | Run a network-condition scenario (attach, service loss, roaming, churn, 2G, SMS, CB, AUTHENTICATE) |
| `/api/net-state` | GET | Cached network-state monitor (service, location, per-file state) |
| `/api/net-state-refresh` | POST | Re-read the monitored files (optional `files` list) and return the updated monitor state |
| `/api/arr` | GET | Cached EF.ARR copies (the FCP '8B' security attribute references): records per ARR file |
| `/api/mcc-mnc` | GET | Search the optional MCC/MNC operator list (`?q=`; `?random=1&exclude=`) |
| `/api/proactive-log` | GET | Last 50 proactive commands |
| `/api/status-poll` | POST | Manual STATUS poll + FETCH if 91XX |
@@ -1398,6 +1399,27 @@ optional body filters the read:
Without a body all monitored files are re-read. Answers `503` when the reader
is not initialized.
### `GET /api/arr`
Returns the card's cached **EF.ARR copies** — the access-rule files the FCP
security attribute DO `'8B'` references (ISO 7816-4 Table 25). The copies
are read once per card session when a card is equipped (`MF/2F06` and
`ADF.USIM/6F06`, best effort) and served from memory, so this endpoint
performs no card I/O; a disconnect clears the cache, and an `EF.ARR` write
through `/api/write` refreshes it.
```json
{"ok": true, "session": 12, "arrs": [
{"fid": "2F06", "path": "MF/2F06", "record_len": 10, "num_records": 23,
"records": ["FFFF...", "..."]}]}
```
`ok` is `false` (with an empty `arrs`) when the card has no readable ARR.
The PWA's file manager resolves the `'8B'` reference of the selected file's
FCI against this cache and shows the referenced record's access rules as
indented lines; snapshots resolve the same references from their own
captured `EF.ARR` copy.
### `GET /api/proactive-log`
Returns the last 50 proactive commands fetched during CAT sessions, newest
+1 -1
View File
@@ -397,7 +397,7 @@
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор&raquo;</strong> — отдельные вкладки верхнего уровня.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 Файловый менеджер</h3>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла.</p>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись <code class="font-mono text-sm">EF.ARR</code> (атрибут безопасности <code class="font-mono text-sm">8B</code>), правила указанной записи показываются с отступом под этой строкой: они берутся из копий <code class="font-mono text-sm">EF.ARR</code> карты (читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи <code class="font-mono text-sm">EF.ARR</code>); снимок разрешает те же ссылки из собственной захваченной копии.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке <em>pySim JSON (сервер)</em>. Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).</li>
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
+1 -1
View File
@@ -396,7 +396,7 @@
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Simulator</strong> are separate top-level tabs.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file&rsquo;s symbolic name — above the content pane.</p>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file&rsquo;s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card&rsquo;s <code class="font-mono text-sm">EF.ARR</code> copies (read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write); a snapshot resolves the same references from its own captured copy.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
+125 -7
View File
@@ -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.23.9';
const SIMPLE_VERSION = '3.24.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -15060,8 +15060,9 @@ async function pysimFsToggleDir(name) {
let pysimFsSelected = null;
function pysimFsInfoHtml(sel, name) {
function pysimFsInfoHtml(sel, name, opts) {
if (!sel) return '';
opts = opts || {};
const num = v => (v === null || v === undefined) ? null : String(v);
const attrs = [];
if (sel.fid) attrs.push('FID: ' + esc(String(sel.fid).toUpperCase()));
@@ -15079,10 +15080,26 @@ function pysimFsInfoHtml(sel, name) {
(name ? '<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">' + esc(name) + '</legend>' : '') +
(attrs.length ? '<div class="' + metaCls + ' mb-0.5">' + attrs.join(' · ') + '</div>' : '') +
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' +
profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null }) +
profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null, arr: opts.arr, path: opts.path }) +
'</div></fieldset>';
}
// EF.ARR cache for the file manager's FCP view (v3.24.0): the server reads
// the card's ARR copies once per session (GET /api/arr is itself cache-only);
// a card-session change drops this copy.
let _pysimArrCache = null;
async function pysimArrEnsure() {
if (_pysimArrCache && _pysimArrCache.session === _pysimCardSession) return _pysimArrCache.arrs;
try {
const data = await pysimFetch('/api/arr');
_pysimArrCache = { session: _pysimCardSession, arrs: (data && data.arrs) || [] };
} catch (e) {
_pysimArrCache = { session: _pysimCardSession, arrs: [] };
}
return _pysimArrCache.arrs;
}
async function pysimFsClickFile(name) {
pysimFsResetEdit();
pysimFsSetMode('raw');
@@ -15095,7 +15112,8 @@ async function pysimFsClickFile(name) {
pysimFsRenderTree();
}
pysimFsSelected = name;
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name) : '';
const arrs = await pysimArrEnsure();
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) }) : '';
document.getElementById('pysim-fs-content').innerHTML = '';
pysimFsSyncContentVisibility();
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
@@ -15257,6 +15275,10 @@ async function pysimFsSave() {
const statusEl = document.getElementById('pysim-fs-status');
const out = document.getElementById('pysim-fs-content');
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
// An EF.ARR write changes the referenced access rules: drop the client
// ARR cache so the next FCP view refetches the server's refreshed copy.
const wroteFid = String((node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : '')).toUpperCase();
const arrTouched = wroteFid === '2F06' || wroteFid === '6F06';
statusEl.textContent = 'Saving...';
try {
const textareas = out.querySelectorAll('textarea');
@@ -15267,6 +15289,7 @@ async function pysimFsSave() {
const data = await pysimFetch('/api/write', body);
if (data.success) {
statusEl.textContent = 'SW: ' + data.sw + ' OK';
if (arrTouched) _pysimArrCache = null;
pysimFsCancel();
pysimFsRead();
} else {
@@ -15298,6 +15321,7 @@ async function pysimFsSave() {
return;
}
statusEl.textContent = 'SW: 9000 OK';
if (arrTouched) _pysimArrCache = null;
pysimFsCancel();
pysimFsRead();
}
@@ -19616,6 +19640,8 @@ function snapshotSaveName() {
function profilerRenderSnapshotFiles(s) {
if (!s.files.length) return '<span class="text-gray-400">' + t('No files captured.') + '</span>';
// the snapshot's own EF.ARR copies resolve the FCP '8B' references offline
const snapArr = profilerSnapshotArr(s);
let html = '';
for (const f of s.files) {
const name = profilerCustomNameForPath(f.path) || f.name;
@@ -19644,7 +19670,7 @@ function profilerRenderSnapshotFiles(s) {
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
'<textarea readonly rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800 break-all">' + escHtml(f.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 class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex, { arr: snapArr, path: f.path }) + '</div></div>';
html += '</div>';
}
const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null;
@@ -20357,6 +20383,65 @@ function fcpPsTemplate(v) {
return pins.length ? pins.join('; ') : (v || '');
}
// FCP '8B' security attribute referencing (ISO 7816-4 5.4.3.3, Table 25):
// one ARR byte (a record in the implicitly known EF.ARR), a file identifier +
// ARR byte (the record number), or a file identifier followed by (SEID, ARR)
// pairs. Returns null for a malformed length (the raw value is shown then).
function fcpArrRef(value) {
const v = (value || '').toUpperCase();
if (!/^[0-9A-F]*$/.test(v)) return null;
if (v.length === 2) return { implicit: true, record: parseInt(v, 16) };
if (v.length === 6) return { fid: v.substr(0, 4), record: parseInt(v.substr(4, 2), 16) };
if (v.length >= 8 && v.length % 2 === 0) {
const pairs = [];
for (let i = 4; i + 4 <= v.length; i += 4) {
pairs.push({ seid: parseInt(v.substr(i, 2), 16), record: parseInt(v.substr(i + 2, 2), 16) });
}
return { fid: v.substr(0, 4), pairs: pairs };
}
return null;
}
// SEID label (TS 102 221 9.3.1 / Table 9.0d: SE00 and SE01 are defined; the
// same pattern names any other value).
function fcpSeidName(seid) {
return 'SE' + (Number(seid) || 0).toString(16).toUpperCase().padStart(2, '0');
}
// 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).
function fcpArrRules(ref, arrs, path) {
if (!ref || !arrs || !arrs.length) return [];
const df = (((path || '').split('/')[0]) || 'MF').toUpperCase();
const pick = fid => {
const hits = arrs.filter(a => String(a.fid || '').toUpperCase() === fid);
if (!hits.length) return null;
return hits.find(a => String(a.path || '').split('/')[0].toUpperCase() === df) || hits[0];
};
const out = [];
const add = (label, fid, record) => {
const arr = pick(fid);
const recs = (arr && arr.records) || [];
if (!(record >= 1) || record > recs.length) return;
const dec = efDecArr(efBytes(recs[record - 1] || ''));
for (const rule of ((dec && dec.rules) || [])) {
out.push((label ? label + ': ' : '') + rule);
}
};
if (ref.implicit) {
add('', df.startsWith('ADF.') ? '6F06' : '2F06', ref.record);
} else if (ref.pairs) {
for (const p of ref.pairs) add(fcpSeidName(p.seid), ref.fid, p.record);
} else {
add('', ref.fid, ref.record);
}
return out;
}
function fcpDo(key, value) {
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
switch (key) {
@@ -20370,7 +20455,13 @@ function fcpDo(key, 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 '8B': {
const ref = fcpArrRef(value);
if (!ref) return { name: 'Security attributes (referenced)', decoded: value };
if (ref.implicit) return { name: 'Security attributes (referenced)', decoded: 'record ' + ref.record + ' (implicit EF_ARR)' };
if (ref.pairs) return { name: 'Security attributes (referenced)', decoded: 'EF_ARR ' + ref.fid + ', ' + ref.pairs.map(p => fcpSeidName(p.seid) + ' \u2192 record ' + p.record).join(', ') };
return { name: 'Security attributes (referenced)', decoded: 'EF_ARR ' + ref.fid + ', record ' + ref.record };
}
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 };
@@ -20562,6 +20653,26 @@ function fcpDiffHtml(expectedHex, actualHex, labels) {
return html;
}
// EF.ARR copies captured inside a snapshot (the scan stores every readable
// file): the snapshot's FCI view resolves '8B' references offline from them,
// shaped like the server's /api/arr entries (v3.24.0).
function profilerSnapshotArr(snapshot) {
const out = [];
for (const f of ((snapshot && snapshot.files) || [])) {
const fid = efFidFromPath(f.path);
if (!fid || (fid !== '2F06' && fid !== '6F06')) continue;
if (!f.content || f.content.kind !== 'record') continue;
out.push({
fid: fid,
path: f.path,
record_len: f.recordLen,
num_records: f.numRecords,
records: (f.content.records || []).map(r => r.data || ''),
});
}
return out;
}
// Decoded FCI rows. The short file identifier belongs to the file
// identifier, so it is merged into that row. `opts.hideFileSize` drops the
// File size DO where the caller already shows the size (file manager header).
@@ -20573,7 +20684,11 @@ function profilerFciPreviewItems(hex, opts) {
for (const it of d.items) {
if (it.decoded === null) continue;
if (opts.hideFileSize && it.key === '80') continue;
rows.push({ key: it.key, name: it.name, text: it.decoded || it.value });
// the referenced EF.ARR record's access rules, when the caller has the
// 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) : [] });
}
const fi = rows.find(r => r.key === '83');
const sfi = rows.find(r => r.key === '88');
@@ -20584,6 +20699,9 @@ function profilerFciPreviewItems(hex, opts) {
let html = '';
for (const r of rows) {
html += '<div>' + esc(r.name) + ': <span class="text-gray-800 dark:text-slate-200">' + esc(r.text) + '</span></div>';
for (const s of (r.sub || [])) {
html += '<div class="pl-2 text-gray-500 dark:text-slate-400">\u21b3 ' + esc(s) + '</div>';
}
}
if (d.error) {
html += '<div class="text-red-600">' + esc(t('Decode failed')) + ': ' + esc(d.error) + '</div>';
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v422';
const CACHE = 'simple-v423';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -443,7 +443,7 @@ test('file manager shows FCI info and keeps the selection in state, not the DOM'
const content = html.indexOf('id="pysim-fs-content"');
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
assert.ok(html.includes('function pysimFsInfoHtml'));
assert.ok(html.includes('pysimFsInfoHtml(sel, name)'));
assert.ok(html.includes('pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) })'));
assert.ok(!html.includes('pysim-fs-filename'));
assert.ok(html.includes('let pysimFsSelected = null;'));
assert.ok(html.includes('pysimFsSelected = name;'));
+89
View File
@@ -22,6 +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',
'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';
@@ -813,6 +814,94 @@ test('pysimFsInfoHtml keeps the FCI File size when the header has none', () => {
delete global.t;
});
test('fcpArrRef parses the three 8B referencing forms', () => {
assert.deepStrictEqual(fcpArrRef('17'), { implicit: true, record: 23 });
assert.deepStrictEqual(fcpArrRef('2F0617'), { fid: '2F06', record: 23 });
assert.deepStrictEqual(fcpArrRef('2f0600170101'), {
fid: '2F06', pairs: [{ seid: 0, record: 23 }, { seid: 1, record: 1 }] });
assert.strictEqual(fcpArrRef('2F060'), null); // odd length
assert.strictEqual(fcpArrRef('ZZZZZZ'), null); // not hex
});
test('fcpDo decodes every 8B referencing form', () => {
global.t = s => s;
assert.strictEqual(fcpDo('8B', '2F0617').decoded, 'EF_ARR 2F06, record 23');
assert.strictEqual(fcpDo('8B', '17').decoded, 'record 23 (implicit EF_ARR)');
assert.strictEqual(fcpDo('8B', '2F0600170101').decoded,
'EF_ARR 2F06, SE00 \u2192 record 23, SE01 \u2192 record 1');
assert.strictEqual(fcpDo('8B', '2F06').decoded, '2F06');
delete global.t;
});
test('the FCI view shows the referenced EF.ARR rules when the ARR is known', () => {
global.t = s => s;
// the stk_params fixture FCI with 8B 03 2F06 02 (record 2)
const hex = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0';
const arrs = [{ fid: '2F06', path: 'MF/2F06', record_len: 6, num_records: 2,
records: ['FFFFFFFFFFFFFFFFFFFFFFFF', '800101A40683010A950108'] }];
const out = profilerFciPreviewItems(hex, { arr: arrs, path: 'MF/7F20/6F07' });
assert.ok(out.includes('EF_ARR 2F06, record 2'), out);
assert.ok(out.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), out);
assert.ok(out.includes('\u21b3'), out);
// without ARR data the row is unchanged
const plain = profilerFciPreviewItems(hex);
assert.ok(plain.includes('EF_ARR 2F06, record 2'), plain);
assert.ok(!plain.includes('ADM1'), plain);
// an out-of-range record stays silent
const hex9 = '621A82054221000F0583026F4F8A01058B032F06098802004B8801B0';
const oor = profilerFciPreviewItems(hex9, { arr: arrs, path: 'MF/2F06' });
assert.ok(oor.includes('EF_ARR 2F06, record 9'), oor);
assert.ok(!oor.includes('ADM1'), oor);
delete global.t;
});
test('the implicit 8B form uses the ARR of the file\'s DF', () => {
global.t = s => s;
// 8B 01 02: implicit EF_ARR, record 2
const hex = '621882054221000F0583026F4F8A01058B01028002004B8801B0';
const arrs = [
{ fid: '2F06', path: 'MF/2F06', records: ['FFFFFFFF', '800101A40683010A950108'] },
{ fid: '6F06', path: 'ADF.USIM/6F06', records: ['FFFFFFFF', '8001019000'] },
];
// an ADF.USIM file resolves against the ADF's 6F06 copy
const adf = profilerFciPreviewItems(hex, { arr: arrs, path: 'ADF.USIM/6F07' });
assert.ok(adf.includes('record 2 (implicit EF_ARR)'), adf);
assert.ok(adf.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'), adf);
assert.ok(!adf.includes('ADM1'), adf);
// an MF-tree file resolves against MF/2F06
const mf = profilerFciPreviewItems(hex, { arr: arrs, path: 'MF/7F20/6F07' });
assert.ok(mf.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), mf);
delete global.t;
});
test('the snapshot FCI view resolves references from the snapshot ARR', () => {
global.t = s => s;
const snap = { files: [
{ path: 'MF/2F06', name: 'EF.ARR', fileType: 'linear_fixed', recordLen: 6, numRecords: 2,
content: { kind: 'record', records: [{ num: 1, data: 'FFFFFFFFFFFFFFFFFFFFFFFF' },
{ num: 2, data: '800101A40683010A950108' }] } },
{ path: 'MF/7F20/6F07', name: 'EF.IMSI',
fciHex: '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0' },
]};
const arrs = profilerSnapshotArr(snap);
assert.deepStrictEqual(arrs.map(a => a.fid), ['2F06']);
const out = profilerFciPreviewItems(snap.files[1].fciHex, { arr: arrs, path: snap.files[1].path });
assert.ok(out.includes('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)'), out);
delete global.t;
});
test('pysimFsInfoHtml shows the referenced ARR rules when given the cache', () => {
global.t = s => s;
const hex = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0';
const arrs = [{ fid: '2F06', path: 'MF/2F06', records: ['', '800101a40683010a950108'] }];
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75,
record_len: 15, num_of_rec: 5, fci_hex: hex }, 'EF.SAMPLE',
{ arr: arrs, path: 'MF/7F20/6F4F' });
assert.ok(out.includes('EF_ARR 2F06, record 2'), out);
assert.ok(out.includes('ADM1 (verify)'), out);
delete global.t;
});
test('pysimFsInfoHtml embeds the symbolic name in the FCI border', () => {
global.t = s => s;
const hex = '6212800200098202412183026F078A0105880110';
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.23.9"
version = "3.24.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.
+90 -2
View File
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.23.9'
VERSION = '3.24.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -485,6 +485,74 @@ def _read_iccid(app):
cleanup()
# EF.ARR copies referenced by the FCP security attribute DO '8B' (ISO 7816-4
# Table 25): the MF copy and the ADF.USIM copy (TS 102 221 13.4). The read
# runs once per card session (_refresh_arr_cache); the PWA's file manager
# resolves the referenced record's access rules against the cache.
ARR_READ_PATHS = ('MF/2F06', 'ADF.USIM/6F06')
ARR_RECORD_LIMIT = 64
def _arr_fid(fid):
"""True if the FID is an EF.ARR copy referenced by FCP '8B'."""
return str(fid or '').upper() in ('2F06', '6F06')
def _read_arr(app):
"""Best-effort EF.ARR reads (ARR_READ_PATHS): the access rule records the
FCP '8B' security attribute references point at. Returns a list of
{'fid','path','record_len','num_records','records'} dicts (records as hex,
1-based) or None; never raises."""
if not app or not getattr(app, 'rs', None):
return None
lchan = app.rs.lchan[0]
out = []
for path in ARR_READ_PATHS:
cleanup = None
try:
_, cleanup = _select_path(lchan, path, app)
if _get_file_type(lchan, lchan.selected_file) not in ('linear_fixed', 'cyclic'):
continue
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
records = []
for i in range(1, n + 1):
rv = lchan.read_record(i)
data = rv[0] if isinstance(rv, tuple) else rv
records.append((data or '').upper())
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
'path': path,
'record_len': _fcp_value(lchan, 'selected_file_record_len'),
'num_records': len(records),
'records': records})
except Exception:
continue
finally:
if cleanup:
try:
cleanup()
except Exception:
pass
return out or None
def _refresh_arr_cache(server):
"""Read the EF.ARR copies into the session cache (best effort); a failed
read clears the previous session's cache."""
server.arr = _read_arr(getattr(server, 'app', None))
if server.arr:
_tlog('equip: EF.ARR ' + ', '.join(
'%s (%d rec)' % (a['fid'], a['num_records']) for a in server.arr))
def _arr_refresh_after_write(server, app, fid):
"""Re-read the cache after a write to an EF.ARR copy: the referenced
access rules may have changed. Returns True when a refresh happened."""
if not _arr_fid(fid):
return False
server.arr = _read_arr(app)
return True
_MCC_MNC_CACHE = {'path': None, 'data': None}
@@ -4361,6 +4429,7 @@ def _handle_card_disconnect(stale=False):
_server_ref.event_list = None
_server_ref.sim_menu = None
_server_ref.iccid = None
_server_ref.arr = None
_server_ref.net_state = None
_server_ref.equipping = False
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
@@ -4435,6 +4504,7 @@ def _apply_equipped_card(server):
server.card_present = True
server.card_session = getattr(server, 'card_session', 0) + 1
server.iccid = None
server.arr = None
# Read the ICCID before the TERMINAL PROFILE starts a CAT session: the
# PWA auto-selects the matching card preset (SCP80 views) from it.
server.iccid = _read_iccid(server.app)
@@ -4444,6 +4514,9 @@ def _apply_equipped_card(server):
# ICCID (still before the TERMINAL PROFILE opens a CAT session). A
# card without a readable ICCID is considered unusable - give up.
_netstate_init(server)
# EF.ARR (FCP '8B' references): read once per card session - the
# file manager resolves the referenced record's rules from it.
_refresh_arr_cache(server)
else:
server.net_state = None
_tlog('equip: ICCID not readable - network state skipped')
@@ -5923,7 +5996,7 @@ _CARD_FREE_GET = frozenset([
'/api/stk-status', '/api/poll-status', '/api/proactive-log',
'/api/menu', '/api/events', '/api/terminal-profile',
'/api/pli-qualifiers', '/api/pli-dict', '/api/commands',
'/api/net-state', '/api/mcc-mnc', '/api/presets', '/api/test/scripts',
'/api/net-state', '/api/arr', '/api/mcc-mnc', '/api/presets', '/api/test/scripts',
'/api/test/suites',
'/api/scp81/status', '/api/bip/status', '/api/scp81/script',
'/api/scp81/log', '/api/bip/log',
@@ -7956,6 +8029,17 @@ class PysimHandler(BaseHTTPRequestHandler):
_netstate_compute(self.server)
self._send_json({'available': True, 'state': state})
self._log_resp({'available': True})
elif self.path == '/api/arr':
# EF.ARR session cache (v3.24.0): read once per card session in
# _refresh_arr_cache; the file manager resolves the FCP '8B'
# security attribute references against it. Cache-only - served
# without the card lock (see _CARD_FREE_GET).
self._log_req()
arrs = getattr(self.server, 'arr', None)
self._send_json({'ok': bool(arrs),
'session': getattr(self.server, 'card_session', 0),
'arrs': arrs or []})
self._log_resp({'ok': bool(arrs), 'arrs': len(arrs or [])})
elif self.path == '/api/status':
self._log_req()
app = self.server.app
@@ -8723,6 +8807,10 @@ class PysimHandler(BaseHTTPRequestHandler):
else:
sw = sw_match.group(1) if sw_match else '9000'
resp = {'success': True, 'sw': sw}
# An EF.ARR write changes the referenced access rules:
# refresh the session cache the file manager reads.
_arr_refresh_after_write(self.server, app,
getattr(lchan.selected_file, 'fid', None))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
+190
View File
@@ -0,0 +1,190 @@
# coding=utf-8
"""EF.ARR session cache (v3.24.0): the equip-time read, the FCP '8B' backing
store, the /api/arr route and the write refresh."""
import json
import pathlib
import threading
import unittest
import urllib.request
from unittest import mock
from pysim_simple_server.__main__ import _build_http_server
from pysim_simple_server.server import (PysimHandler, _CARD_FREE_GET, _arr_fid,
_arr_refresh_after_write, _read_arr,
_refresh_arr_cache)
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = (ROOT / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
class FakeFile:
def __init__(self, fid):
self.fid = fid
class FakeLchan:
"""One selected ARR file: read_record serves its records."""
def __init__(self, files):
self.files = files # path -> [record hex]
self._cur = None
self.selected_file = None
def selected_file_num_of_rec(self):
return len(self.files.get(self._cur) or [])
def selected_file_record_len(self):
return 6
def read_record(self, n):
return (self.files[self._cur][n - 1], '9000')
def _select(lchan, path, app):
if path not in lchan.files:
raise RuntimeError('file not found')
lchan._cur = path
lchan.selected_file = FakeFile(path.rsplit('/', 1)[-1])
return lchan.selected_file, None
def _app(files):
lchan = FakeLchan(files)
app = mock.Mock()
app.rs.lchan = [lchan]
return app, lchan
def _read(app, file_type='linear_fixed'):
with mock.patch('pysim_simple_server.server._select_path', _select), \
mock.patch('pysim_simple_server.server._get_file_type', return_value=file_type):
return _read_arr(app)
class ReadArrTests(unittest.TestCase):
def test_reads_both_copies(self):
app, _ = _app({'MF/2F06': ['800101A40683010A950108', '9000'],
'ADF.USIM/6F06': ['8001019000']})
arrs = _read(app)
self.assertEqual([a['fid'] for a in arrs], ['2F06', '6F06'])
self.assertEqual(arrs[0]['path'], 'MF/2F06')
self.assertEqual(arrs[0]['records'], ['800101A40683010A950108', '9000'])
self.assertEqual(arrs[0]['num_records'], 2)
self.assertEqual(arrs[0]['record_len'], 6)
self.assertEqual(arrs[1]['path'], 'ADF.USIM/6F06')
def test_a_missing_copy_is_skipped(self):
app, _ = _app({'ADF.USIM/6F06': ['9000']})
arrs = _read(app)
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
def test_no_arr_returns_none(self):
app, _ = _app({})
self.assertIsNone(_read(app))
def test_a_transparent_file_is_skipped(self):
app, _ = _app({'MF/2F06': ['9000']})
self.assertIsNone(_read(app, file_type='transparent'))
def test_a_failing_read_skips_the_file(self):
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
orig = lchan.read_record
def flaky(n):
if lchan._cur == 'MF/2F06':
raise RuntimeError('6A82')
return orig(n)
lchan.read_record = flaky
arrs = _read(app)
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
def test_the_record_count_is_capped(self):
app, _ = _app({'MF/2F06': ['9000'] * 200})
arrs = _read(app)
self.assertEqual(arrs[0]['num_records'], 64)
def test_no_app_returns_none(self):
self.assertIsNone(_read_arr(None))
self.assertIsNone(_read_arr(mock.Mock(rs=None)))
class CacheTests(unittest.TestCase):
def test_arr_fid(self):
self.assertTrue(_arr_fid('2F06'))
self.assertTrue(_arr_fid('6f06'))
self.assertFalse(_arr_fid('6F07'))
self.assertFalse(_arr_fid(None))
def test_refresh_stores_and_clears(self):
s = mock.Mock()
with mock.patch('pysim_simple_server.server._read_arr',
return_value=[{'fid': '2F06', 'num_records': 2}]), \
mock.patch('pysim_simple_server.server._tlog'):
_refresh_arr_cache(s)
self.assertEqual(s.arr, [{'fid': '2F06', 'num_records': 2}])
with mock.patch('pysim_simple_server.server._read_arr', return_value=None):
_refresh_arr_cache(s)
self.assertIsNone(s.arr)
def test_write_refresh_only_for_arr_fids(self):
s = mock.Mock()
with mock.patch('pysim_simple_server.server._read_arr',
return_value=[{'fid': '2F06', 'num_records': 2}]) as m:
self.assertFalse(_arr_refresh_after_write(s, None, '6F07'))
m.assert_not_called()
self.assertTrue(_arr_refresh_after_write(s, None, '2f06'))
m.assert_called_once()
self.assertEqual(s.arr, [{'fid': '2F06', 'num_records': 2}])
def test_equip_and_disconnect_wiring(self):
# the equip hook reads the cache; a disconnect clears it; an EF.ARR
# write refreshes it
seg = SRC[SRC.index('def _apply_equipped_card'):SRC.index('def _card_reset_reinit')]
self.assertIn('_refresh_arr_cache(server)', seg)
seg = SRC[SRC.index('def _handle_card_disconnect'):SRC.index('def _ensure_transport')]
self.assertIn('_server_ref.arr = None', seg)
seg = SRC[SRC.index("elif self.path == '/api/write':"):SRC.index("elif self.path == '/api/tree':")]
self.assertIn('_arr_refresh_after_write', seg)
class ArrRouteTests(unittest.TestCase):
"""GET /api/arr serves the session cache without touching the card."""
def setUp(self):
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
self.server.log_requests = False
self.server.app = None
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.port = self.server.server_address[1]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def _get(self, path):
with urllib.request.urlopen('http://127.0.0.1:%d%s' % (self.port, path),
timeout=5) as res:
return res.status, json.loads(res.read() or b'{}')
def test_arr_is_a_card_free_get(self):
self.assertIn('/api/arr', _CARD_FREE_GET)
def test_empty_cache_answers_not_found(self):
status, resp = self._get('/api/arr')
self.assertEqual(status, 200, resp)
self.assertFalse(resp['ok'])
self.assertEqual(resp['arrs'], [])
def test_the_cache_is_served_with_the_session(self):
self.server.arr = [{'fid': '2F06', 'path': 'MF/2F06', 'records': ['9000']}]
self.server.card_session = 7
status, resp = self._get('/api/arr')
self.assertEqual(status, 200, resp)
self.assertTrue(resp['ok'])
self.assertEqual(resp['session'], 7)
self.assertEqual(resp['arrs'][0]['fid'], '2F06')
if __name__ == '__main__':
unittest.main()