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:
+4
-1
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v393';
|
||||
const CACHE = 'simple-v394';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user