Skip to content

Commit a1c770e

Browse files
authored
Add encryption/decryption logic (#11114)
This pr introduces a encryption subsystem, providing the core encryption logic along with comprehensive tests. The main changes include the implementation of a ChaCha20-Poly1305-based encryption module, key provider interfaces and implementations (for both Kubernetes and in-memory usage), and thorough unit and integration tests to ensure reliability and correct behavior. ref: [design doc](https://github.com/radius-project/design-notes/blob/main/resources/2025-11-11-secrets-redactdata.md) **Encryption functionality:** * Implements the `Encryptor` type in `encryption.go`, providing methods for encrypting and decrypting data using ChaCha20-Poly1305 with support for associated data (AD) binding, and includes utility methods for key generation and encrypted data validation. **Key management:** * Defines the `KeyProvider` interface and provides two implementations in `keyprovider.go`: `KubernetesKeyProvider` (retrieves keys from Kubernetes Secrets with configurable options) and `InMemoryKeyProvider` (for testing and development), along with error handling for key retrieval and validation. **Testing and validation:** * Adds `keyprovider_test.go` with comprehensive tests for both key provider implementations, covering success and error cases. ## Type of change - This pull request adds or changes features of Radius and has an approved issue (#11071 ). Fixes: #11071 ## Contributor checklist Please verify that the PR meets the following requirements, where applicable: - An overview of proposed schema changes is included in a linked GitHub issue. - [x] Yes <!-- TaskRadio schema --> - [] Not applicable <!-- TaskRadio schema --> - A design document PR is created in the [design-notes repository](https://github.com/radius-project/design-notes/), if new APIs are being introduced. - [x] Yes <!-- TaskRadio design-pr --> - [] Not applicable <!-- TaskRadio design-pr --> - The design document has been reviewed and approved by Radius maintainers/approvers. - [x] Yes <!-- TaskRadio design-review --> - [] Not applicable <!-- TaskRadio design-review --> - A PR for the [samples repository](https://github.com/radius-project/samples) is created, if existing samples are affected by the changes in this PR. - [ ] Yes <!-- TaskRadio samples-pr --> - [x] Not applicable <!-- TaskRadio samples-pr --> - A PR for the [documentation repository](https://github.com/radius-project/docs) is created, if the changes in this PR affect the documentation or any user facing updates are made. - [ ] Yes <!-- TaskRadio docs-pr --> - [x] Not applicable <!-- TaskRadio docs-pr --> - A PR for the [recipes repository](https://github.com/radius-project/recipes) is created, if existing recipes are affected by the changes in this PR. - [ ] Yes <!-- TaskRadio recipes-pr --> - [x] Not applicable <!-- TaskRadio recipes-pr --> --------- Signed-off-by: lakshmimsft <ljavadekar@microsoft.com>
1 parent af9662f commit a1c770e

6 files changed

Lines changed: 3711 additions & 0 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
Copyright 2023 The Radius Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package encryption
18+
19+
import (
20+
"crypto/cipher"
21+
"crypto/rand"
22+
"crypto/sha256"
23+
"encoding/base64"
24+
"encoding/json"
25+
"errors"
26+
"fmt"
27+
"io"
28+
29+
"golang.org/x/crypto/chacha20poly1305"
30+
)
31+
32+
const (
33+
// KeySize is the required size for ChaCha20-Poly1305 keys (256 bits).
34+
KeySize = chacha20poly1305.KeySize
35+
36+
// NonceSize is the size of the nonce for ChaCha20-Poly1305.
37+
NonceSize = chacha20poly1305.NonceSize
38+
)
39+
40+
var (
41+
// ErrInvalidKeySize is returned when the encryption key is not the correct size.
42+
ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits)")
43+
44+
// ErrEncryptionFailed is returned when encryption fails.
45+
ErrEncryptionFailed = errors.New("encryption failed")
46+
47+
// ErrDecryptionFailed is returned when decryption fails.
48+
ErrDecryptionFailed = errors.New("decryption failed")
49+
50+
// ErrInvalidEncryptedData is returned when the encrypted data format is invalid.
51+
ErrInvalidEncryptedData = errors.New("invalid encrypted data format")
52+
53+
// ErrEmptyPlaintext is returned when attempting to encrypt empty data.
54+
ErrEmptyPlaintext = errors.New("plaintext cannot be empty")
55+
56+
// ErrAssociatedDataMismatch is returned when the associated data provided during
57+
// decryption does not match what was used during encryption.
58+
ErrAssociatedDataMismatch = errors.New("associated data mismatch")
59+
)
60+
61+
// EncryptedData represents the structure for storing encrypted data.
62+
// It contains the base64-encoded ciphertext and nonce, plus optional associated data hash.
63+
type EncryptedData struct {
64+
// Version is the key version used for encryption.
65+
// This allows decryption to use the correct key when multiple versions exist.
66+
Version int `json:"version,omitempty"`
67+
// Encrypted contains the base64-encoded ciphertext.
68+
Encrypted string `json:"encrypted"`
69+
// Nonce contains the base64-encoded nonce used for encryption.
70+
Nonce string `json:"nonce"`
71+
// AD contains a hash of the associated data used during encryption (optional).
72+
// This is stored for verification purposes - the actual AD must be provided during decryption.
73+
// The hash allows detection of AD mismatches without exposing the AD value.
74+
AD string `json:"ad,omitempty"`
75+
}
76+
77+
// Encryptor provides methods for encrypting and decrypting data using ChaCha20-Poly1305.
78+
type Encryptor struct {
79+
aead cipher.AEAD
80+
keyVersion int
81+
}
82+
83+
// NewEncryptor creates a new Encryptor with the provided 256-bit key.
84+
// Returns an error if the key is not exactly 32 bytes.
85+
// The key version defaults to 0 (unversioned). Use NewEncryptorWithVersion for versioned keys.
86+
func NewEncryptor(key []byte) (*Encryptor, error) {
87+
return NewEncryptorWithVersion(key, 0)
88+
}
89+
90+
// NewEncryptorWithVersion creates a new Encryptor with the provided key and version.
91+
// The version is stored in encrypted data to enable decryption with the correct key.
92+
func NewEncryptorWithVersion(key []byte, version int) (*Encryptor, error) {
93+
if len(key) != KeySize {
94+
return nil, ErrInvalidKeySize
95+
}
96+
97+
aead, err := chacha20poly1305.New(key)
98+
if err != nil {
99+
return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
100+
}
101+
102+
return &Encryptor{aead: aead, keyVersion: version}, nil
103+
}
104+
105+
// Encrypt encrypts the plaintext using ChaCha20-Poly1305 with Associated Data (AD).
106+
// The AD provides authentication for contextual data (like resource ID or field path) without
107+
// encrypting it. This binds the ciphertext to its context, preventing an attacker from
108+
// moving encrypted values between different resources or fields.
109+
//
110+
// The AD is authenticated but NOT encrypted - it must be provided again during decryption.
111+
// A hash of the AD is stored in the encrypted data structure to allow early detection of mismatches.
112+
//
113+
// Example AD values:
114+
// - Resource ID: "/planes/radius/local/resourceGroups/test/providers/Foo.Bar/myResources/test"
115+
// - Field path: "credentials.password"
116+
// - Combined: resourceID + ":" + fieldPath
117+
//
118+
// Pass nil for associatedData if no context binding is needed (not recommended for sensitive data).
119+
func (e *Encryptor) Encrypt(plaintext []byte, associatedData []byte) ([]byte, error) {
120+
if len(plaintext) == 0 {
121+
return nil, ErrEmptyPlaintext
122+
}
123+
124+
// Generate a unique nonce for this encryption operation
125+
nonce, err := generateNonce(e.aead.NonceSize())
126+
if err != nil {
127+
return nil, fmt.Errorf("%w: failed to generate nonce: %v", ErrEncryptionFailed, err)
128+
}
129+
130+
// Encrypt the plaintext with associated data
131+
// The AD is authenticated (included in the auth tag) but not encrypted
132+
ciphertext := e.aead.Seal(nil, nonce, plaintext, associatedData)
133+
134+
// Create the encrypted data structure
135+
encryptedData := EncryptedData{
136+
Version: e.keyVersion,
137+
Encrypted: base64.StdEncoding.EncodeToString(ciphertext),
138+
Nonce: base64.StdEncoding.EncodeToString(nonce),
139+
}
140+
141+
// Store a hash of the AD if provided (for verification during decryption)
142+
if len(associatedData) > 0 {
143+
encryptedData.AD = hashAD(associatedData)
144+
}
145+
146+
// Marshal to JSON
147+
result, err := json.Marshal(encryptedData)
148+
if err != nil {
149+
return nil, fmt.Errorf("%w: failed to marshal encrypted data: %v", ErrEncryptionFailed, err)
150+
}
151+
152+
return result, nil
153+
}
154+
155+
// Decrypt decrypts the data that was encrypted using the Encrypt method.
156+
// The associatedData must match what was provided during encryption; if the AD
157+
// was used during encryption, it must be provided here for successful decryption.
158+
// The input should be JSON-encoded EncryptedData.
159+
func (e *Encryptor) Decrypt(data []byte, associatedData []byte) ([]byte, error) {
160+
if len(data) == 0 {
161+
return nil, ErrInvalidEncryptedData
162+
}
163+
164+
// Parse the encrypted data structure
165+
var encryptedData EncryptedData
166+
if err := json.Unmarshal(data, &encryptedData); err != nil {
167+
return nil, fmt.Errorf("%w: failed to parse encrypted data: %v", ErrInvalidEncryptedData, err)
168+
}
169+
170+
// Verify AD hash matches if AD was used during encryption
171+
if encryptedData.AD != "" {
172+
if len(associatedData) == 0 {
173+
return nil, fmt.Errorf("%w: encrypted data requires associated data but none provided", ErrAssociatedDataMismatch)
174+
}
175+
if hashAD(associatedData) != encryptedData.AD {
176+
return nil, fmt.Errorf("%w: provided associated data does not match", ErrAssociatedDataMismatch)
177+
}
178+
}
179+
180+
// Decode the base64-encoded ciphertext
181+
ciphertext, err := base64.StdEncoding.DecodeString(encryptedData.Encrypted)
182+
if err != nil {
183+
return nil, fmt.Errorf("%w: failed to decode ciphertext: %v", ErrInvalidEncryptedData, err)
184+
}
185+
186+
// Decode the base64-encoded nonce
187+
nonce, err := base64.StdEncoding.DecodeString(encryptedData.Nonce)
188+
if err != nil {
189+
return nil, fmt.Errorf("%w: failed to decode nonce: %v", ErrInvalidEncryptedData, err)
190+
}
191+
192+
// Validate nonce size
193+
if len(nonce) != e.aead.NonceSize() {
194+
return nil, fmt.Errorf("%w: invalid nonce size", ErrInvalidEncryptedData)
195+
}
196+
197+
// Decrypt the ciphertext with the same associated data
198+
plaintext, err := e.aead.Open(nil, nonce, ciphertext, associatedData)
199+
if err != nil {
200+
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
201+
}
202+
203+
return plaintext, nil
204+
}
205+
206+
// EncryptString encrypts a string with associated data and returns the JSON-encoded encrypted data as a string.
207+
func (e *Encryptor) EncryptString(plaintext string, associatedData []byte) (string, error) {
208+
encrypted, err := e.Encrypt([]byte(plaintext), associatedData)
209+
if err != nil {
210+
return "", err
211+
}
212+
return string(encrypted), nil
213+
}
214+
215+
// DecryptString decrypts the JSON-encoded encrypted data with associated data and returns the original string.
216+
func (e *Encryptor) DecryptString(data string, associatedData []byte) (string, error) {
217+
decrypted, err := e.Decrypt([]byte(data), associatedData)
218+
if err != nil {
219+
return "", err
220+
}
221+
return string(decrypted), nil
222+
}
223+
224+
// hashAD creates a truncated SHA-256 hash of the associated data for storage.
225+
// This allows verification that the correct AD is provided during decryption
226+
// without storing the actual AD value.
227+
func hashAD(ad []byte) string {
228+
hash := sha256.Sum256(ad)
229+
// Use first 16 bytes (128 bits) - sufficient for verification, saves storage
230+
return base64.StdEncoding.EncodeToString(hash[:16])
231+
}
232+
233+
// generateNonce generates a cryptographically secure random nonce.
234+
func generateNonce(size int) ([]byte, error) {
235+
nonce := make([]byte, size)
236+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
237+
return nil, err
238+
}
239+
return nonce, nil
240+
}
241+
242+
// IsEncryptedData checks if the given data appears to be in the encrypted data format.
243+
// It validates that the data is valid JSON with non-empty encrypted and nonce fields,
244+
// and that both fields contain valid base64-encoded data with appropriate nonce size.
245+
func IsEncryptedData(data []byte) bool {
246+
var encryptedData EncryptedData
247+
if err := json.Unmarshal(data, &encryptedData); err != nil {
248+
return false
249+
}
250+
251+
if encryptedData.Encrypted == "" || encryptedData.Nonce == "" {
252+
return false
253+
}
254+
255+
// Validate base64 encoding of ciphertext
256+
if _, err := base64.StdEncoding.DecodeString(encryptedData.Encrypted); err != nil {
257+
return false
258+
}
259+
260+
// Validate base64 encoding and size of nonce
261+
nonce, err := base64.StdEncoding.DecodeString(encryptedData.Nonce)
262+
if err != nil {
263+
return false
264+
}
265+
266+
// ChaCha20-Poly1305 nonce must be 12 bytes
267+
if len(nonce) != NonceSize {
268+
return false
269+
}
270+
271+
return true
272+
}
273+
274+
// GenerateKey generates a new random 256-bit encryption key.
275+
func GenerateKey() ([]byte, error) {
276+
key := make([]byte, KeySize)
277+
if _, err := io.ReadFull(rand.Reader, key); err != nil {
278+
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
279+
}
280+
return key, nil
281+
}
282+
283+
// GetEncryptedDataVersion extracts the key version from encrypted data without decrypting.
284+
// Returns 0 if the version is not present (for backwards compatibility with unversioned data).
285+
func GetEncryptedDataVersion(data []byte) (int, error) {
286+
if len(data) == 0 {
287+
return 0, ErrInvalidEncryptedData
288+
}
289+
290+
var encryptedData EncryptedData
291+
if err := json.Unmarshal(data, &encryptedData); err != nil {
292+
return 0, fmt.Errorf("%w: failed to parse encrypted data: %v", ErrInvalidEncryptedData, err)
293+
}
294+
295+
return encryptedData.Version, nil
296+
}

0 commit comments

Comments
 (0)