---
title: "远程配置 + 签名兜底"
url: "https://turingblog.org/notes/patterns/pb-remote-config"
date: 2026-09-22
tags: ["PocketBase","Ed25519","TypeScript"]
description: "PocketBase 按域下发配置，Ed25519 签名的离线授权兜底 —— 服务器挂了不影响干活，也没人能自己提权"
insight: "PocketBase 按域下发配置，Ed25519 签名的离线授权兜底 —— 服务器挂了不影响干活，也没人能自己提权"
problem: "没有自有后端的客户端既要不发版就能改参数，又要在云端不可达时安全判断「谁有高级权限」，还不能让用户自己提权。"
---

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

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

四个文件，零依赖。客户端三个 TypeScript，签名脚本一个 Node（只用 `node:crypto`）。

> 📖 设计取舍、分级 fail-open、以及「明文名册等于零鉴权」→ [云端功能开关 + Ed25519 离线兜底](/blog/signed-offline-fallback)

## 怎么用

### 远程配置

```ts
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 会引入不必要的合并逻辑。

### 签名兜底

```bash
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   # 发布前自检
```

```ts
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。

## 代码

```ts
// 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 ?? [])];
}
```

```ts
// 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;
}
```

```ts
// 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 };
}
```

```js
#!/usr/bin/env node
// scripts/sign-flags.mjs
/**
 * 授权名册签名工具（管理员用）。零依赖，只用 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 离线兜底](/blog/signed-offline-fallback)
