1use base64::Engine as _;
25use base64::engine::general_purpose;
26use chacha20poly1305::aead::{Aead, KeyInit, Payload};
27use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
28use secrecy::{ExposeSecret, SecretSlice, SecretString};
29use std::env;
30use thiserror::Error;
31
32#[derive(Debug, Error)]
34pub enum SealedEnvError {
35 #[error("{0}")]
37 MissingVar(String),
38 #[error("{0}")]
40 MissingKey(String),
41 #[error("{0}")]
43 NotEncrypted(String),
44 #[error("{0}")]
46 Crypto(String),
47}
48
49pub fn var(name: &str) -> Result<String, SealedEnvError> {
65 let value = env::var(name).map_err(|_| {
66 SealedEnvError::MissingVar(format!("environment variable '{}' is not set", name))
67 })?;
68
69 if !is_encrypted(&value) {
70 return Err(SealedEnvError::NotEncrypted(format!(
71 "environment variable '{}' is not encrypted",
72 name
73 )));
74 }
75
76 let key_b64 = env::var("SEALED_KEY")
77 .map_err(|_| SealedEnvError::MissingKey("SEALED_KEY is not set".to_string()))?;
78
79 let key = decode_key(&SecretString::from(key_b64))?;
80 let decrypted = decrypt_value(&key, name, &value)?;
81
82 String::from_utf8(decrypted.expose_secret().to_vec())
83 .map_err(|_| SealedEnvError::Crypto("decrypted value is not valid UTF-8".to_string()))
84}
85
86pub fn var_or_plain(name: &str) -> Result<String, SealedEnvError> {
99 let value = env::var(name).map_err(|_| {
100 SealedEnvError::MissingVar(format!("environment variable '{}' is not set", name))
101 })?;
102
103 if !is_encrypted(&value) {
104 return Ok(value);
105 }
106
107 let key_b64 = env::var("SEALED_KEY")
108 .map_err(|_| SealedEnvError::MissingKey("SEALED_KEY is not set".to_string()))?;
109
110 let key = decode_key(&SecretString::from(key_b64))?;
111 let decrypted = decrypt_value(&key, name, &value)?;
112
113 String::from_utf8(decrypted.expose_secret().to_vec())
114 .map_err(|_| SealedEnvError::Crypto("decrypted value is not valid UTF-8".to_string()))
115}
116
117pub fn var_optional(name: &str) -> Result<Option<String>, SealedEnvError> {
132 let value = match env::var(name) {
133 Ok(value) => value,
134 Err(env::VarError::NotPresent) => return Ok(None),
135 Err(_) => {
136 return Err(SealedEnvError::MissingVar(format!(
137 "environment variable '{}' is not set",
138 name
139 )));
140 }
141 };
142
143 if !is_encrypted(&value) {
144 return Ok(Some(value));
145 }
146
147 let key_b64 = env::var("SEALED_KEY")
148 .map_err(|_| SealedEnvError::MissingKey("SEALED_KEY is not set".to_string()))?;
149
150 let key = decode_key(&SecretString::from(key_b64))?;
151 let decrypted = decrypt_value(&key, name, &value)?;
152
153 String::from_utf8(decrypted.expose_secret().to_vec())
154 .map_err(|_| SealedEnvError::Crypto("decrypted value is not valid UTF-8".to_string()))
155 .map(Some)
156}
157
158fn decode_key(b64: &SecretString) -> Result<SecretSlice<u8>, SealedEnvError> {
159 let decoded = general_purpose::STANDARD
160 .decode(b64.expose_secret())
161 .map_err(|_| SealedEnvError::Crypto("invalid base64 key".to_string()))?;
162
163 if decoded.len() != 32 {
164 return Err(SealedEnvError::Crypto(
165 "key must be 32 bytes after base64 decode".to_string(),
166 ));
167 }
168
169 Ok(SecretSlice::from(decoded))
170}
171
172fn decrypt_value(
173 key: &SecretSlice<u8>,
174 var_name: &str,
175 encrypted: &str,
176) -> Result<SecretSlice<u8>, SealedEnvError> {
177 let (nonce, ciphertext) = parse_encrypted(encrypted)?;
178 let key_bytes = key.expose_secret();
179
180 if key_bytes.len() != 32 {
181 return Err(SealedEnvError::Crypto(
182 "key must be 32 bytes after base64 decode".to_string(),
183 ));
184 }
185
186 let cipher = ChaCha20Poly1305::new(Key::from_slice(key_bytes));
187 let plaintext = cipher
188 .decrypt(
189 Nonce::from_slice(&nonce),
190 Payload {
191 msg: &ciphertext,
192 aad: var_name.as_bytes(),
193 },
194 )
195 .map_err(|_| SealedEnvError::Crypto("decryption failed (bad key or data)".to_string()))?;
196
197 Ok(SecretSlice::from(plaintext))
198}
199
200fn parse_encrypted(value: &str) -> Result<(Vec<u8>, Vec<u8>), SealedEnvError> {
201 let mut parts = value.splitn(3, ':');
202
203 let tag = parts.next();
204 let nonce_b64 = parts.next();
205 let ct_b64 = parts.next();
206
207 if tag != Some("ENCv1") || nonce_b64.is_none() || ct_b64.is_none() {
208 return Err(SealedEnvError::Crypto(
209 "invalid encrypted value format".to_string(),
210 ));
211 }
212
213 let nonce = general_purpose::STANDARD
214 .decode(nonce_b64.unwrap())
215 .map_err(|_| SealedEnvError::Crypto("invalid base64 nonce".to_string()))?;
216
217 if nonce.len() != 12 {
218 return Err(SealedEnvError::Crypto(
219 "nonce must be 12 bytes after base64 decode".to_string(),
220 ));
221 }
222
223 let ciphertext = general_purpose::STANDARD
224 .decode(ct_b64.unwrap())
225 .map_err(|_| SealedEnvError::Crypto("invalid base64 ciphertext".to_string()))?;
226
227 Ok((nonce, ciphertext))
228}
229
230fn is_encrypted(value: &str) -> bool {
231 value.starts_with("ENCv1:")
232}