feat: SCP80 SMS concatenation + packet size / SMS count display (v3.2.0)

Packets longer than one SMS now go out as concatenated SMS-PP downloads
per TS 31.115 4.3 and the UI shows how many SMS a packet needs.

Server:
- `_split_secured_packet` cuts the command packet at the exact SMS
  user-data capacities (first SM 132 octets: concat IE 5 + CPI IE 2;
  following ones 134; a single-SM packet may be 137 with the CPI IE) and
  `_build_sms_tpdu` tags every segment with the fixed concatenation
  reference 01; `_build_sms_tpdu` also enforces the 140-octet budget.
- `_send_secured_packet` (shared by /api/send-ota and /api/ram-install)
  sends one ENVELOPE per segment in order, refuses more than
  MAX_ENVELOPE_SEGMENTS (5, the card's concatenation buffer) and reports
  `bytes`/`segments` in the response (RAM install per step as well).
- `_build_secured_packet`/`_encode_cmd_unlimited`: our own TS 102 225
  5.1.1 encoder on pySim's keyset/header constructors, byte-identical to
  pySim for packets <= 140 octets (tests) and not limited to one SMS
  (pySim refuses the longer ones, which is why they never went out).
- RAM LOAD blocks are no longer clamped to one SMS: 1-240 bytes of
  payload with the default 240 (the GP maximum); `load_block_size_auto`
  replaces `load_block_size_clamped`.

PWA:
- `scp80SegmentInfo` / `spSizeInfoText` show "N bytes . M SMS
  (concatenated)" under the packet field, turn red past 5 SMS and report
  size/SMS in the send result and the RAM step log; LOAD block hints and
  placeholders updated; EN/RU.

Tests/docs: Python +2 cases incl. segment order/capacities and byte
identity with pySim; Node drift guard against the server constants and
UI text tests; README/README_RUS, help EN/RU, docs/api.md, AGENTS.
This commit is contained in:
2026-09-22 23:02:13 +03:00
parent f72054f61f
commit 43f50d3b72
11 changed files with 527 additions and 175 deletions
+69 -14
View File
@@ -550,6 +550,7 @@
<button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button>
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-size-info" class="text-xs mt-1 text-gray-500 dark:text-slate-400"></div>
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
@@ -693,9 +694,9 @@
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
</label>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = auto-fit)">LOAD block size (bytes, empty = auto-fit)</label>
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="auto">
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.">Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.</div>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = 240)">LOAD block size (bytes, empty = 240)</label>
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="240">
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).">Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).</div>
</div>
<div id="ram-progress" class="hidden mb-3">
@@ -1436,7 +1437,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.1.4';
const SIMPLE_VERSION = '3.2.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -3268,6 +3269,7 @@ function spCntrAdjust(delta) {
}
function spInvalidate() {
document.getElementById('sp-result').value = '';
spShowSizeInfo();
}
function updateSp() {
@@ -3468,6 +3470,56 @@ function aesCmac(data, keyBytes) {
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() {
_genSpBuild();
spShowSizeInfo();
}
// ===== SCP80 SMS segmentation (TS 31.115 4.2/4.3) =====
// Capacities mirror the server's SCP80_*_BYTES constants (pysim_simple_server/
// server.py): 140 octets of SMS user data minus the UDH, which carries the
// concatenation IE (5 octets) in every SM and the CPI IE (2 octets) in the
// first one of a concatenated command.
const SCP80_MAX_SMS = 5;
const SCP80_SINGLE_BYTES = 137;
const SCP80_FIRST_BYTES = 132;
const SCP80_NEXT_BYTES = 134;
// Secured packet hex -> {bytes, segments} of the SMS-PP download.
function scp80SegmentInfo(hex) {
const bytes = Math.floor((hex || '').replace(/[^0-9a-fA-F]/g, '').length / 2);
if (!bytes) return { bytes: 0, segments: 0 };
if (bytes <= SCP80_SINGLE_BYTES) return { bytes: bytes, segments: 1 };
let segments = 1;
let rest = bytes - SCP80_FIRST_BYTES;
while (rest > 0) { segments += 1; rest -= SCP80_NEXT_BYTES; }
return { bytes: bytes, segments: segments };
}
function spSizeInfoText(hex) {
const info = scp80SegmentInfo(hex);
if (!info.segments) return '';
let s = info.bytes + ' ' + t('bytes') + ' · ' + info.segments + ' SMS';
if (info.segments > 1) s += ' (' + t('concatenated') + ')';
if (info.segments > SCP80_MAX_SMS) {
s += ' — ' + t('too large for the card concatenation buffer') + ' (' + SCP80_MAX_SMS + ' SMS)';
}
return s;
}
// Refresh the packet size / SMS count line under the packet field.
function spShowSizeInfo() {
const el = document.getElementById('sp-size-info');
if (!el) return;
const value = (document.getElementById('sp-result') || {}).value || '';
const isPacket = /^\s*[0-9a-fA-F\s]+$/.test(value);
const info = isPacket ? scp80SegmentInfo(value) : { bytes: 0, segments: 0 };
el.textContent = info.segments ? spSizeInfoText(value) : '';
el.className = 'text-xs mt-1 ' + (info.segments > SCP80_MAX_SMS
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-slate-400');
}
function _genSpBuild() {
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -5804,6 +5856,9 @@ async function pysimSendOta() {
cApduSwitchSubtab('response');
}
let msg = 'OTA sent. SW: ' + data.sw;
if (data.bytes && data.segments) {
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
}
const por = data.por;
if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
@@ -5821,6 +5876,7 @@ async function pysimSendOta() {
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();
sendResultEl.textContent = msg;
@@ -6429,7 +6485,9 @@ async function ramInstallCap(sp) {
let txt = '';
(data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
txt += mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
let line = mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')';
if (s.bytes && s.segments) line += ' · ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
txt += line + '\n';
});
stepsEl.textContent = txt;
@@ -6438,11 +6496,7 @@ async function ramInstallCap(sp) {
let sizeInfo = '';
if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
if (data.load_block_size_clamped) {
sizeInfo += ' (' + t('clamped from') + ' ' + data.load_block_size_requested + ')';
} else if (!data.load_block_size_requested) {
sizeInfo += ' (' + t('auto-fit') + ')';
}
if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
}
resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid + sizeInfo;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
@@ -13993,12 +14047,13 @@ const LANG_RU = {
'Object and related objects': 'Объект и связанные объекты',
'SD AID (empty = ISD)': 'AID SD (пусто = ISD)',
'CAP file': 'CAP-файл',
'LOAD block size (bytes, empty = auto-fit)': 'Размер блока LOAD (байт, пусто = авто)',
'Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.': 'Разбивает load-файл на LOAD APDU, помещающиеся в один SMS SCP80; авто-подбор выбирает максимальный размер, укладывающийся в 140 октетов.',
'LOAD block size (bytes, empty = 240)': 'Размер блока LOAD (байт, пусто = 240)',
'default': 'по умолчанию',
'concatenated': 'конкатенация',
'too large for the card concatenation buffer': 'слишком велик для буфера конкатенации карты',
'Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).': 'Разбивает load-файл на LOAD APDU (полезная нагрузка 1–240 байт). Защищённый пакет больше одного SMS отправляется как конкатенированная SMS-PP загрузка (до 5 SMS).',
'LOAD block size must be 1..240': 'Размер блока LOAD должен быть в диапазоне 1..240',
'LOAD blocks': 'Блоки LOAD',
'clamped from': 'ограничено с',
'auto-fit': 'авто',
'Redirect to external server': 'Перенаправление на внешний сервер',
'Pass-through (card destination)': 'Проброс (адрес карты)',
'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.',