/* * Veskslop daemon — Discord remote-auth QR login session. * * The terminal tool and the web UI share this class. It connects to Discord's * remote-auth gateway, produces the approval URL, waits for phone approval, * and writes the token file on success. */ import crypto from "node:crypto"; import fs from "node:fs"; import WebSocket from "ws"; const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2"; const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login"; const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"; export type QrLoginState = "connecting" | "pending" | "scanned" | "captcha" | "finished" | "failed" | "cancelled"; export interface QrLoginUser { id: string; tag: string; avatar?: string; } export interface QrLoginCaptcha { service?: string; sitekey?: string; rqdata?: string; } export interface QrLoginUpdate { state: QrLoginState; url?: string; user?: QrLoginUser; error?: string; captcha?: QrLoginCaptcha; } export interface QrLoginOptions { tokenFile: string; onUpdate?: (update: QrLoginUpdate) => void; debug?: boolean; } export function isTerminalQrState(state: QrLoginState): boolean { return state === "finished" || state === "failed" || state === "cancelled"; } export class QrLoginSession { state: QrLoginState = "connecting"; url?: string; user?: QrLoginUser; error?: string; captcha?: QrLoginCaptcha; token?: string; private readonly keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); private readonly encodedPublicKey = this.keyPair.publicKey .export({ type: "spki", format: "der" }) .toString("base64"); private ws?: WebSocket; private heartbeat?: NodeJS.Timeout; private ticket?: string; private captchaRqToken?: string; private closed = false; constructor(private readonly options: QrLoginOptions) {} start(): void { this.ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } }); this.ws.on("open", () => this.debug("connected, requesting QR")); this.ws.on("message", raw => { void this.handle(raw).catch(error => this.fail(String(error?.message ?? error))); }); this.ws.on("close", () => { if (this.heartbeat) clearInterval(this.heartbeat); if (!this.closed) this.fail("gateway connection closed"); }); this.ws.on("error", error => { this.debug(`gateway error: ${error.message}`); this.fail(error.message); }); } cancel(): void { if (this.closed) return; this.closed = true; this.ws?.close(); this.update({ state: "cancelled" }); } submitCaptcha(key: string): boolean { if (this.state !== "captcha" || !this.ticket) return false; void this.finishWithTicket(key, this.captchaRqToken); return true; } private update(update: QrLoginUpdate) { this.state = update.state; if (update.url) this.url = update.url; if (update.user) this.user = update.user; if (update.error) this.error = update.error; if (update.captcha) this.captcha = update.captcha; this.options.onUpdate?.(update); } private fail(error: string) { if (this.closed) return; this.closed = true; this.ws?.close(); this.update({ state: "failed", error }); } private async handle(raw: WebSocket.RawData) { const packet: any = JSON.parse(raw.toString()); this.debug(`<- ${packet.op}`); switch (packet.op) { case "hello": this.heartbeat = setInterval( () => this.ws?.send(JSON.stringify({ op: "heartbeat" })), packet.heartbeat_interval ); this.ws?.send(JSON.stringify({ op: "init", encoded_public_key: this.encodedPublicKey })); break; case "nonce_proof": { const nonce = this.decrypt(packet.encrypted_nonce); const proof = crypto.createHash("sha256").update(nonce).digest("base64url"); this.ws?.send(JSON.stringify({ op: "nonce_proof", proof })); break; } case "pending_remote_init": this.update({ state: "pending", url: `https://discord.com/ra/${packet.fingerprint}` }); break; case "pending_ticket": { const payload = this.decrypt(packet.encrypted_user_payload).toString(); const [id, discriminator, avatar, username] = payload.split(":"); const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username; this.update({ state: "scanned", user: { id, tag, ...(avatar ? { avatar } : {}) } }); break; } case "pending_login": this.ticket = packet.ticket; await this.finishWithTicket(); break; case "cancel": this.closed = true; this.ws?.close(); this.update({ state: "cancelled" }); break; case "heartbeat_ack": break; default: this.debug(`unhandled op ${packet.op}`); } } private async finishWithTicket(captchaKey?: string, captchaRqToken?: string) { const token = await this.exchangeTicket(this.ticket!, captchaKey, captchaRqToken); if (!token) return; try { fs.writeFileSync(this.options.tokenFile, token + "\n", { mode: 0o600 }); } catch (error) { this.fail(`could not write the token file: ${String(error)}`); return; } this.token = token; this.closed = true; this.ws?.close(); this.update({ state: "finished", user: this.user }); } private async exchangeTicket( ticket: string, captchaKey?: string, captchaRqToken?: string ): Promise { const body: Record = { ticket }; if (captchaKey) { body.captcha_key = captchaKey; body.captcha_rqtoken = captchaRqToken; } const response = await fetch(LOGIN_URL, { method: "POST", headers: { Accept: "*/*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", Origin: "https://discord.com", Referer: "https://discord.com/login", "User-Agent": USER_AGENT }, body: JSON.stringify(body) }); const data: any = await response.json(); this.debug(`ticket exchange ${response.status}`); if (data.encrypted_token) return this.decrypt(data.encrypted_token).toString(); if (data.captcha_sitekey || data.captcha_rqtoken) { this.captchaRqToken = data.captcha_rqtoken; this.update({ state: "captcha", captcha: { service: data.captcha_service, sitekey: data.captcha_sitekey, rqdata: data.captcha_rqdata } }); return undefined; } this.fail(`ticket exchange failed (HTTP ${response.status})`); return undefined; } private decrypt(payload: string): Buffer { return crypto.privateDecrypt( { key: this.keyPair.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(payload, "base64") ); } private debug(...args: unknown[]) { if (this.options.debug) console.error("[qr:debug]", ...args); } }