feat: Explore exposes the rest of the registry data + install NV footprint (v3.6.51)

The compact (legacy) GET STATUS entry is <aid_len><AID><life cycle>
<privileges> + (P1=10) <module count> (<len><module AID>)*, cross-checked
against GlobalPlatformPro's populate_legacy (the 4th byte is privileges;
0x00 is required for load files).  The parser read it as a 'version' one
byte late, so ELF rows showed the module count / the next entry's length as
Version: 00/10 - the compact listing has no version at all; it now comes
from the expanded CE tag (ramFmtVersion: 2 bytes -> major.minor, GP Table
11-37 Note 1) and merges (the bogus compact value no longer blocked it).

New data exposed:
- EA SCP Registry Data (TS 102 226 8.2.1.6) -> 80 Menu Parameters -> the
  applet's STK menu entries: STK menu entry: 0x80 (position 1, Enabled)
- unknown registry objects are preserved (rawTlvs) and shown as
  'Other registry data: 8F=752B . 90=012D ...' instead of being dropped
- 84 parses the GP coding (one Executable Module AID per TLV, repeated; the
  concatenated 4F<len><aid> form stays as fallback) and the ELF expanded
  queries add 84 to the tag list (with a tag list the card returns only the
  listed tags - the fallback path would otherwise lose the module lists and
  their Install buttons)
- life cycle names decode per object kind (GP Tables 11-3..11-6: ISD/card,
  app, SD - detected via the Security Domain privilege - and ELF), with
  multi-byte 9F70 (02 0700 on live cards) decoding the first byte and
  surfacing non-zero extras; the R-APDU parser's LIFECYCLE_MAP labels were
  aligned with the same tables

/api/ram-install measures the NV footprint: a silent GET DATA FF21 read
before and after the chain (also on failure) returns nv_before/nv_after/
nv_delta; the reads never fail the operation (no FF21 -> no fields) and the
PWA shows 'NV used: ~N B (free A -> B)' in the install result.

