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
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
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,
the counter probe or a pre-built `sp` may deliberately pick one);
- 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 '';
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
// the same fallback cardsApplyFields uses: the matching keyset, else the
// preset's first one (an undefined duplicate would otherwise leave the
// placeholder empty)
const ks = (kvn && spKeysetFor(cards[idx], kvn)) || spKeysetList(cards[idx])[0] || null;
// Only a keyset the packet's KIc/KID actually resolve - exactly the value
// the server's _preset_counter_seed() uses (the body carries the same two
// bytes). No preset / kvn 0 / an unknown keyset = no seed: the packet
// 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) || '';
}
@@ -8598,11 +8600,12 @@ function spCntrPlaceholder() {
}
// 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() {
const el = document.getElementById('sp-cntr');
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);
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v393';
const CACHE = 'simple-v394';
const URLS = [
'index.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']) {
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 };
globalThis.document = { getElementById: id => els[id] || null };
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');
});
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', () => {
assert.strictEqual(spPorAccepted(undefined), 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
# no counter is advanced or persisted.
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)
ram_format = None
try:
@@ -7845,6 +7842,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# preset seed - the packet may hold anything).
sp_hex = sp
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)
# Capture the PoR from either transport: inline in the
# 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 = _decode_por(spi1, spi2,
body.get('kic', ''), body.get('kid', ''),
body.get('cntr', ''), body.get('kicKey', ''),
cntr, body.get('kicKey', ''),
body.get('kidKey', ''), failed_por_hex,
cmd_len=len(body.get('apdu') or '') // 2 or None)
if failed_por:
@@ -7917,7 +7920,7 @@ class PysimHandler(BaseHTTPRequestHandler):
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
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,
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
+78 -1
View File
@@ -598,7 +598,7 @@ class PresetStoreHttpTests(unittest.TestCase):
'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
'response_data': '027100000e0ab0000000000000010000019000'}), \
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', {
'preset_id': pid, 'apdu': '80E2900000', 'tar': 'B00001',
'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
@@ -606,10 +606,39 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 200, resp)
self.assertTrue(resp.get('success'), resp)
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['final_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):
"""An explicit counter is used for the packet (a hand send), but the
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
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):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)