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,153 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { makeSocket, encodeClientPayload } = require('../src')
6
+ const { FrameDecoder, encodeFrame } = require('../src/socket/frame')
7
+ const { WA_HEADER } = require('../src/socket/noise')
8
+ const { ServerHandshake } = require('./helpers')
9
+
10
+ /** Transport palsu di memori + server Noise di belakangnya. */
11
+ function fakeServer(onEvent) {
12
+ const factory = () => {
13
+ const t = new EventEmitter()
14
+ const dec = new FrameDecoder()
15
+ const hs = new ServerHandshake()
16
+ let first = true, done = false
17
+ t.send = (buf) => {
18
+ if (first) {
19
+ assert.deepEqual(buf.subarray(0, 4), WA_HEADER, 'frame pertama harus diawali WA_HEADER')
20
+ buf = buf.subarray(4)
21
+ first = false
22
+ }
23
+ for (const f of dec.push(buf)) {
24
+ if (!hs.noise) {
25
+ const sh = hs.processClientHello(f)
26
+ setImmediate(() => t.emit('message', encodeFrame(sh)))
27
+ } else if (!done) {
28
+ done = true
29
+ onEvent('server-ready', { hs, payload: hs.processClientFinish(f).payload, t })
30
+ } else onEvent('client-frame', { data: hs.noise.decrypt(f) })
31
+ }
32
+ }
33
+ t.close = () => setImmediate(() => t.emit('close', 1000, ''))
34
+ setImmediate(() => t.emit('open'))
35
+ return t
36
+ }
37
+ return factory
38
+ }
39
+
40
+ test('koneksi penuh: handshake, terima & kirim frame, stats, end()', async () => {
41
+ let srv
42
+ const gotFromClient = new Promise((resolve) => {
43
+ srv = fakeServer((type, d) => {
44
+ if (type === 'server-ready') {
45
+ assert.deepEqual(d.payload, Buffer.from('cp'))
46
+ d.t.emit('message', encodeFrame(d.hs.noise.encrypt(Buffer.from('halo dari server'))))
47
+ }
48
+ if (type === 'client-frame') resolve(d.data)
49
+ })
50
+ })
51
+
52
+ const sock = makeSocket({
53
+ transportFactory: srv, clientPayload: Buffer.from('cp'), reconnect: { enabled: false }
54
+ })
55
+ const states = []
56
+ sock.ev.on('connection.update', (u) => states.push(u.connection))
57
+
58
+ const frame = await new Promise((resolve) => sock.ev.on('frame', resolve))
59
+ assert.deepEqual(frame, Buffer.from('halo dari server'))
60
+ assert.equal(sock.state, 'noise-ready')
61
+
62
+ sock.sendFrame(Buffer.from('pong'))
63
+ assert.deepEqual(await gotFromClient, Buffer.from('pong'))
64
+
65
+ const s = sock.stats()
66
+ assert.equal(s.framesIn, 2)
67
+ assert.ok(s.rssMB > 0)
68
+
69
+ sock.end()
70
+ await new Promise((r) => setTimeout(r, 20))
71
+ assert.deepEqual(states, ['connecting', 'handshaking', 'noise-ready', 'close'])
72
+ })
73
+
74
+ test('sendFrame sebelum handshake selesai melempar error', () => {
75
+ const sock = makeSocket({ autoConnect: false })
76
+ assert.throws(() => sock.sendFrame(Buffer.from('x')), /belum siap/)
77
+ })
78
+
79
+ test('listener frame yang melempar error TIDAK memutus koneksi; latensi tercatat', async () => {
80
+ const srv = fakeServer((type, d) => {
81
+ if (type === 'server-ready') {
82
+ d.t.emit('message', encodeFrame(d.hs.noise.encrypt(Buffer.from('satu'))))
83
+ setTimeout(() => d.t.emit('message', encodeFrame(d.hs.noise.encrypt(Buffer.from('dua')))), 10)
84
+ }
85
+ })
86
+ const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: false } })
87
+ const handlerErrors = []
88
+ const frames = []
89
+ const observerFrames = []
90
+ sock.ev.on('frame', (f) => observerFrames.push(f.toString()))
91
+ sock.ev.on('handler.error', (e) => handlerErrors.push(e.message))
92
+ sock.ev.on('frame', (f) => { frames.push(f.toString()); if (f.toString() === 'satu') throw new Error('bug listener') })
93
+ await new Promise((r) => setTimeout(r, 60))
94
+ assert.deepEqual(frames, ['satu', 'dua'])
95
+ assert.deepEqual(handlerErrors, ['bug listener'])
96
+ assert.deepEqual(observerFrames, ['satu', 'dua'])
97
+ assert.equal(sock.state, 'noise-ready')
98
+ const s = sock.stats()
99
+ assert.ok(s.handshakeMs >= 0)
100
+ assert.equal(s.frameLatencyMs.count, 2)
101
+ assert.ok(s.frameLatencyMs.p95 >= s.frameLatencyMs.p50)
102
+ sock.end()
103
+ })
104
+
105
+ test('frame rusak (gagal dekripsi) tetap menutup koneksi sebagai error protokol', async () => {
106
+ const srv = fakeServer((type, d) => {
107
+ if (type === 'server-ready') d.t.emit('message', encodeFrame(Buffer.alloc(40, 1)))
108
+ })
109
+ const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: false } })
110
+ const errs = []
111
+ sock.ev.on('connection.error', (e) => errs.push(e))
112
+ await new Promise((resolve, reject) => {
113
+ const timer = setTimeout(() => reject(new Error('timeout waiting for protocol close')), 1500)
114
+ sock.ev.on('connection.update', (update) => { if (update.connection === 'close') { clearTimeout(timer); resolve() } })
115
+ })
116
+ assert.equal(errs.length, 1)
117
+ assert.equal(sock.state, 'close')
118
+ })
119
+
120
+ test('backoff TIDAK direset bila server menutup tepat setelah handshake', async () => {
121
+ const delays = []
122
+ const log = { debug() {}, info(m) { const x = /dalam (\d+)ms/.exec(m); if (x) delays.push(+x[1]) }, warn() {}, error() {} }
123
+ const srv = fakeServer((type, d) => { if (type === 'server-ready') setImmediate(() => d.t.emit('close', 1006, '')) })
124
+ const sock = makeSocket({
125
+ transportFactory: srv, logger: log,
126
+ reconnect: { minDelay: 10, maxDelay: 1000, factor: 2, stableMs: 30000 }
127
+ })
128
+ await new Promise((r) => setTimeout(r, 400))
129
+ sock.end()
130
+ assert.ok(delays.length >= 3, `harus ada beberapa reconnect, dapat ${delays.length}`)
131
+ assert.ok(delays[2] > delays[0] * 2, `delay harus naik (exponential): ${delays.join(',')}`)
132
+ })
133
+
134
+ test('ClientPayload terstruktur di-encode sebelum dikirim dalam ClientFinish', async () => {
135
+ const options = {
136
+ mode: 'login', username: '6281234567890', passive: false, pushName: 'Kersio test',
137
+ userAgent: { platform: 'WEB', appVersion: [2, 3000, 1040000000], localeLanguage: 'id', localeCountry: 'ID' },
138
+ webInfo: { version: 'test-version-only', webSubPlatform: 'WEB_BROWSER' },
139
+ connectType: 'WIFI_UNKNOWN', connectReason: 'USER_ACTIVATED'
140
+ }
141
+ const expected = encodeClientPayload(options)
142
+ const received = new Promise((resolve) => {
143
+ const sock = makeSocket({
144
+ transportFactory: fakeServer((type, d) => {
145
+ if (type === 'server-ready') resolve(d.payload)
146
+ }),
147
+ clientPayload: options,
148
+ reconnect: { enabled: false }
149
+ })
150
+ sock.ev.on('connection.error', (e) => { throw e })
151
+ })
152
+ assert.deepEqual(await received, expected)
153
+ })
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const crypto = require('node:crypto')
5
+ const { parseFields } = require('../src/proto/wire')
6
+ const { encodeClientPayload } = require('../src/proto/client-payload')
7
+ const { encodeDeviceProps, buildAppVersionHash, encodeBigEndianUnsigned, createDevicePairingData } = require('../src/proto/device-props')
8
+
9
+ test('DeviceProps uses the pinned protobuf field layout (independent golden bytes)', () => {
10
+ const bytes = encodeDeviceProps({
11
+ os: 'Windows', version: [10, 15, 7], platformType: 'CHROME', requireFullSync: true,
12
+ historySyncConfig: {
13
+ storageQuotaMb: 10240,
14
+ inlineInitialPayloadInE2EeMsg: true,
15
+ supportCallLogHistory: false,
16
+ supportCagReactionsAndPolls: true
17
+ }
18
+ })
19
+ assert.equal(bytes.toString('hex'), '0a0757696e646f77731206080a100f1807180120012a09188050200130004001')
20
+ const fields = parseFields(bytes)
21
+ assert.deepEqual(fields.map(({ fieldNumber, wireType }) => [fieldNumber, wireType]), [[1,2],[2,2],[3,0],[4,0],[5,2]])
22
+ })
23
+
24
+ test('registration helpers encode big-endian IDs and the observed app-version MD5', () => {
25
+ assert.equal(encodeBigEndianUnsigned(0x01020304, 4).toString('hex'), '01020304')
26
+ assert.equal(encodeBigEndianUnsigned(0x010203, 3).toString('hex'), '010203')
27
+ assert.throws(() => encodeBigEndianUnsigned(0x1000000, 3), /tidak muat/)
28
+ const version = [2, 3000, 1043857760]
29
+ const hash = buildAppVersionHash(version)
30
+ assert.equal(hash.toString('hex'), 'f6f65c0607a1cdffd8752b051fc95edf')
31
+ assert.deepEqual(hash, crypto.createHash('md5').update('2.3000.1043857760').digest())
32
+ })
33
+
34
+ test('createDevicePairingData prepares fields only from caller-supplied key material', () => {
35
+ const deviceProps = encodeDeviceProps({ os: 'Windows', version: [10, 15, 7], platformType: 'CHROME' })
36
+ const data = createDevicePairingData({
37
+ registrationId: 0x01020304,
38
+ identityPublicKey: Buffer.alloc(32, 1),
39
+ signedPreKey: { keyId: 0x010203, publicKey: Buffer.alloc(32, 2), signature: Buffer.alloc(64, 3) },
40
+ appVersion: [2, 3000, 1043857760], deviceProps
41
+ })
42
+ assert.equal(data.eRegid.toString('hex'), '01020304')
43
+ assert.equal(data.eKeytype.toString('hex'), '05')
44
+ assert.equal(data.eIdent.length, 32)
45
+ assert.equal(data.eSkeyId.toString('hex'), '010203')
46
+ assert.equal(data.eSkeyVal.length, 32)
47
+ assert.equal(data.eSkeySig.length, 64)
48
+ assert.equal(data.buildHash.toString('hex'), 'f6f65c0607a1cdffd8752b051fc95edf')
49
+ assert.deepEqual(data.deviceProps, deviceProps)
50
+ assert.throws(() => createDevicePairingData({ registrationId: 1, identityPublicKey: Buffer.alloc(31), signedPreKey: { keyId: 1, publicKey: Buffer.alloc(32), signature: Buffer.alloc(64) }, appVersion: [1, 2, 3], deviceProps }), /identityPublicKey harus 32 byte/)
51
+ })
52
+
53
+ test('ClientPayload encodes newer optional fields from pinned schema by their wire numbers', () => {
54
+ const payload = encodeClientPayload({
55
+ mode: 'login', username: '6281234567890', passive: true, pull: true, lidDbMigrated: false,
56
+ userAgent: { platform: 'WEB', appVersion: [2, 3000, 1043857760] },
57
+ webInfo: { version: 'schema-fixture', webSubPlatform: 'WEB_BROWSER' },
58
+ shortConnect: true, product: 'WHATSAPP_LID', trafficAnonymization: 'STANDARD', accountType: 'DEFAULT'
59
+ })
60
+ const fieldNos = parseFields(payload).map((field) => field.fieldNumber)
61
+ assert.ok(fieldNos.includes(10))
62
+ assert.ok(fieldNos.includes(20))
63
+ assert.ok(fieldNos.includes(33))
64
+ assert.ok(fieldNos.includes(40))
65
+ assert.ok(fieldNos.includes(41))
66
+ assert.ok(fieldNos.includes(42))
67
+ })
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const crypto = require('node:crypto')
5
+ const { MemoryStore } = require('../src/session/stores')
6
+ const { EncryptedStore } = require('../src/session/encrypted-store')
7
+
8
+ test('EncryptedStore encrypts session values and round-trips Buffer fields', async () => {
9
+ const raw = new MemoryStore()
10
+ const key = crypto.randomBytes(32)
11
+ const store = new EncryptedStore(raw, key)
12
+ const input = { noiseKey: { public: Buffer.alloc(32, 7), private: Buffer.alloc(32, 8) }, registrationId: 12345, name: 'fixture' }
13
+ await store.set('alpha:creds', input)
14
+ const envelope = await raw.get('alpha:creds')
15
+ assert.equal(envelope.format, 'kersiosocket:aes-256-gcm:v1')
16
+ assert.equal(JSON.stringify(envelope).includes('fixture'), false)
17
+ const output = await store.get('alpha:creds')
18
+ assert.deepEqual(output, input)
19
+ assert.deepEqual(await store.keys('alpha:'), ['alpha:creds'])
20
+ })
21
+
22
+ test('EncryptedStore rejects wrong keys, tampering, and plaintext without fallback', async () => {
23
+ const raw = new MemoryStore()
24
+ const store = new EncryptedStore(raw, Buffer.alloc(32, 1))
25
+ await store.set('s:creds', { token: 'secret-value' })
26
+ const wrong = new EncryptedStore(raw, Buffer.alloc(32, 2))
27
+ await assert.rejects(wrong.get('s:creds'), /autentikasi\/dekripsi gagal/)
28
+
29
+ const envelope = await raw.get('s:creds')
30
+ envelope.data = Buffer.from('changed').toString('base64')
31
+ await raw.set('s:creds', envelope)
32
+ await assert.rejects(store.get('s:creds'), /autentikasi\/dekripsi gagal/)
33
+ await raw.set('legacy:creds', { token: 'plaintext' })
34
+ await assert.rejects(store.get('legacy:creds'), /tidak ada fallback plaintext/)
35
+ })
36
+
37
+ test('EncryptedStore binds ciphertext to the storage key', async () => {
38
+ const raw = new MemoryStore()
39
+ const store = new EncryptedStore(raw, Buffer.alloc(32, 3))
40
+ await store.set('first:creds', { a: 1 })
41
+ const encrypted = await raw.get('first:creds')
42
+ await raw.set('second:creds', encrypted)
43
+ await assert.rejects(store.get('second:creds'), /autentikasi\/dekripsi gagal/)
44
+ })
45
+
46
+
47
+ test('EncryptedStore rejects use after close and validates storage keys', async () => {
48
+ const store = new EncryptedStore(new MemoryStore(), Buffer.alloc(32, 9))
49
+ await assert.rejects(store.set('', { secret: true }), /key harus string/)
50
+ await assert.rejects(store.get('bad\0key'), /key harus string/)
51
+ await store.close()
52
+ await assert.rejects(store.get('s:creds'), /sudah ditutup/)
53
+ await assert.rejects(store.set('s:creds', {}), /sudah ditutup/)
54
+ })
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { FrameDecoder, encodeFrame } = require('../src/socket/frame')
5
+ const { NoiseState, MAX_COUNTER, WA_HEADER } = require('../src/socket/noise')
6
+
7
+ test('FrameDecoder: frame terbelah di tiap byte dan beberapa frame menyatu', () => {
8
+ const a = Buffer.from('satu'), b = Buffer.alloc(70000, 7), c = Buffer.alloc(0)
9
+ const wire = Buffer.concat([encodeFrame(a), encodeFrame(b), encodeFrame(c), encodeFrame(Buffer.from('empat'))])
10
+ const dec = new FrameDecoder()
11
+ const got = []
12
+ for (let i = 0; i < wire.length; i += 1000) got.push(...dec.push(wire.subarray(i, i + 1000)))
13
+ assert.equal(got.length, 4)
14
+ assert.deepEqual(got[0], a); assert.deepEqual(got[1], b); assert.equal(got[2].length, 0)
15
+ assert.deepEqual(got[3], Buffer.from('empat'))
16
+
17
+ const dec2 = new FrameDecoder(), got2 = []
18
+ for (const byte of encodeFrame(Buffer.from('halo'))) got2.push(...dec2.push(Buffer.from([byte])))
19
+ assert.deepEqual(got2, [Buffer.from('halo')])
20
+ })
21
+
22
+ test('FrameDecoder: sisa parsial disalin (tidak beralias ke buffer asal)', () => {
23
+ const dec = new FrameDecoder()
24
+ const big = Buffer.concat([encodeFrame(Buffer.from('x')), Buffer.from([0, 0])])
25
+ dec.push(big)
26
+ big[big.length - 1] = 0xff // ubah buffer asal; sisa di decoder harus tidak ikut berubah
27
+ assert.deepEqual(dec.push(Buffer.from([1, 0xaa])), [Buffer.from([0xaa])])
28
+ })
29
+
30
+ test('Noise: counter yang habis ditolak (cegah nonce berulang)', () => {
31
+ const n = new NoiseState(WA_HEADER)
32
+ n.finish(true)
33
+ n.writeCounter = MAX_COUNTER
34
+ assert.throws(() => n.encrypt(Buffer.from('x')), /counter tulis habis/)
35
+ n.readCounter = MAX_COUNTER
36
+ assert.throws(() => n.decrypt(Buffer.alloc(32)), /counter baca habis/)
37
+ })
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+ /** Sisi SERVER Noise XX untuk keperluan test (loopback). Bukan bagian library publik. */
3
+ const { NoiseState } = require('../src/socket/noise')
4
+ const { generateKeyPair, sharedKey } = require('../src/utils/crypto')
5
+ const {
6
+ encodeServerHello, decodeClientHello, decodeClientFinish
7
+ } = require('../src/proto/handshake')
8
+ const { WA_HEADER } = require('../src/socket/noise')
9
+
10
+ class ServerHandshake {
11
+ constructor({ staticKey = generateKeyPair(), certificate = Buffer.from('sertifikat-palsu'), header = WA_HEADER } = {}) {
12
+ this.staticKey = staticKey
13
+ this.certificate = certificate
14
+ this.header = header
15
+ this.noise = null
16
+ this.eph = null
17
+ }
18
+
19
+ processClientHello(data) {
20
+ const { ephemeral: clientEph } = decodeClientHello(data)
21
+ const n = (this.noise = new NoiseState(this.header))
22
+ n.authenticate(clientEph)
23
+ this.eph = generateKeyPair()
24
+ n.authenticate(this.eph.public)
25
+ n.mixIntoKey(sharedKey(this.eph.private, clientEph))
26
+ const encStatic = n.encrypt(this.staticKey.public)
27
+ n.mixIntoKey(sharedKey(this.staticKey.private, clientEph))
28
+ const encCert = n.encrypt(this.certificate)
29
+ return encodeServerHello({ ephemeral: this.eph.public, static: encStatic, payload: encCert })
30
+ }
31
+
32
+ processClientFinish(data) {
33
+ const n = this.noise
34
+ const fin = decodeClientFinish(data)
35
+ const clientStatic = n.decrypt(fin.static)
36
+ n.mixIntoKey(sharedKey(this.eph.private, clientStatic))
37
+ const payload = n.decrypt(fin.payload)
38
+ n.finish(false)
39
+ return { clientStatic, payload }
40
+ }
41
+ }
42
+
43
+ module.exports = { ServerHandshake }
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { NodeProtocol, QueryTimeoutError, RemoteIQError } = require('../src/protocol/node-protocol')
6
+ const { encodeBinaryNode, decodeBinaryNode } = require('../src/wabinary/codec')
7
+
8
+ const tokens = { single: [], double: [[], [], [], []] }
9
+ function connection() {
10
+ const conn = new EventEmitter()
11
+ conn.sent = []
12
+ conn.sendFrame = (frame) => conn.sent.push(Buffer.from(frame))
13
+ conn._safeEmit = (event, ...args) => conn.emit(event, ...args)
14
+ conn.failProtocol = (error) => { conn.failure = error; conn.emit('connection.update', { connection: 'close' }) }
15
+ return conn
16
+ }
17
+
18
+
19
+ test('NodeProtocol menolak token table kosong kecuali mode tes diaktifkan', () => {
20
+ const conn = connection()
21
+ assert.throws(() => new NodeProtocol(conn, { tokens }), /token dictionary non-kosong/)
22
+ const protocol = new NodeProtocol(conn, { tokens, allowEmptyTokenTable: true })
23
+ protocol.close()
24
+ })
25
+
26
+ test('NodeProtocol encodes outbound node and resolves matching IQ response', async () => {
27
+ const conn = connection()
28
+ const protocol = new NodeProtocol(conn, { tokens, allowEmptyTokenTable: true })
29
+ const promise = protocol.query({ tag: 'iq', attrs: { type: 'get', to: 's.whatsapp.net' }, content: [{ tag: 'ping', attrs: {} }] })
30
+ assert.equal(conn.sent.length, 1)
31
+ const sent = decodeBinaryNode(conn.sent[0], { tokens, requirePrefix: true })
32
+ assert.equal(sent.tag, 'iq')
33
+ assert.ok(sent.attrs.id)
34
+ assert.equal(sent.attrs.type, 'get')
35
+ conn.emit('frame', encodeBinaryNode({ tag: 'iq', attrs: { id: sent.attrs.id, type: 'result' }, content: [{ tag: 'pong', attrs: {} }] }, { tokens }))
36
+ const result = await promise
37
+ assert.equal(result.attrs.type, 'result')
38
+ assert.equal(result.content[0].tag, 'pong')
39
+ assert.equal(protocol.stats().pendingQueries, 0)
40
+ protocol.close()
41
+ })
42
+
43
+ test('NodeProtocol rejects IQ error, timeout and abort without leaking pending entries', async () => {
44
+ const conn = connection()
45
+ const protocol = new NodeProtocol(conn, { tokens, timeoutMs: 30, allowEmptyTokenTable: true })
46
+ const remote = protocol.query({ tag: 'iq', attrs: { id: 'remote-1', type: 'get' } })
47
+ conn.emit('frame', encodeBinaryNode({ tag: 'iq', attrs: { id: 'remote-1', type: 'error', code: '400' } }, { tokens }))
48
+ await assert.rejects(remote, RemoteIQError)
49
+
50
+ const timed = protocol.query({ tag: 'iq', attrs: { id: 'timeout-1', type: 'get' } }, { timeoutMs: 10 })
51
+ await assert.rejects(timed, QueryTimeoutError)
52
+ assert.equal(protocol.stats().pendingQueries, 0)
53
+
54
+ const controller = new AbortController()
55
+ const aborted = protocol.query({ tag: 'iq', attrs: { id: 'abort-1', type: 'get' } }, { signal: controller.signal, timeoutMs: 1000 })
56
+ controller.abort(new Error('test cancelled'))
57
+ await assert.rejects(aborted, /test cancelled/)
58
+ assert.equal(protocol.stats().pendingQueries, 0)
59
+ protocol.close()
60
+ })
61
+
62
+
63
+ test('NodeProtocol ignores non-response IQ types and auto-generates ID for null', async () => {
64
+ const conn = connection()
65
+ const protocol = new NodeProtocol(conn, { tokens, allowEmptyTokenTable: true })
66
+ const pending = protocol.query({ tag: 'iq', attrs: { id: null, type: 'get' } }, { timeoutMs: 500 })
67
+ const sent = decodeBinaryNode(conn.sent[0], { tokens, requirePrefix: true })
68
+ assert.ok(sent.attrs.id)
69
+ conn.emit('frame', encodeBinaryNode({ tag: 'iq', attrs: { id: sent.attrs.id, type: 'set' } }, { tokens }))
70
+ assert.equal(protocol.stats().pendingQueries, 1)
71
+ conn.emit('frame', encodeBinaryNode({ tag: 'iq', attrs: { id: sent.attrs.id, type: 'result' } }, { tokens }))
72
+ const result = await pending
73
+ assert.equal(result.attrs.type, 'result')
74
+ protocol.close()
75
+ })
76
+
77
+ test('NodeProtocol fails pending IQs closed and can fail closed on malformed WABinary', async () => {
78
+ const conn = connection()
79
+ const protocol = new NodeProtocol(conn, { tokens, allowEmptyTokenTable: true })
80
+ const pending = protocol.query({ tag: 'iq', attrs: { id: 'pending-1', type: 'get' } })
81
+ conn.emit('connection.update', { connection: 'close' })
82
+ await assert.rejects(pending, /koneksi ditutup/)
83
+
84
+ let protocolError
85
+ conn.on('protocol.error', (error) => { protocolError = error })
86
+ conn.emit('frame', Buffer.from([0x00, 0xF8]))
87
+ assert.ok(protocolError)
88
+ assert.equal(conn.failure, protocolError)
89
+ protocol.close()
90
+ })
@@ -0,0 +1,66 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { ClientHandshake } = require('../src/socket/noise')
5
+ const { generateKeyPair, sharedKey } = require('../src/utils/crypto')
6
+ const { FrameDecoder, encodeFrame } = require('../src/socket/frame')
7
+ const { ServerHandshake } = require('./helpers')
8
+
9
+ test('X25519: DH simetris', () => {
10
+ const a = generateKeyPair(), b = generateKeyPair()
11
+ assert.equal(a.public.length, 32)
12
+ assert.equal(a.private.length, 32)
13
+ assert.deepEqual(sharedKey(a.private, b.public), sharedKey(b.private, a.public))
14
+ })
15
+
16
+ test('frame: encode/decode, terbelah dan menyatu', () => {
17
+ const p1 = Buffer.from('halo'), p2 = Buffer.alloc(70000, 7)
18
+ const wire = Buffer.concat([encodeFrame(p1), encodeFrame(p2)])
19
+ const dec = new FrameDecoder()
20
+ const got = []
21
+ for (let i = 0; i < wire.length; i += 1000) got.push(...dec.push(wire.subarray(i, i + 1000)))
22
+ assert.equal(got.length, 2)
23
+ assert.deepEqual(got[0], p1)
24
+ assert.deepEqual(got[1], p2)
25
+ assert.deepEqual(new FrameDecoder().push(wire).length, 2)
26
+ })
27
+
28
+ test('Noise XX: handshake klien <-> server dan kanal terenkripsi dua arah', () => {
29
+ const clientStatic = generateKeyPair()
30
+ const server = new ServerHandshake()
31
+ const client = new ClientHandshake({ staticKey: clientStatic })
32
+
33
+ const serverHello = server.processClientHello(client.hello())
34
+ const finish = client.processServerHello(serverHello, Buffer.from('payload-login'))
35
+ const res = server.processClientFinish(finish)
36
+
37
+ assert.deepEqual(res.clientStatic, clientStatic.public)
38
+ assert.deepEqual(res.payload, Buffer.from('payload-login'))
39
+ assert.deepEqual(client.serverStatic, server.staticKey.public)
40
+ assert.deepEqual(client.serverCertificate, Buffer.from('sertifikat-palsu'))
41
+
42
+ // kanal transport
43
+ for (let i = 0; i < 5; i++) {
44
+ const m = Buffer.from('klien->server ' + i)
45
+ assert.deepEqual(server.noise.decrypt(client.noise.encrypt(m)), m)
46
+ const r = Buffer.from('server->klien ' + i)
47
+ assert.deepEqual(client.noise.decrypt(server.noise.encrypt(r)), r)
48
+ }
49
+ })
50
+
51
+ test('Noise: ciphertext yang diubah ditolak', () => {
52
+ const server = new ServerHandshake()
53
+ const client = new ClientHandshake({ staticKey: generateKeyPair() })
54
+ const finish = client.processServerHello(server.processClientHello(client.hello()), Buffer.alloc(0))
55
+ server.processClientFinish(finish)
56
+ const ct = client.noise.encrypt(Buffer.from('rahasia'))
57
+ ct[0] ^= 1
58
+ assert.throws(() => server.noise.decrypt(ct))
59
+ })
60
+
61
+ test('Noise: ServerHello dengan hash berbeda (header beda) gagal', () => {
62
+ const server = new ServerHandshake({ header: Buffer.from([0x57, 0x41, 6, 9]) })
63
+ const client = new ClientHandshake({ staticKey: generateKeyPair() })
64
+ const sh = server.processClientHello(client.hello())
65
+ assert.throws(() => client.processServerHello(sh, Buffer.alloc(0)))
66
+ })
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { PairingLifecycle } = require('../src/pairing/lifecycle')
6
+ const { attachQrPairingAdapter } = require('../src/pairing/adapter')
7
+
8
+ function mockSocket(credentials) {
9
+ const ev = new EventEmitter()
10
+ ev._safeEmit = (event, ...args) => {
11
+ for (const listener of ev.rawListeners(event)) {
12
+ try { Reflect.apply(listener, ev, args) } catch { /* test helper isolates events */ }
13
+ }
14
+ }
15
+ return {
16
+ ev,
17
+ sessionId: 'fixture-session',
18
+ pairing: new PairingLifecycle({ sessionId: 'fixture-session' }),
19
+ protocol: {},
20
+ sentNodes: [],
21
+ sendNode(node) { this.sentNodes.push(node) },
22
+ credentials
23
+ }
24
+ }
25
+
26
+ test('QR adapter acknowledges pair-device and emits references from decoded node', async () => {
27
+ const creds = {
28
+ noiseKey: { public: Buffer.alloc(32, 1) },
29
+ signedIdentityKey: { public: Buffer.alloc(32, 2) },
30
+ advSecretKey: Buffer.alloc(32, 3)
31
+ }
32
+ const sock = mockSocket(creds)
33
+ const firstQr = new Promise((resolve) => sock.pairing.once('pairing.qr', resolve))
34
+ sock.pairing.begin('qr')
35
+ const detach = attachQrPairingAdapter(sock, { credentials: creds, clientType: '1', firstQrMs: 60000, nextQrMs: 20000 })
36
+ sock.ev.emit('node', {
37
+ tag: 'iq', attrs: { type: 'set', from: 'server@c.us', id: 'pair-1' },
38
+ content: [{ tag: 'pair-device', attrs: {}, content: [
39
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-fixture-1') },
40
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-fixture-2') }
41
+ ] }]
42
+ })
43
+ const qr = await firstQr
44
+ assert.match(qr.qr, /^https:\/\/wa\.me\/settings\/linked_devices#ref-fixture-1,/)
45
+ assert.equal(qr.sessionId, 'fixture-session')
46
+ assert.deepEqual(sock.sentNodes, [{ tag: 'iq', attrs: { to: 's.whatsapp.net', type: 'result', id: 'pair-1' } }])
47
+ detach()
48
+ sock.pairing.close()
49
+ })
50
+
51
+ test('QR adapter does not invent keys and never marks pair-success verified', async () => {
52
+ const sock = mockSocket(undefined)
53
+ const errors = []
54
+ let candidate
55
+ sock.pairing.on('pairing.error', (event) => errors.push(event.error.message))
56
+ sock.ev.on('pairing.success.candidate', (event) => { candidate = event })
57
+ sock.pairing.begin('qr')
58
+ const detach = attachQrPairingAdapter(sock, { credentials: undefined, clientType: '1' })
59
+ sock.ev.emit('node', {
60
+ tag: 'iq', attrs: { type: 'set', from: 's.whatsapp.net', id: 'pair-2' },
61
+ content: [{ tag: 'pair-device', attrs: {}, content: [{ tag: 'ref', attrs: {}, content: Buffer.from('ref') }] }]
62
+ })
63
+ await new Promise((resolve) => setImmediate(resolve))
64
+ assert.ok(errors.length >= 1)
65
+ assert.match(errors[0], /kunci|key|credential|bytes/i)
66
+ sock.ev.emit('node', { tag: 'iq', attrs: { type: 'set', from: 's.whatsapp.net', id: 'pair-3' }, content: [{ tag: 'pair-success', attrs: {}, content: [] }] })
67
+ assert.ok(candidate)
68
+ assert.notEqual(sock.pairing.state, 'paired')
69
+ detach()
70
+ sock.pairing.close()
71
+ })
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { PairingLifecycle } = require('../src')
5
+
6
+ test('PairingLifecycle QR: update -> QR -> paired, terisolasi per session', () => {
7
+ const a = new PairingLifecycle({ sessionId: 'nomor-a' })
8
+ const b = new PairingLifecycle({ sessionId: 'nomor-b' })
9
+ const updates = [], qrs = [], success = []
10
+ a.on('pairing.update', (e) => updates.push(e.state))
11
+ a.on('pairing.qr', (e) => qrs.push(e.qr))
12
+ a.on('pairing.success', (e) => success.push(e.sessionId))
13
+ a.begin('qr').acceptQr('test-only-qr-payload').complete({ registered: true })
14
+ assert.deepEqual(updates, ['waiting-qr', 'paired'])
15
+ assert.deepEqual(qrs, ['test-only-qr-payload'])
16
+ assert.deepEqual(success, ['nomor-a'])
17
+ assert.equal(b.state, 'idle')
18
+ })
19
+
20
+ test('PairingLifecycle code: validasi transisi dan invalidasi state setelah close', () => {
21
+ const flow = new PairingLifecycle({ sessionId: 'nomor-c' })
22
+ let code = null
23
+ flow.on('pairing.code', (e) => { code = e.code })
24
+ flow.begin('code').acceptCode('AB12-CD34')
25
+ assert.equal(code, 'AB12-CD34')
26
+ assert.throws(() => flow.acceptQr('qr'), /QR tidak diharapkan/)
27
+ flow.fail(new Error('contoh gagal'))
28
+ assert.equal(flow.state, 'failed')
29
+ flow.reset().close()
30
+ assert.equal(flow.state, 'closed')
31
+ assert.throws(() => flow.begin('qr'), /ditutup/)
32
+ })