Telemax/scripts/dev/auth-cli.ts
Folist 8afc421b0a Support password-protected MAX accounts (2FA on top of SMS)
Full flow reverse-engineered and confirmed live: CHECK_CODE responds
with a passwordChallenge (trackId + optional hint) instead of a login
token when the account has a password set. A new opcode, CHECK_PASSWORD
(0x0073), exchanges {trackId, password} for the same 663-char login
token CHECK_CODE would have returned directly — from there it's an
ordinary LOGIN.

verifyCode() now returns a discriminated union (ok / password_required)
instead of throwing either way, so callers can actually ask for a
password instead of just failing. Wired through everywhere MAX auth
happens: the web panel gets a new password step (with hint), setup.sh's
console flow prompts for it too, and the dev auth-cli script handles it
via the same file-polling pattern it already used for the SMS code.

A wrong password doesn't burn the trackId (confirmed live) — both the
web UI and setup.sh let you retry it freely without a fresh SMS. It's
only consumed on success, matching MAX's own behavior.
2026-08-14 11:58:47 +03:00

123 lines
4.3 KiB
TypeScript

/**
* One-shot SMS login for manual verification against the real MAX server.
* Per max-protocol-full.md §1.5/§3, the whole auth chain (DEVICE_INFO ->
* START_AUTH -> CHECK_CODE -> LOGIN) has to happen on a single TCP
* connection, so this stays a single long-lived process rather than
* separate "start" / "verify" invocations.
*
* Usage: npm run auth:cli -- +79991234567
* Then, once the SMS arrives, write the code to .data/sms-code.txt
* (any tool/editor works — the script polls for it).
*/
import 'dotenv/config';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { MaxClient } from '../../src/max/client.js';
import { SessionStore } from '../../src/store/sessionStore.js';
const CODE_FILE = path.join(process.cwd(), '.data', 'sms-code.txt');
const PASSWORD_FILE = path.join(process.cwd(), '.data', 'max-password.txt');
const CODE_POLL_INTERVAL_MS = 500;
const CODE_WAIT_TIMEOUT_MS = 5 * 60_000;
function mask(token: string): string {
if (token.length <= 12) return '***';
return `${token.slice(0, 6)}...${token.slice(-4)} (${token.length} chars)`;
}
async function waitForFile(filePath: string, label: string): Promise<string> {
await mkdir(path.dirname(filePath), { recursive: true });
await rm(filePath, { force: true });
console.log(`Waiting for ${label} in ${filePath} (write it and save) ...`);
const deadline = Date.now() + CODE_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const content = (await readFile(filePath, 'utf8')).trim();
if (content) {
await rm(filePath, { force: true });
return content;
}
} catch {
// file not there yet — keep polling
}
await new Promise((resolve) => setTimeout(resolve, CODE_POLL_INTERVAL_MS));
}
throw new Error(`Timed out waiting for the ${label}`);
}
async function main(): Promise<void> {
const phone = process.argv[2];
if (!phone) {
console.error('Usage: npm run auth:cli -- <phone, e.g. +79991234567>');
process.exitCode = 1;
return;
}
const client = new MaxClient({ reconnect: false });
client.on('error', (err: Error) => console.error('[MAX error]', err.message));
client.on('sent', ({ opcode, length }: { opcode: number; length: number }) =>
console.log(`-> opcode 0x${opcode.toString(16)} (${length} B)`),
);
client.on('message', ({ opcode, dir, length }: { opcode: number; dir: number; length: number }) =>
console.log(`<- opcode 0x${opcode.toString(16)} dir=0x${dir.toString(16)} (${length} B)`),
);
const ready = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out waiting for INIT ack')), 20_000);
client.once('ready', () => {
clearTimeout(timer);
resolve();
});
});
console.log(`Connecting (deviceId=${client.deviceId}) ...`);
client.connect();
await ready;
console.log('Connected and initialized.');
console.log(`Requesting SMS code for ${phone} ...`);
const authToken = await client.requestSms(phone);
console.log('Got auth_token:', mask(authToken));
const code = await waitForFile(CODE_FILE, 'SMS code');
console.log('Verifying code ...');
const verified = await client.verifyCode(authToken, code);
let loginToken: string;
if (verified.status === 'password_required') {
console.log(`Password required${verified.challenge.hint ? ` (hint: ${verified.challenge.hint})` : ''}.`);
for (;;) {
const password = await waitForFile(PASSWORD_FILE, 'MAX password');
try {
loginToken = await client.checkPassword(verified.challenge.trackId, password);
break;
} catch (err) {
console.error('Wrong password, try again:', err instanceof Error ? err.message : err);
}
}
} else {
loginToken = verified.loginToken;
}
console.log('Got login_token:', mask(loginToken));
console.log('Logging in ...');
const { sessionToken } = await client.login(loginToken);
console.log('Got session_token:', mask(sessionToken));
await new SessionStore().save({
sessionToken,
phone,
deviceId: client.deviceId,
savedAt: new Date().toISOString(),
});
console.log('Session saved to .data/max.session.json (encrypted).');
client.disconnect();
process.exitCode = 0;
}
main().catch((err) => {
console.error('Auth flow failed:', err instanceof Error ? err.message : err);
process.exitCode = 1;
});