laforge has uploaded this change for review. ( https://gerrit.osmocom.org/c/pysim/+/35793?usp=email )
Change subject: WIP: Implement Global Platform SCP03 ......................................................................
WIP: Implement Global Platform SCP03
Change-Id: Ibc35af5474923aed2e3bcb29c8d713b4127a160d --- M pySim/global_platform/__init__.py M pySim/global_platform/scp02.py M tests/test_globalplatform.py 3 files changed, 323 insertions(+), 25 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/pysim refs/changes/93/35793/1
diff --git a/pySim/global_platform/__init__.py b/pySim/global_platform/__init__.py index 48e3fe0..7d3ecbb 100644 --- a/pySim/global_platform/__init__.py +++ b/pySim/global_platform/__init__.py @@ -20,9 +20,10 @@ from typing import Optional, List, Dict, Tuple from construct import Optional as COptional from construct import * +from copy import deepcopy from bidict import bidict from Cryptodome.Random import get_random_bytes -from pySim.global_platform.scp02 import SCP02 +from pySim.global_platform.scp02 import SCP02, SCP03 from pySim.construct import * from pySim.utils import * from pySim.filesystem import * @@ -650,16 +651,37 @@ 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) + self._establish_scp(scp02, host_challenge, opts.security_level) + + est_scp03_parser = deepcopy(est_scp02_parser) + est_scp03_parser.add_argument('--s8-mode', action='store_true', help='S8 mode (legacy mode)') + + @cmd2.with_argparser(est_scp03_parser) + def do_establish_scp03(self, opts): + """Establish a secure channel using the GlobalPlatform SCP03 protocol. It can be released + again by using `release_scp`.""" + if self._cmd.lchan.scc.scp: + self._cmd.poutput("Cannot establish SCP03 as this lchan already has a SCP instance!") + return + s_mode = 8 if opts.s8_mode else 16 + host_challenge = h2b(opts.host_challenge) if opts.host_challenge else get_random_bytes(s_mode) + kset = GpCardKeyset(opts.key_ver, h2b(opts.key_enc), h2b(opts.key_mac), h2b(opts.key_dek)) + scp03 = SCP03(card_keys=kset, s_mode = s_mode) + self._establish_scp(scp03, host_challenge, opts.security_level) + + def _establish_scp(self, scp, host_challenge, security_level): + # perform the common functionality shared by SCP02 and SCP03 establishment + init_update_apdu = scp.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) + scp.parse_init_update_resp(h2b(init_update_resp)) + ext_auth_apdu = scp.gen_ext_auth_apdu(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") + self._cmd.poutput("Successfully established a %s secure channel" % str(scp)) # store a reference to the SCP instance - self._cmd.lchan.scc.scp = scp02 + self._cmd.lchan.scc.scp = scp self._cmd.update_prompt()
+ def do_release_scp(self, opts): """Release a previously establiehed secure channel.""" if not self._cmd.lchan.scc.scp: diff --git a/pySim/global_platform/scp02.py b/pySim/global_platform/scp02.py index 3f34407..cc3f0ed 100644 --- a/pySim/global_platform/scp02.py +++ b/pySim/global_platform/scp02.py @@ -17,13 +17,16 @@
import abc import logging +from typing import Optional from Cryptodome.Cipher import DES3, DES from Cryptodome.Util.strxor import strxor -from construct import * +from construct import Struct, Bytes, Int8ub, Int16ub, Const +from construct import Optional as COptional from pySim.utils import b2h from pySim.secure_channel import SecureChannel
logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG)
def scp02_key_derivation(constant: bytes, counter: int, base_key: bytes) -> bytes: assert(len(constant) == 2) @@ -34,11 +37,21 @@ cipher = DES3.new(base_key, DES.MODE_CBC, b'\x00' * 8) return cipher.encrypt(derivation_data)
+# FIXME: 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
+# FIXME: BspAlgoCryptAES128 +def unpad80(padded: bytes) -> bytes: + """Remove the customary 80 00 00 ... padding used for AES.""" + # first remove any trailing zero bytes + stripped = padded.rstrip(b'\0') + # then remove the final 80 + assert stripped[-1] == 0x80 + return stripped[:-1] + class Scp02SessionKeys: """A single set of GlobalPlatform session keys.""" DERIV_CONST_CMAC = b'\x01\x01' @@ -102,6 +115,27 @@ 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)
@@ -111,10 +145,10 @@ ret = ret | CLA_SM return ret + self.lchan_nr
- def wrap_cmd_apdu(self, apdu: bytes) -> bytes: + def wrap_cmd_apdu(self, apdu: bytes, *args, **kwargs) -> bytes: # only protect those APDUs that actually are global platform commands if apdu[0] & 0x80: - return self._wrap_cmd_apdu(apdu) + return self._wrap_cmd_apdu(apdu, *args, **kwargs) else: return apdu
@@ -150,21 +184,6 @@ if self.card_cryptogram != resp['card_cryptogram']: raise ValueError("card cryptogram doesn't match")
- @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 - def gen_ext_auth_apdu(self, security_level: int =0x01) -> bytes: """Generate EXTERNAL AUTHENTICATE APDU.""" self.security_level = security_level @@ -211,3 +230,224 @@ def unwrap_rsp_apdu(self, sw: bytes, apdu: bytes) -> bytes: # TODO: Implement R-MAC / R-ENC return apdu + + + +from Cryptodome.Cipher import AES +from Cryptodome.Hash import CMAC + +def scp03_key_derivation(constant: bytes, context: bytes, base_key: bytes, l: Optional[int] = None) -> bytes: + """SCP03 Key Derivation Function as specified in Annex D 4.1.5.""" + # Data derivation shall use KDF in counter mode as specified in NIST SP 800-108 ([NIST 800-108]). The PRF + # used in the KDF shall be CMAC as specified in [NIST 800-38B], used with full 16-byte output length. + def prf(key: bytes, data:bytes): + return CMAC.new(key, data, AES).digest() + + if l == None: + l = len(base_key) * 8 + + logger.warn("scp03_kdf(constant=%s, context=%s, base_key=%s, l=%u", b2h(constant), b2h(context), b2h(base_key), l) + + output_len = l // 8 + + # SCP03 Section 4.1.5 defines a different parameter order than NIST SP 800-108, so we cannot use the + # existing Cryptodome.Protocol.KDF.SP800_108_Counter function :( + # A 12-byte “label” consisting of 11 bytes with value '00' followed by a 1-byte derivation constant + assert len(constant) == 1 + label = b'\x00' *11 + constant + i = 1 + dk = b'' + while len(dk) < output_len: + # 12B label, 1B separation, 2B L, 1B i, Context + info = label + b'\x00' + l.to_bytes(2, 'big') + bytes([i]) + context + logger.warn('info=%s', b2h(info)) + dk += prf(base_key, info) + i += 1 + if i > 0xffff: + raise ValueError("Overflow in SP800 108 counter") + + print("DK: %s" % b2h(dk)) + return dk[:output_len] + + +class Scp03SessionKeys: + # GPC 2.3 Amendment D v1.2 Section 4.1.5 Table 4-1 + DERIV_CONST_AUTH_CGRAM_CARD = b'\x00' + DERIV_CONST_AUTH_CGRAM_HOST = b'\x01' + DERIV_CONST_CARD_CHLG_GEN = b'\x02' + DERIV_CONST_KDERIV_S_ENC = b'\x04' + DERIV_CONST_KDERIV_S_MAC = b'\x06' + DERIV_CONST_KDERIV_S_RMAC = b'\x07' + blocksize = 16 + + def __init__(self, card_keys: 'GpCardKeyset', host_challenge: bytes, card_challenge: bytes): + # GPC 2.3 Amendment D v1.2 Section 6.2.1 + context = host_challenge + card_challenge + self.s_enc = scp03_key_derivation(self.DERIV_CONST_KDERIV_S_ENC, context, card_keys.enc) + self.s_mac = scp03_key_derivation(self.DERIV_CONST_KDERIV_S_MAC, context, card_keys.mac) + self.s_rmac = scp03_key_derivation(self.DERIV_CONST_KDERIV_S_RMAC, context, card_keys.mac) + + + # The first MAC chaining value is set to 16 bytes '00' + self.mac_chaining_value = b'\x00' * 16 + # The encryption counter’s start value shall be set to 1 (we set it immediately before generating ICV) + self.block_nr = 0 + + def calc_cmac(self, apdu: bytes): + """Compute C-MAC for given to-be-transmitted APDU.""" + cmac_input = self.mac_chaining_value + apdu + cmac_val = CMAC.new(self.s_mac, cmac_input, ciphermod=AES).digest() + self.mac_chaining_value = cmac_val + return cmac_val + + def calc_rmac(self, rdata_and_sw: bytes): + """Compute R-MAC for given received R-APDU data section.""" + rmac_input = self.mac_chaining_value + rdata_and_sw + return CMAC.new(self.s_rmac, rmac_input, ciphermod=AES).digest() + + def _get_icv(self, is_response: bool = False): + """Obtain the ICV value computed as described in 6.2.6.""" + if not is_response: + self.block_nr += 1 + # The binary value of this number SHALL be left padded with zeroes to form a full block. + data = self.block_nr.to_bytes(self.blocksize, "big") + if is_response: + # Section 6.2.7: additional intermediate step: Before encryption, the most significant byte of + # this block shall be set to '80'. + data = b'\x80' + data[1:] + iv = bytes([0] * self.blocksize) + # This block SHALL be encrypted with S-ENC to produce the ICV for command encryption. + cipher = AES.new(self.s_enc, AES.MODE_CBC, iv) + icv = cipher.encrypt(data) + logger.debug("_get_icv(data=%s, is_resp=%s) -> icv=%s", b2h(data), is_response, b2h(icv)) + return icv + + def _encrypt(self, data: bytes, is_response: bool = False) -> bytes: + cipher = AES.new(self.s_enc, AES.MODE_CBC, self._get_icv(is_response)) + return cipher.encrypt(data) + + def _decrypt(self, data: bytes, is_response: bool = True) -> bytes: + cipher = AES.new(self.s_enc, AES.MODE_CBC, self._get_icv(is_response)) + return cipher.decrypt(data) + + +class SCP03(SCP): + """Secure Channel Protocol (SCP) 03 as specified in GlobalPlatform v2.3 Amendment D.""" + + # Section 7.1.1.6 / Table 7-3 + constr_iur = Struct('key_div_data'/Bytes(10), 'key_ver'/Int8ub, Const(b'\x03'), 'i_param'/Int8ub, + 'card_challenge'/Bytes(lambda ctx: ctx._.s_mode), + 'card_cryptogram'/Bytes(lambda ctx: ctx._.s_mode), + 'sequence_counter'/COptional(Bytes(3))) + + def __init__(self, *args, **kwargs): + self.s_mode = kwargs.pop('s_mode', 8) + super().__init__(*args, **kwargs) + + def _compute_cryptograms(self): + logger.debug("host_challenge(%s), card_challenge(%s)", b2h(self.host_challenge), b2h(self.card_challenge)) + # Card + Host Authentication Cryptogram: Section 6.2.2.2 + 6.2.2.3 + context = self.host_challenge + self.card_challenge + self.card_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_CARD, context, self.sk.s_mac, l=self.s_mode*8) + self.host_cryptogram = scp03_key_derivation(self.sk.DERIV_CONST_AUTH_CGRAM_HOST, context, self.sk.s_mac, l=self.s_mode*8) + logger.debug("host_cryptogram(%s), card_cryptogram(%s)", b2h(self.host_cryptogram), b2h(self.card_cryptogram)) + + def gen_init_update_apdu(self, host_challenge: Optional[bytes] = None) -> bytes: + """Generate INITIALIZE UPDATE APDU.""" + if host_challenge == None: + host_challenge = b'\x00' * self.s_mode + if len(host_challenge) != self.s_mode: + raise ValueError('Host Challenge must be %u bytes long' % self.s_mode) + self.host_challenge = host_challenge + return bytes([self._cla(), INS_INIT_UPDATE, self.card_keys.kvn, 0, len(host_challenge)]) + host_challenge + + def parse_init_update_resp(self, resp_bin: bytes): + """Parse response to INITIALIZE UPDATE.""" + if len(resp_bin) not in [10+3+8+8, 10+3+16+16, 10+3+8+8+3, 10+3+16+16+3]: + raise ValueError('Invalid length of Initialize Update Response') + resp = self.constr_iur.parse(resp_bin, s_mode=self.s_mode) + self.card_challenge = resp['card_challenge'] + self.i_param = resp['i_param'] + # derive session keys and compute cryptograms + self.sk = Scp03SessionKeys(self.card_keys, self.host_challenge, self.card_challenge) + logger.debug(self.sk) + self._compute_cryptograms() + # verify computed cryptogram matches received cryptogram + 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.""" + self.security_level = security_level + header = bytes([self._cla(), INS_EXT_AUTH, self.security_level, 0, self.s_mode]) + # bypass encryption for EXTERNAL AUTHENTICATE + return self.wrap_cmd_apdu(header + self.host_cryptogram, skip_cenc=True) + + def _wrap_cmd_apdu(self, apdu: bytes, skip_cenc: bool = False) -> bytes: + """Wrap Command APDU for SCP02: calculate MAC and encrypt.""" + cla = apdu[0] + ins = apdu[1] + p1 = apdu[2] + p2 = apdu[3] + lc = apdu[4] + assert lc == len(apdu) - 5 + cmd_data = apdu[5:] + + if self.do_cenc and not skip_cenc: + assert self.do_cmac + if lc == 0: + # No encryption shall be applied to a command where there is no command data field. In this + # case, the encryption counter shall still be incremented + self.sk.block_nr += 1 + else: + # data shall be padded as defined in [GPCS] section B.2.3 + padded_data = pad80(cmd_data, 16) + lc = len(padded_data) + if lc >= 256: + raise ValueError('Modified Lc (%u) would exceed maximum when appending padding' % (lc, self.s_mode)) + # perform AES-CBC with ICV + S_ENC + cmd_data = self.sk._encrypt(padded_data) + + if self.do_cmac: + # The length of the command message (Lc) shall be incremented by 8 (in S8 mode) or 16 (in S16 + # mode) to indicate the inclusion of the C-MAC in the data field of the command message. + mlc = lc + self.s_mode + if mlc >= 256: + raise ValueError('Modified Lc (%u) would exceed maximum when appending %u bytes of mac' % (mlc, self.s_mode)) + # The class byte shall be modified for the generation or verification of the C-MAC: The logical + # channel number shall be set to zero, bit 4 shall be set to 0 and bit 3 shall be set to 1 to indicate + # GlobalPlatform proprietary secure messaging. + mcla = (cla & 0xF0) | CLA_SM + mapdu = bytes([mcla, ins, p1, p2, mlc]) + cmd_data + cmac = self.sk.calc_cmac(mapdu) + mapdu += cmac[:self.s_mode] + + return mapdu + + def unwrap_rsp_apdu(self, sw: bytes, apdu: bytes) -> bytes: + # No R-MAC shall be generated and no protection shall be applied to a response that includes an error + # status word: in this case only the status word shall be returned in the response. All status words + # except '9000' and warning status words (i.e. '62xx' and '63xx') shall be interpreted as error status + # words. + print("unwrap_rsp_apdu(sw=%s, apdu=%s)" % (sw, apdu)) + if not self.do_rmac: + assert not self.do_renc + return apdu + + if sw != b'\x90\x00' and sw[0] not in [0x62, 0x63]: + return apdu + response_data = apdu[:-self.s_mode] + rmac = apdu[-self.s_mode:] + rmac_exp = self.sk.calc_rmac(response_data + sw)[:self.s_mode] + if rmac != rmac_exp: + raise ValueError("R-MAC value not matching: received: %s, computed: %s" % (rmac, rmac_exp)) + + if self.do_renc: + # decrypt response data + decrypted = self.sk._decrypt(response_data) + print("decrypted: %s" % b2h(decrypted)) + # remove padding + response_data = unpad80(decrypted) + print("response_data: %s" % b2h(response_data)) + + return response_data diff --git a/tests/test_globalplatform.py b/tests/test_globalplatform.py index 2c5e4d4..ade3224 100644 --- a/tests/test_globalplatform.py +++ b/tests/test_globalplatform.py @@ -19,7 +19,7 @@ import logging
from pySim.global_platform import * -from pySim.global_platform.scp02 import SCP02 +from pySim.global_platform.scp02 import * from pySim.utils import b2h, h2b
KIC = h2b('100102030405060708090a0b0c0d0e0f') # enc @@ -64,5 +64,32 @@ wrapped = self.scp02.wrap_cmd_apdu(h2b('80f28002024f00')) self.assertEqual(b2h(wrapped).upper(), '84F280020A4F00B21AAFA3EB2D1672')
+class SCP03_Test_KDF(unittest.TestCase): + host_challenge = h2b('73EAE173050D0459') + # 00000000000000000000 300370 EE29D4C614C2DE31 CD5153A4F5758F91 00000F + card_challenge = h2b('EE29D4C614C2DE31') + + def test_kdf_enc(self): + k_enc = h2b('000102030405060708090a0b0c0d0e0f') + s_enc = scp03_key_derivation(Scp03SessionKeys.DERIV_CONST_KDERIV_S_ENC, self.host_challenge + self.card_challenge, k_enc) + self.assertEqual(b2h(s_enc), 'D948BCF4878524EBC76EA403DC5A2116'.lower()) + + def test_kdf_mac(self): + k_mac = h2b('101112131415161718191A1B1C1D1E1F') + s_mac = scp03_key_derivation(Scp03SessionKeys.DERIV_CONST_KDERIV_S_MAC, self.host_challenge + self.card_challenge, k_mac) + self.assertEqual(b2h(s_mac), 'C27EB5E7827A833CA00BD3F436F7B4E5'.lower()) + + def test_kdf_rmac(self): + k_mac = h2b('101112131415161718191A1B1C1D1E1F') + s_rmac = scp03_key_derivation(Scp03SessionKeys.DERIV_CONST_KDERIV_S_RMAC, self.host_challenge + self.card_challenge, k_mac) + self.assertEqual(b2h(s_rmac), 'B4797059FD825F2BC824F6EC92052948'.lower()) + + def test_cardchallenge(self): + k_enc = h2b('000102030405060708090a0b0c0d0e0f') + aid = h2b('a0000005591010ffffffff8900000100') + counter = h2b('00000f') + ch_comp = scp03_key_derivation(Scp03SessionKeys.DERIV_CONST_AUTH_CGRAM_CARD, counter + aid, k_enc, 8*8) + self.assertEqual(b2h(ch_comp), b2h(self.card_challenge)) + if __name__ == "__main__": unittest.main()