Skip to content

Commit db7c017

Browse files
committed
feat(encryption): add encryption primitives and wire format
Signed-off-by: tenfyzhong <tenfy@tenfy.cn>
1 parent e8de3a6 commit db7c017

File tree

8 files changed

+638
-0
lines changed

8 files changed

+638
-0
lines changed

pkg/encryption/cipher.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2025 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package encryption
15+
16+
import (
17+
"crypto/aes"
18+
"crypto/cipher"
19+
"crypto/rand"
20+
21+
cerrors "github.com/pingcap/ticdc/pkg/errors"
22+
)
23+
24+
// Cipher is the interface for encryption/decryption operations
25+
type Cipher interface {
26+
// Encrypt encrypts data using the provided key and IV
27+
Encrypt(data, key, iv []byte) ([]byte, error)
28+
29+
// Decrypt decrypts data using the provided key and IV
30+
Decrypt(data, key, iv []byte) ([]byte, error)
31+
32+
// IVSize returns the required IV size in bytes
33+
IVSize() int
34+
}
35+
36+
// AES256CTRCipher implements AES-CTR encryption for AES key sizes.
37+
type AES256CTRCipher struct{}
38+
39+
// NewAES256CTRCipher creates a new AES-CTR cipher.
40+
func NewAES256CTRCipher() *AES256CTRCipher {
41+
return &AES256CTRCipher{}
42+
}
43+
44+
// IVSize returns the IV size for AES-CTR (16 bytes)
45+
func (c *AES256CTRCipher) IVSize() int {
46+
return aes.BlockSize
47+
}
48+
49+
func isValidAESKeySize(key []byte) bool {
50+
switch len(key) {
51+
case 16, 24, 32:
52+
return true
53+
default:
54+
return false
55+
}
56+
}
57+
58+
// Encrypt encrypts data using AES-CTR.
59+
func (c *AES256CTRCipher) Encrypt(data, key, iv []byte) ([]byte, error) {
60+
if !isValidAESKeySize(key) {
61+
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("key must be 16, 24, or 32 bytes for AES-CTR")
62+
}
63+
if len(iv) != c.IVSize() {
64+
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("IV must be 16 bytes")
65+
}
66+
67+
block, err := aes.NewCipher(key)
68+
if err != nil {
69+
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
70+
}
71+
72+
stream := cipher.NewCTR(block, iv)
73+
ciphertext := make([]byte, len(data))
74+
stream.XORKeyStream(ciphertext, data)
75+
76+
return ciphertext, nil
77+
}
78+
79+
// Decrypt decrypts data using AES-CTR.
80+
func (c *AES256CTRCipher) Decrypt(data, key, iv []byte) ([]byte, error) {
81+
if !isValidAESKeySize(key) {
82+
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("key must be 16, 24, or 32 bytes for AES-CTR")
83+
}
84+
if len(iv) != c.IVSize() {
85+
return nil, cerrors.ErrDecryptionFailed.GenWithStackByArgs("IV must be 16 bytes")
86+
}
87+
88+
block, err := aes.NewCipher(key)
89+
if err != nil {
90+
return nil, cerrors.ErrDecryptionFailed.Wrap(err)
91+
}
92+
93+
stream := cipher.NewCTR(block, iv)
94+
plaintext := make([]byte, len(data))
95+
stream.XORKeyStream(plaintext, data)
96+
97+
return plaintext, nil
98+
}
99+
100+
// GenerateIV generates a random IV of the specified size
101+
func GenerateIV(size int) ([]byte, error) {
102+
iv := make([]byte, size)
103+
if _, err := rand.Read(iv); err != nil {
104+
return nil, cerrors.ErrEncryptionFailed.Wrap(err)
105+
}
106+
return iv, nil
107+
}

