scripter-x 1.0.2 → 1.0.7

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.
@@ -0,0 +1,238 @@
1
+ // kuku.lu (InstAddr) temp-email provider — ported from karthunt_ids/src/kuku.js to the
2
+ // CLI's native-fetch style. Used for the "JSON + email link" mode: create ONE premium-
3
+ // linked account per campaign, then mint newaddr.com emails (premium-only) from it.
4
+ //
5
+ // Flow per campaign:
6
+ // loginMaster(num,pass) → createAccount() → linkPremium(master, acct) → applyLinkedHash
7
+ // then generateEmail(acct, 'newaddr.com') N times + waitForOtp(acct, {toEmail})
8
+ //
9
+ // newaddr.com requires PREMIUM. A premium master "lends" premium to a fresh account
10
+ // (master session is saved+restored so one master can link many). No axios — uses fetch.
11
+
12
+ const BASE = 'https://m.kuku.lu';
13
+
14
+ // accept-encoding is REQUIRED — without it Cloudflare fingerprints Node's fetch as a bot
15
+ // and 403s (curl auto-sends it; undici does not). This single header is the difference
16
+ // between "could not extract csrf_subtoken" (403→no cookies) and a working session.
17
+ const BASE_HEADERS = {
18
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
19
+ 'accept-language': 'en-US,en;q=0.9',
20
+ 'accept-encoding': 'gzip, deflate, br',
21
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
22
+ 'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
23
+ 'sec-ch-ua-mobile': '?0',
24
+ 'sec-ch-ua-platform': '"macOS"',
25
+ 'upgrade-insecure-requests': '1',
26
+ };
27
+ const AJAX_HEADERS = {
28
+ accept: '*/*',
29
+ 'accept-language': 'en-US,en;q=0.9',
30
+ 'accept-encoding': 'gzip, deflate, br',
31
+ 'user-agent': BASE_HEADERS['user-agent'],
32
+ 'sec-ch-ua': BASE_HEADERS['sec-ch-ua'],
33
+ 'sec-ch-ua-mobile': '?0',
34
+ 'sec-ch-ua-platform': '"macOS"',
35
+ 'sec-fetch-dest': 'empty',
36
+ 'sec-fetch-mode': 'cors',
37
+ 'sec-fetch-site': 'same-origin',
38
+ 'x-requested-with': 'XMLHttpRequest',
39
+ };
40
+
41
+ function parseSetCookies(res) {
42
+ const map = {};
43
+ const raw = typeof res.headers.getSetCookie === 'function'
44
+ ? res.headers.getSetCookie()
45
+ : (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
46
+ for (const line of raw) {
47
+ const part = line.split(';')[0].trim();
48
+ const i = part.indexOf('=');
49
+ if (i === -1) continue;
50
+ const name = part.slice(0, i), value = part.slice(i + 1);
51
+ if (name && value !== 'deleted') map[name] = value;
52
+ }
53
+ return map;
54
+ }
55
+ function cookieStr(session) {
56
+ return Object.entries(session).filter(([k]) => !k.startsWith('_')).map(([k, v]) => `${k}=${v}`).join('; ');
57
+ }
58
+ const seed = { cookie_setlang: 'en', cookie_timezone: 'Asia%2FCalcutta', cookie_keepalive_insert: '1', cookie_failedSlot: '' };
59
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
60
+
61
+ // bootstrap — mint a session by hitting /en.php (NOT /). Cloudflare blocks GET / for
62
+ // non-browser clients (403, no cookies → everything downstream fails), but GET /en.php
63
+ // passes AND sets cookie_csrf_token + cookie_sessionhash AND carries the csrf_subtoken in
64
+ // one shot. This single change is what makes the whole kuku flow work from Node fetch.
65
+ // Returns { session, subtoken, html }.
66
+ async function bootstrap(retries = 4) {
67
+ let lastStatus = 0;
68
+ for (let i = 0; i < retries; i++) {
69
+ const r = await fetch(`${BASE}/en.php`, {
70
+ headers: { ...BASE_HEADERS, 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', referer: `${BASE}/`, cookie: cookieStr(seed) },
71
+ });
72
+ lastStatus = r.status;
73
+ const session = { ...seed, ...parseSetCookies(r) };
74
+ const html = r.status === 200 ? await r.text() : '';
75
+ const m = html.match(/csrf_subtoken_check=([a-f0-9]+)/);
76
+ if (session.cookie_csrf_token && session.cookie_sessionhash && m) {
77
+ return { session, subtoken: m[1], html };
78
+ }
79
+ await sleep(800 + i * 600);
80
+ }
81
+ throw new Error(`kuku: could not bootstrap via /en.php (last status ${lastStatus})`);
82
+ }
83
+
84
+ async function getSubtoken(session) {
85
+ const res = await fetch(`${BASE}/en.php`, {
86
+ headers: { ...BASE_HEADERS, 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', referer: `${BASE}/`, cookie: cookieStr(session) },
87
+ });
88
+ // NEVER overwrite the session's cookie_sessionhash — /en.php hands back a fresh guest
89
+ // hash on every call; keeping our (possibly logged-in) hash is what preserves the login.
90
+ const merged = parseSetCookies(res);
91
+ delete merged.cookie_sessionhash;
92
+ Object.assign(session, merged);
93
+ const html = await res.text();
94
+ const m = html.match(/csrf_subtoken_check=([a-f0-9]+)/);
95
+ if (!m) throw new Error('kuku: could not extract csrf_subtoken');
96
+ return { subtoken: m[1], html };
97
+ }
98
+
99
+ // Create a fresh guest account → { cookie_sessionhash, cookie_csrf_token, accountId, password }
100
+ export async function createAccount() {
101
+ const { session, html } = await bootstrap();
102
+ if (!html.includes('InstAddr')) throw new Error('kuku: session invalid (no InstAddr in /en.php)');
103
+ session.accountId = (html.match(/id="area_numberview"[^>]*>([^<]+)</) || [])[1]?.trim() || null;
104
+ session.password = (html.match(/id="area_passwordview_copy">([^<]+)</) || [])[1]?.trim() || null;
105
+ return session;
106
+ }
107
+
108
+ // Log into an existing account (the premium master) by id + password (KU-10, 2-step).
109
+ export async function loginAccount(number, password) {
110
+ const { session, subtoken } = await bootstrap();
111
+ const csrf = session.cookie_csrf_token;
112
+ const payload = (syncconfirm) => new URLSearchParams({
113
+ action: 'checkLogin', confirmcode: '', nopost: '1', csrf_token_check: csrf,
114
+ csrf_subtoken_check: subtoken, number, password, syncconfirm }).toString();
115
+ const post = (body) => fetch(`${BASE}/index.php`, {
116
+ method: 'POST', body,
117
+ headers: { ...AJAX_HEADERS, 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', origin: BASE, referer: `${BASE}/`, cookie: cookieStr(session) },
118
+ });
119
+ await post(payload('')); // → REQ:SYNC_CONFIRM
120
+ const r2 = await post(payload('no')); // → OK:SHASH:<hash>
121
+ const body = (await r2.text()).trim();
122
+ if (!body.startsWith('OK:SHASH:')) throw new Error(`kuku: master login failed: ${body.slice(0, 80)}`);
123
+ // The BODY's "OK:SHASH:<hash>" is the authoritative logged-in session. The response's
124
+ // Set-Cookie carries a DIFFERENT (fresh guest) sessionhash — do NOT let it overwrite the
125
+ // logged-in hash (that bug landed us on a free guest account → isPremium:false).
126
+ const merged = parseSetCookies(r2);
127
+ delete merged.cookie_sessionhash;
128
+ Object.assign(session, merged);
129
+ session.cookie_sessionhash = encodeURIComponent(body.slice(3));
130
+ return session;
131
+ }
132
+
133
+ // Premium master lends premium to a target account. Master session is restored after,
134
+ // so one master can link many. Returns the target's premium-linked session hash.
135
+ export async function linkPremium(masterSession, targetNumber, targetPassword) {
136
+ const { subtoken } = await getSubtoken(masterSession);
137
+ const csrf = masterSession.cookie_csrf_token;
138
+ const originalHash = masterSession.cookie_sessionhash;
139
+ const payload = (syncconfirm) => new URLSearchParams({
140
+ action: 'checkLogin', confirmcode: '', nopost: '1', csrf_token_check: csrf,
141
+ csrf_subtoken_check: subtoken, number: targetNumber, password: targetPassword, syncconfirm }).toString();
142
+ const post = (body) => fetch(`${BASE}/index.php`, {
143
+ method: 'POST', body,
144
+ headers: { ...AJAX_HEADERS, 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', origin: BASE, referer: `${BASE}/`, cookie: cookieStr(masterSession) },
145
+ });
146
+ await post(payload(''));
147
+ const r2 = await post(payload('no'));
148
+ const body = (await r2.text()).trim();
149
+ masterSession.cookie_sessionhash = originalHash; // restore master
150
+ if (!body.startsWith('OK:SHASH:')) throw new Error(`kuku: linkPremium failed: ${body.slice(0, 80)}`);
151
+ return body.slice(3); // "SHASH:<hash>"
152
+ }
153
+
154
+ export function applyLinkedHash(targetSession, linkedHash) {
155
+ targetSession.cookie_sessionhash = encodeURIComponent(linkedHash);
156
+ }
157
+
158
+ // Is this session premium? (KU-12)
159
+ export async function isPremium(session) {
160
+ const res = await fetch(`${BASE}/smphone.app.premium.php`, {
161
+ headers: { ...BASE_HEADERS, 'sec-fetch-dest': 'document', referer: `${BASE}/`, cookie: cookieStr(session) },
162
+ });
163
+ const merged = parseSetCookies(res);
164
+ delete merged.cookie_sessionhash; // never clobber the (logged-in) hash
165
+ Object.assign(session, merged);
166
+ const text = (await res.text()).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
167
+ return !/you are not subscribed|not subscribed to a premium/i.test(text);
168
+ }
169
+
170
+ // Generate one email on a specific (premium) domain — KU-4 3-step. Default newaddr.com.
171
+ export async function generateEmail(session, domain = 'newaddr.com') {
172
+ const { subtoken } = await getSubtoken(session);
173
+ const csrf = session.cookie_csrf_token;
174
+ const ts = Date.now(), tsec = Math.floor(ts / 1000);
175
+ const get = (params) => fetch(`${BASE}/index.php?${new URLSearchParams(params)}`, {
176
+ headers: { ...AJAX_HEADERS, referer: `${BASE}/`, cookie: cookieStr(session) },
177
+ });
178
+ // agreeEULA (fire-and-forget)
179
+ await get({ action: 'agreeEULA', nopost: '1', by_system: '1', csrf_token_check: csrf, _: String(ts) }).catch(() => {});
180
+ // checkNewMailUser
181
+ const chk = await get({ action: 'checkNewMailUser', ip: '::1', nopost: '1', csrf_token_check: csrf, csrf_subtoken_check: subtoken, newdomain: domain, newuser: '', _: String(ts) });
182
+ const chkBody = (await chk.text()).trim();
183
+ if (!chkBody.startsWith('OK')) throw new Error(`kuku: domain ${domain} unavailable: ${chkBody.slice(0, 60)}`);
184
+ // addMailAddrByManual
185
+ const add = await get({ action: 'addMailAddrByManual', nopost: '1', by_system: '1', t: String(tsec), csrf_token_check: csrf, newdomain: domain, newuser: '', recaptcha_token: '', _: String(ts) });
186
+ const body = (await add.text()).trim();
187
+ if (!body.startsWith('OK:')) throw new Error(`kuku: email create failed (${domain}): ${body.slice(0, 80)}`);
188
+ return body.slice(3).trim();
189
+ }
190
+
191
+ // Fetch inbox (recent mails across all addresses). Returns [{mailId,key,subject,to}].
192
+ async function fetchInbox(session) {
193
+ const { subtoken } = await getSubtoken(session);
194
+ const ts = Date.now();
195
+ const res = await fetch(`${BASE}/recv._ajax.php?&&nopost=1&csrf_token_check=${session.cookie_csrf_token}&csrf_subtoken_check=${subtoken}&_=${ts}`, {
196
+ headers: { ...AJAX_HEADERS, referer: `${BASE}/`, cookie: cookieStr(session) },
197
+ });
198
+ const html = await res.text();
199
+ if (!html.includes('[OK]')) return [];
200
+ const keys = {};
201
+ let m; const keyRe = /openMailData\('(\d+)',\s*'([a-f0-9]+)'/g;
202
+ while ((m = keyRe.exec(html)) !== null) keys[m[1]] = m[2];
203
+ const mails = [];
204
+ const blockRe = /id="area_mail_(\d+)"[\s\S]*?id="area_mail_title_\1"[^>]*>([\s\S]*?)<\/div>/g;
205
+ let b;
206
+ while ((b = blockRe.exec(html)) !== null) {
207
+ const mailId = b[1];
208
+ const sm = b[2].match(/<b>\s*<span[^>]*>([\s\S]*?)<\/span>/);
209
+ mails.push({ mailId, key: keys[mailId] || null, subject: sm ? sm[1].replace(/&#039;/g, "'").trim() : '' });
210
+ }
211
+ // from/to (parallel order)
212
+ let ft; let i = 0; const ftRe = /<div class="font_gray">\s*([\s\S]*?)\s*&raquo;\s*([\s\S]*?)\s*<\/div>/g;
213
+ while ((ft = ftRe.exec(html)) !== null && i < mails.length) {
214
+ mails[i].to = ft[2].replace(/<[^>]+>/g, '').trim();
215
+ i++;
216
+ }
217
+ return mails;
218
+ }
219
+
220
+ // Poll the inbox until a 6-digit OTP arrives (it's in the FK email SUBJECT). Optionally
221
+ // filter by recipient address so concurrent emails don't cross-read each other's OTP.
222
+ export async function waitForOtp(session, { timeoutMs = 120000, pollMs = 5000, toEmail } = {}) {
223
+ const deadline = Date.now() + timeoutMs;
224
+ const seen = new Set();
225
+ while (Date.now() < deadline) {
226
+ let mails = [];
227
+ try { mails = await fetchInbox(session); } catch { /* keep polling */ }
228
+ for (const mail of mails) {
229
+ if (seen.has(mail.mailId)) continue;
230
+ seen.add(mail.mailId);
231
+ if (toEmail && mail.to && !mail.to.includes(toEmail)) continue;
232
+ const m = mail.subject.match(/\b(\d{6})\b/);
233
+ if (m) return m[1];
234
+ }
235
+ await new Promise((r) => setTimeout(r, pollMs));
236
+ }
237
+ return null; // timed out
238
+ }
@@ -65,15 +65,39 @@ class OTPCartStream {
65
65
  this.mobileId = mobileId;
66
66
  this.otp = null;
67
67
  this.arrived = false;
68
- this.ws = new WebSocket(`${WS_URL}?token=${jwt}`);
68
+ this.closed = false;
69
+ this.jwt = jwt;
70
+ this._openP = null; // resolves when the WS is OPEN
71
+ this._connect();
72
+ }
73
+
74
+ _connect() {
75
+ this.ws = new WebSocket(`${WS_URL}?token=${this.jwt}`);
76
+ this._openP = new Promise((resolve) => {
77
+ this.ws.once('open', resolve);
78
+ this.ws.once('error', resolve); // resolve anyway so ready() never hangs
79
+ });
69
80
  this.ws.on('message', (raw) => {
70
81
  let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
82
+ // OTP frame: {message, otpMessage}. Ack frame {serialNumber,mobileId,resend} is ignored.
71
83
  if (msg.otpMessage) {
72
84
  const m = String(msg.otpMessage).match(OTP_RE);
73
85
  if (m) { this.otp = m[1]; this.arrived = true; }
74
86
  }
75
87
  });
76
- this.ws.on('error', () => { /* swallow — poll() just returns nothing */ });
88
+ this.ws.on('error', () => { /* swallow — poll() returns nothing; we auto-reconnect */ });
89
+ // Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
90
+ // silently lose the push — this is the "purchased but never got OTP" symptom).
91
+ this.ws.on('close', () => {
92
+ if (this.closed || this.otp) return;
93
+ setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
94
+ });
95
+ }
96
+
97
+ // Wait until the WS is OPEN (so we never send the FK OTP before we can receive the push).
98
+ async ready(timeoutMs = 8000) {
99
+ await Promise.race([this._openP, new Promise((r) => setTimeout(r, timeoutMs))]);
100
+ return this.ws.readyState === WebSocket.OPEN;
77
101
  }
78
102
 
79
103
  async poll() {
@@ -81,7 +105,10 @@ class OTPCartStream {
81
105
  return { otp: null, arrived: this.arrived };
82
106
  }
83
107
 
84
- resend() {
108
+ // Ask OTPCart to resend the SMS. Waits for the socket to be OPEN first (it may still be
109
+ // connecting/reconnecting), so the resend isn't silently dropped.
110
+ async resend() {
111
+ await this.ready(5000);
85
112
  try {
86
113
  if (this.ws.readyState === WebSocket.OPEN) {
87
114
  this.ws.send(JSON.stringify({ serialNumber: this.serial, mobileId: this.mobileId, resend: true }));
@@ -91,5 +118,5 @@ class OTPCartStream {
91
118
  return false;
92
119
  }
93
120
 
94
- close() { try { this.ws.close(); } catch { /* */ } }
121
+ close() { this.closed = true; try { this.ws.close(); } catch { /* */ } }
95
122
  }
@@ -0,0 +1,155 @@
1
+ // ZAUTH1 envelope codec — the encrypted blob format consumed by the on-device
2
+ // ZeptoAuthManager-v14 injector (native `libzauth.so` `z1`).
3
+ //
4
+ // Reverse-engineered + verified against a known sample (round-trips byte-for-byte).
5
+ // Scheme = RFC 8439 ChaCha20-Poly1305 (IETF), with a key derived from the
6
+ // installed APK's signing-cert SHA-256:
7
+ //
8
+ // envelope = "ZAUTH1:" + base64( nonce(12) | ciphertext | tag(16) )
9
+ // encKey = certSha[0:32] XOR ChaCha20(KD_KEY, nonce=certSha[4:16], ctr=0)[0:32]
10
+ // otk = ChaCha20(encKey, nonce, ctr=0)[0:32] (Poly1305 one-time key)
11
+ // plaintext= ChaCha20(encKey, nonce, ctr=1) XOR ciphertext
12
+ // tag = Poly1305(otk, ciphertext) with pad LE64(0)||LE64(ct.length), empty AAD
13
+ //
14
+ // certSha = SHA-256( apkContentsSigners[0] DER ) of the *installed* ZeptoAuthManager
15
+ // (NativeCrypto.signingCertSha256). The envelope only decrypts inside that signed
16
+ // build — so you MUST encode for the exact cert of the APK you'll import into.
17
+ import { randomBytes } from 'node:crypto';
18
+
19
+ const MAGIC = 'ZAUTH1:';
20
+
21
+ // rodata constants from libzauth.so (.rodata 0x5d0 + 0x5f0) — the KD pass key.
22
+ const KD_KEY = Buffer.from(
23
+ '4d709774999c9977cc6351bbe286676d' + '49af64b60412a2a9b35a5c48b7706bd2',
24
+ 'hex',
25
+ );
26
+
27
+ // ── ChaCha20 (RFC 8439, little-endian) ───────────────────────────────────────
28
+ const rotl32 = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;
29
+
30
+ function chacha20Block(key, counter, nonce) {
31
+ const s = new Uint32Array(16);
32
+ s[0] = 0x61707865; s[1] = 0x3320646e; s[2] = 0x79622d32; s[3] = 0x6b206574;
33
+ for (let i = 0; i < 8; i++) s[4 + i] = key.readUInt32LE(i * 4);
34
+ s[12] = counter >>> 0;
35
+ s[13] = nonce.readUInt32LE(0);
36
+ s[14] = nonce.readUInt32LE(4);
37
+ s[15] = nonce.readUInt32LE(8);
38
+
39
+ const x = s.slice();
40
+ const QR = (a, b, c, d) => {
41
+ x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 16);
42
+ x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 12);
43
+ x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 8);
44
+ x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 7);
45
+ };
46
+ for (let i = 0; i < 10; i++) {
47
+ QR(0, 4, 8, 12); QR(1, 5, 9, 13); QR(2, 6, 10, 14); QR(3, 7, 11, 15);
48
+ QR(0, 5, 10, 15); QR(1, 6, 11, 12); QR(2, 7, 8, 13); QR(3, 4, 9, 14);
49
+ }
50
+ const out = Buffer.alloc(64);
51
+ for (let i = 0; i < 16; i++) out.writeUInt32LE((x[i] + s[i]) >>> 0, i * 4);
52
+ return out;
53
+ }
54
+
55
+ function chacha20Keystream(key, nonce, counter, len) {
56
+ const out = Buffer.alloc(len);
57
+ let off = 0, ctr = counter >>> 0;
58
+ while (off < len) {
59
+ const blk = chacha20Block(key, ctr, nonce);
60
+ const n = Math.min(64, len - off);
61
+ blk.copy(out, off, 0, n);
62
+ off += n; ctr = (ctr + 1) >>> 0;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function chacha20Xor(key, nonce, counter, data) {
68
+ const ks = chacha20Keystream(key, nonce, counter, data.length);
69
+ const out = Buffer.alloc(data.length);
70
+ for (let i = 0; i < data.length; i++) out[i] = data[i] ^ ks[i];
71
+ return out;
72
+ }
73
+
74
+ // ── Poly1305 (RFC 8439, BigInt) ──────────────────────────────────────────────
75
+ function bufToLE(buf) {
76
+ let n = 0n;
77
+ for (let i = buf.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(buf[i]);
78
+ return n;
79
+ }
80
+ function le64(n) { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; }
81
+
82
+ function poly1305(key, msg) {
83
+ const P = (1n << 130n) - 5n;
84
+ let r = bufToLE(key.subarray(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn;
85
+ const s = bufToLE(key.subarray(16, 32));
86
+ let acc = 0n;
87
+ for (let i = 0; i < msg.length; i += 16) {
88
+ const chunk = msg.subarray(i, i + 16);
89
+ let n = bufToLE(chunk) + (1n << BigInt(8 * chunk.length));
90
+ acc = ((acc + n) * r) % P;
91
+ }
92
+ acc = (acc + s) & ((1n << 128n) - 1n);
93
+ const tag = Buffer.alloc(16);
94
+ for (let i = 0; i < 16; i++) { tag[i] = Number(acc & 0xffn); acc >>= 8n; }
95
+ return tag;
96
+ }
97
+
98
+ function aeadTag(otk, ct) {
99
+ const pad = (16 - (ct.length % 16)) % 16;
100
+ return poly1305(otk, Buffer.concat([ct, Buffer.alloc(pad), le64(0), le64(ct.length)]));
101
+ }
102
+
103
+ function ctEqual(a, b) {
104
+ if (a.length !== b.length) return false;
105
+ let d = 0;
106
+ for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
107
+ return d === 0;
108
+ }
109
+
110
+ // ── key derivation ───────────────────────────────────────────────────────────
111
+ export function deriveKey(certSha) {
112
+ const ks = chacha20Keystream(KD_KEY, certSha.subarray(4, 16), 0, 32);
113
+ const enc = Buffer.alloc(32);
114
+ for (let i = 0; i < 32; i++) enc[i] = certSha[i] ^ ks[i];
115
+ return enc;
116
+ }
117
+
118
+ function asCertSha(certShaHex) {
119
+ const b = Buffer.from(certShaHex.replace(/[^0-9a-f]/gi, ''), 'hex');
120
+ if (b.length !== 32) throw new Error('certSha must be 32 bytes (64 hex chars)');
121
+ return b;
122
+ }
123
+
124
+ // ── public API ───────────────────────────────────────────────────────────────
125
+ export function stripMagic(envelope) {
126
+ const s = String(envelope).trim();
127
+ return s.startsWith(MAGIC) ? s.slice(MAGIC.length) : s;
128
+ }
129
+
130
+ // Decrypt a ZAUTH1 envelope. Returns { plaintext(Buffer), tagOk(bool), nonce, ct, tag }.
131
+ export function decrypt(envelope, certShaHex) {
132
+ const certSha = asCertSha(certShaHex);
133
+ const blob = Buffer.from(stripMagic(envelope), 'base64');
134
+ if (blob.length < 28) throw new Error('envelope too short');
135
+ const nonce = blob.subarray(0, 12);
136
+ const ct = blob.subarray(12, blob.length - 16);
137
+ const tag = blob.subarray(blob.length - 16);
138
+ const encKey = deriveKey(certSha);
139
+ const otk = chacha20Keystream(encKey, nonce, 0, 32);
140
+ const tagOk = ctEqual(aeadTag(otk, ct), tag);
141
+ const plaintext = chacha20Xor(encKey, nonce, 1, ct);
142
+ return { plaintext, tagOk, nonce: Buffer.from(nonce), ct: Buffer.from(ct), tag: Buffer.from(tag) };
143
+ }
144
+
145
+ // Encrypt a plaintext (string|Buffer) into a ZAUTH1 envelope string.
146
+ // nonce defaults to random (the format embeds it); pass one for deterministic output.
147
+ export function encrypt(plaintext, certShaHex, nonce) {
148
+ const certSha = asCertSha(certShaHex);
149
+ const n = nonce || randomBytes(12);
150
+ const encKey = deriveKey(certSha);
151
+ const otk = chacha20Keystream(encKey, n, 0, 32);
152
+ const ct = chacha20Xor(encKey, n, 1, Buffer.from(plaintext));
153
+ const tag = aeadTag(otk, ct);
154
+ return MAGIC + Buffer.concat([n, ct, tag]).toString('base64');
155
+ }
@@ -0,0 +1,131 @@
1
+ // Zepto local login provider — send an OTP to a phone number and verify it,
2
+ // straight against Zepto's API (runs on YOUR IP, no emulator, no backend).
3
+ //
4
+ // Flow (reverse-engineered from native-app capture; no SSL pinning on *.zepto.co.in):
5
+ // 1. sendOtp(number) -> Zepto SMSes an OTP; we keep the per-session ids.
6
+ // 2. verifyOtp(session,otp) -> returns the bearer token + user; that's the session.
7
+ //
8
+ // The returned session object carries the same keys the Zepto app persists in
9
+ // shared_prefs/Zepto.xml (accessToken / refreshToken / userId / …), so it can be
10
+ // re-injected later via ZeptoAuthManager.
11
+ import https from 'node:https';
12
+ import crypto from 'node:crypto';
13
+ import { encrypt } from './zauth.js';
14
+
15
+ const API = 'api.zepto.co.in';
16
+ const APP_VERSION = '26.5.5';
17
+
18
+ // ZeptoAuthManager-v14 signing-cert SHA-256 — the key that the injector's native
19
+ // libzauth.so uses to open a ZAUTH1 envelope. Baked so the CLI produces an
20
+ // importable envelope with no Android tooling. Override with $ZAUTH_CERT_SHA
21
+ // (or pass a cert to encodeEnvelope) if you re-sign the AuthManager APK.
22
+ export const DEFAULT_CERT_SHA = '50585f64b0bc746cf3db14b9a181c69b5ad1a6fb496f08efbddf28a668029a89';
23
+
24
+ const randHex = (n) => crypto.randomBytes(n).toString('hex').slice(0, n);
25
+ const uuid = () => crypto.randomUUID();
26
+
27
+ // Headers matching the Zepto Android app, with a fresh per-session device identity.
28
+ function headers(session, extra = {}) {
29
+ return {
30
+ 'content-type': 'application/json; charset=utf-8',
31
+ 'x-requested-with': 'XMLHttpRequest',
32
+ 'user-agent': 'okhttp/4.12.0',
33
+ appversion: APP_VERSION,
34
+ app_version: APP_VERSION,
35
+ platform: 'android',
36
+ tenant: 'ZEPTO',
37
+ sessionid: session.sessionId,
38
+ session_id: session.sessionId,
39
+ deviceuid: session.deviceUid,
40
+ device_uid: session.deviceUid,
41
+ device_brand: 'samsung',
42
+ device_model: 'SM-G991B',
43
+ system_version: '13',
44
+ systemversion: '13',
45
+ source: 'PLAY_STORE',
46
+ is_internal_user: 'false',
47
+ isinternaluser: 'false',
48
+ auth_revamp_flow: 'v2',
49
+ ...(session.token ? { authorization: `Bearer ${session.token}` } : {}),
50
+ ...extra,
51
+ };
52
+ }
53
+
54
+ function req(method, path, hdrs, bodyObj) {
55
+ return new Promise((resolve) => {
56
+ const body = bodyObj != null ? JSON.stringify(bodyObj) : null;
57
+ const h = { ...hdrs, host: API };
58
+ if (body) h['content-length'] = Buffer.byteLength(body);
59
+ const r = https.request({ hostname: API, port: 443, method, path, headers: h, timeout: 20000 }, (res) => {
60
+ const ch = [];
61
+ res.on('data', (c) => ch.push(c));
62
+ res.on('end', () => {
63
+ const text = Buffer.concat(ch).toString('utf8');
64
+ let json = null;
65
+ try { json = JSON.parse(text); } catch { /* not json */ }
66
+ resolve({ status: res.statusCode, text, json });
67
+ });
68
+ });
69
+ r.on('error', (e) => resolve({ status: 0, text: 'err:' + e.message, json: null }));
70
+ r.on('timeout', () => { r.destroy(); resolve({ status: 0, text: 'timeout', json: null }); });
71
+ if (body) r.write(body);
72
+ r.end();
73
+ });
74
+ }
75
+
76
+ // A fresh session = a new install identity (random sessionId + deviceuid).
77
+ export function newSession(number) {
78
+ return {
79
+ sessionId: uuid(),
80
+ deviceUid: randHex(16),
81
+ token: null,
82
+ mobile: String(number).replace(/\D/g, '').slice(-10),
83
+ user: null,
84
+ refreshToken: null,
85
+ };
86
+ }
87
+
88
+ // Step 1 — request an OTP SMS. Returns { ok, status, msg }.
89
+ export async function sendOtp(session) {
90
+ const r = await req('POST', '/api/v1/user/customer/signup/', headers(session), {
91
+ signupType: 'otp_sms',
92
+ data: { mobile_number: session.mobile },
93
+ });
94
+ const ok = r.status === 200 && /OTP Sent/i.test(r.text);
95
+ return { ok, status: r.status, msg: r.json?.data?.msg || r.json?.message || r.text.slice(0, 160) };
96
+ }
97
+
98
+ // Step 2 — verify the OTP. On success, fills session.token / refreshToken / user.
99
+ export async function verifyOtp(session, otp) {
100
+ const r = await req('POST', '/api/v1/user/customer/verify-otp/', headers(session), {
101
+ mobileNumber: session.mobile,
102
+ otpToken: String(otp).trim(),
103
+ });
104
+ if (r.status === 200 && r.json?.token) {
105
+ session.token = r.json.token;
106
+ session.refreshToken = r.json.refreshToken || null;
107
+ session.user = r.json.user || null;
108
+ return { ok: true, user: r.json.user || null };
109
+ }
110
+ return { ok: false, status: r.status, error: r.json?.message || r.text.slice(0, 160) };
111
+ }
112
+
113
+ // Build the persisted-session object (the Zepto.xml key set) from a logged-in session.
114
+ export function toSessionKeys(session) {
115
+ const userId = session.user?.id || '';
116
+ return {
117
+ accessToken: session.token || '',
118
+ refreshToken: session.refreshToken || '',
119
+ userId,
120
+ persistedUserId: userId,
121
+ isUserLoggedIn: 'true',
122
+ deviceId: session.deviceUid,
123
+ };
124
+ }
125
+
126
+ // Encode a session-keys object into a ZAUTH1: envelope (the format ZeptoAuthManager
127
+ // imports). certSha defaults to the bundled v14 cert; override via $ZAUTH_CERT_SHA.
128
+ export function encodeEnvelope(sessionKeys, certSha) {
129
+ const key = (certSha || process.env.ZAUTH_CERT_SHA || DEFAULT_CERT_SHA).trim();
130
+ return encrypt(JSON.stringify(sessionKeys), key);
131
+ }
package/src/ui/App.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // 'run' → the live extraction view (RunView)
8
8
  // 'output' → scrollback lines a command printed (tables, results)
9
9
  import React, { useState, useEffect, useRef } from 'react';
10
- import { Box, Text, useApp } from 'ink';
10
+ import { Box, Text, useApp, useInput } from 'ink';
11
11
  import { Shell, WelcomeBox } from './Shell.js';
12
12
  import { SelectList, Confirm, TextField } from './components.js';
13
13
  import { RunView } from './RunView.js';
@@ -24,10 +24,60 @@ export function App({ ctx }) {
24
24
  const [runController, setRunController] = useState(null);
25
25
  const [busy, setBusy] = useState(false);
26
26
  const [me, setMe] = useState({ username: ctx.username, server: ctx.server });
27
+ const [confirmExit, setConfirmExit] = useState(false);
28
+ const [askStop, setAskStop] = useState(false); // Esc-to-stop confirm during a run
27
29
  const [, forceTick] = useState(0);
28
30
  const frameRef = useRef();
31
+ const ctrlCAt = useRef(0);
32
+ const askStopRef = useRef(false);
33
+ askStopRef.current = askStop;
34
+ const runControllerRef = useRef(null); // always-current controller for the handler
35
+ runControllerRef.current = runController;
29
36
  useMouse(); // enable click selection across the app
30
37
 
38
+ // Resolve the Esc-to-stop confirm. yes → stop the campaign (worker saves what it has +
39
+ // reports stats on its own finish path). no → resume the run.
40
+ const resolveStop = (yes) => {
41
+ setAskStop(false);
42
+ askStopRef.current = false;
43
+ if (yes && runControllerRef.current && typeof runControllerRef.current.stop === 'function') {
44
+ runControllerRef.current.stop();
45
+ }
46
+ };
47
+
48
+ // The actual Ctrl+C handler (shared by the useInput hooks below). First press stops a
49
+ // live campaign + warns; second press within 2s exits CLEANLY (restoring the terminal —
50
+ // mouse modes + alt-screen — so the shell isn't left spewing escape codes).
51
+ const handleCtrlC = () => {
52
+ const now = Date.now();
53
+ if (now - ctrlCAt.current < 2000) {
54
+ if (runControllerRef.current && typeof runControllerRef.current.stop === 'function') runControllerRef.current.stop();
55
+ if (ctx.onExit) ctx.onExit();
56
+ try {
57
+ process.stdout.write('\x1b[?1000l\x1b[?1003l\x1b[?1006l\x1b[?25h\x1b[?1049l'); // restore terminal
58
+ } catch { /* */ }
59
+ exit();
60
+ setTimeout(() => process.exit(0), 50);
61
+ return;
62
+ }
63
+ ctrlCAt.current = now;
64
+ setConfirmExit(true);
65
+ if (runControllerRef.current && typeof runControllerRef.current.stop === 'function') runControllerRef.current.stop();
66
+ setTimeout(() => setConfirmExit(false), 2000);
67
+ };
68
+
69
+ // App-level Ctrl+C + Esc-to-stop-campaign. ink delivers input to ALL active useInput
70
+ // hooks, so this fires even during a run.
71
+ useInput((input, key) => {
72
+ if (key.ctrl && input === 'c') { handleCtrlC(); return; }
73
+ // Esc during a running campaign → confirm stop (only when a run is active and we're not
74
+ // already showing the confirm).
75
+ if (key.escape && runControllerRef.current && !askStopRef.current) {
76
+ askStopRef.current = true;
77
+ setAskStop(true);
78
+ }
79
+ });
80
+
31
81
  // track whether a live run is active, so prompts shown mid-run return to the run view
32
82
  const runActive = useRef(false);
33
83
  const afterPrompt = () => (runActive.current ? 'run' : 'shell');
@@ -96,6 +146,13 @@ export function App({ ctx }) {
96
146
  screen === 'run' && runController ? h(RunView, { controller: runController, embedded: true }) : null,
97
147
  screen === 'prompts' && prompt ? h(PromptView, { prompt, frameRef }) : null,
98
148
  screen === 'shell' ? h(Shell, { username: me.username, server: me.server, busy, onRun: runCommand, frameRef }) : null,
149
+ // Esc-to-stop hint during a run (hidden while the confirm is showing)
150
+ runController && !askStop ? h(Text, { color: 'gray' }, " press Esc to stop the campaign") : null,
151
+ // Esc-stop confirmation
152
+ askStop ? h(Box, { flexDirection: 'column', marginTop: 1 },
153
+ h(Confirm, { message: 'Stop the campaign? (already-fetched sessions are saved)', defaultValue: false, frameRef, onAnswer: resolveStop })) : null,
154
+ confirmExit ? h(Box, { marginTop: 1 },
155
+ h(Text, { color: COLORS.warn, bold: true }, runController ? ' ⚠ stopping campaign — press Ctrl+C again to exit' : ' ⚠ press Ctrl+C again to exit')) : null,
99
156
  );
100
157
  }
101
158