diff --git a/docs/shell.rst b/docs/shell.rst index a917f8ff..dff6cd14 100644 --- a/docs/shell.rst +++ b/docs/shell.rst @@ -959,6 +959,16 @@ put_key :module: pySim.global_platform :func: ADF_SD.AddlShellCommands.put_key_parser +establish_scp02 +~~~~~~~~~~~~~~~ +.. argparse:: + :module: pySim.global_platform + :func: ADF_SD.AddlShellCommands.est_scp02_parser + +release_scp +~~~~~~~~~~~ +Release any previously established SCP (Secure Channel Protocol) + eUICC ISD-R commands -------------------- diff --git a/pySim-shell.py b/pySim-shell.py index 70eaee23..abe0b5fa 100755 --- a/pySim-shell.py +++ b/pySim-shell.py @@ -205,7 +205,11 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/ def update_prompt(self): if self.lchan: path_str = self.lchan.selected_file.fully_qualified_path_str(not self.numeric_path) - self.prompt = 'pySIM-shell (%02u:%s)> ' % (self.lchan.lchan_nr, path_str) + scp = self.lchan.scc.scp + if scp: + self.prompt = 'pySIM-shell (%s:%02u:%s)> ' % (str(scp), self.lchan.lchan_nr, path_str) + else: + self.prompt = 'pySIM-shell (%02u:%s)> ' % (self.lchan.lchan_nr, path_str) else: if self.card: self.prompt = 'pySIM-shell (no card profile)> ' @@ -258,6 +262,8 @@ Online manual available at https://downloads.osmocom.org/docs/pysim/master/html/ def do_reset(self, opts): """Reset the Card.""" atr = self.card.reset() + if self.lchan and self.lchan.scc.scp: + self.lchan.scc.scp = None self.poutput('Card ATR: %s' % i2h(atr)) self.update_prompt() diff --git a/pySim/commands.py b/pySim/commands.py index 2d0736eb..81edf822 100644 --- a/pySim/commands.py +++ b/pySim/commands.py @@ -69,6 +69,7 @@ class SimCardCommands: self.lchan_nr = lchan_nr # invokes the setter below self.cla_byte = "a0" + self.scp = None # Secure Channel Protocol def fork_lchan(self, lchan_nr: int) -> 'SimCardCommands': """Fork a per-lchan specific SimCardCommands instance off the current instance.""" @@ -110,7 +111,10 @@ class SimCardCommands: data : string (in hex) of returned data (ex. "074F4EFFFF") sw : string (in hex) of status word (ex. "9000") """ - return self._tp.send_apdu(pdu) + if self.scp: + return self.scp.send_apdu_wrapper(self._tp.send_apdu, pdu) + else: + return self._tp.send_apdu(pdu) def send_apdu_checksw(self, pdu: Hexstr, sw: SwMatchstr = "9000") -> ResTuple: """Sends an APDU and check returned SW @@ -124,7 +128,10 @@ class SimCardCommands: data : string (in hex) of returned data (ex. "074F4EFFFF") sw : string (in hex) of status word (ex. "9000") """ - return self._tp.send_apdu_checksw(pdu, sw) + if self.scp: + return self.scp.send_apdu_wrapper(self._tp.send_apdu_checksw, pdu, sw) + else: + return self._tp.send_apdu_checksw(pdu, sw) def send_apdu_constr(self, cla: Hexstr, ins: Hexstr, p1: Hexstr, p2: Hexstr, cmd_constr: Construct, cmd_data: Hexstr, resp_constr: Construct) -> Tuple[dict, SwHexstr]: diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py index 25b0d02f..ad193eee 100644 --- a/pySim/global_platform/__init__.py +++ b/pySim/global_platform/__init__.py @@ -1,7 +1,7 @@ # coding=utf-8 """Partial Support for GlobalPLatform Card Spec (currently 2.1.1) -(C) 2022-2023 by Harald Welte +(C) 2022-2024 by Harald Welte This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -21,6 +21,8 @@ from typing import Optional, List, Dict, Tuple from construct import Optional as COptional from construct import * from bidict import bidict +from Cryptodome.Random import get_random_bytes +from pySim.global_platform.scp02 import SCP02 from pySim.construct import * from pySim.utils import * from pySim.filesystem import * @@ -582,6 +584,48 @@ class ADF_SD(CardADF): p2 |= 0x01 return grd_list + est_scp02_parser = argparse.ArgumentParser() + est_scp02_parser.add_argument('--key-ver', type=auto_uint8, required=True, + help='Key Version Number (KVN)') + est_scp02_parser.add_argument('--key-enc', type=is_hexstr, required=True, + help='Secure Channel Encryption Key') + est_scp02_parser.add_argument('--key-mac', type=is_hexstr, required=True, + help='Secure Channel MAC Key') + est_scp02_parser.add_argument('--key-dek', type=is_hexstr, required=True, + help='Data Encryption Key') + est_scp02_parser.add_argument('--host-challenge', type=is_hexstr, + help='Hard-code the host challenge; default: random') + est_scp02_parser.add_argument('--security-level', type=auto_uint8, default=0x01, + help='Security Level. Default: 0x01 (C-MAC only)') + + @cmd2.with_argparser(est_scp02_parser) + def do_establish_scp02(self, opts): + """Establish a secure channel using the GlobalPlatform SCP02 protocol. It can be released + again by using `release_scp`.""" + if self._cmd.lchan.scc.scp: + self._cmd.poutput("Cannot establish SCP02 as this lchan already has a SCP instance!") + return + host_challenge = h2b(opts.host_challenge) if opts.host_challenge else get_random_bytes(8) + kset = GpCardKeyset(opts.key_ver, h2b(opts.key_enc), h2b(opts.key_mac), h2b(opts.key_dek)) + scp02 = SCP02(card_keys=kset) + init_update_apdu = scp02.gen_init_update_apdu(host_challenge=host_challenge) + init_update_resp, sw = self._cmd.lchan.scc.send_apdu_checksw(b2h(init_update_apdu)) + scp02.parse_init_update_resp(h2b(init_update_resp)) + ext_auth_apdu = scp02.gen_ext_auth_apdu(opts.security_level) + ext_auth_resp, sw = self._cmd.lchan.scc.send_apdu_checksw(b2h(ext_auth_apdu)) + self._cmd.poutput("Successfully established a SCP02 secure channel") + # store a reference to the SCP instance + self._cmd.lchan.scc.scp = scp02 + self._cmd.update_prompt() + + def do_release_scp(self, opts): + """Release a previously establiehed secure channel.""" + if not self._cmd.lchan.scc.scp: + self._cmd.poutput("Cannot release SCP as none is established") + return + self._cmd.lchan.scc.scp = None + self._cmd.update_prompt() + # Card Application of a Security Domain class CardApplicationSD(CardApplication): @@ -601,3 +645,22 @@ class CardApplicationISD(CardApplicationSD): # # def __init__(self, name='GlobalPlatform'): # super().__init__(name, desc='GlobalPlatfomr 2.1.1', cla=['00','80','84'], sw=sw_table) + + +class GpCardKeyset: + """A single set of GlobalPlatform card keys and the associated KVN.""" + def __init__(self, kvn: int, enc: bytes, mac: bytes, dek: bytes): + assert kvn >= 0 and kvn < 256 + assert len(enc) == len(mac) == len(dek) + self.kvn = kvn + self.enc = enc + self.mac = mac + self.dek = dek + + @classmethod + def from_single_key(cls, kvn: int, base_key: bytes) -> 'GpCardKeyset': + return cls(int, base_key, base_key, base_key) + + def __str__(self): + return "%s(KVN=%u, ENC=%s, MAC=%s, DEK=%s)" % (self.__class__.__name__, + self.kvn, b2h(self.enc), b2h(self.mac), b2h(self.dek)) diff --git a/pySim/global_platform/scp02.py b/pySim/global_platform/scp02.py new file mode 100644 index 00000000..eec1180c --- /dev/null +++ b/pySim/global_platform/scp02.py @@ -0,0 +1,242 @@ +# Global Platform SCP02 (Secure Channel Protocol) implementation +# +# (C) 2023-2024 by Harald Welte +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import abc +import logging +from Cryptodome.Cipher import DES3, DES +from Cryptodome.Util.strxor import strxor +from construct import * +from pySim.utils import b2h +from pySim.secure_channel import SecureChannel +from typing import Optional + +logger = logging.getLogger(__name__) + +def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes: + assert(len(constant) == 2) + assert(counter >= 0 and counter <= 65535) + assert(len(base_key) == 16) + + derivation_data = constant + counter.to_bytes(2, 'big') + b'\x00' * 12 + cipher = DES3.new(base_key, DES.MODE_CBC, b'\x00' * 8) + return cipher.encrypt(derivation_data) + +# FIXME: overlap with BspAlgoCryptAES128 +def pad80(s: bytes, BS=8) -> bytes: + """ Pad bytestring s: add '\x80' and '\0'* so the result to be multiple of BS.""" + l = BS-1 - len(s) % BS + return s + b'\x80' + b'\0'*l + +class Scp02SessionKeys: + """A single set of GlobalPlatform session keys.""" + DERIV_CONST_CMAC = b'\x01\x01' + DERIV_CONST_RMAC = b'\x01\x02' + DERIV_CONST_ENC = b'\x01\x82' + DERIV_CONST_DENC = b'\x01\x81' + + def calc_mac_1des(self, data: bytes, reset_icv: bool = False) -> bytes: + """Pad and calculate MAC according to B.1.2.2 - Single DES plus final 3DES""" + e = DES.new(self.c_mac[:8], DES.MODE_ECB) + d = DES.new(self.c_mac[8:], DES.MODE_ECB) + padded_data = pad80(data, 8) + q = len(padded_data) // 8 + icv = b'\x00' * 8 if reset_icv else self.icv + h = icv + for i in range(q): + h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)]))) + h = d.decrypt(h) + h = e.encrypt(h) + logger.debug("mac_1des(%s,icv=%s) -> %s", b2h(data), b2h(icv), b2h(h)) + if self.des_icv_enc: + self.icv = self.des_icv_enc.encrypt(h) + else: + self.icv = h + return h + + def calc_mac_3des(self, data: bytes) -> bytes: + e = DES3.new(self.enc, DES.MODE_ECB) + padded_data = pad80(data, 8) + q = len(padded_data) // 8 + h = b'\x00' * 8 + for i in range(q): + h = e.encrypt(strxor(h, bytes(padded_data[8*i:8*(i+1)]))) + logger.debug("mac_3des(%s) -> %s", b2h(data), b2h(h)) + return h + + def __init__(self, counter: int, card_keys: 'GpCardKeyset', icv_encrypt=True): + self.icv = None + self.counter = counter + self.card_keys = card_keys + self.c_mac = scp02_key_derivation(self.DERIV_CONST_CMAC, self.counter, card_keys.mac) + self.r_mac = scp02_key_derivation(self.DERIV_CONST_RMAC, self.counter, card_keys.mac) + self.enc = scp02_key_derivation(self.DERIV_CONST_ENC, self.counter, card_keys.enc) + self.data_enc = scp02_key_derivation(self.DERIV_CONST_DENC, self.counter, card_keys.dek) + self.des_icv_enc = DES.new(self.c_mac[:8], DES.MODE_ECB) if icv_encrypt else None + + def __str__(self) -> str: + return "%s(CTR=%u, ICV=%s, ENC=%s, D-ENC=%s, MAC-C=%s, MAC-R=%s)" % ( + self.__class__.__name__, self.counter, b2h(self.icv) if self.icv else "None", + b2h(self.enc), b2h(self.data_enc), b2h(self.c_mac), b2h(self.r_mac)) + +INS_INIT_UPDATE = 0x50 +INS_EXT_AUTH = 0x82 +CLA_SM = 0x04 + +class SCP(SecureChannel, abc.ABC): + """Abstract base class containing some common interface + functionality for SCP protocols.""" + def __init__(self, card_keys: 'GpCardKeyset', lchan_nr: int = 0): + if hasattr(self, 'kvn_range'): + if not card_keys.kvn in range(self.kvn_range[0], self.kvn_range[1]+1): + raise ValueError('%s cannot be used with KVN outside range 0x%02x..0x%02x' % + (self.__class__.__name__, self.kvn_range[0], self.kvn_range[1])) + self.lchan_nr = lchan_nr + self.card_keys = card_keys + self.sk = None + self.mac_on_unmodified = False + self.security_level = 0x00 + + @property + def do_cmac(self) -> bool: + """Should we perform C-MAC?""" + return self.security_level & 0x01 + + @property + def do_rmac(self) -> bool: + """Should we perform R-MAC?""" + return self.security_level & 0x10 + + @property + def do_cenc(self) -> bool: + """Should we perform C-ENC?""" + return self.security_level & 0x02 + + @property + def do_renc(self) -> bool: + """Should we perform R-ENC?""" + return self.security_level & 0x20 + + def __str__(self) -> str: + return "%s[%02x]" % (self.__class__.__name__, self.security_level) + + def _cla(self, sm: bool = False, b8: bool = True) -> int: + ret = 0x80 if b8 else 0x00 + if sm: + ret = ret | CLA_SM + return ret + self.lchan_nr + + def wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes: + # Generic handling of GlobalPlatform SCP, implements SecureChannel.wrap_cmd_apdu + # only protect those APDUs that actually are global platform commands + if apdu[0] & 0x80: + return self._wrap_cmd_apdu(apdu, *args, **kwargs) + else: + return apdu + + @abc.abstractmethod + def _wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes: + """Method implementation to be provided by derived class.""" + pass + + @abc.abstractmethod + def gen_init_update_apdu(self, host_challenge: Optional[bytes]) -> bytes: + pass + + @abc.abstractmethod + def parse_init_update_resp(self, resp_bin: bytes): + pass + + @abc.abstractmethod + def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes: + pass + + +class SCP02(SCP): + """An instance of the GlobalPlatform SCP02 secure channel protocol.""" + + constr_iur = Struct('key_div_data'/Bytes(10), 'key_ver'/Int8ub, Const(b'\x02'), + 'seq_counter'/Int16ub, 'card_challenge'/Bytes(6), 'card_cryptogram'/Bytes(8)) + kvn_range = [0x20, 0x2f] + + def _compute_cryptograms(self, card_challenge: bytes, host_challenge: bytes): + logger.debug("host_challenge(%s), card_challenge(%s)", b2h(host_challenge), b2h(card_challenge)) + self.host_cryptogram = self.sk.calc_mac_3des(self.sk.counter.to_bytes(2, 'big') + card_challenge + host_challenge) + self.card_cryptogram = self.sk.calc_mac_3des(self.host_challenge + self.sk.counter.to_bytes(2, 'big') + card_challenge) + logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram)) + + def gen_init_update_apdu(self, host_challenge: bytes = b'\x00'*8) -> bytes: + """Generate INITIALIZE UPDATE APDU.""" + self.host_challenge = host_challenge + return bytes([self._cla(), INS_INIT_UPDATE, self.card_keys.kvn, 0, 8]) + self.host_challenge + + def parse_init_update_resp(self, resp_bin: bytes): + """Parse response to INITIALZIE UPDATE.""" + resp = self.constr_iur.parse(resp_bin) + self.card_challenge = resp['card_challenge'] + self.sk = Scp02SessionKeys(resp['seq_counter'], self.card_keys) + logger.debug(self.sk) + self._compute_cryptograms(self.card_challenge, self.host_challenge) + if self.card_cryptogram != resp['card_cryptogram']: + raise ValueError("card cryptogram doesn't match") + + def gen_ext_auth_apdu(self, security_level: int = 0x01) -> bytes: + """Generate EXTERNAL AUTHENTICATE APDU.""" + if security_level & 0xf0: + raise NotImplementedError('R-MAC/R-ENC for SCP02 not implemented yet.') + self.security_level = security_level + if self.mac_on_unmodified: + header = bytes([self._cla(), INS_EXT_AUTH, self.security_level, 0, 8]) + else: + header = bytes([self._cla(True), INS_EXT_AUTH, self.security_level, 0, 16]) + #return self.wrap_cmd_apdu(header + self.host_cryptogram) + mac = self.sk.calc_mac_1des(header + self.host_cryptogram, True) + return bytes([self._cla(True), INS_EXT_AUTH, self.security_level, 0, 16]) + self.host_cryptogram + mac + + def _wrap_cmd_apdu(self, apdu: bytes) -> bytes: + """Wrap Command APDU for SCP02: calculate MAC and encrypt.""" + lc = len(apdu) - 5 + assert len(apdu) >= 5, "Wrong APDU length: %d" % len(apdu) + assert len(apdu) == 5 or apdu[4] == lc, "Lc differs from length of data: %d vs %d" % (apdu[4], lc) + + logger.debug("wrap_cmd_apdu(%s)", b2h(apdu)) + + cla = apdu[0] + b8 = cla & 0x80 + if cla & 0x03 or cla & CLA_SM: + # nonzero logical channel in APDU, check that are the same + assert cla == self._cla(False, b8), "CLA mismatch" + # CLA without log. channel can be 80 or 00 only + if self.do_cmac: + if self.mac_on_unmodified: + mlc = lc + clac = cla + else: # CMAC on modified APDU + mlc = lc + 8 + clac = cla | CLA_SM + mac = self.sk.calc_mac_1des(bytes([clac]) + apdu[1:4] + bytes([mlc]) + apdu[5:]) + if self.do_cenc: + k = DES3.new(self.sk.enc, DES.MODE_CBC, b'\x00'*8) + data = k.encrypt(pad80(apdu[5:], 8)) + lc = len(data) + else: + data = apdu[5:] + lc += 8 + apdu = bytes([self._cla(True, b8)]) + apdu[1:4] + bytes([lc]) + data + mac + return apdu + + def unwrap_rsp_apdu(self, sw: bytes, apdu: bytes) -> bytes: + # TODO: Implement R-MAC / R-ENC + return apdu diff --git a/pySim/secure_channel.py b/pySim/secure_channel.py new file mode 100644 index 00000000..974780ee --- /dev/null +++ b/pySim/secure_channel.py @@ -0,0 +1,37 @@ +# Generic code related to Secure Channel processing +# +# (C) 2023-2024 by Harald Welte +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import abc +from pySim.utils import b2h, h2b, ResTuple, Hexstr + +class SecureChannel(abc.ABC): + @abc.abstractmethod + def wrap_cmd_apdu(self, apdu: bytes) -> bytes: + """Wrap Command APDU according to specific Secure Channel Protocol.""" + pass + + @abc.abstractmethod + def unwrap_rsp_apdu(self, sw: bytes, rsp_apdu: bytes) -> bytes: + """UnWrap Response-APDU according to specific Secure Channel Protocol.""" + pass + + def send_apdu_wrapper(self, send_fn: callable, pdu: Hexstr, *args, **kwargs) -> ResTuple: + """Wrapper function to wrap command APDU and unwrap repsonse APDU around send_apdu callable.""" + pdu_wrapped = b2h(self.wrap_cmd_apdu(h2b(pdu))) + res, sw = send_fn(pdu_wrapped, *args, **kwargs) + res_unwrapped = b2h(self.unwrap_rsp_apdu(h2b(sw), h2b(res))) + return res_unwrapped, sw diff --git a/tests/test_globalplatform.py b/tests/test_globalplatform.py new file mode 100644 index 00000000..280199f3 --- /dev/null +++ b/tests/test_globalplatform.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +# (C) 2023-2024 by Harald Welte +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import unittest +import logging + +from pySim.global_platform import * +from pySim.global_platform.scp02 import SCP02 +from pySim.utils import b2h, h2b + +KIC = h2b('100102030405060708090a0b0c0d0e0f') # enc +KID = h2b('101102030405060708090a0b0c0d0e0f') # MAC +KIK = h2b('102102030405060708090a0b0c0d0e0f') # DEK +ck_3des_70 = GpCardKeyset(0x20, KIC, KID, KIK) + +class SCP02_Auth_Test(unittest.TestCase): + host_challenge = h2b('40A62C37FA6304F8') + init_update_resp = h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3') + + def setUp(self): + self.scp02 = SCP02(card_keys=ck_3des_70) + + def test_mutual_auth_success(self): + init_upd_cmd = self.scp02.gen_init_update_apdu(host_challenge=self.host_challenge) + self.assertEqual(b2h(init_upd_cmd).upper(), '805020000840A62C37FA6304F8') + self.scp02.parse_init_update_resp(self.init_update_resp) + ext_auth_cmd = self.scp02.gen_ext_auth_apdu() + self.assertEqual(b2h(ext_auth_cmd).upper(), '8482010010BA6961667737C5BCEBECE14C7D6A4376') + + def test_mutual_auth_fail_card_cryptogram(self): + init_upd_cmd = self.scp02.gen_init_update_apdu(host_challenge=self.host_challenge) + self.assertEqual(b2h(init_upd_cmd).upper(), '805020000840A62C37FA6304F8') + wrong_init_update_resp = self.init_update_resp.copy() + wrong_init_update_resp[-1:] = b'\xff' + with self.assertRaises(ValueError): + self.scp02.parse_init_update_resp(wrong_init_update_resp) + + +class SCP02_Test(unittest.TestCase): + host_challenge = h2b('40A62C37FA6304F8') + init_update_resp = h2b('00000000000000000000700200016B4524ABEE7CF32EA3838BC148F3') + + def setUp(self): + self.scp02 = SCP02(card_keys=ck_3des_70) + init_upd_cmd = self.scp02.gen_init_update_apdu(host_challenge=self.host_challenge) + self.scp02.parse_init_update_resp(self.init_update_resp) + ext_auth_cmd = self.scp02.gen_ext_auth_apdu() + + def test_mac_command(self): + wrapped = self.scp02.wrap_cmd_apdu(h2b('80f28002024f00')) + self.assertEqual(b2h(wrapped).upper(), '84F280020A4F00B21AAFA3EB2D1672') + +if __name__ == "__main__": + unittest.main()