feat: CAP compatibility test as its own operation + append-only imports (v3.6.48)

The import probe no longer lives in 'Install Package (.cap file)': a
diagnostic that can leave a LOADED-only package does not belong in the
operation meant to produce a working applet.  /api/ram-install no longer
takes probe_imports; the install form's probe fieldset is gone.

New RAM operation CAP compatibility test (POST /api/cap-compat): the
server runs the format check, INSTALL [for load] and the LOAD blocks only
up to the block that completes the Import component (_cap_import_end +
_cap_compat_blocks, computed from the load file and the C4 TLV framing),
then stops - no last-block flag, no INSTALL [for install], so nothing is
committed and nothing needs deleting.  The verdict (imports_ok,
boundary_block/total_blocks) states it covers the LOAD/import gate only;
the link gate stays the real install's business.  Verified on the real
RemMobileID CAP: the boundary is LOAD 1/64, 2/128, 3/240 for 240/120/64 B
blocks - exactly the blocks where the live cards rejected the load.

_probe_import_versions(load_file, overrides, additions) now supports
append-only synthetic imports: +AID=version appends a new entry, the
Import component's size field and the Directory's Import size are patched,
and existing entries keep their positions (constant-pool import indices
stay valid); an AID that is already imported becomes a version override.
This is the card-capability query ('does this card export package X at
>= Y?') without building a probe applet.

PWA: new op section (CAP file + analysis + the version list + block size),
ramCompatTest with inline results and ramCompatVerdict, ramProbeParse
understands +AID=version; help EN/RU documents the operation, the two-step
bisect and the gate scope.

sw cache simple-v321; tests: 538 Python / 686 Node.
This commit is contained in:
2026-09-30 07:57:11 +03:00
parent ef949e6a17
commit 0dc79a3ba4
9 changed files with 490 additions and 121 deletions
+238 -57
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.6.47'
VERSION = '3.6.48'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -845,24 +845,70 @@ def _cap_parse(cap_hex):
return loadfile_aid, module_aid, loadfile_data
def _probe_import_versions(load_file_hex, overrides):
"""Diagnostic probe: rewrite the version bytes of the load file's Import
component (JC VM spec 4.5.2 - the card must export every imported package
at >= the recorded version).
def _version_pair(text):
"""'x.y' -> (minor, major) as the CAP stores an import version."""
try:
major, minor = str(text).strip().split('.')
major, minor = int(major), int(minor)
except (TypeError, ValueError):
raise ValueError('invalid version %r (expected major.minor)' % (text,))
if not (0 <= major <= 255 and 0 <= minor <= 255):
raise ValueError('version out of range: %r' % (text,))
return minor, major
def _cap_import_end(load_file_hex):
"""Offset just past the Import component's last byte in the load file."""
data = bytes.fromhex(load_file_hex or '')
off = 0
while off + 3 <= len(data):
tag = data[off]
size = int.from_bytes(data[off + 1:off + 3], 'big')
if tag == 0x04:
return off + 3 + size
off += 3 + size
raise ValueError('no Import component in the load file')
def _cap_compat_blocks(load_file_hex, block_size):
"""(boundary, total) LOAD block numbers for the compatibility test.
`boundary` is the block whose payload completes the Import component: the
JCRE verifies the import list when that component ends (the observed LOAD
rejections land exactly in that block), so a test stopping after it
answers the compatibility question without committing a load file.
`total` is the block count of the full load (for the report)."""
import_end = _cap_import_end(load_file_hex)
tlv = 'C4' + _ber_len(len(load_file_hex) // 2) + load_file_hex
header_chars = len(tlv) - len(load_file_hex) # the C4 TLV header
last_byte = header_chars + (import_end - 1) * 2 # Import's last byte
boundary = last_byte // (block_size * 2) + 1
total = (len(tlv) + block_size * 2 - 1) // (block_size * 2)
return boundary, total
def _probe_import_versions(load_file_hex, overrides, additions=None):
"""Diagnostic probe: rewrite (and optionally append) the Import component
of the load file (JC VM spec 4.5.2 - the card must export every imported
package at >= the recorded version).
The JCRE checks the import list while it verifies the load file, so a card
that cannot satisfy one of the CAP's export versions rejects the LOAD as
soon as the Import component is complete (live 2026-09-29: remote SW
6438/6985 exactly in the block containing the component's last byte).
Lowering the versions shows whether that list is the cause and, by
bisection, which package the card refuses. Only the two version bytes per
entry change: the load file length, the component sizes, the Directory and
every offset stay untouched.
bisection, which package the card refuses; appending a synthetic entry
(`additions`) queries the card for a package/version the CAP does not use.
`overrides` is {'all': 'x.y'} or {aid_hex: 'x.y'} (lower/upper case and
spaces are tolerated). Returns (patched_hex, applied) where `applied`
lists the entries actually changed. Diagnostic only - never used for a
real install."""
`overrides` is {'all': 'x.y'} or {aid_hex: 'x.y'}; `additions` is a list
of {'aid': hex, 'version': 'x.y'} appended as new entries (append-only:
existing entries keep their positions, so the applet's constant-pool
import indices stay valid - an AID that is already present is treated as a
version override). A version-only probe keeps the load file length
untouched; appends patch the component's size field and the Directory's
Import size. Returns (patched_hex, applied) where `applied` lists the
entries changed or added ('from' is null for an appended entry).
Diagnostic only - never used for a real install."""
data = bytearray.fromhex(load_file_hex or '')
if not data:
raise ValueError('empty load file')
@@ -872,16 +918,6 @@ def _probe_import_versions(load_file_hex, overrides):
k = str(key).replace(' ', '')
norm['all' if k.lower() == 'all' else k.upper()] = value
def version_pair(text):
try:
major, minor = str(text).strip().split('.')
major, minor = int(major), int(minor)
except (TypeError, ValueError):
raise ValueError('invalid version %r (expected major.minor)' % (text,))
if not (0 <= major <= 255 and 0 <= minor <= 255):
raise ValueError('version out of range: %r' % (text,))
return minor, major # the CAP stores [minor][major]
# Walk the components to the Import one (tag 0x04; sizes exclude the
# 3-byte component header).
start = None
@@ -898,31 +934,82 @@ def _probe_import_versions(load_file_hex, overrides):
raise ValueError('no Import component in the load file')
if end > len(data):
raise ValueError('truncated Import component')
count = data[start]
# parse the entries once (bounds-checked)
entries = []
p = start + 1
matched = 0
applied = []
for _ in range(count):
for _ in range(data[start]):
if p + 3 > end:
raise ValueError('truncated Import entry')
minor, major, aid_len = data[p], data[p + 1], data[p + 2]
aid_hex = bytes(data[p + 3:p + 3 + aid_len]).hex().upper()
if p + 3 + aid_len > end:
raise ValueError('truncated Import entry AID')
override = norm.get(aid_hex, norm.get('all'))
if override is not None:
matched += 1
new_minor, new_major = version_pair(override)
if (new_minor, new_major) != (minor, major):
applied.append({'aid': aid_hex,
'from': '%d.%d' % (major, minor),
'to': '%d.%d' % (new_major, new_minor)})
data[p] = new_minor
data[p + 1] = new_major
aid_hex = bytes(data[p + 3:p + 3 + aid_len]).hex().upper()
entries.append([minor, major, aid_hex])
p += 3 + aid_len
matched = 0
applied = []
for entry in entries:
override = norm.get(entry[2], norm.get('all'))
if override is None:
continue
matched += 1
new_minor, new_major = _version_pair(override)
if (new_minor, new_major) != (entry[0], entry[1]):
applied.append({'aid': entry[2],
'from': '%d.%d' % (entry[1], entry[0]),
'to': '%d.%d' % (new_major, new_minor)})
entry[0], entry[1] = new_minor, new_major
known = {e[2] for e in entries}
for item in (additions or []):
aid = str(item.get('aid', '')).replace(' ', '').upper()
if not aid or len(aid) % 2 or not re.fullmatch(r'[0-9A-F]+', aid):
raise ValueError('invalid AID in the additions: %r' % (item.get('aid'),))
if not 10 <= len(aid) <= 32:
raise ValueError('addition AID must be 5..16 bytes: %s' % aid)
new_minor, new_major = _version_pair(item.get('version'))
matched += 1
if aid in known:
# already imported: apply as a version override
for entry in entries:
if entry[2] == aid and (entry[0], entry[1]) != (new_minor, new_major):
applied.append({'aid': aid,
'from': '%d.%d' % (entry[1], entry[0]),
'to': '%d.%d' % (new_major, new_minor)})
entry[0], entry[1] = new_minor, new_major
break
continue
known.add(aid)
entries.append([new_minor, new_major, aid])
applied.append({'aid': aid, 'from': None,
'to': '%d.%d' % (new_major, new_minor)})
if not matched:
raise ValueError('no import matched the probe (give AIDs from the '
'CAP analysis or "all")')
'CAP analysis, "all" or "+AID=version" additions)')
rebuilt = bytes([len(entries)]) + b''.join(
bytes([e[0], e[1], len(e[2]) // 2]) + bytes.fromhex(e[2]) for e in entries)
if rebuilt != bytes(data[start:end]):
# sizes: the component header field and the Directory's array entry
# (index 3 = tag 4) carry the Import's data length
new_size = len(rebuilt)
if new_size > 0xFFFF:
raise ValueError('Import component too large')
data[start - 2:start] = new_size.to_bytes(2, 'big')
off = 0
while off + 3 <= len(data):
tag = data[off]
size = int.from_bytes(data[off + 1:off + 3], 'big')
if tag == 0x02: # Directory: sizes by tag
entry = off + 3 + 3 * 2 # index 3 = tag 4 (Import)
if entry + 2 <= off + 3 + size:
data[entry:entry + 2] = new_size.to_bytes(2, 'big')
break
off += 3 + size
data[start:end] = rebuilt
return data.hex().upper(), applied
@@ -4855,7 +4942,8 @@ _TEST_KIND_LABELS = {
_TEST_BLOCKED_PATHS = frozenset([
'/api/command', '/api/cardinfo', '/api/tree', '/api/select', '/api/read',
'/api/write', '/api/apdu', '/api/verify-adm', '/api/send-ota',
'/api/ram-install', '/api/ram-install-app', '/api/sp-verify', '/api/menu-select',
'/api/ram-install', '/api/ram-install-app', '/api/cap-compat',
'/api/sp-verify', '/api/menu-select',
'/api/menu-respond', '/api/event-send', '/api/net-sim',
'/api/net-state-refresh', '/api/status-poll', '/api/rescue',
'/api/terminal-profile', '/api/poll-toggle', '/api/esim/chip',
@@ -6596,21 +6684,6 @@ class PysimHandler(BaseHTTPRequestHandler):
# Parse .cap file
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# Optional diagnostic: rewrite the Import versions the card is
# asked to satisfy before the LOAD chain is built (v3.6.42)
probe_applied = None
probe = body.get('probe_imports')
if probe:
try:
loadfile_data, probe_applied = _probe_import_versions(loadfile_data, probe)
except ValueError as e:
resp = {'error': 'import probe: %s' % e}
self._send_json(resp, 400)
self._log_resp(resp)
return
sys.stderr.write('RAM-INSTALL: import probe applied: %s\n'
% json.dumps(probe_applied))
# SCP80 params
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
@@ -6691,7 +6764,6 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': (state['encode_error'] or state['failure'].get('error')
or ('%s failed' % step_name)),
'probe_imports': probe_applied,
'final_cntr': state['cntr'], 'ram_format': ram_format,
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'load_block_size': block_size,
@@ -6705,8 +6777,7 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': state['cntr'],
'ram_format': ram_format, 'probe_imports': probe_applied,
'load_block_size': block_size,
'ram_format': ram_format, 'load_block_size': block_size,
'load_block_size_requested': block_size_req,
'load_block_size_auto': not block_size_req}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
@@ -6795,6 +6866,116 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/cap-compat':
# CAP compatibility test (v3.6.48): run the load only as far as the
# block that completes the Import component - the JCRE verifies the
# import list there, so the card's answer to that block says
# whether it can satisfy the CAP's imports. Nothing is committed
# (no last-block flag, no INSTALL [for install]) and the probe
# overrides/additions are applied to the load file first.
body = self._read_body()
self._log_req(body)
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
try:
cap_hex = body.get('cap_hex', '').replace(' ', '')
if not cap_hex:
self._send_json({'error': 'No cap_hex provided'}, 400)
return
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
probe_applied = None
probe = body.get('probe_imports') or {}
additions = body.get('probe_additions') or []
if probe or additions:
try:
loadfile_data, probe_applied = _probe_import_versions(
loadfile_data, probe, additions)
except ValueError as e:
resp = {'error': 'import probe: %s' % e}
self._send_json(resp, 400)
self._log_resp(resp)
return
sys.stderr.write('CAP-COMPAT: import probe applied: %s\n'
% json.dumps(probe_applied))
block_size_req = body.get('load_block_size')
if block_size_req in (None, ''):
block_size_req = None
else:
try:
block_size_req = int(block_size_req)
except (TypeError, ValueError):
block_size_req = -1
if not 1 <= block_size_req <= 240:
err = {'success': False, 'error': 'load_block_size must be 1..240'}
self._send_json(err, 400)
self._log_resp(err)
return
block_size = block_size_req or 240
boundary, total_blocks = _cap_compat_blocks(loadfile_data, block_size)
sys.stderr.write('CAP-COMPAT: %s — the import list ends in LOAD %d/%d '
'(block size %d)\n' % (loadfile_aid, boundary,
total_blocks, block_size))
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '00000000')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
seq = _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data,
block_size=block_size)
run = seq[:1 + boundary] # INSTALL [for load] + boundary blocks
_ram_progress_begin('cap-compat', len(run) + 1)
try:
ram_format = _ram_normalize_format(body.get('ram_format'))
if ram_format == 'auto':
_ram_progress_step(0, 'FORMAT CHECK')
ram_format = _ram_detect_format(self.server, scc, sp_state, state)
ok = True
for apdu_idx, gp_apdu in enumerate(run):
step_name = ('INSTALL [for load]' if apdu_idx == 0
else 'LOAD (%d/%d)' % (apdu_idx, total_blocks))
_ram_progress_step(apdu_idx + 1, step_name)
if not _ram_send_gp_apdu(self.server, scc, sp_state, state,
step_name,
_ram_format_apdu(gp_apdu, ram_format)):
ok = False
break
finally:
_ram_progress_end()
resp = {'success': ok, 'imports_ok': ok, 'steps': steps,
'boundary_block': boundary, 'total_blocks': total_blocks,
'load_file_aid': loadfile_aid, 'module_aid': module_aid,
'ram_format': ram_format, 'probe_imports': probe_applied,
'final_cntr': state['cntr'], 'load_block_size': block_size,
'load_block_size_requested': block_size_req}
if not ok:
resp['error'] = (state['encode_error'] or state['failure'].get('error')
or 'the card rejected the import list')
resp['failed_step'] = len(steps)
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('CAP-COMPAT error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/scp81/bip':
body = self._read_body()
# Never log the pre-shared keys.