sw cache simple-v324; tests: 548 Python / 698 Node.
This commit is contained in:
2026-09-30 09:00:33 +03:00
parent be411a7b4b
commit 599d84fd66
11 changed files with 637 additions and 76 deletions
+87
View File
@@ -16,6 +16,7 @@ import threading
import unittest
import urllib.error
import urllib.request
from unittest import mock
from pysim_simple_server.__main__ import _build_http_server
from pysim_simple_server.server import PysimHandler, _CARD_FREE_GET, _CARD_LOCK
@@ -222,5 +223,91 @@ class CapCompatHttpTests(unittest.TestCase):
self.assertIn('import probe', resp['error'])
class RamInstallNvFootprintHttpTests(unittest.TestCase):
"""The RAM install response carries the measured NV footprint (v3.6.51):
the server reads GET DATA FF21 before and after the chain (best effort)
and reports the free-NV values and their delta."""
def setUp(self):
from pysim_simple_server import server as srv
self.srv = srv
self.server = _build_http_server('127.0.0.1', 0, PysimHandler)
self.server.log_requests = False
self.server.app = None
self.server.sl = None
self.server.scc = object()
self.server.sms_oa = '12345'
self.server.sms_sc = '12345678912'
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.port = self.server.server_address[1]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def _post(self, path, body):
req = urllib.request.Request(
'http://127.0.0.1:%d%s' % (self.port, path),
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'}, method='POST')
try:
with urllib.request.urlopen(req, timeout=5) as res:
return res.status, json.loads(res.read() or b'{}')
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
def _install(self, fail_at=None, nv_before=50000, nv_after=20000):
def fake_send(server, scc, sp, state, name, apdu, silent=False):
if fail_at == name:
state['steps'].append({'name': name, 'por_status': 'por_error', 'por_sw': '6A88'})
return False
state['steps'].append({'name': name, 'por_status': 'por_ok', 'por_sw': '9000'})
return True
def fake_read(server, scc, sp, state, ram_format, label):
return nv_before if label == 'NV before' else nv_after
patches = [
mock.patch.object(self.srv, '_cap_parse',
lambda cap_hex: ('F0414C46416101', 'F0414C4641610101', b'\x01\x02')),
mock.patch.object(self.srv, '_cap_apdu_sequence',
lambda *a, **k: ['80E60200', '80E80000', '80E60000']),
mock.patch.object(self.srv, '_ram_detect_format', lambda *a, **k: 'compact'),
mock.patch.object(self.srv, '_ram_send_gp_apdu', fake_send),
mock.patch.object(self.srv, '_ram_read_ff21', fake_read),
]
for p in patches:
p.start()
try:
return self._post('/api/ram-install', {'cap_hex': 'AA'})
finally:
for p in reversed(patches):
p.stop()
def test_success_reports_the_nv_delta(self):
status, resp = self._install()
self.assertEqual(status, 200, resp)
self.assertTrue(resp['success'], resp)
self.assertEqual(resp['nv_before'], 50000)
self.assertEqual(resp['nv_after'], 20000)
self.assertEqual(resp['nv_delta'], 30000)
def test_failed_install_still_reports_the_nv_delta(self):
status, resp = self._install(fail_at='LOAD (1/1)')
self.assertEqual(status, 200, resp)
self.assertFalse(resp['success'], resp)
self.assertEqual(resp['failed_step'], 2)
self.assertEqual(resp['nv_before'], 50000)
self.assertEqual(resp['nv_after'], 20000)
self.assertEqual(resp['nv_delta'], 30000)
def test_a_card_without_ff21_has_no_nv_fields(self):
status, resp = self._install(nv_before=None, nv_after=None)
self.assertEqual(status, 200, resp)
self.assertTrue(resp['success'], resp)
for key in ('nv_before', 'nv_after', 'nv_delta'):
self.assertNotIn(key, resp)
if __name__ == '__main__':
unittest.main()
+87
View File
@@ -37,6 +37,9 @@ from pysim_simple_server.server import (
_por_remote_sw,
_ram_next_cntr,
_ram_format_apdu,
_ram_free_nv,
_ram_read_ff21,
_ram_nv_fields,
_wrap_expanded_apdu,
_ram_remote_sw_ok,
_sms_submit_por,
@@ -1585,6 +1588,90 @@ class RamSendGpApduLoggingTest(unittest.TestCase):
self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out)
class RamNvFootprintTests(unittest.TestCase):
"""GET DATA FF21 reads around an install (v3.6.51): the free-NV values and
their delta. The reads are best-effort - a card without FF21 (or a
rejected read) must never fail the operation."""
def test_free_nv_from_ff21_response(self):
# TS 102 226 8.2.1.7.2: '81' applet count, '82' free NV, '83' free
# volatile (the length byte follows FF21).
data = 'FF210C' + '810102' + '820300C5D6' + '83020064'
self.assertEqual(_ram_free_nv(data), 0xC5D6)
# CR-set tag variants (0x01/0x02/0x03) decode the same
self.assertEqual(_ram_free_nv('FF210C' + '010102' + '020300C5D6' + '03020064'), 0xC5D6)
# not an FF21 response / empty / garbage
self.assertIsNone(_ram_free_nv('6A88'))
self.assertIsNone(_ram_free_nv(''))
self.assertIsNone(_ram_free_nv('not-hex'))
def test_nv_fields(self):
self.assertEqual(_ram_nv_fields(None, None), {})
self.assertEqual(_ram_nv_fields(50000, 20000),
{'nv_before': 50000, 'nv_after': 20000, 'nv_delta': 30000})
# a freed delta is negative (memory released by the operation)
self.assertEqual(_ram_nv_fields(100, 150),
{'nv_before': 100, 'nv_after': 150, 'nv_delta': -50})
# only one read available: the delta stays None
self.assertEqual(_ram_nv_fields(100, None),
{'nv_before': 100, 'nv_after': None, 'nv_delta': None})
def test_read_ff21_uses_the_silent_sender(self):
from pysim_simple_server import server as srv
calls = []
def fake_send(server, scc, sp, state, name, apdu, silent=False):
calls.append((name, apdu, silent))
if not silent:
state['steps'].append({'name': name})
state['last_step'] = {'por_data': 'FF210C810102820300C5D683020064'}
return True
state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send):
nv = _ram_read_ff21(None, None, {}, state, 'expanded', 'NV before')
self.assertEqual(nv, 0xC5D6)
self.assertEqual(calls, [('NV before', _wrap_expanded_apdu('80CAFF2100'), True)])
self.assertEqual(state['steps'], [])
self.assertEqual(state['failure'], {})
def test_read_ff21_returns_none_when_the_read_fails(self):
from pysim_simple_server import server as srv
def fake_send(server, scc, sp, state, name, apdu, silent=False):
return False
state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
with mock.patch.object(srv, '_ram_send_gp_apdu', fake_send):
self.assertIsNone(_ram_read_ff21(None, None, {}, state, 'compact', 'NV after'))
def test_silent_send_leaves_no_step_or_failure(self):
import contextlib
import io
from pysim_simple_server import server as srv
class FakeServer:
sms_oa = '12345'
sms_sc = '12345678912'
sp = {'spi1': '16', 'spi2': '01', 'kic': '25', 'kid': '25', 'tar': '000000',
'kic_key': 'AA', 'kid_key': 'BB', 'include_cpi': True}
state = {'steps': [], 'encode_error': None, 'failure': {}, 'cntr': '0000000001'}
with mock.patch.object(srv, '_build_secured_packet', lambda *a, **k: ('AABB', {})), \
mock.patch.object(srv, '_send_secured_packet',
lambda *a, **k: {'success': False, 'error': 'stub'}):
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
ok = srv._ram_send_gp_apdu(FakeServer(), object(), sp, state,
'NV before', '80CAFF2100', silent=True)
self.assertFalse(ok)
self.assertEqual(state['steps'], [])
self.assertEqual(state['failure'], {})
self.assertIsNone(state['encode_error'])
self.assertIn('RAM C-APDU (NV before): 80CAFF2100', buf.getvalue())
class RamCommandFormatTests(unittest.TestCase):
"""RAM command format detection helpers (TS 102 226 5.2.1, v3.6.24)."""