忘机山人
在第三章结束时,我们已经解决了一个关键问题:
https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env
> **懒猫的 Authorization Code 不会被随意偷用。**
通过 PKCE,ENV 查看器终于可以确信一件事:
> **来 `/sys/oauth/token` 换 token 的请求,确实来自当初那个发起授权的 Client 实例。**
于是,流程自然向前推进。
ENV 查看器拿到了懒猫 SSO 返回的一组 token,其中最重要的一个是:
```
id_token
```
在实际懒猫 SSO 返回里,它看起来像这样:
```
eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2In0.
eyJpc3MiOiJodHRwczovL2FsaWNlLmhlaXl1LnNwYWNlL3N5cy9vYXV0aCIsInN1YiI6ImNpWUtIaGVpeXUiLCJhdWQiOiJ4dS5kZXBsb3kuZW52IiwiZXhwIjoxNzE2NDE5NzM0LCJpYXQiOjE3MTYzMzMzMzQsIm5vbmNlIjoieXl5IiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiQWxpY2UiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZSIsImdyb3VwcyI6WyJhZG1pbiJdLCJhdF9oYXNoIjoidGFxdl8uLiJ9.
XK9k3...
```
很多懒猫 App 在这一刻会做一件非常“自然”的事:
> **把 token 贴到 [jwt.io](https://jwt.io) → 复制里面的 `email` → 建 session → 登录完成。**
但在我们继续之前,必须先解决一个极其关键、却经常被误解的问题:
> **这个 token 到底是不是“加密的”?**
---
## 4.1 一个常见但危险的直觉
当人们第一次看到懒猫 SSO 吐出来的 JWT 时,几乎都会产生同一个直觉:
> “这看起来像一段乱码,
> 应该是加密过的吧?”
这种直觉非常危险。
因为它会直接导致一个错误的安全假设:
> **“既然 token 是加密的,那把它放 URL、放 localStorage、打到 container log 里,都没事。”**
但事实是:
> **JWT(更准确地说,懒猫 SSO 用的 JWS)默认不是加密的。**
---
## 4.2 JWT 的三段结构,意味着什么
一个标准的 JWT 由三部分组成:
```
header.payload.signature
```
这三部分并不是“加密块”,而是:
* header:算法、密钥标识(懒猫里是 `alg=RS256`, `kid=`)
* payload:一组声明(claims),包括 `iss` `sub` `aud` `exp` `email` `groups` ……
* signature:对前两部分的数字签名
前两部分只是 **Base64URL 编码**。
这意味着一件非常重要的事情:
> **任何人,只要拿到 token,都可以解码 header 和 payload。**
不需要密钥。
不需要破解。
不需要权限。
你随手在 jwt.io 上把懒猫 ID Token 粘进去,能看到:
```json
{
"iss": "https://alice.heiyu.space/sys/oauth",
"sub": "ciYKHheiyu",
"aud": "xu.deploy.env",
"exp": 1716419734,
"iat": 1716333334,
"nonce": "yyy",
"email": "alice@example.com",
"email_verified": true,
"name": "Alice",
"preferred_username": "alice",
"groups": ["admin"],
"at_hash": "taqv_.."
}
```
全是明文。
---
## 4.3 那为什么懒猫还敢把身份信息放在 token 里?
这是一个必须正面回答的问题。
如果 JWT 是明文的,那岂不是意味着:
* 盒子主人的邮箱是公开的?
* 用户所在的 `groups`(比如 `admin`)是公开的?
* `preferred_username` 是公开的?
答案是:
> **是的,这些信息对“任何拿到 token 的人”都是可读的。**
但这里隐藏着一个非常重要的区分:
> **可读(readable) ≠ 可伪造(forgeable)**
JWT 的设计,从一开始就没有试图解决“保密性”问题。
它要解决的是两个完全不同的问题:
1. **完整性**:内容有没有被改过(比如 `groups: ["admin"]` 有没有被塞进去)
2. **真实性**:这些声明是不是由**这台盒子的懒猫 SSO**发出的
这两个目标,**不需要加密**。
---
## 4.4 JWT 的核心语义:声明(Claims)
现在我们可以重新定义 JWT 的本质了:
> **ID Token 不是一段加密数据,
> 而是一组“被懒猫 SSO 签名的声明”。**
每一个字段,都是一个声明:
* `sub`:这个用户在懒猫 SSO 里的稳定 ID
* `iss`:`https://alice.heiyu.space/sys/oauth`,说明是**这台盒子**签的
* `aud`:`xu.deploy.env`,说明是给 ENV 查看器的
* `exp`:声明什么时候失效
* `email` / `preferred_username` / `groups`:懒猫账号的基本信息懒猫 SSO 的安全模型是:
> **“你可以看,但你不能改;
> 你可以拿到,但你不能伪造。”**
---
## 4.5 为什么“签名”比“加密”更重要
我们来对比两个世界:
### 如果 ID Token 是加密的,但没有签名
* 攻击者看不到内容
* 但一旦解密,就无法确认 `groups: ["admin"]` 是不是原本就有
* 也无法确认是不是“这台盒子”发的
在一个多用户、多应用共享一个懒猫 SSO 的系统里,这是灾难。
---
### 如果 ID Token 是签名的,但不加密(懒猫的现实情况)
* 攻击者可以看到 `email`、`preferred_username`、`groups`
* 但任何修改都会导致签名失效
* 没有懒猫 SSO 的私钥,无法生成“合法声明”
这恰恰符合家庭服务器这种 SSO 场景的需求。
---
## 4.6 一个重要的设计判断:
### 懒猫并不假设 token 内容是秘密懒猫 SSO 的设计者非常清楚一件事:
> **token 终究会出现在不完全可信的环境中。**
例如:
* 浏览器 localStorage / sessionStorage
* 前端 JavaScript
* App 容器的日志系统(懒猫玩家热衷开 debug)
* 你自己用 curl 调试时复制到终端
* Authorization 头经过 nginx / openresty 的访问日志
在这样的前提下,试图“依赖 token 的保密性”是脆弱的。
于是,懒猫遵循 OIDC 的设计,做了一个非常明确的选择:
> **不把安全性建立在“别人看不到”上,
> 而建立在“别人改不了、造不出”上。**
---
## 4.7 那为什么还要 HTTPS?
到这里,可能会有人产生另一个误解:
> “既然 JWT 不靠加密,那懒猫给我装证书让 `heiyu.space` 走 HTTPS 还有什么用?”
这是对职责边界的混淆。
* HTTPS 负责:
* 防窃听(防止邻居抓包看到 token)
* 防中间人篡改(防止 WiFi 被劫持后替换 token)
* 防 code 泄漏(URL 不走 HTTPS 的话,连 code 都保不住)
* JWT 签名负责:
* 防伪造(防止别的盒子上的 OP 签出来的 token 被误用)
* 防内容被修改(防止 `groups: ["admin"]` 被塞进来)
它们解决的是**不同层面的问题**。
> **HTTPS 保护的是“传输”,
> JWT 保护的是“声明”。**
懒猫两者都上,缺一不可。
---
## 4.8 为什么懒猫 ID Token 默认不加密
在懒猫 SSO 中,ID Token 是:
* JWS(JSON Web Signature),`alg=RS256`
* **不是** JWE(JSON Web Encryption)
这不是因为“懒猫 SSO 做不到 JWE”,而是因为:
1. ENV 查看器、Memos、Vaultwarden……所有 App 都需要读 payload,不能每个都维护一把加密密钥
2. 读取行为本身不是安全风险
3. 安全风险来自“错误地信任声明”
如果你在这里误以为:
> “只要 token 是加密的,就可以少校验 `iss` / `aud` / `nonce`”
那么你将直接跳过懒猫 SSO 最重要的安全设计。
---
## 4.9 JWT 能解决什么,不能解决什么
在继续深入之前,我们必须明确 JWT 的边界。
### JWT(懒猫 ID Token)能解决的:
* 内容有没有被篡改
* 声明是不是由**某个 OP** 签发
### JWT 不能解决的:
* 声明是不是发给 `xu.deploy.env`(要靠 `aud`)
* 声明是否仍然适用于当前请求(要靠 `exp` / `nonce`)
* 声明是否被重放
* 声明是否来自**正确的** issuer(要靠 `iss`)
* `groups: ["admin"]` 意味着什么业务权限(这是你的业务层)
这些问题,**全部在签名之外**。
---
## 4.10 一个关键过渡:
### 从“能不能信 token”到“该不该信这些声明”
到目前为止,我们只解决了一件事:
> **这个 token 是真的。**
但“真的”,并不等于“适用”。
一个合法签名的懒猫 ID Token,仍然可能是:
* 发给盒子上**另一个 App** 的(`aud` 不是你)
* 用在错误场景的(比如本应只给 Memos 的 token 被 ENV 查看器错误接受)
* 已经过期的
* 被重复使用的
* 来自**另一台盒子**的懒猫 SSO(在多盒子、或者第三方 OIDC 对接的高级玩法里)
所以,从下一章开始,我们要进入 JWT 的**下一层意义**:
> **验证的不再是 token 本身,
> 而是 token 里的每一个声明。**
---

## 4.11 本章结论(必须非常明确)
我们可以用几句话,结束这一章:
1. 懒猫 ID Token 默认不是加密的,是 JWS(`RS256`)
2. JWT 的安全性来自签名,而不是隐藏
3. ID Token 是一组“被签名的声明”
4. 可读不是问题,错误信任才是问题
5. 把 token 贴到 jwt.io 解码**没有任何安全风险**——但你不能只靠那里看到的数据就登录用户
---
### 本章小结(一句话)
> **懒猫 ID Token 的设计目标不是“不让你看到”,
> 而是“让你无法伪造”。**
评论
0暂无评论