kersiosocket 0.0.0-stage → 0.6.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.
- package/BUILD-INFO.md +15 -0
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bench/wabinary.js +31 -0
- package/bridges/whatsmeow/README.md +42 -0
- package/docs/ENGINE-MATRIX.md +23 -0
- package/docs/PROTOCOL-ROADMAP.md +74 -0
- package/docs/RESEARCH-BASELINE.md +36 -0
- package/docs/WABINARY.md +37 -0
- package/examples/basic.js +10 -0
- package/examples/client-payload.js +21 -0
- package/examples/device-props.js +18 -0
- package/examples/sessions.js +11 -0
- package/examples/wabinary.js +15 -0
- package/licenses/THIRD-PARTY-NOTICES.md +12 -0
- package/package.json +31 -4
- package/scripts/import-token-table.js +95 -0
- package/src/backends/baileys.js +121 -0
- package/src/backends/base.js +102 -0
- package/src/backends/whatsmeow-process.js +202 -0
- package/src/backends/zapo.js +123 -0
- package/src/client/kersio-client.js +305 -0
- package/src/client/session-manager.js +67 -0
- package/src/index.js +36 -0
- package/src/pairing/adapter.js +115 -0
- package/src/pairing/lifecycle.js +120 -0
- package/src/pairing/qr.js +38 -0
- package/src/pairing/verified-commit.js +46 -0
- package/src/proto/client-payload.js +185 -0
- package/src/proto/device-props.js +143 -0
- package/src/proto/handshake.js +79 -0
- package/src/proto/wire.js +137 -0
- package/src/protocol/node-protocol.js +155 -0
- package/src/session/codec.js +25 -0
- package/src/session/encrypted-store.js +107 -0
- package/src/session/locking.js +233 -0
- package/src/session/manager.js +61 -0
- package/src/session/stores.js +154 -0
- package/src/socket/connection.js +278 -0
- package/src/socket/frame.js +77 -0
- package/src/socket/make.js +70 -0
- package/src/socket/noise.js +146 -0
- package/src/socket/transport.js +39 -0
- package/src/utils/crypto.js +52 -0
- package/src/utils/latency.js +32 -0
- package/src/utils/lru.js +76 -0
- package/src/wabinary/codec.js +374 -0
- package/src/wabinary/tags.js +25 -0
- package/src/wabinary/token-source.js +146 -0
- package/src/wabinary/tokens.json +5 -0
- package/test/backend-adapters.test.js +59 -0
- package/test/connection.test.js +173 -0
- package/test/device-props.test.js +67 -0
- package/test/encrypted-store.test.js +54 -0
- package/test/frame.test.js +37 -0
- package/test/helpers.js +43 -0
- package/test/locking-archive.test.js +49 -0
- package/test/node-protocol.test.js +90 -0
- package/test/noise.test.js +88 -0
- package/test/pairing-adapter.test.js +71 -0
- package/test/pairing.test.js +32 -0
- package/test/proto.test.js +64 -0
- package/test/session-manager.test.js +34 -0
- package/test/session.test.js +134 -0
- package/test/token-source.test.js +35 -0
- package/test/unified-client.test.js +63 -0
- package/test/verified-commit.test.js +43 -0
- package/test/wabinary.test.js +147 -0
- package/test/whatsmeow-process.test.js +43 -0
|
@@ -0,0 +1,173 @@
|
|
|
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'), strictServerCertificateVerification: false, 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, strictServerCertificateVerification: false, 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, strictServerCertificateVerification: false, 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, strictServerCertificateVerification: false,
|
|
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
|
+
strictServerCertificateVerification: false,
|
|
149
|
+
reconnect: { enabled: false }
|
|
150
|
+
})
|
|
151
|
+
sock.ev.on('connection.error', (e) => { throw e })
|
|
152
|
+
})
|
|
153
|
+
assert.deepEqual(await received, expected)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('native connection fails closed by default without Noise certificate verifier', async () => {
|
|
157
|
+
const srv = fakeServer(() => {})
|
|
158
|
+
const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: true, minDelay: 10, maxDelay: 20, factor: 2, stableMs: 1000 } })
|
|
159
|
+
const errors = []
|
|
160
|
+
sock.ev.on('connection.error', (error) => errors.push(error))
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
const timer = setTimeout(() => reject(new Error('timeout waiting for strict-certificate close')), 1000)
|
|
163
|
+
sock.ev.on('connection.update', (update) => {
|
|
164
|
+
if (update.connection === 'close') { clearTimeout(timer); resolve() }
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
168
|
+
assert.equal(errors.length, 1)
|
|
169
|
+
assert.equal(errors[0].code, 'ERR_NOISE_CERTIFICATE_VERIFIER_MISSING')
|
|
170
|
+
assert.equal(sock.state, 'close')
|
|
171
|
+
assert.equal(sock.stats().serverCertificateVerified, false)
|
|
172
|
+
sock.end()
|
|
173
|
+
})
|
|
@@ -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
|
+
})
|
package/test/helpers.js
ADDED
|
@@ -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,49 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('node:fs/promises')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { MemoryStore } = require('../src/session/stores')
|
|
8
|
+
const { KeyedMutex, FileLockManager, MessageArchive } = require('../src/session/locking')
|
|
9
|
+
|
|
10
|
+
test('KeyedMutex membuat operasi dengan key sama berjalan serial', async () => {
|
|
11
|
+
const mutex = new KeyedMutex()
|
|
12
|
+
const sequence = []
|
|
13
|
+
await Promise.all([
|
|
14
|
+
mutex.run('a', async () => { sequence.push('a1-start'); await new Promise((r) => setTimeout(r, 20)); sequence.push('a1-end') }),
|
|
15
|
+
mutex.run('a', async () => { sequence.push('a2-start'); sequence.push('a2-end') }),
|
|
16
|
+
mutex.run('b', async () => { sequence.push('b-start'); sequence.push('b-end') })
|
|
17
|
+
])
|
|
18
|
+
assert.ok(sequence.indexOf('a1-end') < sequence.indexOf('a2-start'))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('FileLockManager mencegah dua operasi concurrent dan melepaskan lock saat error', async () => {
|
|
22
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kersio-lock-'))
|
|
23
|
+
try {
|
|
24
|
+
const a = new FileLockManager(dir, { staleMs: 5000, heartbeatMs: 500, retryMs: 5 })
|
|
25
|
+
const b = new FileLockManager(dir, { staleMs: 5000, heartbeatMs: 500, retryMs: 5 })
|
|
26
|
+
let active = 0, maxActive = 0
|
|
27
|
+
await Promise.all([
|
|
28
|
+
a.withLock('session-a', async () => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 35)); active-- }),
|
|
29
|
+
b.withLock('session-a', async () => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 10)); active-- })
|
|
30
|
+
])
|
|
31
|
+
assert.equal(maxActive, 1)
|
|
32
|
+
await assert.rejects(() => a.withLock('throws', async () => { throw new Error('expected') }), /expected/)
|
|
33
|
+
assert.equal((await fs.readdir(dir)).filter((x) => x.endsWith('.lock')).length, 0)
|
|
34
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('MessageArchive writes bounded history and exposes getMessage()', async () => {
|
|
38
|
+
const store = new MemoryStore()
|
|
39
|
+
const archive = new MessageArchive(store, { sessionId: 'archive-test', maxMessagesPerChat: 2 })
|
|
40
|
+
const mk = (id, text) => ({ key: { id, remoteJid: '1@s.whatsapp.net' }, message: { conversation: text } })
|
|
41
|
+
await archive.put(mk('1', 'one'))
|
|
42
|
+
await archive.put(mk('2', 'two'))
|
|
43
|
+
await archive.put(mk('3', 'three'))
|
|
44
|
+
assert.equal(await archive.getMessage({ id: '3', remoteJid: '1@s.whatsapp.net' }).then((v) => v.conversation), 'three')
|
|
45
|
+
assert.equal(await archive.getRecord({ id: '1', remoteJid: '1@s.whatsapp.net' }), undefined)
|
|
46
|
+
assert.deepEqual((await archive.recent('1@s.whatsapp.net', 10)).map((m) => m.key.id), ['3', '2'])
|
|
47
|
+
await archive.delete({ id: '2', remoteJid: '1@s.whatsapp.net' })
|
|
48
|
+
assert.deepEqual((await archive.recent('1@s.whatsapp.net')).map((m) => m.key.id), ['3'])
|
|
49
|
+
})
|
|
@@ -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,88 @@
|
|
|
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
|
+
})
|
|
67
|
+
|
|
68
|
+
test('Noise strict mode menolak server certificate bila verifier tidak dikonfigurasi', () => {
|
|
69
|
+
const server = new ServerHandshake({ certificate: Buffer.from('untrusted') })
|
|
70
|
+
const client = new ClientHandshake({ staticKey: generateKeyPair(), requireServerCertificateVerification: true })
|
|
71
|
+
const serverHello = server.processClientHello(client.hello())
|
|
72
|
+
assert.throws(() => client.processServerHello(serverHello, Buffer.alloc(0)), /verifier sertifikat server belum dikonfigurasi/)
|
|
73
|
+
assert.equal(client.serverCertificateVerified, false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('Noise strict mode hanya menerima certificate bila verifier menyatakan true', () => {
|
|
77
|
+
const expected = Buffer.from('pinned-test-certificate')
|
|
78
|
+
const server = new ServerHandshake({ certificate: expected })
|
|
79
|
+
const client = new ClientHandshake({
|
|
80
|
+
staticKey: generateKeyPair(),
|
|
81
|
+
requireServerCertificateVerification: true,
|
|
82
|
+
verifyServerCertificate: (certificate) => certificate.equals(expected)
|
|
83
|
+
})
|
|
84
|
+
const serverHello = server.processClientHello(client.hello())
|
|
85
|
+
const finish = client.processServerHello(serverHello, Buffer.alloc(0))
|
|
86
|
+
server.processClientFinish(finish)
|
|
87
|
+
assert.equal(client.serverCertificateVerified, true)
|
|
88
|
+
})
|