nx-sk 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -2
  3. package/assets/nx-sk/SKILL.md +0 -1
  4. package/assets/nx-sk/references/00-design.md +1 -1
  5. package/assets/nx-sk/references/10-sections-entries.md +1 -1
  6. package/assets/nx-sk/references/20-secrets.md +37 -0
  7. package/assets/nx-sk/references/30-export-backup.md +16 -0
  8. package/package.json +1 -1
  9. package/src/core/argsafe.js +53 -0
  10. package/src/modules/backup/index.js +41 -0
  11. package/src/modules/backup/service.js +188 -0
  12. package/src/modules/backup/view.jsx +145 -0
  13. package/src/modules/entries/index.js +3 -0
  14. package/src/runtime/cli.js +56 -1
  15. package/src/runtime/registry.js +2 -1
  16. package/src/web/frontend/App.jsx +1 -1
  17. package/src/web/frontend/index.html +4 -0
  18. package/src/web/frontend/public/favicon-16.png +0 -0
  19. package/src/web/frontend/public/favicon-32.png +0 -0
  20. package/src/web/frontend/public/favicon-48.png +0 -0
  21. package/src/web/frontend/public/favicon.ico +0 -0
  22. package/src/web/frontend/public/logo-rounded.png +0 -0
  23. package/src/web/frontend/public/logo.png +0 -0
  24. package/src/web/frontend/registry.js +1 -0
  25. package/src/web/frontend/styles.css +2 -1
  26. package/src/web/public/assets/{index-BnVuH1lC.js → index-Bbe3iqam.js} +7 -7
  27. package/src/web/public/assets/index-C1bxqdjl.css +1 -0
  28. package/src/web/public/assets/{view-DsyplQBh.js → view-Bnwx5y_O.js} +1 -1
  29. package/src/web/public/assets/{view-rvADq3vX.js → view-CekTHUPO.js} +1 -1
  30. package/src/web/public/assets/{view-BvyRxTgX.js → view-Djo-TKw9.js} +1 -1
  31. package/src/web/public/assets/{view-CDje4hqZ.js → view-DuKJm1oq.js} +1 -1
  32. package/src/web/public/assets/view-b8TAoyhU.js +3 -0
  33. package/src/web/public/favicon-16.png +0 -0
  34. package/src/web/public/favicon-32.png +0 -0
  35. package/src/web/public/favicon-48.png +0 -0
  36. package/src/web/public/favicon.ico +0 -0
  37. package/src/web/public/index.html +6 -2
  38. package/src/web/public/logo-rounded.png +0 -0
  39. package/src/web/public/logo.png +0 -0
  40. package/assets/nx-sk/references/40-extend.md +0 -75
  41. package/src/web/public/assets/index-CwF5hM2v.css +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.0] - 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - **口令对称加密的整库备份 / 恢复(导入导出)**:新增「加密备份」模块(CLI `backup export|import`,Web 同名面板,HTTP `POST /api/backup` 与 `POST /api/backup/restore`)。
12
+ - 导出:设置口令 → 整份数据(栏目 / 条目 / 设置)经 **scrypt 口令派生密钥 + AES-256-GCM** 加密为单个 `.nxskbak` 文件并在浏览器下载;文件内含随机盐,**口令不落盘**。
13
+ - 导入:选择备份文件并输入口令 → 解密校验后整份恢复;**恢复前自动给当前数据留安全快照**(可回滚)。
14
+ - 口令错误或文件被篡改:GCM 认证失败、明确报 BLOCKED,且不改动当前数据;两个动作均支持 `--dry-run`(只演练 / 只解密校验)。
15
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;复用 `core/crypto.js` 原语,与字段级密钥库(`core/vault.js`)相互独立。
16
+
17
+ ## [0.3.4] - 2026-09-26
18
+
19
+ ### Added
20
+
21
+ - **多行值的可靠通道**:`key set <名> -`(stdin)与 `key set <名> --data @文件.json`
22
+ (也接受 `-` 与 JSON 字面量)。对象含 `value` 键时取它,只含一个键时取那个键,
23
+ 多键且无 `value` 直接报错而不是猜。
24
+
25
+ ### Fixed
26
+
27
+ - **Git Bash 下多行参数不再被静默截断**(原始事故):转发器里 `exec node` 走的是
28
+ PATH 上的 node,而它可能是 Volta 之类的中转 shim,那一跳会把 `$'l1\nl2\nl3'`
29
+ 截成 `l1` 且**不报错**——一次性凭据就这么丢掉大半。改为直连本进程的
30
+ `process.execPath`,并带路径失效回退(node 升级换目录时退回 PATH,重跑
31
+ `link:local` 即恢复)。
32
+ - **恢复被删的 `references/40-extend.md`**:它与 SKILL.md 路由表里的条目被**同时**
33
+ 删掉,于是「路由表 ↔ 目录双向对账」两边一起变空、照样通过——这个 ref 就这样被
34
+ 删了两次。新增断言钉住「ref 体系整体不被削掉」(必存 ref 下限 + 编号升序唯一),
35
+ 已做反向验证(删文件时确实变红)。
36
+
37
+ ### Notes
38
+
39
+ - **`.cmd` 那条路修不了**:实测直连真实 `node.exe` 与走 shim 结果**完全相同**
40
+ (多行都截断、尖括号都失败),瓶颈是 cmd.exe 解析 `.cmd` 内容时的 `%*` 展开。
41
+ cmd / PowerShell 下多行值必须走 `-` 或 `@文件`。
42
+ - 原计划要在 CLI 里做「检测到含换行就拒绝」,实测后**放弃**:损坏后 CR 是被删掉而非
43
+ 保留(`$'a\rb'` 实际落盘 `"ab"`),零真阳性;而 `@文件` 读入的合法 CRLF 多行值含
44
+ CR,会被自己的检测拦住(高假阳性)。LF 截断后与合法单行值不可区分——可靠信号不存在。
45
+
7
46
  ## [0.3.3] - 2026-09-26
