v3.20.0: the server-owned SCP80 counter (an untouched field = the preset's next value)

The counter an operation consumed was re-entered by the PWA on every send
(the form field was filled from the preset and posted back), so a stale tab
could send a value the card rejects (cntr_low), and typing in the field
silently wrote the preset.  Now the store is the single source of truth and
the form field is a per-send override (Option B).

- _preset_counter_seed: an explicit `cntr` in the request wins (a hand send,
  the counter probe or a pre-built packet may deliberately pick one);
  otherwise the preset store's value for the packet's keyset.  Wired into
  /api/send-ota (the APDU path; the pre-built `sp` path keeps the caller's
  bookkeeping value - the packet carries its own counter), /api/ram-install,
  /api/ram-install-app, /api/cap-compat, /api/tar-probe and /api/sp-verify
  (the reference must use the counter the JS packet was built with).
- the send-ota response echoes the effective `cntr` (a caller that omitted
  one learns what was used) and the OTA SEND log line prints it.
- persistence is unchanged and monotonic (presets.set_counter refuses
  backwards writes), pinned by tests: an explicit lower counter is used for
  the packet but never lowers the store, a higher accepted one is saved.
- PWA: the Secured packet counter field starts empty with the preset's next
  value as its placeholder (auto: <cntr>); typing affects that send only and
  is never written to the preset (spCntrSyncPreset is gone), spParams /
  getRamSpParams omit an empty counter, the RAM/send flows sync only the
  local preset copy (forward-only) instead of filling the field, the +/-
  buttons start from the effective value, and the preview/verify packet is
  built from the effective counter (genSp).  Placeholder translated EN/RU.
- the runner and /api/counter-probe keep their existing seeding (the probe
  already starts at the failed value and walks above it).
- docs: api.md's optional-`cntr` contract + seeding notes on every endpoint,
  help EN/RU 3.1.  Tests: 822 frontend + 734 Python; sw cache simple-v393.
This commit is contained in:
2026-10-05 22:22:50 +03:00
parent 0e06b918a7
commit 85cc4b9535
14 changed files with 331 additions and 101 deletions
+30 -9
View File
@@ -31,6 +31,8 @@ let code = '';
for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply',
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', 'spCntrAhead',
'spCntrAdjust',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
@@ -51,6 +53,7 @@ eval(code);
globalThis._genSpBuild = () => {};
globalThis.spShowSizeInfo = () => {};
globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {});
globalThis.t = s => s;
// The SP form is a working copy: the preset is the source of truth, and
// every SCP80/RAM operation re-reads it before starting (v3.6.3). The plain
@@ -81,16 +84,23 @@ function fakeEnv(selValue, selId, presetCntr) {
return { els, gen: () => gen };
}
test('spRefreshFromPreset re-reads the edited preset before an operation', () => {
test('spRefreshFromPreset keeps an explicit counter override, empty = auto', () => {
const env = fakeEnv('0', 'sp-card-sel', '00000000AA');
env.els['sp-cntr'].value = '0000000001'; // stale form copy
env.els['sp-cntr'].value = '0000000001'; // explicit override
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA');
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA');
assert.ok(env.gen() > 0, 'the packet must be regenerated from the new counter');
assert.strictEqual(env.els['sp-cntr'].value, '0000000001',
'a typed counter survives the pre-send refresh');
assert.ok(env.gen() > 0, 'the packet must be regenerated from the preset');
// an empty field means "auto": the server seeds the counter, and the
// preset's next value is shown as the placeholder (v3.20.0)
env.els['sp-cntr'].value = '';
spRefreshFromPreset('sp-card-sel');
assert.strictEqual(env.els['sp-cntr'].value, '');
assert.match(env.els['sp-cntr'].placeholder, /00000000AA/);
// no preset selected: the manual form is left alone
globalThis.cards = [];
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '');
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA');
assert.strictEqual(env.els['sp-cntr'].value, '');
});
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
@@ -105,23 +115,34 @@ test('the pre-send refresh keeps a hand-edited packet TAR (v3.6.21)', () => {
env.els['sp-tar'].value = 'AF4D01'; // e.g. a push/link trigger target
assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020');
assert.strictEqual(env.els['sp-tar'].value, 'AF4D01', 'the typed TAR must survive the send');
assert.strictEqual(env.els['sp-cntr'].value, '0000000020', 'the counter still refreshes');
assert.strictEqual(env.els['sp-cntr'].value, '', 'the counter field stays an override');
assert.match(env.els['sp-cntr'].placeholder, /0000000020/);
// an explicit preset apply (or a pack) still sets the TAR
cardsApplyFields(0);
assert.strictEqual(env.els['sp-tar'].value, 'B00000');
});
test('cardsApplyFields fills the form without generating a packet', () => {
test('cardsApplyFields leaves the counter to the server (auto) and shows it as a placeholder', () => {
const env = fakeEnv('0', 'sp-card-sel', '0000000007');
env.els['sp-cntr'].value = 'nonsense';
env.els['sp-cntr'].value = 'nonsense'; // an old override
assert.strictEqual(cardsApplyFields(0), true);
assert.strictEqual(env.els['sp-cntr'].value, '0000000007');
assert.strictEqual(env.els['sp-cntr'].value, '',
'applying a preset clears the override (empty = the server seeds it)');
assert.match(env.els['sp-cntr'].placeholder, /0000000007/);
assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet');
cardsApply(0);
assert.strictEqual(env.gen(), 1);
assert.strictEqual(cardsApplyFields(3), false);
});
test('the counter +/- buttons start from the preset value and create an override', () => {
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
spCntrAdjust(1);
assert.strictEqual(env.els['sp-cntr'].value, '0000000021');
spCntrAdjust(-1);
assert.strictEqual(env.els['sp-cntr'].value, '0000000020');
});
test('spPorAccepted treats a missing PoR and por_ok as accepted', () => {
assert.strictEqual(spPorAccepted(undefined), true);
assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true);
+12 -15
View File
@@ -31,7 +31,8 @@ let code = '';
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective',
'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'spTarListRender', 'packToSp',
@@ -415,29 +416,25 @@ test('import posts the entries to the server store', async () => {
assert.strictEqual(apiCalls.length, 0);
});
test('the SCP80 counter edit is written to the store, a reported counter only locally', () => {
test('the SCP80 counter field is an override - an edit is never written to the store', () => {
const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
els['sp-card-sel'].value = '0';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
els['sp-cntr'].value = '0000000009';
assert.strictEqual(spCntrSyncPreset(), true);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000009');
assert.strictEqual(apiCalls.length, 1);
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.strictEqual(apiCalls[0].body.fields.keysets[0].cntr, '0000000009');
// a counter the server already persisted: display only, no second write
apiCalls = [];
els['sp-cntr'].value = '0000000009'; // an explicit override
// typing alone writes nothing (no spCntrSyncPreset anymore, v3.20.0)
assert.strictEqual(apiCalls.length, 0);
// a counter the server persisted: the local copy moves forward, display only
spCntrLocalSync('000000000A');
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
assert.strictEqual(apiCalls.length, 0);
// no preset selected: nothing to write
els['sp-card-sel'].value = '';
assert.strictEqual(spCntrSyncPreset(), false);
assert.strictEqual(apiCalls.length, 0);
// and the copy never regresses (the store is monotonic)
spCntrLocalSync('0000000005');
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
// the override in the field is untouched by the copy sync
assert.strictEqual(els['sp-cntr'].value, '0000000009');
});
test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
+2 -1
View File
@@ -979,8 +979,9 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
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');
assert.ok(sync.includes('spCntrPlaceholder()'),
'the stored value must refresh the field placeholder (the field stays an override)');
assert.ok(sync.includes("ramListingSpi2('40')"),
'the RAM view must probe with the RAM listing transport (a PoR is required)');
assert.ok(sync.includes('data.counter_saved'),
+4 -1
View File
@@ -29,6 +29,7 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'escHtml', 'esc',
@@ -94,7 +95,9 @@ test('accepted delete persists the consumed counter and drops the record', async
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
'the preset must carry the counter the card consumed');
assert.strictEqual(els['sp-cntr'].value, '0000000006');
// the form field is an override (empty = auto, the server seeds it); the
// operation only syncs the local preset copy, never the field (v3.20.0)
assert.strictEqual(els['sp-cntr'].value, '0000000005');
assert.strictEqual(calls.sent.preset_id, 'preset-1',
'the operation must name the server-side preset that persists the counter');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
+2
View File
@@ -30,6 +30,8 @@ const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3Cb
'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
'spShowSizeInfo',
// the counter of a built packet: typed override, else the preset's (v3.20.0)
'spCntrEffective', 'spPresetCounterValue',
// the key-version guard genSp runs before building (TS 102 225 A.2)
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spPresetIdx', 'spKeysetGuard'];