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();
+199
View File
@@ -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
View File
@@ -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);
+4 -1
View File
@@ -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
+145
View File
@@ -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;
}