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
13pub 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 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 pub fn decrypt_secret(&self, secret: &Secret) -> Result<String, SecretsError> {
49 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 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 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 pub fn encrypt_secret(&self, key: &str, value: &str) -> Result<EncryptedSecret, SecretsError> {
113 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 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 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#[derive(Debug)]
175pub struct EncryptedSecret {
176 pub ciphertext: Vec<u8>,
177 pub nonce: Vec<u8>,
178}
179
180pub 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 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 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
236pub 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 fn create_test_workspace_keys(
271 principal_keypair: &Keypair,
272 kek: &[u8; 32],
273 workspace_id: &str,
274 ) -> WorkspaceKeys {
275 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 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 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 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 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 let key = "DATABASE_URL";
374 let value = "postgres://localhost/test";
375 let encrypted = ctx.encrypt_secret(key, value).unwrap();
376
377 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 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 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 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 let principal_a = Keypair::generate();
438 let principal_b = Keypair::generate(); 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 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 let ctx = SecretContext::new(
467 principal_b.secret_key_bytes(), 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 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 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 let ctx = SecretContext::new(
558 principal_keypair.secret_key_bytes(),
559 workspace_keys,
560 environment,
561 "workspace-b".to_string(), 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()); }
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 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 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 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(); 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 let result = decrypt_secret_with_dek(
654 wrong_dek.as_bytes(), &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 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 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 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 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}