摘要
本文是对RFC 9807所描述的增强型口令认证密钥交换协议OPAQUE的学习与梳理,包括:1)协议整体执行流程、2)注册方案和在线认证方案、3)关键模块OPRF与3DH。文章保留了原标准的章节对应关系,方便对照RFC原文阅读。
关键词:口令认证、OPAQUE、OPRF、aPAKE、3DH、密钥交换
本文导读
这是啥——这是RFC 9807 OPAQUE协议的配套学习笔记,先总览、再分模块、最后看流程,适合对照原文阅读,也适合想快速搞懂原理的读者通读一遍。
哪个读——密码协议研究者、安全开发工程师,以及正在学习PAKE、OPRF的童鞋。阅读前建议先了解OPRF(不经意伪随机函数,可参考RFC 9497)和DH密钥交换。
啷个读——建议先看第2章的架构总览,建立两个阶段和三条消息的整体印象;再按兴趣深入第3、4章的两个关键模块;第6、7章是完整的注册和登录流程,完整详情可以随时配套阅读RFC原文。
结算——OPAQUE用OPRF把口令隐蔽起来,让服务器在看不到口令的情况下,仍能和客户端完成双向认证并协商出会话密钥;即使服务器数据库泄露,攻击者也无法用彩虹表等手段批量破解口令。
3. 关键模块1:不经意伪随机函数OPRF(对应2.1节)
OPAQUE = OPRF + AKE,本章概述OPRF。
它遵循RFC 9497规范,一共三个接口。
OPRF是一个带密钥的伪随机函数,特殊之处在于不经意:客户端把自己的输入口令盲化之后发给服务器,服务器用自己的私钥求值,但服务器既看不到客户端的原始输入,也无法控制输出;客户端拿到结果后去盲,得到由口令和服务器私钥共同决定的值。
OPRF给协议提供了两个保证。
- 对客户端而言,输出看起来是随机的:相同输入在不同服务器密钥下得到不同的无法预测的结果,客户端也无法仅凭若干次查询就反推出服务器私钥。
- 对服务器而言,它无法挑选输出:求值的点是客户端盲化后送来的,服务器看不到也无法还原原始输入,只能用私钥做一次乘法,无法让某个特定口令得到它想要的结果。
这合起来保证了OPRF输出既和口令绑定又不受任何一方单方面操纵。
3.1 Blind(客户端盲化)
(blind, blinded_element) = Blind(element)
- 功能:客户端对自己的秘密输入做盲化。
- 输入:element(客户端的秘密值,即口令)。
- 输出:随机盲化因子blind、盲化后的椭圆曲线点blinded_element。
- 具体随机数生成和点运算步骤见RFC 9497。
3.2 BlindEvaluate(服务器盲化求值)
evaluated_element = BlindEvaluate(k, blinded_element)
- 功能:服务器用自己的OPRF私钥对盲化点求值。
- 输入:服务器OPRF私钥k、客户端发来的盲化点blinded_element。
- 输出:求值结果点evaluated_element,发回客户端。
- 步骤见RFC 9497。
3.3 Finalize(客户端去盲化)
oprf_output = Finalize(element, blind, evaluated_element)
- 功能:客户端去掉盲化因子,得到OPRF的最终输出。
- 输入:最初的秘密输入element、盲化因子blind、服务器返回的求值点evaluated_element。
- 输出:长度为Nh字节的oprf_output。
- 步骤见RFC 9497。
4. 关键模块2:AKE——3DH协议(对应6.4节)
OPAQUE使用的AKE是用3条消息完成的DH密钥交换,所以叫3DH。
客户端的长期密钥来自第6章的注册流程。
4.1 AKE概述
3DH通过三次交互、四次操作完成双方密钥协商。开始前双方都要准备好各自的公私钥对、身份标识和辅助数据。
图4-1 AKE四次操作三次交互流程概要图
【个人感受】个人认为RFC对3DH的描述略显纠缠——3DH这一层理应只负责AKE本身的事,OPRF的调度应交给上层的在线认证流程处理,标准里两者的边界写得不够清爽。
表4-1 AKE四次操作三次交互流程概要
序 | 客户端 | 交互数据 | 服务器 |
1 | 执行AuthClientStart创建KE1,KE1内含OPRF请求和AKE请求 | KE1 ————> | |
2 | <———— KE2,含OPRF响应和AKE响应 | 执行AuthServerRespond创建AKE响应,上层再与OPRF响应打包成KE2 | |
3 | 执行AuthClientFinalize创建KE3,同时得到session_key和export_key | KE3 ————> | |
4 | 执行AuthServerFinalize核验KE3,得到session_key |
4.2 AKE的数据结构(对应6.1节)
AKE要用到的状态和消息结构。
4.2.1 ClientAkeState(客户端AKE状态)
struct {
uint8 client_secret[Nsk]; // 秘密值,不透明字节串
KE1 ke1; // 本次握手的KE1
} ClientAkeState;
4.2.2 ServerAkeState(服务端AKE状态)
struct {
uint8 expected_client_mac[Nm]; // 预期的客户端MAC
uint8 session_key[Nx]; // 协商出的会话密钥
} ServerAkeState;
4.2.3 KE1(AKE请求)
KE1由一个OPRF请求和一个AKE请求组成:
struct {
CredentialRequest credential_request; // OPRF请求
AuthRequest auth_request; // AKE请求
} KE1;
struct {
uint8 client_nonce[Nn]; // 全新随机数
uint8 client_public_keyshare[Npk]; // 序列化后的客户端临时公钥
} AuthRequest;
4.2.4 KE2(AKE响应)
KE2由一个OPRF响应和一个AKE响应组成:
struct {
CredentialResponse credential_response;
AuthResponse auth_response;
} KE2;
struct {
uint8 server_nonce[Nn]; // 全新随机数
uint8 server_public_keyshare[Npk]; // 服务器临时公钥
uint8 server_mac[Nm]; // 用Km2对握手转录算出的认证标签
} AuthResponse;
4.2.5 KE3(密钥确认)
struct {
uint8 client_mac[Nm]; // 用Km3算出的客户端认证标签
} KE3;
4.3 3DH密钥协商函数(对应6.4.3+6.4.4节)
四个函数分别由客户端和服务器在四次操作中调用。
3DH的思路是:双方各有一对长期密钥和一对临时密钥,通过三种不同的私钥/公钥组合,分别算出三个共享秘密,再把它们拼到一起。这三个组合是:
- dh1:己方临时私钥 × 对方临时公钥(临时对临时,提供前向安全)
- dh2:己方长期私钥 × 对方临时公钥(长期对临时,保证身份绑定)
- dh3:己方临时私钥 × 对方长期公钥(临时对长期,同样绑定身份)
图4-2 AKE四次操作三次交互流程概要图(复用图4-1)
图4-3 3DH共享秘密构成
客户端和服务器各自算出的dh1、dh2、dh3是相同的。以服务器视角(Ser.SK*表示服务器临时私钥,Ser.SK表示服务器长期私钥,其他类似):
dh1 = Ser.SK* × Cli.PK* = Cli.SK* × Ser.PK*
dh2 = Ser.SK × Cli.PK* = Cli.SK* × Ser.PK
dh3 = Ser.SK* × Cli.PK = Cli.SK × Ser.PK*
这里不包含"长期私钥×长期公钥"的组合。因为长期私钥 × 长期公钥是一个固定不变的值,长期密钥一旦泄露,长期对长期的秘密就会被永久还原;去掉它,保留临时密钥参与的组合以保证前向安全。
三个共享秘密拼起来作为密钥材料:ikm = dh1 || dh2 || dh3
4.3.1 AuthClientStart(客户端创建KE1)
ke1 = AuthClientStart(credential_request)
- 功能:客户端生成KE1。
- 输入:credential_request(OPRF第一步生成的请求,用于填进KE1)。
- 输出:ke1,并在内部保存客户端状态。
- 关键步骤:
- 随机生成client_nonce和client_keyshare_seed;
- 用seed派生客户端临时密钥对(client_secret, client_public_keyshare);
- 组装auth_request,再与OPRF请求合成ke1;
- 把临时私钥和ke1写进ClientAkeState。
4.3.2 AuthServerRespond(服务端创建AKE响应)
auth_response = AuthServerRespond(cleartext_credentials, server_private_key, client_public_key, ke1, credential_response)
- 功能:服务器处理KE1,生成AKE响应。
- 输入:明文凭证、服务器长期私钥、客户端长期公钥、KE1、OPRF响应。
- 输出:auth_response,并在内部保存服务器状态。
- 关键步骤:
- 随机生成server_nonce和临时密钥seed,派生服务器临时密钥对;
- 用前面讲的三种组合算出dh1、dh2、dh3,拼成ikm;
- 调用DeriveKeys(ikm, preamble)得到Km2、Km3、session_key;
- 计算服务器MACserver_mac = MAC(Km2, Hash(preamble)),并预存预期的客户端MACexpected_client_mac = MAC(Km3, Hash(preamble) || server_mac);
- 组装auth_response,把预期客户端MAC和session_key写进ServerAkeState。
4.3.3 AuthClientFinalize(客户端创建KE3)
(ke3, session_key) = AuthClientFinalize(cleartext_credentials, client_private_key, ke2)
- 功能:客户端验证服务器、生成KE3和会话密钥。
- 输入:明文凭证、客户端长期私钥、KE2。
- 输出:ke3、session_key;若服务器认证失败抛出ServerAuthenticationError。
- 关键步骤:
- 用与服务器相同的方式构造preamble、算出dh1/dh2/dh3,派生密钥;
- 重算预期的服务器MAC,与KE2里的server_mac比对,不一致就报错;
- 计算客户端MACclient_mac = MAC(Km3, Hash(preamble) || server_mac),组装成KE3。
4.3.4 AuthServerFinalize(服务端最终核验)
session_key = AuthServerFinalize(ke3)
- 功能:服务器核验客户端的KE3,确认无误后输出会话密钥。
- 关键步骤:把KE3里的client_mac与此前保存的expected_client_mac比对,不一致抛出ClientAuthenticationError;一致则返回session_key。
服务器先在KE2里给出server_mac,客户端凭它确认对面确实是持有正确长期密钥的服务器,并把握手转录锁定;客户端随后在KE3里给出client_mac,服务器凭它确认对面确实是恢复出正确凭证的合法用户。两个MAC分别用Km2和Km3计算,且客户端的MAC把服务器MAC也覆盖进去形成认证链。这样任一方的身份被冒充或握手过程中任一消息被改,都会在MAC校验环节暴露。
至此,双方在确认了彼此身份的同时,各自得到了相同的会话密钥。