nx-sk 0.6.1 → 0.6.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## [0.6.2] - 2026-10-12
6
+
7
+ ### Fixed
8
+
9
+ - **加密备份:跨机器恢复后 store.json 里 secret 字段仍是密文**。0.6.1 把 secret 字段解开成明文后直接落回 store.json——丢了本机「落盘密文」防护。回归到 0.5.1 的设计:导出时用本机 vaultKey **解开** secret 字段、整包口令加密入 `.nxskbak`;导入时解开整包后**用本机 vaultKey 重新加密** secret 字段再写回 store.json。两条安全边界各自独立:本地靠 vaultKey、传递靠口令。
10
+
5
11
  ## [0.6.1] - 2026-10-12
6
12
 
7
13
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,8 +39,8 @@ function passwordFrom(ctx) {
39
39
  }
40
40
 
41
41
  /**
42
- * 把 payload 里 secret 栏目的密文字段解开成明文(不修改其他字段)。
43
- * 用本机 vaultKey 解:A 机本地能解;导出文件不依赖 A 机后续状态。
42
+ * 导出时:用本机 vaultKey **解开** secret 栏目的密文字段。
43
+ * 导出文件不再依赖 A 机的 ~/.vaultkey / NX_SK_PASSPHRASE。
44
44
  */
45
45
  async function unsealSecretsForExport(payload) {
46
46
  const sections = payload.sections || [];
@@ -66,6 +66,38 @@ async function unsealSecretsForExport(payload) {
66
66
  return { ...payload, entries: newEntries, _unsealed: unsealed };
67
67
  }
68
68
 
69
+ /**
70
+ * 导入时:payload 里的 secret 字段已是明文,**用本机 vaultKey 重新加密**后落盘。
71
+ * 这样 store.json 里仍是密文(落盘防护不丢),B 机后续 key get 走 B 机 vaultKey 解密。
72
+ * 历史 store 里残留的明文 secret 也会被本步统一收紧成密文。
73
+ */
74
+ async function resealSecretsForImport(payload) {
75
+ const sections = payload.sections || [];
76
+ const entries = payload.entries || [];
77
+ const { key: vaultKey } = await resolveVaultKey();
78
+
79
+ const newEntries = [];
80
+ let resealed = 0;
81
+ for (const e of entries) {
82
+ const sec = sections.find((s) => s.id === e.section);
83
+ if (!sec || !e.values) { newEntries.push(e); continue; }
84
+ const nextValues = { ...e.values };
85
+ let touched = false;
86
+ for (const def of sec.fields || []) {
87
+ if (!isSensitiveField(sec, def.key)) continue;
88
+ const v = nextValues[def.key];
89
+ // 已是本机 vaultKey 加密形态就跳过;明文或旧 kid 都重加密
90
+ if (isCipherBlob(v) && !v.kid) continue;
91
+ const plain = isCipherBlob(v) ? decryptValue(vaultKey, v) : (typeof v === 'string' ? v : null);
92
+ if (plain === null) continue;
93
+ nextValues[def.key] = encryptValue(vaultKey, plain);
94
+ touched = true; resealed++;
95
+ }
96
+ newEntries.push(touched ? { ...e, values: nextValues } : e);
97
+ }
98
+ return { ...payload, entries: newEntries, _resealed: resealed };
99
+ }
100
+
69
101
  /** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
70
102
  export async function buildBackup(ctx) {
71
103
  const password = passwordFrom(ctx);
@@ -156,7 +188,7 @@ export function decryptBackup(text, password) {
156
188
  return { payload, createdAt: archive.createdAt };
157
189
  }
158
190
 
159
- /** 恢复:解密 → 留安全快照 → 整份替换(secret 字段已是明文,落盘后下次写入时由 B 机 vaultKey 重新加密)。 */
191
+ /** 恢复:解密整包 → 把 secret 明文用本机 vaultKey 重新加密 → 留安全快照 → 整份替换。 */
160
192
  export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
161
193
  const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
162
194
 
@@ -170,23 +202,26 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
170
202
  };
171
203
  }
172
204
 
205
+ // 把 payload 里的 secret 明文用**本机 vaultKey** 重新加密——落回 store.json 仍是密文形态。
206
+ const resealed = await resealSecretsForImport(payload);
207
+
173
208
  // 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
174
209
  const safety = await snapshotStore('before-restore');
175
210
 
176
211
  await saveStore({
177
- version: payload.version,
178
- settings: payload.settings,
179
- sections: payload.sections,
180
- entries: payload.entries,
181
- exports: Array.isArray(payload.exports) ? payload.exports : [],
212
+ version: resealed.version,
213
+ settings: resealed.settings,
214
+ sections: resealed.sections,
215
+ entries: resealed.entries,
216
+ exports: Array.isArray(resealed.exports) ? resealed.exports : [],
182
217
  });
183
218
 
184
219
  return {
185
220
  status: 'restored',
186
221
  backupCreatedAt: createdAt || '',
187
- sections: payload.sections.length,
188
- entries: payload.entries.length,
189
- unsealed: payload._unsealed || 0,
222
+ sections: resealed.sections.length,
223
+ entries: resealed.entries.length,
224
+ resealed: resealed._resealed || 0,
190
225
  safetySnapshot: displayPath(safety),
191
226
  };
192
227
  }
@@ -226,7 +261,7 @@ export function renderRestore(d) {
226
261
  }
227
262
  return [
228
263
  `已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
229
- `从备份中拿到的明文密钥字段: ${d.unsealed || 0} 个`,
264
+ `用本机密钥库重新加密的密钥字段: ${d.resealed || 0} 个`,
230
265
  `备份生成于: ${d.backupCreatedAt || '未知'}`,
231
266
  `恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
232
267
  ].join('\n');