Skip to main content

rullst_orm/
privacy.rs

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/// A decrypted secret whose `Debug` output and serde form never contain the
27/// plaintext.
28///
29/// SQLx encoding/decoding encrypts and decrypts the column value. `Serialize`
30/// emits an authenticated `RULLST:v2` envelope under the configured
31/// `RULLST_ENCRYPTION_KEY` (and fails without one); `Deserialize` decrypts such
32/// an envelope and still accepts any other string as plaintext input.
33/// Generated ORM audit, event and search projections serialize it as `"***"`.
34/// Use [`SecretString::reveal_audited`] for deliberate exposure.
35#[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    /// Reveals the real value only when explicitly requested.
44    /// In a real implementation, this might take an `AuditLogToken` to log the access.
45    pub fn reveal_audited(&self) -> &str {
46        &self.0
47    }
48}
49
50// In standard debug, it should never leak.
51impl 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/// Strongly-typed error domain for Rullst ORM privacy and column encryption.
68#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum PrivacyError {
71    /// The encryption key is missing or not exactly 32 bytes long.
72    #[error("RULLST_ENCRYPTION_KEY must be exactly 32 bytes long")]
73    InvalidKeyLength,
74
75    /// A prefixed base64 or hexadecimal key could not be decoded.
76    #[error("RULLST_ENCRYPTION_KEY has invalid {0} encoding")]
77    InvalidKeyEncoding(&'static str),
78
79    /// A key identifier was empty, too long, or contained an envelope delimiter.
80    #[error("Invalid field-encryption key identifier")]
81    InvalidKeyId,
82
83    /// A versioned encrypted value did not contain the required fields.
84    #[error("Invalid field-encryption envelope")]
85    InvalidEnvelope,
86
87    /// The encrypted value belongs to an unknown future format.
88    #[error("Unsupported field-encryption envelope version: {0}")]
89    UnsupportedEnvelopeVersion(String),
90
91    /// The configured keyring does not contain the key selected by the envelope.
92    #[error("Field-encryption key `{0}` was not found in the configured keyring")]
93    KeyNotFound(String),
94
95    /// The operating system could not provide a cryptographically secure nonce.
96    #[error("Operating-system randomness is unavailable")]
97    RandomnessUnavailable,
98
99    /// AES-GCM encryption failed.
100    #[error("Encryption failed: {0}")]
101    EncryptionFailed(String),
102
103    /// AES-GCM decryption failed.
104    #[error("Decryption failed: {0}")]
105    DecryptionFailed(String),
106
107    /// The payload is shorter than the 12-byte nonce requirement.
108    #[error("Invalid encrypted payload (too short)")]
109    PayloadTooShort,
110
111    /// Base64 decoding failed.
112    #[error("Base64 decode error: {0}")]
113    Base64Error(String),
114
115    /// Decrypted payload is not valid UTF-8.
116    #[error("UTF-8 decoding error: {0}")]
117    Utf8Error(String),
118
119    /// Missing environment variable key.
120    #[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
327/// Encrypts an ORM model field using the configured current key and an
328/// authenticated table/column context.
329pub 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
339/// Decrypts an ORM model field and selects its key from the configured current
340/// key or `RULLST_ENCRYPTION_KEYRING` JSON object.
341pub 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    // v12 reads the pre-v12 nonce+ciphertext base64 representation so existing
368    // `SecretString` rows can be loaded and rewritten into the versioned format.
369    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, &current_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// Support for strictly typed databases in Rullst
447#[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;