From 4dd09740b726c3ada62a65b941f53fecb7830df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 16 Sep 2026 07:30:21 +0300 Subject: [PATCH] scp80: advance the counter after every send and persist it to the preset (v2.1.9) The counter only advanced when a PoR came back with status por_ok, so PoR-less sends kept reusing the same counter (the card rejects a repeated counter for replay protection). It now advances on every successful send (and shows "CNTR -> ..." in the result line), and the new value is written into the selected card preset via spCntrSyncPreset(). Manually edited counters are persisted too (the field's onchange). New spNextCntr() helper with carry tests; service worker v146. --- frontend/index.html | 47 ++++++++++++++++++++++------------- frontend/sw.js | 2 +- frontend/tests/sp.test.js | 14 ++++++++++- pyproject.toml | 2 +- pysim_otaman_server/server.py | 2 +- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index d51b445..4a998c0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.1.8

+

OTAMan SIM OTA with a Human Face v2.1.9

@@ -512,7 +512,7 @@
- +
@@ -4761,6 +4761,25 @@ async function pysimApdu() { pysimRefresh(); } +function spNextCntr(value) { + // The OTA counter must strictly increase for every secured packet sent + // (replay protection): increment the 5-byte counter, carries included. + const v = (parseInt((value || '').replace(/[^0-9a-fA-F]/g, ''), 16) || 0) >>> 0; + return ((v + 1) >>> 0).toString(16).toUpperCase().padStart(10, '0').slice(-10); +} + +function spCntrSyncPreset() { + // Persist the counter into the selected card preset: the OTA counter is + // part of the card's state and must survive reloads. + const sel = document.getElementById('sp-card-sel'); + const idx = sel ? parseInt(sel.value, 10) : NaN; + if (isNaN(idx) || !cards[idx]) return false; + cards[idx].cntr = document.getElementById('sp-cntr').value; + cardsSave(); + cardsRender(); + return true; +} + function spParams() { return { spi1: document.getElementById('sp-spi1').value, @@ -4835,22 +4854,16 @@ async function pysimSendOta() { porStatusEl.textContent = 'PoR: ' + por.response_status; porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600'); porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600'); - if (okPor) { - // packet was accepted and executed - advance the counter so the - // same secured packet can never be sent twice (v1.9.6) - const cntrEl = document.getElementById('sp-cntr'); - cntrEl.value = ((parseInt(cntrEl.value, 16) || 0) + 1) - .toString(16).toUpperCase().padStart(10, '0').slice(-10); - document.getElementById('sp-result').value = ''; - // keep the selected card preset in sync with the new counter (v1.9.8) - const selIdx = parseInt(document.getElementById('sp-card-sel').value, 10); - if (!isNaN(selIdx) && cards[selIdx]) { - cards[selIdx].cntr = cntrEl.value; - cardsSave(); - cardsRender(); - } - } } + // The counter advances after every successful send, PoR or not: the + // card rejects a repeated counter (replay protection) and the same + // secured packet must never be sent twice (v1.9.6, revised 2.1.9). + const cntrEl = document.getElementById('sp-cntr'); + cntrEl.value = spNextCntr(cntrEl.value); + msg += ' | CNTR -> ' + cntrEl.value; + document.getElementById('sp-result').value = ''; + // keep the selected card preset in sync with the new counter (v1.9.8) + spCntrSyncPreset(); sendResultEl.textContent = msg; if (por && por.raw) { const rawLine = document.createElement('div'); diff --git a/frontend/sw.js b/frontend/sw.js index befdee6..bcbe342 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v145'; +const CACHE = 'otaman-v146'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/sp.test.js b/frontend/tests/sp.test.js index a81b451..f150090 100644 --- a/frontend/tests/sp.test.js +++ b/frontend/tests/sp.test.js @@ -28,7 +28,7 @@ function extractFunc(src, name) { const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt', 'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys', - 'aesCmac', 'genSp']; + 'aesCmac', 'genSp', 'spNextCntr']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; @@ -217,3 +217,15 @@ test('AES rejects 8-byte key', () => { }); assert.strictEqual(err, 'Error: AES KIc key must be 16, 24, or 32 bytes'); }); + +test('spNextCntr increments with carry', () => { + assert.strictEqual(spNextCntr('0000000001'), '0000000002'); + assert.strictEqual(spNextCntr('00000000FF'), '0000000100'); + assert.strictEqual(spNextCntr('000000FFFF'), '0000010000'); + assert.strictEqual(spNextCntr('0000ABCDEF'), '0000ABCDF0'); +}); + +test('spNextCntr tolerates lower case and separators', () => { + assert.strictEqual(spNextCntr('00000000 0a'), '000000000B'); + assert.strictEqual(spNextCntr(''), '0000000001'); +}); diff --git a/pyproject.toml b/pyproject.toml index af0602c..daf09b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.1.8" +version = "2.1.9" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 9a2ede5..f5e10bc 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.1.8' +VERSION = '2.1.9' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE