跳到正文
FoxAuth

博客

DPoP 能防什么,不能防什么

泄露的访问令牌,谁捡到谁就能用。DPoP 把令牌绑定到客户端保管的密钥上。这篇讲它能挡住什么、代价是什么,以及什么时候我不会用它。

作者 FoxAuth ·

令牌出现在一份崩溃报告里。

我当时负责一个 API,有个移动应用(在 OAuth 里叫客户端)正在接入它。这个应用遇到没有处理的错误时,它用的 HTTP 库会把失败的请求打包,发给一家外部的崩溃收集服务,连请求头一起。于是那家服务的仪表盘上出现了 Authorization: Bearer eyJhbGci...。这个令牌还能用十四分钟。对方值班的支持人员谁都能看到它。据我所知,没有人用过它。但如果有人用了,协议里没有任何东西能拦住他。

这不是谁的代码有 bug。bearer 令牌(「持有者令牌」)本来就是这么定义的。RFC 6750 写得很清楚:谁持有 bearer 令牌,谁就能使用它。资源服务器就是接收令牌的那个 API。它会检查令牌的签名、过期时间、受众(令牌是发给哪个 API 的)和 scope(令牌允许做什么)。但它从来不问那天下午唯一要紧的问题:调用方是不是当初拿到令牌的那一方?它也没法问,因为请求里没有任何能回答这个问题的东西。

DPoP(RFC 9449)在请求里加入了能回答这个问题的东西。我想把 DPoP 能带来什么、带不来什么说准确。我见过有人把它当作「能阻止令牌被盗」来推销。它实际做的事情更窄,也更有用。

客户端在每个请求里签什么,以及为什么被盗的令牌没有密钥就没用

客户端在申请令牌之前,先生成一对密钥:一把公钥,一把私钥。私钥只留在自己手里。之后,每个带着令牌的请求,都会再附上一个小的、已签名的 JWT,叫作证明(proof)。证明的 header 里放着公钥。它的 payload(JWT 的正文)描述这份证明属于哪个请求。下面是一次 userinfo 端点调用的证明,已经解码:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA" }
}
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "GET",
"htu": "https://auth.example.com/userinfo",
"iat": 1758196200,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
"nonce": "eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o"
}

JWT 里的字段叫声明(claim)。证明里的每个声明都堵住一个漏洞。少了它,某种重放(replay)就能钻过去。重放就是攻击者把截获的东西再发一次。下图标出了哪个声明堵住哪个漏洞。顺着箭头看:每支箭头都从证明出发,落在服务器自己就能检查、不必依赖客户端的东西上。

DPoP 证明中每个声明绑定到什么:公钥绑定到令牌的 cnf.jkt,ath 绑定到令牌,htm 和 htu 绑定到请求,jti 绑定到重放存储,nonce 绑定到服务器自己的时间窗口DPoP 证明(已签名的 JWT)header.jwk — 公钥ath — 访问令牌的 SHA-256htm、htu — 方法与 URLjti — 每个证明唯一nonce — 由服务器签发iat — 签名时间由私钥签名,私钥保存在客户端访问令牌cnf.jkt = thumbprint(jwk)本次 HTTP 请求:本主机上的 GET /userinfo重放存储:5 分钟内见过这个 jti 吗?服务器自己的 nonce 窗口,无需存储RFC 7638 指纹哈希必须一致是最近签发的吗?iat:与服务器时间相差在 ±300 秒以内,除非带有 nonce

强调色的那支箭头是整个方案的支点。它就是发送方约束(sender-constraint):令牌绑定到一把密钥上,发送令牌的人必须证明自己持有这把密钥。访问令牌里有一个确认声明 cnf.jkt(cnf 是 confirmation「确认」的缩写)。它的值是签署证明的那把公钥的指纹(thumbprint)。指纹是密钥的一个短哈希,按 RFC 7638 规定的方法计算。这有什么用?资源服务器可能从没和这个客户端打过交道。但只看令牌本身,它就能知道令牌绑定的是哪把密钥。然后它再检查这份证明是不是用这把密钥签的。

其余的声明各管一件事。ath 是访问令牌本身的哈希。它防止有人把一份证明从一个令牌上摘下来,挂到另一个绑定了同一把密钥的令牌上。htm 和 htu 是请求的方法和 URL。它们防止为一个端点做的证明被拿到另一个端点上重放。jti 是每份证明唯一的标识符。服务器靠它发现同一份证明来了两次。nonce 和 iat 都管新鲜度,也就是证明是多久之前做的。它们在后面单独占一节,因为在生产环境里运行 DPoP,大部分麻烦都出在它们身上。

