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
This commit is contained in:
2026-09-20 22:09:39 +03:00
parent 3c6bc7ac02
commit 2c793720f6
10 changed files with 377 additions and 17 deletions
+65
View File
@@ -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()')"));
});
+13 -2
View File
@@ -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 }));