v1.3.0: TS 03.48/TS 102 225 genSp — CBC-MAC (ISO 9797-1 alg 2) KID, spec-conformant SP layout, PoR display, verify vs pySim

- replace retailMac() with cbcMac() (zero IV, '00' padding, last 8 bytes) — matches pySim/TS 03.48
- genSp: CPL/CHL/SPI/KIc/KID/TAR/CNTR/PCNTR/CC layout per TS 102 225 + TS 31.115; drop bogus CPI/CHI bytes
- ciphering covers CNTR..end; padding only when ciphering; CC over all but the CC field
- byte-identical output to pySim OtaDialectSms.encode_cmd (verified live vs card)
- Send to Card now reports PoR status/TAR/last SW; new 'Verify vs pySim' button
This commit is contained in:
2026-08-08 13:06:09 +03:00
parent 2d3618e9c5
commit c269b6521b
+95 -48
View File
@@ -16,7 +16,7 @@
<div class="max-w-7xl mx-auto px-6 py-4"> <div class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<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.2.2</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.3.0</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>
@@ -743,8 +743,10 @@
</div> </div>
<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="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()" class="mb-3 px-5 py-2.5 bg-slate-600 text-white text-sm font-medium rounded hover:bg-slate-700">Verify vs pySim</button>
<button onclick="pysimSendOta()" class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700">Send to Card</button> <button onclick="pysimSendOta()" class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700">Send to Card</button>
<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-2.5 bg-gray-100 dark:bg-slate-800"></textarea> <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-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
</div> </div>
<div id="tab-response" class="tab-content hidden"> <div id="tab-response" class="tab-content hidden">
@@ -1679,30 +1681,25 @@ function zeroPad(data, blockSize) {
return out; return out;
} }
function retailMac(data, keyBytes) { // TS 03.48 / TS 102 225 KID CC: DES/3DES in CBC mode, zero IV, '00' padding.
// The MAC is the last 8 octets of the CBC encryption of the input.
function cbcMac(data, keyBytes) {
const bs = 8; const bs = 8;
const padded = zeroPad(data, bs); const padded = zeroPad(data, bs);
const [k1, k2, k3] = des3Keys(keyBytes);
let prev = new Uint8Array(bs); let prev = new Uint8Array(bs);
for (let i = 0; i < padded.length; i += bs) { for (let i = 0; i < padded.length; i += bs) {
const block = padded.subarray(i, i + bs); const block = padded.subarray(i, i + bs);
const xored = xorBytes(block, prev); const xored = xorBytes(block, prev);
let enc; prev = des3EncryptBlock(xored, keyBytes);
if (keyBytes.length === 8) {
enc = desEncryptBlock(xored, k1);
} else {
enc = des3EncryptBlock(xored, keyBytes);
}
if (i + bs === padded.length && keyBytes.length >= 16) {
enc = desDecryptBlock(enc, k2);
enc = desEncryptBlock(enc, k1);
}
prev = enc;
} }
return prev; return prev;
} }
// ===== Secure Packet assembly ===== // ===== Secure Packet assembly =====
// TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP):
// CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding)
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() { function genSp() {
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, ''); const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; } if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -1745,48 +1742,51 @@ function genSp() {
const apdu = hexToBytes(apduHex); const apdu = hexToBytes(apduHex);
const macLen = hasMac ? 8 : 0; const macLen = hasMac ? 8 : 0;
const pCntr = (8 - (apdu.length % 8)) % 8; // padding aligns CNTR+PCNTR+RC/CC/DS+data to the DES block size (ciphering only)
const pCntrByte = pCntr; const padCnt = ciphering ? (8 - ((6 + macLen + apdu.length) % 8)) % 8 : 0;
const spiOff = 4; const chl = 13 + macLen;
const kicOff = 6; const cpl = 14 + macLen + apdu.length + padCnt;
const secDataOff = 17 + macLen;
const packetLen = secDataOff + apdu.length + pCntr; const packetLen = 3 + chl + apdu.length + padCnt;
const packet = new Uint8Array(packetLen); const packet = new Uint8Array(packetLen);
packet[0] = 0x02; packet[0] = (cpl >> 8) & 0xFF;
packet[2] = 0x01; packet[1] = cpl & 0xFF;
packet[4] = spi1; packet[2] = chl;
packet[5] = spi2; packet[3] = spi1;
packet[6] = parseInt(kicHex, 16); packet[4] = spi2;
packet[7] = parseInt(kidHex, 16); packet[5] = parseInt(kicHex, 16);
packet[8] = parseInt(tarHex.substr(0,2), 16); packet[6] = parseInt(kidHex, 16);
packet[9] = parseInt(tarHex.substr(2,2), 16); packet[7] = parseInt(tarHex.substr(0,2), 16);
packet[10] = parseInt(tarHex.substr(4,2), 16); packet[8] = parseInt(tarHex.substr(2,2), 16);
packet[11] = parseInt(cntrHex.substr(0,2), 16); packet[9] = parseInt(tarHex.substr(4,2), 16);
packet[12] = parseInt(cntrHex.substr(2,2), 16); packet[10] = parseInt(cntrHex.substr(0,2), 16);
packet[13] = parseInt(cntrHex.substr(4,2), 16); packet[11] = parseInt(cntrHex.substr(2,2), 16);
packet[14] = parseInt(cntrHex.substr(6,2), 16); packet[12] = parseInt(cntrHex.substr(4,2), 16);
packet[15] = parseInt(cntrHex.substr(8,2), 16); packet[13] = parseInt(cntrHex.substr(6,2), 16);
packet[16] = pCntrByte; packet[14] = parseInt(cntrHex.substr(8,2), 16);
for (let i = 0; i < macLen; i++) packet[17 + i] = 0x00; packet[15] = padCnt;
packet.set(apdu, secDataOff); const macOff = 16;
for (let i = 0; i < pCntr; i++) packet[secDataOff + apdu.length + i] = padByte; const dataOff = macOff + macLen;
packet.set(apdu, dataOff);
const chl = packetLen - spiOff; for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
packet[1] = packetLen - 2;
packet[3] = chl;
if (hasMac && kidKey) { if (hasMac && kidKey) {
const mac = retailMac(packet, kidKey); // CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
packet.set(mac, 17); // i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2).
const macInput = new Uint8Array(packet.length - macLen);
macInput.set(packet.subarray(0, macOff));
macInput.set(packet.subarray(dataOff), macOff);
const mac = cbcMac(macInput, kidKey);
packet.set(mac, macOff);
} }
if (ciphering && kicKey) { if (ciphering && kicKey) {
const toEncrypt = packet.subarray(secDataOff, packetLen); // ciphering covers CNTR+PCNTR+RC/CC/DS+secured data (TS 102 225 Table 2 note 1)
const toEncrypt = packet.subarray(10, packetLen);
const encrypted = des3CbcEncrypt(toEncrypt, kicKey, new Uint8Array(8)); const encrypted = des3CbcEncrypt(toEncrypt, kicKey, new Uint8Array(8));
packet.set(encrypted, secDataOff); packet.set(encrypted, 10);
} }
resultEl.value = bytesToHex(packet); resultEl.value = bytesToHex(packet);
@@ -2646,6 +2646,47 @@ async function pysimApdu() {
pysimRefresh(); pysimRefresh();
} }
function spParams() {
return {
spi1: document.getElementById('sp-spi1').value,
spi2: document.getElementById('sp-spi2-hex').value,
kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value,
tar: document.getElementById('sp-tar').value,
cntr: document.getElementById('sp-cntr').value,
kicKey: document.getElementById('sp-kic-key').value,
kidKey: document.getElementById('sp-kid-key').value,
apdu: document.getElementById('sp-apdu').value,
};
}
async function pysimVerifySp() {
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
const resultEl = document.getElementById('sp-verify-result');
if (!sp) { alert('No secured packet to verify. Generate a secure packet first.'); return; }
resultEl.classList.remove('hidden');
resultEl.textContent = 'Verifying against pySim reference...';
try {
const data = await pysimFetch('/api/sp-verify', { sp, ...spParams() });
if (data.match) {
resultEl.textContent = 'MATCH: JS packet byte-identical to pySim reference (' + sp.length / 2 + ' bytes)';
resultEl.classList.remove('text-red-600');
resultEl.classList.add('text-green-600');
} else {
let msg = 'MISMATCH vs pySim.';
if (data.diffs) msg += ' First diff at byte ' + data.diffs[0].offset + ' (JS=' + data.diffs[0].js + ' pySim=' + data.diffs[0].ref + ')';
if (data.py_sp) msg += ' pySim reference: ' + data.py_sp;
resultEl.textContent = msg;
resultEl.classList.remove('text-green-600');
resultEl.classList.add('text-red-600');
}
} catch (e) {
resultEl.textContent = 'Error: ' + e.message;
resultEl.classList.remove('text-green-600');
resultEl.classList.add('text-red-600');
}
}
async function pysimSendOta() { async function pysimSendOta() {
const connectedRow = document.getElementById('pysim-connected-row'); const connectedRow = document.getElementById('pysim-connected-row');
if (!connectedRow || connectedRow.classList.contains('hidden')) { if (!connectedRow || connectedRow.classList.contains('hidden')) {
@@ -2657,7 +2698,7 @@ async function pysimSendOta() {
const statusEl = document.getElementById('pysim-status'); const statusEl = document.getElementById('pysim-status');
statusEl.innerHTML = '<span class="text-gray-500">Sending OTA...</span>'; statusEl.innerHTML = '<span class="text-gray-500">Sending OTA...</span>';
try { try {
const data = await pysimFetch('/api/send-ota', { sp }); const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
if (data.success) { if (data.success) {
if (data.response_data) { if (data.response_data) {
document.getElementById('resp-data').value = data.response_data; document.getElementById('resp-data').value = data.response_data;
@@ -2665,7 +2706,13 @@ async function pysimSendOta() {
document.getElementById('resp-sw2').value = data.sw.slice(2, 4); document.getElementById('resp-sw2').value = data.sw.slice(2, 4);
switchTab('response'); switchTab('response');
} }
statusEl.innerHTML = '<span class="text-green-600">OTA sent. SW: ' + data.sw + '</span>'; let msg = 'OTA sent. SW: ' + data.sw;
const por = data.por;
if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
}
statusEl.innerHTML = '<span class="text-green-600">' + esc(msg) + '</span>';
} else { } else {
statusEl.innerHTML = '<span class="text-red-500">OTA failed: ' + (data.error || 'SW: ' + data.sw) + '</span>'; statusEl.innerHTML = '<span class="text-red-500">OTA failed: ' + (data.error || 'SW: ' + data.sw) + '</span>';
} }