diff --git a/network/msgCompressor.go b/network/msgCompressor.go index 6b51476726..34c18bb373 100644 --- a/network/msgCompressor.go +++ b/network/msgCompressor.go @@ -18,6 +18,7 @@ package network import ( "bytes" + "errors" "fmt" "io" "sync/atomic" @@ -219,7 +220,11 @@ func (c *wsPeerMsgCodec) decompress(tag protocol.Tag, data []byte) ([]byte, erro if c.avdec.enabled { res, err := c.avdec.convert(data) if err != nil { - c.log.Warnf("peer %s vote decompress error: %v", c.origin, err) + if errors.Is(err, vpack.ErrLikelyUncompressed) { + // allow uncompressed AV to pass through without logging an error + } else { + c.log.Warnf("peer %s vote decompress error: %v", c.origin, err) + } // fall back to original data return data, nil } diff --git a/network/vpack/parse_test.go b/network/vpack/parse_test.go index 0ad6230c8a..866c0e9ea0 100644 --- a/network/vpack/parse_test.go +++ b/network/vpack/parse_test.go @@ -187,3 +187,66 @@ func TestParseVoteTrailingDataErr(t *testing.T) { _, err := se.CompressVote(nil, buf) assert.ErrorContains(t, err, "unexpected trailing data") } + +// TestUncompressedMsgpackDetection tests that decompression errors on uncompressed +// msgpack data get wrapped with ErrLikelyUncompressed, while corrupted vpack data does not. +func TestUncompressedMsgpackDetection(t *testing.T) { + partitiontest.PartitionTest(t) + + // Create a vote structure shared by both subtests + vote := map[string]any{ + "cred": map[string]any{"pf": crypto.VrfProof{7, 8, 9}}, + "r": map[string]any{ + "rnd": uint64(1000), + "per": uint64(5), + "step": uint64(2), + "snd": [32]byte{1, 2, 3}, + "prop": map[string]any{ + "dig": [32]byte{4, 5, 6}, + }, + }, + "sig": map[string]any{ + "s": [64]byte{10, 11, 12}, + "p": [32]byte{13, 14, 15}, + "p2": [32]byte{16, 17, 18}, + "p1s": [64]byte{22, 23, 24}, + "p2s": [64]byte{25, 26, 27}, + "ps": [64]byte{}, + }, + } + + t.Run("uncompressed_detected", func(t *testing.T) { + msgpackData := protocol.EncodeReflect(vote) + + // Verify it starts with the uncompressed msgpack pattern + assert.GreaterOrEqual(t, len(msgpackData), 6) + assert.Equal(t, byte(0x83), msgpackData[0], "uncompressed vote should start with fixmap(3)") + assert.Equal(t, byte(0xa4), msgpackData[1], "followed by fixstr(4)") + assert.Equal(t, "cred", string(msgpackData[2:6]), "field name should be 'cred'") + + // Try to decompress as vpack - should fail with ErrLikelyUncompressed + var dec StatelessDecoder + _, err := dec.DecompressVote(nil, msgpackData) + + assert.Error(t, err) + assert.ErrorIs(t, err, ErrLikelyUncompressed, "should detect uncompressed msgpack pattern") + assert.ErrorContains(t, err, "data appears to be uncompressed msgpack") + }) + + t.Run("corrupted_vpack_not_detected", func(t *testing.T) { + msgpackData := protocol.EncodeReflect(vote) + var enc StatelessEncoder + compressed, err := enc.CompressVote(nil, msgpackData) + assert.NoError(t, err) + + // Corrupt the compressed data by truncating it + corrupted := compressed[:len(compressed)/2] + + // Try to decompress - should fail but NOT with ErrLikelyUncompressed + var dec StatelessDecoder + _, err = dec.DecompressVote(nil, corrupted) + + assert.Error(t, err) + assert.NotErrorIs(t, err, ErrLikelyUncompressed, "corrupted vpack should not be detected as uncompressed msgpack") + }) +} diff --git a/network/vpack/vpack.go b/network/vpack/vpack.go index 86cfc41567..ec377552a1 100644 --- a/network/vpack/vpack.go +++ b/network/vpack/vpack.go @@ -215,9 +215,31 @@ func (d *StatelessDecoder) proposalValueMapSize(mask uint8) uint8 { return uint8(bits.OnesCount8(mask & (bitDig | bitEncDig | bitOper | bitOprop))) } +// ErrLikelyUncompressed is returned when vpack decompression detects what appears +// to be uncompressed msgpack data from a peer claiming vpack support. +var ErrLikelyUncompressed = fmt.Errorf("data appears to be uncompressed msgpack") + +// isLikelyUncompressedMsgpack checks if the source data looks like an uncompressed +// msgpack vote that was mistakenly treated as vpack-compressed. +func isLikelyUncompressedMsgpack(src []byte) bool { + // uncompressed msgpack votes start with 0x83 (fixmap marker with 3 elements: cred, r, sig), + // followed by 0xa4 (fixstr marker of length 4), then "cred" + return len(src) > 5 && src[0] == 0x83 && src[1] == 0xa4 && + src[2] == 'c' && src[3] == 'r' && src[4] == 'e' && src[5] == 'd' +} + // DecompressVote decodes a compressed vote in src and appends it to dst. // To re-use dst, run like: dst = dec.DecompressVote(dst[:0], src) func (d *StatelessDecoder) DecompressVote(dst, src []byte) ([]byte, error) { + result, err := d.decompressVote(dst, src) + if err != nil && isLikelyUncompressedMsgpack(src) { + return nil, fmt.Errorf("%w: %v", ErrLikelyUncompressed, err) + } + return result, err +} + +// decompressVote performs the actual decompression logic. +func (d *StatelessDecoder) decompressVote(dst, src []byte) ([]byte, error) { if len(src) < 2 { return nil, fmt.Errorf("header missing") }