1pub use crate::native_key_store::{load_or_create_endpoint_key, FileSigner};
9
10pub use crate::native_coordination_gateway::{
11 NativeAuthorityAssignment, NativeAuthorityInterestAssignment, NativeCapabilities,
12 NativeCapabilityHandle, NativeCapabilityKind, NativeGatewayGrant, NativeGatewayGrantProvider,
13 NativeGatewayGrantRequest, NativeServiceErrorObservation,
14};
15
16#[cfg(feature = "adaptive-room-sentinel")]
17#[doc(hidden)]
18#[derive(Debug, Clone, Copy, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct AdaptiveRoomSentinelOwnerLimits {
21 pub max_reconnect_attempts: u8,
22 pub max_operation_attempts: usize,
23 pub max_prepare_page_members: usize,
24 pub max_artifact_chunks: usize,
25}
26
27#[cfg(feature = "adaptive-room-sentinel")]
28#[doc(hidden)]
29pub fn adaptive_room_sentinel_owner_limits() -> AdaptiveRoomSentinelOwnerLimits {
30 AdaptiveRoomSentinelOwnerLimits {
31 max_reconnect_attempts: crate::native_coordination_gateway::MAX_RECONNECT_ATTEMPTS,
32 max_operation_attempts: crate::native_coordination_gateway::MAX_OPERATION_ATTEMPTS,
33 max_prepare_page_members: crate::managed_group_controller::MAX_PREPARE_PAGE_MEMBERS,
34 max_artifact_chunks: crate::managed_group_controller::MAX_ARTIFACT_CHUNKS,
35 }
36}
37
38#[cfg(feature = "managed-group-encryption")]
42pub struct NativeManagedGroupController {
43 inner: crate::managed_group_controller::ManagedGroupController,
44}
45
46#[cfg(feature = "managed-group-encryption")]
47#[derive(Debug, Clone, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct NativeManagedProtectedPayload {
50 pub payload: String,
51 pub sealed_state: String,
52}
53
54#[cfg(feature = "managed-group-encryption")]
55impl NativeManagedGroupController {
56 pub fn new(
57 device_id: &str,
58 wrapping_key: [u8; 32],
59 sealed_state: Option<&[u8]>,
60 ) -> Result<Self> {
61 Ok(Self {
62 inner: crate::managed_group_controller::ManagedGroupController::new(
63 device_id,
64 wrapping_key,
65 sealed_state,
66 )?,
67 })
68 }
69
70 pub fn publish_key_package(&self) -> Result<Value> {
71 Ok(serde_json::to_value(self.inner.publish_key_package()?)?)
72 }
73
74 pub fn handle_prepare_page(&mut self, page: Value) -> Result<Vec<Value>> {
75 let page = serde_json::from_value(page)?;
76 self.inner
77 .handle_prepare_page(page)?
78 .into_iter()
79 .map(|action| serde_json::to_value(action).map_err(Into::into))
80 .collect()
81 }
82
83 pub fn handle_artifact_chunk(&mut self, chunk: Value) -> Result<Vec<Value>> {
84 let chunk = serde_json::from_value(chunk)?;
85 self.inner
86 .handle_artifact_chunk(chunk)?
87 .into_iter()
88 .map(|action| serde_json::to_value(action).map_err(Into::into))
89 .collect()
90 }
91
92 #[allow(clippy::too_many_arguments)]
93 pub fn seal_payload(
94 &mut self,
95 architecture_epoch: u64,
96 encryption_epoch: u64,
97 message_id: &str,
98 channel: &str,
99 priority: u8,
100 zone_id: Option<&str>,
101 payload: &[u8],
102 ) -> Result<NativeManagedProtectedPayload> {
103 let protected = self.inner.seal_payload(
104 architecture_epoch,
105 encryption_epoch,
106 message_id,
107 channel,
108 priority,
109 zone_id,
110 payload,
111 )?;
112 Ok(NativeManagedProtectedPayload {
113 payload: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(protected.data),
114 sealed_state: base64::engine::general_purpose::URL_SAFE_NO_PAD
115 .encode(protected.sealed_state),
116 })
117 }
118
119 #[allow(clippy::too_many_arguments)]
120 pub fn open_payload(
121 &mut self,
122 architecture_epoch: u64,
123 encryption_epoch: u64,
124 message_id: &str,
125 channel: &str,
126 priority: u8,
127 zone_id: Option<&str>,
128 ciphertext: &[u8],
129 ) -> Result<NativeManagedProtectedPayload> {
130 let protected = self.inner.open_payload(
131 architecture_epoch,
132 encryption_epoch,
133 message_id,
134 channel,
135 priority,
136 zone_id,
137 ciphertext,
138 )?;
139 Ok(NativeManagedProtectedPayload {
140 payload: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(protected.data),
141 sealed_state: base64::engine::general_purpose::URL_SAFE_NO_PAD
142 .encode(protected.sealed_state),
143 })
144 }
145}
146#[cfg(feature = "managed-group-encryption")]
147pub use crate::native_coordination_gateway::{
148 InMemoryNativeManagedGroupStateStore, NativeManagedGroupStateStore, NativeManagedRoomMessage,
149 NativeManagedRoomPublish,
150};
151use crate::signaling::{
152 Device, DeviceCapabilities, DeviceEvent, SessionEvent, SignalingBackend, SignalingSession,
153};
154use anyhow::{anyhow, bail, Context, Result};
155use async_trait::async_trait;
156use base64::Engine as _;
157use ed25519_dalek::{Signature, Verifier, VerifyingKey};
158use futures::stream::BoxStream;
159use serde::{Deserialize, Serialize};
160use serde_json::{json, Value};
161use std::fmt;
162use std::sync::{Arc, Mutex as StdMutex};
163use std::time::{SystemTime, UNIX_EPOCH};
164use tokio::sync::{watch, Mutex};
165use uuid::Uuid;
166
167pub const OPENRTC_PRODUCTION_CONTROL_PLANE: &str = "https://api.openrtc.app";
168const DEVICE_CERTIFICATE_RENEW_SKEW_MS: u64 = 24 * 60 * 60_000;
169const SOURCE_RENEW_SKEW_MS: u64 = 5 * 60_000;
170const REQUEST_TIMEOUT_SECONDS: u64 = 15;
171
172#[derive(Debug)]
173pub struct ControlPlaneHttpError {
174 pub(crate) status: u16,
175 pub(crate) message: String,
176 pub(crate) reason: Option<String>,
177 pub service_error: Option<crate::service_errors::ServiceError>,
178}
179
180impl ControlPlaneHttpError {
181 pub(crate) fn is_retryable(&self) -> bool {
182 if let Some(service) = &self.service_error {
183 return service.retryable;
184 }
185 self.status == 408 || self.status == 425 || self.status == 429 || self.status >= 500
186 }
187}
188
189impl fmt::Display for ControlPlaneHttpError {
190 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191 write!(
192 formatter,
193 "OpenRTC control-plane request failed ({}): {}",
194 self.status, self.message
195 )
196 }
197}
198
199impl std::error::Error for ControlPlaneHttpError {}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct IdentityAssertion {
203 pub token: String,
204 pub provider_id: Option<String>,
205}
206
207#[async_trait]
212pub trait AssertionProvider: Send + Sync {
213 async fn assertion(&self, force_refresh: bool) -> Result<IdentityAssertion>;
214
215 async fn assertion_for_device(
223 &self,
224 force_refresh: bool,
225 _device_id: &str,
226 ) -> Result<IdentityAssertion> {
227 self.assertion(force_refresh).await
228 }
229
230 async fn assertion_for_device_recovery(&self, device_id: &str) -> Result<IdentityAssertion> {
235 self.assertion_for_device(true, device_id).await
236 }
237
238 fn session_key(&self) -> Result<Option<String>> {
244 Ok(None)
245 }
246
247 fn identity_epoch(&self) -> u64 {
251 0
252 }
253
254 fn subscribe_identity_epoch(&self) -> Option<watch::Receiver<u64>> {
259 None
260 }
261}
262
263pub trait DeviceSigner: Send + Sync {
266 fn public_jwk(&self, app_tag: &str) -> Result<Value>;
267 fn sign(&self, app_tag: &str, challenge: &[u8]) -> Result<Vec<u8>>;
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct StoredCertificate {
273 pub app_tag: String,
274 pub principal_id: String,
275 pub device_id: String,
276 pub token: String,
277 pub expires_at_ms: u64,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub session_key_hash: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub signing_public_jwk: Option<Value>,
284}
285
286#[async_trait]
291pub trait CertificateStore: Send + Sync {
292 async fn load(
293 &self,
294 app_tag: &str,
295 principal_id: &str,
296 device_id: &str,
297 ) -> Result<Option<StoredCertificate>>;
298
299 async fn save(&self, certificate: &StoredCertificate) -> Result<()>;
300
301 async fn load_for_session(
306 &self,
307 _app_tag: &str,
308 _session_key_hash: &str,
309 _device_id: &str,
310 ) -> Result<Option<StoredCertificate>> {
311 Ok(None)
312 }
313
314 async fn remove(&self, app_tag: &str, principal_id: &str, device_id: &str) -> Result<()>;
315}
316
317#[derive(Default)]
320pub struct InMemoryCertificate {
321 certificate: StdMutex<Option<StoredCertificate>>,
322}
323
324#[async_trait]
325impl CertificateStore for InMemoryCertificate {
326 async fn load(
327 &self,
328 app_tag: &str,
329 principal_id: &str,
330 device_id: &str,
331 ) -> Result<Option<StoredCertificate>> {
332 Ok(self
333 .certificate
334 .lock()
335 .map_err(|_| anyhow!("native device certificate cache is poisoned"))?
336 .clone()
337 .filter(|value| {
338 value.app_tag == app_tag
339 && value.principal_id == principal_id
340 && value.device_id == device_id
341 }))
342 }
343
344 async fn save(&self, certificate: &StoredCertificate) -> Result<()> {
345 *self
346 .certificate
347 .lock()
348 .map_err(|_| anyhow!("native device certificate cache is poisoned"))? =
349 Some(certificate.clone());
350 Ok(())
351 }
352
353 async fn load_for_session(
354 &self,
355 app_tag: &str,
356 session_key_hash: &str,
357 device_id: &str,
358 ) -> Result<Option<StoredCertificate>> {
359 Ok(self
360 .certificate
361 .lock()
362 .map_err(|_| anyhow!("native device certificate cache is poisoned"))?
363 .clone()
364 .filter(|value| {
365 value.app_tag == app_tag
366 && value.device_id == device_id
367 && value.session_key_hash.as_deref() == Some(session_key_hash)
368 }))
369 }
370
371 async fn remove(&self, app_tag: &str, principal_id: &str, device_id: &str) -> Result<()> {
372 let mut guard = self
373 .certificate
374 .lock()
375 .map_err(|_| anyhow!("native device certificate cache is poisoned"))?;
376 if guard.as_ref().is_some_and(|value| {
377 value.app_tag == app_tag
378 && value.principal_id == principal_id
379 && value.device_id == device_id
380 }) {
381 *guard = None;
382 }
383 Ok(())
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
388#[serde(rename_all = "camelCase")]
389pub struct AttestationEvidence {
390 pub kind: String,
391 pub token: String,
392}
393
394#[async_trait]
397pub trait AttestationProvider: Send + Sync {
398 async fn evidence(&self, challenge: &str, api_key: &str) -> Result<AttestationEvidence>;
399}
400
401#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "camelCase")]
403pub struct Features {
404 pub iroh_relay: bool,
405 pub managed_turn: bool,
406 pub moq: bool,
407 pub ble: bool,
408 pub advanced_fanout: bool,
409 pub durable_membership: bool,
410}
411
412#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
419#[serde(rename_all = "camelCase")]
420pub struct DeviceProfile {
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub name: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
424 pub platform: Option<String>,
425}
426
427#[derive(Clone, Default)]
428pub struct DeviceOptions {
429 pub max_peers: Option<u32>,
430 pub features: Features,
431 pub attestation: Option<Arc<dyn AttestationProvider>>,
432 pub identity_relay: Option<CredentialRelay>,
433 pub device_profile: Option<DeviceProfile>,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Eq)]
437pub struct CapabilityOptions {
438 pub max_peers: Option<u32>,
439 pub features: Features,
440}
441
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "lowercase")]
444pub enum RoomArchitectureMode {
445 #[default]
446 Auto,
447 Mesh,
448 Sparse,
449 Managed,
450 Authority,
451}
452
453#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "kebab-case")]
455pub enum RoomDelivery {
456 #[default]
457 Reliable,
458 LatestState,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "lowercase")]
463pub enum EffectiveRoomArchitecture {
464 Mesh,
465 Sparse,
466 Managed,
467 Authority,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "lowercase")]
472pub enum RoomArchitecturePhase {
473 Preparing,
474 Settled,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "lowercase")]
479pub enum RoomArchitectureReason {
480 Size,
481 Traffic,
482 Latency,
483 Cost,
484 Capacity,
485 Manual,
486 Recovery,
487}
488
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491#[serde(rename_all = "camelCase")]
492pub struct RoomArchitectureSnapshot {
493 pub requested: RoomArchitectureMode,
494 pub effective: EffectiveRoomArchitecture,
495 pub epoch: u64,
496 pub phase: RoomArchitecturePhase,
497 pub reason: RoomArchitectureReason,
498 pub held_credits_usd: f64,
499 pub quote_expires_at_ms: u64,
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq)]
503pub struct RoomOptions {
504 pub max_peers: Option<u32>,
505 pub features: Features,
506 pub architecture: RoomArchitectureMode,
507 pub delivery: RoomDelivery,
508}
509
510pub struct RoomAuthorityOptions {
515 pub service_id: String,
516 pub generation: u64,
517 pub shard_ids: Vec<String>,
518 pub max_peers: Option<u32>,
520 pub features: Features,
521 pub assignment_signer: Arc<dyn DeviceSigner>,
522}
523
524pub struct RoomAuthority {
528 handle: NativeCapabilityHandle,
529 app_tag: String,
530 service_id: String,
531 generation: u64,
532 shard_ids: Vec<String>,
533 assignment_signer: Arc<dyn DeviceSigner>,
534}
535
536impl RoomAuthority {
537 pub fn subscribe_service_errors(
538 &self,
539 ) -> Result<tokio::sync::broadcast::Receiver<NativeServiceErrorObservation>> {
540 self.handle.subscribe_service_errors()
541 }
542
543 pub async fn compose_client(
549 &self,
550 builder: crate::client::ClientBuilder,
551 ) -> Result<Arc<crate::Client>> {
552 self.handle.compose_authority_client(builder).await
553 }
554
555 pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
556 self.handle.signaling()
557 }
558
559 pub fn room_architecture(&self) -> Result<Option<RoomArchitectureSnapshot>> {
560 self.handle.room_architecture()
561 }
562
563 pub async fn publish_assignment(
564 &self,
565 revision: u64,
566 subject_device_id: impl Into<String>,
567 shard_id: impl Into<String>,
568 priority_device_ids: Vec<String>,
569 relevant_entity_ids: Vec<String>,
570 ttl_ms: u64,
571 ) -> Result<NativeAuthorityInterestAssignment> {
572 if revision == 0 || ttl_ms == 0 || ttl_ms > 2 * 60_000 {
573 bail!("authority assignment revision or TTL is invalid");
574 }
575 let subject_device_id = bounded_id("subject_device_id", subject_device_id.into())?;
576 let shard_id = bounded_id("shard_id", shard_id.into())?;
577 if !self.shard_ids.contains(&shard_id) {
578 bail!("authority assignment shard is not owned by this service replica");
579 }
580 crate::native_coordination_gateway::validate_native_authority_assignment_targets(
581 &priority_device_ids,
582 &relevant_entity_ids,
583 )?;
584 let issued_at_ms = now_ms();
585 let assignment = NativeAuthorityInterestAssignment {
586 policy_version: "room-authority-assignment-v1".to_string(),
587 service_id: self.service_id.clone(),
588 generation: self.generation,
589 revision,
590 subject_device_id,
591 shard_id,
592 priority_device_ids,
593 relevant_entity_ids,
594 issued_at_ms,
595 expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
596 };
597 let payload =
598 serde_json::to_vec(&assignment).context("encode native authority assignment")?;
599 let signature = self.assignment_signer.sign(&self.app_tag, &payload)?;
600 if signature.len() != 64 {
601 bail!("native authority assignment signer returned an invalid Ed25519 signature");
602 }
603 self.handle
604 .publish_authority_assignment(
605 assignment.clone(),
606 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature),
607 )
608 .await?;
609 Ok(assignment)
610 }
611
612 pub async fn close(&self) {
613 self.handle.close().await;
614 }
615}
616
617impl From<CapabilityOptions> for RoomOptions {
618 fn from(value: CapabilityOptions) -> Self {
619 Self {
620 max_peers: value.max_peers,
621 features: value.features,
622 architecture: RoomArchitectureMode::Auto,
623 delivery: RoomDelivery::Reliable,
624 }
625 }
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629enum AnonymousKind {
630 Space,
631 Room,
632 Ticket,
633}
634
635impl AnonymousKind {
636 fn source_kind(self) -> &'static str {
637 match self {
638 Self::Space => "space",
639 Self::Room => "room",
640 Self::Ticket => "ticket",
641 }
642 }
643}
644
645#[derive(Clone, Default)]
649pub struct CredentialRelay {
650 value: Arc<StdMutex<Option<String>>>,
651}
652
653impl CredentialRelay {
654 pub fn provider(&self) -> Box<dyn Fn() -> Option<String> + Send + Sync> {
655 let state = self.value.clone();
656 Box::new(move || state.lock().ok().and_then(|value| value.clone()))
657 }
658
659 fn set(&self, value: Option<String>) -> Result<()> {
660 *self
661 .value
662 .lock()
663 .map_err(|_| anyhow!("native identity credential state is poisoned"))? = value;
664 Ok(())
665 }
666}
667
668#[derive(Default)]
673pub struct SignalingSlot {
674 backend: tokio::sync::RwLock<Option<Arc<dyn SignalingBackend>>>,
675}
676
677impl SignalingSlot {
678 pub async fn install(&self, backend: Arc<dyn SignalingBackend>) -> Result<()> {
679 let mut current = self.backend.write().await;
680 if current.is_some() {
681 bail!("native OpenRTC capability signaling is already installed");
682 }
683 *current = Some(backend);
684 Ok(())
685 }
686
687 async fn current(&self) -> Result<Arc<dyn SignalingBackend>> {
688 self.backend
689 .read()
690 .await
691 .clone()
692 .ok_or_else(|| anyhow!("native OpenRTC capability is not active"))
693 }
694}
695
696#[async_trait]
697impl SignalingBackend for SignalingSlot {
698 async fn update_presence(
699 &self,
700 user_id: &str,
701 local_node_id: &str,
702 ticket_str: &str,
703 is_online: bool,
704 name: &str,
705 ttl_ms: u64,
706 metadata: Option<&str>,
707 ) -> Result<()> {
708 self.current()
709 .await?
710 .update_presence(
711 user_id,
712 local_node_id,
713 ticket_str,
714 is_online,
715 name,
716 ttl_ms,
717 metadata,
718 )
719 .await
720 }
721
722 async fn set_offline(&self, user_id: &str, local_node_id: &str) -> Result<()> {
723 self.current()
724 .await?
725 .set_offline(user_id, local_node_id)
726 .await
727 }
728
729 async fn update_live_presence(
730 &self,
731 user_id: &str,
732 local_node_id: &str,
733 ticket_str: &str,
734 name: &str,
735 metadata: Option<&str>,
736 ) -> Result<()> {
737 self.current()
738 .await?
739 .update_live_presence(user_id, local_node_id, ticket_str, name, metadata)
740 .await
741 }
742
743 async fn set_live_presence_offline(&self, user_id: &str, local_node_id: &str) -> Result<()> {
744 self.current()
745 .await?
746 .set_live_presence_offline(user_id, local_node_id)
747 .await
748 }
749
750 async fn update_device(
751 &self,
752 user_id: &str,
753 device_id: &str,
754 device_name: Option<&str>,
755 capabilities: Option<DeviceCapabilities>,
756 metadata: Option<&str>,
757 ) -> Result<()> {
758 self.current()
759 .await?
760 .update_device(user_id, device_id, device_name, capabilities, metadata)
761 .await
762 }
763
764 async fn delete_device(&self, user_id: &str, device_id: &str) -> Result<()> {
765 self.current()
766 .await?
767 .delete_device(user_id, device_id)
768 .await
769 }
770
771 async fn set_excluded_peers(
772 &self,
773 user_id: &str,
774 local_node_id: &str,
775 excluded_peers: &[String],
776 ) -> Result<()> {
777 self.current()
778 .await?
779 .set_excluded_peers(user_id, local_node_id, excluded_peers)
780 .await
781 }
782
783 async fn search_devices(
784 &self,
785 user_id: &str,
786 exclude_node_id: Option<&str>,
787 ) -> Result<Vec<Device>> {
788 self.current()
789 .await?
790 .search_devices(user_id, exclude_node_id)
791 .await
792 }
793
794 async fn list_devices(
795 &self,
796 user_id: &str,
797 exclude_node_id: Option<&str>,
798 ) -> Result<Vec<Device>> {
799 self.current()
800 .await?
801 .list_devices(user_id, exclude_node_id)
802 .await
803 }
804
805 async fn send_message(
806 &self,
807 sender_id: &str,
808 target_id: &str,
809 payload: &str,
810 state: Option<&str>,
811 reply_payload: Option<&str>,
812 ) -> Result<String> {
813 self.current()
814 .await?
815 .send_message(sender_id, target_id, payload, state, reply_payload)
816 .await
817 }
818
819 async fn subscribe_devices(
820 &self,
821 user_id: &str,
822 ) -> Result<BoxStream<'static, Result<Vec<DeviceEvent>>>> {
823 self.current().await?.subscribe_devices(user_id).await
824 }
825
826 async fn create_session(&self, session: SignalingSession) -> Result<()> {
827 self.current().await?.create_session(session).await
828 }
829
830 async fn update_session(&self, session_id: &str, update_data: Value) -> Result<()> {
831 self.current()
832 .await?
833 .update_session(session_id, update_data)
834 .await
835 }
836
837 async fn subscribe_sessions(
838 &self,
839 local_device_id: &str,
840 ) -> Result<BoxStream<'static, Result<Vec<SessionEvent>>>> {
841 self.current()
842 .await?
843 .subscribe_sessions(local_device_id)
844 .await
845 }
846}
847
848#[derive(Debug, Clone)]
849struct CredentialSource {
850 token: String,
851 expires_at_ms: u64,
852 principal_id: String,
853 relay: RelayAvailability,
854}
855
856#[derive(Debug, Clone)]
857struct AnonymousCredentialSource {
858 token: String,
859 expires_at_ms: u64,
860 principal_id: String,
861 device_id: String,
862 requested_id: String,
863 avenue_id: String,
864 relay: RelayAvailability,
865}
866
867#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(rename_all = "camelCase")]
869pub struct RelayAvailability {
870 pub iroh_relay: bool,
871 pub managed_turn: bool,
872}
873
874fn relay_availability(
875 iroh_relay: Option<bool>,
876 managed_turn: bool,
877 legacy_relay: Option<bool>,
878) -> RelayAvailability {
879 RelayAvailability {
880 iroh_relay: iroh_relay.or(legacy_relay).unwrap_or(true),
881 managed_turn,
883 }
884}
885
886fn relay_availability_from_token(token: &str) -> Result<RelayAvailability> {
887 let claims = decode_claims(token)?;
888 Ok(relay_availability(
889 claims.get("irohRelay").and_then(Value::as_bool),
890 claims
891 .get("managedTurn")
892 .and_then(Value::as_bool)
893 .unwrap_or(false),
894 claims.get("relay").and_then(Value::as_bool),
895 ))
896}
897
898fn available_features(mut requested: Features, relay: RelayAvailability) -> Features {
899 requested.iroh_relay &= relay.iroh_relay;
900 requested.managed_turn &= relay.managed_turn;
901 requested
902}
903
904#[derive(Clone)]
905pub struct ControlPlane {
906 inner: Arc<ControlPlaneInner>,
907}
908
909struct ControlPlaneInner {
910 api_key: String,
911 app_tag: String,
912 endpoint: String,
913 gateway_endpoint: String,
914 http: reqwest::Client,
915 assertion_provider: Option<Arc<dyn AssertionProvider>>,
916 signer: Arc<dyn DeviceSigner>,
917 certificate_store: Arc<dyn CertificateStore>,
918 #[cfg(feature = "managed-group-encryption")]
919 managed_group_store:
920 StdMutex<Arc<dyn crate::native_coordination_gateway::NativeManagedGroupStateStore>>,
921 auth_epoch: Mutex<AuthEpochState>,
922}
923
924#[derive(Debug, Clone)]
925struct CachedIdentity {
926 response: AssertionExchangeResponse,
927 expires_at_ms: u64,
928 device_id: String,
929}
930
931#[derive(Debug, Default)]
932struct AuthEpochState {
933 epoch: Option<u64>,
934 identity: Option<CachedIdentity>,
935}
936
937pub struct Devices {
941 pub principal_id: String,
942 pub handle: NativeCapabilityHandle,
943 identity_relay: CredentialRelay,
944 identity_epoch_monitor: Option<tokio::task::JoinHandle<()>>,
945 relay: RelayAvailability,
946}
947
948pub struct AnonymousCapability {
952 pub principal_id: String,
953 pub handle: NativeCapabilityHandle,
954 relay: RelayAvailability,
955}
956
957impl Devices {
958 pub fn relay_availability(&self) -> RelayAvailability {
959 self.relay
960 }
961 pub fn identity_credential_provider(&self) -> Box<dyn Fn() -> Option<String> + Send + Sync> {
964 self.identity_relay.provider()
965 }
966
967 pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
970 self.handle.signaling()
971 }
972
973 pub fn media_connection(
977 &self,
978 client: Arc<crate::Client>,
979 peer_id: impl Into<String>,
980 ) -> Result<crate::media::MediaConnection> {
981 media_connection_for_capability(&self.handle, client, peer_id)
982 }
983
984 pub fn is_closed(&self) -> bool {
985 self.handle.is_closed()
986 }
987
988 pub async fn close(&self) {
989 if let Some(monitor) = &self.identity_epoch_monitor {
990 monitor.abort();
991 }
992 self.handle.close().await;
993 }
994}
995
996impl Drop for Devices {
997 fn drop(&mut self) {
998 if let Some(monitor) = &self.identity_epoch_monitor {
999 monitor.abort();
1000 }
1001 }
1002}
1003
1004impl AnonymousCapability {
1005 pub async fn compose_client(
1012 &self,
1013 builder: crate::client::ClientBuilder,
1014 ) -> Result<Arc<crate::Client>> {
1015 self.handle.compose_authority_client(builder).await
1016 }
1017
1018 pub fn relay_availability(&self) -> RelayAvailability {
1019 self.relay
1020 }
1021 pub fn signaling(&self) -> Arc<dyn SignalingBackend> {
1024 self.handle.signaling()
1025 }
1026
1027 pub fn room_architecture(&self) -> Result<Option<RoomArchitectureSnapshot>> {
1028 self.handle.room_architecture()
1029 }
1030
1031 pub fn subscribe_room_architecture(
1032 &self,
1033 ) -> Result<watch::Receiver<Option<RoomArchitectureSnapshot>>> {
1034 self.handle.subscribe_room_architecture()
1035 }
1036
1037 pub fn media_connection(
1039 &self,
1040 client: Arc<crate::Client>,
1041 peer_id: impl Into<String>,
1042 ) -> Result<crate::media::MediaConnection> {
1043 media_connection_for_capability(&self.handle, client, peer_id)
1044 }
1045
1046 pub fn is_closed(&self) -> bool {
1047 self.handle.is_closed()
1048 }
1049
1050 pub async fn close(&self) {
1051 self.handle.close().await;
1052 }
1053}
1054
1055fn media_connection_for_capability(
1056 handle: &NativeCapabilityHandle,
1057 client: Arc<crate::Client>,
1058 peer_id: impl Into<String>,
1059) -> Result<crate::media::MediaConnection> {
1060 let kind = match handle.kind() {
1061 NativeCapabilityKind::Devices => "devices",
1062 NativeCapabilityKind::Space => "space",
1063 NativeCapabilityKind::Room => "room",
1064 NativeCapabilityKind::Ticket => "ticket",
1065 };
1066 crate::media::MediaConnection::for_capability(client, peer_id, kind, handle.id())
1067}
1068
1069impl ControlPlane {
1070 pub fn new(
1071 api_key: &str,
1072 assertion_provider: Arc<dyn AssertionProvider>,
1073 signer: Arc<dyn DeviceSigner>,
1074 certificate_store: Arc<dyn CertificateStore>,
1075 ) -> Result<Self> {
1076 let api_key = crate::validate_api_key(api_key)?.to_string();
1077 let app_tag = crate::app_tag_from_api_key(&api_key);
1078 Ok(Self {
1079 inner: Arc::new(ControlPlaneInner {
1080 api_key,
1081 app_tag,
1082 endpoint: OPENRTC_PRODUCTION_CONTROL_PLANE.to_string(),
1083 gateway_endpoint:
1084 crate::native_coordination_gateway::OPENRTC_PRODUCTION_COORDINATION_GATEWAY
1085 .to_string(),
1086 http: reqwest::Client::builder()
1087 .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
1088 .build()?,
1089 assertion_provider: Some(assertion_provider),
1090 signer,
1091 certificate_store,
1092 #[cfg(feature = "managed-group-encryption")]
1093 managed_group_store: StdMutex::new(Arc::new(
1094 crate::native_coordination_gateway::InMemoryNativeManagedGroupStateStore::default(),
1095 )),
1096 auth_epoch: Mutex::new(AuthEpochState::default()),
1097 }),
1098 })
1099 }
1100
1101 pub fn anonymous(api_key: &str, signer: Arc<dyn DeviceSigner>) -> Result<Self> {
1105 let api_key = crate::validate_api_key(api_key)?.to_string();
1106 let app_tag = crate::app_tag_from_api_key(&api_key);
1107 Ok(Self {
1108 inner: Arc::new(ControlPlaneInner {
1109 api_key,
1110 app_tag,
1111 endpoint: OPENRTC_PRODUCTION_CONTROL_PLANE.to_string(),
1112 gateway_endpoint:
1113 crate::native_coordination_gateway::OPENRTC_PRODUCTION_COORDINATION_GATEWAY
1114 .to_string(),
1115 http: reqwest::Client::builder()
1116 .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
1117 .build()?,
1118 assertion_provider: None,
1119 signer,
1120 certificate_store: Arc::new(InMemoryCertificate::default()),
1121 #[cfg(feature = "managed-group-encryption")]
1122 managed_group_store: StdMutex::new(Arc::new(
1123 crate::native_coordination_gateway::InMemoryNativeManagedGroupStateStore::default(),
1124 )),
1125 auth_epoch: Mutex::new(AuthEpochState::default()),
1126 }),
1127 })
1128 }
1129
1130 pub fn app_tag(&self) -> &str {
1131 &self.inner.app_tag
1132 }
1133
1134 #[cfg(feature = "managed-group-encryption")]
1137 pub fn set_managed_group_state_store(
1138 &self,
1139 store: Arc<dyn crate::native_coordination_gateway::NativeManagedGroupStateStore>,
1140 ) -> Result<()> {
1141 *self
1142 .inner
1143 .managed_group_store
1144 .lock()
1145 .map_err(|_| anyhow!("native managed group state store lock is poisoned"))? = store;
1146 Ok(())
1147 }
1148
1149 pub fn with_testing_endpoints(
1150 self,
1151 control_plane: impl Into<String>,
1152 gateway: impl Into<String>,
1153 ) -> Result<Self> {
1154 #[cfg(any(test, feature = "testing-endpoints"))]
1155 {
1156 let mut control = self;
1157 let control_plane = validate_endpoint(control_plane.into())?;
1158 let gateway = validate_endpoint(gateway.into())?;
1159 Arc::get_mut(&mut control.inner)
1160 .ok_or_else(|| anyhow!("testing endpoints must be set before cloning the client"))?
1161 .endpoint = control_plane;
1162 Arc::get_mut(&mut control.inner)
1163 .ok_or_else(|| anyhow!("testing endpoints must be set before cloning the client"))?
1164 .gateway_endpoint = gateway;
1165 Ok(control)
1166 }
1167 #[cfg(not(any(test, feature = "testing-endpoints")))]
1168 {
1169 let _ = (control_plane.into(), gateway.into());
1170 Err(anyhow!(
1171 "testing endpoints require the OpenRTC testing-endpoints feature"
1172 ))
1173 }
1174 }
1175
1176 pub async fn devices(
1177 &self,
1178 device_id: impl Into<String>,
1179 platform_type: impl Into<String>,
1180 options: DeviceOptions,
1181 ) -> Result<Devices> {
1182 let device_id = bounded_id("device_id", device_id.into())?;
1183 let platform_type = bounded_id("platform_type", platform_type.into())?;
1184 let device_profile = normalized_device_profile(options.device_profile, &platform_type)?;
1185 let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1186 anyhow!("authenticated devices require a native identity assertion provider")
1187 })?;
1188 let mut epoch_receiver = assertion_provider.subscribe_identity_epoch();
1189 let identity_epoch = epoch_receiver
1190 .as_ref()
1191 .map(|receiver| *receiver.borrow())
1192 .unwrap_or_else(|| assertion_provider.identity_epoch());
1193 let source = self
1194 .device_certificate(
1195 &device_id,
1196 device_profile.as_ref(),
1197 options.attestation.as_deref(),
1198 identity_epoch,
1199 )
1200 .await?;
1201 let principal_id = source.principal_id.clone();
1202 let relay = source.relay;
1203 let identity_relay = options.identity_relay.clone().unwrap_or_default();
1204 identity_relay.set(Some(source.token.clone()))?;
1205 let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1206 Arc::new(ControlPlaneGrantProvider {
1207 control_plane: self.clone(),
1208 device_id: device_id.clone(),
1209 source: Mutex::new(source),
1210 max_peers: options.max_peers,
1211 features: options.features,
1212 device_profile,
1213 attestation: options.attestation,
1214 identity_relay: identity_relay.clone(),
1215 identity_epoch,
1216 });
1217 let capabilities = NativeCapabilities::new(
1218 &self.inner.api_key,
1219 device_id,
1220 platform_type,
1221 grant_provider,
1222 )?;
1223 #[cfg(feature = "managed-group-encryption")]
1224 let capabilities = capabilities.with_managed_group_storage(
1225 self.inner.signer.clone(),
1226 self.inner
1227 .managed_group_store
1228 .lock()
1229 .map_err(|_| anyhow!("native managed group state store lock is poisoned"))?
1230 .clone(),
1231 );
1232 #[cfg(any(test, feature = "testing-endpoints"))]
1233 let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1234 let handle = capabilities.devices(principal_id.clone())?;
1235 let identity_epoch_monitor = epoch_receiver.take().map(|receiver| {
1236 spawn_identity_epoch_monitor(
1237 receiver,
1238 identity_epoch,
1239 handle.closer(),
1240 identity_relay.clone(),
1241 )
1242 });
1243 Ok(Devices {
1244 handle,
1245 principal_id,
1246 identity_relay,
1247 identity_epoch_monitor,
1248 relay,
1249 })
1250 }
1251
1252 pub async fn join_space(
1253 &self,
1254 id: impl Into<String>,
1255 platform_type: impl Into<String>,
1256 options: CapabilityOptions,
1257 ) -> Result<AnonymousCapability> {
1258 self.anonymous_capability(
1259 AnonymousKind::Space,
1260 id.into(),
1261 platform_type.into(),
1262 options,
1263 None,
1264 None,
1265 )
1266 .await
1267 }
1268
1269 pub async fn join_room(
1270 &self,
1271 id: impl Into<String>,
1272 platform_type: impl Into<String>,
1273 options: impl Into<RoomOptions>,
1274 ) -> Result<AnonymousCapability> {
1275 let options = options.into();
1276 self.anonymous_capability(
1277 AnonymousKind::Room,
1278 id.into(),
1279 platform_type.into(),
1280 CapabilityOptions {
1281 max_peers: options.max_peers,
1282 features: options.features,
1283 },
1284 Some(options.architecture),
1285 Some(options.delivery),
1286 )
1287 .await
1288 }
1289
1290 pub async fn join_authority_room(
1295 &self,
1296 secret_key: impl Into<String>,
1297 room_id: impl Into<String>,
1298 device_id: impl Into<String>,
1299 platform_type: impl Into<String>,
1300 options: RoomAuthorityOptions,
1301 ) -> Result<RoomAuthority> {
1302 if options
1303 .max_peers
1304 .is_some_and(|count| !(1..=50).contains(&count))
1305 {
1306 bail!("authority service maxPeers must be within 1..50 room members");
1307 }
1308 let secret_key = required("secret_key", secret_key.into())?;
1309 if !secret_key.starts_with("sk_") || secret_key.len() > 128 {
1310 bail!("authority service secret key is invalid");
1311 }
1312 let room_id = bounded_id("room_id", room_id.into())?;
1313 let device_id = bounded_id("device_id", device_id.into())?;
1314 let platform_type = bounded_id("platform_type", platform_type.into())?;
1315 let service_id = bounded_id("service_id", options.service_id)?;
1316 if service_id.len() > 80 || options.generation == 0 {
1317 bail!("authority service identity or generation is invalid");
1318 }
1319 if options.shard_ids.is_empty() || options.shard_ids.len() > 64 {
1320 bail!("authority service must own from 1 to 64 shards");
1321 }
1322 let mut shard_ids = options
1323 .shard_ids
1324 .into_iter()
1325 .map(|value| bounded_id("shard_id", value))
1326 .collect::<Result<Vec<_>>>()?;
1327 let shard_count = shard_ids.len();
1328 if shard_ids.iter().any(|value| value.len() > 80) {
1329 bail!("authority service shard identifier is invalid");
1330 }
1331 shard_ids.sort();
1332 shard_ids.dedup();
1333 if shard_ids.len() != shard_count {
1334 bail!("authority service shard identifiers must be unique");
1335 }
1336 let assignment_public_jwk = options.assignment_signer.public_jwk(&self.inner.app_tag)?;
1337 validate_public_jwk(&assignment_public_jwk)?;
1338 let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1339 Arc::new(AuthorityServiceGrantProvider {
1340 control_plane: self.clone(),
1341 secret_key,
1342 service_id: service_id.clone(),
1343 generation: options.generation,
1344 shard_ids: shard_ids.clone(),
1345 assignment_public_jwk,
1346 device_id: device_id.clone(),
1347 max_peers: options.max_peers,
1348 features: options.features,
1349 });
1350 let capabilities = NativeCapabilities::new(
1351 &self.inner.api_key,
1352 device_id,
1353 platform_type,
1354 grant_provider,
1355 )?;
1356 #[cfg(any(test, feature = "testing-endpoints"))]
1357 let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1358 let handle = capabilities.join_room_with_options(
1359 room_id,
1360 RoomArchitectureMode::Authority,
1361 RoomDelivery::Reliable,
1362 )?;
1363 Ok(RoomAuthority {
1364 handle,
1365 app_tag: self.inner.app_tag.clone(),
1366 service_id,
1367 generation: options.generation,
1368 shard_ids,
1369 assignment_signer: options.assignment_signer,
1370 })
1371 }
1372
1373 pub async fn issue_ticket(
1374 &self,
1375 id: impl Into<String>,
1376 platform_type: impl Into<String>,
1377 options: CapabilityOptions,
1378 ) -> Result<AnonymousCapability> {
1379 self.anonymous_capability(
1380 AnonymousKind::Ticket,
1381 id.into(),
1382 platform_type.into(),
1383 options,
1384 None,
1385 None,
1386 )
1387 .await
1388 }
1389
1390 async fn anonymous_capability(
1391 &self,
1392 kind: AnonymousKind,
1393 id: String,
1394 platform_type: String,
1395 options: CapabilityOptions,
1396 architecture: Option<RoomArchitectureMode>,
1397 room_delivery: Option<RoomDelivery>,
1398 ) -> Result<AnonymousCapability> {
1399 let platform_type = bounded_id("platform_type", platform_type)?;
1400 let requested_id = bounded_id("capability_id", id)?;
1401 let source = self
1402 .anonymous_source(
1403 kind,
1404 requested_id,
1405 options.max_peers,
1406 architecture,
1407 room_delivery,
1408 )
1409 .await?;
1410 let principal_id = source.principal_id.clone();
1411 let device_id = source.device_id.clone();
1412 let avenue_id = source.avenue_id.clone();
1413 let relay = source.relay;
1414 let grant_provider: Arc<dyn NativeGatewayGrantProvider> =
1415 Arc::new(AnonymousControlPlaneGrantProvider {
1416 control_plane: self.clone(),
1417 source: Mutex::new(source),
1418 kind,
1419 max_peers: options.max_peers,
1420 features: options.features,
1421 architecture,
1422 room_delivery,
1423 });
1424 let capabilities = NativeCapabilities::new(
1425 &self.inner.api_key,
1426 device_id,
1427 platform_type,
1428 grant_provider,
1429 )?;
1430 #[cfg(feature = "managed-group-encryption")]
1431 let capabilities = capabilities.with_managed_group_storage(
1432 self.inner.signer.clone(),
1433 self.inner
1434 .managed_group_store
1435 .lock()
1436 .map_err(|_| anyhow!("native managed group state store lock is poisoned"))?
1437 .clone(),
1438 );
1439 #[cfg(any(test, feature = "testing-endpoints"))]
1440 let capabilities = capabilities.with_testing_endpoint(&self.inner.gateway_endpoint)?;
1441 let handle = match kind {
1442 AnonymousKind::Space => capabilities.join_space(avenue_id)?,
1443 AnonymousKind::Room => capabilities.join_room_with_options(
1444 avenue_id,
1445 architecture.unwrap_or_default(),
1446 room_delivery.unwrap_or_default(),
1447 )?,
1448 AnonymousKind::Ticket => capabilities.issue_ticket(avenue_id)?,
1449 };
1450 Ok(AnonymousCapability {
1451 principal_id,
1452 handle,
1453 relay,
1454 })
1455 }
1456
1457 async fn anonymous_source(
1458 &self,
1459 kind: AnonymousKind,
1460 requested_id: String,
1461 max_peers: Option<u32>,
1462 architecture: Option<RoomArchitectureMode>,
1463 room_delivery: Option<RoomDelivery>,
1464 ) -> Result<AnonymousCredentialSource> {
1465 let original_requested_id = requested_id.clone();
1466 let avenue_id = native_capability_avenue_id(&self.inner.api_key, kind, &requested_id);
1467 let proof = self.device_proof(|nonce, issued_at| {
1468 format!(
1469 "openrtc:v2:capability:{}:{}:{}:{}:{}",
1470 self.inner.api_key,
1471 kind.source_kind(),
1472 avenue_id,
1473 nonce,
1474 issued_at
1475 )
1476 })?;
1477 let mut body = json!({
1478 "avenue": { "kind": kind.source_kind(), "id": avenue_id },
1479 "deviceProof": proof,
1480 });
1481 if let Some(max_peers) = max_peers {
1482 body["maxPeers"] = json!(max_peers);
1483 }
1484 if let Some(architecture) = architecture {
1485 body["architecture"] = json!(architecture);
1486 }
1487 if let Some(room_delivery) = room_delivery {
1488 body["roomDelivery"] = json!(room_delivery);
1489 }
1490 let response: CapabilityResponse = self.post("/v2/capabilities", body).await?;
1491 let token = required("capability", response.capability)?;
1492 let principal_id = required_claim_string(&token, "principalId")?;
1493 let thumbprint = required_claim_string(&token, "deviceKeyThumbprint")?;
1494 let claim_exp = decode_claims(&token)?
1495 .get("exp")
1496 .and_then(Value::as_u64)
1497 .ok_or_else(|| anyhow!("OpenRTC capability is missing exp"))?;
1498 if claim_exp != response.expires_at
1499 || response.avenue.kind != kind.source_kind()
1500 || response.avenue.id != avenue_id
1501 {
1502 bail!("OpenRTC capability response scope is inconsistent");
1503 }
1504 Ok(AnonymousCredentialSource {
1505 token,
1506 expires_at_ms: response.expires_at.saturating_mul(1_000),
1507 principal_id,
1508 device_id: format!("anon_{}", thumbprint.chars().take(32).collect::<String>()),
1509 requested_id: original_requested_id,
1510 avenue_id,
1511 relay: relay_availability(response.iroh_relay, response.managed_turn, response.relay),
1512 })
1513 }
1514
1515 async fn device_certificate(
1516 &self,
1517 device_id: &str,
1518 device_profile: Option<&DeviceProfile>,
1519 attestation: Option<&dyn AttestationProvider>,
1520 identity_epoch: u64,
1521 ) -> Result<CredentialSource> {
1522 let device_key_thumbprint =
1523 native_device_key_thumbprint(&self.inner.signer.public_jwk(&self.inner.app_tag)?)?;
1524 let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1525 anyhow!("authenticated devices require a native identity assertion provider")
1526 })?;
1527 let session_key_hash = assertion_provider
1528 .session_key()?
1529 .filter(|value| !value.trim().is_empty())
1530 .map(|value| {
1531 native_session_key_hash(&self.inner.app_tag, &device_key_thumbprint, value.trim())
1532 });
1533 if let Some(session_key_hash) = session_key_hash.as_deref() {
1534 if let Some(cached) = self
1535 .inner
1536 .certificate_store
1537 .load_for_session(&self.inner.app_tag, session_key_hash, device_id)
1538 .await?
1539 .filter(|value| certificate_is_reusable(value, &device_key_thumbprint, now_ms()))
1540 {
1541 return Ok(CredentialSource {
1542 relay: relay_availability_from_token(&cached.token)?,
1543 token: cached.token,
1544 expires_at_ms: cached.expires_at_ms,
1545 principal_id: cached.principal_id,
1546 });
1547 }
1548 }
1549
1550 let identity = self
1551 .identity_for_epoch(identity_epoch, device_id, false)
1552 .await?;
1553 let principal_id = bounded_id("principal_id", identity.response.principal_id.clone())?;
1554
1555 if let Some(cached) = self
1556 .inner
1557 .certificate_store
1558 .load(&self.inner.app_tag, &principal_id, device_id)
1559 .await?
1560 .filter(|value| certificate_is_reusable(value, &device_key_thumbprint, now_ms()))
1561 {
1562 return Ok(CredentialSource {
1563 relay: relay_availability_from_token(&cached.token)?,
1564 token: cached.token,
1565 expires_at_ms: cached.expires_at_ms,
1566 principal_id,
1567 });
1568 }
1569
1570 let identity = if identity.expires_at_ms > now_ms().saturating_add(30_000) {
1575 identity
1576 } else {
1577 self.identity_for_epoch(identity_epoch, device_id, true)
1578 .await?
1579 };
1580 if identity.response.principal_id != principal_id {
1581 bail!("native identity principal changed without advancing its login epoch");
1582 }
1583
1584 let enrollment = match self
1585 .enroll_device(
1586 &identity.response.identity_session,
1587 &principal_id,
1588 device_id,
1589 device_profile,
1590 attestation,
1591 false,
1592 )
1593 .await
1594 {
1595 Ok(enrollment) => enrollment,
1596 Err(error) if is_native_device_key_recovery_required(&error) => {
1597 let recovery_identity = self.exchange_recovery_identity(device_id).await?;
1598 if recovery_identity.principal_id != principal_id {
1599 bail!("native identity principal changed during device-key recovery");
1600 }
1601 self.enroll_device(
1602 &recovery_identity.identity_session,
1603 &principal_id,
1604 device_id,
1605 device_profile,
1606 attestation,
1607 true,
1608 )
1609 .await
1610 .context("recover native OpenRTC device key")?
1611 }
1612 Err(error) => return Err(error).context("enroll native OpenRTC device"),
1613 };
1614 let expires_at_ms = enrollment
1615 .expires_at
1616 .checked_mul(1_000)
1617 .ok_or_else(|| anyhow!("device certificate expiry overflow"))?;
1618 let stored = StoredCertificate {
1619 app_tag: self.inner.app_tag.clone(),
1620 principal_id: principal_id.clone(),
1621 device_id: device_id.to_string(),
1622 token: required("device certificate", enrollment.device_certificate)?,
1623 expires_at_ms,
1624 session_key_hash,
1625 signing_public_jwk: Some(enrollment.signing_public_jwk),
1626 };
1627 validate_stored_certificate(&stored, &device_key_thumbprint)?;
1628 self.inner.certificate_store.save(&stored).await?;
1629 Ok(CredentialSource {
1630 relay: relay_availability_from_token(&stored.token)?,
1631 token: stored.token,
1632 expires_at_ms,
1633 principal_id,
1634 })
1635 }
1636
1637 async fn enroll_device(
1638 &self,
1639 identity_session: &str,
1640 principal_id: &str,
1641 device_id: &str,
1642 device_profile: Option<&DeviceProfile>,
1643 attestation: Option<&dyn AttestationProvider>,
1644 recover_device_key: bool,
1645 ) -> Result<DeviceEnrollmentResponse> {
1646 let proof = self.device_proof(|nonce, issued_at| {
1647 format!(
1648 "openrtc:v2:device-enroll:{}:{}:{}:{}:{}",
1649 self.inner.app_tag, principal_id, device_id, nonce, issued_at
1650 )
1651 })?;
1652 let attestation_value = if let Some(provider) = attestation {
1653 let challenge = format!(
1654 "openrtc:v2:attestation:{}:{}:{}:{}",
1655 self.inner.app_tag, principal_id, device_id, proof.nonce
1656 );
1657 Some(provider.evidence(&challenge, &self.inner.api_key).await?)
1658 } else {
1659 None
1660 };
1661 let mut body = json!({
1662 "identitySession": identity_session,
1663 "deviceId": device_id,
1664 "deviceProof": proof,
1665 });
1666 if let Some(device_profile) = device_profile {
1667 body["deviceProfile"] = json!(device_profile);
1668 }
1669 if let Some(attestation) = attestation_value {
1670 body["attestation"] = json!(attestation);
1671 }
1672 if recover_device_key {
1673 body["recoverDeviceKey"] = json!(true);
1674 }
1675 self.post("/v2/devices/enroll", body).await
1676 }
1677
1678 async fn exchange_recovery_identity(
1679 &self,
1680 device_id: &str,
1681 ) -> Result<AssertionExchangeResponse> {
1682 let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1683 anyhow!("authenticated devices require a native identity assertion provider")
1684 })?;
1685 let assertion = assertion_provider
1686 .assertion_for_device_recovery(device_id)
1687 .await
1688 .context("obtain consumer device-key recovery assertion")?;
1689 let assertion_token = required("identity assertion", assertion.token)?;
1690 let mut body = json!({ "assertion": assertion_token });
1691 if let Some(provider_id) = assertion.provider_id {
1692 body["providerId"] = json!(provider_id);
1693 }
1694 self.post("/v2/assertions/exchange", body)
1695 .await
1696 .context("exchange consumer device-key recovery assertion")
1697 }
1698
1699 async fn exchange_identity_after_rejection(
1700 &self,
1701 force_assertion_refresh: bool,
1702 device_id: &str,
1703 ) -> Result<AssertionExchangeResponse> {
1704 Ok(
1705 match self
1706 .exchange_identity(force_assertion_refresh, device_id)
1707 .await
1708 {
1709 Ok(identity) => identity,
1710 Err(error)
1711 if !force_assertion_refresh
1712 && error
1713 .downcast_ref::<ControlPlaneHttpError>()
1714 .is_some_and(|failure| failure.status == 401) =>
1715 {
1716 self.exchange_identity(true, device_id).await?
1721 }
1722 Err(error) => return Err(error),
1723 },
1724 )
1725 }
1726
1727 async fn identity_for_epoch(
1728 &self,
1729 identity_epoch: u64,
1730 device_id: &str,
1731 require_unexpired_session: bool,
1732 ) -> Result<CachedIdentity> {
1733 let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1734 anyhow!("authenticated devices require a native identity assertion provider")
1735 })?;
1736 if assertion_provider.identity_epoch() != identity_epoch {
1737 bail!("native identity changed before OpenRTC capability activation");
1738 }
1739 let mut state = self.inner.auth_epoch.lock().await;
1740 if state.epoch != Some(identity_epoch) {
1741 state.epoch = Some(identity_epoch);
1742 state.identity = None;
1743 }
1744 if let Some(identity) = state
1745 .identity
1746 .clone()
1747 .filter(|identity| identity.device_id == device_id)
1748 {
1749 if !require_unexpired_session
1750 || identity.expires_at_ms > now_ms().saturating_add(30_000)
1751 {
1752 if assertion_provider.identity_epoch() != identity_epoch {
1753 state.identity = None;
1754 bail!("native identity changed during capability activation");
1755 }
1756 return Ok(identity);
1757 }
1758 }
1759 let response = self
1760 .exchange_identity_after_rejection(false, device_id)
1761 .await?;
1762 if assertion_provider.identity_epoch() != identity_epoch {
1763 state.identity = None;
1764 bail!("native identity changed during assertion exchange");
1765 }
1766 let expires_at_ms = response
1767 .expires_at
1768 .checked_mul(1_000)
1769 .ok_or_else(|| anyhow!("identity session expiry overflow"))?;
1770 let identity = CachedIdentity {
1771 response,
1772 expires_at_ms,
1773 device_id: device_id.to_string(),
1774 };
1775 state.identity = Some(identity.clone());
1776 Ok(identity)
1777 }
1778
1779 async fn exchange_identity(
1780 &self,
1781 force_refresh: bool,
1782 device_id: &str,
1783 ) -> Result<AssertionExchangeResponse> {
1784 let assertion_provider = self.inner.assertion_provider.as_ref().ok_or_else(|| {
1785 anyhow!("authenticated devices require a native identity assertion provider")
1786 })?;
1787 let assertion = assertion_provider
1788 .assertion_for_device(force_refresh, device_id)
1789 .await
1790 .context("obtain consumer identity assertion")?;
1791 let assertion_token = required("identity assertion", assertion.token)?;
1792 let mut assertion_body = json!({ "assertion": assertion_token });
1793 if let Some(provider_id) = assertion.provider_id {
1794 assertion_body["providerId"] = json!(provider_id);
1795 }
1796 self.post("/v2/assertions/exchange", assertion_body)
1797 .await
1798 .context("exchange consumer identity assertion")
1799 }
1800
1801 fn device_proof(
1802 &self,
1803 challenge: impl FnOnce(&str, u64) -> String,
1804 ) -> Result<NativeDeviceProof> {
1805 let public_key_jwk = self.inner.signer.public_jwk(&self.inner.app_tag)?;
1806 validate_public_jwk(&public_key_jwk)?;
1807 let nonce = Uuid::new_v4().simple().to_string();
1808 let issued_at = now_seconds();
1809 let challenge = challenge(&nonce, issued_at);
1810 let signature = self
1811 .inner
1812 .signer
1813 .sign(&self.inner.app_tag, challenge.as_bytes())?;
1814 if signature.len() != 64 {
1815 bail!("native device signer returned an invalid Ed25519 signature");
1816 }
1817 Ok(NativeDeviceProof {
1818 public_key_jwk,
1819 signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature),
1820 nonce,
1821 issued_at,
1822 })
1823 }
1824
1825 async fn post<T: for<'de> Deserialize<'de>>(&self, path: &str, body: Value) -> Result<T> {
1826 let mut object = body
1827 .as_object()
1828 .cloned()
1829 .ok_or_else(|| anyhow!("native control-plane body must be an object"))?;
1830 object.insert("apiKey".to_string(), json!(self.inner.api_key));
1831 self.post_json(path, Value::Object(object), None).await
1832 }
1833
1834 async fn post_with_bearer<T: for<'de> Deserialize<'de>>(
1835 &self,
1836 path: &str,
1837 body: Value,
1838 bearer: &str,
1839 ) -> Result<T> {
1840 if !bearer.starts_with("sk_") || bearer.len() > 128 {
1841 bail!("native authority service secret is invalid");
1842 }
1843 self.post_json(path, body, Some(bearer)).await
1844 }
1845
1846 async fn post_json<T: for<'de> Deserialize<'de>>(
1847 &self,
1848 path: &str,
1849 body: Value,
1850 bearer: Option<&str>,
1851 ) -> Result<T> {
1852 let url = format!("{}{}", self.inner.endpoint.trim_end_matches('/'), path);
1853 let request_id = format!("native_{}", Uuid::new_v4().simple());
1854 let serialized = serde_json::to_vec(&body).context("encode OpenRTC request")?;
1855
1856 for attempt in 0..2 {
1857 let mut request = self
1858 .inner
1859 .http
1860 .post(&url)
1861 .header("X-OpenRTC-Idempotency-Key", &request_id)
1862 .header(reqwest::header::CONTENT_TYPE, "application/json")
1863 .body(serialized.clone());
1864 if let Some(bearer) = bearer {
1865 request = request.bearer_auth(bearer);
1866 }
1867 let response = request.send().await;
1868 let response = match response {
1869 Ok(response) => response,
1870 Err(_error) if attempt == 0 => {
1871 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1872 continue;
1873 }
1874 Err(error) => return Err(error).context("send OpenRTC request"),
1875 };
1876 let status = response.status();
1877 if !status.is_success() {
1878 let payload = response.json::<Value>().await.unwrap_or(Value::Null);
1879 let message = payload
1880 .get("error")
1881 .and_then(Value::as_str)
1882 .unwrap_or("OpenRTC control-plane request failed")
1883 .chars()
1884 .take(256)
1885 .collect::<String>();
1886 let error = ControlPlaneHttpError {
1887 service_error: crate::service_errors::ServiceError::from_value(&payload),
1888 status: status.as_u16(),
1889 message,
1890 reason: payload
1891 .get("reason")
1892 .and_then(Value::as_str)
1893 .filter(|value| {
1894 !value.is_empty()
1895 && value.len() <= 80
1896 && value.bytes().all(|byte| {
1897 byte.is_ascii_lowercase()
1898 || byte.is_ascii_digit()
1899 || byte == b'-'
1900 })
1901 })
1902 .map(str::to_string),
1903 };
1904 if attempt == 0
1908 && error.service_error.is_none()
1909 && matches!(
1910 status,
1911 reqwest::StatusCode::BAD_GATEWAY
1912 | reqwest::StatusCode::SERVICE_UNAVAILABLE
1913 | reqwest::StatusCode::GATEWAY_TIMEOUT
1914 )
1915 {
1916 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1917 continue;
1918 }
1919 return Err(anyhow::Error::new(error));
1920 }
1921 return response
1922 .json::<T>()
1923 .await
1924 .context("decode OpenRTC response");
1925 }
1926 unreachable!("native control-plane retry loop has two terminal attempts")
1927 }
1928}
1929
1930fn is_native_device_key_recovery_required(error: &anyhow::Error) -> bool {
1931 error
1932 .downcast_ref::<ControlPlaneHttpError>()
1933 .is_some_and(|failure| {
1934 failure.status == 412
1935 && failure.reason.as_deref() == Some("device-key-recovery-required")
1936 })
1937}
1938
1939fn spawn_identity_epoch_monitor(
1940 mut receiver: watch::Receiver<u64>,
1941 identity_epoch: u64,
1942 closer: crate::native_coordination_gateway::NativeCapabilityCloser,
1943 relay: CredentialRelay,
1944) -> tokio::task::JoinHandle<()> {
1945 tokio::spawn(async move {
1946 loop {
1947 if receiver.changed().await.is_err() {
1948 return;
1949 }
1950 if *receiver.borrow_and_update() != identity_epoch {
1951 let _ = relay.set(None);
1952 closer.close().await;
1953 return;
1954 }
1955 }
1956 })
1957}
1958
1959struct ControlPlaneGrantProvider {
1960 control_plane: ControlPlane,
1961 device_id: String,
1962 source: Mutex<CredentialSource>,
1963 max_peers: Option<u32>,
1964 features: Features,
1965 device_profile: Option<DeviceProfile>,
1966 attestation: Option<Arc<dyn AttestationProvider>>,
1967 identity_relay: CredentialRelay,
1968 identity_epoch: u64,
1969}
1970
1971struct AnonymousControlPlaneGrantProvider {
1972 control_plane: ControlPlane,
1973 source: Mutex<AnonymousCredentialSource>,
1974 kind: AnonymousKind,
1975 max_peers: Option<u32>,
1976 features: Features,
1977 architecture: Option<RoomArchitectureMode>,
1978 room_delivery: Option<RoomDelivery>,
1979}
1980
1981struct AuthorityServiceGrantProvider {
1982 control_plane: ControlPlane,
1983 secret_key: String,
1984 service_id: String,
1985 generation: u64,
1986 shard_ids: Vec<String>,
1987 assignment_public_jwk: Value,
1988 device_id: String,
1989 max_peers: Option<u32>,
1990 features: Features,
1991}
1992
1993#[async_trait]
1994impl NativeGatewayGrantProvider for ControlPlaneGrantProvider {
1995 async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
1996 if request.device_id != self.device_id {
1997 bail!("gateway grant request device does not match enrolled device");
1998 }
1999 let mut source = self.source.lock().await;
2000 if source.expires_at_ms <= now_ms().saturating_add(SOURCE_RENEW_SKEW_MS) {
2001 *source = self
2002 .control_plane
2003 .device_certificate(
2004 &self.device_id,
2005 self.device_profile.as_ref(),
2006 self.attestation.as_deref(),
2007 self.identity_epoch,
2008 )
2009 .await?;
2010 self.identity_relay.set(Some(source.token.clone()))?;
2011 }
2012 let source_jti = required_claim_string(&source.token, "jti")?;
2013 let proof = self.control_plane.device_proof(|nonce, issued_at| {
2014 format!(
2015 "openrtc:v2:gateway-grant:{}:{}:{}:{}:{}:{}",
2016 source_jti,
2017 request.avenue.kind,
2018 request.avenue.id,
2019 request.runtime_instance_id,
2020 nonce,
2021 issued_at
2022 )
2023 })?;
2024 let mut grant_body = json!({
2025 "credentialType": "device-certificate",
2026 "credential": source.token,
2027 "avenue": request.avenue,
2028 "runtimeInstanceId": request.runtime_instance_id,
2029 "ticketFingerprint": request.ticket_fingerprint,
2030 "deviceProof": proof,
2031 "features": available_features(self.features.clone(), source.relay),
2032 });
2033 if let Some(refresh_grant) = request.refresh_grant {
2034 grant_body["refreshGrant"] = json!(refresh_grant);
2035 }
2036 if let Some(max_peers) = self.max_peers {
2037 grant_body["maxPeers"] = json!(max_peers);
2038 }
2039 if let Some(architecture) = request.architecture {
2040 grant_body["architecture"] = json!(architecture);
2041 }
2042 if let Some(room_delivery) = request.room_delivery {
2043 grant_body["roomDelivery"] = json!(room_delivery);
2044 }
2045 if let Some(device_profile) = &self.device_profile {
2046 grant_body["deviceProfile"] = json!(device_profile);
2047 }
2048 let response: GatewayGrantResponse = self
2049 .control_plane
2050 .post("/v2/gateway/grants", grant_body)
2051 .await?;
2052 if response.gateway_url.trim_end_matches('/')
2053 != self
2054 .control_plane
2055 .inner
2056 .gateway_endpoint
2057 .trim_end_matches('/')
2058 {
2059 bail!("OpenRTC returned an unexpected native gateway origin");
2060 }
2061 Ok(NativeGatewayGrant {
2062 protocol_version: response.protocol_version,
2063 gateway_url: response.gateway_url,
2064 route_key: response.route_key,
2065 token: response.token,
2066 expires_at_ms: response.expires_at_ms,
2067 })
2068 }
2069}
2070
2071#[async_trait]
2072impl NativeGatewayGrantProvider for AnonymousControlPlaneGrantProvider {
2073 async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2074 let mut source = self.source.lock().await;
2075 if request.device_id != source.device_id {
2076 bail!("gateway grant request device does not match the capability install key");
2077 }
2078 if source.expires_at_ms <= now_ms().saturating_add(SOURCE_RENEW_SKEW_MS) {
2079 *source = self
2080 .control_plane
2081 .anonymous_source(
2082 self.kind,
2083 source.requested_id.clone(),
2084 self.max_peers,
2085 self.architecture,
2086 self.room_delivery,
2087 )
2088 .await?;
2089 }
2090 let expected_kind = match self.kind {
2091 AnonymousKind::Ticket => "session",
2092 _ => self.kind.source_kind(),
2093 };
2094 if request.avenue.kind != expected_kind || request.avenue.id != source.avenue_id {
2095 bail!("gateway grant request avenue does not match the capability");
2096 }
2097 let source_jti = required_claim_string(&source.token, "jti")?;
2098 let proof = self.control_plane.device_proof(|nonce, issued_at| {
2099 format!(
2100 "openrtc:v2:gateway-grant:{}:{}:{}:{}:{}:{}",
2101 source_jti,
2102 request.avenue.kind,
2103 request.avenue.id,
2104 request.runtime_instance_id,
2105 nonce,
2106 issued_at
2107 )
2108 })?;
2109 let mut grant_body = json!({
2110 "credentialType": "capability",
2111 "credential": source.token,
2112 "avenue": request.avenue,
2113 "runtimeInstanceId": request.runtime_instance_id,
2114 "ticketFingerprint": request.ticket_fingerprint,
2115 "deviceProof": proof,
2116 "features": available_features(self.features.clone(), source.relay),
2117 });
2118 if let Some(refresh_grant) = request.refresh_grant {
2119 grant_body["refreshGrant"] = json!(refresh_grant);
2120 }
2121 if let Some(max_peers) = self.max_peers {
2122 grant_body["maxPeers"] = json!(max_peers);
2123 }
2124 if let Some(architecture) = request.architecture {
2125 grant_body["architecture"] = json!(architecture);
2126 }
2127 if let Some(room_delivery) = request.room_delivery {
2128 grant_body["roomDelivery"] = json!(room_delivery);
2129 }
2130 let response: GatewayGrantResponse = self
2131 .control_plane
2132 .post("/v2/gateway/grants", grant_body)
2133 .await?;
2134 if response.gateway_url.trim_end_matches('/')
2135 != self
2136 .control_plane
2137 .inner
2138 .gateway_endpoint
2139 .trim_end_matches('/')
2140 {
2141 bail!("OpenRTC returned an unexpected native gateway origin");
2142 }
2143 Ok(NativeGatewayGrant {
2144 protocol_version: response.protocol_version,
2145 gateway_url: response.gateway_url,
2146 route_key: response.route_key,
2147 token: response.token,
2148 expires_at_ms: response.expires_at_ms,
2149 })
2150 }
2151}
2152
2153#[async_trait]
2154impl NativeGatewayGrantProvider for AuthorityServiceGrantProvider {
2155 async fn grant(&self, request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2156 if request.device_id != self.device_id
2157 || request.avenue.kind != "room"
2158 || request.architecture != Some(RoomArchitectureMode::Authority)
2159 {
2160 bail!("authority service grant request scope is invalid");
2161 }
2162 let assignment_key_x = self
2163 .assignment_public_jwk
2164 .get("x")
2165 .and_then(Value::as_str)
2166 .ok_or_else(|| anyhow!("authority assignment public key is invalid"))?;
2167 let proof = self.control_plane.device_proof(|nonce, issued_at| {
2168 format!(
2169 "openrtc:v2:authority-service-grant:{}:{}:{}:{}:{}:{}:{}:{}:{}",
2170 self.control_plane.inner.app_tag,
2171 request.avenue.id,
2172 self.service_id,
2173 self.generation,
2174 self.shard_ids.join(","),
2175 assignment_key_x,
2176 request.runtime_instance_id,
2177 nonce,
2178 issued_at,
2179 )
2180 })?;
2181 let mut body = json!({
2182 "avenue": request.avenue,
2183 "serviceId": self.service_id,
2184 "generation": self.generation,
2185 "shardIds": self.shard_ids,
2186 "assignmentPublicKeyJwk": self.assignment_public_jwk,
2187 "deviceId": self.device_id,
2188 "runtimeInstanceId": request.runtime_instance_id,
2189 "ticketFingerprint": request.ticket_fingerprint,
2190 "deviceProof": proof,
2191 "features": self.features,
2192 });
2193 if let Some(max_peers) = self.max_peers {
2194 body["maxPeers"] = json!(max_peers);
2195 }
2196 if let Some(refresh_grant) = request.refresh_grant {
2197 body["refreshGrant"] = json!(refresh_grant);
2198 }
2199 let response: GatewayGrantResponse = self
2200 .control_plane
2201 .post_with_bearer("/v2/developer/authority/grants", body, &self.secret_key)
2202 .await?;
2203 if response.gateway_url.trim_end_matches('/')
2204 != self
2205 .control_plane
2206 .inner
2207 .gateway_endpoint
2208 .trim_end_matches('/')
2209 {
2210 bail!("OpenRTC returned an unexpected native gateway origin");
2211 }
2212 Ok(NativeGatewayGrant {
2213 protocol_version: response.protocol_version,
2214 gateway_url: response.gateway_url,
2215 route_key: response.route_key,
2216 token: response.token,
2217 expires_at_ms: response.expires_at_ms,
2218 })
2219 }
2220}
2221
2222#[derive(Debug, Serialize)]
2223#[serde(rename_all = "camelCase")]
2224struct NativeDeviceProof {
2225 public_key_jwk: Value,
2226 signature: String,
2227 nonce: String,
2228 issued_at: u64,
2229}
2230
2231#[derive(Debug, Clone, Deserialize)]
2232#[serde(rename_all = "camelCase")]
2233struct AssertionExchangeResponse {
2234 identity_session: String,
2235 principal_id: String,
2236 expires_at: u64,
2237}
2238
2239#[derive(Deserialize)]
2240#[serde(rename_all = "camelCase")]
2241struct DeviceEnrollmentResponse {
2242 device_certificate: String,
2243 expires_at: u64,
2244 signing_public_jwk: Value,
2245}
2246
2247#[derive(Deserialize)]
2248#[serde(rename_all = "camelCase")]
2249struct CapabilityResponse {
2250 capability: String,
2251 expires_at: u64,
2252 avenue: CapabilityAvenueResponse,
2253 #[serde(default)]
2254 iroh_relay: Option<bool>,
2255 #[serde(default)]
2256 managed_turn: bool,
2257 #[serde(default)]
2258 relay: Option<bool>,
2259}
2260
2261#[derive(Deserialize)]
2262struct CapabilityAvenueResponse {
2263 kind: String,
2264 id: String,
2265}
2266
2267#[derive(Deserialize)]
2268#[serde(rename_all = "camelCase")]
2269struct GatewayGrantResponse {
2270 protocol_version: u8,
2271 gateway_url: String,
2272 route_key: String,
2273 token: String,
2274 expires_at_ms: u64,
2275}
2276
2277fn now_seconds() -> u64 {
2278 SystemTime::now()
2279 .duration_since(UNIX_EPOCH)
2280 .unwrap_or_default()
2281 .as_secs()
2282}
2283
2284fn now_ms() -> u64 {
2285 now_seconds().saturating_mul(1_000)
2286}
2287
2288fn required(field: &str, value: String) -> Result<String> {
2289 let value = value.trim().to_string();
2290 if value.is_empty() {
2291 bail!("{field} is required");
2292 }
2293 Ok(value)
2294}
2295
2296fn bounded_id(field: &str, value: String) -> Result<String> {
2297 let value = required(field, value)?;
2298 if value.len() > 160
2299 || !value.bytes().all(|byte| {
2300 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'@')
2301 })
2302 {
2303 bail!("{field} is invalid");
2304 }
2305 Ok(value)
2306}
2307
2308fn bounded_visible_text(field: &str, value: String, max_len: usize) -> Result<String> {
2309 let value = required(field, value)?;
2310 if value.len() > max_len || value.chars().any(char::is_control) {
2311 bail!("{field} is invalid");
2312 }
2313 Ok(value)
2314}
2315
2316fn normalized_device_profile(
2317 profile: Option<DeviceProfile>,
2318 platform_type: &str,
2319) -> Result<Option<DeviceProfile>> {
2320 let profile = profile.unwrap_or_default();
2321 let name = profile
2322 .name
2323 .map(|value| bounded_visible_text("device_profile.name", value, 120))
2324 .transpose()?;
2325 let platform = Some(bounded_visible_text(
2326 "device_profile.platform",
2327 profile
2328 .platform
2329 .unwrap_or_else(|| platform_type.to_string()),
2330 80,
2331 )?);
2332 Ok(Some(DeviceProfile { name, platform }))
2333}
2334
2335fn native_capability_avenue_id(api_key: &str, kind: AnonymousKind, requested_id: &str) -> String {
2336 if kind != AnonymousKind::Space {
2337 return requested_id.to_string();
2338 }
2339 let digest =
2340 <sha2::Sha256 as sha2::Digest>::digest(format!("{api_key}:{requested_id}").as_bytes());
2341 hex::encode(digest)
2342}
2343
2344#[cfg(any(test, feature = "testing-endpoints"))]
2345fn validate_endpoint(value: String) -> Result<String> {
2346 let parsed = reqwest::Url::parse(value.trim())?;
2347 if !matches!(parsed.scheme(), "https" | "http")
2348 || parsed.host_str().is_none()
2349 || !parsed.username().is_empty()
2350 || parsed.password().is_some()
2351 || parsed.query().is_some()
2352 || parsed.fragment().is_some()
2353 {
2354 bail!("OpenRTC testing endpoint is invalid");
2355 }
2356 Ok(parsed.as_str().trim_end_matches('/').to_string())
2357}
2358
2359fn validate_public_jwk(value: &Value) -> Result<()> {
2360 let object = value
2361 .as_object()
2362 .ok_or_else(|| anyhow!("native device public key is invalid"))?;
2363 let x = object.get("x").and_then(Value::as_str).unwrap_or_default();
2364 if object.get("kty").and_then(Value::as_str) != Some("OKP")
2365 || object.get("crv").and_then(Value::as_str) != Some("Ed25519")
2366 || object.contains_key("d")
2367 || x.len() != 43
2368 || !x
2369 .bytes()
2370 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
2371 {
2372 bail!("native device public key must be a public Ed25519 JWK");
2373 }
2374 Ok(())
2375}
2376
2377fn decode_claims(token: &str) -> Result<Value> {
2378 let payload = token
2379 .split('.')
2380 .nth(1)
2381 .ok_or_else(|| anyhow!("OpenRTC credential is malformed"))?;
2382 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2383 .decode(payload)
2384 .context("decode OpenRTC credential claims")?;
2385 serde_json::from_slice(&bytes).context("parse OpenRTC credential claims")
2386}
2387
2388fn required_claim_string(token: &str, name: &str) -> Result<String> {
2389 decode_claims(token)?
2390 .get(name)
2391 .and_then(Value::as_str)
2392 .map(str::to_string)
2393 .filter(|value| !value.is_empty())
2394 .ok_or_else(|| anyhow!("OpenRTC credential is missing {name}"))
2395}
2396
2397fn native_device_key_thumbprint(public_key_jwk: &Value) -> Result<String> {
2398 validate_public_jwk(public_key_jwk)?;
2399 let canonical = json!({
2400 "crv": public_key_jwk.get("crv").and_then(Value::as_str),
2401 "kty": public_key_jwk.get("kty").and_then(Value::as_str),
2402 "x": public_key_jwk.get("x").and_then(Value::as_str),
2403 });
2404 let bytes = serde_json::to_vec(&canonical)?;
2405 let digest = <sha2::Sha256 as sha2::Digest>::digest(bytes);
2406 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest))
2407}
2408
2409fn native_session_key_hash(
2410 app_tag: &str,
2411 device_key_thumbprint: &str,
2412 session_key: &str,
2413) -> String {
2414 let digest = <sha2::Sha256 as sha2::Digest>::digest(
2415 format!(
2416 "openrtc:v2:session\0{}\0{}\0{}",
2417 app_tag, device_key_thumbprint, session_key,
2418 )
2419 .as_bytes(),
2420 );
2421 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
2422}
2423
2424fn validate_stored_certificate(
2425 value: &StoredCertificate,
2426 device_key_thumbprint: &str,
2427) -> Result<()> {
2428 let signing_public_jwk = value
2429 .signing_public_jwk
2430 .as_ref()
2431 .ok_or_else(|| anyhow!("stored OpenRTC device certificate has no verification key"))?;
2432 verify_device_certificate_signature(&value.token, signing_public_jwk)?;
2433 let claims = decode_claims(&value.token)?;
2434 let issued_at = claims.get("iat").and_then(Value::as_u64);
2435 let expires_at = claims.get("exp").and_then(Value::as_u64);
2436 if claims.get("iss").and_then(Value::as_str) != Some("openrtc")
2437 || claims.get("aud").and_then(Value::as_str) != Some("openrtc:v2:gateway-grant")
2438 || claims.get("typ").and_then(Value::as_str) != Some("device-certificate")
2439 || claims.get("appTag").and_then(Value::as_str) != Some(value.app_tag.as_str())
2440 || claims.get("principalId").and_then(Value::as_str) != Some(value.principal_id.as_str())
2441 || claims.get("principalKey").and_then(Value::as_str).is_none()
2442 || claims.get("deviceId").and_then(Value::as_str) != Some(value.device_id.as_str())
2443 || claims.get("deviceKeyThumbprint").and_then(Value::as_str) != Some(device_key_thumbprint)
2444 || claims.get("jti").and_then(Value::as_str).is_none()
2445 || issued_at.is_none()
2446 || expires_at.and_then(|exp| exp.checked_mul(1_000)) != Some(value.expires_at_ms)
2447 || issued_at
2448 .zip(expires_at)
2449 .is_none_or(|(iat, exp)| iat >= exp)
2450 {
2451 bail!("stored OpenRTC device certificate scope is invalid");
2452 }
2453 Ok(())
2454}
2455
2456fn verify_device_certificate_signature(token: &str, public_jwk: &Value) -> Result<()> {
2457 let mut parts = token.split('.');
2458 let header_segment = parts
2459 .next()
2460 .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2461 let claims_segment = parts
2462 .next()
2463 .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2464 let signature_segment = parts
2465 .next()
2466 .ok_or_else(|| anyhow!("invalid certificate JWT"))?;
2467 if parts.next().is_some() {
2468 bail!("invalid certificate JWT");
2469 }
2470 let header: Value = serde_json::from_slice(
2471 &base64::engine::general_purpose::URL_SAFE_NO_PAD
2472 .decode(header_segment)
2473 .context("decode certificate header")?,
2474 )?;
2475 if header.get("alg").and_then(Value::as_str) != Some("EdDSA")
2476 || public_jwk.get("kty").and_then(Value::as_str) != Some("OKP")
2477 || public_jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
2478 || header.get("kid").and_then(Value::as_str)
2479 != public_jwk.get("kid").and_then(Value::as_str)
2480 {
2481 bail!("unsupported OpenRTC certificate signing key");
2482 }
2483 let public_key = base64::engine::general_purpose::URL_SAFE_NO_PAD
2484 .decode(
2485 public_jwk
2486 .get("x")
2487 .and_then(Value::as_str)
2488 .ok_or_else(|| anyhow!("certificate signing key is missing x"))?,
2489 )
2490 .context("decode certificate signing key")?;
2491 let public_key: [u8; 32] = public_key
2492 .try_into()
2493 .map_err(|_| anyhow!("certificate signing key has invalid length"))?;
2494 let verifying_key =
2495 VerifyingKey::from_bytes(&public_key).context("parse certificate signing key")?;
2496 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
2497 .decode(signature_segment)
2498 .context("decode certificate signature")?;
2499 let signature = Signature::from_slice(&signature).context("parse certificate signature")?;
2500 verifying_key
2501 .verify(
2502 format!("{header_segment}.{claims_segment}").as_bytes(),
2503 &signature,
2504 )
2505 .context("verify OpenRTC device certificate")
2506}
2507
2508fn certificate_is_reusable(
2509 value: &StoredCertificate,
2510 device_key_thumbprint: &str,
2511 at_ms: u64,
2512) -> bool {
2513 value.expires_at_ms > at_ms.saturating_add(DEVICE_CERTIFICATE_RENEW_SKEW_MS)
2514 && validate_stored_certificate(value, device_key_thumbprint).is_ok()
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519 use super::*;
2520 use ed25519_dalek::{Signer as _, SigningKey};
2521 use std::sync::atomic::{AtomicU64, Ordering};
2522 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2523 use tokio::net::TcpListener;
2524
2525 struct TestSigner;
2526
2527 struct NeverGrantProvider;
2528
2529 #[async_trait]
2530 impl NativeGatewayGrantProvider for NeverGrantProvider {
2531 async fn grant(&self, _request: NativeGatewayGrantRequest) -> Result<NativeGatewayGrant> {
2532 bail!("inactive epoch-retirement test must not request a gateway grant")
2533 }
2534 }
2535
2536 #[derive(Default)]
2537 struct RecordingAssertionProvider {
2538 force_refreshes: StdMutex<Vec<bool>>,
2539 device_ids: StdMutex<Vec<String>>,
2540 identity_epoch: AtomicU64,
2541 }
2542
2543 #[async_trait]
2544 impl AssertionProvider for RecordingAssertionProvider {
2545 async fn assertion(&self, force_refresh: bool) -> Result<IdentityAssertion> {
2546 self.force_refreshes.lock().unwrap().push(force_refresh);
2547 Ok(IdentityAssertion {
2548 token: if force_refresh { "fresh" } else { "stale" }.to_string(),
2549 provider_id: None,
2550 })
2551 }
2552
2553 async fn assertion_for_device(
2554 &self,
2555 force_refresh: bool,
2556 device_id: &str,
2557 ) -> Result<IdentityAssertion> {
2558 self.device_ids.lock().unwrap().push(device_id.to_string());
2559 self.assertion(force_refresh).await
2560 }
2561
2562 fn identity_epoch(&self) -> u64 {
2563 self.identity_epoch.load(Ordering::Acquire)
2564 }
2565 }
2566
2567 impl DeviceSigner for TestSigner {
2568 fn public_jwk(&self, _app_tag: &str) -> Result<Value> {
2569 Ok(json!({
2570 "kty": "OKP",
2571 "crv": "Ed25519",
2572 "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2573 }))
2574 }
2575
2576 fn sign(&self, _app_tag: &str, _challenge: &[u8]) -> Result<Vec<u8>> {
2577 Ok(vec![0; 64])
2578 }
2579 }
2580
2581 fn signed_certificate(
2582 app_tag: &str,
2583 principal_id: &str,
2584 device_id: &str,
2585 expires_at: u64,
2586 ) -> (String, Value) {
2587 let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
2588 let kid = "test-signing-key";
2589 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
2590 serde_json::to_vec(&json!({ "alg": "EdDSA", "typ": "JWT", "kid": kid })).unwrap(),
2591 );
2592 let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
2593 serde_json::to_vec(&json!({
2594 "iss": "openrtc",
2595 "aud": "openrtc:v2:gateway-grant",
2596 "typ": "device-certificate",
2597 "appTag": app_tag,
2598 "principalId": principal_id,
2599 "principalKey": "principal-key",
2600 "deviceId": device_id,
2601 "deviceKeyThumbprint": "thumbprint",
2602 "iat": expires_at - 60,
2603 "exp": expires_at,
2604 "jti": "test-jti",
2605 }))
2606 .unwrap(),
2607 );
2608 let signing_input = format!("{header}.{claims}");
2609 let signature = signing_key.sign(signing_input.as_bytes());
2610 let token = format!(
2611 "{signing_input}.{}",
2612 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes())
2613 );
2614 let public_jwk = json!({
2615 "kty": "OKP",
2616 "crv": "Ed25519",
2617 "alg": "EdDSA",
2618 "kid": kid,
2619 "x": base64::engine::general_purpose::URL_SAFE_NO_PAD
2620 .encode(signing_key.verifying_key().to_bytes()),
2621 });
2622 (token, public_jwk)
2623 }
2624
2625 #[test]
2626 fn cached_certificate_is_scope_bound_and_renews_a_day_early() {
2627 let now = now_ms();
2628 let expires_at = now / 1_000 + 2 * 24 * 60 * 60;
2629 let (token, public_jwk) =
2630 signed_certificate("app_0000000000000000", "principal", "device", expires_at);
2631 let value = StoredCertificate {
2632 app_tag: "app_0000000000000000".to_string(),
2633 principal_id: "principal".to_string(),
2634 device_id: "device".to_string(),
2635 token,
2636 expires_at_ms: expires_at * 1_000,
2637 session_key_hash: Some("session-hash".to_string()),
2638 signing_public_jwk: Some(public_jwk),
2639 };
2640 assert!(certificate_is_reusable(&value, "thumbprint", now));
2641 assert!(!certificate_is_reusable(
2642 &value,
2643 "thumbprint",
2644 value.expires_at_ms - DEVICE_CERTIFICATE_RENEW_SKEW_MS + 1,
2645 ));
2646
2647 let mut wrong = value.clone();
2648 wrong.device_id = "other-device".to_string();
2649 assert!(!certificate_is_reusable(&wrong, "thumbprint", now));
2650 assert!(!certificate_is_reusable(&value, "rotated-thumbprint", now));
2651
2652 let mut tampered = value.clone();
2653 tampered.principal_id = "attacker".to_string();
2654 assert!(!certificate_is_reusable(&tampered, "thumbprint", now));
2655
2656 let mut missing_key = value.clone();
2657 missing_key.signing_public_jwk = None;
2658 assert!(!certificate_is_reusable(&missing_key, "thumbprint", now));
2659 }
2660
2661 #[test]
2662 fn device_public_jwk_rejects_private_material() {
2663 assert!(validate_public_jwk(&json!({
2664 "kty": "OKP",
2665 "crv": "Ed25519",
2666 "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2667 }))
2668 .is_ok());
2669 assert!(validate_public_jwk(&json!({
2670 "kty": "OKP",
2671 "crv": "Ed25519",
2672 "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2673 "d": "secret",
2674 }))
2675 .is_err());
2676 }
2677
2678 #[test]
2679 fn native_device_profile_defaults_platform_and_rejects_control_text() {
2680 assert_eq!(
2681 normalized_device_profile(None, "ios").unwrap(),
2682 Some(DeviceProfile {
2683 name: None,
2684 platform: Some("ios".to_string()),
2685 }),
2686 );
2687 assert_eq!(
2688 normalized_device_profile(
2689 Some(DeviceProfile {
2690 name: Some("Bryant's iPhone".to_string()),
2691 platform: None,
2692 }),
2693 "ios",
2694 )
2695 .unwrap(),
2696 Some(DeviceProfile {
2697 name: Some("Bryant's iPhone".to_string()),
2698 platform: Some("ios".to_string()),
2699 }),
2700 );
2701 assert!(normalized_device_profile(
2702 Some(DeviceProfile {
2703 name: Some("unsafe\nname".to_string()),
2704 platform: Some("ios".to_string()),
2705 }),
2706 "ios",
2707 )
2708 .is_err());
2709 }
2710
2711 #[test]
2712 fn native_relay_capabilities_do_not_infer_managed_turn_from_legacy_relay() {
2713 let legacy = relay_availability(None, false, Some(true));
2714 assert_eq!(
2715 legacy,
2716 RelayAvailability {
2717 iroh_relay: true,
2718 managed_turn: false,
2719 }
2720 );
2721
2722 let requested = Features {
2723 iroh_relay: true,
2724 managed_turn: true,
2725 ..Features::default()
2726 };
2727 assert_eq!(
2728 available_features(requested, legacy),
2729 Features {
2730 iroh_relay: true,
2731 managed_turn: false,
2732 ..Features::default()
2733 }
2734 );
2735 }
2736
2737 #[test]
2738 fn native_service_error_retryability_overrides_http_status() {
2739 let mut error = ControlPlaneHttpError {
2740 status: 429,
2741 message: "paused".into(),
2742 reason: None,
2743 service_error: None,
2744 };
2745 assert!(error.is_retryable());
2746 error.service_error = crate::service_errors::ServiceError::from_value(&serde_json::json!({
2747 "code": "credit-exhausted", "retryable": false, "scope": "account", "requestId": "request-1"
2748 }));
2749 assert!(!error.is_retryable());
2750 error.status = 503;
2751 assert!(!error.is_retryable());
2752 error.service_error = crate::service_errors::ServiceError::from_value(&serde_json::json!({
2753 "code": "app-rate-limited", "retryable": true, "retryAfterMs": 2300
2754 }));
2755 assert!(error.is_retryable());
2756 assert_eq!(error.service_error.unwrap().retry_after_ms, Some(2300));
2757 }
2758
2759 #[test]
2760 fn native_device_key_recovery_requires_exact_status_and_reason() {
2761 let required = anyhow::Error::new(ControlPlaneHttpError {
2762 service_error: None,
2763 status: 412,
2764 message: "recovery required".to_string(),
2765 reason: Some("device-key-recovery-required".to_string()),
2766 });
2767 assert!(is_native_device_key_recovery_required(&required));
2768
2769 for (status, reason) in [
2770 (403, Some("device-key-recovery-required")),
2771 (412, Some("device-certificate-revoked")),
2772 (412, None),
2773 ] {
2774 let unrelated = anyhow::Error::new(ControlPlaneHttpError {
2775 service_error: None,
2776 status,
2777 message: "unrelated".to_string(),
2778 reason: reason.map(str::to_string),
2779 });
2780 assert!(!is_native_device_key_recovery_required(&unrelated));
2781 }
2782 }
2783
2784 #[tokio::test]
2785 async fn authority_capacity_is_bounded_before_network_or_runtime_start() {
2786 let control =
2787 ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2788 .unwrap();
2789 for max_peers in [
2790 None,
2791 Some(0),
2792 Some(1),
2793 Some(8),
2794 Some(50),
2795 Some(51),
2796 Some(5_000),
2797 ] {
2798 let result = control
2799 .join_authority_room(
2800 "sk_test_service",
2801 "room",
2802 "service",
2803 "native",
2804 RoomAuthorityOptions {
2805 service_id: "authority".into(),
2806 generation: 1,
2807 shard_ids: vec!["main".into()],
2808 max_peers,
2809 features: Features::default(),
2810 assignment_signer: Arc::new(TestSigner),
2811 },
2812 )
2813 .await;
2814 if max_peers.is_some_and(|count| !(1..=50).contains(&count)) {
2815 assert!(result.err().unwrap().to_string().contains("maxPeers"));
2816 } else {
2817 result.unwrap().close().await;
2818 }
2819 }
2820 }
2821
2822 #[tokio::test]
2823 async fn anonymous_constructor_is_network_idle_and_rejects_authenticated_devices() {
2824 let control =
2825 ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2826 .expect("anonymous v2 control plane");
2827 assert_eq!(
2828 control.app_tag(),
2829 crate::app_tag_from_api_key(crate::test_constants::TEST_API_KEY),
2830 );
2831 let error = control
2832 .device_certificate("device", None, None, 0)
2833 .await
2834 .expect_err("anonymous clients do not own consumer authentication");
2835 assert!(error.to_string().contains("identity assertion provider"));
2836 }
2837
2838 #[tokio::test]
2839 async fn control_plane_retry_reuses_the_exact_body_and_idempotency_key() {
2840 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2841 let address = listener.local_addr().unwrap();
2842 let observed = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
2843 let server_observed = observed.clone();
2844 let server = tokio::spawn(async move {
2845 for attempt in 0..2 {
2846 let (mut stream, _) = listener.accept().await.unwrap();
2847 let mut bytes = Vec::new();
2848 let mut buffer = [0_u8; 4096];
2849 let header_end = loop {
2850 let read = stream.read(&mut buffer).await.unwrap();
2851 assert!(read > 0);
2852 bytes.extend_from_slice(&buffer[..read]);
2853 if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
2854 break index + 4;
2855 }
2856 };
2857 let headers = String::from_utf8_lossy(&bytes[..header_end]).to_string();
2858 let content_length = headers
2859 .lines()
2860 .find_map(|line| {
2861 let (name, value) = line.split_once(':')?;
2862 name.eq_ignore_ascii_case("content-length")
2863 .then(|| value.trim().parse::<usize>().unwrap())
2864 })
2865 .unwrap_or(0);
2866 while bytes.len() < header_end + content_length {
2867 let read = stream.read(&mut buffer).await.unwrap();
2868 assert!(read > 0);
2869 bytes.extend_from_slice(&buffer[..read]);
2870 }
2871 let request_id = headers
2872 .lines()
2873 .find_map(|line| {
2874 let (name, value) = line.split_once(':')?;
2875 name.eq_ignore_ascii_case("x-openrtc-idempotency-key")
2876 .then(|| value.trim().to_string())
2877 })
2878 .expect("idempotency header");
2879 server_observed.lock().await.push((
2880 request_id,
2881 bytes[header_end..header_end + content_length].to_vec(),
2882 ));
2883
2884 let (status, body) = if attempt == 0 {
2885 ("503 Service Unavailable", r#"{"error":"retry"}"#)
2886 } else {
2887 ("200 OK", r#"{"ok":true}"#)
2888 };
2889 stream
2890 .write_all(
2891 format!(
2892 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
2893 body.len(),
2894 )
2895 .as_bytes(),
2896 )
2897 .await
2898 .unwrap();
2899 }
2900 });
2901
2902 let control =
2903 ControlPlane::anonymous(crate::test_constants::TEST_API_KEY, Arc::new(TestSigner))
2904 .unwrap()
2905 .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
2906 .unwrap();
2907 let response: Value = control
2908 .post("/retry", json!({ "intent": "same" }))
2909 .await
2910 .unwrap();
2911 assert_eq!(response, json!({ "ok": true }));
2912 server.await.unwrap();
2913
2914 let observed = observed.lock().await;
2915 assert_eq!(observed.len(), 2);
2916 assert_eq!(observed[0], observed[1]);
2917 }
2918
2919 #[tokio::test]
2920 async fn native_identity_refreshes_once_only_after_unauthorized() {
2921 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2922 let address = listener.local_addr().unwrap();
2923 let server = tokio::spawn(async move {
2924 for attempt in 0..2 {
2925 let (mut stream, _) = listener.accept().await.unwrap();
2926 let mut bytes = Vec::new();
2927 let mut buffer = [0_u8; 4096];
2928 let header_end = loop {
2929 let read = stream.read(&mut buffer).await.unwrap();
2930 assert!(read > 0);
2931 bytes.extend_from_slice(&buffer[..read]);
2932 if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
2933 break index + 4;
2934 }
2935 };
2936 let headers = String::from_utf8_lossy(&bytes[..header_end]);
2937 let content_length = headers
2938 .lines()
2939 .find_map(|line| {
2940 let (name, value) = line.split_once(':')?;
2941 name.eq_ignore_ascii_case("content-length")
2942 .then(|| value.trim().parse::<usize>().unwrap())
2943 })
2944 .unwrap_or(0);
2945 while bytes.len() < header_end + content_length {
2946 let read = stream.read(&mut buffer).await.unwrap();
2947 assert!(read > 0);
2948 bytes.extend_from_slice(&buffer[..read]);
2949 }
2950 let request_body: Value =
2951 serde_json::from_slice(&bytes[header_end..header_end + content_length])
2952 .unwrap();
2953 let expected_assertion = if attempt == 0 { "stale" } else { "fresh" };
2954 assert_eq!(
2955 request_body.get("assertion").and_then(Value::as_str),
2956 Some(expected_assertion),
2957 );
2958 let (status, body) = if attempt == 0 {
2959 ("401 Unauthorized", r#"{"error":"assertion rejected"}"#)
2960 } else {
2961 (
2962 "200 OK",
2963 r#"{"identitySession":"identity","principalId":"principal","expiresAt":4102444800}"#,
2964 )
2965 };
2966 stream
2967 .write_all(
2968 format!(
2969 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
2970 body.len(),
2971 )
2972 .as_bytes(),
2973 )
2974 .await
2975 .unwrap();
2976 }
2977 });
2978
2979 let provider = Arc::new(RecordingAssertionProvider::default());
2980 let control = ControlPlane::new(
2981 crate::test_constants::TEST_API_KEY,
2982 provider.clone(),
2983 Arc::new(TestSigner),
2984 Arc::new(InMemoryCertificate::default()),
2985 )
2986 .unwrap()
2987 .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
2988 .unwrap();
2989
2990 let identity = control
2991 .exchange_identity_after_rejection(false, "device-a")
2992 .await
2993 .unwrap();
2994 assert_eq!(identity.principal_id, "principal");
2995 assert_eq!(*provider.force_refreshes.lock().unwrap(), vec![false, true]);
2996 server.await.unwrap();
2997 }
2998
2999 #[tokio::test]
3000 async fn native_identity_exchange_is_coalesced_per_host_login_epoch_and_device() {
3001 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3002 let address = listener.local_addr().unwrap();
3003 let server = tokio::spawn(async move {
3004 for attempt in 0..2 {
3005 let (mut stream, _) = listener.accept().await.unwrap();
3006 let mut bytes = Vec::new();
3007 let mut buffer = [0_u8; 4096];
3008 let header_end = loop {
3009 let read = stream.read(&mut buffer).await.unwrap();
3010 assert!(read > 0);
3011 bytes.extend_from_slice(&buffer[..read]);
3012 if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
3013 break index + 4;
3014 }
3015 };
3016 let headers = String::from_utf8_lossy(&bytes[..header_end]);
3017 let content_length = headers
3018 .lines()
3019 .find_map(|line| {
3020 let (name, value) = line.split_once(':')?;
3021 name.eq_ignore_ascii_case("content-length")
3022 .then(|| value.trim().parse::<usize>().unwrap())
3023 })
3024 .unwrap_or(0);
3025 while bytes.len() < header_end + content_length {
3026 let read = stream.read(&mut buffer).await.unwrap();
3027 assert!(read > 0);
3028 bytes.extend_from_slice(&buffer[..read]);
3029 }
3030 let body = format!(
3031 r#"{{"identitySession":"identity-{attempt}","principalId":"principal-{attempt}","expiresAt":4102444800}}"#,
3032 );
3033 stream
3034 .write_all(
3035 format!(
3036 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
3037 body.len(),
3038 )
3039 .as_bytes(),
3040 )
3041 .await
3042 .unwrap();
3043 }
3044 });
3045
3046 let provider = Arc::new(RecordingAssertionProvider::default());
3047 let control = ControlPlane::new(
3048 crate::test_constants::TEST_API_KEY,
3049 provider.clone(),
3050 Arc::new(TestSigner),
3051 Arc::new(InMemoryCertificate::default()),
3052 )
3053 .unwrap()
3054 .with_testing_endpoints(format!("http://{address}"), "http://127.0.0.1:1")
3055 .unwrap();
3056
3057 let first = control
3058 .identity_for_epoch(0, "device-a", false)
3059 .await
3060 .unwrap();
3061 let reused = control
3062 .identity_for_epoch(0, "device-a", false)
3063 .await
3064 .unwrap();
3065 assert_eq!(first.response.principal_id, "principal-0");
3066 assert_eq!(reused.response.principal_id, "principal-0");
3067 assert_eq!(*provider.force_refreshes.lock().unwrap(), vec![false]);
3068
3069 let next = control
3073 .identity_for_epoch(0, "device-b", false)
3074 .await
3075 .unwrap();
3076 assert_eq!(next.response.principal_id, "principal-1");
3077 assert_eq!(
3078 *provider.force_refreshes.lock().unwrap(),
3079 vec![false, false]
3080 );
3081 assert_eq!(
3082 *provider.device_ids.lock().unwrap(),
3083 vec!["device-a".to_string(), "device-b".to_string()]
3084 );
3085 server.await.unwrap();
3086 }
3087
3088 #[tokio::test]
3089 async fn native_login_epoch_change_retires_the_old_capability_without_network_work() {
3090 let capabilities = NativeCapabilities::new(
3091 crate::test_constants::TEST_API_KEY,
3092 "device",
3093 "native",
3094 Arc::new(NeverGrantProvider),
3095 )
3096 .unwrap();
3097 let handle = capabilities.devices("principal").unwrap();
3098 let relay = CredentialRelay::default();
3099 relay.set(Some("device-certificate".to_string())).unwrap();
3100 let credential = relay.provider();
3101 let (epoch_sender, epoch_receiver) = watch::channel(7_u64);
3102 let monitor = spawn_identity_epoch_monitor(epoch_receiver, 7, handle.closer(), relay);
3103
3104 epoch_sender.send(8).unwrap();
3105 tokio::time::timeout(std::time::Duration::from_secs(1), monitor)
3106 .await
3107 .expect("epoch retirement must be prompt")
3108 .unwrap();
3109 assert!(handle.is_closed());
3110 assert_eq!(credential(), None);
3111 }
3112
3113 #[test]
3114 fn native_space_namespace_matches_browser_derivation() {
3115 let api_key = crate::test_constants::TEST_API_KEY;
3116 let requested = "portfolio-cursors";
3117 let expected = hex::encode(<sha2::Sha256 as sha2::Digest>::digest(
3118 format!("{api_key}:{requested}").as_bytes(),
3119 ));
3120 assert_eq!(
3121 native_capability_avenue_id(api_key, AnonymousKind::Space, requested),
3122 expected,
3123 );
3124 assert_eq!(
3125 native_capability_avenue_id(api_key, AnonymousKind::Room, "match-123"),
3126 "match-123",
3127 );
3128 }
3129
3130 #[tokio::test]
3131 async fn signaling_slot_installs_once_and_fails_closed_before_activation() {
3132 let slot = SignalingSlot::default();
3133 assert!(slot.search_devices("principal", None).await.is_err());
3134 slot.install(Arc::new(crate::signaling::GatewayRequiredSignalingBackend))
3135 .await
3136 .expect("first capability installs");
3137 assert!(slot
3138 .install(Arc::new(crate::signaling::GatewayRequiredSignalingBackend,))
3139 .await
3140 .is_err());
3141 }
3142}