feat: one keyset per key version - indexed keys and counters (v3.8.0)
A card usually holds several OTA key sets, and the SCP80 counter is tracked
per key version on the card, so a preset now carries one keyset per
GlobalPlatform key version - the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2), the version is implicit in the bytes. The spec is explicit:
"a dedicated counter shall be associated to each key version" (Annex A.1) and
"the key version number indicated in the KIc and KID (bits b8..b5) fields
shall be identical when different from 0" - a mismatch is rejected by the card
with "Unidentified security error" (A.2).
Server (presets.py, schema 2):
- a preset carries `keysets: [{kic, kid, kicKey, kidKey, cntr}]`; validation
refuses a KIc/KID version mismatch (A.2), a version outside 01-0F ('00'
means "no security" and is a packet-level choice, not a keyset), duplicate
versions and missing keys/counters; a v3.8.0 flat preset converts into one
keyset on load and on import;
- `set_counter(..., kvn)` writes the counter of that keyset (monotonic per
keyset), the audit line carries the kvn, and `find_keyset()` serves the
lookups;
- the operations derive the key version from the request's KIc byte and
persist into that keyset: /api/send-ota, the RAM install/install-app/compat
steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (which may name
a `kvn` per step and keep one working counter per keyset);
- a non-zero key version the named preset does not define, or a KIc/KID
mismatch, is refused before anything is built (_preset_keyset_check /
_keyset_guard: 400, or a step error in the test runner). Without a
preset_id only the mismatch rule applies.
PWA:
- the Cards form's KIc/KID/counter/keys inputs become a keyset editor (one row
per version: KIc, KID, both keys, that version's counter, Add/Remove; the
version is shown derived from the bytes and a mismatch is flagged);
- the SCP80 and RAM views get a Key-version selector beside the preset
selector, driving the KIc/KID bytes and loading that keyset's keys and
counter; the packet builder refuses a version the preset does not define
(spKeysetCheck/spKeysetGuard, pure and unit-tested);
- the counter syncs are per keyset (spCntrLocalSync/ramSaveCntr), the SCP80
form's counter edit posts the keysets, and the test-run write-back syncs
every counter the server reports (scp80_counters).
Docs/help/AGENTS updated; sw cache simple-v335. Tests: 610 Python (store
keysets, endpoint refusals, the counter landing in the right keyset) and 726
Node (keysets.test.js plus the updated harnesses).
This commit is contained in:
@@ -340,7 +340,7 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка «Карты»</h2>
|
||||
<p class="mb-2">Хранит предустановки карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчик SCP80, израсходованный операцией, сохранялся самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки, выгруженные старыми сборками с хранением в браузере). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.</p>
|
||||
<p class="mb-2">Хранит предустановки карт на сервере (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; переопределяется <code class="font-mono text-sm">--card-presets</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Предустановка содержит <strong>один набор ключей на версию ключа</strong> (ниббл <code class="font-mono text-sm">b8..b5</code> KIc/KID, TS 102 225 §5.1.2/A.2): KIc и KID набора должны содержать одну версию (01–0F; A.2 отвергает несовпадение), и у каждой версии ключа на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по наборам. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки старых сборок — плоские и с наборами ключей). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+1
-1
@@ -339,7 +339,7 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
|
||||
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counter an operation consumed is persisted with the operation itself — a closed tab or a second browser window can no longer lose an increment. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older browser-storage builds). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
|
||||
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself — a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>one keyset per key version</strong> (the <code class="font-mono text-sm">b8..b5</code> nibble of KIc/KID, TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same version (01–0F; A.2 rejects a mismatch), and each key version has its own counter on the card (Annex A.1), so counters are tracked per keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
+379
-46
@@ -527,6 +527,8 @@
|
||||
<select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||||
<option value="" data-l10n="— Select card —">— Select card —</option>
|
||||
</select>
|
||||
<label class="block mt-2 mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Key version (keyset)">Key version (keyset)</label>
|
||||
<select id="sp-keyset-sel" onchange="spKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-3">
|
||||
@@ -690,6 +692,10 @@
|
||||
<option value="" data-l10n="— Select card —">— Select card —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Key version (keyset)">Key version (keyset)</label>
|
||||
<select id="ram-keyset-sel" onchange="ramKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
|
||||
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||||
@@ -1536,7 +1542,7 @@
|
||||
|
||||
|
||||
<div id="tab-cards" class="tab-content hidden">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored locally in your browser. JSON export/import buttons are below the card list.">Card presets are stored locally in your browser. JSON export/import buttons are below the card list.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.">Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
|
||||
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
|
||||
@@ -1548,11 +1554,8 @@
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend>
|
||||
<div class="flex gap-2 mb-2 flex-wrap items-end">
|
||||
<input id="cards-kic" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KIc" maxlength="2">
|
||||
<input id="cards-kid" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KID" maxlength="2">
|
||||
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2">
|
||||
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2">
|
||||
<input id="cards-cntr" class="w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="Counter" maxlength="10">
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
<label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label>
|
||||
@@ -1568,10 +1571,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input id="cards-kic-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KIc key (32 hex chars)">
|
||||
<input id="cards-kid-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KID key (32 hex chars)">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — one per key version, each with its own counter">OTA keysets — one per key version, each with its own counter</span>
|
||||
<button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button>
|
||||
</div>
|
||||
<div id="cards-keysets"></div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).">KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).</p>
|
||||
</fieldset>
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP81 (HTTP OTA)">SCP81 (HTTP OTA)</legend>
|
||||
@@ -1586,12 +1591,10 @@
|
||||
<thead><tr>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc</th>
|
||||
<th class="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" data-l10n="Keysets (key version: KIc/KID — counter)">Keysets (key version: KIc/KID \u2014 counter)</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR (ISD/UICC RFM/ADF RFM)</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Cntr">Cntr</th>
|
||||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</th>
|
||||
<th class="border-b border-gray-200 dark:border-slate-700"></th>
|
||||
</tr></thead>
|
||||
@@ -4898,6 +4901,10 @@ function crc32Bytes(data) {
|
||||
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig (CC 8, RC 4).
|
||||
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
|
||||
function genSp() {
|
||||
// The packet's key version must be defined in the selected preset
|
||||
// (TS 102 225 A.2 / the "keyset must exist" rule).
|
||||
const ksErr = spKeysetGuard();
|
||||
if (ksErr) { alert(ksErr); return; }
|
||||
_genSpBuild();
|
||||
spShowSizeInfo();
|
||||
}
|
||||
@@ -7093,6 +7100,8 @@ function testStepRender() {
|
||||
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
||||
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
||||
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
||||
html += testFormRow('Key version (kvn, optional)',
|
||||
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
|
||||
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
||||
html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16'));
|
||||
html += testFormRow('SPI2 override (hex, optional)', testFormInput('test-f-spi2', p.spi2, '01'));
|
||||
@@ -7272,6 +7281,8 @@ function testStepCollect() {
|
||||
else p.apdu = prev.apdu || '';
|
||||
p.source = src;
|
||||
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
|
||||
const kvn = (document.getElementById('test-f-kvn') || {}).value;
|
||||
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
|
||||
}
|
||||
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
|
||||
step.params = p;
|
||||
@@ -7489,8 +7500,13 @@ async function testPollRun() {
|
||||
|
||||
function testWriteBackCounter() {
|
||||
const st = _testRunState;
|
||||
if (!st || st.running || !st.scp80_counter) return;
|
||||
const next = String(st.scp80_counter).toUpperCase();
|
||||
if (!st || st.running) return;
|
||||
// The server persists every counter the run consumed (one per key version);
|
||||
// this only keeps the local copies in step with what it reports.
|
||||
const counters = (st.scp80_counters && typeof st.scp80_counters === 'object')
|
||||
? st.scp80_counters : null;
|
||||
const next = st.scp80_counter ? String(st.scp80_counter).toUpperCase() : '';
|
||||
if (!counters && !next) return;
|
||||
let idx = _testLastPresetIdx;
|
||||
if (idx < 0 && st.preset) {
|
||||
// A run started elsewhere (or observed after a page reload): the run
|
||||
@@ -7506,6 +7522,21 @@ function testWriteBackCounter() {
|
||||
const preset = cards[idx];
|
||||
if (!preset) return;
|
||||
_testLastPresetIdx = idx;
|
||||
if (counters) {
|
||||
let changed = false;
|
||||
Object.keys(counters).forEach(kvn => {
|
||||
const ks = spKeysetFor(preset, parseInt(kvn, 10));
|
||||
const value = String(counters[kvn] || '').toUpperCase();
|
||||
if (ks && value && String(ks.cntr || '').toUpperCase() !== value) {
|
||||
ks.cntr = value;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (!changed) return;
|
||||
cardsRender();
|
||||
ioStatus('test-io-status', t('SCP80 counters saved to the preset.'));
|
||||
return;
|
||||
}
|
||||
if (String(preset.cntr || '').toUpperCase() === next) return;
|
||||
preset.cntr = next; // the server persisted it with the run (v3.8.0)
|
||||
cardsRender();
|
||||
@@ -8124,14 +8155,17 @@ function spCntrSyncPreset() {
|
||||
if (idx < 0) return false;
|
||||
const preset = cards[idx];
|
||||
const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase();
|
||||
preset.cntr = value;
|
||||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||||
const ks = spKeysetFor(preset, kvn);
|
||||
if (ks) ks.cntr = value;
|
||||
cardsRender();
|
||||
if (preset.id) {
|
||||
pysimFetch('/api/presets/update', { id: preset.id, fields: { cntr: value } })
|
||||
if (preset.id && ks) {
|
||||
pysimFetch('/api/presets/update', { id: preset.id, fields: { keysets: spKeysetList(preset) } })
|
||||
.then(res => {
|
||||
if (res && res.error) throw new Error(res.error);
|
||||
// the store normalises the counter (fixed 10-hex form)
|
||||
if (res && res.preset) { preset.cntr = res.preset.cntr; cardsRender(); }
|
||||
if (res && res.preset) { cards[idx] = res.preset; cardsRender(); spKeysetSync(); }
|
||||
})
|
||||
.catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message));
|
||||
}
|
||||
@@ -8143,7 +8177,10 @@ function spCntrSyncPreset() {
|
||||
function spCntrLocalSync(value) {
|
||||
const idx = spPresetIdx();
|
||||
if (idx < 0) return;
|
||||
cards[idx].cntr = value;
|
||||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||||
const ks = spKeysetFor(cards[idx], kvn);
|
||||
if (ks) ks.cntr = value;
|
||||
cardsRender();
|
||||
}
|
||||
|
||||
@@ -8205,6 +8242,8 @@ async function pysimSendOta() {
|
||||
// (push/link triggers, applet OTA) - the preset sets it only when a card
|
||||
// is picked or a chain is packed (v3.6.21).
|
||||
spRefreshFromPreset('sp-card-sel', true);
|
||||
const ksErr = spKeysetGuard();
|
||||
if (ksErr) { alert(ksErr); return; }
|
||||
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');
|
||||
@@ -8760,7 +8799,10 @@ function ramSaveCntr(cntr) {
|
||||
if (el) el.value = cntr;
|
||||
const selIdx = parseInt((document.getElementById('ram-card-sel') || {}).value, 10);
|
||||
if (!isNaN(selIdx) && cards[selIdx]) {
|
||||
cards[selIdx].cntr = cntr;
|
||||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||||
const ks = spKeysetFor(cards[selIdx], kvn);
|
||||
if (ks) ks.cntr = cntr;
|
||||
cardsRender();
|
||||
ramRender();
|
||||
}
|
||||
@@ -9867,6 +9909,8 @@ async function ramDeleteFromExplorer(aid, withCascade) {
|
||||
// the operation so a stale form copy cannot send an already-consumed
|
||||
// counter.
|
||||
spRefreshFromPreset('ram-card-sel');
|
||||
const ksErr = ramKeysetGuard();
|
||||
if (ksErr) { alert(ksErr); return; }
|
||||
const sp = getRamSpParams();
|
||||
if (!sp.kicKey || !sp.kidKey) {
|
||||
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
|
||||
@@ -10478,7 +10522,16 @@ function ramGoToPreset(idx) {
|
||||
switchTab('cards');
|
||||
const i = Number(idx);
|
||||
if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i);
|
||||
const el = document.getElementById('cards-cntr');
|
||||
// Focus the counter of the key version the failed operation used (the
|
||||
// keyset rows are keyed by their version; the first row otherwise).
|
||||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||||
let row = 0;
|
||||
if (Number.isInteger(i) && i >= 0 && cards[i] && kvn) {
|
||||
const at = spKeysetList(cards[i]).findIndex(ks => spKeysetKvnOf(ks.kic) === kvn);
|
||||
if (at >= 0) row = at;
|
||||
}
|
||||
const el = document.getElementById('cards-ks-' + row + '-cntr');
|
||||
if (el) {
|
||||
if (el.scrollIntoView) el.scrollIntoView({ block: 'center' });
|
||||
el.focus();
|
||||
@@ -10947,6 +11000,234 @@ function cardsTarValue(key, value) {
|
||||
return value || defaults[key] || '';
|
||||
}
|
||||
|
||||
// ===== OTA keysets (one per GlobalPlatform key version) =====
|
||||
// A preset carries one keyset per key version: the b8..b5 nibble of KIc/KID
|
||||
// (TS 102 225 5.1.2/A.2). The version is implicit in the bytes - the "key
|
||||
// index" - and each key version has its own counter on the card ("a dedicated
|
||||
// counter shall be associated to each key version", Annex A.1). A packet may
|
||||
// use any key version the preset defines; '00'/'00' means no security (A.2)
|
||||
// and needs no keyset.
|
||||
|
||||
// The key version of a KIc/KID byte, or null when it is not a hex byte.
|
||||
function spKeysetKvnOf(hex) {
|
||||
const s = String(hex || '').trim().toUpperCase();
|
||||
if (!/^[0-9A-F]{2}$/.test(s)) return null;
|
||||
return (parseInt(s, 16) >> 4) & 0x0F;
|
||||
}
|
||||
|
||||
// The keysets of a preset (a v3.8.0 flat preset converts to a single one).
|
||||
function spKeysetList(preset) {
|
||||
if (!preset) return [];
|
||||
if (Array.isArray(preset.keysets) && preset.keysets.length) return preset.keysets;
|
||||
if (preset.kic || preset.kid) {
|
||||
return [{ kic: preset.kic, kid: preset.kid, kicKey: preset.kicKey,
|
||||
kidKey: preset.kidKey, cntr: preset.cntr }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// The keyset of a preset for a key version, or null.
|
||||
function spKeysetFor(preset, kvn) {
|
||||
return spKeysetList(preset).find(ks => spKeysetKvnOf(ks.kic) === kvn) || null;
|
||||
}
|
||||
|
||||
// The packet's key-version rules (pure): '' when the pair is usable, else the
|
||||
// reason. With a preset, the (non-zero) key version must be defined in it -
|
||||
// a packet may use any key version, but only one the preset defines.
|
||||
function spKeysetCheck(preset, kicHex, kidHex) {
|
||||
const kic = String(kicHex || '').trim().toUpperCase();
|
||||
const kid = String(kidHex || '').trim().toUpperCase();
|
||||
for (const pair of [['KIc', kic], ['KID', kid]]) {
|
||||
if (pair[1] && !/^[0-9A-F]{2}$/.test(pair[1])) {
|
||||
return t(pair[0]) + ' ' + t('must be one hex byte') + ': ' + pair[1];
|
||||
}
|
||||
}
|
||||
const kicV = spKeysetKvnOf(kic), kidV = spKeysetKvnOf(kid);
|
||||
if (kicV && kidV && kicV !== kidV) {
|
||||
return t('KIc and KID must carry the same key version (TS 102 225 A.2)') + ': ' + kic + ' / ' + kid;
|
||||
}
|
||||
const kvn = kicV || kidV || 0;
|
||||
if (!kvn) return ''; // '00'/'00': no security
|
||||
if (preset && !spKeysetFor(preset, kvn)) {
|
||||
return t('Key version') + ' ' + kvn + ' ' + t('is not defined in the preset') +
|
||||
(preset.name ? ' \u2014 ' + preset.name : '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// The <option> list of a keyset selector: the preset's keysets, plus the key
|
||||
// version currently in the form when the preset does not define it.
|
||||
function spKeysetOptionsHtml(preset, kicHex, kidHex) {
|
||||
const kvn = spKeysetKvnOf(kicHex) || spKeysetKvnOf(kidHex) || 0;
|
||||
let html = '';
|
||||
let found = false;
|
||||
spKeysetList(preset).forEach(ks => {
|
||||
const v = spKeysetKvnOf(ks.kic);
|
||||
if (v === null) return;
|
||||
if (v === kvn) found = true;
|
||||
html += '<option value="' + v + '"' + (v === kvn ? ' selected' : '') + '>' +
|
||||
v + ' \u2014 ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
|
||||
' \u2014 ' + esc(t('counter')) + ' ' + esc(ks.cntr || '') + '</option>';
|
||||
});
|
||||
if (kvn && !found) {
|
||||
html += '<option value="' + kvn + '" selected>' + kvn + ' \u2014 ' +
|
||||
esc(t('not defined in the preset')) + '</option>';
|
||||
}
|
||||
if (!html) html = '<option value="">' + esc(t('\u2014 no keyset \u2014')) + '</option>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ===== Keyset editor (Cards form) =====
|
||||
let _cardsKeysetCount = 0;
|
||||
|
||||
function cardsKeysetRowHtml(ks, i) {
|
||||
const cls = 'border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono';
|
||||
const kvn = spKeysetKvnOf(ks.kic);
|
||||
return '<div class="flex gap-2 items-center mb-1">' +
|
||||
'<span id="cards-ks-' + i + '-kvn" class="w-10 text-xs font-mono text-center text-gray-500 dark:text-slate-400" title="' + esc(t('Key version (b8..b5 of KIc/KID)')) + '">' + (kvn === null ? '?' : kvn) + '</span>' +
|
||||
'<input id="cards-ks-' + i + '-kic" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KIc" value="' + esc(ks.kic || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
|
||||
'<input id="cards-ks-' + i + '-kid" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KID" value="' + esc(ks.kid || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
|
||||
'<input id="cards-ks-' + i + '-kic-key" class="flex-1 ' + cls + '" placeholder="KIc key (hex)" value="' + esc(ks.kicKey || '') + '">' +
|
||||
'<input id="cards-ks-' + i + '-kid-key" class="flex-1 ' + cls + '" placeholder="KID key (hex)" value="' + esc(ks.kidKey || '') + '">' +
|
||||
'<input id="cards-ks-' + i + '-cntr" class="w-24 ' + cls + '" maxlength="10" placeholder="Counter" value="' + esc(ks.cntr || '') + '">' +
|
||||
'<button type="button" onclick="cardsKeysetRemove(' + i + ')" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-red-100 dark:hover:bg-red-900/40" title="' + esc(t('Remove keyset')) + '">\u2715</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function cardsKeysetRowsRender(keysets) {
|
||||
const box = document.getElementById('cards-keysets');
|
||||
if (!box) return;
|
||||
const list = (keysets && keysets.length)
|
||||
? keysets
|
||||
: [{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }];
|
||||
_cardsKeysetCount = list.length;
|
||||
box.innerHTML = list.map((ks, i) => cardsKeysetRowHtml(ks, i)).join('');
|
||||
}
|
||||
|
||||
function cardsKeysetAdd() {
|
||||
cardsKeysetRowsRender(cardsKeysetsFromForm().concat([
|
||||
{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }]));
|
||||
}
|
||||
|
||||
function cardsKeysetRemove(i) {
|
||||
const rows = cardsKeysetsFromForm();
|
||||
rows.splice(i, 1);
|
||||
cardsKeysetRowsRender(rows);
|
||||
}
|
||||
|
||||
function cardsKeysetKvnUpdate(i) {
|
||||
const el = document.getElementById('cards-ks-' + i + '-kvn');
|
||||
if (!el) return;
|
||||
const kic = (document.getElementById('cards-ks-' + i + '-kic') || {}).value;
|
||||
const kid = (document.getElementById('cards-ks-' + i + '-kid') || {}).value;
|
||||
const a = spKeysetKvnOf(kic), b = spKeysetKvnOf(kid);
|
||||
const bad = (a !== null && b !== null && a !== b) || a === 0 || b === 0;
|
||||
el.textContent = a === null ? '?' : a;
|
||||
el.classList.toggle('text-red-500', !!bad);
|
||||
el.title = bad ? t('KIc and KID must carry the same key version (TS 102 225 A.2)')
|
||||
: t('Key version (b8..b5 of KIc/KID)');
|
||||
}
|
||||
|
||||
// The keyset rows as preset entries (empty rows are dropped).
|
||||
function cardsKeysetsFromForm() {
|
||||
const out = [];
|
||||
for (let i = 0; i < _cardsKeysetCount; i++) {
|
||||
const get = id => {
|
||||
const el = document.getElementById('cards-ks-' + i + '-' + id);
|
||||
return el ? String(el.value).trim() : '';
|
||||
};
|
||||
const ks = { kic: get('kic').toUpperCase(), kid: get('kid').toUpperCase(),
|
||||
kicKey: get('kic-key').toUpperCase(), kidKey: get('kid-key').toUpperCase(),
|
||||
cntr: get('cntr').toUpperCase() };
|
||||
if (!ks.kic && !ks.kid && !ks.kicKey && !ks.kidKey) continue;
|
||||
out.push(ks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===== Keyset selectors (SCP80 / RAM) =====
|
||||
// The selector follows the KIc/KID bytes: a keyset of the selected preset that
|
||||
// matches their key version is selected; a version the preset does not define
|
||||
// shows as "not defined" (and the packet actions refuse it).
|
||||
function spKeysetSync() {
|
||||
const spIdx = spPresetIdx();
|
||||
const spPreset = spIdx >= 0 ? cards[spIdx] : null;
|
||||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||||
const spSel = document.getElementById('sp-keyset-sel');
|
||||
if (spSel) spSel.innerHTML = spKeysetOptionsHtml(spPreset, kic, kid);
|
||||
const ramSel = document.getElementById('ram-keyset-sel');
|
||||
if (ramSel) {
|
||||
const ramIdx = ramPresetIdx();
|
||||
const ramPreset = ramIdx >= 0 ? cards[ramIdx] : null;
|
||||
ramSel.innerHTML = spKeysetOptionsHtml(ramPreset, kic, kid);
|
||||
}
|
||||
}
|
||||
|
||||
function spKeysetApply(ks) {
|
||||
if (!ks) return;
|
||||
const set = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value;
|
||||
};
|
||||
const kic = parseInt(ks.kic, 16), kid = parseInt(ks.kid, 16);
|
||||
set('sp-kic-idx', ((kic >> 4) & 0x0F).toString(16).toUpperCase());
|
||||
set('sp-kic-alg', (kic & 0x0F).toString(16).padStart(2, '0').toUpperCase());
|
||||
set('sp-kid-idx', ((kid >> 4) & 0x0F).toString(16).toUpperCase());
|
||||
set('sp-kid-alg', (kid & 0x0F).toString(16).padStart(2, '0').toUpperCase());
|
||||
updateSpKic();
|
||||
updateSpKid();
|
||||
set('sp-kic-key', ks.kicKey || '');
|
||||
set('sp-kid-key', ks.kidKey || '');
|
||||
set('sp-cntr', ks.cntr || '');
|
||||
spInvalidate();
|
||||
updateSp();
|
||||
}
|
||||
|
||||
function spKeysetChanged() {
|
||||
const sel = document.getElementById('sp-keyset-sel');
|
||||
if (!sel) return;
|
||||
const kvn = parseInt(sel.value, 10);
|
||||
const idx = spPresetIdx();
|
||||
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
|
||||
if (ks) spKeysetApply(ks);
|
||||
spKeysetSync();
|
||||
}
|
||||
|
||||
function ramKeysetChanged() {
|
||||
const sel = document.getElementById('ram-keyset-sel');
|
||||
if (!sel) return;
|
||||
const kvn = parseInt(sel.value, 10);
|
||||
const idx = ramPresetIdx();
|
||||
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
|
||||
if (ks) spKeysetApply(ks);
|
||||
spKeysetSync();
|
||||
}
|
||||
|
||||
// The RAM view's preset index.
|
||||
function ramPresetIdx() {
|
||||
const sel = document.getElementById('ram-card-sel');
|
||||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||||
return (!isNaN(idx) && cards[idx]) ? idx : -1;
|
||||
}
|
||||
|
||||
// '' when the SCP80 form may build a packet, else the reason. With a preset
|
||||
// selected the key version must be one of its keysets; without one only the
|
||||
// byte rules apply (hand-entered keys stay possible, a workbench flow).
|
||||
function spKeysetGuard() {
|
||||
const idx = spPresetIdx();
|
||||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||||
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
|
||||
}
|
||||
|
||||
function ramKeysetGuard() {
|
||||
const idx = ramPresetIdx();
|
||||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||||
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
|
||||
}
|
||||
|
||||
function cardsFormValues() {
|
||||
const val = id => {
|
||||
const el = document.getElementById('cards-' + id);
|
||||
@@ -10956,18 +11237,14 @@ function cardsFormValues() {
|
||||
name: val('name'),
|
||||
iccid: val('iccid'),
|
||||
adm: val('adm').replace(/\s/g, '').toUpperCase(),
|
||||
kic: val('kic'),
|
||||
kid: val('kid'),
|
||||
spi1: val('spi1'),
|
||||
spi2: val('spi2'),
|
||||
tar: val('tar'),
|
||||
uiccTar: val('uicc-tar'),
|
||||
usimTar: val('usim-tar'),
|
||||
cntr: val('cntr'),
|
||||
kicKey: val('kic-key'),
|
||||
kidKey: val('kid-key'),
|
||||
pskIdentity: val('psk-id'),
|
||||
pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(),
|
||||
keysets: cardsKeysetsFromForm(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10979,8 +11256,7 @@ function cardsSetFormMode() {
|
||||
}
|
||||
|
||||
function cardsClearForm() {
|
||||
['name','iccid','adm','kic','kid','spi1','spi2','cntr','kic-key','kid-key',
|
||||
'psk-id','psk-key'].forEach(id => {
|
||||
['name','iccid','adm','spi1','spi2','psk-id','psk-key'].forEach(id => {
|
||||
const el = document.getElementById('cards-' + id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
@@ -10989,6 +11265,7 @@ function cardsClearForm() {
|
||||
const el = document.getElementById('cards-' + id);
|
||||
if (el) el.value = cardsTarValue(key, '');
|
||||
});
|
||||
cardsKeysetRowsRender([]);
|
||||
}
|
||||
|
||||
async function cardsAdd() {
|
||||
@@ -10998,8 +11275,27 @@ async function cardsAdd() {
|
||||
v.uiccTar = cardsTarValue('uiccTar', v.uiccTar);
|
||||
v.usimTar = cardsTarValue('usimTar', v.usimTar);
|
||||
if (!v.name) { alert(t('Name is required')); return; }
|
||||
if (!v.kic || !v.kid) { alert('KIc and KID are required'); return; }
|
||||
if (!v.kicKey || !v.kidKey) { alert('KIc key and KID key are required'); return; }
|
||||
if (!v.keysets.length) {
|
||||
alert(t('Define at least one keyset (KIc/KID and their keys)'));
|
||||
return;
|
||||
}
|
||||
for (const ks of v.keysets) {
|
||||
if (!ks.kic || !ks.kid || !ks.kicKey || !ks.kidKey) {
|
||||
alert(t('Each keyset needs KIc, KID and both keys'));
|
||||
return;
|
||||
}
|
||||
const ksErr = spKeysetCheck(null, ks.kic, ks.kid);
|
||||
if (ksErr) { alert(ksErr); return; }
|
||||
if (!spKeysetKvnOf(ks.kic) || !spKeysetKvnOf(ks.kid)) {
|
||||
alert(t('Key version 00 means "no security" and is not a preset keyset'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const kvns = v.keysets.map(ks => spKeysetKvnOf(ks.kic));
|
||||
if (new Set(kvns).size !== kvns.length) {
|
||||
alert(t('Duplicate key version in the keysets'));
|
||||
return;
|
||||
}
|
||||
if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) {
|
||||
alert(t('PSK identity and PSK key must be set together'));
|
||||
return;
|
||||
@@ -11035,13 +11331,13 @@ function cardsEdit(i) {
|
||||
const el = document.getElementById('cards-' + id);
|
||||
if (el) el.value = value == null ? '' : value;
|
||||
};
|
||||
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm); set('kic', c.kic); set('kid', c.kid);
|
||||
set('spi1', c.spi1); set('spi2', c.spi2); set('cntr', c.cntr);
|
||||
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm);
|
||||
set('spi1', c.spi1); set('spi2', c.spi2);
|
||||
set('tar', cardsTarValue('tar', c.tar));
|
||||
set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar));
|
||||
set('usim-tar', cardsTarValue('usimTar', c.usimTar));
|
||||
set('kic-key', c.kicKey); set('kid-key', c.kidKey);
|
||||
set('psk-id', c.pskIdentity); set('psk-key', c.pskKey);
|
||||
cardsKeysetRowsRender(spKeysetList(c));
|
||||
cardsSetFormMode();
|
||||
document.getElementById('cards-name').focus();
|
||||
}
|
||||
@@ -11092,12 +11388,15 @@ function cardsRender() {
|
||||
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
|
||||
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kic) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kid) + '</td>';
|
||||
const ksText = spKeysetList(c).map(ks => {
|
||||
const kvn = spKeysetKvnOf(ks.kic);
|
||||
return (kvn === null ? '?' : kvn) + ': ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
|
||||
' \u2014 ' + esc(ks.cntr || '');
|
||||
}).join(' \u00b7 ') || '\u2014';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs">' + ksText + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs" title="ISD / UICC RFM / ADF RFM">' + esc(cardsTarValue('tar', c.tar)) + ' ' + esc(cardsTarValue('uiccTar', c.uiccTar)) + ' ' + esc(cardsTarValue('usimTar', c.usimTar)) + '</td>';
|
||||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.cntr) + '</td>';
|
||||
html += '<td class="py-1 px-2 text-center">' + ota + '</td>';
|
||||
html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> '
|
||||
+ '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>';
|
||||
@@ -11224,26 +11523,36 @@ function cardsApplyFields(idx, keepTar) {
|
||||
const smSel = document.getElementById('sp-spi2-sm');
|
||||
if (smSel) smSel.value = String((spi2Val >> 5) & 1);
|
||||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||||
const kicByte = parseInt(c.kic, 16);
|
||||
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||||
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
|
||||
updateSpKic();
|
||||
const kidByte = parseInt(c.kid, 16);
|
||||
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||||
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
|
||||
updateSpKid();
|
||||
// The keyset: keep the key version the form carries when this preset
|
||||
// defines it, otherwise start from the preset's first keyset.
|
||||
const kicNow = (document.getElementById('sp-kic-hex') || {}).value;
|
||||
const kidNow = (document.getElementById('sp-kid-hex') || {}).value;
|
||||
const kvnNow = spKeysetKvnOf(kicNow) || spKeysetKvnOf(kidNow) || 0;
|
||||
const ks = (kvnNow && spKeysetFor(c, kvnNow)) || spKeysetList(c)[0] || null;
|
||||
if (ks) {
|
||||
const kicByte = parseInt(ks.kic, 16);
|
||||
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||||
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
|
||||
updateSpKic();
|
||||
const kidByte = parseInt(ks.kid, 16);
|
||||
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||||
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
|
||||
updateSpKid();
|
||||
document.getElementById('sp-cntr').value = ks.cntr || '';
|
||||
document.getElementById('sp-kic-key').value = ks.kicKey || '';
|
||||
document.getElementById('sp-kid-key').value = ks.kidKey || '';
|
||||
}
|
||||
if (!keepTar) document.getElementById('sp-tar').value = cardsTarValue(_spTarKey, c[_spTarKey]);
|
||||
document.getElementById('sp-cntr').value = c.cntr;
|
||||
document.getElementById('sp-kic-key').value = c.kicKey;
|
||||
document.getElementById('sp-kid-key').value = c.kidKey;
|
||||
spInvalidate();
|
||||
updateSp();
|
||||
spKeysetSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function cardsApply(idx, keepTar) {
|
||||
if (!cardsApplyFields(idx, keepTar)) return;
|
||||
genSp();
|
||||
_genSpBuild();
|
||||
spShowSizeInfo();
|
||||
}
|
||||
|
||||
// SCP80/RAM operations re-read the selected preset before starting: the
|
||||
@@ -19096,6 +19405,30 @@ const LANG_RU = {
|
||||
'SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.': 'SCP81 HTTP OTA: слушатель выбирает ключ по PSK identity, которую карта присылает в TLS-рукопожатии. Карты без PSK слушателем игнорируются.',
|
||||
'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке',
|
||||
'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается',
|
||||
'OTA keysets — one per key version, each with its own counter': 'Наборы ключей OTA — по одному на версию ключа, у каждой свой счётчик',
|
||||
'Add keyset': 'Добавить набор ключей',
|
||||
'Remove keyset': 'Удалить набор ключей',
|
||||
'Key version (keyset)': 'Версия ключа (набор ключей)',
|
||||
'Key version': 'Версия ключа',
|
||||
'Key version (b8..b5 of KIc/KID)': 'Версия ключа (биты b8..b5 KIc/KID)',
|
||||
'Keysets (key version: KIc/KID — counter)': 'Наборы ключей (версия: KIc/KID — счётчик)',
|
||||
'must be one hex byte': 'должен быть одним hex-байтом',
|
||||
'KIc and KID must carry the same key version (TS 102 225 A.2)': 'KIc и KID должны содержать одну и ту же версию ключа (TS 102 225 A.2)',
|
||||
'is not defined in the preset': 'не определена в предустановке',
|
||||
'not defined in the preset': 'не определена в предустановке',
|
||||
'— no keyset —': '— нет набора ключей —',
|
||||
'counter': 'счётчик',
|
||||
'Define at least one keyset (KIc/KID and their keys)': 'Определите хотя бы один набор ключей (KIc/KID и их ключи)',
|
||||
'Each keyset needs KIc, KID and both keys': 'Каждому набору нужны KIc, KID и оба ключа',
|
||||
'Key version 00 means "no security" and is not a preset keyset': 'Версия ключа 00 означает «без защиты» и не является набором ключей предустановки',
|
||||
'Duplicate key version in the keysets': 'Дублирующаяся версия ключа в наборах',
|
||||
'Select a card preset - its keysets define the key versions': 'Выберите предустановку карты — её наборы ключей определяют версии ключей',
|
||||
'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в предустановке.',
|
||||
'Key version (kvn, optional)': 'Версия ключа (kvn, опционально)',
|
||||
"empty = the preset's first keyset": 'пусто = первый набор ключей предустановки',
|
||||
'Keysets (key version: KIc/KID — counter)': 'Наборы ключей (версия: KIc/KID — счётчик)',
|
||||
'KIc and KID must carry the same key version (their high nibble) in the range 01-0F — TS 102 225 A.2; each key version has its own counter on the card (Annex A.1).': 'KIc и KID должны содержать одну и ту же версию ключа (старший ниббл) в диапазоне 01-0F — TS 102 225 A.2; у каждой версии ключа свой счётчик на карте (Annex A.1).',
|
||||
'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries one keyset per key version, each with its own counter.': 'Предустановки карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; предустановка содержит один набор ключей на версию, у каждой свой счётчик.',
|
||||
'Save error': 'Ошибка сохранения',
|
||||
'Delete error': 'Ошибка удаления',
|
||||
'Counter not saved:': 'Счётчик не сохранён:',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v334';
|
||||
const CACHE = 'simple-v335';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -23,10 +23,22 @@ function extractFunc(src, name) {
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
|
||||
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr']) {
|
||||
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
|
||||
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
|
||||
'ramKeysetGuard',
|
||||
]) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code = 'var _cardsKeysetCount = 0;\n' + code;
|
||||
code = 'var _genSpBuildStub = function() {};\n' + code;
|
||||
eval(code);
|
||||
globalThis._genSpBuild = () => {};
|
||||
globalThis.spShowSizeInfo = () => {};
|
||||
globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {});
|
||||
|
||||
// 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
|
||||
@@ -49,6 +61,9 @@ function fakeEnv(selValue, selId, presetCntr) {
|
||||
globalThis.updateSp = () => {};
|
||||
let gen = 0;
|
||||
globalThis.genSp = () => { gen++; };
|
||||
// cardsApply refreshes the preview directly (the guarded genSp() is the
|
||||
// Generate button's path), so the counter stub is _genSpBuild
|
||||
globalThis._genSpBuild = () => { gen++; };
|
||||
return { els, gen: () => gen };
|
||||
}
|
||||
|
||||
|
||||
@@ -28,21 +28,42 @@ for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEd
|
||||
'spPresetIdx', 'spCntrSyncPreset', 'spCntrLocalSync', 'ramSaveCntr',
|
||||
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
|
||||
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
|
||||
'spTarKeyForPack', 'spPresetTar', 'packToSp']) {
|
||||
'spTarKeyForPack', 'spPresetTar', 'packToSp',
|
||||
'esc', 'escHtml',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
|
||||
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
|
||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||
}
|
||||
code = 'var _cardsKeysetCount = 0;\n' + code;
|
||||
eval(code);
|
||||
|
||||
const CARD_IDS = ['cards-name','cards-iccid','cards-adm','cards-kic','cards-kid',
|
||||
const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
|
||||
'cards-spi1','cards-spi2','cards-tar','cards-uicc-tar','cards-usim-tar',
|
||||
'cards-cntr','cards-kic-key','cards-kid-key','cards-psk-id','cards-psk-key',
|
||||
'cards-add-btn','cards-cancel-btn'];
|
||||
'cards-psk-id','cards-psk-key','cards-keysets',
|
||||
'cards-add-btn','cards-cancel-btn',
|
||||
// the keyset editor rows (two rows is enough for the tests)
|
||||
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
|
||||
'cards-ks-0-kid-key','cards-ks-0-cntr',
|
||||
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
|
||||
'cards-ks-1-kid-key','cards-ks-1-cntr'];
|
||||
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
|
||||
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
|
||||
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex'];
|
||||
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex',
|
||||
'sp-keyset-sel','ram-keyset-sel'];
|
||||
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
|
||||
'ber-result','hota-preview'];
|
||||
|
||||
function ksRow(els, i, ks) {
|
||||
for (const field of ['kic', 'kid', 'kic-key', 'kid-key', 'cntr']) {
|
||||
const id = 'cards-ks-' + i + '-' + field;
|
||||
if (!els[id]) els[id] = fakeEl();
|
||||
els[id].value = ks[field === 'kic-key' ? 'kicKey' : (field === 'kid-key' ? 'kidKey' : field)] || '';
|
||||
}
|
||||
}
|
||||
|
||||
function fakeEl() {
|
||||
return {
|
||||
value: '',
|
||||
@@ -63,8 +84,11 @@ function setup() {
|
||||
globalThis.cards = [];
|
||||
globalThis._cardsEditIdx = null;
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
globalThis._spTarKey = 'tar';
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => s;
|
||||
globalThis.escHtml = s => s;
|
||||
globalThis.alert = () => {};
|
||||
globalThis.cardsSave = () => {};
|
||||
globalThis.cardsRender = () => {};
|
||||
@@ -81,7 +105,10 @@ function setup() {
|
||||
globalThis.spInvalidate = () => {};
|
||||
globalThis.updateSp = () => {};
|
||||
globalThis.genSp = () => {};
|
||||
globalThis._genSpBuild = () => {};
|
||||
globalThis.spShowSizeInfo = () => {};
|
||||
globalThis.ramRender = () => {};
|
||||
globalThis.switchTab = () => {};
|
||||
// the server store is the source of truth (v3.8.0): the Cards tab talks to
|
||||
// /api/presets* and reloads through cardsFetch()
|
||||
apiCalls = [];
|
||||
@@ -174,10 +201,8 @@ test('the form values carry the ADM and per-target TAR fields', () => {
|
||||
test('saving a preset posts the form with spec-default TARs', async () => {
|
||||
const els = setup();
|
||||
els['cards-name'].value = 'New card';
|
||||
els['cards-kic'].value = '15';
|
||||
els['cards-kid'].value = '15';
|
||||
els['cards-kic-key'].value = 'AA';
|
||||
els['cards-kid-key'].value = 'BB';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
els['cards-tar'].value = '';
|
||||
els['cards-uicc-tar'].value = '';
|
||||
els['cards-usim-tar'].value = '';
|
||||
@@ -186,6 +211,8 @@ test('saving a preset posts the form with spec-default TARs', async () => {
|
||||
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
|
||||
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: '' }]);
|
||||
assert.strictEqual(apiCalls[0].body.tar, '000000');
|
||||
assert.strictEqual(apiCalls[0].body.uiccTar, 'B00000');
|
||||
assert.strictEqual(apiCalls[0].body.usimTar, 'B00001');
|
||||
@@ -194,14 +221,12 @@ test('saving a preset posts the form with spec-default TARs', async () => {
|
||||
|
||||
test('editing a preset updates it by its store id', async () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ id: 'abc123', name: 'Old', kic: '15', kid: '15',
|
||||
kicKey: 'AA', kidKey: 'BB' }];
|
||||
globalThis.cards = [{ id: 'abc123', name: 'Old', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
|
||||
globalThis._cardsEditIdx = 0;
|
||||
els['cards-name'].value = 'Renamed';
|
||||
els['cards-kic'].value = '15';
|
||||
els['cards-kid'].value = '15';
|
||||
els['cards-kic-key'].value = 'AA';
|
||||
els['cards-kid-key'].value = 'BB';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
|
||||
assert.strictEqual(apiCalls[0].body.id, 'abc123');
|
||||
@@ -235,19 +260,22 @@ test('import posts the entries to the server store', async () => {
|
||||
|
||||
test('the SCP80 counter edit is written to the store, a reported counter only locally', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ id: 'abc123', name: 'C', cntr: '0000000001' }];
|
||||
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].cntr, '0000000009');
|
||||
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.deepStrictEqual(apiCalls[0].body.fields, { cntr: '0000000009' });
|
||||
assert.strictEqual(apiCalls[0].body.fields.keysets[0].cntr, '0000000009');
|
||||
// a counter the server already persisted: display only, no second write
|
||||
apiCalls = [];
|
||||
spCntrLocalSync('000000000A');
|
||||
assert.strictEqual(cards[0].cntr, '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 = '';
|
||||
|
||||
@@ -135,8 +135,8 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
|
||||
const scp81 = fieldsets.find(f => /data-l10n="SCP81 \(HTTP OTA\)"/.test(f));
|
||||
assert.ok(scp80, 'SCP80 card fieldset not found');
|
||||
assert.ok(scp81, 'SCP81 card fieldset not found');
|
||||
assert.match(scp80, /id="cards-kic"/);
|
||||
assert.match(scp80, /id="cards-kid-key"/);
|
||||
assert.match(scp80, /id="cards-keysets"/);
|
||||
assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/);
|
||||
assert.match(scp80, /id="cards-tar"[^>]*value="000000"/);
|
||||
assert.match(scp80, /id="cards-uicc-tar"[^>]*value="B00000"/);
|
||||
assert.match(scp80, /id="cards-usim-tar"[^>]*value="B00001"/);
|
||||
@@ -144,8 +144,8 @@ test('the card form groups the SCP80 and SCP81 fields into labelled fieldsets',
|
||||
assert.match(scp80, />ISD TAR<\/label>/);
|
||||
assert.match(scp80, />UICC RFM TAR<\/label>/);
|
||||
assert.match(scp80, />ADF RFM TAR<\/label>/);
|
||||
assert.ok(scp80.indexOf('id="cards-cntr"') < scp80.indexOf('id="cards-tar"'),
|
||||
'TAR fields must follow the Counter field');
|
||||
assert.ok(scp80.indexOf('id="cards-spi1"') < scp80.indexOf('id="cards-tar"'),
|
||||
'TAR fields must follow the SPI fields');
|
||||
assert.match(scp81, /id="cards-psk-id"/);
|
||||
assert.match(scp81, /id="cards-psk-key"/);
|
||||
// the PSK explanation lives inside the SCP81 group, not outside it
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'esc', 'escHtml']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
eval(code);
|
||||
|
||||
const PRESET = {
|
||||
name: 'Card A',
|
||||
keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' },
|
||||
{ kic: '29', kid: '29', kicKey: 'CC', kidKey: 'DD', cntr: '0000000005' },
|
||||
],
|
||||
};
|
||||
|
||||
test('spKeysetKvnOf reads the key version from the high nibble', () => {
|
||||
assert.strictEqual(spKeysetKvnOf('15'), 1);
|
||||
assert.strictEqual(spKeysetKvnOf('29'), 2);
|
||||
assert.strictEqual(spKeysetKvnOf('3A'), 3);
|
||||
assert.strictEqual(spKeysetKvnOf('05'), 0);
|
||||
assert.strictEqual(spKeysetKvnOf('ff'), 15);
|
||||
// not a byte: no key version
|
||||
assert.strictEqual(spKeysetKvnOf(''), null);
|
||||
assert.strictEqual(spKeysetKvnOf('5'), null);
|
||||
assert.strictEqual(spKeysetKvnOf('ZZ'), null);
|
||||
assert.strictEqual(spKeysetKvnOf(null), null);
|
||||
});
|
||||
|
||||
test('spKeysetList converts a v3.8.0 flat preset into one keyset', () => {
|
||||
assert.deepStrictEqual(spKeysetList({ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
|
||||
cntr: '0000000001' }), [{ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
|
||||
cntr: '0000000001' }]);
|
||||
assert.deepStrictEqual(spKeysetList(PRESET).length, 2);
|
||||
assert.deepStrictEqual(spKeysetList(null), []);
|
||||
assert.deepStrictEqual(spKeysetList({}), []);
|
||||
});
|
||||
|
||||
test('spKeysetFor finds a keyset by key version', () => {
|
||||
assert.strictEqual(spKeysetFor(PRESET, 1).cntr, '0000000001');
|
||||
assert.strictEqual(spKeysetFor(PRESET, 2).cntr, '0000000005');
|
||||
assert.strictEqual(spKeysetFor(PRESET, 3), null);
|
||||
assert.strictEqual(spKeysetFor(null, 1), null);
|
||||
});
|
||||
|
||||
test('spKeysetCheck enforces the key-version rules', () => {
|
||||
// a defined key version passes
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '15', '15'), '');
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '29', '29'), '');
|
||||
// '00'/'00' means no security: no keyset needed (TS 102 225 A.2)
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '00', '00'), '');
|
||||
// ... and one zero byte with a defined version passes too
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '15', '00'), '');
|
||||
// KIc/KID versions must agree when both are non-zero (A.2)
|
||||
assert.match(spKeysetCheck(PRESET, '15', '25'), /same key version/);
|
||||
// the key version must be defined in the preset
|
||||
assert.match(spKeysetCheck(PRESET, '35', '35'), /3 .*not defined/);
|
||||
// one zero byte: the other byte's version applies (keyset 2 is defined)
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '00', '25'), '');
|
||||
assert.match(spKeysetCheck(PRESET, '00', '35'), /3 .*not defined/);
|
||||
// without a preset only the byte rules apply (hand-entered keys)
|
||||
assert.strictEqual(spKeysetCheck(null, '35', '35'), '');
|
||||
assert.match(spKeysetCheck(null, '15', '25'), /15 \/ 25/);
|
||||
// malformed bytes are named
|
||||
assert.match(spKeysetCheck(PRESET, '5', '15'), /KIc/);
|
||||
assert.match(spKeysetCheck(PRESET, '15', 'ZZ'), /KID/);
|
||||
});
|
||||
|
||||
test('spKeysetOptionsHtml lists the keysets and marks an undefined version', () => {
|
||||
const html1 = spKeysetOptionsHtml(PRESET, '15', '15');
|
||||
assert.match(html1, /value="1" selected/);
|
||||
assert.match(html1, /value="2"/);
|
||||
assert.ok(html1.indexOf('0000000005') > 0, 'the counters are shown');
|
||||
// a version the preset does not define is offered but marked
|
||||
const html2 = spKeysetOptionsHtml(PRESET, '35', '35');
|
||||
assert.match(html2, /value="3" selected/);
|
||||
assert.match(html2, /not defined in the preset/);
|
||||
// a preset without keysets: the form's version shows as undefined
|
||||
const html3 = spKeysetOptionsHtml({ keysets: [] }, '15', '15');
|
||||
assert.match(html3, /not defined in the preset/);
|
||||
// no key version in the form and no keysets at all
|
||||
assert.match(spKeysetOptionsHtml({}, '', ''), /no keyset/);
|
||||
});
|
||||
|
||||
test('the Cards form wires the keyset editor', () => {
|
||||
assert.match(html, /id="cards-keysets"/);
|
||||
assert.match(html, /onclick="cardsKeysetAdd\(\)"/);
|
||||
assert.match(html, /oninput="cardsKeysetKvnUpdate\(/);
|
||||
assert.match(html, /data-l10n="Add keyset"/);
|
||||
// the keyset rows are built with the KIc/KID/key/counter inputs
|
||||
assert.match(html, /cards-ks-' \+ i \+ '-kic-key/);
|
||||
assert.match(html, /cards-ks-' \+ i \+ '-cntr/);
|
||||
// the selectors exist in both SCP80 views
|
||||
assert.match(html, /id="sp-keyset-sel" onchange="spKeysetChanged\(\)"/);
|
||||
assert.match(html, /id="ram-keyset-sel" onchange="ramKeysetChanged\(\)"/);
|
||||
// the packet actions run the guard
|
||||
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
|
||||
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
|
||||
});
|
||||
@@ -27,6 +27,9 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
|
||||
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
|
||||
'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
|
||||
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
|
||||
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
|
||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
|
||||
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
|
||||
let code = '';
|
||||
@@ -806,7 +809,7 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
|
||||
const go = extractFunc(html, 'ramGoToPreset');
|
||||
assert.ok(go.includes("switchTab('cards')"), go);
|
||||
assert.ok(go.includes('cardsEdit('), go);
|
||||
assert.ok(go.includes("getElementById('cards-cntr')"), go);
|
||||
assert.ok(go.includes("getElementById('cards-ks-' + row + '-cntr')"), go);
|
||||
});
|
||||
|
||||
test('the Explore detects the expanded query form and reports it', () => {
|
||||
|
||||
@@ -31,9 +31,16 @@ for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr
|
||||
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
|
||||
'ramHideProgress', 'ramDeleteFromExplorer',
|
||||
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
|
||||
'escHtml', 'esc']) {
|
||||
'escHtml', 'esc',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
|
||||
'ramKeysetGuard',
|
||||
]) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code = 'var _cardsKeysetCount = 0;\n' + code;
|
||||
eval(code);
|
||||
|
||||
function fakeEnv(por, success) {
|
||||
@@ -49,8 +56,11 @@ function fakeEnv(por, success) {
|
||||
els['sp-kic-key'].value = 'AA';
|
||||
els['sp-kid-key'].value = 'BB';
|
||||
els['sp-spi2-hex'].value = '01';
|
||||
els['sp-kic-hex'].value = '15';
|
||||
els['sp-kid-hex'].value = '15';
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ id: 'preset-1', name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }];
|
||||
globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
|
||||
const calls = { saved: 0, explored: null, removed: null, sent: null };
|
||||
globalThis.cardsSave = () => { calls.saved++; };
|
||||
globalThis.cardsRender = () => {};
|
||||
@@ -59,8 +69,9 @@ function fakeEnv(por, success) {
|
||||
globalThis.alert = () => {};
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.spRefreshFromPreset = () => {
|
||||
els['sp-cntr'].value = cards[0].cntr;
|
||||
return cards[0].cntr;
|
||||
const cntr = cards[0].keysets[0].cntr;
|
||||
els['sp-cntr'].value = cntr;
|
||||
return cntr;
|
||||
};
|
||||
globalThis.ramSendOta = async (apdu, sp) => {
|
||||
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
|
||||
@@ -81,7 +92,7 @@ test('accepted delete persists the consumed counter and drops the record', async
|
||||
assert.strictEqual(calls.sent.cntr, '0000000005');
|
||||
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
|
||||
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
|
||||
assert.strictEqual(cards[0].cntr, '0000000006',
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
|
||||
'the preset must carry the counter the card consumed');
|
||||
assert.strictEqual(els['sp-cntr'].value, '0000000006');
|
||||
assert.strictEqual(calls.sent.preset_id, 'preset-1',
|
||||
@@ -94,7 +105,7 @@ test('accepted delete persists the consumed counter and drops the record', async
|
||||
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
|
||||
const { els, calls } = fakeEnv(undefined);
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].cntr, '0000000006');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
||||
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
|
||||
@@ -103,7 +114,7 @@ test('a delete with no PoR is accepted (the envelope 9000 is the result)', async
|
||||
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'cntr_low' });
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].cntr, '0000000005');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
||||
assert.strictEqual(calls.saved, 0);
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
@@ -112,20 +123,20 @@ test('a refused DELETE still advances the counter but drops nothing', async () =
|
||||
const { calls } = fakeEnv({ response_status: 'por_ok',
|
||||
decoded: { last_status_word: '6A88' } });
|
||||
await ramDeleteFromExplorer('F0414C46416101', true);
|
||||
assert.strictEqual(cards[0].cntr, '0000000006');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].cntr, '0000000006');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
||||
});
|
||||
|
||||
test('a send failure leaves the preset untouched', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].cntr, '0000000005');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ function extractFunc(src, name) {
|
||||
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
|
||||
'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
|
||||
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
|
||||
'spShowSizeInfo'];
|
||||
'spShowSizeInfo',
|
||||
// the key-version guard genSp runs before building (TS 102 225 A.2)
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spPresetIdx', 'spKeysetGuard'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) {
|
||||
|
||||
Reference in New Issue
Block a user