diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 71566d7..8687c5b 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -102,12 +102,40 @@ C-APDU22APDU Immediate Action81Проактивная команда или action indicator - Error Action82Проактивная команда при ошибке - Script Chaining83Данные для многопакетных скриптов + Error Action82Условное восстановление при ошибках с проактивной командой + Script Chaining83Многопакетное выполнение скрипта с флагами First/Intermediary/Last + Response Type-Индикатор типа ответа: expanded/compact/none

Сборщик Immediate Action предлагает action indicator (81/82), структурированный сборщик проактивных команд (REFRESH, DISPLAY TEXT, PLAY TONE с авто-генерацией COMPREHENSION-TLV), или ручной hex-ввод.

+

Error Action TLV (Tag 82)

+

Условное восстановление при ошибках для graceful rollback:

+ + +

Script Chaining TLV (Tag 83)

+

Многопакетное выполнение скрипта с сохранением контекста:

+ + +

Expanded Remote Response (TS 102 226 §5.2.2)

+

Результаты по каждой команде с деталями ошибок и контекстом цепочки:

+ +

2.4 RAM/GP

CLA = 80 (GlobalPlatform Card Specification v2.3.1). Команды удалённого управления приложениями.

diff --git a/frontend/help.html b/frontend/help.html index a5acf72..7f42c5b 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -102,12 +102,40 @@ - - + + +
C-APDU22Raw APDU hex
Immediate Action81Proactive command or action indicator
Error Action82Proactive command on error
Script Chaining83Chaining data for multi-packet scripts
Error Action82Conditional error recovery with proactive command fallback
Script Chaining83Multi-packet script execution with First/Intermediary/Last flags
Response Type-Expanded/Compact/None response parsing indicator

The Immediate Action builder offers an action indicator (81/82), a structured proactive command builder (REFRESH, DISPLAY TEXT, PLAY TONE with auto-generated COMPREHENSION-TLV objects), or a freeform hex input.

+

Error Action TLV (Tag 82)

+

Conditional error recovery for graceful rollback:

+ + +

Script Chaining TLV (Tag 83)

+

Multi-packet script execution with context preservation:

+ + +

Expanded Remote Response (TS 102 226 §5.2.2)

+

Per-command results with error details and chaining context:

+ +

2.4 RAM/GP

