Complete UDP audio protocol handling

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-09 13:22:22 -04:00
committed by Brandon McGinty
parent 4ad22db5aa
commit 95ef0be1f4
6 changed files with 106 additions and 11 deletions
+2 -1
View File
@@ -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)
}
+4 -1
View File
@@ -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
}
+7
View File
@@ -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
}
+75 -5
View File
@@ -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)
+1 -1
View File
@@ -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 {
+17 -3
View File
@@ -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 {