@kangwifi-pro/waliwa 1.0.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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +600 -0
  3. package/dist/advanced/features.d.ts +211 -0
  4. package/dist/advanced/features.d.ts.map +1 -0
  5. package/dist/advanced/features.js +671 -0
  6. package/dist/advanced/features.js.map +1 -0
  7. package/dist/auth/auth-state.d.ts +89 -0
  8. package/dist/auth/auth-state.d.ts.map +1 -0
  9. package/dist/auth/auth-state.js +330 -0
  10. package/dist/auth/auth-state.js.map +1 -0
  11. package/dist/auth/pairing-code.d.ts +78 -0
  12. package/dist/auth/pairing-code.d.ts.map +1 -0
  13. package/dist/auth/pairing-code.js +191 -0
  14. package/dist/auth/pairing-code.js.map +1 -0
  15. package/dist/auth/qr-code.d.ts +60 -0
  16. package/dist/auth/qr-code.d.ts.map +1 -0
  17. package/dist/auth/qr-code.js +151 -0
  18. package/dist/auth/qr-code.js.map +1 -0
  19. package/dist/calls/handler.d.ts +61 -0
  20. package/dist/calls/handler.d.ts.map +1 -0
  21. package/dist/calls/handler.js +117 -0
  22. package/dist/calls/handler.js.map +1 -0
  23. package/dist/core/binary.d.ts +74 -0
  24. package/dist/core/binary.d.ts.map +1 -0
  25. package/dist/core/binary.js +448 -0
  26. package/dist/core/binary.js.map +1 -0
  27. package/dist/core/crypto.d.ts +60 -0
  28. package/dist/core/crypto.d.ts.map +1 -0
  29. package/dist/core/crypto.js +170 -0
  30. package/dist/core/crypto.js.map +1 -0
  31. package/dist/core/noise.d.ts +106 -0
  32. package/dist/core/noise.d.ts.map +1 -0
  33. package/dist/core/noise.js +307 -0
  34. package/dist/core/noise.js.map +1 -0
  35. package/dist/events/emitter.d.ts +44 -0
  36. package/dist/events/emitter.d.ts.map +1 -0
  37. package/dist/events/emitter.js +79 -0
  38. package/dist/events/emitter.js.map +1 -0
  39. package/dist/features/index.d.ts +342 -0
  40. package/dist/features/index.d.ts.map +1 -0
  41. package/dist/features/index.js +755 -0
  42. package/dist/features/index.js.map +1 -0
  43. package/dist/fixes/index.d.ts +333 -0
  44. package/dist/fixes/index.d.ts.map +1 -0
  45. package/dist/fixes/index.js +762 -0
  46. package/dist/fixes/index.js.map +1 -0
  47. package/dist/groups/management.d.ts +86 -0
  48. package/dist/groups/management.d.ts.map +1 -0
  49. package/dist/groups/management.js +443 -0
  50. package/dist/groups/management.js.map +1 -0
  51. package/dist/index.d.ts +65 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +236 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/messages/media.d.ts +93 -0
  56. package/dist/messages/media.d.ts.map +1 -0
  57. package/dist/messages/media.js +252 -0
  58. package/dist/messages/media.js.map +1 -0
  59. package/dist/messages/message-encoder.d.ts +70 -0
  60. package/dist/messages/message-encoder.d.ts.map +1 -0
  61. package/dist/messages/message-encoder.js +453 -0
  62. package/dist/messages/message-encoder.js.map +1 -0
  63. package/dist/messages/send.d.ts +101 -0
  64. package/dist/messages/send.d.ts.map +1 -0
  65. package/dist/messages/send.js +409 -0
  66. package/dist/messages/send.js.map +1 -0
  67. package/dist/recovery/index.d.ts +125 -0
  68. package/dist/recovery/index.d.ts.map +1 -0
  69. package/dist/recovery/index.js +584 -0
  70. package/dist/recovery/index.js.map +1 -0
  71. package/dist/skdm/index.d.ts +220 -0
  72. package/dist/skdm/index.d.ts.map +1 -0
  73. package/dist/skdm/index.js +600 -0
  74. package/dist/skdm/index.js.map +1 -0
  75. package/dist/socket/hybrid.d.ts +118 -0
  76. package/dist/socket/hybrid.d.ts.map +1 -0
  77. package/dist/socket/hybrid.js +352 -0
  78. package/dist/socket/hybrid.js.map +1 -0
  79. package/dist/socket/wa-socket.d.ts +300 -0
  80. package/dist/socket/wa-socket.d.ts.map +1 -0
  81. package/dist/socket/wa-socket.js +1094 -0
  82. package/dist/socket/wa-socket.js.map +1 -0
  83. package/dist/socket/ws-socket.d.ts +96 -0
  84. package/dist/socket/ws-socket.d.ts.map +1 -0
  85. package/dist/socket/ws-socket.js +302 -0
  86. package/dist/socket/ws-socket.js.map +1 -0
  87. package/dist/types/index.d.ts +444 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +7 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/utils/jid.d.ts +46 -0
  92. package/dist/utils/jid.d.ts.map +1 -0
  93. package/dist/utils/jid.js +152 -0
  94. package/dist/utils/jid.js.map +1 -0
  95. package/dist/utils/logger.d.ts +45 -0
  96. package/dist/utils/logger.d.ts.map +1 -0
  97. package/dist/utils/logger.js +79 -0
  98. package/dist/utils/logger.js.map +1 -0
  99. package/dist/utils/retry.d.ts +43 -0
  100. package/dist/utils/retry.d.ts.map +1 -0
  101. package/dist/utils/retry.js +175 -0
  102. package/dist/utils/retry.js.map +1 -0
  103. package/docs/API.md +745 -0
  104. package/docs/ARCHITECTURE.md +307 -0
  105. package/docs/BAILEYS_FIXES.md +360 -0
  106. package/docs/FEATURES.md +532 -0
  107. package/docs/PROTOCOL.md +489 -0
  108. package/docs/RECOVERY.md +409 -0
  109. package/docs/SKDM.md +233 -0
  110. package/examples/ai-bot/index.ts +479 -0
  111. package/examples/ai-bot/package.json +17 -0
  112. package/examples/echo-bot/index.ts +171 -0
  113. package/examples/echo-bot/package.json +18 -0
  114. package/examples/group-bot/index.ts +557 -0
  115. package/examples/group-bot/package.json +17 -0
  116. package/examples/rest-gateway/index.ts +499 -0
  117. package/examples/rest-gateway/package.json +19 -0
  118. package/package.json +75 -0
  119. package/src/advanced/features.ts +817 -0
  120. package/src/auth/auth-state.ts +342 -0
  121. package/src/auth/pairing-code.ts +246 -0
  122. package/src/auth/qr-code.ts +191 -0
  123. package/src/calls/handler.ts +153 -0
  124. package/src/core/binary.ts +464 -0
  125. package/src/core/crypto.ts +189 -0
  126. package/src/core/noise.ts +406 -0
  127. package/src/events/emitter.ts +88 -0
  128. package/src/features/index.ts +921 -0
  129. package/src/fixes/index.ts +882 -0
  130. package/src/groups/management.ts +497 -0
  131. package/src/index.ts +274 -0
  132. package/src/messages/media.ts +372 -0
  133. package/src/messages/message-encoder.ts +520 -0
  134. package/src/messages/send.ts +521 -0
  135. package/src/recovery/index.ts +704 -0
  136. package/src/skdm/index.ts +693 -0
  137. package/src/socket/hybrid.ts +414 -0
  138. package/src/socket/wa-socket.ts +1347 -0
  139. package/src/socket/ws-socket.ts +355 -0
  140. package/src/types/index.ts +457 -0
  141. package/src/utils/jid.ts +156 -0
  142. package/src/utils/logger.ts +84 -0
  143. package/src/utils/retry.ts +205 -0
  144. package/tsconfig.json +30 -0
