一个内部工具,不是所有功能都该给所有人。基础功能装了就能用;有一个监控看板读的是全租户的数据、跨所有人,这个必须逐人授权。

正常情况下,授权走云端:扩展读到当前登录者的工号,查一张远程表,按记录里的布尔位开关功能。

问题是下一句:我的服务器挂了怎么办?

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):

src/auth/verify.ts
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、改一个字段看它当场失效。