From 2c793720f6db262b2e37a8faa797aa3df4d5494b 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: Sun, 20 Sep 2026 22:09:39 +0300 Subject: [PATCH] ui: verify the ADM PIN from the matched card preset (v2.7.8) The preset ADM key was stored but never used: the header badge showed whether a key exists and whether the card was verified, yet the only way to verify was the pySim command line. - POST /api/verify-adm builds the TS 102 221 VERIFY itself (CHV number from the card model, short keys padded to 8 bytes with 'f') so the raw SW is reported: 63Cx -> attempts_left, 6983/9804 -> blocked, 6982 -> security error. The key is never stored and is redacted from request logs. - PWA: the header ADM badge is clickable when the matched preset has a key; a failed file-manager read/write (6982/9804) shows a Verify ADM button next to the error. Every retry after a failure asks for confirmation and shows the remaining attempts (stronger text on the last attempt); a blocked ADM disables both entry points until the card session changes. No automatic retries. - tests: tests/test_adm_verify.py (fake scc, APDU/SW mapping, redaction) and frontend/tests/adm_verify.test.js (retry prompt, SW classifier, wiring) + card_state indicator expectations - docs/api.md, help EN/RU, AGENTS; version trio 2.7.8; sw cache v211 --- docs/api.md | 28 ++++++ frontend/help-ru.html | 1 + frontend/help.html | 1 + frontend/index.html | 139 +++++++++++++++++++++++++++--- frontend/sw.js | 2 +- frontend/tests/adm_verify.test.js | 65 ++++++++++++++ frontend/tests/card_state.test.js | 15 +++- pyproject.toml | 2 +- pysim_simple_server/server.py | 59 ++++++++++++- tests/test_adm_verify.py | 82 ++++++++++++++++++ 10 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 frontend/tests/adm_verify.test.js create mode 100644 tests/test_adm_verify.py diff --git a/docs/api.md b/docs/api.md index b049553..20e9fc2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -30,6 +30,7 @@ a 2.x PWA). | `/api/read` | POST | Read file content | | `/api/write` | POST | Write raw hex data to a file | | `/api/apdu` | POST | Raw APDU send | +| `/api/verify-adm` | POST | Verify the card's ADM PIN (from the matched card preset) | | `/api/help` | POST | pySim help for a given command | | `/api/send-ota` | POST | SCP80 OTA secured packet delivery | | `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) | @@ -134,6 +135,33 @@ Returns: {"response": "...", "sw": "9000"} ``` +### `POST /api/verify-adm` + +Verify the card's ADM PIN (TS 102 221 VERIFY, CHV number from the card +model). The key comes from the PWA's matched card preset and is never stored; +it is redacted from the request log. Short keys (4-16 hex digits) are padded +to the 8 CHV bytes with `f`, like pySim's `verify_adm`. The response is +structured so the UI can warn about the remaining attempts before retrying. + +```json +{"adm": "0011223344556677"} +``` + +Returns on success: + +```json +{"ok": true, "sw": "9000"} +``` + +On a wrong key (`63Cx`, x attempts left): + +```json +{"ok": false, "sw": "63C2", "attempts_left": 2} +``` + +A blocked ADM (`6983`/`9804`) reports `{"ok": false, "sw": "9804", +"blocked": true}` and cannot be recovered without the card's unblock key. + ### `POST /api/help` Get structured help for a shell command. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 495d631..3ea079a 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -356,6 +356,7 @@
  • Редактировать — изменение hex-данных, Сохранить для записи (или Отмена)
  • Данные как на карте / Декодированные данные — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке pySim JSON (сервер)
  • Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
  • +
  • ADM — файлы, требующие администраторский PIN, возвращают 6982/9804; если в подходящей предустановке карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка «Проверить ADM», а значок в заголовке (ADM ✓/✗ ⚿) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.
  • 6.2 Командная строка pySim

    diff --git a/frontend/help.html b/frontend/help.html index be2abbd..2841a93 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -356,6 +356,7 @@
  • Edit — modify hex data, Save to write back (or Cancel)
  • Raw / Decoded — toggle between the hex dump and 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 pySim JSON (server) disclosure
  • Probe all files — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show (empty). Shows progress N / total, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.
  • +
  • ADM — files that need the administrator PIN fail with 6982/9804; if the matching card preset (same ICCID) carries an ADM key, a Verify ADM button appears next to the error and the header badge (ADM ✓/✗ ⚿) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.
  • 6.2 pySim command line

    diff --git a/frontend/index.html b/frontend/index.html index 9349fbc..c3f9dc3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1413,7 +1413,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 = '2.7.7'; +const SIMPLE_VERSION = '2.7.8'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -4858,6 +4858,12 @@ let _pysimCardEquipped = false; let _pysimEquipping = false; let _pysimAdmVerified = null; // null = no card session let _pysimAdmKey = null; // ADM key present in the matching preset +let _pysimAdmCanVerify = false; // indicator clickable (card + preset key, not blocked) +let _pysimAdmAttemptsLeft = null; // last 63Cx result for this card session +let _pysimAdmBlocked = false; // 6983/9804 — no further tries until the session changes +let _pysimAdmVerifying = false; // verify request in flight (double-click guard) +let _pysimAdmStateKey = null; // last rendered indicator state +let _pysimLastStatus = null; // last /api/status payload (for local repaints) let _pysimCardIccid = null; // EF.ICCID digits of the equipped card (null = unknown) let _pysimHeaderIccid = undefined; // last value rendered in the header indicator let _pysimHeaderScp80 = undefined; // last SCP80/SCP81 marker state @@ -4934,17 +4940,24 @@ function pysimApplyAvailability() { // Compact ADM state next to the header's card indicator: "ADM ✓" when the // administrator PIN was verified (pySim rs.adm_verified), "ADM ✗" otherwise; // a trailing key glyph (⚿) marks that the matching card preset carries an ADM -// key. Hidden without a card session; only rewrites the DOM when the state -// changes (the 2s /api/status poll calls this on every update). +// key. With a key present the badge is clickable and verifies it on demand; +// hidden without a card session. Only rewrites the DOM when the state changes +// (the 2s /api/status poll calls this on every update). function pysimUpdateAdmIndicator(status) { const el = document.getElementById('state-indicator-adm'); if (!el) return; const verified = (status && status.connected) ? !!status.adm_verified : null; const key = verified === null ? null : cardsAdmPresent(cardsMatchedPreset()); - if (verified === _pysimAdmVerified && key === _pysimAdmKey) return; + const canVerify = key === true && !_pysimAdmBlocked && !_pysimAdmVerifying; + const stateKey = [verified, key, canVerify, _pysimAdmAttemptsLeft, _pysimAdmBlocked].join('|'); + if (stateKey === _pysimAdmStateKey) return; + _pysimAdmStateKey = stateKey; _pysimAdmVerified = verified; _pysimAdmKey = key; - el.classList.remove('text-emerald-600', 'dark:text-emerald-400', 'text-red-500'); + _pysimAdmCanVerify = canVerify; + el.classList.remove('text-emerald-600', 'dark:text-emerald-400', 'text-red-500', 'cursor-pointer'); + el.removeAttribute('onclick'); + el.removeAttribute('role'); if (verified === null) { el.classList.add('hidden'); el.removeAttribute('title'); @@ -4954,9 +4967,101 @@ function pysimUpdateAdmIndicator(status) { el.classList.remove('hidden'); el.classList.add(verified ? 'text-emerald-600' : 'text-red-500'); if (verified) el.classList.add('dark:text-emerald-400'); - el.setAttribute('title', t(key - ? (verified ? 'ADM key in the card preset — verified' : 'ADM key in the card preset — not verified') - : (verified ? 'Verified — no ADM key in the card preset' : 'No ADM key in the card preset — not verified'))); + let tip; + if (key && _pysimAdmBlocked) { + tip = t('ADM is blocked — unblock the card to try again'); + } else if (key) { + tip = t('ADM key in the card preset — click to verify'); + if (_pysimAdmAttemptsLeft !== null) tip += ' — ' + _pysimAdmAttemptsLeft + ' ' + t('attempt(s) left'); + } else { + tip = t(verified ? 'Verified — no ADM key in the card preset' : 'No ADM key in the card preset — not verified'); + } + el.setAttribute('title', tip); + if (canVerify) { + el.classList.add('cursor-pointer'); + el.setAttribute('role', 'button'); + el.setAttribute('onclick', 'pysimVerifyAdm()'); + } +} + +// Confirmation text before a repeated ADM attempt (null on the first try). +// Every wrong key consumes an attempt; a blocked ADM is unrecoverable here. +function pysimAdmRetryPrompt(attemptsLeft) { + if (attemptsLeft === null || attemptsLeft === undefined) return null; + if (attemptsLeft <= 1) return t('This is the last attempt before the ADM is blocked. Try again?'); + return t('ADM verification already failed') + ' — ' + attemptsLeft + ' ' + t('attempt(s) left') + + '. ' + t('A wrong key can block the ADM permanently. Try again?'); +} + +// Verify the ADM PIN from the matched card preset (top-bar indicator or the +// file manager's security-error hint). `statusEl` receives the result inline +// when given; otherwise the card status line is used. Never retries by +// itself: every attempt after a failure is confirmed first. +async function pysimVerifyAdm(statusEl) { + if (_pysimAdmVerifying) return; + const preset = cardsMatchedPreset(); + if (!cardsAdmPresent(preset)) return; + const el = statusEl || document.getElementById('pysim-status'); + if (_pysimAdmBlocked) { + if (el) el.textContent = t('ADM is blocked — unblock the card to try again'); + return; + } + const prompt = pysimAdmRetryPrompt(_pysimAdmAttemptsLeft); + if (prompt && !confirm(prompt)) return; + _pysimAdmVerifying = true; + if (el) el.textContent = t('Verifying ADM...'); + let verifiedOverride; + try { + const data = await pysimFetch('/api/verify-adm', { adm: preset.adm }); + if (data.ok) { + _pysimAdmAttemptsLeft = null; + _pysimAdmBlocked = false; + verifiedOverride = true; + if (el) el.textContent = t('ADM verified'); + } else if (data.blocked) { + _pysimAdmBlocked = true; + if (el) el.textContent = t('ADM is blocked'); + } else if (typeof data.attempts_left === 'number') { + _pysimAdmAttemptsLeft = data.attempts_left; + if (el) el.textContent = t('ADM verification failed') + ' — ' + data.attempts_left + ' ' + t('attempt(s) left'); + } else if (el) { + el.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error'); + } + } catch (e) { + if (el) el.textContent = t('Error') + ': ' + e.message; + } finally { + _pysimAdmVerifying = false; + const base = _pysimLastStatus || { connected: true }; + pysimUpdateAdmIndicator(Object.assign({}, base, + verifiedOverride === undefined ? {} : { adm_verified: verifiedOverride })); + } +} + +// SW codes that mean "verify the ADM PIN first" (access condition / security). +function pysimAdmSecuritySw(sw) { + return sw === '6982' || sw === '9804'; +} + +// A new card session (equip/removal) forgets the attempt/blocked state: the +// counter belongs to the physical card that was just replaced or reset. +function pysimAdmResetAttempts() { + _pysimAdmAttemptsLeft = null; + _pysimAdmBlocked = false; + _pysimAdmStateKey = null; +} + +// Render a failed file-manager operation; when the SW is a security error and +// the matched preset carries an ADM, offer verification inline (the key is +// only ever sent on an explicit click). +function pysimFsShowError(statusEl, sw, error) { + statusEl.textContent = 'SW: ' + (sw || '?') + ' — ' + (error || 'Error'); + if (!pysimAdmSecuritySw(sw) || !cardsAdmPresent(cardsMatchedPreset()) || _pysimAdmBlocked) return; + const btn = document.createElement('button'); + btn.textContent = t('Verify ADM'); + btn.className = 'ml-2 px-2 py-0.5 text-xs rounded bg-amber-600 text-white hover:bg-amber-700'; + btn.onclick = () => pysimVerifyAdm(statusEl); + statusEl.appendChild(document.createTextNode(' ')); + statusEl.appendChild(btn); } // EF.ICCID digits of the equipped card, printed next to the card image @@ -7082,7 +7187,7 @@ async function pysimFsRead() { body.mode = 'raw'; // decoding is client-side (works offline in snapshots too) const data = await pysimFetch('/api/read', body); if (!data.success) { - statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error'); + pysimFsShowError(statusEl, data.sw, data.error); return; } statusEl.textContent = 'SW: ' + data.sw + ' OK'; @@ -7209,7 +7314,7 @@ async function pysimFsSave() { pysimFsCancel(); pysimFsRead(); } else { - statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error'); + pysimFsShowError(statusEl, data.sw, data.error); } } else { // Record file @@ -7227,7 +7332,7 @@ async function pysimFsSave() { body.record_nr = parseInt(num); const data = await pysimFetch('/api/write', body); if (!data.success) { - statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')'; + pysimFsShowError(statusEl, data.sw, (data.error || 'Error') + ' (record ' + num + ')'); return; } } @@ -8054,6 +8159,7 @@ async function pysimResetCardData(refreshStatus) { function pysimCardStateUpdate(status) { if (!status || typeof status.connected !== 'boolean') return; _pysimServerAvailable = true; + _pysimLastStatus = status; _pysimCardEquipped = !!status.connected; _pysimEquipping = !!status.equipping; _pysimCardIccid = status.connected ? (status.iccid || null) : null; @@ -8071,6 +8177,7 @@ function pysimCardStateUpdate(status) { const sessionChanged = _pysimCardSession !== null && status.card_session !== undefined && status.card_session !== _pysimCardSession; if (status.card_session !== undefined) _pysimCardSession = status.card_session; + if (sessionChanged) pysimAdmResetAttempts(); if (status.connected) { pysimSetConnected(true); pysimResetCardData(true); @@ -13313,6 +13420,16 @@ const LANG_RU = { 'ADM not verified': 'ADM не подтверждён', 'ADM key in the card preset — verified': 'Ключ ADM в предустановке — подтверждён', 'ADM key in the card preset — not verified': 'Ключ ADM в предустановке — не подтверждён', + 'ADM key in the card preset — click to verify': 'Ключ ADM в предустановке — нажмите для проверки', + 'Verify ADM': 'Проверить ADM', + 'Verifying ADM...': 'Проверка ADM...', + 'ADM verification failed': 'Проверка ADM не удалась', + 'ADM verification already failed': 'Проверка ADM уже завершилась неудачей', + 'attempt(s) left': 'попыток осталось', + 'A wrong key can block the ADM permanently. Try again?': 'Неверный ключ может навсегда заблокировать ADM. Попробовать снова?', + 'This is the last attempt before the ADM is blocked. Try again?': 'Это последняя попытка — неверный ключ заблокирует ADM. Попробовать снова?', + 'ADM is blocked': 'ADM заблокирован', + 'ADM is blocked — unblock the card to try again': 'ADM заблокирован — разблокируйте карту, чтобы повторить', 'Verified — no ADM key in the card preset': 'Подтверждён — ключа ADM в предустановке нет', 'No ADM key in the card preset — not verified': 'Ключа ADM в предустановке нет — не подтверждён', 'SCP80 preset complete': 'Предустановка SCP80 заполнена', diff --git a/frontend/sw.js b/frontend/sw.js index 0d84d03..30b4c27 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v210'; +const CACHE = 'simple-v211'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/adm_verify.test.js b/frontend/tests/adm_verify.test.js new file mode 100644 index 0000000..a65cd96 --- /dev/null +++ b/frontend/tests/adm_verify.test.js @@ -0,0 +1,65 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +let code = ''; +for (const fn of ['pysimAdmRetryPrompt', 'pysimAdmSecuritySw']) { + code += extractFunc(html, fn) + '\n'; +} +code += 'globalThis.t = s => s;\n'; +eval(code); + +test('pysimAdmRetryPrompt is silent on the first try', () => { + assert.strictEqual(pysimAdmRetryPrompt(null), null); + assert.strictEqual(pysimAdmRetryPrompt(undefined), null); +}); + +test('pysimAdmRetryPrompt warns with the remaining attempts', () => { + const msg = pysimAdmRetryPrompt(3); + assert.ok(msg.includes('3'), msg); + assert.ok(msg.includes('attempt(s) left'), msg); + assert.ok(msg.includes('block'), msg); + assert.ok(msg.includes('Try again?'), msg); +}); + +test('pysimAdmRetryPrompt uses the stronger last-attempt text', () => { + for (const left of [1, 0]) { + const msg = pysimAdmRetryPrompt(left); + assert.ok(msg.includes('last attempt'), left + ': ' + msg); + assert.ok(msg.includes('Try again?'), msg); + } +}); + +test('pysimAdmSecuritySw flags only the access-condition SWs', () => { + for (const sw of ['6982', '9804']) assert.ok(pysimAdmSecuritySw(sw), sw); + for (const sw of ['9000', '6985', '63C2', '', null, undefined]) { + assert.ok(!pysimAdmSecuritySw(sw), String(sw)); + } +}); + +test('file-manager security failures route through the ADM hint', () => { + // no raw SW error rendering is left (all three sites use the hint helper) + assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone'); + assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 3); + assert.ok(html.includes("btn.textContent = t('Verify ADM')")); + assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')")); +}); diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js index b42b073..47bdaf7 100644 --- a/frontend/tests/card_state.test.js +++ b/frontend/tests/card_state.test.js @@ -25,6 +25,9 @@ let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n' + 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n' + 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n' + 'var _pysimAdmKey = null;\n' + + 'var _pysimAdmCanVerify = false;\nvar _pysimAdmAttemptsLeft = null;\n' + + 'var _pysimAdmBlocked = false;\nvar _pysimAdmVerifying = false;\n' + + 'var _pysimAdmStateKey = null;\nvar _pysimLastStatus = null;\n' + 'var _pysimHeaderIccid = undefined;\nvar _pysimHeaderScp80 = undefined;\nvar _pysimHeaderScp81 = undefined;\n' + 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; @@ -33,6 +36,7 @@ code += extractFunc(html, 'pysimControlDisabled') + '\n'; code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n'; code += extractFunc(html, 'pysimStkStatusChanged') + '\n'; code += extractFunc(html, 'pysimUpdateAdmIndicator') + '\n'; +code += extractFunc(html, 'pysimAdmResetAttempts') + '\n'; code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n'; code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n'; code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n'; @@ -72,6 +76,12 @@ function setup() { _pysimProactiveSeq = null; _pysimAdmVerified = null; _pysimAdmKey = null; + _pysimAdmCanVerify = false; + _pysimAdmAttemptsLeft = null; + _pysimAdmBlocked = false; + _pysimAdmVerifying = false; + _pysimAdmStateKey = null; + _pysimLastStatus = null; _pysimHeaderIccid = undefined; _pysimHeaderScp80 = undefined; _pysimHeaderScp81 = undefined; @@ -272,11 +282,12 @@ test('the ADM badge marks an ADM key in the matching preset', () => { globalThis.cardsFindByIccid = () => 0; pysimCardStateUpdate(st({ adm_verified: true })); assert.strictEqual(adm.textContent, 'ADM ✓ ⚿'); - assert.strictEqual(adm.title, 'ADM key in the card preset — verified'); + assert.strictEqual(adm.title, 'ADM key in the card preset — click to verify'); + assert.ok(adm.classes.has('cursor-pointer'), 'the badge is clickable with a preset key'); // key present, verification lost (e.g. card reset) pysimCardStateUpdate(st({ adm_verified: false })); assert.strictEqual(adm.textContent, 'ADM ✗ ⚿'); - assert.strictEqual(adm.title, 'ADM key in the card preset — not verified'); + assert.strictEqual(adm.title, 'ADM key in the card preset — click to verify'); // verified manually, preset has no ADM -> no key glyph globalThis.cards = []; pysimCardStateUpdate(st({ adm_verified: true })); diff --git a/pyproject.toml b/pyproject.toml index 79a1756..4dd53a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.7.7" +version = "2.7.8" 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 504bf87..c365bee 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -23,9 +23,10 @@ import gsm0338 # registers 'gsm03.38' codec from construct import GreedyBytes from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE +from osmocom.utils import rpad -VERSION = '2.7.7' +VERSION = '2.7.8' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -2164,13 +2165,15 @@ def _parse_psk_map(raw): def _redact_psk_fields(body): - """Copy of a request body with PSK key material masked (keys must never + """Copy of a request body with key material masked (keys must never reach the logs; identities stay visible for diagnostics).""" if not isinstance(body, dict): return body out = dict(body) if out.get('psk_hex'): out['psk_hex'] = '' + if out.get('adm'): + out['adm'] = '' psk_map = out.get('psk_map') if isinstance(psk_map, dict): out['psk_map'] = {k: '' for k in psk_map} @@ -2672,6 +2675,32 @@ def _send_status(scc): return scc._tp.send_apdu('%sf2000c%s' % (scc.cat_cla, p3)) +def _verify_adm(scc, app, adm_hex): + """Verify the card's ADM PIN (TS 102 221 VERIFY) and report the result. + + ``adm_hex`` is the key from the matched card preset (4-16 hex digits); + short keys are padded to the 8 CHV bytes with 'f', like pySim's + verify_adm. The result is structured so the UI can warn about the + remaining attempts: every failed VERIFY consumes one, and a blocked ADM + cannot be recovered from here (it needs the unblock key). + """ + chv = getattr(getattr(app, 'card', None), '_adm_chv_num', 0x0A) + fc = rpad(str(adm_hex).lower(), 16) + _data, sw = scc.send_apdu(scc.cla_byte + '2000' + ('%02X' % chv) + '08' + fc) + sw = str(sw).upper() + if sw == '9000': + rs = getattr(app, 'rs', None) + if rs is not None: + rs.adm_verified = True + return {'ok': True, 'sw': sw} + if re.fullmatch(r'63C[0-9A-F]', sw): + return {'ok': False, 'sw': sw, 'attempts_left': int(sw[3], 16)} + if sw in ('6983', '9804'): + return {'ok': False, 'sw': sw, 'blocked': True} + return {'ok': False, 'sw': sw, + 'error': 'Security status not satisfied' if sw == '6982' else 'Error'} + + def _send_event_download(scc, event_type, event_data=None): """Send ENVELOPE(Event Download) for the given event type. Builds: CLA C2 0000 Lc D6 [len] (99 01 [type] 82 02 82 81 [extra])""" @@ -3592,6 +3621,32 @@ class PysimHandler(BaseHTTPRequestHandler): sys.stderr.write("APDU: %s → ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed)) self._send_json(err, 500) self._log_resp(err) + elif self.path == '/api/verify-adm': + scc = self.server.scc + app = self.server.app + if not scc or not app: + self._send_json({'error': _err('reader_not_init', lang)}, 503) + self._log_resp({'error': _err('reader_not_init', lang)}) + return + body = self._read_body() + self._log_req(_redact_psk_fields(body)) + adm = re.sub(r'\s', '', str(body.get('adm') or '')).upper() + if not re.fullmatch(r'(?:[0-9A-F]{2}){2,8}', adm): + resp = {'ok': False, 'error': 'adm must be 4-16 hex digits'} + self._send_json(resp, 400) + self._log_resp(resp) + return + try: + with _CARD_LOCK: + resp = _verify_adm(scc, app, adm) + sys.stderr.write('VERIFY ADM → SW: %s\n' % resp.get('sw')) + self._send_json(resp) + self._log_resp(resp) + except Exception as e: + resp = {'ok': False, 'error': str(e)} + sys.stderr.write('VERIFY ADM → ERROR: %s\n' % e) + self._send_json(resp, 500) + self._log_resp(resp) elif self.path == '/api/status-poll': scc = self.server.scc if not scc: diff --git a/tests/test_adm_verify.py b/tests/test_adm_verify.py new file mode 100644 index 0000000..76b6533 --- /dev/null +++ b/tests/test_adm_verify.py @@ -0,0 +1,82 @@ +# coding=utf-8 +"""Tests for the ADM verification helper used by ``POST /api/verify-adm``. + +The helper builds the TS 102 221 VERIFY APDU itself (instead of pySim's +``verify_chv``) so the raw SW can be reported back: the UI warns about the +remaining attempts after a 63Cx and stops trying once the ADM is blocked. +""" + +import unittest +from types import SimpleNamespace + +from pysim_simple_server import server + + +class FakeScc: + """Records the VERIFY APDU and returns a canned SW.""" + + cla_byte = '00' + + def __init__(self, sw='9000'): + self.sw = sw + self.apdus = [] + + def send_apdu(self, pdu): + self.apdus.append(pdu) + return '', self.sw + + +def _app(chv=0x0A): + return SimpleNamespace(card=SimpleNamespace(_adm_chv_num=chv), + rs=SimpleNamespace(adm_verified=False)) + + +class AdmVerifyTests(unittest.TestCase): + def test_success_sets_adm_verified(self): + app = _app() + scc = FakeScc('9000') + res = server._verify_adm(scc, app, '0011') + self.assertEqual(res, {'ok': True, 'sw': '9000'}) + self.assertTrue(app.rs.adm_verified) + # short keys are padded to the 8 CHV bytes with 'f' (pySim behaviour) + self.assertEqual(scc.apdus, ['0020000A08' + '0011' + 'f' * 12]) + + def test_full_length_key_is_not_padded(self): + scc = FakeScc('9000') + server._verify_adm(scc, _app(), 'DEADBEEFDEADBEEF') + self.assertEqual(scc.apdus, ['0020000A08' + 'deadbeefdeadbeef']) + + def test_chv_number_comes_from_the_card_model(self): + scc = FakeScc('9000') + server._verify_adm(scc, _app(chv=0x0B), '0011') + self.assertIn('0020000B', scc.apdus[0]) + + def test_63cx_reports_attempts_left(self): + app = _app() + res = server._verify_adm(FakeScc('63C2'), app, '0011') + self.assertEqual(res, {'ok': False, 'sw': '63C2', 'attempts_left': 2}) + self.assertFalse(app.rs.adm_verified) + + def test_last_attempt_reports_zero(self): + res = server._verify_adm(FakeScc('63C0'), _app(), '0011') + self.assertEqual(res, {'ok': False, 'sw': '63C0', 'attempts_left': 0}) + + def test_blocked_sw_marks_blocked(self): + for sw in ('6983', '9804'): + res = server._verify_adm(FakeScc(sw), _app(), '0011') + self.assertEqual(res, {'ok': False, 'sw': sw, 'blocked': True}) + + def test_other_sw_is_a_plain_error(self): + self.assertEqual(server._verify_adm(FakeScc('6982'), _app(), '0011'), + {'ok': False, 'sw': '6982', 'error': 'Security status not satisfied'}) + self.assertEqual(server._verify_adm(FakeScc('6A88'), _app(), '0011'), + {'ok': False, 'sw': '6A88', 'error': 'Error'}) + + def test_redaction_masks_adm(self): + out = server._redact_psk_fields({'adm': '0011', 'psk_hex': 'AA', 'other': 'x'}) + self.assertEqual(out, {'adm': '', 'psk_hex': '', 'other': 'x'}) + self.assertEqual(server._redact_psk_fields({'adm': ''}), {'adm': ''}) + + +if __name__ == '__main__': + unittest.main()