v3.20.0 (fix): the install-parameter composition - one System Specific Parameters EF

Two reported SCP80/RAM issues, both confirmed against the specs:

- docs/api.md called `stk_params` a "Hex CA TLV", but TS 102 226 8.2.1.3.2.1
  puts the CA *inside* the System Specific Parameters EF ('EF'), and the PWA
  builds `EF <len> CA <len> ...` (the SIM access domain leads the CA value as
  `01 <ad>` / `00`).  A client following the doc sent a CA at the wrong level.
- the RAM composition appended the STK part after a caller-composed
  C9+EF(quotas) prefix, producing TWO EF objects.  The card read the first
  (the quotas) and ignored the CA: the install was accepted (por_ok) but the
  toolkit parameters were never registered, so no STK menu appeared (live
  2026-10-05).  Per GPD_SPE_013 v1.1 Table 6-5 (pySim's
  gen_install_parameters) the quotas (C7/C8) and the CA share ONE EF; an
  EA-form STK part is a sibling of EF (the reference TCA loader's
  `C9 00 EF 00 EA ...`).

- server: `_compose_install_params()` (used by `_cap_install_apdu` ->
  `_cap_apdu_sequence`, hence /api/ram-install, /api/ram-install-app,
  /api/scp81/gen-install and the PWA's install script template) enforces the
  rule: a caller-composed EF plus an EF-form `stk_params`, a bare CA, a
  malformed EF and quotas next to a non-empty `install_params` are refused
  (400 / `{"ok": false}` with the fix named); with an empty `install_params`
  the quotas and an EF-form CA are composed into one EF
  (`C900 EF{C7?,C8?,CA}`), an EA part stays a sibling.
- the documented `nv_quota`/`volatile_quota` fields are now real (they were
  named in the docs but implemented nowhere): integers composing C8/C7.
- PWA: `composeRamInstallParams()` composes the same single EF itself (a
  CA-form STK field takes the quotas into its EF, the merged EF travels in
  `stk_params`), the RAM install bodies send it, the placeholder shows it
  with a note when the quotas moved into the STK EF, and a refused request
  (no steps) reports the error instead of "FAILED at step: undefined".
- docs/api.md: the corrected `stk_params` description with both accepted
  shapes, the composition rule paragraph with the refusal cases, the fixed
  example (`C90000` -> `C900`), and the /api/ram-install-app and
  /api/scp81/gen-install cross-references.
- tests: composition unit tests (merge/refusals/EA-sibling) and the
  ram-install-app 400 paths (Python); `ramQuotaTlvs`/`composeRamInstallParams`/
  `ramInstallParamsCompose` pins incl. the CA merge (Node).
  Tests: 824 frontend + 740 Python; sw cache simple-v395.
This commit is contained in:
2026-10-05 23:01:42 +03:00
parent ff3feb6079
commit 82c5733906
7 changed files with 382 additions and 69 deletions
+139 -17
View File
@@ -724,16 +724,112 @@ def _encode_scts(dt=None):
])
def _single_ef_value(hex_str):
"""The value of a lone EF (System Specific Parameters) TLV, or None when
the input is not EF-form at all. A blob that starts with the EF tag but
does not parse as exactly one TLV is refused - appending it could add a
second EF object (TS 102 226 8.2.1.3.2.1), and a bare CA is refused too:
the CA belongs *inside* the EF."""
if not hex_str:
return None
try:
data = bytes.fromhex(hex_str)
except ValueError:
raise ValueError('stk_params is not valid hex')
if data[0] == 0xCA:
raise ValueError('the CA (SIM file access and toolkit parameters) '
'belongs inside the System Specific Parameters EF '
'(TS 102 226 8.2.1.3.2.1): send EF <len> CA ...')
if data[0] != 0xEF:
return None
ln, off = _ber_len_at(data, 1)
if off + ln != len(data):
raise ValueError('stk_params: malformed EF TLV (the length does not '
'match the value)')
return data[off:off + ln].hex().upper()
def _quota_tlv(tag, value):
"""A C7/C8 memory-quota TLV: the value is a 2-byte integer up to 32767
and a 4-byte integer above (GP Card Spec 9.7 / Table 11-49; pySim's
``StripHeaderAdapter(GreedyBytes, 4, steps=[2,4])``)."""
try:
v = int(str(value).strip(), 10)
except (TypeError, ValueError):
raise ValueError('%s quota must be an integer in bytes' % tag)
if not 0 <= v <= 0xFFFFFFFF:
raise ValueError('%s quota out of range (0..4294967295): %d' % (tag, v))
return tag + ('%02X%04X' % (2, v) if v <= 0x7FFF else '%02X%08X' % (4, v))
def _compose_install_params(install_params, stk_params, nv_quota=None,
volatile_quota=None):
"""The INSTALL [for install] parameter field, with the composition the
spec mandates enforced:
- the memory quotas (C7 volatile / C8 non-volatile) and an EF-form STK
part (the CA) share **one** System Specific Parameters EF (TS 102 226
8.2.1.3.2.1; GPD_SPE_013 v1.1 Table 6-5 - pySim's
``gen_install_parameters``);
- an EF-form STK part next to a caller-composed EF is refused: the card
takes the first EF (quotas) and the CA is ignored - live 2026-10-05:
por_ok but no toolkit registration, no menu;
- an EA-form (or opaque) STK part is a sibling of EF (the reference TCA
loader sends ``C9 00 EF 00 EA ...``);
- the quotas are composed from the primitives only when no
``install_params`` were given (there is no EF to place them into
otherwise).
Raises ValueError (-> HTTP 400) on a refusal.
"""
raw = (install_params or '').replace(' ', '')
stk = (stk_params or '').replace(' ', '')
has_quota = (str(nv_quota or '').strip() != ''
or str(volatile_quota or '').strip() != '')
stk_ef = _single_ef_value(stk)
if raw:
if has_quota:
raise ValueError('memory quotas are composed only when '
'install_params is empty; merge them into the '
'install EF instead')
try:
tlvs = _ber_tlv_list(bytes.fromhex(raw))
except ValueError:
raise ValueError('install_params is not valid hex')
if stk_ef is not None and any(tag == 0xEF for tag, _ in tlvs):
raise ValueError('the EF-form STK part would add a second System '
'Specific Parameters (EF) object (TS 102 226 '
'8.2.1.3.2.1): merge the CA into the install '
'parameters\' EF, or pass the quotas via '
'volatile_quota/nv_quota with an empty '
'install_params')
return (raw + stk).upper()
# Compose from the primitives: C9 is mandatory (GP Table 11-49).
inner = ''
if str(volatile_quota or '').strip() != '':
inner += _quota_tlv('C7', volatile_quota)
if str(nv_quota or '').strip() != '':
inner += _quota_tlv('C8', nv_quota)
if stk_ef is not None:
inner += stk_ef
stk = ''
params = 'C900'
if inner:
params += 'EF' + _ber_len(len(inner) // 2) + inner
return (params + stk).upper()
def _cap_install_apdu(loadfile_aid, module_aid, instance_aid='', privileges='00',
install_params='', stk_params='', make_selectable=True):
install_params='', stk_params='', make_selectable=True,
nv_quota=None, volatile_quota=None):
"""INSTALL [for install] APDU (GP Card Spec 11.5.2.3.2, Table 11-43) - the
final step of `_cap_apdu_sequence` and the /api/ram-install-app operation.
Case 3: no trailing Le (a trailing byte becomes a phantom command on the
card's SCP80 layer)."""
instance = instance_aid or module_aid
params = install_params if install_params else 'C900'
if stk_params:
params += stk_params
params = _compose_install_params(install_params, stk_params,
nv_quota=nv_quota,
volatile_quota=volatile_quota)
p1 = 0x0C if make_selectable else 0x04
data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
@@ -750,7 +846,8 @@ def _cap_make_selectable_apdu(instance_aid, privileges='00'):
def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
privileges='00', install_params='', stk_params='',
make_selectable=True, block_size=240, instance_aid=None):
make_selectable=True, block_size=240, instance_aid=None,
nv_quota=None, volatile_quota=None):
"""RAM (GP) APDU sequence for a parsed .cap: INSTALL [for load], LOAD
blocks (240-byte payloads, block counter in P2, last block P1=0x80),
INSTALL [for install]. Shared by the SCP80 delivery path and the SCP81
@@ -776,7 +873,8 @@ def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
apdus.append(_cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params,
stk_params=stk_params, make_selectable=make_selectable))
stk_params=stk_params, make_selectable=make_selectable,
nv_quota=nv_quota, volatile_quota=volatile_quota))
return apdus
@@ -3677,7 +3775,11 @@ def _scp81_gen_install(body):
privileges=re.sub(r'\s', '', body.get('privileges') or '') or '00',
install_params=re.sub(r'\s', '', body.get('install_params') or ''),
stk_params=re.sub(r'\s', '', body.get('stk_params') or ''),
make_selectable=bool(body.get('make_selectable', True)))
make_selectable=bool(body.get('make_selectable', True)),
nv_quota=body.get('nv_quota'), volatile_quota=body.get('volatile_quota'))
except ValueError as e:
# the install-parameter composition rule (or a CAP parse refusal)
return {'ok': False, 'error': str(e)}
except Exception as e:
return {'ok': False, 'error': 'cap parse failed: %s' % e}
_BIP.log('gen-install', apdus=len(seq), load_file_aid=loadfile_aid,
@@ -8111,12 +8213,24 @@ class PysimHandler(BaseHTTPRequestHandler):
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install]
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid,
privileges=privileges_hex,
install_params=install_params_hex, stk_params=stk_params_hex,
make_selectable=make_selectable, block_size=block_size)
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install].
# The install parameters are composed per the spec: an
# EF-form STK part shares one System Specific Parameters EF
# with the quotas (TS 102 226 8.2.1.3.2.1; v3.20.0) - an
# invalid combination is refused before anything is sent.
try:
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid,
privileges=privileges_hex,
install_params=install_params_hex, stk_params=stk_params_hex,
make_selectable=make_selectable, block_size=block_size,
nv_quota=body.get('nv_quota'),
volatile_quota=body.get('volatile_quota'))
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
# Live progress for the PWA's operation modal (the request holds
# _CARD_LOCK until the chain is done; /api/status carries this)
@@ -8226,10 +8340,18 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(err, 400)
self._log_resp(err)
return
apdu = _cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params_hex,
stk_params=stk_params_hex, make_selectable=make_selectable)
try:
apdu = _cap_install_apdu(
loadfile_aid, module_aid, instance_aid=instance_aid,
privileges=privileges, install_params=install_params_hex,
stk_params=stk_params_hex, make_selectable=make_selectable,
nv_quota=body.get('nv_quota'),
volatile_quota=body.get('volatile_quota'))
except ValueError as e:
err = {'success': False, 'error': str(e)}
self._send_json(err, 400)
self._log_resp(err)
return
step_name = 'INSTALL [for install]'
# SCP80 params: the SPI1 comes from the MSL of the packet's
# TAR (the operation uses the preset's table); a caller-supplied