Fix IV increment direction and OCB decrypt checksum

Two critical bugs found by testing against wumble's Crystal OCB:

1. IV increment was big-endian (byte[15] first). Wumble uses
   little-endian (byte[0] first). Since byte[0] is what goes on
   the wire, it never changed — server saw every packet as a
   replay. Changed to little-endian increment matching wumble's
   increment_encrypt_iv.

2. In ocb15Decrypt's final partial block, the checksum was
   computed from recovered plaintext XOR pad (= ciphertext).
   But ocb15Encrypt computes it from ciphertext XOR pad
   (= plaintext). Now both use ciphertext XOR pad.

Added udp15_test.go with:
- TestIncrementIV: verifies little-endian overflow behavior
- TestOCB15RoundTrip: encrypt→decrypt for 0-100 byte inputs
- TestCryptState15RoundTrip: full pipeline with real crypto keys
- TestUDPAudioProtobuf: protobuf encode/decode round-trip
This commit is contained in:
Brandon McGinty (deepseek)
2026-08-09 01:11:56 -04:00
committed by Brandon McGinty
parent 63941f7785
commit 266f1fce73
2 changed files with 179 additions and 7 deletions
+9 -7
View File
@@ -148,8 +148,8 @@ func (cs *cryptState15) encrypt15(plaintext []byte) ([]byte, error) {
return nil, errors.New("gumble: crypto not initialized")
}
// Increment IV (big-endian).
incrementBE(cs.encryptIV[:])
// Increment IV (little-endian, byte 0 is LSB).
incrementIV(cs.encryptIV[:])
ciphertext, tag := ocb15Encrypt(cs.key[:], cs.encryptIV[:], plaintext)
@@ -224,9 +224,10 @@ func (cs *cryptState15) decrypt15(packet []byte) ([]byte, error) {
return plaintext, nil
}
// incrementBE increments a 16-byte slice as a big-endian integer.
func incrementBE(iv []byte) {
for i := len(iv) - 1; i >= 0; i-- {
// incrementIV increments a 16-byte IV as a little-endian integer
// (byte 0 is the least significant byte). Matches wumble's increment_encrypt_iv.
func incrementIV(iv []byte) {
for i := 0; i < len(iv); i++ {
iv[i]++
if iv[i] != 0 {
break
@@ -343,9 +344,10 @@ func ocb15Decrypt(key, nonce, ciphertext []byte) (plaintext, tag []byte, err err
xorBytes(ppart, ciphertext[pos:pos+remaining], pad[:remaining])
plaintext = append(plaintext, ppart...)
// checksum ^= (ppart || 0*) XOR pad
// checksum ^= (ciphertext_partial || 0*) XOR pad
// This gives ciphertext XOR pad = plaintext, matching encrypt's checksum.
csTemp := make([]byte, 16)
copy(csTemp, ppart)
copy(csTemp, ciphertext[pos:pos+remaining])
xor16(csTemp, csTemp, pad)
xor16(checksum, checksum, csTemp)
+170
View File
@@ -0,0 +1,170 @@
package gumble
import (
"bytes"
"encoding/hex"
"testing"
)
// Test IV increment matches wumble's little-endian behavior.
func TestIncrementIV(t *testing.T) {
iv := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
incrementIV(iv)
// After first increment: byte 0 should be 0x01
if iv[0] != 0x01 {
t.Fatalf("after increment 1, iv[0]=%02x, want 01", iv[0])
}
// Increment 254 more times to get byte 0 to 0xFF, then overflow
for i := 0; i < 254; i++ {
incrementIV(iv)
}
if iv[0] != 0xFF || iv[1] != 0x00 {
t.Fatalf("after 255 increments, iv[0]=%02x iv[1]=%02x, want FF 00", iv[0], iv[1])
}
// One more: overflow to byte 1
incrementIV(iv)
if iv[0] != 0x00 || iv[1] != 0x01 {
t.Fatalf("after 256 increments, iv[0]=%02x iv[1]=%02x, want 00 01", iv[0], iv[1])
}
}
// Test OCB round-trip: encrypt then decrypt should recover plaintext.
func TestOCB15RoundTrip(t *testing.T) {
key := mustDecodeHex("000102030405060708090a0b0c0d0e0f")
nonce := mustDecodeHex("000102030405060708090a0b0c0d0e0f")
tests := []struct {
name string
plaintext []byte
}{
{"empty", []byte{}},
{"1 byte", []byte{0x41}},
{"15 bytes", []byte("hello world 1234")}, // 15
{"16 bytes", []byte("hello world 12345")}, // exactly 1 block
{"17 bytes", []byte("hello world 123456")}, // 1 full + 1 partial
{"32 bytes", []byte("hello world 12345678901234567")}, // exactly 2 blocks
{"33 bytes", []byte("hello world 123456789012345678")},
{"100 bytes", bytes.Repeat([]byte{0x41}, 100)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ct, tag := ocb15Encrypt(key, nonce, tt.plaintext)
pt, tag2, err := ocb15Decrypt(key, nonce, ct)
if err != nil {
t.Fatalf("decrypt error: %v", err)
}
if !bytes.Equal(pt, tt.plaintext) {
t.Fatalf("round-trip failed:\n input: %s\n output: %s",
hex.EncodeToString(tt.plaintext),
hex.EncodeToString(pt))
}
if !bytes.Equal(tag, tag2) {
t.Fatalf("tag mismatch:\n encrypt tag: %s\n decrypt tag: %s",
hex.EncodeToString(tag),
hex.EncodeToString(tag2))
}
})
}
}
// Test full 1.5 crypto pipeline: setup -> encrypt -> decrypt.
func TestCryptState15RoundTrip(t *testing.T) {
key := mustDecodeHex("93360b0f86a926c4561563469026eb94")
clientNonce := mustDecodeHex("d4e53c00a2f6512a61cbe8540eba6314")
serverNonce := mustDecodeHex("1463352a4d2375a2695ae0800b22d71d")
csClient := &cryptState15{}
if err := csClient.setup15(key, clientNonce, serverNonce); err != nil {
t.Fatalf("client setup: %v", err)
}
csServer := &cryptState15{}
if err := csServer.setup15(key, serverNonce, clientNonce); err != nil {
t.Fatalf("server setup: %v", err)
}
plaintext := []byte("test audio frame")
// Encrypt with client state.
encrypted, err := csClient.encrypt15(plaintext)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
t.Logf("encrypted len=%d hex=%s", len(encrypted), hex.EncodeToString(encrypted))
// Decrypt with server state.
decrypted, err := csServer.decrypt15(encrypted)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Fatalf("round-trip failed:\n input: %s\n output: %s",
hex.EncodeToString(plaintext),
hex.EncodeToString(decrypted))
}
t.Logf("Client IV after encrypt: %s", hex.EncodeToString(csClient.encryptIV[:]))
t.Logf("Server IV before decrypt: %s", hex.EncodeToString(csServer.decryptIV[:]))
// Second packet.
plaintext2 := []byte("second audio frame")
encrypted2, _ := csClient.encrypt15(plaintext2)
decrypted2, err := csServer.decrypt15(encrypted2)
if err != nil {
t.Fatalf("decrypt2: %v", err)
}
if !bytes.Equal(decrypted2, plaintext2) {
t.Fatalf("round-trip 2 failed")
}
t.Logf("Client IV after 2 encrypts: %s", hex.EncodeToString(csClient.encryptIV[:]))
t.Logf("Server IV after 2 decrypts: %s", hex.EncodeToString(csServer.decryptIV[:]))
}
// Test protobuf encode/decode round-trip.
func TestUDPAudioProtobuf(t *testing.T) {
tests := []struct {
session uint32
frameNumber uint32
opusData []byte
terminator bool
}{
{0, 42, []byte{0x01, 0x02, 0x03}, false},
{123, 0, []byte{}, true},
{0, 99, []byte{0xFF}, false},
}
for _, tt := range tests {
encoded := encodeUDPAudio(tt.session, tt.frameNumber, tt.opusData, tt.terminator)
session, frameNum, opusData, terminator := decodeUDPAudio(encoded)
if session != tt.session {
t.Errorf("session: got %d, want %d", session, tt.session)
}
if frameNum != tt.frameNumber {
t.Errorf("frameNumber: got %d, want %d", frameNum, tt.frameNumber)
}
if !bytes.Equal(opusData, tt.opusData) {
t.Errorf("opusData mismatch: got %x, want %x", opusData, tt.opusData)
}
if terminator != tt.terminator {
t.Errorf("terminator: got %v, want %v", terminator, tt.terminator)
}
}
}
func mustDecodeHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}