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.
This commit is contained in:
2026-10-02 14:11:03 +03:00
parent cc480c30b7
commit 7c2841ca06
6 changed files with 48 additions and 13 deletions
+11 -3
View File
@@ -1710,7 +1710,7 @@
<th class="w-14 text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc key</th>
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID key</th>
<th class="w-28 text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Counter">Counter</th>
<th class="w-28 text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Counter (hex)">Counter (hex)</th>
<th class="w-12 border-b border-gray-200 dark:border-slate-700"></th>
</tr></thead>
<tbody id="cards-keysets"></tbody>
@@ -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) {
'<td class="py-1 px-2"><input id="cards-ks-' + i + '-kid" class="' + cls + ' text-center" maxlength="2" placeholder="KID" value="' + esc(ks.kid || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')"></td>' +
'<td class="py-1 px-2"><input id="cards-ks-' + i + '-kic-key" class="' + cls + '" placeholder="KIc key (hex)" value="' + esc(ks.kicKey || '') + '"></td>' +
'<td class="py-1 px-2"><input id="cards-ks-' + i + '-kid-key" class="' + cls + '" placeholder="KID key (hex)" value="' + esc(ks.kidKey || '') + '"></td>' +
'<td class="py-1 px-2"><input id="cards-ks-' + i + '-cntr" class="' + cls + '" maxlength="10" placeholder="Counter" value="' + esc(ks.cntr || '') + '"></td>' +
'<td class="py-1 px-2"><input id="cards-ks-' + i + '-cntr" class="' + cls + '" maxlength="10" placeholder="0000000001" value="' + esc(ks.cntr || '') + '"></td>' +
'<td class="py-1 px-2"><button type="button" onclick="cardsKeysetRemove(' + i + ')" class="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700" title="' + esc(t('Remove keyset')) + '">\u2715</button></td>' +
'</tr>';
}
@@ -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',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v373';
const CACHE = 'simple-v374';
const URLS = [
'index.html',
'help.html',
+32 -5
View File
@@ -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/);
});
+2 -2
View File
@@ -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, /<table class="w-full table-fixed text-sm mb-2">/);
assert.match(scp80, /<tbody id="cards-keysets">/);
for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter']) {
assert.match(scp80, new RegExp('>' + h + '</th>'));
for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter (hex)']) {
assert.ok(scp80.includes('>' + h + '</th>'), h);
}
assert.match(scp81, /id="cards-psk-id"/);
assert.match(scp81, /id="cards-psk-key"/);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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