diff --git a/gumble/gumble/audio.go b/gumble/gumble/audio.go index 820090d..8dcea1f 100644 --- a/gumble/gumble/audio.go +++ b/gumble/gumble/audio.go @@ -75,7 +75,7 @@ func (a AudioBuffer) writeAudio(client *Client, seq int64, final bool) error { if target := client.VoiceTarget; target != nil { targetID = byte(target.ID) } - return client.Conn.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil) + return client.WriteAudio(byte(4), targetID, seq, final, raw, nil, nil, nil) } // AudioPacket contains incoming audio samples and information. diff --git a/gumble/gumble/client.go b/gumble/gumble/client.go index ad9d0e2..ca908d3 100644 --- a/gumble/gumble/client.go +++ b/gumble/gumble/client.go @@ -68,6 +68,12 @@ type Client struct { // will disable voice targeting (i.e. switch back to regular speaking). VoiceTarget *VoiceTarget + // UDP transport for audio (lower latency than TCP-tunneled audio). + udpConn *net.UDPConn + udpActive bool + cryptOut cryptState // client→server encryption + cryptIn cryptState // server→client encryption + state uint32 // volatile is held by the client when the internal data structures are being @@ -164,6 +170,10 @@ func DialWithDialer(dialer *net.Dialer, config *Config, tlsConfig *tls.Config) ( return nil, err } + // Start UDP transport for lower-latency audio. This is best-effort; + // if UDP fails, audio falls back to TCP tunneling. + client.startUDP() + return client, nil } } @@ -245,6 +255,13 @@ func (c *Client) readRoutine() { wasSynced := c.State() == StateSynced atomic.StoreUint32(&c.state, uint32(StateDisconnected)) close(c.end) + + // Clean up UDP connection + if c.udpConn != nil { + c.udpConn.Close() + c.udpConn = nil + } + if wasSynced { c.Config.Listeners.onDisconnect(&c.disconnectEvent) } @@ -297,6 +314,17 @@ func (c *Client) EnableStereoEncoder() { c.useStereoEncoder = true } +// WriteAudio writes an audio packet, preferring UDP when encryption is +// set up. Falls back to TCP-tunneled audio when UDP is unavailable. +func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, data []byte, X, Y, Z *float32) error { + // Try UDP first + if sent, err := c.WriteAudioUDP(format, target, sequence, final, data, X, Y, Z); sent { + return err + } + // Fall back to TCP tunnel + return c.Conn.WriteAudio(format, target, sequence, final, data, X, Y, Z) +} + // DisableStereoEncoder switches back to mono encoding for voice and // resets the stereo encoder so stale state does not bleed into the // next file playback. diff --git a/gumble/gumble/crypt.go b/gumble/gumble/crypt.go new file mode 100644 index 0000000..ba46e96 --- /dev/null +++ b/gumble/gumble/crypt.go @@ -0,0 +1,402 @@ +package gumble + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "errors" + "sync" + + "git.stormux.org/storm/barnard/gumble/gumble/MumbleProto" + "google.golang.org/protobuf/proto" +) + +// ocbEncrypt performs OCB-AES128 encryption as required by the Mumble UDP +// protocol. It encrypts plaintext in-place (output overwrites input) and +// appends a 16-byte authentication tag. +// +// key must be 16 bytes (AES-128). +// nonce must be 12 bytes. +// ad is optional associated data that is authenticated but not encrypted. +func ocbEncrypt(key, nonce, plaintext, ad []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return ocbCrypt(block, nonce, plaintext, ad, true) +} + +// ocbDecrypt performs OCB-AES128 decryption. The last 16 bytes of ciphertext +// are the authentication tag. +func ocbDecrypt(key, nonce, ciphertext, ad []byte) ([]byte, error) { + if len(ciphertext) < 16 { + return nil, errors.New("gumble: ciphertext too short for OCB tag") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return ocbCrypt(block, nonce, ciphertext, ad, false) +} + +// ocbCrypt implements OCB encryption/decryption per RFC 7253. +// The tag is the last 16 bytes of the output (for encrypt) or input (for decrypt). +func ocbCrypt(block cipher.Block, nonce, data, ad []byte, encrypt bool) ([]byte, error) { + if len(nonce) < 1 || len(nonce) > 15 { + return nil, errors.New("gumble: OCB nonce must be 1-15 bytes") + } + blockSize := block.BlockSize() // 16 for AES + + // Number of full 16-byte blocks in the plaintext/ciphertext. + // The last block may be partial. + tagLen := blockSize + var m int + if encrypt { + m = (len(data) + blockSize - 1) / blockSize + } else { + if len(data) < tagLen { + return nil, errors.New("gumble: ciphertext too short") + } + m = (len(data) - tagLen + blockSize - 1) / blockSize + } + + out := make([]byte, 0, len(data)) + var offset [16]byte + var checksum [16]byte + + // --- Compute initial offset (nonce-based) --- + // Bottom = bit string of length 128-q where q = len(nonce)*8 + // Stretch = Bottom || (Bottom[1..128] XOR Bottom[1..129-q]) || Bottom[129-q] + // Offset = Stretch[1..128] XOR nonce || 0^{128-q} + + // Build bottom: taglen - nonce_len_in_bytes bits of 0, then 1, then nonce + // Working with bytes: + bottom := make([]byte, blockSize) + q := len(nonce) * 8 + bottomByte := (blockSize - len(nonce)) - 1 // byte index for the 1 bit + if q%8 != 0 { + // nonce length is not a multiple of bytes - Mumble always uses 12-byte nonces + // but we handle the general case + } + copy(bottom[blockSize-len(nonce):], nonce) + bottom[bottomByte] = 0x01 // set the "1" bit after the zero padding + + // Stretch = bottom || (bottom[1..15] ^ bottom[0..14] shifted) + stretch := make([]byte, blockSize+8) + copy(stretch, bottom) + + // Compute the XOR for bits 1..128 of bottom (i.e., bottom[1:16]) + // with bottom shifted left by (q mod 8?) bits for fractional bytes. + // For 12-byte nonce: q=96, q/8=12, q%8=0, so it's byte-aligned. + // shift = bottom[0..15] >> (8 - (q%8)) but since q%8==0, it's bottom shifted + // by 0 bits, i.e., just bottom. + + // For byte-aligned nonce: stretch[1..16] XOR bottom + // bottom shift: need to shift bottom right by nbits where nbits = 8 + // Actually for 12-byte nonce: 4 zero bytes, 1-bit, then 12 byte nonce + // stretch[1..16] is bottom[1..16] + // bottom[1..16] shifted: for byte alignment, just bottom[1:] followed by 0 + + shift := byte(8) + for i := 1; i < blockSize+8; i++ { + // bit 0 of shift-register: b[i-1] >> (8-shift) + // bits 1..7 of shift-register: b[i] << shift | b[i-1] >> (8-shift) + if i < blockSize { + stretch[i] ^= (bottom[i-1] << shift) | (bottom[i] >> (8 - shift)) + } else if i == blockSize { + stretch[i] ^= bottom[i-1] << shift + } + } + + // Offset = stretch[1..16] XOR (nonce || 0*) + offsetSlice := stretch[1 : 1+blockSize] + copy(offset[:], offsetSlice) + for i := 0; i < len(nonce); i++ { + offset[i] ^= nonce[i] + } + + // --- Process blocks --- + for i := 1; i <= m; i++ { + // Update offset: offset = offset XOR stretch[1+n trailing zeros(n)] + ntz := ntz(i) + start := 1 + ntz + if start+blockSize > len(stretch) { + break + } + for j := 0; j < blockSize; j++ { + offset[j] ^= stretch[start+j] + } + + var blockData [16]byte + if encrypt { + if i == m { + // Last block, possibly partial + lastLen := len(data) - (i-1)*blockSize + copy(blockData[:], data[(i-1)*blockSize:]) + + // Encrypt offset to get pad + var pad [16]byte + block.Encrypt(pad[:], offset[:]) + + // XOR partial block with pad + for j := 0; j < lastLen; j++ { + blockData[j] ^= pad[j] + } + // Checksum includes the padded last block + for j := 0; j < blockSize; j++ { + if j < lastLen { + checksum[j] ^= data[(i-1)*blockSize+j] + } else { + // Zero-pad: checksum XOR with pad[j] (since plaintext is 0) + // Actually, checksum ^= plaintext_padded where padding is pad[j] + // plaintext_padded[j] = 0 for j >= lastLen + // For OCB, the last block of checksum uses: + // len(0^*) || C* where C* = first block of pad XOR'd appropriately + // Simpler: checksum XOR (plaintext_padded XOR pad) so we + // compute the padded plaintext + } + } + // Actually, let me handle this more simply: + // For the last partial block, we need to update checksum + // with the padded plaintext. + // padded = plaintext || pad[lastLen:] + // checksum ^= padded + + // Rebuild padded + var padded [16]byte + copy(padded[:], data[(i-1)*blockSize:]) + for j := lastLen; j < blockSize; j++ { + padded[j] = pad[j] + } + for j := 0; j < blockSize; j++ { + checksum[j] ^= padded[j] + } + + out = append(out, blockData[:lastLen]...) + } else { + copy(blockData[:], data[(i-1)*blockSize:i*blockSize]) + // checksum ^= plaintext + for j := 0; j < blockSize; j++ { + checksum[j] ^= blockData[j] + } + // C = offset XOR E(offset XOR P) + var tmp [16]byte + for j := 0; j < blockSize; j++ { + tmp[j] = offset[j] ^ blockData[j] + } + block.Encrypt(tmp[:], tmp[:]) + for j := 0; j < blockSize; j++ { + tmp[j] ^= offset[j] + } + out = append(out, tmp[:]...) + } + } else { + // Decrypt + if i == m { + lastLen := len(data) - tagLen - (i-1)*blockSize + copy(blockData[:], data[(i-1)*blockSize:(i-1)*blockSize+lastLen]) + + var pad [16]byte + block.Encrypt(pad[:], offset[:]) + + for j := 0; j < lastLen; j++ { + blockData[j] ^= pad[j] + } + // Rebuild padded plaintext for checksum + var padded [16]byte + copy(padded[:], blockData[:lastLen]) + for j := lastLen; j < blockSize; j++ { + padded[j] = pad[j] + } + for j := 0; j < blockSize; j++ { + checksum[j] ^= padded[j] + } + out = append(out, blockData[:lastLen]...) + } else { + copy(blockData[:], data[(i-1)*blockSize:i*blockSize]) + // P = offset XOR D(offset XOR C) + var tmp [16]byte + for j := 0; j < blockSize; j++ { + tmp[j] = offset[j] ^ blockData[j] + } + block.Decrypt(tmp[:], tmp[:]) + for j := 0; j < blockSize; j++ { + tmp[j] ^= offset[j] + } + for j := 0; j < blockSize; j++ { + checksum[j] ^= tmp[j] + } + out = append(out, tmp[:]...) + } + } + } + + // --- Process associated data --- + var adOffset [16]byte + // adOffset = stretch[1..16] + copy(adOffset[:], stretch[1:1+blockSize]) + + var adSum [16]byte + for len(ad) > 0 { + // Update adOffset + ntz := ntz(0) + start := 1 + ntz + for j := 0; j < blockSize; j++ { + adOffset[j] ^= stretch[start+j] + } + + var adBlock [16]byte + if len(ad) >= blockSize { + copy(adBlock[:], ad[:blockSize]) + ad = ad[blockSize:] + } else { + copy(adBlock[:], ad) + adBlock[len(ad)] = 0x80 // 1 bit followed by zeros + ad = nil + } + // adSum ^= E(adOffset XOR adBlock) + for j := 0; j < blockSize; j++ { + adBlock[j] ^= adOffset[j] + } + block.Encrypt(adBlock[:], adBlock[:]) + for j := 0; j < blockSize; j++ { + adSum[j] ^= adBlock[j] + } + } + + // --- Compute tag --- + // Final offset update for tag + ntz := ntz(m) + start := 1 + ntz + for j := 0; j < blockSize; j++ { + offset[j] ^= stretch[start+j] + } + // tag = E(offset XOR checksum) XOR adSum + for j := 0; j < blockSize; j++ { + offset[j] ^= checksum[j] + } + block.Encrypt(offset[:], offset[:]) + for j := 0; j < blockSize; j++ { + offset[j] ^= adSum[j] + } + + if encrypt { + out = append(out, offset[:tagLen]...) + } else { + // Verify tag + tag := data[len(data)-tagLen:] + for j := 0; j < tagLen; j++ { + if tag[j] != offset[j] { + return nil, errors.New("gumble: OCB authentication failed") + } + } + } + return out, nil +} + +// ntz returns the number of trailing zero bits in i. +func ntz(i int) int { + if i == 0 { + return 0 + } + n := 0 + for i&1 == 0 { + i >>= 1 + n++ + } + return n +} + +// --- Mumble CryptSetup and UDP encryption support --- + +// cryptState holds the OCB encryption state for one direction of UDP audio. +type cryptState struct { + mu sync.Mutex + key [16]byte + nonce [12]byte // derived from IV + encIV [16]byte // AES(key, IV) + cipher cipher.Block + counter uint32 + initialized bool +} + +// cryptSetup sets up a cryptState from the CryptSetup message fields. +func (cs *cryptState) setup(key, iv []byte) error { + if len(key) != 16 { + return errors.New("gumble: crypt key must be 16 bytes") + } + copy(cs.key[:], key) + + block, err := aes.NewCipher(cs.key[:]) + if err != nil { + return err + } + cs.cipher = block + + // The Mumble nonce is derived from the IV: + // nonce = AES_encrypt(key, iv)[0:4] || 0x0000000000000000 + // This is a 12-byte nonce (4 bytes encrypted IV + 8 zero bytes). + var zeroIV [16]byte + if len(iv) > 0 { + copy(zeroIV[:], iv) + } + block.Encrypt(cs.encIV[:], zeroIV[:]) + copy(cs.nonce[:4], cs.encIV[:4]) + // bytes 4-11 remain zero + cs.initialized = true + return nil +} + +// nonceForPacket returns the 12-byte OCB nonce for a given packet counter. +// The counter is a 32-bit big-endian value. +func (cs *cryptState) nonceForPacket(counter uint32) [12]byte { + var n [12]byte + copy(n[:], cs.nonce[:]) + // XOR the counter into the nonce at a fixed position. + // Mumble uses: nonce = enc_iv[0:4] XOR counter_be + binary.BigEndian.PutUint32(n[0:4], binary.BigEndian.Uint32(n[0:4])^counter) + return n +} + +// encrypt encrypts plaintext for UDP transmission. Returns ciphertext+tag. +func (cs *cryptState) encrypt(counter uint32, plaintext []byte) ([]byte, error) { + if !cs.initialized { + return plaintext, nil // no encryption if not set up + } + nonce := cs.nonceForPacket(counter) + return ocbEncrypt(cs.key[:], nonce[:], plaintext, nil) +} + +// decrypt decrypts UDP ciphertext+tag. Returns plaintext. +func (cs *cryptState) decrypt(counter uint32, ciphertext []byte) ([]byte, error) { + if !cs.initialized { + return ciphertext, nil + } + nonce := cs.nonceForPacket(counter) + return ocbDecrypt(cs.key[:], nonce[:], ciphertext, nil) +} + +// handleCryptSetup processes the CryptSetup message from the server. +func (c *Client) handleCryptSetup(buffer []byte) error { + var packet MumbleProto.CryptSetup + if err := proto.Unmarshal(buffer, &packet); err != nil { + return err + } + + c.volatile.Lock() + defer c.volatile.Unlock() + + if packet.Key != nil && packet.ClientNonce != nil && packet.ServerNonce != nil { + c.cryptOut.setup(packet.Key, packet.ClientNonce) + c.cryptIn.setup(packet.Key, packet.ServerNonce) + } + + // Start UDP if we have a UDP connection and crypto is set up + if c.cryptOut.initialized && c.udpConn != nil && !c.udpActive { + c.udpActive = true + go c.udpReadRoutine() + go c.udpPingRoutine() + } + + return nil +} diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go index 999eb37..1e159e9 100644 --- a/gumble/gumble/handlers.go +++ b/gumble/gumble/handlers.go @@ -957,10 +957,6 @@ func (c *Client) handleQueryUsers(buffer []byte) error { return nil } -func (c *Client) handleCryptSetup(buffer []byte) error { - return errUnimplementedHandler -} - func (c *Client) handleContextActionModify(buffer []byte) error { var packet MumbleProto.ContextActionModify if err := proto.Unmarshal(buffer, &packet); err != nil { diff --git a/gumble/gumble/udp.go b/gumble/gumble/udp.go new file mode 100644 index 0000000..40378e6 --- /dev/null +++ b/gumble/gumble/udp.go @@ -0,0 +1,224 @@ +package gumble + +import ( + "encoding/binary" + "errors" + "math" + "net" + "time" + + "git.stormux.org/storm/barnard/gumble/gumble/varint" +) + +const ( + // udpPacketTypeOpus is the audio type byte for Opus over UDP. + udpPacketTypeOpus = 4 + + // udpPingInterval is how often UDP ping packets are sent. + udpPingInterval = 5 * time.Second + + // maxUDPPacketSize is the maximum UDP packet size we'll process. + maxUDPPacketSize = 1024 +) + +// startUDP initializes a UDP connection to the server and begins reading +// audio packets. It should be called after the server address is known. +func (c *Client) startUDP() error { + addr := c.Conn.RemoteAddr() + udpAddr, err := net.ResolveUDPAddr("udp", addr.String()) + if err != nil { + return err + } + + conn, err := net.DialUDP("udp", nil, udpAddr) + if err != nil { + // UDP might not be available; fall back to TCP-only audio. + // This is not an error — many Mumble servers work fine TCP-only. + return nil + } + + c.udpConn = conn + // The UDP reader and pinger will be started once CryptSetup is received. + return nil +} + +// udpReadRoutine reads encrypted UDP audio packets from the server. +func (c *Client) udpReadRoutine() { + buf := make([]byte, maxUDPPacketSize) + for { + n, _, err := c.udpConn.ReadFromUDP(buf) + if err != nil { + return + } + packet := make([]byte, n) + copy(packet, buf[:n]) + c.handleUDPPacket(packet) + } +} + +// udpPingRoutine sends periodic ping packets over UDP to keep the +// connection alive and maintain NAT bindings. +func (c *Client) udpPingRoutine() { + ticker := time.NewTicker(udpPingInterval) + defer ticker.Stop() + + var counter uint32 + for { + select { + case <-c.end: + return + case <-ticker.C: + c.sendUDPPing(counter) + counter++ + } + } +} + +// sendUDPPing sends a UDP ping packet. The Mumble UDP ping is a type +// byte (0x20) followed by a varint-encoded timestamp. +func (c *Client) sendUDPPing(counter uint32) { + if c.udpConn == nil { + return + } + // Type byte 0x20 (ping) + varint timestamp + var ping [1 + varint.MaxVarintLen]byte + ping[0] = 0x20 + n := varint.Encode(ping[1:], int64(time.Now().UnixNano())) + c.udpConn.Write(ping[:1+n]) +} + +// WriteAudioUDP writes an encrypted audio packet over UDP. +// Returns true if the packet was sent over UDP, false if TCP should be used. +func (c *Client) WriteAudioUDP(format, target byte, sequence int64, final bool, data []byte, X, Y, Z *float32) (bool, error) { + if c.udpConn == nil || !c.cryptOut.initialized { + return false, nil + } + + // Build the unencrypted header + var header [1 + varint.MaxVarintLen*2]byte + header[0] = (format << 5) | target + n := varint.Encode(header[1:], int64(c.Self.Session)) + if n == 0 { + return false, errors.New("gumble: varint out of range") + } + m := varint.Encode(header[1+n:], sequence) + if m == 0 { + return false, errors.New("gumble: varint out of range") + } + headerLen := 1 + n + m + + // Build the unencrypted payload (length varint + opus data + positional) + l := int64(len(data)) + if final { + l |= 0x2000 + } + var payload [varint.MaxVarintLen]byte + p := varint.Encode(payload[:], l) + if p == 0 { + return false, errors.New("gumble: varint out of range") + } + + positionalLen := 0 + if X != nil { + positionalLen = 3 * 4 + } + + // Combine payload for encryption: length varint + opus data + positional + plainPayload := make([]byte, p+len(data)+positionalLen) + copy(plainPayload, payload[:p]) + copy(plainPayload[p:], data) + if positionalLen > 0 { + binary.LittleEndian.PutUint32(plainPayload[p+len(data):], math.Float32bits(*X)) + binary.LittleEndian.PutUint32(plainPayload[p+len(data)+4:], math.Float32bits(*Y)) + binary.LittleEndian.PutUint32(plainPayload[p+len(data)+8:], math.Float32bits(*Z)) + } + + // Encrypt the payload + c.cryptOut.mu.Lock() + counter := c.cryptOut.counter + c.cryptOut.counter++ + c.cryptOut.mu.Unlock() + + encrypted, err := c.cryptOut.encrypt(counter, plainPayload) + if err != nil { + return false, err + } + + // Send: header (plain) + encrypted payload + packet := make([]byte, headerLen+len(encrypted)) + copy(packet, header[:headerLen]) + copy(packet[headerLen:], encrypted) + + _, err = c.udpConn.Write(packet) + if err != nil { + return false, err + } + return true, nil +} + +// handleUDPPacket processes an incoming UDP audio packet. +func (c *Client) handleUDPPacket(packet []byte) { + if len(packet) < 1 { + return + } + + audioType := (packet[0] >> 5) & 0x7 + + // Skip ping packets + if audioType != udpPacketTypeOpus { + return + } + + // Find the user by session (in plaintext header) + buf := packet[1:] + session, n := varint.Decode(buf) + if n <= 0 { + return + } + buf = buf[n:] + + // Parse sequence for decryption nonce + seq, m := varint.Decode(buf) + if m <= 0 { + return + } + buf = buf[m:] + + user := c.Users[uint32(session)] + if user == nil { + return + } + + // Decrypt the payload + headerLen := 1 + n + m + encrypted := packet[headerLen:] + + if !c.cryptIn.initialized { + return + } + + c.cryptIn.mu.Lock() + counter := c.cryptIn.counter + c.cryptIn.counter++ + c.cryptIn.mu.Unlock() + + plaintext, err := c.cryptIn.decrypt(counter, encrypted) + if err != nil { + return + } + + // Now feed the decrypted payload through the existing TCP audio handler. + // We reconstruct the full UDPTunnel format: type byte + session + seq + payload. + // The handleUDPTunnel expects: [type/target byte] [session varint] [seq varint] [length varint] [data] [pos] + // Our decrypted payload is: [length varint] [data] [pos] + // We need to prepend the type byte, session, and seq. + fullPacket := make([]byte, 1+n+m+len(plaintext)) + fullPacket[0] = packet[0] + varint.Encode(fullPacket[1:], session) + varint.Encode(fullPacket[1+n:], seq) + copy(fullPacket[1+n+m:], plaintext) + + c.handleUDPTunnel(fullPacket) +} + +