8
47
 
9
48
  ### Fixed
package/README.md CHANGED
@@ -117,6 +117,8 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
117
117
  | `nx-sk entry fields [--section job]` | 字段字典 —— 填之前先看这个,别猜字段名 |
118
118
  | `nx-sk export run [--format json\|md\|both] [--section id] [--with-secrets] [--out DIR] [--dry-run]` | 整体导出 |
119
119
  | `nx-sk export list` | 历次导出与目录内文件 |
120
+ | `nx-sk backup export [--password 口令] [--out DIR] [--dry-run]` | 口令对称加密整库,导出单个 `.nxskbak`(口令不落盘) |
121
+ | `nx-sk backup import --file <备份> [--password 口令] [--dry-run]` | 输入口令解密、整份恢复(恢复前自动留安全快照) |
120
122
  | `nx-sk setting get \| set` | 全局设置 |
121
123
  | `nx-sk skill list \| install \| get` | 把内置 skill 装给 agent / 给外部 agent 取上下文 |
122
124
 
@@ -161,6 +163,18 @@ nx-sk export run --format both --out D:/备份/nx-sk
161
163
  nx-sk export list
162
164
  ```
163
165
 
166
+ 加密备份 / 恢复(口令对称加密,AES-256-GCM;导出设口令、恢复输口令):
167
+
168
+ ```bash
169
+ nx-sk backup export --password '你的备份口令' # 写成 ~/nx-sk/backup/*.nxskbak
170
+ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份口令' --dry-run # 先只解密校验
171
+ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份口令' # 整份恢复
172
+ # 口令也可用环境变量 NX_SK_BACKUP_PASSWORD 提供
173
+ ```
174
+
175
+ Web 面板的「加密备份」页同样支持:导出在浏览器下载 `.nxskbak`,导入选择文件并输入口令;
176
+ 口令错误或文件损坏会明确报错、不改动当前数据。
177
+
164
178
  ## 数据与安全
165
179
 
166
180
  | 项 | 位置 / 做法 |
@@ -170,6 +184,7 @@ nx-sk export list
170
184
  | 密文密钥 | 环境变量 `NX_SK_PASSPHRASE`(scrypt 派生 + 盐)优先,否则 `~/nx-sk/.vaultkey`(32 字节随机,0600) |
171
185
  | 快照 | 每次破坏性写入前整库备份到 `~/nx-sk/backup/`(最多 20 份),拷回即回滚 |
172
186
  | 导出 | `~/nx-sk/export/`,默认不含凭据明文 |
187
+ | 加密备份 | `.nxskbak`:整库经 scrypt(口令)+AES-256-GCM 加密,口令不落盘;恢复前自动留安全快照 |
173
188
  | 服务绑定 | 默认 `127.0.0.1`,不暴露到局域网;写操作校验 `Origin` |
174
189
  | 数据损坏 | `store.json` 解析失败会先被改名为 `.corrupt-<时间戳>.json`,不会静默覆盖 |
175
190
 
@@ -197,7 +212,6 @@ nx-sk skill get nx-sk 10-sections-entries # 裸名 → references/10-sections-
197
212
  | `10-sections-entries` | 填/改信息的完整命令与 AI 补全流程 |
198
213
  | `20-secrets` | 密文机制、密钥来源、排查「解不开」 |
199
214
  | `30-export-backup` | 导出、快照与回滚 |
200
- | `40-extend` | 改这个项目本身:闭环落点表与静默失效点 |
201
215
 
202
216
  ## 开发
203
217
 
@@ -215,7 +229,9 @@ pnpm test # = lint + build + 冒烟(56 项断言) + 单测(40 项
215
229
  | `assets/nx-sk/` | 随包分发的 skill(SKILL.md + references/) |
216
230
  | `tests/unit/` · `tests/smoke.mjs` | 一致性断言 + 端到端冒烟 |
217
231
 
218
- **加一个功能域要碰的 12 处**(其中 3 处没有任何断言会替你发现)见 `assets/nx-sk/references/40-extend.md`。
232
+ **加一个功能域要碰的 12 处**(其中 3 处没有任何断言会替你发现)属于**开发规范、不属于随包分发的
233
+ agent 手册** —— 它写在创建本项目的脚手架 skill `server-cli-web-scaffold` 的
234
+ `references/A07-extension-loop.md`,`assets/nx-sk/` 里只放「怎么用 nx-sk」。
219
235
 
220
236
  ## License
221
237
 
@@ -73,7 +73,6 @@ nx-sk key remove OPENAI_KEY # 删除,写前自动留快照
73
73
  | `10-sections-entries` | **动手填/改信息时必读**——栏目与条目的完整命令、AI 补全个人信息的推荐流程、字段字典怎么扩、读命令与写命令的参数陷阱 |
74
74
  | `20-secrets` | 存/读大模型 key、密文机制、密钥来源(环境变量 vs 本机密钥文件)、什么时候会解不开 |
75
75
  | `30-export-backup` | 整体导出、只导出某个栏目、快照与回滚、明文密钥的取舍 |
76
- | `40-extend` | 要**改这个项目本身**(加一个栏目模板、加一个功能域)时——闭环落点表与静默失效点 |
77
76
 
78
77
  取 ref 全文(外部 agent 用这条,不必进终端翻文件):
79
78
 
@@ -112,5 +112,5 @@ CLI 侧这四个取值分别渲染成不同的可读提示,退出码只有「
112
112
  | 删条目/栏目不留快照 | 误删即不可逆;这是用户的**唯一一份**个人信息 | 所有破坏性写操作先 `snapshotStore()` 到 `~/nx-sk/backup/` |
113
113
  | store.json 解析失败就当空库继续 | 下一次写入把用户数据永久覆盖 | 先把损坏文件改名为 `store.json.corrupt-<时间>.json`,再降级返回空结构 |
114
114
  | 前端 import 了 `node:*` 或 `core/` | Vite 把 Node 侧代码打进浏览器包,dev 模式白屏 | lint 的 `files` 同时覆盖 `view.jsx`,禁掉 `node:*` / `core` / `runtime` / `service.js` |
115
- | 新增模块忘了往 eslint 互依禁列里补一行 | **静默**:新模块变成「谁都可以依赖」,且规则随模块数增加持续衰减 | 把「加模块必补禁列」写进 `40-extend` 的闭环表,并在禁列旁留注释说明漏补是静默的 |
115
+ | 新增模块忘了往 eslint 互依禁列里补一行 | **静默**:新模块变成「谁都可以依赖」,且规则随模块数增加持续衰减 | 在禁列旁留注释说明它是**逐模块枚举**的、漏补是静默的;加模块前先读 `eslint.config.js` |
116
116
  | 代理前缀 `/api` 与前端源码目录 `api/` 撞车 | 只在 dev 模式整页白屏,报错却是 404 / ECONNREFUSED,离根因很远 | vite 代理加 `bypass`,按「是不是前端资源」分流;并用测试钉住 `shouldServeLocally` |
@@ -31,7 +31,7 @@ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
31
31
  - `remove` 目标不存在时**报 `NOT_FOUND`**(不静默成功),就是为了让「打错一个字」当场暴露。
32
32
  - `dump` 是「快速获得一个栏目的全部信息」的正解:字段字典 + 全部条目 + 每个条目的完整度与**未填清单**。
33
33
 
34
- 内置模板:`job`(求职 / 个人信息,约 70 字段 / 9 分组)、`secret`(密钥,**单字段 KV**——细节见 `20-secrets`)。
34
+ 内置模板 5 个:`job`(求职 / 个人信息,75 字段 / 9 分组)、`projects`(项目经历)、`internships`(实习/工作经历)、`campus`(校内职务/实践)、`secret`(密钥,**单字段 KV**——细节见 `20-secrets`)。三类经历栏目(projects / internships / campus)**一个项目 / 一段经历 = 一个条目**,角色、时间、技术栈都是独立字段——不要往 job 栏目里塞经历。
35
35
 
36
36
  ## 三、条目命令
37
37
 
@@ -91,6 +91,43 @@ nx-sk section list # 列表里 KV 栏目带 [KV 表]
91
91
  - **重复写同一个值不会重写密文**:返回 `{ status: 'skipped', unchanged: true }`。
92
92
  每次加密都用新 IV,无脑重写只会产生一堆无意义的新密文和新快照。
93
93
 
94
+ ### 多行值:走 stdin 或 `@文件`(真实事故)
95
+
96
+ Windows 上命令经启动器转发时,**命令行参数里的换行会被截断**,而命令**照样报成功**——
97
+ 一次性凭据(多行私钥、恢复码、证书)就这么静默丢掉后半截。同一条通道还会让含 `<` `>`
98
+ 的参数整串失败(被当成重定向符,报「系统找不到指定的路径」)。
99
+
100
+ 值的可靠通道有两条,任选:
101
+
102
+ ```bash
103
+ printf '%s' "$KEY" | nx-sk key set OPENAI_KEY - # stdin:值的位置参数写 -
104
+ nx-sk key set OPENAI_KEY --data @C:/Users/me/值.json # 文件:--data @路径(含 value 键)
105
+ ```
106
+
107
+ `--data` 也接受 `-`(从 stdin 读 JSON)与直接给 JSON 字面量;对象含 `value` 键时取它,
108
+ 只含一个键时取那个键,多键且无 `value` 直接报错(不猜)。
109
+
110
+ **路径必须用 Windows 形式**(`C:/Users/...`):Git Bash 的 `/tmp` 在 node 侧看不到
111
+ (实测 ENOENT),node 侧拿到的会是 `D:\tmp\...` 这种对不上的路径。
112
+
113
+ #### 哪些情况下命令行参数本身是安全的
114
+
115
+ 实测(Windows 11 · Volta · Git Bash)分两种情况,**别当成一回事**:
116
+
117
+ | 你在哪敲 | 命令行多行参数 | 说明 |
118
+ | --- | --- | --- |
119
+ | **Git Bash / WSL 终端** | ✅ **安全** | 0.3.4 起转发器直连真实 `node.exe`,绕开了会截断参数的那一跳 |
120
+ | **cmd.exe / PowerShell** | ❌ **损坏** | 瓶颈是 cmd 解析 `.cmd` 文件内容(`%*` 展开),换 node 也治不了 |
121
+
122
+ 所以:**cmd / PowerShell 下多行值一律走 `-` 或 `@文件`**,这是硬要求;
123
+ Git Bash 下裸参数已经能用,但脚本里要可移植的话,仍然推荐上面两条通道。
124
+
125
+ > 没有「检测到损坏就报错」这回事——**做不到**。损坏后的值里 CR 是被**删掉**而非保留
126
+ > (`$'a\rb'` 实际落盘 `"ab"`),所以「含 CR 即损坏」既抓不到真实损坏(零真阳性)、
127
+ > 又会拦住用 `@文件` 写入的**合法 CRLF 多行值**(高假阳性)。而 LF 截断后与一个合法的
128
+ > 单行值完全不可区分。可靠信号不存在,因此只能「提供可靠通道 + 修好通道本身」,
129
+ > 不能靠事后检测兜底。
130
+
94
131
  ## 三、密文机制
95
132
 
96
133
  - 算法 **AES-256-GCM**(`core/crypto.js`)。每个值独立 IV,带认证标签。
@@ -78,3 +78,19 @@ cp ~/nx-sk/backup/20260924-144238-entry-remove-e_xxx.json ~/nx-sk/store.json
78
78
  | 内容 | 结构化 + 可读的 Markdown;凭据默认含明文(`--no-secrets` 可打码) | 原样的整库 JSON(含密文) |
79
79
  | 位置 | `~/nx-sk/export/`(可改) | `~/nx-sk/backup/`(固定) |
80
80
  | 回滚用 | 不能直接回滚(形状不同) | 能,拷回 store.json 即可 |
81
+
82
+ ## 五、口令加密备份 / 恢复(导入导出)
83
+
84
+ 整份数据用**口令派生密钥 + AES-256-GCM** 对称加密为单个 `.nxskbak` 文件;口令不落盘。
85
+
86
+ ```bash
87
+ nx-sk backup export --password '备份口令' # → ~/nx-sk/backup/nx-sk-backup-<时间>.nxskbak
88
+ nx-sk backup export --password '备份口令' --out D:/备份 # 指定输出目录
89
+ nx-sk backup import --file <备份>.nxskbak --password '备份口令' --dry-run # 只解密校验,不改动
90
+ nx-sk backup import --file <备份>.nxskbak --password '备份口令' # 整份恢复
91
+ ```
92
+
93
+ - 导出时文件内写入**随机盐**,密钥由 `scrypt(口令, 盐)` 派生;恢复时用同一口令派生同一把密钥。
94
+ - **口令错误 / 文件被篡改**:GCM 认证失败、明确报 BLOCKED,且不改动当前数据。
95
+ - 恢复(整份替换)前会**自动给当前数据另留一份安全快照**(`…-before-restore.json`),可回滚。
96
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web「加密备份」页提供同样能力(浏览器下载 / 选择文件导入)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,53 @@
1
+ // CLI 参数安全:把「命令行传不了的东西」换成可传的形式。
2
+ //
3
+ // 为什么不放 runtime/spec.js:那里的 coerce() 是 CLI 与 HTTP **共用**的纯净解码器,
4
+ // 而 HTTP 没有「命令行通道」这回事。CLI 专属的通道问题集中在 CLI 适配层解决。
5
+ //
6
+ // 背景(真实事故,见 ref 20-secrets):Windows 上值经命令行参数传递时,启动器 shim
7
+ // (Volta shim / npm 生成的 .cmd)会把换行当命令分隔符截断、把 < > 当重定向符,
8
+ // 而命令**报成功**——一次性凭据就这么静默丢掉大半。文件与管道是可靠通道。
9
+ //
10
+ // 为什么不在这里做「检测损坏就报错」:实测做不到(详见 cli.js 的 expandSafeArgs)。
11
+ // 损坏后的值里 CR 是被**删掉**而非保留,所以「含 CR」既抓不到损坏(零真阳性)、
12
+ // 又会拦住用 --data 写入的合法 CRLF 多行值(高假阳性);LF 截断后则与合法单行值
13
+ // 完全不可区分。可靠的信号不存在,因此只能提供可靠通道 + 修好通道本身。
14
+ import { readFileSync } from 'node:fs';
15
+ import { badInput } from './errors.js';
16
+
17
+ /**
18
+ * `--data` 的值:JSON 字面量 / `@文件路径` / `-`(stdin)。
19
+ *
20
+ * takeStdin 由调用方注入——CLI 传 readFileSync(0),测试能换桩。
21
+ * 「读 stdin」因此不是一个无法测试的隐式依赖,也让本模块保持纯函数(不自己碰 fd 0)。
22
+ */
23
+ export function parseDataArg(raw, takeStdin) {
24
+ const s = String(raw ?? '').trim();
25
+ if (!s) throw badInput('--data 需要一个 JSON 字面量、@文件路径,或 -(从 stdin 读)');
26
+ if (s === '-' || s === '@-') return parseJson(takeStdin(), '-');
27
+ if (s.startsWith('@')) return parseJson(readText(s.slice(1)), s.slice(1));
28
+ return parseJson(s, '--data'); // 直接给的字面 JSON
29
+ }
30
+
31
+ /** 值本身走 stdin 的入口(`key set NAME -`)。true 表示「这个值来自 stdin」。 */
32
+ export function parseStdinValue(raw) {
33
+ return String(raw ?? '').trim() === '-';
34
+ }
35
+
36
+ function parseJson(text, src) {
37
+ try {
38
+ return JSON.parse(text);
39
+ } catch (e) {
40
+ throw badInput(`--data 不是合法 JSON(${src}): ${e.message}`);
41
+ }
42
+ }
43
+
44
+ function readText(p) {
45
+ try {
46
+ return readFileSync(p, 'utf8');
47
+ } catch (e) {
48
+ // 路径写法是最常见的坑:Git Bash 的 /tmp 在 node 侧看不到(真实事故 ENOENT),
49
+ // 所以错误里直接把 Windows 路径形式写出来,省掉一轮排查。
50
+ throw badInput(`读取文件失败: ${p}(${e.message})—— Windows 上请用 C:/Users/... 形式的路径`);
51
+ }
52
+ }
53
+
@@ -0,0 +1,41 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'backup',
5
+ title: '加密备份',
6
+ order: 750,
7
+ view: () => import('./view.jsx'),
8
+ actions: [
9
+ {
10
+ id: 'backup.export',
11
+ cli: ['backup', 'export'],
12
+ http: ['POST', '/api/backup'],
13
+ summary: '把整份数据用口令对称加密(AES-256-GCM)导出为单个备份文件;口令不落盘',
14
+ flags: {
15
+ password: { type: 'string', hint: '备份口令(也可用环境变量 NX_SK_BACKUP_PASSWORD),恢复时需再次输入' },
16
+ out: { type: 'string', hint: '输出目录,缺省 ~/nx-sk/backup' },
17
+ 'dry-run': { type: 'boolean', hint: '只演练:生成密文但不写文件、不下载' },
18
+ },
19
+ run: (ctx, meta) => (meta?.transport === 'http'
20
+ ? service.buildBackup(ctx)
21
+ : service.exportBackup(ctx)),
22
+ render: (d) => service.renderBackup(d),
23
+ },
24
+ {
25
+ id: 'backup.import',
26
+ cli: ['backup', 'import'],
27
+ http: ['POST', '/api/backup/restore'],
28
+ summary: '选择加密备份文件并输入口令,解密后整份恢复;恢复前自动给当前数据留安全快照',
29
+ flags: {
30
+ password: { type: 'string', hint: '导出时设置的口令(也可用环境变量 NX_SK_BACKUP_PASSWORD)' },
31
+ file: { type: 'string', hint: '备份文件路径(CLI 用)' },
32
+ content: { type: 'string', hint: '备份文件内容(Web 上传时自动带)' },
33
+ 'dry-run': { type: 'boolean', hint: '只解密校验,不替换数据、不留快照' },
34
+ },
35
+ run: (ctx) => (ctx.content !== undefined
36
+ ? service.restoreBackup({ content: ctx.content, password: ctx.password, 'dry-run': ctx['dry-run'] })
37
+ : service.importBackup(ctx)),
38
+ render: (d) => service.renderRestore(d),
39
+ },
40
+ ],
41
+ };
@@ -0,0 +1,188 @@
1
+ // 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
2
+ //
3
+ // 导出:用户设置口令 → 随机盐写进备份文件,密钥由 scrypt(口令, 盐) 派生(口令不落盘)。
4
+ // 导入:用户输入口令 → 用文件里的盐派生同一把密钥解密;口令错误 GCM 校验失败、明确报错。
5
+ //
6
+ // 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue),
7
+ // 与字段级密钥库(core/vault.js)相互独立:备份口令是一次性的,不写进 vault。
8
+ import fsp from 'node:fs/promises';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { resolve } from 'node:path';
11
+ import { displayPath, snapshotDir } from '../../core/paths.js';
12
+ import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
13
+ import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
14
+ import { deriveKey, encryptValue, decryptValue } from '../../core/crypto.js';
15
+ import { badInput, blocked } from '../../core/errors.js';
16
+ import { nowIso, stamp } from '../../core/ids.js';
17
+
18
+ export const BACKUP_KIND = 'encrypted-backup';
19
+ export const BACKUP_VERSION = 1;
20
+ export const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
21
+ const EXT = '.nxskbak';
22
+
23
+ function passwordFrom(ctx) {
24
+ const pw = ctx?.password ?? process.env[ENV_BACKUP_PASSWORD];
25
+ const s = String(pw ?? '');
26
+ if (!s) {
27
+ throw badInput(
28
+ `备份口令不能为空(用 --password,或设置环境变量 ${ENV_BACKUP_PASSWORD})。` +
29
+ '口令用于加密备份文件,导入时需要再次输入。',
30
+ );
31
+ }
32
+ return s;
33
+ }
34
+
35
+ /** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
36
+ export async function buildBackup(ctx) {
37
+ const password = passwordFrom(ctx);
38
+ const store = await loadStore();
39
+ const payload = {
40
+ version: store.version,
41
+ settings: store.settings,
42
+ sections: store.sections,
43
+ entries: store.entries,
44
+ exports: store.exports,
45
+ };
46
+
47
+ const salt = randomBytes(16);
48
+ const key = deriveKey(password, salt);
49
+ const data = encryptValue(key, JSON.stringify(payload));
50
+ const archive = {
51
+ app: 'nx-sk',
52
+ kind: BACKUP_KIND,
53
+ v: BACKUP_VERSION,
54
+ createdAt: nowIso(),
55
+ kdf: 'scrypt',
56
+ salt: salt.toString('base64'),
57
+ data,
58
+ };
59
+ const counts = { sections: store.sections.length, entries: store.entries.length };
60
+ if (ctx['dry-run']) return { dryRun: true, counts };
61
+ return { archive, counts };
62
+ }
63
+
64
+ /** CLI 导出:把加密备份写成文件(缺省放到 ~/nx-sk/backup)。 */
65
+ export async function exportBackup(ctx) {
66
+ const built = await buildBackup(ctx);
67
+ if (built.dryRun) return { status: 'skipped', dryRun: true, ...built.counts };
68
+ const { archive, counts } = built;
69
+ const dir = ctx.out ? resolve(ctx.out) : snapshotDir();
70
+ const name = `nx-sk-backup-${stamp()}${EXT}`;
71
+ await ensureDir(dir);
72
+ const file = resolve(dir, name);
73
+ await writeAtomic(file, JSON.stringify(archive, null, 2) + '\n');
74
+ return {
75
+ status: 'ok',
76
+ file: displayPath(file),
77
+ fileRaw: file,
78
+ bytes: await fileSize(file),
79
+ encrypted: true,
80
+ ...counts,
81
+ };
82
+ }
83
+
84
+ function parseArchive(text) {
85
+ let archive;
86
+ try {
87
+ archive = JSON.parse(text);
88
+ } catch (e) {
89
+ throw badInput(`备份文件不是合法 JSON(${e.message})——请确认选择的是 nx-sk 导出的 ${EXT} 文件`);
90
+ }
91
+ if (!archive || archive.app !== 'nx-sk' || archive.kind !== BACKUP_KIND || !archive.data) {
92
+ throw badInput(`不是 nx-sk 的加密备份文件(缺少 app/kind 标记)`);
93
+ }
94
+ if (archive.v !== BACKUP_VERSION) {
95
+ throw badInput(`备份文件版本不受支持: v${archive.v}`);
96
+ }
97
+ return archive;
98
+ }
99
+
100
+ /** 用口令解密归档文本 → 备份时的 store 负载。口令错误在此抛 BLOCKED。 */
101
+ export function decryptBackup(text, password) {
102
+ const archive = parseArchive(text);
103
+ if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
104
+ let payload;
105
+ try {
106
+ const key = deriveKey(password, archive.salt);
107
+ payload = JSON.parse(decryptValue(key, archive.data));
108
+ } catch (e) {
109
+ if (e && (e.code === 'BLOCKED' || /解密失败/.test(String(e.message)))) {
110
+ throw blocked('解密失败:口令不正确,或备份文件已损坏。请确认输入的是导出时设置的口令。');
111
+ }
112
+ throw e;
113
+ }
114
+ if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
115
+ throw badInput('备份内容缺少 sections / entries,无法恢复。');
116
+ }
117
+ return { payload, createdAt: archive.createdAt };
118
+ }
119
+
120
+ /** 恢复:解密 → 先给当前数据留安全快照 → 整份替换(写入会过 normalize 迁移到当前版本)。 */
121
+ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
122
+ const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
123
+
124
+ if (dryRun) {
125
+ return {
126
+ status: 'dry-run', dryRun: true,
127
+ backupCreatedAt: createdAt || '',
128
+ sections: payload.sections.length,
129
+ entries: payload.entries.length,
130
+ };
131
+ }
132
+
133
+ // 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
134
+ const safety = await snapshotStore('before-restore');
135
+
136
+ await saveStore({
137
+ version: payload.version,
138
+ settings: payload.settings,
139
+ sections: payload.sections,
140
+ entries: payload.entries,
141
+ exports: Array.isArray(payload.exports) ? payload.exports : [],
142
+ });
143
+
144
+ return {
145
+ status: 'restored',
146
+ backupCreatedAt: createdAt || '',
147
+ sections: payload.sections.length,
148
+ entries: payload.entries.length,
149
+ safetySnapshot: displayPath(safety),
150
+ };
151
+ }
152
+
153
+ /** CLI 导入:从文件读取归档文本后恢复。 */
154
+ export async function importBackup(ctx) {
155
+ const file = resolve(String(ctx.file ?? ''));
156
+ if (!ctx.file || !(await pathExists(file))) {
157
+ throw badInput(`找不到备份文件: ${ctx.file || '(未用 --file 指定)'}`);
158
+ }
159
+ const text = await fsp.readFile(file, 'utf8');
160
+ return restoreBackup({
161
+ content: text,
162
+ password: ctx.password ?? process.env[ENV_BACKUP_PASSWORD],
163
+ 'dry-run': ctx['dry-run'],
164
+ });
165
+ }
166
+
167
+ export function renderBackup(d) {
168
+ if (d.status === 'skipped') {
169
+ return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`;
170
+ }
171
+ return [
172
+ `已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
173
+ `文件: ${d.file} ${d.bytes} 字节`,
174
+ `内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
175
+ `恢复时需要输入本次设置的口令。`,
176
+ ].join('\n');
177
+ }
178
+
179
+ export function renderRestore(d) {
180
+ if (d.status === 'dry-run') {
181
+ return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目,生成于 ${d.backupCreatedAt || '未知'}`;
182
+ }
183
+ return [
184
+ `已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
185
+ `备份生成于: ${d.backupCreatedAt || '未知'}`,
186
+ `恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
187
+ ].join('\n');
188
+ }
@@ -0,0 +1,145 @@
1
+ import { useRef, useState } from 'react';
2
+ import { api } from '../../web/frontend/api/client.js';
3
+ import { useStore } from '../../web/frontend/store.jsx';
4
+ import { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
5
+
6
+ function fileStamp() {
7
+ const d = new Date();
8
+ const p = (n) => String(n).padStart(2, '0');
9
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
10
+ }
11
+
12
+ function downloadText(filename, text) {
13
+ const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
14
+ const url = URL.createObjectURL(blob);
15
+ const a = document.createElement('a');
16
+ a.href = url;
17
+ a.download = filename;
18
+ document.body.appendChild(a);
19
+ a.click();
20
+ a.remove();
21
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
22
+ }
23
+
24
+ export default function BackupView() {
25
+ const { toast, guard, dialog, reload } = useStore();
26
+ const [pw, setPw] = useState('');
27
+ const [pw2, setPw2] = useState('');
28
+ const [restorePw, setRestorePw] = useState('');
29
+ const [picked, setPicked] = useState(null);
30
+ const [result, setResult] = useState(null);
31
+ const fileRef = useRef(null);
32
+
33
+ const doExport = guard(async () => {
34
+ if (!pw) { toast('请先设置备份口令', 'bad'); return; }
35
+ if (pw !== pw2) { toast('两次输入的口令不一致', 'bad'); return; }
36
+ const r = await api('/api/backup', { method: 'POST', body: { password: pw } });
37
+ const filename = `nx-sk-backup-${fileStamp()}.nxskbak`;
38
+ downloadText(filename, JSON.stringify(r.archive, null, 2) + '\n');
39
+ toast(`已生成加密备份:${r.counts.sections} 栏目 / ${r.counts.entries} 条目`);
40
+ setPw(''); setPw2('');
41
+ });
42
+
43
+ const onPick = async (e) => {
44
+ const file = e.target.files?.[0];
45
+ if (!file) return;
46
+ const text = await file.text();
47
+ setPicked({ name: file.name, bytes: file.size, text });
48
+ };
49
+
50
+ const doRestore = guard(async () => {
51
+ if (!picked) { toast('请先选择备份文件', 'bad'); return; }
52
+ if (!restorePw) { toast('请输入备份口令', 'bad'); return; }
53
+ const ok = await dialog({
54
+ danger: true,
55
+ confirmText: '解密并整份恢复',
56
+ message: '恢复会用备份内容整份替换当前数据。',
57
+ detail: `备份文件:${picked.name}\n恢复前会自动给当前数据留一份安全快照(可回滚)。`,
58
+ });
59
+ if (!ok) return;
60
+ const r = await api('/api/backup/restore', {
61
+ method: 'POST',
62
+ body: { password: restorePw, content: picked.text },
63
+ });
64
+ setResult(r);
65
+ setRestorePw('');
66
+ setPicked(null);
67
+ if (fileRef.current) fileRef.current.value = '';
68
+ await reload();
69
+ });
70
+
71
+ return (
72
+ <>
73
+ <div className="toolbar rowgap pad">
74
+ <strong>加密备份</strong>
75
+ <span className="muted">整份数据用口令对称加密(AES-256-GCM);导出设口令、恢复输口令,口令不落盘</span>
76
+ </div>
77
+
78
+ <div className="card">
79
+ <div className="colhead">加密导出</div>
80
+ <div className="pad stack">
81
+ <div className="grid">
82
+ <div className="field">
83
+ <div className="flabel"><span>设置备份口令</span><span className="fkey">建议至少 6 位</span></div>
84
+ <div className="fctrl">
85
+ <input type="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="备份口令" />
86
+ </div>
87
+ </div>
88
+ <div className="field">
89
+ <div className="flabel"><span>再输入一次</span></div>
90
+ <div className="fctrl">
91
+ <input type="password" autoComplete="new-password" value={pw2} onChange={(e) => setPw2(e.target.value)} placeholder="确认口令" />
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div className="hint">导出为单个 .nxskbak 文件并在浏览器下载;文件内含随机盐,密钥由口令经 scrypt 派生。恢复时需要输入同一口令。</div>
96
+ <div className="rowgap">
97
+ <button className="btn" onClick={doExport}>加密导出并下载</button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div className="card">
103
+ <div className="colhead">解密恢复(导入)</div>
104
+ <div className="pad stack">
105
+ <div className="grid">
106
+ <div className="field">
107
+ <div className="flabel"><span>备份文件</span><span className="fkey">.nxskbak</span></div>
108
+ <div className="fctrl">
109
+ <input ref={fileRef} type="file" accept=".nxskbak,application/json" onChange={onPick} />
110
+ </div>
111
+ </div>
112
+ <div className="field">
113
+ <div className="flabel"><span>输入备份口令</span></div>
114
+ <div className="fctrl">
115
+ <input type="password" autoComplete="off" value={restorePw} onChange={(e) => setRestorePw(e.target.value)} placeholder="导出时设置的口令" />
116
+ </div>
117
+ </div>
118
+ </div>
119
+ {picked ? (
120
+ <div className="row">
121
+ <span className="mono name">{picked.name}</span>
122
+ <span className="acts"><span className="tag">{picked.bytes} 字节</span></span>
123
+ </div>
124
+ ) : <Empty>还没有选择备份文件。</Empty>}
125
+ <div className="hint">口令错误或文件损坏会明确报错、不会改动当前数据;确认恢复前会先留安全快照。</div>
126
+ <div className="rowgap">
127
+ <button className="btn danger" onClick={doRestore}>解密并恢复</button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <CliHints module="backup" />
133
+
134
+ {result ? (
135
+ <Modal title="恢复完成" onClose={() => setResult(null)}>
136
+ <div className="stack">
137
+ <div>已从加密备份恢复:{result.sections} 个栏目 / {result.entries} 条条目</div>
138
+ <div className="muted">备份生成于 {result.backupCreatedAt || '未知'}</div>
139
+ <pre className="block">恢复前的安全快照:{result.safetySnapshot}</pre>
140
+ </div>
141
+ </Modal>
142
+ ) : null}
143
+ </>
144
+ );
145
+ }
@@ -129,6 +129,9 @@ export default {
129
129
  args: ['name', 'value'],
130
130
  flags: {
131
131
  value: { type: 'string', hint: '值的另一种给法(值以 - 开头时用 --value=<值>)' },
132
+ // 多行值经命令行会被 Windows 启动器 shim 截断成首行且不报错(见 ref 20-secrets),
133
+ // 文件与 stdin 是可靠通道。取 value 键;没有则取对象里唯一那个键。
134
+ data: { type: 'string', hint: '@文件.json(含 value 键),或 -(从 stdin 读原文)' },
132
135
  section: { type: 'string' },
133
136
  'dry-run': { type: 'boolean' },
134
137
  },