1use base64::Engine as _;
2use serde::{Deserialize, Serialize};
3
4fn parse_application_key_agreement_public_key(
5 json: &serde_json::Value,
6) -> Option<[u8; crate::key_agreement::PUBLIC_KEY_BYTES]> {
7 let payload = json.get("applicationKeyAgreement")?.as_object()?;
8 if payload.get("algorithm").and_then(|value| value.as_str())
9 != Some(crate::key_agreement::KEY_ALGORITHM)
10 {
11 return None;
12 }
13 let public_key = payload.get("publicKey")?.as_str()?.trim();
14 if public_key.is_empty() {
15 return None;
16 }
17 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
18 .decode(public_key)
19 .ok()?;
20 if bytes.len() != crate::key_agreement::PUBLIC_KEY_BYTES {
21 return None;
22 }
23 let mut out = [0u8; crate::key_agreement::PUBLIC_KEY_BYTES];
24 out.copy_from_slice(&bytes);
25 Some(out)
26}
27
28fn parse_transport_trust_device_id(json: &serde_json::Value) -> Option<String> {
29 json.get("transportTrust")
30 .and_then(|value| value.as_object())
31 .and_then(|payload| payload.get("deviceId"))
32 .and_then(|value| value.as_str())
33 .map(str::trim)
34 .filter(|value| !value.is_empty())
35 .map(ToOwned::to_owned)
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum NativeMessageAction {
41 Request,
42 Response,
43 Event,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct NativeMainMessage {
49 pub channel: String,
50 pub action: NativeMessageAction,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub request_id: Option<String>,
53 pub payload: serde_json::Value,
54 pub timestamp: i64,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub from: Option<String>,
57}
58
59pub const SESSION_TOKEN_CHANNEL: &str = "session-token";
63pub const PEER_DATA_CHANNEL: &str = "peer-data";
64pub const OFFLINE_PROOF_CHANNEL: &str = "offline-device-proof";
65pub const MAX_PEER_MESSAGE_BYTES: usize = 64 * 1024;
69
70#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "kebab-case")]
77pub enum TokenStreamContract {
78 #[default]
79 OneShotAdmission,
80 PersistentControl,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "camelCase")]
89pub struct ReciprocalSessionTokenPresentation {
90 pub presentation_id: String,
91 pub token: String,
92 pub token_payload: String,
93 pub device_id: String,
94 pub stream_contract: TokenStreamContract,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[cfg(not(target_arch = "wasm32"))]
99pub(crate) enum ReciprocalSessionTokenPresentationState {
100 Absent,
101 Malformed,
102 Present(ReciprocalSessionTokenPresentation),
103}
104
105impl NativeMainMessage {
106 pub fn is_handshake(&self) -> bool {
107 self.channel == "handshake"
108 }
109
110 pub fn is_session_token_presentation(&self) -> bool {
111 self.channel == SESSION_TOKEN_CHANNEL && matches!(self.action, NativeMessageAction::Request)
112 }
113
114 pub fn is_session_token_response(&self) -> bool {
115 self.channel == SESSION_TOKEN_CHANNEL
116 && matches!(self.action, NativeMessageAction::Response)
117 }
118
119 pub fn is_session_token_response_ack(&self, connection_id: &str) -> bool {
120 self.channel == SESSION_TOKEN_CHANNEL
121 && matches!(self.action, NativeMessageAction::Event)
122 && self
123 .payload
124 .get("responseAck")
125 .and_then(|value| value.as_bool())
126 == Some(true)
127 && self
128 .payload
129 .get("connectionId")
130 .and_then(|value| value.as_str())
131 == Some(connection_id)
132 }
133
134 pub fn is_peer_data(&self) -> bool {
135 self.channel == PEER_DATA_CHANNEL
136 }
137
138 #[cfg(not(target_arch = "wasm32"))]
139 pub(crate) fn is_offline_proof_request(&self) -> bool {
140 self.channel == OFFLINE_PROOF_CHANNEL && matches!(self.action, NativeMessageAction::Request)
141 }
142
143 #[cfg(not(target_arch = "wasm32"))]
144 pub(crate) fn is_offline_proof_response(&self) -> bool {
145 self.channel == OFFLINE_PROOF_CHANNEL
146 && matches!(self.action, NativeMessageAction::Response)
147 }
148
149 #[cfg(not(target_arch = "wasm32"))]
150 pub(crate) fn offline_proof_challenge(&self) -> Option<crate::offline::OfflineProofChallenge> {
151 serde_json::from_value(self.payload.get("challenge")?.clone()).ok()
152 }
153
154 #[cfg(not(target_arch = "wasm32"))]
155 pub(crate) fn offline_connection_proof(
156 &self,
157 ) -> Option<crate::offline::OfflineConnectionProof> {
158 serde_json::from_value(self.payload.get("proof")?.clone()).ok()
159 }
160
161 #[cfg(not(target_arch = "wasm32"))]
162 pub(crate) fn offline_proof_request(challenge: &crate::offline::OfflineProofChallenge) -> Self {
163 Self {
164 channel: OFFLINE_PROOF_CHANNEL.to_string(),
165 action: NativeMessageAction::Request,
166 request_id: Some(challenge.request_id.clone()),
167 payload: serde_json::json!({ "challenge": challenge }),
168 timestamp: crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64,
169 from: None,
170 }
171 }
172
173 #[cfg(not(target_arch = "wasm32"))]
174 pub(crate) fn offline_proof_response(
175 request_id: &str,
176 proof: &crate::offline::OfflineConnectionProof,
177 ) -> Self {
178 Self {
179 channel: OFFLINE_PROOF_CHANNEL.to_string(),
180 action: NativeMessageAction::Response,
181 request_id: Some(request_id.to_string()),
182 payload: serde_json::json!({ "proof": proof }),
183 timestamp: crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64,
184 from: None,
185 }
186 }
187
188 pub fn peer_data_payload(&self) -> Option<Vec<u8>> {
189 let payload_base64 = self
190 .payload
191 .get("payloadBase64")
192 .and_then(|value| value.as_str())
193 .map(str::trim)
194 .filter(|value| !value.is_empty())?;
195 base64::engine::general_purpose::STANDARD
196 .decode(payload_base64.as_bytes())
197 .ok()
198 }
199
200 pub fn claimed_device_id(&self) -> Option<String> {
201 self.payload
202 .get("deviceId")
203 .and_then(|value| value.as_str())
204 .map(str::trim)
205 .filter(|value| !value.is_empty())
206 .map(ToOwned::to_owned)
207 }
208
209 pub fn presented_session_token(&self) -> Option<String> {
210 self.payload
211 .get("token")
212 .and_then(|value| value.as_str())
213 .map(str::trim)
214 .filter(|value| !value.is_empty())
215 .map(ToOwned::to_owned)
216 }
217
218 pub fn presented_session_token_payload(&self) -> Option<String> {
219 self.payload
220 .get("tokenPayload")
221 .and_then(|value| value.as_str())
222 .map(str::trim)
223 .filter(|value| !value.is_empty())
224 .map(ToOwned::to_owned)
225 }
226
227 pub fn session_token_stream_contract(&self) -> TokenStreamContract {
228 self.payload
229 .get("streamContract")
230 .cloned()
231 .and_then(|value| serde_json::from_value(value).ok())
232 .unwrap_or_default()
233 }
234
235 #[cfg(not(target_arch = "wasm32"))]
239 pub(crate) fn session_token_reciprocal_requested(&self) -> bool {
240 self.payload
241 .get("reciprocalRequested")
242 .and_then(|value| value.as_bool())
243 .unwrap_or(false)
244 }
245
246 pub fn reciprocal_session_token_mode(&self) -> Option<&str> {
247 self.payload
248 .get("reciprocalMode")
249 .and_then(|value| value.as_str())
250 }
251
252 #[cfg(not(target_arch = "wasm32"))]
253 pub(crate) fn reciprocal_session_token_presentation_state(
254 &self,
255 ) -> ReciprocalSessionTokenPresentationState {
256 let Some(presentation) = self.payload.get("reciprocalPresentation").cloned() else {
257 return ReciprocalSessionTokenPresentationState::Absent;
258 };
259 let Ok(parsed) = serde_json::from_value::<ReciprocalSessionTokenPresentation>(presentation)
260 else {
261 return ReciprocalSessionTokenPresentationState::Malformed;
262 };
263 if parsed.presentation_id.trim().is_empty()
264 || parsed.token.trim().is_empty()
265 || parsed.token_payload.trim().is_empty()
266 || parsed.device_id.trim().is_empty()
267 {
268 return ReciprocalSessionTokenPresentationState::Malformed;
269 }
270 ReciprocalSessionTokenPresentationState::Present(parsed)
271 }
272
273 #[cfg(not(target_arch = "wasm32"))]
274 pub(crate) fn reciprocal_session_token_presentation_id(&self) -> Option<String> {
275 self.payload
276 .get("reciprocalPresentation")
277 .and_then(|value| value.get("presentationId"))
278 .and_then(|value| value.as_str())
279 .map(str::trim)
280 .filter(|value| !value.is_empty())
281 .map(ToOwned::to_owned)
282 }
283
284 pub(crate) fn session_token_response_ack_requested(&self) -> bool {
287 self.payload
288 .get("responseAckRequested")
289 .and_then(|value| value.as_bool())
290 .unwrap_or(false)
291 }
292
293 pub fn with_session_token_stream_contract(mut self, contract: TokenStreamContract) -> Self {
294 if let Some(payload) = self.payload.as_object_mut() {
295 payload.insert(
296 "streamContract".to_string(),
297 serde_json::to_value(contract)
298 .expect("session-token stream contract must serialize"),
299 );
300 }
301 self
302 }
303
304 pub(crate) fn with_session_token_reciprocal_requested(mut self, requested: bool) -> Self {
305 if requested {
306 if let Some(payload) = self.payload.as_object_mut() {
307 payload.insert(
308 "reciprocalRequested".to_string(),
309 serde_json::Value::Bool(true),
310 );
311 payload.insert(
312 "reciprocalMode".to_string(),
313 serde_json::Value::String("inline-v1".to_string()),
314 );
315 }
316 }
317 self
318 }
319
320 pub fn session_token_approved(&self) -> Option<bool> {
321 self.payload
322 .get("approved")
323 .and_then(|value| value.as_bool())
324 }
325
326 pub fn approved_session_scope(&self) -> Option<String> {
327 self.payload
328 .get("scope")
329 .and_then(|value| value.as_str())
330 .map(str::trim)
331 .filter(|value| !value.is_empty())
332 .map(ToOwned::to_owned)
333 }
334
335 pub fn session_token_error(&self) -> Option<String> {
336 self.payload
337 .get("reason")
338 .and_then(|value| value.as_str())
339 .map(str::trim)
340 .filter(|value| !value.is_empty())
341 .map(ToOwned::to_owned)
342 }
343
344 pub fn session_token_presentation(token: &str) -> Self {
347 Self::session_token_presentation_with_payload(token, None)
348 }
349
350 pub(crate) fn session_token_presentation_with_payload(
351 token: &str,
352 token_payload: Option<&str>,
353 ) -> Self {
354 Self::session_token_presentation_with_payload_and_device_id(token, token_payload, None)
355 }
356
357 pub(crate) fn session_token_presentation_with_payload_and_device_id(
358 token: &str,
359 token_payload: Option<&str>,
360 device_id: Option<&str>,
361 ) -> Self {
362 #[cfg(target_arch = "wasm32")]
363 let timestamp = js_sys::Date::now() as i64;
364 #[cfg(not(target_arch = "wasm32"))]
365 let timestamp = std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .map(|d| d.as_millis() as i64)
368 .unwrap_or(0);
369
370 let mut payload = serde_json::json!({
371 "token": token,
372 "streamContract": TokenStreamContract::OneShotAdmission,
373 "responseAckRequested": true,
374 });
375 if let Some(token_payload) = token_payload {
376 if let Some(object) = payload.as_object_mut() {
377 object.insert(
378 "tokenPayload".to_string(),
379 serde_json::Value::String(token_payload.to_string()),
380 );
381 }
382 }
383 if let Some(device_id) = device_id.map(str::trim).filter(|value| !value.is_empty()) {
384 if let Some(object) = payload.as_object_mut() {
385 object.insert(
386 "deviceId".to_string(),
387 serde_json::Value::String(device_id.to_string()),
388 );
389 }
390 }
391
392 Self {
393 channel: SESSION_TOKEN_CHANNEL.to_string(),
394 action: NativeMessageAction::Request,
395 request_id: None,
396 payload,
397 timestamp,
398 from: None,
399 }
400 }
401
402 pub fn session_token_approval(scope: Option<&str>, connection_id: &str) -> Self {
403 #[cfg(target_arch = "wasm32")]
404 let timestamp = js_sys::Date::now() as i64;
405 #[cfg(not(target_arch = "wasm32"))]
406 let timestamp = std::time::SystemTime::now()
407 .duration_since(std::time::UNIX_EPOCH)
408 .map(|d| d.as_millis() as i64)
409 .unwrap_or(0);
410
411 Self {
412 channel: SESSION_TOKEN_CHANNEL.to_string(),
413 action: NativeMessageAction::Response,
414 request_id: None,
415 payload: serde_json::json!({
416 "approved": true,
417 "scope": scope,
418 "connectionId": connection_id,
419 }),
420 timestamp,
421 from: None,
422 }
423 }
424
425 #[cfg(not(target_arch = "wasm32"))]
426 pub(crate) fn with_reciprocal_session_token_presentation(
427 mut self,
428 presentation: ReciprocalSessionTokenPresentation,
429 ) -> Self {
430 if let Some(payload) = self.payload.as_object_mut() {
431 payload.insert(
432 "reciprocalMode".to_string(),
433 serde_json::Value::String("inline-v1".to_string()),
434 );
435 payload.insert(
436 "reciprocalPresentation".to_string(),
437 serde_json::to_value(presentation)
438 .expect("reciprocal session-token presentation must serialize"),
439 );
440 }
441 self
442 }
443
444 pub fn session_token_rejection(reason: &str, connection_id: &str) -> Self {
445 #[cfg(target_arch = "wasm32")]
446 let timestamp = js_sys::Date::now() as i64;
447 #[cfg(not(target_arch = "wasm32"))]
448 let timestamp = std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .map(|d| d.as_millis() as i64)
451 .unwrap_or(0);
452
453 Self {
454 channel: SESSION_TOKEN_CHANNEL.to_string(),
455 action: NativeMessageAction::Response,
456 request_id: None,
457 payload: serde_json::json!({
458 "approved": false,
459 "reason": reason,
460 "connectionId": connection_id,
461 }),
462 timestamp,
463 from: None,
464 }
465 }
466
467 pub fn session_token_response_ack(connection_id: &str) -> Self {
468 #[cfg(target_arch = "wasm32")]
469 let timestamp = js_sys::Date::now() as i64;
470 #[cfg(not(target_arch = "wasm32"))]
471 let timestamp = std::time::SystemTime::now()
472 .duration_since(std::time::UNIX_EPOCH)
473 .map(|d| d.as_millis() as i64)
474 .unwrap_or(0);
475
476 Self {
477 channel: SESSION_TOKEN_CHANNEL.to_string(),
478 action: NativeMessageAction::Event,
479 request_id: None,
480 payload: serde_json::json!({
481 "responseAck": true,
482 "connectionId": connection_id,
483 }),
484 timestamp,
485 from: None,
486 }
487 }
488
489 pub fn with_reciprocal_session_token_ack(
490 mut self,
491 presentation_id: &str,
492 accepted: bool,
493 scope: Option<&str>,
494 reason: Option<&str>,
495 ) -> Self {
496 if let Some(payload) = self.payload.as_object_mut() {
497 payload.insert(
498 "reciprocalPresentationId".to_string(),
499 serde_json::Value::String(presentation_id.to_string()),
500 );
501 payload.insert(
502 "reciprocalAccepted".to_string(),
503 serde_json::Value::Bool(accepted),
504 );
505 if let Some(scope) = scope {
506 payload.insert(
507 "reciprocalScope".to_string(),
508 serde_json::Value::String(scope.to_string()),
509 );
510 }
511 if let Some(reason) = reason {
512 payload.insert(
513 "reciprocalReason".to_string(),
514 serde_json::Value::String(reason.to_string()),
515 );
516 }
517 }
518 self
519 }
520
521 #[cfg(not(target_arch = "wasm32"))]
522 pub(crate) fn reciprocal_session_token_ack_presentation_id(&self) -> Option<String> {
523 self.payload
524 .get("reciprocalPresentationId")
525 .and_then(|value| value.as_str())
526 .map(str::trim)
527 .filter(|value| !value.is_empty())
528 .map(ToOwned::to_owned)
529 }
530
531 #[cfg(not(target_arch = "wasm32"))]
532 pub(crate) fn reciprocal_session_token_ack_accepted(&self) -> Option<bool> {
533 self.payload
534 .get("reciprocalAccepted")
535 .and_then(|value| value.as_bool())
536 }
537
538 pub fn reciprocal_session_token_ack_scope(&self) -> Option<String> {
539 self.payload
540 .get("reciprocalScope")
541 .and_then(|value| value.as_str())
542 .map(str::trim)
543 .filter(|value| !value.is_empty())
544 .map(ToOwned::to_owned)
545 }
546
547 pub fn peer_data(payload: &[u8]) -> Self {
548 #[cfg(target_arch = "wasm32")]
549 let timestamp = js_sys::Date::now() as i64;
550 #[cfg(not(target_arch = "wasm32"))]
551 let timestamp = std::time::SystemTime::now()
552 .duration_since(std::time::UNIX_EPOCH)
553 .map(|d| d.as_millis() as i64)
554 .unwrap_or(0);
555
556 Self {
557 channel: PEER_DATA_CHANNEL.to_string(),
558 action: NativeMessageAction::Event,
559 request_id: None,
560 payload: serde_json::json!({
561 "payloadBase64": base64::engine::general_purpose::STANDARD.encode(payload),
562 }),
563 timestamp,
564 from: None,
565 }
566 }
567}
568
569#[cfg(not(target_arch = "wasm32"))]
570pub(crate) fn encode_native_main_payload(message: &NativeMainMessage) -> Result<Vec<u8>, String> {
571 let payload = serde_json::to_vec(message)
572 .map_err(|error| format!("encode native-main message: {error}"))?;
573 let len = u32::try_from(payload.len())
574 .map_err(|_| "native-main message exceeds the frame bound".to_string())?;
575 let mut frame = Vec::with_capacity(4 + payload.len());
576 frame.extend_from_slice(&len.to_be_bytes());
577 frame.extend_from_slice(&payload);
578 Ok(frame)
579}
580
581#[cfg(not(target_arch = "wasm32"))]
582pub(crate) fn encode_native_main_opening(message: &NativeMainMessage) -> Result<Vec<u8>, String> {
583 let payload = encode_native_main_payload(message)?;
584 let label = b"main";
585 let mut opening = Vec::with_capacity(1 + 4 + label.len() + payload.len());
586 opening.push(0);
587 opening.extend_from_slice(&(label.len() as u32).to_be_bytes());
588 opening.extend_from_slice(label);
589 opening.extend_from_slice(&payload);
590 Ok(opening)
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct HandshakeFeatures {
595 pub carrier_schema: Option<u8>,
596 pub webrtc: Option<bool>,
597 pub moq: Option<bool>,
598 pub dedicated_carrier_control: Option<bool>,
599 pub dedicated_carrier_control_ready: Option<bool>,
600 pub ble: Option<bool>,
601 pub application_key_agreement: Option<bool>,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct TypeScriptHandshake {
606 pub action: Option<String>,
607 pub session_token: Option<String>,
608 pub session_token_payload: Option<String>,
609 pub claimed_device_id: Option<String>,
610 pub capabilities: Option<HandshakeFeatures>,
611 pub application_key_agreement_public_key: Option<[u8; crate::key_agreement::PUBLIC_KEY_BYTES]>,
612}
613
614#[derive(Debug, Clone)]
615pub enum ParsedMainFrame {
616 NativeMessage(NativeMainMessage),
617 TypeScriptHandshake(TypeScriptHandshake),
618 TypeScriptJson(serde_json::Value),
619 Opaque,
620}
621
622#[derive(Debug, Clone)]
623pub enum InspectedMainFrame {
624 NativeMessage {
625 message: NativeMainMessage,
626 handshake: Option<HandshakeBinding>,
627 },
628 SessionTokenResponse {
633 response: NativeMainMessage,
634 accepted: bool,
635 is_first_presentation: bool,
636 },
637 ForwardOpaque,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
641#[serde(rename_all = "camelCase")]
642pub struct HandshakeBinding {
643 pub known_device_id: Option<String>,
644 pub claimed_device_id: Option<String>,
645 pub admitted_device_id: Option<String>,
646 pub authoritative_device_id_hint: Option<String>,
647}
648
649pub fn parse_main_frame(frame: &[u8]) -> ParsedMainFrame {
650 if let Ok(message) = serde_json::from_slice::<NativeMainMessage>(frame) {
651 return ParsedMainFrame::NativeMessage(message);
652 }
653
654 if frame.len() >= 2 && frame[0] == 0x00 {
655 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&frame[1..]) {
656 if json.get("type").and_then(|value| value.as_str()) == Some("handshake") {
657 return ParsedMainFrame::TypeScriptHandshake(TypeScriptHandshake {
658 action: json
659 .get("action")
660 .and_then(|value| value.as_str())
661 .map(ToOwned::to_owned),
662 session_token: json
663 .get("sessionToken")
664 .and_then(|value| value.as_str())
665 .map(str::trim)
666 .filter(|value| !value.is_empty())
667 .map(ToOwned::to_owned),
668 session_token_payload: json
669 .get("sessionTokenPayload")
670 .and_then(|value| value.as_str())
671 .map(str::trim)
672 .filter(|value| !value.is_empty())
673 .map(ToOwned::to_owned),
674 claimed_device_id: parse_transport_trust_device_id(&json),
675 capabilities: json
676 .get("capabilities")
677 .and_then(|value| value.as_object())
678 .map(|value| HandshakeFeatures {
679 carrier_schema: value
680 .get("carrierSchema")
681 .and_then(|version| version.as_u64())
682 .and_then(|version| u8::try_from(version).ok()),
683 webrtc: value.get("webrtc").and_then(|flag| flag.as_bool()),
684 moq: value.get("moq").and_then(|flag| flag.as_bool()),
685 dedicated_carrier_control: value
686 .get("dedicatedCarrierControl")
687 .and_then(|flag| flag.as_bool()),
688 dedicated_carrier_control_ready: value
689 .get("dedicatedCarrierControlReady")
690 .and_then(|flag| flag.as_bool()),
691 ble: value.get("ble").and_then(|flag| flag.as_bool()),
692 application_key_agreement: value
693 .get("applicationKeyAgreement")
694 .and_then(|flag| flag.as_bool()),
695 }),
696 application_key_agreement_public_key:
697 parse_application_key_agreement_public_key(&json),
698 });
699 }
700 return ParsedMainFrame::TypeScriptJson(json);
701 }
702 }
703
704 ParsedMainFrame::Opaque
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn parses_native_handshake_message() {
713 let frame = serde_json::json!({
714 "channel": "handshake",
715 "action": "request",
716 "payload": { "deviceId": "device-1" },
717 "timestamp": 123,
718 });
719
720 match parse_main_frame(frame.to_string().as_bytes()) {
721 ParsedMainFrame::NativeMessage(message) => {
722 assert!(message.is_handshake());
723 assert_eq!(message.claimed_device_id().as_deref(), Some("device-1"));
724 }
725 other => panic!("expected native message, got {:?}", other),
726 }
727 }
728
729 #[test]
730 fn parses_typescript_handshake_message() {
731 let json = serde_json::json!({
732 "type": "handshake",
733 "action": "hello",
734 "sessionToken": "token-1",
735 "sessionTokenPayload": "payload-1",
736 "transportTrust": {
737 "version": 1,
738 "deviceId": "device-1",
739 "identityFingerprint": "fingerprint-1"
740 },
741 "capabilities": {
742 "carrierSchema": 2,
743 "webrtc": true,
744 "moq": false,
745 "dedicatedCarrierControl": true,
746 "dedicatedCarrierControlReady": false,
747 "ble": true,
748 },
749 });
750 let mut frame = vec![0x00];
751 frame.extend_from_slice(json.to_string().as_bytes());
752
753 match parse_main_frame(&frame) {
754 ParsedMainFrame::TypeScriptHandshake(handshake) => {
755 assert_eq!(handshake.action.as_deref(), Some("hello"));
756 assert_eq!(handshake.session_token.as_deref(), Some("token-1"));
757 assert_eq!(
758 handshake.session_token_payload.as_deref(),
759 Some("payload-1")
760 );
761 assert_eq!(handshake.claimed_device_id.as_deref(), Some("device-1"));
762 let capabilities = handshake.capabilities.expect("capabilities");
763 assert_eq!(capabilities.carrier_schema, Some(2));
764 assert_eq!(capabilities.webrtc, Some(true));
765 assert_eq!(capabilities.moq, Some(false));
766 assert_eq!(capabilities.dedicated_carrier_control, Some(true));
767 assert_eq!(capabilities.dedicated_carrier_control_ready, Some(false));
768 assert_eq!(capabilities.ble, Some(true));
769 }
770 other => panic!("expected typescript handshake, got {:?}", other),
771 }
772 }
773
774 #[test]
775 fn falls_back_to_opaque_for_unrecognized_bytes() {
776 assert!(matches!(
777 parse_main_frame(b"\x01\x02\x03"),
778 ParsedMainFrame::Opaque
779 ));
780 }
781
782 #[test]
783 fn parses_peer_data_native_message() {
784 let message = NativeMainMessage::peer_data(b"hello peer");
785 let frame = serde_json::to_vec(&message).expect("peer data should serialize");
786
787 match parse_main_frame(&frame) {
788 ParsedMainFrame::NativeMessage(parsed) => {
789 assert!(parsed.is_peer_data());
790 assert_eq!(
791 parsed.peer_data_payload().as_deref(),
792 Some(b"hello peer".as_slice())
793 );
794 }
795 other => panic!("expected native peer data message, got {:?}", other),
796 }
797 }
798
799 #[test]
800 fn session_token_stream_contract_round_trips_and_legacy_defaults_to_one_shot() {
801 let persistent = NativeMainMessage::session_token_presentation("token")
802 .with_session_token_stream_contract(TokenStreamContract::PersistentControl);
803 let encoded = serde_json::to_vec(&persistent).expect("serialize persistent contract");
804 let decoded: NativeMainMessage =
805 serde_json::from_slice(&encoded).expect("deserialize persistent contract");
806 assert_eq!(
807 decoded.session_token_stream_contract(),
808 TokenStreamContract::PersistentControl,
809 );
810
811 let mut legacy = NativeMainMessage::session_token_presentation("legacy-token");
812 legacy
813 .payload
814 .as_object_mut()
815 .expect("presentation payload")
816 .remove("streamContract");
817 assert_eq!(
818 legacy.session_token_stream_contract(),
819 TokenStreamContract::OneShotAdmission,
820 );
821 assert!(!legacy.session_token_reciprocal_requested());
822 }
823
824 #[test]
825 fn session_token_reciprocal_request_round_trips_and_defaults_false() {
826 let legacy = NativeMainMessage::session_token_presentation("legacy-token");
827 assert!(!legacy.session_token_reciprocal_requested());
828 assert_eq!(legacy.reciprocal_session_token_mode(), None);
829
830 let requested = NativeMainMessage::session_token_presentation("token")
831 .with_session_token_reciprocal_requested(true);
832 let encoded = serde_json::to_vec(&requested).expect("serialize reciprocal request");
833 let decoded: NativeMainMessage =
834 serde_json::from_slice(&encoded).expect("deserialize reciprocal request");
835 assert!(decoded.session_token_reciprocal_requested());
836 assert_eq!(decoded.reciprocal_session_token_mode(), Some("inline-v1"));
837 }
838
839 #[test]
840 fn reciprocal_session_token_presentation_round_trips_on_approval() {
841 let response = NativeMainMessage::session_token_approval(Some("user-device"), "conn-1")
842 .with_reciprocal_session_token_presentation(ReciprocalSessionTokenPresentation {
843 presentation_id: "presentation-1".to_string(),
844 token: "reverse-token".to_string(),
845 token_payload: "reverse-payload".to_string(),
846 device_id: "device-1".to_string(),
847 stream_contract: TokenStreamContract::PersistentControl,
848 });
849 let encoded = serde_json::to_vec(&response).expect("serialize reciprocal response");
850 let decoded: NativeMainMessage =
851 serde_json::from_slice(&encoded).expect("deserialize reciprocal response");
852 assert_eq!(decoded.reciprocal_session_token_mode(), Some("inline-v1"));
853 assert_eq!(
854 decoded.reciprocal_session_token_presentation_state(),
855 ReciprocalSessionTokenPresentationState::Present(ReciprocalSessionTokenPresentation {
856 presentation_id: "presentation-1".to_string(),
857 token: "reverse-token".to_string(),
858 token_payload: "reverse-payload".to_string(),
859 device_id: "device-1".to_string(),
860 stream_contract: TokenStreamContract::PersistentControl,
861 })
862 );
863
864 assert_eq!(
865 NativeMainMessage::session_token_approval(None, "conn-1")
866 .reciprocal_session_token_presentation_state(),
867 ReciprocalSessionTokenPresentationState::Absent,
868 );
869
870 let malformed = NativeMainMessage::session_token_approval(None, "conn-1")
871 .with_reciprocal_session_token_presentation(ReciprocalSessionTokenPresentation {
872 presentation_id: "presentation-2".to_string(),
873 token: "".to_string(),
874 token_payload: "reverse-payload".to_string(),
875 device_id: "device-1".to_string(),
876 stream_contract: TokenStreamContract::OneShotAdmission,
877 });
878 assert_eq!(
879 malformed.reciprocal_session_token_presentation_state(),
880 ReciprocalSessionTokenPresentationState::Malformed,
881 );
882 }
883
884 #[test]
885 fn parses_pluto_signal_envelope_with_ts_prefix() {
886 let json = serde_json::json!({
887 "type": "#pluto-signal",
888 "content": {
889 "transport": "webrtc",
890 "type": "sdp",
891 "sdp": { "type": "answer", "sdp": "v=0\r\n" }
892 }
893 });
894 let mut frame = vec![0x00];
895 frame.extend_from_slice(json.to_string().as_bytes());
896
897 match parse_main_frame(&frame) {
898 ParsedMainFrame::TypeScriptJson(parsed) => {
899 assert_eq!(
900 parsed.get("type").and_then(|v| v.as_str()),
901 Some("#pluto-signal")
902 );
903 let content = parsed.get("content").unwrap();
904 assert_eq!(
905 content.get("transport").and_then(|v| v.as_str()),
906 Some("webrtc")
907 );
908 }
909 other => panic!("expected TypeScriptJson for #pluto-signal, got {:?}", other),
910 }
911 }
912
913 #[test]
914 fn raw_pluto_signal_without_prefix_is_opaque() {
915 let json = serde_json::json!({
916 "type": "#pluto-signal",
917 "content": { "transport": "webrtc", "type": "sdp" }
918 });
919 let frame = json.to_string().into_bytes();
920
921 match parse_main_frame(&frame) {
922 ParsedMainFrame::NativeMessage(_) => {
923 panic!("raw #pluto-signal should not parse as NativeMainMessage")
924 }
925 ParsedMainFrame::TypeScriptJson(_) => {
926 panic!("raw #pluto-signal without 0x00 prefix should not be TypeScriptJson")
927 }
928 ParsedMainFrame::Opaque => {}
929 other => panic!("expected Opaque, got {:?}", other),
930 }
931 }
932
933 #[test]
934 fn session_token_response_helpers_round_trip() {
935 let presentation = NativeMainMessage::session_token_presentation_with_payload(
936 "token-1",
937 Some("payload-1"),
938 );
939 assert!(presentation.is_session_token_presentation());
940 assert_eq!(
941 presentation.presented_session_token().as_deref(),
942 Some("token-1")
943 );
944 assert_eq!(
945 presentation.presented_session_token_payload().as_deref(),
946 Some("payload-1")
947 );
948 assert!(presentation.session_token_response_ack_requested());
949
950 let presentation_with_device =
951 NativeMainMessage::session_token_presentation_with_payload_and_device_id(
952 "token-1",
953 Some("payload-1"),
954 Some("device-1"),
955 );
956 assert_eq!(
957 presentation_with_device.claimed_device_id().as_deref(),
958 Some("device-1")
959 );
960
961 let approved = NativeMainMessage::session_token_approval(Some("magic-link"), "conn-1");
962 assert!(approved.is_session_token_response());
963 assert_eq!(approved.session_token_approved(), Some(true));
964 assert_eq!(
965 approved.approved_session_scope().as_deref(),
966 Some("magic-link")
967 );
968
969 let rejected = NativeMainMessage::session_token_rejection("invalid-token", "conn-1");
970 assert!(rejected.is_session_token_response());
971 assert_eq!(rejected.session_token_approved(), Some(false));
972 assert_eq!(
973 rejected.session_token_error().as_deref(),
974 Some("invalid-token")
975 );
976
977 let ack = NativeMainMessage::session_token_response_ack("conn-1");
978 assert!(!ack.is_session_token_presentation());
979 assert!(ack.is_session_token_response_ack("conn-1"));
980 assert!(!ack.is_session_token_response_ack("conn-2"));
981
982 let reciprocal_ack = NativeMainMessage::session_token_response_ack("conn-1")
983 .with_reciprocal_session_token_ack("presentation-1", true, Some("user-device"), None);
984 assert_eq!(
985 reciprocal_ack.payload.get("reciprocalPresentationId"),
986 Some(&serde_json::Value::String("presentation-1".to_string())),
987 );
988 assert_eq!(
989 reciprocal_ack.payload.get("reciprocalAccepted"),
990 Some(&serde_json::Value::Bool(true)),
991 );
992 }
993}