nx-sk 0.6.0 → 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,18 @@
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
+
11
+ ## [0.6.1] - 2026-10-12
12
+
13
+ ### Changed
14
+
15
+ - **加密备份跨机器恢复:密钥栏目与其他栏目走同一路径**。`backup export --password` 导出时,先把 secret 栏目的密文字段用本机 vaultKey **解开为明文**,再随整份 payload 一起用口令派生密钥(AES-256-GCM)加密入 `.nxskbak`;导入时直接拿明文落回 store.json,B 机不需要 A 机的 `~/.vaultkey` 即可 `key get` 出原文。理由:密钥条目就是一段文本,"跨机器恢复要看"才是它的实际场景,再绕一层「reWrapped 兑回本机 vaultKey」是多余的间接层。落回 store.json 后,下次写入会由 B 机 vaultKey 重新加密(落盘仍是密文),所以单机"store.json 泄露不等于密钥泄露"这条不变量没退化。
16
+
5
17
  ## [0.6.0] - 2026-10-12
6
18
 
7
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,19 +1,14 @@
1
1
  // 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
2
2
  //
3
- // 导出:用户设置口令 → 随机盐写进备份文件,密钥由 scrypt(口令, 盐) 派生(口令不落盘)。
4
- // 导入:用户输入口令 → 用文件里的盐派生同一把密钥解密;口令错误 GCM 校验失败、明确报错。
3
+ // 导出:用户设置口令 → 先把 secret 栏目的密文字段用**本机 vaultKey 解开**(明文),
4
+ // 再把整份 payload 用口令派生 key 加密落到备份文件;口令不落盘。
5
+ // 导入:用户输入口令 → 解密后 payload 里的 secret 字段就是明文;直接写回 store.json。
5
6
  //
6
- // 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue),
7
- // 与字段级密钥库(core/vault.js)相互独立:备份口令是一次性的,不写进 vault。
7
+ // 这样跨机器恢复:B 机拿到口令后 secret 字段就是明文,**不再依赖 A 机的 vaultKey**。
8
+ // 这是 store.json 内的密文被解开的瞬态——落回磁盘后字段仍是 vaultKey 加密形态(每次
9
+ // `key set` 会重新用 B 机 vaultKey 加密),所以单机的"落盘密文"防护没退化。
8
10
  //
9
- // 跨机器恢复:store.json 里凭据原本是「本机 vault key(AES-256-GCM + kid)」加密的,
10
- // 备份打包会把这份密文**再裹一层**——用备份口令派生 key 重加密一份副本,写进
11
- // `reWrapped`。导入时先解开 reWrapped 拿明文,再用**目标机器**的 vault key 重新加密,
12
- // 写回主字段、丢掉 reWrapped。这样:
13
- // - 备份包在 A 机器上能独立解开(不依赖 A 的 ~/.vaultkey / NX_SK_PASSPHRASE)
14
- // - 导入 B 机器后,凭据绑的是 B 的本地密钥,跟其他密文一样走 decryptValue
15
- // - 没有「reWrapped 的密文只能输口令解」的尾巴——一次导入就兑回本机
16
- // 没有 reWrapped 的密文(早期版本/未带口令的导出)依然走 kid 校验;换机会得到明确 BLOCKED。
11
+ // 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue)。
17
12
  import fsp from 'node:fs/promises';
18
13
  import { randomBytes } from 'node:crypto';
19
14
  import { resolve } from 'node:path';
@@ -27,7 +22,7 @@ import { isSensitiveField } from '../../core/fields.js';
27
22
  import { resolveVaultKey } from '../../core/vault.js';
28
23
 
29
24
  export const BACKUP_KIND = 'encrypted-backup';
30
- export const BACKUP_VERSION = 1;
25
+ export const BACKUP_VERSION = 2;
31
26
  export const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
32
27
  const EXT = '.nxskbak';
33
28
 
@@ -44,19 +39,16 @@ function passwordFrom(ctx) {
44
39
  }
45
40
 
46
41
  /**
47
- * 给 payload 里的每条密文字段加一层「口令派生的重加密」。
48
- *
49
- * 输入是 buildBackup 准备好的 store payload;输出是**新对象**,原对象不变。
50
- * 已带 reWrapped 的不再二次包——避免循环。
42
+ * 导出时:用本机 vaultKey **解开** secret 栏目的密文字段。
43
+ * 导出文件不再依赖 A 机的 ~/.vaultkey / NX_SK_PASSPHRASE。
51
44
  */
