ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SCP03安全通道完全指南:从密码学原语到APDU联调实战

SCP03安全通道完全指南:从密码学原语到APDU联调实战 1. 为什么安全通道是智能卡管理的地基1.1 从Card Manager与Security Domain说起很多人第一次接触GlobalPlatform Technology规范时脑子里只有一个模糊的概念它是一个智能卡标准组织。确实GlobalPlatform定义了SESecure Element上应用生命周期管理的整套框架但真正落地时几乎所有和卡片打交道的人都会撞上同一个入口——安全通道。没有安全通道你连往卡里装一个Applet的资格都没有。这套体系里有两个必须先记住的角色Card ManagerCM和Security DomainSD。Card Manager是卡的“根管理者”负责全局状态、应用加载和密钥管理Security Domain则可以理解为某个业务方在卡里的“专属管家”比如发卡行、电信运营商各有一个SD。你无论是初始化卡片、安装应用还是更新密钥、下载profile本质上都是通过SD或CM来执行APDU指令。问题是这些APDU在外部主机和卡之间传输时走的往往是普通读卡器、OTA网络或调试总线这些信道天然不可信。如果指令明文裸奔攻击者只需要一台可以模拟读卡器的设备就能窃听密钥更新指令、篡改应用安装内容甚至重放一条已失效的旧指令把卡片状态回滚。安全通道协议Secure Channel Protocol就是在这条不可信信道里为APDU指令和响应加上“信封”。信封要解决三件事机密性别人看不懂、完整性内容不可篡改、新鲜性旧消息不能重放。SCP03正是GlobalPlatform为满足这三件事基于AES体系推出的第三代协议。1.2 SCP01/SCP02的局限造就了SCP03SCP01和SCP02都是3DES时代的产物。SCP01用3DES做CBC加密和MAC算法本身已经明显落后SCP02改善了密钥更新机制和MAC算法基于ISO/IEC 9797-1 MAC算法3的Retail MAC在很长一段时间里是行业主力。但如果你实际做过SCP02的跨平台联调一定遇到过这些别扭的地方3DES的有效密钥强度有限面对当前的安全审计很难过关。会话密钥的派生方式偏“工程化”不同实现之间对上下文数据的前后顺序存在微妙的差异经常出现卡A能过、卡B挂掉的怪事。SCP02的C-MAC覆盖范围在不同时期、不同芯片上有边界模糊的情况有的实现不覆盖Lc字段给协议级攻击留下了灰色地带。SCP02不支持命令数据的加密只能做完整性保护。SCP03全部推倒重来。它把算法底座换成了AESMAC原语换成AES-CMAC密钥派生明确采用NIST SP 800-108标准同时它把命令MAC的覆盖范围、命令加密的填充规则、会话密钥派生时的输入顺序全部固定下来。你在联调时最大的感受就是一套实现打通不同卡商产品的概率比SCP02时代高得多。这也是为什么现在几乎所有的eSIM、手机钱包、安全支付项目都在向SCP03迁移。2. 先啃下三个密码学地基SCP03不是一种全新的密码算法它只是把几种已经成熟的密码学原语按严谨的方式组合在一起。想玩明白协议本身必须先对这三个地基有肌肉记忆AES-CMAC、SP 800-108 KDF、AES-CBC。2.1 AES-CMAC不是每个MAC都适合做协议原语AES-CMAC的本质是用AES分组密码构造一个带密钥的哈希函数。它的输入是任意长度的消息输出固定16字节。SCP03里大量使用它派生会话密钥、计算Card Cryptogram、生成C-MAC和R-MAC全部都是AES-CMAC。为什么要换掉Retail MAC因为Retail MAC的构造基于3DES处理变长消息时要处理补位、迭代等一堆边界问题而且它的输出长度是8字节碰撞空间太小。AES-CMAC有NIST标准背书输出16字节SCP03里通常截断前8字节使用但内部状态仍然是完整的16字节算法构造上比DES类MAC要干净得多。在Python里用cryptography库算AES-CMAC只需要几行from cryptography.hazmat.primitives.cmac import CMAC from cryptography.hazmat.primitives.ciphers import algorithms key bytes.fromhex(404142434445464748494A4B4C4D4E4F) msg bytes.fromhex(8402000010) # 示例消息 cmac CMAC(algorithms.AES(key)) cmac.update(msg) mac cmac.finalize() print(mac.hex())联调时我经常拿这个小脚本和卡的实际输出做对比一旦MAC对不上立刻能定位是KDF问题还是CMAC覆盖范围问题。2.2 SP 800-108 KDF在SCP03里的具体调参密钥派生函数KDF是SCP03最容易写错的地方。SCP03用的是NIST SP 800-108的Counter Mode KDF底层就是AES-CMAC。基本构造是K_i CMAC(K_derivation, Counter || Label || 0x00 || Context || L)其中Counter是4字节大端计数器Label固定为ASCII字符串“SCP03”即53 43 50 30 33中间的0x00是分隔符Context是派生上下文L表示要派生的密钥长度以bit为单位4字节大端。AES-128的场景下L就是00 00 00 80。注意两点第一Counter从1开始三种会话密钥分别用Counter1、2、3第二Context的拼法是Host Challenge || Card Challenge || 0x00末尾这个额外的0x00字节很多人会漏。漏掉一个字节会话密钥就完全不同而且这种错误在日志里非常难发现因为看起来“结构都对”。GP规范里针对三种会话密钥KDF输入分别是派生S-ENCCounter1ContextHostChallenge||CardChallenge||0x00派生S-MACCounter2ContextHostChallenge||CardChallenge||0x00派生S-RMACCounter3ContextHostChallenge||CardChallenge||0x002.3 AES-CBC命令数据加密的基本盘SCP03的加密模式是AES-CBCIV固定为全0。它的使用范围很明确需要对命令数据做机密性保护时用S-ENC对明文数据加密需要对响应数据加密时同样用S-ENC加密。填充规则是ISO/IEC 9797-1的Padding Method 2也就是先补一个80后面用00补齐到16字节边界。我见过有人把这里的填充和TLS的PKCS#7混在一起导致加密结果完全对不上。这里不是补01到10而是补80 00...。这是SCP03特色务必记牢。3. 密钥体系与会话密钥派生3.1 静态密钥从哪里来SCP03在工作时使用的是会话密钥但会话密钥由静态密钥派生而来。静态密钥在GP体系里叫Secure Channel Keys通常包括三组ENC Key加密密钥、MAC KeyMAC密钥、DEK Key数据加密密钥。其中DEK Key用于本地加密敏感数据和SCP03安全通道本身的ENC不是一回事联调时别搞混。这三组静态密钥一般在卡片个性化阶段写入或者通过安全通道内部的PUT KEY指令更新。每组密钥可以有一个Key Version版本号APDU里用这个版本号索引具体使用哪套密钥。密钥分散Key Diversification通常由发卡方根据自己的规则进行分散算法可以是3DES或AES KDF。GP规范并不强制具体分散算法但SCP03会话密钥派生路径是强制的——这部分必须严格按照规范来。3.2 三段会话密钥的派生公式与Python实现每次建立安全通道时主机先发一个Host Challenge8字节随机数卡返回Card Challenge8字节。拿到两个Challenge后主机和卡各自独立派生出三把会话密钥from cryptography.hazmat.primitives.cmac import CMAC from cryptography.hazmat.primitives.ciphers import algorithms def aes_cmac(key: bytes, msg: bytes) - bytes: cmac CMAC(algorithms.AES(key)) cmac.update(msg) return cmac.finalize() def scp03_session_key(static_key: bytes, counter: int, label: bytes, context: bytes) - bytes: counter_bytes counter.to_bytes(4, big) # L字段表示输出长度为AES-128位即0x00000080 L (128).to_bytes(4, big) derivation_data counter_bytes label b\x00 context L return aes_cmac(static_key, derivation_data) label bSCP03 host_challenge bytes.fromhex(0011223344556677) card_challenge bytes.fromhex(8899AABBCCDDEEFF) context host_challenge card_challenge b\x00 s_enc scp03_session_key(k_enc, 1, label, context) s_mac scp03_session_key(k_mac, 2, label, context) s_rmac scp03_session_key(k_rmac, 3, label, context)如果你不是写Python而是用C或Java也完全可以按这个结构翻译核心就是保证Counter、Label、分隔符、Context、L这五段字节的拼接顺序与GP规范完全一致。这里最容易踩的坑有三个Counter不是从0开始而是从1开始Label要用ASCII码而不是先转成Hex字符串再拼末尾的L字段是bit数不是字节数AES-128为00000080如果你用AES-192、AES-256这里要相应改成000000C0和00000100。4. 握手阶段逐字节拆解很多初学者看SCP03的握手总觉得很抽象实际上它只涉及两条指令INITIALIZE UPDATE和EXTERNAL AUTHENTICATE。这两条指令跑完后续所有APDU的安全属性就定了。4.1 INITIALIZE UPDATE挑战与响应主机生成一个8字节的随机数作为Host Challenge然后发送80 50 00 KeyVersion 00 08 HostChallenge(8字节)CLA80INS50P1是密钥版本00通常表示使用当前激活的密钥版本P2固定为00。00 08是Lc表示后面跟8字节数据也就是Host Challenge本身。这KeyVersion一字节很容易被忽略但它决定了后面会话密钥派生用哪一套静态密钥。如果卡返回6A88Referenced data not found多半是KeyVersion写错或者卡里根本没装这套密钥。卡收到后会返回一个响应数据区结构大致是Key Diversification Data(长度由卡片定义) || Key Information(1字节) || Card Challenge(8字节) || Card Cryptogram(8字节)其中Key Information的低4位表示Key Version高4位表示Key Type比如1表示AES。Key Diversification Data用于指示这张卡的密钥是如何分散的长度由GP规范里的Key Information相关参数决定典型值是10字节。主机拿到这些数据后先按上节的KDF公式用自己的静态密钥和两个Challenge派生出S-ENC、S-MAC、S-RMAC然后计算Card Cryptogram并验证Card Cryptogram CMAC(S-MAC, 00 || Host Challenge || Card Challenge || Key Diversification Data || Key Information) 的前8字节注意开头的00是一个单字节前缀别和KDF里的分隔符记混。验证通过说明卡确实拥有同一套静态密钥而且响应在传输过程中没有被替换。4.2 EXTERNAL AUTHENTICATEhost证明自己卡已经向主机证明了身份但主机还需要向卡证明自己。主机计算Host CryptogramHost Cryptogram CMAC(S-MAC, 01 || Host Challenge || Card Challenge) 的前8字节然后构造EXTERNAL AUTHENTICATE指令数据区是Host Cryptogram(8字节) || C-MAC(8字节)总长16字节所以Lc1084 82 00 00 10 Host Cryptogram(8字节) C-MAC(8字节)这里最关键的细节是C-MAC怎么算。握手中的这条指令比较特殊计算C-MAC时使用的是初始ICV全0覆盖范围是ICV_initial(16字节全0) || 84 82 00 00 10 || Host Cryptogram(8字节)注意覆盖范围包含Lc10但不包含数据里最后的8字节C-MAC本身。这符合“指令的所有前置字段和已出现的数据都要被MAC保护”的原则。有人会问为什么Host Cryptogram没把Lc和整个指令头都算进去因为Host Cryptogram的定位是向卡证明“我知道这套会话密钥”并不是这条指令的消息认证码消息认证由紧随其后的C-MAC承担。两者分工不同。4.3 两个握手最容易被忽略的字节我在实际联调里被坑得最惨的两个字节一个是Card Cryptogram输入里的前缀00另一个是Host Cryptogram输入里的前缀01。这两个前缀很容易被想当然地忽略。少一个字节计算出的MAC和卡对不上而且因为KDF和CMAC都“跑通了”日志看起来一切正常只能逐字节比对时才发现。另外一个隐藏问题是卡的Card Challenge生成策略。GP规范建议卡的Card Challenge不能每次固定更不能可预测否则重放保护形同虚设。你在开发时如果发现每次INITIALIZE UPDATE返回的Card Challenge都一样先别急着怀疑协议大概率是卡在测试模式下用固定Challenge方便调试量产前务必改回随机或计数器增强模式。5. 后续APDU的C-MAC、ENC与R-MAC规则5.1 C-MAC到底盖住了哪些字节握手成功后后续每条命令都要根据安全属性决定是否加C-MAC、是否做ENC。C-MAC的输入不是简单的一句话能说清的它分为几种情况我建议直接用一张表来记场景C-MAC输入不加密有数据ICV_prev不加密无数据ICV_prev加密有数据ICV_prev这里的Lc是指APDU里的长度字段。要注意在SCP03下如果命令数据需要加密那么Lc字段本身还是按加密后的数据长度来填MAC计算时覆盖的是加密后的数据。也就是说先加密、再算MAC、最后把MAC追加到命令末尾。C-MAC输出用的是完整16字节CMAC的前8字节。完整CMAC的16字节结果会作为下一个ICV_prev形成一条哈希链。这样做有一个非常好的性质后面每一条命令的MAC都隐含了前面所有命令的状态攻击者不能把第N条命令换到第M条的位置因为一旦调整顺序ICV就变了。5.2 ENC模式的填充和CBC细节当命令需要做机密性保护时主机用S-ENC对明文命令数据进行AES-CBC加密IV为全0填充规则是80 00 ...。举个具体例子明文数据是A0 B0它是2字节。先补80变成A0 B0 80再用00补齐到16字节A0 B0 80 00 00 00 00 00 00 00 00 00 00 00 00 00。然后用S-ENC做AES-CBC加密。响应数据的解密也是同样的填充规则和IV。注意这里有个微妙点如果命令本身没有数据Lc0那就不需要加密也不需要加C-MAC吗不对。即使没有命令数据只要安全属性要求C-MAC仍然要追加8字节的C-MAC。所以一条受SCP03保护的STORE DATA指令哪怕数据为空发送出来也可能有8字节的MAC尾巴。5.3 ICV延续与重放保护链路SCP03的重放保护靠两层一层是会话层的Challenge随机性和新生成的会话密钥另一层就是上面说的ICV链。ICV链的意义在于即使攻击者截获了某条合法命令他也没法把它重放到另一个位置因为前后命令的ICV早已改变。不过要注意ICV链只能保证“命令顺序”和“内容完整性”不能防止攻击者在当前会话内无限重放同一条命令。比如一条加钱的指令被重放两次两条指令的MAC都相同如果卡端没有额外的命令计数器或业务层幂等控制这种重放还是可能生效。SCP03在规范层面主要依赖挑战值、会话密钥和ICV链来解决协议级重放业务层的幂等和计数还得应用自己管。R-MAC的机制与C-MAC对称。如果启用了R-MAC卡对每条响应也会追加8字节的R-MAC。它的输入是R-MAC ICV_prev || 响应头(SW1 SW2前的状态字节或整个响应APDU的前缀) || Lr || Response DataR-MAC的ICV同样独立成链。我调试时习惯把C-MAC和R-MAC链的ICV单独打印出来和卡端日志比对谁先断链一目了然。6. 从协议到落地密钥更新与eSIM场景6.1 PUT KEY等管理指令在SCP03下怎么走SCP03握手的最终目的是让后续管理指令在安全通道里执行。最常见的场景是密钥更新也就是PUT KEYINSD8。在SCP03保护下PUT KEY的数据区往往需要加密因为里面装的是新的静态密钥明文。此时命令格式大概是80 D8 00 80 Lc Encrypted(KeyData) C-MAC(8字节)这里面的Lc是Encrypted KeyData的长度加8字节MAC后的总长还是只算加密数据长度这是一个高频错误点。按SCP03规则APDU的Lc是“命令数据区总长度”也就是加密数据MAC尾巴的总长度但C-MAC计算时覆盖的Lc也是这个总长度。你可以理解为卡先解析出Lc再从数据区末尾切出8字节MAC余下部分解密。如果Lc写错要么MAC验不过要么解密后数据错位。新的静态密钥在数据区里通常还要包一层GP的TLV结构比如Key Information Template标签E0等。很多新手在联调时会忽略TLV里的Key Version和Key Type实际上这两个字段决定新密钥往哪个槽位写、算法是AES还是3DES。用错版本号会导致“密钥写进去了但下次用的时候找不到”。6.2 eSIM profile下载中的SCP03如果你做eSIM相关开发对SCP03一定不陌生。eSIM profile下载的本质是远程管理平台向卡内的Provisioning Profile安全域发起一系列APDU把profile包里的安全数据、网络接入参数和应用写入卡内。这个过程走的就是SCP03安全通道。实际实现里SM-DP服务器并不会直接和卡通信而是把APDU指令打包成ES2接口服务器到LPA和ES10接口LPA到eUICC。LPA从服务器拿到一串待执行APDU后逐条通过逻辑信道发给eUICC。如果安全通道没建立好服务器生成的密文和MAC在卡端验不过profile绑定那一步就会失败。这类联调问题最大的特点是日志里看不到明显的异常只有6A88、6988这类通用错误码但真正的原因却要翻到APDU层面的填充和MAC覆盖才能找到。eSIM场景还有一个特征不同的profile包可能使用不同的密钥版本甚至同一个eUICC里同时存在多套SCP03密钥。联调工具如果没按Key Version去选择正确的静态密钥就会出现换一台测试机就失败的诡异现象。7. 联调自测中的典型坑与排错7.1 用GlobalPlatformPro做对照实验我强烈建议你准备一个开源工具做对照实验GlobalPlatformPro。它支持SCP02和SCP03可以快速建立安全通道并执行管理操作。下面是一个SCP03场景的参考命令java -jar gp.jar -scp 03 \ -key-enc 404142434445464748494A4B4C4D4E4F \ -key-mac 404142434445464748494A4B4C4D4E4F \ -key-dek 404142434445464748494A4B4C4D4E4F \ -info具体参数映射到GP规范的哪个字段不同版本可能略有差异但思路是通用的-key-enc对应静态ENC Key-key-mac对应静态MAC Key-key-dek对应数据加密密钥DEK。这套工具用来做交叉验证特别好使。如果GlobalPlatformPro能和卡正常建立SCP03安全通道而你的Host端代码不行问题大概率出在KDF或CMAC的计算细节上。你可以打开日志把自己的中间值和工具的中间值逐字节对比。联调时我一般这样操作先用GlobalPlatformPro跑通一次INITIALIZE UPDATE保留完整hex日志。在自己的实现里打同样的日志对比两个Card Challenge之前的响应是否一致。对比KDF后的三把会话密钥是否一致。对比Card Cryptogram和Host Cryptogram是否一致。最后对比第一条受保护命令的C-MAC。哪一步不一致问题就在哪一段。7.2 常见失败模式速查表下面这张表是我这些年被各种实现坑过之后总结出来的放在这里当速查手册非常实用现象根因INITIALIZE UPDATE返回6A88密钥版本不对或卡内没有对应版本的静态密钥握手成功但第一条受保护命令返回6988C-MAC计算覆盖范围不对多半忘了覆盖Lc字段Cryptogram验证一直失败前缀字节用错Card侧用00Host侧用01加密数据解密后乱码填充不是80 00...或者把PKCS#7带进来了命令重排后MAC验不过ICV链没有按完整16字节延续只用了截断后的8字节换一台测试机就握手失败静态密钥版本或分散算法与卡内不一致KDF结果和参考实现不一致Context末尾漏了0x00或L字段写成了字节数而不是bit数卡返回6A80APDU长度字段不对加密数据和MAC尾巴的总长度没算对最后再分享一个我自己反复验证过的经验SCP03联调时一定不要凭肉眼比对十六进制字符串最好写一个脚本把参考工具和自研实现的每一步中间值都算出来做diff。这个习惯帮我节省的时间比我写整个Host端代码的时间还要多。只要KDF、CMAC、ICV链这三条主线逻辑对齐了SCP03这座山就算真正翻过去了。
返回列表