v1.9.17: fix explore card — chain GET STATUS + GET RESPONSE inside SCP80
Key fix: re-add chained C0000000 (GET RESPONSE Le=00) in paginate() so the full GP APDU inside SCP80 is 80F2<p1>p2024F0000C0000000, matching the working tool. The card's SCP80 layer executes both commands internally (GET STATUS → 61XX → GET RESPONSE) and puts the final 9000 + data in the PoR. Also: server logging improvements (RAM RESPONSE-PACKET label, no truncation of FETCH/PoR hex), docs for /api/ram-install endpoint, minor test fix.
This commit is contained in:
+55
@@ -30,6 +30,7 @@ connect and warns if versions are incompatible.
|
|||||||
| `/api/apdu` | POST | Raw APDU send |
|
| `/api/apdu` | POST | Raw APDU send |
|
||||||
| `/api/help` | POST | pySim help for a given command |
|
| `/api/help` | POST | pySim help for a given command |
|
||||||
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
|
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
|
||||||
|
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
|
||||||
| `/api/sp-verify` | POST | Verify secured packet against pySim reference |
|
| `/api/sp-verify` | POST | Verify secured packet against pySim reference |
|
||||||
| `/api/menu` | GET | Current STK menu (title + items + active) |
|
| `/api/menu` | GET | Current STK menu (title + items + active) |
|
||||||
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
|
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
|
||||||
@@ -140,6 +141,60 @@ same `por` structure if decoding succeeds.
|
|||||||
|
|
||||||
The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
|
The SPI2 `por_in_submit` bit (0x20) selects submit-mode PoR.
|
||||||
|
|
||||||
|
### `POST /api/ram-install`
|
||||||
|
|
||||||
|
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cap_hex": "DECAFFED...",
|
||||||
|
"sd_aid": "A000000003000000",
|
||||||
|
"install_params": "C90000",
|
||||||
|
"stk_params": "",
|
||||||
|
"nv_quota": 0,
|
||||||
|
"volatile_quota": 0,
|
||||||
|
"make_selectable": true,
|
||||||
|
"spi1": "0E", "spi2": "01",
|
||||||
|
"kic": "15", "kid": "15",
|
||||||
|
"tar": "000000",
|
||||||
|
"cntr": "0000000001",
|
||||||
|
"kicKey": "D6FCC023...",
|
||||||
|
"kidKey": "1B07E7E0..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Req | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `cap_hex` | yes | Even-length hex of the `.cap` file (zipped Java Card CAP), max 48 kB (98304 hex chars) |
|
||||||
|
| `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` |
|
||||||
|
| `install_params` | no | Hex C9 TLV install parameters; if empty, `gen_install_parameters()` is used with the quota/stk params |
|
||||||
|
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
|
||||||
|
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
|
||||||
|
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
|
||||||
|
|
||||||
|
**Response (success):**
|
||||||
|
```json
|
||||||
|
{"success": true, "failed_step": null,
|
||||||
|
"steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000"},
|
||||||
|
{"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000"},
|
||||||
|
{"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000"}],
|
||||||
|
"final_cntr": "0000000004",
|
||||||
|
"load_file_aid": "A000000003000000",
|
||||||
|
"module_aid": "A000000003000000",
|
||||||
|
"application_aid": "A000000003000000"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (failure):**
|
||||||
|
```json
|
||||||
|
{"success": false, "failed_step": "load_1",
|
||||||
|
"steps": [{"name": "install_for_load", "por_status": "por_ok", "sw": "9000"},
|
||||||
|
{"name": "load_1", "por_status": "rc_error", "sw": null}],
|
||||||
|
"error": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
|
||||||
|
|
||||||
### `POST /api/sp-verify`
|
### `POST /api/sp-verify`
|
||||||
|
|
||||||
Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd`
|
Cross-check a secured packet against pySim's `OtaDialectSms.encode_cmd`
|
||||||
|
|||||||
+551
-5
@@ -18,7 +18,7 @@
|
|||||||
<div class="max-w-7xl mx-auto px-6 py-2">
|
<div class="max-w-7xl mx-auto px-6 py-2">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.9.12</span></h1>
|
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.9.17</span></h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
||||||
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
||||||
@@ -616,8 +616,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="genRam()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate APDU">Generate APDU</button>
|
<button onclick="genRam()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate APDU">Generate APDU</button>
|
||||||
<textarea id="ram-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>
|
<textarea id="ram-apdu-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>
|
||||||
<button onclick="packToSp('ram-result')" id="ram-pack-btn" disabled class="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="Pack into Secured packet">Pack into Secured packet</button>
|
<button onclick="packToSp('ram-apdu-result')" id="ram-pack-btn" disabled class="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="Pack into Secured packet">Pack into Secured packet</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="c-apdu-sub-parse" class="hidden">
|
<div id="c-apdu-sub-parse" class="hidden">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -675,6 +675,7 @@
|
|||||||
<div class="flex gap-2 mb-3">
|
<div class="flex gap-2 mb-3">
|
||||||
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp80-sub="sp" onclick="scp80SwitchSubtab('sp')" data-l10n="Secured packet">Secured packet</button>
|
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp80-sub="sp" onclick="scp80SwitchSubtab('sp')" data-l10n="Secured packet">Secured packet</button>
|
||||||
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="cards" onclick="scp80SwitchSubtab('cards')" data-l10n="Cards">Cards</button>
|
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="cards" onclick="scp80SwitchSubtab('cards')" data-l10n="Cards">Cards</button>
|
||||||
|
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="ram" onclick="scp80SwitchSubtab('ram')" data-l10n="RAM">RAM</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="scp80-sub-sp">
|
<div id="scp80-sub-sp">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -877,6 +878,61 @@
|
|||||||
<textarea id="cards-io" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 hidden" placeholder="JSON data"></textarea>
|
<textarea id="cards-io" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 hidden" placeholder="JSON data"></textarea>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scp80-sub-ram" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.">RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card preset">Card preset</label>
|
||||||
|
<select id="ram-card-sel" onchange="ramApplyCard(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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<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">
|
||||||
|
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
|
||||||
|
<option value="delete" data-l10n="Delete (DELETE)">Delete (DELETE)</option>
|
||||||
|
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ram-del-params" class="hidden mb-3">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="AID to delete (hex)">AID to delete (hex)</label>
|
||||||
|
<input id="ram-del-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
|
||||||
|
<label class="flex items-center gap-2 mt-2">
|
||||||
|
<input type="checkbox" id="ram-del-related"> <span class="text-sm" data-l10n="Delete related objects">Delete related objects</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ram-install-params" class="hidden mb-3">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
|
||||||
|
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
|
||||||
|
<div id="ram-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
|
||||||
|
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="Install parameters (hex, optional)">Install parameters (hex, optional)</label>
|
||||||
|
<input id="ram-install-params-hex" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="C9 TLV">
|
||||||
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="STK parameters (hex, optional)">STK parameters (hex, optional)</label>
|
||||||
|
<input id="ram-stk-params" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="CA TLV">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="ramExecute()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Execute">Execute</button>
|
||||||
|
|
||||||
|
<div id="ram-progress" class="hidden mb-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
|
||||||
|
<div class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||||
|
<span id="ram-progress-text" data-l10n="Working...">Working...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
|
||||||
|
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
|
||||||
|
<div id="ram-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap hidden"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-response" class="tab-content hidden">
|
<div id="tab-response" class="tab-content hidden">
|
||||||
@@ -1139,8 +1195,10 @@ function scp80SwitchSubtab(name) {
|
|||||||
});
|
});
|
||||||
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
|
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
|
||||||
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
|
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
|
||||||
|
document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram');
|
||||||
if (name === 'cards') cardsRender();
|
if (name === 'cards') cardsRender();
|
||||||
scp80HelpAnchor = { sp: 'secured-packet', cards: 'cards' }[name] || 'secured-packet';
|
if (name === 'ram') ramRender();
|
||||||
|
scp80HelpAnchor = { sp: 'secured-packet', cards: 'cards', ram: 'ram' }[name] || 'secured-packet';
|
||||||
setHelpAnchor(scp80HelpAnchor);
|
setHelpAnchor(scp80HelpAnchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,7 +1739,7 @@ function genRam() {
|
|||||||
const ssMode = parseInt(document.getElementById('ram-ss-mode').value, 16);
|
const ssMode = parseInt(document.getElementById('ram-ss-mode').value, 16);
|
||||||
const ssState = parseInt(document.getElementById('ram-ss-state').value, 16);
|
const ssState = parseInt(document.getElementById('ram-ss-state').value, 16);
|
||||||
|
|
||||||
const resultEl = document.getElementById('ram-result');
|
const resultEl = document.getElementById('ram-apdu-result');
|
||||||
|
|
||||||
const INSTALL_P1 = {
|
const INSTALL_P1 = {
|
||||||
'install-load': 0x02,
|
'install-load': 0x02,
|
||||||
@@ -4172,6 +4230,493 @@ async function pysimSendOta() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== SCP80/RAM pill =====
|
||||||
|
// Atomic GlobalPlatform Remote Application Management over SCP80.
|
||||||
|
// Simple ops (GET DATA / GET STATUS / DELETE) build the GP APDU locally and
|
||||||
|
// reuse /api/send-ota. Install Package sends the .cap hex to /api/ram-install
|
||||||
|
// which orchestrates INSTALL[for load] -> LOAD x N -> INSTALL[for install].
|
||||||
|
|
||||||
|
function ramRender() {
|
||||||
|
// populate the card preset selector from the in-memory cards[] array
|
||||||
|
const sel = document.getElementById('ram-card-sel');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = '<option value="" data-l10n="— Select card —">— Select card —</option>';
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = c.name || ('Card ' + i);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
ramOpChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramApplyCard(idx) {
|
||||||
|
// copy the selected card preset into the SP form fields so that
|
||||||
|
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
|
||||||
|
cardsApply(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramOpChanged() {
|
||||||
|
const op = document.getElementById('ram-op').value;
|
||||||
|
document.getElementById('ram-del-params').classList.toggle('hidden', op !== 'delete');
|
||||||
|
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramShowProgress(text) {
|
||||||
|
const el = document.getElementById('ram-progress');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
document.getElementById('ram-progress-text').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramHideProgress() {
|
||||||
|
document.getElementById('ram-progress').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramClearResults() {
|
||||||
|
document.getElementById('ram-result').classList.add('hidden');
|
||||||
|
document.getElementById('ram-explorer').classList.add('hidden');
|
||||||
|
document.getElementById('ram-explorer').innerHTML = '';
|
||||||
|
document.getElementById('ram-steps').classList.add('hidden');
|
||||||
|
document.getElementById('ram-steps').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a File as a hex string via FileReader (client-side, no upload)
|
||||||
|
function ramReadFileHex(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const bytes = new Uint8Array(reader.result);
|
||||||
|
let hex = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
|
||||||
|
resolve(hex.toUpperCase());
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
|
||||||
|
function getRamSpParams() {
|
||||||
|
return {
|
||||||
|
spi1: document.getElementById('sp-spi1').value,
|
||||||
|
spi2: document.getElementById('sp-spi2').value,
|
||||||
|
kic: document.getElementById('sp-kic-hex').value,
|
||||||
|
kid: document.getElementById('sp-kid-hex').value,
|
||||||
|
tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramIncrementCntr(cntr) {
|
||||||
|
let v = (parseInt(cntr, 16) || 0) + 1;
|
||||||
|
return v.toString(16).toUpperCase().padStart(10, '0').slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramSaveCntr(cntr) {
|
||||||
|
const el = document.getElementById('sp-cntr');
|
||||||
|
el.value = cntr;
|
||||||
|
const selIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
|
||||||
|
if (!isNaN(selIdx) && cards[selIdx]) {
|
||||||
|
cards[selIdx].cntr = cntr;
|
||||||
|
cardsSave();
|
||||||
|
cardsRender();
|
||||||
|
ramRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a single GP APDU wrapped in SCP80 via /api/send-ota
|
||||||
|
async function ramSendOta(apduHex, sp) {
|
||||||
|
// RAM operations: send raw GP command + SCP80 params; server handles SCP80 wrapping
|
||||||
|
// Caller controls SPI2: 0x01 = PoR via ENVELOPE response, 0x21 = PoR via SMS-SUBMIT
|
||||||
|
const body = Object.assign({}, sp, { apdu: apduHex, sp: '', includeCpi: true });
|
||||||
|
const res = await fetch('/api/send-ota', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Raw parsers for GET STATUS responses (P2=00 format) =====
|
||||||
|
// Raw format: consecutive <aid_len><AID><lifecycle><privileges> entries (no tag wrappers).
|
||||||
|
// Per GP Card Spec Table 11-33: AID is length-prefixed, lifecycle is 1 byte,
|
||||||
|
// privileges is 1 byte (bitmask).
|
||||||
|
function ramParseAppStatus(hex) {
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
const s = (hex || '').toUpperCase();
|
||||||
|
while (i + 6 <= s.length) {
|
||||||
|
const aidLen = parseInt(s.substr(i, 2), 16);
|
||||||
|
if (aidLen < 1 || i + 2 + aidLen * 2 + 4 > s.length) break;
|
||||||
|
const aid = s.substr(i + 2, aidLen * 2);
|
||||||
|
i += 2 + aidLen * 2;
|
||||||
|
const lifecycle = s.substr(i, 2); i += 2;
|
||||||
|
const privileges = s.substr(i, 2); i += 2;
|
||||||
|
out.push({ type: 'app', aid: aid.toUpperCase(), lifecycle, privileges });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELF raw format (P2=00): <aid_len><AID><lifecycle> per entry.
|
||||||
|
// Some cards may append version/module data but it's not guaranteed in raw mode.
|
||||||
|
function ramParseElfStatus(hex) {
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
const s = (hex || '').toUpperCase();
|
||||||
|
while (i + 6 <= s.length) {
|
||||||
|
const aidLen = parseInt(s.substr(i, 2), 16);
|
||||||
|
if (aidLen < 1 || i + 2 + aidLen * 2 + 2 > s.length) break;
|
||||||
|
const aid = s.substr(i + 2, aidLen * 2);
|
||||||
|
i += 2 + aidLen * 2;
|
||||||
|
const lifecycle = s.substr(i, 2); i += 2;
|
||||||
|
out.push({ type: 'elf', aid: aid.toUpperCase(), lifecycle });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the '84' module-AID list (concatenated 4F<len><aid> TLVs) into an array
|
||||||
|
function ramParseModuleAids(hex) {
|
||||||
|
const out = [];
|
||||||
|
let j = 0;
|
||||||
|
const s = hex || '';
|
||||||
|
while (j + 4 <= s.length) {
|
||||||
|
const t = s.substr(j, 2);
|
||||||
|
const l = parseInt(s.substr(j + 2, 2), 16);
|
||||||
|
const v = s.substr(j + 4, l * 2);
|
||||||
|
j += 4 + l * 2;
|
||||||
|
if (t === '4F') out.push(v.toUpperCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET DATA FF21 response: 81 app count, 82 free NV (3B), 83 free volatile (2B)
|
||||||
|
function ramParseGetMemory(hex) {
|
||||||
|
const s = (hex || '').toUpperCase();
|
||||||
|
if (!s.startsWith('FF21')) return null;
|
||||||
|
let i = 6; // Skip FF21 (4 bytes) + length byte (1 byte) = start at first tag
|
||||||
|
const out = {};
|
||||||
|
while (i + 4 <= s.length) {
|
||||||
|
const t = s.substr(i, 2);
|
||||||
|
const l = parseInt(s.substr(i + 2, 2), 16);
|
||||||
|
const v = s.substr(i + 4, l * 2);
|
||||||
|
i += 4 + l * 2;
|
||||||
|
if (t === '81') out.appCount = parseInt(v, 16);
|
||||||
|
else if (t === '82') out.freeNV = parseInt(v, 16);
|
||||||
|
else if (t === '83') out.freeV = parseInt(v, 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Lifecycle / privilege formatting =====
|
||||||
|
const RAM_LIFECYCLE = {
|
||||||
|
'01': 'OP_READY', '07': 'SELECTABLE', '0F': 'PERSONALIZED',
|
||||||
|
'03': 'INSTALLED', '1F': 'SD_PERSONALIZED', '7F': 'LOCKED',
|
||||||
|
'FF': 'TERMINATED',
|
||||||
|
};
|
||||||
|
function ramFmtLifecycle(hex) {
|
||||||
|
return RAM_LIFECYCLE[hex] || ('0x' + hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings
|
||||||
|
function ramFmtPrivileges(hex) {
|
||||||
|
const p = hex || '';
|
||||||
|
if (!p) return '(none)';
|
||||||
|
const bytes = p.match(/.{2}/g) || [];
|
||||||
|
const privs = [];
|
||||||
|
const b1 = parseInt(bytes[0] || '00', 16);
|
||||||
|
if (b1 & 0x01) privs.push('Card Lock');
|
||||||
|
if (b1 & 0x02) privs.push('Card Terminate');
|
||||||
|
if (b1 & 0x04) privs.push('Card Reset');
|
||||||
|
if (b1 & 0x08) privs.push('Cum Deletion Ctr');
|
||||||
|
if (b1 & 0x10) privs.push('GSM Card Binding');
|
||||||
|
if (b1 & 0x20) privs.push('Default Selected');
|
||||||
|
if (b1 & 0x40) privs.push('Global PIN');
|
||||||
|
const b2 = parseInt(bytes[1] || '00', 16);
|
||||||
|
if (b2 & 0x01) privs.push('Mandated DAP');
|
||||||
|
if (b2 & 0x02) privs.push('Security Domain');
|
||||||
|
if (b2 & 0x04) privs.push('DAP Verification');
|
||||||
|
if (b2 & 0x08) privs.push('Delegated Mgmt');
|
||||||
|
if (b2 & 0x10) privs.push('RFM');
|
||||||
|
if (b2 & 0x20) privs.push('CFM');
|
||||||
|
const b3 = parseInt(bytes[2] || '00', 16);
|
||||||
|
if (b3 & 0x01) privs.push('Receipt Gen');
|
||||||
|
if (b3 & 0x02) privs.push('Ciphered Load');
|
||||||
|
if (b3 & 0x04) privs.push('Delegated Perso');
|
||||||
|
if (b3 & 0x08) privs.push('Trusted Path');
|
||||||
|
if (b3 & 0x10) privs.push('Authorized Mgmt');
|
||||||
|
return privs.length ? privs.join(', ') : '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=40)
|
||||||
|
function ramMergeElfData(elfs, modules) {
|
||||||
|
const byAid = {};
|
||||||
|
elfs.forEach(e => { if (e.aid) byAid[e.aid] = e; });
|
||||||
|
modules.forEach(m => {
|
||||||
|
if (m.aid && byAid[m.aid]) {
|
||||||
|
if (m.moduleAids && m.moduleAids.length) byAid[m.aid].moduleAids = m.moduleAids;
|
||||||
|
} else if (m.aid) {
|
||||||
|
elfs.push(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return elfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the full explorer result as structured HTML
|
||||||
|
function ramRenderExploreHtml(mem, isd, apps, elfs) {
|
||||||
|
let html = '';
|
||||||
|
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) {
|
||||||
|
html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
|
||||||
|
html += '<div class="font-semibold text-sm mb-1" data-l10n="Memory (GET DATA FF21)">Memory (GET DATA FF21)</div>';
|
||||||
|
html += '<div>Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
|
||||||
|
html += '<div>Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
|
||||||
|
html += '<div>Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
if (isd && isd.length) {
|
||||||
|
html += '<div class="mb-4">';
|
||||||
|
html += '<div class="font-semibold text-sm mb-1" data-l10n="ISD">ISD</div>';
|
||||||
|
isd.forEach(o => {
|
||||||
|
html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">';
|
||||||
|
html += '<div>AID: ' + (o.aid || '?') + '</div>';
|
||||||
|
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||||||
|
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||||||
|
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
if (apps && apps.length) {
|
||||||
|
html += '<div class="mb-4">';
|
||||||
|
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications">Applications</div>';
|
||||||
|
apps.forEach(o => {
|
||||||
|
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
|
||||||
|
html += '<div>AID: ' + (o.aid || '?') + '</div>';
|
||||||
|
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||||||
|
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||||||
|
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>';
|
||||||
|
if (o.elfAid) html += '<div>ELF AID: ' + o.elfAid + '</div>';
|
||||||
|
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
if (elfs && elfs.length) {
|
||||||
|
html += '<div class="mb-4">';
|
||||||
|
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files">Executable Load Files</div>';
|
||||||
|
elfs.forEach(o => {
|
||||||
|
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
|
||||||
|
html += '<div>AID: ' + (o.aid || '?') + '</div>';
|
||||||
|
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||||||
|
if (o.version) html += '<div>Version: ' + o.version + '</div>';
|
||||||
|
if (o.moduleAids && o.moduleAids.length) {
|
||||||
|
html += '<div>Module AIDs:</div>';
|
||||||
|
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; });
|
||||||
|
}
|
||||||
|
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Operation handlers =====
|
||||||
|
async function ramExplore(sp) {
|
||||||
|
const resultEl = document.getElementById('ram-result');
|
||||||
|
const explorerEl = document.getElementById('ram-explorer');
|
||||||
|
const stepsEl = document.getElementById('ram-steps');
|
||||||
|
let cntr = sp.cntr;
|
||||||
|
const errors = [];
|
||||||
|
const mem = { appCount: null, freeNV: null, freeV: null };
|
||||||
|
const isd = [], apps = [], elfs = [], modules = [];
|
||||||
|
|
||||||
|
async function paginate(p1, collector, parser, label) {
|
||||||
|
// Chain GET STATUS + GET RESPONSE into a single SCP80 payload.
|
||||||
|
// The card's SCP80 layer executes both: GET STATUS returns 61XX,
|
||||||
|
// then GET RESPONSE fetches the data — the PoR captures the final
|
||||||
|
// result (9000 + response data) without the frontend handling 61XX.
|
||||||
|
// ELF queries (P1=20/10) use SPI2=0x21 (PoR via SMS-SUBMIT) because
|
||||||
|
// ELF data won't fit in the ENVELOPE response.
|
||||||
|
const isElf = (p1 === '20' || p1 === '10');
|
||||||
|
const spi2 = isElf ? '21' : '01';
|
||||||
|
let p2 = '00';
|
||||||
|
let guard = 0;
|
||||||
|
while (guard++ < 32) {
|
||||||
|
const apdu = '80F2' + p1 + p2 + '024F0000' + '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 || 'no data');
|
||||||
|
errors.push(label + ': ' + errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
|
||||||
|
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
|
||||||
|
// Defensive: 61XX means more data available (shouldn't happen with
|
||||||
|
// chained GET RESPONSE, but handle it if the card responds this way).
|
||||||
|
if (sw && sw.startsWith('61')) {
|
||||||
|
if (data) {
|
||||||
|
const parsed61 = parser(data);
|
||||||
|
if (parsed61.length) collector.push(...parsed61);
|
||||||
|
}
|
||||||
|
p2 = '01';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sw === '6F00') break;
|
||||||
|
if (!data) {
|
||||||
|
if (sw !== '9000') errors.push(label + ': (no data) — SW ' + sw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parsed = parser(data);
|
||||||
|
if (!parsed.length) break;
|
||||||
|
collector.push(...parsed);
|
||||||
|
if (sw === '9000') break;
|
||||||
|
p2 = '01';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ramShowProgress('GET DATA FF21 (memory)...');
|
||||||
|
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') {
|
||||||
|
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
|
||||||
|
if (!data) {
|
||||||
|
errors.push('Memory: (no data)');
|
||||||
|
} else {
|
||||||
|
const m = ramParseGetMemory(data);
|
||||||
|
if (m) Object.assign(mem, m);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data');
|
||||||
|
errors.push('Memory: ' + errorMsg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push('Memory: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules
|
||||||
|
// P1 per GP Card Spec v2.3.1 table 11-33:
|
||||||
|
// 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules
|
||||||
|
await paginate('80', isd, ramParseAppStatus, 'ISD');
|
||||||
|
await paginate('40', apps, ramParseAppStatus, 'Apps');
|
||||||
|
await paginate('20', elfs, ramParseElfStatus, 'ELFs');
|
||||||
|
await paginate('10', modules, ramParseElfStatus, 'ELF Modules');
|
||||||
|
|
||||||
|
ramMergeElfData(elfs, modules);
|
||||||
|
ramSaveCntr(cntr);
|
||||||
|
ramHideProgress();
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
resultEl.textContent = 'Partial — ' + errors.join('; ');
|
||||||
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
|
stepsEl.classList.remove('hidden');
|
||||||
|
stepsEl.textContent = errors.join('\n');
|
||||||
|
} else {
|
||||||
|
resultEl.textContent = 'OK — ' + isd.length + ' ISD, ' + apps.length + ' apps, ' + elfs.length + ' ELFs' + (mem.freeNV ? ', ' + mem.freeNV + ' free NV' : '');
|
||||||
|
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = ramRenderExploreHtml(mem, isd, apps, elfs);
|
||||||
|
explorerEl.innerHTML = html || '(no data)';
|
||||||
|
explorerEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ramDelete(sp) {
|
||||||
|
const aid = (document.getElementById('ram-del-aid').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
if (!aid) { alert('Enter AID to delete'); return; }
|
||||||
|
const related = document.getElementById('ram-del-related').checked;
|
||||||
|
const p2 = related ? '80' : '00';
|
||||||
|
const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
|
||||||
|
const apdu = '80E400' + p2 + (2 + aid.length / 2) + '4F' + aidLen + aid;
|
||||||
|
const res = await ramSendOta(apdu, sp);
|
||||||
|
const resultEl = document.getElementById('ram-result');
|
||||||
|
const stepsEl = document.getElementById('ram-steps');
|
||||||
|
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
||||||
|
resultEl.textContent = 'Failed: ' + (res.por ? res.por.response_status : res.error || res.sw);
|
||||||
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ramSaveCntr(ramIncrementCntr(sp.cntr));
|
||||||
|
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
|
||||||
|
stepsEl.classList.remove('hidden');
|
||||||
|
stepsEl.textContent = 'DELETE ' + aid + ' -> ' + sw;
|
||||||
|
resultEl.textContent = 'OK';
|
||||||
|
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ramInstallCap(sp) {
|
||||||
|
const fileInput = document.getElementById('ram-cap-file');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) { alert('Select a .cap file'); return; }
|
||||||
|
if (file.size > 48 * 1024) { alert('CAP file exceeds 48 kB limit'); return; }
|
||||||
|
|
||||||
|
ramShowProgress('Reading CAP file...');
|
||||||
|
const capHex = await ramReadFileHex(file);
|
||||||
|
ramShowProgress('Sending to server for install...');
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
cap_hex: capHex,
|
||||||
|
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
install_params: (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||||||
|
make_selectable: document.getElementById('ram-make-sel').checked,
|
||||||
|
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
|
||||||
|
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/ram-install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
ramHideProgress();
|
||||||
|
const resultEl = document.getElementById('ram-result');
|
||||||
|
const stepsEl = document.getElementById('ram-steps');
|
||||||
|
stepsEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
let txt = '';
|
||||||
|
(data.steps || []).forEach((s, idx) => {
|
||||||
|
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
|
||||||
|
txt += mark + ' Step ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
|
||||||
|
});
|
||||||
|
stepsEl.textContent = txt;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
ramSaveCntr(data.final_cntr);
|
||||||
|
resultEl.textContent = 'Install OK — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid;
|
||||||
|
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||||||
|
} else {
|
||||||
|
resultEl.textContent = 'Install FAILED at step: ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
|
||||||
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ramExecute() {
|
||||||
|
ramClearResults();
|
||||||
|
const op = document.getElementById('ram-op').value;
|
||||||
|
const sp = getRamSpParams();
|
||||||
|
if (!sp.kicKey || !sp.kidKey) {
|
||||||
|
alert('Select a card preset with keys first (RAM subtab → Card preset)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (op === 'explore') await ramExplore(sp);
|
||||||
|
else if (op === 'delete') await ramDelete(sp);
|
||||||
|
else if (op === 'install-cap') await ramInstallCap(sp);
|
||||||
|
} catch (e) {
|
||||||
|
const resultEl = document.getElementById('ram-result');
|
||||||
|
resultEl.textContent = 'Error: ' + e.message;
|
||||||
|
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||||||
|
ramHideProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== STK Menu Browser =====
|
// ===== STK Menu Browser =====
|
||||||
let stkMenuStack = [];
|
let stkMenuStack = [];
|
||||||
|
|
||||||
@@ -4400,6 +4945,7 @@ function cardsApply(idx) {
|
|||||||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||||||
document.getElementById('sp-kic-hex').value = c.kic;
|
document.getElementById('sp-kic-hex').value = c.kic;
|
||||||
document.getElementById('sp-kid-hex').value = c.kid;
|
document.getElementById('sp-kid-hex').value = c.kid;
|
||||||
|
document.getElementById('sp-tar').value = c.tar || 'B00001';
|
||||||
document.getElementById('sp-cntr').value = c.cntr;
|
document.getElementById('sp-cntr').value = c.cntr;
|
||||||
document.getElementById('sp-kic-key').value = c.kicKey;
|
document.getElementById('sp-kic-key').value = c.kicKey;
|
||||||
document.getElementById('sp-kid-key').value = c.kidKey;
|
document.getElementById('sp-kid-key').value = c.kidKey;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v24';
|
const CACHE = 'otaman-v29';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function setChecked(id, v) { el(id).checked = v; }
|
|||||||
|
|
||||||
function genRamResult() {
|
function genRamResult() {
|
||||||
genRam();
|
genRam();
|
||||||
return els['ram-result'].value;
|
return els['ram-apdu-result'].value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function genRamApdu() {
|
function genRamApdu() {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "1.9.12"
|
version = "1.9.17"
|
||||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
|||||||
from osmocom.tlv import BER_TLV_IE
|
from osmocom.tlv import BER_TLV_IE
|
||||||
|
|
||||||
|
|
||||||
VERSION = '1.9.12'
|
VERSION = '1.9.17'
|
||||||
|
|
||||||
|
|
||||||
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
||||||
@@ -344,6 +344,7 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
|
|||||||
|
|
||||||
# Try ExpandedRemoteResponse first (TS 102 226 §5.2.2)
|
# Try ExpandedRemoteResponse first (TS 102 226 §5.2.2)
|
||||||
if res.response_status == 'por_ok' and len(res['secured_data']):
|
if res.response_status == 'por_ok' and len(res['secured_data']):
|
||||||
|
expanded_response_data = ''
|
||||||
try:
|
try:
|
||||||
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
|
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
|
||||||
ExpandedRemoteResponse = Struct(
|
ExpandedRemoteResponse = Struct(
|
||||||
@@ -381,17 +382,20 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
|
|||||||
response_data['is_first'] = resp.chaining_context.is_first == 0x01
|
response_data['is_first'] = resp.chaining_context.is_first == 0x01
|
||||||
response_data['is_last'] = resp.chaining_context.is_last == 0x01
|
response_data['is_last'] = resp.chaining_context.is_last == 0x01
|
||||||
out['responses'].append(response_data)
|
out['responses'].append(response_data)
|
||||||
|
if expanded.response_count > 0 and expanded.responses[0].response_data:
|
||||||
|
expanded_response_data = b2h(expanded.responses[0].response_data).upper()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to CompactRemoteResp
|
pass
|
||||||
if dec is not None:
|
if dec is not None:
|
||||||
out['response_type'] = 'compact'
|
out['response_type'] = 'compact'
|
||||||
out['decoded'] = {
|
# Use compact parser's last_response_data; expanded parser gives wrong results for compact format
|
||||||
'number_of_commands': dec.number_of_commands,
|
out['decoded'] = {
|
||||||
'last_status_word': str(dec.last_status_word),
|
'number_of_commands': dec.number_of_commands,
|
||||||
'last_response_data': str(dec.last_response_data),
|
'last_status_word': str(dec.last_status_word),
|
||||||
}
|
'last_response_data': str(dec.last_response_data),
|
||||||
else:
|
}
|
||||||
out['response_type'] = 'none'
|
else:
|
||||||
|
out['response_type'] = 'none'
|
||||||
elif dec is not None:
|
elif dec is not None:
|
||||||
out['response_type'] = 'compact'
|
out['response_type'] = 'compact'
|
||||||
out['decoded'] = {
|
out['decoded'] = {
|
||||||
@@ -1017,7 +1021,7 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
|
|||||||
while sw.startswith('91'):
|
while sw.startswith('91'):
|
||||||
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
|
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
|
||||||
rv = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
|
rv = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len))
|
||||||
sys.stderr.write('FETCH(%s): %s -> %s\n' % (fetch_len, rv[0][:80] if rv[0] else '(none)', rv[1]))
|
sys.stderr.write('FETCH(%s): %s -> %s\n' % (fetch_len, rv[0] if rv[0] else '(none)', rv[1]))
|
||||||
fdata, sw = rv[0], rv[1]
|
fdata, sw = rv[0], rv[1]
|
||||||
raw = bytes.fromhex(fdata) if fdata else None
|
raw = bytes.fromhex(fdata) if fdata else None
|
||||||
action = None
|
action = None
|
||||||
@@ -1812,6 +1816,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
body = self._read_body()
|
body = self._read_body()
|
||||||
self._log_req(body)
|
self._log_req(body)
|
||||||
sp = body.get('sp', '')
|
sp = body.get('sp', '')
|
||||||
|
apdu = body.get('apdu', '')
|
||||||
scc = self.server.scc
|
scc = self.server.scc
|
||||||
if not scc:
|
if not scc:
|
||||||
self._send_json({'error': _err('reader_not_init', lang)}, 503)
|
self._send_json({'error': _err('reader_not_init', lang)}, 503)
|
||||||
@@ -1819,7 +1824,22 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
include_cpi = body.get('includeCpi', True)
|
include_cpi = body.get('includeCpi', True)
|
||||||
try:
|
try:
|
||||||
sp_bytes = bytes.fromhex(sp)
|
if apdu:
|
||||||
|
# RAM operation: SCP80-wrap the raw GP command
|
||||||
|
spi1 = body.get('spi1', '16')
|
||||||
|
spi2 = body.get('spi2', '01')
|
||||||
|
kic = body.get('kic', '25')
|
||||||
|
kid = body.get('kid', '25')
|
||||||
|
tar = body.get('tar', '000000')
|
||||||
|
cntr = body.get('cntr', '')
|
||||||
|
kic_key = body.get('kicKey', '')
|
||||||
|
kid_key = body.get('kidKey', '')
|
||||||
|
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu, kic_key, kid_key)
|
||||||
|
sp_bytes = bytes.fromhex(sp_hex)
|
||||||
|
else:
|
||||||
|
# Regular SCP80: use pre-built secured packet
|
||||||
|
sp_hex = sp
|
||||||
|
sp_bytes = bytes.fromhex(sp_hex)
|
||||||
spi2_val = int(body.get('spi2', '00'), 16)
|
spi2_val = int(body.get('spi2', '00'), 16)
|
||||||
por_in_submit = bool(spi2_val & 0x20)
|
por_in_submit = bool(spi2_val & 0x20)
|
||||||
submit_handler = None
|
submit_handler = None
|
||||||
@@ -1836,12 +1856,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
|
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
|
||||||
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
|
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
|
||||||
len(sp_bytes), total))
|
len(sp_bytes), total))
|
||||||
|
sys.stderr.write('RAM C-APDU: %s\n' % apdu if apdu else sp)
|
||||||
|
sys.stderr.write('RAM SECURED-PACKET: %s\n' % sp_hex)
|
||||||
last_data = None
|
last_data = None
|
||||||
last_sw = None
|
last_sw = None
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa,
|
tpdu = _build_sms_tpdu(chunk.hex(), total, i + 1, oa_number=self.server.sms_oa,
|
||||||
include_cpi=include_cpi) if total > 1 else _build_sms_tpdu(sp, oa_number=self.server.sms_oa,
|
include_cpi=include_cpi)
|
||||||
include_cpi=include_cpi)
|
|
||||||
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler)
|
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc, submit_handler=submit_handler)
|
||||||
last_data = data
|
last_data = data
|
||||||
last_sw = sw
|
last_sw = sw
|
||||||
@@ -1862,17 +1883,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
|
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
|
||||||
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
|
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
|
||||||
body.get('kidKey', ''), por_hex)
|
body.get('kidKey', ''), por_hex)
|
||||||
|
# Check for SPI2=0x21 (PoR required) but got 9000 with no PoR → card refuses PoR
|
||||||
|
is_ram = bool(apdu)
|
||||||
|
por_required = bool(spi2_val & 0x01)
|
||||||
|
no_por_received = not por_hex and not (submit_handler and submit_handler.submit_tpdu_hex)
|
||||||
|
if is_ram and por_required and last_sw == '9000' and no_por_received:
|
||||||
|
sys.stderr.write('WARNING: Card refused to return PoR - ENVELOPE returned 9000 with no response data\n')
|
||||||
|
sys.stderr.write('RAM RESPONSE-PACKET: %s\n' % (por_hex if por_hex else 'empty'))
|
||||||
if por:
|
if por:
|
||||||
resp['por'] = por
|
resp['por'] = por
|
||||||
extra = ''
|
extra = ''
|
||||||
if por.get('decoded'):
|
if por.get('decoded'):
|
||||||
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
|
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
|
||||||
por['decoded'].get('last_status_word', '?'))
|
por['decoded'].get('last_status_word', '?'))
|
||||||
|
sys.stderr.write('RAM R-APDU: %s\n' % por['decoded'].get('last_response_data', ''))
|
||||||
sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
|
sys.stderr.write('OTA PoR[%s]: status=%s TAR=%s CNTR=%s PCNTR=%s RPL=%s RHL=%s%s\n' % (
|
||||||
por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
|
por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
|
||||||
por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
|
por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
|
||||||
elif por_hex:
|
elif por_hex:
|
||||||
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)[:64]))
|
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
|
||||||
else:
|
else:
|
||||||
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
|
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user