From 95ef0be1f4f2b37ea858de228afe9ce928071831 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (chatgpt)" Date: Sun, 9 Aug 2026 13:22:22 -0400 Subject: [PATCH] Complete UDP audio protocol handling --- gumble/gumble/client.go | 3 +- gumble/gumble/conn.go | 5 ++- gumble/gumble/handlers.go | 7 +++ gumble/gumble/udp15.go | 80 ++++++++++++++++++++++++++++++++--- gumble/gumble/udp15_test.go | 2 +- gumble/gumbleopenal/stream.go | 20 +++++++-- 6 files changed, 106 insertions(+), 11 deletions(-) diff --git a/gumble/gumble/client.go b/gumble/gumble/client.go index fb44557..d4444c8 100644 --- a/gumble/gumble/client.go +++ b/gumble/gumble/client.go @@ -78,6 +78,7 @@ type Client struct { udpCryptoOut *cryptState15 udpCryptoIn *cryptState15 udpFrameNumber uint64 + udpProtobuf bool udpFallbackLogged atomic.Bool udpFirstRecv atomic.Bool cryptOut cryptState // client→server encryption @@ -350,7 +351,7 @@ func (c *Client) EnableStereoEncoder() { func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, data []byte, X, Y, Z *float32) error { // Try Mumble 1.5 native UDP first (unless disabled) if !c.Config.DisableUDP { - if sent, err := c.WriteAudioUDP15(uint32(target), data, final); sent { + if sent, err := c.WriteAudioUDP15(format, uint32(target), sequence, data, final, X, Y, Z); sent { if err != nil { log.Error("UDP15 send error: %v", err) } diff --git a/gumble/gumble/conn.go b/gumble/gumble/conn.go index 61572f4..ed58cfa 100644 --- a/gumble/gumble/conn.go +++ b/gumble/gumble/conn.go @@ -80,7 +80,10 @@ func (c *Conn) WriteAudio(format, target byte, sequence int64, final bool, data header := buff[:1+n+m] var positionalLength int - if X != nil { + if X != nil || Y != nil || Z != nil { + if X == nil || Y == nil || Z == nil { + return errors.New("gumble: positional audio requires X, Y, and Z") + } positionalLength = 3 * 4 } diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go index 5ac2c89..b1d9f0a 100644 --- a/gumble/gumble/handlers.go +++ b/gumble/gumble/handlers.go @@ -74,6 +74,13 @@ func (c *Client) handleVersion(buffer []byte) error { if err := proto.Unmarshal(buffer, &packet); err != nil { return err } + // Mumble 1.5 introduced protobuf UDP audio. Older servers retain the + // legacy UDP payload inside the same encrypted envelope. + if packet.VersionV1 != nil { + c.udpMu.Lock() + c.udpProtobuf = *packet.VersionV1 >= ClientVersion + c.udpMu.Unlock() + } return nil } diff --git a/gumble/gumble/udp15.go b/gumble/gumble/udp15.go index 2a0d24c..dff68eb 100644 --- a/gumble/gumble/udp15.go +++ b/gumble/gumble/udp15.go @@ -63,7 +63,7 @@ func pbDecodeVarint(buf []byte) (uint64, int) { // encodeUDPAudio builds a MumbleUDP.Audio protobuf message. // If session == 0, sender_session is omitted (used for outbound). // Uses standard protobuf varint encoding, not Mumble's custom varint. -func encodeUDPAudio(target uint32, frameNumber uint64, opusData []byte, terminator bool) []byte { +func encodeUDPAudio(target uint32, frameNumber uint64, opusData []byte, terminator bool, X, Y, Z *float32) []byte { var buf bytes.Buffer var tmp [10]byte // max protobuf varint size @@ -84,6 +84,17 @@ func encodeUDPAudio(target uint32, frameNumber uint64, opusData []byte, terminat buf.Write(tmp[:n]) buf.Write(opusData) } + if X != nil && Y != nil && Z != nil { + n := pbEncodeVarint(tmp[:], uint64((6<<3)|2)) + buf.Write(tmp[:n]) + n = pbEncodeVarint(tmp[:], 12) + buf.Write(tmp[:n]) + for _, value := range []float32{*X, *Y, *Z} { + var fixed [4]byte + binary.LittleEndian.PutUint32(fixed[:], math.Float32bits(value)) + buf.Write(fixed[:]) + } + } if terminator { n := pbEncodeVarint(tmp[:], uint64((16<<3)|0)) buf.Write(tmp[:n]) @@ -367,11 +378,28 @@ func ocb15Encrypt(key, nonce, plaintext []byte) (ciphertext, tag []byte) { for remaining > 16 { shift2inplace(delta) + // Mitigate the XEX* forgery attack (eprint 2019/311), matching + // Mumble's CryptStateOCB2 implementation. + flipBit := remaining <= 32 + if flipBit { + for _, b := range plaintext[pos : pos+15] { + if b != 0 { + flipBit = false + break + } + } + } xor16(checksum, checksum, plaintext[pos:pos+16]) + if flipBit { + checksum[0] ^= 1 + } // C = delta XOR AES_K(delta XOR plaintext) tmp := make([]byte, 16) xorBytes(tmp, plaintext[pos:pos+16], delta) + if flipBit { + tmp[0] ^= 1 + } block.Encrypt(tmp, tmp) xorBytes(tmp, tmp, delta) @@ -460,6 +488,18 @@ func ocb15Decrypt(key, nonce, ciphertext []byte) (plaintext, tag []byte, err err xor16(csTemp, csTemp, pad) xor16(checksum, checksum, csTemp) + // Reject the XEX* forgery pattern before authenticating the tag. + matchesDelta := true + for i := 0; i < 15; i++ { + if csTemp[i] != delta[i] { + matchesDelta = false + break + } + } + if matchesDelta { + return nil, nil, errors.New("gumble: OCB XEX* forgery detected") + } + // Tag = AES_K(3*delta XOR checksum) shift3inplace(delta) xor16(delta, delta, checksum) @@ -535,10 +575,32 @@ func (c *Client) setUDP15Crypto(key, clientNonce, serverNonce []byte) error { return nil } +// encodeLegacyUDPAudio builds the pre-1.5 UDPVoice packet payload. +func encodeLegacyUDPAudio(format, target byte, sequence int64, data []byte, final bool, X, Y, Z *float32) []byte { + var header [1 + varint.MaxVarintLen*2]byte + header[0] = format<<5 | target + n := varint.Encode(header[1:], sequence) + length := int64(len(data)) + if final { + length |= 0x2000 + } + m := varint.Encode(header[1+n:], length) + payload := append([]byte(nil), header[:1+n+m]...) + payload = append(payload, data...) + if X != nil && Y != nil && Z != nil { + for _, value := range []float32{*X, *Y, *Z} { + var fixed [4]byte + binary.LittleEndian.PutUint32(fixed[:], math.Float32bits(value)) + payload = append(payload, fixed[:]...) + } + } + return payload +} + // --------------------------------------------------------------------------- -// WriteAudioUDP15 writes an encrypted audio packet using Mumble 1.5 native UDP. +// WriteAudioUDP15 writes encrypted UDP audio in the negotiated payload format. // Returns true if sent, false if TCP should be used. -func (c *Client) WriteAudioUDP15(target uint32, data []byte, final bool) (bool, error) { +func (c *Client) WriteAudioUDP15(format byte, target uint32, sequence int64, data []byte, final bool, X, Y, Z *float32) (bool, error) { // Encryption and socket writes must remain ordered: otherwise a later // packet can reach the server before the packet with the preceding IV. c.udpWriteMu.Lock() @@ -546,13 +608,21 @@ func (c *Client) WriteAudioUDP15(target uint32, data []byte, final bool) (bool, c.udpMu.Lock() cs, udpConn := c.udpCryptoOut, c.udpConn frameNum := c.udpFrameNumber - c.udpFrameNumber++ + protobuf := c.udpProtobuf + if protobuf { + c.udpFrameNumber++ + } c.udpMu.Unlock() if cs == nil || udpConn == nil { return false, nil } - payload := append([]byte{0x00}, encodeUDPAudio(target, frameNum, data, final)...) + var payload []byte + if protobuf { + payload = append([]byte{0x00}, encodeUDPAudio(target, frameNum, data, final, X, Y, Z)...) + } else { + payload = encodeLegacyUDPAudio(format, byte(target), sequence, data, final, X, Y, Z) + } encrypted, err := cs.encrypt15(payload) if err != nil { log.Error("UDP15 encrypt failed: %v", err) diff --git a/gumble/gumble/udp15_test.go b/gumble/gumble/udp15_test.go index e3ea5ce..e51adde 100644 --- a/gumble/gumble/udp15_test.go +++ b/gumble/gumble/udp15_test.go @@ -142,7 +142,7 @@ func TestUDPAudioProtobuf(t *testing.T) { } for _, tt := range tests { - encoded := encodeUDPAudio(uint32(tt.session), uint64(tt.frameNumber), tt.opusData, tt.terminator) + encoded := encodeUDPAudio(uint32(tt.session), uint64(tt.frameNumber), tt.opusData, tt.terminator, nil, nil, nil) session, frameNum, opusData, terminator, _, _, _ := decodeUDPAudio(encoded) if session != 0 { diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index 7112289..a9cb4b9 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -512,6 +512,20 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { }(e) } +func applyVolumeAdjustment(sample int16, adjustment float32) int16 { + if adjustment == 0 || adjustment == 1 { + return sample + } + adjusted := float32(sample) * adjustment + if adjusted > 32767 { + return 32767 + } + if adjusted < -32768 { + return -32768 + } + return int16(adjusted) +} + // processAudioPacket decodes and queues a single audio packet for playback. // Returns the updated emptyBufs slice after consuming a buffer. // The caller must call reclaim() before invoking this to ensure buffers @@ -544,7 +558,7 @@ func (s *Stream) processAudioPacket(packet *gumble.AudioPacket, user *gumble.Use // Process stereo samples as pairs for i := 0; i < samples*2; i += 2 { // Process left channel with saturation protection - sample := packet.AudioBuffer[i] + sample := applyVolumeAdjustment(packet.AudioBuffer[i], packet.VolumeAdjustment) if boost > 1 { boosted := int32(sample) * int32(boost) if boosted > 32767 { @@ -563,7 +577,7 @@ func (s *Stream) processAudioPacket(packet *gumble.AudioPacket, user *gumble.Use rawPtr += 2 // Process right channel with saturation protection - sample = packet.AudioBuffer[i+1] + sample = applyVolumeAdjustment(packet.AudioBuffer[i+1], packet.VolumeAdjustment) if boost > 1 { boosted := int32(sample) * int32(boost) if boosted > 32767 { @@ -584,7 +598,7 @@ func (s *Stream) processAudioPacket(packet *gumble.AudioPacket, user *gumble.Use } else { // Process mono samples with saturation protection for i := 0; i < samples; i++ { - sample := packet.AudioBuffer[i] + sample := applyVolumeAdjustment(packet.AudioBuffer[i], packet.VolumeAdjustment) if boost > 1 { boosted := int32(sample) * int32(boost) if boosted > 32767 {