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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
- Functions: use camel case and **do not** include `_` (e.g. `getPartitionNum`, not `get_partition_num`).
- Variables: use lowerCamelCase (e.g. `flushInterval`, not `flush_interval`).
- Logging: structured logs via `github.com/pingcap/log` + `zap` fields; message strings should **not** include function names and should avoid `-` (use spaces instead).
- Errors: when an error comes from a third party/library call, wrap it immediately with `errors.Trace(err)` or `errors.WrapError(...)` to attach a stack trace; upstream callers should propagate wrapped errors without wrapping again.
- Errors: when an error comes from a third party/library call, wrap it immediately with `errors.Trace(err)` or `errors.WrapError(...)` to attach a stack trace; upstream callers should propagate wrapped errors without wrapping again. Avoid using `errors.New` to create error objects; instead, utilize the predefined objects available in the `cerrors` package.

## Testing Guidelines

Expand Down
199 changes: 199 additions & 0 deletions pkg/encryption/encryption_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// 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 (
"context"

"github.com/pingcap/log"
"github.com/pingcap/ticdc/pkg/config"
cerrors "github.com/pingcap/ticdc/pkg/errors"
"go.uber.org/zap"
)

// EncryptionManager is the main interface for encryption/decryption operations
type EncryptionManager interface {
// EncryptData encrypts data for a keyspace
// Returns encrypted data with header, or original data if encryption is not enabled
EncryptData(ctx context.Context, keyspaceID uint32, data []byte) ([]byte, error)

// DecryptData decrypts data for a keyspace
// Automatically detects if data is encrypted and handles accordingly
DecryptData(ctx context.Context, keyspaceID uint32, encryptedData []byte) ([]byte, error)
}

type encryptionManager struct {
metaManager EncryptionMetaManager
}

// NewEncryptionManager creates a new encryption manager
func NewEncryptionManager(metaManager EncryptionMetaManager) EncryptionManager {
return &encryptionManager{
metaManager: metaManager,
}
}

// EncryptData encrypts data for a keyspace
func (m *encryptionManager) EncryptData(ctx context.Context, keyspaceID uint32, data []byte) ([]byte, error) {
allowDegrade := true
serverCfg := config.GetGlobalServerConfig()
if serverCfg != nil && serverCfg.Encryption != nil {
allowDegrade = serverCfg.Encryption.AllowDegradeOnError
}
Comment thread
tenfyzhong marked this conversation as resolved.

// Get current data key, key ID and version together to avoid mismatch when keys rotate.
dataKey, currentDataKeyID, version, err := m.metaManager.GetCurrentDataKey(ctx, keyspaceID)
if err != nil {
if allowDegrade {
log.Warn("failed to get current data key, degrade to plaintext",
zap.Uint32("keyspaceID", keyspaceID),
zap.Error(err))
return data, nil
}
log.Error("failed to get current data key",
zap.Uint32("keyspaceID", keyspaceID),
zap.Error(err))
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
}

if len(dataKey) == 0 {
log.Debug("encryption not enabled for keyspace",
zap.Uint32("keyspaceID", keyspaceID))
return data, nil
}

cipherImpl := NewAES256CTRCipher()

// Generate IV
iv, err := GenerateIV(cipherImpl.IVSize())
if err != nil {
log.Error("failed to generate IV",
zap.Uint32("keyspaceID", keyspaceID),
zap.Error(err))
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
}

// Encrypt data
encryptedData, err := cipherImpl.Encrypt(data, dataKey, iv)
if err != nil {
log.Error("failed to encrypt data",
zap.Uint32("keyspaceID", keyspaceID),
zap.Error(err))
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
}

// Prepend IV to encrypted data
encryptedWithIV := make([]byte, len(iv)+len(encryptedData))
copy(encryptedWithIV, iv)
copy(encryptedWithIV[len(iv):], encryptedData)

// Encode with encryption header
result, err := EncodeEncryptedData(encryptedWithIV, version, currentDataKeyID)
if err != nil {
log.Error("failed to encode encrypted data",
zap.Uint32("keyspaceID", keyspaceID),
zap.Uint8("version", version),
zap.Binary("dataKeyID", []byte(currentDataKeyID)),
zap.Error(err))
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
Comment on lines +55 to +109

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

AllowDegradeOnError currently stops at key lookup.

Line 58 degrades only when GetCurrentDataKey fails. IV generation, AES encryption, and header encoding still hard-fail even when AllowDegradeOnError is enabled, so the fallback does not actually cover the full encryption path. Please send those later error branches through the same plaintext fallback, or narrow the config name so its scope is explicit.

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

In `@pkg/encryption/encryption_manager.go` around lines 55 - 109, The current
AllowDegradeOnError (allowDegrade) only triggers fallback when GetCurrentDataKey
fails; update the subsequent error branches in Encrypt (the GenerateIV call,
cipherImpl.Encrypt, and EncodeEncryptedData) to behave the same: when err != nil
and allowDegrade is true, log a warning (using log.Warn with keyspaceID and
error) and return the original plaintext (return data, nil); otherwise keep the
existing error logging and return cerrors.ErrEncryptionFailed.Wrap(err). Make
these changes in the function that calls NewAES256CTRCipher(), GenerateIV(...),
cipherImpl.Encrypt(...), and EncodeEncryptedData(...) so all encryption-step
failures respect allowDegrade (or alternatively rename the config to a narrower
name if you prefer to limit scope).

}

log.Debug("data encrypted successfully",
zap.Uint32("keyspaceID", keyspaceID),
zap.String("dataKeyID", currentDataKeyID),
zap.Int("originalSize", len(data)),
zap.Int("encryptedSize", len(result)))

return result, nil
}

// DecryptData decrypts data for a keyspace
func (m *encryptionManager) DecryptData(ctx context.Context, keyspaceID uint32, encryptedData []byte) ([]byte, error) {
// Check if data is encrypted
if !IsEncrypted(encryptedData) {
// Data is not encrypted, return as-is (backward compatibility)
log.Debug("data is not encrypted",
zap.Uint32("keyspaceID", keyspaceID))
return encryptedData, nil
}

// Decode encryption header
version, dataKeyID, dataWithIV, err := DecodeEncryptedData(encryptedData)
if err != nil {
log.Warn("failed to decode encrypted data header",
zap.Uint32("keyspaceID", keyspaceID),
zap.Int("encryptedSize", len(encryptedData)),
zap.Error(err))
return nil, cerrors.ErrDecryptionFailed.Wrap(err)
}
Comment on lines +122 to +139

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 | 🔴 Critical

IsEncrypted is too weak to preserve legacy plaintext.

Line 124 relies on IsEncrypted(encryptedData), but pkg/encryption/format.go:74-81 currently treats any payload with len >= 4 and a non-zero first byte as encrypted. That means ordinary plaintext/binary values can be misclassified during rollout, so DecryptData will fail reads on existing unencrypted rows or try to decrypt garbage. This needs an unambiguous wire-format marker before it can safely claim plaintext compatibility.

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

In `@pkg/encryption/encryption_manager.go` around lines 122 - 139, The IsEncrypted
check in DecryptData is too permissive and can misclassify ordinary plaintext as
encrypted; update the wire-format to include an unambiguous marker (e.g., a
fixed magic prefix + explicit version byte) and change IsEncrypted to only
return true when that marker is present, then have DecodeEncryptedData validate
that same marker/version and return a clear error if missing/invalid; keep
DecryptData behavior of returning the input as plaintext only when the stricter
IsEncrypted returns false, and ensure any downstream callers use the updated
IsEncrypted/DecodeEncryptedData contract (refer to functions DecryptData,
IsEncrypted and DecodeEncryptedData to locate the changes).


if version == VersionUnencrypted {
// Should not happen if IsEncrypted returned true, but handle it anyway
return dataWithIV, nil
}

dataKey, err := m.metaManager.GetDataKey(ctx, keyspaceID, dataKeyID)
if err != nil {
log.Warn("failed to get data key for decryption",
zap.Uint32("keyspaceID", keyspaceID),
zap.Uint8("version", version),
zap.Binary("dataKeyID", []byte(dataKeyID)),
zap.Error(err))
return nil, cerrors.ErrDecryptionFailed.Wrap(err)
}

if len(dataKey) == 0 {
log.Warn("data key is empty for decryption",
zap.Uint32("keyspaceID", keyspaceID),
zap.Uint8("version", version),
zap.Binary("dataKeyID", []byte(dataKeyID)))
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("data key is empty")
}

cipherImpl := NewAES256CTRCipher()

// Extract IV from the beginning of data
ivSize := cipherImpl.IVSize()
if len(dataWithIV) < ivSize {
log.Warn("encrypted data too short for IV",
zap.Uint32("keyspaceID", keyspaceID),
zap.Uint8("version", version),
zap.Binary("dataKeyID", []byte(dataKeyID)),
zap.Int("dataWithIVSize", len(dataWithIV)),
zap.Int("expectedIVSize", ivSize))
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("data too short for IV")
}

iv := dataWithIV[:ivSize]
encryptedDataOnly := dataWithIV[ivSize:]

// Decrypt data
plaintext, err := cipherImpl.Decrypt(encryptedDataOnly, dataKey, iv)
if err != nil {
log.Warn("failed to decrypt data",
zap.Uint32("keyspaceID", keyspaceID),
zap.Uint8("version", version),
zap.Binary("dataKeyID", []byte(dataKeyID)),
zap.Error(err))
return nil, cerrors.ErrDecryptionFailed.Wrap(err)
}

log.Debug("data decrypted successfully",
zap.Uint32("keyspaceID", keyspaceID),
zap.String("dataKeyID", dataKeyID),
zap.Int("encryptedSize", len(encryptedData)),
zap.Int("plaintextSize", len(plaintext)))

return plaintext, nil
}
154 changes: 154 additions & 0 deletions pkg/encryption/encryption_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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 (
"bytes"
"context"
"testing"

"github.com/pingcap/ticdc/pkg/config"
cerrors "github.com/pingcap/ticdc/pkg/errors"
"github.com/stretchr/testify/require"
)

