diff --git a/network/http/payload_obfuscation.go b/network/http/payload_obfuscation.go new file mode 100644 index 00000000..e8aa6426 --- /dev/null +++ b/network/http/payload_obfuscation.go @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package http + +import "errors" + +const ( + // PayloadObfuscationHeader is the HTTP header name carrying the payload obfuscation mode. + PayloadObfuscationHeader = "X-Guance-Payload-Obfuscation" + // PayloadObfuscationGzipCaesarV1 identifies a mode that adds 10 modulo 256 to each of the first 256 bytes + // of a gzip-compressed payload, or to all bytes when the payload is shorter. The transformation is in place + // and allocation-free. It is obfuscation, not encryption, and does not protect against MITM attacks or tampering. + PayloadObfuscationGzipCaesarV1 = "gzip-caesar-v1" +) + +// ErrUnsupportedPayloadObfuscation is returned when the requested mode is not supported. +// In this case, ObfuscatePayload and DeobfuscatePayload leave body unchanged. +var ErrUnsupportedPayloadObfuscation = errors.New("unsupported payload obfuscation") + +// ObfuscatePayload obfuscates body in place using mode. +// It does not compress body. +func ObfuscatePayload(mode string, body []byte) error { + return transformPayload(mode, body, false) +} + +// DeobfuscatePayload reverses ObfuscatePayload in place using mode. +// It does not validate or decompress gzip data. +func DeobfuscatePayload(mode string, body []byte) error { + return transformPayload(mode, body, true) +} + +func transformPayload(mode string, body []byte, reverse bool) error { + const ( + PAYLOAD_OBFUSCATION_PREFIX_LENGTH = 256 + PAYLOAD_OBFUSCATION_CAESAR_SHIFT = 10 + ) + + if mode != PayloadObfuscationGzipCaesarV1 { + return ErrUnsupportedPayloadObfuscation + } + + for i := range body[:min(len(body), PAYLOAD_OBFUSCATION_PREFIX_LENGTH)] { + if reverse { + body[i] -= PAYLOAD_OBFUSCATION_CAESAR_SHIFT + } else { + body[i] += PAYLOAD_OBFUSCATION_CAESAR_SHIFT + } + } + + return nil +} diff --git a/network/http/payload_obfuscation_test.go b/network/http/payload_obfuscation_test.go new file mode 100644 index 00000000..52b26698 --- /dev/null +++ b/network/http/payload_obfuscation_test.go @@ -0,0 +1,129 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package http + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPayloadObfuscationGzipCaesarV1SingleByte(t *testing.T) { + assert.Equal(t, "X-Guance-Payload-Obfuscation", PayloadObfuscationHeader) + assert.Equal(t, "gzip-caesar-v1", PayloadObfuscationGzipCaesarV1) + + body := []byte{250} + + require.NoError(t, ObfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + assert.Equal(t, []byte{4}, body) + + require.NoError(t, DeobfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + assert.Equal(t, []byte{250}, body) +} + +func TestPayloadObfuscationGzipCaesarV1RoundTripBoundaries(t *testing.T) { + for _, size := range []int{0, 1, 255, 256, 257, 1 << 20} { + t.Run(fmt.Sprintf("length_%d", size), func(t *testing.T) { + body := make([]byte, size) + for i := range body { + body[i] = byte(i) + } + original := append([]byte(nil), body...) + + require.NoError(t, ObfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + if len(body) > 256 { + assert.True(t, bytes.Equal(original[256:], body[256:]), "bytes after the first 256 changed") + } + + require.NoError(t, DeobfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + assert.True(t, bytes.Equal(original, body), "round trip changed a payload of length %d", size) + }) + } +} + +func TestPayloadObfuscationRejectsUnsupportedModeWithoutMutation(t *testing.T) { + tests := []struct { + name string + transform func(string, []byte) error + }{ + { + name: "obfuscate", + transform: ObfuscatePayload, + }, + { + name: "deobfuscate", + transform: DeobfuscatePayload, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := []byte{0, 1, 127, 128, 254, 255} + original := append([]byte(nil), body...) + + err := test.transform("unknown-mode", body) + + require.ErrorIs(t, err, ErrUnsupportedPayloadObfuscation) + assert.Equal(t, original, body) + }) + } +} + +func TestPayloadObfuscationGzipCaesarV1WrapsAllByteValues(t *testing.T) { + original := make([]byte, 256) + for i := range original { + original[i] = byte(i) + } + + t.Run("obfuscate", func(t *testing.T) { + body := append([]byte(nil), original...) + expected := append(append([]byte(nil), original[10:]...), original[:10]...) + + require.NoError(t, ObfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + assert.Equal(t, expected, body) + }) + + t.Run("deobfuscate", func(t *testing.T) { + body := append([]byte(nil), original...) + expected := append(append([]byte(nil), original[256-10:]...), original[:256-10]...) + + require.NoError(t, DeobfuscatePayload(PayloadObfuscationGzipCaesarV1, body)) + assert.Equal(t, expected, body) + }) +} + +func TestPayloadObfuscationGzipCaesarV1DoesNotAllocate(t *testing.T) { + tests := []struct { + name string + transform func(string, []byte) error + }{ + { + name: "obfuscate", + transform: ObfuscatePayload, + }, + { + name: "deobfuscate", + transform: DeobfuscatePayload, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := make([]byte, 1024) + var transformErr error + + allocations := testing.AllocsPerRun(1000, func() { + transformErr = test.transform(PayloadObfuscationGzipCaesarV1, body) + }) + + require.NoError(t, transformErr) + assert.Zero(t, allocations) + }) + } +}