忘机山人
在第四章,我们已经澄清了一件非常重要的事: https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env > **懒猫 ID Token 不是加密的。 > 它是一组“被懒猫 SSO 签名的声明”。** 这句话看起来很简单,但它隐含了一个巨大的问题: > **如果任何人都能看到 `groups: ["admin"]`, > ENV 查看器又凭什么相信这些声明是真的?** 换句话说: > **签名到底在“证明”什么?** 这一章,我们就只回答这一件事。 --- ## 5.1 从一句最容易被误解的话开始 在很多讨论中,你可能见过这样一句话: > “懒猫 SSO 用私钥加密,Client 用公钥解密。” 这句话**在懒猫 SSO / OIDC 语境下是错误的**。 它把两件完全不同的事情混在了一起: * **加密(Encryption)** * **签名(Signature)** 如果你不在这里把这两个概念彻底分开,后面所有关于 JWT、JWKS(`/sys/oauth/keys`)、ID Token 校验逻辑的理解都会偏掉。 --- ## 5.2 加密 vs 签名:这是两套安全模型 我们先用最严格、最不含糊的方式区分这两件事。 ### 加密(Encryption) 目标只有一个: > **让别人看不到内容** 典型模型是: * 发送方用接收方的公钥加密 * 只有接收方能用私钥解密 这是 **保密性(Confidentiality)**。 --- ### 签名(Signature) 目标完全不同: > **证明“这是谁发的”,以及“中途有没有被改”** 典型模型是: * 声明方(懒猫 SSO)用自己的私钥签名 * 任何人(ENV 查看器、Memos、Vaultwarden……)都可以用公钥验证 这是 **真实性(Authenticity)+ 完整性(Integrity)**。 --- ### 懒猫 ID Token 用的是哪一个? > **只用签名。** 懒猫 SSO 在 discovery 里写得明明白白: ```json "id_token_signing_alg_values_supported": ["RS256"] ``` 没有 `alg_values_supported` 里的加密算法字段,也没有 JWE 相关的任何东西。 懒猫的设计从一开始就**不试图隐藏内容**。 它只试图证明一件事: > **“这组声明,确实是由 `https://alice.heiyu.space/sys/oauth` 这个懒猫 SSO 发出的,而且没有被修改。”** --- ## 5.3 签名要解决的核心问题 站在 ENV 查看器的角度,签名必须同时解决三个问题: 1. **来源问题** > 这些声明是不是**这台盒子的** 懒猫 SSO 发的?(不是别的盒子、不是自建的假 IdP) 2. **篡改问题** > 中途有没有人把 `groups: ["family"]` 改成 `groups: ["admin"]`? 3. **不可伪造问题** > 攻击者能不能自己造一个“看起来合法”的 token,直接塞给 ENV 查看器? 如果签名无法同时解决这三个问题,那整个懒猫 SSO 的安全模型就会崩塌—— 毕竟所有 App 都信懒猫,懒猫只要一处可伪造,全盒子的应用都可以被绕过。 --- ## 5.4 签名并不是“对内容做点处理” 我们来看一个懒猫 ID Token 的真实签名结构(`RS256`): ``` header.payload.signature ``` 其中: * `header.payload` 是**完全可读的** * `signature` 是唯一的安全锚点 签名不是“把 payload 加密一下”, 而是一个**数学等式的结果**。 --- ## 5.5 签名在数学上到底做了什么(不绕弯) 我们用最直白、但不偷懒的方式,把懒猫 ID Token 的签名过程拆开。 ### Step 1:确定要被保护的数据 在 JWT 中,被签名的数据是: ``` base64url(header) + "." + base64url(payload) ``` 注意三点: 1. 是 **编码后的字符串** 2. 顺序固定 3. 任意一个字符改变,结果都会变 --- ### Step 2:对数据做 Hash(摘要) `RS256` 里的 `256` 对应 SHA-256。 对这段字符串做一次哈希运算: ``` hash = SHA256(data) ``` 哈希的意义只有一个: > **把任意长度的数据,映射成一个固定长度、不可逆、对变化极其敏感的摘要。** 只要原始数据有 1 bit 变化,hash 就完全不同。 --- ### Step 3:用懒猫 SSO 的私钥对 hash 进行签名 这一步才是真正的“签名”: ``` signature = RSA-Sign(hash, sso_private_key) ``` 这里的关键点是: * **只有懒猫 SSO(跑在盒子里的那个进程)**持有这个私钥 * 签名过程是单向的 * 没有这把私钥,无法伪造一个“能通过验证的 signature” 懒猫官方会在升级、Key Rotation 的时候轮换这把 key,但不会把它泄漏给任何 App。 如果你在 App 里“需要懒猫 SSO 的私钥”,那你已经做错了一件事。 --- ### Step 4:Client 用公钥验证 ENV 查看器拿到 token 后,会做对称的操作: 1. 用同样的方式计算 `hash_client = SHA256(base64url(header) + "." + base64url(payload))` 2. 从 `https://alice.heiyu.space/sys/oauth/keys` 拉到的 JWKS 里,找到 header.`kid` 对应的公钥 3. 用公钥验证 `signature` 是否对应这个 hash 如果验证通过,数学上意味着一件事: > **这个 signature 只能来自对应的私钥, > 而这个私钥只在盒子里的身份服务 手里。** --- ## 5.6 为什么不能伪造签名 这是整个懒猫 SSO 信任链的核心。 攻击者即使: * 完全拿到 header 和 payload(反正它们本来就明文) * 完全知道算法(懒猫 discovery 里大大方方写着 `RS256`) * 完全知道公钥(`/sys/oauth/keys` 对全世界公开) 他依然做不到一件事: > **生成一个能通过验证的 signature** 因为 RSA 签名算法的安全性,建立在一个非常强的前提上: > **从公钥推导私钥,在计算上是不可行的。** 这不是工程约定,而是现代密码学的基础假设。 --- ## 5.7 为什么“改 1 个字符”就会彻底失败 这是懒猫 ID Token 安全性的第二个关键点。 如果攻击者尝试修改 payload 中的任何字段,比如把: ```json "preferred_username": "alice", "groups": ["family"] ``` 改成: ```json "preferred_username": "alice", "groups": ["admin"] ``` 会发生什么? * `payload` 改变 * `base64url(header) + "." + base64url(payload)` 改变 * `hash` 改变 * 但 `signature` 没变(攻击者没有私钥重签) 结果只有一个: > **签名验证失败。** 这保证了: > **懒猫 SSO 声明一旦被签名,就不可被悄悄篡改。** 这也意味着:**你可以、并且应该信任 `groups` 字段**。只要签名验证通过,它就是懒猫原始发出的样子。 ---  ## 5.8 签名解决的,只是“真实性”和“完整性” 到这里,我们必须再次强调签名的边界。 签名能证明: * 声明来自**某个** OP * 声明未被修改 但它**不能证明**: * 声明是不是发给 `xu.deploy.env` 的(那是 `aud`) * 声明是否已经过期(那是 `exp`) * 声明是否对应你这次登录(那是 `nonce`) * 声明是否被错误使用 * 声明来自的是**这台盒子**的懒猫 SSO 还是别的(那是 `iss`) 换句话说: > **签名只能回答“这是真的吗”, > 不能回答“这该不该信”。** --- ## 5.9 一个容易被忽略但致命的误解 很多懒猫 App 里会出现这种错误逻辑: > “既然签名验证通过了,那 token 就是安全的,我就可以直接读 `preferred_username` 登录用户了。” 这是**不成立的**。 在懒猫 SSO 里: * **签名验证是必要条件** * **但远远不是充分条件** 你可以把它理解成: > **签名只是“验明正身”, > 而不是“授权使用”。** ## 5.10 本章结论(必须钉死) 我们用几条不可模糊的结论结束这一章: 1. 懒猫 ID Token 使用的是数字签名(`RS256`),而不是加密 2. 签名保证来源真实性和内容完整性 3. 懒猫 SSO 的私钥决定“谁能签”,公钥(发布在 `/sys/oauth/keys`)决定“谁能验证” 4. 没有懒猫 SSO 的私钥,就无法伪造任何合法 token 5. 签名通过 ≠ token 可以被使用 --- ### 本章小结(一句话) > **签名证明“这些声明只能来自这台盒子的懒猫 SSO”, > 但不证明“你现在就该相信它们”。**
评论
0暂无评论