1use aes_gcm::{
2 Aes256Gcm,
3 aead::{Aead, Generate, KeyInit, Nonce, Payload},
4};
5use base64::{
6 Engine as _,
7 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
8};
9
10const ENVELOPE_PREFIX: &str = "RULLST";
11const ENVELOPE_VERSION: &str = "v2";
12const DEFAULT_KEY_ID: &str = "default";
13const KEY_LENGTH: usize = 32;
14const NONCE_LENGTH: usize = 12;
15const TAG_LENGTH: usize = 16;
16const MAX_KEY_ID_LENGTH: usize = 128;
17const AAD_DOMAIN: &[u8] = b"rullst-orm:field-encryption:aes-256-gcm";
18const KEY_ENV: &str = "RULLST_ENCRYPTION_KEY";
19const KEY_ID_ENV: &str = "RULLST_ENCRYPTION_KEY_ID";
20const KEYRING_ENV: &str = "RULLST_ENCRYPTION_KEYRING";
21
22mod secret_serde;
23#[doc(hidden)]
24pub use secret_serde::with_redacted_secrets;
25
26#[derive(Clone, PartialEq, Eq)]
36pub struct SecretString(String);
37
38impl SecretString {
39 pub fn new(val: impl Into<String>) -> Self {
40 SecretString(val.into())
41 }
42
43 pub fn reveal_audited(&self) -> &str {
46 &self.0
47 }
48}
49
50impl std::fmt::Debug for SecretString {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.write_str("[ENCRYPTED_SECRET]")
54 }
55}
56
57pub struct PrivacyReport {
58 pub table_name: String,
59 pub has_encrypted_data: bool,
60 pub encrypted_fields: Vec<&'static str>,
61}
62
63pub trait ComplianceModel {
64 fn compliance_schema() -> PrivacyReport;
65}
66
67#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum PrivacyError {
71 #[error("RULLST_ENCRYPTION_KEY must be exactly 32 bytes long")]
73 InvalidKeyLength,
74
75 #[error("RULLST_ENCRYPTION_KEY has invalid {0} encoding")]
77 InvalidKeyEncoding(&'static str),
78
79 #[error("Invalid field-encryption key identifier")]
81 InvalidKeyId,
82
83 #[error("Invalid field-encryption envelope")]
85 InvalidEnvelope,
86
87 #[error("Unsupported field-encryption envelope version: {0}")]
89 UnsupportedEnvelopeVersion(String),
90
91 #[error("Field-encryption key `{0}` was not found in the configured keyring")]
93 KeyNotFound(String),
94
95 #[error("Operating-system randomness is unavailable")]
97 RandomnessUnavailable,
98
99 #[error("Encryption failed: {0}")]
101 EncryptionFailed(String),
102
103 #[error("Decryption failed: {0}")]
105 DecryptionFailed(String),
106
107 #[error("Invalid encrypted payload (too short)")]
109 PayloadTooShort,
110
111 #[error("Base64 decode error: {0}")]
113 Base64Error(String),
114
115 #[error("UTF-8 decoding error: {0}")]
117 Utf8Error(String),
118
119 #[error("Environment variable error: {0}")]
121 EnvError(String),
122}
123
124fn validate_key(key: &[u8]) -> Result<(), PrivacyError> {
125 if key.len() == KEY_LENGTH {
126 Ok(())
127 } else {
128 Err(PrivacyError::InvalidKeyLength)
129 }
130}
131
132fn validate_key_id(key_id: &str) -> Result<(), PrivacyError> {
133 if key_id.is_empty()
134 || key_id.len() > MAX_KEY_ID_LENGTH
135 || !key_id
136 .bytes()
137 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
138 {
139 Err(PrivacyError::InvalidKeyId)
140 } else {
141 Ok(())
142 }
143}
144
145fn authenticated_data(key_id: &str, context: &[u8]) -> Vec<u8> {
146 let mut aad = Vec::with_capacity(AAD_DOMAIN.len() + key_id.len() + context.len() + 4);
147 aad.extend_from_slice(AAD_DOMAIN);
148 aad.push(0);
149 aad.extend_from_slice(ENVELOPE_VERSION.as_bytes());
150 aad.push(0);
151 aad.extend_from_slice(key_id.as_bytes());
152 aad.push(0);
153 aad.extend_from_slice(context);
154 aad
155}
156
157fn model_context(table: &str, column: &str) -> Vec<u8> {
158 let mut context = Vec::with_capacity(table.len() + column.len() + 1);
159 context.extend_from_slice(table.as_bytes());
160 context.push(0);
161 context.extend_from_slice(column.as_bytes());
162 context
163}
164
165fn encrypt_with_context(
166 plaintext: &str,
167 key: &[u8],
168 key_id: &str,
169 context: &[u8],
170) -> Result<String, PrivacyError> {
171 validate_key(key)?;
172 validate_key_id(key_id)?;
173 let cipher = Aes256Gcm::new_from_slice(key)
174 .map_err(|error| PrivacyError::EncryptionFailed(error.to_string()))?;
175 let nonce =
176 Nonce::<Aes256Gcm>::try_generate().map_err(|_| PrivacyError::RandomnessUnavailable)?;
177 let aad = authenticated_data(key_id, context);
178 let ciphertext = cipher
179 .encrypt(
180 &nonce,
181 Payload {
182 msg: plaintext.as_bytes(),
183 aad: &aad,
184 },
185 )
186 .map_err(|error| PrivacyError::EncryptionFailed(error.to_string()))?;
187
188 Ok(format!(
189 "{ENVELOPE_PREFIX}:{ENVELOPE_VERSION}:{key_id}:{}:{}",
190 URL_SAFE_NO_PAD.encode(nonce),
191 URL_SAFE_NO_PAD.encode(ciphertext)
192 ))
193}
194
195struct ParsedEnvelope<'a> {
196 key_id: &'a str,
197 nonce: Vec<u8>,
198 ciphertext: Vec<u8>,
199}
200
201fn parse_envelope(encrypted: &str) -> Result<ParsedEnvelope<'_>, PrivacyError> {
202 let mut fields = encrypted.split(':');
203 if fields.next() != Some(ENVELOPE_PREFIX) {
204 return Err(PrivacyError::InvalidEnvelope);
205 }
206 let version = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
207 if version != ENVELOPE_VERSION {
208 return Err(PrivacyError::UnsupportedEnvelopeVersion(
209 version.to_string(),
210 ));
211 }
212 let key_id = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
213 validate_key_id(key_id)?;
214 let encoded_nonce = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
215 let encoded_ciphertext = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
216 if fields.next().is_some() {
217 return Err(PrivacyError::InvalidEnvelope);
218 }
219
220 let nonce = URL_SAFE_NO_PAD
221 .decode(encoded_nonce)
222 .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
223 if nonce.len() != NONCE_LENGTH {
224 return Err(PrivacyError::PayloadTooShort);
225 }
226 let ciphertext = URL_SAFE_NO_PAD
227 .decode(encoded_ciphertext)
228 .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
229 if ciphertext.len() < TAG_LENGTH {
230 return Err(PrivacyError::PayloadTooShort);
231 }
232
233 Ok(ParsedEnvelope {
234 key_id,
235 nonce,
236 ciphertext,
237 })
238}
239
240fn decrypt_envelope(
241 envelope: &ParsedEnvelope<'_>,
242 key: &[u8],
243 context: &[u8],
244) -> Result<String, PrivacyError> {
245 validate_key(key)?;
246 let cipher = Aes256Gcm::new_from_slice(key)
247 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
248 let nonce = Nonce::<Aes256Gcm>::try_from(envelope.nonce.as_slice())
249 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
250 let aad = authenticated_data(envelope.key_id, context);
251 let plaintext = cipher
252 .decrypt(
253 &nonce,
254 Payload {
255 msg: &envelope.ciphertext,
256 aad: &aad,
257 },
258 )
259 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
260 String::from_utf8(plaintext).map_err(|error| PrivacyError::Utf8Error(error.to_string()))
261}
262
263fn environment_variable(name: &str) -> Result<String, PrivacyError> {
264 std::env::var(name).map_err(|error| PrivacyError::EnvError(format!("{name}: {error}")))
265}
266
267fn current_key_id() -> Result<String, PrivacyError> {
268 match std::env::var(KEY_ID_ENV) {
269 Ok(key_id) => {
270 validate_key_id(&key_id)?;
271 Ok(key_id)
272 }
273 Err(std::env::VarError::NotPresent) => Ok(DEFAULT_KEY_ID.to_string()),
274 Err(error) => Err(PrivacyError::EnvError(format!("{KEY_ID_ENV}: {error}"))),
275 }
276}
277
278fn decode_configured_key(value: &str) -> Result<Vec<u8>, PrivacyError> {
279 let key = if let Some(encoded) = value.strip_prefix("base64:") {
280 STANDARD
281 .decode(encoded)
282 .map_err(|_| PrivacyError::InvalidKeyEncoding("base64"))?
283 } else if let Some(encoded) = value.strip_prefix("hex:") {
284 let (pairs, remainder) = encoded.as_bytes().as_chunks::<2>();
285 if !remainder.is_empty() {
286 return Err(PrivacyError::InvalidKeyEncoding("hex"));
287 }
288 pairs
289 .iter()
290 .map(|pair| {
291 let pair = std::str::from_utf8(pair)
292 .map_err(|_| PrivacyError::InvalidKeyEncoding("hex"))?;
293 u8::from_str_radix(pair, 16).map_err(|_| PrivacyError::InvalidKeyEncoding("hex"))
294 })
295 .collect::<Result<Vec<_>, _>>()?
296 } else {
297 value.as_bytes().to_vec()
298 };
299 validate_key(&key)?;
300 Ok(key)
301}
302
303fn current_key() -> Result<Vec<u8>, PrivacyError> {
304 decode_configured_key(&environment_variable(KEY_ENV)?)
305}
306
307fn configured_key(key_id: &str) -> Result<Vec<u8>, PrivacyError> {
308 if current_key_id()? == key_id {
309 return current_key();
310 }
311
312 let keyring = environment_variable(KEYRING_ENV)?;
313 let values: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&keyring)
314 .map_err(|error| {
315 PrivacyError::EnvError(format!(
316 "{KEYRING_ENV} must be a JSON object of key IDs to 32-byte keys: {error}"
317 ))
318 })?;
319 let key = values
320 .get(key_id)
321 .and_then(serde_json::Value::as_str)
322 .ok_or_else(|| PrivacyError::KeyNotFound(key_id.to_string()))?
323 .to_string();
324 decode_configured_key(&key)
325}
326
327pub fn encrypt_model_field(
330 plaintext: &str,
331 table: &str,
332 column: &str,
333) -> Result<String, PrivacyError> {
334 let key_id = current_key_id()?;
335 let key = current_key()?;
336 encrypt_with_context(plaintext, &key, &key_id, &model_context(table, column))
337}
338
339pub fn decrypt_model_field(
342 encrypted: &str,
343 table: &str,
344 column: &str,
345) -> Result<String, PrivacyError> {
346 let envelope = parse_envelope(encrypted)?;
347 let key = configured_key(envelope.key_id)?;
348 decrypt_envelope(&envelope, &key, &model_context(table, column))
349}
350
351pub fn encrypt_aes_gcm(plaintext: &str, key: &str) -> Result<String, PrivacyError> {
352 encrypt_with_context(plaintext, key.as_bytes(), DEFAULT_KEY_ID, b"")
353}
354
355pub fn decrypt_aes_gcm(encrypted: &str, key: &str) -> Result<String, PrivacyError> {
356 let key_bytes = key.as_bytes();
357 validate_key(key_bytes)?;
358 if encrypted.starts_with(&format!("{ENVELOPE_PREFIX}:")) {
359 let envelope = parse_envelope(encrypted)?;
360 return decrypt_envelope(&envelope, key_bytes, b"");
361 }
362
363 decrypt_legacy_aes_gcm(encrypted, key_bytes)
364}
365
366fn decrypt_legacy_aes_gcm(encrypted: &str, key: &[u8]) -> Result<String, PrivacyError> {
367 let payload = STANDARD
370 .decode(encrypted)
371 .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
372 if payload.len() < NONCE_LENGTH + TAG_LENGTH {
373 return Err(PrivacyError::PayloadTooShort);
374 }
375
376 let cipher = Aes256Gcm::new_from_slice(key)
377 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
378 let nonce = Nonce::<Aes256Gcm>::try_from(&payload[..NONCE_LENGTH])
379 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
380 let ciphertext = &payload[NONCE_LENGTH..];
381
382 let plaintext = cipher
383 .decrypt(&nonce, ciphertext)
384 .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
385
386 String::from_utf8(plaintext).map_err(|error| PrivacyError::Utf8Error(error.to_string()))
387}
388
389fn encrypt_configured_secret(plaintext: &str) -> Result<String, PrivacyError> {
390 let key_id = current_key_id()?;
391 let key = current_key()?;
392 encrypt_with_context(plaintext, &key, &key_id, b"")
393}
394
395fn decrypt_configured_secret(encrypted: &str) -> Result<String, PrivacyError> {
396 if encrypted.starts_with(&format!("{ENVELOPE_PREFIX}:")) {
397 let envelope = parse_envelope(encrypted)?;
398 let key = configured_key(envelope.key_id)?;
399 decrypt_envelope(&envelope, &key, b"")
400 } else {
401 decrypt_legacy_aes_gcm(encrypted, ¤t_key()?)
402 }
403}
404
405#[cfg(not(any(
406 feature = "strict-postgres",
407 feature = "strict-mysql",
408 feature = "strict-sqlite"
409)))]
410impl<'r> sqlx::Decode<'r, sqlx::Any> for SecretString {
411 fn decode(
412 value: sqlx::any::AnyValueRef<'r>,
413 ) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
414 let text = <String as sqlx::Decode<sqlx::Any>>::decode(value)?;
415 let decrypted = decrypt_configured_secret(&text)?;
416 Ok(SecretString(decrypted))
417 }
418}
419
420#[cfg(not(any(
421 feature = "strict-postgres",
422 feature = "strict-mysql",
423 feature = "strict-sqlite"
424)))]
425impl<'q> sqlx::Encode<'q, sqlx::Any> for SecretString {
426 fn encode_by_ref(
427 &self,
428 buf: &mut <sqlx::Any as sqlx::database::Database>::ArgumentBuffer,
429 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
430 let encrypted = encrypt_configured_secret(&self.0)?;
431 <String as sqlx::Encode<sqlx::Any>>::encode(encrypted, buf)
432 }
433}
434
435#[cfg(not(any(
436 feature = "strict-postgres",
437 feature = "strict-mysql",
438 feature = "strict-sqlite"
439)))]
440impl sqlx::Type<sqlx::Any> for SecretString {
441 fn type_info() -> sqlx::any::AnyTypeInfo {
442 <String as sqlx::Type<sqlx::Any>>::type_info()
443 }
444}
445
446#[cfg_attr(test, mutants::skip)]
448#[cfg(any(
449 feature = "strict-postgres",
450 feature = "strict-mysql",
451 feature = "strict-sqlite"
452))]
453impl<'r> sqlx::Decode<'r, crate::database::RullstDatabase> for SecretString {
454 fn decode(
455 value: <crate::database::RullstDatabase as sqlx::database::Database>::ValueRef<'r>,
456 ) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
457 let text = <String as sqlx::Decode<crate::database::RullstDatabase>>::decode(value)?;
458 let decrypted = decrypt_configured_secret(&text)?;
459 Ok(SecretString(decrypted))
460 }
461}
462
463#[cfg_attr(test, mutants::skip)]
464#[cfg(any(
465 feature = "strict-postgres",
466 feature = "strict-mysql",
467 feature = "strict-sqlite"
468))]
469impl<'q> sqlx::Encode<'q, crate::database::RullstDatabase> for SecretString {
470 fn encode_by_ref(
471 &self,
472 buf: &mut <crate::database::RullstDatabase as sqlx::database::Database>::ArgumentBuffer,
473 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
474 let encrypted = encrypt_configured_secret(&self.0)?;
475 <String as sqlx::Encode<crate::database::RullstDatabase>>::encode(encrypted, buf)
476 }
477}
478
479#[cfg_attr(test, mutants::skip)]
480#[cfg(any(
481 feature = "strict-postgres",
482 feature = "strict-mysql",
483 feature = "strict-sqlite"
484))]
485impl sqlx::Type<crate::database::RullstDatabase> for SecretString {
486 fn type_info() -> <crate::database::RullstDatabase as sqlx::database::Database>::TypeInfo {
487 <String as sqlx::Type<crate::database::RullstDatabase>>::type_info()
488 }
489}
490
491#[cfg(test)]
492mod tests;