zopp_secrets/
lib.rs

1use thiserror::Error;
2use zopp_crypto::{decrypt, encrypt, public_key_from_bytes, unwrap_key, Dek, Keypair, Nonce};
3use zopp_proto::{Environment, Secret, WorkspaceKeys};
4
5#[derive(Debug, Error)]
6pub enum SecretsError {
7    #[error("Crypto error: {0}")]
8    Crypto(String),
9    #[error("Invalid data: {0}")]
10    InvalidData(String),
11}
12
13/// High-level context for encrypting and decrypting secrets.
14/// Bundles principal key, workspace keys, and environment to hide all crypto details.
15pub struct SecretContext {
16    principal_keypair: Keypair,
17    workspace_keys: WorkspaceKeys,
18    environment: Environment,
19    workspace_name: String,
20    project_name: String,
21    environment_name: String,
22}
23
24impl SecretContext {
25    /// Create a new SecretContext from principal key, workspace keys, and environment.
26    pub fn new(
27        principal_x25519_private_key: [u8; 32],
28        workspace_keys: WorkspaceKeys,
29        environment: Environment,
30        workspace_name: String,
31        project_name: String,
32        environment_name: String,
33    ) -> Result<Self, SecretsError> {
34        let principal_keypair = Keypair::from_secret_bytes(&principal_x25519_private_key);
35
36        Ok(Self {
37            principal_keypair,
38            workspace_keys,
39            environment,
40            workspace_name,
41            project_name,
42            environment_name,
43        })
44    }
45
46    /// Decrypt a secret using the bundled context.
47    /// Handles all KEK/DEK unwrapping, ECDH, and AAD construction internally.
48    pub fn decrypt_secret(&self, secret: &Secret) -> Result<String, SecretsError> {
49        // 1. Unwrap KEK using ECDH
50        let ephemeral_public = public_key_from_bytes(&self.workspace_keys.ephemeral_pub)
51            .map_err(|e| SecretsError::InvalidData(e.to_string()))?;
52
53        let mut nonce_array = [0u8; 24];
54        nonce_array.copy_from_slice(&self.workspace_keys.kek_nonce);
55        let kek_nonce = Nonce(nonce_array);
56
57        let shared_secret = self.principal_keypair.shared_secret(&ephemeral_public);
58        let aad = format!("workspace:{}", self.workspace_keys.workspace_id).into_bytes();
59
60        let kek_unwrapped = unwrap_key(
61            &self.workspace_keys.kek_wrapped,
62            &kek_nonce,
63            &shared_secret,
64            &aad,
65        )
66        .map_err(|e| SecretsError::Crypto(e.to_string()))?;
67
68        let mut kek_bytes = [0u8; 32];
69        kek_bytes.copy_from_slice(&kek_unwrapped);
70        let kek =
71            Dek::from_bytes(&kek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
72
73        // 2. Unwrap DEK using KEK
74        let mut dek_nonce_array = [0u8; 24];
75        dek_nonce_array.copy_from_slice(&self.environment.dek_nonce);
76        let dek_nonce = Nonce(dek_nonce_array);
77
78        let dek_aad = format!(
79            "environment:{}:{}:{}",
80            self.workspace_name, self.project_name, self.environment_name
81        )
82        .into_bytes();
83
84        let dek_unwrapped = decrypt(&self.environment.dek_wrapped, &dek_nonce, &kek, &dek_aad)
85            .map_err(|e| SecretsError::Crypto(e.to_string()))?;
86
87        let mut dek_bytes = [0u8; 32];
88        dek_bytes.copy_from_slice(&dek_unwrapped);
89        let dek =
90            Dek::from_bytes(&dek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
91
92        // 3. Decrypt secret using DEK
93        let mut secret_nonce_array = [0u8; 24];
94        secret_nonce_array.copy_from_slice(&secret.nonce);
95        let secret_nonce = Nonce(secret_nonce_array);
96
97        let secret_aad = format!(
98            "secret:{}:{}:{}:{}",
99            self.workspace_name, self.project_name, self.environment_name, secret.key
100        )
101        .into_bytes();
102
103        let plaintext = decrypt(&secret.ciphertext, &secret_nonce, &dek, &secret_aad)
104            .map_err(|e| SecretsError::Crypto(e.to_string()))?;
105
106        String::from_utf8(plaintext.to_vec())
107            .map_err(|e| SecretsError::InvalidData(format!("Invalid UTF-8: {}", e)))
108    }
109
110    /// Encrypt a secret using the bundled context.
111    /// Handles all KEK/DEK unwrapping, ECDH, nonce generation, and AAD construction internally.
112    pub fn encrypt_secret(&self, key: &str, value: &str) -> Result<EncryptedSecret, SecretsError> {
113        // 1. Unwrap KEK using ECDH
114        let ephemeral_public = public_key_from_bytes(&self.workspace_keys.ephemeral_pub)
115            .map_err(|e| SecretsError::InvalidData(e.to_string()))?;
116
117        let mut nonce_array = [0u8; 24];
118        nonce_array.copy_from_slice(&self.workspace_keys.kek_nonce);
119        let kek_nonce = Nonce(nonce_array);
120
121        let shared_secret = self.principal_keypair.shared_secret(&ephemeral_public);
122        let aad = format!("workspace:{}", self.workspace_keys.workspace_id).into_bytes();
123
124        let kek_unwrapped = unwrap_key(
125            &self.workspace_keys.kek_wrapped,
126            &kek_nonce,
127            &shared_secret,
128            &aad,
129        )
130        .map_err(|e| SecretsError::Crypto(e.to_string()))?;
131
132        let mut kek_bytes = [0u8; 32];
133        kek_bytes.copy_from_slice(&kek_unwrapped);
134        let kek =
135            Dek::from_bytes(&kek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
136
137        // 2. Unwrap DEK using KEK
138        let mut dek_nonce_array = [0u8; 24];
139        dek_nonce_array.copy_from_slice(&self.environment.dek_nonce);
140        let dek_nonce = Nonce(dek_nonce_array);
141
142        let dek_aad = format!(
143            "environment:{}:{}:{}",
144            self.workspace_name, self.project_name, self.environment_name
145        )
146        .into_bytes();
147
148        let dek_unwrapped = decrypt(&self.environment.dek_wrapped, &dek_nonce, &kek, &dek_aad)
149            .map_err(|e| SecretsError::Crypto(e.to_string()))?;
150
151        let mut dek_bytes = [0u8; 32];
152        dek_bytes.copy_from_slice(&dek_unwrapped);
153        let dek =
154            Dek::from_bytes(&dek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
155
156        // 3. Construct AAD and encrypt value with DEK
157        let secret_aad = format!(
158            "secret:{}:{}:{}:{}",
159            self.workspace_name, self.project_name, self.environment_name, key
160        )
161        .into_bytes();
162
163        let (nonce, ciphertext) = encrypt(value.as_bytes(), &dek, &secret_aad)
164            .map_err(|e| SecretsError::Crypto(e.to_string()))?;
165
166        Ok(EncryptedSecret {
167            ciphertext: ciphertext.0,
168            nonce: nonce.0.to_vec(),
169        })
170    }
171}
172
173/// Result of encrypting a secret
174#[derive(Debug)]
175pub struct EncryptedSecret {
176    pub ciphertext: Vec<u8>,
177    pub nonce: Vec<u8>,
178}
179
180/// Unwrap an environment DEK given workspace keys and environment data.
181/// This is a lower-level helper useful for caching DEKs (e.g., in the operator).
182/// Returns the raw 32-byte DEK.
183pub fn unwrap_dek(
184    principal_x25519_private_key: &[u8; 32],
185    workspace_keys: &WorkspaceKeys,
186    environment: &Environment,
187    workspace_name: &str,
188    project_name: &str,
189    environment_name: &str,
190) -> Result<[u8; 32], SecretsError> {
191    let principal_keypair = Keypair::from_secret_bytes(principal_x25519_private_key);
192
193    // 1. Unwrap KEK using ECDH
194    let ephemeral_public = public_key_from_bytes(&workspace_keys.ephemeral_pub)
195        .map_err(|e| SecretsError::InvalidData(e.to_string()))?;
196
197    let mut nonce_array = [0u8; 24];
198    nonce_array.copy_from_slice(&workspace_keys.kek_nonce);
199    let kek_nonce = Nonce(nonce_array);
200
201    let shared_secret = principal_keypair.shared_secret(&ephemeral_public);
202    let aad = format!("workspace:{}", workspace_keys.workspace_id).into_bytes();
203
204    let kek_unwrapped = unwrap_key(
205        &workspace_keys.kek_wrapped,
206        &kek_nonce,
207        &shared_secret,
208        &aad,
209    )
210    .map_err(|e| SecretsError::Crypto(e.to_string()))?;
211
212    let mut kek_bytes = [0u8; 32];
213    kek_bytes.copy_from_slice(&kek_unwrapped);
214    let kek = Dek::from_bytes(&kek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
215
216    // 2. Unwrap DEK using KEK
217    let mut dek_nonce_array = [0u8; 24];
218    dek_nonce_array.copy_from_slice(&environment.dek_nonce);
219    let dek_nonce = Nonce(dek_nonce_array);
220
221    let dek_aad = format!(
222        "environment:{}:{}:{}",
223        workspace_name, project_name, environment_name
224    )
225    .into_bytes();
226
227    let dek_unwrapped = decrypt(&environment.dek_wrapped, &dek_nonce, &kek, &dek_aad)
228        .map_err(|e| SecretsError::Crypto(e.to_string()))?;
229
230    let mut dek_bytes = [0u8; 32];
231    dek_bytes.copy_from_slice(&dek_unwrapped);
232
233    Ok(dek_bytes)
234}
235
236/// Decrypt a single secret given a raw DEK and environment context.
237/// This is a lower-level helper useful when you have a cached DEK.
238pub fn decrypt_secret_with_dek(
239    dek_bytes: &[u8; 32],
240    secret: &Secret,
241    workspace_name: &str,
242    project_name: &str,
243    environment_name: &str,
244) -> Result<String, SecretsError> {
245    let dek = Dek::from_bytes(dek_bytes).map_err(|e| SecretsError::InvalidData(e.to_string()))?;
246
247    let mut secret_nonce_array = [0u8; 24];
248    secret_nonce_array.copy_from_slice(&secret.nonce);
249    let secret_nonce = Nonce(secret_nonce_array);
250
251    let secret_aad = format!(
252        "secret:{}:{}:{}:{}",
253        workspace_name, project_name, environment_name, secret.key
254    )
255    .into_bytes();
256
257    let plaintext = decrypt(&secret.ciphertext, &secret_nonce, &dek, &secret_aad)
258        .map_err(|e| SecretsError::Crypto(e.to_string()))?;
259
260    String::from_utf8(plaintext.to_vec())
261        .map_err(|e| SecretsError::InvalidData(format!("Invalid UTF-8: {}", e)))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use zopp_crypto::{encrypt, generate_dek, wrap_key, Keypair};
268
269    /// Helper to create test workspace keys
270    fn create_test_workspace_keys(
271        principal_keypair: &Keypair,
272        kek: &[u8; 32],
273        workspace_id: &str,
274    ) -> WorkspaceKeys {
275        // Generate ephemeral keypair for wrapping
276        let ephemeral = Keypair::generate();
277        let shared_secret = ephemeral.shared_secret(principal_keypair.public_key());
278        let aad = format!("workspace:{}", workspace_id).into_bytes();
279        let (nonce, ciphertext) = wrap_key(kek, &shared_secret, &aad).unwrap();
280
281        WorkspaceKeys {
282            workspace_id: workspace_id.to_string(),
283            ephemeral_pub: ephemeral.public_key_bytes().to_vec(),
284            kek_wrapped: ciphertext.0,
285            kek_nonce: nonce.0.to_vec(),
286        }
287    }
288
289    /// Helper to create test environment
290    fn create_test_environment(
291        kek: &[u8; 32],
292        dek: &[u8; 32],
293        workspace_name: &str,
294        project_name: &str,
295        environment_name: &str,
296    ) -> Environment {
297        let kek_dek = Dek::from_bytes(kek).unwrap();
298        let aad = format!(
299            "environment:{}:{}:{}",
300            workspace_name, project_name, environment_name
301        )
302        .into_bytes();
303        let (nonce, ciphertext) = encrypt(dek, &kek_dek, &aad).unwrap();
304
305        Environment {
306            id: "env-123".to_string(),
307            project_id: "proj-456".to_string(),
308            name: environment_name.to_string(),
309            dek_wrapped: ciphertext.0,
310            dek_nonce: nonce.0.to_vec(),
311            created_at: 0,
312            updated_at: 0,
313        }
314    }
315
316    /// Helper to create test secret
317    fn create_test_secret(
318        dek: &[u8; 32],
319        key: &str,
320        value: &str,
321        workspace_name: &str,
322        project_name: &str,
323        environment_name: &str,
324    ) -> Secret {
325        let dek_key = Dek::from_bytes(dek).unwrap();
326        let aad = format!(
327            "secret:{}:{}:{}:{}",
328            workspace_name, project_name, environment_name, key
329        )
330        .into_bytes();
331        let (nonce, ciphertext) = encrypt(value.as_bytes(), &dek_key, &aad).unwrap();
332
333        Secret {
334            key: key.to_string(),
335            nonce: nonce.0.to_vec(),
336            ciphertext: ciphertext.0,
337        }
338    }
339
340    #[test]
341    fn test_secret_context_encrypt_decrypt_roundtrip() {
342        // Setup
343        let principal_keypair = Keypair::generate();
344        let kek = generate_dek();
345        let dek = generate_dek();
346        let workspace_name = "test-workspace";
347        let project_name = "test-project";
348        let environment_name = "test-env";
349        let workspace_id = "ws-123";
350
351        let workspace_keys =
352            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
353        let environment = create_test_environment(
354            kek.as_bytes(),
355            dek.as_bytes(),
356            workspace_name,
357            project_name,
358            environment_name,
359        );
360
361        // Create context
362        let ctx = SecretContext::new(
363            principal_keypair.secret_key_bytes(),
364            workspace_keys,
365            environment,
366            workspace_name.to_string(),
367            project_name.to_string(),
368            environment_name.to_string(),
369        )
370        .unwrap();
371
372        // Test encrypt/decrypt roundtrip
373        let key = "DATABASE_URL";
374        let value = "postgres://localhost/test";
375        let encrypted = ctx.encrypt_secret(key, value).unwrap();
376
377        // Create Secret from encrypted data
378        let secret = Secret {
379            key: key.to_string(),
380            nonce: encrypted.nonce,
381            ciphertext: encrypted.ciphertext,
382        };
383
384        let decrypted = ctx.decrypt_secret(&secret).unwrap();
385        assert_eq!(decrypted, value);
386    }
387
388    #[test]
389    fn test_secret_context_decrypt_existing_secret() {
390        // Setup - simulate server-created secret
391        let principal_keypair = Keypair::generate();
392        let kek = generate_dek();
393        let dek = generate_dek();
394        let workspace_name = "acme";
395        let project_name = "backend";
396        let environment_name = "production";
397        let workspace_id = "ws-789";
398
399        let workspace_keys =
400            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
401        let environment = create_test_environment(
402            kek.as_bytes(),
403            dek.as_bytes(),
404            workspace_name,
405            project_name,
406            environment_name,
407        );
408
409        // Create a secret the way the server would
410        let secret = create_test_secret(
411            dek.as_bytes(),
412            "API_KEY",
413            "super-secret-key-12345",
414            workspace_name,
415            project_name,
416            environment_name,
417        );
418
419        // Create context and decrypt
420        let ctx = SecretContext::new(
421            principal_keypair.secret_key_bytes(),
422            workspace_keys,
423            environment,
424            workspace_name.to_string(),
425            project_name.to_string(),
426            environment_name.to_string(),
427        )
428        .unwrap();
429
430        let decrypted = ctx.decrypt_secret(&secret).unwrap();
431        assert_eq!(decrypted, "super-secret-key-12345");
432    }
433
434    #[test]
435    fn test_secret_context_wrong_principal_fails() {
436        // Setup with principal A
437        let principal_a = Keypair::generate();
438        let principal_b = Keypair::generate(); // Different principal
439        let kek = generate_dek();
440        let dek = generate_dek();
441        let workspace_name = "test-workspace";
442        let project_name = "test-project";
443        let environment_name = "test-env";
444        let workspace_id = "ws-999";
445
446        // Workspace keys wrapped for principal A
447        let workspace_keys = create_test_workspace_keys(&principal_a, kek.as_bytes(), workspace_id);
448        let environment = create_test_environment(
449            kek.as_bytes(),
450            dek.as_bytes(),
451            workspace_name,
452            project_name,
453            environment_name,
454        );
455
456        let secret = create_test_secret(
457            dek.as_bytes(),
458            "SECRET",
459            "value",
460            workspace_name,
461            project_name,
462            environment_name,
463        );
464
465        // Try to decrypt with principal B (should fail)
466        let ctx = SecretContext::new(
467            principal_b.secret_key_bytes(), // Wrong principal!
468            workspace_keys,
469            environment,
470            workspace_name.to_string(),
471            project_name.to_string(),
472            environment_name.to_string(),
473        )
474        .unwrap();
475
476        let result = ctx.decrypt_secret(&secret);
477        assert!(result.is_err());
478    }
479
480    #[test]
481    fn test_secret_context_tampered_ciphertext_fails() {
482        let principal_keypair = Keypair::generate();
483        let kek = generate_dek();
484        let dek = generate_dek();
485        let workspace_name = "test-workspace";
486        let project_name = "test-project";
487        let environment_name = "test-env";
488        let workspace_id = "ws-111";
489
490        let workspace_keys =
491            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
492        let environment = create_test_environment(
493            kek.as_bytes(),
494            dek.as_bytes(),
495            workspace_name,
496            project_name,
497            environment_name,
498        );
499
500        let mut secret = create_test_secret(
501            dek.as_bytes(),
502            "SECRET",
503            "value",
504            workspace_name,
505            project_name,
506            environment_name,
507        );
508
509        // Tamper with ciphertext
510        secret.ciphertext[0] ^= 0x01;
511
512        let ctx = SecretContext::new(
513            principal_keypair.secret_key_bytes(),
514            workspace_keys,
515            environment,
516            workspace_name.to_string(),
517            project_name.to_string(),
518            environment_name.to_string(),
519        )
520        .unwrap();
521
522        let result = ctx.decrypt_secret(&secret);
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn test_secret_context_wrong_workspace_context_fails() {
528        let principal_keypair = Keypair::generate();
529        let kek = generate_dek();
530        let dek = generate_dek();
531        let workspace_name = "workspace-a";
532        let project_name = "project-a";
533        let environment_name = "env-a";
534        let workspace_id = "ws-222";
535
536        let workspace_keys =
537            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
538        let environment = create_test_environment(
539            kek.as_bytes(),
540            dek.as_bytes(),
541            workspace_name,
542            project_name,
543            environment_name,
544        );
545
546        // Create secret with correct context
547        let secret = create_test_secret(
548            dek.as_bytes(),
549            "SECRET",
550            "value",
551            workspace_name,
552            project_name,
553            environment_name,
554        );
555
556        // Try to decrypt with WRONG workspace context
557        let ctx = SecretContext::new(
558            principal_keypair.secret_key_bytes(),
559            workspace_keys,
560            environment,
561            "workspace-b".to_string(), // Wrong workspace!
562            project_name.to_string(),
563            environment_name.to_string(),
564        )
565        .unwrap();
566
567        let result = ctx.decrypt_secret(&secret);
568        assert!(result.is_err()); // Should fail due to AAD mismatch
569    }
570
571    #[test]
572    fn test_unwrap_dek_function() {
573        let principal_keypair = Keypair::generate();
574        let kek = generate_dek();
575        let dek = generate_dek();
576        let workspace_name = "test-ws";
577        let project_name = "test-proj";
578        let environment_name = "test-env";
579        let workspace_id = "ws-333";
580
581        let workspace_keys =
582            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
583        let environment = create_test_environment(
584            kek.as_bytes(),
585            dek.as_bytes(),
586            workspace_name,
587            project_name,
588            environment_name,
589        );
590
591        // Unwrap DEK
592        let unwrapped_dek = unwrap_dek(
593            &principal_keypair.secret_key_bytes(),
594            &workspace_keys,
595            &environment,
596            workspace_name,
597            project_name,
598            environment_name,
599        )
600        .unwrap();
601
602        // Should match original DEK
603        assert_eq!(unwrapped_dek, *dek.as_bytes());
604    }
605
606    #[test]
607    fn test_decrypt_secret_with_dek_function() {
608        let dek = generate_dek();
609        let workspace_name = "test-ws";
610        let project_name = "test-proj";
611        let environment_name = "test-env";
612
613        let secret = create_test_secret(
614            dek.as_bytes(),
615            "MY_SECRET",
616            "my-value",
617            workspace_name,
618            project_name,
619            environment_name,
620        );
621
622        // Decrypt with raw DEK
623        let decrypted = decrypt_secret_with_dek(
624            dek.as_bytes(),
625            &secret,
626            workspace_name,
627            project_name,
628            environment_name,
629        )
630        .unwrap();
631
632        assert_eq!(decrypted, "my-value");
633    }
634
635    #[test]
636    fn test_decrypt_secret_with_dek_wrong_dek_fails() {
637        let dek = generate_dek();
638        let wrong_dek = generate_dek(); // Different DEK
639        let workspace_name = "test-ws";
640        let project_name = "test-proj";
641        let environment_name = "test-env";
642
643        let secret = create_test_secret(
644            dek.as_bytes(),
645            "MY_SECRET",
646            "my-value",
647            workspace_name,
648            project_name,
649            environment_name,
650        );
651
652        // Try to decrypt with wrong DEK
653        let result = decrypt_secret_with_dek(
654            wrong_dek.as_bytes(), // Wrong DEK!
655            &secret,
656            workspace_name,
657            project_name,
658            environment_name,
659        );
660
661        assert!(result.is_err());
662    }
663
664    #[test]
665    fn test_multiple_secrets_same_context() {
666        // Test that we can encrypt/decrypt multiple secrets with same context
667        let principal_keypair = Keypair::generate();
668        let kek = generate_dek();
669        let dek = generate_dek();
670        let workspace_name = "multi-test";
671        let project_name = "multi-proj";
672        let environment_name = "multi-env";
673        let workspace_id = "ws-444";
674
675        let workspace_keys =
676            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
677        let environment = create_test_environment(
678            kek.as_bytes(),
679            dek.as_bytes(),
680            workspace_name,
681            project_name,
682            environment_name,
683        );
684
685        let ctx = SecretContext::new(
686            principal_keypair.secret_key_bytes(),
687            workspace_keys,
688            environment,
689            workspace_name.to_string(),
690            project_name.to_string(),
691            environment_name.to_string(),
692        )
693        .unwrap();
694
695        // Encrypt multiple secrets
696        let secrets = vec![
697            ("DATABASE_URL", "postgres://localhost/db"),
698            ("API_KEY", "sk-1234567890"),
699            ("REDIS_URL", "redis://localhost:6379"),
700        ];
701
702        for (key, value) in &secrets {
703            let encrypted = ctx.encrypt_secret(key, value).unwrap();
704            let secret = Secret {
705                key: key.to_string(),
706                nonce: encrypted.nonce,
707                ciphertext: encrypted.ciphertext,
708            };
709            let decrypted = ctx.decrypt_secret(&secret).unwrap();
710            assert_eq!(&decrypted, value);
711        }
712    }
713
714    #[test]
715    fn test_empty_secret_value() {
716        let principal_keypair = Keypair::generate();
717        let kek = generate_dek();
718        let dek = generate_dek();
719        let workspace_name = "test";
720        let project_name = "test";
721        let environment_name = "test";
722        let workspace_id = "ws-555";
723
724        let workspace_keys =
725            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
726        let environment = create_test_environment(
727            kek.as_bytes(),
728            dek.as_bytes(),
729            workspace_name,
730            project_name,
731            environment_name,
732        );
733
734        let ctx = SecretContext::new(
735            principal_keypair.secret_key_bytes(),
736            workspace_keys,
737            environment,
738            workspace_name.to_string(),
739            project_name.to_string(),
740            environment_name.to_string(),
741        )
742        .unwrap();
743
744        // Test empty value
745        let encrypted = ctx.encrypt_secret("EMPTY_SECRET", "").unwrap();
746        let secret = Secret {
747            key: "EMPTY_SECRET".to_string(),
748            nonce: encrypted.nonce,
749            ciphertext: encrypted.ciphertext,
750        };
751        let decrypted = ctx.decrypt_secret(&secret).unwrap();
752        assert_eq!(decrypted, "");
753    }
754
755    #[test]
756    fn test_unicode_secret_value() {
757        let principal_keypair = Keypair::generate();
758        let kek = generate_dek();
759        let dek = generate_dek();
760        let workspace_name = "test";
761        let project_name = "test";
762        let environment_name = "test";
763        let workspace_id = "ws-666";
764
765        let workspace_keys =
766            create_test_workspace_keys(&principal_keypair, kek.as_bytes(), workspace_id);
767        let environment = create_test_environment(
768            kek.as_bytes(),
769            dek.as_bytes(),
770            workspace_name,
771            project_name,
772            environment_name,
773        );
774
775        let ctx = SecretContext::new(
776            principal_keypair.secret_key_bytes(),
777            workspace_keys,
778            environment,
779            workspace_name.to_string(),
780            project_name.to_string(),
781            environment_name.to_string(),
782        )
783        .unwrap();
784
785        // Test unicode value
786        let unicode_value = "Hello 世界 🔐 Здравствуй мир";
787        let encrypted = ctx.encrypt_secret("UNICODE_SECRET", unicode_value).unwrap();
788        let secret = Secret {
789            key: "UNICODE_SECRET".to_string(),
790            nonce: encrypted.nonce,
791            ciphertext: encrypted.ciphertext,
792        };
793        let decrypted = ctx.decrypt_secret(&secret).unwrap();
794        assert_eq!(decrypted, unicode_value);
795    }
796}