use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zeroize::Zeroizing;
use crate::error::{PhotonError, Result};
const ENVELOPE_VERSION: u8 = 1;
const KEY_ENV: &str = "PHOTON_TRANSPORT_KEY";
const ALLOW_DEV_KEY_ENV: &str = "PHOTON_ALLOW_DEV_TRANSPORT_KEY";
const DEV_KEY: [u8; 32] = *b"photon-dev-transport-key-32bytes";
#[derive(Debug, Serialize, Deserialize)]
struct TransportEnvelope {
version: u8,
actor_json: Value,
payload_json: Value,
}
#[derive(Clone)]
pub struct TransportCrypto {
key: Zeroizing<[u8; 32]>,
}
impl TransportCrypto {
#[must_use]
pub fn from_bytes(key: [u8; 32]) -> Self {
Self {
key: Zeroizing::new(key),
}
}
pub fn from_env() -> Result<Self> {
let raw = std::env::var(KEY_ENV).map_err(|_| {
PhotonError::Internal(format!(
"{KEY_ENV} is required (base64-encoded 32-byte transport key). \
For local development only, set {ALLOW_DEV_KEY_ENV}=1 to allow the \
hard-coded development key via from_env_or_dev_default()"
))
})?;
Self::from_base64(raw.trim())
}
pub fn from_env_or_dev_default() -> Result<Self> {
match Self::from_env() {
Ok(crypto) => Ok(crypto),
Err(env_err) => {
if allow_dev_transport_key() {
eprintln!(
"WARNING: Photon is using the hard-coded development transport key \
({ALLOW_DEV_KEY_ENV} is set). Do not use this in production. \
Set {KEY_ENV} to a base64-encoded 32-byte key instead."
);
Ok(Self::from_bytes(DEV_KEY))
} else {
Err(env_err)
}
}
}
}
fn from_base64(s: &str) -> Result<Self> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s)
.map_err(|e| {
PhotonError::Internal(format!("{KEY_ENV} is not valid standard base64: {e}"))
})?;
if bytes.len() != 32 {
return Err(PhotonError::Internal(format!(
"{KEY_ENV} must decode to exactly 32 bytes (got {})",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Self::from_bytes(key))
}
pub fn encrypt(&self, actor_json: &Value, payload_json: &Value) -> Result<Vec<u8>> {
let plaintext = serde_json::to_vec(&TransportEnvelope {
version: ENVELOPE_VERSION,
actor_json: actor_json.clone(),
payload_json: payload_json.clone(),
})?;
if bench_crypto_disabled() {
return Ok(plaintext);
}
self.seal(&plaintext)
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<(Value, Value)> {
let plaintext = if bench_crypto_disabled() {
ciphertext.to_vec()
} else {
self.open(ciphertext)?
};
let env: TransportEnvelope = serde_json::from_slice(&plaintext)
.map_err(|e| PhotonError::PayloadError(e.to_string()))?;
if env.version != ENVELOPE_VERSION {
return Err(PhotonError::PayloadError(format!(
"unsupported transport envelope version {}",
env.version
)));
}
Ok((env.actor_json, env.payload_json))
}
fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
.map_err(|e| PhotonError::Internal(e.to_string()))?;
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut nonce);
let ct = cipher
.encrypt(XNonce::from_slice(&nonce), plaintext)
.map_err(|e| PhotonError::Internal(e.to_string()))?;
let mut out = Vec::with_capacity(24 + ct.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
fn open(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
if ciphertext.len() < 24 {
return Err(PhotonError::PayloadError(
"transport ciphertext too short".into(),
));
}
let (nonce, ct) = ciphertext.split_at(24);
let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_slice())
.map_err(|e| PhotonError::Internal(e.to_string()))?;
cipher
.decrypt(XNonce::from_slice(nonce), ct)
.map_err(|e| PhotonError::PayloadError(e.to_string()))
}
}
fn allow_dev_transport_key() -> bool {
matches!(
std::env::var(ALLOW_DEV_KEY_ENV).as_deref(),
Ok("1" | "true" | "TRUE" | "yes" | "YES")
)
}
fn bench_crypto_disabled() -> bool {
matches!(
std::env::var("PHOTON_BENCH_CRYPTO").as_deref(),
Ok("0" | "false" | "FALSE" | "no" | "NO")
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roundtrip_encrypt_decrypt() {
let crypto = TransportCrypto::from_bytes(DEV_KEY);
let actor = json!({"System": {"operation": "test"}});
let payload = json!({"n": 1});
let ct = crypto.encrypt(&actor, &payload).expect("encrypt");
let (a, p) = crypto.decrypt(&ct).expect("decrypt");
assert_eq!(a, actor);
assert_eq!(p, payload);
}
}