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
+25 -3
View File
@@ -8,6 +8,7 @@ import * as tray from './tray.ts';
import { fetchLinkPreview } from './link-preview.ts';
import { fetchPublicListWith, pingServer } from './publist.ts';
import { openTls } from './tls-transport.ts';
import { udpChannel } from './udp-voice.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL;
@@ -16,7 +17,7 @@ const identities = () => (identityStore ??= new IdentityStore(app.getPath('userD
// ─── Mumble TLS connections, one per renderer request ─────────────────────────
type Conn = ReturnType<typeof openTls> & { owner: WebContents };
type Conn = ReturnType<typeof openTls> & { owner: WebContents; udp?: ReturnType<typeof udpChannel> };
const conns = new Map<string, Conn>();
// ─── Identities (client certificates) ─────────────────────────────────────────
@@ -54,9 +55,19 @@ ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: numb
const owner = e.sender;
const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); };
const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, {
onSecure: info => emit('mumble:secure', info),
onSecure: info => {
// Encrypted UDP voice to the address the TLS connection actually reached
const udp = udpChannel(info.address, info.port);
udp.onVoice = p => emit('mumble:udpVoice', p);
udp.onState = (ok, rtt) => emit('mumble:udpState', ok, rtt);
udp.onResync = () => emit('mumble:udpResync');
const c = conns.get(connId);
if (c) c.udp = udp;
else udp.close();
emit('mumble:secure', info);
},
onData: chunk => emit('mumble:data', chunk),
onClose: reason => { conns.delete(connId); emit('mumble:close', reason); }
onClose: reason => { conns.get(connId)?.udp?.close(); conns.delete(connId); emit('mumble:close', reason); }
});
conns.set(connId, Object.assign(conn, { owner }));
owner.once('destroyed', () => conn.close());
@@ -67,6 +78,17 @@ ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => {
if (conn && conn.owner === e.sender) conn.send(bytes);
});
// UDP voice: keys from the server's CryptSetup, voice packets, nonce resync
const ownUdp = (e: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent, connId: string) => {
const conn = conns.get(connId);
return conn && conn.owner === e.sender ? conn.udp : undefined;
};
ipcMain.on('mumble:udpSetup', (e, connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) =>
ownUdp(e, connId)?.setup(new Uint8Array(key), new Uint8Array(cn), new Uint8Array(sn), !!protobuf));
ipcMain.on('mumble:udpNonce', (e, connId: string, sn: Uint8Array) => ownUdp(e, connId)?.setServerNonce(new Uint8Array(sn)));
ipcMain.on('mumble:udpSend', (e, connId: string, bytes: Uint8Array) => ownUdp(e, connId)?.send(new Uint8Array(bytes)));
ipcMain.handle('mumble:udpClientNonce', (e, connId: string) => ownUdp(e, connId)?.clientNonce() ?? null);
ipcMain.on('mumble:close', (e, connId: string) => {
const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.close();