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:
committed by
Brandon McGinty
parent
63941f7785
commit
266f1fce73
@@ -148,8 +148,8 @@ func (cs *cryptState15) encrypt15(plaintext []byte) ([]byte, error) {
|
|||||||
return nil, errors.New("gumble: crypto not initialized")
|
return nil, errors.New("gumble: crypto not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment IV (big-endian).
|
// Increment IV (little-endian, byte 0 is LSB).
|
||||||
incrementBE(cs.encryptIV[:])
|
incrementIV(cs.encryptIV[:])
|
||||||
|
|
||||||
ciphertext, tag := ocb15Encrypt(cs.key[:], cs.encryptIV[:], plaintext)
|
ciphertext, tag := ocb15Encrypt(cs.key[:], cs.encryptIV[:], plaintext)
|
||||||
|
|
||||||
@@ -224,9 +224,10 @@ func (cs *cryptState15) decrypt15(packet []byte) ([]byte, error) {
|
|||||||
return plaintext, nil
|
return plaintext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// incrementBE increments a 16-byte slice as a big-endian integer.
|
// incrementIV increments a 16-byte IV as a little-endian integer
|
||||||
func incrementBE(iv []byte) {
|
// (byte 0 is the least significant byte). Matches wumble's increment_encrypt_iv.
|
||||||
for i := len(iv) - 1; i >= 0; i-- {
|
func incrementIV(iv []byte) {
|
||||||
|
for i := 0; i < len(iv); i++ {
|
||||||
iv[i]++
|
iv[i]++
|
||||||
if iv[i] != 0 {
|
if iv[i] != 0 {
|
||||||
break
|
break
|
||||||
@@ -343,9 +344,10 @@ func ocb15Decrypt(key, nonce, ciphertext []byte) (plaintext, tag []byte, err err
|
|||||||
xorBytes(ppart, ciphertext[pos:pos+remaining], pad[:remaining])
|
xorBytes(ppart, ciphertext[pos:pos+remaining], pad[:remaining])
|
||||||
plaintext = append(plaintext, ppart...)
|
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)
|
csTemp := make([]byte, 16)
|
||||||
copy(csTemp, ppart)
|
copy(csTemp, ciphertext[pos:pos+remaining])
|
||||||
xor16(csTemp, csTemp, pad)
|
xor16(csTemp, csTemp, pad)
|
||||||
xor16(checksum, checksum, csTemp)
|
xor16(checksum, checksum, csTemp)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user