ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Milenage算法实现与USIM认证细节:从AES到f函数家族

2026/9/12 22:17:27 拓冰建站 浏览量
Milenage算法实现与USIM认证细节:从AES到f函数家族 说到3GPP USIM上的Milenage算法很多人的第一反应是打开TS 35.205然后被那张f1到f5的构造图劝退。我最初也是这个状态直到做eSIM profile调试需要把整套认证算法搬进测试环境才被迫把这套东西从头到尾啃了一遍。回头来看Milenage没有想象中那么玄它的核心逻辑甚至可以用“一次AES加密加上几次循环移位和异或”概括真正的坑全藏在字节序、拼接顺序和输出截位这些看起来不起眼的细节里。这篇文章面向的是要自己写Milenage代码、要把算法移植到软卡或测试工具、以及想搞清楚USIM认证流程的工程师。我会从规范结构讲起给出可参考的实现骨架最后用测试向量把最容易出错的点逐个过一遍。如果你已经有AES基础今天这篇看下来大概率能直接动手写第一版代码。1. Milenage在USIM里到底扮演什么角色1.1 从手机开机到网络认证USIM参与的完整链路很多人搞不清Milenage在整套通信流程中的位置。简单说手机开机后网络侧要确认“你手里这张卡是不是真的”这个确认过程叫AKAAuthentication and Key Agreement而USIM卡里的Milenage就是AKA中负责计算认证参数的那颗“算法心脏”。流程大致是这样网络侧HSS/UDM生成一个128位的随机数RAND同时用USIM里预置的密钥K算出期望应答XRES和AUTN包含MAC-A和隐藏后的SQN然后把RAND和AUTN下发给手机。手机把这两个参数交给USIMUSIM内部调用Milenage相关函数算出RES、CK加密密钥、IK完整性密钥并校验AUTN里的MAC-A是否合法。如果校验通过、RES与网络侧期望值一致双方就建立了信任后续业务数据的加密和完整性保护直接用CK/IK派生。这里有个关键点K和OP/OPc这些参数从发卡那一刻起就固化在USIM里外部任何接口都读不出来。Milenage跑在卡片的安全域里对外暴露的只有命令交互。但做测试环境或软卡模拟时我们手里有完整的K、OP、SQN、AMF可以直接把算法在PC上实现一遍跑出和真实USIM一致的结果。1.2 为什么都5G了还要回头啃3GPP老算法这可能是新人最困惑的问题。5G的AKA流程确实改了引入了5G AKA和EAP-AKA但从USIM角度看卡上执行的核心仍然是以Milenage为代表的f1、f2、f3、f4、f5函数集合。5G只是在ME和网络侧加了一层KDF派生把CK/IK或者RES进一步推演出Kseaf、Kausf等5G锚密钥USIM本身算的东西和3G时代没有本质区别。另外一个现实原因是eSIM和物联网设备的爆发。大量eSIM profile的个人化、测试工具链、网络模拟器都需要在网元侧或者终端侧独立实现Milenage而不是依赖真实USIM。我见过不少做NB-IoT模块的团队因为拿不到卡商的内部文档只能自己照着3GPP规范撸一份算法代码最后卡在测试向量对不上这个坎上。1.3 Milenage不是“一个算法”而是一组f函数家族这是理解Milenage最核心的认知升级。它不是单个算法而是一组承担不同职责的函数函数输出用途f1MAC-A64位计算消息认证码供USIM校验网络侧AUTNf1*MAC-S64位重同步场景下计算MAC-Sf2RES64位计算应答值供网络侧校验USIMf3CK128位生成加密密钥f4IK128位生成完整性保护密钥f5AK48位生成匿名密钥用于隐藏SQNf5*AK48位重同步场景下生成匿名密钥这些函数共享同一套密码学构件只是输入组合和参数不同所以代码上可以用一套核心逻辑统一处理而不是写七个独立函数。2. 把TS 35.205读薄Milenage的算法骨架2.1 底层构件为什么选AES/Rijndael当引擎Milenage的底层加密引擎是Rijndael算法固定使用128位分组长度和128位密钥长度。这里有个历史背景3GPP在2000年前后遴选算法时Rijndael刚刚被选为AES标准硬件实现简单、评估充分于是直接拿来做Milenage的核心。这意味着你的AES-128加密函数可以直接复用。几乎所有主流平台都有现成的AES实现OpenSSL、mbedTLS、或者自己写一个纯查表版都行Milenage本身不要求特殊的AES变体。实现Milenage第一步是确认目标平台有AES-128加密能力。解密操作在Milenage里反而不需要所有运算都是正向加密这一点比很多混合密码协议要省事。2.2 OP与OPc运营商定制参数是如何嵌入算法的Milenage里有两个容易混淆的参数OP和OPc。OP是运营商自定义的128位参数用来将标准算法实例化成运营商自己的版本。但算法内部不会直接用OP而是用从OP派生出来的OPc。OPc的计算公式特别简单OPc AES_K(OP) XOR OP也就是先用密钥K对OP做一次AES-128加密再把加密结果和OP本身逐字节异或。这一步在卡个人化阶段就完成OPc被固化在USIM里。密钥K和运营商参数OP分开管理这种设计的好处是就算开源实现了标准算法没有正确的OP/OPc也推不出任何有效认证参数。代码里我建议把OPc的计算独立成一个函数每次启动时根据K和OP算一次并缓存而不是每次认证请求都重复计算。后面第四章会给出具体实现。2.3 五个核心函数的统一生成模型TS 35.205里那张构造图看起来复杂拆开看其实就两步。第一次AES加密生成TEMP第二次针对不同函数用不同方式加工TEMP。整体模型如下第一步计算TEMP AES_K(RAND XOR OPc)。这是所有f函数共用的中间值。第二步按函数类型构造对应的128位输入IN分别经过异或OPc、循环移位、与TEMP异或后再做一次AES-128加密得到OUT。第三步从OUT中按规范指定的位置截取MAC、RES、AK等输出。具体到每个函数函数输入IN的构造方式输出处理f1/f1*SQN48位 AMF16位 SQN48位 AMF16位共128位取OUT的前64位作为MAC-A/MAC-Sf2直接使用RAND取64位作为RESf3直接使用RAND取全部128位作为CKf4直接使用RAND取全部128位作为IKf5直接使用RAND取低48位作为AK这里要注意f2到f5虽然输入都是RAND但标准为不同函数分配了不同的循环移位量和常量最终输出完全不同。2.4 标准常数r1到r5和c1到c5是谁定的、能不能改Milenage的构造中包含一组标准常数循环移位量r1到r5以及异或常量c1到c5。规范给出一组默认值运营商理论上可以自行选择是否更换。标准实现里常用的默认循环移位量是函数旋转量位f1/f1*64f20f332f464f596循环移位方向是比特循环左移。实现时要注意这个移位是跨字节的不是简单的字节数组左移而是把整个128位当作一个环从最低位通常认为是数组第一个字节的最低位方向往高位移动。正因为这个特性很多第一次写代码的人会在这里栽跟头。c1到c5则是参与第二次AES加密前的异或常量不同函数用不同常量来区分。具体值在TS 35.205的表格里有明确列表实现时直接照抄即可。3. 写代码前必须理清的三个字节级细节3.1 端序为什么同样的K和OP你算出来跟测试向量差一截Milenage实现中翻车率最高的点就是端序。3GPP规范里的所有参数都以网络字节序大端给出也就是十六进制字符串从左到右依次对应数组下标0到15。但很多AES库的输入输出都直接吃字节数组如果你在不同环节做了一次大小端转换结果就会完全错乱。比如测试向量里K是000102030405060708090A0B0C0D0E0F数组第一个字节就是0x00最后一个字节是0x0F。如果有人在读入十六进制字符串时用了小端解析K就变成了0F0E0D0C0B0A09080706050403020100算出来的所有结果都会对不上。我自己的经验是所有参数统一用uint8_t[16]表示从十六进制字符串到字节数组的转换只做一次后续全部按数组下标访问绝不做字节序转换。这样最不容易出错。3.2 ROT循环移位到底怎么移跨字节位运算ROT操作是Milenage里最容易写错的地方。它把128位数据看作一个循环队列从bit位置0开始向左移动r位移出最左端的比特回到最右端。在C语言里可以用类似下面的方式实现void rot_bits(uint8_t *out, const uint8_t *in, int shift) { int i, byte_shift shift / 8; int bit_shift shift % 8; uint16_t acc; for (i 0; i 16; i) { uint8_t cur in[(i byte_shift) % 16]; uint8_t next in[(i byte_shift 1) % 16]; acc (cur 8) | next; out[i] (uint8_t)((acc bit_shift) 8); } /* 处理环回最后一次的next要取in[0] */ uint8_t last_next in[(0 byte_shift 1) % 16]; out[15] ...; }上面只是示意真正的跨字节循环左移需要把16个字节首尾相接后再按位搬移。有一个更省心的做法把字节数组转成两个64位整数用64位整数的循环位移完成后再拆回字节数组。这样代码清晰很多。处理64位整数时要注意移动的方向和整个128位的整体性拆成高低两个64位后会引入跨64位边界的8位传递问题所以处理起来反而容易引入边界bug。我最终选择的是保留字节数组实现多做几次测试向量验证比较稳妥。3.3 SQN和AMF的拼接顺序IN1不是你想的那样f1函数里IN1的构造是另一个高频出错点。规范要求把SQN48位和AMF16位各拼接一遍凑成128位IN1 SQN || AMF || SQN || AMF也就是说64位的前半段是SQN加AMF后半段和前半段完全一样。因为SQN是6字节、AMF是2字节合起来正好8字节重复两遍就是16字节。我第一次看到这个拼接方式的时候也愣了一下第一反应是“是不是写错了为什么重复一遍”。后来想明白了Rijndael分组长128位但SQN和AMF加起来只有64位为了把输入凑满就重复一遍。这个设计本身是为了让MAC计算覆盖完整的RAND等上下文重复拼接是规范明确规定的做法。3.4 RES/AK/MAC的截位位置每个函数输出后要从完整的128位OUT中截出目标长度的数据。规范对每个函数的截位位置有明确图示。最常见的版本是取OUT的高位部分。这里我不建议凭记忆去写一定要对着TS 35.205里的图或者直接拿35.206测试向量反推。反推的方法很简单如果你已经知道RAND、K、OPc、SQN、AMF并且手头有标准给出的RES、CK、IK、AK、MAC-A那就可以对OUT做不同截位尝试一旦截位方式正确所有测试向量全部会匹配。这是最可靠的校准方式。4. 一个能跑起来的Milenage核心参考实现4.1 OPc计算的经典写法OPc计算很短但要特别注意XOR的先后顺序。下面是Python的示意实现from Crypto.Cipher import AES def calc_opc(k: bytes, op: bytes) - bytes: cipher AES.new(k, AES.MODE_ECB) enc_op cipher.encrypt(op) return bytes(a ^ b for a, b in zip(enc_op, op))注意两点使用ECB模式不需要IVAES的输入输出都是16字节和OP长度一致。把OPc计算做成独立函数还有一个好处调试时如果怀疑OPc有误单独跑这一小段就能定位问题。4.2 主流程generate()的骨架下面给出一个参考性的核心生成流程展示算法的整体结构。这段代码做了一些简化重点展示各个函数如何复用TEMPdef milenage_generate(k, rand, sqn, amf, opc): # 第一步TEMP AES_K(RAND XOR OPc) temp xor_bytes(rand, opc) temp aes128_encrypt(k, temp) # f1: MAC-A 第二次AES加密后取前64位 in1 sqn amf sqn amf # 12字节? 需要补4字节? 见说明 out1 aes128_encrypt(k, xor_bytes(temp, rot_l(xor_bytes(in1, opc), R1))) mac_a out1[:8] # f2: RES ROT(R2, TEMP) XOR RAND取64位 res_tmp xor_bytes(rot_l(temp, R2), rand) res res_tmp[:8] # 截位位置以规范为准 # f3: CK AES_K(TEMP XOR ROT(R3, RAND XOR OPc)) out3 aes128_encrypt(k, xor_bytes(temp, rot_l(xor_bytes(rand, opc), R3))) ck out3 # f4: IK 和f3结构一致只是旋转量换成R4 out4 aes128_encrypt(k, xor_bytes(temp, rot_l(xor_bytes(rand, opc), R4))) ik out4 # f5: AK ROT(R5, TEMP) XOR RAND取低48位 ak_tmp xor_bytes(rot_l(temp, R5), rand) ak ak_tmp[:6] return mac_a, res, ck, ik, ak上面关于in1的注释是特意留的。很多人在这个位置会疑惑SQN加AMF各重复一遍应该是16字节但SQN是6字节、AMF是2字节拼接后是16字节所以我注释里的“需要补4字节”是错的不用补。实际上sqn amf sqn amf恰好在Python里得到16字节。这只是一个示意具体以规范为准。这里的重点是所有f函数都建立在同一个TEMP基础上所以整个计算非常紧凑。实际C实现里可以用一个16字节的临时数组反复复用避免大量内存拷贝。4.3 在开源实现里找正确答案PySim、OsmoCNI怎么组织的如果不想完全从零开始强烈建议看两个成熟开源实现PySim和OsmoCNI。PySim是一个SIM卡管理工具集里面的milenage实现非常清晰用Python写的适合快速理解和验证算法逻辑。OsmoCNI原OpenBSC里的milenage.c是C实现很多商业测试工具都在参考它。我的建议是先用PySim的代码把流程跑通确认自己的理解正确再按目标平台用C或者其它语言重写一遍。这样比直接抱着规范硬啃效率高很多。5. 拿TS 35.206测试向量校准实现比看十篇博客有用5.1 测试向量长什么样怎么读TS 35.206的标题是“Implementation Data”里面提供了完整的测试向量。每个向量会给出K、RAND、SQN、AMF、OP以及对应的OPc、MAC-A、MAC-S、RES、CK、IK、AK。第一组测试向量的参数大概是K: 000102030405060708090A0B0C0D0E0FRAND: 1112131415161718191A1B1C1D1E1F20OP: 101112131415161718191A1B1C1D1E1F具体SQN和AMF值我不在这里凭记忆写了实际使用务必打开35.206原始文档核对。测试向量的意义在于它是从“实现正确”的角度给出的答案比任何博客里的简化推导都权威。5.2 逐项比对MAC-A、RES、CK、IK、AK校准实现时不要只比对一个输出要把MAC-A、RES、CK、IK、AK全部比对。比对方式就是逐字节打印十六进制字符串然后用diff工具和测试向量对比。我习惯写一个小脚本把每组测试向量跑完后输出成统一格式再和标准答案做diff。只要有一个字节不一致就说明实现还有问题。各种输出的预期长度也要确认MAC-A是8字节RES是8字节AK是6字节CK和IK各16字节。长度不对说明截位逻辑有误。5.3 调试中常见失败模式现象最可能的原因所有输出全错且完全无规律K或RAND字节序搞反或者OPc计算错误只有MAC-A错RES/CK/IK/AK对f1的IN1拼接错误或者f1的旋转量不对只有RES错其它对f2的旋转量或截位位置不对CK和IK错但RES对f3/f4的旋转量或IN构造错误几乎所有值对只有一个字节不对常见于循环移位边界处理错误比如跨字节进位丢了一位如果结果是“差一字节”基本都是ROT实现中跨越字节边界的那段位搬移出了问题。建议把移位量改成0和64两个极端值单独测试能更快定位问题。6. 从裸算法到真实产品USIM集成与调试工具链6.1 算法在USIM里是怎么被调用的USIM卡上的Milenage不是独立运行的应用程序它由卡内的COSCard Operating System调用。手机发起AUTHENTICATE命令COS解析命令参数后把RAND、AUTN中的相关字段传给Milenage模块Milenage计算完毕后把RES、CK、IK等结果返回给COS由COS封装成命令响应返回给手机。TS 31.102和TS 31.111对AUTHENTICATE命令的参数格式有详细定义。如果你在做终端侧适配通常不需要直接接触USIM内部的算法只需要知道命令交互格式。但如果你在做软卡模拟就需要自己把这些函数嵌入到模拟COS的命令处理逻辑里。6.2 软卡模拟与读卡器实测的手段做Milenage开发时我常用的验证路径有三条纯软件验证在PC上写好算法用35.206测试向量把结果校准到完全一致。这一步是基础所有后续工作都建立在这个基础上。软卡模拟用jCardSim或类似的Java Card模拟器把Milenage算法以Applet形式加载进去再用读卡器测试工具走一条真实的AUTHENTICATE命令链路。这一步能验证算法和命令交互的配合。真实卡片反向验证如果手头有运营商提供的测试USIM可以把RAND作为输入发给卡片把卡片返回的RES和软件计算结果做对比。这一步是最终验收因为真实卡片上的OP/OPc只有卡商知道通常需要和卡商约定专门的测试RAND场景。6.3 面向eSIM和物联网的移植注意点eSIM和物联网场景下USIM表现形态可能是eUICC芯片、贴片卡、软SIM甚至纯软件模拟。移植Milenage时除了算法本身还要考虑几个实际问题。第一平台差异。物联网模组上通常用C语言很多是MCU环境可能没有浮点单元和完整标准库AES实现要选择适合嵌入式场景的开源版本比如tiny-AES或者mbedTLS裁剪版。第二密钥保护。虽然软SIM场景下密钥无法做到硬件级保护但至少不要把K和OPc明文硬编码在通用代码里。我见过很多原型项目把测试密钥直接写在全局数组里调试完就忘了删这在产品化时是高风险隐患。第三性能。Milenage单个计算只有两次AES加密性能瓶颈几乎可以忽略真正要关注的是SIM卡命令交互的时序。如果是在ICCB上做实现要留意COS调度的开销。工具/参考用途TS 35.205算法规范看懂构造逻辑TS 35.206测试向量校准实现的唯一标准PySimPython参考实现快速验证理解OsmoCNI milenage.cC参考实现适合嵌入式移植参考jCardSimJava Card模拟器软卡验证以我自己的经验最有效的学习路径是先跑测试向量再读规范最后写代码。顺序反过来会非常痛苦。Milenage这种老牌算法网上资料虽多但鱼龙混杂直接引用一手规范文档再用开源实现做交叉验证比自己闷头推导要省时间得多。最后再分享一个小技巧开发调试时把OPc单独打印出来先确认OPc算对了再往下走。我遇到过的几乎每一起“算法全错”案例最后都回到OPc计算这个起点。Milenage整个链条只要OPc错一位后面所有结果都会错得“很有规律”让你误以为是某个f函数的问题排查半天才发现是源头错了。