use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use regex::Regex;
use std::fmt;
use std::sync::LazyLock;
use thiserror::Error;
pub const NONCE_SIZE: usize = 24;
pub const PUBLIC_KEY_SIZE: usize = 32;
static MESSAGE_PARSER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^EJ\[(\d):([A-Za-z0-9+=/]{44}):([A-Za-z0-9+=/]{32}):(.+)\]$").unwrap()
});
#[derive(Error, Debug)]
pub enum BoxedMessageError {
#[error("invalid message format")]
InvalidFormat,
#[error("invalid base64 encoding")]
InvalidBase64,
#[error("public key invalid")]
InvalidPublicKey,
#[error("nonce invalid")]
InvalidNonce,
#[error("invalid schema version")]
InvalidSchemaVersion,
}
#[derive(Clone)]
pub struct BoxedMessage {
pub schema_version: u8,
pub encrypter_public: [u8; PUBLIC_KEY_SIZE],
pub nonce: [u8; NONCE_SIZE],
pub box_data: Vec<u8>,
}
impl fmt::Debug for BoxedMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BoxedMessage")
.field("schema_version", &self.schema_version)
.field("encrypter_public", &"[REDACTED]")
.field("nonce", &"[REDACTED]")
.field("box_data", &format!("[{} bytes]", self.box_data.len()))
.finish()
}
}
impl BoxedMessage {
pub fn estimate_size(plaintext_len: usize) -> usize {
let ciphertext_len = plaintext_len + 16;
let box_b64_len = ciphertext_len.div_ceil(3) * 4;
4 + 44 + 1 + 32 + 1 + box_b64_len + 1 }
pub fn dump(&self) -> Vec<u8> {
let estimated_size = Self::estimate_size(self.box_data.len());
let mut result = Vec::with_capacity(estimated_size);
result.extend_from_slice(b"EJ[");
result.extend_from_slice(self.schema_version.to_string().as_bytes());
result.push(b':');
result.extend_from_slice(BASE64.encode(self.encrypter_public).as_bytes());
result.push(b':');
result.extend_from_slice(BASE64.encode(self.nonce).as_bytes());
result.push(b':');
result.extend_from_slice(BASE64.encode(&self.box_data).as_bytes());
result.push(b']');
result
}
pub fn load(data: &[u8]) -> Result<Self, BoxedMessageError> {
let s = std::str::from_utf8(data).map_err(|_| BoxedMessageError::InvalidFormat)?;
let captures = MESSAGE_PARSER
.captures(s)
.ok_or(BoxedMessageError::InvalidFormat)?;
let schema_version: u8 = captures
.get(1)
.ok_or(BoxedMessageError::InvalidFormat)?
.as_str()
.parse()
.map_err(|_| BoxedMessageError::InvalidSchemaVersion)?;
let pub_b64 = captures
.get(2)
.ok_or(BoxedMessageError::InvalidFormat)?
.as_str();
let pub_bytes = BASE64
.decode(pub_b64)
.map_err(|_| BoxedMessageError::InvalidBase64)?;
let encrypter_public: [u8; PUBLIC_KEY_SIZE] = pub_bytes
.try_into()
.map_err(|_| BoxedMessageError::InvalidPublicKey)?;
let nonce_b64 = captures
.get(3)
.ok_or(BoxedMessageError::InvalidFormat)?
.as_str();
let nonce_bytes = BASE64
.decode(nonce_b64)
.map_err(|_| BoxedMessageError::InvalidBase64)?;
let nonce: [u8; NONCE_SIZE] = nonce_bytes
.try_into()
.map_err(|_| BoxedMessageError::InvalidNonce)?;
let box_b64 = captures
.get(4)
.ok_or(BoxedMessageError::InvalidFormat)?
.as_str();
let box_data = BASE64
.decode(box_b64)
.map_err(|_| BoxedMessageError::InvalidBase64)?;
Ok(Self {
schema_version,
encrypter_public,
nonce,
box_data,
})
}
}
pub fn is_boxed_message(data: &[u8]) -> bool {
if !data.starts_with(b"EJ[") {
return false;
}
if let Ok(s) = std::str::from_utf8(data) {
MESSAGE_PARSER.is_match(s)
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_boxed_message_roundtrip() {
let msg = BoxedMessage {
schema_version: 1,
encrypter_public: [1u8; 32],
nonce: [2u8; 24],
box_data: vec![3, 4, 5, 6, 7, 8, 9, 10],
};
let serialized = msg.dump();
let parsed = BoxedMessage::load(&serialized).unwrap();
assert_eq!(parsed.schema_version, msg.schema_version);
assert_eq!(parsed.encrypter_public, msg.encrypter_public);
assert_eq!(parsed.nonce, msg.nonce);
assert_eq!(parsed.box_data, msg.box_data);
}
#[test]
fn test_is_boxed_message() {
let valid = b"EJ[1:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=:AgICAgICAgICAgICAgICAgICAgICAgIC:AwQFBgcICQo=]";
assert!(is_boxed_message(valid));
assert!(!is_boxed_message(b"not encrypted"));
assert!(!is_boxed_message(b"EJ[invalid"));
assert!(!is_boxed_message(b""));
}
}