Skip to main content

wscall_protocol/
lib.rs

1//! Shared protocol definitions for WSCALL.
2//!
3//! This crate contains the transport envelope, frame codec, encryption modes,
4//! and inline attachment model used by both the server and client crates.
5
6use std::borrow::Cow;
7use std::sync::Arc;
8
9use aes_gcm::{Aes256Gcm, KeyInit as AesKeyInit, Nonce as AesNonce, aead::Aead as AesAead};
10use chacha20poly1305::{ChaCha20Poly1305, Nonce};
11use getrandom::getrandom;
12use serde::de::Deserializer;
13use serde::ser::SerializeMap;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18use x25519_dalek::{PublicKey, StaticSecret};
19
20// ─── ECDH Dynamic Key Agreement ─────────────────────────────────────────────
21//
22// When the ECDH handshake mode is enabled, the client and server exchange
23// raw X25519 public keys (32 bytes each) over the WebSocket binary channel
24// immediately after the TCP/TLS upgrade. Both sides derive the same 32-byte
25// ChaCha20-Poly1305 session key via SHA-256(domain ‖ DH secret). This key is
26// unique per connection and never travels over the wire.
27
28/// Domain-separation tag mixed into the SHA-256 KDF so that wscall session
29/// keys cannot collide with keys derived for any other protocol.
30pub const ECDH_DOMAIN_TAG: &[u8] = b"wscall-ecdh-v1";
31
32/// Byte length of an X25519 public key (also the secret key length).
33pub const ECDH_KEY_LEN: usize = 32;
34
35/// A freshly generated X25519 keypair for the ECDH handshake.
36///
37/// The secret is zeroized on drop. Keep the secret on the side that generated
38/// it and send only the [`EcdhKeypair::public`] bytes to the peer.
39pub struct EcdhKeypair {
40    secret: StaticSecret,
41    public: PublicKey,
42}
43
44impl EcdhKeypair {
45    /// Generates a random X25519 keypair using the platform CSPRNG.
46    pub fn generate() -> Result<Self, ProtocolError> {
47        let mut secret_bytes = [0u8; ECDH_KEY_LEN];
48        getrandom(&mut secret_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
49        let secret = StaticSecret::from(secret_bytes);
50        let public = PublicKey::from(&secret);
51        Ok(Self { secret, public })
52    }
53
54    /// Returns the 32-byte public key that should be sent to the peer.
55    pub fn public_bytes(&self) -> [u8; ECDH_KEY_LEN] {
56        self.public.to_bytes()
57    }
58
59    /// Derives the 32-byte ChaCha20-Poly1305 session key from the peer's
60    /// 32-byte public key.
61    ///
62    /// `peer_public` must be exactly 32 bytes received from the other side.
63    pub fn derive_session_key(&self, peer_public: &[u8; ECDH_KEY_LEN]) -> [u8; 32] {
64        let peer = PublicKey::from(*peer_public);
65        let shared = self.secret.diffie_hellman(&peer);
66        derive_session_key(&shared.to_bytes())
67    }
68}
69
70/// Derives a 32-byte symmetric key from a raw X25519 shared secret.
71///
72/// Uses SHA-256(domain ‖ shared_secret) for domain separation. The result is
73/// suitable as a ChaCha20-Poly1305 or AES-256-GCM key.
74pub fn derive_session_key(shared_secret: &[u8; 32]) -> [u8; 32] {
75    let mut hasher = Sha256::new();
76    hasher.update(ECDH_DOMAIN_TAG);
77    hasher.update(shared_secret);
78    let result = hasher.finalize();
79    let mut key = [0u8; 32];
80    key.copy_from_slice(&result);
81    key
82}
83
84/// Parses 32 raw bytes received from the peer into an X25519 public key.
85///
86/// Returns an error if the slice is not exactly 32 bytes.
87pub fn parse_peer_public(bytes: &[u8]) -> Result<[u8; ECDH_KEY_LEN], ProtocolError> {
88    if bytes.len() != ECDH_KEY_LEN {
89        return Err(ProtocolError::InvalidEcdhPublicKey {
90            expected: ECDH_KEY_LEN,
91            actual: bytes.len(),
92        });
93    }
94    let mut key = [0u8; ECDH_KEY_LEN];
95    key.copy_from_slice(bytes);
96    Ok(key)
97}
98
99const AES256_NONCE_LEN: usize = 12;
100const CHACHA20_NONCE_LEN: usize = 12;
101
102/// Default maximum frame size: 100 MiB.
103///
104/// This is no longer a hard protocol constant; the server configures it via
105/// `WscallServer::with_max_frame_bytes(n)` and the codec carries the value.
106pub const DEFAULT_MAX_FRAME_BYTES: usize = 100 * 1024 * 1024;
107
108/// Distinguishes API messages from event messages inside a WSCALL frame.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[repr(u8)]
111pub enum MessageType {
112    /// API request or API response.
113    Api = 0x00,
114    /// Event emit or event acknowledgement.
115    Event = 0x01,
116}
117
118impl TryFrom<u8> for MessageType {
119    type Error = ProtocolError;
120
121    fn try_from(value: u8) -> Result<Self, Self::Error> {
122        match value {
123            0x00 => Ok(Self::Api),
124            0x01 => Ok(Self::Event),
125            _ => Err(ProtocolError::UnknownMessageType(value)),
126        }
127    }
128}
129
130/// Selects how the payload section of a frame is encoded.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[repr(u8)]
133pub enum EncryptionKind {
134    /// Unencrypted JSON payload.
135    None = 0x00,
136    /// ChaCha20-Poly1305 encrypted payload.
137    ChaCha20 = 0x01,
138    /// AES256-GCM encrypted payload.
139    Aes256 = 0x02,
140}
141
142impl TryFrom<u8> for EncryptionKind {
143    type Error = ProtocolError;
144
145    fn try_from(value: u8) -> Result<Self, Self::Error> {
146        match value {
147            0x00 => Ok(Self::None),
148            0x01 => Ok(Self::ChaCha20),
149            0x02 => Ok(Self::Aes256),
150            _ => Err(ProtocolError::UnknownEncryption(value)),
151        }
152    }
153}
154
155/// Binary attachment carried alongside JSON params or event data.
156///
157/// In protocol v2 the attachment payload is stored as raw bytes in a binary
158/// section appended after the JSON metadata, eliminating the 33% overhead of
159/// Base64 encoding used in protocol v1.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct FileAttachment {
162    /// Attachment identifier referenced from JSON using `{ "$file": "..." }`.
163    pub id: String,
164    /// Original file name.
165    pub name: String,
166    /// MIME type supplied by the sender.
167    pub content_type: String,
168    /// Raw attachment payload bytes.
169    pub data: Vec<u8>,
170}
171
172impl FileAttachment {
173    /// Builds a text attachment from a string.
174    pub fn inline_text(
175        id: impl Into<String>,
176        name: impl Into<String>,
177        content_type: impl Into<String>,
178        text: impl AsRef<str>,
179    ) -> Self {
180        Self::inline_bytes(id, name, content_type, text.as_ref().as_bytes().to_vec())
181    }
182
183    /// Builds a binary attachment from raw bytes.
184    pub fn inline_bytes(
185        id: impl Into<String>,
186        name: impl Into<String>,
187        content_type: impl Into<String>,
188        bytes: Vec<u8>,
189    ) -> Self {
190        Self {
191            id: id.into(),
192            name: name.into(),
193            content_type: content_type.into(),
194            data: bytes,
195        }
196    }
197
198    /// Returns the byte length of the attachment payload.
199    pub fn size(&self) -> usize {
200        self.data.len()
201    }
202
203    /// Returns a JSON reference object that points to an attachment by id.
204    pub fn param_ref(id: impl Into<String>) -> Value {
205        json!({ "$file": id.into() })
206    }
207
208    /// Computes the total wire size of this attachment's binary section.
209    ///
210    /// Layout: id_len:u8 + id + name_len:u8 + name + ct_len:u8 + ct + data_len:u32 + data
211    pub(crate) fn wire_size(&self) -> usize {
212        1 + self.id.len() + 1 + self.name.len() + 1 + self.content_type.len() + 4 + self.data.len()
213    }
214
215    /// Appends this attachment's binary section to `buf`.
216    pub(crate) fn write_wire(&self, buf: &mut Vec<u8>) {
217        buf.push(self.id.len() as u8);
218        buf.extend_from_slice(self.id.as_bytes());
219        buf.push(self.name.len() as u8);
220        buf.extend_from_slice(self.name.as_bytes());
221        buf.push(self.content_type.len() as u8);
222        buf.extend_from_slice(self.content_type.as_bytes());
223        buf.extend_from_slice(&(self.data.len() as u32).to_be_bytes());
224        buf.extend_from_slice(&self.data);
225    }
226
227    /// Parses one attachment binary section from `data`, returning the
228    /// attachment and the remaining unconsumed bytes.
229    pub(crate) fn read_wire(data: &[u8]) -> Result<(Self, &[u8]), ProtocolError> {
230        let mut pos = 0;
231
232        // id
233        if pos >= data.len() {
234            return Err(ProtocolError::InvalidAttachmentEncoding(
235                "truncated id_len".into(),
236            ));
237        }
238        let id_len = data[pos] as usize;
239        pos += 1;
240        if pos + id_len > data.len() {
241            return Err(ProtocolError::InvalidAttachmentEncoding(
242                "truncated id".into(),
243            ));
244        }
245        let id = String::from_utf8_lossy(&data[pos..pos + id_len]).into_owned();
246        pos += id_len;
247
248        // name
249        if pos >= data.len() {
250            return Err(ProtocolError::InvalidAttachmentEncoding(
251                "truncated name_len".into(),
252            ));
253        }
254        let name_len = data[pos] as usize;
255        pos += 1;
256        if pos + name_len > data.len() {
257            return Err(ProtocolError::InvalidAttachmentEncoding(
258                "truncated name".into(),
259            ));
260        }
261        let name = String::from_utf8_lossy(&data[pos..pos + name_len]).into_owned();
262        pos += name_len;
263
264        // content_type
265        if pos >= data.len() {
266            return Err(ProtocolError::InvalidAttachmentEncoding(
267                "truncated ct_len".into(),
268            ));
269        }
270        let ct_len = data[pos] as usize;
271        pos += 1;
272        if pos + ct_len > data.len() {
273            return Err(ProtocolError::InvalidAttachmentEncoding(
274                "truncated content_type".into(),
275            ));
276        }
277        let content_type = String::from_utf8_lossy(&data[pos..pos + ct_len]).into_owned();
278        pos += ct_len;
279
280        // data
281        if pos + 4 > data.len() {
282            return Err(ProtocolError::InvalidAttachmentEncoding(
283                "truncated data_len".into(),
284            ));
285        }
286        let data_len =
287            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
288        pos += 4;
289        if pos + data_len > data.len() {
290            return Err(ProtocolError::InvalidAttachmentEncoding(
291                "truncated data".into(),
292            ));
293        }
294        let payload = data[pos..pos + data_len].to_vec();
295        pos += data_len;
296
297        Ok((
298            Self {
299                id,
300                name,
301                content_type,
302                data: payload,
303            },
304            &data[pos..],
305        ))
306    }
307}
308
309/// Standard error payload embedded in API responses and event acknowledgements.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ErrorPayload {
312    pub code: String,
313    pub message: String,
314    pub status: u16,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub details: Option<Value>,
317}
318
319/// Compact numeric tag identifying each [`PacketBody`] variant in the JSON
320/// wire format. Using a single-byte numeric field (`"k": 0`) instead of a
321/// string tag (`"kind": "api_request"`) saves several bytes per frame.
322pub const K_API_REQUEST: u8 = 0;
323pub const K_EVENT_EMIT: u8 = 1;
324pub const K_API_RESPONSE: u8 = 2;
325pub const K_EVENT_ACK: u8 = 3;
326
327/// JSON-level message body transported inside a WSCALL frame.
328///
329/// The wire format uses short single-letter keys and a numeric `k` tag to
330/// minimize per-frame overhead:
331///
332/// | Variant | `k` | Fields |
333/// | --- | --- | --- |
334/// | `ApiRequest`  | 0 | `i` `r` `p` `a` `m` |
335/// | `EventEmit`   | 1 | `i` `n` `d` `a` `m` `e` |
336/// | `ApiResponse` | 2 | `i` `o` `s` `d` `m` [`er`] |
337/// | `EventAck`    | 3 | `i` `o` `rc` [`er`] |
338#[derive(Debug, Clone)]
339pub enum PacketBody {
340    /// Client-to-server API request.
341    ApiRequest {
342        request_id: u64,
343        route: String,
344        params: Value,
345        attachments: Vec<FileAttachment>,
346        metadata: Value,
347    },
348    /// Server-to-client API response.
349    ApiResponse {
350        request_id: u64,
351        ok: bool,
352        status: u16,
353        data: Value,
354        error: Option<ErrorPayload>,
355        metadata: Value,
356    },
357    /// Event emission in either direction.
358    ///
359    /// The `data` payload is always a JSON object (never a scalar or string).
360    EventEmit {
361        event_id: u64,
362        name: String,
363        data: Map<String, Value>,
364        attachments: Vec<FileAttachment>,
365        metadata: Value,
366        expect_ack: bool,
367    },
368    /// Acknowledgement for an emitted event.
369    EventAck {
370        event_id: u64,
371        ok: bool,
372        receipt: Value,
373        error: Option<ErrorPayload>,
374    },
375}
376
377// --- Manual Serialize: short keys + numeric `k` tag -------------------------
378
379/// Returns `true` when the metadata value carries no information (null or `{}`).
380fn metadata_is_empty(v: &Value) -> bool {
381    matches!(v, Value::Null) || v.as_object().is_some_and(|m| m.is_empty())
382}
383
384impl Serialize for PacketBody {
385    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
386    where
387        S: serde::Serializer,
388    {
389        match self {
390            Self::ApiRequest {
391                request_id,
392                route,
393                params,
394                attachments: _,
395                metadata,
396            } => {
397                let include_meta = !metadata_is_empty(metadata);
398                let field_count = 4 + usize::from(include_meta);
399                let mut s = serializer.serialize_map(Some(field_count))?;
400                s.serialize_entry("k", &K_API_REQUEST)?;
401                s.serialize_entry("i", request_id)?;
402                s.serialize_entry("r", route)?;
403                s.serialize_entry("p", params)?;
404                if include_meta {
405                    s.serialize_entry("m", metadata)?;
406                }
407                s.end()
408            }
409            Self::ApiResponse {
410                request_id,
411                ok,
412                status,
413                data,
414                error,
415                metadata,
416            } => {
417                let include_meta = !metadata_is_empty(metadata);
418                let field_count = 5 + usize::from(error.is_some()) + usize::from(include_meta);
419                let mut s = serializer.serialize_map(Some(field_count))?;
420                s.serialize_entry("k", &K_API_RESPONSE)?;
421                s.serialize_entry("i", request_id)?;
422                s.serialize_entry("o", ok)?;
423                s.serialize_entry("s", status)?;
424                s.serialize_entry("d", data)?;
425                if let Some(err) = error {
426                    s.serialize_entry("er", err)?;
427                }
428                if include_meta {
429                    s.serialize_entry("m", metadata)?;
430                }
431                s.end()
432            }
433            Self::EventEmit {
434                event_id,
435                name,
436                data,
437                attachments: _,
438                metadata,
439                expect_ack,
440            } => {
441                let include_meta = !metadata_is_empty(metadata);
442                let field_count = 5 + usize::from(include_meta);
443                let mut s = serializer.serialize_map(Some(field_count))?;
444                s.serialize_entry("k", &K_EVENT_EMIT)?;
445                s.serialize_entry("i", event_id)?;
446                s.serialize_entry("n", name)?;
447                s.serialize_entry("d", data)?;
448                if include_meta {
449                    s.serialize_entry("m", metadata)?;
450                }
451                s.serialize_entry("e", expect_ack)?;
452                s.end()
453            }
454            Self::EventAck {
455                event_id,
456                ok,
457                receipt,
458                error,
459            } => {
460                let field_count = 3 + usize::from(error.is_some()) + 1;
461                let mut s = serializer.serialize_map(Some(field_count))?;
462                s.serialize_entry("k", &K_EVENT_ACK)?;
463                s.serialize_entry("i", event_id)?;
464                s.serialize_entry("o", ok)?;
465                s.serialize_entry("rc", receipt)?;
466                if let Some(err) = error {
467                    s.serialize_entry("er", err)?;
468                }
469                s.end()
470            }
471        }
472    }
473}
474
475// --- Manual Deserialize: read numeric `k`, move fields out directly ----------
476//
477// The body is parsed into a JSON object map exactly once; each variant then
478// *moves* its fields out of that map. Nested payloads such as `params`, `data`,
479// `metadata` and `receipt` are transferred without a second traversal, unlike
480// the previous `Value::deserialize` + `serde_json::from_value` pair which walked
481// the whole tree twice.
482
483/// Moves a required unsigned integer field out of the map.
484fn take_u64<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<u64, E> {
485    map.remove(key).and_then(|v| v.as_u64()).ok_or_else(|| {
486        E::custom(format!(
487            "missing or non-numeric '{key}' field in packet body"
488        ))
489    })
490}
491
492/// Moves a required string field out of the map.
493fn take_string<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<String, E> {
494    match map.remove(key) {
495        Some(Value::String(s)) => Ok(s),
496        _ => Err(E::custom(format!(
497            "missing or non-string '{key}' field in packet body"
498        ))),
499    }
500}
501
502/// Moves an optional JSON object field, defaulting to an empty map.
503fn take_object<E: serde::de::Error>(
504    map: &mut Map<String, Value>,
505    key: &str,
506) -> Result<Map<String, Value>, E> {
507    match map.remove(key) {
508        None => Ok(Map::new()),
509        Some(Value::Object(o)) => Ok(o),
510        Some(_) => Err(E::custom(format!("'{key}' field must be a JSON object"))),
511    }
512}
513
514/// Moves an optional boolean field, defaulting to `false`.
515fn take_bool(map: &mut Map<String, Value>, key: &str) -> bool {
516    map.remove(key).and_then(|v| v.as_bool()).unwrap_or(false)
517}
518
519/// Moves an optional `u16` field, defaulting to `0`.
520fn take_u16(map: &mut Map<String, Value>, key: &str) -> u16 {
521    map.remove(key).and_then(|v| v.as_u64()).unwrap_or(0) as u16
522}
523
524/// Moves the optional binary-attachment reference list (`a`).
525///
526/// Attachments normally travel in the frame's binary section and are injected
527/// via [`PacketBody::set_attachments`]; the JSON `a` field is only honoured for
528/// completeness and is almost always absent.
529fn take_attachments<E: serde::de::Error>(
530    map: &mut Map<String, Value>,
531) -> Result<Vec<FileAttachment>, E> {
532    match map.remove("a") {
533        None => Ok(Vec::new()),
534        Some(value) => serde_json::from_value(value).map_err(E::custom),
535    }
536}
537
538/// Moves the optional error payload (`er`).
539fn take_error<E: serde::de::Error>(
540    map: &mut Map<String, Value>,
541) -> Result<Option<ErrorPayload>, E> {
542    match map.remove("er") {
543        None => Ok(None),
544        Some(value) => serde_json::from_value(value).map(Some).map_err(E::custom),
545    }
546}
547
548impl<'de> Deserialize<'de> for PacketBody {
549    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
550    where
551        D: Deserializer<'de>,
552    {
553        // Parse into a JSON object map once so we can inspect the numeric `k`
554        // discriminator, then move the remaining fields out by short key.
555        let mut map = match Value::deserialize(deserializer)? {
556            Value::Object(map) => map,
557            _ => {
558                return Err(serde::de::Error::custom(
559                    "packet body must be a JSON object",
560                ));
561            }
562        };
563
564        let k = map.get("k").and_then(|v| v.as_u64()).ok_or_else(|| {
565            serde::de::Error::custom("missing or non-numeric 'k' field in packet body")
566        })?;
567        let k = u8::try_from(k).map_err(|_| {
568            serde::de::Error::custom(format!("'k' value {k} is outside the valid range"))
569        })?;
570
571        match k {
572            K_API_REQUEST => Ok(Self::ApiRequest {
573                request_id: take_u64(&mut map, "i")?,
574                route: take_string(&mut map, "r")?,
575                params: map.remove("p").unwrap_or(Value::Null),
576                attachments: take_attachments(&mut map)?,
577                metadata: map.remove("m").unwrap_or(Value::Null),
578            }),
579            K_EVENT_EMIT => Ok(Self::EventEmit {
580                event_id: take_u64(&mut map, "i")?,
581                name: take_string(&mut map, "n")?,
582                data: take_object(&mut map, "d")?,
583                attachments: take_attachments(&mut map)?,
584                metadata: map.remove("m").unwrap_or(Value::Null),
585                expect_ack: take_bool(&mut map, "e"),
586            }),
587            K_API_RESPONSE => Ok(Self::ApiResponse {
588                request_id: take_u64(&mut map, "i")?,
589                ok: take_bool(&mut map, "o"),
590                status: take_u16(&mut map, "s"),
591                data: map.remove("d").unwrap_or(Value::Null),
592                error: take_error(&mut map)?,
593                metadata: map.remove("m").unwrap_or(Value::Null),
594            }),
595            K_EVENT_ACK => Ok(Self::EventAck {
596                event_id: take_u64(&mut map, "i")?,
597                ok: take_bool(&mut map, "o"),
598                receipt: map.remove("rc").unwrap_or(Value::Null),
599                error: take_error(&mut map)?,
600            }),
601            _ => Err(serde::de::Error::custom(format!("unknown 'k' value: {k}"))),
602        }
603    }
604}
605
606impl PacketBody {
607    pub fn message_type(&self) -> MessageType {
608        match self {
609            Self::ApiRequest { .. } | Self::ApiResponse { .. } => MessageType::Api,
610            Self::EventEmit { .. } | Self::EventAck { .. } => MessageType::Event,
611        }
612    }
613
614    /// Returns the attachments carried by this packet (empty for responses/acks).
615    pub fn attachments(&self) -> &[FileAttachment] {
616        match self {
617            Self::ApiRequest { attachments, .. } => attachments,
618            Self::EventEmit { attachments, .. } => attachments,
619            _ => &[],
620        }
621    }
622
623    /// Injects decoded binary attachments back into the packet body.
624    pub fn set_attachments(&mut self, attachments: Vec<FileAttachment>) {
625        match self {
626            Self::ApiRequest { attachments: a, .. } => *a = attachments,
627            Self::EventEmit { attachments: a, .. } => *a = attachments,
628            _ => {}
629        }
630    }
631}
632
633/// Full transport envelope before frame encoding.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct PacketEnvelope {
636    /// Message category declared in the frame header.
637    pub message_type: MessageType,
638    /// Encryption mode declared in the frame header.
639    pub encryption: EncryptionKind,
640    /// JSON body payload.
641    pub body: PacketBody,
642}
643
644impl PacketEnvelope {
645    /// Builds a plaintext envelope from a body.
646    pub fn new(body: PacketBody) -> Self {
647        Self {
648            message_type: body.message_type(),
649            encryption: EncryptionKind::None,
650            body,
651        }
652    }
653
654    /// Builds an envelope with an explicit encryption mode.
655    pub fn with_encryption(body: PacketBody, encryption: EncryptionKind) -> Self {
656        Self {
657            message_type: body.message_type(),
658            encryption,
659            body,
660        }
661    }
662}
663
664/// Encodes and decodes WSCALL binary frames (protocol v2).
665///
666/// Protocol v2 uses a composite payload format: JSON metadata followed by raw
667/// binary attachment sections, eliminating the Base64 overhead of protocol v1.
668///
669/// The symmetric ciphers are constructed once when a key is configured and shared
670/// via [`Arc`] across every clone of the codec. This avoids redoing the key
671/// schedule (which for AES-256-GCM is especially expensive) on every frame.
672#[derive(Clone)]
673pub struct FrameCodec {
674    aes256_cipher: Option<Arc<Aes256Gcm>>,
675    chacha20_cipher: Option<Arc<ChaCha20Poly1305>>,
676    /// Maximum total frame size (including the 4-byte length prefix).
677    max_frame_bytes: usize,
678}
679
680impl Default for FrameCodec {
681    fn default() -> Self {
682        Self {
683            aes256_cipher: None,
684            chacha20_cipher: None,
685            max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
686        }
687    }
688}
689
690impl std::fmt::Debug for FrameCodec {
691    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
692        f.debug_struct("FrameCodec")
693            .field("aes256", &self.aes256_cipher.is_some())
694            .field("chacha20", &self.chacha20_cipher.is_some())
695            .field("max_frame_bytes", &self.max_frame_bytes)
696            .finish()
697    }
698}
699
700impl FrameCodec {
701    /// Builds a codec configured for plaintext transport.
702    pub fn plaintext() -> Self {
703        Self::default()
704    }
705
706    /// Configures a ChaCha20-Poly1305 key.
707    ///
708    /// The cipher is constructed eagerly so that subsequent frame encoding never
709    /// pays for the key schedule again.
710    pub fn with_chacha20_key(self, key: [u8; 32]) -> Self {
711        let cipher = ChaCha20Poly1305::new_from_slice(&key)
712            .expect("a 32-byte key is always valid for ChaCha20-Poly1305");
713        Self {
714            chacha20_cipher: Some(Arc::new(cipher)),
715            ..self
716        }
717    }
718
719    /// Configures an AES256-GCM key.
720    ///
721    /// The cipher is constructed eagerly so that subsequent frame encoding never
722    /// pays for the AES key expansion again.
723    pub fn with_aes256_key(self, key: [u8; 32]) -> Self {
724        let cipher =
725            Aes256Gcm::new_from_slice(&key).expect("a 32-byte key is always valid for AES-256-GCM");
726        Self {
727            aes256_cipher: Some(Arc::new(cipher)),
728            ..self
729        }
730    }
731
732    /// Sets the maximum total frame size (including the 4-byte length prefix).
733    ///
734    /// Frames exceeding this limit are rejected during encode/decode.
735    pub fn with_max_frame_bytes(mut self, max: usize) -> Self {
736        self.max_frame_bytes = max;
737        self
738    }
739
740    /// Returns the configured maximum frame size.
741    pub fn max_frame_bytes(&self) -> usize {
742        self.max_frame_bytes
743    }
744
745    /// Encodes an envelope into a binary WSCALL frame (protocol v2 composite format).
746    ///
747    /// The composite payload layout (before encryption):
748    /// ```text
749    /// | meta_len:u32(be) | JSON_bytes | att_count:u8 | [att sections...] |
750    /// ```
751    pub fn encode(&self, packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
752        let max_payload = self.max_frame_bytes.saturating_sub(6);
753        let attachments = packet.body.attachments();
754        let att_size: usize = attachments.iter().map(|a| a.wire_size()).sum();
755        let message_type = packet.message_type as u8;
756        let encryption = packet.encryption as u8;
757
758        // Plaintext fast path: build the final frame in a single buffer with no
759        // intermediate copies. The JSON metadata is written directly into the
760        // frame via `to_writer` (no temporary `json_bytes` Vec), and the
761        // `frame_len` / `meta_len` prefixes are patched in place once known.
762        //
763        // Layout: frame_len:u32 | msg_type:u8 | enc:u8 | meta_len:u32 | json | att_count:u8 | [atts]
764        if packet.encryption == EncryptionKind::None {
765            let mut frame = Vec::with_capacity(4 + 2 + 4 + 1 + att_size + 64);
766            frame.extend_from_slice(&[0u8; 4]); // frame_len placeholder
767            frame.push(message_type);
768            frame.push(encryption);
769            frame.extend_from_slice(&[0u8; 4]); // meta_len placeholder
770            serde_json::to_writer(&mut frame, &packet.body)?;
771            let json_len = frame.len() - 10;
772            frame.push(attachments.len() as u8);
773            for att in attachments {
774                att.write_wire(&mut frame);
775            }
776            let payload_len = frame.len() - 6;
777            if payload_len > max_payload {
778                return Err(ProtocolError::PayloadTooLarge {
779                    actual: payload_len,
780                    max: max_payload,
781                });
782            }
783            let frame_len = (frame.len() - 4) as u32;
784            frame[0..4].copy_from_slice(&frame_len.to_be_bytes());
785            frame[6..10].copy_from_slice(&(json_len as u32).to_be_bytes());
786            return Ok(frame);
787        }
788
789        // Encrypted path: build the composite payload (meta_len | json |
790        // att_count | atts) writing JSON directly, encrypt it, then wrap with
791        // the frame header.
792        let mut composite = Vec::with_capacity(4 + 1 + att_size + 64);
793        composite.extend_from_slice(&[0u8; 4]); // meta_len placeholder
794        serde_json::to_writer(&mut composite, &packet.body)?;
795        let json_len = composite.len() - 4;
796        composite[0..4].copy_from_slice(&(json_len as u32).to_be_bytes());
797        composite.push(attachments.len() as u8);
798        for att in attachments {
799            att.write_wire(&mut composite);
800        }
801
802        // Pre-check size before encryption.
803        if composite.len() > max_payload {
804            return Err(ProtocolError::PayloadTooLarge {
805                actual: composite.len(),
806                max: max_payload,
807            });
808        }
809
810        let payload = match packet.encryption {
811            EncryptionKind::ChaCha20 => self.encrypt_chacha20(&composite)?,
812            EncryptionKind::Aes256 => self.encrypt_aes256(&composite)?,
813            EncryptionKind::None => unreachable!("plaintext is handled by the fast path above"),
814        };
815
816        // Post-encryption size check (nonce + tag add overhead).
817        if payload.len() > max_payload {
818            return Err(ProtocolError::PayloadTooLarge {
819                actual: payload.len(),
820                max: max_payload,
821            });
822        }
823
824        let frame_len = 2 + payload.len();
825        let mut frame = Vec::with_capacity(4 + frame_len);
826        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
827        frame.push(message_type);
828        frame.push(encryption);
829        frame.extend_from_slice(&payload);
830        Ok(frame)
831    }
832
833    /// Decodes a binary WSCALL frame back into an envelope (protocol v2 composite format).
834    pub fn decode(&self, frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
835        if frame.len() < 6 {
836            return Err(ProtocolError::FrameTooShort);
837        }
838
839        let declared = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
840        let actual = frame.len() - 4;
841        if declared != actual {
842            return Err(ProtocolError::FrameLengthMismatch { declared, actual });
843        }
844
845        if frame.len() > self.max_frame_bytes {
846            return Err(ProtocolError::FrameTooLarge {
847                actual: frame.len(),
848                max: self.max_frame_bytes,
849            });
850        }
851
852        let message_type = MessageType::try_from(frame[4])?;
853        let encryption = EncryptionKind::try_from(frame[5])?;
854
855        // Decrypt (or borrow) the composite payload.
856        let composite: Cow<'_, [u8]> = match encryption {
857            EncryptionKind::None => Cow::Borrowed(&frame[6..]),
858            EncryptionKind::ChaCha20 => Cow::Owned(self.decrypt_chacha20(&frame[6..])?),
859            EncryptionKind::Aes256 => Cow::Owned(self.decrypt_aes256(&frame[6..])?),
860        };
861
862        // Parse composite: meta_len:u32 | JSON | att_count:u8 | [att sections]
863        if composite.len() < 5 {
864            return Err(ProtocolError::FrameTooShort);
865        }
866        let meta_len =
867            u32::from_be_bytes([composite[0], composite[1], composite[2], composite[3]]) as usize;
868        if composite.len() < 4 + meta_len + 1 {
869            return Err(ProtocolError::FrameTooShort);
870        }
871        let json_slice = &composite[4..4 + meta_len];
872        let att_count = composite[4 + meta_len] as usize;
873        let mut att_data = &composite[4 + meta_len + 1..];
874
875        // Parse binary attachment sections.
876        let mut attachments = Vec::with_capacity(att_count);
877        for _ in 0..att_count {
878            let (att, rest) = FileAttachment::read_wire(att_data)?;
879            attachments.push(att);
880            att_data = rest;
881        }
882
883        // Parse JSON body and inject attachments.
884        let mut body: PacketBody = serde_json::from_slice(json_slice)?;
885        body.set_attachments(attachments);
886
887        if body.message_type() != message_type {
888            return Err(ProtocolError::MessageTypeMismatch);
889        }
890
891        Ok(PacketEnvelope {
892            message_type,
893            encryption,
894            body,
895        })
896    }
897
898    fn encrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
899        let cipher = self
900            .chacha20_cipher
901            .as_ref()
902            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
903        let mut nonce_bytes = [0_u8; CHACHA20_NONCE_LEN];
904        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
905        let ciphertext = cipher
906            .encrypt(Nonce::from_slice(&nonce_bytes), payload)
907            .map_err(|_| ProtocolError::EncryptionFailed("chacha20"))?;
908
909        let mut encoded = Vec::with_capacity(CHACHA20_NONCE_LEN + ciphertext.len());
910        encoded.extend_from_slice(&nonce_bytes);
911        encoded.extend_from_slice(&ciphertext);
912        Ok(encoded)
913    }
914
915    fn decrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
916        if payload.len() < CHACHA20_NONCE_LEN {
917            return Err(ProtocolError::EncryptedPayloadTooShort {
918                algorithm: "chacha20",
919                expected_min: CHACHA20_NONCE_LEN,
920                actual: payload.len(),
921            });
922        }
923
924        let cipher = self
925            .chacha20_cipher
926            .as_ref()
927            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
928        let (nonce_bytes, ciphertext) = payload.split_at(CHACHA20_NONCE_LEN);
929        cipher
930            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
931            .map_err(|_| ProtocolError::DecryptionFailed("chacha20"))
932    }
933
934    fn encrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
935        let cipher = self
936            .aes256_cipher
937            .as_ref()
938            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
939        let mut nonce_bytes = [0_u8; AES256_NONCE_LEN];
940        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
941        let ciphertext = cipher
942            .encrypt(AesNonce::from_slice(&nonce_bytes), payload)
943            .map_err(|_| ProtocolError::EncryptionFailed("aes256"))?;
944
945        let mut encoded = Vec::with_capacity(AES256_NONCE_LEN + ciphertext.len());
946        encoded.extend_from_slice(&nonce_bytes);
947        encoded.extend_from_slice(&ciphertext);
948        Ok(encoded)
949    }
950
951    fn decrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
952        if payload.len() < AES256_NONCE_LEN {
953            return Err(ProtocolError::EncryptedPayloadTooShort {
954                algorithm: "aes256",
955                expected_min: AES256_NONCE_LEN,
956                actual: payload.len(),
957            });
958        }
959
960        let cipher = self
961            .aes256_cipher
962            .as_ref()
963            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
964        let (nonce_bytes, ciphertext) = payload.split_at(AES256_NONCE_LEN);
965        cipher
966            .decrypt(AesNonce::from_slice(nonce_bytes), ciphertext)
967            .map_err(|_| ProtocolError::DecryptionFailed("aes256"))
968    }
969}
970
971/// Helper for encoding a plaintext frame without constructing a custom codec.
972pub fn encode_frame(packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
973    FrameCodec::plaintext().encode(packet)
974}
975
976/// Helper for decoding a plaintext frame without constructing a custom codec.
977pub fn decode_frame(frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
978    FrameCodec::plaintext().decode(frame)
979}
980
981/// Errors returned while encoding or decoding WSCALL frames.
982#[derive(Debug, Error)]
983pub enum ProtocolError {
984    #[error("frame too short")]
985    FrameTooShort,
986    #[error("frame length mismatch: declared={declared}, actual={actual}")]
987    FrameLengthMismatch { declared: usize, actual: usize },
988    #[error("payload too large: actual={actual}, max={max}")]
989    PayloadTooLarge { actual: usize, max: usize },
990    #[error("frame too large: actual={actual}, max={max}")]
991    FrameTooLarge { actual: usize, max: usize },
992    #[error("unknown message type: {0:#x}")]
993    UnknownMessageType(u8),
994    #[error("unknown encryption kind: {0:#x}")]
995    UnknownEncryption(u8),
996    #[error("unsupported encryption kind: {0:#x}")]
997    UnsupportedEncryption(u8),
998    #[error("missing encryption key for {0}")]
999    MissingEncryptionKey(&'static str),
1000    #[error("invalid encryption key for {0}")]
1001    InvalidEncryptionKey(&'static str),
1002    #[error("secure random generation failed: {0}")]
1003    Random(String),
1004    #[error(
1005        "encrypted payload too short for {algorithm}: expected at least {expected_min}, actual={actual}"
1006    )]
1007    EncryptedPayloadTooShort {
1008        algorithm: &'static str,
1009        expected_min: usize,
1010        actual: usize,
1011    },
1012    #[error("encryption failed for {0}")]
1013    EncryptionFailed(&'static str),
1014    #[error("decryption failed for {0}")]
1015    DecryptionFailed(&'static str),
1016    #[error("invalid ECDH public key: expected {expected} bytes, got {actual}")]
1017    InvalidEcdhPublicKey { expected: usize, actual: usize },
1018    #[error("ECDH handshake failed: {0}")]
1019    EcdhHandshake(String),
1020    #[error("message type does not match packet body")]
1021    MessageTypeMismatch,
1022    #[error("invalid attachment encoding: {0}")]
1023    InvalidAttachmentEncoding(String),
1024    #[error("json error: {0}")]
1025    Json(#[from] serde_json::Error),
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::{
1031        EncryptionKind, FileAttachment, FrameCodec, PacketBody, PacketEnvelope, ProtocolError,
1032        decode_frame, encode_frame,
1033    };
1034    use serde_json::json;
1035
1036    const TEST_KEY: [u8; 32] = [0x11; 32];
1037
1038    #[test]
1039    fn plaintext_helpers_still_work() {
1040        let packet = PacketEnvelope::new(PacketBody::EventAck {
1041            event_id: 1,
1042            ok: true,
1043            receipt: json!({ "ok": true }),
1044            error: None,
1045        });
1046
1047        let encoded = encode_frame(&packet).expect("encode plaintext");
1048        let decoded = decode_frame(&encoded).expect("decode plaintext");
1049        assert!(matches!(decoded.encryption, EncryptionKind::None));
1050    }
1051
1052    #[test]
1053    fn aes256_roundtrip_works() {
1054        let codec = FrameCodec::plaintext().with_aes256_key(TEST_KEY);
1055        let packet = PacketEnvelope::with_encryption(
1056            PacketBody::ApiResponse {
1057                request_id: 1,
1058                ok: true,
1059                status: 200,
1060                data: json!({ "message": "encrypted" }),
1061                error: None,
1062                metadata: json!({}),
1063            },
1064            EncryptionKind::Aes256,
1065        );
1066
1067        let encoded = codec.encode(&packet).expect("encode aes256");
1068        let decoded = codec.decode(&encoded).expect("decode aes256");
1069        assert!(matches!(decoded.encryption, EncryptionKind::Aes256));
1070    }
1071
1072    #[test]
1073    fn attachment_roundtrip_plaintext() {
1074        let att = FileAttachment::inline_bytes(
1075            "f1",
1076            "test.bin",
1077            "application/octet-stream",
1078            vec![1, 2, 3, 4, 5],
1079        );
1080        let packet = PacketEnvelope::new(PacketBody::ApiRequest {
1081            request_id: 42,
1082            route: "files.upload".to_string(),
1083            params: json!({ "file": { "$file": "f1" } }),
1084            attachments: vec![att],
1085            metadata: json!({}),
1086        });
1087
1088        let encoded = encode_frame(&packet).expect("encode with attachment");
1089        let decoded = decode_frame(&encoded).expect("decode with attachment");
1090
1091        let atts = decoded.body.attachments();
1092        assert_eq!(atts.len(), 1);
1093        assert_eq!(atts[0].id, "f1");
1094        assert_eq!(atts[0].name, "test.bin");
1095        assert_eq!(atts[0].content_type, "application/octet-stream");
1096        assert_eq!(atts[0].data, vec![1, 2, 3, 4, 5]);
1097    }
1098
1099    #[test]
1100    fn attachment_roundtrip_encrypted() {
1101        let codec = FrameCodec::plaintext().with_chacha20_key(TEST_KEY);
1102        let att = FileAttachment::inline_text("f2", "hello.txt", "text/plain", "hello world");
1103        let packet = PacketEnvelope::with_encryption(
1104            PacketBody::EventEmit {
1105                event_id: 7,
1106                name: "chat.message".to_string(),
1107                data: json!({ "text": "see attached" })
1108                    .as_object()
1109                    .unwrap()
1110                    .clone(),
1111                attachments: vec![att],
1112                metadata: json!({}),
1113                expect_ack: true,
1114            },
1115            EncryptionKind::ChaCha20,
1116        );
1117
1118        let encoded = codec
1119            .encode(&packet)
1120            .expect("encode encrypted with attachment");
1121        let decoded = codec
1122            .decode(&encoded)
1123            .expect("decode encrypted with attachment");
1124
1125        let atts = decoded.body.attachments();
1126        assert_eq!(atts.len(), 1);
1127        assert_eq!(atts[0].id, "f2");
1128        assert_eq!(atts[0].data, b"hello world");
1129    }
1130
1131    #[test]
1132    fn encode_rejects_payloads_over_limit() {
1133        // Use a small max to trigger the limit without allocating 100 MiB.
1134        let codec = FrameCodec::plaintext().with_max_frame_bytes(1024);
1135        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1136            request_id: 999,
1137            ok: true,
1138            status: 200,
1139            data: json!({ "blob": "a".repeat(2048) }),
1140            error: None,
1141            metadata: json!({}),
1142        });
1143
1144        let error = codec
1145            .encode(&packet)
1146            .expect_err("oversized payload should fail");
1147        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
1148    }
1149
1150    #[test]
1151    fn decode_rejects_frames_over_limit() {
1152        let codec = FrameCodec::plaintext().with_max_frame_bytes(64);
1153        // Build a valid frame that exceeds 64 bytes total.
1154        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1155            request_id: 1,
1156            ok: true,
1157            status: 200,
1158            data: json!({ "msg": "a]".repeat(50) }),
1159            error: None,
1160            metadata: json!({}),
1161        });
1162        let encoded = FrameCodec::plaintext()
1163            .encode(&packet)
1164            .expect("encode with default limit");
1165        assert!(encoded.len() > 64);
1166
1167        let error = codec
1168            .decode(&encoded)
1169            .expect_err("oversized frame should fail");
1170        assert!(matches!(error, ProtocolError::FrameTooLarge { .. }));
1171    }
1172}