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`, dispatch to short-key fields -------
476
477/// Deserialization helper for [`PacketBody::ApiRequest`].
478#[derive(Deserialize)]
479struct ApiRequestFields {
480    #[serde(rename = "i")]
481    request_id: u64,
482    #[serde(rename = "r")]
483    route: String,
484    #[serde(rename = "p", default)]
485    params: Value,
486    #[serde(rename = "a", default)]
487    attachments: Vec<FileAttachment>,
488    #[serde(rename = "m", default)]
489    metadata: Value,
490}
491
492/// Deserialization helper for [`PacketBody::ApiResponse`].
493#[derive(Deserialize)]
494struct ApiResponseFields {
495    #[serde(rename = "i")]
496    request_id: u64,
497    #[serde(rename = "o", default)]
498    ok: bool,
499    #[serde(rename = "s", default)]
500    status: u16,
501    #[serde(rename = "d", default)]
502    data: Value,
503    #[serde(rename = "er", default)]
504    error: Option<ErrorPayload>,
505    #[serde(rename = "m", default)]
506    metadata: Value,
507}
508
509/// Deserialization helper for [`PacketBody::EventEmit`].
510#[derive(Deserialize)]
511struct EventEmitFields {
512    #[serde(rename = "i")]
513    event_id: u64,
514    #[serde(rename = "n")]
515    name: String,
516    #[serde(rename = "d", default)]
517    data: Map<String, Value>,
518    #[serde(rename = "a", default)]
519    attachments: Vec<FileAttachment>,
520    #[serde(rename = "m", default)]
521    metadata: Value,
522    #[serde(rename = "e", default)]
523    expect_ack: bool,
524}
525
526/// Deserialization helper for [`PacketBody::EventAck`].
527#[derive(Deserialize)]
528struct EventAckFields {
529    #[serde(rename = "i")]
530    event_id: u64,
531    #[serde(rename = "o", default)]
532    ok: bool,
533    #[serde(rename = "rc", default)]
534    receipt: Value,
535    #[serde(rename = "er", default)]
536    error: Option<ErrorPayload>,
537}
538
539impl<'de> Deserialize<'de> for PacketBody {
540    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
541    where
542        D: Deserializer<'de>,
543    {
544        // Parse into a generic JSON value first so we can inspect the numeric
545        // `k` discriminator, then dispatch to the appropriate short-key struct.
546        let value = Value::deserialize(deserializer)?;
547        let k = value.get("k").and_then(|v| v.as_u64()).ok_or_else(|| {
548            serde::de::Error::custom("missing or non-numeric 'k' field in packet body")
549        })?;
550        let k = u8::try_from(k).map_err(|_| {
551            serde::de::Error::custom(format!("'k' value {k} is outside the valid range"))
552        })?;
553
554        match k {
555            K_API_REQUEST => {
556                let f = serde_json::from_value::<ApiRequestFields>(value)
557                    .map_err(serde::de::Error::custom)?;
558                Ok(Self::ApiRequest {
559                    request_id: f.request_id,
560                    route: f.route,
561                    params: f.params,
562                    attachments: f.attachments,
563                    metadata: f.metadata,
564                })
565            }
566            K_EVENT_EMIT => {
567                let f = serde_json::from_value::<EventEmitFields>(value)
568                    .map_err(serde::de::Error::custom)?;
569                Ok(Self::EventEmit {
570                    event_id: f.event_id,
571                    name: f.name,
572                    data: f.data,
573                    attachments: f.attachments,
574                    metadata: f.metadata,
575                    expect_ack: f.expect_ack,
576                })
577            }
578            K_API_RESPONSE => {
579                let f = serde_json::from_value::<ApiResponseFields>(value)
580                    .map_err(serde::de::Error::custom)?;
581                Ok(Self::ApiResponse {
582                    request_id: f.request_id,
583                    ok: f.ok,
584                    status: f.status,
585                    data: f.data,
586                    error: f.error,
587                    metadata: f.metadata,
588                })
589            }
590            K_EVENT_ACK => {
591                let f = serde_json::from_value::<EventAckFields>(value)
592                    .map_err(serde::de::Error::custom)?;
593                Ok(Self::EventAck {
594                    event_id: f.event_id,
595                    ok: f.ok,
596                    receipt: f.receipt,
597                    error: f.error,
598                })
599            }
600            _ => Err(serde::de::Error::custom(format!("unknown 'k' value: {k}"))),
601        }
602    }
603}
604
605impl PacketBody {
606    pub fn message_type(&self) -> MessageType {
607        match self {
608            Self::ApiRequest { .. } | Self::ApiResponse { .. } => MessageType::Api,
609            Self::EventEmit { .. } | Self::EventAck { .. } => MessageType::Event,
610        }
611    }
612
613    /// Returns the attachments carried by this packet (empty for responses/acks).
614    pub fn attachments(&self) -> &[FileAttachment] {
615        match self {
616            Self::ApiRequest { attachments, .. } => attachments,
617            Self::EventEmit { attachments, .. } => attachments,
618            _ => &[],
619        }
620    }
621
622    /// Injects decoded binary attachments back into the packet body.
623    pub fn set_attachments(&mut self, attachments: Vec<FileAttachment>) {
624        match self {
625            Self::ApiRequest { attachments: a, .. } => *a = attachments,
626            Self::EventEmit { attachments: a, .. } => *a = attachments,
627            _ => {}
628        }
629    }
630}
631
632/// Full transport envelope before frame encoding.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct PacketEnvelope {
635    /// Message category declared in the frame header.
636    pub message_type: MessageType,
637    /// Encryption mode declared in the frame header.
638    pub encryption: EncryptionKind,
639    /// JSON body payload.
640    pub body: PacketBody,
641}
642
643impl PacketEnvelope {
644    /// Builds a plaintext envelope from a body.
645    pub fn new(body: PacketBody) -> Self {
646        Self {
647            message_type: body.message_type(),
648            encryption: EncryptionKind::None,
649            body,
650        }
651    }
652
653    /// Builds an envelope with an explicit encryption mode.
654    pub fn with_encryption(body: PacketBody, encryption: EncryptionKind) -> Self {
655        Self {
656            message_type: body.message_type(),
657            encryption,
658            body,
659        }
660    }
661}
662
663/// Encodes and decodes WSCALL binary frames (protocol v2).
664///
665/// Protocol v2 uses a composite payload format: JSON metadata followed by raw
666/// binary attachment sections, eliminating the Base64 overhead of protocol v1.
667///
668/// The symmetric ciphers are constructed once when a key is configured and shared
669/// via [`Arc`] across every clone of the codec. This avoids redoing the key
670/// schedule (which for AES-256-GCM is especially expensive) on every frame.
671#[derive(Clone)]
672pub struct FrameCodec {
673    aes256_cipher: Option<Arc<Aes256Gcm>>,
674    chacha20_cipher: Option<Arc<ChaCha20Poly1305>>,
675    /// Maximum total frame size (including the 4-byte length prefix).
676    max_frame_bytes: usize,
677}
678
679impl Default for FrameCodec {
680    fn default() -> Self {
681        Self {
682            aes256_cipher: None,
683            chacha20_cipher: None,
684            max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
685        }
686    }
687}
688
689impl std::fmt::Debug for FrameCodec {
690    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691        f.debug_struct("FrameCodec")
692            .field("aes256", &self.aes256_cipher.is_some())
693            .field("chacha20", &self.chacha20_cipher.is_some())
694            .field("max_frame_bytes", &self.max_frame_bytes)
695            .finish()
696    }
697}
698
699impl FrameCodec {
700    /// Builds a codec configured for plaintext transport.
701    pub fn plaintext() -> Self {
702        Self::default()
703    }
704
705    /// Configures a ChaCha20-Poly1305 key.
706    ///
707    /// The cipher is constructed eagerly so that subsequent frame encoding never
708    /// pays for the key schedule again.
709    pub fn with_chacha20_key(self, key: [u8; 32]) -> Self {
710        let cipher = ChaCha20Poly1305::new_from_slice(&key)
711            .expect("a 32-byte key is always valid for ChaCha20-Poly1305");
712        Self {
713            chacha20_cipher: Some(Arc::new(cipher)),
714            ..self
715        }
716    }
717
718    /// Configures an AES256-GCM key.
719    ///
720    /// The cipher is constructed eagerly so that subsequent frame encoding never
721    /// pays for the AES key expansion again.
722    pub fn with_aes256_key(self, key: [u8; 32]) -> Self {
723        let cipher =
724            Aes256Gcm::new_from_slice(&key).expect("a 32-byte key is always valid for AES-256-GCM");
725        Self {
726            aes256_cipher: Some(Arc::new(cipher)),
727            ..self
728        }
729    }
730
731    /// Sets the maximum total frame size (including the 4-byte length prefix).
732    ///
733    /// Frames exceeding this limit are rejected during encode/decode.
734    pub fn with_max_frame_bytes(mut self, max: usize) -> Self {
735        self.max_frame_bytes = max;
736        self
737    }
738
739    /// Returns the configured maximum frame size.
740    pub fn max_frame_bytes(&self) -> usize {
741        self.max_frame_bytes
742    }
743
744    /// Encodes an envelope into a binary WSCALL frame (protocol v2 composite format).
745    ///
746    /// The composite payload layout (before encryption):
747    /// ```text
748    /// | meta_len:u32(be) | JSON_bytes | att_count:u8 | [att sections...] |
749    /// ```
750    pub fn encode(&self, packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
751        let max_payload = self.max_frame_bytes.saturating_sub(6);
752
753        // Serialize JSON metadata (attachments excluded from JSON).
754        let json_bytes = serde_json::to_vec(&packet.body)?;
755
756        // Collect attachments from the packet body.
757        let attachments = packet.body.attachments();
758
759        // Build the composite payload.
760        let mut composite = Vec::with_capacity(
761            4 + json_bytes.len() + 1 + attachments.iter().map(|a| a.wire_size()).sum::<usize>(),
762        );
763        composite.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
764        composite.extend_from_slice(&json_bytes);
765        composite.push(attachments.len() as u8);
766        for att in attachments {
767            att.write_wire(&mut composite);
768        }
769
770        // Pre-check size before encryption.
771        if composite.len() > max_payload {
772            return Err(ProtocolError::PayloadTooLarge {
773                actual: composite.len(),
774                max: max_payload,
775            });
776        }
777
778        let payload = match packet.encryption {
779            EncryptionKind::None => composite,
780            EncryptionKind::ChaCha20 => self.encrypt_chacha20(&composite)?,
781            EncryptionKind::Aes256 => self.encrypt_aes256(&composite)?,
782        };
783
784        // Post-encryption size check (nonce + tag add overhead).
785        if payload.len() > max_payload {
786            return Err(ProtocolError::PayloadTooLarge {
787                actual: payload.len(),
788                max: max_payload,
789            });
790        }
791
792        let frame_len = 2 + payload.len();
793        let mut frame = Vec::with_capacity(4 + frame_len);
794        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
795        frame.push(packet.message_type as u8);
796        frame.push(packet.encryption as u8);
797        frame.extend_from_slice(&payload);
798        Ok(frame)
799    }
800
801    /// Decodes a binary WSCALL frame back into an envelope (protocol v2 composite format).
802    pub fn decode(&self, frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
803        if frame.len() < 6 {
804            return Err(ProtocolError::FrameTooShort);
805        }
806
807        let declared = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
808        let actual = frame.len() - 4;
809        if declared != actual {
810            return Err(ProtocolError::FrameLengthMismatch { declared, actual });
811        }
812
813        if frame.len() > self.max_frame_bytes {
814            return Err(ProtocolError::FrameTooLarge {
815                actual: frame.len(),
816                max: self.max_frame_bytes,
817            });
818        }
819
820        let message_type = MessageType::try_from(frame[4])?;
821        let encryption = EncryptionKind::try_from(frame[5])?;
822
823        // Decrypt (or borrow) the composite payload.
824        let composite: Cow<'_, [u8]> = match encryption {
825            EncryptionKind::None => Cow::Borrowed(&frame[6..]),
826            EncryptionKind::ChaCha20 => Cow::Owned(self.decrypt_chacha20(&frame[6..])?),
827            EncryptionKind::Aes256 => Cow::Owned(self.decrypt_aes256(&frame[6..])?),
828        };
829
830        // Parse composite: meta_len:u32 | JSON | att_count:u8 | [att sections]
831        if composite.len() < 5 {
832            return Err(ProtocolError::FrameTooShort);
833        }
834        let meta_len =
835            u32::from_be_bytes([composite[0], composite[1], composite[2], composite[3]]) as usize;
836        if composite.len() < 4 + meta_len + 1 {
837            return Err(ProtocolError::FrameTooShort);
838        }
839        let json_slice = &composite[4..4 + meta_len];
840        let att_count = composite[4 + meta_len] as usize;
841        let mut att_data = &composite[4 + meta_len + 1..];
842
843        // Parse binary attachment sections.
844        let mut attachments = Vec::with_capacity(att_count);
845        for _ in 0..att_count {
846            let (att, rest) = FileAttachment::read_wire(att_data)?;
847            attachments.push(att);
848            att_data = rest;
849        }
850
851        // Parse JSON body and inject attachments.
852        let mut body: PacketBody = serde_json::from_slice(json_slice)?;
853        body.set_attachments(attachments);
854
855        if body.message_type() != message_type {
856            return Err(ProtocolError::MessageTypeMismatch);
857        }
858
859        Ok(PacketEnvelope {
860            message_type,
861            encryption,
862            body,
863        })
864    }
865
866    fn encrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
867        let cipher = self
868            .chacha20_cipher
869            .as_ref()
870            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
871        let mut nonce_bytes = [0_u8; CHACHA20_NONCE_LEN];
872        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
873        let ciphertext = cipher
874            .encrypt(Nonce::from_slice(&nonce_bytes), payload)
875            .map_err(|_| ProtocolError::EncryptionFailed("chacha20"))?;
876
877        let mut encoded = Vec::with_capacity(CHACHA20_NONCE_LEN + ciphertext.len());
878        encoded.extend_from_slice(&nonce_bytes);
879        encoded.extend_from_slice(&ciphertext);
880        Ok(encoded)
881    }
882
883    fn decrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
884        if payload.len() < CHACHA20_NONCE_LEN {
885            return Err(ProtocolError::EncryptedPayloadTooShort {
886                algorithm: "chacha20",
887                expected_min: CHACHA20_NONCE_LEN,
888                actual: payload.len(),
889            });
890        }
891
892        let cipher = self
893            .chacha20_cipher
894            .as_ref()
895            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
896        let (nonce_bytes, ciphertext) = payload.split_at(CHACHA20_NONCE_LEN);
897        cipher
898            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
899            .map_err(|_| ProtocolError::DecryptionFailed("chacha20"))
900    }
901
902    fn encrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
903        let cipher = self
904            .aes256_cipher
905            .as_ref()
906            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
907        let mut nonce_bytes = [0_u8; AES256_NONCE_LEN];
908        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
909        let ciphertext = cipher
910            .encrypt(AesNonce::from_slice(&nonce_bytes), payload)
911            .map_err(|_| ProtocolError::EncryptionFailed("aes256"))?;
912
913        let mut encoded = Vec::with_capacity(AES256_NONCE_LEN + ciphertext.len());
914        encoded.extend_from_slice(&nonce_bytes);
915        encoded.extend_from_slice(&ciphertext);
916        Ok(encoded)
917    }
918
919    fn decrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
920        if payload.len() < AES256_NONCE_LEN {
921            return Err(ProtocolError::EncryptedPayloadTooShort {
922                algorithm: "aes256",
923                expected_min: AES256_NONCE_LEN,
924                actual: payload.len(),
925            });
926        }
927
928        let cipher = self
929            .aes256_cipher
930            .as_ref()
931            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
932        let (nonce_bytes, ciphertext) = payload.split_at(AES256_NONCE_LEN);
933        cipher
934            .decrypt(AesNonce::from_slice(nonce_bytes), ciphertext)
935            .map_err(|_| ProtocolError::DecryptionFailed("aes256"))
936    }
937}
938
939/// Helper for encoding a plaintext frame without constructing a custom codec.
940pub fn encode_frame(packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
941    FrameCodec::plaintext().encode(packet)
942}
943
944/// Helper for decoding a plaintext frame without constructing a custom codec.
945pub fn decode_frame(frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
946    FrameCodec::plaintext().decode(frame)
947}
948
949/// Errors returned while encoding or decoding WSCALL frames.
950#[derive(Debug, Error)]
951pub enum ProtocolError {
952    #[error("frame too short")]
953    FrameTooShort,
954    #[error("frame length mismatch: declared={declared}, actual={actual}")]
955    FrameLengthMismatch { declared: usize, actual: usize },
956    #[error("payload too large: actual={actual}, max={max}")]
957    PayloadTooLarge { actual: usize, max: usize },
958    #[error("frame too large: actual={actual}, max={max}")]
959    FrameTooLarge { actual: usize, max: usize },
960    #[error("unknown message type: {0:#x}")]
961    UnknownMessageType(u8),
962    #[error("unknown encryption kind: {0:#x}")]
963    UnknownEncryption(u8),
964    #[error("unsupported encryption kind: {0:#x}")]
965    UnsupportedEncryption(u8),
966    #[error("missing encryption key for {0}")]
967    MissingEncryptionKey(&'static str),
968    #[error("invalid encryption key for {0}")]
969    InvalidEncryptionKey(&'static str),
970    #[error("secure random generation failed: {0}")]
971    Random(String),
972    #[error(
973        "encrypted payload too short for {algorithm}: expected at least {expected_min}, actual={actual}"
974    )]
975    EncryptedPayloadTooShort {
976        algorithm: &'static str,
977        expected_min: usize,
978        actual: usize,
979    },
980    #[error("encryption failed for {0}")]
981    EncryptionFailed(&'static str),
982    #[error("decryption failed for {0}")]
983    DecryptionFailed(&'static str),
984    #[error("invalid ECDH public key: expected {expected} bytes, got {actual}")]
985    InvalidEcdhPublicKey { expected: usize, actual: usize },
986    #[error("ECDH handshake failed: {0}")]
987    EcdhHandshake(String),
988    #[error("message type does not match packet body")]
989    MessageTypeMismatch,
990    #[error("invalid attachment encoding: {0}")]
991    InvalidAttachmentEncoding(String),
992    #[error("json error: {0}")]
993    Json(#[from] serde_json::Error),
994}
995
996#[cfg(test)]
997mod tests {
998    use super::{
999        EncryptionKind, FileAttachment, FrameCodec, PacketBody, PacketEnvelope, ProtocolError,
1000        decode_frame, encode_frame,
1001    };
1002    use serde_json::json;
1003
1004    const TEST_KEY: [u8; 32] = [0x11; 32];
1005
1006    #[test]
1007    fn plaintext_helpers_still_work() {
1008        let packet = PacketEnvelope::new(PacketBody::EventAck {
1009            event_id: 1,
1010            ok: true,
1011            receipt: json!({ "ok": true }),
1012            error: None,
1013        });
1014
1015        let encoded = encode_frame(&packet).expect("encode plaintext");
1016        let decoded = decode_frame(&encoded).expect("decode plaintext");
1017        assert!(matches!(decoded.encryption, EncryptionKind::None));
1018    }
1019
1020    #[test]
1021    fn aes256_roundtrip_works() {
1022        let codec = FrameCodec::plaintext().with_aes256_key(TEST_KEY);
1023        let packet = PacketEnvelope::with_encryption(
1024            PacketBody::ApiResponse {
1025                request_id: 1,
1026                ok: true,
1027                status: 200,
1028                data: json!({ "message": "encrypted" }),
1029                error: None,
1030                metadata: json!({}),
1031            },
1032            EncryptionKind::Aes256,
1033        );
1034
1035        let encoded = codec.encode(&packet).expect("encode aes256");
1036        let decoded = codec.decode(&encoded).expect("decode aes256");
1037        assert!(matches!(decoded.encryption, EncryptionKind::Aes256));
1038    }
1039
1040    #[test]
1041    fn attachment_roundtrip_plaintext() {
1042        let att = FileAttachment::inline_bytes(
1043            "f1",
1044            "test.bin",
1045            "application/octet-stream",
1046            vec![1, 2, 3, 4, 5],
1047        );
1048        let packet = PacketEnvelope::new(PacketBody::ApiRequest {
1049            request_id: 42,
1050            route: "files.upload".to_string(),
1051            params: json!({ "file": { "$file": "f1" } }),
1052            attachments: vec![att],
1053            metadata: json!({}),
1054        });
1055
1056        let encoded = encode_frame(&packet).expect("encode with attachment");
1057        let decoded = decode_frame(&encoded).expect("decode with attachment");
1058
1059        let atts = decoded.body.attachments();
1060        assert_eq!(atts.len(), 1);
1061        assert_eq!(atts[0].id, "f1");
1062        assert_eq!(atts[0].name, "test.bin");
1063        assert_eq!(atts[0].content_type, "application/octet-stream");
1064        assert_eq!(atts[0].data, vec![1, 2, 3, 4, 5]);
1065    }
1066
1067    #[test]
1068    fn attachment_roundtrip_encrypted() {
1069        let codec = FrameCodec::plaintext().with_chacha20_key(TEST_KEY);
1070        let att = FileAttachment::inline_text("f2", "hello.txt", "text/plain", "hello world");
1071        let packet = PacketEnvelope::with_encryption(
1072            PacketBody::EventEmit {
1073                event_id: 7,
1074                name: "chat.message".to_string(),
1075                data: json!({ "text": "see attached" })
1076                    .as_object()
1077                    .unwrap()
1078                    .clone(),
1079                attachments: vec![att],
1080                metadata: json!({}),
1081                expect_ack: true,
1082            },
1083            EncryptionKind::ChaCha20,
1084        );
1085
1086        let encoded = codec
1087            .encode(&packet)
1088            .expect("encode encrypted with attachment");
1089        let decoded = codec
1090            .decode(&encoded)
1091            .expect("decode encrypted with attachment");
1092
1093        let atts = decoded.body.attachments();
1094        assert_eq!(atts.len(), 1);
1095        assert_eq!(atts[0].id, "f2");
1096        assert_eq!(atts[0].data, b"hello world");
1097    }
1098
1099    #[test]
1100    fn encode_rejects_payloads_over_limit() {
1101        // Use a small max to trigger the limit without allocating 100 MiB.
1102        let codec = FrameCodec::plaintext().with_max_frame_bytes(1024);
1103        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1104            request_id: 999,
1105            ok: true,
1106            status: 200,
1107            data: json!({ "blob": "a".repeat(2048) }),
1108            error: None,
1109            metadata: json!({}),
1110        });
1111
1112        let error = codec
1113            .encode(&packet)
1114            .expect_err("oversized payload should fail");
1115        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
1116    }
1117
1118    #[test]
1119    fn decode_rejects_frames_over_limit() {
1120        let codec = FrameCodec::plaintext().with_max_frame_bytes(64);
1121        // Build a valid frame that exceeds 64 bytes total.
1122        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1123            request_id: 1,
1124            ok: true,
1125            status: 200,
1126            data: json!({ "msg": "a]".repeat(50) }),
1127            error: None,
1128            metadata: json!({}),
1129        });
1130        let encoded = FrameCodec::plaintext()
1131            .encode(&packet)
1132            .expect("encode with default limit");
1133        assert!(encoded.len() > 64);
1134
1135        let error = codec
1136            .decode(&encoded)
1137            .expect_err("oversized frame should fail");
1138        assert!(matches!(error, ProtocolError::FrameTooLarge { .. }));
1139    }
1140}