diff --git a/docs/scp81-findings.md b/docs/scp81-findings.md
index 473bb11..c2f5772 100644
--- a/docs/scp81-findings.md
+++ b/docs/scp81-findings.md
@@ -197,6 +197,22 @@ continuation with `D276000005AA060200000000B00000` -> page 2 `SW 9000`
`D276000005AAFFCAFE0001/0010`, `A0000001515350`, `A000000151535041`).
Full session: 7/7 commands, all `X-Admin-Script-Status: ok`.
+## RESOLVED 2026-09-16c: RAM install over SCP81 - BER length in the script template
+
+**Root cause:** `_scp81_command_body` wrote the C-APDU TLV length as a raw
+byte (`AE 80 22 F5 <245 bytes> 00 00` for a 245-byte LOAD). BER reads a byte
+above 0x7F as a long-form marker, so the card mis-parsed every LOAD >128
+bytes; the small INSTALL commands (<128 bytes) executed normally, which made
+the install look alive. Symptoms: the card accepted the LOAD responses with
+`X-Admin-Script-Status: ok` but sent a degenerate `AF 80` body, no LOAD
+R-APDU appeared in the results, and the final INSTALL [for install] answered
+`6A88` (module not found) because the package was never loaded.
+
+**Fix:** both the indefinite ("22" TLV) and definite ("AA" outer) template
+lengths are BER-encoded (`_ber_len_bytes`); the same rule as the BIP channel
+data TLV fix earlier the same day. Tests cover the 245-byte LOAD body, the
+short-form case and the definite variant.
+
## Next tests / work
1. **UI:** group the per-page R-APDUs under their logical command in the
diff --git a/frontend/index.html b/frontend/index.html
index 49db8de..cd6633d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,7 +18,7 @@
-
OTAMan SIM OTA with a Human Face v2.1.10
+
OTAMan SIM OTA with a Human Face v2.1.11
●
diff --git a/frontend/sw.js b/frontend/sw.js
index 2780346..587101b 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v147';
+const CACHE = 'otaman-v148';
const URLS = [
'index.html',
'help.html',
diff --git a/pyproject.toml b/pyproject.toml
index a948345..5433bef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "2.1.10"
+version = "2.1.11"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index 42b1d98..2baae70 100644
--- a/pysim_otaman_server/server.py
+++ b/pysim_otaman_server/server.py
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
-VERSION = '2.1.10'
+VERSION = '2.1.11'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1436,16 +1436,27 @@ _SCP81_CHUNKED = False
_SCP81_LINK_EVENTS = True
+def _ber_len_bytes(n):
+ """BER-TLV length as bytes (short form up to 127, then 0x81/0x82)."""
+ if n < 0x80:
+ return bytes([n])
+ if n < 0x100:
+ return bytes([0x81, n])
+ return bytes([0x82, n >> 8, n & 0xFF])
+
+
def _scp81_command_body(apdu_hex, definite=False, cr_tag=False):
"""Command Scripting template with one C-APDU TLV: the indefinite-length
variant ('AE 80 22 00 00', recommended for RAM over HTTPS) or
the definite-length one ('AA 22 '). The C-APDU TLV tag
is '22' per TS 101 220 (CR flag 0); some cards expect the CR-set 'A2'
- instead, so it is configurable."""
+ instead, so it is configurable. Both lengths are BER-encoded: a raw byte
+ above 0x7F is read as a long-form marker by the card, which garbles the
+ script (the 245-byte LOAD commands of a RAM install, live 2026-09-16)."""
apdu = bytes.fromhex(re.sub(r'\s', '', apdu_hex))
- cmd_tlv = bytes([0xA2 if cr_tag else 0x22, len(apdu)]) + apdu
+ cmd_tlv = bytes([0xA2 if cr_tag else 0x22]) + _ber_len_bytes(len(apdu)) + apdu
if definite:
- return bytes([0xAA, len(cmd_tlv)]) + cmd_tlv
+ return bytes([0xAA]) + _ber_len_bytes(len(cmd_tlv)) + cmd_tlv
return bytes([0xAE, 0x80]) + cmd_tlv + b'\x00\x00'
diff --git a/tests/test_scp81.py b/tests/test_scp81.py
index 035ceb7..0b59ecf 100644
--- a/tests/test_scp81.py
+++ b/tests/test_scp81.py
@@ -790,3 +790,26 @@ class QueueScriptTest(unittest.TestCase):
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_KIND = 'explore'
+
+
+class ScriptBodyLengthTest(unittest.TestCase):
+ def test_long_c_apdu_uses_ber_long_form(self):
+ from pysim_otaman_server.server import _scp81_command_body
+ apdu = '80E80000F0' + 'AB' * 239 + '00' # exactly 245-byte LOAD
+ body = _scp81_command_body(apdu)
+ # AE 80 22 81 F5 <245 bytes> 00 00
+ self.assertEqual(body[:5].hex().upper(), 'AE802281F5')
+ self.assertEqual(body[-2:].hex().upper(), '0000')
+ self.assertEqual(len(body), 5 + 245 + 2)
+
+ def test_short_apdu_stays_short_form(self):
+ from pysim_otaman_server.server import _scp81_command_body
+ self.assertEqual(_scp81_command_body('80CAFF2100').hex().upper(),
+ 'AE80220580CAFF21000000')
+
+ def test_definite_variant_ber_lengths(self):
+ from pysim_otaman_server.server import _scp81_command_body
+ apdu = 'AB' * 130
+ body = _scp81_command_body(apdu, definite=True)
+ # AA 81 85 22 81 82 <130 bytes> (outer 1+2+130 = 133 = 0x85)
+ self.assertEqual(body[:6].hex().upper(), 'AA8185228182')