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
17#[derive(Debug, Serialize, Deserialize)]
18struct TransportEnvelope {
19 version: u8,
20 actor_json: Value,
21 payload_json: Value,
22}
23
24#[derive(Clone)]
26pub struct TransportCrypto {
27 key: Zeroizing<[u8; 32]>,
28}
29
30impl TransportCrypto {
31 #[must_use]
36 pub fn from_bytes(key: [u8; 32]) -> Self {
37 Self {
38 key: Zeroizing::new(key),
39 }
40 }
41
42 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 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 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 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}