1use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
10use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
11use serde::{Deserialize, Serialize};
12use zeroize::Zeroize;
13
14use crate::error::CoreError;
15use crate::record::SecretRecord;
16
17pub const KEY_LEN: usize = 32;
19pub const NONCE_LEN: usize = 12;
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct SealedRecord {
27 pub nonce: Vec<u8>,
29 pub ciphertext: Vec<u8>,
31}
32
33pub fn seal_bytes(plaintext: &[u8], key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
38 let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
39 let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
40 let ciphertext = cipher
41 .encrypt(&nonce, plaintext)
42 .map_err(|_| CoreError::Crypto)?;
43 Ok(SealedRecord {
44 nonce: nonce.to_vec(),
45 ciphertext,
46 })
47}
48
49pub fn open_bytes(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<Vec<u8>, CoreError> {
53 if sealed.nonce.len() != NONCE_LEN {
54 return Err(CoreError::Crypto);
55 }
56 let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
57 let nonce = Nonce::from_slice(&sealed.nonce);
58 cipher
59 .decrypt(nonce, sealed.ciphertext.as_slice())
60 .map_err(|_| CoreError::Crypto)
61}
62
63pub fn seal(record: &SecretRecord, key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
68 let mut plaintext =
69 serde_json::to_vec(record).map_err(|e| CoreError::Serialization(e.to_string()))?;
70 let sealed = seal_bytes(&plaintext, key);
71 plaintext.zeroize();
72 sealed
73}
74
75pub fn open(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<SecretRecord, CoreError> {
79 let mut plaintext = open_bytes(sealed, key)?;
80 let record = serde_json::from_slice::<SecretRecord>(&plaintext)
81 .map_err(|e| CoreError::Serialization(e.to_string()));
82 plaintext.zeroize();
83 record
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use crate::secret::SecretValue;
90 use crate::sensitivity::Sensitivity;
91
92 fn key() -> [u8; KEY_LEN] {
93 [7u8; KEY_LEN]
94 }
95
96 fn literal(value: &str) -> SecretRecord {
97 SecretRecord::Literal {
98 value: SecretValue::from(value),
99 sensitivity: Sensitivity::High,
100 revealable: false,
101 environment: "prod".to_string(),
102 component: "db".to_string(),
103 key: "password".to_string(),
104 description: None,
105 created: "2026-05-30T00:00:00Z".to_string(),
106 updated: "2026-05-30T00:00:00Z".to_string(),
107 }
108 }
109
110 #[test]
111 fn seal_open_round_trip() {
112 let record = literal("hunter2");
113 let sealed = seal(&record, &key()).unwrap();
114 let opened = open(&sealed, &key()).unwrap();
115 assert_eq!(opened, record);
116 }
117
118 #[test]
119 fn nonce_is_unique_per_write() {
120 let record = literal("hunter2");
121 let a = seal(&record, &key()).unwrap();
122 let b = seal(&record, &key()).unwrap();
123 assert_ne!(a.nonce, b.nonce);
124 assert_ne!(a.ciphertext, b.ciphertext);
125 }
126
127 #[test]
128 fn sealed_bytes_do_not_contain_plaintext() {
129 let sealed = seal(&literal("hunter2"), &key()).unwrap();
130 assert!(!sealed.ciphertext.windows(7).any(|w| w == b"hunter2"));
131 assert!(!sealed.ciphertext.windows(8).any(|w| w == b"password"));
133 }
134
135 #[test]
136 fn wrong_key_fails_to_open() {
137 let sealed = seal(&literal("hunter2"), &key()).unwrap();
138 let err = open(&sealed, &[9u8; KEY_LEN]).unwrap_err();
139 assert!(matches!(err, CoreError::Crypto));
140 }
141
142 #[test]
143 fn tampered_ciphertext_fails_to_open() {
144 let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
145 sealed.ciphertext[0] ^= 0xff;
146 assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
147 }
148
149 #[test]
150 fn malformed_nonce_fails_to_open() {
151 let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
152 sealed.nonce.truncate(4);
153 assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
154 }
155}