@@ -0,0 +1,693 @@
1
+ /**
2
+ * Waliwa - SKDM (Store Key Data Manager)
3
+ *
4
+ * Subsystem cerdas untuk manage Signal protocol keys:
5
+ * - Auto-regenerate pre-keys saat hampir habis (prevents Baileys pre-key exhaustion)
6
+ * - LRU cache untuk sessions (RAM efficient)
7
+ * - Atomic writes untuk prevent corruption saat disconnect (Baileys issue)
8
+ * - Pre-key rotation policy dengan background jobs
9
+ * - Session cleanup untuk orphaned sessions
10
+ * - Key prefetching untuk known conversations (fix decryption failures)
11
+ *
12
+ * Fixes yang addressed:
13
+ * - Baileys issue: pre-key exhaustion → pre-keys habis gak auto-regenerate
14
+ * - Baileys issue: session corruption on disconnect → atomic write
15
+ * - Baileys issue: decryption failure for old messages → key prefetch
16
+ * - Baileys issue: memory leak dari sessions lama → LRU eviction
17
+ */
18
+
19
+ import { promises as fs } from 'fs';
20
+ import * as path from 'path';
21
+ import { rename } from 'fs/promises';
22
+ import {
23
+ generateCurveKeyPair
24
+ } from '../core/crypto.js';
25
+ import { debounce } from '../utils/retry.js';
26
+ import { SilentLogger } from '../utils/logger.js';
27
+ import type {
28
+ AuthenticationCreds,
29
+ SignalKeyStore,
30
+ KeyPair,
31
+ WALogger
32
+ } from '../types/index.js';
33
+
34
+ // ============== SKDM Config ==============
35
+
36
+ export interface SKDMConfig {
37
+ /** Min pre-keys sebelum regenerate (default: 5) */
38
+ minPreKeys?: number;
39
+ /** Max pre-keys yang di-generate sekaligus (default: 50) */
40
+ maxPreKeysBatch?: number;
41
+ /** Total pre-keys yang di-keep (default: 100) */
42
+ targetPreKeyCount?: number;
43
+ /** Max sessions di RAM cache (default: 500, RAM optimization) */
44
+ maxSessionCacheSize?: number;
45
+ /** Auto-save interval ms (default: 5000) */
46
+ autoSaveIntervalMs?: number;
47
+ /** Enable background key rotation (default: true) */
48
+ enableKeyRotation?: boolean;
49
+ /** Logger */
50
+ logger?: WALogger;
51
+ /** Folder untuk atomic writes */
52
+ storageFolder: string;
53
+ /** File prefix */
54
+ prefix?: string;
55
+ /** Enable session prefetching (default: true, fix decryption failure) */
56
+ enableSessionPrefetch?: boolean;
57
+ /** Session expiry (ms, default: 30 days) */
58
+ sessionExpiryMs?: number;
59
+ }
60
+
61
+ // ============== LRU Cache (RAM efficient) ==============
62
+
63
+ /**
64
+ * LRU cache dengan O(1) operations, manual implementation untuk RAM efficiency.
65
+ * Used untuk caching sessions dan senderKeys.
66
+ */
67
+ export class LRUCache<K, V> {
68
+ private cache: Map<K, V>;
69
+ private maxSize: number;
70
+ private hits: number = 0;
71
+ private misses: number = 0;
72
+
73
+ constructor(maxSize: number = 500) {
74
+ this.cache = new Map();
75
+ this.maxSize = maxSize;
76
+ }
77
+
78
+ get(key: K): V | undefined {
79
+ const value = this.cache.get(key);
80
+ if (value !== undefined) {
81
+ // Move to end (most recent)
82
+ this.cache.delete(key);
83
+ this.cache.set(key, value);
84
+ this.hits++;
85
+ return value;
86
+ }
87
+ this.misses++;
88
+ return undefined;
89
+ }
90
+
91
+ set(key: K, value: V): void {
92
+ if (this.cache.has(key)) {
93
+ // Update existing - move to end
94
+ this.cache.delete(key);
95
+ } else if (this.cache.size >= this.maxSize) {
96
+ // Evict oldest
97
+ const firstKey = this.cache.keys().next().value;
98
+ if (firstKey !== undefined) {
99
+ this.cache.delete(firstKey);
100
+ }
101
+ }
102
+ this.cache.set(key, value);
103
+ }
104
+
105
+ has(key: K): boolean {
106
+ return this.cache.has(key);
107
+ }
108
+
109
+ delete(key: K): boolean {
110
+ return this.cache.delete(key);
111
+ }
112
+
113
+ clear(): void {
114
+ this.cache.clear();
115
+ this.hits = 0;
116
+ this.misses = 0;
117
+ }
118
+
119
+ size(): number {
120
+ return this.cache.size;
121
+ }
122
+
123
+ getStats(): { size: number; hits: number; misses: number; hitRate: number } {
124
+ const total = this.hits + this.misses;
125
+ return {
126
+ size: this.cache.size,
127
+ hits: this.hits,
128
+ misses: this.misses,
129
+ hitRate: total === 0 ? 0 : this.hits / total
130
+ };
131
+ }
132
+ }
133
+
134
+ // ============== Atomic File Writer ==============
135
+
136
+ /**
137
+ * Atomic file writer - prevents corruption saat disconnect/power failure.
138
+ * Baileys issue: session files corrupt jika process kill saat write.
139
+ *
140
+ * Strategy: write to .tmp file, then rename (atomic on POSIX systems).
141
+ */
142
+ export class AtomicFileWriter {
143
+ private static writeLocks: Map<string, Promise<void>> = new Map();
144
+
145
+ static async writeFile(
146
+ filePath: string,
147
+ data: string | Buffer,
148
+ encoding: 'utf-8' | 'binary' = 'utf-8'
149
+ ): Promise<void> {
150
+ // Serialize writes per file (prevent concurrent writes)
151
+ const existingLock = AtomicFileWriter.writeLocks.get(filePath);
152
+ const newPromise = (existingLock ?? Promise.resolve())
153
+ .then(() => AtomicFileWriter.performWrite(filePath, data, encoding));
154
+
155
+ AtomicFileWriter.writeLocks.set(filePath, newPromise);
156
+
157
+ try {
158
+ await newPromise;
159
+ } finally {
160
+ if (AtomicFileWriter.writeLocks.get(filePath) === newPromise) {
161
+ AtomicFileWriter.writeLocks.delete(filePath);
162
+ }
163
+ }
164
+ }
165
+
166
+ private static async performWrite(
167
+ filePath: string,
168
+ data: string | Buffer,
169
+ encoding: 'utf-8' | 'binary'
170
+ ): Promise<void> {
171
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
172
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
173
+ if (encoding === 'utf-8') {
174
+ await fs.writeFile(tmpPath, data as string, 'utf-8');
175
+ } else {
176
+ await fs.writeFile(tmpPath, data as Buffer);
177
+ }
178
+ // Atomic rename (POSIX)
179
+ await rename(tmpPath, filePath);
180
+ }
181
+ }
182
+
183
+ // ============== Pre-Key Manager ==============
184
+
185
+ /**
186
+ * Pre-key manager - auto-regenerate saat low, prevents exhaustion.
187
+ * Fix Baileys issue: pre-keys habis tanpa warning.
188
+ */
189
+ export class PreKeyManager {
190
+ private creds: AuthenticationCreds;
191
+ private keys: SignalKeyStore;
192
+ private minPreKeys: number;
193
+ private maxBatch: number;
194
+ private targetCount: number;
195
+ private logger: WALogger;
196
+ private isRegenerating: boolean = false;
197
+ private lastRegenTime: number = 0;
198
+ private regenDebounce: NodeJS.Timeout | null = null;
199
+
200
+ constructor(
201
+ creds: AuthenticationCreds,
202
+ keys: SignalKeyStore,
203
+ config: Pick<SKDMConfig, 'minPreKeys' | 'maxPreKeysBatch' | 'targetPreKeyCount' | 'logger'>
204
+ ) {
205
+ this.creds = creds;
206
+ this.keys = keys;
207
+ this.minPreKeys = config.minPreKeys ?? 5;
208
+ this.maxBatch = config.maxPreKeysBatch ?? 50;
209
+ this.targetCount = config.targetPreKeyCount ?? 100;
210
+ this.logger = config.logger ?? new SilentLogger();
211
+ }
212
+
213
+ needsRegeneration(): boolean {
214
+ return this.getPreKeyCount() < this.minPreKeys;
215
+ }
216
+
217
+ getPreKeyCount(): number {
218
+ return Object.keys(this.keys.preKeys).length;
219
+ }
220
+
221
+ getNextPreKeyId(): number {
222
+ return this.creds.nextPreKeyId ?? 1;
223
+ }
224
+
225
+ getPreKey(id: number): KeyPair | undefined {
226
+ return this.keys.preKeys[id];
227
+ }
228
+
229
+ /**
230
+ * Consume a pre-key (mark as used)
231
+ * Returns true jika key consumed, false jika tidak ada
232
+ */
233
+ consumePreKey(id: number): boolean {
234
+ const key = this.keys.preKeys[id];
235
+ if (!key) return false;
236
+ delete this.keys.preKeys[id];
237
+ this.logger.debug(`Consumed pre-key ${id}, ${this.getPreKeyCount()} remaining`);
238
+
239
+ if (this.needsRegeneration()) {
240
+ this.scheduleRegeneration();
241
+ }
242
+ return true;
243
+ }
244
+
245
+ scheduleRegeneration(): void {
246
+ if (this.regenDebounce) clearTimeout(this.regenDebounce);
247
+ this.regenDebounce = setTimeout(() => {
248
+ this.regeneratePreKeys().catch(err => {
249
+ this.logger.error(`Pre-key regeneration failed: ${err.message}`);
250
+ });
251
+ }, 2000);
252
+ }
253
+
254
+ async regeneratePreKeys(): Promise<void> {
255
+ if (this.isRegenerating) {
256
+ this.logger.debug('Pre-key regeneration already in progress');
257
+ return;
258
+ }
259
+
260
+ this.isRegenerating = true;
261
+ const currentCount = this.getPreKeyCount();
262
+ const needed = this.targetCount - currentCount;
263
+ const toGenerate = Math.min(needed, this.maxBatch);
264
+
265
+ if (toGenerate <= 0) {
266
+ this.isRegenerating = false;
267
+ return;
268
+ }
269
+
270
+ this.logger.info(`Regenerating ${toGenerate} pre-keys (current: ${currentCount})`);
271
+
272
+ let nextId = this.getNextPreKeyId();
273
+ for (let i = 0; i < toGenerate; i++) {
274
+ const keyPair = generateCurveKeyPair();
275
+ this.keys.preKeys[nextId] = keyPair;
276
+ nextId++;
277
+ }
278
+ this.creds.nextPreKeyId = nextId;
279
+ this.lastRegenTime = Date.now();
280
+
281
+ this.logger.info(`Pre-keys regenerated. Total: ${this.getPreKeyCount()}`);
282
+ this.isRegenerating = false;
283
+ }
284
+
285
+ startBackgroundRotation(intervalMs: number = 60 * 60 * 1000): NodeJS.Timeout {
286
+ return setInterval(() => {
287
+ if (this.needsRegeneration()) {
288
+ this.regeneratePreKeys().catch(err => {
289
+ this.logger.error(`Background pre-key regen failed: ${err.message}`);
290
+ });
291
+ }
292
+ }, intervalMs);
293
+ }
294
+
295
+ getStats(): {
296
+ total: number;
297
+ nextId: number;
298
+ lastRegenTime: number;
299
+ needsRegen: boolean;
300
+ } {
301
+ return {
302
+ total: this.getPreKeyCount(),
303
+ nextId: this.creds.nextPreKeyId ?? 1,
304
+ lastRegenTime: this.lastRegenTime,
305
+ needsRegen: this.needsRegeneration()
306
+ };
307
+ }
308
+ }
309
+
310
+ // ============== Session Manager ==============
311
+
312
+ /**
313
+ * Session manager dengan LRU cache untuk RAM efficiency.
314
+ * Fix Baileys issue: memory leak dari sessions lama.
315
+ */
316
+ export class SessionManager {
317
+ private keys: SignalKeyStore;
318
+ private cache: LRUCache<string, any>;
319
+ private maxCacheSize: number;
320
+ private logger: WALogger;
321
+ private sessionExpiryMs: number;
322
+ private lastAccess: Map<string, number> = new Map();
323
+
324
+ constructor(
325
+ keys: SignalKeyStore,
326
+ config: Pick<SKDMConfig, 'maxSessionCacheSize' | 'logger' | 'sessionExpiryMs'>
327
+ ) {
328
+ this.keys = keys;
329
+ this.maxCacheSize = config.maxSessionCacheSize ?? 500;
330
+ this.cache = new LRUCache<string, any>(this.maxCacheSize);
331
+ this.logger = config.logger ?? new SilentLogger();
332
+ this.sessionExpiryMs = config.sessionExpiryMs ?? 30 * 24 * 60 * 60 * 1000;
333
+ }
334
+
335
+ getSession(jid: string): any | undefined {
336
+ const cached = this.cache.get(jid);
337
+ if (cached !== undefined) return cached;
338
+
339
+ const session = this.keys.sessions[jid];
340
+ if (session) {
341
+ this.cache.set(jid, session);
342
+ this.lastAccess.set(jid, Date.now());
343
+ }
344
+ return session;
345
+ }
346
+
347
+ setSession(jid: string, session: any): void {
348
+ this.cache.set(jid, session);
349
+ this.keys.sessions[jid] = session;
350
+ this.lastAccess.set(jid, Date.now());
351
+ }
352
+
353
+ deleteSession(jid: string): void {
354
+ this.cache.delete(jid);
355
+ delete this.keys.sessions[jid];
356
+ this.lastAccess.delete(jid);
357
+ }
358
+
359
+ /**
360
+ * Cleanup expired sessions (untuk free RAM)
361
+ * Returns count of cleaned sessions
362
+ */
363
+ cleanupExpiredSessions(): number {
364
+ const now = Date.now();
365
+ let cleaned = 0;
366
+ const toDelete: string[] = [];
367
+
368
+ for (const [jid, lastTime] of this.lastAccess.entries()) {
369
+ if (now - lastTime > this.sessionExpiryMs) {
370
+ toDelete.push(jid);
371
+ }
372
+ }
373
+
374
+ for (const jid of toDelete) {
375
+ this.deleteSession(jid);
376
+ cleaned++;
377
+ }
378
+
379
+ if (cleaned > 0) {
380
+ this.logger.info(`Cleaned ${cleaned} expired sessions`);
381
+ }
382
+ return cleaned;
383
+ }
384
+
385
+ /**
386
+ * Prefetch sessions untuk multiple JIDs (fix decryption failure)
387
+ * Bulk load sessions sebelum decrypt group messages
388
+ */
389
+ prefetchSessions(jids: string[]): void {
390
+ for (const jid of jids) {
391
+ if (!this.cache.has(jid) && this.keys.sessions[jid]) {
392
+ this.cache.set(jid, this.keys.sessions[jid]);
393
+ this.lastAccess.set(jid, Date.now());
394
+ }
395
+ }
396
+ }
397
+
398
+ getStats() {
399
+ return {
400
+ ...this.cache.getStats(),
401
+ totalSessions: Object.keys(this.keys.sessions).length,
402
+ expiredSessions: Array.from(this.lastAccess.values())
403
+ .filter(t => Date.now() - t > this.sessionExpiryMs).length
404
+ };
405
+ }
406
+ }
407
+
408
+ // ============== AppState Key Manager ==============
409
+
410
+ /**
411
+ * AppState key manager - manage app state sync keys dengan deduplication.
412
+ * Fix Baileys issue: app state sync keys bertumpuk tanpa cleanup.
413
+ */
414
+ export class AppStateKeyManager {
415
+ private keys: SignalKeyStore;
416
+ private logger: WALogger;
417
+ private cache: LRUCache<string, Uint8Array>;
418
+
419
+ constructor(keys: SignalKeyStore, logger?: WALogger) {
420
+ this.keys = keys;
421
+ this.logger = logger ?? new SilentLogger();
422
+ this.cache = new LRUCache<string, Uint8Array>(100);
423
+ }
424
+
425
+ get(keyId: string): Uint8Array | undefined {
426
+ const cached = this.cache.get(keyId);
427
+ if (cached) return cached;
428
+ const stored = this.keys.appStateSyncKeys[keyId];
429
+ if (stored) {
430
+ this.cache.set(keyId, stored);
431
+ return stored;
432
+ }
433
+ return undefined;
434
+ }
435
+
436
+ set(keyId: string, key: Uint8Array): void {
437
+ this.cache.set(keyId, key);
438
+ this.keys.appStateSyncKeys[keyId] = key;
439
+ }
440
+
441
+ cleanup(maxKeep: number = 50): number {
442
+ const allKeys = Object.keys(this.keys.appStateSyncKeys);
443
+ if (allKeys.length <= maxKeep) return 0;
444
+
445
+ const toDelete = allKeys.slice(0, allKeys.length - maxKeep);
446
+ for (const keyId of toDelete) {
447
+ delete this.keys.appStateSyncKeys[keyId];
448
+ this.cache.delete(keyId);
449
+ }
450
+ this.logger.debug(`Cleaned ${toDelete.length} old app state keys`);
451
+ return toDelete.length;
452
+ }
453
+ }
454
+
455
+ // ============== Identity Key Manager ==============
456
+
457
+ /**
458
+ * Identity key manager dengan verification.
459
+ * Fix Baileys issue: identity changes gak tracked properly.
460
+ */
461
+ export class IdentityKeyManager {
462
+ private keys: SignalKeyStore;
463
+ private logger: WALogger;
464
+ private trustedIdentities: Map<string, Uint8Array> = new Map();
465
+ private changedIdentities: Set<string> = new Set();
466
+
467
+ constructor(keys: SignalKeyStore, logger?: WALogger) {
468
+ this.keys = keys;
469
+ this.logger = logger ?? new SilentLogger();
470
+ for (const [jid, keyPair] of Object.entries(keys.identityKeys)) {
471
+ if (keyPair?.public) {
472
+ this.trustedIdentities.set(jid, keyPair.public);
473
+ }
474
+ }
475
+ }
476
+
477
+ get(jid: string): Uint8Array | undefined {
478
+ return this.trustedIdentities.get(jid) ?? this.keys.identityKeys[jid]?.public;
479
+ }
480
+
481
+ /**
482
+ * Save identity key, detect changes
483
+ * Returns true jika identity CHANGED (security concern)
484
+ */
485
+ save(jid: string, identity: Uint8Array): boolean {
486
+ const existing = this.trustedIdentities.get(jid);
487
+ const changed = existing !== undefined && !this.bytesEqual(existing, identity);
488
+
489
+ this.trustedIdentities.set(jid, identity);
490
+ this.keys.identityKeys[jid] = { private: new Uint8Array(0), public: identity };
491
+
492
+ if (changed) {
493
+ this.changedIdentities.add(jid);
494
+ this.logger.warn(`Identity changed for ${jid} - possible security issue!`);
495
+ }
496
+ return changed;
497
+ }
498
+
499
+ getChangedIdentities(): string[] {
500
+ return Array.from(this.changedIdentities);
501
+ }
502
+
503
+ acknowledge(jid: string): void {
504
+ this.changedIdentities.delete(jid);
505
+ }
506
+
507
+ trust(jid: string): void {
508
+ this.changedIdentities.delete(jid);
509
+ }
510
+
511
+ private bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
512
+ if (a.length !== b.length) return false;
513
+ let diff = 0;
514
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
515
+ return diff === 0;
516
+ }
517
+ }
518
+
519
+ // ============== Main SKDM Class ==============
520
+
521
+ /**
522
+ * SKDM - Store Key Data Manager
523
+ * Single entry point untuk semua key management operations.
524
+ */
525
+ export class SKDM {
526
+ private config: SKDMConfig;
527
+ private logger: WALogger;
528
+ private creds: AuthenticationCreds;
529
+ private keys: SignalKeyStore;
530
+
531
+ public preKeyManager: PreKeyManager;
532
+ public sessionManager: SessionManager;
533
+ public identityManager: IdentityKeyManager;
534
+ public appStateManager: AppStateKeyManager;
535
+
536
+ private dirty: { creds: boolean; keys: boolean } = { creds: false, keys: false };
537
+ private saveCredsDebounced: () => void;
538
+ private saveKeysDebounced: () => void;
539
+ private backgroundJobs: NodeJS.Timeout[] = [];
540
+ private isInitialized: boolean = false;
541
+
542
+ constructor(creds: AuthenticationCreds, keys: SignalKeyStore, config: SKDMConfig) {
543
+ this.creds = creds;
544
+ this.keys = keys;
545
+ this.config = config;
546
+ this.logger = config.logger ?? new SilentLogger();
547
+
548
+ this.preKeyManager = new PreKeyManager(creds, keys, config);
549
+ this.sessionManager = new SessionManager(keys, config);
550
+ this.identityManager = new IdentityKeyManager(keys, this.logger);
551
+ this.appStateManager = new AppStateKeyManager(keys, this.logger);
552
+
553
+ const debounceMs = 100;
554
+ this.saveCredsDebounced = debounce(() => this.flushCreds(), debounceMs);
555
+ this.saveKeysDebounced = debounce(() => this.flushKeys(), debounceMs);
556
+ }
557
+
558
+ /**
559
+ * Initialize SKDM - load state, start background jobs
560
+ */
561
+ async initialize(): Promise<void> {
562
+ if (this.isInitialized) return;
563
+
564
+ this.logger.info('Initializing SKDM...');
565
+
566
+ if (this.preKeyManager.needsRegeneration()) {
567
+ await this.preKeyManager.regeneratePreKeys();
568
+ }
569
+
570
+ if (this.config.enableKeyRotation !== false) {
571
+ const preKeyJob = this.preKeyManager.startBackgroundRotation(60 * 60 * 1000);
572
+ this.backgroundJobs.push(preKeyJob);
573
+ }
574
+
575
+ const sessionCleanupJob = setInterval(() => {
576
+ const cleaned = this.sessionManager.cleanupExpiredSessions();
577
+ if (cleaned > 0) this.markKeysDirty();
578
+ }, 6 * 60 * 60 * 1000);
579
+ this.backgroundJobs.push(sessionCleanupJob);
580
+
581
+ const appStateCleanupJob = setInterval(() => {
582
+ const cleaned = this.appStateManager.cleanup(50);
583
+ if (cleaned > 0) this.markKeysDirty();
584
+ }, 24 * 60 * 60 * 1000);
585
+ this.backgroundJobs.push(appStateCleanupJob);
586
+
587
+ this.isInitialized = true;
588
+ this.logger.info('SKDM initialized successfully');
589
+ }
590
+
591
+ markCredsDirty(): void {
592
+ this.dirty.creds = true;
593
+ this.saveCredsDebounced();
594
+ }
595
+
596
+ markKeysDirty(): void {
597
+ this.dirty.keys = true;
598
+ this.saveKeysDebounced();
599
+ }
600
+
601
+ private async flushCreds(): Promise<void> {
602
+ if (!this.dirty.creds) return;
603
+ this.dirty.creds = false;
604
+
605
+ const data = JSON.stringify(this.creds, (key, value) => {
606
+ if (value instanceof Uint8Array) {
607
+ return { type: 'Uint8Array', data: Array.from(value) };
608
+ }
609
+ if (typeof value === 'object' && value !== null && 'private' in value && 'public' in value &&
610
+ value.private instanceof Uint8Array) {
611
+ return {
612
+ type: 'KeyPair',
613
+ private: Array.from(value.private),
614
+ public: Array.from(value.public)
615
+ };
616
+ }
617
+ return value;
618
+ });
619
+
620
+ const filePath = path.join(this.config.storageFolder, `${this.config.prefix ?? 'waliwa'}-creds.json`);
621
+ try {
622
+ await AtomicFileWriter.writeFile(filePath, data, 'utf-8');
623
+ this.logger.debug('Credentials saved (atomic)');
624
+ } catch (err: any) {
625
+ this.logger.error(`Failed to save creds: ${err.message}`);
626
+ this.dirty.creds = true;
627
+ }
628
+ }
629
+
630
+ private async flushKeys(): Promise<void> {
631
+ if (!this.dirty.keys) return;
632
+ this.dirty.keys = false;
633
+
634
+ const data = JSON.stringify(this.keys, (key, value) => {
635
+ if (value instanceof Uint8Array) {
636
+ return { type: 'Uint8Array', data: Array.from(value) };
637
+ }
638
+ if (typeof value === 'object' && value !== null && 'private' in value && 'public' in value &&
639
+ value.private instanceof Uint8Array) {
640
+ return {
641
+ type: 'KeyPair',
642
+ private: Array.from(value.private),
643
+ public: Array.from(value.public)
644
+ };
645
+ }
646
+ return value;
647
+ });
648
+
649
+ const filePath = path.join(this.config.storageFolder, `${this.config.prefix ?? 'waliwa'}-keys.json`);
650
+ try {
651
+ await AtomicFileWriter.writeFile(filePath, data, 'utf-8');
652
+ this.logger.debug('Keys saved (atomic)');
653
+ } catch (err: any) {
654
+ this.logger.error(`Failed to save keys: ${err.message}`);
655
+ this.dirty.keys = true;
656
+ }
657
+ }
658
+
659
+ async flush(): Promise<void> {
660
+ await Promise.all([this.flushCreds(), this.flushKeys()]);
661
+ }
662
+
663
+ getStats(): SKDMStats {
664
+ return {
665
+ preKeys: this.preKeyManager.getStats(),
666
+ sessions: this.sessionManager.getStats(),
667
+ identities: {
668
+ total: this.identityManager['trustedIdentities'].size,
669
+ changed: this.identityManager.getChangedIdentities().length
670
+ },
671
+ appStateKeys: Object.keys(this.keys.appStateSyncKeys).length,
672
+ pendingSaves: (this.dirty.creds ? 1 : 0) + (this.dirty.keys ? 1 : 0)
673
+ };
674
+ }
675
+
676
+ async destroy(): Promise<void> {
677
+ for (const job of this.backgroundJobs) {
678
+ clearInterval(job);
679
+ }
680
+ this.backgroundJobs = [];
681
+ await this.flush();
682
+ this.isInitialized = false;
683
+ this.logger.info('SKDM destroyed');
684
+ }
685
+ }
686
+
687
+ export interface SKDMStats {
688
+ preKeys: ReturnType<PreKeyManager['getStats']>;
689
+ sessions: ReturnType<SessionManager['getStats']>;
690
+ identities: { total: number; changed: number };
691
+ appStateKeys: number;
692
+ pendingSaves: number;
693
+ }