v3.14.2: the counter probe's verdict rule
The ladder only advances on cntr_low; any other verdict stops the search.
A stop on a non-por_ok verdict after at least one cntr_low keeps the value
anyway - the earlier rejection proves it is above the card's counter - but
the response reports success: false with counter_saved, the verdict and the
saved counter ("something is likely wrong with the security settings");
the PWA shows it amber. Without a prior cntr_low nothing is saved, and a
missing PoR stays an error. actual_response_sms_submit/ussd count as
accepted.
Docs: docs/api.md, repo AGENTS. sw cache simple-v377.
Tests: 672 Python + 798 frontend green.
This commit is contained in:
+10
@@ -306,6 +306,16 @@ read-only `SELECT MF` - the counter advances on the packet's security
|
|||||||
acceptance whatever the applet answers (`6A86`/`6D00` are normal there). A
|
acceptance whatever the applet answers (`6A86`/`6D00` are normal there). A
|
||||||
SPI1 without a counter check is refused (400).
|
SPI1 without a counter check is refused (400).
|
||||||
|
|
||||||
|
The ladder only advances on a `cntr_low` verdict (the card's "your counter is
|
||||||
|
not above mine"); **any other verdict stops the search**. A stop on a
|
||||||
|
non-`por_ok` verdict (`rc_cc_ds_failed`, `tar_unknown`, …) *after* at least one
|
||||||
|
`cntr_low` keeps the counter value anyway - the earlier rejection proves the
|
||||||
|
value is above the card's counter - but the response reports `success: false`,
|
||||||
|
`counter_saved: true`, the `verdict` and the saved counter, stating that
|
||||||
|
something is likely wrong with the security settings. Without a prior
|
||||||
|
`cntr_low` nothing is saved (the value is not established), and a missing PoR
|
||||||
|
is an error too.
|
||||||
|
|
||||||
**Response:** `{"success": true, "accepted_cntr": "0001000000",
|
**Response:** `{"success": true, "accepted_cntr": "0001000000",
|
||||||
"stored_cntr": "0001000001", "packets": 3, "stopped": "accepted",
|
"stored_cntr": "0001000001", "packets": 3, "stopped": "accepted",
|
||||||
"attempts": [{"cntr": "…", "por_status": "cntr_low"}, …],
|
"attempts": [{"cntr": "…", "por_status": "cntr_low"}, …],
|
||||||
|
|||||||
+12
-2
@@ -1877,7 +1877,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.14.1';
|
const SIMPLE_VERSION = '3.14.2';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -11335,15 +11335,24 @@ async function ramSyncCounter(presetIdx, view) {
|
|||||||
esc(t('Syncing counter...')) + '</div>';
|
esc(t('Syncing counter...')) + '</div>';
|
||||||
try {
|
try {
|
||||||
const data = await pysimFetch('/api/counter-probe', body);
|
const data = await pysimFetch('/api/counter-probe', body);
|
||||||
if (data.success) {
|
if (data.success || data.counter_saved) {
|
||||||
await cardsFetch();
|
await cardsFetch();
|
||||||
// the keyset selects' labels carry the counter - refresh them too
|
// the keyset selects' labels carry the counter - refresh them too
|
||||||
spKeysetSync();
|
spKeysetSync();
|
||||||
const cntrEl = document.getElementById('sp-cntr');
|
const cntrEl = document.getElementById('sp-cntr');
|
||||||
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
|
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
|
||||||
|
}
|
||||||
|
if (data.success) {
|
||||||
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
|
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
|
||||||
esc(data.stored_cntr) + '. ' + t('Retry the operation.');
|
esc(data.stored_cntr) + '. ' + t('Retry the operation.');
|
||||||
if (box) box.innerHTML = '<div class="text-sm text-emerald-600 dark:text-emerald-400">' + msg + '</div>';
|
if (box) box.innerHTML = '<div class="text-sm text-emerald-600 dark:text-emerald-400">' + msg + '</div>';
|
||||||
|
} else if (data.counter_saved) {
|
||||||
|
// the search stopped on a security verdict, but the value was
|
||||||
|
// saved (a cntr_low rejection preceded it, so it is above the
|
||||||
|
// card's counter) - amber: the security settings need a look
|
||||||
|
const msg = t('Counter sync error') + ': ' + esc(data.error || '') + ' ' +
|
||||||
|
t('Retry the operation.');
|
||||||
|
if (box) box.innerHTML = '<div class="text-sm text-amber-600 dark:text-amber-400">' + msg + '</div>';
|
||||||
} else {
|
} else {
|
||||||
const msg = t('Counter sync failed') + ': ' + esc(data.error || '');
|
const msg = t('Counter sync failed') + ': ' + esc(data.error || '');
|
||||||
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' + msg + '</div>';
|
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' + msg + '</div>';
|
||||||
@@ -21133,6 +21142,7 @@ const LANG_RU = {
|
|||||||
'Syncing counter...': 'Синхронизация счётчика...',
|
'Syncing counter...': 'Синхронизация счётчика...',
|
||||||
'Counter synced': 'Счётчик синхронизирован',
|
'Counter synced': 'Счётчик синхронизирован',
|
||||||
'Counter sync failed': 'Не удалось синхронизировать счётчик',
|
'Counter sync failed': 'Не удалось синхронизировать счётчик',
|
||||||
|
'Counter sync error': 'Ошибка синхронизации счётчика',
|
||||||
'Retry the operation.': 'Повторите операцию.',
|
'Retry the operation.': 'Повторите операцию.',
|
||||||
'No card preset selected': 'Пресет карты не выбран',
|
'No card preset selected': 'Пресет карты не выбран',
|
||||||
'Format': 'Формат',
|
'Format': 'Формат',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v376';
|
const CACHE = 'simple-v377';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -983,6 +983,8 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
|
|||||||
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
|
assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
|
||||||
assert.ok(sync.includes("ramListingSpi2('40')"),
|
assert.ok(sync.includes("ramListingSpi2('40')"),
|
||||||
'the RAM view must probe with the RAM listing transport (a PoR is required)');
|
'the RAM view must probe with the RAM listing transport (a PoR is required)');
|
||||||
|
assert.ok(sync.includes('data.counter_saved'),
|
||||||
|
'the saved-on-error case must be reported in amber');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the RAM form offers the command format choice', () => {
|
test('the RAM form offers the command format choice', () => {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.14.1"
|
version = "3.14.2"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.14.1'
|
VERSION = '3.14.2'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -1765,6 +1765,7 @@ def _counter_probe(server, scc, preset, body, send_fn=None):
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
out['error'] = 'the counter is already at the ceiling %s' % p['ceiling']
|
out['error'] = 'the counter is already at the ceiling %s' % p['ceiling']
|
||||||
return out
|
return out
|
||||||
|
saw_low = False
|
||||||
for cntr in candidates:
|
for cntr in candidates:
|
||||||
if send_fn is not None:
|
if send_fn is not None:
|
||||||
send, por = send_fn(p, cntr)
|
send, por = send_fn(p, cntr)
|
||||||
@@ -1803,20 +1804,47 @@ def _counter_probe(server, scc, preset, body, send_fn=None):
|
|||||||
out['attempts'].append(attempt)
|
out['attempts'].append(attempt)
|
||||||
sys.stderr.write('COUNTER-PROBE: %s -> %s\n'
|
sys.stderr.write('COUNTER-PROBE: %s -> %s\n'
|
||||||
% (cntr, pstatus or ('ENVELOPE %s' % (send.get('sw') or '?'))))
|
% (cntr, pstatus or ('ENVELOPE %s' % (send.get('sw') or '?'))))
|
||||||
if pstatus == 'por_ok':
|
if pstatus == 'cntr_low':
|
||||||
|
# the only verdict that keeps the ladder going: the card says the
|
||||||
|
# attempted counter is not above its own
|
||||||
|
saw_low = True
|
||||||
|
continue
|
||||||
|
if pstatus in ('por_ok', 'actual_response_sms_submit', 'actual_response_ussd'):
|
||||||
|
# accepted (an actual-response status means the response travels
|
||||||
|
# via SMS/USSD - the packet itself was accepted)
|
||||||
stored = _ram_next_cntr(cntr, True)
|
stored = _ram_next_cntr(cntr, True)
|
||||||
_preset_counter_persist(server, preset.get('id'), stored,
|
_preset_counter_persist(server, preset.get('id'), stored,
|
||||||
'counter-probe', p['kvn'])
|
'counter-probe', p['kvn'])
|
||||||
out.update({'success': True, 'stopped': 'accepted',
|
out.update({'success': True, 'stopped': 'accepted', 'verdict': pstatus,
|
||||||
|
'counter_saved': True,
|
||||||
'accepted_cntr': cntr, 'stored_cntr': stored})
|
'accepted_cntr': cntr, 'stored_cntr': stored})
|
||||||
sys.stderr.write('COUNTER-PROBE: accepted %s, preset stores %s\n'
|
sys.stderr.write('COUNTER-PROBE: accepted %s (%s), preset stores %s\n'
|
||||||
% (cntr, stored))
|
% (cntr, pstatus, stored))
|
||||||
return out
|
return out
|
||||||
if pstatus == 'cntr_low':
|
|
||||||
continue
|
|
||||||
out['stopped'] = 'error'
|
out['stopped'] = 'error'
|
||||||
if pstatus:
|
if pstatus:
|
||||||
out['error'] = send.get('error') or ('card verdict: %s' % pstatus)
|
if saw_low:
|
||||||
|
# A cntr_low rejection earlier proved the card's counter is
|
||||||
|
# below this value, so the packet got past the counter check
|
||||||
|
# and failed a later security/permission one (wrong keys,
|
||||||
|
# TAR, level). Keep the value - it is the sync point the
|
||||||
|
# probe was looking for - but report the error so the
|
||||||
|
# security settings get looked at.
|
||||||
|
stored = _ram_next_cntr(cntr, True)
|
||||||
|
_preset_counter_persist(server, preset.get('id'), stored,
|
||||||
|
'counter-probe', p['kvn'])
|
||||||
|
out.update({'counter_saved': True, 'verdict': pstatus,
|
||||||
|
'accepted_cntr': cntr, 'stored_cntr': stored,
|
||||||
|
'error': 'card verdict: %s - the counter value was still '
|
||||||
|
'saved (accepted %s, stored %s); something is '
|
||||||
|
'likely wrong with the security settings'
|
||||||
|
% (pstatus, cntr, stored)})
|
||||||
|
sys.stderr.write('COUNTER-PROBE: %s -> %s, value saved (preset stores %s)\n'
|
||||||
|
% (cntr, pstatus, stored))
|
||||||
|
else:
|
||||||
|
out['error'] = send.get('error') or (
|
||||||
|
'card verdict: %s - the counter was not synced '
|
||||||
|
'(the search never saw a cntr_low rejection)' % pstatus)
|
||||||
else:
|
else:
|
||||||
out['error'] = send.get('error') or (
|
out['error'] = send.get('error') or (
|
||||||
'card verdict: no PoR (the card sent none for SPI2 %s)' % p['spi2'])
|
'card verdict: no PoR (the card sent none for SPI2 %s)' % p['spi2'])
|
||||||
|
|||||||
@@ -2410,17 +2410,64 @@ class CounterProbeTests(unittest.TestCase):
|
|||||||
self.assertEqual(res['packets'], 3)
|
self.assertEqual(res['packets'], 3)
|
||||||
self.assertEqual(persisted, [('p1', '0000000C35', 'counter-probe', 2)])
|
self.assertEqual(persisted, [('p1', '0000000C35', 'counter-probe', 2)])
|
||||||
|
|
||||||
def test_probe_stops_on_a_non_counter_error(self):
|
def test_probe_saves_a_post_cntr_low_verdict_but_reports_it(self):
|
||||||
def send_fn(p, cntr):
|
# a cntr_low -> other-error transition proves the value is above the
|
||||||
return ({'success': False, 'sw': '6200', 'error': 'ENVELOPE failed (6200)'},
|
# card's counter (the packet got past the counter check): keep it, but
|
||||||
{'response_status': 'refused'})
|
# report the security error so the settings get looked at
|
||||||
|
persisted = []
|
||||||
|
|
||||||
res = _counter_probe(None, None, self._preset(), {'max_attempts': 4},
|
class Store:
|
||||||
send_fn=send_fn)
|
def set_counter(self, pid, cntr, source, kvn=None):
|
||||||
|
persisted.append((pid, cntr, source, kvn))
|
||||||
|
|
||||||
|
server = types.SimpleNamespace(card_presets=Store())
|
||||||
|
verdicts = ['cntr_low', 'rc_cc_ds_failed']
|
||||||
|
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
status = verdicts.pop(0)
|
||||||
|
return ({'success': False, 'sw': '9000', 'bytes': 42, 'segments': 1},
|
||||||
|
{'response_status': status, 'decoded': {'last_status_word': '9000'}})
|
||||||
|
|
||||||
|
res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn)
|
||||||
self.assertFalse(res['success'])
|
self.assertFalse(res['success'])
|
||||||
self.assertEqual(res['packets'], 1)
|
|
||||||
self.assertEqual(res['stopped'], 'error')
|
self.assertEqual(res['stopped'], 'error')
|
||||||
self.assertIn('6200', res['error'])
|
self.assertTrue(res['counter_saved'])
|
||||||
|
self.assertEqual(res['verdict'], 'rc_cc_ds_failed')
|
||||||
|
self.assertEqual(res['accepted_cntr'], '0000000C30')
|
||||||
|
self.assertEqual(res['stored_cntr'], '0000000C31')
|
||||||
|
self.assertEqual(persisted, [('p1', '0000000C31', 'counter-probe', 2)])
|
||||||
|
self.assertIn('security settings', res['error'])
|
||||||
|
|
||||||
|
def test_probe_does_not_save_a_verdict_without_a_cntr_low(self):
|
||||||
|
# no transition: the value is not established, nothing may be stored
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
return ({'success': False, 'sw': '9000'},
|
||||||
|
{'response_status': 'rc_cc_ds_failed', 'decoded': {}})
|
||||||
|
|
||||||
|
res = _counter_probe(None, None, self._preset(), {}, send_fn=send_fn)
|
||||||
|
self.assertFalse(res['success'])
|
||||||
|
self.assertFalse(res.get('counter_saved'))
|
||||||
|
self.assertNotIn('stored_cntr', res)
|
||||||
|
self.assertIn('not synced', res['error'])
|
||||||
|
|
||||||
|
def test_probe_treats_an_actual_response_as_accepted(self):
|
||||||
|
# 0x0B: the packet was accepted, the response travels via SMS-SUBMIT
|
||||||
|
persisted = []
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def set_counter(self, pid, cntr, source, kvn=None):
|
||||||
|
persisted.append((pid, cntr, source, kvn))
|
||||||
|
|
||||||
|
server = types.SimpleNamespace(card_presets=Store())
|
||||||
|
|
||||||
|
def send_fn(p, cntr):
|
||||||
|
return ({'success': True, 'sw': '9000'},
|
||||||
|
{'response_status': 'actual_response_sms_submit', 'decoded': {}})
|
||||||
|
|
||||||
|
res = _counter_probe(server, None, self._preset(), {}, send_fn=send_fn)
|
||||||
|
self.assertTrue(res['success'])
|
||||||
|
self.assertEqual(res['verdict'], 'actual_response_sms_submit')
|
||||||
|
self.assertEqual(persisted, [('p1', '0000000C2F', 'counter-probe', 2)])
|
||||||
|
|
||||||
def test_probe_reports_a_ceiling_overrun(self):
|
def test_probe_reports_a_ceiling_overrun(self):
|
||||||
def send_fn(p, cntr):
|
def send_fn(p, cntr):
|
||||||
|
|||||||
Reference in New Issue
Block a user