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:
2026-08-30 14:13:13 +03:00
parent 32985a2951
commit 54dfb2f6b6
6 changed files with 654 additions and 24 deletions
+55
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v24'; const CACHE = 'otaman-v29';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+1 -1
View File
@@ -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
View File
@@ -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.
+45 -16
View File
@@ -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: