Hoernchen has uploaded this change for review.

View Change

GP: LOAD/STORE DATA chunk size from SCP overhead

SCP.overhead was so far set at construction time (SCP02: 8, SCP03:
s_mode), so the C-MAC length only.
Unfortunately sec lvl >= 3 pads the data field to the cipher block size
before encryption, so the real worst-case overhead is larger,
scc.max_cmd_len (255 - overhead) was too big, and ADF_SD.load()
used a hardcoded chunk_len=240.

Real world issue with a 286 byte CAP + SCP02 + sec lvl 3:
- 240-byte LOAD block is padded to 248,
- encrypted
- gets 8 byte C-MAC appended
-> Lc = 256
That dies with a weird "ValueError: bytes must be in range(0, 256)".
The only "fix" for that was to downgrade the seclevel.

STORE DATA has the same overflow with large max_cmd_len
(247 + padding + MAC = 256 as well).

Therefore the overhead must be properly calculated from the sec level.

While at it adjust the error in case I missed something to get a more
useful ValueError.

Change-Id: Ic208f3959a38896f64fb6ccefb24cc360a3ac3a2
---
M pySim/global_platform/__init__.py
M pySim/global_platform/scp.py
M tests/unittests/test_globalplatform.py
3 files changed, 220 insertions(+), 12 deletions(-)

git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/72/43172/1
diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py
index 31f3907..d14c6ff 100644
--- a/pySim/global_platform/__init__.py
+++ b/pySim/global_platform/__init__.py
@@ -868,23 +868,32 @@
load_parser_from_grp.add_argument('--from-hex', type=is_hexstr, help='load from hex string')
load_parser_from_grp.add_argument('--from-file', type=argparse.FileType('rb', 0), help='load from binary file')
load_parser_from_grp.add_argument('--from-cap-file', type=argparse.FileType('rb', 0), help='load from JAVA-card CAP file')
+ load_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
+ help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')

@cmd2.with_argparser(load_parser)
def do_load(self, opts):
"""Perform a GlobalPlatform LOAD command. (We currently only support loading without DAP and
without ciphering.)"""
if opts.from_hex is not None:
- self.load(h2b(opts.from_hex))
+ self.load(h2b(opts.from_hex), opts.chunk_len)
elif opts.from_file is not None:
- self.load(opts.from_file.read())
+ self.load(opts.from_file.read(), opts.chunk_len)
elif opts.from_cap_file is not None:
cap = CapFile(opts.from_cap_file)
- self.load(cap.get_loadfile())
+ self.load(cap.get_loadfile(), opts.chunk_len)
else:
raise ValueError('load source not specified!')

- def load(self, contents:bytes, chunk_len:int = 240):
- # TODO:tune chunk_len based on the overhead of the used SCP?
+ def load(self, contents:bytes, chunk_len:Optional[int] = None):
+ # scc.max_cmd_len knows the overhead the currently active SCP
+ # 240 is the old default, keep it for now.
+ max_chunk_len = self._cmd.lchan.scc.max_cmd_len
+ if chunk_len is None:
+ chunk_len = min(240, max_chunk_len)
+ elif not 1 <= chunk_len <= max_chunk_len:
+ raise ValueError('chunk_len must be in range 1..%u (limited by the overhead of the current secure channel)' %
+ max_chunk_len)
# build TLV according to GPC_SPE_034 section 11.6.2.3 / Table 11-58 for unencrypted case
remainder = b'\xC4' + bertlv_encode_len(len(contents)) + contents
# transfer this in various chunks to the card
@@ -923,6 +932,8 @@
install_cap_parser_inst_prm_grp.add_argument('--install-parameters-stk',
type=is_hexstr, default=None,
help='Load Parameters (ETSI TS 102 226, section 8.2.1.3.2.1)')
+ install_cap_parser.add_argument('--chunk-len', type=auto_uint8, default=None,
+ help='Block size for the LOAD command; default: as large as the current secure channel overhead permits, at most 240')

