Add encrypted UDP voice (OCB2-AES128) with TCP fallback
- Port of Mumble's CryptStateOCB2 (XEX* counter-measures, late/lost/replay handling, nonce resync), verified against Mumble's OCB2 test vectors - UDP channel per connection in the main process, to the address the TLS connection reached; used only while the server answers UDP pings, falls back to the TCP tunnel automatically; "voice over TCP only" setting - Voice statistics show the live transport - Information dialog no longer infers the transport from ping counters - Message box: no padding, input fills the bar; Edit HTML only in descriptions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import dgram from 'node:dgram';
|
||||
import net from 'node:net';
|
||||
import { CryptState } from './ocb2.ts';
|
||||
import { writeVarint, readVarint } from '../src/core/voice-packet.ts';
|
||||
import { MumbleUDP } from '../src/core/mumble-udp-pb.js';
|
||||
import type { UdpChannel } from '../src/core/transport.ts';
|
||||
|
||||
// Encrypted UDP voice channel to a Mumble server. Voice uses UDP only while the server
|
||||
// answers our UDP pings; until then (and if it stops) the client keeps using the TCP tunnel.
|
||||
|
||||
export interface UdpCallbacks {
|
||||
onVoice(plain: Uint8Array): void;
|
||||
onState(ok: boolean, rtt: number): void;
|
||||
onResync(): void; // too many decrypt failures: ask the server for a fresh nonce
|
||||
}
|
||||
|
||||
const PING_MS = 2000;
|
||||
const DEAD_MS = 8000;
|
||||
|
||||
export class UdpVoice {
|
||||
readonly crypt = new CryptState();
|
||||
ok = false;
|
||||
rtt = 0;
|
||||
private sock: dgram.Socket;
|
||||
private protobuf = true;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastPong = 0;
|
||||
private failures = 0;
|
||||
private lastResync = 0;
|
||||
private closed = false;
|
||||
private host: string;
|
||||
private port: number;
|
||||
private cb: UdpCallbacks;
|
||||
|
||||
constructor(host: string, port: number, cb: UdpCallbacks) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.cb = cb;
|
||||
this.sock = dgram.createSocket(net.isIPv6(host) ? 'udp6' : 'udp4');
|
||||
this.sock.on('message', msg => this.receive(msg));
|
||||
this.sock.on('error', () => this.setOk(false));
|
||||
}
|
||||
|
||||
setup(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array, protobuf: boolean): void {
|
||||
this.crypt.setKey(key, clientNonce, serverNonce);
|
||||
this.protobuf = protobuf;
|
||||
if (!this.pingTimer) {
|
||||
this.ping();
|
||||
this.pingTimer = setInterval(() => this.ping(), PING_MS);
|
||||
}
|
||||
}
|
||||
|
||||
setServerNonce(nonce: Uint8Array): void {
|
||||
this.crypt.setDecryptIv(nonce);
|
||||
this.failures = 0;
|
||||
}
|
||||
|
||||
send(plain: Uint8Array): void {
|
||||
if (this.closed || !this.crypt.valid) return;
|
||||
const packet = this.crypt.encrypt(plain);
|
||||
this.sock.send(packet, this.port, this.host);
|
||||
}
|
||||
|
||||
private ping(): void {
|
||||
if (this.lastPong && Date.now() - this.lastPong > DEAD_MS) this.setOk(false);
|
||||
const ts = Date.now();
|
||||
if (this.protobuf) {
|
||||
const body: Uint8Array = MumbleUDP.Ping.encode(MumbleUDP.Ping.fromObject({ timestamp: ts })).finish();
|
||||
const out = new Uint8Array(body.length + 1);
|
||||
out[0] = 1;
|
||||
out.set(body, 1);
|
||||
this.send(out);
|
||||
} else {
|
||||
const out: number[] = [1 << 5];
|
||||
writeVarint(out, ts);
|
||||
this.send(new Uint8Array(out));
|
||||
}
|
||||
}
|
||||
|
||||
private receive(msg: Buffer): void {
|
||||
const plain = this.crypt.decrypt(msg);
|
||||
if (!plain) {
|
||||
// Repeated failures mean the nonces drifted apart; ask for a resync now and then
|
||||
if (++this.failures > 8 && Date.now() - this.lastResync > 5000) {
|
||||
this.lastResync = Date.now();
|
||||
this.failures = 0;
|
||||
this.cb.onResync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.failures = 0;
|
||||
const pingTs = this.pingTimestamp(plain);
|
||||
if (pingTs != null) {
|
||||
this.lastPong = Date.now();
|
||||
this.rtt = Math.max(0, Date.now() - pingTs);
|
||||
this.setOk(true);
|
||||
return;
|
||||
}
|
||||
this.cb.onVoice(new Uint8Array(plain));
|
||||
}
|
||||
|
||||
private pingTimestamp(p: Buffer): number | null {
|
||||
try {
|
||||
if (p[0] === 1 && this.protobuf) return Number(MumbleUDP.Ping.toObject(MumbleUDP.Ping.decode(p.subarray(1)), { longs: Number }).timestamp);
|
||||
if (p[0] >> 5 === 1 && !this.protobuf) return readVarint(p, 1)[0];
|
||||
} catch { /* not a ping */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private setOk(ok: boolean): void {
|
||||
if (ok === this.ok) {
|
||||
if (ok) this.cb.onState(true, this.rtt);
|
||||
return;
|
||||
}
|
||||
this.ok = ok;
|
||||
this.cb.onState(ok, this.rtt);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
try { this.sock.close(); } catch { /* closed */ }
|
||||
}
|
||||
}
|
||||
|
||||
// UdpVoice behind the client's UdpChannel interface (used directly in Node, bridged over IPC in the app)
|
||||
export function udpChannel(host: string, port: number): UdpChannel & { close(): void; voice: UdpVoice } {
|
||||
const ch: UdpChannel & { close(): void; voice: UdpVoice } = {
|
||||
onVoice: null, onState: null, onResync: null,
|
||||
setup: (k, c, s, p) => voice.setup(k, c, s, p),
|
||||
setServerNonce: n => voice.setServerNonce(n),
|
||||
clientNonce: () => Promise.resolve(voice.crypt.valid ? new Uint8Array(voice.crypt.encryptIv) : null),
|
||||
send: p => voice.send(p),
|
||||
close: () => voice.close(),
|
||||
voice: null as unknown as UdpVoice
|
||||
};
|
||||
const voice = new UdpVoice(host, port, {
|
||||
onVoice: p => ch.onVoice?.(p),
|
||||
onState: (ok, rtt) => ch.onState?.(ok, rtt),
|
||||
onResync: () => ch.onResync?.()
|
||||
});
|
||||
ch.voice = voice;
|
||||
return ch;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user