@xiaoyuyu6420/dsh-backup 0.13.4 → 0.14.0

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 ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * dsh-backup — 可选口令加密(P2 宿主半边)。
3
+ *
4
+ * 纯 node:crypto 实现,零外部依赖:scrypt(KDF,内存难哈希,抗口令穷举)派生
5
+ * 256 位密钥,AES-256-GCM(AEAD,密文自带认证)加密。每次加密随机生成
6
+ * salt 与 nonce(IV),GCM 的认证标签一并写入边车——口令错误或密文被篡改都会
7
+ * 让 final() 抛认证失败,绝无「解密出半个错文件」的可能。
8
+ *
9
+ * 安全边界(硬性):
10
+ * - 口令只作为函数入参在内存里流转,**绝不写 settings.yaml、绝不进日志、
11
+ * 绝不写盘**。调用方(backupPanel RPC)在客户端内存里持口令,传过来用完即弃。
12
+ * - 密文布局:归档文件本身只存 ciphertext(不含 nonce/tag),nonce/tag/salt/
13
+ * kdf 参数全在 `.encrypted.json` 边车里。边车是恢复时唯一需要的参数来源,
14
+ * 不含任何明文内容,可随密文一起备份/同步。
15
+ *
16
+ * 版本契约:sidecar.version 递增;读端对未知 version 一律拒绝(宁可不恢复,
17
+ * 也不拿不认识的参数猜着解)。
18
+ */
19
+
20
+ import {
21
+ createCipheriv,
22
+ createDecipheriv,
23
+ randomBytes,
24
+ scrypt as scryptCb,
25
+ } from 'node:crypto';
26
+ import { promisify } from 'node:util';
27
+
28
+ const scrypt = promisify(scryptCb);
29
+
30
+ /** 边车/密文格式版本。 */
31
+ export const ENCRYPT_VERSION = 1;
32
+ /** 加密算法标识(写进边车供读端核对,避免未来换算法时静默误解密)。 */
33
+ export const CIPHER = 'aes-256-gcm';
34
+ /** scrypt 参数:N=16384(2^14)、r=8、p=1——行业常见默认,约 16MB 内存、~50ms。 */
35
+ export const KDF_PARAMS = Object.freeze({ algorithm: 'scrypt', N: 16384, r: 8, p: 1, keyLen: 32 });
36
+ /** GCM 推荐 nonce 长度(12 字节)与认证标签长度(16 字节)。 */
37
+ export const NONCE_BYTES = 12;
38
+ export const SALT_BYTES = 16;
39
+ export const TAG_BYTES = 16;
40
+
41
+ /**
42
+ * 由口令 + 边车里的参数派生 AES 密钥。scrypt 的 maxmem 显式放宽到参数所需
43
+ * 内存的两倍余量(N=16384,r=8 → 128*N*r = 16MB),避免默认 32MB 上限在未来
44
+ * 参数升级时误杀。
45
+ */
46
+ async function deriveKey(password, params) {
47
+ const N = Number(params?.N) || KDF_PARAMS.N;
48
+ const r = Number(params?.r) || KDF_PARAMS.r;
49
+ const p = Number(params?.p) || KDF_PARAMS.p;
50
+ const keyLen = Number(params?.keyLen) || KDF_PARAMS.keyLen;
51
+ const maxmem = 128 * N * r * 2 + 1024 * 1024;
52
+ return scrypt(password, params.salt, keyLen, { N, r, p, maxmem });
53
+ }
54
+
55
+ /**
56
+ * 加密一段明文,返回 { ciphertext, sidecar }。
57
+ *
58
+ * @param {Buffer|Uint8Array} plaintext 明文(tar.gz 字节)
59
+ * @param {string} password 口令(内存态,绝不落盘)
60
+ * @returns {Promise<{ ciphertext: Buffer, sidecar: object }>}
61
+ */
62
+ export async function encryptBuffer(plaintext, password) {
63
+ if (typeof password !== 'string' || !password) {
64
+ throw new Error('加密需要非空口令(口令只经内存传入,绝不写盘)');
65
+ }
66
+ const salt = randomBytes(SALT_BYTES);
67
+ const nonce = randomBytes(NONCE_BYTES);
68
+ const key = await deriveKey(password, { ...KDF_PARAMS, salt });
69
+ const cipher = createCipheriv(CIPHER, key, nonce);
70
+ const body = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
71
+ const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]);
72
+ const authTag = cipher.getAuthTag();
73
+ return {
74
+ ciphertext,
75
+ sidecar: {
76
+ version: ENCRYPT_VERSION,
77
+ cipher: CIPHER,
78
+ kdf: KDF_PARAMS.algorithm,
79
+ kdfParams: {
80
+ N: KDF_PARAMS.N,
81
+ r: KDF_PARAMS.r,
82
+ p: KDF_PARAMS.p,
83
+ keyLen: KDF_PARAMS.keyLen,
84
+ },
85
+ salt: salt.toString('base64'),
86
+ nonce: nonce.toString('base64'),
87
+ authTag: authTag.toString('base64'),
88
+ ciphertextSize: ciphertext.length,
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
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 字节)
101
+ */
102
+ export async function decryptBuffer(ciphertext, sidecar, password) {
103
+ if (typeof password !== 'string' || !password) {
104
+ throw new Error('这份备份已加密,恢复需要口令(口令只经内存传入,绝不写盘)');
105
+ }
106
+ if (!sidecar || typeof sidecar !== 'object') {
107
+ throw new Error('加密边车缺失或损坏,无法解密(请核对备份是否完整)');
108
+ }
109
+ if (Number(sidecar.version) !== ENCRYPT_VERSION) {
110
+ throw new Error(`加密格式版本不兼容(边车 version=${sidecar.version},本机支持 ${ENCRYPT_VERSION})——请用更新版本的插件恢复`);
111
+ }
112
+ // 算法门:未知/缺失 cipher 一律拒绝(严格 !==,宁可不可恢复也不拿错误算法猜着解)。
113
+ if (sidecar.cipher !== CIPHER) {
114
+ throw new Error(`不支持的加密算法: ${sidecar.cipher ?? '(缺失)'}(本机只支持 ${CIPHER})`);
115
+ }
116
+ const salt = sidecar.salt ? Buffer.from(sidecar.salt, 'base64') : null;
117
+ const nonce = sidecar.nonce ? Buffer.from(sidecar.nonce, 'base64') : null;
118
+ const authTag = sidecar.authTag ? Buffer.from(sidecar.authTag, 'base64') : null;
119
+ if (!salt || !nonce || !authTag) {
120
+ throw new Error('加密边车字段缺失(salt/nonce/authTag),无法解密');
121
+ }
122
+ const key = await deriveKey(password, { ...(sidecar.kdfParams || KDF_PARAMS), salt });
123
+ const decipher = createDecipheriv(CIPHER, key, nonce);
124
+ decipher.setAuthTag(authTag);
125
+ const body = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext);
126
+ try {
127
+ return Buffer.concat([decipher.update(body), decipher.final()]);
128
+ } 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
+ }
133
+ throw err;
134
+ }
135
+ }
package/lib/gfs.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * dsh-backup — GFS 保留策略(P3 宿主半边,纯函数,可独立自检)。
3
+ *
4
+ * 在现有「最近 keep N」之外补 GFS 两个长尾分层:
5
+ * - monthly:keep 窗口之外的尾巴里,每个自然月保留**一份**(最早那份),最多
6
+ * `monthly` 个不同月份;
7
+ * - yearly:同理,每个自然年保留一份,最多 `yearly` 个不同年份。
8
+ *
9
+ * 默认 `{ monthly: 0, yearly: 0 }` 即等价旧行为(只 keep N + 既有日/周分层),
10
+ * 设置侧由 `gfsMonthly` / `gfsYearly` 两个字段支配,缺省 0 完全不改变现状。
11
+ * 旧 keep 语义不回退:keep 仍是「最近窗口份数」的唯一依据。
12
+ *
13
+ * 与 index.js 的 tierProtectedSet(日/周分层)互补——GFS 只负责月/年两层,
14
+ * 最终受保护集 = tierProtectedSet ∪ gfsProtectedSet。
15
+ */
16
+
17
+ /** 从归档名解析自然日(YYYY-MM-DD);解析不出返回 null。 */
18
+ function dayOf(name) {
19
+ const m = /^dsh-(?:t-)?(\d{4})(\d{2})(\d{2})-\d{6}\d{3}\.tar\.gz$/.exec(name);
20
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
21
+ }
22
+
23
+ /**
24
+ * 计算 GFS 月/年分层应保护的归档名集合。
25
+ *
26
+ * @param {Array<{name:string}>} tailDesc keep 窗口外、新→旧排序的归档
27
+ * @param {string|null} newestName 最新归档名(用于跳过「今日」的档内冗余)
28
+ * @param {{monthly?:number, yearly?:number}} opts 月/年分层各保留多少个桶(0=关)
29
+ * @returns {Set<string>} 受保护的归档名
30
+ */
31
+ export function gfsProtectedSet(tailDesc, newestName, { monthly = 0, yearly = 0 } = {}) {
32
+ const protectedNames = new Set();
33
+ if ((!monthly || monthly <= 0) && (!yearly || yearly <= 0)) return protectedNames;
34
+ const monthSeen = new Set();
35
+ const yearSeen = new Set();
36
+ const today = newestName ? dayOf(newestName) : null;
37
+ for (const b of tailDesc) {
38
+ const d = dayOf(b.name);
39
+ if (!d || d === today) continue;
40
+ const month = d.slice(0, 7); // YYYY-MM
41
+ const year = d.slice(0, 4); // YYYY
42
+ if (monthly > 0 && !monthSeen.has(month)) {
43
+ monthSeen.add(month);
44
+ if (monthSeen.size <= monthly) protectedNames.add(b.name);
45
+ }
46
+ if (yearly > 0 && !yearSeen.has(year)) {
47
+ yearSeen.add(year);
48
+ if (yearSeen.size <= yearly) protectedNames.add(b.name);
49
+ }
50
+ }
51
+ return protectedNames;
52
+ }