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-APDU 22 APDU
Immediate Action 81 Проактивная команда или action indicator
- Error Action 82 Проактивная команда при ошибке
- Script Chaining 83 Данные для многопакетных скриптов
+ Error Action 82 Условное восстановление при ошибках с проактивной командой
+ Script Chaining 83 Многопакетное выполнение скрипта с флагами 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:
+
+ Тип действия: Немедленное (00), отложенное (01) или условное (02) выполнение
+ Условия запуска: Сопоставление SW (любая ошибка, конкретный SW, диапазон SW, конкретная команда)
+ Действие восстановления: Проактивная команда (DISPLAY TEXT, PLAY TONE, REFRESH) для уведомления или отката
+ Количество попыток: Число повторных попыток до отказа (0=без повтора)
+
+
+ Script Chaining TLV (Tag 83)
+ Многопакетное выполнение скрипта с сохранением контекста:
+
+ Флаги цепочки: Позиция First/Intermediary/Last Script (бит 0/1/2)
+ Идентификатор скрипта: Корреляционный идентификатор между пакетами (1-4 байта hex, авто-инкремент подсказки)
+ Дополнительные данные: Расширенная информация цепочки (опционально hex)
+ Сохранение контекста: UICC сохраняет состояние безопасности/транзакции между пакетами
+
+
+ Expanded Remote Response (TS 102 226 §5.2.2)
+ Результаты по каждой команде с деталями ошибок и контекстом цепочки:
+
+ Номер команды, статус слова, данные ответа для каждой команды
+ Код ошибки и информация о ней для неудачных команд (подсвечено красным)
+ Идентификатор скрипта и позиция для корреляции цепочки (ID, FIRST, LAST)
+ Индикатор типа ответа: 'expanded' vs 'compact' vs 'none'
+
+
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-APDU 22 Raw APDU hex
Immediate Action 81 Proactive command or action indicator
- Error Action 82 Proactive command on error
- Script Chaining 83 Chaining data for multi-packet scripts
+ Error Action 82 Conditional error recovery with proactive command fallback
+ Script Chaining 83 Multi-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:
+
+ Action Type: Immediate (00), delayed (01), or conditional (02) execution
+ Trigger Conditions: SW pattern matching (any error, specific SW, SW range, specific command fails)
+ Recovery Action: Proactive command (DISPLAY TEXT, PLAY TONE, REFRESH) to notify user or fallback
+ Retry Count: Number of retry attempts before giving up (0=no retry)
+
+
+ Script Chaining TLV (Tag 83)
+ Multi-packet script execution with context preservation:
+
+ Chaining Flags: First/Intermediary/Last Script position (bit 0/1/2)
+ Script ID: Correlation identifier across packets (1-4 bytes hex, auto-increment hints provided)
+ Additional Data: Extended chaining information (optional hex)
+ Context Preservation: UICC keeps security/transaction state open across chained scripts
+
+
+ Expanded Remote Response (TS 102 226 §5.2.2)
+ Per-command results with error details and chaining context:
+
+ Command number, status word, response data for each command
+ Error code and error info for failed commands (highlighted in red)
+ Script ID and position for chained script correlation (ID, FIRST, LAST)
+ Response type indicator: 'expanded' vs 'compact' vs 'none'
+
+
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 = `
@@ -2061,14 +2063,88 @@ function onBerTypeChange(sel) {
updateBerRow(sel);
}
+function buildErrorActionRow(rowIdx) {
+ return `
+
+
+ Action Type:
+
+ Immediate execution
+ Delayed execution
+ Conditional execution
+
+
+
+
+
Trigger Conditions:
+
+
+ When:
+
+ Any error
+ Specific SW
+ SW range
+ Specific command fails
+
+
+
+
+ SW Pattern:
+
+ =
+
+ (hex)
+
+
+
+ Command #:
+
+ (optional)
+
+
+
+ Mask: FFFF
+
+
+
+
+
+
+ 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 = `
- 81 (Session indication)
- 82 (Early response)
- `;
+ 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 = `
+ 81 (Session indication)
+ 82 (Early response)
+ `;
+ }
} 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 `
+ `;
+}
+
+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()