Skip to main content

sealed_env/
lib.rs

1//! Read and decrypt sealed environment variables.
2//!
3//! This crate mirrors the ergonomics of `std::env::var`, but understands values stored
4//! in the `ENCv1:<base64(nonce)>:<base64(ciphertext)>` format. If a value is encrypted,
5//! `SEALED_KEY` must be present in the environment for decryption.
6//!
7//! # Quick start
8//! ```rust,no_run
9//! use sealed_env::{var, var_or_plain, var_optional};
10//!
11//! std::env::set_var("SEALED_KEY", "<base64-key>");
12//! std::env::set_var("DATABASE_PASSWORD", "ENCv1:...:...");
13//!
14//! let secret = var("DATABASE_PASSWORD")?;
15//! let maybe_plain = var_or_plain("MAYBE_PLAINTEXT")?;
16//! let optional = var_optional("OPTIONAL_SECRET")?;
17//! # Ok::<(), sealed_env::SealedEnvError>(())
18//! ```
19//!
20//! # Behavior summary
21//! - `var`: requires the variable to be present and encrypted.
22//! - `var_or_plain`: returns plaintext as-is if it is not encrypted.
23//! - `var_optional`: returns `Ok(None)` if not set; otherwise decrypts if needed.
24use 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/// Errors returned by `sealed-env`.
33#[derive(Debug, Error)]
34pub enum SealedEnvError {
35    /// The requested environment variable is not set.
36    #[error("{0}")]
37    MissingVar(String),
38    /// `SEALED_KEY` is missing from the environment.
39    #[error("{0}")]
40    MissingKey(String),
41    /// The variable is set but does not start with `ENCv1:`.
42    #[error("{0}")]
43    NotEncrypted(String),
44    /// Any cryptographic or decoding error.
45    #[error("{0}")]
46    Crypto(String),
47}
48
49/// Read an encrypted variable from the process environment.
50///
51/// This is the strict variant: the variable must be present and encrypted.
52/// Use `var_or_plain` if you want plaintext values to pass through.
53///
54/// # Examples
55/// ```rust,no_run
56/// use sealed_env::var;
57///
58/// std::env::set_var("SEALED_KEY", "<base64-key>");
59/// std::env::set_var("DATABASE_PASSWORD", "ENCv1:...:...");
60///
61/// let value = var("DATABASE_PASSWORD")?;
62/// # Ok::<(), sealed_env::SealedEnvError>(())
63/// ```
64pub 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
86/// Read a variable and return plaintext as-is if it is not encrypted.
87///
88/// # Examples
89/// ```rust,no_run
90/// use sealed_env::var_or_plain;
91///
92/// std::env::set_var("SEALED_KEY", "<base64-key>");
93/// std::env::set_var("FEATURE_FLAG", "true");
94///
95/// let value = var_or_plain("FEATURE_FLAG")?;
96/// # Ok::<(), sealed_env::SealedEnvError>(())
97/// ```
98pub 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
117/// Read a variable, returning `Ok(None)` if it is not set.
118///
119/// If the variable exists and is encrypted, it will be decrypted. If it is not encrypted,
120/// the plaintext is returned.
121///
122/// # Examples
123/// ```rust,no_run
124/// use sealed_env::var_optional;
125///
126/// std::env::set_var("SEALED_KEY", "<base64-key>");
127///
128/// let value = var_optional("OPTIONAL_SECRET")?;
129/// # Ok::<(), sealed_env::SealedEnvError>(())
130/// ```
131pub 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}