一个内部工具,不是所有功能都该给所有人。基础功能装了就能用;有一个监控看板读的是全租户的数据、跨所有人,这个必须逐人授权。
正常情况下,授权走云端:扩展读到当前登录者的工号,查一张远程表,按记录里的布尔位开关功能。
问题是下一句:我的服务器挂了怎么办?
fail-open 还是 fail-closed
两个极端都不能接受:
- fail-closed(连不上就全锁死):我这台小服务器抽一次风,所有人当场干不了活。这个代价远大于它防住的东西。
- fail-open(连不上就全放开):那高级功能的授权就形同虚设——想用的人断个网就有了。
我们的答案是分级 fail-open:
/** * 云端不可达时的基线。 * 普惠功能恒开;高级功能(读跨人数据的那个)默认关。 * 这是写死在代码里的常量,不依赖任何外部输入 —— 兜底的兜底。 */const BASE_GRANTED: FeatureFlags = { quickTicket: true, autoReply: true, knowledgeBase: true, ai: true, monitor: false, // ← 唯一需要逐人授权的};关键在于这个常量不读任何文件、不查任何存储。无论后面的逻辑出什么问题,最差的结果是回到这里——基础功能可用,高级功能关闭。
但高级功能的白名单要落地,就得有一份本地名册
云端不可达时,「谁能用监控」这份信息得从本地拿。早期版本的做法是:一份明文 JSON,管理员发给需要的人,坐席在设置页导入。
这等于零鉴权。
名册在用户手里,判定也在用户手里。改一行就给自己开了全部功能;里面那个「工号」字段完全是摆设——自己写的工号,当然和自己对得上。
这个错误很容易犯,因为它披着一层「有名册 = 有管控」的外衣。真正的判断标准是一句话:
如果校验的依据和被校验的数据都在对方手里,那就不是校验。
Ed25519:改一个字节,整份拒绝
修法是非对称签名:
- 管理员用私钥对名册签名,私钥永不进仓库、不进扩展包;
- 扩展只内置公钥,加载名册时先验签;
- 用户改任何一个字段 → 签名对不上 → 整份拒绝,退回上面那个
BASE_GRANTED基线。
用户没有私钥,伪造不出签名,也就无法自行提权。
信封长这样:
{ "alg": "ed25519", "sig": "<base64 签名,对 canonicalize(payload) 的 UTF-8 字节>", "payload": { "version": 1, "flags": [ { "employee_id": "10001", "display_name": "示例", "monitor": true } ] }}验签这一侧(浏览器,WebCrypto):
const EMBEDDED_PUBLIC_KEY = '<32 字节 Ed25519 公钥的 base64>';
let publicKeyPromise: Promise<CryptoKey | null> | null = null;function getPublicKey(): Promise<CryptoKey | null> { if (!publicKeyPromise) { publicKeyPromise = crypto.subtle .importKey('raw', b64ToBytes(EMBEDDED_PUBLIC_KEY), { name: 'Ed25519' }, false, ['verify']) .catch(() => null); } return publicKeyPromise;}
async function verifyEnvelope(env: SignedEnvelope): Promise<boolean> { try { if (!env?.sig || !env?.payload) return false; const key = await getPublicKey(); if (!key) return false; const bytes = new TextEncoder().encode(canonicalize(env.payload)); return await crypto.subtle.verify({ name: 'Ed25519' }, key, b64ToBytes(env.sig), bytes); } catch { return false; // 任何异常都当验签失败,绝不放行 }}可以在浏览器里亲手试一遍:生成密钥对、签一份 JSON、改一个字段,看验签怎么当场失败。
最容易出错的地方是规范化序列化
签名签的是字节,不是对象。所以签名端和验签端必须从同一个对象产出完全相同的字节。
JSON.stringify 做不到这件事:key 的顺序取决于对象构造过程,空格取决于参数,跨语言更是各写各的。
所以要自己写一个规范化序列化:
/** 递归按 key 排序、紧凑无空格。两端必须字节级完全一致,否则验签必败。 */function canonicalize(value: unknown): string { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']'; const obj = value as Record<string, unknown>; return '{' + Object.keys(obj).sort() .map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k])) .join(',') + '}';}这段代码在项目里存在两份:签名脚本在 Node,验签在浏览器。
这是个天然的双实现陷阱——两边任何一点不一致,验签就会无差别地全部失败,而失败的表现是「所有人都没有高级权限」,看起来像授权逻辑出了问题,不像序列化出了问题。
所以两个文件的注释里都写着同一句话:改一处必须同步改另一处。
(数组不排序,只有对象的 key 排序。数组顺序是数据的一部分。)
一个 TypeScript 的坑
写验签的时候会撞上这个:
Argument of type 'Uint8Array<ArrayBufferLike>' is not assignable to parameter of type 'BufferSource'新版 TS 的 lib 把 Uint8Array 泛型化成了 Uint8Array<ArrayBufferLike>,而 WebCrypto 的 BufferSource 要求的是 ArrayBufferView<ArrayBuffer>——ArrayBufferLike 可能是 SharedArrayBuffer,所以不兼容。
解法是让 base64 解码函数直接返回 ArrayBuffer:
function b64ToBytes(s: string): ArrayBuffer { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out.buffer; // ← 不是 out}importKey / verify 本来就收 ArrayBuffer,运行时行为一个字节都没变,只是类型两边都满意了。
两路名册,各自验签
名册有两个来源:
| 来源 | 用途 | 更新方式 |
|---|---|---|
| 随包内置 | 主路径。装了就有,零配置 | 管理员重签 + 发新版 |
| 手动导入 | 补充。临时给某人开通,不用发版 | 管理员发一份已签名 JSON |
两路各自独立验签——不能因为「内置的那份验过了」就信任导入的那份。
合并规则是:内置为基线,导入项按人覆盖,并追加新人。
两路都不进主配置结构、不参与「导出个人偏好」。它们是服务端可控的授权数据,不是用户偏好——混进去的话,用户导出再导入就能把旧权限带回来。
服务器挂了,别让它一直刷屏
签名解决了安全问题,但还有个体验问题。
服务器停机那几天,三处云端拉取(功能开关、管理员参数、云端配置)每 30 分钟一轮 Failed to fetch,把 Console 刷满,而且每次都要白等 8 秒超时。
所以加了一层健康追踪,三档模式:
| 模式 | 行为 |
|---|---|
local | 三处入口直接短路,根本不联网。彻底离线,零噪音 |
cloud | 强制每轮都连、不退避。排查云端时用 |
auto(默认) | 正常连,但连续失败到阈值后进入静默退避期 |
auto 的静默退避期做三件事:
/** 连续失败多少次算挂了。3 次 ≈ 三轮 8s 尝试,足以确认 */const FAIL_THRESHOLD = 3;
/** 退避期把周期拉长到 4 倍,上限 2 小时 —— 仍会偶尔探测,所以能自动发现恢复 */export function backoffInterval(baseMs: number): number { return down ? Math.min(baseMs * 4, 2 * 60 * 60 * 1000) : baseMs;}
/** * 记录一次失败。 * @returns 本次是否该打印警告 —— 只在「首次失败」和「刚翻转到 down」时为 true,其余静默。 */export function recordFailure(): boolean { if (authMode() === 'cloud') return true; consecutiveFailures++; const justWentDown = !down && consecutiveFailures >= FAIL_THRESHOLD; if (justWentDown) down = true; if (consecutiveFailures === 1) return true; if (justWentDown && !warnedThisOutage) { warnedThisOutage = true; return true; } return false;}三个设计点:
① 高频路径在 down 期间直接走本地兜底,不发请求。 权限裁决是每次页面加载都要跑的,不能每次都白等 8 秒。
② 任何一次成功就清零、退出静默。 服务器恢复之后自动切回云端,不需要任何人做任何事——这一点比「挂了怎么办」更重要,因为「恢复了没人发现」是更常见的故障形态。
③ 这个状态是进程内内存,不落盘。 刷新页面自然重新探测。落盘的话你还得考虑什么时候清,而它的价值只在一次会话之内。
顺带:缺字段 ≠ false
名册里的布尔位有个坑,栽过一次。
最初的归一化写的是 rec.monitor === true——看起来很严谨。但对普惠功能那几列用同一个写法就出事了:名册里的老条目根本没写那几列,于是 === true 把它们全判成 false,命中名册的人反而比没命中的人权限更少。
正确的写法是按字段语义分开:
function recordToFlags(rec: LocalFlagRecord): FeatureFlags { return { // 普惠项:缺字段 = 开。只有显式写 false 才禁 quickTicket: rec.quick_ticket !== false, autoReply: rec.auto_reply !== false, knowledgeBase: rec.knowledge_base !== false, ai: rec.ai !== false, // 高级项:白名单语义,缺字段 = 关 monitor: rec.monitor === true, };}!== false 和 === true 的区别,就是「默认开」和「默认关」的区别。 每个字段都要单独想清楚它属于哪一种,不能凭手感统一。
回过头看,这套东西的安全性不来自任何「藏起来」的东西——公钥是公开的,名册内容用户也看得见,验签逻辑就在扩展包里,随便读。
它来自一件事:改了就验不过。
这也是为什么我更愿意在这里花时间,而不是去做混淆或者把名册加密。前者是可以证明的性质,后者只是提高了一点点门槛。
完整代码(远程配置客户端 + 签名兜底 + 签名脚本):/showcase/pb-remote-config
亲手试试:/tools/ed25519-envelope.html —— 生成密钥对、签一份 JSON、改一个字段看它当场失效。