Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions pkg/encryption/cipher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// See the License for the specific language governing permissions and
// limitations under the License.

package encryption

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"

cerrors "github.com/pingcap/ticdc/pkg/errors"
)

// Cipher is the interface for encryption/decryption operations
type Cipher interface {
// Encrypt encrypts data using the provided key and IV
Encrypt(data, key, iv []byte) ([]byte, error)

// Decrypt decrypts data using the provided key and IV
Decrypt(data, key, iv []byte) ([]byte, error)

// IVSize returns the required IV size in bytes
IVSize() int
}

// AES256CTRCipher implements AES-CTR encryption for AES key sizes.
type AES256CTRCipher struct{}
Comment thread
tenfyzhong marked this conversation as resolved.

// NewAES256CTRCipher creates a new AES-CTR cipher.
func NewAES256CTRCipher() *AES256CTRCipher {
return &AES256CTRCipher{}
}

// IVSize returns the IV size for AES-CTR (16 bytes)
func (c *AES256CTRCipher) IVSize() int {
return aes.BlockSize
}

func isValidAESKeySize(key []byte) bool {
switch len(key) {
case 16, 24, 32:
return true
default:
return false
}
}

// Encrypt encrypts data using AES-CTR.
func (c *AES256CTRCipher) Encrypt(data, key, iv []byte) ([]byte, error) {
if !isValidAESKeySize(key) {
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("key must be 16, 24, or 32 bytes for AES-CTR")
}
if len(iv) != c.IVSize() {
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("IV must be 16 bytes")
}

block, err := aes.NewCipher(key)
if err != nil {
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
}

stream := cipher.NewCTR(block, iv)
ciphertext := make([]byte, len(data))
stream.XORKeyStream(ciphertext, data)

return ciphertext, nil
}

// Decrypt decrypts data using AES-CTR.
func (c *AES256CTRCipher) Decrypt(data, key, iv []byte) ([]byte, error) {
if !isValidAESKeySize(key) {
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("key must be 16, 24, or 32 bytes for AES-CTR")
}
if len(iv) != c.IVSize() {
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("IV must be 16 bytes")
}

block, err := aes.NewCipher(key)
if err != nil {
return nil, cerrors.ErrDecryptionFailed.Wrap(err)
}

stream := cipher.NewCTR(block, iv)
plaintext := make([]byte, len(data))
stream.XORKeyStream(plaintext, data)

return plaintext, nil
}

// GenerateIV generates a random IV of the specified size
func GenerateIV(size int) ([]byte, error) {
iv := make([]byte, size)
if _, err := rand.Read(iv); err != nil {
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
Comment on lines +101 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect callsites and argument sources for GenerateIV to confirm whether non-positive values can flow in.
rg -nP -C2 '\bGenerateIV\s*\(' --type=go

Repository: pingcap/ticdc

Length of output: 371


🏁 Script executed:

#!/bin/bash
# Find all call sites of GenerateIV (excluding the definition line)
rg -nP '\bGenerateIV\s*\(' --type=go -B2 -A2 | grep -v "^pkg/encryption/cipher.go-10[01]"

Repository: pingcap/ticdc

Length of output: 284


🏁 Script executed:

#!/bin/bash
# Find all mentions of GenerateIV in the codebase
rg 'GenerateIV' --type=go -n

Repository: pingcap/ticdc

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Read the GenerateIV function and surrounding context
sed -n '95,110p' pkg/encryption/cipher.go

Repository: pingcap/ticdc

Length of output: 364


🏁 Script executed:

#!/bin/bash
# Check the cerrors.ErrEncryptionFailed to understand the error type
rg 'ErrEncryptionFailed' --type=go -B2 -A2 | head -30

Repository: pingcap/ticdc

Length of output: 1513


Guard IV size to avoid runtime panic.

GenerateIV is a public API function with no input validation. Calling it with size <= 0 will panic in make([]byte, size). Add validation before allocation:

 func GenerateIV(size int) ([]byte, error) {
+	if size <= 0 {
+		return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("IV size must be positive")
+	}
 	iv := make([]byte, size)
 	if _, err := rand.Read(iv); err != nil {
 		return nil, cerrors.ErrEncryptionFailed.Wrap(err)
 	}
 	return iv, nil
 }

This follows the validation pattern already established in the same file (e.g., Encrypt method checks key and IV sizes).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/encryption/cipher.go` around lines 101 - 104, Add input validation to
GenerateIV: before allocating iv, check that size is > 0 and return a
descriptive wrapped error (consistent with the file's error patterns, e.g., use
cerrors.ErrInvalidArgument or an appropriate cerrors.* and Wrap/Warpf) when size
<= 0; place the guard at the top of GenerateIV so callers get a clear error
instead of a runtime panic (see how Encrypt validates key/iv sizes for
reference).

}
return iv, nil
}
35 changes: 35 additions & 0 deletions pkg/encryption/cipher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2025 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// See the License for the specific language governing permissions and
// limitations under the License.

package encryption

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestAES256CTREncryptDecrypt(t *testing.T) {
Comment thread
tenfyzhong marked this conversation as resolved.
key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes
iv := []byte("1234567890abcdef") // 16 bytes
plain := []byte("hello world")

cipherImpl := NewAES256CTRCipher()
encrypted, err := cipherImpl.Encrypt(plain, key, iv)
require.NoError(t, err)
require.NotEqual(t, plain, encrypted)

decrypted, err := cipherImpl.Decrypt(encrypted, key, iv)
require.NoError(t, err)
require.Equal(t, plain, decrypted)
}
34 changes: 34 additions & 0 deletions pkg/encryption/data_key_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// See the License for the specific language governing permissions and
// limitations under the License.

package encryption

import cerrors "github.com/pingcap/ticdc/pkg/errors"

// DataKeyID represents a 3-byte data key identifier in the encryption header.
type DataKeyID [3]byte

// ToString converts DataKeyID to string.
func (id DataKeyID) ToString() string {
return string(id[:])
}

// DataKeyIDFromString creates DataKeyID from string (must be 3 bytes).
func DataKeyIDFromString(s string) (DataKeyID, error) {
if len(s) != 3 {
return DataKeyID{}, cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID must be exactly 3 bytes")
}
Comment thread
wk989898 marked this conversation as resolved.
var id DataKeyID
copy(id[:], s)
return id, nil
}
32 changes: 32 additions & 0 deletions pkg/encryption/data_key_id_24be.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// See the License for the specific language governing permissions and
// limitations under the License.

package encryption

import cerrors "github.com/pingcap/ticdc/pkg/errors"

func encodeDataKeyID24BE(id uint32) (string, error) {
if id > 0xFFFFFF {
return "", cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID exceeds 24-bit range")
}
b := [3]byte{byte(id >> 16), byte(id >> 8), byte(id)}
return string(b[:]), nil
}

func decodeDataKeyID24BE(id string) (uint32, error) {
if len(id) != 3 {
return 0, cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID must be 3 bytes")
}
b := []byte(id)
return uint32(b[0])<<16 | uint32(b[1])<<8 | uint32(b[2]), nil
}
157 changes: 157 additions & 0 deletions pkg/encryption/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright 2025 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// See the License for the specific language governing permissions and
// limitations under the License.

package encryption

import (
cerrors "github.com/pingcap/ticdc/pkg/errors"
)

const (
// EncryptionHeaderSize is the size of encryption header (4 bytes)
// Format: [version(1 byte)][dataKeyID(3 bytes)]
EncryptionHeaderSize = 4

// VersionUnencrypted indicates data is not encrypted
VersionUnencrypted byte = 0x00
)

// EncryptionHeader represents the 4-byte encryption header
// Format: [version(1 byte)][dataKeyID(3 bytes)]
type EncryptionHeader struct {
Version byte
DataKeyID [3]byte
}

// EncodeEncryptedData encodes data with encryption header
// Format: [version(1)][dataKeyID(3)][encryptedData]
// The version byte comes from the encryption metadata obtained from TiKV
func EncodeEncryptedData(data []byte, version byte, dataKeyID string) ([]byte, error) {
if len(dataKeyID) != 3 {
return nil, cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID must be 3 bytes")
}

if version == VersionUnencrypted {
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("version cannot be 0 for encrypted data")
}

result := make([]byte, EncryptionHeaderSize+len(data))
result[0] = version
copy(result[1:4], dataKeyID)
copy(result[4:], data)

return result, nil
}

// DecodeEncryptedData decodes data and extracts encryption header
// Returns: (version, dataKeyID, encryptedData, error)
func DecodeEncryptedData(data []byte) (byte, string, []byte, error) {
if len(data) < EncryptionHeaderSize {
return 0, "", nil, cerrors.ErrDecodeFailed.GenWithStackByArgs("data too short for encryption header")
}

version := data[0]
var dataKeyID [3]byte
copy(dataKeyID[:], data[1:4])
encryptedData := data[4:]

return version, string(dataKeyID[:]), encryptedData, nil
}

// IsEncrypted checks if data is encrypted by examining the version byte
// Data is considered encrypted if version != 0 (VersionUnencrypted)
// The caller should validate that the version matches expected versions from TiKV metadata
func IsEncrypted(data []byte) bool {
if len(data) < EncryptionHeaderSize {
return false
}
return data[0] != VersionUnencrypted
}

// IsEncryptedWithVersion checks if data is encrypted with a specific version
// This is useful when you know the expected version from TiKV metadata
func IsEncryptedWithVersion(data []byte, expectedVersion byte) bool {
if len(data) < EncryptionHeaderSize {
return false
}
return data[0] == expectedVersion
}
Comment on lines +83 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent unencrypted data from passing encrypted-version checks.

IsEncryptedWithVersion currently returns true for expectedVersion == 0, which can classify unencrypted payloads as encrypted-with-version. Enforce non-zero version in this predicate.

✅ Proposed fix
 func IsEncryptedWithVersion(data []byte, expectedVersion byte) bool {
 	if len(data) < EncryptionHeaderSize {
 		return false
 	}
-	return data[0] == expectedVersion
+	if expectedVersion == VersionUnencrypted {
+		return false
+	}
+	return data[0] == expectedVersion
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func IsEncryptedWithVersion(data []byte, expectedVersion byte) bool {
if len(data) < EncryptionHeaderSize {
return false
}
return data[0] == expectedVersion
}
func IsEncryptedWithVersion(data []byte, expectedVersion byte) bool {
if len(data) < EncryptionHeaderSize {
return false
}
if expectedVersion == VersionUnencrypted {
return false
}
return data[0] == expectedVersion
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/encryption/format.go` around lines 83 - 88, IsEncryptedWithVersion
currently treats expectedVersion==0 as a valid match and may classify
unencrypted payloads as encrypted; update the predicate in
IsEncryptedWithVersion to require expectedVersion != 0 in addition to len(data)
>= EncryptionHeaderSize and data[0] == expectedVersion so that a zero (unset)
version can never be treated as a match.


// GetVersion extracts the version byte from data
// Returns 0 if data is too short
func GetVersion(data []byte) byte {
if len(data) < EncryptionHeaderSize {
return 0
}
return data[0]
}

// EncodeUnencryptedData encodes unencrypted data with version=0 header
// This creates a unified format where all new data has the 4-byte header
func EncodeUnencryptedData(data []byte) []byte {
result := make([]byte, EncryptionHeaderSize+len(data))
result[0] = VersionUnencrypted
// DataKeyID is zero for unencrypted data (3 bytes)
result[1] = 0
result[2] = 0
result[3] = 0
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These lines explicitly zero out bytes result[1] through result[3]. However, make([]byte, ...) already returns a zero-initialized slice, so these assignments are redundant. Removing them will make the code cleaner and less confusing.

copy(result[4:], data)
return result
}

// DecodeUnencryptedData decodes unencrypted data (removes header if present)
func DecodeUnencryptedData(data []byte) ([]byte, error) {
if len(data) < EncryptionHeaderSize {
// No header, return as-is (backward compatibility)
return data, nil
}

version := data[0]
dataKeyID1, dataKeyID2, dataKeyID3 := data[1], data[2], data[3]
dataKeyIDIsZero := dataKeyID1 == 0 && dataKeyID2 == 0 && dataKeyID3 == 0

if version == VersionUnencrypted && dataKeyIDIsZero {
// New-format unencrypted data with header, remove header
return data[4:], nil
}

// For backward compatibility, treat any other format as legacy unencrypted data
// This includes:
// - Legacy data without header (any pattern)
// - Data that might look like encrypted but is actually legacy
// The caller is responsible for ensuring data is not actually encrypted
return data, nil
}

// ExtractDataKeyID extracts the data key ID from encrypted data
func ExtractDataKeyID(data []byte) (string, error) {
if len(data) < EncryptionHeaderSize {
return "", cerrors.ErrDecodeFailed.GenWithStackByArgs("data too short")
}

version := data[0]
dataKeyID1, dataKeyID2, dataKeyID3 := data[1], data[2], data[3]
dataKeyIDIsZero := dataKeyID1 == 0 && dataKeyID2 == 0 && dataKeyID3 == 0

// Only extract key ID from data that definitively looks like new-format encrypted:
// - version != 0 (encrypted data has non-zero version)
// - DataKeyID is non-zero (encrypted data always has a valid key ID)
if version != VersionUnencrypted && !dataKeyIDIsZero {
var keyID [3]byte
copy(keyID[:], data[1:4])
return string(keyID[:]), nil
}

// Otherwise, this is not encrypted data (legacy data or new-format unencrypted)
return "", cerrors.ErrDecodeFailed.GenWithStackByArgs("data is not encrypted")
}
Loading
Loading