Skip to main content

photon_backend/event/
envelope.rs

1//! Payload envelope encryption for storage adapters.
2
3use chacha20poly1305::aead::{Aead, KeyInit};
4use chacha20poly1305::{XChaCha20Poly1305, XNonce};
5use rand_core::{OsRng, RngCore};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use zeroize::Zeroizing;
9
10use crate::error::{PhotonError, Result};
11
12const ENVELOPE_VERSION: u8 = 1;
13const KEY_ENV: &str = "PHOTON_TRANSPORT_KEY";
14const ALLOW_DEV_KEY_ENV: &str = "PHOTON_ALLOW_DEV_TRANSPORT_KEY";
15const DEV_KEY: [u8; 32] = *b"photon-dev-transport-key-32bytes";
16
17/// JSON object key marking sealed actor/payload slots on stored and wire events.
18pub const ENVELOPE_JSON_KEY: &str = "__photon_envelope_v1";
19
20#[derive(Debug, Serialize, Deserialize)]
21struct TransportEnvelope {
22    version: u8,
23    actor_json: Value,
24    payload_json: Value,
25}
26
27/// Symmetric payload encryption (XChaCha20-Poly1305).
28#[derive(Clone)]
29pub struct TransportCrypto {
30    key: Zeroizing<[u8; 32]>,
31}
32
33impl TransportCrypto {
34    /// Build from an explicit 32-byte key.
35    ///
36    /// Callers own key material and secrecy. Prefer this in tests and when wiring a
37    /// key from a secrets manager rather than the process environment.
38    #[must_use]
39    pub fn from_bytes(key: [u8; 32]) -> Self {
40        Self {
41            key: Zeroizing::new(key),
42        }
43    }
44
45    /// Load key from `PHOTON_TRANSPORT_KEY` (standard base64 encoding of exactly 32 bytes).
46    ///
47    /// # Errors
48    ///
49    /// Returns [`PhotonError::Internal`] when the variable is missing, not valid
50    /// base64, or does not decode to 32 bytes. Production hosts should fail closed here.
51    ///
52    /// # Contract
53    ///
54    /// Does not fall back to a development key. Use [`Self::from_env_or_dev_default`]
55    /// only with an explicit development opt-in.
56    pub fn from_env() -> Result<Self> {
57        let raw = std::env::var(KEY_ENV).map_err(|_| {
58            PhotonError::Internal(format!(
59                "{KEY_ENV} is required (base64-encoded 32-byte transport key). \
60                 For local development only, set {ALLOW_DEV_KEY_ENV}=1 to allow the \
61                 hard-coded development key via from_env_or_dev_default()"
62            ))
63        })?;
64        Self::from_base64(raw.trim())
65    }
66
67    /// Load key from `PHOTON_TRANSPORT_KEY`, or the hard-coded development key when
68    /// explicitly opted in.
69    ///
70    /// **Development-only.** The hard-coded key is used only when
71    /// `PHOTON_ALLOW_DEV_TRANSPORT_KEY` is `1` or `true` **and** `PHOTON_TRANSPORT_KEY`
72    /// is unset or invalid. A loud warning is printed when the development key is used.
73    ///
74    /// Prefer [`Self::from_env`] in production and CI (set a real `PHOTON_TRANSPORT_KEY`).
75    ///
76    /// # Errors
77    ///
78    /// Returns an error when the environment key is missing/invalid and the
79    /// development-key opt-in is not set.
80    pub fn from_env_or_dev_default() -> Result<Self> {
81        match Self::from_env() {
82            Ok(crypto) => Ok(crypto),
83            Err(env_err) => {
84                if allow_dev_transport_key() {
85                    tracing::warn!(
86                        env = ALLOW_DEV_KEY_ENV,
87                        key_env = KEY_ENV,
88                        "Photon is using the hard-coded development transport key; do not use in production"
89                    );
90                    Ok(Self::from_bytes(DEV_KEY))
91                } else {
92                    Err(env_err)
93                }
94            }
95        }
96    }
97
98    fn from_base64(s: &str) -> Result<Self> {
99        let bytes =
100            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s).map_err(|e| {
101                PhotonError::caused(format!("{KEY_ENV} is not valid standard base64"), e)
102            })?;
103        if bytes.len() != 32 {
104            return Err(PhotonError::Internal(format!(
105                "{KEY_ENV} must decode to exactly 32 bytes (got {})",
106                bytes.len()
107            )));
108        }
109        let mut key = [0u8; 32];
110        key.copy_from_slice(&bytes);
111        Ok(Self::from_bytes(key))
112    }
113
114    /// Encrypt actor + payload JSON into opaque ciphertext bytes.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the operation fails.
119    pub fn encrypt(&self, actor_json: &Value, payload_json: &Value) -> Result<Vec<u8>> {
120        let plaintext = serde_json::to_vec(&TransportEnvelope {
121            version: ENVELOPE_VERSION,
122            actor_json: actor_json.clone(),
123            payload_json: payload_json.clone(),
124        })?;
125        if bench_crypto_disabled() {
126            return Ok(plaintext);
127        }
128        self.seal(&plaintext)
129    }
130
131    /// Decrypt ciphertext into actor + payload JSON.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the operation fails.
136    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<(Value, Value)> {
137        let plaintext = if bench_crypto_disabled() {
138            ciphertext.to_vec()
139        } else {
140            self.open(ciphertext)?
141        };
142        let env: TransportEnvelope = serde_json::from_slice(&plaintext)
143            .map_err(|e| PhotonError::PayloadError(e.to_string()))?;
144        if env.version != ENVELOPE_VERSION {
145            return Err(PhotonError::PayloadError(format!(
146                "unsupported transport envelope version {}",
147                env.version
148            )));
149        }
150        Ok((env.actor_json, env.payload_json))
151    }
152
153    /// Seal actor and payload JSON for storage or transport.
154    ///
155    /// The actor slot is replaced with `null`; the encrypted envelope is standard-base64 encoded
156    /// under [`ENVELOPE_JSON_KEY`] in the payload slot.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if JSON serialization or encryption fails.
161    pub fn seal_json_fields(
162        &self,
163        actor_json: &Value,
164        payload_json: &Value,
165    ) -> Result<(Value, Value)> {
166        let ciphertext = self.encrypt(actor_json, payload_json)?;
167        let encoded =
168            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext);
169        Ok((
170            Value::Null,
171            serde_json::json!({ ENVELOPE_JSON_KEY: encoded }),
172        ))
173    }
174
175    /// Open actor and payload JSON from a stored or wire event.
176    ///
177    /// Unmarked fields are treated as legacy plaintext so existing persisted rows remain
178    /// readable during migration.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if a marked envelope contains invalid base64 or cannot be decrypted.
183    pub fn open_json_fields(
184        &self,
185        actor_json: &Value,
186        payload_json: &Value,
187    ) -> Result<(Value, Value)> {
188        let Some(encoded) = payload_json
189            .as_object()
190            .and_then(|fields| fields.get(ENVELOPE_JSON_KEY))
191            .and_then(Value::as_str)
192        else {
193            return Ok((actor_json.clone(), payload_json.clone()));
194        };
195        let ciphertext =
196            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).map_err(
197                |e| PhotonError::caused("transport envelope is not valid standard base64", e),
198            )?;
199        self.decrypt(&ciphertext)
200    }
201
202    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
203        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
204            .map_err(|e| PhotonError::caused("transport seal key", e))?;
205        let mut nonce = [0u8; 24];
206        OsRng.fill_bytes(&mut nonce);
207        let ct = cipher
208            .encrypt(XNonce::from_slice(&nonce), plaintext)
209            .map_err(|e| PhotonError::caused("transport seal encrypt", e))?;
210        let mut out = Vec::with_capacity(24 + ct.len());
211        out.extend_from_slice(&nonce);
212        out.extend_from_slice(&ct);
213        Ok(out)
214    }
215
216    fn open(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
217        if ciphertext.len() < 24 {
218            return Err(PhotonError::PayloadError(
219                "transport ciphertext too short".into(),
220            ));
221        }
222        let (nonce, ct) = ciphertext.split_at(24);
223        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
224            .map_err(|e| PhotonError::caused("transport open key", e))?;
225        cipher
226            .decrypt(XNonce::from_slice(nonce), ct)
227            .map_err(|e| PhotonError::caused("transport open decrypt", e))
228    }
229}
230
231fn allow_dev_transport_key() -> bool {
232    matches!(
233        std::env::var(ALLOW_DEV_KEY_ENV).as_deref(),
234        Ok("1" | "true" | "TRUE" | "yes" | "YES")
235    )
236}
237
238fn bench_crypto_disabled() -> bool {
239    matches!(
240        std::env::var("PHOTON_BENCH_CRYPTO").as_deref(),
241        Ok("0" | "false" | "FALSE" | "no" | "NO")
242    )
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    #[test]
251    fn roundtrip_encrypt_decrypt() {
252        let crypto = TransportCrypto::from_bytes(DEV_KEY);
253        let actor = json!({"System": {"operation": "test"}});
254        let payload = json!({"n": 1});
255        let ct = crypto.encrypt(&actor, &payload).expect("encrypt");
256        let (a, p) = crypto.decrypt(&ct).expect("decrypt");
257        assert_eq!(a, actor);
258        assert_eq!(p, payload);
259    }
260
261    #[test]
262    fn seal_open_roundtrip() {
263        let crypto = TransportCrypto::from_bytes(DEV_KEY);
264        let actor = json!({"System": {"operation": "test"}});
265        let payload = json!({"secret": "SECRET_PLAINTEXT_MARKER_xyz"});
266
267        let (sealed_actor, sealed_payload) = crypto
268            .seal_json_fields(&actor, &payload)
269            .expect("seal fields");
270        assert_eq!(sealed_actor, Value::Null);
271        assert!(!sealed_payload
272            .to_string()
273            .contains("SECRET_PLAINTEXT_MARKER_xyz"));
274
275        let (opened_actor, opened_payload) = crypto
276            .open_json_fields(&sealed_actor, &sealed_payload)
277            .expect("open fields");
278        assert_eq!(opened_actor, actor);
279        assert_eq!(opened_payload, payload);
280    }
281
282    #[test]
283    fn open_legacy_plaintext_passthrough() {
284        let crypto = TransportCrypto::from_bytes(DEV_KEY);
285        let actor = json!({"System": {"operation": "legacy"}});
286        let payload = json!({"legacy": true});
287
288        let (opened_actor, opened_payload) = crypto
289            .open_json_fields(&actor, &payload)
290            .expect("open legacy fields");
291        assert_eq!(opened_actor, actor);
292        assert_eq!(opened_payload, payload);
293    }
294
295    #[test]
296    fn encrypt_ciphertext_differs_from_plaintext_json() {
297        let crypto = TransportCrypto::from_bytes(DEV_KEY);
298        let actor = json!({"System": {"operation": "test"}});
299        let payload = json!({"n": 1});
300        let encrypted = crypto.encrypt(&actor, &payload).expect("encrypt");
301        let plaintext = serde_json::to_vec(&TransportEnvelope {
302            version: ENVELOPE_VERSION,
303            actor_json: actor,
304            payload_json: payload,
305        })
306        .expect("serialize plaintext");
307        assert_ne!(encrypted, plaintext);
308    }
309
310    #[test]
311    fn open_rejects_invalid_base64_envelope() {
312        let crypto = TransportCrypto::from_bytes(DEV_KEY);
313        let payload = json!({ ENVELOPE_JSON_KEY: "not base64!" });
314        assert!(crypto.open_json_fields(&Value::Null, &payload).is_err());
315    }
316}