Fail-fast checkpoint the arbiter mandated before committing to option A (protocol-level selfbot stream receive). dave/gate.mjs proves, live against Discord, that a user token can pass the voice DAVE/MLS handshake: - voice GW v8 IDENTIFY with max_dave_protocol_version=1 -> NO close 4017 - dave_protocol_version=1 negotiated (E2EE active on the channel) - @snazzah/davey drives full MLS membership: op25 external_sender -> op26 key_package -> op27 proposals -> op28 commit_welcome -> op29 announce_commit -> MLS session ready=true, stable 5s, voicePrivacyCode derived Confirms option A is viable: the selfbot can join the E2EE group as a full member, which is the prerequisite for receiving+decrypting the video RTP. PLAN.md updated with gate result, exact binary framing, and next A steps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
// Fail-fast gate (arbiter checkpoint #1):
|
|
// Can a selfbot pass the Discord voice DAVE/MLS handshake (no close 4017),
|
|
// and does the voice gateway negotiate DAVE + push the external-sender (op25)?
|
|
//
|
|
// This intentionally stops at "gate evidence": we log the voice IDENTIFY result,
|
|
// whether close code 4017 occurs, the negotiated dave_protocol_version, and any
|
|
// DAVE opcodes the server pushes. It joins a voice channel muted+deaf and leaves
|
|
// immediately after collecting evidence. No media is transmitted.
|
|
//
|
|
// Usage: DAVE_VER=1 node gate.mjs (declare DAVE support)
|
|
// DAVE_VER=0 node gate.mjs (declare DAVE UNsupported -> expect rejection)
|
|
|
|
import WebSocket from 'ws';
|
|
import dgram from 'node:dgram';
|
|
import fs from 'node:fs';
|
|
import * as davey from '@snazzah/davey';
|
|
|
|
// --- config ---
|
|
const env = Object.fromEntries(
|
|
fs.readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
|
.split('\n').filter(l => l && !l.startsWith('#') && l.includes('='))
|
|
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
|
|
);
|
|
const TOKEN = env.DISCORD_SELFBOT_TOKEN;
|
|
const GUILD_ID = process.env.GUILD_ID || '1352269198297923648';
|
|
const CHANNEL_ID = process.env.CHANNEL_ID || '1352269198914621465'; // "일반"
|
|
const DAVE_VER = process.env.DAVE_VER != null ? Number(process.env.DAVE_VER) : davey.DAVE_PROTOCOL_VERSION;
|
|
const HARD_TIMEOUT_MS = Number(process.env.TIMEOUT_MS || 30000);
|
|
|
|
if (!TOKEN) { console.error('no token'); process.exit(2); }
|
|
|
|
const t0 = Date.now();
|
|
const log = (...a) => console.log(`[+${String(Date.now() - t0).padStart(6)}ms]`, ...a);
|
|
console.log(`davey VERSION=${davey.VERSION} DAVE_PROTOCOL_VERSION=${davey.DAVE_PROTOCOL_VERSION}`);
|
|
log(`gate start: declaring max_dave_protocol_version=${DAVE_VER}, channel=${CHANNEL_ID}`);
|
|
|
|
const evidence = {
|
|
daveVerDeclared: DAVE_VER,
|
|
mainReady: false,
|
|
voiceServerReceived: false,
|
|
voiceIdentifySent: false,
|
|
voiceReady: false,
|
|
voiceReadyModes: null,
|
|
negotiatedDaveVersion: null,
|
|
sawExternalSender: false,
|
|
sawDaveOpcodes: [],
|
|
mlsSessionReady: false,
|
|
voiceCloseCode: null,
|
|
voiceCloseReason: null,
|
|
verdict: 'INCOMPLETE',
|
|
notes: [],
|
|
};
|
|
|
|
let mainWs, voiceWs, udp;
|
|
let daveSession = null;
|
|
let voiceState = { session_id: null, token: null, endpoint: null, ssrc: null, ip: null, port: null, mode: null };
|
|
let finished = false;
|
|
|
|
function finish(verdict, extra) {
|
|
if (finished) return;
|
|
finished = true;
|
|
evidence.verdict = verdict;
|
|
if (extra) evidence.notes.push(extra);
|
|
// leave the voice channel politely
|
|
try { mainWs?.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: null, self_mute: true, self_deaf: true } })); } catch {}
|
|
setTimeout(() => {
|
|
try { voiceWs?.close(); } catch {}
|
|
try { udp?.close(); } catch {}
|
|
try { mainWs?.close(); } catch {}
|
|
console.log('\n===== GATE EVIDENCE =====');
|
|
console.log(JSON.stringify(evidence, null, 2));
|
|
process.exit(0);
|
|
}, 400);
|
|
}
|
|
setTimeout(() => finish(evidence.verdict === 'INCOMPLETE' ? 'TIMEOUT' : evidence.verdict, `hard timeout ${HARD_TIMEOUT_MS}ms`), HARD_TIMEOUT_MS);
|
|
|
|
// ---------- MAIN GATEWAY ----------
|
|
mainWs = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json');
|
|
let mainHb;
|
|
mainWs.on('open', () => log('main gw: open'));
|
|
mainWs.on('message', (raw) => {
|
|
const p = JSON.parse(raw.toString());
|
|
if (p.op === 10) {
|
|
const iv = p.d.heartbeat_interval;
|
|
mainHb = setInterval(() => { try { mainWs.send(JSON.stringify({ op: 1, d: null })); } catch {} }, iv);
|
|
mainWs.send(JSON.stringify({ op: 2, d: {
|
|
token: TOKEN,
|
|
capabilities: 16381,
|
|
properties: { os: 'Linux', browser: 'Chrome', device: '', system_locale: 'en-US', browser_user_agent: 'Mozilla/5.0', browser_version: '124.0', os_version: '', release_channel: 'stable', client_build_number: 300000 },
|
|
compress: false,
|
|
presence: { status: 'invisible', since: 0, activities: [], afk: false },
|
|
}}));
|
|
log('main gw: sent IDENTIFY');
|
|
} else if (p.op === 0) {
|
|
if (p.t === 'READY') {
|
|
evidence.mainReady = true;
|
|
log(`main gw: READY as ${p.d.user?.username} (${p.d.user?.id})`);
|
|
// join voice channel muted+deaf
|
|
mainWs.send(JSON.stringify({ op: 4, d: { guild_id: GUILD_ID, channel_id: CHANNEL_ID, self_mute: true, self_deaf: true } }));
|
|
log('main gw: sent Voice State Update (join)');
|
|
} else if (p.t === 'VOICE_STATE_UPDATE' && p.d.user_id) {
|
|
if (p.d.session_id) { voiceState.session_id = p.d.session_id; log('VOICE_STATE_UPDATE session_id acquired'); maybeConnectVoice(); }
|
|
} else if (p.t === 'VOICE_SERVER_UPDATE') {
|
|
voiceState.token = p.d.token; voiceState.endpoint = p.d.endpoint;
|
|
evidence.voiceServerReceived = true;
|
|
log(`VOICE_SERVER_UPDATE endpoint=${p.d.endpoint}`);
|
|
maybeConnectVoice();
|
|
}
|
|
}
|
|
});
|
|
mainWs.on('close', (c, r) => { log(`main gw: close ${c} ${r}`); clearInterval(mainHb); });
|
|
mainWs.on('error', (e) => log('main gw error', e.message));
|
|
|
|
// ---------- VOICE GATEWAY ----------
|
|
function maybeConnectVoice() {
|
|
if (voiceWs || !voiceState.session_id || !voiceState.token || !voiceState.endpoint) return;
|
|
const url = `wss://${voiceState.endpoint}/?v=8`;
|
|
log(`voice gw: connecting ${url}`);
|
|
voiceWs = new WebSocket(url);
|
|
let voiceHb, lastSeq = null;
|
|
const knownUsers = new Set(['1513862586112671786']);
|
|
voiceWs.on('open', () => {
|
|
voiceWs.send(JSON.stringify({ op: 0, d: {
|
|
server_id: GUILD_ID,
|
|
user_id: '1513862586112671786',
|
|
session_id: voiceState.session_id,
|
|
token: voiceState.token,
|
|
max_dave_protocol_version: DAVE_VER,
|
|
}}));
|
|
evidence.voiceIdentifySent = true;
|
|
log(`voice gw: sent IDENTIFY (max_dave_protocol_version=${DAVE_VER})`);
|
|
});
|
|
voiceWs.on('message', (raw, isBinary) => {
|
|
if (isBinary) {
|
|
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
// received binary DAVE frame: uint16 seq BE, uint8 opcode, payload
|
|
const seq = buf.readUInt16BE(0);
|
|
const op = buf.readUInt8(2);
|
|
const payload = buf.subarray(3);
|
|
lastSeq = seq;
|
|
onDaveBinary(op, payload, seq);
|
|
return;
|
|
}
|
|
const p = JSON.parse(raw.toString());
|
|
handleVoiceJson(p);
|
|
});
|
|
voiceWs.on('close', (c, r) => {
|
|
evidence.voiceCloseCode = c; evidence.voiceCloseReason = r?.toString() || '';
|
|
log(`voice gw: CLOSE code=${c} reason="${evidence.voiceCloseReason}"`);
|
|
clearInterval(voiceHb);
|
|
if (c === 4017) finish('FAIL_4017', 'voice gateway rejected with close 4017 (DAVE-unsupported/protocol)');
|
|
else if (!finished) finish(evidence.voiceReady ? evidence.verdict : 'FAIL_VOICE_CLOSED', `voice closed ${c}`);
|
|
});
|
|
voiceWs.on('error', (e) => log('voice gw error', e.message));
|
|
|
|
function handleVoiceJson(p) {
|
|
switch (p.op) {
|
|
case 8: { // HELLO
|
|
const iv = p.d.heartbeat_interval;
|
|
voiceHb = setInterval(() => {
|
|
try { voiceWs.send(JSON.stringify({ op: 3, d: { t: Date.now(), seq_ack: lastSeq ?? 0 } })); } catch {}
|
|
}, iv);
|
|
log(`voice gw: HELLO heartbeat_interval=${iv}`);
|
|
break;
|
|
}
|
|
case 2: { // READY
|
|
evidence.voiceReady = true;
|
|
evidence.voiceReadyModes = p.d.modes;
|
|
voiceState.ssrc = p.d.ssrc; voiceState.ip = p.d.ip; voiceState.port = p.d.port;
|
|
log(`voice gw: READY ssrc=${p.d.ssrc} udp=${p.d.ip}:${p.d.port} modes=${JSON.stringify(p.d.modes)}`);
|
|
doUdpDiscoveryAndSelect(p.d);
|
|
break;
|
|
}
|
|
case 4: { // Session Description / select_protocol_ack (carries dave_protocol_version)
|
|
if (p.d && p.d.dave_protocol_version != null) {
|
|
evidence.negotiatedDaveVersion = p.d.dave_protocol_version;
|
|
log(`voice gw: SESSION_DESCRIPTION dave_protocol_version=${p.d.dave_protocol_version} mode=${p.d.mode}`);
|
|
} else {
|
|
log(`voice gw: SESSION_DESCRIPTION (no dave version field) mode=${p.d?.mode}`);
|
|
}
|
|
evaluateGate();
|
|
break;
|
|
}
|
|
case 11: { // clients connect
|
|
for (const u of (p.d.user_ids || [])) knownUsers.add(u);
|
|
log(`voice gw: op11 clients_connect ${JSON.stringify(p.d.user_ids)}`);
|
|
break;
|
|
}
|
|
case 20: { if (p.d.user_id) knownUsers.add(p.d.user_id); log(`voice gw: op20 platform user=${p.d.user_id}`); break; }
|
|
case 21: { // dave_prepare_transition (JSON)
|
|
evidence.sawDaveOpcodes.push(21);
|
|
log(`voice gw: DAVE op21 prepare_transition ${JSON.stringify(p.d)}`);
|
|
// ack readiness
|
|
try { voiceWs.send(JSON.stringify({ op: 23, d: { transition_id: p.d.transition_id } })); } catch {}
|
|
break;
|
|
}
|
|
case 22: {
|
|
evidence.sawDaveOpcodes.push(22);
|
|
log(`voice gw: DAVE op22 execute_transition ${JSON.stringify(p.d)}`);
|
|
if (daveSession && daveSession.ready) { evidence.mlsSessionReady = true; }
|
|
evaluateGate();
|
|
break;
|
|
}
|
|
case 24: {
|
|
evidence.sawDaveOpcodes.push(24);
|
|
log(`voice gw: DAVE op24 prepare_epoch ${JSON.stringify(p.d)}`);
|
|
break;
|
|
}
|
|
case 31: {
|
|
evidence.sawDaveOpcodes.push(31);
|
|
log(`voice gw: DAVE op31 invalid_commit_welcome ${JSON.stringify(p.d)}`);
|
|
break;
|
|
}
|
|
default:
|
|
log(`voice gw: op${p.op} ${JSON.stringify(p.d)?.slice(0, 200)}`);
|
|
}
|
|
}
|
|
|
|
function onDaveBinary(op, payload, seq) {
|
|
evidence.sawDaveOpcodes.push(op);
|
|
log(`voice gw: DAVE binary op${op} seq=${seq} len=${payload.length}`);
|
|
try {
|
|
switch (op) {
|
|
case 25: { // external sender package
|
|
evidence.sawExternalSender = true;
|
|
if (!daveSession) {
|
|
daveSession = new davey.DAVESession(evidence.negotiatedDaveVersion || DAVE_VER, '1513862586112671786', CHANNEL_ID);
|
|
}
|
|
daveSession.setExternalSender(payload);
|
|
const kp = daveSession.getSerializedKeyPackage();
|
|
// send op26 key package (binary): [uint8 opcode][payload]
|
|
voiceWs.send(Buffer.concat([Buffer.from([26]), kp]), { binary: true });
|
|
log(`voice gw: sent DAVE op26 key_package len=${kp.length}`);
|
|
break;
|
|
}
|
|
case 27: { // proposals: [operation_type u8][proposals...]
|
|
const opType = payload.readUInt8(0); // ProposalsOperationType
|
|
const proposals = payload.subarray(1);
|
|
const known = Array.from(knownUsers);
|
|
const res = daveSession.processProposals(opType, proposals, known);
|
|
if (res && res.commit) {
|
|
const parts = [Buffer.from([28]), res.commit];
|
|
if (res.welcome) parts.push(res.welcome);
|
|
voiceWs.send(Buffer.concat(parts), { binary: true });
|
|
log(`voice gw: sent DAVE op28 commit_welcome commit=${res.commit.length} welcome=${res.welcome?.length || 0} (known=${known.length})`);
|
|
} else {
|
|
log(`voice gw: op27 processed, no commit produced (known=${known.length})`);
|
|
}
|
|
break;
|
|
}
|
|
case 29: { // announce commit transition: [transition_id u16][commit...]
|
|
const commit = payload.subarray(2);
|
|
daveSession.processCommit(commit);
|
|
if (daveSession.ready) evidence.mlsSessionReady = true;
|
|
log(`voice gw: processed op29 commit (tid=${payload.readUInt16BE(0)}), sessionReady=${daveSession.ready}`);
|
|
evaluateGate();
|
|
break;
|
|
}
|
|
case 30: { // welcome: [transition_id u16][welcome...]
|
|
const welcome = payload.subarray(2);
|
|
daveSession.processWelcome(welcome);
|
|
if (daveSession.ready) evidence.mlsSessionReady = true;
|
|
log(`voice gw: processed op30 welcome (tid=${payload.readUInt16BE(0)}), sessionReady=${daveSession.ready}`);
|
|
evaluateGate();
|
|
break;
|
|
}
|
|
default:
|
|
log(`voice gw: unhandled DAVE binary op${op}`);
|
|
}
|
|
} catch (e) {
|
|
evidence.notes.push(`DAVE op${op} error: ${e.message}`);
|
|
log(`voice gw: DAVE op${op} handler error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function daveKnownUsers() { return ['1513862586112671786']; }
|
|
|
|
function doUdpDiscoveryAndSelect(ready) {
|
|
udp = dgram.createSocket('udp4');
|
|
const disc = Buffer.alloc(74);
|
|
disc.writeUInt16BE(1, 0); disc.writeUInt16BE(70, 2); disc.writeUInt32BE(ready.ssrc, 4);
|
|
let selected = false;
|
|
udp.on('message', (msg) => {
|
|
if (selected) return; selected = true;
|
|
const ipEnd = msg.indexOf(0, 8);
|
|
const ip = msg.subarray(8, ipEnd).toString();
|
|
const port = msg.readUInt16BE(msg.length - 2);
|
|
const mode = (ready.modes || []).includes('aead_aes256_gcm_rtpsize') ? 'aead_aes256_gcm_rtpsize'
|
|
: (ready.modes || []).includes('aead_xchacha20_poly1305_rtpsize') ? 'aead_xchacha20_poly1305_rtpsize'
|
|
: (ready.modes || [])[0];
|
|
voiceState.mode = mode;
|
|
voiceWs.send(JSON.stringify({ op: 1, d: { protocol: 'udp', data: { address: ip, port, mode }, codecs: [
|
|
{ name: 'opus', type: 'audio', priority: 1000, payload_type: 120 },
|
|
{ name: 'VP8', type: 'video', priority: 1000, payload_type: 101, rtx_payload_type: 102 },
|
|
{ name: 'H264', type: 'video', priority: 2000, payload_type: 103, rtx_payload_type: 104 },
|
|
] }}));
|
|
log(`voice gw: sent SELECT PROTOCOL (mode=${mode}) after UDP discovery ${ip}:${port}`);
|
|
});
|
|
udp.on('error', (e) => log('udp error', e.message));
|
|
udp.send(disc, ready.port, ready.ip, (e) => { if (e) log('udp send err', e.message); else log('udp: sent IP discovery'); });
|
|
}
|
|
|
|
function evaluateGate() {
|
|
if (finished) return;
|
|
if (!evidence.voiceReady || evidence.negotiatedDaveVersion == null) return;
|
|
if (evidence.negotiatedDaveVersion === 0) {
|
|
// DAVE not enforced on this channel right now: gateway accepted, no E2EE membership needed.
|
|
return finish('PASS_NO_DAVE', 'voice gateway accepted; dave_protocol_version=0 (E2EE not active on this channel)');
|
|
}
|
|
if (evidence.mlsSessionReady) {
|
|
// Full E2EE membership achieved. Hold briefly to confirm the membership stays stable
|
|
// (no server disconnect), then leave cleanly.
|
|
log('gate: MLS session READY — holding 5s to confirm stable membership');
|
|
setTimeout(() => {
|
|
if (evidence.voiceCloseCode == null) evidence.notes.push('membership stable for 5s (no server disconnect)');
|
|
finish('PASS_MLS_READY', `DAVE v${evidence.negotiatedDaveVersion} negotiated; joined E2EE MLS group (session ready); privacyCode=${daveSession?.voicePrivacyCode || 'n/a'}`);
|
|
}, 5000);
|
|
}
|
|
// else: DAVE negotiated but MLS not yet complete — keep waiting for op25/27/29/30.
|
|
}
|
|
}
|