news 2026/9/12 22:17:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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在整套通信流程中的位置。简单说,手机开机后网络侧要确认“你手里这张卡是不是真的”,这个确认过程叫AKA(Authentication and Key Agreement),而USIM卡里的Milenage就是AKA中负责计算认证参数的那颗“算法心脏”。

流程大致是这样:网络侧(HSS/UDM)生成一个128位的随机数RAND,同时用USIM里预置的密钥K算出期望应答XRES和AUTN(包含MAC-A和隐藏后的SQN),然后把RAND和AUTN下发给手机。手机把这两个参数交给USIM,USIM内部调用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-A(64位)计算消息认证码,供USIM校验网络侧AUTN
f1*MAC-S(64位)重同步场景下计算MAC-S
f2RES(64位)计算应答值,供网络侧校验USIM
f3CK(128位)生成加密密钥
f4IK(128位)生成完整性保护密钥
f5AK(48位)生成匿名密钥,用于隐藏SQN
f5*AK(48位)重同步场景下生成匿名密钥

这些函数共享同一套密码学构件,只是输入组合和参数不同,所以代码上可以用一套核心逻辑统一处理,而不是写七个独立函数。

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*SQN(48位)+ AMF(16位)+ SQN(48位)+ AMF(16位),共128位取OUT的前64位作为MAC-A/MAC-S
f2直接使用RAND取64位作为RES
f3直接使用RAND取全部128位作为CK
f4直接使用RAND取全部128位作为IK
f5直接使用RAND取低48位作为AK

这里要注意,f2到f5虽然输入都是RAND,但标准为不同函数分配了不同的循环移位量和常量,最终输出完全不同。

2.4 标准常数r1到r5和c1到c5是谁定的、能不能改

Milenage的构造中包含一组标准常数:循环移位量r1到r5,以及异或常量c1到c5。规范给出一组默认值,运营商理论上可以自行选择是否更换。

标准实现里常用的默认循环移位量是:

函数旋转量(位)
f1/f1*64
f20
f332
f464
f596

循环移位方向是比特循环左移。实现时要注意,这个移位是跨字节的,不是简单的字节数组左移,而是把整个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的构造是另一个高频出错点。规范要求把SQN(48位)和AMF(16位)各拼接一遍,凑成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模式,不需要IV;AES的输入输出都是16字节,和OP长度一致。把OPc计算做成独立函数还有一个好处,调试时如果怀疑OPc有误,单独跑这一小段就能定位问题。

4.2 主流程generate()的骨架

下面给出一个参考性的核心生成流程,展示算法的整体结构。这段代码做了一些简化,重点展示各个函数如何复用TEMP:

def 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: 000102030405060708090A0B0C0D0E0F
  • RAND: 1112131415161718191A1B1C1D1E1F20
  • OP: 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不是独立运行的应用程序,它由卡内的COS(Card 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函数的问题,排查半天才发现是源头错了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 22:11:55

语音短时分析技术解析:分帧加窗、能量过零率与基音周期估计

简介&#xff1a;面向语音信号处理课程与数字信号处理初学者的短时时域分析实操包&#xff0c;以 MATLAB 源码和配套语音样本演示非平稳语音信号的分帧、加窗与短时参数提取过程。包内共 9 个文件&#xff0c;包括 8 个 .m 脚本和 1 个 .wav 测试音频&#xff1b;脚本分别实现短…

作者头像 李华
网站建设 2026/9/12 22:08:47

Windows本地部署COZE智能体:Docker Desktop+WSL2+DeepSeek实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 22:03:31

SSM就业平台:岗位智能匹配与MySQL索引优化实战

简介&#xff1a;本资源是一套完整的毕业设计级学生就业服务平台实现方案&#xff0c;面向计算机专业本科生、Java初学者及SSM框架学习者&#xff0c;解决高校学生求职与企业招聘信息不对称、流程管理低效等实际问题。压缩包含867个文件&#xff0c;总计52.83MB&#xff0c;涵盖…

作者头像 李华
网站建设 2026/9/12 22:01:12

遥感影像地块分割全流程优化:预处理-建模-后处理抗干扰闭环

简介&#xff1a;本资源为2021年MathorCup高校数学建模挑战赛大数据竞赛B题「遥感地块分割」国家一等奖获奖作品完整技术包&#xff0c;面向数学建模参赛学生、遥感图像处理初学者及深度学习实践者。方案基于tif遥感影像与png标注图&#xff0c;集成数据预处理、U-Net模型训练、…

作者头像 李华