全部作品

Udesk 系列 · 示范代码

远程配置 + 签名兜底

PocketBase 按域下发配置,Ed25519 签名的离线授权兜底 —— 服务器挂了不影响干活,也没人能自己提权

安装 复制即用 · 零依赖 · 四个文件 次浏览

远程配置 + 签名兜底

给一个没有自己后端的客户端(浏览器扩展、桌面工具、脚本)做两件事:

  1. 远程配置:参数放 PocketBase,按域拆 key,改参数不发版;云端挂了用上次的缓存继续跑。
  2. 离线授权兜底:云端不可达时,「谁有高级权限」从一份本地名册拿——而这份名册经 Ed25519 签名,用户看得见但改不了。

四个文件,零依赖。客户端三个 TypeScript,签名脚本一个 Node(只用 node:crypto)。

📖 设计取舍、分级 fail-open、以及「明文名册等于零鉴权」→ 云端功能开关 + Ed25519 离线兜底

🔐 亲手签一份再改一个字段 → Ed25519 签名信封

怎么用

远程配置

import { syncRemoteDomain, readRemoteSlot, mergeRemoteFirst } from './remote/remote-config';
const src = { baseUrl: 'https://pb.example.com', collection: 'params', code: '<共享读码>' };
await syncRemoteDomain(src, {
recordKey: 'category', // PB 记录的 key 列
storageKey: 'cfg:categoryRemote', // 本地只读键(⚠ 别放进主配置键清单)
pick: (raw) => ({ presets: raw.presets ?? [] }), // 白名单挑字段 + 形状校验
}, store);
// 消费:云端是权威基线,本地是个人补充,两层合并、互不覆盖
const effective = mergeRemoteFirst(await readRemoteSlot('cfg:categoryRemote', store), localPresets);

拆 key 之前先决定用哪种下发模型:

模型 A:纯覆盖模型 B:只读层(本文件)
云端值去哪deep-merge 进本地配置写进独立只读键
用户能改吗能改,但下次同步被冲掉改不了,但可以本地补充
适合管理员说了算的参数(阈值、模板)既要统一基线、又要留个性化口子

别一刀切。纯管理员参数用 A 更简单,B 会引入不必要的合并逻辑。

签名兜底

终端窗口
node scripts/sign-flags.mjs keygen # 一次性。公钥抄进客户端,私钥离线保管
node scripts/sign-flags.mjs sign roster.json # → roster.signed.json
node scripts/sign-flags.mjs verify roster.signed.json # 发布前自检
import { parseSignedFlags, resolveLocalFlags, mergeFlagFiles } from './remote/signed-fallback';
// 两路名册各自独立验签:随包内置(主路径)+ 手动导入(临时加人)
const file = mergeFlagFiles(
await parseSignedFlags(embeddedJson),
await parseSignedFlags(importedJson),
);
// 永远返回可用权限,没有「全关」分支 —— 分级 fail-open
const { effective, outcome } = resolveLocalFlags(file, employeeId, displayName);

三条最容易踩的

  1. canonicalize 在两端各有一份,必须字节级一致。 签的是字节不是对象;两边差一点,验签就无差别地全部失败,而表现是「所有人都没有高级权限」——看起来像授权逻辑坏了,不像序列化坏了。
  2. 记录不存在要回 nochange,不是 failed。 管理员还没建那条记录时,客户端就该安静地用内置默认。按失败处理会污染健康计数,拖累其它域被误判成「云端挂了」。
  3. pick 会静默丢弃未声明的字段。 云端加了新字段却忘了在 pick 里放行 → 云端明明有、客户端拿不到、而且不报错。加字段时两边一起改。

验证

三个 TypeScript 文件过 tsc --strict;另有 31 项端到端测试,其中最关键的一条是跨实现一致性——用 Node 签名脚本真签一份名册,再用客户端那份 canonicalize 复算验签。

