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.
- package/BUILD-INFO.md +14 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +145 -2
- package/bench/wabinary.js +31 -0
- package/docs/PROTOCOL-ROADMAP.md +43 -0
- package/docs/RESEARCH-BASELINE.md +25 -0
- package/docs/WABINARY.md +37 -0
- package/examples/basic.js +18 -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 +7 -0
- package/licenses/whatsmeow-MPL-2.0.txt +374 -0
- package/package.json +36 -3
- package/scripts/import-token-table.js +95 -0
- package/src/index.js +25 -0
- package/src/pairing/adapter.js +115 -0
- package/src/pairing/lifecycle.js +120 -0
- package/src/pairing/qr.js +38 -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/manager.js +61 -0
- package/src/session/stores.js +154 -0
- package/src/socket/connection.js +265 -0
- package/src/socket/frame.js +77 -0
- package/src/socket/make.js +70 -0
- package/src/socket/noise.js +126 -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 +1287 -0
- package/test/connection.test.js +153 -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/node-protocol.test.js +90 -0
- package/test/noise.test.js +66 -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.test.js +134 -0
- package/test/token-source.test.js +35 -0
- package/test/wabinary.test.js +147 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { encodeVarint, fieldVarint, fieldBool, fieldString, fieldBytes, fieldFixed32, pack, parseFields } = require('../src/proto/wire')
|
|
5
|
+
const { encodeClientPayload, PLATFORM, CONNECT_REASON } = require('../src/proto/client-payload')
|
|
6
|
+
|
|
7
|
+
const userAgent = {
|
|
8
|
+
platform: 'WEB', appVersion: [2, 3000, 1040000000],
|
|
9
|
+
releaseChannel: 'RELEASE', localeLanguage: 'id', localeCountry: 'ID'
|
|
10
|
+
}
|
|
11
|
+
const webInfo = { version: 'test-version-only', webSubPlatform: 'WEB_BROWSER' }
|
|
12
|
+
const registration = {
|
|
13
|
+
mode: 'registration', passive: false, pushName: 'Kersio test', userAgent, webInfo,
|
|
14
|
+
connectReason: 'USER_ACTIVATED', connectType: 'WIFI_UNKNOWN', connectAttemptCount: 0,
|
|
15
|
+
devicePairingData: {
|
|
16
|
+
eRegid: Buffer.from([0, 0, 0, 1]), eKeytype: Buffer.from([5]), eIdent: Buffer.alloc(32, 1),
|
|
17
|
+
eSkeyId: Buffer.from([0, 0, 1]), eSkeyVal: Buffer.alloc(32, 2), eSkeySig: Buffer.alloc(64, 3),
|
|
18
|
+
buildHash: Buffer.alloc(16, 4), deviceProps: Buffer.from([0x0a, 0x01, 0x78])
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('protobuf wire: varint, string, bool, bytes, sfixed32 round-trip ke entri wire', () => {
|
|
23
|
+
const payload = pack([
|
|
24
|
+
fieldVarint(1, 300), fieldBool(2, false), fieldString(3, 'kersio'),
|
|
25
|
+
fieldBytes(4, Buffer.from([1, 2, 3])), fieldFixed32(5, -42)
|
|
26
|
+
])
|
|
27
|
+
const f = parseFields(payload)
|
|
28
|
+
assert.deepEqual(f.map((x) => [x.fieldNumber, x.wireType]), [[1, 0], [2, 0], [3, 2], [4, 2], [5, 5]])
|
|
29
|
+
assert.equal(f[0].value, 300n)
|
|
30
|
+
assert.equal(f[1].value, 0n)
|
|
31
|
+
assert.equal(f[2].value.toString(), 'kersio')
|
|
32
|
+
assert.deepEqual(f[3].value, Buffer.from([1, 2, 3]))
|
|
33
|
+
assert.equal(f[4].value.readInt32LE(), -42)
|
|
34
|
+
assert.deepEqual(encodeVarint(0), Buffer.from([0]))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('ClientPayload registration: membuat bytes berisi field utama dan DevicePairingRegistrationData', () => {
|
|
38
|
+
const out = encodeClientPayload(registration)
|
|
39
|
+
assert.ok(out.length > 100)
|
|
40
|
+
const fields = parseFields(out)
|
|
41
|
+
assert.ok(fields.some((f) => f.fieldNumber === 3 && f.wireType === 0 && f.value === 0n))
|
|
42
|
+
assert.ok(fields.some((f) => f.fieldNumber === 5 && f.wireType === 2))
|
|
43
|
+
assert.ok(fields.some((f) => f.fieldNumber === 6 && f.wireType === 2))
|
|
44
|
+
assert.ok(fields.some((f) => f.fieldNumber === 19 && f.wireType === 2))
|
|
45
|
+
assert.equal(PLATFORM.WEB, 14)
|
|
46
|
+
assert.equal(CONNECT_REASON.USER_ACTIVATED, 1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('ClientPayload login: username harus nomor digit dan tanpa pairing registration data', () => {
|
|
50
|
+
const out = encodeClientPayload({ mode: 'login', username: '6281234567890', userAgent, webInfo, passive: false })
|
|
51
|
+
const first = parseFields(out)[0]
|
|
52
|
+
assert.equal(first.fieldNumber, 1)
|
|
53
|
+
assert.equal(first.value, 6281234567890n)
|
|
54
|
+
assert.throws(() => encodeClientPayload({ mode: 'login', username: '+6281234567890', userAgent, webInfo }), /digit nomor/)
|
|
55
|
+
assert.throws(() => encodeClientPayload({ mode: 'registration', userAgent, webInfo }), /devicePairingData lengkap/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('ClientPayload: memvalidasi ukuran kunci dan schema wajib', () => {
|
|
59
|
+
const bad = structuredClone(registration)
|
|
60
|
+
bad.devicePairingData.eSkeySig = Buffer.alloc(32)
|
|
61
|
+
assert.throws(() => encodeClientPayload(bad), /eSkeySig harus 64 byte/)
|
|
62
|
+
assert.throws(() => encodeClientPayload({ mode: 'login', username: '6281234567890', webInfo }), /userAgent wajib diisi/)
|
|
63
|
+
assert.throws(() => parseFields(Buffer.from([0x0a, 0x05, 0x01])), /panjang field melebihi buffer/)
|
|
64
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { MemoryStore, FileStore, SQLiteStore, RedisStore, MongoStore, LRUCache, createSessionManager, makeSocket } = require('../src')
|
|
8
|
+
|
|
9
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kersio-'))
|
|
10
|
+
|
|
11
|
+
async function contract(store) {
|
|
12
|
+
assert.equal(await store.get('a:x'), undefined)
|
|
13
|
+
const v = { buf: Buffer.from([1, 2, 3]), big: 123456789012345678901234567890n, n: 5, s: 'é/:*.', nested: { u: new Uint8Array([9, 8]) } }
|
|
14
|
+
await store.set('a:x', v)
|
|
15
|
+
const r = await store.get('a:x')
|
|
16
|
+
assert.ok(Buffer.isBuffer(r.buf)); assert.deepEqual(r.buf, v.buf)
|
|
17
|
+
assert.equal(r.big, v.big); assert.equal(r.n, 5); assert.equal(r.s, v.s)
|
|
18
|
+
assert.deepEqual([...r.nested.u], [9, 8])
|
|
19
|
+
await store.set('a:x', { n: 6 })
|
|
20
|
+
assert.equal((await store.get('a:x')).n, 6)
|
|
21
|
+
await store.set('b:y', 1)
|
|
22
|
+
assert.deepEqual((await store.keys('a:')).sort(), ['a:x'])
|
|
23
|
+
assert.deepEqual((await store.keys()).sort(), ['a:x', 'b:y'])
|
|
24
|
+
await store.delete('a:x')
|
|
25
|
+
assert.equal(await store.get('a:x'), undefined)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('kontrak store: memory', () => contract(new MemoryStore()))
|
|
29
|
+
test('kontrak store: file', () => contract(new FileStore(tmp())))
|
|
30
|
+
test('kontrak store: sqlite', async () => { const s = new SQLiteStore(':memory:'); await contract(s); await s.close() })
|
|
31
|
+
|
|
32
|
+
test('kontrak store: redis (klien palsu)', async () => {
|
|
33
|
+
const m = new Map()
|
|
34
|
+
const c = { get: async (k) => m.get(k) ?? null, set: async (k, v) => void m.set(k, v), del: async (k) => void m.delete(k),
|
|
35
|
+
keys: async (p) => [...m.keys()].filter((k) => k.startsWith(p.replace(/\\/g, '').slice(0, -1))) }
|
|
36
|
+
await contract(new RedisStore(c))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('kontrak store: mongo (collection palsu)', async () => {
|
|
40
|
+
const m = new Map()
|
|
41
|
+
const col = {
|
|
42
|
+
findOne: async ({ _id }) => m.get(_id) ?? null,
|
|
43
|
+
replaceOne: async ({ _id }, doc) => void m.set(_id, doc),
|
|
44
|
+
deleteOne: async ({ _id }) => void m.delete(_id),
|
|
45
|
+
find: ({ _id: { $regex } }) => ({ toArray: async () => [...m.values()].filter((d) => new RegExp($regex).test(d._id)) })
|
|
46
|
+
}
|
|
47
|
+
await contract(new MongoStore(col))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('FileStore: tulis atomik, tulis berurutan, tmp dibersihkan, file rusak dikarantina', async () => {
|
|
51
|
+
const dir = tmp()
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'sisa.json.1.abcd.tmp'), 'setengah')
|
|
53
|
+
const s = new FileStore(dir)
|
|
54
|
+
assert.equal(fs.readdirSync(dir).some((f) => f.endsWith('.tmp')), false)
|
|
55
|
+
|
|
56
|
+
await Promise.all(Array.from({ length: 50 }, (_, i) => s.set('k', { i })))
|
|
57
|
+
assert.equal((await s.get('k')).i, 49) // urutan terjaga
|
|
58
|
+
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(path.join(dir, 'rusak.json'), '{"setengah":')
|
|
61
|
+
assert.equal(await s.get('rusak'), undefined)
|
|
62
|
+
assert.ok(fs.readdirSync(dir).some((f) => f.startsWith('rusak.json.') && f.endsWith('.corrupt')))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('FileStore: kegagalan rename tidak merusak file lama', async () => {
|
|
66
|
+
const dir = tmp()
|
|
67
|
+
const s = new FileStore(dir)
|
|
68
|
+
await s.set('k', { v: 'lama' })
|
|
69
|
+
const orig = fs.promises.rename
|
|
70
|
+
fs.promises.rename = async () => { throw new Error('mati di tengah') }
|
|
71
|
+
try { await assert.rejects(s.set('k', { v: 'baru' }), /mati di tengah/) } finally { fs.promises.rename = orig }
|
|
72
|
+
assert.equal((await s.get('k')).v, 'lama')
|
|
73
|
+
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('makeSocket + store: kunci dibuat sekali lalu dipakai ulang antar restart', async () => {
|
|
77
|
+
const store = new FileStore(tmp())
|
|
78
|
+
const a = makeSocket({ store, sessionId: 'nomor-a', autoConnect: false })
|
|
79
|
+
await a.ready
|
|
80
|
+
const k1 = a.creds.staticKey.public
|
|
81
|
+
const b = makeSocket({ store, sessionId: 'nomor-a', autoConnect: false })
|
|
82
|
+
await b.ready
|
|
83
|
+
assert.deepEqual(b.creds.staticKey.public, k1)
|
|
84
|
+
assert.ok(Buffer.isBuffer(b.creds.staticKey.private))
|
|
85
|
+
const c = makeSocket({ store, sessionId: 'nomor-b', autoConnect: false })
|
|
86
|
+
await c.ready
|
|
87
|
+
assert.notDeepEqual(c.creds.staticKey.public, k1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('SessionManager: banyak sesi, list, close + purge', async () => {
|
|
91
|
+
const store = new MemoryStore()
|
|
92
|
+
const mgr = createSessionManager({ store, autoConnect: false })
|
|
93
|
+
const a = mgr.open('a'), b = mgr.open('b')
|
|
94
|
+
assert.equal(mgr.open('a'), a)
|
|
95
|
+
await Promise.all([a.ready, b.ready])
|
|
96
|
+
assert.deepEqual((await mgr.list()).sort(), ['a', 'b'])
|
|
97
|
+
assert.equal(Object.keys(mgr.stats().sessions).length, 2)
|
|
98
|
+
await mgr.close('a', { purge: true })
|
|
99
|
+
assert.deepEqual(await mgr.list(), ['b'])
|
|
100
|
+
assert.throws(() => mgr.open('x:y'), /tidak valid/)
|
|
101
|
+
await mgr.closeAll()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('LRUCache: batas entri, batas byte, TTL, urutan pakai', async () => {
|
|
105
|
+
const evicted = []
|
|
106
|
+
const c = new LRUCache({ max: 2, onEvict: (k, v, why) => evicted.push([k, why]) })
|
|
107
|
+
c.set('a', 1).set('b', 2); c.get('a'); c.set('c', 3)
|
|
108
|
+
assert.equal(c.has('b'), false); assert.equal(c.get('a'), 1)
|
|
109
|
+
assert.deepEqual(evicted, [['b', 'evicted']])
|
|
110
|
+
|
|
111
|
+
const m = new LRUCache({ max: 100, maxBytes: 10, sizeOf: (v) => v.length })
|
|
112
|
+
m.set('x', Buffer.alloc(6)); m.set('y', Buffer.alloc(6))
|
|
113
|
+
assert.equal(m.has('x'), false); assert.equal(m.bytes, 6)
|
|
114
|
+
m.set('gede', Buffer.alloc(50)) // entri tunggal raksasa tidak membuat cache kosong total
|
|
115
|
+
assert.equal(m.size, 1)
|
|
116
|
+
|
|
117
|
+
const t = new LRUCache({ ttl: 10 })
|
|
118
|
+
t.set('k', 1)
|
|
119
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
120
|
+
assert.equal(t.get('k'), undefined)
|
|
121
|
+
assert.ok(c.stats().hitRate > 0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('multi-session: pairing lifecycle dan state tiap socket terisolasi', async () => {
|
|
125
|
+
const mgr = createSessionManager({ store: new MemoryStore(), autoConnect: false })
|
|
126
|
+
const a = mgr.open('nomor-a')
|
|
127
|
+
const b = mgr.open('nomor-b')
|
|
128
|
+
await Promise.all([a.ready, b.ready])
|
|
129
|
+
assert.notEqual(a.pairing, b.pairing)
|
|
130
|
+
a.pairing.begin('qr')
|
|
131
|
+
assert.equal(a.pairing.state, 'waiting-qr')
|
|
132
|
+
assert.equal(b.pairing.state, 'idle')
|
|
133
|
+
await mgr.closeAll()
|
|
134
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('node:test')
|
|
4
|
+
const assert = require('node:assert/strict')
|
|
5
|
+
const { parseGoTokenTable, validateTokenTable } = require('../src/wabinary/token-source')
|
|
6
|
+
|
|
7
|
+
const fixture = String.raw`package token
|
|
8
|
+
var (
|
|
9
|
+
SingleByteTokens = [...]string{"", "iq", "quoted\\\"token", "line\\nfeed", "hex\\x41"}
|
|
10
|
+
DoubleByteTokens = [...][]string{
|
|
11
|
+
{"first", "second"},
|
|
12
|
+
{"third"},
|
|
13
|
+
{},
|
|
14
|
+
{"fourth"},
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
const DictVersion = 7
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
test('Go token-data parser reads string escapes and page boundaries', () => {
|
|
21
|
+
const parsed = parseGoTokenTable(fixture)
|
|
22
|
+
assert.equal(parsed.dictVersion, 7)
|
|
23
|
+
assert.deepEqual(parsed.single, ['', 'iq', 'quoted\\"token', 'line\\nfeed', 'hex\\x41'])
|
|
24
|
+
assert.deepEqual(parsed.double, [['first', 'second'], ['third'], [], ['fourth']])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('token-data validator rejects the wrong dictionary version', () => {
|
|
28
|
+
const table = { dictVersion: 2, single: Array(236).fill('x'), double: Array.from({ length: 4 }, () => Array(256).fill('x')) }
|
|
29
|
+
table.single[0] = ''
|
|
30
|
+
assert.throws(() => validateTokenTable(table, { dictVersion: 3 }), /DictVersion mismatch/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('token-data validator requires exact versioned table dimensions', () => {
|
|
34
|
+
assert.throws(() => validateTokenTable({ dictVersion: 3, single: [], double: [[], [], [], []] }), /single-byte token table shape unexpected/)
|
|
35
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const zlib = require('node:zlib')
|
|
5
|
+
const { encodeBinaryNode, decodeBinaryNode, TAGS } = require('../src/wabinary/codec')
|
|
6
|
+
const { buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX } = require('../src/pairing/qr')
|
|
7
|
+
|
|
8
|
+
const emptyTokens = { single: [], double: [[], [], [], []] }
|
|
9
|
+
|
|
10
|
+
test('WABinary: round-trip node attrs, nested children, strings and binary content', () => {
|
|
11
|
+
const input = {
|
|
12
|
+
tag: 'iq', attrs: { id: '42', type: 'result', to: 's.whatsapp.net' },
|
|
13
|
+
content: [
|
|
14
|
+
{ tag: 'pair-device', attrs: {}, content: [{ tag: 'ref', attrs: {}, content: Buffer.from([0x01, 0x02, 0xff]) }] },
|
|
15
|
+
{ tag: 'success', attrs: { t: '123' } }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
const wire = encodeBinaryNode(input, { tokens: emptyTokens })
|
|
19
|
+
const out = decodeBinaryNode(wire, { tokens: emptyTokens })
|
|
20
|
+
assert.equal(out.tag, 'iq')
|
|
21
|
+
assert.equal(out.attrs.id, '42')
|
|
22
|
+
assert.equal(out.content[0].tag, 'pair-device')
|
|
23
|
+
assert.deepEqual(out.content[0].content[0].content, Buffer.from([1, 2, 255]))
|
|
24
|
+
assert.equal(out.content[1].tag, 'success')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('WABinary: raw JID pair, injected single/double token dictionaries', () => {
|
|
28
|
+
const tokens = { single: [null, 'iq', 'to'], double: [['pair-device'], [], [], []] }
|
|
29
|
+
const node = { tag: 'iq', attrs: { to: '123@s.whatsapp.net' }, content: [{ tag: 'pair-device', attrs: {} }] }
|
|
30
|
+
const out = decodeBinaryNode(encodeBinaryNode(node, { tokens }), { tokens })
|
|
31
|
+
assert.equal(out.tag, 'iq')
|
|
32
|
+
assert.equal(out.attrs.to, '123@s.whatsapp.net')
|
|
33
|
+
assert.equal(out.content[0].tag, 'pair-device')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('WABinary: compressed and uncompressed transport prefixes', () => {
|
|
37
|
+
const node = { tag: 'message', attrs: { id: 'x' }, content: Buffer.from('hello') }
|
|
38
|
+
const raw = encodeBinaryNode(node, { tokens: emptyTokens })
|
|
39
|
+
const plain = decodeBinaryNode(raw, { tokens: emptyTokens })
|
|
40
|
+
const body = raw[0] === 0 ? raw.subarray(1) : raw
|
|
41
|
+
const compressed = decodeBinaryNode(Buffer.concat([Buffer.from([2]), zlib.deflateSync(body)]), { tokens: emptyTokens })
|
|
42
|
+
for (const out of [plain, compressed]) {
|
|
43
|
+
assert.equal(out.tag, node.tag)
|
|
44
|
+
assert.equal(out.attrs.id, node.attrs.id)
|
|
45
|
+
assert.deepEqual(out.content, node.content)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('WABinary: truncated/unknown token inputs fail closed', () => {
|
|
50
|
+
assert.throws(() => decodeBinaryNode(Buffer.from([TAGS.LIST_8]), { tokens: emptyTokens, decompress: false }), /terpotong/)
|
|
51
|
+
assert.throws(() => decodeBinaryNode(Buffer.from([TAGS.LIST_8, 1, 1]), { tokens: emptyTokens, decompress: false }), /token tunggal/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
test('WABinary default encoder emits protocol 0x00 prefix and a pinned golden byte sequence', () => {
|
|
57
|
+
const single = Array(236).fill('')
|
|
58
|
+
single[3] = 's.whatsapp.net'
|
|
59
|
+
single[4] = 'type'
|
|
60
|
+
single[8] = 'id'
|
|
61
|
+
single[17] = 'to'
|
|
62
|
+
single[25] = 'iq'
|
|
63
|
+
single[41] = 'get'
|
|
64
|
+
single[86] = 'ping'
|
|
65
|
+
const tokens = { single, double: [Array(256).fill(''), Array(256).fill(''), Array(256).fill(''), Array(256).fill('')] }
|
|
66
|
+
const node = { tag: 'iq', attrs: { id: 'kersio-local-test', type: 'get', to: 's.whatsapp.net' }, content: [{ tag: 'ping', attrs: {} }] }
|
|
67
|
+
const expected = '00f8081908fc116b657273696f2d6c6f63616c2d7465737404291103f801f80156'
|
|
68
|
+
const encoded = encodeBinaryNode(node, { tokens })
|
|
69
|
+
assert.equal(encoded.toString('hex'), expected)
|
|
70
|
+
const decoded = decodeBinaryNode(encoded, { tokens })
|
|
71
|
+
assert.equal(decoded.tag, node.tag)
|
|
72
|
+
assert.deepEqual({ ...decoded.attrs }, node.attrs)
|
|
73
|
+
assert.equal(decoded.content[0].tag, 'ping')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('WABinary packs digit/hex strings and round-trips device JID formats', () => {
|
|
77
|
+
const tokens = { single: [], double: [[], [], [], []] }
|
|
78
|
+
for (const value of ['1234567890', '12-34.56', 'ABCDEF09', '12345:2@s.whatsapp.net', '12345:7@lid', 'user@g.us']) {
|
|
79
|
+
const input = { tag: 'node', attrs: { value } }
|
|
80
|
+
const wire = encodeBinaryNode(input, { tokens })
|
|
81
|
+
assert.equal(wire[0], 0x00)
|
|
82
|
+
const output = decodeBinaryNode(wire, { tokens })
|
|
83
|
+
assert.equal(output.attrs.value, value)
|
|
84
|
+
}
|
|
85
|
+
const packed = encodeBinaryNode({ tag: 'x', attrs: { v: '12345' } }, { tokens })
|
|
86
|
+
assert.ok(packed.includes(0xff)) // HEX_8 or NIBBLE_8 marker in encoded attributes
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
test('WABinary decodes FB_JID and INTEROP_JID marker fixtures', () => {
|
|
92
|
+
const rawString = (value) => {
|
|
93
|
+
const b = Buffer.from(value, 'utf8')
|
|
94
|
+
assert.ok(b.length < 256)
|
|
95
|
+
return Buffer.concat([Buffer.from([TAGS.BINARY_8, b.length]), b])
|
|
96
|
+
}
|
|
97
|
+
const wrapValue = (encodedValue) => Buffer.concat([
|
|
98
|
+
Buffer.from([0x00, TAGS.LIST_8, 3]), rawString('x'), rawString('jid'), encodedValue
|
|
99
|
+
])
|
|
100
|
+
const fb = Buffer.concat([Buffer.from([TAGS.FB_JID]), rawString('alice'), Buffer.from([0x00, 0x02]), rawString('s.whatsapp.net')])
|
|
101
|
+
const fbNode = decodeBinaryNode(wrapValue(fb), { tokens: emptyTokens, requirePrefix: true })
|
|
102
|
+
assert.equal(fbNode.attrs.jid, 'alice:2@s.whatsapp.net')
|
|
103
|
+
|
|
104
|
+
const interop = Buffer.concat([Buffer.from([TAGS.INTEROP_JID]), rawString('user'), Buffer.from([0x00, 0x07, 0x00, 0x03]), rawString('interop.example')])
|
|
105
|
+
const interopNode = decodeBinaryNode(wrapValue(interop), { tokens: emptyTokens, requirePrefix: true })
|
|
106
|
+
assert.equal(interopNode.attrs.jid, '3-user:7@interop.example')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('WABinary leaves lowercase hex-looking text as raw UTF-8 and normalizes AD_JID device zero', () => {
|
|
110
|
+
const tokens = { single: [], double: [[], [], [], []] }
|
|
111
|
+
const lower = { tag: 'x', attrs: { value: 'deadbeef' } }
|
|
112
|
+
const lowerBytes = encodeBinaryNode(lower, { tokens })
|
|
113
|
+
assert.deepEqual(decodeBinaryNode(lowerBytes, { tokens }).attrs.value, 'deadbeef')
|
|
114
|
+
const encoded = encodeBinaryNode({ tag: 'x', attrs: { jid: '123:0@s.whatsapp.net' } }, { tokens })
|
|
115
|
+
const decoded = decodeBinaryNode(encoded, { tokens })
|
|
116
|
+
assert.equal(decoded.attrs.jid, '123@s.whatsapp.net')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('WABinary compression option emits 0x02 prefix and respects output limits', () => {
|
|
120
|
+
const tokens = { single: [], double: [[], [], [], []] }
|
|
121
|
+
const node = { tag: 'message', attrs: { id: 'a'.repeat(2000) }, content: Buffer.from('payload') }
|
|
122
|
+
const compressed = encodeBinaryNode(node, { tokens, compress: true })
|
|
123
|
+
assert.equal(compressed[0], 0x02)
|
|
124
|
+
const decoded = decodeBinaryNode(compressed, { tokens })
|
|
125
|
+
assert.equal(decoded.tag, node.tag)
|
|
126
|
+
assert.equal(decoded.attrs.id, node.attrs.id)
|
|
127
|
+
assert.deepEqual(decoded.content, node.content)
|
|
128
|
+
assert.throws(() => encodeBinaryNode(node, { tokens, limits: { maxBytes: 100 } }), /maxBytes/)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('pairing QR helper formats the observed fields and extracts pair-device refs', () => {
|
|
132
|
+
const payload = buildPairingQrPayload({
|
|
133
|
+
ref: Buffer.from('ref-1'), noisePublicKey: Buffer.alloc(32, 1),
|
|
134
|
+
identityPublicKey: Buffer.alloc(32, 2), advSecretKey: Buffer.alloc(32, 3), clientType: '1'
|
|
135
|
+
})
|
|
136
|
+
assert.ok(payload.startsWith(LINKED_DEVICES_PREFIX + 'ref-1,'))
|
|
137
|
+
const refs = extractPairingRefs({ tag: 'iq', attrs: {}, content: [{ tag: 'pair-device', attrs: {}, content: [
|
|
138
|
+
{ tag: 'ref', attrs: {}, content: Buffer.from('ref-a') },
|
|
139
|
+
{ tag: 'ref', attrs: {}, content: Buffer.from('ref-b') },
|
|
140
|
+
{ tag: 'not-ref', attrs: {}, content: Buffer.from('ignore') }
|
|
141
|
+
] }] })
|
|
142
|
+
assert.deepEqual(refs, [Buffer.from('ref-a'), Buffer.from('ref-b')])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('pairing QR helper rejects incomplete key material', () => {
|
|
146
|
+
assert.throws(() => buildPairingQrPayload({ ref: 'ref', noisePublicKey: Buffer.alloc(31), identityPublicKey: Buffer.alloc(32), advSecretKey: Buffer.alloc(32), clientType: '1' }), /32 byte/)
|
|
147
|
+
})
|