下面是实际的 HTTP 流量。先看授权码交换:客户端用授权码换取令牌。和普通的使用 PKCE 的交换相比,新增的只有两样:请求里的 DPoP 头,和响应里的 token_type 字段:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3a
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ...",
"token_type": "DPoP",
"expires_in": 900,
"scope": "openid orders:read",
"refresh_token": "Ql3oQ4fq8NWY0m9tHqqvqA1H7ArTn0O1x1PbUdECfxa"
}

token_type: DPoP 告诉客户端,从现在起 Authorization 头里该写哪种方案。客户端发送 Authorization: DPoP <token>,而不是 Bearer。如果服务器收到一个绑定了 DPoP 的令牌,用的却是 Bearer 方案,就应当拒绝。为什么?客户端写 Bearer,说明它不知道这个令牌绑定了密钥。那它也不会发送证明。

绑定信息放在哪里,取决于令牌的类型。不透明令牌(opaque token)是一串随机字符,里面没有任何内容,只有授权服务器知道它代表什么。要检查这种令牌,资源服务器得去问授权服务器(这种请求叫内省,introspection)。绑定信息就在回答里,放在 cnf 字段中。JWT 则自带内容。如果访问令牌是 JWT(这里正是如此),同样的 cnf 字段就直接放在它的 payload 里:

{
"iss": "https://auth.example.com",
"sub": "248289761001",
"aud": "https://api.example.com",
"client_id": "spa-7f3a",
"exp": 1758197100,
"scope": "openid orders:read",
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
}

所以整个流程是:授权服务器上两步,然后资源服务器上一组检查。最重要的就是这组检查,也就是第 4 步,值得仔细看一看。

绑定 DPoP 的令牌在令牌端点签发、随后出示给资源服务器的时序;资源服务器验证令牌、证明签名、密钥指纹、方法与 URL、令牌哈希以及证明的唯一性客户端(持有密钥 K)授权服务器资源服务器1 POST /token grant_type=authorization_codeDPoP: proof(htm POST, htu /token, jwk = pub(K))2 200 token_type: DPoPaccess_token,带 cnf.jkt = thumbprint(pub(K))3 GET /ordersAuthorization: DPoP <access_token>DPoP: proof(htm GET, htu /orders, ath = sha256(token), jti, jwk = pub(K))4 全部验证,缺一不可令牌签名、exp、aud、scope — 与以往相同证明签名可用 header.jwk 验证通过thumbprint(header.jwk) 等于令牌的 cnf.jkthtm/htu 即本请求,ath 即本令牌jti 从未见过,iat 或 nonce 足够新鲜5 200 OK

现在拿那份崩溃报告来走一遍这个流程。供应商的支持工程师拿到了访问令牌。但他没有私钥 K,所以签不出证明。用任何其他密钥签的证明,都会在第 4 步比对指纹时失败。令牌单独拿着,一文不值。

再假设他截获的是整个请求:令牌和证明一起。这份证明只对一个方法、一个 URL、一个令牌有效。它保持新鲜的时间,要么是按 iat 算的五分钟,要么是它那个 nonce 还被接受的期间。如果资源服务器还维护着重放存储(记录已经接受过的 jti),这份证明就只能用一次。攻击者手里原本是一个十四分钟内谁都能用的密码,现在只剩一个请求,而且未必能再发一次。

DPoP 防不住什么:在存放密钥的地方运行的代码

这是实实在在的改进。但也只有这一项改进。DPoP 保护的情形是:令牌在签发之后、在客户端以外的地方泄露。比如崩溃报告、代理日志、Referer 头、读取存储的浏览器扩展、配置错误的缓存。在所有这些情形里,攻击者拿到的是令牌,而不是密钥。

如果攻击者的代码就在存放密钥的地方运行,DPoP 毫无作用。以跨站脚本(XSS)为例:别人的脚本在你的页面里运行。假设一个单页应用(完全在浏览器里运行的网页应用)把密钥存在 IndexedDB 里,也就是浏览器内置的数据库。攻击者的脚本不需要偷走任何东西再发出去。它直接用真正的密钥签署证明,以真实用户的身份、从真实的页面调用 API。第 4 步的每项检查都会通过,它们本来就该通过。把令牌绑定到一把攻击者也能使用的密钥上,什么也保护不了。

我在不止一次设计评审中听到过这样的一问一答:「前端被攻破了会怎样?」「我们用了 DPoP。」这根本不是那个问题的答案。DPoP 能阻止被盗的令牌在别处被重放。但如果存放密钥的地方本身被攻破,它帮不上忙。

DPoP 也替代不了那些乏味的基础措施。假设有一个绑定了 DPoP 的令牌,有效期四小时,没有受众限制。谁攻破了客户端,谁就拿到一个四小时内对任何 API 都有效的令牌。限制这种损失的,是短有效期、受众限制和刷新令牌轮换。刷新令牌比访问令牌活得更久,客户端用它换取新的访问令牌。轮换的意思是每用一次刷新令牌,就换发一个新的。DPoP 和这些措施一起工作,而不是取代其中任何一项。

服务器为什么要发 nonce:客户端的时钟可能不准

没有 nonce 时,证明是否新鲜只能看 iat,也就是签名的时间。这就要求客户端的时钟和服务器的时钟一致。我们的服务器接受的 iat,与自身时间相差不能超过 300 秒。RFC 有意把这个窗口的大小交给各个服务器决定。现在设想一部时钟偏了六分钟的手机。它的每个请求都会以 invalid_dpop_proof 失败。这个错误里没有任何地方说「你的时钟不对」,只说证明有问题。总会有人为此耗掉一个下午。我就当过那个人。

服务器 nonce(RFC 9449 §8 和 §9)让新鲜度不再依赖客户端的时钟,而是依赖服务器的时钟。nonce 是服务器下发的一个值。客户端不需要理解里面是什么,只要把它放进下一份证明的 nonce 字段。只要这个 nonce 是服务器最近签发的,证明就会被接受。现在新鲜度由 nonce 保证,iat 只作参考。代价在第一个请求上。客户端第一次来时还没有 nonce,所以第一个请求按设计必然失败。这次失败必须带回客户端修正所需的一切:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce is required in the DPoP proof", algs="ES256 PS256"
DPoP-Nonce: eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o

客户端读取 DPoP-Nonce 头,用这个 nonce 签一份新证明,然后重试。之后的任何响应都可能带来一个新的 DPoP-Nonce,客户端把它用在下一个请求里。所以一个持续发请求的客户端再也不会看到 401。一个闲置了几分钟的客户端会再看到一次。下图展示了这个往返。客户端库必须把它实现成一个普通的循环,而不是当作错误来处理。

DPoP nonce 往返:不带 nonce 的首个请求被拒绝,返回 use_dpop_nonce 和 DPoP-Nonce 头;重试时携带该 nonce 并成功,成功响应再交出下一个 nonce客户端资源服务器1 GET /orders DPoP: proof(无 nonce)401 WWW-Authenticate: DPoP error=“use_dpop_nonce”DPoP-Nonce: n12 GET /orders DPoP: proof(nonce = n1)200 OKDPoP-Nonce: n2 — 带入下一份证明;客户端保持繁忙时不会再出现 401在令牌端点,同样的拒绝是 400,错误放在响应体中,而不是 401。

这次交换里有两件事花掉了我们实实在在的时间,而且都与密码学无关。第一件是状态码。先看令牌端点 /token,也就是授权服务器签发令牌的地方。按 RFC 9449,在那里证明有问题要报告为 400,错误放在响应体里,和这个端点的其他所有错误一样。资源服务器报告同一个问题,则用 401,错误放在 WWW-Authenticate 头里,和 RFC 6750 的做法一样。同一个错误有两种状态码,哪个正确取决于端点扮演的角色。我们的服务器自己有两个接收访问令牌的端点(受保护资源)。起初,它们各自在自己的 catch 块里纠正状态码。后来我们把这个决定移进了共享的错误处理器,由它按路由选择状态码。从那以后,新增第三个受保护资源时,就不可能绕开这条规则。

第二件是 CORS:浏览器用来决定脚本能从另一个源(scheme、主机或端口不同)的响应里读到什么的规则。除非服务器在 Access-Control-Expose-Headers 里列出 WWW-Authenticate 和 DPoP-Nonce,否则浏览器不会让脚本读取这类响应里的这两个头。读不到它们,单页应用只看到一个 401,却看不到里面写了什么,于是永远无法重试。规范只是顺带提了一句。可 DPoP 在浏览器里到底能不能用,恰恰取决于这一点。

nonce 背后藏着一个设计问题,我觉得它比规范里看起来更有意思:服务器怎么知道哪些 nonce 是它最近签发的?显而易见的答案是存下来。这是个糟糕的答案。每个响应都会带一个 nonce,于是每次 API 调用在返回之前都得先写一次存储。所以我们不存 nonce,而是算出来。每一分钟的 nonce 都由 HKDF 得到,HKDF 是一个从秘密值推导出新值的标准函数。它的输入是服务器的秘密值(secret),以及作为 salt(额外输入)的分钟序号。只要证明里的 nonce 与当前分钟前后共五分钟中任意一分钟的 nonce 一致,证明就会被接受:

const STEP = 60;
function nonceFor(secret: Uint8Array, step: number): string {
const salt = new Uint8Array(8);
new DataView(salt.buffer).setBigUint64(0, BigInt(step));
return base64url(hkdfSync('sha256', secret, salt, '', 32));
}
export function nextNonce(secret: Uint8Array): string {
return nonceFor(secret, Math.floor(Date.now() / 1000 / STEP) + 1);
}
export function acceptsNonce(secret: Uint8Array, nonce: string): boolean {
const now = Math.floor(Date.now() / 1000 / STEP);
let matched = 0;
for (const offset of [-2, -1, 0, 1, 2]) {
matched |= Number(constantTimeEqual(nonceFor(secret, now + offset), nonce));
}
return matched === 1;
}

这段代码里有三个刻意的选择。第一,服务器下发的是下一分钟的 nonce,而不是当前这一分钟的。客户端即使在某一分钟快结束时才拿到它,在它滑出窗口之前也还有三四分钟。第二,acceptsNonce 会把 nonce 和全部五个候选值都比较一遍,再把结果合并起来,而不是一匹配上就返回。因此,检查花的时间不会透露究竟是哪一分钟匹配上了。第三,什么都不存。只要持有相同的秘密值,任何一个副本(服务器的一份拷贝)都会接受其他副本签发的 nonce。重启也不会忘掉任何东西,因为本来就没有东西要记。

代价是无法撤销。算出来的 nonce 不能提前收回,也不区分客户端。这没问题。nonce 只说明证明是新鲜的,并不让证明变成一次性的。一次性靠的是 jti。把两者混为一谈,人们就会误以为 nonce 能防重放。它不能。

检测重放,意味着每个请求都要查一次数据库

要靠 jti 检测重放,服务器就得按客户端记住它接受过的每一个证明标识符。每个标识符要记多久,取决于证明还可能被接受多久:五分钟;如果证明上的时间比服务器时钟超前,最长可达十分钟。这意味着每次带令牌的 API 调用,都要先读一次、再写一次。假设你的资源服务器是一个自动扩缩、自身不保存任何状态的函数。那么这个存储就是 DPoP 之前不存在的新依赖。人们第一次注意到它,通常是在重放存储开始触发告警的时候。

人们很想把重放检测关掉。在这里我想坦白说说我们做的一个决定。我们的服务器有一个关闭重放检测的开关。它写在服务器启动时读取的配置里,并且刻意没有出现在管理控制台和管理 API 中。它只为两种情况存在。一种是一致性测试套件(检查服务器是否符合标准的测试),它会故意重放同一份证明。另一种是排查问题时重复发送请求的人。这两者都不属于管理工作。

设想一个开关:它去掉了一项安全保护,而发现文档(discovery,服务器列出自己支持哪些功能的文档)还在宣告这项功能。这正是那种会在凌晨两点的事故中被拨动、然后再也没被拨回来的开关。而凌晨两点,人们恰恰是在控制台里操作。所以它不在那里。

没有重放检测,你失去的东西远比「DPoP 失效了」要少。假设攻击者截获了完整的请求,令牌加证明。他可以重放这个一模一样的请求,方法和 URL 都相同,直到证明的 nonce 或 iat 过期。把一个 GET /orders 重放五分钟,是真实的泄露,但不是灾难。把一个 POST /transfers 重放五分钟,就是另一回事了。假设你的 API 是幂等的(重复请求不会改变任何东西),而且以读取为主。那你完全可以认定,重放存储不值得建。我不会默认这么做。我也不会让人能在网页上来回切换它。

DPoP 最大的用处是保护刷新令牌,而不是访问令牌

规范里有一条很容易一扫而过的条款。有些客户端是公共客户端:它们没法在令牌端点证明自己是谁。对公共客户端,刷新令牌也会绑定到密钥上。对机密客户端(能证明自己是谁的客户端)则不会。机密客户端的刷新令牌本来就绑定在客户端凭据上,服务器每次使用刷新令牌时都会检查这些凭据。在我们的实现里,这只是一个条件判断:如果客户端的认证方式是 none,刷新令牌就继承访问令牌的 jkt。

我认为,这一条是在浏览器里使用 DPoP 最有力的理由,比任何关于访问令牌的理由都更有力。在单页应用里,访问令牌活十五分钟,刷新令牌却能活好几天。它存放在浏览器存储里。攻击者最可能找到它的地方恰恰就是这里:泄露的浏览器配置文件备份,或者一台共用的电脑。在 DPoP 之前,从浏览器里偷走的刷新令牌,只要轮换还让它活着,就能一直打开这个账户。

现在加上 DPoP 和一把不可导出的 WebCrypto 密钥:页面可以使用这把密钥,却无法把它从浏览器里复制出去。这时被盗的刷新令牌只是一串字符,换不到新的令牌。因为兑换本身需要一份用这把密钥签署的证明,而密钥从没离开过这个源。轮换依然重要。但它现在拦住的是一个已经被挡住的攻击者,而不是一个已经进门的攻击者。

同样的思路还能往前再推一步,靠的是 dpop_jkt。这是授权请求的一个参数。它在授权码签发之前,就把授权码绑定到密钥上。这样,在重定向和兑换之间被截获的授权码,就无法用另一把密钥兑换。其实 PKCE 已经能防住其中的大部分。而且当证明随推送授权请求(PAR:客户端先把授权参数直接发给服务器)一起到达时,我们的服务器会自动填入 dpop_jkt。所以实际上它没有任何成本。它不是开启 DPoP 的理由,也不是关闭 DPoP 的理由。

什么时候 DPoP 值得,什么时候不值得

DPoP 的成本有三项,签名不在其中。第一,客户端需要一把密钥、一个存放它的地方,以及每个请求上的一份证明。在浏览器里,密钥应当是 IndexedDB 中一个不可导出的 CryptoKey;在原生应用里,应当放在平台的密钥库里。如果密钥能被导出,你花代价换来的大部分东西就悄悄没了。第二,客户端必须把 use_dpop_nonce 当作重试的信号,而不是失败。服务器也必须向跨源调用方开放两个响应头,否则重试永远不会发生。第三,必须有人负责一个记录五分钟后过期的重放存储,或者为不建它的决定负责。

作为回报,DPoP 在三种情形下物有所值。令牌要经过你控制不了的基础设施。公共客户端持有长期有效的刷新令牌。或者一个令牌泄露造成的损失大到,「谁捡到都能用十四分钟」这句话你在评审里说不出口。

再看相反的情形:你自己的机密客户端,运行在服务器上,通过 TLS 调用你自己的 API,令牌有效期五分钟,受众范围很窄。这种情况下我不会费这个事。用 mTLS 绑定的令牌(客户端也出示证书的 TLS),或者普通的短期 bearer 令牌,就能以更少的客户端代码得到同样的效果。

我答不上来的问题是,浏览器接下来会怎么做。Device Bound Session Credentials(DBSC)原是 Chrome 的提案,现在是一份 IETF 草案。它想给 cookie 同样的属性:绑定到密钥。在它那里,密钥由浏览器而不是页面持有。这恰恰是 DPoP 跨不过去的边界。如果 DBSC 落地,并且发展出支持 API 令牌的方式,今天客户端里的大量 DPoP 代码就会变成平台替你做的事。如果它只停留在 cookie 上,DPoP 就仍是浏览器客户端在不用客户端证书的情况下唯一能用的发送方约束。我会按第二种情况去构建,第一种真发生了我会很高兴。

如果你想看看它在一个运行中的服务器里是什么样子:在 FoxAuth 里,先为整个服务器开启 DPoP,再通过客户端属性 dpop_bound_access_tokens 为每个客户端开启。是否要求 nonce 是一项设置。重放开关就是上文说的那个,刻意不放在控制台里。设置参考列出了这些选项,端点参考说明了哪些端点接受证明,威胁模型则记录了每项检查是为了阻止什么。