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:
@@ -37,7 +37,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
||||
|
||||
## Architecture
|
||||
|
||||
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
|
||||
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
|
||||
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
|
||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||
|
||||
@@ -41,7 +41,7 @@ mumh5 keeps the foundation and replaces the experience.
|
||||
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
|
||||
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
|
||||
|
||||
**Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
|
||||
**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,6 +55,7 @@ mumh5 keeps the foundation and replaces the experience.
|
||||
- Per-person volume (0 to 300%) and "mute for me"
|
||||
- Speaking indicators in the channel tree, member list and your own panel
|
||||
- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand
|
||||
- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch
|
||||
- Bitrate automatically limited to what the server allows
|
||||
- Right-click the mute or deafen button for quick device and volume options
|
||||
- Hear-yourself microphone test
|
||||
@@ -164,7 +165,6 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
|
||||
## Roadmap
|
||||
|
||||
- UDP voice with Mumble's OCB2-AES128 encryption, for the lowest latency
|
||||
- System-wide push to talk
|
||||
- Whisper and shout
|
||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||
|
||||
+25
-3
@@ -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();
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
||||
|
||||
// Mumble's UDP encryption: OCB2-AES128 with 4-byte packet headers (IV byte + 3 tag bytes),
|
||||
// ported from Mumble's CryptStateOCB2.cpp including its counter-measures against the
|
||||
// XEX* attack (https://eprint.iacr.org/2019/311, section 9).
|
||||
|
||||
const BLOCK = 16;
|
||||
|
||||
function aes(key: Buffer, block: Buffer): Buffer {
|
||||
const c = createCipheriv('aes-128-ecb', key, null);
|
||||
c.setAutoPadding(false);
|
||||
return c.update(block);
|
||||
}
|
||||
|
||||
function aesDecrypt(key: Buffer, block: Buffer): Buffer {
|
||||
const d = createDecipheriv('aes-128-ecb', key, null);
|
||||
d.setAutoPadding(false);
|
||||
return d.update(block);
|
||||
}
|
||||
|
||||
function xor(a: Buffer, b: Buffer): Buffer {
|
||||
const out = Buffer.alloc(BLOCK);
|
||||
for (let i = 0; i < BLOCK; i++) out[i] = a[i] ^ b[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Multiply by x in GF(2^128), big endian
|
||||
function times2(b: Buffer): Buffer {
|
||||
const out = Buffer.alloc(BLOCK);
|
||||
const carry = b[0] >> 7;
|
||||
for (let i = 0; i < BLOCK - 1; i++) out[i] = ((b[i] << 1) | (b[i + 1] >> 7)) & 0xff;
|
||||
out[BLOCK - 1] = ((b[BLOCK - 1] << 1) ^ (carry * 0x87)) & 0xff;
|
||||
return out;
|
||||
}
|
||||
|
||||
const times3 = (b: Buffer) => xor(b, times2(b));
|
||||
|
||||
// Returns [ciphertext, tag, ok]; ok is false only when an attack pattern was seen and
|
||||
// modifyOnAttack is off (used by tests)
|
||||
export function ocbEncrypt(key: Buffer, plain: Buffer, nonce: Buffer, modifyOnAttack = true): [Buffer, Buffer, boolean] {
|
||||
let delta: Buffer = aes(key, nonce);
|
||||
let checksum: Buffer = Buffer.alloc(BLOCK);
|
||||
const out = Buffer.alloc(plain.length);
|
||||
let ok = true;
|
||||
let off = 0;
|
||||
let len = plain.length;
|
||||
while (len > BLOCK) {
|
||||
const block = plain.subarray(off, off + BLOCK);
|
||||
let flip = false;
|
||||
if (len - BLOCK <= BLOCK) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < BLOCK - 1; i++) sum |= block[i];
|
||||
if (sum === 0) {
|
||||
if (modifyOnAttack) flip = true;
|
||||
else ok = false;
|
||||
}
|
||||
}
|
||||
delta = times2(delta);
|
||||
const tmp = xor(delta, block);
|
||||
if (flip) tmp[0] ^= 1;
|
||||
xor(delta, aes(key, tmp)).copy(out, off);
|
||||
checksum = xor(checksum, block);
|
||||
if (flip) checksum[0] ^= 1;
|
||||
len -= BLOCK;
|
||||
off += BLOCK;
|
||||
}
|
||||
delta = times2(delta);
|
||||
const lenBlock = Buffer.alloc(BLOCK);
|
||||
lenBlock[BLOCK - 1] = (len * 8) & 0xff;
|
||||
const pad = aes(key, xor(lenBlock, delta));
|
||||
const tmp = Buffer.from(pad);
|
||||
plain.copy(tmp, 0, off, off + len);
|
||||
checksum = xor(checksum, tmp);
|
||||
xor(pad, tmp).copy(out, off, 0, len);
|
||||
delta = times3(delta);
|
||||
const tag = aes(key, xor(delta, checksum));
|
||||
return [out, tag, ok];
|
||||
}
|
||||
|
||||
export function ocbDecrypt(key: Buffer, encrypted: Buffer, nonce: Buffer): [Buffer, Buffer, boolean] {
|
||||
let delta: Buffer = aes(key, nonce);
|
||||
let checksum: Buffer = Buffer.alloc(BLOCK);
|
||||
const out = Buffer.alloc(encrypted.length);
|
||||
let off = 0;
|
||||
let len = encrypted.length;
|
||||
while (len > BLOCK) {
|
||||
delta = times2(delta);
|
||||
const plainBlock = xor(delta, aesDecrypt(key, xor(delta, encrypted.subarray(off, off + BLOCK))));
|
||||
plainBlock.copy(out, off);
|
||||
checksum = xor(checksum, plainBlock);
|
||||
len -= BLOCK;
|
||||
off += BLOCK;
|
||||
}
|
||||
delta = times2(delta);
|
||||
const lenBlock = Buffer.alloc(BLOCK);
|
||||
lenBlock[BLOCK - 1] = (len * 8) & 0xff;
|
||||
const pad = aes(key, xor(lenBlock, delta));
|
||||
const tmp = Buffer.alloc(BLOCK);
|
||||
encrypted.copy(tmp, 0, off, off + len);
|
||||
const last = xor(tmp, pad);
|
||||
checksum = xor(checksum, last);
|
||||
last.copy(out, off, 0, len);
|
||||
// Attack check: the decrypted last block must not equal delta (all but the length byte)
|
||||
const ok = !last.subarray(0, BLOCK - 1).equals(delta.subarray(0, BLOCK - 1));
|
||||
delta = times3(delta);
|
||||
const tag = aes(key, xor(delta, checksum));
|
||||
return [out, tag, ok];
|
||||
}
|
||||
|
||||
export class CryptState {
|
||||
private key = Buffer.alloc(BLOCK);
|
||||
encryptIv = Buffer.alloc(BLOCK);
|
||||
decryptIv = Buffer.alloc(BLOCK);
|
||||
private history = new Uint8Array(256);
|
||||
valid = false;
|
||||
good = 0;
|
||||
late = 0;
|
||||
lost = 0;
|
||||
resync = 0;
|
||||
lastGood = 0;
|
||||
|
||||
setKey(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array): void {
|
||||
this.key = Buffer.from(key);
|
||||
this.encryptIv = Buffer.from(clientNonce);
|
||||
this.decryptIv = Buffer.from(serverNonce);
|
||||
this.history.fill(0);
|
||||
this.valid = this.key.length === BLOCK && this.encryptIv.length === BLOCK && this.decryptIv.length === BLOCK;
|
||||
}
|
||||
|
||||
setDecryptIv(iv: Uint8Array): void {
|
||||
this.decryptIv = Buffer.from(iv);
|
||||
this.resync++;
|
||||
}
|
||||
|
||||
encrypt(plain: Uint8Array): Buffer {
|
||||
for (let i = 0; i < BLOCK; i++) {
|
||||
this.encryptIv[i] = (this.encryptIv[i] + 1) & 0xff;
|
||||
if (this.encryptIv[i]) break;
|
||||
}
|
||||
const [ct, tag] = ocbEncrypt(this.key, Buffer.from(plain), this.encryptIv);
|
||||
const out = Buffer.alloc(ct.length + 4);
|
||||
out[0] = this.encryptIv[0];
|
||||
tag.copy(out, 1, 0, 3);
|
||||
ct.copy(out, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mirrors CryptStateOCB2::decrypt: accepts late and out-of-order packets, rejects replays
|
||||
decrypt(packet: Uint8Array): Buffer | null {
|
||||
if (!this.valid || packet.length < 4) return null;
|
||||
const src = Buffer.from(packet);
|
||||
const save = Buffer.from(this.decryptIv);
|
||||
const ivbyte = src[0];
|
||||
const iv = this.decryptIv;
|
||||
let restore = false;
|
||||
let late = 0, lost = 0;
|
||||
|
||||
if (((iv[0] + 1) & 0xff) === ivbyte) {
|
||||
if (ivbyte > iv[0]) iv[0] = ivbyte;
|
||||
else if (ivbyte < iv[0]) {
|
||||
iv[0] = ivbyte;
|
||||
for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break;
|
||||
} else return null;
|
||||
} else {
|
||||
let diff = ivbyte - iv[0];
|
||||
if (diff > 128) diff -= 256;
|
||||
else if (diff < -128) diff += 256;
|
||||
if (ivbyte < iv[0] && diff > -30 && diff < 0) {
|
||||
late = 1; lost = -1; iv[0] = ivbyte; restore = true;
|
||||
} else if (ivbyte > iv[0] && diff > -30 && diff < 0) {
|
||||
late = 1; lost = -1; iv[0] = ivbyte;
|
||||
for (let i = 1; i < BLOCK; i++) { const was = iv[i]; iv[i] = (was - 1) & 0xff; if (was) break; }
|
||||
restore = true;
|
||||
} else if (ivbyte > iv[0] && diff > 0) {
|
||||
lost = ivbyte - iv[0] - 1; iv[0] = ivbyte;
|
||||
} else if (ivbyte < iv[0] && diff > 0) {
|
||||
lost = 256 - iv[0] + ivbyte - 1; iv[0] = ivbyte;
|
||||
for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break;
|
||||
} else return null;
|
||||
if (this.history[iv[0]] === iv[1]) {
|
||||
save.copy(this.decryptIv);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const [plain, tag, ok] = ocbDecrypt(this.key, src.subarray(4), iv);
|
||||
if (!ok || !tag.subarray(0, 3).equals(src.subarray(1, 4))) {
|
||||
save.copy(this.decryptIv);
|
||||
return null;
|
||||
}
|
||||
this.history[iv[0]] = iv[1];
|
||||
if (restore) save.copy(this.decryptIv);
|
||||
this.good++;
|
||||
this.late = Math.max(0, this.late + late);
|
||||
this.lost = Math.max(0, this.lost + lost);
|
||||
this.lastGood = Date.now();
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1,7 +1,10 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
type Listener = (connId: string, ...args: any[]) => void;
|
||||
const listeners = { secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>() };
|
||||
const listeners = {
|
||||
secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>(),
|
||||
udpVoice: new Set<Listener>(), udpState: new Set<Listener>(), udpResync: new Set<Listener>()
|
||||
};
|
||||
for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
|
||||
ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => {
|
||||
for (const fn of listeners[key]) fn(connId, ...args);
|
||||
@@ -34,6 +37,10 @@ contextBridge.exposeInMainWorld('mumh5Native', {
|
||||
open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId),
|
||||
send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes),
|
||||
close: (connId: string) => ipcRenderer.send('mumble:close', connId),
|
||||
udpSetup: (connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) => ipcRenderer.send('mumble:udpSetup', connId, key, cn, sn, protobuf),
|
||||
udpNonce: (connId: string, sn: Uint8Array) => ipcRenderer.send('mumble:udpNonce', connId, sn),
|
||||
udpSend: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:udpSend', connId, bytes),
|
||||
udpClientNonce: (connId: string) => ipcRenderer.invoke('mumble:udpClientNonce', connId),
|
||||
on: (event: keyof typeof listeners, fn: Listener) => {
|
||||
listeners[event].add(fn);
|
||||
return () => listeners[event].delete(fn);
|
||||
|
||||
@@ -2,7 +2,7 @@ import tls from 'node:tls';
|
||||
import { describeCert, type CertDetails } from './certs.ts';
|
||||
|
||||
export interface TlsHandlers {
|
||||
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void;
|
||||
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[]; address: string; port: number }): void;
|
||||
onData(chunk: Uint8Array): void;
|
||||
onClose(reason: string): void;
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export function openTls(host: string, port: number, cert: string, key: string, h
|
||||
}
|
||||
h.onSecure({
|
||||
chain,
|
||||
// The server's actual IP, so UDP voice goes to the same machine as the TCP connection
|
||||
address: socket.remoteAddress ?? host,
|
||||
port: socket.remotePort ?? port,
|
||||
fingerprint: chain[0]?.fingerprint256 ?? '',
|
||||
authorized: socket.authorized,
|
||||
authError: socket.authorizationError ? String(socket.authorizationError) : null
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{#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>
|
||||
|
||||
|
||||
@@ -180,6 +180,13 @@ try {
|
||||
const statsText = await page.locator('.stats').textContent();
|
||||
const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]);
|
||||
assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`);
|
||||
// Voice runs over encrypted UDP once the server answers UDP pings
|
||||
await page.locator('.stats', { hasText: /Transport: UDP, \d+ ms ping/ }).waitFor({ timeout: 10000 });
|
||||
await page.getByLabel(/Send voice over TCP only/).check();
|
||||
await page.locator('.stats', { hasText: 'Transport: TCP tunnel' }).waitFor();
|
||||
await page.getByLabel(/Send voice over TCP only/).uncheck();
|
||||
await page.locator('.stats', { hasText: /Transport: UDP/ }).waitFor();
|
||||
console.log('ok: voice transport UDP, with a TCP-only switch');
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log(`ok: ${decoded} frames decoded`);
|
||||
await setMode('Push to talk');
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { ocbEncrypt, ocbDecrypt, CryptState } from '../electron/ocb2.ts';
|
||||
|
||||
const key = Buffer.from([...Array(16).keys()]);
|
||||
|
||||
test('OCB2 matches the test vectors used by Mumble (draft-krovetz-ocb-00)', () => {
|
||||
const [, blankTag] = ocbEncrypt(key, Buffer.alloc(0), key);
|
||||
assert.equal(blankTag.toString('hex'), 'bf3108130773ad5ec70ec69e7875a7b0');
|
||||
const source = Buffer.from([...Array(40).keys()]);
|
||||
const [crypt, tag] = ocbEncrypt(key, source, key);
|
||||
assert.equal(tag.toString('hex'), '9db0cdf880f73e3e10d4eb3217766688');
|
||||
assert.equal(crypt.toString('hex'), 'f75d6bc8b4dc8d66b836a2b08b32a6369f1cd3c5228d79fd6c267f5f6aa7b231c7dfb9d59951ae9c');
|
||||
});
|
||||
|
||||
test('encrypt and decrypt round trip for every length up to 127 bytes', () => {
|
||||
const nonce = Buffer.from('ffeeddccbbaa99887766554433221100', 'hex');
|
||||
for (let len = 0; len < 128; len++) {
|
||||
const plain = randomBytes(len);
|
||||
const [ct, tag] = ocbEncrypt(key, plain, nonce);
|
||||
const [back, tag2, ok] = ocbDecrypt(key, ct, nonce);
|
||||
assert.ok(ok);
|
||||
assert.ok(back.equals(plain), `len ${len}`);
|
||||
assert.ok(tag.equals(tag2));
|
||||
}
|
||||
});
|
||||
|
||||
function pair() {
|
||||
const k = randomBytes(16), cn = randomBytes(16), sn = randomBytes(16);
|
||||
const client = new CryptState(), server = new CryptState();
|
||||
client.setKey(k, cn, sn);
|
||||
server.setKey(k, sn, cn); // the server encrypts with the client's decrypt nonce and vice versa
|
||||
return { client, server };
|
||||
}
|
||||
|
||||
test('crypt state: in order, lost, late, replay and tampering', () => {
|
||||
const { client, server } = pair();
|
||||
const packets = Array.from({ length: 300 }, (_, i) => client.encrypt(Buffer.from(`voice ${i}`)));
|
||||
// In order (crosses the 256 wrap of the IV byte)
|
||||
for (let i = 0; i < 280; i++) assert.equal(server.decrypt(packets[i])?.toString(), `voice ${i}`);
|
||||
// 280-282 missing, then 281 arrives late (lost goes back down, like Mumble)
|
||||
assert.equal(server.decrypt(packets[283])?.toString(), 'voice 283');
|
||||
assert.equal(server.lost, 3);
|
||||
assert.equal(server.decrypt(packets[281])?.toString(), 'voice 281');
|
||||
assert.equal(server.late, 1);
|
||||
assert.equal(server.lost, 2);
|
||||
// Replays are rejected
|
||||
assert.equal(server.decrypt(packets[281]), null);
|
||||
assert.equal(server.decrypt(packets[283]), null);
|
||||
// Tampered packets are rejected and do not disturb the state
|
||||
const bad = Buffer.from(packets[284]); bad[6] ^= 1;
|
||||
assert.equal(server.decrypt(bad), null);
|
||||
assert.equal(server.decrypt(packets[284])?.toString(), 'voice 284');
|
||||
assert.equal(server.good, 283);
|
||||
});
|
||||
|
||||
test('XEX* attack counter-measure: the pattern is changed on encrypt and refused on decrypt', () => {
|
||||
const nonce = randomBytes(16);
|
||||
const attack = Buffer.alloc(32);
|
||||
attack[15] = 16 * 8; // second to last block all zero except the last byte
|
||||
const [, , okStrict] = ocbEncrypt(key, attack, nonce, false);
|
||||
assert.equal(okStrict, false, 'pattern detected');
|
||||
const [ct] = ocbEncrypt(key, attack, nonce);
|
||||
const [plain, , ok] = ocbDecrypt(key, ct, nonce);
|
||||
assert.ok(ok);
|
||||
assert.notDeepEqual([...plain], [...attack], 'one bit was flipped to defuse the pattern');
|
||||
});
|
||||
@@ -8,10 +8,34 @@ import type { Transport } from '../src/core/transport.ts';
|
||||
import { openTls } from '../electron/tls-transport.ts';
|
||||
import { generateIdentity } from '../electron/identity.ts';
|
||||
import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
|
||||
import { udpChannel } from '../electron/udp-voice.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
const codec = createCodec();
|
||||
|
||||
function connectUdp(name: string): Promise<MumbleClient> {
|
||||
const [host, port] = target!.split(':');
|
||||
const id = generateIdentity(name);
|
||||
const client = new MumbleClient(codec);
|
||||
let udp: ReturnType<typeof udpChannel> | undefined;
|
||||
let conn: ReturnType<typeof openTls> | undefined;
|
||||
const transport: Transport = { onData: null, onClose: null, send: b => conn?.send(b), close: () => { udp?.close(); conn?.close(); } };
|
||||
return new Promise((resolve, reject) => {
|
||||
conn = openTls(host, Number(port || 64738), id.certPem, id.keyPem, {
|
||||
// UDP goes to the address the TLS connection actually reached
|
||||
onSecure: info => {
|
||||
udp = udpChannel(info.address, info.port);
|
||||
transport.udp = udp;
|
||||
client.connect(transport, { username: name, os: 'test' });
|
||||
},
|
||||
onData: c => transport.onData?.(c),
|
||||
onClose: r => { udp?.close(); transport.onClose?.(r); }
|
||||
});
|
||||
client.on('synced', () => resolve(client));
|
||||
client.on('close', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function connect(name: string, password = ''): Promise<MumbleClient> {
|
||||
const [host, port] = target!.split(':');
|
||||
const id = generateIdentity(name);
|
||||
@@ -232,3 +256,30 @@ test('channel management and ACLs as SuperUser', { skip: !target || !superPw },
|
||||
}
|
||||
});
|
||||
|
||||
test('voice over encrypted UDP (OCB2) between two clients', { skip: !target }, async () => {
|
||||
const alice = await connectUdp('udpA' + Date.now() % 1000);
|
||||
const bob = await connectUdp('udpB' + Date.now() % 1000);
|
||||
try {
|
||||
// Both sides confirm UDP with encrypted pings
|
||||
await until<void>(res => { if (alice.udpOk) res(); else alice.on('udp', ok => { if (ok) res(); }); }, 8000);
|
||||
await until<void>(res => { if (bob.udpOk) res(); else bob.on('udp', ok => { if (ok) res(); }); }, 8000);
|
||||
assert.ok(alice.udpRtt >= 0);
|
||||
|
||||
// Alice's voice goes out over UDP and reaches bob over UDP, not the TCP tunnel
|
||||
let tunneled = 0;
|
||||
const origSend = (alice as any).sendVoiceTunnel.bind(alice);
|
||||
(alice as any).sendVoiceTunnel = (p: Uint8Array) => { tunneled++; origSend(p); };
|
||||
const got = until<number>(res => {
|
||||
let n = 0;
|
||||
bob.on('voice', raw => { const p = decodeVoice(raw); if (p?.session === alice.session && ++n === 10) res(n); });
|
||||
});
|
||||
const opus = new Uint8Array([0x78, ...new Array(40).fill(9)]);
|
||||
for (let i = 0; i < 10; i++) alice.sendVoice(encodeVoice({ target: 0, frame: i * 2, opus, last: i === 9 }, alice.protobufVoice));
|
||||
assert.equal(await got, 10);
|
||||
assert.equal(tunneled, 0, 'nothing went through the TCP tunnel');
|
||||
} finally {
|
||||
alice.disconnect();
|
||||
bob.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user