scp81: queue explicit commands, expanded-format output for the RAM chain (v2.1.14)
- POST /api/scp81/queue takes an explicit APDU list (or single APDU) and queues it as the SCP81 script; entries that already are Command Scripting templates (AA.../AE80..., the expanded format) are sent verbatim instead of being wrapped again - Remote APDU -> RAM chain: "To expanded" builds each command in the TS 102 226 expanded form (AA definite / AE80 indefinite selector) and "Queue in SCP81" queues the built commands for the next card POST, so the full-featured RAM/INSTALL [for install] form (AIDs, privileges, TK/STK parameters) can drive the HTTP OTA install - RU strings; api.md; service worker v151
This commit is contained in:
@@ -496,6 +496,15 @@ Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS
|
|||||||
handshake and HTTP request/response records). `?after=<seq>` returns only
|
handshake and HTTP request/response records). `?after=<seq>` returns only
|
||||||
newer entries; `seq` echoes the latest sequence number.
|
newer entries; `seq` echoes the latest sequence number.
|
||||||
|
|
||||||
|
### `POST /api/scp81/queue`
|
||||||
|
|
||||||
|
Queue explicit commands as the SCP81 script (used by the Remote APDU tab's
|
||||||
|
RAM chain "Queue in SCP81"). Body `{"apdus": ["80E60C002E...", ...]}` (or a
|
||||||
|
single `apdu`), optional `kind` and `force`. Entries that already are
|
||||||
|
Command Scripting templates (`AA...`/`AE80...`, the expanded format) are
|
||||||
|
sent verbatim instead of being wrapped again. Refused while a script is
|
||||||
|
mid-run unless forced.
|
||||||
|
|
||||||
### `POST /api/scp81/ram-install`
|
### `POST /api/scp81/ram-install`
|
||||||
|
|
||||||
Queue a RAM (GP) install as the SCP81 command script. The `.cap` is parsed
|
Queue a RAM (GP) install as the SCP81 command script. The `.cap` is parsed
|
||||||
|
|||||||
+62
-1
@@ -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">v2.1.13</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">v2.1.14</span></h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
|
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
|
||||||
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting...">●</span>
|
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting...">●</span>
|
||||||
@@ -140,6 +140,17 @@
|
|||||||
<button onclick="chainAddRow('chain-ram','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
|
<button onclick="chainAddRow('chain-ram','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="chain-ram-preview" 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" placeholder="Chain preview — add commands above"></textarea>
|
<textarea id="chain-ram-preview" 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" placeholder="Chain preview — add commands above"></textarea>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<label class="text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Expanded format">Expanded format</label>
|
||||||
|
<select id="chain-ram-expand-fmt" onchange="ramBuildExpanded()" class="border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
|
||||||
|
<option value="AE80">AE80 (indefinite)</option>
|
||||||
|
<option value="AA">AA (definite)</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="ramBuildExpanded()" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="To expanded">To expanded</button>
|
||||||
|
<button data-needs="server" onclick="ramQueueScp81()" class="text-xs px-2 py-1 bg-violet-600 text-white rounded hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Queue in SCP81">Queue in SCP81</button>
|
||||||
|
<span id="chain-ram-queue-msg" class="text-xs"></span>
|
||||||
|
</div>
|
||||||
|
<textarea id="chain-ram-expanded" 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 mb-2" placeholder="Expanded script forms (AA / AE80)"></textarea>
|
||||||
<button onclick="packToSp('chain-ram-preview')" id="chain-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('chain-ram-preview')" id="chain-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">
|
||||||
@@ -2521,6 +2532,52 @@ function genConvert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Secured Packet UI =====
|
// ===== Secured Packet UI =====
|
||||||
|
function chainRamApduList() {
|
||||||
|
// One hex APDU per chain row (the same rows the preview concatenates).
|
||||||
|
const list = [];
|
||||||
|
chainInit('chain-ram');
|
||||||
|
const rows = _chains['chain-ram'].rows;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const h = chainBuildRowHex('chain-ram', i);
|
||||||
|
if (h) list.push(h);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramBuildExpanded() {
|
||||||
|
// Show each built command in the TS 102 226 Command Scripting (expanded)
|
||||||
|
// form: AA <len> 22 <len> <apdu> or AE80 22 <len> <apdu> 0000.
|
||||||
|
const fmt = (document.getElementById('chain-ram-expand-fmt').value || 'AE80');
|
||||||
|
const out = chainRamApduList().map(apdu => {
|
||||||
|
if (fmt === 'AA') {
|
||||||
|
const tlv = '22' + berLenStr(apdu.length / 2) + apdu;
|
||||||
|
return 'AA' + berLenStr(tlv.length / 2) + tlv;
|
||||||
|
}
|
||||||
|
return 'AE8022' + berLenStr(apdu.length / 2) + apdu + '0000';
|
||||||
|
});
|
||||||
|
document.getElementById('chain-ram-expanded').value = out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ramQueueScp81() {
|
||||||
|
const msg = document.getElementById('chain-ram-queue-msg');
|
||||||
|
const apdus = chainRamApduList();
|
||||||
|
if (!apdus.length) { msg.textContent = t('No commands'); msg.className = 'text-xs text-red-500'; return; }
|
||||||
|
msg.textContent = '';
|
||||||
|
try {
|
||||||
|
const resp = await pysimFetch('/api/scp81/queue', { apdus: apdus, kind: 'custom' });
|
||||||
|
if (resp.ok) {
|
||||||
|
msg.textContent = t('Queued') + ': ' + resp.apdus + ' ' + t('APDUs') + ' - ' + t('send a push to run it');
|
||||||
|
msg.className = 'text-xs text-emerald-600 dark:text-emerald-400';
|
||||||
|
} else {
|
||||||
|
msg.textContent = resp.error || t('Error');
|
||||||
|
msg.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = String(err.message || err);
|
||||||
|
msg.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function packToSp(sourceId) {
|
function packToSp(sourceId) {
|
||||||
const val = document.getElementById(sourceId).value;
|
const val = document.getElementById(sourceId).value;
|
||||||
if (!val) return;
|
if (!val) return;
|
||||||
@@ -9907,6 +9964,10 @@ const LANG_RU = {
|
|||||||
'Make selectable': 'Сделать выбираемым',
|
'Make selectable': 'Сделать выбираемым',
|
||||||
'Queue CAP install': 'Поставить установку CAP в очередь',
|
'Queue CAP install': 'Поставить установку CAP в очередь',
|
||||||
'Queued': 'В очереди',
|
'Queued': 'В очереди',
|
||||||
|
'Expanded format': 'Развёрнутый формат',
|
||||||
|
'To expanded': 'В развёрнутый',
|
||||||
|
'Queue in SCP81': 'В очередь SCP81',
|
||||||
|
'No commands': 'Нет команд',
|
||||||
'APDUs': 'APDU',
|
'APDUs': 'APDU',
|
||||||
'INSTALL / LOAD / INSTALL': 'INSTALL / LOAD / INSTALL',
|
'INSTALL / LOAD / INSTALL': 'INSTALL / LOAD / INSTALL',
|
||||||
'send a push to run it': 'отправьте push для запуска',
|
'send a push to run it': 'отправьте push для запуска',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v150';
|
const CACHE = 'otaman-v151';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "2.1.13"
|
version = "2.1.14"
|
||||||
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.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
|||||||
from osmocom.tlv import BER_TLV_IE
|
from osmocom.tlv import BER_TLV_IE
|
||||||
|
|
||||||
|
|
||||||
VERSION = '2.1.13'
|
VERSION = '2.1.14'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -1611,9 +1611,15 @@ def _scp81_script_responder(method, target, headers, body):
|
|||||||
next_uri = template % _SCP81_SCRIPT_SENT if '%d' in template else template
|
next_uri = template % _SCP81_SCRIPT_SENT if '%d' in template else template
|
||||||
if next_uri:
|
if next_uri:
|
||||||
headers['X-Admin-Next-URI'] = next_uri
|
headers['X-Admin-Next-URI'] = next_uri
|
||||||
body_out = _scp81_command_body(
|
u = apdu.upper()
|
||||||
apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'),
|
if u.startswith('AA') or u.startswith('AE80'):
|
||||||
cr_tag=_SCP81_SCRIPT_CR_TAG)
|
# Already a Command Scripting template (expanded format): send it
|
||||||
|
# verbatim instead of wrapping it again.
|
||||||
|
body_out = bytes.fromhex(u)
|
||||||
|
else:
|
||||||
|
body_out = _scp81_command_body(
|
||||||
|
apdu, definite=(_SCP81_SCRIPT_TEMPLATE == 'definite'),
|
||||||
|
cr_tag=_SCP81_SCRIPT_CR_TAG)
|
||||||
if _SCP81_APACHE_HEADERS:
|
if _SCP81_APACHE_HEADERS:
|
||||||
if _SCP81_CHUNKED:
|
if _SCP81_CHUNKED:
|
||||||
headers['Transfer-Encoding'] = 'chunked'
|
headers['Transfer-Encoding'] = 'chunked'
|
||||||
@@ -3568,6 +3574,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
resp = {'ok': False, 'error': str(e)}
|
resp = {'ok': False, 'error': str(e)}
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
|
elif self.path == '/api/scp81/queue':
|
||||||
|
body = self._read_body()
|
||||||
|
self._log_req(body)
|
||||||
|
apdus = body.get('apdus') or ([body.get('apdu')] if body.get('apdu') else [])
|
||||||
|
apdus = [a for a in apdus if a]
|
||||||
|
if not apdus:
|
||||||
|
resp = {'ok': False, 'error': 'no apdus given'}
|
||||||
|
elif _SCP81_LISTENER is None:
|
||||||
|
resp = {'ok': False, 'error': 'SCP81 listener is not running'}
|
||||||
|
else:
|
||||||
|
queued = _scp81_queue_script(
|
||||||
|
apdus, kind=body.get('kind') or 'custom',
|
||||||
|
force=bool(body.get('force', False)))
|
||||||
|
resp = dict(queued, ok=bool(queued.get('queued')))
|
||||||
|
if queued.get('queued'):
|
||||||
|
resp['note'] = ('queued as the SCP81 command script; '
|
||||||
|
'runs on the card next POST (push/trigger)')
|
||||||
|
self._send_json(resp)
|
||||||
|
self._log_resp(resp)
|
||||||
elif self.path == '/api/scp81/ram-install':
|
elif self.path == '/api/scp81/ram-install':
|
||||||
body = self._read_body()
|
body = self._read_body()
|
||||||
self._log_req(body)
|
self._log_req(body)
|
||||||
|
|||||||
@@ -813,3 +813,32 @@ class ScriptBodyLengthTest(unittest.TestCase):
|
|||||||
body = _scp81_command_body(apdu, definite=True)
|
body = _scp81_command_body(apdu, definite=True)
|
||||||
# AA 81 85 22 81 82 <130 bytes> (outer 1+2+130 = 133 = 0x85)
|
# AA 81 85 22 81 82 <130 bytes> (outer 1+2+130 = 133 = 0x85)
|
||||||
self.assertEqual(body[:6].hex().upper(), 'AA8185228182')
|
self.assertEqual(body[:6].hex().upper(), 'AA8185228182')
|
||||||
|
|
||||||
|
|
||||||
|
class VerbatimScriptTest(unittest.TestCase):
|
||||||
|
def test_expanded_templates_sent_verbatim(self):
|
||||||
|
server._SCP81_SCRIPT = ['AA0B2208 80CAFF2100'.replace(' ', '')]
|
||||||
|
server._SCP81_SCRIPT_SENT = 0
|
||||||
|
server._SCP81_SCRIPT_RESULTS = []
|
||||||
|
server._SCP81_SCRIPT_INSERTED = []
|
||||||
|
server._SCP81_PAGES = 0
|
||||||
|
try:
|
||||||
|
status, headers, body = server._scp81_script_responder(
|
||||||
|
'POST', '/api/scp81', {}, b'')
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
# Sent as-is (no AE80/22 wrapper added)
|
||||||
|
self.assertEqual(body.hex().upper(), 'AA0B220880CAFF2100')
|
||||||
|
finally:
|
||||||
|
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||||
|
server._SCP81_SCRIPT_SENT = 0
|
||||||
|
|
||||||
|
def test_plain_apdu_still_wrapped(self):
|
||||||
|
server._SCP81_SCRIPT = ['80CAFF2100']
|
||||||
|
server._SCP81_SCRIPT_SENT = 0
|
||||||
|
try:
|
||||||
|
status, headers, body = server._scp81_script_responder(
|
||||||
|
'POST', '/api/scp81', {}, b'')
|
||||||
|
self.assertEqual(body.hex().upper(), 'AE80220580CAFF21000000')
|
||||||
|
finally:
|
||||||
|
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
|
||||||
|
server._SCP81_SCRIPT_SENT = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user