Add Nocturne debug web UI, core process control, and web QR login
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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<string | undefined> {
|
||||
const body: Record<string, unknown> = { 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user