Skip to main content

rullst_orm/
privacy.rs

1use aes_gcm::{
2    Aes256Gcm, Nonce,
3    aead::{Aead, KeyInit},
4};
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use rand::RngExt;
7use serde::{Deserialize, Serialize};
8
9#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
10pub struct SecretString(String);
11
12impl SecretString {
13    pub fn new(val: &str) -> Self {
14        SecretString(val.to_string())
15    }
16
17    /// Reveals the real value only when explicitly requested.
18    /// In a real implementation, this might take an `AuditLogToken` to log the access.
19    pub fn reveal_audited(&self) -> &str {
20        &self.0
21    }
22}
23
24// In standard debug, it should never leak.
25impl std::fmt::Debug for SecretString {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.write_str("[ENCRYPTED_SECRET]")
28    }
29}
30
31pub struct PrivacyReport {
32    pub table_name: String,
33    pub has_encrypted_data: bool,
34    pub encrypted_fields: Vec<&'static str>,
35}
36
37pub trait ComplianceModel {
38    fn compliance_schema() -> PrivacyReport;
39}
40
41pub fn encrypt_aes_gcm(plaintext: &str, key: &str) -> Result<String, String> {
42    let key_bytes = key.as_bytes();
43    if key_bytes.len() != 32 {
44        return Err("RULLST_ENCRYPTION_KEY must be exactly 32 bytes long".to_string());
45    }
46
47    let cipher = Aes256Gcm::new_from_slice(key_bytes).map_err(|e| e.to_string())?;
48
49    let mut nonce_bytes = [0u8; 12];
50    rand::rng().fill(&mut nonce_bytes);
51    let nonce = Nonce::try_from(&nonce_bytes[..]).map_err(|e| e.to_string())?;
52
53    let ciphertext = cipher
54        .encrypt(&nonce, plaintext.as_bytes())
55        .map_err(|e| e.to_string())?;
56
57    let mut payload = nonce_bytes.to_vec();
58    payload.extend_from_slice(&ciphertext);
59
60    Ok(STANDARD.encode(payload))
61}
62
63pub fn decrypt_aes_gcm(encrypted: &str, key: &str) -> Result<String, String> {
64    let key_bytes = key.as_bytes();
65    if key_bytes.len() != 32 {
66        return Err("RULLST_ENCRYPTION_KEY must be exactly 32 bytes long".to_string());
67    }
68
69    let payload = STANDARD.decode(encrypted).map_err(|e| e.to_string())?;
70    if payload.len() < 12 {
71        return Err("Invalid encrypted payload (too short)".to_string());
72    }
73
74    let cipher = Aes256Gcm::new_from_slice(key_bytes).map_err(|e| e.to_string())?;
75    let nonce = Nonce::try_from(&payload[..12]).map_err(|e| e.to_string())?;
76    let ciphertext = &payload[12..];
77
78    let plaintext = cipher
79        .decrypt(&nonce, ciphertext)
80        .map_err(|e| e.to_string())?;
81
82    String::from_utf8(plaintext).map_err(|e| e.to_string())
83}
84
85#[cfg(not(any(
86    feature = "strict-postgres",
87    feature = "strict-mysql",
88    feature = "strict-sqlite"
89)))]
90impl<'r> sqlx::Decode<'r, sqlx::Any> for SecretString {
91    fn decode(
92        value: sqlx::any::AnyValueRef<'r>,
93    ) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
94        let text = <String as sqlx::Decode<sqlx::Any>>::decode(value)?;
95        let encryption_key = std::env::var("RULLST_ENCRYPTION_KEY")
96            .map_err(|_| "RULLST_ENCRYPTION_KEY is not set in environment")?;
97
98        let decrypted = decrypt_aes_gcm(&text, &encryption_key)?;
99        Ok(SecretString(decrypted))
100    }
101}
102
103#[cfg(not(any(
104    feature = "strict-postgres",
105    feature = "strict-mysql",
106    feature = "strict-sqlite"
107)))]
108impl<'q> sqlx::Encode<'q, sqlx::Any> for SecretString {
109    fn encode_by_ref(
110        &self,
111        buf: &mut <sqlx::Any as sqlx::database::Database>::ArgumentBuffer,
112    ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
113        let encryption_key = std::env::var("RULLST_ENCRYPTION_KEY")
114            .map_err(|_| "RULLST_ENCRYPTION_KEY is not set in environment")?;
115
116        let encrypted = encrypt_aes_gcm(&self.0, &encryption_key)?;
117        <String as sqlx::Encode<sqlx::Any>>::encode(encrypted, buf)
118    }
119}
120
121#[cfg(not(any(
122    feature = "strict-postgres",
123    feature = "strict-mysql",
124    feature = "strict-sqlite"
125)))]
126impl sqlx::Type<sqlx::Any> for SecretString {
127    fn type_info() -> sqlx::any::AnyTypeInfo {
128        <String as sqlx::Type<sqlx::Any>>::type_info()
129    }
130}
131
132// Support for strictly typed databases in Rullst
133#[cfg_attr(test, mutants::skip)]
134#[cfg(any(
135    feature = "strict-postgres",
136    feature = "strict-mysql",
137    feature = "strict-sqlite"
138))]
139impl<'r> sqlx::Decode<'r, crate::database::RullstDatabase> for SecretString {
140    fn decode(
141        value: <crate::database::RullstDatabase as sqlx::database::Database>::ValueRef<'r>,
142    ) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
143        let text = <String as sqlx::Decode<crate::database::RullstDatabase>>::decode(value)?;
144        let encryption_key = std::env::var("RULLST_ENCRYPTION_KEY")
145            .map_err(|_| "RULLST_ENCRYPTION_KEY is not set in environment")?;
146
147        let decrypted = decrypt_aes_gcm(&text, &encryption_key)?;
148        Ok(SecretString(decrypted))
149    }
150}
151
152#[cfg_attr(test, mutants::skip)]
153#[cfg(any(
154    feature = "strict-postgres",
155    feature = "strict-mysql",
156    feature = "strict-sqlite"
157))]
158impl<'q> sqlx::Encode<'q, crate::database::RullstDatabase> for SecretString {
159    fn encode_by_ref(
160        &self,
161        buf: &mut <crate::database::RullstDatabase as sqlx::database::Database>::ArgumentBuffer,
162    ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
163        let encryption_key = std::env::var("RULLST_ENCRYPTION_KEY")
164            .map_err(|_| "RULLST_ENCRYPTION_KEY is not set in environment")?;
165
166        let encrypted = encrypt_aes_gcm(&self.0, &encryption_key)?;
167        <String as sqlx::Encode<crate::database::RullstDatabase>>::encode(encrypted, buf)
168    }
169}
170
171#[cfg_attr(test, mutants::skip)]
172#[cfg(any(
173    feature = "strict-postgres",
174    feature = "strict-mysql",
175    feature = "strict-sqlite"
176))]
177impl sqlx::Type<crate::database::RullstDatabase> for SecretString {
178    fn type_info() -> <crate::database::RullstDatabase as sqlx::database::Database>::TypeInfo {
179        <String as sqlx::Type<crate::database::RullstDatabase>>::type_info()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_secret_string_encryption() {
189        let key = "01234567890123456789012345678901"; // 32 bytes
190        let plaintext = "Sensitive Data 123";
191
192        let encrypted = encrypt_aes_gcm(plaintext, key).unwrap();
193        assert_ne!(encrypted, plaintext);
194
195        let decrypted = decrypt_aes_gcm(&encrypted, key).unwrap();
196        assert_eq!(decrypted, plaintext);
197    }
198
199    #[test]
200    fn test_secret_string_debug() {
201        let secret = SecretString::new("my-cpf-123");
202        let debug_str = format!("{:?}", secret);
203        assert_eq!(debug_str, "[ENCRYPTED_SECRET]");
204    }
205
206    #[test]
207    fn test_decrypt_aes_gcm_invalid_length() {
208        let key = "01234567890123456789012345678901";
209
210        let short_payload = STANDARD.encode([0u8; 11]);
211        let result = decrypt_aes_gcm(&short_payload, key);
212        assert!(result.is_err());
213        assert_eq!(result.unwrap_err(), "Invalid encrypted payload (too short)");
214
215        let exactly_12_payload = STANDARD.encode([0u8; 12]);
216        let result = decrypt_aes_gcm(&exactly_12_payload, key);
217        assert!(result.is_err());
218        assert_ne!(result.unwrap_err(), "Invalid encrypted payload (too short)");
219    }
220
221    #[test]
222    fn test_secret_string_reveal_audited() {
223        let secret = SecretString::new("my-secret-data");
224        assert_eq!(secret.reveal_audited(), "my-secret-data");
225    }
226}