fix: SCP80/RAM re-read the preset before every operation; no counter bump on a rejected send (v3.6.3)
The SCP80/RAM forms hold a copy of the preset (counter, keys, TAR, SPI). Editing the preset on the Cards tab saved correctly, but the form kept the old copy: the operation sent the stale counter (the card answers cntr_low) and the post-send sync wrote the stale value back over the preset - the saved counter silently reverted. Reproduced in a real DOM: after select in SCP80: preset=0000000001 sp=0000000001 after Cards edit+save: preset=00000000AA sp=0000000001 after a sync: preset=0000000001 (edit lost) - `cardsApply()` split into `cardsApplyFields()` (field copy, no packet) and `cardsApply()` = fields + genSp; new `spRefreshFromPreset(selId)` re-reads the selected preset from `sp-card-sel` / `ram-card-sel` and re-applies it. - `pysimSendOta()` and `ramExecute()` call it before starting, so every SCP80/RAM operation uses the preset as it is now. - A rejected send no longer advances the counter: new `spPorAccepted(por)` gates the advance+write-back in `pysimSendOta`, the Explore pagination and its GET DATA step, and the server's RAM install (`_ram_next_cntr`: advance only for `por_ok`/`no_por` steps). A failed install still returns `final_cntr` (the accepted prefix) and the PWA persists it, so a retry never replays a counter the card already consumed. - tests: cards_counter.test.js (the stale-form regression, RAM selector, fields-without-genSp, spPorAccepted) and `_ram_next_cntr` cases; the cards_form/ram harnesses updated for the split. - docs/api.md counter semantics; AGENTS preset-source-of-truth rule. 607 frontend / 488 Python green; version 3.6.3; sw simple-v276.
This commit is contained in:
+60
-16
@@ -1665,7 +1665,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.6.2';
|
||||
const SIMPLE_VERSION = '3.6.3';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -7896,6 +7896,10 @@ async function pysimSendOta() {
|
||||
alert('Card reader server is not connected');
|
||||
return;
|
||||
}
|
||||
// Re-read the preset (counter and keys) before sending: the Cards tab may
|
||||
// have changed it after the form was filled, and a stale counter is
|
||||
// rejected by the card (cntr_low) and then written back over the preset.
|
||||
spRefreshFromPreset('sp-card-sel');
|
||||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||||
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
|
||||
const sendResultEl = document.getElementById('sp-send-result');
|
||||
@@ -7927,16 +7931,21 @@ async function pysimSendOta() {
|
||||
porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600');
|
||||
porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600');
|
||||
}
|
||||
// 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 = '';
|
||||
spShowSizeInfo();
|
||||
// keep the selected card preset in sync with the new counter (v1.9.8)
|
||||
spCntrSyncPreset();
|
||||
// The counter advances only for a packet the card accepted: a
|
||||
// rejected send (cntr_low, PoR error) must leave the preset value
|
||||
// untouched - the card did not consume the packet (v3.6.3; the old
|
||||
// "advance always" rule silently reverted preset edits).
|
||||
if (spPorAccepted(por)) {
|
||||
const cntrEl = document.getElementById('sp-cntr');
|
||||
cntrEl.value = spNextCntr(cntrEl.value);
|
||||
msg += ' | CNTR -> ' + cntrEl.value;
|
||||
document.getElementById('sp-result').value = '';
|
||||
spShowSizeInfo();
|
||||
// keep the selected card preset in sync with the new counter
|
||||
spCntrSyncPreset();
|
||||
} else {
|
||||
msg += ' | CNTR unchanged (' + ((por && por.response_status) || 'PoR error') + ')';
|
||||
}
|
||||
sendResultEl.textContent = msg;
|
||||
if (por && por.raw) {
|
||||
const rawLine = document.createElement('div');
|
||||
@@ -8741,13 +8750,14 @@ async function ramExplore(sp) {
|
||||
const apdu = '80F2' + p1 + p2 + dataField + 'C0000000';
|
||||
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
|
||||
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
||||
cntr = ramIncrementCntr(cntr);
|
||||
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
||||
const errorMsg = res.por ? res.por.response_status : (res.error || t('no data'));
|
||||
errors.push(label + ': ' + errorMsg);
|
||||
tlvFailed = true;
|
||||
break;
|
||||
}
|
||||
// the card consumed the packet: advance for the next step
|
||||
cntr = ramIncrementCntr(cntr);
|
||||
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
|
||||
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
|
||||
// If first attempt with P2=02 gets an unsupported error, retry with P2=00.
|
||||
@@ -8786,8 +8796,8 @@ async function ramExplore(sp) {
|
||||
ramShowProgress(t('Memory (GET DATA FF21)') + '...');
|
||||
try {
|
||||
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
|
||||
cntr = ramIncrementCntr(cntr);
|
||||
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
|
||||
cntr = ramIncrementCntr(cntr);
|
||||
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
|
||||
if (!data) {
|
||||
errors.push(t('Memory') + ': ' + t('(no data)'));
|
||||
@@ -8924,8 +8934,11 @@ async function ramInstallCap(sp) {
|
||||
(data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx) + '\n'; });
|
||||
stepsEl.textContent = txt;
|
||||
|
||||
// Persist the counter the card actually consumed (accepted packets only):
|
||||
// even a failed install leaves the accepted steps behind, and replaying
|
||||
// their counter would make the card reject the next attempt.
|
||||
if (data.final_cntr) ramSaveCntr(data.final_cntr);
|
||||
if (data.success) {
|
||||
ramSaveCntr(data.final_cntr);
|
||||
let sizeInfo = '';
|
||||
if (data.load_block_size) {
|
||||
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
|
||||
@@ -8948,6 +8961,9 @@ async function ramExecute() {
|
||||
ramClearResults();
|
||||
const cardIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
|
||||
if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx;
|
||||
// Re-read the preset before the operation: its counter/keys may have
|
||||
// changed in the Cards tab after the RAM form was filled.
|
||||
spRefreshFromPreset('ram-card-sel');
|
||||
const sp = getRamSpParams();
|
||||
if (!sp.kicKey || !sp.kidKey) {
|
||||
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
|
||||
@@ -9399,9 +9415,11 @@ function cardsAutoSelectByIccid(iccid) {
|
||||
return idx;
|
||||
}
|
||||
|
||||
function cardsApply(idx) {
|
||||
// Fill the SP form from the preset (no packet generation): the form is a
|
||||
// working copy, the preset stays the source of truth.
|
||||
function cardsApplyFields(idx) {
|
||||
const c = cards[parseInt(idx)];
|
||||
if (!c) return;
|
||||
if (!c) return false;
|
||||
document.getElementById('sp-spi1').value = c.spi1;
|
||||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||||
const kicByte = parseInt(c.kic, 16);
|
||||
@@ -9418,9 +9436,35 @@ function cardsApply(idx) {
|
||||
document.getElementById('sp-kid-key').value = c.kidKey;
|
||||
spInvalidate();
|
||||
updateSp();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cardsApply(idx) {
|
||||
if (!cardsApplyFields(idx)) return;
|
||||
genSp();
|
||||
}
|
||||
|
||||
// SCP80/RAM operations re-read the selected preset before starting: the
|
||||
// Cards tab (or a counter write-back) may have changed it after the form was
|
||||
// filled, and a stale counter is rejected by the card (cntr_low) and would
|
||||
// then be written back over the preset. Returns the preset counter ('' when
|
||||
// no preset is selected).
|
||||
function spRefreshFromPreset(selId) {
|
||||
const sel = document.getElementById(selId || 'sp-card-sel');
|
||||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||||
if (isNaN(idx) || !cards[idx]) return '';
|
||||
if (selId === 'ram-card-sel') _spTarKey = 'tar';
|
||||
cardsApply(idx);
|
||||
return cards[idx].cntr;
|
||||
}
|
||||
|
||||
// A send counts as accepted when there is no PoR to check (the SPI requests
|
||||
// none) or the PoR is por_ok; anything else (cntr_low, Por error) must leave
|
||||
// the counter untouched.
|
||||
function spPorAccepted(por) {
|
||||
return !por || por.response_status === 'por_ok';
|
||||
}
|
||||
|
||||
function downloadJson(name, obj) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
|
||||
const a = document.createElement('a');
|
||||
|
||||
Reference in New Issue
Block a user