Skip to main content

rullst_orm/privacy/
secret_serde.rs

1//! Serde contract for [`SecretString`]: never plaintext.
2//!
3//! `Serialize` writes an authenticated `RULLST:v2` envelope under the
4//! configured key, so secondary stores such as the Redis query cache hold
5//! ciphertext that `Deserialize` turns back into the real value. Generated
6//! audit, event and search projections run inside [`with_redacted_secrets`],
7//! where the value becomes the fixed `"***"` marker and no key is needed.
8
9use super::{
10    ENVELOPE_PREFIX, PrivacyError, SecretString, configured_key, current_key, current_key_id,
11    decrypt_envelope, encrypt_with_context, parse_envelope,
12};
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use std::cell::Cell;
15
16/// Authenticated-data context for serialized secrets. It differs from the SQL
17/// column context, so a serialized value and a stored column value are not
18/// interchangeable ciphertexts.
19const SERDE_CONTEXT: &[u8] = b"rullst-orm:secret-string:serde";
20
21thread_local! {
22    static REDACTION_DEPTH: Cell<usize> = const { Cell::new(0) };
23}
24
25struct RedactionScope;
26
27impl RedactionScope {
28    fn enter() -> Self {
29        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1)));
30        Self
31    }
32}
33
34impl Drop for RedactionScope {
35    fn drop(&mut self) {
36        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
37    }
38}
39
40/// Runs `serialize` with every [`SecretString`] serialized as `"***"`.
41///
42/// Generated `to_json()` and search projections use this scope. It applies to
43/// the current thread for the duration of the synchronous closure only.
44#[doc(hidden)]
45pub fn with_redacted_secrets<R>(serialize: impl FnOnce() -> R) -> R {
46    let _scope = RedactionScope::enter();
47    serialize()
48}
49
50fn redacting() -> bool {
51    REDACTION_DEPTH.with(|depth| depth.get() > 0)
52}
53
54fn encrypt_serialized(plaintext: &str) -> Result<String, PrivacyError> {
55    let key_id = current_key_id()?;
56    let key = current_key()?;
57    encrypt_with_context(plaintext, &key, &key_id, SERDE_CONTEXT)
58}
59
60fn decrypt_serialized(encrypted: &str) -> Result<String, PrivacyError> {
61    let envelope = parse_envelope(encrypted)?;
62    let key = configured_key(envelope.key_id)?;
63    decrypt_envelope(&envelope, &key, SERDE_CONTEXT)
64}
65
66fn is_envelope(value: &str) -> bool {
67    value
68        .strip_prefix(ENVELOPE_PREFIX)
69        .is_some_and(|rest| rest.starts_with(':'))
70}
71
72impl Serialize for SecretString {
73    /// Emits an encrypted envelope, or `"***"` inside a redacted projection.
74    /// Fails when `RULLST_ENCRYPTION_KEY` is not configured.
75    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
76        if redacting() {
77            return serializer.serialize_str(crate::audit::REDACTED_VALUE);
78        }
79        let envelope = encrypt_serialized(&self.0).map_err(serde::ser::Error::custom)?;
80        serializer.serialize_str(&envelope)
81    }
82}
83
84impl<'de> Deserialize<'de> for SecretString {
85    /// Decrypts an envelope written by `Serialize` (current key or keyring).
86    /// Any other string is accepted as plaintext input; a malformed or
87    /// unauthenticated envelope is rejected.
88    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
89        let value = String::deserialize(deserializer)?;
90        if !is_envelope(&value) {
91            return Ok(SecretString(value));
92        }
93        decrypt_serialized(&value)
94            .map(SecretString)
95            .map_err(serde::de::Error::custom)
96    }
97}