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 base64::Engine;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use chacha20poly1305::{ChaCha20Poly1305, Nonce};
13use getrandom::getrandom;
14use serde::de::Deserializer;
15use serde::ser::SerializeMap;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20use x25519_dalek::{PublicKey, StaticSecret};
21
22// ─── ECDH Dynamic Key Agreement ─────────────────────────────────────────────
23//
24// When the ECDH handshake mode is enabled, the client and server exchange
25// raw X25519 public keys (32 bytes each) over the WebSocket binary channel
26// immediately after the TCP/TLS upgrade. Both sides derive the same 32-byte
27// ChaCha20-Poly1305 session key via SHA-256(domain ‖ DH secret). This key is
28// unique per connection and never travels over the wire.
29
30/// Domain-separation tag mixed into the SHA-256 KDF so that wscall session
31/// keys cannot collide with keys derived for any other protocol.
32pub const ECDH_DOMAIN_TAG: &[u8] = b"wscall-ecdh-v1";
33
34/// Byte length of an X25519 public key (also the secret key length).
35pub const ECDH_KEY_LEN: usize = 32;
36
37/// A freshly generated X25519 keypair for the ECDH handshake.
38///
39/// The secret is zeroized on drop. Keep the secret on the side that generated
40/// it and send only the [`EcdhKeypair::public`] bytes to the peer.
41pub struct EcdhKeypair {
42    secret: StaticSecret,
43    public: PublicKey,
44}
45
46impl EcdhKeypair {
47    /// Generates a random X25519 keypair using the platform CSPRNG.
48    pub fn generate() -> Result<Self, ProtocolError> {
49        let mut secret_bytes = [0u8; ECDH_KEY_LEN];
50        getrandom(&mut secret_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
51        let secret = StaticSecret::from(secret_bytes);
52        let public = PublicKey::from(&secret);
53        Ok(Self { secret, public })
54    }
55
56    /// Returns the 32-byte public key that should be sent to the peer.
57    pub fn public_bytes(&self) -> [u8; ECDH_KEY_LEN] {
58        self.public.to_bytes()
59    }
60
61    /// Derives the 32-byte ChaCha20-Poly1305 session key from the peer's
62    /// 32-byte public key.
63    ///
64    /// `peer_public` must be exactly 32 bytes received from the other side.
65    pub fn derive_session_key(&self, peer_public: &[u8; ECDH_KEY_LEN]) -> [u8; 32] {
66        let peer = PublicKey::from(*peer_public);
67        let shared = self.secret.diffie_hellman(&peer);
68        derive_session_key(&shared.to_bytes())
69    }
70}
71
72/// Derives a 32-byte symmetric key from a raw X25519 shared secret.
73///
74/// Uses SHA-256(domain ‖ shared_secret) for domain separation. The result is
75/// suitable as a ChaCha20-Poly1305 or AES-256-GCM key.
76pub fn derive_session_key(shared_secret: &[u8; 32]) -> [u8; 32] {
77    let mut hasher = Sha256::new();
78    hasher.update(ECDH_DOMAIN_TAG);
79    hasher.update(shared_secret);
80    let result = hasher.finalize();
81    let mut key = [0u8; 32];
82    key.copy_from_slice(&result);
83    key
84}
85
86/// Parses 32 raw bytes received from the peer into an X25519 public key.
87///
88/// Returns an error if the slice is not exactly 32 bytes.
89pub fn parse_peer_public(bytes: &[u8]) -> Result<[u8; ECDH_KEY_LEN], ProtocolError> {
90    if bytes.len() != ECDH_KEY_LEN {
91        return Err(ProtocolError::InvalidEcdhPublicKey {
92            expected: ECDH_KEY_LEN,
93            actual: bytes.len(),
94        });
95    }
96    let mut key = [0u8; ECDH_KEY_LEN];
97    key.copy_from_slice(bytes);
98    Ok(key)
99}
100
101const AES256_NONCE_LEN: usize = 12;
102const CHACHA20_NONCE_LEN: usize = 12;
103const MAX_FRAME_BYTES: usize = 10 * 1024 * 1024;
104const MAX_PAYLOAD_BYTES: usize = MAX_FRAME_BYTES - 6;
105
106/// Distinguishes API messages from event messages inside a WSCALL frame.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[repr(u8)]
109pub enum MessageType {
110    /// API request or API response.
111    Api = 0x00,
112    /// Event emit or event acknowledgement.
113    Event = 0x01,
114}
115
116impl TryFrom<u8> for MessageType {
117    type Error = ProtocolError;
118
119    fn try_from(value: u8) -> Result<Self, Self::Error> {
120        match value {
121            0x00 => Ok(Self::Api),
122            0x01 => Ok(Self::Event),
123            _ => Err(ProtocolError::UnknownMessageType(value)),
124        }
125    }
126}
127
128/// Selects how the payload section of a frame is encoded.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[repr(u8)]
131pub enum EncryptionKind {
132    /// Unencrypted JSON payload.
133    None = 0x00,
134    /// ChaCha20-Poly1305 encrypted payload.
135    ChaCha20 = 0x01,
136    /// AES256-GCM encrypted payload.
137    Aes256 = 0x02,
138}
139
140impl TryFrom<u8> for EncryptionKind {
141    type Error = ProtocolError;
142
143    fn try_from(value: u8) -> Result<Self, Self::Error> {
144        match value {
145            0x00 => Ok(Self::None),
146            0x01 => Ok(Self::ChaCha20),
147            0x02 => Ok(Self::Aes256),
148            _ => Err(ProtocolError::UnknownEncryption(value)),
149        }
150    }
151}
152
153/// Inline attachment carried alongside JSON params or event data.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FileAttachment {
156    /// Attachment identifier referenced from JSON using `{ "$file": "..." }`.
157    pub id: String,
158    /// Original file name.
159    pub name: String,
160    /// MIME type supplied by the sender.
161    pub content_type: String,
162    /// Content transfer encoding. Current implementation uses Base64.
163    pub encoding: String,
164    /// Encoded attachment payload.
165    pub data: String,
166    /// Original byte length before encoding.
167    pub size: usize,
168}
169
170impl FileAttachment {
171    /// Builds an inline text attachment and encodes it as Base64.
172    pub fn inline_text(
173        id: impl Into<String>,
174        name: impl Into<String>,
175        content_type: impl Into<String>,
176        text: impl AsRef<str>,
177    ) -> Self {
178        Self::inline_bytes(id, name, content_type, text.as_ref().as_bytes().to_vec())
179    }
180
181    /// Builds an inline binary attachment and encodes it as Base64.
182    pub fn inline_bytes(
183        id: impl Into<String>,
184        name: impl Into<String>,
185        content_type: impl Into<String>,
186        bytes: Vec<u8>,
187    ) -> Self {
188        let size = bytes.len();
189        Self {
190            id: id.into(),
191            name: name.into(),
192            content_type: content_type.into(),
193            encoding: "base64".to_string(),
194            data: BASE64.encode(bytes),
195            size,
196        }
197    }
198
199    /// Decodes the attachment payload back into raw bytes.
200    pub fn decode_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
201        BASE64
202            .decode(self.data.as_bytes())
203            .map_err(|source| ProtocolError::InvalidAttachmentEncoding(source.to_string()))
204    }
205
206    /// Returns a JSON reference object that points to an attachment by id.
207    pub fn param_ref(id: impl Into<String>) -> Value {
208        json!({ "$file": id.into() })
209    }
210}
211
212/// Standard error payload embedded in API responses and event acknowledgements.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ErrorPayload {
215    pub code: String,
216    pub message: String,
217    pub status: u16,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub details: Option<Value>,
220}
221
222/// Compact numeric tag identifying each [`PacketBody`] variant in the JSON
223/// wire format. Using a single-byte numeric field (`"k": 0`) instead of a
224/// string tag (`"kind": "api_request"`) saves several bytes per frame.
225pub const K_API_REQUEST: u8 = 0;
226pub const K_EVENT_EMIT: u8 = 1;
227pub const K_API_RESPONSE: u8 = 2;
228pub const K_EVENT_ACK: u8 = 3;
229
230/// JSON-level message body transported inside a WSCALL frame.
231///
232/// The wire format uses short single-letter keys and a numeric `k` tag to
233/// minimize per-frame overhead:
234///
235/// | Variant | `k` | Fields |
236/// | --- | --- | --- |
237/// | `ApiRequest`  | 0 | `i` `r` `p` `a` `m` |
238/// | `EventEmit`   | 1 | `i` `n` `d` `a` `m` `e` [`si`] |
239/// | `ApiResponse` | 2 | `i` `o` `s` `d` `m` [`er`] |
240/// | `EventAck`    | 3 | `i` `o` `rc` [`er`] |
241#[derive(Debug, Clone)]
242pub enum PacketBody {
243    /// Client-to-server API request.
244    ApiRequest {
245        request_id: u64,
246        route: String,
247        params: Value,
248        attachments: Vec<FileAttachment>,
249        metadata: Value,
250    },
251    /// Server-to-client API response.
252    ApiResponse {
253        request_id: u64,
254        ok: bool,
255        status: u16,
256        data: Value,
257        error: Option<ErrorPayload>,
258        metadata: Value,
259    },
260    /// Event emission in either direction.
261    EventEmit {
262        event_id: u64,
263        name: String,
264        data: Value,
265        attachments: Vec<FileAttachment>,
266        metadata: Value,
267        expect_ack: bool,
268        /// Storage ID assigned by a database or other persistent store.
269        /// Serialized as `"si"` when present, omitted when `None`.
270        storage_id: Option<u64>,
271    },
272    /// Acknowledgement for an emitted event.
273    EventAck {
274        event_id: u64,
275        ok: bool,
276        receipt: Value,
277        error: Option<ErrorPayload>,
278    },
279}
280
281// --- Manual Serialize: short keys + numeric `k` tag -------------------------
282
283impl Serialize for PacketBody {
284    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
285    where
286        S: serde::Serializer,
287    {
288        match self {
289            Self::ApiRequest {
290                request_id,
291                route,
292                params,
293                attachments,
294                metadata,
295            } => {
296                let mut s = serializer.serialize_map(Some(6))?;
297                s.serialize_entry("k", &K_API_REQUEST)?;
298                s.serialize_entry("i", request_id)?;
299                s.serialize_entry("r", route)?;
300                s.serialize_entry("p", params)?;
301                s.serialize_entry("a", attachments)?;
302                s.serialize_entry("m", metadata)?;
303                s.end()
304            }
305            Self::ApiResponse {
306                request_id,
307                ok,
308                status,
309                data,
310                error,
311                metadata,
312            } => {
313                let field_count = 5 + usize::from(error.is_some()) + 1;
314                let mut s = serializer.serialize_map(Some(field_count))?;
315                s.serialize_entry("k", &K_API_RESPONSE)?;
316                s.serialize_entry("i", request_id)?;
317                s.serialize_entry("o", ok)?;
318                s.serialize_entry("s", status)?;
319                s.serialize_entry("d", data)?;
320                if let Some(err) = error {
321                    s.serialize_entry("er", err)?;
322                }
323                s.serialize_entry("m", metadata)?;
324                s.end()
325            }
326            Self::EventEmit {
327                event_id,
328                name,
329                data,
330                attachments,
331                metadata,
332                expect_ack,
333                storage_id,
334            } => {
335                let field_count = 7 + usize::from(storage_id.is_some());
336                let mut s = serializer.serialize_map(Some(field_count))?;
337                s.serialize_entry("k", &K_EVENT_EMIT)?;
338                s.serialize_entry("i", event_id)?;
339                s.serialize_entry("n", name)?;
340                s.serialize_entry("d", data)?;
341                s.serialize_entry("a", attachments)?;
342                s.serialize_entry("m", metadata)?;
343                s.serialize_entry("e", expect_ack)?;
344                if let Some(si) = storage_id {
345                    s.serialize_entry("si", si)?;
346                }
347                s.end()
348            }
349            Self::EventAck {
350                event_id,
351                ok,
352                receipt,
353                error,
354            } => {
355                let field_count = 3 + usize::from(error.is_some()) + 1;
356                let mut s = serializer.serialize_map(Some(field_count))?;
357                s.serialize_entry("k", &K_EVENT_ACK)?;
358                s.serialize_entry("i", event_id)?;
359                s.serialize_entry("o", ok)?;
360                s.serialize_entry("rc", receipt)?;
361                if let Some(err) = error {
362                    s.serialize_entry("er", err)?;
363                }
364                s.end()
365            }
366        }
367    }
368}
369
370// --- Manual Deserialize: read numeric `k`, dispatch to short-key fields -------
371
372/// Deserialization helper for [`PacketBody::ApiRequest`].
373#[derive(Deserialize)]
374struct ApiRequestFields {
375    #[serde(rename = "i")]
376    request_id: u64,
377    #[serde(rename = "r")]
378    route: String,
379    #[serde(rename = "p", default)]
380    params: Value,
381    #[serde(rename = "a", default)]
382    attachments: Vec<FileAttachment>,
383    #[serde(rename = "m", default)]
384    metadata: Value,
385}
386
387/// Deserialization helper for [`PacketBody::ApiResponse`].
388#[derive(Deserialize)]
389struct ApiResponseFields {
390    #[serde(rename = "i")]
391    request_id: u64,
392    #[serde(rename = "o", default)]
393    ok: bool,
394    #[serde(rename = "s", default)]
395    status: u16,
396    #[serde(rename = "d", default)]
397    data: Value,
398    #[serde(rename = "er", default)]
399    error: Option<ErrorPayload>,
400    #[serde(rename = "m", default)]
401    metadata: Value,
402}
403
404/// Deserialization helper for [`PacketBody::EventEmit`].
405#[derive(Deserialize)]
406struct EventEmitFields {
407    #[serde(rename = "i")]
408    event_id: u64,
409    #[serde(rename = "n")]
410    name: String,
411    #[serde(rename = "d", default)]
412    data: Value,
413    #[serde(rename = "a", default)]
414    attachments: Vec<FileAttachment>,
415    #[serde(rename = "m", default)]
416    metadata: Value,
417    #[serde(rename = "e", default)]
418    expect_ack: bool,
419    #[serde(rename = "si", default)]
420    storage_id: Option<u64>,
421}
422
423/// Deserialization helper for [`PacketBody::EventAck`].
424#[derive(Deserialize)]
425struct EventAckFields {
426    #[serde(rename = "i")]
427    event_id: u64,
428    #[serde(rename = "o", default)]
429    ok: bool,
430    #[serde(rename = "rc", default)]
431    receipt: Value,
432    #[serde(rename = "er", default)]
433    error: Option<ErrorPayload>,
434}
435
436impl<'de> Deserialize<'de> for PacketBody {
437    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
438    where
439        D: Deserializer<'de>,
440    {
441        // Parse into a generic JSON value first so we can inspect the numeric
442        // `k` discriminator, then dispatch to the appropriate short-key struct.
443        let value = Value::deserialize(deserializer)?;
444        let k = value.get("k").and_then(|v| v.as_u64()).ok_or_else(|| {
445            serde::de::Error::custom("missing or non-numeric 'k' field in packet body")
446        })?;
447        let k = u8::try_from(k).map_err(|_| {
448            serde::de::Error::custom(format!("'k' value {k} is outside the valid range"))
449        })?;
450
451        match k {
452            K_API_REQUEST => {
453                let f = serde_json::from_value::<ApiRequestFields>(value)
454                    .map_err(serde::de::Error::custom)?;
455                Ok(Self::ApiRequest {
456                    request_id: f.request_id,
457                    route: f.route,
458                    params: f.params,
459                    attachments: f.attachments,
460                    metadata: f.metadata,
461                })
462            }
463            K_EVENT_EMIT => {
464                let f = serde_json::from_value::<EventEmitFields>(value)
465                    .map_err(serde::de::Error::custom)?;
466                Ok(Self::EventEmit {
467                    event_id: f.event_id,
468                    name: f.name,
469                    data: f.data,
470                    attachments: f.attachments,
471                    metadata: f.metadata,
472                    expect_ack: f.expect_ack,
473                    storage_id: f.storage_id,
474                })
475            }
476            K_API_RESPONSE => {
477                let f = serde_json::from_value::<ApiResponseFields>(value)
478                    .map_err(serde::de::Error::custom)?;
479                Ok(Self::ApiResponse {
480                    request_id: f.request_id,
481                    ok: f.ok,
482                    status: f.status,
483                    data: f.data,
484                    error: f.error,
485                    metadata: f.metadata,
486                })
487            }
488            K_EVENT_ACK => {
489                let f = serde_json::from_value::<EventAckFields>(value)
490                    .map_err(serde::de::Error::custom)?;
491                Ok(Self::EventAck {
492                    event_id: f.event_id,
493                    ok: f.ok,
494                    receipt: f.receipt,
495                    error: f.error,
496                })
497            }
498            _ => Err(serde::de::Error::custom(format!("unknown 'k' value: {k}"))),
499        }
500    }
501}
502
503impl PacketBody {
504    pub fn message_type(&self) -> MessageType {
505        match self {
506            Self::ApiRequest { .. } | Self::ApiResponse { .. } => MessageType::Api,
507            Self::EventEmit { .. } | Self::EventAck { .. } => MessageType::Event,
508        }
509    }
510}
511
512/// Full transport envelope before frame encoding.
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct PacketEnvelope {
515    /// Message category declared in the frame header.
516    pub message_type: MessageType,
517    /// Encryption mode declared in the frame header.
518    pub encryption: EncryptionKind,
519    /// JSON body payload.
520    pub body: PacketBody,
521}
522
523impl PacketEnvelope {
524    /// Builds a plaintext envelope from a body.
525    pub fn new(body: PacketBody) -> Self {
526        Self {
527            message_type: body.message_type(),
528            encryption: EncryptionKind::None,
529            body,
530        }
531    }
532
533    /// Builds an envelope with an explicit encryption mode.
534    pub fn with_encryption(body: PacketBody, encryption: EncryptionKind) -> Self {
535        Self {
536            message_type: body.message_type(),
537            encryption,
538            body,
539        }
540    }
541}
542
543/// Encodes and decodes WSCALL binary frames.
544///
545/// The symmetric ciphers are constructed once when a key is configured and shared
546/// via [`Arc`] across every clone of the codec. This avoids redoing the key
547/// schedule (which for AES-256-GCM is especially expensive) on every frame.
548#[derive(Clone, Default)]
549pub struct FrameCodec {
550    aes256_cipher: Option<Arc<Aes256Gcm>>,
551    chacha20_cipher: Option<Arc<ChaCha20Poly1305>>,
552}
553
554impl std::fmt::Debug for FrameCodec {
555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556        f.debug_struct("FrameCodec")
557            .field("aes256", &self.aes256_cipher.is_some())
558            .field("chacha20", &self.chacha20_cipher.is_some())
559            .finish()
560    }
561}
562
563impl FrameCodec {
564    /// Builds a codec configured for plaintext transport.
565    pub fn plaintext() -> Self {
566        Self::default()
567    }
568
569    /// Configures a ChaCha20-Poly1305 key.
570    ///
571    /// The cipher is constructed eagerly so that subsequent frame encoding never
572    /// pays for the key schedule again.
573    pub fn with_chacha20_key(self, key: [u8; 32]) -> Self {
574        let cipher = ChaCha20Poly1305::new_from_slice(&key)
575            .expect("a 32-byte key is always valid for ChaCha20-Poly1305");
576        Self {
577            chacha20_cipher: Some(Arc::new(cipher)),
578            ..self
579        }
580    }
581
582    /// Configures an AES256-GCM key.
583    ///
584    /// The cipher is constructed eagerly so that subsequent frame encoding never
585    /// pays for the AES key expansion again.
586    pub fn with_aes256_key(self, key: [u8; 32]) -> Self {
587        let cipher =
588            Aes256Gcm::new_from_slice(&key).expect("a 32-byte key is always valid for AES-256-GCM");
589        Self {
590            aes256_cipher: Some(Arc::new(cipher)),
591            ..self
592        }
593    }
594
595    /// Encodes an envelope into a binary WSCALL frame.
596    pub fn encode(&self, packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
597        let payload = serde_json::to_vec(&packet.body)?;
598
599        // Pre-check the serialized JSON size before doing any crypto work so
600        // that oversized payloads are rejected without paying for encryption.
601        if payload.len() > MAX_PAYLOAD_BYTES {
602            return Err(ProtocolError::PayloadTooLarge {
603                actual: payload.len(),
604                max: MAX_PAYLOAD_BYTES,
605            });
606        }
607
608        let payload = match packet.encryption {
609            EncryptionKind::None => payload,
610            EncryptionKind::ChaCha20 => self.encrypt_chacha20(&payload)?,
611            EncryptionKind::Aes256 => self.encrypt_aes256(&payload)?,
612        };
613
614        // The encrypted form (nonce + ciphertext + tag) can still overflow the
615        // limit even when the plaintext fit, so guard once more.
616        if payload.len() > MAX_PAYLOAD_BYTES {
617            return Err(ProtocolError::PayloadTooLarge {
618                actual: payload.len(),
619                max: MAX_PAYLOAD_BYTES,
620            });
621        }
622
623        let frame_len = 2 + payload.len();
624        let mut frame = Vec::with_capacity(4 + frame_len);
625        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
626        frame.push(packet.message_type as u8);
627        frame.push(packet.encryption as u8);
628        frame.extend_from_slice(&payload);
629        Ok(frame)
630    }
631
632    /// Decodes a binary WSCALL frame back into an envelope.
633    pub fn decode(&self, frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
634        if frame.len() < 6 {
635            return Err(ProtocolError::FrameTooShort);
636        }
637
638        let declared = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
639        let actual = frame.len() - 4;
640        if declared != actual {
641            return Err(ProtocolError::FrameLengthMismatch { declared, actual });
642        }
643
644        let payload_len = actual - 2;
645        if payload_len > MAX_PAYLOAD_BYTES {
646            return Err(ProtocolError::PayloadTooLarge {
647                actual: payload_len,
648                max: MAX_PAYLOAD_BYTES,
649            });
650        }
651
652        let message_type = MessageType::try_from(frame[4])?;
653        let encryption = EncryptionKind::try_from(frame[5])?;
654        // Borrow the plaintext slice directly instead of cloning it into a Vec.
655        let payload: Cow<'_, [u8]> = match encryption {
656            EncryptionKind::None => Cow::Borrowed(&frame[6..]),
657            EncryptionKind::ChaCha20 => Cow::Owned(self.decrypt_chacha20(&frame[6..])?),
658            EncryptionKind::Aes256 => Cow::Owned(self.decrypt_aes256(&frame[6..])?),
659        };
660
661        let body: PacketBody = serde_json::from_slice(&payload)?;
662        if body.message_type() != message_type {
663            return Err(ProtocolError::MessageTypeMismatch);
664        }
665
666        Ok(PacketEnvelope {
667            message_type,
668            encryption,
669            body,
670        })
671    }
672
673    fn encrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
674        let cipher = self
675            .chacha20_cipher
676            .as_ref()
677            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
678        let mut nonce_bytes = [0_u8; CHACHA20_NONCE_LEN];
679        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
680        let ciphertext = cipher
681            .encrypt(Nonce::from_slice(&nonce_bytes), payload)
682            .map_err(|_| ProtocolError::EncryptionFailed("chacha20"))?;
683
684        let mut encoded = Vec::with_capacity(CHACHA20_NONCE_LEN + ciphertext.len());
685        encoded.extend_from_slice(&nonce_bytes);
686        encoded.extend_from_slice(&ciphertext);
687        Ok(encoded)
688    }
689
690    fn decrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
691        if payload.len() < CHACHA20_NONCE_LEN {
692            return Err(ProtocolError::EncryptedPayloadTooShort {
693                algorithm: "chacha20",
694                expected_min: CHACHA20_NONCE_LEN,
695                actual: payload.len(),
696            });
697        }
698
699        let cipher = self
700            .chacha20_cipher
701            .as_ref()
702            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
703        let (nonce_bytes, ciphertext) = payload.split_at(CHACHA20_NONCE_LEN);
704        cipher
705            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
706            .map_err(|_| ProtocolError::DecryptionFailed("chacha20"))
707    }
708
709    fn encrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
710        let cipher = self
711            .aes256_cipher
712            .as_ref()
713            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
714        let mut nonce_bytes = [0_u8; AES256_NONCE_LEN];
715        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
716        let ciphertext = cipher
717            .encrypt(AesNonce::from_slice(&nonce_bytes), payload)
718            .map_err(|_| ProtocolError::EncryptionFailed("aes256"))?;
719
720        let mut encoded = Vec::with_capacity(AES256_NONCE_LEN + ciphertext.len());
721        encoded.extend_from_slice(&nonce_bytes);
722        encoded.extend_from_slice(&ciphertext);
723        Ok(encoded)
724    }
725
726    fn decrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
727        if payload.len() < AES256_NONCE_LEN {
728            return Err(ProtocolError::EncryptedPayloadTooShort {
729                algorithm: "aes256",
730                expected_min: AES256_NONCE_LEN,
731                actual: payload.len(),
732            });
733        }
734
735        let cipher = self
736            .aes256_cipher
737            .as_ref()
738            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
739        let (nonce_bytes, ciphertext) = payload.split_at(AES256_NONCE_LEN);
740        cipher
741            .decrypt(AesNonce::from_slice(nonce_bytes), ciphertext)
742            .map_err(|_| ProtocolError::DecryptionFailed("aes256"))
743    }
744}
745
746/// Helper for encoding a plaintext frame without constructing a custom codec.
747pub fn encode_frame(packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
748    FrameCodec::plaintext().encode(packet)
749}
750
751/// Helper for decoding a plaintext frame without constructing a custom codec.
752pub fn decode_frame(frame: &[u8]) -> Result<PacketEnvelope, ProtocolError> {
753    FrameCodec::plaintext().decode(frame)
754}
755
756/// Errors returned while encoding or decoding WSCALL frames.
757#[derive(Debug, Error)]
758pub enum ProtocolError {
759    #[error("frame too short")]
760    FrameTooShort,
761    #[error("frame length mismatch: declared={declared}, actual={actual}")]
762    FrameLengthMismatch { declared: usize, actual: usize },
763    #[error("payload too large: actual={actual}, max={max}")]
764    PayloadTooLarge { actual: usize, max: usize },
765    #[error("unknown message type: {0:#x}")]
766    UnknownMessageType(u8),
767    #[error("unknown encryption kind: {0:#x}")]
768    UnknownEncryption(u8),
769    #[error("unsupported encryption kind: {0:#x}")]
770    UnsupportedEncryption(u8),
771    #[error("missing encryption key for {0}")]
772    MissingEncryptionKey(&'static str),
773    #[error("invalid encryption key for {0}")]
774    InvalidEncryptionKey(&'static str),
775    #[error("secure random generation failed: {0}")]
776    Random(String),
777    #[error(
778        "encrypted payload too short for {algorithm}: expected at least {expected_min}, actual={actual}"
779    )]
780    EncryptedPayloadTooShort {
781        algorithm: &'static str,
782        expected_min: usize,
783        actual: usize,
784    },
785    #[error("encryption failed for {0}")]
786    EncryptionFailed(&'static str),
787    #[error("decryption failed for {0}")]
788    DecryptionFailed(&'static str),
789    #[error("invalid ECDH public key: expected {expected} bytes, got {actual}")]
790    InvalidEcdhPublicKey { expected: usize, actual: usize },
791    #[error("ECDH handshake failed: {0}")]
792    EcdhHandshake(String),
793    #[error("message type does not match packet body")]
794    MessageTypeMismatch,
795    #[error("invalid attachment encoding: {0}")]
796    InvalidAttachmentEncoding(String),
797    #[error("json error: {0}")]
798    Json(#[from] serde_json::Error),
799}
800
801#[cfg(test)]
802mod tests {
803    use super::{
804        EncryptionKind, FrameCodec, MAX_PAYLOAD_BYTES, MessageType, PacketBody, PacketEnvelope,
805        ProtocolError, decode_frame, encode_frame,
806    };
807    use serde_json::json;
808
809    const TEST_KEY: [u8; 32] = [0x11; 32];
810
811    #[test]
812    fn plaintext_helpers_still_work() {
813        let packet = PacketEnvelope::new(PacketBody::EventAck {
814            event_id: 1,
815            ok: true,
816            receipt: json!({ "ok": true }),
817            error: None,
818        });
819
820        let encoded = encode_frame(&packet).expect("encode plaintext");
821        let decoded = decode_frame(&encoded).expect("decode plaintext");
822        assert!(matches!(decoded.encryption, EncryptionKind::None));
823    }
824
825    #[test]
826    fn aes256_roundtrip_works() {
827        let codec = FrameCodec::plaintext().with_aes256_key(TEST_KEY);
828        let packet = PacketEnvelope::with_encryption(
829            PacketBody::ApiResponse {
830                request_id: 1,
831                ok: true,
832                status: 200,
833                data: json!({ "message": "encrypted" }),
834                error: None,
835                metadata: json!({}),
836            },
837            EncryptionKind::Aes256,
838        );
839
840        let encoded = codec.encode(&packet).expect("encode aes256");
841        let decoded = codec.decode(&encoded).expect("decode aes256");
842        assert!(matches!(decoded.encryption, EncryptionKind::Aes256));
843    }
844
845    #[test]
846    fn encode_rejects_payloads_over_limit() {
847        let codec = FrameCodec::plaintext();
848        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
849            request_id: 999,
850            ok: true,
851            status: 200,
852            data: json!({ "blob": "a".repeat(10 * 1024 * 1024) }),
853            error: None,
854            metadata: json!({}),
855        });
856
857        let error = codec
858            .encode(&packet)
859            .expect_err("oversized payload should fail");
860        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
861    }
862
863    #[test]
864    fn decode_rejects_payloads_over_limit() {
865        let payload = vec![0_u8; MAX_PAYLOAD_BYTES + 1];
866        let frame_len = 2 + payload.len();
867        let mut frame = Vec::with_capacity(4 + frame_len);
868        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
869        frame.push(MessageType::Api as u8);
870        frame.push(EncryptionKind::None as u8);
871        frame.extend_from_slice(&payload);
872
873        let error = FrameCodec::plaintext()
874            .decode(&frame)
875            .expect_err("oversized payload should fail");
876        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
877    }
878}