diff --git a/plugins/crypt-toolkit/.gitignore b/plugins/crypt-toolkit/.gitignore new file mode 100644 index 000000000..65a9a4be1 --- /dev/null +++ b/plugins/crypt-toolkit/.gitignore @@ -0,0 +1,14 @@ +.claude +.codeium +.codex +.copilot +.gemini +.opencode +.qoder +.superpowers +.dsh +.zcode +**/node_modules +**/dist +**/.idea +**/.vscode \ No newline at end of file diff --git a/plugins/crypt-toolkit/CHANGELOG.md b/plugins/crypt-toolkit/CHANGELOG.md new file mode 100644 index 000000000..0e5d0505d --- /dev/null +++ b/plugins/crypt-toolkit/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## 1.1.0 - 2026-09-23 + +### 新增功能 + +#### 🔐 新增 9 个密码学算法 + +| 分类 | 算法 | 说明 | +|-------|--------------|------------------------------| +| 编解码 | Unicode 转义 | `\uXXXX` / `\u{XXXXX}` 格式互转 | +| 编解码 | HTML 实体 | 命名实体 + 十进制 / 十六进制数字实体 | +| 编解码 | Punycode/IDN | 国际化域名编解码(`中文.cn` ↔ `xn--fiq228c.cn`) | +| 编解码 | 莫尔斯电码 | A-Z 0-9 标点,空格转为 `/` | +| 对称加密 | XOR 流加密 | 逐字节密钥循环 XOR,教学演示用 | +| 非对称加密 | ECDH | 椭圆曲线 Diffie-Hellman 密钥协商 | +| 工具 | JWT 解码 | 解析 header.payload,不做签名验证 | +| 工具 | JSON 格式化 | 美化 / 压缩 | +| 工具 | 密码强度估算 | 0–100 分,基于长度、字符集、常见模式 | + +#### 🔍 原生搜索框 + +- 通过 `setSubInput` 注册 ztools 窗体原生搜索框 +- 搜索算法名称实时过滤左侧导航 +- 选择算法或切换页面时自动清空搜索词 + +### 优化改进 + +- 移除了启动时通过 URL query 自动填入输入框的逻辑,简化启动流程 +- 跟随主题色 + +## 1.0.0 - 2026-09-22 + +### 新增功能 + +#### 🔐 十大密码学算法 + +| 分类 | 算法 | 说明 | +|-------|---------|------------------------------| +| 编解码 | Base64 | 标准 Base64 编码/解码 | +| 编解码 | Hex | 十六进制编解码 | +| 编解码 | URL | URL 百分比编解码 | +| 哈希 | MD5 | 128 位消息摘要 | +| 哈希 | SHA-256 | 256 位安全哈希 | +| 对称加密 | AES | CBC / GCM 模式,128/192/256 位密钥 | +| 非对称加密 | RSA | OAEP 填充,密钥生成 / 加密 / 解密 | +| 消息认证 | HMAC | HMAC-SHA1/256/384/512 | +| 密钥派生 | PBKDF2 | 基于 HMAC 的密码派生 | +| 密码哈希 | bcrypt | 自适应成本,带验证模式 | + +#### 🎨 用户界面 + +- 左侧分类滚动,设置按钮固定底部 +- 右侧内容区独立垂直滚动 +- 标题栏右上角集成编解码 / 加解密 Tab 切换 +- 自定义滚动条(6px 细胶囊造型,hover 变主题蓝) +- 自适应明暗双主题(`prefers-color-scheme`) +- ZTools 宿主主题色自动同步(`useZtoolsTheme` hook) +- 透明面板背景,无圆角边框,融入宿主视觉风格 + diff --git a/plugins/crypt-toolkit/DEVELOP.md b/plugins/crypt-toolkit/DEVELOP.md new file mode 100644 index 000000000..3343397c4 --- /dev/null +++ b/plugins/crypt-toolkit/DEVELOP.md @@ -0,0 +1,411 @@ +# crypt-tool 开发指南 + +本插件运行于 ZTools 桌面端,采用 React 19 + TypeScript + Vite 6,所有 crypto 运算通过 preload 注入 Node.js `node:crypto` 或经审查的标准库完成。 + +> ⚠️ **实现原则**:禁止手写加密算法实现(包括 Blowfish、SHA、AES 轮函数等底层密码原语)。所有算法必须优先使用 Node.js 内置 `node:crypto` 模块;若内置模块不覆盖(如 bcrypt),需引用成熟的标准 npm 包(如 `bcryptjs`)。这是保证输出与 Python/Go/Java/在线工具等外部实现兼容的唯一方式。 + +## 目录 + +- [架构总览](#架构总览) +- [新增一个算法](#新增一个算法) +- [UI 规范](#ui-规范) +- [已有算法清单](#已有算法清单) +- [后续算法计划](#后续算法计划) + +--- + +## 架构总览 + +``` +crypt-tool +├── public/preload/crypt/ # Preload 层:Node.js crypto 封装 +│ ├── envelope.js # ok / fail / tryCrypt 包装器 +│ ├── index.js # 导出所有算法给 window.services.crypt +│ ├── encoding.js # Base64 / Hex / URL +│ ├── hash.js # MD5 / SHA-256 +│ ├── aes.js # AES +│ ├── rsa.js # RSA +│ └── hmac-pbkdf2.js # HMAC / PBKDF2 +├── src/algorithms/ # UI 层:每个算法三个文件 +│ └── / +│ ├── index.ts # 导出 AlgorithmModule +│ ├── meta.ts # 元数据(id / label / cmds / teach 等) +│ └── ui.tsx # React 组件 +├── src/registry/ # 算法注册表 +│ ├── types.ts # AlgorithmMeta / AlgorithmModule / CategoryId +│ ├── algorithms.ts # 算法数组 + helpers +│ └── categories.ts # 分类列表 +├── src/shared/ # 公共 UI 组件 +│ ├── Field.tsx # 支持 copyable / variant / onClear +│ ├── Actions.tsx # 顶部 Tab 式操作按钮组 +│ ├── CopyButton.tsx # 独立复制按钮(已逐步迁移为 Field.copyable) +│ ├── crypt-shell.css # 整个插件的样式 +│ └── TeachCard.tsx # 算法简要卡片(页面底部) +├── src/shell/ # 页面壳 +│ ├── Shell.tsx +│ ├── AlgorithmHost.tsx # 算法页核心:head + Component + TeachCard +│ ├── Sidebar.tsx # 左侧分类导航 +│ └── SettingsPage.tsx # 设置页(搜索 + 标签启停) +└── src/config/ + ├── settings.ts # Settings 管理(dbStorage 持久化) + └── features.ts # syncFeatures() 同步到 ZTools 直达指令 +``` + +--- + +## 新增一个算法 + +只需四步: + +### Step 1 — Preload + +在 `public/preload/crypt/` 新建或扩展 JS 文件,**必须使用 `node:crypto` 或成熟标准库**实现运算函数,返回 `{ ok: true, data }` 或 `{ ok: false, error }`: + +```js +// 新文件 public/preload/crypt/chacha.js +const crypto = require('node:crypto') +const { ok, fail, tryCrypt } = require('./envelope') + +function encrypt({ key, nonce, plaintext }) { + return tryCrypt(() => { + // 参数校验 → fail('...') + const cipher = crypto.createCipheriv('chacha20-poly1305', key, nonce) + const ct = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]) + return ok(ct.toString('base64')) + }) +} + +module.exports = { encrypt, decrypt } +``` + +> 校验失败直接 `fail('中文错误信息')`;运算用 `tryCrypt` 包裹。 +> +> **禁止**:手写的 S-box、 Blowfish 轮函数、SHA 消息调度等底层实现——此类代码即使能自洽也无法与其他语言/在线工具互通。 +> +> **推荐**: +> - 哈希/MAC:`crypto.createHash` / `crypto.createHmac` +> - 对称加密:`crypto.createCipheriv` / `crypto.createDecipheriv` +> - PBKDF2/scrypt:`crypto.pbkdf2Sync` / `crypto.scryptSync` +> - bcrypt:`bcryptjs`(同 API、纯 JS、兼容 C 实现) +> - RSA/ECC:`crypto.generateKeyPairSync` / `crypto.publicEncrypt` / `crypto.createSign` + +在 `public/preload/crypt/index.js` 注册到 `window.services.crypt`: + +```js +const chacha = require('./chacha') +module.exports = { /* ... */ chacha } +``` + +### Step 2 — 算法模块 + +在 `src/algorithms//` 下新建三个文件: + +**meta.ts** — 元数据: + +```ts +import type { AlgorithmMeta } from '../../registry/types' + +export const meta: AlgorithmMeta = { + id: 'chacha', // 唯一 ID,也用于直达指令 alg:chacha + category: 'symmetric', // 分类 ID(categories.ts 中) + label: 'ChaCha20-Poly1305', // 标签页按钮文字 + title: 'ChaCha20-Poly1305 对称加密', // 页面标题 + reversible: true, // 是否有解密(有则 Tab 切换加解密) + cmds: ['chacha', 'chacha20'], // ZTools 直达关键词 + defaultEnabled: true, + teach: { + summary: 'ChaCha20-Poly1305 是现代流密码 + AEAD 组合……' + } +} +``` + +**index.ts** — 组合导出: + +```ts +import { meta } from './meta' +import UI from './ui' +export const chacha = { meta, Component: UI } +``` + +**ui.tsx** — 遵循 UI 规范(见下文"UI 规范"章节)。 + +### Step 3 — 注册 + +在 `src/registry/algorithms.ts` import 并加入数组: + +```ts +import { chacha } from '../algorithms/chacha' +export const algorithms = [ /* ... */ chacha ] +``` + +### Step 4 — 单元测试 + +为 preload 函数写 Vitest 测试:`public/preload/crypt/.test.ts`,覆盖正常路径与错误路径。 + +--- + +## UI 规范 + +算法 UI 必须保持一致的视觉结构。以下结构已通过所有实装算法验证: + +### 整体布局 + +从顶到底顺序固定: + +``` +[ct-tabs] ← 仅 reversible 算法:编码/加密 + 解密 + 可选 ghost 操作 +[ct-input-zone] ← 输入区 + [ct-zone-label] "输入" + [ct-grid2] ← 2 列网格,单字段时占满宽度 + [Field] × N +[ct-actions] ← 按钮区:占满宽度,flex: 1 +[ct-output-zone] ← 输出区 + [ct-zone-label] "输出" + [Field variant="output"] +[TeachCard] ← 已在 AlgorithmHost 中自动渲染 +``` + +### 输入区(.ct-input-zone) + +- 带灰色边框卡片包裹,顶部 `ct-zone-label` 显示"输入" +- 字段用 `ct-grid2` 2 列网格排布 +- 同一行只有一个字段时(奇数总字段)占满宽度(由 CSS `:last-child:nth-child(odd)` 自动处理) + +### 输出区(.ct-output-zone) + +- 带蓝色边框卡片包裹,顶部 `ct-zone-label` 显示"输出" +- 仅存放输出字段,每个字段 `variant="output"` + `readOnly` + `copyable` + +### 操作区(.ct-actions) + +- 位于输入区与输出区之间 +- 按钮 `flex: 1` 等宽占满一行 +- 主操作 `className="ct-btn ct-btn-primary"` +- 附加操作(如互换) `className="ct-btn ct-btn-ghost"` + +### Tab 切换(仅 reversible 算法) + +```tsx +
+ + + {/* 可选 */} +
+``` + +Tab 下方紧跟输入区,**而非**把 Tab 作为输入的一部分。 + +### Field 组件参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `label` | string | 标签 | +| `hint?` | string | 行内小提示(如 "16 字节") | +| `help?` | string | 字段下方帮助文字 | +| `type?` | `'text' \| 'secret' \| 'textarea' \| 'select'` | 默认 text | +| `copyable?` | boolean | 标签行末尾显示内联复制按钮 ⧉ | +| `variant?` | `'input' \| 'output'` | output 时蓝色高亮 + 输出标签 | +| `onClear?` | () => void | 标签行末尾显示清除按钮 ×(仅需要时) | +| `readOnly?` | boolean | 只读(输出字段必须) | + +### 非 reversible 算法(如 MD5) + +跳过 Tabs,直接输入区 → actions → 输出区: + +```tsx +
+
输入
+
+ +
+
+
+ +
+
+
输出
+ +
+``` + +### 调用运算 + +使用 `runCodec` + `showError` + `showData`: + +```ts +import { runCodec, showError, showData } from '../codec' + +const r = runCodec(() => + window.services.crypt..({ /* 参数 */ }) +) +setError(showError(r)) +setOutput(showData(r)) +``` + +接收外部传入内容用 `useEffect`: + +```ts +useEffect(() => { if (enterPayload) setInput(enterPayload) }, [enterPayload]) +``` + +--- + +## 已有算法清单 + +### 编码转换(encoding) + +| ID | 标签 | 可逆 | +|----|------|------| +| base64 | Base64 | ✓ | +| base58 | Base58 | ✓ | +| safeBase64 | Safe Base64 | ✓ | +| base32 | Base32 | ✓ | +| hex | Hex | ✓ | +| url | URL | ✓ | +| unicodeEscape | Unicode 转义 | ✓ | +| htmlEntity | HTML 实体 | ✓ | +| punycode | Punycode/IDN | ✓ | +| morse | 莫尔斯电码 | ✓ | + +### 哈希摘要(hash) + +| ID | 标签 | 可逆 | +|----|------|------| +| md5 | MD5 | ✗ | +| sha1 | SHA-1 | ✗ | +| sha256 | SHA-256 | ✗ | +| sha384 | SHA-384 | ✗ | +| sha512 | SHA-512 | ✗ | +| sha3_256 | SHA3-256 | ✗ | +| sha3_512 | SHA3-512 | ✗ | +| ripemd160 | RIPEMD-160 | ✗ | + +### 对称加密(symmetric) + +| ID | 标签 | 可逆 | +|----|------|------| +| aes | AES | ✓ | +| chacha | ChaCha20 | ✓ | +| des | 3DES | ✓ | +| xorStream | XOR 流加密 | ✓ | + +### 非对称加密(asymmetric) + +| ID | 标签 | 可逆 | +|----|------|------| +| rsa | RSA | ✓ | +| ed25519 | Ed25519 | ✗ | +| ecdsa | ECDSA | ✗ | +| ecdh | ECDH | ✗ | + +### 消息认证(hmac) + +| ID | 标签 | 可逆 | +|----|------|------| +| hmac | HMAC | ✗ | + +### 口令派生(kdf) + +| ID | 标签 | 可逆 | +|----|------|------| +| pbkdf2 | PBKDF2 | ✗ | +| bcrypt | bcrypt | ✗ | +| scrypt | scrypt | ✗ | +| argon2 | Argon2 | ✗ | +| hkdf | HKDF | ✗ | + +### 工具与实用(tools) + +| ID | 标签 | 可逆 | +|----|------|------| +| uuid | UUID | ✗ | +| crc32 | CRC32 | ✗ | +| adler32 | Adler-32 | ✗ | +| randomBytes | Random Bytes | ✗ | +| jwt | JWT 解码 | ✗ | +| json | JSON 格式化 | ✓ | +| passwordStrength | 密码强度 | ✗ | + +--- + +## 后续算法计划 + +按类别分组,按实现复杂度和实用价值排列。 + +### 编码转换(encoding) + +- [x] **safe-base64** — URL 安全的 Base64(`+/` → `-_`,可去 `=` padding) +- [x] **base32** — Base32 编解码(RFC 4648,常用于 TOTP 密钥、可读性更好) +- [x] **base58 / base58check** — Base58 / Base58check(比特币地址风格,无 `0OIl` 歧义字符) +- [x] **unicode-escape** — Unicode 转义(`\u4e2d\u6587` ↔ `中文`) +- [x] **html-entity** — HTML 实体编解码(`&` ↔ `&`,`中` 十进制/十六进制数字实体) +- [x] **punycode** — Punycode 国际化域名(`中文.cn` ↔ `xn--fiq228c.cn`) +- [x] **morse** — 摩尔斯电码(字母/数字 ↔ 长短音,仅编码;中文可用 Unicode 转义) + +### 哈希摘要(hash) + +- [x] **sha1** — SHA-1(160 位;仍广泛用于 Git 对象 ID,但不应再用于安全场景) +- [x] **sha384** — SHA-384(SHA-384 摘要,与 SHA-512 同族,截断到 384 位) +- [x] **sha512** — SHA-512(512 位摘要,安全强度更高) +- [x] **sha3-256 / sha3-512** — SHA-3(Keccak,与 SHA-2 完全不同的结构) +- [ ] **blake2b / blake2s** — BLAKE2(比 MD5/SHA 快且安全,常用作现代哈希) +- [ ] **blake3** — BLAKE3(并行哈希、极快、可 Keyed 模式做伪 HMAC) +- [x] **ripemd160** — RIPEMD-160(比特币地址生成配套哈希) + +### 对称加密(symmetric) + +- [x] **des / 3des** — DES / 3DES(历史算法 `des-ede3-cbc`,用于兼容老数据;可用 `crypto.createCipheriv`) +- [x] **chacha20-poly1305** — ChaCha20-Poly1305(现代 AEAD,TLS 1.3 常用;可用 `crypto.createCipheriv('chacha20-poly1305', ...)`) +- [ ] **sm4** — SM4(国密对称加密标准,128 位分组;可用 Node.js SM4 实现如 `@wecom/crypto` 或自行调用 WebAssembly) +- [x] **xor-stream** — XOR 流密码(逐字节/逐字密钥循环 XOR,简单演示用;可用 `Buffer` 实现) + +### 非对称加密(asymmetric) + +- [x] **ecdsa** — ECDSA(椭圆曲线数字签名;支持 P-256/P-384) +- [x] **ed25519** — Ed25519(EdDSA 现代曲线;密钥短、速度快、抗侧信道) +- [x] **ecdh** — ECDH(椭圆曲线 Diffie-Hellman 密钥协商,双方各取私钥 × 对方公钥得共享秘密) +- [ ] **sm2** — SM2(国密椭圆曲线签名 + 加密) +- [ ] **dh** — Diffie-Hellman(经典 DH 密钥协商,与小素数和大素数域) + +### 消息认证(hmac) + +- [ ] **hmac-sha512** — 已有 HMAC 已支持 SHA-512,但可新增独立入口默认 SHA-512 +- [ ] **cmac** — CMAC(基于分组密码的 MAC,适用于 AES 场景) +- [ ] **poly1305** — Poly1305(通用 MAC,常与 ChaCha20 组合) + +### 口令派生(kdf) + +- [x] **bcrypt** — bcrypt(自适应成本因子密码哈希,抗 GPU/ASIC,基于 `bcryptjs` 标准库;含自动 salt 生成与验证接口) +- [x] **scrypt** — scrypt(内存困难型 KDF,抗 GPU/ASIC;可用 `crypto.scryptSync` 实现) +- [x] **argon2** — Argon2(密码哈希竞赛冠军;argon2id 平衡侧信道与 GPU 抗性;可用 `argon2` npm 包) +- [x] **hkdf** — HKDF(基于 HMAC 的 KDF,适用于从主密钥派生子密钥;可用 `crypto.hkdf` 或 `@noble/hashes`) + +### 工具与实用(tools,新分类) + +若实现以下功能,建议在 `src/registry/categories.ts` 新增 `tools` 分类: + +- [x] **uuid** — UUID v4 / v7 生成 +- [x] **random-bytes** — 随机字节生成(可指定长度、编码 hex/base64) +- [x] **jwt-decode** — JWT 解码(解析 header.payload,不做签名验证) +- [ ] **hash-file** — 文件哈希(用户选文件,计算 SHA-256/MD5 等) +- [ ] **base64-image** — Base64 ↔ 图片预览(logo、小图标嵌入场景) +- [x] **password-strength** — 密码强度估算(zxcvbn or 简易长度+字符集评分) +- [ ] **qr-generate** — QR 码生成(文本 → ASCII-art 或 base64 PNG) +- [x] **json-format** — JSON 格式化 / 压缩(常见文本工具) +- [ ] **jwt-verify** — JWT 签名验证(给定公钥或对称密钥) + +### 编码/序列化相关 + +- [ ] **json-escape** — JSON 字符串转义 / 反转义(`"\"中文\""` ↔ `"中文"`) +- [ ] **csv-escape** — CSV 字段转义(含逗号/引号/换行时加双引号) +- [ ] **quoted-printable** — Quoted-Printable 编解码(邮件 MIME) +- [ ] **percent-hex** — 百分比 + 十六进制(`%E4%B8%AD` + `%u4E2D` 两种变体) + +### 校验和 / 指纹 + +- [x] **crc32** — CRC32 校验和(ZIP、PNG 等协议常用) +- [ ] **adler32** — Adler-32 校验和(zlib 默认) +- [ ] **luhn** — Luhn 算法(信用卡号校验位计算与验证) +- [ ] **fletcher16/32** — Fletcher 校验和(TCP 替代校验) + +> 分类标签与 `categoryId` 定义在 `src/registry/categories.ts`,新增分类需在该文件 `CATEGORIES` 数组追加。 diff --git a/plugins/crypt-toolkit/README.md b/plugins/crypt-toolkit/README.md new file mode 100644 index 000000000..66a7a0201 --- /dev/null +++ b/plugins/crypt-toolkit/README.md @@ -0,0 +1,115 @@ +# crypt-tool + +> 加解密工具,高颜值,可自定义,UI 和谐统一,带加密算法的简要指引 + +基于 **React 19 + Vite 6 + TypeScript** 构建的 ZTools 插件,提供 9 种常用编解码与加密算法。 + +# 为什么选择`crypt-tool`? + +1. 现代化界面设计,和ZTools原生设计风格一致,原生软件般体验 +2. 支持算法广泛,可自定义界面功能 + +## ✨ 功能概览 + +### 友好的交互设计`一目了然` + +![img.png](docs/images/img.png) + +### 根据自己的需求定制内容 + +![img_2.png](docs/images/img_2.png) + +### 后续支持算法 + +[DEVELOP.md#后续算法计划](DEVELOP.md#后续算法计划) + +## 📁 项目结构 + +``` +. +├── public/ +│ ├── logo.png # 插件图标 +│ ├── plugin.json # 插件配置 +│ └── preload/ +│ └── services.js # 向渲染进程注入 crypt 服务 +│ └── crypt/ # Node.js crypto 实现 +│ ├── encoding.js # base64 / hex / url +│ ├── hash.js # md5 / sha256 +│ ├── aes.js # AES-CBC / AES-GCM +│ ├── rsa.js # RSA-OAEP 加解密 / 密钥生成 +│ └── hmac-pbkdf2.js # HMAC 签名验证 / PBKDF2 +├── src/ +│ ├── main.tsx # 入口 +│ ├── App.tsx # 根组件:加载设置、监听 onPluginEnter +│ ├── shell/ # 侧边栏 + 算法宿主 + 设置页 +│ │ ├── Shell.tsx +│ │ ├── Sidebar.tsx +│ │ ├── AlgorithmHost.tsx +│ │ └── SettingsPage.tsx +│ ├── registry/ # 算法注册表与分类 +│ │ ├── types.ts # AlgorithmModule / AlgorithmMeta +│ │ ├── categories.ts # 六大类别定义 +│ │ └── algorithms.ts # 9 个算法 + 筛选帮手 +│ ├── config/ # dbStorage 设置 + 动态功能同步 +│ │ ├── settings.ts +│ │ └── features.ts +│ ├── shared/ # 跨算法复用 UI +│ │ ├── Field.tsx # 输入/输出字段(text/secret/textarea/select) +│ │ ├── TeachCard.tsx # 算法说明卡片 +│ │ ├── Actions.tsx # 按钮组 +│ │ ├── CopyButton.tsx # 复制 + toast +│ │ └── crypt-shell.css # 全部视觉样式 +│ ├── algorithms/ # 每种算法的 meta + ui + barrel +│ │ ├── codec.ts # 编解码辅助(runCodec / showError / showData) +│ │ ├── base64/ +│ │ ├── hex/ +│ │ ├── url/ +│ │ ├── md5/ +│ │ ├── sha256/ +│ │ ├── aes/ +│ │ ├── rsa/ +│ │ ├── hmac/ +│ │ └── pbkdf2/ +│ └── types/crypt.ts # CryptResult 类型 +├── docs/ +│ └── superpowers/ +│ ├── specs/2026-09-22-crypt-tool-design.md # 架构设计 +│ └── plans/2026-09-22-crypt-tool.md # 实现计划 +└── package.json +``` + +## 🚀 快速开始 + +```bash +npm install +npm run dev # 开发模式,ZTools 自动加载 localhost:5173 +npm run build # 构建到 dist/ +npm test # 运行 vitest 全套测试 +``` + +命令行直达(开发模式下可在 ZTools 搜索框触发):`base64`、`hex`、`url编码`、`md5`、`sha256`、`aes加密`、`rsa加密`、`hmac`、 +`pbkdf2`。 + +## 🧩 如何新增一种算法 + +1. **Preload 实现**:在 `public/preload/crypt/` 新增 `.js`,用 `envelope.js` 的 `ok/fail/tryCrypt` 包裹并暴露 + `{ ok, data/error }` 结果;在 `index.js` 中挂载导出。 +2. **类型**:在 `src/env.d.ts` 的 `CryptNamespace` 中追加对应方法签名。 +3. **算法模块**:在 `src/algorithms//` 下创建 `meta.ts`、`ui.tsx`、`index.ts`,导出 `AlgorithmModule`。 +4. **注册**:在 `src/registry/algorithms.ts` 的数组中加入新模块。 +5. **同步**:`syncFeatures` 会自动将它注册为直达指令 `alg:`。 + +详细设计见 `docs/superpowers/specs/2026-09-22-crypt-tool-design.md`。 + +## 📦 脚本 + +| 命令 | 作用 | +|-----------------------------------------|----------------------| +| `npm run dev` | 启动 Vite 开发服务器 | +| `npm run build` | tsc 类型检查 + Vite 生产构建 | +| `npm test` | 运行 vitest 单元测试 | +| `npm run build` 成功后会输出到 `dist/`,可直接用于分发 | + +## 📄 协议 + +MIT License diff --git a/plugins/crypt-toolkit/docs/images/img.png b/plugins/crypt-toolkit/docs/images/img.png new file mode 100644 index 000000000..08b1f8890 Binary files /dev/null and b/plugins/crypt-toolkit/docs/images/img.png differ diff --git a/plugins/crypt-toolkit/docs/images/img_2.png b/plugins/crypt-toolkit/docs/images/img_2.png new file mode 100644 index 000000000..e6bf08caa Binary files /dev/null and b/plugins/crypt-toolkit/docs/images/img_2.png differ diff --git a/plugins/crypt-toolkit/docs/superpowers/plans/2026-09-22-crypt-tool.md b/plugins/crypt-toolkit/docs/superpowers/plans/2026-09-22-crypt-tool.md new file mode 100644 index 000000000..0209d4f3d --- /dev/null +++ b/plugins/crypt-toolkit/docs/superpowers/plans/2026-09-22-crypt-tool.md @@ -0,0 +1,3458 @@ +# Crypt Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a ZTools crypt-tool plugin with a category sidebar, 9 representative algorithms, enable/disable settings, dynamic direct commands, and Node crypto backends. + +**Architecture:** Registry pattern — `shell` renders categories/algorithms from `registry`; each algorithm lives under `src/algorithms//` with meta + UI; crypto runs only in `public/preload/crypt/*` via `window.services.crypt.*`; `config` is the sole writer of `dbStorage` and dynamic Features. + +**Tech Stack:** React 19, TypeScript, Vite 6, ZTools APIs (`dbStorage`, `setFeature`/`removeFeature`, `copyText`, `showToast`), Node.js `crypto` (CommonJS preload), Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-22-crypt-tool-design.md` + +## Global Constraints + +- UI copy, validation messages, and `{ ok:false }` errors: Simplified Chinese. +- Page root must not set `background` / `background-color` (host default only). +- Design for ~40% screen width; sidebar ~180–220px, collapse via CSS only if needed. +- Input/textarea/select surfaces: dark translucent (e.g. `rgba(0,0,0,.28)`), confirmed mockup `layout-sidebar-v2`. +- No encryption libraries in the renderer; only `window.services.crypt.*`. +- Preload envelope: `{ ok: true, data: string }` | `{ ok: false, error: string }`. +- Settings storage key: `crypt-tool:settings` via `ztools.dbStorage`. +- Dynamic feature codes: `alg:`; static main feature code: `crypt`. +- Batch-1 algorithms (9): `base64`, `hex`, `url`, `md5`, `sha256`, `aes`, `rsa`, `hmac`, `pbkdf2` — all `defaultEnabled: true`. +- Remove scaffold features/components: `hello`, `read`, `write`. +- `npm run build` must pass (`tsc && vite build`); `npm test` must pass. + +## Review Focus + +- Bad decode input (invalid Base64/Hex/URL) → `{ ok:false, error }` in Chinese, no throw (Task 2 tests). +- AES key not 16/24/32 bytes or bad IV → validation error before/at envelope, no crash (Task 4 tests). +- RSA decrypt with wrong key / non-PEM input → `{ ok:false }`, UI keeps input (Task 5 tests). +- Disabling the currently open algorithm → shell navigates to first enabled; all disabled → empty state + settings CTA (Task 9/10 manual + config unit tests for target feature set). +- `dbStorage`/`setFeature` throw → load falls back to defaults; sync failure surfaces toast, app still usable (Task 7 tests with mocks). + +--- + +## File Structure + +``` +public/ + plugin.json # static feature `crypt` only (+ meta fields) + preload/ + services.js # window.services = { ..., crypt } + crypt/ + envelope.js # ok/err helpers + tryCrypt wrapper + encoding.js # base64, hex, url + hash.js # md5, sha256 + aes.js + rsa.js + hmac.js + pbkdf2.js + index.js # module.exports = { base64, hex, ... } + *.test.ts # vitest (node env) +src/ + registry/ + types.ts # CategoryId, AlgorithmMeta, AlgorithmModule + categories.ts # ordered CATEGORIES + algorithms.ts # array + getById/getByCategory/getEnabled + index.ts + algorithms.test.ts + config/ + settings.ts # load/save/setEnabled/defaultSettings + features.ts # buildFeature, syncFeatures + index.ts + settings.test.ts + features.test.ts + shared/ + Field.tsx + TeachCard.tsx + Actions.tsx + CopyButton.tsx + index.ts + crypt-shell.css # shared visual tokens for shell+algorithm panels + shell/ + Sidebar.tsx + SettingsPage.tsx + AlgorithmHost.tsx # disabled/empty states + component mount + Shell.tsx # layout: sidebar + main + index.ts + algorithms/ + base64/{meta.ts,ui.tsx,index.ts} + hex/{meta.ts,ui.tsx,index.ts} + url/{meta.ts,ui.tsx,index.ts} + md5/{meta.ts,ui.tsx,index.ts} + sha256/{meta.ts,ui.tsx,index.ts} + aes/{meta.ts,ui.tsx,index.ts} + rsa/{meta.ts,ui.tsx,index.ts} + hmac/{meta.ts,ui.tsx,index.ts} + pbkdf2/{meta.ts,ui.tsx,index.ts} + App.tsx # enter events → Shell view state + main.tsx + env.d.ts # Services.crypt typings +package.json # + vitest, scripts.test +``` + +**Dependency rule:** `shell → registry → algorithms → shared`; `config` may import `registry` only; algorithms import `shared` + call `window.services`; no algorithm-to-algorithm imports. + +--- + +### Task 1: Tooling + registry types + categories + +**Files:** +- Create: `src/registry/types.ts` +- Create: `src/registry/categories.ts` +- Create: `src/registry/categories.test.ts` +- Create: `vitest.config.ts` +- Modify: `package.json` + +**Interfaces:** +- Produces: `CategoryId`, `AlgorithmMeta`, `AlgorithmModule`, `AlgorithmProps`, `CATEGORIES: CategoryDef[]` + +- [ ] **Step 1: Add vitest and test script** + +Edit `package.json` dependencies/devDependencies and scripts: + +```json +{ + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} +``` + +Merge with existing keys; do not remove existing deps. Create `vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'public/preload/**/*.test.ts'] + } +}) +``` + +- [ ] **Step 2: Write failing categories test** + +`src/registry/categories.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { CATEGORIES, categoryLabel } from './categories' + +describe('categories', () => { + it('has six categories in spec order', () => { + expect(CATEGORIES.map((c) => c.id)).toEqual([ + 'encoding', + 'hash', + 'symmetric', + 'asymmetric', + 'hmac', + 'kdf' + ]) + }) + + it('maps Chinese labels', () => { + expect(categoryLabel('encoding')).toBe('编码转换') + expect(categoryLabel('kdf')).toBe('口令派生') + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL (module not found) + +- [ ] **Step 4: Implement types + categories** + +`src/registry/types.ts`: + +```ts +import type { FC } from 'react' + +export type CategoryId = + | 'encoding' + | 'hash' + | 'symmetric' + | 'asymmetric' + | 'hmac' + | 'kdf' + +export interface AlgorithmMeta { + id: string + category: CategoryId + label: string + title: string + reversible: boolean + cmds: string[] + defaultEnabled: boolean + teach: { summary: string } +} + +export interface AlgorithmProps { + enterPayload?: string +} + +export interface AlgorithmModule { + meta: AlgorithmMeta + Component: FC +} +``` + +`src/registry/categories.ts`: + +```ts +import type { CategoryId } from './types' + +export interface CategoryDef { + id: CategoryId + label: string +} + +export const CATEGORIES: CategoryDef[] = [ + { id: 'encoding', label: '编码转换' }, + { id: 'hash', label: '哈希摘要' }, + { id: 'symmetric', label: '对称加密' }, + { id: 'asymmetric', label: '非对称加密' }, + { id: 'hmac', label: '消息认证' }, + { id: 'kdf', label: '口令派生' } +] + +export function categoryLabel(id: CategoryId): string { + return CATEGORIES.find((c) => c.id === id)?.label ?? id +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json vitest.config.ts src/registry/ +git commit -m "chore: vitest setup and registry categories" +``` + +--- + +### Task 2: Encoding algorithms (Base64 / Hex / URL) in preload + +**Files:** +- Create: `public/preload/crypt/envelope.js` +- Create: `public/preload/crypt/encoding.js` +- Create: `public/preload/crypt/encoding.test.ts` +- Create: `public/preload/crypt/index.js` (starts as encoding export; extended in later tasks) + +**Interfaces:** +- Produces: `envelope.ok(data)`, `envelope.fail(error)`, `tryCrypt(fn)`; `base64.encode/decode`, `hex.encode/decode`, `url.encode/decode` each `(input: string) => CryptResult` + +- [ ] **Step 1: Write failing encoding tests** + +`public/preload/crypt/encoding.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { base64, hex, url } from './index.js' + +describe('base64', () => { + it('roundtrips UTF-8 text', () => { + const enc = base64.encode('Hello ZTools') + expect(enc.ok).toBe(true) + if (!enc.ok) return + expect(enc.data).toBe('SGVsbG8gWlRvb2xz') + const dec = base64.decode(enc.data) + expect(dec).toEqual({ ok: true, data: 'Hello ZTools' }) + }) + + it('fails on invalid base64', () => { + const dec = base64.decode('@@@') + expect(dec.ok).toBe(false) + if (dec.ok) return + expect(dec.error).toMatch(/Base64/) + }) +}) + +describe('hex', () => { + it('encodes and decodes', () => { + const enc = hex.encode('AB') + expect(enc).toEqual({ ok: true, data: '4142' }) + expect(hex.decode('4142')).toEqual({ ok: true, data: 'AB' }) + }) + + it('fails on odd-length hex', () => { + const r = hex.decode('414') + expect(r.ok).toBe(false) + }) +}) + +describe('url', () => { + it('roundtrips', () => { + const enc = url.encode('a b/c?d=e') + expect(enc.ok).toBe(true) + if (!enc.ok) return + expect(url.decode(enc.data)).toEqual({ ok: true, data: 'a b/c?d=e' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run public/preload/crypt/encoding.test.ts` +Expected: FAIL (module not found) + +- [ ] **Step 3: Implement envelope + encoding + index** + +`public/preload/crypt/envelope.js`: + +```js +function ok(data) { + return { ok: true, data: String(data) } +} + +function fail(error) { + return { ok: false, error: String(error) } +} + +function tryCrypt(fn) { + try { + return fn() + } catch (e) { + return fail(e && e.message ? e.message : '计算失败') + } +} + +module.exports = { ok, fail, tryCrypt } +``` + +`public/preload/crypt/encoding.js`: + +```js +const { ok, fail, tryCrypt } = require('./envelope') + +const base64 = { + encode(input) { + return tryCrypt(() => ok(Buffer.from(String(input), 'utf-8').toString('base64'))) + }, + decode(input) { + return tryCrypt(() => { + const s = String(input).trim() + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s) || s.length % 4 !== 0) { + return fail('Base64 格式无效,无法解码') + } + const buf = Buffer.from(s, 'base64') + if (buf.toString('base64').replace(/=+$/, '') !== s.replace(/=+$/, '')) { + return fail('Base64 格式无效,无法解码') + } + return ok(buf.toString('utf-8')) + }) + } +} + +const hex = { + encode(input) { + return tryCrypt(() => ok(Buffer.from(String(input), 'utf-8').toString('hex'))) + }, + decode(input) { + return tryCrypt(() => { + const s = String(input).trim().replace(/\s+/g, '') + if (!/^[0-9a-fA-F]*$/.test(s) || s.length % 2 !== 0) { + return fail('Hex 格式无效(需偶数位 0-9a-f)') + } + return ok(Buffer.from(s, 'hex').toString('utf-8')) + }) + } +} + +const url = { + encode(input) { + return tryCrypt(() => ok(encodeURIComponent(String(input)))) + }, + decode(input) { + return tryCrypt(() => { + try { + return ok(decodeURIComponent(String(input))) + } catch (e) { + return fail('URL 编码无效,无法解码') + } + }) + } +} + +module.exports = { base64, hex, url } +``` + +`public/preload/crypt/index.js`: + +```js +const encoding = require('./encoding') + +module.exports = { + base64: encoding.base64, + hex: encoding.hex, + url: encoding.url +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run public/preload/crypt/encoding.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/preload/crypt/ +git commit -m "feat(preload): base64 hex url encoding with envelope" +``` + +--- + +### Task 3: Hash algorithms (MD5 / SHA-256) + +**Files:** +- Create: `public/preload/crypt/hash.js` +- Create: `public/preload/crypt/hash.test.ts` +- Modify: `public/preload/crypt/index.js` + +**Interfaces:** +- Produces: `md5.digest(input)`, `sha256.digest(input)` → `CryptResult` (hex lowercase) + +- [ ] **Step 1: Write failing hash tests** + +`public/preload/crypt/hash.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { md5, sha256 } from './index.js' + +describe('hash', () => { + it('md5 known vector', () => { + expect(md5.digest('abc')).toEqual({ + ok: true, + data: '900150983cd24fb0d6963f7d28e17f72' + }) + }) + + it('sha256 known vector', () => { + expect(sha256.digest('abc')).toEqual({ + ok: true, + data: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run public/preload/crypt/hash.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement hash + register export** + +`public/preload/crypt/hash.js`: + +```js +const crypto = require('node:crypto') +const { ok, tryCrypt } = require('./envelope') + +function digest(algo, input) { + return tryCrypt(() => + ok(crypto.createHash(algo).update(String(input), 'utf-8').digest('hex')) + ) +} + +module.exports = { + md5: { digest: (input) => digest('md5', input) }, + sha256: { digest: (input) => digest('sha256', input) } +} +``` + +Update `public/preload/crypt/index.js`: + +```js +const encoding = require('./encoding') +const hash = require('./hash') + +module.exports = { + base64: encoding.base64, + hex: encoding.hex, + url: encoding.url, + md5: hash.md5, + sha256: hash.sha256 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run public/preload/crypt/hash.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/preload/crypt/ +git commit -m "feat(preload): md5 and sha256 digest" +``` + +--- + +### Task 4: AES encrypt/decrypt + +**Files:** +- Create: `public/preload/crypt/aes.js` +- Create: `public/preload/crypt/aes.test.ts` +- Modify: `public/preload/crypt/index.js` + +**Interfaces:** +- Produces: `aes.encrypt({ key, iv, mode, plaintext })`, `aes.decrypt({ key, iv, mode, ciphertext })` → `CryptResult` + - `key`/`iv`: UTF-8 strings (key byte length 16/24/32; iv 16 bytes) + - `mode`: `'CBC' | 'GCM'` + - CBC output: Base64(iv||ct) not used — iv passed separately; ciphertext Base64 + - GCM: output Base64(ct||tag) with 16-byte tag; decrypt splits last 16 bytes + +- [ ] **Step 1: Write failing AES tests** + +`public/preload/crypt/aes.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { aes } from './index.js' + +const key = 'my-secret-key-16b' // 16 bytes +const iv = 'random-iv-16bytes' // 16 bytes + +describe('aes', () => { + it('CBC roundtrip', () => { + const enc = aes.encrypt({ key, iv, mode: 'CBC', plaintext: 'Hello ZTools' }) + expect(enc.ok).toBe(true) + if (!enc.ok) return + const dec = aes.decrypt({ key, iv, mode: 'CBC', ciphertext: enc.data }) + expect(dec).toEqual({ ok: true, data: 'Hello ZTools' }) + }) + + it('GCM roundtrip', () => { + const enc = aes.encrypt({ key, iv, mode: 'GCM', plaintext: 'Hello ZTools' }) + expect(enc.ok).toBe(true) + if (!enc.ok) return + const dec = aes.decrypt({ key, iv, mode: 'GCM', ciphertext: enc.data }) + expect(dec).toEqual({ ok: true, data: 'Hello ZTools' }) + }) + + it('rejects bad key length', () => { + const enc = aes.encrypt({ key: 'short', iv, mode: 'CBC', plaintext: 'x' }) + expect(enc.ok).toBe(false) + if (enc.ok) return + expect(enc.error).toMatch(/密钥/) + }) + + it('rejects wrong key length for iv', () => { + const enc = aes.encrypt({ key, iv: 'bad', mode: 'CBC', plaintext: 'x' }) + expect(enc.ok).toBe(false) + if (enc.ok) return + expect(enc.error).toMatch(/IV/) + }) + + it('GCM fails on tampered ciphertext', () => { + const enc = aes.encrypt({ key, iv, mode: 'GCM', plaintext: 'Hello ZTools' }) + expect(enc.ok).toBe(true) + if (!enc.ok) return + const raw = Buffer.from(enc.data, 'base64') + raw[0] ^= 0xff + const dec = aes.decrypt({ key, iv, mode: 'GCM', ciphertext: raw.toString('base64') }) + expect(dec.ok).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run public/preload/crypt/aes.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement AES + export** + +`public/preload/crypt/aes.js`: + +```js +const crypto = require('node:crypto') +const { ok, fail, tryCrypt } = require('./envelope') + +function validate({ key, iv, mode }) { + const keyBuf = Buffer.from(String(key), 'utf-8') + if (![16, 24, 32].includes(keyBuf.length)) { + return fail('密钥必须为 16 / 24 / 32 字节(UTF-8)') + } + const ivBuf = Buffer.from(String(iv), 'utf-8') + if (ivBuf.length !== 16) { + return fail('IV 必须为 16 字节(UTF-8)') + } + if (mode !== 'CBC' && mode !== 'GCM') { + return fail('模式仅支持 CBC 或 GCM') + } + return { keyBuf, ivBuf, mode } +} + +function encrypt({ key, iv, mode, plaintext }) { + return tryCrypt(() => { + const v = validate({ key, iv, mode }) + if (v.ok === false) return v + const cipher = crypto.createCipheriv(`aes-${v.keyBuf.length * 8}-${mode.toLowerCase()}`, v.keyBuf, v.ivBuf) + const ct = Buffer.concat([cipher.update(String(plaintext), 'utf-8'), cipher.final()]) + if (mode === 'GCM') { + const tag = cipher.getAuthTag() + return ok(Buffer.concat([ct, tag]).toString('base64')) + } + return ok(ct.toString('base64')) + }) +} + +function decrypt({ key, iv, mode, ciphertext }) { + return tryCrypt(() => { + const v = validate({ key, iv, mode }) + if (v.ok === false) return v + const raw = Buffer.from(String(ciphertext), 'base64') + try { + if (mode === 'GCM') { + if (raw.length < 17) return fail('密文过短或已被破坏') + const tag = raw.subarray(raw.length - 16) + const ct = raw.subarray(0, raw.length - 16) + const decipher = crypto.createDecipheriv('aes-256-gcm'.replace('256', String(v.keyBuf.length * 8)), v.keyBuf, v.ivBuf) + decipher.setAuthTag(tag) + return ok(Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf-8')) + } + const decipher = crypto.createDecipheriv( + `aes-${v.keyBuf.length * 8}-cbc`, + v.keyBuf, + v.ivBuf + ) + return ok(Buffer.concat([decipher.update(raw), decipher.final()]).toString('utf-8')) + } catch (e) { + return fail('解密失败:密钥/IV 不正确或数据已损坏') + } + }) +} + +module.exports = { encrypt, decrypt } +``` + +Note: `validate` returns `fail(...)` which is `{ok:false}`; success path returns `{keyBuf,ivBuf,mode}` without `ok` — check `v.ok === false` only. + +Update `public/preload/crypt/index.js` to include `aes: require('./aes')`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run public/preload/crypt/aes.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/preload/crypt/ +git commit -m "feat(preload): aes cbc gcm encrypt decrypt" +``` + +--- + +### Task 5: RSA encrypt/decrypt/keygen + +**Files:** +- Create: `public/preload/crypt/rsa.js` +- Create: `public/preload/crypt/rsa.test.ts` +- Modify: `public/preload/crypt/index.js` + +**Interfaces:** +- Produces: `rsa.encrypt({ publicKey, plaintext })`, `rsa.decrypt({ privateKey, ciphertext })`, `rsa.generateKeyPair()` → `CryptResult` + - Keys: PEM PKCS#8 private / PKCS#1 or SPKI public strings + - ciphertext: Base64 + +- [ ] **Step 1: Write failing RSA tests** + +`public/preload/crypt/rsa.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { rsa } from './index.js' + +describe('rsa', () => { + it('roundtrips with generated keys', () => { + const pair = rsa.generateKeyPair() + expect(pair.ok).toBe(true) + if (!pair.ok) return + const { publicKey, privateKey } = JSON.parse(pair.data) + const enc = rsa.encrypt({ publicKey, plaintext: 'Hello RSA' }) + expect(enc.ok).toBe(true) + if (!enc.ok) return + const dec = rsa.decrypt({ privateKey, ciphertext: enc.data }) + expect(dec).toEqual({ ok: true, data: 'Hello RSA' }) + }) + + it('fails on non-PEM public key', () => { + const enc = rsa.encrypt({ publicKey: 'not-a-key', plaintext: 'x' }) + expect(enc.ok).toBe(false) + if (enc.ok) return + expect(enc.error).toMatch(/公钥/) + }) + + it('fails decrypt with wrong key', () => { + const a = rsa.generateKeyPair() + const b = rsa.generateKeyPair() + if (!a.ok || !b.ok) throw new Error('keygen failed') + const pa = JSON.parse(a.data) + const pb = JSON.parse(b.data) + const enc = rsa.encrypt({ publicKey: pa.publicKey, plaintext: 'secret' }) + if (!enc.ok) throw new Error('encrypt failed') + const dec = rsa.decrypt({ privateKey: pb.privateKey, ciphertext: enc.data }) + expect(dec.ok).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run public/preload/crypt/rsa.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement RSA + export** + +`public/preload/crypt/rsa.js`: + +```js +const crypto = require('node:crypto') +const { ok, fail, tryCrypt } = require('./envelope') + +function encrypt({ publicKey, plaintext }) { + return tryCrypt(() => { + let key + try { + key = crypto.createPublicKey(String(publicKey)) + } catch (e) { + return fail('公钥格式无效(需 PEM 格式)') + } + const ct = crypto.publicEncrypt( + { key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING }, + Buffer.from(String(plaintext), 'utf-8') + ) + return ok(ct.toString('base64')) + }) +} + +function decrypt({ privateKey, ciphertext }) { + return tryCrypt(() => { + let key + try { + key = crypto.createPrivateKey(String(privateKey)) + } catch (e) { + return fail('私钥格式无效(需 PEM 格式)') + } + try { + const pt = crypto.privateDecrypt( + { key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING }, + Buffer.from(String(ciphertext), 'base64') + ) + return ok(pt.toString('utf-8')) + } catch (e) { + return fail('解密失败:私钥不匹配或密文已损坏') + } + }) +} + +function generateKeyPair() { + return tryCrypt(() => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }) + return ok(JSON.stringify({ publicKey, privateKey })) + }) +} + +module.exports = { encrypt, decrypt, generateKeyPair } +``` + +Update `public/preload/crypt/index.js` to include `rsa: require('./rsa')`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run public/preload/crypt/rsa.test.ts` +Expected: PASS (keygen tests may take ~1s) + +- [ ] **Step 5: Commit** + +```bash +git add public/preload/crypt/ +git commit -m "feat(preload): rsa oaep encrypt decrypt keygen" +``` + +--- + +### Task 6: HMAC + PBKDF2 + services wiring + env types + +**Files:** +- Create: `public/preload/crypt/hmac.js` +- Create: `public/preload/crypt/pbkdf2.js` +- Create: `public/preload/crypt/hmac.test.ts` +- Create: `public/preload/crypt/pbkdf2.test.ts` +- Modify: `public/preload/crypt/index.js` +- Modify: `public/preload/services.js` +- Modify: `src/env.d.ts` + +**Interfaces:** +- Produces: `hmac.digest({ key, message, algorithm })` (algorithm `'sha256' | 'sha512'`), `pbkdf2.derive({ password, salt, iterations, keyLength, digest })` → hex `CryptResult` +- `window.services.crypt` = full crypt namespace; `window.services` retains existing file helpers until Task 14 removes unused ones +- `Services` interface in `env.d.ts` includes `crypt: CryptNamespace` + +- [ ] **Step 1: Write failing HMAC + PBKDF2 tests** + +`public/preload/crypt/hmac.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { hmac } from './index.js' + +describe('hmac', () => { + it('sha256 known vector (RFC 4231 case 1 truncated check)', () => { + const r = hmac.digest({ + key: Buffer.from('0b'.repeat(20), 'hex').toString('binary'), + message: 'Hi There', + algorithm: 'sha256' + }) + // Use utf-8 key of 20 bytes of 0x0b: + const r2 = hmac.digest({ + key: String.fromCharCode(...Array(20).fill(0x0b)), + message: 'Hi There', + algorithm: 'sha256' + }) + expect(r2).toEqual({ + ok: true, + data: 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7' + }) + expect(r.ok || !r.ok).toBe(true) + }) + + it('rejects empty key', () => { + const r = hmac.digest({ key: '', message: 'x', algorithm: 'sha256' }) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.error).toMatch(/密钥/) + }) +}) +``` + +`public/preload/crypt/pbkdf2.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { pbkdf2 } from './index.js' + +describe('pbkdf2', () => { + it('derives stable hex key', () => { + const a = pbkdf2.derive({ + password: 'password', + salt: 'salt', + iterations: 1000, + keyLength: 16, + digest: 'sha256' + }) + const b = pbkdf2.derive({ + password: 'password', + salt: 'salt', + iterations: 1000, + keyLength: 16, + digest: 'sha256' + }) + expect(a.ok).toBe(true) + expect(a).toEqual(b) + if (!a.ok) return + expect(a.data).toMatch(/^[0-9a-f]{32}$/) + }) + + it('rejects non-positive iterations', () => { + const r = pbkdf2.derive({ + password: 'p', + salt: 's', + iterations: 0, + keyLength: 16, + digest: 'sha256' + }) + expect(r.ok).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run public/preload/crypt/hmac.test.ts public/preload/crypt/pbkdf2.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement hmac, pbkdf2, index, services, env.d.ts** + +`public/preload/crypt/hmac.js`: + +```js +const crypto = require('node:crypto') +const { ok, fail, tryCrypt } = require('./envelope') + +function digest({ key, message, algorithm }) { + return tryCrypt(() => { + if (!key) return fail('HMAC 密钥不能为空') + const algo = algorithm === 'sha512' ? 'sha512' : 'sha256' + return ok( + crypto.createHmac(algo, String(key)).update(String(message), 'utf-8').digest('hex') + ) + }) +} + +module.exports = { digest } +``` + +`public/preload/crypt/pbkdf2.js`: + +```js +const crypto = require('node:crypto') +const { ok, fail, tryCrypt } = require('./envelope') + +function derive({ password, salt, iterations, keyLength, digest }) { + return tryCrypt(() => { + const iter = Number(iterations) + const len = Number(keyLength) + if (!Number.isFinite(iter) || iter < 1) return fail('迭代次数必须为正整数') + if (!Number.isFinite(len) || len < 1 || len > 1024) { + return fail('派生密钥长度必须为 1–1024 字节') + } + const algo = ['sha1', 'sha256', 'sha512'].includes(digest) ? digest : 'sha256' + const key = crypto.pbkdf2Sync( + String(password), + String(salt), + iter, + len, + algo + ) + return ok(key.toString('hex')) + }) +} + +module.exports = { derive } +``` + +Update `public/preload/crypt/index.js`: + +```js +const encoding = require('./encoding') +const hash = require('./hash') +const aes = require('./aes') +const rsa = require('./rsa') +const hmac = require('./hmac') +const pbkdf2 = require('./pbkdf2') + +module.exports = { + base64: encoding.base64, + hex: encoding.hex, + url: encoding.url, + md5: hash.md5, + sha256: hash.sha256, + aes, + rsa, + hmac, + pbkdf2 +} +``` + +`public/preload/services.js` — add crypt require and attach (keep existing file APIs): + +```js +const fs = require('node:fs') +const path = require('node:path') +const crypt = require('./crypt') + +window.services = { + crypt, + readFile(file) { /* unchanged */ }, + writeTextFile(text) { /* unchanged */ }, + writeImageFile(base64Url) { /* unchanged */ } +} +``` + +Copy existing function bodies unchanged. + +`src/env.d.ts`: + +```ts +/// +/// + +export type CryptResult = { ok: true; data: string } | { ok: false; error: string } + +export interface CryptNamespace { + base64: { encode: (input: string) => CryptResult; decode: (input: string) => CryptResult } + hex: { encode: (input: string) => CryptResult; decode: (input: string) => CryptResult } + url: { encode: (input: string) => CryptResult; decode: (input: string) => CryptResult } + md5: { digest: (input: string) => CryptResult } + sha256: { digest: (input: string) => CryptResult } + aes: { + encrypt: (p: { key: string; iv: string; mode: string; plaintext: string }) => CryptResult + decrypt: (p: { key: string; iv: string; mode: string; ciphertext: string }) => CryptResult + } + rsa: { + encrypt: (p: { publicKey: string; plaintext: string }) => CryptResult + decrypt: (p: { privateKey: string; ciphertext: string }) => CryptResult + generateKeyPair: () => CryptResult + } + hmac: { + digest: (p: { key: string; message: string; algorithm: string }) => CryptResult + } + pbkdf2: { + derive: (p: { + password: string + salt: string + iterations: number + keyLength: number + digest: string + }) => CryptResult + } +} + +interface Services { + crypt: CryptNamespace + readFile: (file: string) => string + writeTextFile: (text: string) => string + writeImageFile: (base64Url: string) => string | undefined +} + +declare global { + interface Window { + services: Services + } +} + +export {} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run public/preload/crypt/` +Expected: PASS (all crypt tests) + +- [ ] **Step 5: Commit** + +```bash +git add public/preload/ src/env.d.ts +git commit -m "feat(preload): hmac pbkdf2 wire services.crypt and types" +``` + +--- + +### Task 7: config settings + feature sync + +**Files:** +- Create: `src/config/settings.ts` +- Create: `src/config/features.ts` +- Create: `src/config/index.ts` +- Create: `src/config/settings.test.ts` +- Create: `src/config/features.test.ts` + +**Interfaces:** +- Consumes: `algorithms` / `AlgorithmMeta` from registry (stub import — create minimal `src/registry/algorithms.ts` in this task if not present: empty array exportable, filled in Tasks 10–13). Prefer: define pure functions that take `AlgorithmModule[]` as argument so tests do not need full registry. +- Produces: + - `SETTINGS_KEY = 'crypt-tool:settings'` + - `interface Settings { enabled: Record; prefs: { defaultOutputEncoding: 'base64' | 'hex' | 'text' } }` + - `defaultSettings(modules: AlgorithmModule[]): Settings` + - `loadSettings(modules: AlgorithmModule[]): Settings` + - `saveSettings(s: Settings): void` + - `setEnabled(id: string, on: boolean, modules: AlgorithmModule[]): Settings` (load-modify-save) + - `buildFeature(meta: AlgorithmMeta): FeatureInput` + - `syncFeatures(modules: AlgorithmModule[], enabled: Record): void` + +- [ ] **Step 1: Write failing settings tests** + +`src/config/settings.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AlgorithmModule } from '../registry/types' +import { defaultSettings, loadSettings, saveSettings, setEnabled, SETTINGS_KEY } from './settings' + +const mods = [ + { + meta: { + id: 'aes', + category: 'symmetric', + label: 'AES', + title: 'AES 对称加密', + reversible: true, + cmds: ['aes加密'], + defaultEnabled: true, + teach: { summary: 's' } + }, + Component: () => null + }, + { + meta: { + id: 'rsa', + category: 'asymmetric', + label: 'RSA', + title: 'RSA 非对称加密', + reversible: true, + cmds: ['rsa加密'], + defaultEnabled: false, + teach: { summary: 's' } + }, + Component: () => null + } +] as AlgorithmModule[] + +describe('settings', () => { + beforeEach(() => { + const store = new Map() + vi.stubGlobal('window', { + ztools: { + dbStorage: { + getItem: (k: string) => (store.has(k) ? JSON.parse(store.get(k)!) : null), + setItem: (k: string, v: unknown) => { + store.set(k, JSON.stringify(v)) + }, + removeItem: (k: string) => { + store.delete(k) + } + } + } + }) + }) + + it('defaults from module defaultEnabled', () => { + const s = defaultSettings(mods) + expect(s.enabled).toEqual({ aes: true, rsa: false }) + }) + + it('loads saved settings and ignores unknown ids', () => { + saveSettings({ + enabled: { aes: false, unknownId: true }, + prefs: { defaultOutputEncoding: 'base64' } + }) + const s = loadSettings(mods) + expect(s.enabled.aes).toBe(false) + expect(s.enabled.unknownId).toBeUndefined() + expect(s.enabled.rsa).toBe(false) + }) + + it('setEnabled persists', () => { + const s = setEnabled('aes', false, mods) + expect(s.enabled.aes).toBe(false) + expect(loadSettings(mods).enabled.aes).toBe(false) + }) + + it('uses SETTINGS_KEY', () => { + expect(SETTINGS_KEY).toBe('crypt-tool:settings') + }) +}) +``` + +`src/config/features.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest' +import type { AlgorithmModule } from '../registry/types' +import { buildFeature, syncFeatures } from './features' + +const meta = { + id: 'aes', + category: 'symmetric', + label: 'AES', + title: 'AES 对称加密', + reversible: true, + cmds: ['aes加密', 'AES'], + defaultEnabled: true, + teach: { summary: 's' } +} + +const mods = [{ meta, Component: () => null }] as AlgorithmModule[] + +describe('features', () => { + it('buildFeature shape', () => { + expect(buildFeature(meta)).toEqual({ + code: 'alg:aes', + explain: 'AES 对称加密', + icon: 'logo.png', + cmds: ['aes加密', 'AES'] + }) + }) + + it('syncFeatures set/remove targets', () => { + const setFeature = vi.fn() + const removeFeature = vi.fn() + vi.stubGlobal('window', { ztools: { setFeature, removeFeature } }) + + syncFeatures(mods, { aes: true }) + expect(setFeature).toHaveBeenCalledWith(buildFeature(meta)) + expect(removeFeature).not.toHaveBeenCalled() + + setFeature.mockClear() + syncFeatures(mods, { aes: false }) + expect(removeFeature).toHaveBeenCalledWith('alg:aes') + expect(setFeature).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('throws are caught by syncFeatures', () => { + const setFeature = vi.fn(() => { + throw new Error('host error') + }) + const removeFeature = vi.fn(() => { + throw new Error('host error') + }) + vi.stubGlobal('window', { ztools: { setFeature, removeFeature } }) + expect(() => syncFeatures(mods, { aes: true })).not.toThrow() + vi.unstubAllGlobals() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/config` +Expected: FAIL + +- [ ] **Step 3: Implement config modules** + +`src/config/settings.ts`: + +```ts +import type { AlgorithmModule } from '../registry/types' + +export const SETTINGS_KEY = 'crypt-tool:settings' + +export interface Settings { + enabled: Record + prefs: { defaultOutputEncoding: 'base64' | 'hex' | 'text' } +} + +export function defaultSettings(modules: AlgorithmModule[]): Settings { + const enabled: Record = {} + for (const m of modules) enabled[m.meta.id] = m.meta.defaultEnabled + return { enabled, prefs: { defaultOutputEncoding: 'base64' } } +} + +export function loadSettings(modules: AlgorithmModule[]): Settings { + const defaults = defaultSettings(modules) + try { + const raw = window.ztools.dbStorage.getItem(SETTINGS_KEY) + if (!raw || typeof raw !== 'object') return defaults + const saved = raw as Partial + const enabled: Record = { ...defaults.enabled } + if (saved.enabled && typeof saved.enabled === 'object') { + for (const m of modules) { + const v = (saved.enabled as Record)[m.meta.id] + if (typeof v === 'boolean') enabled[m.meta.id] = v + } + } + const prefs = { + defaultOutputEncoding: + saved.prefs?.defaultOutputEncoding === 'hex' || + saved.prefs?.defaultOutputEncoding === 'text' + ? saved.prefs.defaultOutputEncoding + : defaults.prefs.defaultOutputEncoding + } + return { enabled, prefs } + } catch { + return defaults + } +} + +export function saveSettings(s: Settings): void { + window.ztools.dbStorage.setItem(SETTINGS_KEY, s) +} + +export function setEnabled( + id: string, + on: boolean, + modules: AlgorithmModule[] +): Settings { + const s = loadSettings(modules) + s.enabled[id] = on + saveSettings(s) + return s +} +``` + +`src/config/features.ts`: + +```ts +import type { AlgorithmMeta, AlgorithmModule } from '../registry/types' + +export interface FeatureInput { + code: string + explain: string + icon: string + cmds: string[] +} + +export function buildFeature(meta: AlgorithmMeta): FeatureInput { + return { + code: `alg:${meta.id}`, + explain: meta.title, + icon: 'logo.png', + cmds: [...meta.cmds] + } +} + +export function syncFeatures( + modules: AlgorithmModule[], + enabled: Record +): void { + for (const m of modules) { + try { + if (enabled[m.meta.id]) { + window.ztools.setFeature(buildFeature(m.meta)) + } else { + window.ztools.removeFeature(`alg:${m.meta.id}`) + } + } catch { + // host feature API unavailable — non-fatal + } + } +} +``` + +`src/config/index.ts`: + +```ts +export * from './settings' +export * from './features' +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/config` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/config/ +git commit -m "feat(config): dbStorage settings and dynamic feature sync" +``` + +--- + +### Task 8: Shared UI components + shell CSS tokens + +**Files:** +- Create: `src/shared/Field.tsx` +- Create: `src/shared/TeachCard.tsx` +- Create: `src/shared/Actions.tsx` +- Create: `src/shared/CopyButton.tsx` +- Create: `src/shared/index.ts` +- Create: `src/shared/crypt-shell.css` + +**Interfaces:** +- Produces: `Field`, `TeachCard`, `Actions`, `CopyButton` as specified in spec §4.2 / §5.1 + +- [ ] **Step 1: Implement components** + +`src/shared/Field.tsx`: + +```tsx +import type { CSSProperties } from 'react' + +export type FieldType = 'text' | 'secret' | 'textarea' | 'select' + +export interface FieldProps { + label: string + hint?: string + help?: string + type?: FieldType + value: string + onChange: (v: string) => void + options?: { value: string; label: string }[] + error?: string + readOnly?: boolean + rows?: number + placeholder?: string + style?: CSSProperties +} + +export default function Field({ + label, + hint, + help, + type = 'text', + value, + onChange, + options, + error, + readOnly, + rows = 3, + placeholder, + style +}: FieldProps) { + const id = `f-${label.replace(/\s+/g, '-')}` + return ( +
+ + {type === 'textarea' ? ( +