nx-sk 0.6.0 → 0.6.1
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 +6 -0
- package/package.json +1 -1
- package/src/modules/backup/service.js +36 -86
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
|
|
4
4
|
|
|
5
|
+
## [0.6.1] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **加密备份跨机器恢复:密钥栏目与其他栏目走同一路径**。`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 泄露不等于密钥泄露"这条不变量没退化。
|
|
10
|
+
|
|
5
11
|
## [0.6.0] - 2026-10-12
|
|
6
12
|
|
|
7
13
|
### 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
|
+
* 把 payload 里 secret 栏目的密文字段解开成明文(不修改其他字段)。
|
|
43
|
+
* 用本机 vaultKey 解:A 机本地能解;导出文件不依赖 A 机后续状态。
|
|
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,51 +57,13 @@ 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,
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 反向:把 reWrapped 副本兑回本机 vault key。
|
|
82
|
-
* 拿不到口令(密码错)会抛 BLOCKED;拿到口令则静默完成,用户不感知。
|
|
83
|
-
*/
|
|
84
|
-
async function unwrapSecretsToLocal(payload, password, salt) {
|
|
85
|
-
const sections = payload.sections || [];
|
|
86
|
-
const entries = payload.entries || [];
|
|
87
|
-
const transportKey = deriveKey(password, salt);
|
|
88
|
-
const { key: vaultKey } = await resolveVaultKey();
|
|
89
|
-
|
|
90
|
-
const newEntries = [];
|
|
91
|
-
let unwrapped = 0;
|
|
92
|
-
for (const e of entries) {
|
|
93
|
-
const sec = sections.find((s) => s.id === e.section);
|
|
94
|
-
if (!sec || !e.values) { newEntries.push(e); continue; }
|
|
95
|
-
const nextValues = { ...e.values };
|
|
96
|
-
let touched = false;
|
|
97
|
-
for (const def of sec.fields || []) {
|
|
98
|
-
if (!isSensitiveField(sec, def.key)) continue;
|
|
99
|
-
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++;
|
|
109
|
-
}
|
|
110
|
-
newEntries.push(touched ? { ...e, values: nextValues } : e);
|
|
111
|
-
}
|
|
112
|
-
return { ...payload, entries: newEntries, _reWrapped: unwrapped };
|
|
66
|
+
return { ...payload, entries: newEntries, _unsealed: unsealed };
|
|
113
67
|
}
|
|
114
68
|
|
|
115
69
|
/** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
|
|
@@ -126,7 +80,7 @@ export async function buildBackup(ctx) {
|
|
|
126
80
|
|
|
127
81
|
const salt = randomBytes(16);
|
|
128
82
|
const wrapKey = deriveKey(password, salt);
|
|
129
|
-
const payload = await
|
|
83
|
+
const payload = await unsealSecretsForExport(raw);
|
|
130
84
|
const data = encryptValue(wrapKey, JSON.stringify(payload));
|
|
131
85
|
const archive = {
|
|
132
86
|
app: 'nx-sk',
|
|
@@ -140,7 +94,7 @@ export async function buildBackup(ctx) {
|
|
|
140
94
|
const counts = {
|
|
141
95
|
sections: store.sections.length,
|
|
142
96
|
entries: store.entries.length,
|
|
143
|
-
|
|
97
|
+
unsealed: payload._unsealed || 0,
|
|
144
98
|
};
|
|
145
99
|
if (ctx['dry-run']) return { dryRun: true, counts };
|
|
146
100
|
return { archive, counts };
|
|
@@ -182,7 +136,7 @@ function parseArchive(text) {
|
|
|
182
136
|
return archive;
|
|
183
137
|
}
|
|
184
138
|
|
|
185
|
-
/** 用口令解密归档文本 → 备份时的 store
|
|
139
|
+
/** 用口令解密归档文本 → 备份时的 store 负载。口令错误在此抛 BLOCKED。 */
|
|
186
140
|
export function decryptBackup(text, password) {
|
|
187
141
|
const archive = parseArchive(text);
|
|
188
142
|
if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
|
|
@@ -199,12 +153,12 @@ export function decryptBackup(text, password) {
|
|
|
199
153
|
if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
|
|
200
154
|
throw badInput('备份内容缺少 sections / entries,无法恢复。');
|
|
201
155
|
}
|
|
202
|
-
return { payload, createdAt: archive.createdAt
|
|
156
|
+
return { payload, createdAt: archive.createdAt };
|
|
203
157
|
}
|
|
204
158
|
|
|
205
|
-
/** 恢复:解密 →
|
|
159
|
+
/** 恢复:解密 → 留安全快照 → 整份替换(secret 字段已是明文,落盘后下次写入时由 B 机 vaultKey 重新加密)。 */
|
|
206
160
|
export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
207
|
-
const { payload, createdAt
|
|
161
|
+
const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
|
|
208
162
|
|
|
209
163
|
if (dryRun) {
|
|
210
164
|
return {
|
|
@@ -212,31 +166,27 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
|
|
|
212
166
|
backupCreatedAt: createdAt || '',
|
|
213
167
|
sections: payload.sections.length,
|
|
214
168
|
entries: payload.entries.length,
|
|
215
|
-
|
|
169
|
+
unsealed: payload._unsealed || 0,
|
|
216
170
|
};
|
|
217
171
|
}
|
|
218
172
|
|
|
219
|
-
// 把 reWrapped 兑回本机 vault key:解口令派生密文 → 用本机 vaultKey 重加密。
|
|
220
|
-
// 失败(口令对但 reWrapped 损坏)会被 unwrapSecretsToLocal 抛 BLOCKED。
|
|
221
|
-
const unwrapped = await unwrapSecretsToLocal(payload, password, salt);
|
|
222
|
-
|
|
223
173
|
// 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
|
|
224
174
|
const safety = await snapshotStore('before-restore');
|
|
225
175
|
|
|
226
176
|
await saveStore({
|
|
227
|
-
version:
|
|
228
|
-
settings:
|
|
229
|
-
sections:
|
|
230
|
-
entries:
|
|
231
|
-
exports: Array.isArray(
|
|
177
|
+
version: payload.version,
|
|
178
|
+
settings: payload.settings,
|
|
179
|
+
sections: payload.sections,
|
|
180
|
+
entries: payload.entries,
|
|
181
|
+
exports: Array.isArray(payload.exports) ? payload.exports : [],
|
|
232
182
|
});
|
|
233
183
|
|
|
234
184
|
return {
|
|
235
185
|
status: 'restored',
|
|
236
186
|
backupCreatedAt: createdAt || '',
|
|
237
|
-
sections:
|
|
238
|
-
entries:
|
|
239
|
-
|
|
187
|
+
sections: payload.sections.length,
|
|
188
|
+
entries: payload.entries.length,
|
|
189
|
+
unsealed: payload._unsealed || 0,
|
|
240
190
|
safetySnapshot: displayPath(safety),
|
|
241
191
|
};
|
|
242
192
|
}
|
|
@@ -258,13 +208,13 @@ export async function importBackup(ctx) {
|
|
|
258
208
|
export function renderBackup(d) {
|
|
259
209
|
if (d.status === 'skipped') {
|
|
260
210
|
return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
|
|
261
|
-
+ (d.
|
|
211
|
+
+ (d.unsealed ? `\n含明文保存的密钥字段: ${d.unsealed} 个(其他机器用本次口令可解开)` : '');
|
|
262
212
|
}
|
|
263
213
|
return [
|
|
264
214
|
`已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
|
|
265
215
|
`文件: ${d.file} ${d.bytes} 字节`,
|
|
266
216
|
`内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
267
|
-
|
|
217
|
+
`本次解开为明文的密钥字段: ${d.unsealed || 0} 个(其他机器用本次口令可解开)`,
|
|
268
218
|
`恢复时需要输入本次设置的口令。`,
|
|
269
219
|
].join('\n');
|
|
270
220
|
}
|
|
@@ -272,12 +222,12 @@ export function renderBackup(d) {
|
|
|
272
222
|
export function renderRestore(d) {
|
|
273
223
|
if (d.status === 'dry-run') {
|
|
274
224
|
return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目`
|
|
275
|
-
+
|
|
225
|
+
+ `,含明文密钥字段 ${d.unsealed || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
|
|
276
226
|
}
|
|
277
227
|
return [
|
|
278
228
|
`已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
279
|
-
|
|
229
|
+
`从备份中拿到的明文密钥字段: ${d.unsealed || 0} 个`,
|
|
280
230
|
`备份生成于: ${d.backupCreatedAt || '未知'}`,
|
|
281
231
|
`恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
|
|
282
232
|
].join('\n');
|
|
283
|
-
}
|
|
233
|
+
}
|