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;
25pub use secret_serde::{deserialize_optional_plaintext_secret, deserialize_plaintext_secret};
26mod settings;
27mod sqlx_codec;
28
29/// A decrypted secret whose `Debug` output and serde form never contain the
30/// plaintext.
31///
32/// SQLx encoding/decoding encrypts and decrypts the column value. `Serialize`
33/// emits an authenticated `RULLST:v2` envelope under the configured
34/// `RULLST_ENCRYPTION_KEY` (and fails without one); `Deserialize` decrypts such
35/// an envelope and still accepts any other string as plaintext input.
36/// Because the envelope binds no model, field or owner, `Deserialize` is for
37/// trusted stores only: deserialize client input with
38/// [`deserialize_plaintext_secret`] or [`deserialize_optional_plaintext_secret`].
39/// Generated ORM audit, event and search projections serialize it as `"***"`.
40/// Use [`SecretString::reveal_audited`] for deliberate exposure.
41#[derive(Clone, PartialEq, Eq)]
42pub struct SecretString(String);
43
44impl SecretString {
45    pub fn new(val: impl Into<String>) -> Self {
46        SecretString(val.into())
47    }
48
49    /// Reveals the real value only when explicitly requested.
50    /// In a real implementation, this might take an `AuditLogToken` to log the access.
51    pub fn reveal_audited(&self) -> &str {
52        &self.0
53    }
54}
55
56// In standard debug, it should never leak.
57impl std::fmt::Debug for SecretString {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str("[ENCRYPTED_SECRET]")
60    }
61}
62
63/// Compliance metadata generated by `#[derive(PersonalData)]`.
64pub struct PrivacyReport {
65    /// The model's table: `#[orm(table = "...")]`, else the ORM's `<struct>s`
66    /// default.
67    pub table_name: String,
68    /// Whether any persisted field is encrypted at rest.
69    pub has_encrypted_data: bool,
70    /// Persisted `#[orm(encrypted)]` and `SecretString` fields. A `#[privacy]`
71    /// tag only redacts `Debug`; see [`ComplianceModel::personal_fields`].
72    pub encrypted_fields: Vec<&'static str>,
73}
74
75pub trait ComplianceModel {
76    fn compliance_schema() -> PrivacyReport;
77
78    /// The fields the application declared as personal data with
79    /// `#[privacy]` on `#[derive(PersonalData)]`; empty by default.
80    /// Added in v13.
81    fn personal_fields() -> Vec<&'static str> {
82        Vec::new()
83    }
84}
85
86/// Strongly-typed error domain for Rullst ORM privacy and column encryption.
87#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum PrivacyError {
90    /// The encryption key is missing or not exactly 32 bytes long.
91    #[error("RULLST_ENCRYPTION_KEY must be exactly 32 bytes long")]
92    InvalidKeyLength,
93
94    /// A prefixed base64 or hexadecimal key could not be decoded.
95    #[error("RULLST_ENCRYPTION_KEY has invalid {0} encoding")]
96    InvalidKeyEncoding(&'static str),
97
98    /// A key identifier was empty, too long, or contained an envelope delimiter.
99    #[error("Invalid field-encryption key identifier")]
100    InvalidKeyId,
101
102    /// A versioned encrypted value did not contain the required fields.
103    #[error("Invalid field-encryption envelope")]
104    InvalidEnvelope,
105
106    /// The encrypted value belongs to an unknown future format.
107    #[error("Unsupported field-encryption envelope version: {0}")]
108    UnsupportedEnvelopeVersion(String),
109
110    /// The configured keyring does not contain the key selected by the envelope.
111    #[error("Field-encryption key `{0}` was not found in the configured keyring")]
112    KeyNotFound(String),
113
114    /// The operating system could not provide a cryptographically secure nonce.
115    #[error("Operating-system randomness is unavailable")]
116    RandomnessUnavailable,
117
118    /// AES-GCM encryption failed.
119    #[error("Encryption failed: {0}")]
120    EncryptionFailed(String),
121
122    /// AES-GCM decryption failed.
123    #[error("Decryption failed: {0}")]
124    DecryptionFailed(String),
125
126    /// The payload is shorter than the 12-byte nonce requirement.
127    #[error("Invalid encrypted payload (too short)")]
128    PayloadTooShort,
129
130    /// Base64 decoding failed.
131    #[error("Base64 decode error: {0}")]
132    Base64Error(String),
133
134    /// Decrypted payload is not valid UTF-8.
135    #[error("UTF-8 decoding error: {0}")]
136    Utf8Error(String),
137
138    /// Missing environment variable key.
139    #[error("Environment variable error: {0}")]
140    EnvError(String),
141}
142
143fn validate_key(key: &[u8]) -> Result<(), PrivacyError> {
144    if key.len() == KEY_LENGTH {
145        Ok(())
146    } else {
147        Err(PrivacyError::InvalidKeyLength)
148    }
149}
150
151fn validate_key_id(key_id: &str) -> Result<(), PrivacyError> {
152    if key_id.is_empty()
153        || key_id.len() > MAX_KEY_ID_LENGTH
154        || !key_id
155            .bytes()
156            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
157    {
158        Err(PrivacyError::InvalidKeyId)
159    } else {
160        Ok(())
161    }
162}
163
164fn authenticated_data(key_id: &str, context: &[u8]) -> Vec<u8> {
165    let mut aad = Vec::with_capacity(AAD_DOMAIN.len() + key_id.len() + context.len() + 4);
166    aad.extend_from_slice(AAD_DOMAIN);
167    aad.push(0);
168    aad.extend_from_slice(ENVELOPE_VERSION.as_bytes());
169    aad.push(0);
170    aad.extend_from_slice(key_id.as_bytes());
171    aad.push(0);
172    aad.extend_from_slice(context);
173    aad
174}
175
176fn model_context(table: &str, column: &str) -> Vec<u8> {
177    let mut context = Vec::with_capacity(table.len() + column.len() + 1);
178    context.extend_from_slice(table.as_bytes());
179    context.push(0);
180    context.extend_from_slice(column.as_bytes());
181    context
182}
183
184fn encrypt_with_context(
185    plaintext: &str,
186    key: &[u8],
187    key_id: &str,
188    context: &[u8],
189) -> Result<String, PrivacyError> {
190    validate_key(key)?;
191    validate_key_id(key_id)?;
192    let cipher = Aes256Gcm::new_from_slice(key)
193        .map_err(|error| PrivacyError::EncryptionFailed(error.to_string()))?;
194    let nonce =
195        Nonce::<Aes256Gcm>::try_generate().map_err(|_| PrivacyError::RandomnessUnavailable)?;
196    let aad = authenticated_data(key_id, context);
197    let ciphertext = cipher
198        .encrypt(
199            &nonce,
200            Payload {
201                msg: plaintext.as_bytes(),
202                aad: &aad,
203            },
204        )
205        .map_err(|error| PrivacyError::EncryptionFailed(error.to_string()))?;
206
207    Ok(format!(
208        "{ENVELOPE_PREFIX}:{ENVELOPE_VERSION}:{key_id}:{}:{}",
209        URL_SAFE_NO_PAD.encode(nonce),
210        URL_SAFE_NO_PAD.encode(ciphertext)
211    ))
212}
213
214struct ParsedEnvelope<'a> {
215    key_id: &'a str,
216    nonce: Vec<u8>,
217    ciphertext: Vec<u8>,
218}
219
220fn parse_envelope(encrypted: &str) -> Result<ParsedEnvelope<'_>, PrivacyError> {
221    let mut fields = encrypted.split(':');
222    if fields.next() != Some(ENVELOPE_PREFIX) {
223        return Err(PrivacyError::InvalidEnvelope);
224    }
225    let version = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
226    if version != ENVELOPE_VERSION {
227        return Err(PrivacyError::UnsupportedEnvelopeVersion(
228            version.to_string(),
229        ));
230    }
231    let key_id = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
232    validate_key_id(key_id)?;
233    let encoded_nonce = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
234    let encoded_ciphertext = fields.next().ok_or(PrivacyError::InvalidEnvelope)?;
235    if fields.next().is_some() {
236        return Err(PrivacyError::InvalidEnvelope);
237    }
238
239    let nonce = URL_SAFE_NO_PAD
240        .decode(encoded_nonce)
241        .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
242    if nonce.len() != NONCE_LENGTH {
243        return Err(PrivacyError::PayloadTooShort);
244    }
245    let ciphertext = URL_SAFE_NO_PAD
246        .decode(encoded_ciphertext)
247        .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
248    if ciphertext.len() < TAG_LENGTH {
249        return Err(PrivacyError::PayloadTooShort);
250    }
251
252    Ok(ParsedEnvelope {
253        key_id,
254        nonce,
255        ciphertext,
256    })
257}
258
259fn decrypt_envelope(
260    envelope: &ParsedEnvelope<'_>,
261    key: &[u8],
262    context: &[u8],
263) -> Result<String, PrivacyError> {
264    validate_key(key)?;
265    let cipher = Aes256Gcm::new_from_slice(key)
266        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
267    let nonce = Nonce::<Aes256Gcm>::try_from(envelope.nonce.as_slice())
268        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
269    let aad = authenticated_data(envelope.key_id, context);
270    let plaintext = cipher
271        .decrypt(
272            &nonce,
273            Payload {
274                msg: &envelope.ciphertext,
275                aad: &aad,
276            },
277        )
278        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
279    String::from_utf8(plaintext).map_err(|error| PrivacyError::Utf8Error(error.to_string()))
280}
281
282fn current_key_id() -> Result<String, PrivacyError> {
283    match settings::setting(KEY_ID_ENV)? {
284        Some(key_id) => {
285            validate_key_id(&key_id)?;
286            Ok(key_id)
287        }
288        None => Ok(DEFAULT_KEY_ID.to_string()),
289    }
290}
291
292fn decode_configured_key(value: &str) -> Result<Vec<u8>, PrivacyError> {
293    let key = if let Some(encoded) = value.strip_prefix("base64:") {
294        STANDARD
295            .decode(encoded)
296            .map_err(|_| PrivacyError::InvalidKeyEncoding("base64"))?
297    } else if let Some(encoded) = value.strip_prefix("hex:") {
298        let (pairs, remainder) = encoded.as_bytes().as_chunks::<2>();
299        if !remainder.is_empty() {
300            return Err(PrivacyError::InvalidKeyEncoding("hex"));
301        }
302        pairs
303            .iter()
304            .map(|pair| {
305                let pair = std::str::from_utf8(pair)
306                    .map_err(|_| PrivacyError::InvalidKeyEncoding("hex"))?;
307                u8::from_str_radix(pair, 16).map_err(|_| PrivacyError::InvalidKeyEncoding("hex"))
308            })
309            .collect::<Result<Vec<_>, _>>()?
310    } else {
311        value.as_bytes().to_vec()
312    };
313    validate_key(&key)?;
314    Ok(key)
315}
316
317fn current_key() -> Result<Vec<u8>, PrivacyError> {
318    decode_configured_key(&settings::required_setting(KEY_ENV)?)
319}
320
321fn parse_keyring(
322    keyring: &str,
323) -> Result<serde_json::Map<String, serde_json::Value>, PrivacyError> {
324    serde_json::from_str(keyring).map_err(|error| {
325        PrivacyError::EnvError(format!(
326            "{KEYRING_ENV} must be a JSON object of key IDs to 32-byte keys: {error}"
327        ))
328    })
329}
330
331fn configured_key(key_id: &str) -> Result<Vec<u8>, PrivacyError> {
332    if current_key_id()? == key_id {
333        return current_key();
334    }
335
336    let values = parse_keyring(&settings::required_setting(KEYRING_ENV)?)?;
337    let key = values
338        .get(key_id)
339        .and_then(serde_json::Value::as_str)
340        .ok_or_else(|| PrivacyError::KeyNotFound(key_id.to_string()))?
341        .to_string();
342    decode_configured_key(&key)
343}
344
345/// Decrypts a pre-v12 value, which names no key: the current key first, then
346/// the `RULLST_ENCRYPTION_KEYRING` keys (`default`, the implicit ID of those
347/// values, before the others), so legacy rows stay readable after rotation.
348/// AES-GCM authentication rejects every key but the one that wrote the value.
349fn decrypt_legacy_configured(encrypted: &str) -> Result<String, PrivacyError> {
350    let rejected = match decrypt_legacy_aes_gcm(encrypted, &current_key()?) {
351        Err(error @ PrivacyError::DecryptionFailed(_)) => error,
352        other => return other,
353    };
354    let Some(keyring) = settings::setting(KEYRING_ENV)? else {
355        return Err(rejected);
356    };
357    let values = parse_keyring(&keyring)?;
358    let candidates = values.get(DEFAULT_KEY_ID).into_iter().chain(
359        values
360            .iter()
361            .filter(|(key_id, _)| key_id.as_str() != DEFAULT_KEY_ID)
362            .map(|(_, key)| key),
363    );
364    for candidate in candidates {
365        let key = candidate.as_str().ok_or_else(|| {
366            PrivacyError::EnvError(format!("{KEYRING_ENV} values must be key strings"))
367        })?;
368        match decrypt_legacy_aes_gcm(encrypted, &decode_configured_key(key)?) {
369            Err(PrivacyError::DecryptionFailed(_)) => continue,
370            other => return other,
371        }
372    }
373    Err(rejected)
374}
375
376/// Encrypts an ORM model field using the configured current key and an
377/// authenticated table/column context.
378///
379/// `RULLST_ENCRYPTION_KEY`, `RULLST_ENCRYPTION_KEY_ID` and
380/// `RULLST_ENCRYPTION_KEYRING` are each read from the process environment,
381/// then from `./.env` (never loaded into the process environment).
382pub fn encrypt_model_field(
383    plaintext: &str,
384    table: &str,
385    column: &str,
386) -> Result<String, PrivacyError> {
387    let key_id = current_key_id()?;
388    let key = current_key()?;
389    encrypt_with_context(plaintext, &key, &key_id, &model_context(table, column))
390}
391
392/// Decrypts an ORM model field and selects its key from the configured current
393/// key or `RULLST_ENCRYPTION_KEYRING` JSON object.
394pub fn decrypt_model_field(
395    encrypted: &str,
396    table: &str,
397    column: &str,
398) -> Result<String, PrivacyError> {
399    let envelope = parse_envelope(encrypted)?;
400    let key = configured_key(envelope.key_id)?;
401    decrypt_envelope(&envelope, &key, &model_context(table, column))
402}
403
404pub fn encrypt_aes_gcm(plaintext: &str, key: &str) -> Result<String, PrivacyError> {
405    encrypt_with_context(plaintext, key.as_bytes(), DEFAULT_KEY_ID, b"")
406}
407
408pub fn decrypt_aes_gcm(encrypted: &str, key: &str) -> Result<String, PrivacyError> {
409    let key_bytes = key.as_bytes();
410    validate_key(key_bytes)?;
411    if encrypted.starts_with(&format!("{ENVELOPE_PREFIX}:")) {
412        let envelope = parse_envelope(encrypted)?;
413        return decrypt_envelope(&envelope, key_bytes, b"");
414    }
415
416    decrypt_legacy_aes_gcm(encrypted, key_bytes)
417}
418
419fn decrypt_legacy_aes_gcm(encrypted: &str, key: &[u8]) -> Result<String, PrivacyError> {
420    // v12 reads the pre-v12 nonce+ciphertext base64 representation so existing
421    // `SecretString` rows can be loaded and rewritten into the versioned format.
422    let payload = STANDARD
423        .decode(encrypted)
424        .map_err(|error| PrivacyError::Base64Error(error.to_string()))?;
425    if payload.len() < NONCE_LENGTH + TAG_LENGTH {
426        return Err(PrivacyError::PayloadTooShort);
427    }
428
429    let cipher = Aes256Gcm::new_from_slice(key)
430        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
431    let nonce = Nonce::<Aes256Gcm>::try_from(&payload[..NONCE_LENGTH])
432        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
433    let ciphertext = &payload[NONCE_LENGTH..];
434
435    let plaintext = cipher
436        .decrypt(&nonce, ciphertext)
437        .map_err(|error| PrivacyError::DecryptionFailed(error.to_string()))?;
438
439    String::from_utf8(plaintext).map_err(|error| PrivacyError::Utf8Error(error.to_string()))
440}
441
442fn encrypt_configured_secret(plaintext: &str) -> Result<String, PrivacyError> {
443    let key_id = current_key_id()?;
444    let key = current_key()?;
445    encrypt_with_context(plaintext, &key, &key_id, b"")
446}
447
448fn decrypt_configured_secret(encrypted: &str) -> Result<String, PrivacyError> {
449    if encrypted.starts_with(&format!("{ENVELOPE_PREFIX}:")) {
450        let envelope = parse_envelope(encrypted)?;
451        let key = configured_key(envelope.key_id)?;
452        decrypt_envelope(&envelope, &key, b"")
453    } else {
454        decrypt_legacy_configured(encrypted)
455    }
456}
457
458#[cfg(test)]
459mod tests;