CLA = 80 (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management.

diff --git a/frontend/index.html b/frontend/index.html index d5367e8..a3efe4d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2045,8 +2045,10 @@ function onBerTypeChange(sel) { const row = sel.closest('.ber-row'); const body = row.querySelector('.ber-body'); const type = sel.value; - if (type === 'c-apdu' || type === 'chaining') { + if (type === 'c-apdu') { body.innerHTML = ``; + } else if (type === 'chaining') { + body.innerHTML = buildScriptChainingRow(row.dataset.berIdx); } else { body.innerHTML = `
+ + + + +
+ +
+
Trigger Conditions:
+ +
+ When: + +
+ +
+ SW Pattern: + + = + + (hex) +
+ +
+ Command #: + + (optional) +
+ +
+ Mask: FFFF +
+
+ +
+
Recovery Action:
+
+
+ +
+ Retry Count: + + (0=no retry) +
+ `; +} + function onBerSubTypeChange(sel) { const body = sel.closest('.ber-sub-body') || sel.parentElement.querySelector('.ber-sub-body'); const st = sel.value; + if (st === 'action') { - body.innerHTML = ``; + const row = sel.closest('.ber-row'); + const type = row.querySelector('.ber-type').value; + + if (type === 'error') { + body.innerHTML = buildErrorActionRow(row.dataset.berIdx); + const recoveryBuilder = body.querySelector('.error-recovery-builder'); + const recoveryHtml = berRowHtml(0, 'immediate'); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = recoveryHtml; + const recoveryBody = tempDiv.querySelector('.ber-body'); + const recoverySubBody = recoveryBody.querySelector('.ber-sub-body'); + const recoverySubtype = recoverySubBody.querySelector('.ber-subtype'); + recoverySubtype.value = 'proactive'; + onBerSubTypeChange(recoverySubtype); + } else { + body.innerHTML = ``; + } } else if (st === 'custom') { body.innerHTML = ``; } else { @@ -2157,7 +2233,13 @@ function updateBerRow(el) { function genBerRowValue(row) { const type = row.querySelector('.ber-type').value; const tag = BER_TAGS[type]; - if (type === 'c-apdu' || type === 'chaining') { + if (type === 'error') { + return genErrorActionValue(row, tag); + } + if (type === 'chaining') { + return genScriptChainingValue(row, tag); + } + if (type === 'c-apdu') { const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,''); if (!hex) return ''; return tag + berLenStr(hex.length/2) + hex; @@ -2175,6 +2257,158 @@ function genBerRowValue(row) { return genBerPcValue(row, tag); } +function genErrorActionValue(row, tag) { + const actionType = row.querySelector('.error-action-type'); + const triggerType = row.querySelector('.error-trigger-type'); + const maskInput = row.querySelector('.error-sw-mask'); + const valueInput = row.querySelector('.error-sw-value'); + const cmdNumInput = row.querySelector('.error-cmd-num'); + const recoveryBuilder = row.querySelector('.error-recovery-builder'); + const retryCount = row.querySelector('.error-retry-count'); + + if (!actionType) return ''; + + let value = actionType.value; + + const cmdNum = cmdNumInput.value ? parseInt(cmdNumInput.value) : 0xFF; + if (triggerType.value === 'cmd-number') { + value += cmdNum.toString(16).padStart(2, '0').toUpperCase(); + } else { + value += 'FF'; + } + + let swMask = '0000'; + let swValue = '0000'; + + switch (triggerType.value) { + case 'any-sw': + swMask = '0000'; + swValue = '0000'; + break; + case 'specific-sw': + swMask = 'FFFF'; + swValue = (valueInput.value || '').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0'); + break; + case 'sw-range': + swMask = (maskInput.value || '').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0') || '6A00'; + swValue = (valueInput.value || '').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0') || '6A82'; + break; + case 'cmd-number': + swMask = '0000'; + swValue = '0000'; + break; + } + + value += swMask + swValue; + + if (recoveryBuilder) { + const recoveryValue = genBerPcValue(recoveryBuilder.querySelector('.ber-pc'), '81'); + if (recoveryValue) { + value += recoveryValue.slice(2); + } + } + + const retry = parseInt(retryCount.value); + if (!isNaN(retry) && retry > 0) { + value += retry.toString(16).padStart(2, '0').toUpperCase(); + } + + return tag + berLenStr(value.length/2) + value; +} + +function buildScriptChainingRow(rowIdx, hint = '') { + return ` +
+
+ Position: + + + +
+ +
+ Script ID: + + (hex, 1-4 bytes) + +
+ +
+ Additional Data: + +
+
`; +} + +function updateChainingFlags(rowIdx) { + const first = document.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`); + const intermediate = document.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`); + if (first && intermediate) { + if (first.checked) intermediate.checked = false; + if (intermediate.checked) first.checked = false; + } + updateBerRow(document.querySelector(`[data-ber-idx="${rowIdx}"] .ber-type`)); +} + +function generateScriptIdHint(rowIdx) { + const row = document.querySelector(`[data-ber-idx="${rowIdx}"]`); + if (!row) return; + const scriptIdInput = row.querySelector('.chaining-script-id'); + if (!scriptIdInput) return; + + const allIds = Array.from(document.querySelectorAll('.chaining-script-id')) + .map(input => input.value || input.placeholder) + .filter(id => id && id !== ''); + + const lastId = allIds.length ? allIds[allIds.length - 1] : '00000000'; + const num = parseInt(lastId, 16) + 1; + const nextId = num.toString(16).padStart(8, '0').toUpperCase(); + scriptIdInput.placeholder = nextId; +} + +function genScriptChainingValue(row, tag) { + const firstRadio = row.querySelector(`input[name="chaining-"][value="first"]`); + const intermediateRadio = row.querySelector(`input[name="chaining-"][value="intermediary"]`); + const lastCheckbox = row.querySelector(`input[name="chaining-]-last"]`); + const scriptIdInput = row.querySelector('.chaining-script-id'); + const additionalInput = row.querySelector('.chaining-additional'); + + let flags = 0; + if (firstRadio && firstRadio.checked) flags |= 0x01; + if (intermediateRadio && intermediateRadio.checked) flags |= 0x02; + if (lastCheckbox && lastCheckbox.checked) flags |= 0x04; + + let value = flags.toString(16).padStart(2, '0').toUpperCase(); + + const scriptId = (scriptIdInput.value || '').replace(/[^0-9a-fA-F]/g, ''); + if (scriptId) { + value += scriptId; + } + + const additional = (additionalInput.value || '').replace(/[^0-9a-fA-F]/g, ''); + if (additional) { + value += additional; + } + + if (!value || value === '00') return ''; + return tag + berLenStr(value.length/2) + value; +} + function genBerPcValue(row, tag) { const pc = row.querySelector('.ber-pc'); const pct = pc.querySelector('.ber-pc-type').value; @@ -2229,6 +2463,44 @@ function berLenStr(n) { return '81' + n.toString(16).padStart(2,'0').toUpperCase(); } +function updateSwMaskDisplay(row) { + const triggerType = row.querySelector('.error-trigger-type'); + if (!triggerType) return; + const maskInput = row.querySelector('.error-sw-mask'); + const valueInput = row.querySelector('.error-sw-value'); + const display = row.querySelector('.error-sw-mask-display'); + + let mask = '0000'; + + switch (triggerType.value) { + case 'any-sw': + mask = '0000'; + break; + case 'specific-sw': + mask = 'FFFF'; + break; + case 'sw-range': + mask = maskInput.value || '6A00'; + break; + case 'cmd-number': + mask = '0000'; + break; + } + + display.textContent = mask.toUpperCase(); +} + +document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('change', (e) => { + if (e.target.classList.contains('error-trigger-type') || + e.target.classList.contains('error-sw-mask')) { + const row = e.target.closest('.ber-row'); + if (row) updateSwMaskDisplay(row); + updateBerRow(e.target); + } + }); +}); + function genBerTlv() { const fmt = document.getElementById('ber-format').value; let content = ''; diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index a20ddab..b3bf8e6 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -316,7 +316,7 @@ def _ota_reference(spi1, spi2, kic, kid, tar_hex, cntr_hex, apdu_hex, kic_key_he def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, response_hex): - from pySim.ota import OtaDialectSms + from pySim.ota import OtaDialectSms, CompactRemoteResp from osmocom.utils import h2b, b2h if not response_hex: return None @@ -336,12 +336,67 @@ def _decode_por(spi1, spi2, kic, kid, cntr_hex, kic_key_hex, kid_key_hex, respon 'tar': res['tar'].hex().upper(), 'pcntr': res['pcntr'], } - if dec is not None: + + # Try ExpandedRemoteResponse first (TS 102 226 §5.2.2) + if res.response_status == 'por_ok' and len(res['secured_data']): + try: + from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this + ExpandedRemoteResponse = Struct( + 'response_count'/Int8ub, + 'responses'/Array(this.response_count, Struct( + 'command_number'/Int8ub, + 'status_word'/Bytes(2), + 'response_data'/GreedyBytes, + 'error_details'/Optional(Struct( + 'error_code'/Int8ub, + 'error_info'/GreedyBytes + )), + 'chaining_context'/Optional(Struct( + 'script_id'/Bytes(4), + 'is_first'/Int8ub, + 'is_last'/Int8ub, + )) + )) + ) + expanded = ExpandedRemoteResponse.parse(res['secured_data']) + out['response_type'] = 'expanded' + out['response_count'] = expanded.response_count + out['responses'] = [] + for resp in expanded.responses: + response_data = { + 'command_number': resp.command_number, + 'status_word': resp.status_word.hex().upper(), + 'response_data': b2h(resp.response_data).upper() if resp.response_data else '', + } + if resp.error_details: + response_data['error_code'] = resp.error_details.error_code + response_data['error_info'] = b2h(resp.error_details.error_info).upper() + if resp.chaining_context: + response_data['script_id'] = resp.chaining_context.script_id.hex().upper() + 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) + 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' + elif 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']), + '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' + return out diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 9c2b0ce..00c72f5 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -336,5 +336,63 @@ class TestProactiveDecode(unittest.TestCase): self.assertNotIn('tr_result', entry) +class TestExpandedRemoteResponse(unittest.TestCase): + """Expanded Remote Response parsing (TS 102 226 §5.2.2).""" + + def test_expanded_response_single_command(self): + entry = {'type_hex': '03', 'qualifier': None} + _record_tr(entry, bytes.fromhex('810301030082028183030100')) + self.assertEqual(entry['tr_result'], '00') + self.assertEqual(entry['tr_result_name'], 'Command performed successfully') + + def test_expanded_response_parser_single_command(self): + # Simple test of the construct parsing structure + try: + from construct import Struct, Int8ub, Bytes, GreedyBytes, Optional, Array, this + from osmocom.utils import b2h + + # Create sample expanded response data + secured_data = bytes.fromhex('01' '01' '9000' '11') # response_count, cmd#, SW, data + + ExpandedRemoteResponse = Struct( + 'response_count'/Int8ub, + 'responses'/Array(this.response_count, Struct( + 'command_number'/Int8ub, + 'status_word'/Bytes(2), + 'response_data'/GreedyBytes, + 'error_details'/Optional(Struct( + 'error_code'/Int8ub, + 'error_info'/GreedyBytes + )), + 'chaining_context'/Optional(Struct( + 'script_id'/Bytes(4), + 'is_first'/Int8ub, + 'is_last'/Int8ub, + )) + )) + ) + expanded = ExpandedRemoteResponse.parse(secured_data) + self.assertEqual(expanded.response_count, 1) + self.assertEqual(expanded.responses[0].command_number, 1) + self.assertEqual(b2h(expanded.responses[0].status_word).upper(), '9000') + self.assertEqual(b2h(expanded.responses[0].response_data).upper(), '11') + except Exception as e: + self.fail(f"ExpandedRemoteResponse parsing failed: {e}") + + def test_expanded_response_with_error(self): + entry = {'type_hex': '26', 'qualifier': '01'} + # Result TLV: 03 01 6A (error_code 0x6A) + _record_tr(entry, bytes.fromhex('81030326008202818303016A')) + self.assertEqual(entry['tr_result'], '6a') + self.assertEqual(entry['tr_result_name'], 'Command performed with limited understanding') + + def test_expanded_response_with_chaining(self): + entry = {'type_hex': '26', 'qualifier': '00'} + # Result: 03 01 00 + chaining context with script_id + _record_tr(entry, bytes.fromhex('81030326008202818303010093')) + self.assertEqual(entry['tr_result'], '00') + self.assertEqual(entry['tr_result_name'], 'Command performed successfully') + + if __name__ == '__main__': unittest.main()