@lyrify/znl 0.4.1 → 0.5.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.
@@ -0,0 +1,578 @@
1
+ /**
2
+ * 安全辅助模块(security.js)
3
+ *
4
+ * 目标:
5
+ * 1) 从 authKey 派生“签名密钥 / 加密密钥”
6
+ * 2) 生成与校验签名(HMAC-SHA256)
7
+ * 3) 生成 nonce 与重放检测缓存
8
+ * 4) 使用 AES-256-GCM 对 payload 帧进行加密/解密
9
+ *
10
+ * 设计原则:
11
+ * - 保持纯函数优先,易测试、易审计
12
+ * - 二进制优先,避免字符串编码歧义
13
+ * - 所有比较使用 timingSafeEqual,规避时序侧信道
14
+ */
15
+
16
+ import {
17
+ createCipheriv,
18
+ createDecipheriv,
19
+ createHash,
20
+ createHmac,
21
+ hkdfSync,
22
+ randomBytes,
23
+ timingSafeEqual,
24
+ } from "node:crypto";
25
+
26
+ import {
27
+ ENCRYPT_ALGORITHM,
28
+ ENCRYPT_IV_BYTES,
29
+ ENCRYPT_TAG_BYTES,
30
+ KDF_INFO_ENCRYPT,
31
+ KDF_INFO_SIGN,
32
+ MAX_TIME_SKEW_MS,
33
+ REPLAY_WINDOW_MS,
34
+ } from "./constants.js";
35
+
36
+ /** HKDF 输出密钥长度(AES-256 / HMAC-SHA256) */
37
+ const KEY_BYTES = 32;
38
+
39
+ /** 固定盐值(用于 HKDF,非机密) */
40
+ const KDF_SALT = Buffer.from("znl-kdf-salt-v1", "utf8");
41
+
42
+ /**
43
+ * 将任意输入标准化为 Buffer
44
+ * @param {string|Buffer|Uint8Array|null|undefined} value
45
+ * @returns {Buffer}
46
+ */
47
+ function toBuffer(value) {
48
+ if (value == null) return Buffer.alloc(0);
49
+ if (Buffer.isBuffer(value)) return value;
50
+ if (value instanceof Uint8Array) return Buffer.from(value);
51
+ return Buffer.from(String(value), "utf8");
52
+ }
53
+
54
+ /**
55
+ * 将输入标准化为帧数组(每帧为 Buffer)
56
+ * @param {string|Buffer|Uint8Array|Array<string|Buffer|Uint8Array|null|undefined>} payload
57
+ * @returns {Buffer[]}
58
+ */
59
+ export function toFrameBuffers(payload) {
60
+ if (Array.isArray(payload)) return payload.map(toBuffer);
61
+ return [toBuffer(payload)];
62
+ }
63
+
64
+ /**
65
+ * 将帧数组编码为紧凑二进制:
66
+ * [count:u32be][len:u32be][frame-bytes]...
67
+ *
68
+ * @param {Buffer[]} frames
69
+ * @returns {Buffer}
70
+ */
71
+ export function encodeFrames(frames) {
72
+ const safeFrames = Array.isArray(frames) ? frames : [];
73
+ const head = Buffer.allocUnsafe(4);
74
+ head.writeUInt32BE(safeFrames.length, 0);
75
+
76
+ const chunks = [head];
77
+ for (const frame of safeFrames) {
78
+ const buf = toBuffer(frame);
79
+ const len = Buffer.allocUnsafe(4);
80
+ len.writeUInt32BE(buf.length, 0);
81
+ chunks.push(len, buf);
82
+ }
83
+ return Buffer.concat(chunks);
84
+ }
85
+
86
+ /**
87
+ * 将二进制还原为帧数组
88
+ * @param {Buffer|Uint8Array|string} packed
89
+ * @returns {Buffer[]}
90
+ */
91
+ export function decodeFrames(packed) {
92
+ const data = toBuffer(packed);
93
+ if (data.length < 4) {
94
+ throw new Error("无效帧数据:长度不足(缺少 count)。");
95
+ }
96
+
97
+ let offset = 0;
98
+ const count = data.readUInt32BE(offset);
99
+ offset += 4;
100
+
101
+ const out = [];
102
+ for (let i = 0; i < count; i++) {
103
+ if (offset + 4 > data.length) {
104
+ throw new Error(`无效帧数据:第 ${i} 帧缺少长度字段。`);
105
+ }
106
+ const len = data.readUInt32BE(offset);
107
+ offset += 4;
108
+
109
+ if (offset + len > data.length) {
110
+ throw new Error(`无效帧数据:第 ${i} 帧长度越界。`);
111
+ }
112
+ out.push(data.slice(offset, offset + len));
113
+ offset += len;
114
+ }
115
+
116
+ if (offset !== data.length) {
117
+ throw new Error("无效帧数据:存在未消费的尾部字节。");
118
+ }
119
+
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * 从 authKey 派生签名密钥与加密密钥(HKDF-SHA256)
125
+ * @param {string} authKey
126
+ * @returns {{ signKey: Buffer, encryptKey: Buffer }}
127
+ */
128
+ export function deriveKeys(authKey) {
129
+ const ikm = toBuffer(authKey);
130
+ if (ikm.length === 0) {
131
+ throw new Error("authKey 不能为空:启用安全功能时必须提供非空 authKey。");
132
+ }
133
+
134
+ const signKey = Buffer.from(
135
+ hkdfSync(
136
+ "sha256",
137
+ ikm,
138
+ KDF_SALT,
139
+ Buffer.from(KDF_INFO_SIGN, "utf8"),
140
+ KEY_BYTES,
141
+ ),
142
+ );
143
+ const encryptKey = Buffer.from(
144
+ hkdfSync(
145
+ "sha256",
146
+ ikm,
147
+ KDF_SALT,
148
+ Buffer.from(KDF_INFO_ENCRYPT, "utf8"),
149
+ KEY_BYTES,
150
+ ),
151
+ );
152
+
153
+ return { signKey, encryptKey };
154
+ }
155
+
156
+ /**
157
+ * 生成随机 nonce(hex)
158
+ * @param {number} [bytes=16]
159
+ * @returns {string}
160
+ */
161
+ export function generateNonce(bytes = 16) {
162
+ return randomBytes(Math.max(8, bytes)).toString("hex");
163
+ }
164
+
165
+ /**
166
+ * 生成当前时间戳(毫秒)
167
+ * @returns {number}
168
+ */
169
+ export function nowMs() {
170
+ return Date.now();
171
+ }
172
+
173
+ /**
174
+ * 校验时间戳是否在允许漂移窗口内
175
+ * @param {number|string} timestampMs
176
+ * @param {number} [maxSkewMs=MAX_TIME_SKEW_MS]
177
+ * @param {number} [now=Date.now()]
178
+ * @returns {boolean}
179
+ */
180
+ export function isTimestampFresh(
181
+ timestampMs,
182
+ maxSkewMs = MAX_TIME_SKEW_MS,
183
+ now = Date.now(),
184
+ ) {
185
+ const ts = Number(timestampMs);
186
+ if (!Number.isFinite(ts) || ts <= 0) return false;
187
+ return (
188
+ Math.abs(now - ts) <= Math.max(1, Number(maxSkewMs) || MAX_TIME_SKEW_MS)
189
+ );
190
+ }
191
+
192
+ /**
193
+ * 将签名输入标准化为稳定字符串
194
+ * @param {{
195
+ * kind: string,
196
+ * nodeId: string,
197
+ * requestId?: string|null,
198
+ * timestamp: number|string,
199
+ * nonce: string,
200
+ * payloadDigest?: string|null,
201
+ * }} envelope
202
+ * @returns {string}
203
+ */
204
+ export function canonicalSignInput(envelope) {
205
+ const {
206
+ kind,
207
+ nodeId,
208
+ requestId = "",
209
+ timestamp,
210
+ nonce,
211
+ payloadDigest = "",
212
+ } = envelope ?? {};
213
+
214
+ return [
215
+ "znl-sign-v1",
216
+ String(kind ?? ""),
217
+ String(nodeId ?? ""),
218
+ String(requestId ?? ""),
219
+ String(timestamp ?? ""),
220
+ String(nonce ?? ""),
221
+ String(payloadDigest ?? ""),
222
+ ].join("|");
223
+ }
224
+
225
+ /**
226
+ * 对文本计算 HMAC-SHA256 签名(hex)
227
+ * @param {Buffer|string} signKey
228
+ * @param {string|Buffer} text
229
+ * @returns {string}
230
+ */
231
+ export function signText(signKey, text) {
232
+ return createHmac("sha256", toBuffer(signKey))
233
+ .update(toBuffer(text))
234
+ .digest("hex");
235
+ }
236
+
237
+ /**
238
+ * 验证 HMAC 签名(hex),使用 timingSafeEqual
239
+ * @param {Buffer|string} signKey
240
+ * @param {string|Buffer} text
241
+ * @param {string} signatureHex
242
+ * @returns {boolean}
243
+ */
244
+ export function verifyTextSignature(signKey, text, signatureHex) {
245
+ try {
246
+ const expected = Buffer.from(signText(signKey, text), "hex");
247
+ const provided = Buffer.from(String(signatureHex || ""), "hex");
248
+ if (expected.length !== provided.length) return false;
249
+ return timingSafeEqual(expected, provided);
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * base64url 编码(无填充)
257
+ * @param {Buffer|string|Uint8Array} value
258
+ * @returns {string}
259
+ */
260
+ export function toBase64Url(value) {
261
+ return toBuffer(value).toString("base64url");
262
+ }
263
+
264
+ /**
265
+ * base64url 解码
266
+ * @param {string} text
267
+ * @returns {Buffer}
268
+ */
269
+ export function fromBase64Url(text) {
270
+ return Buffer.from(String(text ?? ""), "base64url");
271
+ }
272
+
273
+ /**
274
+ * 将防重放信封编码为“签名证明令牌”
275
+ *
276
+ * 令牌结构:
277
+ * <base64url(header)>.<base64url(payload)>.<hmac-hex>
278
+ *
279
+ * header 固定为:
280
+ * { alg: "HS256", typ: "ZNL-AUTH-PROOF", v: 1 }
281
+ *
282
+ * payload 建议字段:
283
+ * - kind / nodeId / requestId / timestamp / nonce / payloadDigest
284
+ *
285
+ * @param {Buffer|string} signKey
286
+ * @param {{
287
+ * kind: string,
288
+ * nodeId: string,
289
+ * requestId?: string|null,
290
+ * timestamp: number|string,
291
+ * nonce: string,
292
+ * payloadDigest?: string|null
293
+ * }} envelope
294
+ * @returns {string}
295
+ */
296
+ export function encodeAuthProofToken(signKey, envelope) {
297
+ const header = { alg: "HS256", typ: "ZNL-AUTH-PROOF", v: 1 };
298
+ const payload = {
299
+ kind: String(envelope?.kind ?? ""),
300
+ nodeId: String(envelope?.nodeId ?? ""),
301
+ requestId: envelope?.requestId == null ? "" : String(envelope.requestId),
302
+ timestamp: Number(envelope?.timestamp ?? 0),
303
+ nonce: String(envelope?.nonce ?? ""),
304
+ payloadDigest:
305
+ envelope?.payloadDigest == null ? "" : String(envelope.payloadDigest),
306
+ };
307
+
308
+ const h = toBase64Url(JSON.stringify(header));
309
+ const p = toBase64Url(JSON.stringify(payload));
310
+ const signingInput = `${h}.${p}`;
311
+ const signatureHex = signText(signKey, signingInput);
312
+ return `${signingInput}.${signatureHex}`;
313
+ }
314
+
315
+ /**
316
+ * 解码并验证签名证明令牌
317
+ *
318
+ * 返回结构:
319
+ * - ok=true : envelope 可用
320
+ * - ok=false : error 描述失败原因
321
+ *
322
+ * @param {Buffer|string} signKey
323
+ * @param {string} token
324
+ * @param {{ maxSkewMs?: number, now?: number }} [options]
325
+ * @returns {{
326
+ * ok: boolean,
327
+ * error?: string,
328
+ * envelope?: {
329
+ * kind: string,
330
+ * nodeId: string,
331
+ * requestId: string,
332
+ * timestamp: number,
333
+ * nonce: string,
334
+ * payloadDigest: string
335
+ * }
336
+ * }}
337
+ */
338
+ export function decodeAuthProofToken(
339
+ signKey,
340
+ token,
341
+ { maxSkewMs = MAX_TIME_SKEW_MS, now = Date.now() } = {},
342
+ ) {
343
+ try {
344
+ const parts = String(token ?? "").split(".");
345
+ if (parts.length !== 3) {
346
+ return { ok: false, error: "令牌格式非法:必须为 3 段。" };
347
+ }
348
+
349
+ const [h, p, signatureHex] = parts;
350
+ const signingInput = `${h}.${p}`;
351
+
352
+ if (!verifyTextSignature(signKey, signingInput, signatureHex)) {
353
+ return { ok: false, error: "签名校验失败。" };
354
+ }
355
+
356
+ const header = JSON.parse(fromBase64Url(h).toString("utf8"));
357
+ const payload = JSON.parse(fromBase64Url(p).toString("utf8"));
358
+
359
+ if (header?.alg !== "HS256" || header?.typ !== "ZNL-AUTH-PROOF") {
360
+ return { ok: false, error: "令牌头非法:alg/typ 不匹配。" };
361
+ }
362
+
363
+ const envelope = {
364
+ kind: String(payload?.kind ?? ""),
365
+ nodeId: String(payload?.nodeId ?? ""),
366
+ requestId: String(payload?.requestId ?? ""),
367
+ timestamp: Number(payload?.timestamp ?? 0),
368
+ nonce: String(payload?.nonce ?? ""),
369
+ payloadDigest: String(payload?.payloadDigest ?? ""),
370
+ };
371
+
372
+ if (!envelope.kind || !envelope.nodeId || !envelope.nonce) {
373
+ return { ok: false, error: "令牌负载非法:缺少关键字段。" };
374
+ }
375
+
376
+ if (!isTimestampFresh(envelope.timestamp, maxSkewMs, now)) {
377
+ return { ok: false, error: "令牌已过期或时间戳异常。" };
378
+ }
379
+
380
+ return { ok: true, envelope };
381
+ } catch (error) {
382
+ return { ok: false, error: `令牌解析失败:${error?.message ?? error}` };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * 计算 payload 帧摘要(sha256 hex)
388
+ * 说明:按协议顺序增量写入,避免一次性拼接大 Buffer
389
+ * @param {Buffer[]} frames
390
+ * @returns {string}
391
+ */
392
+ export function digestFrames(frames) {
393
+ const safeFrames = Array.isArray(frames) ? frames : [];
394
+ const hash = createHash("sha256");
395
+
396
+ // 写入帧数量(u32be)
397
+ const head = Buffer.allocUnsafe(4);
398
+ head.writeUInt32BE(safeFrames.length, 0);
399
+ hash.update(head);
400
+
401
+ // 逐帧写入长度与内容(u32be + bytes)
402
+ for (const frame of safeFrames) {
403
+ const buf = toBuffer(frame);
404
+ const len = Buffer.allocUnsafe(4);
405
+ len.writeUInt32BE(buf.length, 0);
406
+ hash.update(len);
407
+ if (buf.length > 0) hash.update(buf);
408
+ }
409
+
410
+ return hash.digest("hex");
411
+ }
412
+
413
+ /**
414
+ * AES-256-GCM 加密
415
+ * @param {Buffer|string} encryptKey
416
+ * @param {Buffer|string|Uint8Array} plaintext
417
+ * @param {Buffer|string|Uint8Array} [aad]
418
+ * @returns {{ iv: Buffer, ciphertext: Buffer, tag: Buffer }}
419
+ */
420
+ export function encryptBytes(encryptKey, plaintext, aad = Buffer.alloc(0)) {
421
+ const key = toBuffer(encryptKey);
422
+ if (key.length !== KEY_BYTES) {
423
+ throw new Error(
424
+ `加密密钥长度非法:期望 ${KEY_BYTES} 字节,实际 ${key.length} 字节。`,
425
+ );
426
+ }
427
+
428
+ const iv = randomBytes(ENCRYPT_IV_BYTES);
429
+ const cipher = createCipheriv(ENCRYPT_ALGORITHM, key, iv, {
430
+ authTagLength: ENCRYPT_TAG_BYTES,
431
+ });
432
+
433
+ const aadBuf = toBuffer(aad);
434
+ if (aadBuf.length > 0) cipher.setAAD(aadBuf);
435
+
436
+ const ciphertext = Buffer.concat([
437
+ cipher.update(toBuffer(plaintext)),
438
+ cipher.final(),
439
+ ]);
440
+ const tag = cipher.getAuthTag();
441
+
442
+ return { iv, ciphertext, tag };
443
+ }
444
+
445
+ /**
446
+ * AES-256-GCM 解密
447
+ * @param {Buffer|string} encryptKey
448
+ * @param {Buffer|string|Uint8Array} iv
449
+ * @param {Buffer|string|Uint8Array} ciphertext
450
+ * @param {Buffer|string|Uint8Array} tag
451
+ * @param {Buffer|string|Uint8Array} [aad]
452
+ * @returns {Buffer}
453
+ */
454
+ export function decryptBytes(
455
+ encryptKey,
456
+ iv,
457
+ ciphertext,
458
+ tag,
459
+ aad = Buffer.alloc(0),
460
+ ) {
461
+ const key = toBuffer(encryptKey);
462
+ if (key.length !== KEY_BYTES) {
463
+ throw new Error(
464
+ `解密密钥长度非法:期望 ${KEY_BYTES} 字节,实际 ${key.length} 字节。`,
465
+ );
466
+ }
467
+
468
+ const ivBuf = toBuffer(iv);
469
+ if (ivBuf.length !== ENCRYPT_IV_BYTES) {
470
+ throw new Error(
471
+ `IV 长度非法:期望 ${ENCRYPT_IV_BYTES} 字节,实际 ${ivBuf.length} 字节。`,
472
+ );
473
+ }
474
+
475
+ const tagBuf = toBuffer(tag);
476
+ if (tagBuf.length !== ENCRYPT_TAG_BYTES) {
477
+ throw new Error(
478
+ `认证标签长度非法:期望 ${ENCRYPT_TAG_BYTES} 字节,实际 ${tagBuf.length} 字节。`,
479
+ );
480
+ }
481
+
482
+ const decipher = createDecipheriv(ENCRYPT_ALGORITHM, key, ivBuf, {
483
+ authTagLength: ENCRYPT_TAG_BYTES,
484
+ });
485
+ decipher.setAuthTag(tagBuf);
486
+
487
+ const aadBuf = toBuffer(aad);
488
+ if (aadBuf.length > 0) decipher.setAAD(aadBuf);
489
+
490
+ return Buffer.concat([
491
+ decipher.update(toBuffer(ciphertext)),
492
+ decipher.final(),
493
+ ]);
494
+ }
495
+
496
+ /**
497
+ * 对帧数组进行加密(先打包后加密)
498
+ * @param {Buffer|string} encryptKey
499
+ * @param {Buffer[]} frames
500
+ * @param {Buffer|string|Uint8Array} [aad]
501
+ * @returns {{ iv: Buffer, ciphertext: Buffer, tag: Buffer }}
502
+ */
503
+ export function encryptFrames(encryptKey, frames, aad = Buffer.alloc(0)) {
504
+ const packed = encodeFrames(frames);
505
+ return encryptBytes(encryptKey, packed, aad);
506
+ }
507
+
508
+ /**
509
+ * 解密并还原帧数组
510
+ * @param {Buffer|string} encryptKey
511
+ * @param {Buffer|string|Uint8Array} iv
512
+ * @param {Buffer|string|Uint8Array} ciphertext
513
+ * @param {Buffer|string|Uint8Array} tag
514
+ * @param {Buffer|string|Uint8Array} [aad]
515
+ * @returns {Buffer[]}
516
+ */
517
+ export function decryptFrames(
518
+ encryptKey,
519
+ iv,
520
+ ciphertext,
521
+ tag,
522
+ aad = Buffer.alloc(0),
523
+ ) {
524
+ const packed = decryptBytes(encryptKey, iv, ciphertext, tag, aad);
525
+ return decodeFrames(packed);
526
+ }
527
+
528
+ /**
529
+ * 重放检测缓存(基于 nonce)
530
+ * - seenOrAdd(nonce): 首次出现返回 false,重复返回 true
531
+ * - 自动清理过期条目,防止内存增长
532
+ */
533
+ export class ReplayGuard {
534
+ /**
535
+ * @param {{ windowMs?: number }} [options]
536
+ */
537
+ constructor({ windowMs = REPLAY_WINDOW_MS } = {}) {
538
+ /** @type {Map<string, number>} nonce -> expiresAt */
539
+ this._map = new Map();
540
+ this._windowMs = Math.max(10_000, Number(windowMs) || REPLAY_WINDOW_MS);
541
+ }
542
+
543
+ /**
544
+ * 清理过期 nonce
545
+ * @param {number} [now=Date.now()]
546
+ */
547
+ sweep(now = Date.now()) {
548
+ for (const [nonce, expiresAt] of this._map) {
549
+ if (expiresAt <= now) this._map.delete(nonce);
550
+ }
551
+ }
552
+
553
+ /**
554
+ * 检查 nonce 是否重复;若非重复则写入缓存
555
+ * @param {string} nonce
556
+ * @param {number} [now=Date.now()]
557
+ * @returns {boolean} true=重复(疑似重放), false=首次出现
558
+ */
559
+ seenOrAdd(nonce, now = Date.now()) {
560
+ const key = String(nonce ?? "");
561
+ if (!key) return true; // 空 nonce 直接判定异常
562
+ this.sweep(now);
563
+
564
+ if (this._map.has(key)) return true;
565
+ this._map.set(key, now + this._windowMs);
566
+ return false;
567
+ }
568
+
569
+ /** 当前缓存条目数(便于监控) */
570
+ get size() {
571
+ return this._map.size;
572
+ }
573
+
574
+ /** 清空缓存 */
575
+ clear() {
576
+ this._map.clear();
577
+ }
578
+ }