@cmd2.with_argparser(install_cap_parser)
def do_install_cap(self, opts):
@@ -961,7 +972,7 @@
self._cmd.poutput("step #1: install for load...")
self.do_install_for_load("--load-file-aid %s --security-domain-aid %s" % (load_file_aid, security_domain_aid))
self._cmd.poutput("step #2: load...")
- self.load(load_file)
+ self.load(load_file, opts.chunk_len)
self._cmd.poutput("step #3: install_for_install (and make selectable)...")
self.do_install_for_install("--load-file-aid %s --module-aid %s --application-aid %s --install-parameters %s --make-selectable" %
(load_file_aid, module_aid, application_aid, install_parameters))
diff --git a/pySim/global_platform/scp.py b/pySim/global_platform/scp.py
index a5fcf51..7573b47 100644
--- a/pySim/global_platform/scp.py
+++ b/pySim/global_platform/scp.py
@@ -182,6 +182,20 @@
"""Should we perform R-ENC?"""
return self.security_level & 0x20

+ @property
+ def overhead(self) -> int:
+ """Worst-case len that wrapping a command APDU adds to its data field at the
+ current sec level is (255 - overhead), C-MAC + C-DECRYPTION encryption padding."""
+ if not self.do_cmac:
+ return 0
+ if not self.do_cenc:
+ return self.mac_len
+ # C-DECRYPTION pads with ('80'+['00'...] at least 1 byte) up to
+ # the cipher block size + C-MAC on top -> largest usable data field
+ # is one byte less than the largest block-size multiple within 255 - mac_len.
+ bs = self.sk.blocksize
+ return 255 - ((255 - self.mac_len) // bs * bs - 1)
+
def __str__(self) -> str:
return "%s[%02x]" % (self.__class__.__name__, self.security_level)

@@ -260,10 +274,8 @@
# Key Version Number 0x70 is a non-spec special-case of sysmoISIM-SJA2/SJA5 and possibly more sysmocom products
# Key Version Number 0x01 is a non-spec special-case of sysmoUSIM-SJS1
kvn_ranges = [[0x01, 0x01], [0x20, 0x2f], [0x70, 0x70]]
-
- def __init__(self, *args, **kwargs):
- self.overhead = 8
- super().__init__(*args, **kwargs)
+ # C-MAC (Single DES + final 3DES, B.1.2.2) is always one full DES block
+ mac_len = 8

def dek_encrypt(self, plaintext:bytes) -> bytes:
# See also GPC section B.1.1.2, E.4.7, and E.4.1
@@ -338,10 +350,16 @@
# CMAC on modified APDU
mlc = lc + 8
clac = cla | CLA_SM
+ if mlc >= 256:
+ raise ValueError('Modified Lc (%u) would exceed maximum when appending 8 bytes of mac' % mlc)
mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + data)
if self.do_cenc:
+ padded_data = pad80(data, 8)
+ if len(padded_data) + 8 >= 256:
+ raise ValueError('Modified Lc (%u) would exceed maximum when appending padding and mac' %
+ (len(padded_data) + 8))
k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8)
- data = k.encrypt(pad80(data, 8))
+ data = k.encrypt(padded_data)
lc = len(data)

lc += 8
@@ -477,9 +495,13 @@

def __init__(self, *args, **kwargs):
self.s_mode = kwargs.pop('s_mode', 8)
- self.overhead = self.s_mode
super().__init__(*args, **kwargs)

+ @property
+ def mac_len(self) -> int:
+ # C-MAC truncated to 8 in S8 or 16 bytes in S16 mode
+ return self.s_mode
+
def dek_encrypt(self, plaintext:bytes) -> bytes:
cipher = AES.new(self.card_keys.dek, AES.MODE_CBC, b'\x00'*16)
return cipher.encrypt(plaintext)
diff --git a/tests/unittests/test_globalplatform.py b/tests/unittests/test_globalplatform.py
index d016d7d..98ff02e 100644
--- a/tests/unittests/test_globalplatform.py
+++ b/tests/unittests/test_globalplatform.py
@@ -424,5 +424,180 @@
load_parameters = gen_install_parameters()
self.assertEqual(load_parameters, 'c900')

