From 7c2841ca0674c112e175e763a0c7153eca444fad 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: Fri, 2 Oct 2026 14:11:03 +0300
Subject: [PATCH] v3.13.4: Cards editor counter field: explicit heading,
placeholder, validation
The keyset table's column is headed "Counter (hex)" with the placeholder
0000000001, and cardsAdd refuses a save whose counter is not 1-10 hex
digits (empty included; whitespace stripped) - the same rule the store
enforces. The store keeps its import-compatible empty -> 1 default for
API/import callers.
Docs: repo AGENTS (v3.13.4 note). sw cache simple-v374.
Tests: 667 Python + 797 frontend green.
---
frontend/index.html | 14 +++++++++---
frontend/sw.js | 2 +-
frontend/tests/cards_form.test.js | 37 ++++++++++++++++++++++++++-----
frontend/tests/html.test.js | 4 ++--
pyproject.toml | 2 +-
pysim_simple_server/server.py | 2 +-
6 files changed, 48 insertions(+), 13 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 8c58d51..3df769e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1710,7 +1710,7 @@
KID
KIc key
KID key
-
Counter
+
Counter (hex)
@@ -1877,7 +1877,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '3.13.3';
+const SIMPLE_VERSION = '3.13.4';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -11999,7 +11999,7 @@ function cardsKeysetRowHtml(ks, i) {
'
' +
'
' +
'
' +
- '
' +
+ '
' +
'
' +
'';
}
@@ -12408,6 +12408,12 @@ async function cardsAdd() {
alert(t('Keyset 00 means "no security" and is not a preset keyset'));
return;
}
+ // The counter is required: 1-10 hex digits - the same rule the server
+ // enforces (whitespace is stripped on both sides)
+ if (!/^[0-9A-F]{1,10}$/.test(ks.cntr.replace(/\s+/g, ''))) {
+ alert(t('Counter must be 1-10 hex digits') + ': ' + (ks.cntr || t('(empty)')));
+ return;
+ }
}
const kvns = v.keysets.map(ks => spKeysetKvnOf(ks.kic));
if (new Set(kvns).size !== kvns.length) {
@@ -20848,6 +20854,7 @@ const LANG_RU = {
'Test script running': 'Выполняется тест-скрипт',
'Preset': 'Пресет',
'Counter': 'Счётчик',
+ 'Counter (hex)': 'Счётчик (hex)',
'No run yet.': 'Запусков пока нет.',
'running': 'выполняется',
'Received': 'Получено',
@@ -21124,6 +21131,7 @@ const LANG_RU = {
'Remove TAR': 'Удалить TAR',
'Description (optional)': 'Описание (необязательно)',
'Every TAR needs three hex bytes': 'Каждый TAR должен состоять из трёх hex-байт',
+ 'Counter must be 1-10 hex digits': 'Счётчик должен быть 1-10 hex-цифр',
'Every TAR needs its MSL (one hex byte)': 'Каждому TAR нужен MSL (один hex-байт)',
'Duplicate TAR in the preset': 'Повторяющийся TAR в пресете',
'SPI1 is below the MSL of this TAR': 'SPI1 ниже MSL этого TAR',
diff --git a/frontend/sw.js b/frontend/sw.js
index 828e032..0e0e25d 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v373';
+const CACHE = 'simple-v374';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/cards_form.test.js b/frontend/tests/cards_form.test.js
index fd407fa..6f49ef0 100644
--- a/frontend/tests/cards_form.test.js
+++ b/frontend/tests/cards_form.test.js
@@ -337,7 +337,7 @@ test('the release field is collected, cleared and restored', () => {
test('saving a preset posts the form with the pre-settled TAR table', async () => {
const els = setup();
els['cards-name'].value = 'New card';
- ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
// the role fields are pre-settled by the markup; the test's fake elements
// start empty - the defaults fill in
@@ -347,7 +347,7 @@ test('saving a preset posts the form with the pre-settled TAR table', async () =
assert.strictEqual(apiCalls[0].path, '/api/presets');
assert.strictEqual(apiCalls[0].body.name, 'New card');
assert.deepStrictEqual(apiCalls[0].body.keysets, [
- { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' }]);
+ { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }]);
assert.deepStrictEqual(apiCalls[0].body.tars, [
{ role: 'isd', tar: '000000', msl: '16', desc: '' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '' },
@@ -359,7 +359,7 @@ test('saving a preset posts the form with the pre-settled TAR table', async () =
test('saving refuses a free TAR row without its MSL or with a duplicate TAR', async () => {
const els = setup();
els['cards-name'].value = 'New card';
- ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
@@ -381,7 +381,7 @@ test('editing a preset updates it by its store id', async () => {
globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
globalThis._cardsEditIdx = 0;
els['cards-name'].value = 'Renamed';
- ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
await cardsAdd();
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
@@ -629,7 +629,7 @@ test('editing opens the editor with the preset title', () => {
test('the editor refuses PIN/PUK codes that are not 4-8 digits', async () => {
const els = setup();
els['cards-name'].value = 'X';
- ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
@@ -644,3 +644,30 @@ test('the editor refuses PIN/PUK codes that are not 4-8 digits', async () => {
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /PUK2/);
});
+
+test('the editor refuses a counter that is not 1-10 hex digits', async () => {
+ const els = setup();
+ els['cards-name'].value = 'X';
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: 'XYZ' });
+ globalThis._cardsKeysetCount = 1;
+ const seen = [];
+ globalThis.alert = msg => seen.push(msg);
+ await cardsAdd();
+ assert.strictEqual(apiCalls.length, 0);
+ assert.match(seen[0], /Counter must be 1-10 hex digits/);
+ // a valid value - spaces included, the server strips them - is posted
+ seen.length = 0;
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '00 00 00 00 01' });
+ await cardsAdd();
+ assert.strictEqual(apiCalls.length, 1, JSON.stringify(seen));
+ assert.strictEqual(apiCalls[0].body.keysets[0].cntr, '00 00 00 00 01');
+ // an empty counter is refused too (the value is required)
+ seen.length = 0;
+ apiCalls.length = 0;
+ els['cards-name'].value = 'X';
+ globalThis._cardsKeysetCount = 1;
+ ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' });
+ await cardsAdd();
+ assert.strictEqual(apiCalls.length, 0);
+ assert.match(seen[0], /Counter must be 1-10 hex digits/);
+});
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index a9c1959..f893431 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -155,8 +155,8 @@ test('the card editor groups its fields into labelled fieldsets', () => {
// ... and mirror the TAR table: a fixed-layout table with headers
assert.match(scp80, /
/);
assert.match(scp80, //);
- for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter']) {
- assert.match(scp80, new RegExp('>' + h + ''));
+ for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter (hex)']) {
+ assert.ok(scp80.includes('>' + h + ''), h);
}
assert.match(scp81, /id="cards-psk-id"/);
assert.match(scp81, /id="cards-psk-key"/);
diff --git a/pyproject.toml b/pyproject.toml
index 4c8b984..f84dc67 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.13.3"
+version = "3.13.4"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 68cff38..ac08996 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.13.3'
+VERSION = '3.13.4'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE