kersiosocket 0.0.0-stage → 0.5.0-dev

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 (55) hide show
  1. package/BUILD-INFO.md +14 -0
  2. package/CHANGELOG.md +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +145 -2
  5. package/bench/wabinary.js +31 -0
  6. package/docs/PROTOCOL-ROADMAP.md +43 -0
  7. package/docs/RESEARCH-BASELINE.md +25 -0
  8. package/docs/WABINARY.md +37 -0
  9. package/examples/basic.js +18 -0
  10. package/examples/client-payload.js +21 -0
  11. package/examples/device-props.js +18 -0
  12. package/examples/sessions.js +11 -0
  13. package/examples/wabinary.js +15 -0
  14. package/licenses/THIRD-PARTY-NOTICES.md +7 -0
  15. package/licenses/whatsmeow-MPL-2.0.txt +374 -0
  16. package/package.json +36 -3
  17. package/scripts/import-token-table.js +95 -0
  18. package/src/index.js +25 -0
  19. package/src/pairing/adapter.js +115 -0
  20. package/src/pairing/lifecycle.js +120 -0
  21. package/src/pairing/qr.js +38 -0
  22. package/src/proto/client-payload.js +185 -0
  23. package/src/proto/device-props.js +143 -0
  24. package/src/proto/handshake.js +79 -0
  25. package/src/proto/wire.js +137 -0
  26. package/src/protocol/node-protocol.js +155 -0
  27. package/src/session/codec.js +25 -0
  28. package/src/session/encrypted-store.js +107 -0
  29. package/src/session/manager.js +61 -0
  30. package/src/session/stores.js +154 -0
  31. package/src/socket/connection.js +265 -0
  32. package/src/socket/frame.js +77 -0
  33. package/src/socket/make.js +70 -0
  34. package/src/socket/noise.js +126 -0
  35. package/src/socket/transport.js +39 -0
  36. package/src/utils/crypto.js +52 -0
  37. package/src/utils/latency.js +32 -0
  38. package/src/utils/lru.js +76 -0
  39. package/src/wabinary/codec.js +374 -0
  40. package/src/wabinary/tags.js +25 -0
  41. package/src/wabinary/token-source.js +146 -0
  42. package/src/wabinary/tokens.json +1287 -0
  43. package/test/connection.test.js +153 -0
  44. package/test/device-props.test.js +67 -0
  45. package/test/encrypted-store.test.js +54 -0
  46. package/test/frame.test.js +37 -0
  47. package/test/helpers.js +43 -0
  48. package/test/node-protocol.test.js +90 -0
  49. package/test/noise.test.js +66 -0
  50. package/test/pairing-adapter.test.js +71 -0
  51. package/test/pairing.test.js +32 -0
  52. package/test/proto.test.js +64 -0
  53. package/test/session.test.js +134 -0
  54. package/test/token-source.test.js +35 -0
  55. package/test/wabinary.test.js +147 -0
@@ -0,0 +1,265 @@
1
+ 'use strict'
2
+ /**
3
+ * Connection: WebSocket + Noise handshake + reconnect (exponential backoff + jitter).
4
+ *
5
+ * Event:
6
+ * connection.update { connection: 'connecting'|'handshaking'|'noise-ready'|'close', ... }
7
+ * frame Buffer plaintext hasil dekripsi Noise
8
+ * connection.error Error protokol/transport
9
+ * handler.error Error sinkron/async dari satu listener; listener lain tetap dipanggil
10
+ */
11
+ const { EventEmitter } = require('node:events')
12
+ const { createTransport } = require('./transport')
13
+ const { FrameDecoder, encodeFrame } = require('./frame')
14
+ const { ClientHandshake, WA_HEADER } = require('./noise')
15
+ const { generateKeyPair, EMPTY } = require('../utils/crypto')
16
+ const { LatencyTracker } = require('../utils/latency')
17
+ const { encodeClientPayload } = require('../proto/client-payload')
18
+
19
+ const noopLog = { debug() {}, info() {}, warn() {}, error() {} }
20
+
21
+ const DEFAULTS = {
22
+ url: 'wss://web.whatsapp.com/ws/chat',
23
+ origin: 'https://web.whatsapp.com',
24
+ clientPayload: EMPTY, // empty buffer is transport smoke-test only; real login needs verified auth payload
25
+ // stableMs: koneksi baru dianggap 'sehat' (counter backoff direset) kalau bertahan selama ini
26
+ reconnect: { enabled: true, minDelay: 1000, maxDelay: 30000, factor: 2, stableMs: 30000 }
27
+ }
28
+
29
+ class Connection extends EventEmitter {
30
+ constructor(config = {}) {
31
+ super()
32
+ this.config = { ...DEFAULTS, ...config, reconnect: { ...DEFAULTS.reconnect, ...config.reconnect } }
33
+ this.log = config.logger || noopLog
34
+ const requestedPayload = this.config.clientPayload
35
+ if (Buffer.isBuffer(requestedPayload) || requestedPayload instanceof Uint8Array) {
36
+ this.clientPayload = Buffer.from(requestedPayload.buffer, requestedPayload.byteOffset, requestedPayload.byteLength)
37
+ } else if (requestedPayload && typeof requestedPayload === 'object') {
38
+ this.clientPayload = encodeClientPayload(requestedPayload)
39
+ } else {
40
+ throw new TypeError('clientPayload harus Buffer atau objek ClientPayload')
41
+ }
42
+ this.staticKey = config.staticKey || generateKeyPair()
43
+ this.state = 'close'
44
+ this.ended = false
45
+ this.attempt = 0
46
+ this.startedAt = 0
47
+ this.readyAt = 0
48
+ this.counters = { bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 }
49
+ this.frameLatency = new LatencyTracker() // dekripsi + dispatch per frame
50
+ this.handshakeMs = 0
51
+ this._t = null
52
+ this._decoder = null
53
+ this._hs = null
54
+ this._noise = null
55
+ this._timer = null
56
+ this._generation = 0
57
+ this._closeHandled = false
58
+ }
59
+
60
+ connect() {
61
+ if (this._t) return
62
+ clearTimeout(this._timer)
63
+ this._timer = null
64
+ this.ended = false
65
+ this._setState('connecting')
66
+ const factory = this.config.transportFactory || createTransport
67
+ let t
68
+ try {
69
+ t = factory(this.config.url, { origin: this.config.origin, headers: this.config.headers })
70
+ } catch (err) {
71
+ this._safeEmit('connection.error', err)
72
+ this._setState('close', { error: err })
73
+ this._scheduleReconnect()
74
+ return
75
+ }
76
+ this._t = t
77
+ const generation = ++this._generation
78
+ this._closeHandled = false
79
+ t.on('open', () => {
80
+ if (this._t !== t || generation !== this._generation) return
81
+ try { this._onOpen() } catch (err) { this._abortConnection(err, t, generation) }
82
+ })
83
+ t.on('message', (d) => {
84
+ if (this._t !== t || generation !== this._generation) return
85
+ this._onMessage(d)
86
+ })
87
+ t.on('close', (code, reason) => this._onClose(code, reason, t, generation))
88
+ t.on('transport.error', (err) => {
89
+ if (this._t === t && generation === this._generation) this._safeEmit('connection.error', err)
90
+ })
91
+ }
92
+
93
+ end() {
94
+ this.ended = true
95
+ clearTimeout(this._timer)
96
+ if (this._t) this._t.close()
97
+ }
98
+
99
+ /** Kirim data (plaintext) lewat kanal Noise terenkripsi. */
100
+ sendFrame(plain) {
101
+ if (this.state !== 'noise-ready') throw new Error('koneksi belum siap (handshake belum selesai)')
102
+ this._write(encodeFrame(this._noise.encrypt(plain)))
103
+ }
104
+
105
+ stats() {
106
+ const m = process.memoryUsage()
107
+ return {
108
+ state: this.state,
109
+ uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
110
+ ...this.counters,
111
+ handshakeMs: this.handshakeMs,
112
+ frameLatencyMs: this.frameLatency.summary(),
113
+ serverCertificateVerified: false,
114
+ rssMB: +(m.rss / 1048576).toFixed(1),
115
+ heapUsedMB: +(m.heapUsed / 1048576).toFixed(1)
116
+ }
117
+ }
118
+
119
+ _setState(connection, extra) {
120
+ this.state = connection
121
+ this._safeEmit('connection.update', { connection, ...extra })
122
+ }
123
+
124
+ _write(buf) {
125
+ this.counters.bytesOut += buf.length
126
+ this.counters.framesOut++
127
+ this._t.send(buf)
128
+ }
129
+
130
+ _onOpen() {
131
+ this._decoder = new FrameDecoder()
132
+ this._hs = new ClientHandshake({ staticKey: this.staticKey })
133
+ this.startedAt = Date.now()
134
+ this._setState('handshaking')
135
+ this._write(encodeFrame(this._hs.hello(), WA_HEADER))
136
+ }
137
+
138
+ _onMessage(chunk) {
139
+ if (!this._decoder || !this._t) return
140
+ try {
141
+ this.counters.bytesIn += chunk && typeof chunk.length === 'number' ? chunk.length : 0
142
+ const frames = this._decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
143
+ for (const frame of frames) {
144
+ if (!this._t) break
145
+ this.counters.framesIn++
146
+ try {
147
+ this._onFrame(frame)
148
+ } catch (err) {
149
+ this._abortConnection(err, this._t, this._generation)
150
+ break
151
+ }
152
+ }
153
+ } catch (err) {
154
+ this._abortConnection(err, this._t, this._generation)
155
+ }
156
+ }
157
+
158
+ _onFrame(frame) {
159
+ if (this._hs) {
160
+ const hs = this._hs
161
+ const finish = hs.processServerHello(frame, this.clientPayload)
162
+ this._noise = hs.noise
163
+ this._hs = null
164
+ // Publish the completed Noise state before writing ClientFinish. A fake or
165
+ // custom transport may synchronously deliver the server reply from send().
166
+ this.readyAt = Date.now()
167
+ this.handshakeMs = Date.now() - this.startedAt
168
+ this._setState('noise-ready', {
169
+ serverStatic: hs.serverStatic,
170
+ serverCertificateVerified: false
171
+ })
172
+ this._write(encodeFrame(finish))
173
+ return
174
+ }
175
+ if (!this._noise) throw new Error('Noise state tidak tersedia di luar handshake')
176
+ const done = this.frameLatency.start()
177
+ try {
178
+ const plain = this._noise.decrypt(frame)
179
+ this._safeEmit('frame', plain)
180
+ } finally {
181
+ done()
182
+ }
183
+ }
184
+
185
+ /** Dispatch each listener independently, so one user callback cannot starve others. */
186
+ _safeEmit(event, ...args) {
187
+ const listeners = this.rawListeners(event)
188
+ if (listeners.length === 0) return false
189
+ for (const listener of listeners) {
190
+ try {
191
+ const result = Reflect.apply(listener, this, args)
192
+ if (result && typeof result.then === 'function') {
193
+ Promise.resolve(result).catch((err) => this._reportHandlerError(err, event))
194
+ }
195
+ } catch (err) {
196
+ this._reportHandlerError(err, event)
197
+ }
198
+ }
199
+ return true
200
+ }
201
+
202
+ _reportHandlerError(error, sourceEvent) {
203
+ const err = error instanceof Error ? error : new Error(String(error))
204
+ this.log.error(`listener '${sourceEvent}' melempar error: ${err.message}`)
205
+ if (sourceEvent === 'handler.error') return
206
+ const listeners = this.rawListeners('handler.error')
207
+ for (const listener of listeners) {
208
+ try {
209
+ const result = Reflect.apply(listener, this, [err, sourceEvent])
210
+ if (result && typeof result.then === 'function') {
211
+ Promise.resolve(result).catch((nested) => this.log.error(`listener 'handler.error' melempar error: ${nested.message}`))
212
+ }
213
+ } catch (nested) {
214
+ this.log.error(`listener 'handler.error' melempar error: ${nested.message}`)
215
+ }
216
+ }
217
+ }
218
+
219
+ _abortConnection(error, transport = this._t, generation = this._generation) {
220
+ const err = error instanceof Error ? error : new Error(String(error))
221
+ this._safeEmit('connection.error', err)
222
+ if (!transport || transport !== this._t || generation !== this._generation) return
223
+ // Transition/cleanup synchronously. The transport's eventual close event is de-duplicated.
224
+ this._onClose(1002, Buffer.from('protocol/transport failure'), transport, generation)
225
+ try { transport.close() } catch (closeError) {
226
+ this.log.warn(`gagal menutup transport: ${closeError.message}`)
227
+ }
228
+ }
229
+
230
+ _onClose(code, reason, source = this._t, generation = this._generation) {
231
+ if (!source || source !== this._t || generation !== this._generation || this._closeHandled) return
232
+ this._closeHandled = true
233
+ const lived = this.readyAt ? Date.now() - this.readyAt : 0
234
+ const wasReady = this.readyAt > 0
235
+ this.readyAt = 0
236
+ this._t = this._hs = this._noise = this._decoder = null
237
+ this._setState('close', { code, reason: Buffer.isBuffer(reason) ? reason.toString('utf8') : reason })
238
+ const r = this.config.reconnect
239
+ if (lived >= r.stableMs) this.attempt = 0
240
+ else if (wasReady && this.clientPayload.length === 0) {
241
+ this.log.warn('server menutup koneksi tepat setelah handshake: clientPayload masih kosong (login/registrasi belum diimplementasi)')
242
+ }
243
+ this._scheduleReconnect()
244
+ }
245
+
246
+ _scheduleReconnect() {
247
+ const r = this.config.reconnect
248
+ if (this.ended || !r.enabled || this._timer) return
249
+ const base = Math.min(r.maxDelay, r.minDelay * Math.pow(r.factor, this.attempt++))
250
+ const delay = Math.round(base * (0.8 + Math.random() * 0.4))
251
+ this.log.info(`reconnect dalam ${delay}ms (percobaan ${this.attempt})`)
252
+ this._timer = setTimeout(() => {
253
+ this._timer = null
254
+ if (!this.ended && !this._t) this.connect()
255
+ }, delay)
256
+ }
257
+
258
+ /** Fail a decrypted protocol frame closed, while preserving configured backoff behavior. */
259
+ failProtocol(error) {
260
+ this._abortConnection(error)
261
+ }
262
+
263
+ }
264
+
265
+ module.exports = { Connection }
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+ /**
3
+ * Framing WhatsApp: [prefix opsional][panjang 3 byte big-endian][payload].
4
+ * Prefix (WA_HEADER) hanya dikirim di frame pertama.
5
+ */
6
+ const MAX_FRAME = 0xffffff
7
+
8
+ function encodeFrame(payload, prefix) {
9
+ if (payload.length > MAX_FRAME) throw new Error('frame terlalu besar')
10
+ const head = Buffer.allocUnsafe(3)
11
+ head[0] = payload.length >>> 16
12
+ head[1] = (payload.length >>> 8) & 0xff
13
+ head[2] = payload.length & 0xff
14
+ return prefix ? Buffer.concat([prefix, head, payload]) : Buffer.concat([head, payload])
15
+ }
16
+
17
+ /**
18
+ * Penyusun frame dari potongan data WebSocket (frame bisa terbelah/menyatu).
19
+ * Potongan disimpan dalam daftar dan baru digabung SEKALI saat satu frame lengkap,
20
+ * jadi frame besar yang datang bertahap tidak memicu concat berulang (O(n^2)).
21
+ * Sisa data yang belum lengkap disalin supaya tidak menahan buffer induk yang besar.
22
+ */
23
+ class FrameDecoder {
24
+ constructor() {
25
+ this.chunks = []
26
+ this.total = 0
27
+ this.need = 0 // total byte (header + payload) frame yang sedang dirakit; 0 = belum tahu
28
+ }
29
+
30
+ push(chunk) {
31
+ if (chunk.length) { this.chunks.push(chunk); this.total += chunk.length }
32
+ const frames = []
33
+ for (;;) {
34
+ if (this.need === 0) {
35
+ if (this.total < 3) break
36
+ this.need = 3 + ((this._byte(0) << 16) | (this._byte(1) << 8) | this._byte(2))
37
+ }
38
+ if (this.total < this.need) break
39
+ frames.push(this._take(this.need).subarray(3))
40
+ this.need = 0
41
+ }
42
+ if (this.chunks.length === 1 && this.chunks[0].length) this.chunks[0] = Buffer.from(this.chunks[0])
43
+ return frames
44
+ }
45
+
46
+ _byte(i) {
47
+ for (const c of this.chunks) {
48
+ if (i < c.length) return c[i]
49
+ i -= c.length
50
+ }
51
+ return 0
52
+ }
53
+
54
+ /** Ambil n byte dari depan. Tanpa salinan bila seluruhnya ada di satu potongan. */
55
+ _take(n) {
56
+ const first = this.chunks[0]
57
+ this.total -= n
58
+ if (first.length >= n) {
59
+ if (first.length === n) this.chunks.shift()
60
+ else this.chunks[0] = first.subarray(n)
61
+ return first.subarray(0, n)
62
+ }
63
+ const out = Buffer.allocUnsafe(n)
64
+ let off = 0
65
+ while (off < n) {
66
+ const c = this.chunks[0]
67
+ const take = Math.min(c.length, n - off)
68
+ c.copy(out, off, 0, take)
69
+ off += take
70
+ if (take === c.length) this.chunks.shift()
71
+ else this.chunks[0] = c.subarray(take)
72
+ }
73
+ return out
74
+ }
75
+ }
76
+
77
+ module.exports = { encodeFrame, FrameDecoder, MAX_FRAME }
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+ const { Connection } = require('./connection')
3
+ const { generateKeyPair } = require('../utils/crypto')
4
+ const { scoped } = require('../session/stores')
5
+ const { PairingLifecycle } = require('../pairing/lifecycle')
6
+ const { NodeProtocol } = require('../protocol/node-protocol')
7
+
8
+ /**
9
+ * Buat socket kersiosocket.
10
+ *
11
+ * @param {object} [config]
12
+ * autoConnect true -> sambung otomatis (default)
13
+ * store store session {get,set,delete}; bila ada, kredensial disimpan/dimuat otomatis
14
+ * sessionId nama sesi di dalam store (default 'default')
15
+ * logger, reconnect, url, origin, staticKey, clientPayload
16
+ *
17
+ * Event (sock.ev): connection.update, creds.update, frame, connection.error, handler.error
18
+ * sock.ready: Promise yang selesai setelah kredensial dimuat (dan koneksi dimulai bila autoConnect).
19
+ */
20
+ function makeSocket(config = {}) {
21
+ const conn = new Connection(config)
22
+ const pairing = new PairingLifecycle({ sessionId: config.sessionId || 'default' })
23
+ const protocolConfig = config.nodeProtocol
24
+ const protocol = protocolConfig ? new NodeProtocol(conn, protocolConfig === true ? {} : protocolConfig) : null
25
+ const store = config.store ? scoped(config.store, config.sessionId || 'default') : null
26
+ let creds = null
27
+
28
+ const saveCreds = async () => {
29
+ if (!store || !creds) return
30
+ await store.set('creds', creds)
31
+ conn._safeEmit('creds.update', creds)
32
+ }
33
+
34
+ const start = async () => {
35
+ if (store) {
36
+ creds = (await store.get('creds')) || null
37
+ if (!creds) {
38
+ creds = { staticKey: config.staticKey || generateKeyPair(), createdAt: Date.now() }
39
+ await saveCreds()
40
+ }
41
+ conn.staticKey = creds.staticKey
42
+ }
43
+ if (config.autoConnect !== false) conn.connect()
44
+ }
45
+
46
+ const sock = {
47
+ ev: conn,
48
+ sessionId: config.sessionId || 'default',
49
+ pairing,
50
+ connect: () => conn.connect(),
51
+ end: () => { protocol?.close(); pairing.close(); conn.end() },
52
+ sendFrame: (buf) => conn.sendFrame(buf),
53
+ protocol,
54
+ sendNode: (node) => { if (!protocol) throw new Error('aktifkan nodeProtocol pada makeSocket() untuk sendNode()'); return protocol.sendNode(node) },
55
+ query: (node, options) => { if (!protocol) return Promise.reject(new Error('aktifkan nodeProtocol pada makeSocket() untuk query()')); return protocol.query(node, options) },
56
+ stats: () => conn.stats(),
57
+ saveCreds,
58
+ get creds() { return creds },
59
+ get state() { return conn.state }
60
+ }
61
+
62
+ // tunda satu tick supaya pemanggil sempat memasang listener
63
+ sock.ready = new Promise((resolve) => process.nextTick(resolve))
64
+ .then(start)
65
+ .catch((err) => { conn._safeEmit('connection.error', err) })
66
+
67
+ return sock
68
+ }
69
+
70
+ module.exports = { makeSocket }
@@ -0,0 +1,126 @@
1
+ 'use strict'
2
+ /**
3
+ * Noise Protocol (pola XX, X25519, AES-GCM, SHA-256) untuk transport WhatsApp Web.
4
+ * Ditulis dari spesifikasi Noise (noiseprotocol.org), bukan salinan library lain.
5
+ *
6
+ * NoiseState : state mesin Noise (hash, chaining key, kunci, counter)
7
+ * ClientHandshake : alur sisi klien (initiator)
8
+ */
9
+ const {
10
+ EMPTY, generateKeyPair, sharedKey, sha256, hkdf, aesGcmEncrypt, aesGcmDecrypt
11
+ } = require('../utils/crypto')
12
+ const { encodeClientHello, encodeClientFinish, decodeServerHello } = require('../proto/handshake')
13
+
14
+ // Nama protokol, dipad nol sampai 32 byte (panjang hash SHA-256)
15
+ const NOISE_MODE = Buffer.from('Noise_XX_25519_AESGCM_SHA256\0\0\0\0')
16
+ // 'W','A', versi magic, versi dictionary. PERLU diverifikasi ke versi WA Web terbaru.
17
+ const WA_HEADER = Buffer.from([0x57, 0x41, 6, 3])
18
+ // IV AES-GCM memakai counter 32 bit; lewat ini nonce akan berulang (fatal), jadi berhenti.
19
+ const MAX_COUNTER = 0xffffffff
20
+
21
+ class NoiseState {
22
+ constructor(header) {
23
+ this.hash = NOISE_MODE.length === 32 ? NOISE_MODE : sha256(NOISE_MODE)
24
+ this.salt = this.hash // chaining key
25
+ this.encKey = this.hash
26
+ this.decKey = this.hash
27
+ this.readCounter = 0
28
+ this.writeCounter = 0
29
+ this.finished = false
30
+ this.authenticate(header)
31
+ }
32
+
33
+ /** hash = SHA256(hash || data). Tidak dilakukan setelah handshake selesai. */
34
+ authenticate(data) {
35
+ if (!this.finished) this.hash = sha256(Buffer.concat([this.hash, data]))
36
+ }
37
+
38
+ _iv(counter) {
39
+ const iv = Buffer.alloc(12)
40
+ iv.writeUInt32BE(counter, 8)
41
+ return iv
42
+ }
43
+
44
+ encrypt(plaintext) {
45
+ if (this.writeCounter >= MAX_COUNTER) throw new Error('noise: counter tulis habis, koneksi harus dibuat ulang')
46
+ const aad = this.finished ? EMPTY : this.hash
47
+ const out = aesGcmEncrypt(this.encKey, this._iv(this.writeCounter++), plaintext, aad)
48
+ this.authenticate(out)
49
+ return out
50
+ }
51
+
52
+ decrypt(ciphertext) {
53
+ if (this.readCounter >= MAX_COUNTER) throw new Error('noise: counter baca habis, koneksi harus dibuat ulang')
54
+ const aad = this.finished ? EMPTY : this.hash
55
+ const out = aesGcmDecrypt(this.decKey, this._iv(this.readCounter++), ciphertext, aad)
56
+ this.authenticate(ciphertext)
57
+ return out
58
+ }
59
+
60
+ /** MixKey: (ck, k) = HKDF(ck, input). Counter di-reset. */
61
+ mixIntoKey(data) {
62
+ const out = hkdf(data, 64, this.salt)
63
+ this.salt = out.subarray(0, 32)
64
+ this.encKey = this.decKey = out.subarray(32)
65
+ this.readCounter = this.writeCounter = 0
66
+ }
67
+
68
+ /** Split: turunkan kunci transport akhir. */
69
+ finish(isInitiator) {
70
+ const out = hkdf(EMPTY, 64, this.salt)
71
+ const a = out.subarray(0, 32), b = out.subarray(32)
72
+ this.encKey = isInitiator ? a : b
73
+ this.decKey = isInitiator ? b : a
74
+ this.hash = EMPTY
75
+ this.readCounter = this.writeCounter = 0
76
+ this.finished = true
77
+ }
78
+ }
79
+
80
+ class ClientHandshake {
81
+ /**
82
+ * @param {{ staticKey: {public:Buffer, private:Buffer}, ephemeralKey?: object, header?: Buffer }} opts
83
+ */
84
+ constructor({ staticKey, ephemeralKey = generateKeyPair(), header = WA_HEADER }) {
85
+ this.staticKey = staticKey
86
+ this.ephemeralKey = ephemeralKey
87
+ this.noise = new NoiseState(header)
88
+ this.noise.authenticate(ephemeralKey.public)
89
+ this.serverStatic = null
90
+ this.serverCertificate = null
91
+ }
92
+
93
+ /** Pesan pertama (ClientHello), sudah di-encode protobuf. */
94
+ hello() {
95
+ return encodeClientHello(this.ephemeralKey.public)
96
+ }
97
+
98
+ /**
99
+ * Proses ServerHello, kembalikan ClientFinish (protobuf).
100
+ * TODO v0.2: verifikasi rantai sertifikat server terhadap root key WhatsApp.
101
+ * @param {Buffer} data isi frame ServerHello
102
+ * @param {Buffer} clientPayload ClientPayload (login/registrasi), sudah di-encode protobuf
103
+ */
104
+ processServerHello(data, clientPayload) {
105
+ const n = this.noise
106
+ const sh = decodeServerHello(data)
107
+
108
+ n.authenticate(sh.ephemeral)
109
+ n.mixIntoKey(sharedKey(this.ephemeralKey.private, sh.ephemeral))
110
+
111
+ this.serverStatic = n.decrypt(sh.static)
112
+ n.mixIntoKey(sharedKey(this.ephemeralKey.private, this.serverStatic))
113
+
114
+ this.serverCertificate = n.decrypt(sh.payload)
115
+
116
+ const encStatic = n.encrypt(this.staticKey.public)
117
+ n.mixIntoKey(sharedKey(this.staticKey.private, sh.ephemeral))
118
+
119
+ const encPayload = n.encrypt(clientPayload)
120
+ n.finish(true)
121
+
122
+ return encodeClientFinish({ static: encStatic, payload: encPayload })
123
+ }
124
+ }
125
+
126
+ module.exports = { NoiseState, ClientHandshake, WA_HEADER, NOISE_MODE, MAX_COUNTER }
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+ /**
3
+ * Adapter WebSocket. Memakai paket `ws` bila terpasang (bisa set header Origin,
4
+ * disarankan untuk WhatsApp), jika tidak jatuh ke WebSocket bawaan Node.
5
+ * Event yang dipancarkan: open, message(Buffer), close(code, reason), transport.error(err)
6
+ */
7
+ const { EventEmitter } = require('node:events')
8
+
9
+ function createTransport(url, { origin, headers } = {}) {
10
+ const t = new EventEmitter()
11
+ let WS = null
12
+ try { WS = require('ws') } catch { /* opsional */ }
13
+
14
+ if (WS) {
15
+ const ws = new WS(url, { origin, headers, handshakeTimeout: 20000 })
16
+ ws.on('open', () => t.emit('open'))
17
+ ws.on('message', (d) => t.emit('message', Buffer.isBuffer(d) ? d : Buffer.from(d)))
18
+ ws.on('close', (code, reason) => t.emit('close', code, String(reason || '')))
19
+ ws.on('error', (err) => t.emit('transport.error', err))
20
+ t.send = (buf) => ws.send(buf)
21
+ t.close = () => ws.close()
22
+ return t
23
+ }
24
+
25
+ if (typeof WebSocket === 'undefined') {
26
+ throw new Error('WebSocket tidak tersedia. Pakai Node >= 22 atau jalankan: npm i ws')
27
+ }
28
+ const ws = new WebSocket(url)
29
+ ws.binaryType = 'arraybuffer'
30
+ ws.onopen = () => t.emit('open')
31
+ ws.onmessage = (e) => t.emit('message', Buffer.from(e.data))
32
+ ws.onclose = (e) => t.emit('close', e.code, e.reason)
33
+ ws.onerror = (e) => t.emit('transport.error', e.error || new Error('WebSocket error'))
34
+ t.send = (buf) => ws.send(buf)
35
+ t.close = () => ws.close()
36
+ return t
37
+ }
38
+
39
+ module.exports = { createTransport }
@@ -0,0 +1,52 @@
1
+ 'use strict'
2
+ /**
3
+ * Helper kripto kersiosocket. Hanya memakai node:crypto (tanpa dependensi).
4
+ * Semua fungsi bekerja langsung di atas Buffer agar hemat memori.
5
+ */
6
+ const crypto = require('node:crypto')
7
+
8
+ const EMPTY = Buffer.alloc(0)
9
+ // Prefix DER untuk membungkus kunci mentah X25519 (32 byte)
10
+ const X25519_SPKI = Buffer.from('302a300506032b656e032100', 'hex')
11
+ const X25519_PKCS8 = Buffer.from('302e020100300506032b656e04220420', 'hex')
12
+
13
+ /** Buat pasangan kunci Curve25519 mentah (32 byte masing-masing). */
14
+ function generateKeyPair() {
15
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519')
16
+ return {
17
+ public: publicKey.export({ type: 'spki', format: 'der' }).subarray(X25519_SPKI.length),
18
+ private: privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(X25519_PKCS8.length)
19
+ }
20
+ }
21
+
22
+ /** Diffie-Hellman X25519: rahasia bersama dari kunci privat sendiri + publik lawan. */
23
+ function sharedKey(privateKey, publicKey) {
24
+ const priv = crypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8, privateKey]), format: 'der', type: 'pkcs8' })
25
+ const pub = crypto.createPublicKey({ key: Buffer.concat([X25519_SPKI, publicKey]), format: 'der', type: 'spki' })
26
+ return crypto.diffieHellman({ privateKey: priv, publicKey: pub })
27
+ }
28
+
29
+ function sha256(data) {
30
+ return crypto.createHash('sha256').update(data).digest()
31
+ }
32
+
33
+ function hkdf(ikm, length, salt, info = EMPTY) {
34
+ return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, length))
35
+ }
36
+
37
+ /** AES-256-GCM. Hasil = ciphertext || tag(16). */
38
+ function aesGcmEncrypt(key, iv, plaintext, aad = EMPTY) {
39
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv)
40
+ c.setAAD(aad)
41
+ return Buffer.concat([c.update(plaintext), c.final(), c.getAuthTag()])
42
+ }
43
+
44
+ function aesGcmDecrypt(key, iv, data, aad = EMPTY) {
45
+ if (data.length < 16) throw new Error('ciphertext terlalu pendek')
46
+ const d = crypto.createDecipheriv('aes-256-gcm', key, iv)
47
+ d.setAAD(aad)
48
+ d.setAuthTag(data.subarray(data.length - 16))
49
+ return Buffer.concat([d.update(data.subarray(0, data.length - 16)), d.final()])
50
+ }
51
+
52
+ module.exports = { EMPTY, generateKeyPair, sharedKey, sha256, hkdf, aesGcmEncrypt, aesGcmDecrypt }
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+ /** Pelacak latensi ringan: ring buffer Float64Array tetap (tanpa alokasi per sampel). */
3
+ class LatencyTracker {
4
+ constructor(size = 256) {
5
+ this.buf = new Float64Array(size)
6
+ this.n = 0
7
+ this.i = 0
8
+ this.max = 0
9
+ }
10
+
11
+ /** Mulai ukur; kembalikan fungsi yang mencatat selisih (ms) saat dipanggil. */
12
+ start() {
13
+ const t0 = process.hrtime.bigint()
14
+ return () => this.add(Number(process.hrtime.bigint() - t0) / 1e6)
15
+ }
16
+
17
+ add(ms) {
18
+ this.buf[this.i] = ms
19
+ this.i = (this.i + 1) % this.buf.length
20
+ if (this.n < this.buf.length) this.n++
21
+ if (ms > this.max) this.max = ms
22
+ }
23
+
24
+ summary() {
25
+ if (!this.n) return { count: 0, p50: 0, p95: 0, max: 0 }
26
+ const a = Array.from(this.buf.subarray(0, this.n)).sort((x, y) => x - y)
27
+ const q = (p) => +a[Math.min(a.length - 1, Math.floor(p * a.length))].toFixed(3)
28
+ return { count: this.n, p50: q(0.5), p95: q(0.95), max: +this.max.toFixed(3) }
29
+ }
30
+ }
31
+
32
+ module.exports = { LatencyTracker }