+class SCP_Overhead_Test(unittest.TestCase):
+ """SCP.overhead varies according to the current security level:
+ C-MAC + at level >= 3 the worst-case padding!
+ """
+
+ def _scp02(self, security_level):
+ scp = SCP02(card_keys=ck_3des_70)
+ scp.sk = Scp02SessionKeys(0x0001, ck_3des_70)
+ scp.security_level = security_level
+ return scp
+
+ def _scp03(self, security_level, s_mode=8):
+ scp = SCP03(card_keys=KEYSET_AES128, s_mode=s_mode)
+ scp.sk = Scp03SessionKeys(KEYSET_AES128, b'\x00' * s_mode, b'\x11' * s_mode)
+ scp.security_level = security_level
+ return scp
+
+ def test_scp02(self):
+ self.assertEqual(self._scp02(0x00).overhead, 0) # no wrapping at all
+ self.assertEqual(self._scp02(0x01).overhead, 8) # C-MAC
+ self.assertEqual(self._scp02(0x03).overhead, 16) # C-MAC + C-DEC: pad80 to 8, largest fit 239
+
+ def test_scp03_s8(self):
+ self.assertEqual(self._scp03(0x00).overhead, 0)
+ self.assertEqual(self._scp03(0x01).overhead, 8)
+ self.assertEqual(self._scp03(0x03).overhead, 16) # pad80 to 16 within 247 -> 240, minus pad byte
+ self.assertEqual(self._scp03(0x33).overhead, 16) # R-MAC/R-ENC add no *command* overhead
+
+ def test_scp03_s16(self):
+ self.assertEqual(self._scp03(0x01, s_mode=16).overhead, 16)
+ self.assertEqual(self._scp03(0x03, s_mode=16).overhead, 32) # pad80 to 16 within 239 -> 224, minus pad byte
+
+
+class SCP_Lc_Limit_Test_Base:
+ """Test wrap_cmd_apdu() boundary handling: data of (255 - overhead) must produce Lc <= 255 else ValueError"""
+
+ def _load_apdu(self, data_len):
+ return h2b('80E80000') + bytes([data_len]) + b'\xa5' * data_len
+
+ def _check_boundary(self, scp):
+ fits = 255 - scp.overhead
+ wrapped = scp.wrap_cmd_apdu(self._load_apdu(fits))
+ self.assertLessEqual(wrapped[4], 255)
+ self.assertEqual(len(wrapped), 5 + wrapped[4]) # case #3: header + Lc bytes, no Le
+ with self.assertRaises(ValueError) as ctx:
+ scp.wrap_cmd_apdu(self._load_apdu(fits + 1))
+ self.assertIn('Lc', str(ctx.exception))
+
+
+class SCP02_Lc_Limit_Test(SCP_Lc_Limit_Test_Base, unittest.TestCase):
+ """Same session vectors as SCP02_Auth_Test"""
+
+ def setUp(self):
+ self.scp02 = SCP02(card_keys=ck_3des_70)
+ self.scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
+ self.scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
+ self.scp02.gen_ext_auth_apdu()
+
+ def test_cmac_only(self):
+ self.scp02.security_level = 0x01
+ self._check_boundary(self.scp02) # 247 fits, 248 raises
+
+ def test_cmac_cdec(self):
+ self.scp02.security_level = 0x03
+ self._check_boundary(self.scp02) # 239 fits (-> Lc 248), 240 raises (would be 256)
+
+ def test_cmac_cdec_wrapped_lc(self):
+ # my actual failing case: 240 bytes at level 3
+ self.scp02.security_level = 0x03
+ wrapped = self.scp02.wrap_cmd_apdu(self._load_apdu(239))
+ self.assertEqual(wrapped[4], 248) # 239 -> pad80 -> 240 ciphertext + 8 mac
+
+
+class SCP03_Lc_Limit_Test(SCP_Lc_Limit_Test_Base, unittest.TestCase):
+ """Session keys derived directly"""
+
+ def _scp03(self, security_level, s_mode):
+ scp = SCP03(card_keys=KEYSET_AES128, s_mode=s_mode)
+ scp.sk = Scp03SessionKeys(KEYSET_AES128, b'\x00' * s_mode, b'\x11' * s_mode)
+ scp.security_level = security_level
+ return scp
+
+ def test_s8_cmac_only(self):
+ self._check_boundary(self._scp03(0x01, 8)) # 247 fits, 248 raises
+
+ def test_s8_cmac_cdec(self):
+ self._check_boundary(self._scp03(0x03, 8)) # 239 fits, 240 raises
+
+ def test_s16_cmac_only(self):
+ self._check_boundary(self._scp03(0x01, 16)) # 239 fits, 240 raises
+
+ def test_s16_cmac_cdec(self):
+ self._check_boundary(self._scp03(0x03, 16)) # 223 fits, 224 raises
+
+
+class _FakeSccForLoad:
+ """mock lchan.scc: records LOAD APDUs, optionally wrapping them through a real SCP
+ instance first where the Lc overflow used to blow up"""
+
+ def __init__(self, max_cmd_len=255, scp=None):
+ self.max_cmd_len = max_cmd_len
+ self.scp = scp
+ self.sent = []
+ self.wrapped = []
+
+ def send_apdu_checksw(self, apdu, sw='9000'):
+ self.sent.append(apdu.lower())
+ if self.scp:
+ self.wrapped.append(self.scp.wrap_cmd_apdu(h2b(apdu)))
+ return ('', '9000')
+
+
+class Load_ChunkLen_Test(unittest.TestCase):
+ """ADF_SD.load() chunking: block size must use scc.max_cmd_len"""
+
+ payload = b'\xaa' * 500 # actual real world case LOAD TLV: C4 + 8201f4 + 500 = 504 total
+
+ def _sd(self, scc):
+ cmd = type('_Cmd', (), {'lchan': type('_Lchan', (), {'scc': scc})(),
+ 'poutput': lambda self, *args: None})()
+ # cmd2 CommandSet has a r/o _cmd property -> shadow it
+ _SD = type('_SD', (ADF_SD.AddlShellCommands,), {'_cmd': cmd})
+ return _SD.__new__(_SD)
+
+ def _blocks(self, scc):
+ """Get (p1, p2, lc) from LOAD APDU"""
+ for apdu in scc.sent:
+ self.assertEqual(apdu[0:4], '80e8')
+ yield int(apdu[4:6], 16), int(apdu[6:8], 16), int(apdu[8:10], 16)
+
+ def test_default_no_scp(self):
+ """Without SCP the old 240 byte block size is kept, no idea what else might rely on this number"""
+ scc = _FakeSccForLoad(max_cmd_len=255)
+ self._sd(scc).load(self.payload)
+ blocks = list(self._blocks(scc))
+ self.assertEqual([b[2] for b in blocks], [240, 240, 24])
+ self.assertEqual([b[0] for b in blocks], [0x00, 0x00, 0x80]) # P1: last block flagged
+ self.assertEqual([b[1] for b in blocks], [0, 1, 2]) # P2: block num
+
+ def test_default_scp02_level3(self):
+ """max_cmd_len 239 (SCP02 lvl 3) squeezes the blocks"""
+ scc = _FakeSccForLoad(max_cmd_len=239)
+ self._sd(scc).load(self.payload)
+ self.assertEqual([b[2] for b in list(self._blocks(scc))], [239, 239, 26])
+
+ def test_explicit_chunk_len(self):
+ scc = _FakeSccForLoad(max_cmd_len=255)
+ self._sd(scc).load(self.payload, chunk_len=100)
+ self.assertEqual([b[2] for b in list(self._blocks(scc))], [100] * 5 + [4])
+
+ def test_explicit_chunk_len_too_large(self):
+ scc = _FakeSccForLoad(max_cmd_len=239)
+ with self.assertRaises(ValueError):
+ self._sd(scc).load(self.payload, chunk_len=240)
+ self.assertEqual(scc.sent, []) # nothing sent!
+
+ def test_explicit_chunk_len_zero(self):
+ scc = _FakeSccForLoad(max_cmd_len=255)
+ with self.assertRaises(ValueError):
+ self._sd(scc).load(self.payload, chunk_len=0)
+
+ def test_end_to_end_scp02_level3(self):
+ """original failure: 286 byte CAP + SCP02 lvl 3"""
+ scp02 = SCP02(card_keys=ck_3des_70)
+ scp02.gen_init_update_apdu(host_challenge=h2b('40A62C37FA6304F8'))
+ scp02.parse_init_update_resp(h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3'))
+ scp02.gen_ext_auth_apdu()
+ scp02.security_level = 0x03
+ scc = _FakeSccForLoad(max_cmd_len=255 - scp02.overhead, scp=scp02)
+ self._sd(scc).load(b'\x5a' * 286)
+ self.assertEqual(len(scc.sent), 2) # 289 byte TLV in blocks of 239
+ for wrapped in scc.wrapped:
+ self.assertLessEqual(wrapped[4], 255)
+
+
if __name__ == "__main__":
unittest.main()

To view, visit change 43172. To unsubscribe, or for help writing mail filters, visit settings.

Gerrit-MessageType: newchange
Gerrit-Project: pysim
Gerrit-Branch: master
Gerrit-Change-Id: Ic208f3959a38896f64fb6ccefb24cc360a3ac3a2
Gerrit-Change-Number: 43172
Gerrit-PatchSet: 1
Gerrit-Owner: Hoernchen <ewild@sysmocom.de>