ota: indefinite length en/decoding support

Adds TS 102 226 tables 5.2a/5.10a indefinite length coding,
recommended by TS 102 226 5.2.1 for RAM/RFM over HTTPS.

Noteworthy notable things to note:
- encode_expanded_cmd()
  indefinite version -> inner C-APDU TLVs are still definite
- decode_expanded_resp()s returned container does not care, but
  Indef has no 'number of executed commands' TLV -> number_of_commands
  is the R-APDU count.

Tests are being fed with some known-good values from my sja5 sessions.

Change-Id: I4e023112e98729489ed443eec3ed5ab45c773b17
This commit is contained in:
Eric Wild
2026-09-23 17:51:04 +02:00
parent 6313b83e0e
commit 6076e4e6ff
2 changed files with 140 additions and 17 deletions
+62 -17
View File
@@ -19,7 +19,7 @@ import zlib
import abc
import struct
from typing import Optional, Tuple, List, Union
from construct import Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct
from construct import ConstructError, Enum, Int8ub, Int16ub, Struct, BitsInteger, BitStruct
from construct import Flag, Padding, Switch, this, PrefixedArray, GreedyRange
from construct import Const, Prefixed, Select, Construct, SizeofError, stream_read, stream_write
from osmocom.construct import *
@@ -67,6 +67,8 @@ CompactRemoteResp = Struct('number_of_commands'/Int8ub,
# 5.2.1.4 Script Chaining TLV
# 5.2.2 Expanded Remote response structure (tables 5.10 .. 5.16)
#
# definite length coding and indefinite length coding are supported.
#
# BER-TLV tag values from ETSI TS 101 220 V19.0.0 tables 7.18, 7.19, 7.20
# C-APDU / R-APDU ETSI TS 102 223 Section 8.35 + 8.36
# inside these the CR flag of the tag is 0 (TS 101 220 tables 7.19/7.20),
@@ -112,16 +114,31 @@ class _RApduValueAdapter(Adapter):
def _encode(self, obj, context, path):
return h2b(obj['response_data']) + h2b(obj['status_word'])
#### Command Scripting template TS 102 226 table 5.2, TS 101 220 tables 7.18/7.19
#### Command Scripting template TS 102 226 tables 5.2 / 5.2a, TS 101 220 tables 7.18/7.19
#
# The two TS 101 220 table 7.18 length codings use different template tags:
# - definite tag AA
# - indefinite AE
# In both codings the inner Command TLVs use definite length coding, only the
# surrounding template differs.
# TS 102 223 8.35
ExpandedC_APDU = Struct('_tag'/Const(b'\x22'),
'c_apdu'/Prefixed(BerTlvLen, HexAdapter(GreedyBytes)))
ExpandedCmd = Struct('_tag'/Const(b'\xaa'),
'commands'/Prefixed(BerTlvLen, GreedyRange(ExpandedC_APDU)))
# shared by both length codings.
ExpandedCmdItems = GreedyRange(ExpandedC_APDU)
#### Response Scripting template TS 102 226 tables 5.10-5.16, TS 101 220 table 7.20
# TS 102 226 table 5.2: Command Scripting template, definite length coding only
ExpandedCmd = Struct('_tag'/Const(b'\xaa'),
'commands'/Prefixed(BerTlvLen, ExpandedCmdItems))
# TS 102 226 table 5.2a: indefinite length coding, 'AE 80 <C-APDU TLVs> 00 00'. GreedyRange
# stops at the first octet that is not a C-APDU tag, which is the end-of-contents marker.
ExpandedCmdIndef = Struct('_tag'/Const(b'\xae'), '_indef'/Const(b'\x80'),
'commands'/ExpandedCmdItems, '_eoc'/Const(b'\x00\x00'))
#### Response Scripting template TS 102 226 5.2.2, tables 5.10-5.16, TS 101 220 table 7.20
# TS 102 223 8.36
ExpandedR_APDU = Struct('_tag'/Const(b'\x23'),
@@ -148,39 +165,57 @@ ExpandedScriptChainingResp = Struct('_tag'/Const(b'\x83'),
Enum(Int8ub, no_previous_script=1,
not_supported=2, unable_to_process=3)))
# response TLVs shared by the def and indef Response Scripting templates
ExpandedRespItems = GreedyRange(Select(ExpandedR_APDU,
ExpandedBadFormat,
ExpandedImmediateActionResp,
ExpandedScriptChainingResp))
# - starts with the "Number of executed command TLV objects" (table 5.10/5.13/5.15)
# - followed by a sequence of R-APDU TLVs
# - and/or one of the error # response TLVs
ExpandedRemoteResp = Struct('_tag'/Const(b'\xab'),
'body'/Prefixed(BerTlvLen, Struct(
'num_executed'/ExpandedNumExecuted,
'responses'/GreedyRange(Select(ExpandedR_APDU,
ExpandedBadFormat,
ExpandedImmediateActionResp,
ExpandedScriptChainingResp)))))
'responses'/ExpandedRespItems)))
# TS 102 226 table 5.10a: indefinite length coding, no "number of executed" TLV
ExpandedRemoteRespIndef = Struct('_tag'/Const(b'\xaf'), '_indef'/Const(b'\x80'),
'responses'/ExpandedRespItems, '_eoc'/Const(b'\x00\x00'))
def encode_expanded_cmd(apdus: Union[bytes, List[bytes]]) -> bytes:
"""builds the Command Scripting template, TS 102 226 5.2.1, definite length coding
def encode_expanded_cmd(apdus: Union[bytes, List[bytes]],
length_coding: str = 'definite') -> bytes:
"""builds the Command Scripting template, TS 102 226 5.2.1
Args:
apdus: single C-APDU bytes or list of C-APDUs bytes. Each
C-APDU is wrapped into a C-APDU TLV- This function does not add
or modify Le.
length_coding: 'definite' (the default, tag 'AA', table 5.2) or
'indefinite' (tag 'AE', table 5.2a: 'AE 80 <cmd TLVs> 00 00').
Inner C-APDU TLVs use definite length coding in both cases.
Returns:
encoded Command Scripting template (AA...) as bytes
encoded Command Scripting template as bytes
"""
if isinstance(apdus, (bytes, bytearray)):
apdus = [apdus]
return ExpandedCmd.build({'commands': [{'c_apdu': b2h(a)} for a in apdus]})
commands = [{'c_apdu': b2h(a)} for a in apdus]
if length_coding == 'definite':
return ExpandedCmd.build({'commands': commands})
if length_coding == 'indefinite':
return ExpandedCmdIndef.build({'commands': commands})
raise ValueError("Invalid length_coding: %r" % length_coding)
def decode_expanded_resp(data: bytes) -> Container:
"""Decode a Response Scripting template, TS 102 226 5.2.2 definite length
"""Decode a Response Scripting template, TS 102 226 5.2.2 def and indef length
coding
returned Container has:
number_of_commands -- "number of executed command TLV objects" table 5.11
for definite coding. indefinite coding does not have
this TLV, so report the number of returned R-APDUs instead.
commands -- list of Containers, one per R-APDU TLV, each
with 'response_data' and 'status_word' hexstr
last_response_data -- response_data of the last R-APDU or ''
@@ -198,12 +233,22 @@ def decode_expanded_resp(data: bytes) -> Container:
CompactRemoteResp so existing callers keep working."""
if isinstance(data, str):
data = h2b(data)
parsed = ExpandedRemoteResp.parse(data)
try:
if data[:1] == b'\xaf':
responses = ExpandedRemoteRespIndef.parse(data)['responses']
num_executed = None
else:
parsed = ExpandedRemoteResp.parse(data)
responses = parsed['body']['responses']
num_executed = parsed['body']['num_executed']['number_of_commands']
except ConstructError as e:
raise ValueError('malformed Response Scripting template: %s' % e) from e
commands = []
bad_format = None
immediate_action_response = None
script_chaining_response = None
for item in parsed['body']['responses']:
for item in responses:
if 'r_apdu' in item:
commands.append(Container(response_data=item['r_apdu']['response_data'],
status_word=item['r_apdu']['status_word']))
@@ -215,7 +260,7 @@ def decode_expanded_resp(data: bytes) -> Container:
script_chaining_response = item['script_chaining_response']
# TS 102 226 5.2.1.1: 62F1 means response of a C-APDU was truncated, processing terminated
truncated = any(c['status_word'].lower() == '62f1' for c in commands)
return Container(number_of_commands=parsed['body']['num_executed']['number_of_commands'],
return Container(number_of_commands=num_executed if num_executed is not None else len(commands),
commands=commands,
last_response_data=commands[-1]['response_data'] if commands else '',
last_status_word=commands[-1]['status_word'] if commands else None,
+78
View File
@@ -451,6 +451,84 @@ class ExpandedRespTestCase(unittest.TestCase):
self.assertFalse(decode_expanded_resp(data).truncated)
class ExpandedIndefiniteTestCase(unittest.TestCase):
"""Indef len coding of expanded format TS 102 226 tables
5.2a/5.10a; cmd tag AE, resp tag AF.
Golden vectors captured from live eUICC over SCP81/HTTPS."""
def test_cmd_single_golden(self):
# RAM GET DATA 80CA00E000 -> AE 80 | 22 05 80ca00e000 | 00 00
out = encode_expanded_cmd(h2b('80ca00e000'), length_coding='indefinite')
self.assertEqual(b2h(out), 'ae80220580ca00e0000000')
def test_cmd_multi_golden(self):
# RFM: SELECT MF / SELECT EF.ICCID / READ BINARY, each in one C-APDU
# TLV, wrapped in indef Command Scripting template
out = encode_expanded_cmd([h2b('00a4000c023f00'), h2b('00a4000c022fe2'),
h2b('00b000000a')], length_coding='indefinite')
self.assertEqual(b2h(out),
'ae80220700a4000c023f00220700a4000c022fe2220500b000000a0000')
def test_cmd_definite_is_default(self):
# The default/explicit definite keeps the tag AA
self.assertEqual(encode_expanded_cmd(h2b('80ca00e000')),
encode_expanded_cmd(h2b('80ca00e000'), length_coding='definite'))
self.assertEqual(b2h(encode_expanded_cmd(h2b('80ca00e000'))), 'aa07220580ca00e000')
def test_cmd_invalid_length_coding(self):
with self.assertRaises(ValueError):
encode_expanded_cmd(h2b('80ca00e000'), length_coding='bogus')
def test_resp_rfm_golden(self):
# AF 80 | 23 02 9000 | 23 02 9000 | 23 0c <ICCID> 9000 | 00 00
# indef res has no "number of executed" TLV.
dec = decode_expanded_resp(h2b(
'af80' '23029000' '23029000' '230c988812010000408608149000' '0000'))
self.assertEqual(len(dec.commands), 3)
self.assertEqual([(c.status_word, c.response_data) for c in dec.commands],
[('9000', ''), ('9000', ''), ('9000', '98881201000040860814')])
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data, '98881201000040860814')
# report the R-APDU count instead
self.assertEqual(dec.number_of_commands, 3)
def test_resp_ram_golden(self):
# RAM GET DATA: R-APDU carrying the SD key info TLV + SW.
resp = ('af80' '2334e030c00403308810c00402308810c00401308810c00402408810'
'c00401408510c00403018810c00402018810c004010188109000' '0000')
dec = decode_expanded_resp(h2b(resp))
self.assertEqual(len(dec.commands), 1)
self.assertEqual(dec.last_status_word, '9000')
self.assertEqual(dec.last_response_data,
'e030c00403308810c00402308810c00401308810c00402408810'
'c00401408510c00403018810c00402018810c00401018810')
def test_resp_truncated_is_rejected(self):
# last byte chopped off: the end-of-contents marker is incomplete
good = h2b('af80' '23029000' '230c988812010000408608149000' '0000')
for cut in (1, 2, 3):
with self.subTest(cut=cut):
with self.assertRaises(ValueError):
decode_expanded_resp(good[:-cut])
def test_resp_definite_still_parses(self):
# same decoder still handles the definite AB template.
dec = decode_expanded_resp(h2b('ab0780010123029000'))
self.assertEqual(dec.number_of_commands, 1)
self.assertEqual(dec.last_status_word, '9000')
def test_resp_indefinite_bad_format(self):
# AF 80 | 90 01 01 | 00 00 unknown_tag no R-APDU
dec = decode_expanded_resp(h2b('af8090010100 00'.replace(' ', '')))
self.assertEqual(str(dec.bad_format), 'unknown_tag')
self.assertIsNone(dec.last_status_word)
def test_resp_missing_eoc_raises(self):
# AF 80 | 23 02 9000 without end-of-contents.
with self.assertRaises(ValueError):
decode_expanded_resp(h2b('af8023029000'))
class ExpandedSmsPipelineTestCase(unittest.TestCase):
"""expanded format + TS 102 225 SMS security witj 3DES keyset,
to ensure remote_format does not affect the compact path"""