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,120 @@
1
+ 'use strict'
2
+ const { EventEmitter } = require('node:events')
3
+
4
+ /**
5
+ * Local pairing lifecycle/state machine.
6
+ *
7
+ * This class isolates pairing state and events per session. The optional WABinary
8
+ * QR adapter feeds observed QR candidates into this lifecycle, but success must
9
+ * only be finalized by a caller after full cryptographic verification.
10
+ */
11
+ class PairingLifecycle extends EventEmitter {
12
+ constructor({ sessionId = 'default' } = {}) {
13
+ super()
14
+ this.sessionId = sessionId
15
+ this.state = 'idle'
16
+ this.method = null
17
+ this.startedAt = 0
18
+ this.completedAt = 0
19
+ }
20
+
21
+ begin(method) {
22
+ if (!['qr', 'code'].includes(method)) throw new TypeError("metode pairing harus 'qr' atau 'code'")
23
+ if (this.state === 'waiting-qr' || this.state === 'waiting-code') throw new Error('pairing sedang berlangsung')
24
+ if (this.state === 'closed') throw new Error('pairing lifecycle sudah ditutup')
25
+ this.method = method
26
+ this.state = method === 'qr' ? 'waiting-qr' : 'waiting-code'
27
+ this.startedAt = Date.now()
28
+ this.completedAt = 0
29
+ this._emitUpdate()
30
+ return this
31
+ }
32
+
33
+ acceptQr(qrPayload, metadata = {}) {
34
+ if (this.state !== 'waiting-qr') throw new Error('QR tidak diharapkan pada state sekarang')
35
+ if (typeof qrPayload !== 'string' || qrPayload.length === 0) throw new TypeError('qrPayload harus string non-kosong')
36
+ this._safeEmit('pairing.qr', { sessionId: this.sessionId, qr: qrPayload, ...metadata })
37
+ return this
38
+ }
39
+
40
+ acceptCode(code, metadata = {}) {
41
+ if (this.state !== 'waiting-code') throw new Error('kode tidak diharapkan pada state sekarang')
42
+ if (typeof code !== 'string' || !/^[A-Z0-9-]{4,16}$/i.test(code)) throw new TypeError('kode pairing memiliki format tidak valid')
43
+ this._safeEmit('pairing.code', { sessionId: this.sessionId, code, ...metadata })
44
+ return this
45
+ }
46
+
47
+ complete(credentials) {
48
+ if (!['waiting-qr', 'waiting-code'].includes(this.state)) throw new Error('pairing tidak sedang menunggu konfirmasi')
49
+ if (!credentials || typeof credentials !== 'object') throw new TypeError('credentials hasil pairing wajib berupa objek')
50
+ this.state = 'paired'
51
+ this.completedAt = Date.now()
52
+ this._emitUpdate()
53
+ this._safeEmit('pairing.success', { sessionId: this.sessionId, credentials })
54
+ return this
55
+ }
56
+
57
+ fail(error) {
58
+ if (this.state === 'closed') return this
59
+ const normalized = error instanceof Error ? error : new Error(String(error || 'pairing gagal'))
60
+ this.state = 'failed'
61
+ this._emitUpdate({ error: normalized.message })
62
+ this._safeEmit('pairing.error', { sessionId: this.sessionId, error: normalized })
63
+ return this
64
+ }
65
+
66
+ reset() {
67
+ if (this.state === 'closed') throw new Error('pairing lifecycle sudah ditutup')
68
+ this.state = 'idle'
69
+ this.method = null
70
+ this.startedAt = 0
71
+ this.completedAt = 0
72
+ this._emitUpdate()
73
+ return this
74
+ }
75
+
76
+ close() {
77
+ if (this.state === 'closed') return this
78
+ this.state = 'closed'
79
+ this._emitUpdate()
80
+ this.removeAllListeners()
81
+ return this
82
+ }
83
+
84
+
85
+ _safeEmit(event, ...args) {
86
+ for (const listener of this.rawListeners(event)) {
87
+ try {
88
+ const result = Reflect.apply(listener, this, args)
89
+ if (result && typeof result.then === 'function') {
90
+ Promise.resolve(result).catch((error) => this._reportListenerError(error, event))
91
+ }
92
+ } catch (error) {
93
+ this._reportListenerError(error, event)
94
+ }
95
+ }
96
+ }
97
+
98
+ _reportListenerError(error, sourceEvent) {
99
+ if (sourceEvent === 'pairing.handler.error') return
100
+ const normalized = error instanceof Error ? error : new Error(String(error))
101
+ for (const listener of this.rawListeners('pairing.handler.error')) {
102
+ try { Reflect.apply(listener, this, [normalized, sourceEvent]) } catch { /* never break pairing state transitions */ }
103
+ }
104
+ }
105
+
106
+ stats() {
107
+ return {
108
+ sessionId: this.sessionId,
109
+ state: this.state,
110
+ method: this.method,
111
+ elapsedMs: this.startedAt ? (this.completedAt || Date.now()) - this.startedAt : 0
112
+ }
113
+ }
114
+
115
+ _emitUpdate(extra = {}) {
116
+ this._safeEmit('pairing.update', { ...this.stats(), ...extra })
117
+ }
118
+ }
119
+
120
+ module.exports = { PairingLifecycle }
@@ -0,0 +1,38 @@
1
+ 'use strict'
2
+
3
+ const LINKED_DEVICES_PREFIX = 'https://wa.me/settings/linked_devices#'
4
+
5
+ function bytes(value, name, length) {
6
+ if (!(Buffer.isBuffer(value) || value instanceof Uint8Array)) throw new TypeError(`${name} harus bytes`)
7
+ const out = Buffer.from(value.buffer, value.byteOffset, value.byteLength)
8
+ if (length !== undefined && out.length !== length) throw new RangeError(`${name} harus ${length} byte`)
9
+ return out
10
+ }
11
+
12
+ /**
13
+ * Build the observed linked-device QR payload. This only formats the QR payload;
14
+ * it does not implement registration, signature verification, or the live socket handler.
15
+ * The field layout is based on public whatsmeow/Baileys observations and must be
16
+ * checked against the WhatsApp Web version being targeted.
17
+ */
18
+ function buildPairingQrPayload({ ref, noisePublicKey, identityPublicKey, advSecretKey, clientType }) {
19
+ const refBytes = typeof ref === 'string' ? Buffer.from(ref, 'utf8') : bytes(ref, 'ref')
20
+ if (refBytes.length === 0) throw new TypeError('ref pairing kosong')
21
+ const noise = bytes(noisePublicKey, 'noisePublicKey', 32)
22
+ const identity = bytes(identityPublicKey, 'identityPublicKey', 32)
23
+ const adv = bytes(advSecretKey, 'advSecretKey', 32)
24
+ if (typeof clientType !== 'string' || !/^[0-9a-z]$/.test(clientType)) throw new TypeError('clientType harus kode satu karakter yang telah diverifikasi')
25
+ return `${LINKED_DEVICES_PREFIX}${refBytes.toString('utf8')},${noise.toString('base64')},${identity.toString('base64')},${adv.toString('base64')},${clientType}`
26
+ }
27
+
28
+ /** Extract only ref payloads from a decoded iq/pair-device node; no side effects. */
29
+ function extractPairingRefs(node) {
30
+ if (!node || node.tag !== 'iq' || !Array.isArray(node.content)) return []
31
+ const pairDevice = node.content.find((child) => child && child.tag === 'pair-device')
32
+ if (!pairDevice || !Array.isArray(pairDevice.content)) return []
33
+ return pairDevice.content
34
+ .filter((child) => child && child.tag === 'ref' && (Buffer.isBuffer(child.content) || child.content instanceof Uint8Array))
35
+ .map((child) => Buffer.from(child.content))
36
+ }
37
+
38
+ module.exports = { LINKED_DEVICES_PREFIX, buildPairingQrPayload, extractPairingRefs }
@@ -0,0 +1,185 @@
1
+ 'use strict'
2
+ /**
3
+ * KersioSocket's hand-maintained ClientPayload encoder.
4
+ * Field numbers are based on publicly documented/reverse-engineered protobuf
5
+ * schema observations and must be checked against the target WhatsApp Web build.
6
+ * This module does not perform pairing cryptography or manufacture key signatures.
7
+ */
8
+ const { fieldVarint, fieldBool, fieldBytes, fieldString, fieldMessage, fieldFixed32, pack } = require('./wire')
9
+
10
+ const PLATFORM = Object.freeze({
11
+ ANDROID: 0, IOS: 1, WINDOWS_PHONE: 2, BLACKBERRY: 3, BLACKBERRYX: 4,
12
+ S40: 5, S60: 6, PYTHON_CLIENT: 7, TIZEN: 8, ENTERPRISE: 9,
13
+ SMB_ANDROID: 10, KAIOS: 11, SMB_IOS: 12, WINDOWS: 13, WEB: 14,
14
+ PORTAL: 15, GREEN_ANDROID: 16, GREEN_IPHONE: 17, BLUE_ANDROID: 18,
15
+ BLUE_IPHONE: 19, FBLITE_ANDROID: 20, MLITE_ANDROID: 21, IGLITE_ANDROID: 22,
16
+ PAGE: 23, MACOS: 24, OCULUS_MSG: 25, OCULUS_CALL: 26, MILAN: 27, CAPI: 28,
17
+ WEAROS: 29, ARDEVICE: 30, VRDEVICE: 31, BLUE_WEB: 32, IPAD: 33, TEST: 34,
18
+ SMART_GLASSES: 35, BLUE_VR: 36, AR_WRIST: 37
19
+ })
20
+ const RELEASE_CHANNEL = Object.freeze({ RELEASE: 0, BETA: 1, ALPHA: 2, DEBUG: 3 })
21
+ const WEB_SUB_PLATFORM = Object.freeze({ WEB_BROWSER: 0, APP_STORE: 1, WIN_STORE: 2, DARWIN: 3, WIN32: 4, WIN_HYBRID: 5 })
22
+ const CONNECT_REASON = Object.freeze({ PUSH: 0, USER_ACTIVATED: 1, SCHEDULED: 2, ERROR_RECONNECT: 3, NETWORK_SWITCH: 4, PING_RECONNECT: 5 })
23
+ const CONNECT_TYPE = Object.freeze({ CELLULAR_UNKNOWN: 0, WIFI_UNKNOWN: 1, CELLULAR_EDGE: 100, CELLULAR_IDEN: 101, CELLULAR_UMTS: 102, CELLULAR_EVDO: 103, CELLULAR_GPRS: 104, CELLULAR_HSDPA: 105, CELLULAR_HSUPA: 106, CELLULAR_HSPA: 107, CELLULAR_CDMA: 108, CELLULAR_1XRTT: 109, CELLULAR_EHRPD: 110, CELLULAR_LTE: 111, CELLULAR_HSPAP: 112 })
24
+ const PRODUCT = Object.freeze({ WHATSAPP: 0, MESSENGER: 1, INTEROP: 2, INTEROP_MSGR: 3, WHATSAPP_LID: 4 })
25
+ const DEVICE_TYPE = Object.freeze({ PHONE: 0, TABLET: 1, DESKTOP: 2, WEARABLE: 3, VR: 4 })
26
+ const IOS_APP_EXTENSION = Object.freeze({ SHARE_EXTENSION: 0, SERVICE_EXTENSION: 1, INTENTS_EXTENSION: 2 })
27
+ const TRAFFIC_ANONYMIZATION = Object.freeze({ OFF: 0, STANDARD: 1 })
28
+ const ACCOUNT_TYPE = Object.freeze({ DEFAULT: 0, GUEST: 1 })
29
+
30
+ function enumValue(table, value, name) {
31
+ if (typeof value === 'string') {
32
+ if (!Object.hasOwn(table, value)) throw new TypeError(`${name} enum tidak dikenal: ${value}`)
33
+ return table[value]
34
+ }
35
+ if (!Number.isInteger(value) || !Object.values(table).includes(value)) throw new TypeError(`${name} harus nama enum atau nilai enum yang dikenal`)
36
+ return value
37
+ }
38
+
39
+ function nonEmptyString(value, name) {
40
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} wajib berupa string non-kosong`)
41
+ return value
42
+ }
43
+
44
+ function normalizeVersion(version, name) {
45
+ if (Array.isArray(version)) {
46
+ if (version.length < 3 || version.length > 5) throw new TypeError(`${name} harus memiliki 3 sampai 5 angka versi`)
47
+ return version.map((part, i) => {
48
+ if (!Number.isInteger(part) || part < 0 || part > 0xffffffff) throw new TypeError(`${name}[${i}] harus uint32`)
49
+ return part
50
+ })
51
+ }
52
+ if (version && typeof version === 'object') {
53
+ const ordered = ['primary', 'secondary', 'tertiary', 'quaternary', 'quinary']
54
+ const out = ordered.filter((key) => version[key] !== undefined).map((key) => version[key])
55
+ if (out.length < 3 || out.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffffffff)) {
56
+ throw new TypeError(`${name} perlu primary/secondary/tertiary berupa uint32`)
57
+ }
58
+ return out
59
+ }
60
+ throw new TypeError(`${name} harus array angka atau objek versi`)
61
+ }
62
+
63
+ function encodeAppVersion(version) {
64
+ const parts = normalizeVersion(version, 'userAgent.appVersion')
65
+ return pack(parts.map((part, i) => fieldVarint(i + 1, part)))
66
+ }
67
+
68
+ function encodeUserAgent(input) {
69
+ if (!input || typeof input !== 'object') throw new TypeError('userAgent wajib diisi')
70
+ if (input.platform === undefined) throw new TypeError('userAgent.platform wajib diisi')
71
+ if (input.appVersion === undefined) throw new TypeError('userAgent.appVersion wajib diisi')
72
+ const parts = [
73
+ fieldVarint(1, enumValue(PLATFORM, input.platform, 'platform')),
74
+ fieldMessage(2, encodeAppVersion(input.appVersion)),
75
+ fieldString(3, input.mcc), fieldString(4, input.mnc),
76
+ fieldString(5, input.osVersion), fieldString(6, input.manufacturer),
77
+ fieldString(7, input.device), fieldString(8, input.osBuildNumber),
78
+ fieldString(9, input.phoneId),
79
+ input.releaseChannel === undefined ? null : fieldVarint(10, enumValue(RELEASE_CHANNEL, input.releaseChannel, 'releaseChannel')),
80
+ fieldString(11, input.localeLanguageIso6391 || input.localeLanguage),
81
+ fieldString(12, input.localeCountryIso31661Alpha2 || input.localeCountry),
82
+ fieldString(13, input.deviceBoard), fieldString(14, input.deviceExpId),
83
+ input.deviceType === undefined ? null : fieldVarint(15, enumValue(DEVICE_TYPE, input.deviceType, 'deviceType')),
84
+ fieldString(16, input.deviceModelType)
85
+ ]
86
+ return pack(parts)
87
+ }
88
+
89
+ function encodeWebInfo(input) {
90
+ if (!input || typeof input !== 'object') throw new TypeError('webInfo wajib diisi')
91
+ const version = nonEmptyString(input.version, 'webInfo.version')
92
+ const parts = [fieldString(1, input.refToken), fieldString(2, version)]
93
+ if (input.webSubPlatform !== undefined) {
94
+ parts.push(fieldVarint(4, enumValue(WEB_SUB_PLATFORM, input.webSubPlatform, 'webSubPlatform')))
95
+ }
96
+ if (input.webdPayload !== undefined) {
97
+ if (!(Buffer.isBuffer(input.webdPayload) || input.webdPayload instanceof Uint8Array)) {
98
+ throw new TypeError('webInfo.webdPayload harus bytes protobuf yang sudah di-encode')
99
+ }
100
+ parts.push(fieldMessage(3, input.webdPayload))
101
+ }
102
+ return pack(parts)
103
+ }
104
+
105
+ function requireBytes(input, key, length, label) {
106
+ const value = input[key]
107
+ if (!(Buffer.isBuffer(value) || value instanceof Uint8Array)) throw new TypeError(`devicePairingData.${key} wajib berupa bytes`)
108
+ const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength)
109
+ if (length !== undefined && buf.length !== length) throw new RangeError(`devicePairingData.${key} harus ${length} byte`)
110
+ if (label === 'regid' && ![2, 3, 4].includes(buf.length)) throw new RangeError('devicePairingData.eRegid harus 2, 3, atau 4 byte')
111
+ if (label === 'deviceProps' && buf.length === 0) throw new RangeError('devicePairingData.deviceProps tidak boleh kosong')
112
+ return buf
113
+ }
114
+
115
+ function encodeDevicePairingData(input) {
116
+ if (!input || typeof input !== 'object') throw new TypeError('devicePairingData wajib berupa objek')
117
+ return pack([
118
+ fieldBytes(1, requireBytes(input, 'eRegid', 4)),
119
+ fieldBytes(2, requireBytes(input, 'eKeytype', 1)),
120
+ fieldBytes(3, requireBytes(input, 'eIdent', 32)),
121
+ fieldBytes(4, requireBytes(input, 'eSkeyId', 3)),
122
+ fieldBytes(5, requireBytes(input, 'eSkeyVal', 32)),
123
+ fieldBytes(6, requireBytes(input, 'eSkeySig', 64)),
124
+ fieldBytes(7, requireBytes(input, 'buildHash', 16)),
125
+ fieldBytes(8, requireBytes(input, 'deviceProps', undefined, 'deviceProps'))
126
+ ])
127
+ }
128
+
129
+ /**
130
+ * Encode an explicit ClientPayload object to bytes for Noise ClientFinish.
131
+ * `mode: 'registration'` requires a complete, externally prepared pairing bundle;
132
+ * `mode: 'login'` requires an existing WhatsApp numeric username.
133
+ *
134
+ * Version values are intentionally supplied by the caller, not hard-coded, because
135
+ * WhatsApp Web changes them. A successful encode does not prove server compatibility.
136
+ */
137
+ function encodeClientPayload(input) {
138
+ if (!input || typeof input !== 'object' || Buffer.isBuffer(input)) throw new TypeError('clientPayload harus berupa objek terstruktur')
139
+ const mode = input.mode || (input.devicePairingData ? 'registration' : 'login')
140
+ if (mode !== 'registration' && mode !== 'login') throw new TypeError("mode harus 'registration' atau 'login'")
141
+ if (mode === 'registration' && input.username !== undefined) throw new TypeError('registration tidak boleh memakai username login')
142
+ if (input.agent !== undefined) throw new TypeError('ClientPayload.agent tidak tersedia di schema yang dipatok; field 17 tidak didefinisikan di referensi ini')
143
+ if (mode === 'registration' && input.devicePairingData === undefined) throw new TypeError('registration memerlukan devicePairingData lengkap')
144
+ if (mode === 'login' && input.devicePairingData !== undefined) throw new TypeError('login tidak boleh membawa devicePairingData registrasi')
145
+ if (mode === 'login') {
146
+ const username = input.username
147
+ if (typeof username !== 'string' || !/^\d{5,20}$/.test(username)) throw new TypeError('username login harus string digit nomor akun, tanpa tanda +')
148
+ }
149
+
150
+ const userAgent = encodeUserAgent(input.userAgent)
151
+ const webInfo = encodeWebInfo(input.webInfo)
152
+ const payload = [
153
+ input.username === undefined ? null : fieldVarint(1, BigInt(input.username)),
154
+ fieldBool(3, input.passive),
155
+ fieldMessage(5, userAgent), fieldMessage(6, webInfo),
156
+ fieldString(7, input.pushName),
157
+ fieldFixed32(9, input.sessionId),
158
+ fieldBool(10, input.shortConnect),
159
+ input.connectType === undefined ? null : fieldVarint(12, enumValue(CONNECT_TYPE, input.connectType, 'connectType')),
160
+ input.connectReason === undefined ? null : fieldVarint(13, enumValue(CONNECT_REASON, input.connectReason, 'connectReason')),
161
+ fieldVarint(16, input.connectAttemptCount), fieldVarint(18, input.device),
162
+ input.devicePairingData === undefined ? null : fieldMessage(19, encodeDevicePairingData(input.devicePairingData)),
163
+ input.product === undefined ? null : fieldVarint(20, enumValue(PRODUCT, input.product, 'product')),
164
+ fieldBytes(21, input.fbCat), fieldBytes(22, input.fbUserAgent),
165
+ fieldBool(23, input.oc), fieldVarint(24, input.lc),
166
+ input.iosAppExtension === undefined ? null : fieldVarint(30, enumValue(IOS_APP_EXTENSION, input.iosAppExtension, 'iosAppExtension')),
167
+ input.fbAppId === undefined ? null : fieldVarint(31, input.fbAppId), fieldBytes(32, input.fbDeviceId),
168
+ fieldBool(33, input.pull), fieldBytes(34, input.paddingBytes),
169
+ fieldVarint(36, input.yearClass), fieldVarint(37, input.memClass),
170
+ input.trafficAnonymization === undefined ? null : fieldVarint(40, enumValue(TRAFFIC_ANONYMIZATION, input.trafficAnonymization, 'trafficAnonymization')),
171
+ fieldBool(41, input.lidDbMigrated),
172
+ input.accountType === undefined ? null : fieldVarint(42, enumValue(ACCOUNT_TYPE, input.accountType, 'accountType')),
173
+ fieldFixed32(43, input.connectionSequenceInfo), fieldBool(44, input.paaLink),
174
+ fieldVarint(45, input.preacksCount), fieldVarint(46, input.processingQueueSize)
175
+ ]
176
+ const encoded = pack(payload)
177
+ if (encoded.length === 0) throw new Error('ClientPayload hasil encode kosong')
178
+ return encoded
179
+ }
180
+
181
+ module.exports = {
182
+ encodeClientPayload, encodeUserAgent, encodeWebInfo, encodeDevicePairingData,
183
+ PLATFORM, RELEASE_CHANNEL, WEB_SUB_PLATFORM, CONNECT_REASON, CONNECT_TYPE, PRODUCT, DEVICE_TYPE,
184
+ IOS_APP_EXTENSION, TRAFFIC_ANONYMIZATION, ACCOUNT_TYPE
185
+ }
@@ -0,0 +1,143 @@
1
+ 'use strict'
2
+ /**
3
+ * Explicit encoder for the pinned WAProto DeviceProps schema snapshot.
4
+ * This builds protobuf bytes only; it does not create/verify Signal keys or signatures.
5
+ */
6
+ const crypto = require('node:crypto')
7
+ const { fieldVarint, fieldBool, fieldString, fieldMessage, fieldBytes, pack } = require('./wire')
8
+
9
+ const PLATFORM_TYPE = Object.freeze({
10
+ UNKNOWN: 0, CHROME: 1, FIREFOX: 2, IE: 3, OPERA: 4, SAFARI: 5, EDGE: 6,
11
+ DESKTOP: 7, IPAD: 8, ANDROID_TABLET: 9, OHANA: 10, ALOHA: 11, CATALINA: 12,
12
+ TCL_TV: 13, IOS_PHONE: 14, IOS_CATALYST: 15, ANDROID_PHONE: 16,
13
+ ANDROID_AMBIGUOUS: 17, WEAR_OS: 18, AR_WRIST: 19, AR_DEVICE: 20, UWP: 21,
14
+ VR: 22, CLOUD_API: 23, SMARTGLASSES: 24
15
+ })
16
+ const HISTORY_FIELDS = Object.freeze([
17
+ ['fullSyncDaysLimit', 1, 'uint'], ['fullSyncSizeMbLimit', 2, 'uint'], ['storageQuotaMb', 3, 'uint'],
18
+ ['inlineInitialPayloadInE2EeMsg', 4, 'bool'], ['recentSyncDaysLimit', 5, 'uint'],
19
+ ['supportCallLogHistory', 6, 'bool'], ['supportBotUserAgentChatHistory', 7, 'bool'],
20
+ ['supportCagReactionsAndPolls', 8, 'bool'], ['supportBizHostedMsg', 9, 'bool'],
21
+ ['supportRecentSyncChunkMessageCountTuning', 10, 'bool'], ['supportHostedGroupMsg', 11, 'bool'],
22
+ ['supportFbidBotChatHistory', 12, 'bool'], ['supportAddOnHistorySyncMigration', 13, 'bool'],
23
+ ['supportMessageAssociation', 14, 'bool'], ['supportGroupHistory', 15, 'bool'],
24
+ ['onDemandReady', 16, 'bool'], ['supportGuestChat', 17, 'bool'],
25
+ ['completeOnDemandReady', 18, 'bool'], ['thumbnailSyncDaysLimit', 19, 'uint']
26
+ ])
27
+ const VERSION_FIELDS = ['primary', 'secondary', 'tertiary', 'quaternary', 'quinary']
28
+
29
+ function versionParts(version, label = 'version') {
30
+ let parts
31
+ if (Array.isArray(version)) parts = version
32
+ else if (version && typeof version === 'object') parts = VERSION_FIELDS.filter((k) => version[k] !== undefined).map((k) => version[k])
33
+ else throw new TypeError(`${label} harus array atau objek versi`)
34
+ if (parts.length < 3 || parts.length > 5 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffffffff)) {
35
+ throw new TypeError(`${label} harus berisi 3–5 uint32`)
36
+ }
37
+ return parts
38
+ }
39
+
40
+ function encodeVersion(version, label) {
41
+ return pack(versionParts(version, label).map((n, i) => fieldVarint(i + 1, n)))
42
+ }
43
+
44
+ function enumField(table, value, field, label) {
45
+ if (value === undefined || value === null) return null
46
+ let n = value
47
+ if (typeof n === 'string') {
48
+ if (!Object.hasOwn(table, n)) throw new TypeError(`${label} enum tidak dikenal: ${n}`)
49
+ n = table[n]
50
+ }
51
+ if (!Number.isInteger(n) || !Object.values(table).includes(n)) throw new TypeError(`${label} harus nilai enum yang dikenal`)
52
+ return fieldVarint(field, n)
53
+ }
54
+
55
+ function encodeHistorySyncConfig(input) {
56
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('historySyncConfig harus objek')
57
+ const known = new Set(HISTORY_FIELDS.map(([key]) => key))
58
+ for (const key of Object.keys(input)) if (!known.has(key)) throw new TypeError(`historySyncConfig field tidak dikenal: ${key}`)
59
+ const fields = []
60
+ for (const [key, no, type] of HISTORY_FIELDS) {
61
+ if (input[key] === undefined || input[key] === null) continue
62
+ if (type === 'bool') fields.push(fieldBool(no, input[key]))
63
+ else {
64
+ if (!Number.isInteger(input[key]) || input[key] < 0 || input[key] > 0xffffffff) throw new TypeError(`historySyncConfig.${key} harus uint32`)
65
+ fields.push(fieldVarint(no, input[key]))
66
+ }
67
+ }
68
+ return pack(fields)
69
+ }
70
+
71
+ function encodeDeviceProps(input) {
72
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('deviceProps harus objek')
73
+ const allowed = new Set(['os', 'version', 'platformType', 'requireFullSync', 'historySyncConfig'])
74
+ for (const key of Object.keys(input)) if (!allowed.has(key)) throw new TypeError(`DeviceProps field tidak dikenal: ${key}`)
75
+ const fields = [
76
+ input.os === undefined ? null : fieldString(1, input.os),
77
+ input.version === undefined ? null : fieldMessage(2, encodeVersion(input.version, 'DeviceProps.version')),
78
+ enumField(PLATFORM_TYPE, input.platformType, 3, 'DeviceProps.platformType'),
79
+ fieldBool(4, input.requireFullSync),
80
+ input.historySyncConfig === undefined ? null : fieldMessage(5, encodeHistorySyncConfig(input.historySyncConfig))
81
+ ]
82
+ const encoded = pack(fields)
83
+ if (encoded.length === 0) throw new TypeError('DeviceProps tidak boleh kosong')
84
+ return encoded
85
+ }
86
+
87
+ /** Encode a registration ID / signed-prekey ID in unsigned big-endian form. */
88
+ function encodeBigEndianUnsigned(value, width) {
89
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('nilai big-endian harus integer non-negatif yang aman')
90
+ if (!Number.isInteger(width) || width < 1 || width > 6) throw new RangeError('width harus 1..6 byte')
91
+ if (BigInt(value) >= (1n << BigInt(width * 8))) throw new RangeError(`nilai tidak muat dalam ${width} byte`)
92
+ const out = Buffer.alloc(width)
93
+ let n = BigInt(value)
94
+ for (let i = width - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n }
95
+ return out
96
+ }
97
+
98
+ /** Matches the observed registration buildHash: MD5 of dotted client version. */
99
+ function buildAppVersionHash(version) {
100
+ const parts = versionParts(version, 'appVersion')
101
+ return crypto.createHash('md5').update(parts.join('.'), 'utf8').digest()
102
+ }
103
+
104
+ /**
105
+ * Build the DevicePairingRegistrationData fields from caller-created key material.
106
+ * No keys/signatures are generated here; signedPreKey.signature must already be valid.
107
+ */
108
+ function createDevicePairingData({ registrationId, identityPublicKey, signedPreKey, appVersion, deviceProps, eKeytype = Buffer.from([5]) }) {
109
+ if (!signedPreKey || typeof signedPreKey !== 'object') throw new TypeError('signedPreKey harus berisi keyId, publicKey, signature')
110
+ if (!Buffer.isBuffer(identityPublicKey) && !(identityPublicKey instanceof Uint8Array)) throw new TypeError('identityPublicKey harus bytes')
111
+ if (!Buffer.isBuffer(signedPreKey.publicKey) && !(signedPreKey.publicKey instanceof Uint8Array)) throw new TypeError('signedPreKey.publicKey harus bytes')
112
+ if (!Buffer.isBuffer(signedPreKey.signature) && !(signedPreKey.signature instanceof Uint8Array)) throw new TypeError('signedPreKey.signature harus bytes')
113
+ if (!Buffer.isBuffer(eKeytype) && !(eKeytype instanceof Uint8Array)) throw new TypeError('eKeytype harus bytes')
114
+ const identity = Buffer.from(identityPublicKey)
115
+ const preKey = Buffer.from(signedPreKey.publicKey)
116
+ const signature = Buffer.from(signedPreKey.signature)
117
+ const keyType = Buffer.from(eKeytype)
118
+ if (identity.length !== 32) throw new RangeError('identityPublicKey harus 32 byte')
119
+ if (preKey.length !== 32) throw new RangeError('signedPreKey.publicKey harus 32 byte')
120
+ if (signature.length !== 64) throw new RangeError('signedPreKey.signature harus 64 byte')
121
+ if (keyType.length !== 1) throw new RangeError('eKeytype harus 1 byte pada schema ini')
122
+ const props = Buffer.isBuffer(deviceProps) || deviceProps instanceof Uint8Array ? Buffer.from(deviceProps) : encodeDeviceProps(deviceProps)
123
+ if (!props.length) throw new RangeError('deviceProps tidak boleh kosong')
124
+ return {
125
+ eRegid: encodeBigEndianUnsigned(registrationId, 4),
126
+ eKeytype: keyType,
127
+ eIdent: identity,
128
+ eSkeyId: encodeBigEndianUnsigned(signedPreKey.keyId, 3),
129
+ eSkeyVal: preKey,
130
+ eSkeySig: signature,
131
+ buildHash: buildAppVersionHash(appVersion),
132
+ deviceProps: props
133
+ }
134
+ }
135
+
136
+ module.exports = {
137
+ PLATFORM_TYPE,
138
+ encodeDeviceProps,
139
+ encodeHistorySyncConfig,
140
+ encodeBigEndianUnsigned,
141
+ buildAppVersionHash,
142
+ createDevicePairingData
143
+ }
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+ /**
3
+ * Encoder/decoder protobuf MINIMAL khusus pesan handshake.
4
+ * Hanya mendukung field length-delimited (wire type 2) + skip varint.
5
+ * Nanti (v0.2) diganti proto lengkap; ini sengaja kecil supaya cepat dimuat.
6
+ *
7
+ * HandshakeMessage { clientHello = 2; serverHello = 3; clientFinish = 4 }
8
+ * ClientHello { ephemeral = 1; static = 2; payload = 3 }
9
+ * ServerHello { ephemeral = 1; static = 2; payload = 3 }
10
+ * ClientFinish { static = 1; payload = 2 }
11
+ */
12
+
13
+ function varint(n) {
14
+ const out = []
15
+ while (n > 0x7f) { out.push((n & 0x7f) | 0x80); n >>>= 7 }
16
+ out.push(n)
17
+ return Buffer.from(out)
18
+ }
19
+
20
+ function field(num, buf) {
21
+ return Buffer.concat([varint((num << 3) | 2), varint(buf.length), buf])
22
+ }
23
+
24
+ function decodeFields(buf) {
25
+ const fields = new Map()
26
+ let i = 0
27
+ const readVarint = () => {
28
+ let result = 0, shift = 0, b
29
+ do {
30
+ if (i >= buf.length) throw new Error('protobuf: varint terpotong')
31
+ b = buf[i++]
32
+ result |= (b & 0x7f) << shift
33
+ shift += 7
34
+ } while (b & 0x80)
35
+ return result >>> 0
36
+ }
37
+ while (i < buf.length) {
38
+ const tag = readVarint()
39
+ const num = tag >>> 3, wire = tag & 7
40
+ if (wire === 0) readVarint()
41
+ else if (wire === 2) {
42
+ const len = readVarint()
43
+ if (i + len > buf.length) throw new Error('protobuf: field melebihi buffer')
44
+ fields.set(num, buf.subarray(i, i + len))
45
+ i += len
46
+ } else throw new Error(`protobuf: wire type ${wire} belum didukung`)
47
+ }
48
+ return fields
49
+ }
50
+
51
+ const encodeClientHello = (ephemeral) => field(2, field(1, ephemeral))
52
+ const encodeServerHello = ({ ephemeral, static: s, payload }) =>
53
+ field(3, Buffer.concat([field(1, ephemeral), field(2, s), field(3, payload)]))
54
+ const encodeClientFinish = ({ static: s, payload }) =>
55
+ field(4, Buffer.concat([field(1, s), field(2, payload)]))
56
+
57
+ function need(map, num, name) {
58
+ const v = map.get(num)
59
+ if (!v) throw new Error(`handshake: field ${name} tidak ada`)
60
+ return v
61
+ }
62
+
63
+ function decodeClientHello(buf) {
64
+ const inner = decodeFields(need(decodeFields(buf), 2, 'clientHello'))
65
+ return { ephemeral: need(inner, 1, 'ephemeral') }
66
+ }
67
+ function decodeServerHello(buf) {
68
+ const inner = decodeFields(need(decodeFields(buf), 3, 'serverHello'))
69
+ return { ephemeral: need(inner, 1, 'ephemeral'), static: need(inner, 2, 'static'), payload: need(inner, 3, 'payload') }
70
+ }
71
+ function decodeClientFinish(buf) {
72
+ const inner = decodeFields(need(decodeFields(buf), 4, 'clientFinish'))
73
+ return { static: need(inner, 1, 'static'), payload: need(inner, 2, 'payload') }
74
+ }
75
+
76
+ module.exports = {
77
+ encodeClientHello, encodeServerHello, encodeClientFinish,
78
+ decodeClientHello, decodeServerHello, decodeClientFinish
79
+ }