From 266f1fce732eedd483f68a24e4bbc0fa93514420 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Sun, 9 Aug 2026 01:11:56 -0400 Subject: [PATCH] Fix IV increment direction and OCB decrypt checksum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gumble/gumble/udp15.go | 16 ++-- gumble/gumble/udp15_test.go | 170 ++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 gumble/gumble/udp15_test.go diff --git a/gumble/gumble/udp15.go b/gumble/gumble/udp15.go index 3cde238..146d54a 100644 --- a/gumble/gumble/udp15.go +++ b/gumble/gumble/udp15.go @@ -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) diff --git a/gumble/gumble/udp15_test.go b/gumble/gumble/udp15_test.go new file mode 100644 index 0000000..5a38d8e --- /dev/null +++ b/gumble/gumble/udp15_test.go @@ -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 +}