@xiaoyuyu6420/dsh-backup 0.14.1 → 0.14.3

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/lib/encrypt.js CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  randomBytes,
24
24
  scrypt as scryptCb,
25
25
  } from 'node:crypto';
26
+ import { createReadStream, createWriteStream } from 'node:fs';
27
+ import { rm } from 'node:fs/promises';
28
+ import { Transform } from 'node:stream';
29
+ import { pipeline } from 'node:stream/promises';
26
30
  import { promisify } from 'node:util';
27
31
 
28
32
  const scrypt = promisify(scryptCb);
@@ -91,15 +95,10 @@ export async function encryptBuffer(plaintext, password) {
91
95
  }
92
96
 
93
97
  /**
94
- * 解密一段密文。口令错误 / 密文被篡改时 AES-GCM 认证失败,抛出口令错误人话报错
95
- * (调用方可直接展示);未知版本 / 未知算法 / 边车字段缺失一律拒绝,不猜着解。
96
- *
97
- * @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
98
- * @param {object} sidecar `.encrypted.json` 解析出的对象
99
- * @param {string} password 口令(内存态)
100
- * @returns {Promise<Buffer>} 明文(tar.gz 字节)
98
+ * 校验边车并派生出解开这段密文所需的 { key, nonce, authTag }。
99
+ * 版本/算法/字段缺失一律拒绝(宁可不恢复,也不拿不认识的参数猜着解)。
101
100
  */
102
- export async function decryptBuffer(ciphertext, sidecar, password) {
101
+ async function unlock(sidecar, password) {
103
102
  if (typeof password !== 'string' || !password) {
104
103
  throw new Error('这份备份已加密,恢复需要口令(口令只经内存传入,绝不写盘)');
105
104
  }
@@ -120,16 +119,116 @@ export async function decryptBuffer(ciphertext, sidecar, password) {
120
119
  throw new Error('加密边车字段缺失(salt/nonce/authTag),无法解密');
121
120
  }
122
121
  const key = await deriveKey(password, { ...(sidecar.kdfParams || KDF_PARAMS), salt });
122
+ return { key, nonce, authTag };
123
+ }
124
+
125
+ /** GCM 认证失败的统一人话报错(不泄露具体原因)。 */
126
+ function authFailed(err) {
127
+ if (/unable to authenticate|authentication/i.test(String(err && err.message ? err.message : err))) {
128
+ return new Error('口令错误或归档已损坏(AES-GCM 认证失败)——请核对口令后重试');
129
+ }
130
+ return err;
131
+ }
132
+
133
+ /**
134
+ * 解密一段密文(内存版,供小体积数据/测试用)。口令错误 / 密文被篡改时 AES-GCM
135
+ * 认证失败,抛出口令错误人话报错(调用方可直接展示)。
136
+ *
137
+ * @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
138
+ * @param {object} sidecar `.encrypted.json` 解析出的对象
139
+ * @param {string} password 口令(内存态)
140
+ * @returns {Promise<Buffer>} 明文(tar.gz 字节)
141
+ */
142
+ export async function decryptBuffer(ciphertext, sidecar, password) {
143
+ const { key, nonce, authTag } = await unlock(sidecar, password);
123
144
  const decipher = createDecipheriv(CIPHER, key, nonce);
124
145
  decipher.setAuthTag(authTag);
125
146
  const body = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext);
126
147
  try {
127
148
  return Buffer.concat([decipher.update(body), decipher.final()]);
128
149
  } catch (err) {
129
- // GCM final() 认证失败:口令错或密文被改。两条都指向同一句人话,不泄露具体原因。
130
- if (/unable to authenticate|authentication/i.test(String(err && err.message ? err.message : err))) {
131
- throw new Error('口令错误或归档已损坏(AES-GCM 认证失败)——请核对口令后重试');
132
- }
150
+ throw authFailed(err);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 加密文件:**分块流式**,不整包入内存(归档可以远超 Buffer 上限)。
156
+ * 调用方负责把输出写到临时路径后自己 `rename` 覆盖(本函数只保证「要么完整
157
+ * 密文、要么失败且不留半截文件」)。
158
+ *
159
+ * @param {string} inPath 明文文件
160
+ * @param {string} outPath 密文输出文件
161
+ * @param {string} password 口令
162
+ * @returns {Promise<{ sidecar: object, ciphertextSize: number }>}
163
+ */
164
+ export async function encryptFile(inPath, outPath, password) {
165
+ if (typeof password !== 'string' || !password) {
166
+ throw new Error('加密需要非空口令(口令只经内存传入,绝不写盘)');
167
+ }
168
+ const salt = randomBytes(SALT_BYTES);
169
+ const nonce = randomBytes(NONCE_BYTES);
170
+ const key = await deriveKey(password, { ...KDF_PARAMS, salt });
171
+ const cipher = createCipheriv(CIPHER, key, nonce);
172
+ let ciphertextSize = 0;
173
+ const counter = new Transform({
174
+ transform(chunk, _enc, done) {
175
+ ciphertextSize += chunk.length;
176
+ done(null, chunk);
177
+ },
178
+ });
179
+ try {
180
+ await pipeline(
181
+ createReadStream(inPath),
182
+ cipher,
183
+ counter,
184
+ createWriteStream(outPath, { mode: 0o600 }),
185
+ );
186
+ } catch (err) {
187
+ await rm(outPath, { force: true }).catch(() => {});
133
188
  throw err;
134
189
  }
190
+ const authTag = cipher.getAuthTag();
191
+ return {
192
+ ciphertextSize,
193
+ sidecar: {
194
+ version: ENCRYPT_VERSION,
195
+ cipher: CIPHER,
196
+ kdf: KDF_PARAMS.algorithm,
197
+ kdfParams: {
198
+ N: KDF_PARAMS.N,
199
+ r: KDF_PARAMS.r,
200
+ p: KDF_PARAMS.p,
201
+ keyLen: KDF_PARAMS.keyLen,
202
+ },
203
+ salt: salt.toString('base64'),
204
+ nonce: nonce.toString('base64'),
205
+ authTag: authTag.toString('base64'),
206
+ ciphertextSize,
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * 解密文件:**分块流式**。认证通过才产出完整明文;口令错/密文被改时删除半截
213
+ * 输出并抛人话报错(绝不留下「解了一半」的明文)。
214
+ *
215
+ * @param {string} inPath 密文文件
216
+ * @param {string} outPath 明文输出文件(可能不完整;失败时已被删除)
217
+ * @param {object} sidecar `.encrypted.json` 内容
218
+ * @param {string} password 口令
219
+ */
220
+ export async function decryptFile(inPath, outPath, sidecar, password) {
221
+ const { key, nonce, authTag } = await unlock(sidecar, password);
222
+ const decipher = createDecipheriv(CIPHER, key, nonce);
223
+ decipher.setAuthTag(authTag);
224
+ try {
225
+ await pipeline(
226
+ createReadStream(inPath),
227
+ decipher,
228
+ createWriteStream(outPath, { mode: 0o600 }),
229
+ );
230
+ } catch (err) {
231
+ await rm(outPath, { force: true }).catch(() => {});
232
+ throw authFailed(err);
233
+ }
135
234
  }