cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/website-download-wiring
goq / crates / sigil-protocol / src / handshake.rs
handshake.rs 13.6K
1use serde::{Deserialize, Serialize};2use tokio::io::{AsyncRead, AsyncWrite};34use crate::framing::{read_json, write_json};5use crate::{6 MAX_HANDSHAKE_MESSAGE_LEN, MAX_INVITATION_TOKEN_LEN, PROTOCOL_VERSION, ProtocolError, Result,7 SignedInvitation,8};910const MAX_AGENT_LEN: usize = 128;11const MAX_MESSAGE_LEN: usize = 512;12const MAX_CAPABILITIES: usize = 16;13pub const MIN_POINTER_SURFACE_WIDTH: u16 = 64;14pub const MAX_POINTER_SURFACE_WIDTH: u16 = 7_680;15pub const MIN_POINTER_SURFACE_HEIGHT: u16 = 64;16pub const MAX_POINTER_SURFACE_HEIGHT: u16 = 4_320;1718/// Negotiable v1 session capabilities.19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]20#[serde(rename_all = "snake_case")]21pub enum Capability {22 VideoH264,23 AbsolutePointer,24 RelativePointer,25 Keyboard,26 Text,27 Gamepad,28 InputAck,29 PointerPositionFeedback,30 PointerVisibilityFeedback,31 AudioOpus,32}3334/// First control message sent by a connecting client.35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]36#[serde(deny_unknown_fields)]37pub struct ClientHello {38 pub version: u16,39 pub agent: String,40 pub nonce: [u8; 16],41 pub capabilities: Vec<Capability>,42 /// One-time enrollment invitation. It is accepted only on the first media43 /// connection and is never sent again after the host persists enrollment.44 #[serde(default, skip_serializing_if = "Option::is_none")]45 pub invitation: Option<String>,46}4748/// Native coordinate space used by the host compositor for relative-pointer49/// injection. It can differ from the encoded video size when the capture50/// pipeline downscales Gamescope output.51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]52#[serde(deny_unknown_fields)]53pub struct PointerSurfaceDimensions {54 pub width: u16,55 pub height: u16,56}5758impl PointerSurfaceDimensions {59 pub fn new(width: u16, height: u16) -> Result<Self> {60 let dimensions = Self { width, height };61 dimensions.validate()?;62 Ok(dimensions)63 }6465 pub fn validate(&self) -> Result<()> {66 if !(MIN_POINTER_SURFACE_WIDTH..=MAX_POINTER_SURFACE_WIDTH).contains(&self.width)67 || !(MIN_POINTER_SURFACE_HEIGHT..=MAX_POINTER_SURFACE_HEIGHT).contains(&self.height)68 {69 return Err(ProtocolError::InvalidMessage {70 message_type: "pointer surface dimensions",71 reason: "width must be 64..=7680 and height must be 64..=4320",72 });73 }74 Ok(())75 }76}7778impl ClientHello {79 pub fn new(agent: impl Into<String>, nonce: [u8; 16], capabilities: Vec<Capability>) -> Self {80 Self {81 version: PROTOCOL_VERSION,82 agent: agent.into(),83 nonce,84 capabilities,85 invitation: None,86 }87 }8889 pub fn with_invitation(mut self, invitation: impl Into<String>) -> Self {90 self.invitation = Some(invitation.into());91 self92 }9394 pub fn validate(&self) -> Result<()> {95 validate_version(self.version)?;96 validate_agent(&self.agent)?;97 validate_capabilities(&self.capabilities)?;98 if let Some(invitation) = &self.invitation {99 if invitation.len() > MAX_INVITATION_TOKEN_LEN {100 return Err(ProtocolError::InvalidMessage {101 message_type: "client hello",102 reason: "invitation exceeds the bounded token length",103 });104 }105 SignedInvitation::decode(invitation)?;106 }107 Ok(())108 }109}110111/// Host response to a client hello. Rejections carry no session id.112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]113#[serde(deny_unknown_fields)]114pub struct HostHello {115 pub version: u16,116 pub accepted: bool,117 pub session_id: Option<u64>,118 pub capabilities: Vec<Capability>,119 pub message: Option<String>,120 #[serde(default, skip_serializing_if = "Option::is_none")]121 pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,122}123124impl HostHello {125 pub fn accepted(session_id: u64, capabilities: Vec<Capability>) -> Self {126 Self {127 version: PROTOCOL_VERSION,128 accepted: true,129 session_id: Some(session_id),130 capabilities,131 message: None,132 pointer_surface_dimensions: None,133 }134 }135136 pub fn with_pointer_surface_dimensions(mut self, dimensions: PointerSurfaceDimensions) -> Self {137 self.pointer_surface_dimensions = Some(dimensions);138 self139 }140141 pub fn rejected(message: impl Into<String>) -> Self {142 Self {143 version: PROTOCOL_VERSION,144 accepted: false,145 session_id: None,146 capabilities: Vec::new(),147 message: Some(message.into()),148 pointer_surface_dimensions: None,149 }150 }151152 pub fn validate(&self) -> Result<()> {153 validate_version(self.version)?;154 validate_capabilities(&self.capabilities)?;155 if self.accepted != self.session_id.is_some() {156 return Err(ProtocolError::InvalidMessage {157 message_type: "host hello",158 reason: "accepted responses require a session id; rejections forbid one",159 });160 }161 if let Some(message) = &self.message {162 if message.is_empty() || message.len() > MAX_MESSAGE_LEN {163 return Err(ProtocolError::InvalidMessage {164 message_type: "host hello",165 reason: "message must contain 1..=512 UTF-8 bytes",166 });167 }168 }169 if !self.accepted && self.message.is_none() {170 return Err(ProtocolError::InvalidMessage {171 message_type: "host hello",172 reason: "rejections require a message",173 });174 }175 if let Some(dimensions) = self.pointer_surface_dimensions {176 dimensions.validate()?;177 if !self.accepted || !self.capabilities.contains(&Capability::VideoH264) {178 return Err(ProtocolError::InvalidMessage {179 message_type: "host hello",180 reason: "pointer surface dimensions require accepted H.264 media",181 });182 }183 }184 Ok(())185 }186}187188pub async fn read_client_hello<R>(reader: &mut R) -> Result<Option<ClientHello>>189where190 R: AsyncRead + Unpin,191{192 let hello: Option<ClientHello> = read_json(reader, MAX_HANDSHAKE_MESSAGE_LEN).await?;193 if let Some(hello) = &hello {194 hello.validate()?;195 }196 Ok(hello)197}198199pub async fn write_client_hello<W>(writer: &mut W, hello: &ClientHello) -> Result<()>200where201 W: AsyncWrite + Unpin,202{203 hello.validate()?;204 write_json(writer, hello, MAX_HANDSHAKE_MESSAGE_LEN).await205}206207pub async fn read_host_hello<R>(reader: &mut R) -> Result<Option<HostHello>>208where209 R: AsyncRead + Unpin,210{211 let hello: Option<HostHello> = read_json(reader, MAX_HANDSHAKE_MESSAGE_LEN).await?;212 if let Some(hello) = &hello {213 hello.validate()?;214 }215 Ok(hello)216}217218pub async fn write_host_hello<W>(writer: &mut W, hello: &HostHello) -> Result<()>219where220 W: AsyncWrite + Unpin,221{222 hello.validate()?;223 write_json(writer, hello, MAX_HANDSHAKE_MESSAGE_LEN).await224}225226fn validate_version(version: u16) -> Result<()> {227 if version != PROTOCOL_VERSION {228 return Err(ProtocolError::UnsupportedVersion {229 expected: PROTOCOL_VERSION,230 actual: version,231 });232 }233 Ok(())234}235236fn validate_agent(agent: &str) -> Result<()> {237 if agent.is_empty() || agent.len() > MAX_AGENT_LEN {238 return Err(ProtocolError::InvalidMessage {239 message_type: "client hello",240 reason: "agent must contain 1..=128 UTF-8 bytes",241 });242 }243 Ok(())244}245246fn validate_capabilities(capabilities: &[Capability]) -> Result<()> {247 if capabilities.len() > MAX_CAPABILITIES {248 return Err(ProtocolError::InvalidMessage {249 message_type: "hello",250 reason: "too many capabilities",251 });252 }253 for (index, capability) in capabilities.iter().enumerate() {254 if capabilities[..index].contains(capability) {255 return Err(ProtocolError::InvalidMessage {256 message_type: "hello",257 reason: "duplicate capability",258 });259 }260 }261 Ok(())262}263264#[cfg(test)]265mod tests {266 use ed25519_dalek::SigningKey;267 use tokio::io::{AsyncWriteExt, duplex};268269 use super::*;270271 fn client_hello() -> ClientHello {272 ClientHello::new(273 "sigil-client/0.1.0",274 [0x2a; 16],275 vec![276 Capability::VideoH264,277 Capability::Keyboard,278 Capability::Gamepad,279 ],280 )281 }282283 #[test]284 fn client_hello_json_is_a_golden_vector() {285 assert_eq!(286 serde_json::to_string(&client_hello()).unwrap(),287 r#"{"version":1,"agent":"sigil-client/0.1.0","nonce":[42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42],"capabilities":["video_h264","keyboard","gamepad"]}"#288 );289 }290291 #[test]292 fn client_hello_carries_only_a_bounded_valid_invitation() {293 let host = SigningKey::from_bytes(&[7; 32]);294 let peer = SigningKey::from_bytes(&[9; 32]);295 let claims = crate::InvitationClaims::new(296 host.verifying_key().to_bytes(),297 peer.verifying_key().to_bytes(),298 100,299 200,300 1,301 [1; 32],302 crate::InvitationGrants::VIEW,303 )304 .unwrap();305 let token = crate::SignedInvitation::issue(claims, &[7; 32])306 .unwrap()307 .encode();308 let hello = ClientHello::new("portal", [0; 16], vec![Capability::VideoH264])309 .with_invitation(token.clone());310 hello.validate().unwrap();311 assert_eq!(hello.invitation.as_deref(), Some(token.as_str()));312313 let mut malformed = hello;314 malformed.invitation = Some("goq-invite-v1.invalid".into());315 assert!(malformed.validate().is_err());316 }317318 #[test]319 fn relative_pointer_capability_json_is_a_golden_vector() {320 assert_eq!(321 serde_json::to_string(&Capability::RelativePointer).unwrap(),322 r#""relative_pointer""#323 );324 assert_eq!(325 serde_json::to_string(&Capability::PointerPositionFeedback).unwrap(),326 r#""pointer_position_feedback""#327 );328 assert_eq!(329 serde_json::to_string(&Capability::PointerVisibilityFeedback).unwrap(),330 r#""pointer_visibility_feedback""#331 );332 }333334 #[test]335 fn media_hello_carries_bounded_pointer_surface_dimensions() {336 let hello = HostHello::accepted(42, vec![Capability::VideoH264])337 .with_pointer_surface_dimensions(PointerSurfaceDimensions::new(2_560, 1_600).unwrap());338 assert_eq!(339 serde_json::to_string(&hello).unwrap(),340 r#"{"version":1,"accepted":true,"session_id":42,"capabilities":["video_h264"],"message":null,"pointer_surface_dimensions":{"width":2560,"height":1600}}"#341 );342 assert_eq!(343 serde_json::from_str::<HostHello>(344 r#"{"version":1,"accepted":true,"session_id":42,"capabilities":["video_h264"],"message":null}"#,345 )346 .unwrap()347 .pointer_surface_dimensions,348 None,349 "a new client must accept an old host that omits the optional field",350 );351 }352353 #[test]354 fn pointer_surface_dimensions_fail_closed() {355 assert!(PointerSurfaceDimensions::new(63, 800).is_err());356 assert!(PointerSurfaceDimensions::new(1_280, 4_321).is_err());357 assert!(358 HostHello::accepted(42, vec![Capability::Keyboard])359 .with_pointer_surface_dimensions(PointerSurfaceDimensions::new(1_280, 800).unwrap())360 .validate()361 .is_err()362 );363 }364365 #[tokio::test]366 async fn both_hello_directions_round_trip() {367 let client = client_hello();368 let host = HostHello::accepted(42, vec![Capability::VideoH264]);369 let (mut sender, mut receiver) = duplex(1024);370 write_client_hello(&mut sender, &client).await.unwrap();371 write_host_hello(&mut sender, &host).await.unwrap();372 sender.shutdown().await.unwrap();373374 assert_eq!(375 read_client_hello(&mut receiver).await.unwrap(),376 Some(client)377 );378 assert_eq!(read_host_hello(&mut receiver).await.unwrap(), Some(host));379 assert_eq!(read_host_hello(&mut receiver).await.unwrap(), None);380 }381382 #[test]383 fn hello_validation_fails_closed() {384 let mut bad_version = client_hello();385 bad_version.version = 0;386 assert!(matches!(387 bad_version.validate(),388 Err(ProtocolError::UnsupportedVersion { .. })389 ));390391 let duplicate = ClientHello::new(392 "client",393 [0; 16],394 vec![Capability::Keyboard, Capability::Keyboard],395 );396 assert!(duplicate.validate().is_err());397 assert!(HostHello::rejected("").validate().is_err());398 assert!(399 HostHello {400 version: PROTOCOL_VERSION,401 accepted: true,402 session_id: None,403 capabilities: vec![],404 message: None,405 pointer_surface_dimensions: None,406 }407 .validate()408 .is_err()409 );410 }411412 #[tokio::test]413 async fn peer_controlled_handshake_length_is_bounded() {414 let (mut sender, mut receiver) = duplex(16);415 sender416 .write_all(&((MAX_HANDSHAKE_MESSAGE_LEN as u32) + 1).to_be_bytes())417 .await418 .unwrap();419 assert!(matches!(420 read_client_hello(&mut receiver).await,421 Err(ProtocolError::InvalidMessageLength { .. })422 ));423 }424}