stealth-ws 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # stealth-ws
2
+
3
+ WebSocket client with TLS fingerprint spoofing. Produces TLS `ClientHello` messages that are byte-for-byte identical to real browser handshakes, bypassing fingerprint-based bot detection at the transport layer.
4
+
5
+ ---
6
+
7
+ ## How TLS Fingerprinting Works
8
+
9
+ When any TLS client connects to a server, the first thing it sends is a `ClientHello` message. This message contains:
10
+
11
+ - **Cipher suites** — list of encryption algorithms the client supports, in preference order
12
+ - **TLS extensions** — e.g. `server_name` (SNI), `supported_groups`, `signature_algorithms`, `key_share`, `session_ticket`, `ALPN`, etc.
13
+ - **Extension ordering** — the exact sequence in which extensions appear
14
+ - **Compression methods**
15
+ - **Supported TLS versions**
16
+
17
+ Every TLS implementation has a unique combination of these fields. Chrome, Firefox, curl, Node.js's built-in `https`, Go's `net/tls` — they all produce structurally different `ClientHello` messages.
18
+
19
+ ### JA3
20
+
21
+ JA3 (Salesforce, 2017) is the first widely adopted fingerprinting method. It computes an MD5 hash over five fields extracted from the `ClientHello`:
22
+
23
+ ```
24
+ JA3 = MD5(TLSVersion, Ciphers, Extensions, EllipticCurves, EllipticCurvePointFormats)
25
+ ```
26
+
27
+ Each field is a comma-separated list of decimal values, joined by `|`. For example:
28
+
29
+ ```
30
+ 771,4866-4867-4865-...,0-23-65281-10-11-35-16-5-13-...,29-23-24,0
31
+ ```
32
+
33
+ This string is MD5-hashed to produce a 32-character fingerprint like `cd08e31494f9531f560d64c695473da9` (Chrome 120).
34
+
35
+ Because the hash is deterministic and stable per browser version, servers can maintain a database of known-good hashes (Chrome, Firefox, Safari) and reject anything that doesn't match — including raw TLS stacks like Go's `crypto/tls`, Node.js's `tls`, or Python's `ssl`.
36
+
37
+ ### JA3S
38
+
39
+ The server-side counterpart. Hashes fields from the `ServerHello` response. Used to fingerprint servers, less commonly used for bot detection.
40
+
41
+ ### JA4
42
+
43
+ JA4 (FoxIO, 2023) is a successor to JA3 that addresses several weaknesses:
44
+
45
+ - JA3 is broken by **extension randomization** (Chrome 110+ shuffles extension order) — the same browser produces different JA3 hashes across connections
46
+ - JA4 sorts extensions and cipher suites before hashing, making it **randomization-resistant**
47
+ - JA4 uses a human-readable format (`t13d1516h2_...`) instead of MD5, making it inspectable without a lookup table
48
+ - JA4 also captures ALPN first/last values and whether SNI is present
49
+
50
+ JA4 format:
51
+ ```
52
+ {protocol}{tls_version}{sni}{cipher_count}{ext_count}{alpn_first_last}_{sorted_cipher_hash}_{sorted_ext_hash}
53
+ ```
54
+
55
+ Example: `t13d1516h2_8daaf6152771_b0da82dd1658` (Chrome 120)
56
+
57
+ ### What Gets Inspected in Practice
58
+
59
+ Bot detection services (Cloudflare, Akamai, DataDome, PerimeterX, etc.) don't just check JA3/JA4. A full fingerprint inspection layer includes:
60
+
61
+ | Layer | What's checked |
62
+ |-------|----------------|
63
+ | TLS | JA3, JA4, cipher suite order, extension order, `key_share` groups, ALPN |
64
+ | HTTP/1.1 | Header order, `User-Agent`, `Accept`, `Accept-Encoding`, `Accept-Language` |
65
+ | HTTP/2 | SETTINGS frame values, WINDOW_UPDATE size, header pseudo-order, HPACK huffman encoding |
66
+ | WebSocket | Upgrade header casing, extension negotiation |
67
+ | Behavioral | Request timing, mouse movement, JS challenge results |
68
+
69
+ A Node.js WebSocket client using the standard `ws` package over Node's built-in TLS will fail at the first layer — the TLS fingerprint — before any HTTP headers are even examined, because Go's and Node's `crypto/tls` produce well-known non-browser fingerprints that are trivially blocklisted.
70
+
71
+ ---
72
+
73
+ ## What This Package Does
74
+
75
+ ### The Problem
76
+
77
+ Node.js's `tls` module uses Go's `crypto/tls` under the hood (via libuv/OpenSSL). Its `ClientHello` looks nothing like a browser. The cipher suite list, extension set, and ordering are all wrong. You can set `User-Agent: Mozilla/5.0 ...` all you want — the TLS handshake happens before HTTP and gives you away immediately.
78
+
79
+ ### The Solution
80
+
81
+ This package spawns a Go subprocess (`stealth-bridge`) that uses [**uTLS**](https://github.com/refraction-networking/utls) — a fork of Go's `crypto/tls` that allows full manual control over `ClientHello` construction. uTLS ships pre-built `ClientHelloSpec` definitions for every major browser version, capturing the exact cipher suites, extensions, values, and ordering that real browsers produce.
82
+
83
+ The Go bridge:
84
+ 1. Receives connection config from Node.js over stdin (JSON)
85
+ 2. Dials the target with a uTLS connection using the specified browser spec
86
+ 3. Performs the WebSocket upgrade over the spoofed TLS connection
87
+ 4. Relays frames to/from Node.js over stdout/stdin (newline-delimited JSON)
88
+
89
+ Node.js never touches the TLS connection directly. The handshake is entirely owned by the Go process.
90
+
91
+ ### ALPN Handling
92
+
93
+ WebSocket connections use HTTP/1.1 for the upgrade handshake. Chrome sends `ALPN: http/1.1` for WebSocket connections — not `h2`. If the server negotiates HTTP/2 in response to an `h2` ALPN offer, the WebSocket upgrade will fail because HTTP/2 doesn't support the `Connection: Upgrade` mechanism (RFC 7540 §8.1.1).
94
+
95
+ The bridge explicitly patches the ALPN extension in the cloned spec before the handshake:
96
+
97
+ ```go
98
+ alpn.AlpnProtocols = []string{"http/1.1"}
99
+ ```
100
+
101
+ This ensures the TLS fingerprint is still browser-accurate while forcing the connection to HTTP/1.1 for the WebSocket upgrade.
102
+
103
+ ### Architecture
104
+
105
+ ```
106
+ Node.js process
107
+
108
+ ├── new WebSocket(url, options)
109
+ │ └── spawn stealth-bridge (Go binary)
110
+ │ │
111
+ │ ├── stdin ← JSON config + send/close commands
112
+ │ └── stdout → JSON events (open/message/binary/close/error)
113
+
114
+ └── stealth-bridge
115
+ ├── uTLS ClientHello (spoofed browser spec)
116
+ ├── TCP → TLS → HTTP/1.1 Upgrade → WebSocket
117
+ └── gorilla/websocket for frame handling
118
+ ```
119
+
120
+ ### Available Fingerprint Profiles
121
+
122
+ Profiles map to pre-built `ClientHelloSpec` definitions in uTLS v1.6.6:
123
+
124
+ | Profile | Maps to |
125
+ |---------|---------|
126
+ | `chrome120` | `HelloChrome_120` |
127
+ | `chrome115` | `HelloChrome_115_PQ` (post-quantum key share) |
128
+ | `chrome114` | `HelloChrome_114_Padding_PSK_Shuf` |
129
+ | `chrome112` | `HelloChrome_112_PSK_Shuf` |
130
+ | `chrome100` | `HelloChrome_100` |
131
+ | `chromeAuto` | `HelloChrome_Auto` (latest Chrome in uTLS) |
132
+ | `firefox120` | `HelloFirefox_120` |
133
+ | `firefoxAuto` | `HelloFirefox_Auto` |
134
+ | `safari16` | `HelloSafari_16_0` |
135
+ | `safariAuto` | `HelloSafari_Auto` |
136
+ | `edge106` | `HelloEdge_106` |
137
+ | `edgeAuto` | `HelloEdge_Auto` |
138
+ | `ios14` | `HelloIOS_14` |
139
+ | `iosAuto` | `HelloIOS_Auto` |
140
+ | `android11` | `HelloAndroid_11_OkHttp` |
141
+
142
+ > Note: uTLS v1.6.6 does not include specs for every minor browser version. Profiles without an exact match fall back to the nearest available spec. See `fingerprintMap` in `src/bridge/main.go` for the full mapping.
143
+
144
+ ---
145
+
146
+ ## Installation
147
+
148
+ ```bash
149
+ npm install stealth-ws
150
+ ```
151
+
152
+ The `postinstall` script copies the prebuilt binary for your platform from `prebuilds/{platform}-x64/` to `bin/`.
153
+
154
+ ## Quick Start
155
+
156
+ ```javascript
157
+ import WebSocket from 'stealth-ws';
158
+
159
+ const ws = new WebSocket('wss://example.com/ws', {
160
+ fingerprint: 'chrome120',
161
+ headers: {
162
+ 'Origin': 'https://example.com',
163
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
164
+ }
165
+ });
166
+
167
+ ws.on('open', () => {
168
+ ws.send('Hello!');
169
+ });
170
+
171
+ ws.on('message', (data, isBinary) => {
172
+ console.log('Received:', data.toString());
173
+ });
174
+ ```
175
+
176
+ ## API
177
+
178
+ ### `new WebSocket(url, options)`
179
+
180
+ | Option | Type | Default | Description |
181
+ |--------|------|---------|-------------|
182
+ | `fingerprint` | `string` | `'chrome120'` | TLS fingerprint profile |
183
+ | `cookies` | `string\|Array\|CookieJar` | — | Cookie header value |
184
+ | `proxy` | `string` | — | `socks5://` or `http://` proxy URL |
185
+ | `headers` | `object` | — | HTTP headers sent on upgrade (Origin, User-Agent, etc.) |
186
+ | `perMessageDeflate` | `boolean` | `true` | Enable per-message deflate compression |
187
+ | `debug` | `boolean` | `false` | Log debug messages from bridge |
188
+
189
+ ### Events
190
+
191
+ | Event | Args | Description |
192
+ |-------|------|-------------|
193
+ | `open` | — | Handshake complete |
194
+ | `message` | `(data: Buffer, isBinary: boolean)` | Frame received |
195
+ | `close` | `(code: number, reason: string)` | Connection closed |
196
+ | `error` | `(err: Error)` | Error |
197
+ | `auth_required` | — | Server returned 403 |
198
+
199
+ ### Methods
200
+
201
+ | Method | Description |
202
+ |--------|-------------|
203
+ | `send(data, [options], [cb])` | Send text or binary frame |
204
+ | `close([code], [reason])` | Graceful close |
205
+ | `terminate()` | Kill bridge process immediately |
206
+ | `ping([data], [mask], [cb])` | Send ping frame |
207
+ | `pong([data], [mask], [cb])` | Send pong frame |
208
+
209
+ ### Cookie Management
210
+
211
+ ```javascript
212
+ // String
213
+ new WebSocket(url, { cookies: 'session=abc; token=xyz' });
214
+
215
+ // Array (Puppeteer format)
216
+ const cookies = await page.cookies();
217
+ new WebSocket(url, { cookies });
218
+
219
+ // CookieJar
220
+ import { CookieJar } from 'stealth-ws';
221
+ const jar = new CookieJar();
222
+ jar.loadFromFile('cookies.json');
223
+ new WebSocket(url, { cookies: jar });
224
+ ```
225
+
226
+ ### Proxy
227
+
228
+ ```javascript
229
+ new WebSocket(url, { proxy: 'socks5://127.0.0.1:1080', fingerprint: 'chrome120' });
230
+ new WebSocket(url, { proxy: 'http://user:pass@proxy.example.com:8080' });
231
+ ```
232
+
233
+ ## Platform Support
234
+
235
+ | Platform | Status |
236
+ |----------|--------|
237
+ | Windows x64 | ✅ prebuilt |
238
+ | Linux x64 | ✅ prebuilt |
239
+ | macOS x64 | ✅ prebuilt |
240
+ | Any ARM64 | ❌ build from source: `npm run build:go` |
241
+
242
+ ## Building from Source
243
+
244
+ Requires Go 1.21+.
245
+
246
+ ```bash
247
+ # Current platform only
248
+ npm run build:go
249
+
250
+ # All x64 platforms (cross-compile)
251
+ npm run prebuild
252
+ ```
253
+
254
+ ## Migration from `ws`
255
+
256
+ ```javascript
257
+ // Before
258
+ import WebSocket from 'ws';
259
+ const ws = new WebSocket('wss://example.com');
260
+
261
+ // After
262
+ import WebSocket from 'stealth-ws';
263
+ const ws = new WebSocket('wss://example.com', {
264
+ fingerprint: 'chrome120',
265
+ headers: { 'Origin': 'https://example.com' }
266
+ });
267
+ ```
268
+
269
+ The API is compatible. Add `fingerprint` and `headers` as needed.
270
+
271
+ ## License
272
+
273
+ MIT
Binary file
@@ -0,0 +1,276 @@
1
+ /**
2
+ * CookieJar - Cookie management for WebSocket connections
3
+ *
4
+ * Provides cookie storage and retrieval similar to browser CookieJar.
5
+ * Can load cookies from Puppeteer or other sources.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { parse, serialize } from 'url';
10
+
11
+ export class CookieJar {
12
+ /**
13
+ * Create a new CookieJar
14
+ */
15
+ constructor() {
16
+ this.cookies = new Map();
17
+ }
18
+
19
+ /**
20
+ * Set a cookie
21
+ *
22
+ * @param {string} name - Cookie name
23
+ * @param {string} value - Cookie value
24
+ * @param {Object} options - Cookie options
25
+ * @param {string} [options.domain] - Cookie domain
26
+ * @param {string} [options.path] - Cookie path
27
+ * @param {Date} [options.expires] - Expiration date
28
+ * @param {number} [options.maxAge] - Max age in seconds
29
+ * @param {boolean} [options.secure] - Secure flag
30
+ * @param {boolean} [options.httpOnly] - HttpOnly flag
31
+ * @param {string} [options.sameSite] - SameSite attribute
32
+ */
33
+ set(name, value, options = {}) {
34
+ const cookie = {
35
+ name,
36
+ value,
37
+ domain: options.domain || '',
38
+ path: options.path || '/',
39
+ expires: options.expires || null,
40
+ maxAge: options.maxAge || null,
41
+ secure: options.secure || false,
42
+ httpOnly: options.httpOnly || false,
43
+ sameSite: options.sameSite || null
44
+ };
45
+
46
+ const key = this._makeKey(cookie);
47
+ this.cookies.set(key, cookie);
48
+ }
49
+
50
+ /**
51
+ * Get a cookie by name for a URL
52
+ *
53
+ * @param {string} name - Cookie name
54
+ * @param {string} url - URL to get cookie for
55
+ * @returns {string|null} Cookie value or null
56
+ */
57
+ get(name, url) {
58
+ const cookie = this._findCookie(name, url);
59
+ return cookie ? cookie.value : null;
60
+ }
61
+
62
+ /**
63
+ * Get all cookies for a URL
64
+ *
65
+ * @param {string} url - URL to get cookies for
66
+ * @returns {Array} Array of cookie objects
67
+ */
68
+ getCookies(url) {
69
+ const parsedUrl = parse(url);
70
+ const domain = parsedUrl.hostname;
71
+ const path = parsedUrl.pathname;
72
+ const isSecure = parsedUrl.protocol === 'https:';
73
+
74
+ const result = [];
75
+
76
+ for (const cookie of this.cookies.values()) {
77
+ if (this._matchesCookie(cookie, domain, path, isSecure)) {
78
+ result.push({ ...cookie });
79
+ }
80
+ }
81
+
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Get cookies as a string for a URL
87
+ *
88
+ * @param {string} url - URL to get cookies for
89
+ * @returns {string} Cookie string
90
+ */
91
+ getCookieString(url) {
92
+ const cookies = this.getCookies(url);
93
+ return cookies.map(c => `${c.name}=${c.value}`).join('; ');
94
+ }
95
+
96
+ /**
97
+ * Check if a cookie exists
98
+ *
99
+ * @param {string} name - Cookie name
100
+ * @param {string} url - URL to check
101
+ * @returns {boolean} True if exists
102
+ */
103
+ has(name, url) {
104
+ return this._findCookie(name, url) !== null;
105
+ }
106
+
107
+ /**
108
+ * Remove a cookie
109
+ *
110
+ * @param {string} name - Cookie name
111
+ * @param {string} url - URL
112
+ */
113
+ remove(name, url) {
114
+ const cookie = this._findCookie(name, url);
115
+ if (cookie) {
116
+ const key = this._makeKey(cookie);
117
+ this.cookies.delete(key);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Clear all cookies
123
+ */
124
+ clear() {
125
+ this.cookies.clear();
126
+ }
127
+
128
+ /**
129
+ * Load cookies from a Puppeteer cookies array
130
+ *
131
+ * @param {Array} cookies - Puppeteer cookies
132
+ */
133
+ loadFromPuppeteer(cookies) {
134
+ for (const cookie of cookies) {
135
+ this.set(cookie.name, cookie.value, {
136
+ domain: cookie.domain,
137
+ path: cookie.path,
138
+ expires: cookie.expires ? new Date(cookie.expires * 1000) : null,
139
+ maxAge: cookie.maxAge,
140
+ secure: cookie.secure,
141
+ httpOnly: cookie.httpOnly,
142
+ sameSite: cookie.sameSite
143
+ });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Load cookies from a JSON file
149
+ *
150
+ * @param {string} filename - Path to JSON file
151
+ */
152
+ loadFromFile(filename) {
153
+ if (!existsSync(filename)) {
154
+ throw new Error(`Cookie file not found: ${filename}`);
155
+ }
156
+
157
+ const data = JSON.parse(readFileSync(filename, 'utf8'));
158
+
159
+ // Handle array format (Puppeteer style)
160
+ if (Array.isArray(data)) {
161
+ this.loadFromPuppeteer(data);
162
+ } else if (data.cookies && Array.isArray(data.cookies)) {
163
+ this.loadFromPuppeteer(data.cookies);
164
+ } else {
165
+ throw new Error('Invalid cookie file format');
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Save cookies to a JSON file
171
+ *
172
+ * @param {string} filename - Path to save to
173
+ */
174
+ saveToFile(filename) {
175
+ const cookies = Array.from(this.cookies.values());
176
+ writeFileSync(filename, JSON.stringify(cookies, null, 2));
177
+ }
178
+
179
+ /**
180
+ * Export cookies as Puppeteer format
181
+ *
182
+ * @returns {Array} Cookies in Puppeteer format
183
+ */
184
+ toPuppeteerFormat() {
185
+ return Array.from(this.cookies.values()).map(cookie => ({
186
+ name: cookie.name,
187
+ value: cookie.value,
188
+ domain: cookie.domain,
189
+ path: cookie.path,
190
+ expires: cookie.expires ? Math.floor(cookie.expires.getTime() / 1000) : -1,
191
+ maxAge: cookie.maxAge,
192
+ secure: cookie.secure,
193
+ httpOnly: cookie.httpOnly,
194
+ sameSite: cookie.sameSite
195
+ }));
196
+ }
197
+
198
+ /**
199
+ * Get cookie count
200
+ *
201
+ * @returns {number} Number of cookies
202
+ */
203
+ get size() {
204
+ return this.cookies.size;
205
+ }
206
+
207
+ /**
208
+ * Create a CookieJar from a cookie string
209
+ *
210
+ * @param {string} cookieString - Cookie string
211
+ * @param {string} url - URL context
212
+ * @returns {CookieJar} New CookieJar instance
213
+ */
214
+ static fromCookieString(cookieString, url) {
215
+ const jar = new CookieJar();
216
+ const parsedUrl = parse(url);
217
+ const domain = parsedUrl.hostname;
218
+
219
+ const parts = cookieString.split(';');
220
+ for (const part of parts) {
221
+ const [name, ...valueParts] = part.trim().split('=');
222
+ if (name && valueParts.length > 0) {
223
+ jar.set(name.trim(), valueParts.join('=').trim(), {
224
+ domain: domain.startsWith('.') ? domain : `.${domain}`
225
+ });
226
+ }
227
+ }
228
+
229
+ return jar;
230
+ }
231
+
232
+ // Private methods
233
+
234
+ _makeKey(cookie) {
235
+ return `${cookie.domain}:${cookie.path}:${cookie.name}`;
236
+ }
237
+
238
+ _findCookie(name, url) {
239
+ const parsedUrl = parse(url);
240
+ const domain = parsedUrl.hostname;
241
+ const path = parsedUrl.pathname;
242
+ const isSecure = parsedUrl.protocol === 'https:';
243
+
244
+ for (const cookie of this.cookies.values()) {
245
+ if (cookie.name === name && this._matchesCookie(cookie, domain, path, isSecure)) {
246
+ return cookie;
247
+ }
248
+ }
249
+
250
+ return null;
251
+ }
252
+
253
+ _matchesCookie(cookie, domain, path, isSecure) {
254
+ // Check domain
255
+ if (cookie.domain) {
256
+ if (cookie.domain.startsWith('.')) {
257
+ if (!domain.endsWith(cookie.domain)) return false;
258
+ } else {
259
+ if (domain !== cookie.domain) return false;
260
+ }
261
+ }
262
+
263
+ // Check path
264
+ if (cookie.path) {
265
+ if (!path.startsWith(cookie.path)) return false;
266
+ }
267
+
268
+ // Check secure
269
+ if (cookie.secure && !isSecure) return false;
270
+
271
+ // Check expiration
272
+ if (cookie.expires && cookie.expires < new Date()) return false;
273
+
274
+ return true;
275
+ }
276
+ }