photon_backend/event/
envelope.rs1use 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
17pub 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#[derive(Clone)]
29pub struct TransportCrypto {
30 key: Zeroizing<[u8; 32]>,
31}
32
33impl TransportCrypto {
34 #[must_use]
39 pub fn from_bytes(key: [u8; 32]) -> Self {
40 Self {
41 key: Zeroizing::new(key),
42 }
43 }
44
45 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 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 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 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 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 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}