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//! Client input goes through [`deserialize_plaintext_secret`] instead, which
9//! never decrypts.
10
11use super::{
12    ENVELOPE_PREFIX, PrivacyError, SecretString, configured_key, current_key, current_key_id,
13    decrypt_envelope, encrypt_with_context, parse_envelope,
14};
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use std::cell::Cell;
17
18/// Authenticated-data context for serialized secrets. It differs from the SQL
19/// column context, so a serialized value and a stored column value are not
20/// interchangeable ciphertexts.
21const SERDE_CONTEXT: &[u8] = b"rullst-orm:secret-string:serde";
22
23thread_local! {
24    static REDACTION_DEPTH: Cell<usize> = const { Cell::new(0) };
25}
26
27struct RedactionScope;
28
29impl RedactionScope {
30    fn enter() -> Self {
31        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1)));
32        Self
33    }
34}
35
36impl Drop for RedactionScope {
37    fn drop(&mut self) {
38        REDACTION_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
39    }
40}
41
42/// Runs `serialize` with every [`SecretString`] serialized as `"***"`.
43///
44/// Generated `to_json()` and search projections use this scope. It applies to
45/// the current thread for the duration of the synchronous closure only.
46#[doc(hidden)]
47pub fn with_redacted_secrets<R>(serialize: impl FnOnce() -> R) -> R {
48    let _scope = RedactionScope::enter();
49    serialize()
50}
51
52fn redacting() -> bool {
53    REDACTION_DEPTH.with(|depth| depth.get() > 0)
54}
55
56fn encrypt_serialized(plaintext: &str) -> Result<String, PrivacyError> {
57    let key_id = current_key_id()?;
58    let key = current_key()?;
59    encrypt_with_context(plaintext, &key, &key_id, SERDE_CONTEXT)
60}
61
62fn decrypt_serialized(encrypted: &str) -> Result<String, PrivacyError> {
63    let envelope = parse_envelope(encrypted)?;
64    let key = configured_key(envelope.key_id)?;
65    decrypt_envelope(&envelope, &key, SERDE_CONTEXT)
66}
67
68fn is_envelope(value: &str) -> bool {
69    value
70        .strip_prefix(ENVELOPE_PREFIX)
71        .is_some_and(|rest| rest.starts_with(':'))
72}
73
74impl Serialize for SecretString {
75    /// Emits an encrypted envelope, or `"***"` inside a redacted projection.
76    /// Fails when `RULLST_ENCRYPTION_KEY` is not configured.
77    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        if redacting() {
79            return serializer.serialize_str(crate::audit::REDACTED_VALUE);
80        }
81        let envelope = encrypt_serialized(&self.0).map_err(serde::ser::Error::custom)?;
82        serializer.serialize_str(&envelope)
83    }
84}
85
86impl<'de> Deserialize<'de> for SecretString {
87    /// Decrypts an envelope written by `Serialize` (current key or keyring).
88    /// Any other string is accepted as plaintext input; a malformed or
89    /// unauthenticated envelope is rejected.
90    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
91        let value = String::deserialize(deserializer)?;
92        if !is_envelope(&value) {
93            return Ok(SecretString(value));
94        }
95        decrypt_serialized(&value)
96            .map(SecretString)
97            .map_err(serde::de::Error::custom)
98    }
99}
100
101const ENVELOPE_INPUT_ERROR: &str = "an encrypted RULLST envelope is not accepted as secret input";
102
103/// Deserializes a [`SecretString`] from untrusted input, such as a request
104/// body, accepting plaintext only.
105///
106/// `SecretString`'s own `Deserialize` decrypts a serde envelope so trusted
107/// stores (the generated query cache and Redis model hashes) round-trip it.
108/// That envelope is bound to the key, not to a model, field or owner, so an
109/// envelope exposed for one record (for example by a plain
110/// `#[derive(Serialize)]` response) would decrypt into another record if a
111/// client could submit it. Fields filled from clients therefore use
112/// `#[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")]`,
113/// which rejects any envelope-shaped value (`RULLST:...`) without decrypting
114/// it. The generated cache path decodes fields with their own `Deserialize`
115/// and is unaffected. Added in v13.
116pub fn deserialize_plaintext_secret<'de, D: Deserializer<'de>>(
117    deserializer: D,
118) -> Result<SecretString, D::Error> {
119    plaintext(String::deserialize(deserializer)?)
120}
121
122/// [`deserialize_plaintext_secret`] for an `Option<SecretString>` field:
123/// `null` is `None`. Add `#[serde(default)]` to accept a missing field too.
124/// Added in v13.
125pub fn deserialize_optional_plaintext_secret<'de, D: Deserializer<'de>>(
126    deserializer: D,
127) -> Result<Option<SecretString>, D::Error> {
128    Option::<String>::deserialize(deserializer)?
129        .map(plaintext)
130        .transpose()
131}
132
133fn plaintext<E: serde::de::Error>(value: String) -> Result<SecretString, E> {
134    if is_envelope(&value) {
135        return Err(E::custom(ENVELOPE_INPUT_ERROR));
136    }
137    Ok(SecretString(value))
138}