pkg/encryption/cipher_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2025 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package encryption
15+
16+
import (
17+
"testing"
18+
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
func TestAES256CTREncryptDecrypt(t *testing.T) {
23+
key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes
24+
iv := []byte("1234567890abcdef") // 16 bytes
25+
plain := []byte("hello world")
26+
27+
cipherImpl := NewAES256CTRCipher()
28+
encrypted, err := cipherImpl.Encrypt(plain, key, iv)
29+
require.NoError(t, err)
30+
require.NotEqual(t, plain, encrypted)
31+
32+
decrypted, err := cipherImpl.Decrypt(encrypted, key, iv)
33+
require.NoError(t, err)
34+
require.Equal(t, plain, decrypted)
35+
}

pkg/encryption/data_key_id.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2026 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package encryption
15+
16+
import "github.com/pingcap/errors"
17+
18+
// DataKeyID represents a 3-byte data key identifier in the encryption header.
19+
type DataKeyID [3]byte
20+
21+
// ToString converts DataKeyID to string.
22+
func (id DataKeyID) ToString() string {
23+
return string(id[:])
24+
}
25+
26+
// DataKeyIDFromString creates DataKeyID from string (must be 3 bytes).
27+
func DataKeyIDFromString(s string) (DataKeyID, error) {
28+
if len(s) != 3 {
29+
return DataKeyID{}, errors.New("data key ID must be exactly 3 bytes")
30+
}
31+
var id DataKeyID
32+
copy(id[:], s)
33+
return id, nil
34+
}

pkg/encryption/data_key_id_24be.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2026 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package encryption
15+
16+
import cerrors "github.com/pingcap/ticdc/pkg/errors"
17+
18+
func encodeDataKeyID24BE(id uint32) (string, error) {
19+
if id > 0xFFFFFF {
20+
return "", cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID exceeds 24-bit range")
21+
}
22+
b := [3]byte{byte(id >> 16), byte(id >> 8), byte(id)}
23+
return string(b[:]), nil
24+
}
25+
26+
func decodeDataKeyID24BE(id string) (uint32, error) {
27+
if len(id) != 3 {
28+
return 0, cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID must be 3 bytes")
29+
}
30+
b := []byte(id)
31+
return uint32(b[0])<<16 | uint32(b[1])<<8 | uint32(b[2]), nil
32+
}

pkg/encryption/format.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright 2025 PingCAP, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package encryption
15+
16+
import (
17+
cerrors "github.com/pingcap/ticdc/pkg/errors"
18+
)
19+
20+
const (
21+
// EncryptionHeaderSize is the size of encryption header (4 bytes)
22+
// Format: [version(1 byte)][dataKeyID(3 bytes)]
23+
EncryptionHeaderSize = 4
24+
25+
// VersionUnencrypted indicates data is not encrypted
26+
VersionUnencrypted byte = 0x00
27+
)
28+
29+
// EncryptionHeader represents the 4-byte encryption header
30+
// Format: [version(1 byte)][dataKeyID(3 bytes)]
31+
type EncryptionHeader struct {
32+
Version byte
33+
DataKeyID [3]byte
34+
}
35+
36+
// EncodeEncryptedData encodes data with encryption header
37+
// Format: [version(1)][dataKeyID(3)][encryptedData]
38+
// The version byte comes from the encryption metadata obtained from TiKV
39+
func EncodeEncryptedData(data []byte, version byte, dataKeyID string) ([]byte, error) {
40+
if len(dataKeyID) != 3 {
41+
return nil, cerrors.ErrInvalidDataKeyID.GenWithStackByArgs("data key ID must be 3 bytes")
42+
}
43+
44+
if version == VersionUnencrypted {
45+
return nil, cerrors.ErrEncryptionFailed.GenWithStackByArgs("version cannot be 0 for encrypted data")
46+
}
47+
48+
result := make([]byte, EncryptionHeaderSize+len(data))
49+
result[0] = version
50+
copy(result[1:4], dataKeyID)
51+
copy(result[4:], data)
52+
53+
return result, nil
54+
}
55+
56+
// DecodeEncryptedData decodes data and extracts encryption header
57+
// Returns: (version, dataKeyID, encryptedData, error)
58+
func DecodeEncryptedData(data []byte) (byte, string, []byte, error) {
59+
if len(data) < EncryptionHeaderSize {
60+
return 0, "", nil, cerrors.ErrDecodeFailed.GenWithStackByArgs("data too short for encryption header")
61+
}
62+
63+
version := data[0]
64+
var dataKeyID [3]byte
65+
copy(dataKeyID[:], data[1:4])
66+
encryptedData := data[4:]
67+
68+
return version, string(dataKeyID[:]), encryptedData, nil
69+
}
70+
71+
// IsEncrypted checks if data is encrypted by examining the version byte
72+
// Data is considered encrypted if version != 0 (VersionUnencrypted)
73+
// The caller should validate that the version matches expected versions from TiKV metadata
74+
func IsEncrypted(data []byte) bool {
75+
if len(data) < EncryptionHeaderSize {
76+
return false
77+
}
78+
return data[0] != VersionUnencrypted
79+
}
80+
81+
// IsEncryptedWithVersion checks if data is encrypted with a specific version
82+
// This is useful when you know the expected version from TiKV metadata
83+
func IsEncryptedWithVersion(data []byte, expectedVersion byte) bool {
84+
if len(data) < EncryptionHeaderSize {
85+
return false
86+
}
87+
return data[0] == expectedVersion
88+
}
89+
90+
// GetVersion extracts the version byte from data
91+
// Returns 0 if data is too short
92+
func GetVersion(data []byte) byte {
93+
if len(data) < EncryptionHeaderSize {
94+
return 0
95+
}
96+
return data[0]
97+
}
98+
99+
// EncodeUnencryptedData encodes unencrypted data with version=0 header
100+
// This creates a unified format where all new data has the 4-byte header
101+
func EncodeUnencryptedData(data []byte) []byte {
102+
result := make([]byte, EncryptionHeaderSize+len(data))
103+
result[0] = VersionUnencrypted
104+
// DataKeyID is zero for unencrypted data (3 bytes)
105+
result[1] = 0
106+
result[2] = 0
107+
result[3] = 0
108+
copy(result[4:], data)
109+
return result
110+
}
111+
112+
// DecodeUnencryptedData decodes unencrypted data (removes header if present)
113+
func DecodeUnencryptedData(data []byte) ([]byte, error) {
114+
if len(data) < EncryptionHeaderSize {
115+
// No header, return as-is (backward compatibility)
116+
return data, nil
117+
}
118+
119+
version := data[0]
120+
dataKeyID1, dataKeyID2, dataKeyID3 := data[1], data[2], data[3]
121+
dataKeyIDIsZero := dataKeyID1 == 0 && dataKeyID2 == 0 && dataKeyID3 == 0
122+
123+
if version == VersionUnencrypted && dataKeyIDIsZero {
124+
// New-format unencrypted data with header, remove header
125+
return data[4:], nil
126+
}
127+
128+
// For backward compatibility, treat any other format as legacy unencrypted data
129+
// This includes:
130+
// - Legacy data without header (any pattern)
131+
// - Data that might look like encrypted but is actually legacy
132+
// The caller is responsible for ensuring data is not actually encrypted
133+
return data, nil
134+
}
135+
136+
// ExtractDataKeyID extracts the data key ID from encrypted data
137+
func ExtractDataKeyID(data []byte) (string, error) {
138+
if len(data) < EncryptionHeaderSize {
139+
return "", cerrors.ErrDecodeFailed.GenWithStackByArgs("data too short")
140+
}
141+
142+
version := data[0]
143+
dataKeyID1, dataKeyID2, dataKeyID3 := data[1], data[2], data[3]
144+
dataKeyIDIsZero := dataKeyID1 == 0 && dataKeyID2 == 0 && dataKeyID3 == 0
145+
146+
// Only extract key ID from data that definitively looks like new-format encrypted:
147+
// - version != 0 (encrypted data has non-zero version)
148+
// - DataKeyID is non-zero (encrypted data always has a valid key ID)
149+
if version != VersionUnencrypted && !dataKeyIDIsZero {
150+
var keyID [3]byte
151+
copy(keyID[:], data[1:4])
152+
return string(keyID[:]), nil
153+
}
154+
155+
// Otherwise, this is not encrypted data (legacy data or new-format unencrypted)
156+
return "", cerrors.ErrDecodeFailed.GenWithStackByArgs("data is not encrypted")
157+
}

0 commit comments

Comments
 (0)