@@ -1139,8 +1195,10 @@ function scp80SwitchSubtab(name) {
});
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
+ document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram');
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);
}
@@ -1681,7 +1739,7 @@ function genRam() {
const ssMode = parseInt(document.getElementById('ram-ss-mode').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 = {
'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 = '
';
+ 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
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): 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 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 += '';
+ html += '
Memory (GET DATA FF21)
';
+ html += '
Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '
';
+ html += '
Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '
';
+ html += '
Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '
';
+ html += '
';
+ }
+ if (isd && isd.length) {
+ html += '';
+ html += '
ISD
';
+ isd.forEach(o => {
+ html += '
';
+ html += '
AID: ' + (o.aid || '?') + '
';
+ html += '
Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '
';
+ if (o.privileges) html += '
Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
';
+ if (o.sdAid) html += '
SD AID: ' + o.sdAid + '
';
+ html += '
';
+ });
+ html += '
';
+ }
+ if (apps && apps.length) {
+ html += '';
+ html += '
Applications
';
+ apps.forEach(o => {
+ html += '
';
+ html += '
AID: ' + (o.aid || '?') + '
';
+ html += '
Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '
';
+ if (o.privileges) html += '
Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')
';
+ if (o.implicitSel) html += '
Implicit sel: ' + o.implicitSel + '
';
+ if (o.elfAid) html += '
ELF AID: ' + o.elfAid + '
';
+ if (o.sdAid) html += '
SD AID: ' + o.sdAid + '
';
+ html += '
';
+ });
+ html += '
';
+ }
+ if (elfs && elfs.length) {
+ html += '';
+ html += '
Executable Load Files
';
+ elfs.forEach(o => {
+ html += '
';
+ html += '
AID: ' + (o.aid || '?') + '
';
+ html += '
Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '
';
+ if (o.version) html += '
Version: ' + o.version + '
';
+ if (o.moduleAids && o.moduleAids.length) {
+ html += '
Module AIDs:
';
+ o.moduleAids.forEach(m => { html += '
- ' + m + '
'; });
+ }
+ if (o.sdAid) html += '
SD AID: ' + o.sdAid + '
';
+ html += '
';
+ });
+ html += '
';
+ }
+ 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 =====
let stkMenuStack = [];
@@ -4400,6 +4945,7 @@ function cardsApply(idx) {
document.getElementById('sp-spi2-hex').value = c.spi2;
document.getElementById('sp-kic-hex').value = c.kic;
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-kic-key').value = c.kicKey;
document.getElementById('sp-kid-key').value = c.kidKey;
diff --git a/frontend/sw.js b/frontend/sw.js
index 6a3d724..5da1066 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v24';
+const CACHE = 'otaman-v29';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 4bd32a3..ac204a3 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -59,7 +59,7 @@ function setChecked(id, v) { el(id).checked = v; }
function genRamResult() {
genRam();
- return els['ram-result'].value;
+ return els['ram-apdu-result'].value;
}
function genRamApdu() {
diff --git a/pyproject.toml b/pyproject.toml
index edc8d95..7ead484 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "1.9.12"
+version = "1.9.17"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index b8f7340..ae5d8c9 100644
--- a/pysim_otaman_server/server.py
+++ b/pysim_otaman_server/server.py
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
-VERSION = '1.9.12'
+VERSION = '1.9.17'
# Static file serving (the PWA lives in /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)
if res.response_status == 'por_ok' and len(res['secured_data']):
+ expanded_response_data = ''
try:
from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this
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_last'] = resp.chaining_context.is_last == 0x01
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:
- # Fallback to CompactRemoteResp
- if dec is not None:
- out['response_type'] = 'compact'
- out['decoded'] = {
- 'number_of_commands': dec.number_of_commands,
- 'last_status_word': str(dec.last_status_word),
- 'last_response_data': str(dec.last_response_data),
- }
- else:
- out['response_type'] = 'none'
+ pass
+ if dec is not None:
+ out['response_type'] = 'compact'
+ # Use compact parser's last_response_data; expanded parser gives wrong results for compact format
+ out['decoded'] = {
+ 'number_of_commands': dec.number_of_commands,
+ 'last_status_word': str(dec.last_status_word),
+ 'last_response_data': str(dec.last_response_data),
+ }
+ else:
+ out['response_type'] = 'none'
elif dec is not None:
out['response_type'] = 'compact'
out['decoded'] = {
@@ -1017,7 +1021,7 @@ def _handle_proactive_chain(scc, sw91, on_fetch=None):
while sw.startswith('91'):
fetch_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
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]
raw = bytes.fromhex(fdata) if fdata else None
action = None
@@ -1812,6 +1816,7 @@ class PysimHandler(BaseHTTPRequestHandler):
body = self._read_body()
self._log_req(body)
sp = body.get('sp', '')
+ apdu = body.get('apdu', '')
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
@@ -1819,7 +1824,22 @@ class PysimHandler(BaseHTTPRequestHandler):
return
include_cpi = body.get('includeCpi', True)
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)
por_in_submit = bool(spi2_val & 0x20)
submit_handler = None
@@ -1836,12 +1856,13 @@ class PysimHandler(BaseHTTPRequestHandler):
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
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_sw = None
for i, chunk in enumerate(chunks):
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)
last_data = data
last_sw = sw
@@ -1862,17 +1883,25 @@ class PysimHandler(BaseHTTPRequestHandler):
por = _decode_por(body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
body.get('kid', ''), body.get('cntr', ''), body.get('kicKey', ''),
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:
resp['por'] = por
extra = ''
if por.get('decoded'):
extra = ' (compact: %s cmd, last SW %s)' % (por['decoded'].get('number_of_commands', '?'),
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' % (
por_src, por.get('response_status'), por.get('tar'), por.get('cntr'),
por.get('pcntr'), por.get('rpl'), por.get('rhl'), extra))
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:
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
finally: