v3.20.0 (review fixes): the PoR decode uses the effective counter; the PWA fallback matches the server's seed

Two boundary flaws found reviewing v3.20.0:

- /api/send-ota decoded the PoR with the request's `cntr`, not the counter
  the packet was actually built with.  With the new seeding an omitted
  counter is the normal case, so a warning ENVELOPE's verdict - notably the
  live card's cntr_low PoR - could fail its RC/CC check and be lost (the
  success and the failure path are both fixed).  Tests pin the decode counter
  on both paths; the failure path also echoes the effective `cntr`.
- the PWA's effective counter (preview / verify / the older-server fallback)
  fell back to the preset's first keyset and then 1, while the server's empty
  seed builds counter 0 (no preset / kvn 0 / an unknown keyset).  A manual
  form or a keyless packet then produced a spurious "Verify vs pySim"
  MISMATCH and a preview that differed from the delivered packet.
  spPresetCounterValue()/spCntrEffective() now mirror _preset_counter_seed()
  exactly: the packet's matched keyset, else 0.
- the `OTA SEND: counter not tracked` line reflects the effective counter (a
  seeded value is logged too); docs/api.md records that the seed uses the
  packet's keyset whatever SPI1.b5b4 says.

Tests: 823 frontend + 737 Python; sw cache simple-v394.
This commit is contained in:
2026-10-05 22:37:28 +03:00
parent 85cc4b9535
commit bf6730f028
6 changed files with 120 additions and 14 deletions
+4 -1
View File
@@ -514,7 +514,10 @@ the counter that was sent, advanced by one when the card accepted the packet.
- with `apdu` (the server builds the packet) a missing `cntr` is seeded from - with `apdu` (the server builds the packet) a missing `cntr` is seeded from
the named preset's keyset (the one the packet's KIc/KID resolve to); the the named preset's keyset (the one the packet's KIc/KID resolve to); the
effective value is echoed back as `cntr`, so a caller that omits it learns effective value is echoed back as `cntr`, so a caller that omits it learns
what was used; what was used. The seed uses the packet's keyset whatever `SPI1.b5b4`
says — with no counter check the CNTR field is spec-ignored, nothing is
advanced or persisted, and the `OTA SEND: counter not tracked` line names
it;
- an explicit `cntr` always wins — the packet uses it verbatim (a hand send, - an explicit `cntr` always wins — the packet uses it verbatim (a hand send,
the counter probe or a pre-built `sp` may deliberately pick one); the counter probe or a pre-built `sp` may deliberately pick one);
- the store only moves **forward**: an explicit value (or its next counter) - the store only moves **forward**: an explicit value (or its next counter)
+9 -6
View File
@@ -8583,10 +8583,12 @@ function spPresetCounterValue() {
if (idx < 0) return ''; if (idx < 0) return '';
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) || const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0; spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
// the same fallback cardsApplyFields uses: the matching keyset, else the // Only a keyset the packet's KIc/KID actually resolve - exactly the value
// preset's first one (an undefined duplicate would otherwise leave the // the server's _preset_counter_seed() uses (the body carries the same two
// placeholder empty) // bytes). No preset / kvn 0 / an unknown keyset = no seed: the packet
const ks = (kvn && spKeysetFor(cards[idx], kvn)) || spKeysetList(cards[idx])[0] || null; // then carries counter 0, and neither the preview nor the pySim reference
// may show a value the send would not use.
const ks = kvn ? spKeysetFor(cards[idx], kvn) : null;
return (ks && ks.cntr) || ''; return (ks && ks.cntr) || '';
} }
@@ -8598,11 +8600,12 @@ function spCntrPlaceholder() {
} }
// The counter the packet is built with (preview / verify): the typed // The counter the packet is built with (preview / verify): the typed
// override, else the preset's next value, else the historic 1 default. // override, else the preset's next value, else 0 - the same value the
// server's empty seed puts into the packet (v3.20.0).
function spCntrEffective() { function spCntrEffective() {
const el = document.getElementById('sp-cntr'); const el = document.getElementById('sp-cntr');
const typed = ((el && el.value) || '').replace(/[^0-9a-fA-F]/g, ''); const typed = ((el && el.value) || '').replace(/[^0-9a-fA-F]/g, '');
const v = typed || spPresetCounterValue() || '1'; const v = typed || spPresetCounterValue() || '0';
return v.toUpperCase().padStart(10, '0').slice(0, 10); return v.toUpperCase().padStart(10, '0').slice(0, 10);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v393'; const CACHE = 'simple-v394';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+20
View File
@@ -64,6 +64,10 @@ function fakeEnv(selValue, selId, presetCntr) {
'sp-kid-idx', 'sp-kid-alg', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) { 'sp-kid-idx', 'sp-kid-alg', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) {
els[id] = { value: '' }; els[id] = { value: '' };
} }
// the combined key bytes the server reads (updateSpKic maintains them in
// the real form): the fake preset's keyset resolves to kvn 1
els['sp-kic-hex'] = { value: '15' };
els['sp-kid-hex'] = { value: '15' };
els[selId || 'sp-card-sel'] = { value: selValue }; els[selId || 'sp-card-sel'] = { value: selValue };
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ name: 'C', cntr: presetCntr, globalThis.cards = [{ name: 'C', cntr: presetCntr,
@@ -143,6 +147,22 @@ test('the counter +/- buttons start from the preset value and create an override
assert.strictEqual(env.els['sp-cntr'].value, '0000000020'); assert.strictEqual(env.els['sp-cntr'].value, '0000000020');
}); });
test('spCntrEffective mirrors the server seed: matched keyset, else 0', () => {
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
assert.strictEqual(spCntrEffective(), '0000000020');
env.els['sp-cntr'].value = '0000000042'; // a typed override wins
assert.strictEqual(spCntrEffective(), '0000000042');
env.els['sp-cntr'].value = '';
env.els['sp-kic-hex'].value = '00'; // keyless: no keyset
env.els['sp-kid-hex'].value = '00';
assert.strictEqual(spCntrEffective(), '0000000000',
'the server seeds nothing for kvn 0 - the packet carries 0');
env.els['sp-kic-hex'].value = '15';
env.els['sp-kid-hex'].value = '15';
globalThis.cards = []; // no preset: no seed
assert.strictEqual(spCntrEffective(), '0000000000');
});
test('spPorAccepted treats a missing PoR and por_ok as accepted', () => { test('spPorAccepted treats a missing PoR and por_ok as accepted', () => {
assert.strictEqual(spPorAccepted(undefined), true); assert.strictEqual(spPorAccepted(undefined), true);
assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true); assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true);
+8 -5
View File
@@ -7800,9 +7800,6 @@ class PysimHandler(BaseHTTPRequestHandler):
# keyless SPI1 00 case) the field is ignored and never updated, so # keyless SPI1 00 case) the field is ignored and never updated, so
# no counter is advanced or persisted. # no counter is advanced or persisted.
counter_tracked = _counter_tracked(spi1) counter_tracked = _counter_tracked(spi1)
if body.get('cntr') and not counter_tracked:
sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: '
'no counter check)\n' % spi1)
include_cpi = body.get('includeCpi', True) include_cpi = body.get('includeCpi', True)
ram_format = None ram_format = None
try: try:
@@ -7845,6 +7842,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# preset seed - the packet may hold anything). # preset seed - the packet may hold anything).
sp_hex = sp sp_hex = sp
cntr = str(body.get('cntr') or '').strip().upper() cntr = str(body.get('cntr') or '').strip().upper()
if cntr and not counter_tracked:
# the packet carries a counter (explicit or seeded) but
# the card will not track it: SPI1.b5b4 = 00 means
# "present, ignored, never updated" (TS 102 225 5.1.1)
sys.stderr.write('OTA SEND: counter not tracked (SPI1 %s: '
'no counter check)\n' % spi1)
spi2_val = int(spi2, 16) spi2_val = int(spi2, 16)
# Capture the PoR from either transport: inline in the # Capture the PoR from either transport: inline in the
# ENVELOPE response or as a proactive SEND SHORT MESSAGE (some # ENVELOPE response or as a proactive SEND SHORT MESSAGE (some
@@ -7879,7 +7882,7 @@ class PysimHandler(BaseHTTPRequestHandler):
failed_por_hex = _sms_submit_por(submit_handler) failed_por_hex = _sms_submit_por(submit_handler)
failed_por = _decode_por(spi1, spi2, failed_por = _decode_por(spi1, spi2,
body.get('kic', ''), body.get('kid', ''), body.get('kic', ''), body.get('kid', ''),
body.get('cntr', ''), body.get('kicKey', ''), cntr, body.get('kicKey', ''),
body.get('kidKey', ''), failed_por_hex, body.get('kidKey', ''), failed_por_hex,
cmd_len=len(body.get('apdu') or '') // 2 or None) cmd_len=len(body.get('apdu') or '') // 2 or None)
if failed_por: if failed_por:
@@ -7917,7 +7920,7 @@ class PysimHandler(BaseHTTPRequestHandler):
por_hex = tpdu_b[idx:].hex() por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit' por_src = 'sms-submit'
por = _decode_por(spi1, spi2, body.get('kic', ''), por = _decode_por(spi1, spi2, body.get('kic', ''),
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''), body.get('kid', ''), cntr, body.get('kicKey', ''),
body.get('kidKey', ''), por_hex, body.get('kidKey', ''), por_hex,
cmd_len=len(body.get('apdu') or '') // 2 or None) cmd_len=len(body.get('apdu') or '') // 2 or None)
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR # Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
+78 -1
View File
@@ -598,7 +598,7 @@ class PresetStoreHttpTests(unittest.TestCase):
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \ 'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por', mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'por_ok'}): return_value={'response_status': 'por_ok'}) as decode:
status, resp = self._post('/api/send-ota', { status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001', 'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
@@ -606,10 +606,39 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp) self.assertTrue(resp.get('success'), resp)
self.assertEqual(build.call_args[0][5], '0000000001') # seeded self.assertEqual(build.call_args[0][5], '0000000001') # seeded
# the PoR decode must use the counter the packet was built with - a
# different one breaks the RC/CC check and loses the verdict
self.assertEqual(decode.call_args[0][4], '0000000001')
self.assertEqual(resp['cntr'], '0000000001') self.assertEqual(resp['cntr'], '0000000001')
self.assertEqual(resp['final_cntr'], '0000000002') self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002')
def test_send_ota_failure_reports_the_effective_counter(self):
"""A refused ENVELOPE still echoes the counter the packet carried and
decodes its PoR with it (v3.20.0)."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
self.server.app = object()
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})), \
mock.patch.object(self.srv, '_send_secured_packet',
return_value={'success': False, 'sw': '6A82',
'bytes': 34, 'segments': 1,
'error': 'card refused'}), \
mock.patch.object(self.srv, '_decode_por',
return_value={'response_status': 'cntr_low'}) as decode:
status, resp = self._post('/api/send-ota', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'kicKey': 'AA', 'kidKey': 'BB'})
self.assertEqual(status, 200, resp)
self.assertFalse(resp['success'], resp)
self.assertEqual(resp['cntr'], '0000000001')
self.assertEqual(decode.call_args[0][4], '0000000001')
# the store must not move for a rejected packet
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001')
def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self): def test_send_ota_explicit_lower_counter_does_not_regress_the_store(self):
"""An explicit counter is used for the packet (a hand send), but the """An explicit counter is used for the packet (a hand send), but the
store keeps its higher value (monotonic persistence, v3.20.0).""" store keeps its higher value (monotonic persistence, v3.20.0)."""
@@ -638,6 +667,54 @@ class PresetStoreHttpTests(unittest.TestCase):
# the lower value never regresses the store # the lower value never regresses the store
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010') self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000010')
def test_sp_verify_seeds_the_reference_counter(self):
"""v3.20.0: a verify without `cntr` builds the pySim reference from
the preset's keyset - the same value the JS packet was built with."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
key = '00112233445566778899AABBCCDDEEFF' # synthetic, tests only
body = {'preset_id': pid, 'apdu': '00A40000023F00', 'tar': 'B00000',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
'kicKey': key, 'kidKey': key}
status, seeded = self._post('/api/sp-verify', body)
self.assertEqual(status, 200, seeded)
status, pinned = self._post('/api/sp-verify', dict(body, cntr='0000000001'))
self.assertEqual(status, 200, pinned)
self.assertEqual(seeded['py_sp'], pinned['py_sp'])
# the JS packet built with that counter matches the reference
status, match = self._post('/api/sp-verify', dict(body, sp=seeded['py_sp']))
self.assertEqual(status, 200, match)
self.assertTrue(match['match'], match)
# an explicit counter still wins over the store's
status, other = self._post('/api/sp-verify',
dict(body, sp=seeded['py_sp'], cntr='0000000002'))
self.assertFalse(other['match'])
def test_tar_probe_without_cntr_seeds_the_preset_counter(self):
"""v3.20.0: the probe's first packet carries the preset's counter."""
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)
pid = resp['preset']['id']
self.server.app = object() # the TAR probe needs an app + scc
with mock.patch.object(self.srv, '_build_secured_packet',
return_value=('00' * 20, {})) as build, \
mock.patch.object(self.srv, '_send_secured_packet', return_value={
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
mock.patch.object(self.srv, '_decode_por', return_value={
'response_status': 'por_ok', 'tar': 'B00000', 'cntr': '0000000001',
'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
status, resp = self._post('/api/tar-probe', {
'preset_id': pid, 'kic': '15', 'kid': '15', 'spi1': '16',
'spi2': '01', 'kicKey': 'AA', 'kidKey': 'BB',
'tars': ['B00000']})
self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp)
self.assertEqual(build.call_args[0][5], '0000000001') # seeded
self.assertEqual(resp['final_cntr'], '0000000002')
self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000002')
def test_create_update_delete_round_trip(self): def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp) self.assertEqual(status, 200, resp)