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 +12 -0
- package/package.json +1 -1
- package/src/modules/backup/service.js +50 -65
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,19 +1,14 @@
|
|
|
1
1
|
// 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
|
|
2
2
|
//
|
|
3
|
-
// 导出:用户设置口令 →
|
|
4
|
-
//
|
|
3
|
+
// 导出:用户设置口令 → 先把 secret 栏目的密文字段用**本机 vaultKey 解开**(明文),
|
|
4
|
+
// 再把整份 payload 用口令派生 key 加密落到备份文件;口令不落盘。
|
|
5
|
+
// 导入:用户输入口令 → 解密后 payload 里的 secret 字段就是明文;直接写回 store.json。
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
7
|
+
// 这样跨机器恢复:B 机拿到口令后 secret 字段就是明文,**不再依赖 A 机的 vaultKey**。
|
|
8
|
+
// 这是 store.json 内的密文被解开的瞬态——落回磁盘后字段仍是 vaultKey 加密形态(每次
|
|
9
|
+
// `key set` 会重新用 B 机 vaultKey 加密),所以单机的"落盘密文"防护没退化。
|
|
8
10
|
//
|
|
9
|
-
//
|
|
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 =
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* 输入是 buildBackup 准备好的 store payload;输出是**新对象**,原对象不变。
|
|
50
|
-
* 已带 reWrapped 的不再二次包——避免循环。
|
|
42
|
+
* 导出时:用本机 vaultKey **解开** secret 栏目的密文字段。
|
|
43
|
+
* 导出文件不再依赖 A 机的 ~/.vaultkey / NX_SK_PASSPHRASE。
|
|
51
44
|
*/
|
|
52
|
-
async function
|
|
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
|
|
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)
|
|
69
|
-
|
|
70
|
-
|
|
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,
|
|
66
|
+
return { ...payload, entries: newEntries, _unsealed: unsealed };
|
|
78
67
|
}
|
|
79
68
|
|
|
80
69
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
70
|
+
* 导入时:payload 里的 secret 字段已是明文,**用本机 vaultKey 重新加密**后落盘。
|
|
71
|
+
* 这样 store.json 里仍是密文(落盘防护不丢),B 机后续 key get 走 B 机 vaultKey 解密。
|
|
72
|
+
* 历史 store 里残留的明文 secret 也会被本步统一收紧成密文。
|
|
83
73
|
*/
|
|
84
|
-
async function
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
188
|
+
return { payload, createdAt: archive.createdAt };
|
|
203
189
|
}
|
|
204
190
|
|
|
205
|
-
/**
|
|
191
|
+
/** 恢复:解密整包 → 把 secret 明文用本机 vaultKey 重新加密 → 留安全快照 → 整份替换。 */
|
|
206
192
|
export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
207
|
-
const { payload, createdAt
|
|
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
|
-
|
|
201
|
+
unsealed: payload._unsealed || 0,
|
|
216
202
|
};
|
|
217
203
|
}
|
|
218
204
|
|
|
219
|
-
// 把
|
|
220
|
-
|
|
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:
|
|
228
|
-
settings:
|
|
229
|
-
sections:
|
|
230
|
-
entries:
|
|
231
|
-
exports: Array.isArray(
|
|
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:
|
|
238
|
-
entries:
|
|
239
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
+
|
|
260
|
+
+ `,含明文密钥字段 ${d.unsealed || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
|
|
276
261
|
}
|
|
277
262
|
return [
|
|
278
263
|
`已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
279
|
-
|
|
264
|
+
`用本机密钥库重新加密的密钥字段: ${d.resealed || 0} 个`,
|
|
280
265
|
`备份生成于: ${d.backupCreatedAt || '未知'}`,
|
|
281
266
|
`恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
|
|
282
267
|
].join('\n');
|
|
283
|
-
}
|
|
268
|
+
}
|