其余覆盖:改一个字段 / 只改签名两个字符 → 验签失败;payload key 顺序不影响、数组顺序影响;三态字段(缺字段的普惠项=开、高级项=关);按 id / 按显示名两条命中路径;无名册仍返回可用基线;两路名册按人覆盖合并;健康探测的失败阈值、静默、4 倍退避与 2h 上限、一次成功即恢复、三档模式;远程拉取的 applied / nochange / 记录不存在 / 网络失败 fail-open 保留缓存 / 退避期 skipped。

代码

src/remote/remote-config.ts
/**
* PocketBase 远程配置客户端(只读层模型)。
*
* 一条记录一个域(`key` 列),客户端各取各的。拉到的值写进一个**独立的只读键**,
* 不进本地配置结构、设置页不可编辑、不随「导出个人偏好」导出。
*
* ## 为什么按域拆 key
*
* 最初是一条 `global` 记录装所有参数。改一处要动整块 JSON,冲突风险和误删风险都高,
* 而且任何一个域的脏数据会让整条记录的读取都不可信。
*
* ## 两种下发模型,拆之前先想清楚用哪种
*
* | | 模型 A:纯覆盖 | 模型 B:只读层(本文件) |
* |---|---|---|
* | 云端值去哪 | deep-merge 进本地配置 slice | 写进独立只读键 |
* | 用户能改吗 | 能改,但下次同步被冲掉 | 改不了,但可以在本地**补充** |
* | 消费方式 | 直接读配置 | `[...云端, ...本地]` 两层合并 |
* | 适合 | 管理员说了算的参数(阈值、模板) | 既要统一基线、又要留个性化口子的 |
*
* **别一刀切。** 纯管理员参数用 A 更简单,B 会引入不必要的合并逻辑。
*/
import { shouldTryCloud, recordSuccess, recordFailure } from './cloud-health';
export interface RemoteSource {
/** PocketBase 实例地址,如 https://pb.example.com */
baseUrl: string;
/** 集合名 */
collection: string;
/**
* 共享读码。读规则形如
* `@request.query.code != "" && code = @request.query.code`
* —— 多条记录用同一个 code 完全正常,新增记录不需要新 code。
*/
code: string;
/** 单次请求超时。默认 8s */
timeoutMs?: number;
}
export type SyncOutcome =
/** 拉到了,且内容与上次不同 → 已写入 */
| 'applied'
/** 拉到了但没变化,或**这条记录还不存在** */
| 'nochange'
/** 网络失败 / 超时 / 响应不可解析 */
| 'failed'
/** 模式为 local,或已进入静默退避期 —— 压根没发请求 */
| 'skipped';
export interface SyncResult<T> {
outcome: SyncOutcome;
value: T | null;
}
export interface DomainSpec<T> {
/** PB 记录的 key 列 */
recordKey: string;
/** 本地只读存储键。⚠ 不要放进你的主配置键清单 */
storageKey: string;
/**
* 白名单挑字段 + 形状校验。
*
* ⚠ 这个函数会**静默丢弃未声明的字段** —— 云端加了新字段却忘了在这里放行,
* 表现是「云端明明有、客户端拿不到,而且不报错」。加字段时两边一起改。
*/
pick: (raw: Record<string, unknown>) => T;
/** 可选:写入后立刻刷新进程内缓存(有同步取值路径的域才需要) */
onApplied?: (value: T) => void;
}
/** 存取抽象:浏览器扩展用 chrome.storage,其它环境自己实现两个方法。 */
export interface Store {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
}
/**
* 拉一个域并写入只读键。
*
* **永不抛错。** 任何失败都退化成「保留上一次的缓存」——这是 fail-open:
* 云端挂了不该让功能停摆,用上次拉到的值继续跑是最合理的降级。
*/
export async function syncRemoteDomain<T>(
src: RemoteSource | undefined,
spec: DomainSpec<T>,
store: Store,
): Promise<SyncResult<T>> {
if (!src?.baseUrl || !src.collection || !src.code) {
return { outcome: 'skipped', value: null };
}
if (!shouldTryCloud()) {
return { outcome: 'skipped', value: null };
}
const url =
`${src.baseUrl.replace(/\/+$/, '')}/api/collections/${encodeURIComponent(src.collection)}/records`
+ `?perPage=1&filter=${encodeURIComponent(`(key='${spec.recordKey}')`)}`
+ `&code=${encodeURIComponent(src.code)}`;
let raw: unknown;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), src.timeoutMs ?? 8000);
try {
const res = await fetch(url, {
// ★ 匿名请求。带上凭据既没必要,也会让读规则的行为和你预期的不一样。
credentials: 'omit',
headers: { Accept: 'application/json' },
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
raw = await res.json();
} finally {
clearTimeout(timer);
}
} catch {
recordFailure();
return { outcome: 'failed', value: null };
}
recordSuccess();
const items = (raw as { items?: unknown[] } | null)?.items;
const first = Array.isArray(items) ? items[0] : undefined;
const params = (first as { params?: unknown } | undefined)?.params;
// ★ 记录不存在 → 'nochange',**不是** 'failed'。
// 管理员还没建这条记录时,客户端就该安静地用内置默认值。
// 按失败处理会污染健康计数,拖累**其它域**被误判成「云端挂了」。
if (!params || typeof params !== 'object') {
return { outcome: 'nochange', value: null };
}
let value: T;
try {
value = spec.pick(params as Record<string, unknown>);
} catch {
return { outcome: 'nochange', value: null }; // 脏数据:宁可用旧的
}
const prev = await store.get(spec.storageKey);
if (JSON.stringify(prev) === JSON.stringify(value)) {
return { outcome: 'nochange', value };
}
await store.set(spec.storageKey, value);
spec.onApplied?.(value);
return { outcome: 'applied', value };
}
/** 读只读键的当前值。拉取失败时它就是 fail-open 的那份缓存。 */
export async function readRemoteSlot<T>(storageKey: string, store: Store): Promise<T | null> {
try {
return ((await store.get(storageKey)) as T | undefined) ?? null;
} catch {
return null;
}
}
/**
* 模型 B 的消费方式:云端是权威基线,本地是个人补充,两层合并。
* 云端与本地互不覆盖 —— 用户的本地补充永远不会被同步冲掉。
*/
export function mergeRemoteFirst<T>(remote: T[] | null, local: T[] | null): T[] {
return [...(remote ?? []), ...(local ?? [])];
}
src/remote/cloud-health.ts
/**
* 云端健康追踪 + 静默退避。
*
* 要解决的是一个体验问题:服务器停机那几天,每一轮周期拉取都 `Failed to fetch`,
* 把 Console 刷满,而且每次都要白等一整个超时。
*
* 三档模式:
* 'local' → 所有拉取入口直接短路,根本不联网。彻底离线,零噪音
* 'cloud' → 强制每轮都连、不退避。排查云端时用
* 'auto' → 正常连,但连续失败到阈值后进入「静默退避期」
*
* 这是**进程内内存状态**,不落盘:刷新页面自然重新探测。
* 落盘的话你还得决定什么时候清,而它的价值只在一次会话之内。
*/
export type AuthMode = 'auto' | 'local' | 'cloud';
/** 连续失败多少次算挂了。3 次 ≈ 三轮完整超时,足以确认不是偶发抖动。 */
const FAIL_THRESHOLD = 3;
/** 退避期的周期上限。 */
const MAX_BACKOFF_MS = 2 * 60 * 60 * 1000;
let mode: AuthMode = 'auto';
let consecutiveFailures = 0;
let down = false;
let warnedThisOutage = false;
export function setAuthMode(m: AuthMode): void {
mode = m;
}
export function authMode(): AuthMode {
return mode;
}
/**
* 本次是否该真的发请求。
*
* 高频路径(比如每次页面加载都要跑的权限裁决)用它来短路 —— down 期间直接走本地兜底,
* 不能每次都白等一个超时。
*/
export function shouldTryCloud(): boolean {
if (mode === 'local') return false;
if (mode === 'cloud') return true;
return !down;
}
/** 当前是否判定云端不可达。local 视作 down,cloud 视作 up。 */
export function isCloudDown(): boolean {
if (mode === 'local') return true;
if (mode === 'cloud') return false;
return down;
}
/**
* 记录一次成功。
*
* ★ 任何一次成功都清零并退出静默 —— 服务器恢复后**自动**切回云端,不需要任何人做任何事。
* 「恢复了没人发现」比「挂了没人发现」更常见,所以这条比退避本身更重要。
*
* @returns 是否发生了「从 down 恢复」的翻转(调用方可据此打一条恢复日志)
*/
export function recordSuccess(): boolean {
const wasDown = down;
consecutiveFailures = 0;
down = false;
warnedThisOutage = false;
return wasDown;
}
/**
* 记录一次失败。
*
* @returns 本次是否该打印警告 —— 只在「首次失败」和「刚翻转到 down」时为 true,其余静默。
* 日志的价值在于被看见;每 30 分钟刷一条一模一样的,等于没有日志。
*/
export function recordFailure(): boolean {
if (mode === '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 期间拉长到 4 倍(上限 2 小时)。
* 仍然会偶尔探测一次,所以服务器恢复能被发现 —— 完全停掉就永远回不来了。
*/
export function backoffInterval(baseMs: number): number {
return down ? Math.min(baseMs * 4, MAX_BACKOFF_MS) : baseMs;
}
/** 仅供测试 / 排查:把状态清回初始。 */
export function resetHealth(): void {
consecutiveFailures = 0;
down = false;
warnedThisOutage = false;
}
src/remote/signed-fallback.ts
/**
* Ed25519 签名的本地授权兜底 —— 云端不可达时,「谁有高级权限」从哪来。
*
* 早期版本是明文 JSON、用户自己导入,**等于零鉴权**:名册在用户手里、判定也在用户手里,
* 改一行就给自己开了全部功能,里面那个身份字段纯属摆设(自己写的当然和自己对得上)。
*
* 判断标准只有一句:**如果校验的依据和被校验的数据都在对方手里,那就不是校验。**
*
* 现在:管理员用私钥签名,客户端只内置公钥验签。改任何一个字节 → 验签失败 → **整份拒绝**,
* 退回写死在代码里的基线。用户没有私钥,伪造不出签名。
*
* 安全性不来自「藏起来」—— 公钥是公开的,名册内容用户看得见,验签代码就在这里。
* 它来自一件事:**改了就验不过**。
*/
/** 32 字节 Ed25519 公钥的 base64。配对私钥离线保管,绝不进仓库。 */
const EMBEDDED_PUBLIC_KEY = '<把 keygen 产出的 publicKey 抄到这里>';
export interface SignedEnvelope {
alg?: string;
sig?: string;
payload?: { version?: number; flags?: unknown };
}
export interface FlagRecord {
employee_id: string;
display_name?: string;
/** 普惠项:缺字段 = 开 */
quick_ticket?: boolean;
auto_reply?: boolean;
knowledge_base?: boolean;
ai?: boolean;
/** 高级项:白名单语义,缺字段 = 关 */
monitor?: boolean;
}
export interface FeatureFlags {
quickTicket: boolean;
autoReply: boolean;
knowledgeBase: boolean;
ai: boolean;
monitor: boolean;
}
/**
* 云端不可达、或验签失败时的基线。
*
* ★ 这个常量**不读任何文件、不查任何存储**。无论前面的逻辑出什么问题,
* 最差的结果就是回到这里:普惠功能可用,高级功能关闭。
*
* 分级 fail-open:全锁死(我的服务器抽风就让所有人干不了活)和全放开
* (断个网就有高级权限)两个极端都不能接受。
*/
export const BASE_GRANTED: FeatureFlags = {
quickTicket: true,
autoReply: true,
knowledgeBase: true,
ai: true,
monitor: false,
};
/* ------------------------------------------------------------------ *
* 规范化序列化
* ------------------------------------------------------------------ */
/**
* 递归按 key 排序、紧凑无空格。
*
* ★★ **签名端和验签端必须字节级完全一致,否则验签无差别地全部失败。**
*
* 这段代码在项目里存在两份(签名脚本在 Node,验签在浏览器),是个天然的双实现陷阱。
* 失败的表现是「所有人都没有高级权限」——看起来像授权逻辑出了问题,不像序列化出了问题。
* **改一处必须同步改另一处。**
*
* 注意数组**不排序**:数组顺序是数据的一部分。
*/
export 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(',') +
'}'
);
}
/**
* base64 → ArrayBuffer。
*
* ⚠ 返回 **ArrayBuffer 而不是 Uint8Array**:新版 TS 的 lib 把 `Uint8Array` 泛型化成
* `Uint8Array<ArrayBufferLike>`,而 WebCrypto 的 `BufferSource` 要求
* `ArrayBufferView<ArrayBuffer>` —— `ArrayBufferLike` 可能是 `SharedArrayBuffer`,不兼容。
* 直接给 ArrayBuffer 两边都满意,运行时行为一个字节没变。
*/
export 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;
}
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;
}
/** 验签。任何异常都当失败,绝不放行。 */
export 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;
}
}
/* ------------------------------------------------------------------ *
* 解析与裁决
* ------------------------------------------------------------------ */
/**
* 命中名册条目 → 权限。
*
* ★ `!== false` 和 `=== true` 的区别,就是「默认开」和「默认关」的区别。
* 每个字段都要单独想清楚它属于哪一种,不能凭手感统一。
*
* 栽过一次:普惠项用了 `=== true`,而老条目根本没写那几列,
* 于是**命中名册的人反而比没命中的人权限更少**。
*/
export function recordToFlags(rec: FlagRecord): FeatureFlags {
return {
quickTicket: rec.quick_ticket !== false,
autoReply: rec.auto_reply !== false,
knowledgeBase: rec.knowledge_base !== false,
ai: rec.ai !== false,
monitor: rec.monitor === true,
};
}
/** 归一化:丢弃没有身份字段的条目;布尔位**三态透传**(缺字段 → undefined,不是 false)。 */
function normalize(rawFlags: unknown): FlagRecord[] {
if (!Array.isArray(rawFlags)) return [];
const out: FlagRecord[] = [];
const tri = (v: unknown) => (typeof v === 'boolean' ? v : undefined);
for (const f of rawFlags) {
if (!f || typeof f !== 'object') continue;
const r = f as Record<string, unknown>;
const employee_id = typeof r.employee_id === 'string' ? r.employee_id.trim() : '';
if (!employee_id) continue;
out.push({
employee_id,
display_name: typeof r.display_name === 'string' ? r.display_name : undefined,
quick_ticket: tri(r.quick_ticket),
auto_reply: tri(r.auto_reply),
knowledge_base: tri(r.knowledge_base),
ai: tri(r.ai),
monitor: r.monitor === true,
});
}
return out;
}
export interface FlagsFile {
version: number;
flags: FlagRecord[];
}
/**
* 校验一份已签名名册。
* 验签失败 / 不是签名信封 / 解析失败 → null。**明文未签名的一律拒收。**
*/
export async function parseSignedFlags(raw: string): Promise<FlagsFile | null> {
let env: SignedEnvelope;
try {
env = JSON.parse(raw) as SignedEnvelope;
} catch {
return null;
}
if (!(await verifyEnvelope(env))) return null;
const flags = normalize(env.payload?.flags);
if (!flags.length) return null;
return { version: typeof env.payload?.version === 'number' ? env.payload.version : 1, flags };
}
export type Outcome =
/** 命中签名名册 */
| 'local-granted'
/** 无名册 / 未命中 / 验签失败 → 基线 */
| 'local-base';
export interface Decision {
effective: FeatureFlags;
outcome: Outcome;
displayName: string | null;
}
/**
* 本地裁决。
*
* ★ **永远返回有效权限**,没有「全关」分支 —— 那正是分级 fail-open 的落地点。
*
* 两条命中路径(都免额外凭据:名册整份经签名、用户改不了,
* 谁有权限由管理员的私钥锁定,不需要用户再自证):
* 1. 按身份 id 命中
* 2. 按显示名命中(id 没识别到时的兜底)
*/
export function resolveLocalFlags(
file: FlagsFile | null,
employeeId: string,
displayName: string | null,
): Decision {
if (file && employeeId) {
const rec = file.flags.find((f) => f.employee_id === employeeId);
if (rec) {
return { effective: recordToFlags(rec), outcome: 'local-granted', displayName: rec.display_name ?? null };
}
}
const name = (displayName || '').trim();
if (file && name) {
const rec = file.flags.find((f) => (f.display_name || '').trim() === name);
if (rec) {
return { effective: recordToFlags(rec), outcome: 'local-granted', displayName: rec.display_name ?? null };
}
}
return { effective: { ...BASE_GRANTED }, outcome: 'local-base', displayName: null };
}
/**
* 合并两路名册:随包内置为基线,手动导入按人覆盖并追加新人。
* ★ 两路**各自独立验签** —— 不能因为内置那份验过了就信任导入那份。
*/
export function mergeFlagFiles(embedded: FlagsFile | null, imported: FlagsFile | null): FlagsFile | null {
if (!embedded && !imported) return null;
const byKey = new Map<string, FlagRecord>();
const keyOf = (r: FlagRecord) => `${r.employee_id}�${r.display_name ?? ''}`;
for (const r of embedded?.flags ?? []) byKey.set(keyOf(r), r);
for (const r of imported?.flags ?? []) byKey.set(keyOf(r), r);
const flags = [...byKey.values()];
if (!flags.length) return null;
return { version: imported?.version ?? embedded?.version ?? 1, flags };
}
scripts/sign-flags.mjs
#!/usr/bin/env node
/**
* 授权名册签名工具(管理员用)。零依赖,只用 node:crypto 的 WebCrypto。
*
* node scripts/sign-flags.mjs keygen
* 生成密钥对 → keys/flags-keypair.json。
* 把其中的 publicKey 抄进客户端的 EMBEDDED_PUBLIC_KEY,
* 私钥离线备份后从工作目录移走,**绝不入库**。
*
* node scripts/sign-flags.mjs sign <明文名册.json> [私钥base64 或 keypair.json]
* 产出同目录的 <名>.signed.json({alg, sig, payload} 信封)。
*
* node scripts/sign-flags.mjs verify <signed.json> [公钥base64 或 keypair.json]
* 自检。发布前跑一次,比在客户端发现验签失败便宜得多。
*
* ⚠ 下面这个 canonicalize 必须与客户端那份**字节级完全一致**。改一处同步改另一处。
*/
import { webcrypto as crypto } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
/** 递归按 key 排序、紧凑无空格。数组不排序。 */
function canonicalize(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']';
return (
'{' +
Object.keys(value)
.sort()
.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k]))
.join(',') +
'}'
);
}
const b64 = {
enc: (buf) => Buffer.from(buf).toString('base64'),
dec: (s) => new Uint8Array(Buffer.from(s, 'base64')),
};
const KEYFILE = join('keys', 'flags-keypair.json');
async function keygen() {
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const out = {
alg: 'ed25519',
publicKey: b64.enc(await crypto.subtle.exportKey('raw', pair.publicKey)), // 32 字节
privateKey: b64.enc(await crypto.subtle.exportKey('pkcs8', pair.privateKey)),
note: 'privateKey 离线保管,切勿提交仓库 / 打进客户端。publicKey 抄进客户端源码。',
createdAt: new Date().toISOString(),
};
mkdirSync(dirname(KEYFILE), { recursive: true });
writeFileSync(KEYFILE, JSON.stringify(out, null, 2), 'utf8');
console.log('✅ 密钥对 →', KEYFILE);
console.log('\n公钥(抄进客户端的 EMBEDDED_PUBLIC_KEY):\n ' + out.publicKey + '\n');
console.log('⚠ 私钥只在该文件里,离线备份后请从工作目录移走。');
}
/** 参数可以是 base64 串,也可以是 keypair.json 路径;都不给就读默认路径。 */
function readKeyArg(arg, field) {
if (!arg) return JSON.parse(readFileSync(KEYFILE, 'utf8'))[field];
if (arg.endsWith('.json')) return JSON.parse(readFileSync(arg, 'utf8'))[field];
return arg;
}
/** 从明文名册抽 payload;若传进来的已经是签名信封,就取它的 payload(支持重签)。 */
function extractPayload(raw) {
const obj = JSON.parse(raw);
if (obj?.payload && obj?.sig) return obj.payload;
if (!Array.isArray(obj.flags)) throw new Error('名册缺 flags 数组');
return { version: typeof obj.version === 'number' ? obj.version : 1, flags: obj.flags };
}
async function sign(inFile, privArg) {
const payload = extractPayload(readFileSync(inFile, 'utf8'));
const priv = await crypto.subtle.importKey(
'pkcs8', b64.dec(readKeyArg(privArg, 'privateKey')), { name: 'Ed25519' }, false, ['sign'],
);
const bytes = new TextEncoder().encode(canonicalize(payload));
const sig = await crypto.subtle.sign({ name: 'Ed25519' }, priv, bytes);
const outFile = inFile.replace(/\.json$/i, '') + '.signed.json';
writeFileSync(outFile, JSON.stringify({ alg: 'ed25519', sig: b64.enc(sig), payload }, null, 2), 'utf8');
console.log('✅ 已签名 →', outFile, `(${bytes.length} 字节)`);
}
async function verify(inFile, pubArg) {
const env = JSON.parse(readFileSync(inFile, 'utf8'));
if (!env.payload || !env.sig) throw new Error('不是签名信封(缺 payload / sig)');
const pub = await crypto.subtle.importKey(
'raw', b64.dec(readKeyArg(pubArg, 'publicKey')), { name: 'Ed25519' }, false, ['verify'],
);
const bytes = new TextEncoder().encode(canonicalize(env.payload));
const ok = await crypto.subtle.verify({ name: 'Ed25519' }, pub, b64.dec(env.sig), bytes);
console.log(ok ? '✅ 验签通过' : '❌ 验签失败');
if (!ok) process.exit(1);
}
const [cmd, a, b] = process.argv.slice(2);
try {
if (cmd === 'keygen') await keygen();
else if (cmd === 'sign') {
if (!a) throw new Error('用法:sign <明文名册.json> [私钥/keypair.json]');
await sign(a, b);
} else if (cmd === 'verify') {
if (!a) throw new Error('用法:verify <signed.json> [公钥/keypair.json]');
await verify(a, b);
} else {
console.log('用法:\n keygen\n sign <明文.json> [私钥/keypair.json]\n verify <signed.json> [公钥/keypair.json]');
}
} catch (e) {
console.error('❌', e.message);
process.exit(1);
}

设计与踩坑 → 云端功能开关 + Ed25519 离线兜底 · 演示 → /tools/ed25519-envelope.html

ESC