Udesk Series · Sample Code
Remote Config + Signed Fallback
Per-domain config from PocketBase plus an Ed25519-signed offline authorization fallback — outages don't stop work, and nobody can grant themselves access
远程配置 + 签名兜底
给一个没有自己后端的客户端(浏览器扩展、桌面工具、脚本)做两件事:
- 远程配置:参数放 PocketBase,按域拆 key,改参数不发版;云端挂了用上次的缓存继续跑。
- 离线授权兜底:云端不可达时,「谁有高级权限」从一份本地名册拿——而这份名册经 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.jsonnode 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-openconst { effective, outcome } = resolveLocalFlags(file, employeeId, displayName);三条最容易踩的
canonicalize在两端各有一份,必须字节级一致。 签的是字节不是对象;两边差一点,验签就无差别地全部失败,而表现是「所有人都没有高级权限」——看起来像授权逻辑坏了,不像序列化坏了。- 记录不存在要回
nochange,不是failed。 管理员还没建那条记录时,客户端就该安静地用内置默认。按失败处理会污染健康计数,拖累其它域被误判成「云端挂了」。 pick会静默丢弃未声明的字段。 云端加了新字段却忘了在pick里放行 → 云端明明有、客户端拿不到、而且不报错。加字段时两边一起改。
验证
三个 TypeScript 文件过 tsc --strict;另有 31 项端到端测试,其中最关键的一条是跨实现一致性——用 Node 签名脚本真签一份名册,再用客户端那份 canonicalize 复算验签。
其余覆盖:改一个字段 / 只改签名两个字符 → 验签失败;payload key 顺序不影响、数组顺序影响;三态字段(缺字段的普惠项=开、高级项=关);按 id / 按显示名两条命中路径;无名册仍返回可用基线;两路名册按人覆盖合并;健康探测的失败阈值、静默、4 倍退避与 2h 上限、一次成功即恢复、三档模式;远程拉取的 applied / nochange / 记录不存在 / 网络失败 fail-open 保留缓存 / 退避期 skipped。
代码
/** * 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 ?? [])];}/** * 云端健康追踪 + 静默退避。 * * 要解决的是一个体验问题:服务器停机那几天,每一轮周期拉取都 `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;}/** * 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 };}#!/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