Initial commit: mumh5, a modern Mumble client

Electron desktop app with a Svelte 5 interface for any Mumble server.

- Mumble protocol core: TLS, handshake, channels, users, text, plugin data,
  client-side pacing of Murmur's rate limits
- Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk,
  always-on, devices, per-user volume and local mute
- Several servers at once, voice on one; server rail with icons and ordering
- Chat: channels, direct messages, side chat, file sharing through f0ckm,
  inline images without it, click-to-play YouTube
- Profiles with rich descriptions, registration, rename, nicknames,
  connection information and moderation menus
- Identity wizard, multiple identities, PKCS#12 import/export, desktop
  Mumble certificate import, certificate pinning and viewer
- Tray icon with voice state
- Unit, server and end-to-end tests against a real Murmur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:28:43 +02:00
co-authored by Claude Opus 5.5
commit 6d29ee1485
85 changed files with 22997 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import forge from 'node-forge';
import { createHash } from 'node:crypto';
export interface Identity {
certPem: string;
keyPem: string;
fingerprint: string;
}
// Self-signed client certificate, the same kind the desktop Mumble client generates.
// Mumble servers identify registered users by the hash of this certificate.
export function generateIdentity(name: string, email = ''): Identity {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01' + forge.util.bytesToHex(forge.random.getBytesSync(8));
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 20);
const attrs: { name: string; value: string }[] = [{ name: 'commonName', value: name || 'mumh5 user' }];
if (email) attrs.push({ name: 'emailAddress', value: email });
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', clientAuth: true }
]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(keys.privateKey), fingerprint: certFingerprint(certPem) };
}
// SHA-1 of the DER certificate, the hash Mumble shows and stores for users.
export function certFingerprint(certPem: string): string {
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem))).getBytes();
return createHash('sha1').update(Buffer.from(der, 'binary')).digest('hex');
}
// Read a PKCS#12 file (.p12/.pfx, as exported by the desktop Mumble client).
export function identityFromP12(bytes: Uint8Array, password: string): Identity {
let p12: forge.pkcs12.Pkcs12Pfx;
try {
const der = forge.util.binary.raw.encode(bytes);
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), password);
} catch {
throw new Error('Could not open the file. Wrong password, or not a PKCS#12 certificate.');
}
const bags = (type: string) => p12.getBags({ bagType: type })[type] ?? [];
const cert = bags(forge.pki.oids.certBag).find(b => b.cert)?.cert;
const key = [...bags(forge.pki.oids.pkcs8ShroudedKeyBag), ...bags(forge.pki.oids.keyBag)].find(b => b.key)?.key;
if (!cert || !key) throw new Error('The file does not contain both a certificate and its private key.');
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(key), fingerprint: certFingerprint(certPem) };
}
// PKCS#12 with 3DES, which the desktop Mumble client and other tools can import.
export function identityToP12(id: Identity, password: string, friendlyName: string): Uint8Array {
const asn1 = forge.pkcs12.toPkcs12Asn1(
forge.pki.privateKeyFromPem(id.keyPem),
[forge.pki.certificateFromPem(id.certPem)],
password,
{ algorithm: '3des', friendlyName }
);
return new Uint8Array(Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary'));
}
// Common name of a certificate, used as the default identity name
export function certCommonName(certPem: string): string {
const cn = forge.pki.certificateFromPem(certPem).subject.getField('CN');
return cn ? String(cn.value) : '';
}