';
@@ -10173,7 +10182,13 @@ function ramStepLine(s, idx, comps, blockSize) {
if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B';
if (s.segments > 1) line += ' / ' + s.segments + ' SMS';
} else {
- if (s.por_status === 'por_ok') line += t('PoR ok');
+ if (s.por_bad_format) {
+ // the card parsed the scripting template but aborted on a
+ // malformed command (TS 102 226 5.2.2 Bad format TLV): there is no
+ // remote status word to show
+ line += t('bad format') + ' ' + s.por_bad_format +
+ (s.por_bad_format_name ? ' (' + s.por_bad_format_name + ')' : '');
+ } else if (s.por_status === 'por_ok') line += t('PoR ok');
else if (s.por_status === 'no_por') line += t('no PoR');
else if (s.por_status && s.por_status.indexOf('por_error') === 0) {
const legacy = s.por_status.replace(/^por_error_?/, '');
@@ -10519,8 +10534,9 @@ async function ramCompatTest(sp) {
if (probeNote) modal += '
' + esc(probeNote) + '
';
modal += ramCompatDetailHtml(data, mem);
if (spCntrLow(data)) {
- modal += ramCntrLowHtml(ramCntrLowPresetIdx());
- ramShowCntrLow();
+ const v = ramCntrLowView();
+ modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
+ ramShowCntrLow(undefined, v);
}
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
}
@@ -10880,27 +10896,42 @@ function ramNvLine(data) {
// The preset the failed operation used (the RAM form's selection, else the
// SCP80 form's) - the "Go to preset" target.
-function ramCntrLowPresetIdx() {
+// The view a low-counter notice came from ('ram' | 'sp'): the notice's
+// actions (sync probe, preset jump) must follow the failed operation's view -
+// the two views can sit on different presets.
+function ramCntrLowView() {
+ const pick = id => {
+ const el = document.getElementById(id);
+ const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN;
+ return (!isNaN(i) && cards[i]) ? i : -1;
+ };
+ return pick('ram-card-sel') >= 0 ? 'ram' : 'sp';
+}
+
+function ramCntrLowPresetIdx(view) {
const pick = id => {
const el = document.getElementById(id);
const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN;
return (!isNaN(i) && cards[i]) ? i : -1;
};
const ram = pick('ram-card-sel');
- return ram >= 0 ? ram : pick('sp-card-sel');
+ const sp = pick('sp-card-sel');
+ if (view === 'sp') return sp >= 0 ? sp : ram;
+ return ram >= 0 ? ram : sp;
}
// Low-counter notice (pure HTML): the facts plus two actions - a bounded
// counter sync probe and a button that opens the failed operation's preset in
// the Cards tab, where the counter can be raised by hand.
-function ramCntrLowHtml(presetIdx) {
+function ramCntrLowHtml(presetIdx, view) {
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
+ const v = view === 'sp' ? 'sp' : 'ram';
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' +
'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap';
return '
' +
'' + text + '' +
- '
';
@@ -10908,9 +10939,10 @@ function ramCntrLowHtml(presetIdx) {
// Show the low-counter notice in the RAM result area (and, when the operation
// modal is still open, in its detail block).
-function ramShowCntrLow(presetIdx) {
- const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
- const html = ramCntrLowHtml(idx);
+function ramShowCntrLow(presetIdx, view) {
+ const v = view === undefined ? ramCntrLowView() : view;
+ const idx = presetIdx === undefined ? ramCntrLowPresetIdx(v) : presetIdx;
+ const html = ramCntrLowHtml(idx, v);
const el = document.getElementById('ram-cntr-low');
if (el && el.classList) {
el.innerHTML = html;
@@ -10927,21 +10959,24 @@ function ramShowCntrLow(presetIdx) {
// +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists
// accepted+1. A rejected attempt changes nothing on the card, so only the
// accepted packet moves its counter (the card never reports its value).
-async function ramSyncCounter(presetIdx) {
+async function ramSyncCounter(presetIdx, view) {
const i = Number(presetIdx);
const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null;
const box = document.getElementById('ram-cntr-low');
if (!preset) { alert(t('No card preset selected')); return; }
- // The keyset the failed operation used: the RAM form's selector, else the
- // SCP80 form's (both hold the keyset number as their option value).
+ // The keyset the failed operation used: the originating view's selector
+ // first, the other view's as the fallback (both hold the keyset number as
+ // their option value).
const kvnOf = id => {
const sel = document.getElementById(id);
const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN;
return Number.isInteger(v) && v > 0 ? v : null;
};
+ const own = view === 'sp' ? 'sp-keyset-sel' : 'ram-keyset-sel';
+ const other = own === 'ram-keyset-sel' ? 'sp-keyset-sel' : 'ram-keyset-sel';
const val = id => (document.getElementById(id) || {}).value || '';
const body = { preset_id: preset.id };
- const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel');
+ const kvn = kvnOf(own) || kvnOf(other);
if (kvn) body.kvn = kvn;
const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
if (tar) body.tar = tar;
@@ -10955,6 +10990,8 @@ async function ramSyncCounter(presetIdx) {
const data = await pysimFetch('/api/counter-probe', body);
if (data.success) {
await cardsFetch();
+ // the keyset selects' labels carry the counter - refresh them too
+ spKeysetSync();
const cntrEl = document.getElementById('sp-cntr');
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
@@ -11037,8 +11074,9 @@ function ramShowInstallResult(data, okText, capMem) {
// low counter: not recoverable by retrying - state it and offer the
// bounded sync probe or the preset jump (v3.6.58)
if (spCntrLow(data)) {
- modal += ramCntrLowHtml(ramCntrLowPresetIdx());
- ramShowCntrLow();
+ const v = ramCntrLowView();
+ modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
+ ramShowCntrLow(undefined, v);
}
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
}
@@ -19644,6 +19682,7 @@ const LANG_RU = {
'Free NV:': 'Свободно NV:',
'ETSI release:': 'Релиз ETSI:',
'unknown': 'неизвестно',
+ 'bad format': 'неверный формат',
'Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.': 'Определяется по версии установленного на карте пакета uicc.toolkit (та же таблица, что и в анализе CAP); версии даёт расширенный запрос реестра.',
'Free Volatile:': 'Свободно volatile:',
'AID:': 'AID:',
diff --git a/frontend/sw.js b/frontend/sw.js
index 01acac4..a2b39ad 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v355';
+const CACHE = 'simple-v356';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index fc91421..478691d 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -27,7 +27,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
'ramExpandedReport', 'ramExpandedMore', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'cardEtsiRelease', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
- 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
+ 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
@@ -852,18 +852,21 @@ test('ramCntrLowHtml states the facts without a fabricated counter', () => {
globalThis.t = s => s;
const out = ramCntrLowHtml(2);
const noPreset = ramCntrLowHtml(-1);
+ // the notice follows the failed operation's view ('sp' -> its own keyset)
+ const spView = ramCntrLowHtml(1, 'sp');
delete globalThis.t;
assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out);
// the PoR's CNTR is a copy of the command's counter, never the card's: no
// value may be shown (v3.9.x), only the verdict and the two actions
assert.ok(!out.includes('card counter'), out);
- assert.ok(out.includes('onclick="ramSyncCounter(2)"'), out);
+ assert.ok(out.includes('onclick="ramSyncCounter(2, \'ram\')"'), out);
assert.ok(out.includes('Sync counter'), out);
assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out);
assert.ok(out.includes('Go to preset'), out);
// without a known preset the buttons still work (-1)
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
- assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset);
+ assert.ok(noPreset.includes('onclick="ramSyncCounter(-1, \'ram\')"'), noPreset);
+ assert.ok(spView.includes('onclick="ramSyncCounter(1, \'sp\')"'), spView);
});
test('the explorer shows versions on app and ISD rows too', () => {
@@ -877,6 +880,19 @@ test('the explorer shows versions on app and ISD rows too', () => {
assert.ok(out.includes('Version: 9.3'), out);
});
+test('ramStepLine names a bad-format failure instead of "PoR ok"', () => {
+ globalThis.t = s => s;
+ const line = ramStepLine({ name: 'P2=02 (lean)', por_status: 'por_ok',
+ por_error: 'bad format 02 (wrong length)', por_bad_format: '02',
+ por_bad_format_name: 'wrong length' }, 0);
+ const plain = ramStepLine({ name: 'LOAD', por_status: 'por_ok', por_sw: '9000' }, 1);
+ delete globalThis.t;
+ assert.ok(line.includes('bad format 02 (wrong length)'), line);
+ assert.ok(!line.includes('PoR ok'), line);
+ // a healthy step is unchanged
+ assert.ok(plain.includes('9000') || plain.includes('PoR ok'), plain);
+});
+
test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => {
const src = extractFunc(html, 'ramExplore');
assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist');
@@ -908,9 +924,11 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
// preset, the keyset the failed operation used and its SPI/TAR
const sync = extractFunc(html, 'ramSyncCounter');
assert.ok(sync.includes("'/api/counter-probe'"), sync);
- assert.ok(sync.includes("kvnOf('ram-keyset-sel')"), sync);
- assert.ok(sync.includes("kvnOf('sp-keyset-sel')"), sync);
+ assert.ok(sync.includes("own = view === 'sp'"), sync);
+ assert.ok(sync.includes('kvnOf(own)'), sync);
+ assert.ok(sync.includes('kvnOf(other)'), sync);
assert.ok(sync.includes("getElementById('sp-cntr')"), sync);
+ assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh');
});
test('the RAM form offers the command format choice', () => {
diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js
index 3685a85..42935ac 100644
--- a/frontend/tests/ram_counter_flow.test.js
+++ b/frontend/tests/ram_counter_flow.test.js
@@ -30,7 +30,7 @@ let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'ramHideProgress', 'ramDeleteFromExplorer',
- 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
+ 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index b6c46a6..b84e8aa 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -1553,10 +1553,11 @@ def _ram_detect_format(server, scc, sp, state):
"""Detect the card's RAM command format for one operation: send the
read-only GET STATUS [ISD] probe compact, then in the two expanded
codings; the first format whose remote SW succeeds wins (compact
- preferred). Cards implement different subsets - the live ISD answers the
- definite form with a Bad format TLV (wrong length) and executes the
- indefinite one, while an RFM applet took the definite form. Probe
- packets are recorded as steps and consume counters only when accepted;
+ preferred). Cards implement different subsets: the reference TCA loader
+ sends the listing queries wrapped and unchained, while the live card needs
+ the compact chain (a length-mismatched template draws a Bad format TLV,
+ which the step result names). Probe packets are recorded as steps and
+ consume counters only when accepted;
the result is used for this operation only - the next operation re-checks
(v3.6.24)."""
for fmt, apdu in (('compact', _RAM_PROBE_APDU),
@@ -1579,7 +1580,8 @@ def _ram_detect_format(server, scc, sp, state):
# accepted, so rejected attempts change nothing; the search walks a doubling
# ladder (start+1, +2, +4, ...) and stops at a ceiling far below the 40-bit
# maximum, where the counter would get blocked.
-COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: read-only, applet-visible
+COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF, as TAR_PROBE_DEFAULT_APDU
+ # (a literal: that constant is defined later)
COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default
COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits
@@ -1810,6 +1812,7 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments):
# the expanded script was rejected as malformed before executing:
# there is no remote status word
step['por_bad_format'] = por['bad_format']
+ step['por_bad_format_name'] = por.get('bad_format_name')
error = 'bad format %s (%s)' % (por['bad_format'],
por.get('bad_format_name') or '')
elif pstatus != 'por_ok':
@@ -3793,7 +3796,8 @@ def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None):
The object order follows TS 102 223 6.8.0: command details, device
identities, Result, then the command-specific data objects (the echoed
- Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION)."""
+ Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION).
+ `dt` is a test seam for the PLI date/time default (see _pli_data_hex)."""
base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
0x82, 0x02, dev_dst, dev_src,
0x03, 0x01, 0x00])
@@ -7275,8 +7279,6 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = result
if ram_format is not None:
resp['ram_format'] = ram_format
- if _counter_valid(cntr) and counter_tracked:
- resp['final_cntr'] = cntr
# A warning ENVELOPE may still carry the PoR (the
# card's counter verdict): decode it so the caller
# sees 'cntr_low' instead of a bare failure.
@@ -7294,6 +7296,15 @@ class PysimHandler(BaseHTTPRequestHandler):
resp.update(low)
sys.stderr.write('OTA CNTR-LOW: the packet counter is not '
'above the card\'s\n')
+ # only a packet the card accepted consumes the
+ # counter (a warning ENVELOPE can still carry a
+ # por_ok PoR); a rejected one must leave the store
+ # untouched (v3.9.x - it used to persist the
+ # packet's own counter here)
+ if (_counter_valid(cntr) and counter_tracked and
+ failed_por.get('response_status') in
+ ('por_ok', 'actual_response_sms_submit')):
+ resp['final_cntr'] = _ram_next_cntr(cntr, True)
sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error'))
else:
resp = {'success': True, 'sw': result['sw'],
@@ -7358,9 +7369,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# (v3.8.0). A pre-built packet may come without a
# counter, and a packet whose SPI1 does not ask the card
# to check it has none to track - nothing is reported
- # then (and int('') never happens).
- if _counter_valid(cntr) and counter_tracked:
- resp['final_cntr'] = _ram_next_cntr(cntr, accepted)
+ # then (and int('') never happens). A rejected verdict
+ # (cntr_low, PoR error) reports nothing either: the
+ # store must not move on a packet the card did not
+ # consume.
+ if _counter_valid(cntr) and counter_tracked and accepted:
+ resp['final_cntr'] = _ram_next_cntr(cntr, True)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py
index 5eeb956..2a93ed0 100644
--- a/tests/test_fetch_routes.py
+++ b/tests/test_fetch_routes.py
@@ -393,6 +393,31 @@ class CntrLowGuardHttpTests(unittest.TestCase):
# the verdict is reported without a value (v3.9.x)
self.assertNotIn('card_cntr', resp)
self.assertNotIn('suggested_cntr', resp)
+ # a rejected packet reports no counter to use next either (v3.9.x):
+ # the store must not move on a packet the card did not consume
+ self.assertNotIn('final_cntr', resp)
+
+ def test_send_ota_reports_the_next_counter_only_when_accepted(self):
+ patches = [
+ mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
+ 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
+ 'response_data': '027100000b0a00000000000000bb0000'}),
+ mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
+ 'response_status': 'por_ok', 'tar': '000000', 'cntr': '00000000BB',
+ 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}),
+ ]
+ self.server.app = object() # the send path only checks truthiness
+ for p in patches:
+ p.start()
+ try:
+ status, resp = self._post('/api/send-ota', {
+ 'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15',
+ 'tar': '000000', 'cntr': '00000000BB'})
+ finally:
+ for p in reversed(patches):
+ p.stop()
+ self.assertEqual(status, 200, resp)
+ self.assertEqual(resp['final_cntr'], '00000000BC')
def test_the_ram_chain_stops_at_a_low_counter(self):
calls = []
@@ -517,6 +542,48 @@ class PresetStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['presets'], [])
self.assertEqual(resp['path'], str(self.store.path))
+ def test_counter_probe_endpoint(self):
+ status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
+ self.assertEqual(status, 200, resp)
+ pid = resp['preset']['id']
+ status, resp = self._post('/api/counter-probe', {})
+ self.assertEqual(status, 400, resp)
+ self.assertIn('preset_id', resp['error'])
+ status, resp = self._post('/api/counter-probe', {'preset_id': 'nope'})
+ self.assertEqual(status, 400, resp)
+ self.assertIn('unknown preset', resp['error'])
+ # a walk that never gets accepted leaves the store untouched
+ with mock.patch.object(self.srv, '_build_secured_packet',
+ lambda *a, **k: ('00' * 20, {})), \
+ mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
+ 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
+ 'response_data': '027100000b0a00000000000000000000'}), \
+ mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
+ 'response_status': 'cntr_low', 'tar': '000000', 'cntr': '0000000000',
+ 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
+ status, resp = self._post('/api/counter-probe',
+ {'preset_id': pid, 'max_attempts': 2})
+ self.assertEqual(status, 200, resp)
+ self.assertFalse(resp['success'], resp)
+ self.assertEqual(resp['packets'], 2)
+ self.assertEqual(resp['stopped'], 'attempts')
+ self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001')
+ # an accepted walk stores accepted+1 (the first ladder value)
+ with mock.patch.object(self.srv, '_build_secured_packet',
+ lambda *a, **k: ('00' * 20, {})), \
+ mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: {
+ 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1,
+ 'response_data': '027100000b0a00000000000000000000'}), \
+ mock.patch.object(self.srv, '_decode_por', lambda *a, **k: {
+ 'response_status': 'por_ok', 'tar': '000000', 'cntr': '0000000002',
+ 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}):
+ status, resp = self._post('/api/counter-probe', {'preset_id': pid})
+ self.assertEqual(status, 200, resp)
+ self.assertTrue(resp['success'], resp)
+ self.assertEqual(resp['accepted_cntr'], '0000000002')
+ self.assertEqual(resp['stored_cntr'], '0000000003')
+ self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003')
+
def test_create_update_delete_round_trip(self):
status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098'))
self.assertEqual(status, 200, resp)