Compare commits
3 Commits
7327a879ce
...
33255dc35e
| Author | SHA1 | Date | |
|---|---|---|---|
| 33255dc35e | |||
| 73f46e99c4 | |||
| a36f26e8e1 |
+1
-1
@@ -54,7 +54,7 @@ Returns server version for compatibility checking.
|
||||
|
||||
**Example response:**
|
||||
```json
|
||||
{"version": "1.9.3"}
|
||||
{"version": "1.9.5"}
|
||||
```
|
||||
|
||||
### `GET /api/status`
|
||||
|
||||
+35
-4
@@ -18,7 +18,7 @@
|
||||
<div class="max-w-7xl mx-auto px-6 py-2">
|
||||
|
||||
<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.3</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.5</span></h1>
|
||||
<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>
|
||||
<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>
|
||||
@@ -812,7 +812,9 @@
|
||||
<button onclick="genSp()" 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 Secure Packet">Generate Secure Packet</button>
|
||||
<button onclick="pysimVerifySp()" id="sp-verify-btn" class="hidden mb-3 px-5 py-2.5 bg-slate-600 text-white text-sm font-medium rounded hover:bg-slate-700" data-l10n="Verify vs pySim">Verify vs pySim</button>
|
||||
<button onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700" data-l10n="Send to Card">Send to Card</button>
|
||||
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
|
||||
<textarea id="sp-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>
|
||||
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
|
||||
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
|
||||
</div>
|
||||
|
||||
@@ -2068,7 +2070,7 @@ function genSp() {
|
||||
const kicHex = document.getElementById('sp-kic-hex').value;
|
||||
const kidHex = document.getElementById('sp-kid-hex').value;
|
||||
const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').padEnd(6, '0').slice(0, 6);
|
||||
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padEnd(10, '0').slice(0, 10);
|
||||
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10);
|
||||
const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||||
const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||||
const padByte = parseInt(document.getElementById('sp-padding').value, 16);
|
||||
@@ -2179,7 +2181,9 @@ function genSp() {
|
||||
packet.set(encrypted, 10);
|
||||
}
|
||||
|
||||
resultEl.value = bytesToHex(packet);
|
||||
// pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when
|
||||
// ciphering is applied; unciphered packets start at CHL (v1.9.5).
|
||||
resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2));
|
||||
}
|
||||
|
||||
// ===== BER-TLV =====
|
||||
@@ -4034,6 +4038,10 @@ async function pysimSendOta() {
|
||||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||||
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
|
||||
const statusEl = document.getElementById('pysim-status');
|
||||
const sendResultEl = document.getElementById('sp-send-result');
|
||||
const porStatusEl = document.getElementById('sp-por-status');
|
||||
sendResultEl.classList.add('hidden');
|
||||
porStatusEl.classList.add('hidden');
|
||||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Sending OTA...') + '</span>';
|
||||
try {
|
||||
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
||||
@@ -4047,13 +4055,36 @@ async function pysimSendOta() {
|
||||
const por = data.por;
|
||||
if (por && por.response_status) {
|
||||
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
|
||||
if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr;
|
||||
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
|
||||
const okPor = por.response_status === 'por_ok';
|
||||
porStatusEl.textContent = 'PoR: ' + por.response_status;
|
||||
porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600');
|
||||
porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600');
|
||||
}
|
||||
sendResultEl.textContent = msg;
|
||||
if (por && por.raw) {
|
||||
const rawLine = document.createElement('div');
|
||||
rawLine.className = 'break-all';
|
||||
rawLine.textContent = 'raw PoR: ' + por.raw;
|
||||
sendResultEl.appendChild(rawLine);
|
||||
}
|
||||
sendResultEl.classList.remove('hidden', 'text-red-600');
|
||||
sendResultEl.classList.add('text-green-600');
|
||||
statusEl.innerHTML = '<span class="text-green-600">' + esc(msg) + '</span>';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="text-red-500">OTA failed: ' + (data.error || 'SW: ' + data.sw) + '</span>';
|
||||
let msg = 'OTA failed';
|
||||
if (data.error) msg += ': ' + data.error;
|
||||
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
|
||||
sendResultEl.textContent = msg;
|
||||
sendResultEl.classList.remove('hidden', 'text-green-600');
|
||||
sendResultEl.classList.add('text-red-600');
|
||||
statusEl.innerHTML = '<span class="text-red-500">' + esc(msg) + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
sendResultEl.textContent = 'Error: ' + e.message;
|
||||
sendResultEl.classList.remove('hidden', 'text-green-600');
|
||||
sendResultEl.classList.add('text-red-600');
|
||||
statusEl.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v14';
|
||||
const CACHE = 'otaman-v16';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -93,13 +93,13 @@ test('ciphered + CC SPI 16/01 (counter_must_be_higher, plaintext PoR)', () => {
|
||||
test('unciphered + CC SPI 02/09', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }),
|
||||
'001D1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
'1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
});
|
||||
|
||||
test('unciphered packet uses CPL = octets from CHL to end (0x001d)', () => {
|
||||
test('unciphered packet starts at CHL, no CPL prefix (pySim parity)', () => {
|
||||
const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
|
||||
assert.strictEqual(out.slice(0, 4), '001D');
|
||||
assert.strictEqual(out.length, 62);
|
||||
assert.strictEqual(out.slice(0, 2), '15');
|
||||
assert.strictEqual(out.length, 58);
|
||||
});
|
||||
|
||||
test('sysmocom public reference vector (spi1 04 / spi2 19, cntr=0)', () => {
|
||||
@@ -169,7 +169,7 @@ test('AES unciphered + CC SPI 12/09 (counter higher)', () => {
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
}),
|
||||
'001D1512092222B0001100000000110029826122C7A0B79500A40004023F00');
|
||||
'1512092222B0001100000000110029826122C7A0B79500A40004023F00');
|
||||
});
|
||||
|
||||
test('AES ciphered + CC SPI 1E/19 (counter +1)', () => {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "1.9.3"
|
||||
version = "1.9.5"
|
||||
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.
|
||||
|
||||
@@ -18,7 +18,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
||||
from osmocom.tlv import BER_TLV_IE
|
||||
|
||||
|
||||
VERSION = '1.9.3'
|
||||
VERSION = '1.9.5'
|
||||
|
||||
|
||||
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
||||
@@ -334,7 +334,12 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon
|
||||
out = {
|
||||
'response_status': str(res['response_status']),
|
||||
'tar': res['tar'].hex().upper(),
|
||||
'cntr': res['cntr'].hex().upper(),
|
||||
'pcntr': res['pcntr'],
|
||||
'rpl': res['rpl'],
|
||||
'rhl': res['rhl'],
|
||||
'cc_rc': res['cc_rc'].hex(),
|
||||
'raw': response_hex,
|
||||
}
|
||||
|
||||
# Try ExpandedRemoteResponse first (TS 102 226 §5.2.2)
|
||||
@@ -1825,6 +1830,10 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
max_chunk = 130
|
||||
chunks = [sp_bytes[i:i+max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
|
||||
total = len(chunks)
|
||||
sys.stderr.write('OTA SEND: SPI %s %s KIc %s KID %s TAR %s CNTR %s LEN %dB CHUNKS %d\n' % (
|
||||
body.get('spi1', ''), body.get('spi2', ''), body.get('kic', ''),
|
||||
body.get('kid', ''), body.get('tar', ''), body.get('cntr', ''),
|
||||
len(sp_bytes), total))
|
||||
last_data = None
|
||||
last_sw = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
@@ -1836,20 +1845,34 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
last_sw = sw
|
||||
if sw != '9000' and not sw.startswith('91'):
|
||||
resp = {'success': False, 'sw': sw, 'error': 'ENVELOPE failed at chunk %d' % (i + 1)}
|
||||
sys.stderr.write('OTA SEND FAILED: chunk %d SW %s\n' % (i + 1, sw))
|
||||
break
|
||||
else:
|
||||
resp = {'success': True, 'sw': last_sw, 'response_data': last_data if last_data else None}
|
||||
por_src = 'envelope'
|
||||
por_hex = resp['response_data']
|
||||
if submit_handler and submit_handler.submit_tpdu_hex:
|
||||
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
|
||||
idx = tpdu_b.find(b'\x02\x71\x00')
|
||||
if idx >= 0:
|
||||
por_hex = tpdu_b[idx:].hex()
|
||||
por_src = 'sms-submit'
|
||||
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)
|
||||
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('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]))
|
||||
else:
|
||||
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
|
||||
finally:
|
||||
if submit_handler and hasattr(scc, '_tp'):
|
||||
scc._tp.proactive_handler = old_proactive
|
||||
|
||||
@@ -34,4 +34,4 @@ fi
|
||||
|
||||
echo "Starting pysim-otaman-server on http://127.0.0.1:8080"
|
||||
echo "Press Ctrl+C to stop."
|
||||
$SERVER --http-port 8080 $READER_ARGS
|
||||
$SERVER --http-port 8080 $READER_ARGS --log-requests --apdu-trace
|
||||
|
||||
@@ -202,6 +202,30 @@ class TestDecodePor(unittest.TestCase):
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '612f')
|
||||
|
||||
def test_complete_field_report(self):
|
||||
"""All parsed PoR fields are surfaced verbatim (v1.9.4)."""
|
||||
raw = '027100000e0ab000110000000000000001612f'
|
||||
r = _decode_por('06', '01', '35', '35', '0000000001', KIC3, KID3, raw)
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['tar'], 'B00011')
|
||||
self.assertEqual(r['cntr'], '0000000000')
|
||||
self.assertEqual(r['pcntr'], 0)
|
||||
self.assertEqual(r['rpl'], 14)
|
||||
self.assertEqual(r['rhl'], 10)
|
||||
self.assertEqual(r['cc_rc'], '')
|
||||
self.assertEqual(r['raw'], raw)
|
||||
self.assertNotIn('cntr_low', str(r))
|
||||
|
||||
def test_cntr_low_fields(self):
|
||||
r = _decode_por('02', '01', '15', '15', '0000000001', K, K,
|
||||
'027100000b0ab0000000000000070002')
|
||||
self.assertEqual(r['response_status'], 'cntr_low')
|
||||
self.assertEqual(r['tar'], 'B00000')
|
||||
self.assertEqual(r['cntr'], '0000000007')
|
||||
self.assertEqual(r['rpl'], 11)
|
||||
self.assertEqual(r['rhl'], 10)
|
||||
self.assertIsNone(r.get('decoded'))
|
||||
|
||||
def test_sysmocom_bad_cc_returns_none(self):
|
||||
r = _decode_por('06', '09', '35', '35', '0000000001', KIC3, KID3,
|
||||
'027100001612b000110000000000000055f47118381175fb02612f')
|
||||
|
||||
Reference in New Issue
Block a user