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
+57 -2
View File
@@ -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'] = '<redacted>'
if out.get('adm'):
out['adm'] = '<redacted>'
psk_map = out.get('psk_map')
if isinstance(psk_map, dict):
out['psk_map'] = {k: '<redacted>' 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: