第三章:PKCE:懒猫的 Authorization Code 为什么不能被偷用

忘机山人

发布于62天前
博客图片修整中,看不了可以先搜索公众号“忘机山人”看。
https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env

在第二章的结尾,我们刻意停在了一个被大多数流程图忽略的位置:

https://env.alice.heiyu.space/callback?code=abc123

这是懒猫 Authorization Code Flow 中一个看起来非常不起眼的瞬间。

浏览器回跳,URL 里多了一个参数。
ENV 查看器继续往下走,拿 code 换 token。

但如果你站在 ENV 查看器的立场仔细想一想,就会发现一个问题几乎是 unavoidable 的:

这个 code 是暴露在浏览器环境里的。
那它为什么还敢被当作“凭证”使用?

这一章,我们只做一件事:

证明:即使在“懒猫自动注入 CLIENT_SECRET”这种看似有密的场景下,没有 PKCE,Authorization Code Flow 在家庭服务器环境里也并不安全。


3.1 Authorization Code 到底是什么

在开始谈 PKCE 之前,我们必须先明确一件事:

Authorization Code 本身不是 token,也不是身份声明。

它只是一个中间凭证,用来完成两件事之间的衔接:

  • 浏览器参与的授权阶段(/sys/oauth/auth
  • 容器与懒猫 SSO 直接通信的 token 交换阶段(/sys/oauth/token

在理想设计中,它应该满足三个特性:

  1. 短期有效(懒猫 SSO 默认就是几十秒)
  2. 只能使用一次(用过即作废,重试会拿到 invalid_grant
  3. 只能被特定 Client 使用

前两个特性,单靠懒猫 SSO 就能保证。
第三个特性,问题就来了。


3.2 一个经常被低估的事实:家里的浏览器也是不可信的

在 Authorization Code Flow 中,code 一定会经过浏览器

这不是实现问题,而是协议设计的必然结果。

而浏览器环境在懒猫这种场景下意味着什么?

  • URL 会被浏览器历史记录、同步到云端
  • 如果你在盒子前挂了自建 nginx / openresty,访问日志里会留下 code
  • JavaScript 可以读地址栏(跨 App 的第三方脚本如果被加载进来,能读到)
  • 浏览器插件可以读取页面内容
  • Referer 可能被发送到第三方
  • 家人用的浏览器你不可能审计
  • 盒子开了公网访问后,任何经过的中间设备都有可能留下痕迹

换句话说:

只要 code 出现在 URL 里,就默认处在“可能被窃取”的环境中。

家庭内网不是纯净室,懒猫官方做了很多努力(证书、加密、子域隔离),
不能替你解决“code 被读到之后怎么办”。

如果你此刻的直觉是:

“那 code 被偷了是不是就完了?”

恭喜你,你已经走到 PKCE 之前所有安全设计者走到的那个位置。


3.3 没有 PKCE 会发生什么(不是假设,是攻击模型)

我们来构造一个完全现实的攻击场景,不引入任何极端条件。

场景

  • 盒子主人 Alice 装了一个稍微不那么干净的浏览器扩展(它请求了 <all_urls> 权限)
  • ENV 查看器使用 Authorization Code Flow
  • 假设没有 PKCE

正常流程

  1. Alice 在 Grant Access 页点同意

  2. 浏览器回跳:

    https://env.alice.heiyu.space/callback?code=abc123&state=xyz
    
  3. ENV 查看器后端用 abc123 换 token

攻击者(那个扩展)只需要做一件事

在第 2 步,读到这个 code

扩展不需要:

  • 窃取密码
  • 破解加密
  • 控制懒猫 SSO 它只需要获得:
abc123

然后把这个 code 连同它偷到的 CLIENT_SECRET(如果 App 是纯 SPA,secret 也在前端代码里),
自己向懒猫 SSO 发起:

POST /sys/oauth/token
  code=abc123
  client_id=xu.deploy.env
  client_secret=...

如果没有 PKCE,懒猫 SSO 没有任何技术手段区分

  • 这是 ENV 查看器的后端
  • 还是一个拿着同样 client 凭证的攻击者

于是,一个结论非常残酷:

在没有 PKCE 的情况下,懒猫 Authorization Code 本质上是“谁拿到 code + client 凭证,谁能用”。


3.4 为什么 LAZYCAT_AUTH_OIDC_CLIENT_SECRET 解决不了这个问题

到这里,很多懒猫 App 开发者会下意识想到:

“我在 manifest 里读 LAZYCAT_AUTH_OIDC_CLIENT_SECRET 了啊,应该算 Confidential Client 吧?”

这是一个只在后端场景成立的直觉

对有后端容器的懒猫 App

  • CLIENT_SECRET 通过环境变量注入到容器
  • 换 token 的 POST 发生在容器内,不经过浏览器
  • 攻击者拿不到 secret

👉 这种情况下,secret 确实有意义。

对纯前端 SPA 型懒猫 App

  • CLIENT_SECRET 要么在构建期打进前端 bundle
  • 要么在运行期从某个 /config 接口传给前端
  • 等价于公开
  • 攻击者同样可以使用

于是,对于纯前端 App:

CLIENT_SECRET 在安全意义上等于不存在。

即使是有后端的 App,只要你的 CLIENT_SECRET 不小心被日志打出来(懒猫玩家经常开 debug)、或者写到了可被别的容器读到的文件里,它的保密性就已经被打穿。

这正是 PKCE 存在的历史背景,也是它在懒猫 SSO 里不能省略的直接原因。


3.5 PKCE 的核心思想(不是参数,而是绑定)

PKCE(Proof Key for Code Exchange)不是“多加一个校验字段”。

它解决的是一个非常明确的问题:

如何证明:
“来换 token 的这个请求,
就是当初发起授权的那个 Client 实例?”

注意这里的关键词是:

  • 不是“哪个用户”(那是 Alice 的事)
  • 不是“哪个 IdP”(那是懒猫 SSO 的事)
  • 也不是“哪个应用包名”(那是 client_id 的事)
  • 而是:哪个正在运行的 Client 实例

即使两个人都在用 ENV 查看器,他们是两个独立的 Client 实例,PKCE 让他们彼此的 code 也不可互换。


3.6 PKCE 在懒猫上的完整流程(一步都不能省)

现在我们重新走一遍懒猫 Authorization Code Flow,但这次加上 PKCE。

懒猫 SSO discovery 里明确支持:

"code_challenge_methods_supported": ["S256", "plain"]

必须用 S256plain 只是兼容古董 App,新项目不要用。


Step 1:Client 本地生成一个秘密

在发起授权之前,ENV 查看器先在本地生成一个高熵随机值:

code_verifier = random(43~128 字符, [A-Z a-z 0-9 - . _ ~])

这个值有两个重要特性:

  1. 从不发送给懒猫 SSO(此时)
  2. 从不出现在浏览器 URL 中

它是真正的“秘密”,存在服务端 session 或安全的客户端存储里。


Step 2:Client 派生一个公开承诺

ENV 查看器对这个 secret 做一次变换:

code_challenge = BASE64URL(SHA256(code_verifier))

这个 challenge:

  • 可以公开
  • 不能反推 verifier
  • 与 verifier 有确定性数学关系

这一步,本质上是一个 commitment


Step 3:授权请求中只发送 challenge

浏览器跳转到懒猫 SSO 时,参数变成了:

GET https://alice.heiyu.space/sys/oauth/auth?
  response_type=code
  &client_id=xu.deploy.env
  &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback
  &scope=openid%20email%20profile
  &state=...
  &nonce=...
  &code_challenge=yyy
  &code_challenge_method=S256

注意:

  • 浏览器里只有 code_challenge
  • code_verifier 仍然只存在于 ENV 查看器本地(session / 后端内存)

Step 4:懒猫 SSO 将 code 与 challenge 绑定懒猫 SSO 在生成 Authorization Code 时,会记录:

authorization_code:
  value = abc123
  client_id = xu.deploy.env
  bound_challenge = yyy
  bound_method = S256

从这一刻起,这个 code 就不再是“自由凭证”。


Step 5:换 token 时提交 verifier(关键验证)

ENV 查看器请求 token 时,除了原本的参数,再带一个 code_verifier

POST https://alice.heiyu.space/sys/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(xu.deploy.env:<secret>)

grant_type=authorization_code
&code=abc123
&redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback
&code_verifier=zzz

懒猫 SSO 执行验证:

SHA256(code_verifier) == bound_challenge ?
  • ❌ 不相等 → 直接 invalid_grant
  • ✅ 相等 → 继续发 token

3.7 PKCE 实际验证的是什么

我们用一句话把 PKCE 的验证逻辑说清楚:

PKCE 验证的不是“你是谁”,
而是“你是不是当初那个发起授权的人”。

它解决的是:

  • code 被偷
  • code 被重放
  • code 被跨 Client 使用
  • 两个同名 App 实例之间的 code 串用

而它不解决

  • token 是否伪造(那是 JWKS + 签名的活)
  • token 是否过期
  • token 是否发给正确的 Client(那是 aud 的活)

这些问题,会在后面的章节里由 JWT 签名和 ID Token Validation 解决。


3.8 为什么 PKCE 对懒猫 App 是“必须”,不是“增强”

在懒猫 SSO 的实践中,可以明确给出一个结论:

在懒猫场景下,
没有 PKCE 的 Authorization Code Flow 是不安全的。

这不是建议,而是逻辑必然。

因为:

  • 盒子可能暴露公网,heiyu.space 子域对全世界可见
  • 同一个盒子上会跑很多 App,共享同一个懒猫 SSO
  • 家人的设备、家里的浏览器你不可能都信得过
  • 部分 App 是纯前端 SPA,CLIENT_SECRET 等于裸奔
  • Authorization Code 一定会暴露在 URL 里

PKCE 是唯一能够在这些前提下恢复 Client 绑定能力的机制。

懒猫 SSO 支持 S256,你只要用就对了。所有主流 OIDC 客户端库默认都打开 PKCE,不要主动关掉。


3.9 PKCE 在验证链中的位置

现在我们可以把 PKCE 放回到整条懒猫 SSO 验证链中:

阶段验证目标
PKCEAuthorization Code 是否被原始 Client 使用
JWT 验签token 是否由这台盒子的懒猫 SSO 签发
iss / audtoken 是否适用于当前 App(xu.deploy.env
exp / noncetoken 是否仍然有效、是否对应这次登录
scope / groupstoken 能做什么、用户是不是盒子主人

你会发现一个重要事实:

PKCE 是懒猫 SSO 验证链中最早的一道闸门。

如果这一关被绕过,后面所有校验都失去意义。


image.png

3.10 本章结论(明确且不可妥协)

我们可以用几句话结束这一章:

  1. Authorization Code 一定会暴露在浏览器环境,懒猫也不例外
  2. 家庭网络 + NAS暴露公网,是现实中的常见组合
  3. 没有 PKCE,code 等价于“谁拿到谁能用”
  4. 懒猫注入的 CLIENT_SECRET 无法单独替代 PKCE
  5. PKCE 把 code 与 Client 实例进行了数学绑定
  6. 因此,PKCE 不是增强,而是懒猫 SSO 的 成立条件

本章小结(一句话)

PKCE 的存在,说明懒猫 SSO 从一开始就假设:
浏览器和家庭网络,永远不值得无条件信任。

评论

0

暂无评论

说点什么呢~
收藏
0
0
0