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:
@@ -156,6 +156,7 @@ type ClientEvents = {
|
||||
reject: (type: number, reason: string) => void;
|
||||
voice: (packet: Uint8Array) => void;
|
||||
userStats: (stats: any) => void;
|
||||
udp: (ok: boolean, rtt: number) => void;
|
||||
acl: (acl: ChannelAcl) => void;
|
||||
userNames: (names: Map<number, string>) => void;
|
||||
permissions: (channelId: number | null, bits: number) => void;
|
||||
@@ -178,6 +179,11 @@ export class MumbleClient extends Emitter<ClientEvents> {
|
||||
serverVersion = '';
|
||||
// Numeric server version (major << 16 | minor << 8 | patch), 0 until known
|
||||
serverVersionNum = 0;
|
||||
// Voice goes over encrypted UDP while the server answers our UDP pings, otherwise over TCP
|
||||
udpOk = false;
|
||||
udpRtt = 0;
|
||||
// Force the TCP tunnel even when UDP would work (for networks that mangle UDP)
|
||||
forceTcp = false;
|
||||
rtt = 0;
|
||||
config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true };
|
||||
|
||||
@@ -203,6 +209,18 @@ export class MumbleClient extends Emitter<ClientEvents> {
|
||||
|
||||
connect(transport: Transport, opts: ConnectOptions): void {
|
||||
this.transport = transport;
|
||||
const udp = transport.udp;
|
||||
if (udp) {
|
||||
udp.onVoice = plain => { if (!this.closed) this.emit('voice', plain); };
|
||||
udp.onState = (ok, rtt) => {
|
||||
const changed = ok !== this.udpOk;
|
||||
this.udpOk = ok;
|
||||
this.udpRtt = rtt;
|
||||
if (changed) this.emit('udp', ok, rtt);
|
||||
};
|
||||
// Ask the server for a fresh nonce (an empty CryptSetup)
|
||||
udp.onResync = () => this.send('CryptSetup', {});
|
||||
}
|
||||
transport.onData = chunk => {
|
||||
try {
|
||||
this.reader.push(chunk, (id, body) => this.handleFrame(id, body));
|
||||
@@ -409,6 +427,12 @@ export class MumbleClient extends Emitter<ClientEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
// Voice over UDP when it works, else through the TCP tunnel
|
||||
sendVoice(packet: Uint8Array): void {
|
||||
if (this.udpOk && !this.forceTcp && this.transport?.udp) this.transport.udp.send(packet);
|
||||
else this.sendVoiceTunnel(packet);
|
||||
}
|
||||
|
||||
// Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message
|
||||
sendVoiceTunnel(packet: Uint8Array): void {
|
||||
if (!this.transport || this.closed) return;
|
||||
@@ -529,6 +553,19 @@ export class MumbleClient extends Emitter<ClientEvents> {
|
||||
case 'UserStats':
|
||||
this.emit('userStats', msg);
|
||||
break;
|
||||
case 'CryptSetup': {
|
||||
const udp = this.transport?.udp;
|
||||
if (!udp || this.forceTcp) break;
|
||||
if (msg.key?.length && msg.client_nonce?.length && msg.server_nonce?.length) {
|
||||
udp.setup(msg.key, msg.client_nonce, msg.server_nonce, this.protobufVoice);
|
||||
} else if (msg.server_nonce?.length) {
|
||||
udp.setServerNonce(msg.server_nonce);
|
||||
} else {
|
||||
// The server asks for our nonce to resync its side
|
||||
udp.clientNonce().then(n => { if (n) this.send('CryptSetup', { client_nonce: n }); });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ACL': {
|
||||
// proto2 defaults: missing booleans are true
|
||||
const t = (v: unknown) => v !== false;
|
||||
|
||||
@@ -4,6 +4,18 @@ export interface Transport {
|
||||
close(): void;
|
||||
onData: ((chunk: Uint8Array) => void) | null;
|
||||
onClose: ((reason: string) => void) | null;
|
||||
// Encrypted UDP for voice, where the platform has it (desktop); absent on the web
|
||||
udp?: UdpChannel;
|
||||
}
|
||||
|
||||
export interface UdpChannel {
|
||||
setup(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array, protobuf: boolean): void;
|
||||
setServerNonce(nonce: Uint8Array): void;
|
||||
clientNonce(): Promise<Uint8Array | null>;
|
||||
send(plain: Uint8Array): void;
|
||||
onVoice: ((plain: Uint8Array) => void) | null;
|
||||
onState: ((ok: boolean, rtt: number) => void) | null;
|
||||
onResync: (() => void) | null;
|
||||
}
|
||||
|
||||
export interface ConnectTarget {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface VoiceSettings {
|
||||
bitrate: number; // Opus bits per second
|
||||
frameMs: 10 | 20 | 40 | 60;
|
||||
jitterMs: number;
|
||||
forceTcp: boolean; // send voice through the TCP connection even when UDP works
|
||||
// Per-user local volume and mute, keyed by certificate hash (or name without one)
|
||||
userVolumes: Record<string, number>;
|
||||
localMutes: Record<string, boolean>;
|
||||
@@ -41,6 +42,7 @@ const defaults: VoiceSettings = {
|
||||
bitrate: 40000,
|
||||
frameMs: 20,
|
||||
jitterMs: 60,
|
||||
forceTcp: false,
|
||||
userVolumes: {},
|
||||
localMutes: {}
|
||||
};
|
||||
@@ -337,7 +339,7 @@ class VoiceEngine {
|
||||
private sendPacket(opus: Uint8Array, last: boolean): void {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
client.sendVoiceTunnel(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
|
||||
client.sendVoice(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
|
||||
this.frameCounter += this.settings.frameMs / 10;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
import type { Transport, ServerCertInfo, CertDetails } from '../core/transport.ts';
|
||||
import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts';
|
||||
|
||||
interface NativeApi {
|
||||
platformInfo(): Promise<{ os: string; osVersion: string }>;
|
||||
@@ -25,7 +25,11 @@ interface NativeApi {
|
||||
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
|
||||
send(connId: string, bytes: Uint8Array): void;
|
||||
close(connId: string): void;
|
||||
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
|
||||
on(event: 'secure' | 'data' | 'close' | 'udpVoice' | 'udpState' | 'udpResync', fn: (connId: string, ...args: any[]) => void): () => void;
|
||||
udpSetup(connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean): void;
|
||||
udpNonce(connId: string, sn: Uint8Array): void;
|
||||
udpSend(connId: string, bytes: Uint8Array): void;
|
||||
udpClientNonce(connId: string): Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
// What Electron reports for a right-click without a custom menu
|
||||
@@ -76,6 +80,7 @@ export class ElectronTransport implements Transport {
|
||||
onData: ((chunk: Uint8Array) => void) | null = null;
|
||||
onClose: ((reason: string) => void) | null = null;
|
||||
readonly secure: Promise<ServerCertInfo>;
|
||||
udp: UdpChannel;
|
||||
private id = crypto.randomUUID();
|
||||
private offs: (() => void)[] = [];
|
||||
private api: NativeApi;
|
||||
@@ -86,7 +91,19 @@ export class ElectronTransport implements Transport {
|
||||
let rejectSecure!: (e: Error) => void;
|
||||
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
|
||||
const id = this.id;
|
||||
// UDP voice lives in the main process; this bridges it to the client's UdpChannel
|
||||
const udp: UdpChannel = {
|
||||
onVoice: null, onState: null, onResync: null,
|
||||
setup: (k, c, s, p) => api.udpSetup(id, k, c, s, p),
|
||||
setServerNonce: n => api.udpNonce(id, n),
|
||||
clientNonce: () => api.udpClientNonce(id),
|
||||
send: p => api.udpSend(id, p)
|
||||
};
|
||||
this.udp = udp;
|
||||
this.offs.push(
|
||||
api.on('udpVoice', (cid, p) => { if (cid === id) udp.onVoice?.(p); }),
|
||||
api.on('udpState', (cid, ok, rtt) => { if (cid === id) udp.onState?.(ok, rtt); }),
|
||||
api.on('udpResync', cid => { if (cid === id) udp.onResync?.(); }),
|
||||
api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }),
|
||||
api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }),
|
||||
api.on('close', (cid, reason) => {
|
||||
|
||||
@@ -161,6 +161,14 @@ export class Session {
|
||||
this.expanded = all;
|
||||
}
|
||||
|
||||
// How voice travels right now
|
||||
get voiceTransport(): { udp: boolean; rtt: number } {
|
||||
void this.tick;
|
||||
const c = this.client;
|
||||
// The setting (reactive) rather than the client's copy, so toggling it updates the view
|
||||
return { udp: !!c && c.udpOk && !voice.settings.forceTcp, rtt: c?.udpRtt ?? 0 };
|
||||
}
|
||||
|
||||
// true / false when known, null when the server has not told us yet
|
||||
can(channelId: number, bit: number): boolean | null {
|
||||
void this.tick;
|
||||
@@ -236,6 +244,7 @@ export class Session {
|
||||
}
|
||||
|
||||
const client = new MumbleClient(codec);
|
||||
client.forceTcp = voice.settings.forceTcp;
|
||||
this.client = client;
|
||||
this.wire(client);
|
||||
// TLS is up but the server may still never finish the login
|
||||
@@ -309,6 +318,7 @@ export class Session {
|
||||
bump();
|
||||
});
|
||||
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
|
||||
client.on('udp', bump);
|
||||
|
||||
client.on('user', (user, changed, actor, isNew) => {
|
||||
if (!live()) return;
|
||||
|
||||
+7
-29
@@ -14,8 +14,6 @@
|
||||
let picker: HTMLInputElement;
|
||||
let imagePicker: HTMLInputElement;
|
||||
let empty = $state(true);
|
||||
let source = $state(false);
|
||||
let sourceText = $state('');
|
||||
// The formatting toolbar is opt-in and remembered
|
||||
let formatting = $state(loadFormatting());
|
||||
const disabled = $derived(session.status !== 'connected');
|
||||
@@ -27,7 +25,6 @@
|
||||
function toggleFormatting() {
|
||||
formatting = !formatting;
|
||||
try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ }
|
||||
if (!formatting && source) toggleSource();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
@@ -45,16 +42,14 @@
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const html = source ? sourceText : input.innerHTML;
|
||||
const html = input.innerHTML;
|
||||
// Plain text goes out the usual way; anything formatted as sanitized XHTML
|
||||
const formatted = source || !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img');
|
||||
const formatted = !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img');
|
||||
const ok = formatted
|
||||
? session.sendHtml(toMumbleHtml(html, { linkify: true }), target)
|
||||
: session.sendText(input.innerText, target);
|
||||
if (!ok) return;
|
||||
input.innerHTML = '';
|
||||
sourceText = '';
|
||||
if (source) source = false;
|
||||
sync();
|
||||
}
|
||||
|
||||
@@ -97,17 +92,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSource() {
|
||||
if (!source) {
|
||||
sourceText = toMumbleHtml(input.innerHTML);
|
||||
source = true;
|
||||
} else {
|
||||
input.innerHTML = toMumbleHtml(sourceText);
|
||||
source = false;
|
||||
sync();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void target;
|
||||
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
|
||||
@@ -128,7 +112,7 @@
|
||||
{/if}
|
||||
<div class="frame">
|
||||
{#if formatting}
|
||||
<FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} {source} ontogglesource={toggleSource} />
|
||||
<FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} />
|
||||
{/if}
|
||||
<div class="box">
|
||||
<button class="icon-btn" title={store.settings.uploadHost ? 'Upload a file' : 'Send an image'} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
|
||||
@@ -137,33 +121,27 @@
|
||||
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
|
||||
<input bind:this={imagePicker} type="file" accept="image/*" hidden
|
||||
onchange={() => { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} />
|
||||
<div class="input" class:blank={empty} class:hidden={source} bind:this={input} contenteditable={!disabled}
|
||||
<div class="input" class:blank={empty} bind:this={input} contenteditable={!disabled}
|
||||
role="textbox" aria-multiline="true" aria-label="Message" tabindex="0" data-placeholder={placeholder}
|
||||
oninput={sync} {onkeydown} {onpaste}></div>
|
||||
{#if source}
|
||||
<textarea class="input src" bind:value={sourceText} aria-label="HTML source" spellcheck="false"
|
||||
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }}></textarea>
|
||||
{/if}
|
||||
<button class="icon-btn fmt" class:on={formatting} title="Text formatting" aria-label="Text formatting" aria-pressed={formatting}
|
||||
{disabled} onmousedown={e => e.preventDefault()} onclick={toggleFormatting}>Aa</button>
|
||||
<button class="icon-btn send" title="Send" disabled={disabled || (empty && !(source && sourceText.trim()))} onclick={submit}><Icon name="send" size={18} /></button>
|
||||
<button class="icon-btn send" title="Send" disabled={disabled || empty} onclick={submit}><Icon name="send" size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */
|
||||
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 6px 16px; background: var(--bg-0); }
|
||||
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); }
|
||||
.frame { background: var(--bg-3); }
|
||||
.frame :global(.toolbar) { background: var(--bg-2); }
|
||||
.box { display: flex; align-items: flex-end; gap: 4px; min-height: calc(var(--footer-h) - 12px); padding: 2px 4px; }
|
||||
.box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; }
|
||||
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.input.hidden { display: none; }
|
||||
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
|
||||
.input :global(img) { max-height: 120px; }
|
||||
.input :global(ul), .input :global(ol) { margin: 0; padding-left: 20px; }
|
||||
.input[contenteditable='false'] { color: var(--text-faint); }
|
||||
.src { border: 0; resize: none; background: transparent; font: 12px var(--mono); min-height: 60px; }
|
||||
.fmt { font-weight: 700; font-size: 13px; }
|
||||
.fmt.on { color: var(--accent-hover); }
|
||||
.send:not(:disabled) { color: var(--accent-hover); }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Formatting controls shared by the description editor and the message input.
|
||||
// Buttons keep the text selection (mousedown is suppressed) and act through the callbacks.
|
||||
let { exec, onlink, onimage, source = false, ontogglesource }:
|
||||
{ exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource: () => void } = $props();
|
||||
{ exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource?: () => void } = $props();
|
||||
|
||||
let color = $state('#e8b23c');
|
||||
const keep = (e: MouseEvent) => e.preventDefault();
|
||||
@@ -32,8 +32,10 @@
|
||||
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={keep} onclick={onlink}><Icon name="link" size={16} /></button>
|
||||
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={keep} onclick={onimage}><Icon name="image" size={16} /></button>
|
||||
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={keep} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
|
||||
<span class="grow"></span>
|
||||
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button>
|
||||
{#if ontogglesource}
|
||||
<span class="grow"></span>
|
||||
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -79,9 +79,10 @@
|
||||
<dl>
|
||||
<dt>Online for</dt><dd>{duration(stats.onlinesecs != null ? stats.onlinesecs + since : undefined)}</dd>
|
||||
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs + since)}</dd>{/if}
|
||||
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
|
||||
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
|
||||
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd>
|
||||
<!-- The server counts the client's ping messages, not voice; clients that never ping (many bots) show none -->
|
||||
<dt>Ping (TCP)</dt><dd>{stats.tcp_packets ? ping(stats.tcp_ping_avg, stats.tcp_ping_var) : 'n/a (this client sends no pings)'}</dd>
|
||||
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd>
|
||||
<dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd>
|
||||
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
|
||||
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
|
||||
</dl>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { session, sessions } from '../lib/session.svelte.ts';
|
||||
|
||||
function setForceTcp(on: boolean) {
|
||||
voice.update({ forceTcp: on });
|
||||
for (const s of sessions.list) if (s.client) s.client.forceTcp = on;
|
||||
}
|
||||
|
||||
const s = voice.settings;
|
||||
let capturing = $state(false);
|
||||
@@ -133,6 +138,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" checked={s.forceTcp} onchange={e => setForceTcp(e.currentTarget.checked)} /> Send voice over TCP only (for networks that block or mangle UDP)</label>
|
||||
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label>
|
||||
<input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
|
||||
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
|
||||
@@ -140,7 +146,8 @@
|
||||
|
||||
{#if session.status === 'connected'}
|
||||
<h3>Statistics</h3>
|
||||
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: TCP tunnel.</p>
|
||||
{@const t = session.voiceTransport}
|
||||
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: {t.udp ? `UDP, ${Math.round(t.rtt)} ms ping` : 'TCP tunnel'}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user