type mockMetaManager struct {
currentKey []byte
currentKeyID string
version byte
currentKeyErr error
dataKeys map[string][]byte
}

func (m *mockMetaManager) IsEncryptionEnabled(ctx context.Context, keyspaceID uint32) bool {
return true
}

func (m *mockMetaManager) GetCurrentDataKey(ctx context.Context, keyspaceID uint32) ([]byte, string, byte, error) {
return m.currentKey, m.currentKeyID, m.version, m.currentKeyErr
}

func (m *mockMetaManager) GetDataKey(ctx context.Context, keyspaceID uint32, dataKeyID string) ([]byte, error) {
if m.dataKeys == nil {
if m.currentKeyID == dataKeyID && len(m.currentKey) > 0 {
return m.currentKey, nil
}
return nil, cerrors.ErrDataKeyNotFound.GenWithStackByArgs("data key not found")
}
key, ok := m.dataKeys[dataKeyID]
if !ok {
return nil, cerrors.ErrDataKeyNotFound.GenWithStackByArgs("data key not found")
}
return key, nil
}

func (m *mockMetaManager) Start(ctx context.Context) error { return nil }
func (m *mockMetaManager) Stop() {}

func setAllowDegradeOnError(t *testing.T, allow bool) func() {
t.Helper()
original := config.GetGlobalServerConfig().Clone()
updated := original.Clone()
updated.Encryption.AllowDegradeOnError = allow
config.StoreGlobalServerConfig(updated)
return func() {
config.StoreGlobalServerConfig(original)
}
}

func TestEncryptDataAllowDegradeOnError(t *testing.T) {
restore := setAllowDegradeOnError(t, true)
defer restore()

meta := &mockMetaManager{
currentKeyErr: cerrors.ErrEncryptionFailed.GenWithStackByArgs("boom"),
}
manager := NewEncryptionManager(meta)
input := []byte("payload")

output, err := manager.EncryptData(context.Background(), 1, input)
require.NoError(t, err)
require.Equal(t, input, output)
}

func TestEncryptDataDisallowDegradeOnError(t *testing.T) {
restore := setAllowDegradeOnError(t, false)
defer restore()

meta := &mockMetaManager{
currentKeyErr: cerrors.ErrEncryptionFailed.GenWithStackByArgs("boom"),
}
manager := NewEncryptionManager(meta)
_, err := manager.EncryptData(context.Background(), 1, []byte("payload"))
require.Error(t, err)
}

func TestEncryptDataDisabledSkipsEncryption(t *testing.T) {
restore := setAllowDegradeOnError(t, false)
defer restore()

meta := &mockMetaManager{}
manager := NewEncryptionManager(meta)
input := []byte("payload")

output, err := manager.EncryptData(context.Background(), 1, input)
require.NoError(t, err)
require.Equal(t, input, output)
}

func TestEncryptDecryptRoundTrip(t *testing.T) {
restore := setAllowDegradeOnError(t, false)
defer restore()

key := bytes.Repeat([]byte{0x11}, 32)
meta := &mockMetaManager{
currentKey: key,
currentKeyID: "K01",
version: 0x01,
}
manager := NewEncryptionManager(meta)

input := []byte("round-trip-payload")
encrypted, err := manager.EncryptData(context.Background(), 1, input)
require.NoError(t, err)
require.NotEqual(t, input, encrypted)
require.True(t, IsEncrypted(encrypted))

decrypted, err := manager.DecryptData(context.Background(), 1, encrypted)
require.NoError(t, err)
require.Equal(t, input, decrypted)
}

func TestEncryptDecryptRoundTripWithAES128Key(t *testing.T) {
restore := setAllowDegradeOnError(t, false)
defer restore()

key := bytes.Repeat([]byte{0x22}, 16)
meta := &mockMetaManager{
currentKey: key,
currentKeyID: "K02",
version: 0x01,
}
manager := NewEncryptionManager(meta)

input := []byte("round-trip-with-16-byte-key")
encrypted, err := manager.EncryptData(context.Background(), 1, input)
require.NoError(t, err)
require.NotEqual(t, input, encrypted)
require.True(t, IsEncrypted(encrypted))

decrypted, err := manager.DecryptData(context.Background(), 1, encrypted)
require.NoError(t, err)
require.Equal(t, input, decrypted)
}
Loading
Loading