52
- async function reWrapSecretsInPlace(payload, password, salt) {
45
+ async function unsealSecretsForExport(payload) {
53
46
  const sections = payload.sections || [];
54
47
  const entries = payload.entries || [];
55
- const transportKey = deriveKey(password, salt);
56
48
  const { key: vaultKey } = await resolveVaultKey();
57
49
 
58
50
  const newEntries = [];
59
- let reWrapped = 0;
51
+ let unsealed = 0;
60
52
  for (const e of entries) {
61
53
  const sec = sections.find((s) => s.id === e.section);
62
54
  if (!sec || !e.values) { newEntries.push(e); continue; }
@@ -65,30 +57,27 @@ async function reWrapSecretsInPlace(payload, password, salt) {
65
57
  for (const def of sec.fields || []) {
66
58
  if (!isSensitiveField(sec, def.key)) continue;
67
59
  const v = nextValues[def.key];
68
- if (!isCipherBlob(v) || v.reWrapped) continue;
69
- // 用本机 vault key 解开 → 用口令派生 key 重加密 → 挂到 reWrapped 上。
70
- // 原来的密文保留——A 机用原 vaultKey 仍能解(不动既有逻辑)。
71
- const plain = decryptValue(vaultKey, v);
72
- nextValues[def.key] = { ...v, reWrapped: encryptValue(transportKey, plain) };
73
- touched = true; reWrapped++;
60
+ if (!isCipherBlob(v)) continue;
61
+ nextValues[def.key] = decryptValue(vaultKey, v);
62
+ touched = true; unsealed++;
74
63
  }
75
64
  newEntries.push(touched ? { ...e, values: nextValues } : e);
76
65
  }
77
- return { ...payload, entries: newEntries, _reWrapped: reWrapped };
66
+ return { ...payload, entries: newEntries, _unsealed: unsealed };
78
67
  }
79
68
 
80
69
  /**
81
- * 反向:把 reWrapped 副本兑回本机 vault key。
82
- * 拿不到口令(密码错)会抛 BLOCKED;拿到口令则静默完成,用户不感知。
70
+ * 导入时:payload 里的 secret 字段已是明文,**用本机 vaultKey 重新加密**后落盘。
71
+ * 这样 store.json 里仍是密文(落盘防护不丢),B 机后续 key get 走 B 机 vaultKey 解密。
72
+ * 历史 store 里残留的明文 secret 也会被本步统一收紧成密文。
83
73
  */
84
- async function unwrapSecretsToLocal(payload, password, salt) {
74
+ async function resealSecretsForImport(payload) {
85
75
  const sections = payload.sections || [];
86
76
  const entries = payload.entries || [];
87
- const transportKey = deriveKey(password, salt);
88
77
  const { key: vaultKey } = await resolveVaultKey();
89
78
 
90
79
  const newEntries = [];
91
- let unwrapped = 0;
80
+ let resealed = 0;
92
81
  for (const e of entries) {
93
82
  const sec = sections.find((s) => s.id === e.section);
94
83
  if (!sec || !e.values) { newEntries.push(e); continue; }
@@ -97,19 +86,16 @@ async function unwrapSecretsToLocal(payload, password, salt) {
97
86
  for (const def of sec.fields || []) {
98
87
  if (!isSensitiveField(sec, def.key)) continue;
99
88
  const v = nextValues[def.key];
100
- if (!isCipherBlob(v) || !v.reWrapped) continue;
101
- const plain = decryptValue(transportKey, v.reWrapped);
102
- // tenant 用来保留 A 机原 blob 的非密文字段(如 v/alg)但**不能**包含 iv/ct/tag/kid——
103
- // 这些必须来自新的 encryptValue(用本机 vaultKey 生成的)。否则 spread 后会把
104
- // 新的 iv/ct/tag 覆盖回 A 机的旧值,写进 store.json 的密文就解不开了。
105
- const { reWrapped: _rw, iv: _oldIv, ct: _oldCt, tag: _oldTag, kid: _oldKid, ...tenant } = v;
106
- void _rw; void _oldIv; void _oldCt; void _oldTag; void _oldKid;
107
- nextValues[def.key] = { ...encryptValue(vaultKey, plain), ...tenant };
108
- touched = true; unwrapped++;
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++;
109
95
  }
110
96
  newEntries.push(touched ? { ...e, values: nextValues } : e);
111
97
  }
112
- return { ...payload, entries: newEntries, _reWrapped: unwrapped };
98
+ return { ...payload, entries: newEntries, _resealed: resealed };
113
99
  }
114
100
 
115
101
  /** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
@@ -126,7 +112,7 @@ export async function buildBackup(ctx) {
126
112
 
127
113
  const salt = randomBytes(16);
128
114
  const wrapKey = deriveKey(password, salt);
129
- const payload = await reWrapSecretsInPlace(raw, password, salt);
115
+ const payload = await unsealSecretsForExport(raw);
130
116
  const data = encryptValue(wrapKey, JSON.stringify(payload));
131
117
  const archive = {
132
118
  app: 'nx-sk',
@@ -140,7 +126,7 @@ export async function buildBackup(ctx) {
140
126
  const counts = {
141
127
  sections: store.sections.length,
142
128
  entries: store.entries.length,
143
- reWrapped: payload._reWrapped || 0,
129
+ unsealed: payload._unsealed || 0,
144
130
  };
145
131
  if (ctx['dry-run']) return { dryRun: true, counts };
146
132
  return { archive, counts };
@@ -182,7 +168,7 @@ function parseArchive(text) {
182
168
  return archive;
183
169
  }
184
170
 
185
- /** 用口令解密归档文本 → 备份时的 store 负载 + archive(带 salt)。口令错误在此抛 BLOCKED。 */
171
+ /** 用口令解密归档文本 → 备份时的 store 负载。口令错误在此抛 BLOCKED。 */
186
172
  export function decryptBackup(text, password) {
187
173
  const archive = parseArchive(text);
188
174
  if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
@@ -199,12 +185,12 @@ export function decryptBackup(text, password) {
199
185
  if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
200
186
  throw badInput('备份内容缺少 sections / entries,无法恢复。');
201
187
  }
202
- return { payload, createdAt: archive.createdAt, salt: archive.salt };
188
+ return { payload, createdAt: archive.createdAt };
203
189
  }
204
190
 
205
- /** 恢复:解密 → 把 reWrapped 兑回本机 vault key → 留安全快照 → 整份替换。 */
191
+ /** 恢复:解密整包 → 把 secret 明文用本机 vaultKey 重新加密 → 留安全快照 → 整份替换。 */
206
192
  export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
207
- const { payload, createdAt, salt } = decryptBackup(String(content ?? ''), password);
193
+ const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
208
194
 
209
195
  if (dryRun) {
210
196
  return {
@@ -212,31 +198,30 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
212
198
  backupCreatedAt: createdAt || '',
213
199
  sections: payload.sections.length,
214
200
  entries: payload.entries.length,
215
- reWrapped: payload._reWrapped || 0,
201
+ unsealed: payload._unsealed || 0,
216
202
  };
217
203
  }
218
204
 
219
- // 把 reWrapped 兑回本机 vault key:解口令派生密文 → 用本机 vaultKey 重加密。
220
- // 失败(口令对但 reWrapped 损坏)会被 unwrapSecretsToLocal 抛 BLOCKED。
221
- const unwrapped = await unwrapSecretsToLocal(payload, password, salt);
205
+ // 把 payload 里的 secret 明文用**本机 vaultKey** 重新加密——落回 store.json 仍是密文形态。
206
+ const resealed = await resealSecretsForImport(payload);
222
207
 
223
208
  // 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
224
209
  const safety = await snapshotStore('before-restore');
225
210
 
226
211
  await saveStore({
227
- version: unwrapped.version,
228
- settings: unwrapped.settings,
229
- sections: unwrapped.sections,
230
- entries: unwrapped.entries,
231
- exports: Array.isArray(unwrapped.exports) ? unwrapped.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 : [],
232
217
  });
233
218
 
234
219
  return {
235
220
  status: 'restored',
236
221
  backupCreatedAt: createdAt || '',
237
- sections: unwrapped.sections.length,
238
- entries: unwrapped.entries.length,
239
- reWrapped: unwrapped._reWrapped || 0,
222
+ sections: resealed.sections.length,
223
+ entries: resealed.entries.length,
224
+ resealed: resealed._resealed || 0,
240
225
  safetySnapshot: displayPath(safety),
241
226
  };
242
227
  }
@@ -258,13 +243,13 @@ export async function importBackup(ctx) {
258
243
  export function renderBackup(d) {
259
244
  if (d.status === 'skipped') {
260
245
  return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
261
- + (d.reWrapped ? `\n携带口令派生重加密的密文字段: ${d.reWrapped} 个(其他机器用本次口令可解开)` : '');
246
+ + (d.unsealed ? `\n含明文保存的密钥字段: ${d.unsealed} 个(其他机器用本次口令可解开)` : '');
262
247
  }
263
248
  return [
264
249
  `已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
265
250
  `文件: ${d.file} ${d.bytes} 字节`,
266
251
  `内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
267
- `携带口令派生重加密的密文字段: ${d.reWrapped || 0} 个(其他机器用本次口令可解开)`,
252
+ `本次解开为明文的密钥字段: ${d.unsealed || 0} 个(其他机器用本次口令可解开)`,
268
253
  `恢复时需要输入本次设置的口令。`,
269
254
  ].join('\n');
270
255
  }
@@ -272,12 +257,12 @@ export function renderBackup(d) {
272
257
  export function renderRestore(d) {
273
258
  if (d.status === 'dry-run') {
274
259
  return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目`
275
- + `,含口令派生重加密的密文字段 ${d.reWrapped || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
260
+ + `,含明文密钥字段 ${d.unsealed || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
276
261
  }
277
262
  return [
278
263
  `已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
279
- `兑回本机密钥库的密文字段: ${d.reWrapped || 0} 个`,
264
+ `用本机密钥库重新加密的密钥字段: ${d.resealed || 0} 个`,
280
265
  `备份生成于: ${d.backupCreatedAt || '未知'}`,
281
266
  `恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
282
267
  ].join('\n');
283
- }
268
+ }