Skip to main content

reddb_server/runtime/
impl_dml_crypto.rs

1//! DML crypto / secret-payload helpers extracted from `impl_dml`.
2//!
3//! Behaviour-preserving move (issue #1634). Names and behaviour are unchanged
4//! from `impl_dml`; `resolve_crypto_sentinel` stays `pub(crate)` (called from
5//! `impl_dml` and `impl_dml_support`) and `decrypt_secret_payload` stays
6//! `pub(crate)` and importable at a stable path — its single external import
7//! in `impl_core` is updated to `super::impl_dml_crypto::decrypt_secret_payload`.
8
9use super::*;
10
11/// Sentinel prefix produced by the parser for `PASSWORD('...')` and
12/// `SECRET('...')` literals. The runtime strips this marker and
13/// applies the actual crypto transform during INSERT execution.
14pub(crate) const PLAINTEXT_SENTINEL: &str = "@@plain@@";
15
16#[derive(Debug)]
17pub(crate) struct SecretInputReadLimit {
18    pub required_bytes: usize,
19    pub limit_bytes: usize,
20}
21
22impl From<SecretInputReadLimit> for RedDBError {
23    fn from(limit: SecretInputReadLimit) -> Self {
24        crate::storage::unified::StoreError::ReadLimitExceeded {
25            required_bytes: limit.required_bytes,
26            limit_bytes: limit.limit_bytes,
27        }
28        .into()
29    }
30}
31
32impl RedDBRuntime {
33    pub(crate) fn resolve_secret_column_value(
34        &self,
35        value: Value,
36        is_secret_column: bool,
37    ) -> RedDBResult<Value> {
38        let value = if is_secret_column {
39            match value {
40                Value::Text(text) => {
41                    let key = self.secret_aes_key().ok_or_else(|| {
42                        RedDBError::Query("SECRET column requires an available vault key".into())
43                    })?;
44                    Value::Secret(encrypt_secret_payload(&key, text.as_bytes()))
45                }
46                Value::Null | Value::Secret(_) => value,
47                _ => {
48                    return Err(RedDBError::Query(
49                        "SECRET columns accept text or encrypted secrets".into(),
50                    ))
51                }
52            }
53        } else {
54            value
55        };
56        self.resolve_crypto_sentinel(value)
57    }
58
59    pub(crate) fn secret_query_input(&self, value: Value) -> Option<Value> {
60        // Optional compatibility adapters have not all acquired an operating
61        // error channel yet. Keep their existing behavior until each is moved
62        // to the fallible evaluator; never turn a new limit failure into NULL.
63        self.decode_secret_query_input(value, usize::MAX)
64            .ok()
65            .flatten()
66    }
67
68    pub(crate) fn try_secret_query_input(
69        &self,
70        value: Value,
71    ) -> Result<Option<Value>, SecretInputReadLimit> {
72        let limit_bytes =
73            usize::try_from(self.memory_accounting().budget().resolved_bytes).unwrap_or(usize::MAX);
74        self.decode_secret_query_input(value, limit_bytes)
75    }
76
77    fn decode_secret_query_input(
78        &self,
79        value: Value,
80        limit_bytes: usize,
81    ) -> Result<Option<Value>, SecretInputReadLimit> {
82        let Value::Secret(payload) = value else {
83            return Ok(Some(value));
84        };
85        let Some(key) = self.secret_aes_key() else {
86            return Ok(None);
87        };
88        let cipher_bytes = payload.len().saturating_mul(2);
89        let crypto_bytes = cipher_bytes.saturating_add(payload.len());
90        if crypto_bytes > limit_bytes {
91            return Err(SecretInputReadLimit {
92                required_bytes: crypto_bytes,
93                limit_bytes,
94            });
95        }
96        if let Some(plain) = decrypt_secret_payload(&key, &payload) {
97            return Ok(String::from_utf8(plain).ok().map(Value::text));
98        }
99        if payload.len() < 12 {
100            return Ok(None);
101        }
102        let nonce: &[u8; 12] = payload[..12].try_into().expect("validated nonce length");
103        let Ok(plain) = crate::crypto::aes_gcm::aes256_gcm_decrypt(
104            &key,
105            nonce,
106            b"reddb.secret.value",
107            &payload[12..],
108        ) else {
109            return Ok(None);
110        };
111        let (value, consumed) =
112            match Value::from_bytes_with_limit(&plain, limit_bytes.saturating_sub(cipher_bytes)) {
113                Ok(decoded) => decoded,
114                Err(reddb_types::ValueError::ReadLimitExceeded { required_bytes, .. }) => {
115                    return Err(SecretInputReadLimit {
116                        required_bytes: required_bytes.saturating_add(cipher_bytes),
117                        limit_bytes,
118                    });
119                }
120                Err(_) => return Ok(None),
121            };
122        Ok((consumed == plain.len()).then_some(value))
123    }
124
125    /// Intermediate expressions are encrypted as typed values. They can pass
126    /// through CTEs and subqueries without losing their sensitivity or type.
127    pub(crate) fn secret_query_output(&self, value: Value) -> RedDBResult<Value> {
128        let key = self.secret_aes_key().ok_or_else(|| {
129            RedDBError::Query("secret expression requires an available vault key".into())
130        })?;
131        let nonce_bytes = crate::auth::store::random_bytes(12);
132        let nonce: &[u8; 12] = nonce_bytes
133            .as_slice()
134            .try_into()
135            .expect("invariant: generated nonce has twelve bytes");
136        let ciphertext = crate::crypto::aes_gcm::aes256_gcm_encrypt(
137            &key,
138            nonce,
139            b"reddb.secret.value",
140            &value.to_bytes(),
141        );
142        let mut payload = nonce_bytes;
143        payload.extend_from_slice(&ciphertext);
144        Ok(Value::Secret(payload))
145    }
146    /// Strip the plaintext sentinel from a `Value::Password` or
147    /// `Value::Secret` produced by the parser and apply the real
148    /// crypto transform. `Password` is always hashed with argon2id.
149    /// `Secret` is encrypted with AES-256-GCM keyed by the vault
150    /// when `red.config.secret.auto_encrypt = true` (default).
151    pub(crate) fn resolve_crypto_sentinel(&self, value: Value) -> RedDBResult<Value> {
152        match value {
153            Value::Password(marked) => {
154                if let Some(plain) = marked.strip_prefix(PLAINTEXT_SENTINEL) {
155                    Ok(Value::Password(crate::auth::store::hash_password(plain)))
156                } else {
157                    Ok(Value::Password(marked))
158                }
159            }
160            Value::Secret(bytes) => {
161                if bytes.starts_with(PLAINTEXT_SENTINEL.as_bytes()) {
162                    if !self.secret_auto_encrypt() {
163                        return Err(RedDBError::Query(
164                            "SECRET() literal rejected: red.config.secret.auto_encrypt \
165                             is false. Insert pre-encrypted bytes directly instead."
166                                .to_string(),
167                        ));
168                    }
169                    let key = self.secret_aes_key().ok_or_else(|| {
170                        RedDBError::Query(
171                            "SECRET() column encryption requires a bootstrapped \
172                             vault (red.secret.aes_key is missing). Start the server \
173                             with --vault to enable."
174                                .to_string(),
175                        )
176                    })?;
177                    let plain = &bytes[PLAINTEXT_SENTINEL.len()..];
178                    Ok(Value::Secret(encrypt_secret_payload(&key, plain)))
179                } else {
180                    Ok(Value::Secret(bytes))
181                }
182            }
183            other => Ok(other),
184        }
185    }
186}
187
188/// Encode an AES-256-GCM ciphertext as `[12-byte nonce][ciphertext||tag]`.
189/// This is the on-disk representation of `Value::Secret`.
190fn encrypt_secret_payload(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
191    let nonce_bytes = crate::auth::store::random_bytes(12);
192    let mut nonce = [0u8; 12];
193    nonce.copy_from_slice(&nonce_bytes[..12]);
194    let ct = crate::crypto::aes_gcm::aes256_gcm_encrypt(key, &nonce, b"reddb.secret", plaintext);
195    let mut out = Vec::with_capacity(12 + ct.len());
196    out.extend_from_slice(&nonce);
197    out.extend_from_slice(&ct);
198    out
199}
200
201/// Decode a `Value::Secret` payload back to plaintext. Returns
202/// `None` when the payload is too short or AES-GCM authentication
203/// fails (tampered or wrong key).
204pub(crate) fn decrypt_secret_payload(key: &[u8; 32], payload: &[u8]) -> Option<Vec<u8>> {
205    if payload.len() < 12 {
206        return None;
207    }
208    let mut nonce = [0u8; 12];
209    nonce.copy_from_slice(&payload[..12]);
210    crate::crypto::aes_gcm::aes256_gcm_decrypt(key, &nonce, b"reddb.secret", &payload[12..]).ok()
211}