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#[derive(Debug, Serialize, Deserialize)]
18struct TransportEnvelope {
19    version: u8,
20    actor_json: Value,
21    payload_json: Value,
22}
23
24/// Symmetric payload encryption (XChaCha20-Poly1305).
25#[derive(Clone)]
26pub struct TransportCrypto {
27    key: Zeroizing<[u8; 32]>,
28}
29
30impl TransportCrypto {
31    /// Build from an explicit 32-byte key.
32    ///
33    /// Callers own key material and secrecy. Prefer this in tests and when wiring a
34    /// key from a secrets manager rather than the process environment.
35    #[must_use]
36    pub fn from_bytes(key: [u8; 32]) -> Self {
37        Self {
38            key: Zeroizing::new(key),
39        }
40    }
41
42    /// Load key from `PHOTON_TRANSPORT_KEY` (standard base64 encoding of exactly 32 bytes).
43    ///
44    /// # Errors
45    ///
46    /// Returns [`PhotonError::Internal`] when the variable is missing, not valid
47    /// base64, or does not decode to 32 bytes. Production hosts should fail closed here.
48    ///
49    /// # Contract
50    ///
51    /// Does not fall back to a development key. Use [`Self::from_env_or_dev_default`]
52    /// only with an explicit development opt-in.
53    pub fn from_env() -> Result<Self> {
54        let raw = std::env::var(KEY_ENV).map_err(|_| {
55            PhotonError::Internal(format!(
56                "{KEY_ENV} is required (base64-encoded 32-byte transport key). \
57                 For local development only, set {ALLOW_DEV_KEY_ENV}=1 to allow the \
58                 hard-coded development key via from_env_or_dev_default()"
59            ))
60        })?;
61        Self::from_base64(raw.trim())
62    }
63
64    /// Load key from `PHOTON_TRANSPORT_KEY`, or the hard-coded development key when
65    /// explicitly opted in.
66    ///
67    /// **Development-only.** The hard-coded key is used only when
68    /// `PHOTON_ALLOW_DEV_TRANSPORT_KEY` is `1` or `true` **and** `PHOTON_TRANSPORT_KEY`
69    /// is unset or invalid. A loud warning is printed when the development key is used.
70    ///
71    /// Prefer [`Self::from_env`] in production and CI (set a real `PHOTON_TRANSPORT_KEY`).
72    ///
73    /// # Errors
74    ///
75    /// Returns an error when the environment key is missing/invalid and the
76    /// development-key opt-in is not set.
77    pub fn from_env_or_dev_default() -> Result<Self> {
78        match Self::from_env() {
79            Ok(crypto) => Ok(crypto),
80            Err(env_err) => {
81                if allow_dev_transport_key() {
82                    eprintln!(
83                        "WARNING: Photon is using the hard-coded development transport key \
84                         ({ALLOW_DEV_KEY_ENV} is set). Do not use this in production. \
85                         Set {KEY_ENV} to a base64-encoded 32-byte key instead."
86                    );
87                    Ok(Self::from_bytes(DEV_KEY))
88                } else {
89                    Err(env_err)
90                }
91            }
92        }
93    }
94
95    fn from_base64(s: &str) -> Result<Self> {
96        let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s)
97            .map_err(|e| {
98                PhotonError::Internal(format!("{KEY_ENV} is not valid standard base64: {e}"))
99            })?;
100        if bytes.len() != 32 {
101            return Err(PhotonError::Internal(format!(
102                "{KEY_ENV} must decode to exactly 32 bytes (got {})",
103                bytes.len()
104            )));
105        }
106        let mut key = [0u8; 32];
107        key.copy_from_slice(&bytes);
108        Ok(Self::from_bytes(key))
109    }
110
111    /// Encrypt actor + payload JSON into opaque ciphertext bytes.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the operation fails.
116    pub fn encrypt(&self, actor_json: &Value, payload_json: &Value) -> Result<Vec<u8>> {
117        let plaintext = serde_json::to_vec(&TransportEnvelope {
118            version: ENVELOPE_VERSION,
119            actor_json: actor_json.clone(),
120            payload_json: payload_json.clone(),
121        })?;
122        if bench_crypto_disabled() {
123            return Ok(plaintext);
124        }
125        self.seal(&plaintext)
126    }
127
128    /// Decrypt ciphertext into actor + payload JSON.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the operation fails.
133    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<(Value, Value)> {
134        let plaintext = if bench_crypto_disabled() {
135            ciphertext.to_vec()
136        } else {
137            self.open(ciphertext)?
138        };
139        let env: TransportEnvelope = serde_json::from_slice(&plaintext)
140            .map_err(|e| PhotonError::PayloadError(e.to_string()))?;
141        if env.version != ENVELOPE_VERSION {
142            return Err(PhotonError::PayloadError(format!(
143                "unsupported transport envelope version {}",
144                env.version
145            )));
146        }
147        Ok((env.actor_json, env.payload_json))
148    }
149
150    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
151        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
152            .map_err(|e| PhotonError::Internal(e.to_string()))?;
153        let mut nonce = [0u8; 24];
154        OsRng.fill_bytes(&mut nonce);
155        let ct = cipher
156            .encrypt(XNonce::from_slice(&nonce), plaintext)
157            .map_err(|e| PhotonError::Internal(e.to_string()))?;
158        let mut out = Vec::with_capacity(24 + ct.len());
159        out.extend_from_slice(&nonce);
160        out.extend_from_slice(&ct);
161        Ok(out)
162    }
163
164    fn open(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
165        if ciphertext.len() < 24 {
166            return Err(PhotonError::PayloadError(
167                "transport ciphertext too short".into(),
168            ));
169        }
170        let (nonce, ct) = ciphertext.split_at(24);
171        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
172            .map_err(|e| PhotonError::Internal(e.to_string()))?;
173        cipher
174            .decrypt(XNonce::from_slice(nonce), ct)
175            .map_err(|e| PhotonError::PayloadError(e.to_string()))
176    }
177}
178
179fn allow_dev_transport_key() -> bool {
180    matches!(
181        std::env::var(ALLOW_DEV_KEY_ENV).as_deref(),
182        Ok("1" | "true" | "TRUE" | "yes" | "YES")
183    )
184}
185
186fn bench_crypto_disabled() -> bool {
187    matches!(
188        std::env::var("PHOTON_BENCH_CRYPTO").as_deref(),
189        Ok("0" | "false" | "FALSE" | "no" | "NO")
190    )
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::json;
197
198    #[test]
199    fn roundtrip_encrypt_decrypt() {
200        let crypto = TransportCrypto::from_bytes(DEV_KEY);
201        let actor = json!({"System": {"operation": "test"}});
202        let payload = json!({"n": 1});
203        let ct = crypto.encrypt(&actor, &payload).expect("encrypt");
204        let (a, p) = crypto.decrypt(&ct).expect("decrypt");
205        assert_eq!(a, actor);
206        assert_eq!(p, payload);
207    }
208}