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:
2026-10-01 01:14:36 +02:00
co-authored by Claude Opus 5.5
parent 9a3aeb29ad
commit b44b8230d4
19 changed files with 616 additions and 48 deletions
+7 -29
View File
@@ -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); }
+5 -3
View File
@@ -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>
+4 -3
View File
@@ -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>
+9 -2
View File
@@ -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>