Add UDP encryption & protocol versioning reference document
Based on analysis of the official Mumble client source at ./mumble/. Documents the CryptStateOCB2 encryption layer, legacy vs protobuf UDP audio formats, ping formats, version negotiation, and the IV tracking algorithm. Key finding: barnard advertises version 1.3.0 which is below the 1.5.0 protobuf-introduction threshold, so the server sends us legacy-format audio (type 0x80) inside the 1.5 crypto envelope.
This commit is contained in:
committed by
Brandon McGinty
parent
e93437a212
commit
cd32abaaef
@@ -0,0 +1,342 @@
|
|||||||
|
# Mumble UDP Encryption & Protocol Versioning
|
||||||
|
|
||||||
|
Based on the official Mumble client source at `./mumble/` (v1.5.x / v1.6.x).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Protocol Versioning
|
||||||
|
|
||||||
|
The protocol version determines the UDP packet format.
|
||||||
|
|
||||||
|
| Version | UDP Format | Audio Type Byte | Ping Type Byte |
|
||||||
|
|---------|-----------|-----------------|-----------------|
|
||||||
|
| < 1.5.0 | Legacy (varint-based) | `(codec << 5) \| target` (e.g. `0x80` for Opus) | `(1 << 5) = 0x20` |
|
||||||
|
| >= 1.5.0 | Protobuf (MumbleUDP) | `0x00` | `0x01` |
|
||||||
|
|
||||||
|
The version boundary is defined in `MumbleProtocol.h`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr Version::full_t PROTOBUF_INTRODUCTION_VERSION = Version::fromComponents(1, 5, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
The client advertises its version in the initial `Version` TCP message (field 1 = `VersionV1`).
|
||||||
|
The server sends its version in `CodecVersion` (TCP message type 21).
|
||||||
|
|
||||||
|
**Critical**: The UDP decoder checks the negotiated protocol version to decide which format
|
||||||
|
to use. However, it also auto-upgrades: if a protobuf-format ping (`0x01`) arrives while in
|
||||||
|
legacy mode, the version is bumped to >= 1.5.0.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From MumbleProtocol.cpp UDPDecoder::decode():
|
||||||
|
if (header == static_cast<byte>(UDPMessageType::Ping)) {
|
||||||
|
// Upgrade to at least PROTOBUF_INTRODUCTION_VERSION
|
||||||
|
this->setProtocolVersion(std::max(this->getProtocolVersion(), PROTOBUF_INTRODUCTION_VERSION));
|
||||||
|
return decodePing_protobuf(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Encryption Layer: CryptStateOCB2
|
||||||
|
|
||||||
|
All UDP packets (both legacy and protobuf) share the same encryption layer.
|
||||||
|
|
||||||
|
### Wire Format
|
||||||
|
|
||||||
|
```
|
||||||
|
[iv_byte(1)] [tag(3)] [ciphertext(variable)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Total overhead: **4 bytes** (ssize = plaintext_size + 4).
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
- **AES-128-OCB** (not OCB2, despite the class name)
|
||||||
|
- Key: 16 bytes (from `CryptSetup` TCP message)
|
||||||
|
- Encrypt IV: 16 bytes (`client_nonce` from `CryptSetup`)
|
||||||
|
- Decrypt IV: 16 bytes (`server_nonce` from `CryptSetup`)
|
||||||
|
|
||||||
|
### IV Increment (Little-Endian)
|
||||||
|
|
||||||
|
The IV is a 16-byte integer incremented **little-endian** (byte 0 is the LSB):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From CryptStateOCB2::encrypt():
|
||||||
|
for (int i = 0; i < AES_BLOCK_SIZE; i++)
|
||||||
|
if (++encrypt_iv[i])
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Starts at byte 0, increments, breaks on non-overflow. This matches wumble's
|
||||||
|
`increment_encrypt_iv`. **Important**: byte 0 changes every packet.
|
||||||
|
|
||||||
|
### Encrypt
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From CryptStateOCB2::encrypt():
|
||||||
|
// 1. Increment IV
|
||||||
|
for (int i = 0; i < AES_BLOCK_SIZE; i++)
|
||||||
|
if (++encrypt_iv[i]) break;
|
||||||
|
|
||||||
|
// 2. OCB encrypt the plaintext
|
||||||
|
ocb_encrypt(source, dst+4, plain_length, encrypt_iv, tag);
|
||||||
|
|
||||||
|
// 3. Wire format: [iv_byte][tag[0..2]][ciphertext]
|
||||||
|
dst[0] = encrypt_iv[0];
|
||||||
|
dst[1] = tag[0];
|
||||||
|
dst[2] = tag[1];
|
||||||
|
dst[3] = tag[2];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decrypt (with IV Tracking)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From CryptStateOCB2::decrypt():
|
||||||
|
// 1. Read IV byte from wire
|
||||||
|
ivbyte = source[0];
|
||||||
|
|
||||||
|
// 2. Check if in-order: decrypt_iv[0] + 1 == ivbyte
|
||||||
|
if (((decrypt_iv[0] + 1) & 0xFF) == ivbyte) {
|
||||||
|
if (ivbyte > decrypt_iv[0]) {
|
||||||
|
decrypt_iv[0] = ivbyte; // Normal forward
|
||||||
|
} else if (ivbyte < decrypt_iv[0]) {
|
||||||
|
decrypt_iv[0] = ivbyte;
|
||||||
|
for (int i = 1; i < AES_BLOCK_SIZE; i++)
|
||||||
|
if (++decrypt_iv[i]) break; // Wrapped: carry to higher bytes
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Late/reorder handling with diff-based window (±30)
|
||||||
|
// ... (see MumbleProtocol.cpp for full logic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. OCB decrypt
|
||||||
|
ocb_decrypt(source+4, dst, crypted_length-4, decrypt_iv, tag);
|
||||||
|
|
||||||
|
// 4. Verify tag (first 3 bytes)
|
||||||
|
if (memcmp(tag, source+1, 3) != 0) { /* auth failure */ }
|
||||||
|
|
||||||
|
// 5. Update replay history
|
||||||
|
decrypt_history[decrypt_iv[0]] = decrypt_iv[1];
|
||||||
|
```
|
||||||
|
|
||||||
|
### OCB Implementation Details
|
||||||
|
|
||||||
|
The OCB implementation (`CryptStateOCB2::ocb_encrypt` / `ocb_decrypt`):
|
||||||
|
|
||||||
|
- Uses OpenSSL's `EVP_aes_128_ecb` as the block cipher primitive
|
||||||
|
- Nonce is the full 16-byte IV (no bottom-bit clearing — different from legacy OCB2)
|
||||||
|
- No associated data
|
||||||
|
- Final partial block: pad block has `byte[15] = remaining * 8` (bit-length encoding)
|
||||||
|
- GF(2^128) doubling via `S2()` (multiply by 2) and `S3()` (multiply by 3)
|
||||||
|
- Reduction constant: `0x87`
|
||||||
|
- Includes XEX* attack mitigation: if the second-to-last plaintext block is all zeros
|
||||||
|
except potentially the last byte, a bit is flipped to prevent the attack described in
|
||||||
|
https://eprint.iacr.org/2019/311
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Legacy UDP Audio Format (version < 1.5.0)
|
||||||
|
|
||||||
|
Used when negotiated protocol version < `PROTOBUF_INTRODUCTION_VERSION`.
|
||||||
|
|
||||||
|
### Encode (Server → Client)
|
||||||
|
|
||||||
|
```
|
||||||
|
[byte 0: header] [session varint] [seq varint] [Opus: size varint] [opus data] [optional: 3×float32 position]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Header byte**: `(codec_type << 5) | target`
|
||||||
|
- `codec_type`: 0=CELT_Alpha, 1=Ping, 2=Speex, 3=CELT_Beta, 4=Opus
|
||||||
|
- `target`: 5-bit target/context (0=normal, 1=shout, 2=whisper, 3=listen)
|
||||||
|
- **Session varint**: sender's session ID (present only in server→client direction)
|
||||||
|
- **Seq varint**: frame number (monotonic, 10ms units)
|
||||||
|
- **Opus size varint**: bit 13 (0x2000) is the terminator flag; bits 0-12 are the opus data length
|
||||||
|
- **Position**: 3× float32 (x, y, z), only if space remains after opus data
|
||||||
|
|
||||||
|
### Decode (Client)
|
||||||
|
|
||||||
|
From `UDPDecoder::decodeAudio_legacy()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
m_audioData.targetOrContext = data[0] & 0x1f;
|
||||||
|
m_audioData.usedCodec = codec; // Opus = 4
|
||||||
|
|
||||||
|
// Read session (server→client only)
|
||||||
|
if (this->getRole() == Role::Client) {
|
||||||
|
stream >> m_audioData.senderSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read frame number
|
||||||
|
stream >> m_audioData.frameNumber;
|
||||||
|
|
||||||
|
// Opus: size varint with terminator bit
|
||||||
|
stream >> helper;
|
||||||
|
payloadSize = helper & 0x1FFF; // 13 bits for size
|
||||||
|
m_audioData.isLastFrame = helper & 0x2000; // bit 13 = terminator
|
||||||
|
|
||||||
|
// Read opus data
|
||||||
|
m_audioData.payload = span(payloadBegin, payloadSize);
|
||||||
|
|
||||||
|
// Check for positional data
|
||||||
|
if (stream.left() == 3 * sizeof(float)) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Protobuf UDP Audio Format (version >= 1.5.0)
|
||||||
|
|
||||||
|
Uses `MumbleUDP::Audio` protobuf message. Defined in `MumbleUDP.proto`.
|
||||||
|
|
||||||
|
### Message Fields
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
|-------|--------|------|-------------|
|
||||||
|
| sender_session | 3 | uint32 | Session ID of the speaker (server→client only) |
|
||||||
|
| frame_number | 4 | uint64 | Frame number in 10ms units |
|
||||||
|
| opus_data | 5 | bytes | The encoded Opus frame |
|
||||||
|
| is_terminator | 16 | bool | End of audio transmission |
|
||||||
|
| positional_data | 7 | repeated float | X, Y, Z position (3 floats) |
|
||||||
|
| volume_adjustment | 8 | float | Volume adjustment factor (server→client) |
|
||||||
|
| context | 9 | uint32 | Audio context (server→client: normal/shout/whisper/listen) |
|
||||||
|
| target | 10 | uint32 | Voice target ID (client→server) |
|
||||||
|
|
||||||
|
### Encode (Client → Server)
|
||||||
|
|
||||||
|
From `UDPAudioEncoder::prepareAudioPacket_protobuf()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
m_audioMessage.set_frame_number(data.frameNumber);
|
||||||
|
m_audioMessage.set_opus_data(data.payload.data(), data.payload.size());
|
||||||
|
m_audioMessage.set_is_terminator(data.isLastFrame);
|
||||||
|
|
||||||
|
// Serialize protobuf with 1-byte header prefix
|
||||||
|
encodeProtobuf(m_audioMessage, m_byteBuffer, 1, MAX_UDP_PACKET_SIZE);
|
||||||
|
m_byteBuffer[0] = static_cast<byte>(UDPMessageType::Audio); // 0x00
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `updateAudioPacket_protobuf()`:
|
||||||
|
```cpp
|
||||||
|
m_audioMessage.set_target(data.targetOrContext);
|
||||||
|
encodeProtobuf(m_audioMessage, m_byteBuffer, offset, MAX_UDP_PACKET_SIZE);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wire Format (inside crypto envelope)
|
||||||
|
|
||||||
|
```
|
||||||
|
[0x00] [protobuf: frame_number + opus_data + is_terminator] [protobuf: target]
|
||||||
|
```
|
||||||
|
|
||||||
|
The encoder splits into "static" (frame data) and "variable" (target/context, volume) parts
|
||||||
|
for efficient re-encoding when forwarding to multiple recipients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ping Format
|
||||||
|
|
||||||
|
### Legacy Ping
|
||||||
|
|
||||||
|
```
|
||||||
|
[header: 0x20] [timestamp varint]
|
||||||
|
```
|
||||||
|
Or extended (12/24 bytes): `[version uint32] [timestamp uint64] [user_count uint32] [max_users uint32] [max_bw uint32]`
|
||||||
|
|
||||||
|
### Protobuf Ping
|
||||||
|
|
||||||
|
```
|
||||||
|
[0x01] [protobuf: MumbleUDP::Ping]
|
||||||
|
```
|
||||||
|
Fields: `timestamp` (uint64), `request_extended_information` (bool), `server_version_v2` (uint32), `user_count` (uint32), `max_user_count` (uint32), `max_bandwidth_per_user` (uint32).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UDP Send Path (Client)
|
||||||
|
|
||||||
|
From `ServerHandler::sendMessage()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void ServerHandler::sendMessage(const unsigned char *data, int len, bool force) {
|
||||||
|
// data = encoded audio packet (legacy or protobuf)
|
||||||
|
|
||||||
|
if (!force && (NetworkConfig::TcpModeEnabled() || !bUdp)) {
|
||||||
|
// TCP tunnel: wrap in UDPTunnel message
|
||||||
|
// [UDPTunnel type(2 bytes)] [length(4 bytes)] [data]
|
||||||
|
} else {
|
||||||
|
// Encrypt and send via UDP
|
||||||
|
connection->csCrypt->encrypt(data, crypto.data(), len);
|
||||||
|
qusUdp->writeDatagram(crypto.data(), len + 4, qhaRemote, usResolvedPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server chooses UDP vs TCP per-message based on whether UDP is established (`bUdp`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UDP Receive Path (Client)
|
||||||
|
|
||||||
|
From `ServerHandler::udpReady()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void ServerHandler::udpReady() {
|
||||||
|
while (qusUdp->hasPendingDatagrams()) {
|
||||||
|
// 1. Read from UDP socket
|
||||||
|
qusUdp->readDatagram(encrypted, buflen, &senderAddr, &senderPort);
|
||||||
|
|
||||||
|
// 2. Verify sender address/port matches server
|
||||||
|
// 3. Check crypto is initialized
|
||||||
|
// 4. Decrypt
|
||||||
|
connection->csCrypt->decrypt(encrypted, buffer.data(), buflen);
|
||||||
|
|
||||||
|
// 5. Decode based on protocol version
|
||||||
|
m_udpDecoder.decode(buffer.subspan(0, buflen - 4));
|
||||||
|
|
||||||
|
// 6. Dispatch
|
||||||
|
switch (m_udpDecoder.getMessageType()) {
|
||||||
|
case UDPMessageType::Ping: /* measure latency */ break;
|
||||||
|
case UDPMessageType::Audio: /* play audio */ break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Barnard-Specific Findings
|
||||||
|
|
||||||
|
### Advertised Version
|
||||||
|
|
||||||
|
Barnard's `gumble` library sends `VersionV1 = 1<<16 | 3<<8 | 0` = **1.3.0**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// From gumble/gumble/client.go DialWithDialer():
|
||||||
|
versionPacket := MumbleProto.Version{
|
||||||
|
VersionV1: proto.Uint32(ClientVersion), // 1<<16 | 3<<8 | 0
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **below 1.5.0**, so the server falls back to **legacy UDP format** for all audio
|
||||||
|
sent to barnard. This is why incoming packets have type byte `0x80` (legacy Opus) instead
|
||||||
|
of `0x00` (protobuf Audio).
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
To receive protobuf-format audio, barnard should advertise version >= 1.5.0:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ClientVersion = 1<<16 | 5<<8 | 0 // 1.5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
However, this change must be accompanied by full support for the protobuf UDP format
|
||||||
|
(both encode and decode), which is what we've implemented in `udp15.go`.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
- **Outbound**: We send protobuf-format audio (type `0x00`) encrypted with 1.5 OCB.
|
||||||
|
The server accepts this because it recognizes the 1.5 crypto format regardless of
|
||||||
|
the advertised version.
|
||||||
|
|
||||||
|
- **Inbound**: The server sends us legacy-format audio (type `0x80` in bits 5-7)
|
||||||
|
encrypted with 1.5 OCB. Our `handleLegacyUDPVoice` correctly parses this format.
|
||||||
|
|
||||||
|
- **Both paths work** given the current hybrid setup.
|
||||||
Reference in New Issue
Block a user