cat / goq
clone

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

release/portal-packaging
goq / crates / sigil-protocol / src / moq_catalog.rs
moq_catalog.rs 17.1K
1use serde::{Deserialize, Serialize};23use crate::{4 MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX, MOQ_AUDIO_OPUS_TRACK, MOQ_VIDEO_H264_TRACK,5 MediaAuthMode, ProtocolError, Result, SignedMediaGenerationCertificate,6};78pub const MOQ_CATALOG_EXTENSION_VERSION_V1: u16 = 1;9pub const MOQ_CATALOG_EXTENSION_VERSION_V2: u16 = 2;10/// Maximum immutable catalog snapshot accepted from a peer before JSON decoding.11pub const MAX_MOQ_CATALOG_BYTES: usize = 4 * 1024;12pub const MOQ_MEDIA_OBJECT_FORMAT_V1: &str = "sigil/media-frame/1";13pub const MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1: &str = "sigil/authenticated-media-object/1";14pub const MOQ_GOP_GROUP_FORMAT_V1: &str = "sigil/moq-gop/1";15pub const MOQ_AUDIO_OBJECT_FORMAT_V1: &str = "sigil/audio-packet/1";16pub const MOQ_AUDIO_GROUP_FORMAT_V1: &str = "sigil/moq-audio-group/1";17pub const MOQ_VIDEO_TRACK_PRIORITY: u8 = u8::MAX;18pub const MOQ_AUDIO_TRACK_PRIORITY: u8 = u8::MAX - 1;1920#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]21#[serde(rename_all = "camelCase", deny_unknown_fields)]22pub struct MoqTrackDescriptorV1 {23 pub name: String,24 pub priority: u8,25}2627#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]28#[serde(rename_all = "camelCase", deny_unknown_fields)]29pub struct MoqVideoCatalogV1 {30 pub track: MoqTrackDescriptorV1,31 pub codec: String,32 pub object_format: String,33 pub group_format: String,34}3536#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]37#[serde(rename_all = "camelCase", deny_unknown_fields)]38pub struct MoqCatalogExtensionV1 {39 pub version: u16,40 pub video: MoqVideoCatalogV1,41}4243impl MoqCatalogExtensionV1 {44 pub fn video_h264() -> Self {45 Self {46 version: MOQ_CATALOG_EXTENSION_VERSION_V1,47 video: MoqVideoCatalogV1 {48 track: MoqTrackDescriptorV1 {49 name: MOQ_VIDEO_H264_TRACK.to_owned(),50 priority: MOQ_VIDEO_TRACK_PRIORITY,51 },52 codec: "h264".to_owned(),53 object_format: MOQ_MEDIA_OBJECT_FORMAT_V1.to_owned(),54 group_format: MOQ_GOP_GROUP_FORMAT_V1.to_owned(),55 },56 }57 }5859 pub fn validate(&self) -> Result<()> {60 if self.version != MOQ_CATALOG_EXTENSION_VERSION_V1 {61 return Err(ProtocolError::InvalidMessage {62 message_type: "Goq MoQ catalog extension",63 reason: "unsupported extension version",64 });65 }66 if self.video.track.name != MOQ_VIDEO_H264_TRACK {67 return Err(ProtocolError::InvalidMessage {68 message_type: "Goq MoQ catalog extension",69 reason: "unexpected video track name",70 });71 }72 if self.video.track.priority != MOQ_VIDEO_TRACK_PRIORITY {73 return Err(ProtocolError::InvalidMessage {74 message_type: "Goq MoQ catalog extension",75 reason: "unexpected video track priority",76 });77 }78 if self.video.codec != "h264" {79 return Err(ProtocolError::InvalidMessage {80 message_type: "Goq MoQ catalog extension",81 reason: "unsupported video codec",82 });83 }84 if self.video.object_format != MOQ_MEDIA_OBJECT_FORMAT_V1 {85 return Err(ProtocolError::InvalidMessage {86 message_type: "Goq MoQ catalog extension",87 reason: "unsupported media object format",88 });89 }90 if self.video.group_format != MOQ_GOP_GROUP_FORMAT_V1 {91 return Err(ProtocolError::InvalidMessage {92 message_type: "Goq MoQ catalog extension",93 reason: "unsupported GOP group format",94 });95 }96 Ok(())97 }98}99100#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]101#[serde(rename_all = "camelCase", deny_unknown_fields)]102pub struct MoqTrackAuthenticationV2 {103 pub mode: String,104 pub object_format: String,105 pub generation_certificate: String,106}107108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]109#[serde(rename_all = "camelCase", deny_unknown_fields)]110pub struct MoqVideoCatalogV2 {111 pub track: MoqTrackDescriptorV1,112 pub codec: String,113 pub payload_format: String,114 pub group_format: String,115 pub authentication: MoqTrackAuthenticationV2,116}117118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]119#[serde(rename_all = "camelCase", deny_unknown_fields)]120pub struct MoqAudioCatalogV2 {121 pub track: MoqTrackDescriptorV1,122 pub codec: String,123 pub sample_rate: u32,124 pub channels: u8,125 pub frame_samples: u16,126 pub payload_format: String,127 pub group_format: String,128 pub authentication: MoqTrackAuthenticationV2,129}130131#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]132#[serde(rename_all = "camelCase", deny_unknown_fields)]133pub struct MoqCatalogExtensionV2 {134 pub version: u16,135 pub generation_id: u64,136 pub video: MoqVideoCatalogV2,137 #[serde(skip_serializing_if = "Option::is_none")]138 pub audio: Option<MoqAudioCatalogV2>,139}140141impl MoqCatalogExtensionV2 {142 pub fn video_h264(143 generation_id: u64,144 certificate: &SignedMediaGenerationCertificate,145 ) -> Result<Self> {146 let extension = Self {147 version: MOQ_CATALOG_EXTENSION_VERSION_V2,148 generation_id,149 video: MoqVideoCatalogV2 {150 track: MoqTrackDescriptorV1 {151 name: MOQ_VIDEO_H264_TRACK.to_owned(),152 priority: MOQ_VIDEO_TRACK_PRIORITY,153 },154 codec: "h264".to_owned(),155 payload_format: MOQ_MEDIA_OBJECT_FORMAT_V1.to_owned(),156 group_format: MOQ_GOP_GROUP_FORMAT_V1.to_owned(),157 authentication: MoqTrackAuthenticationV2 {158 mode: MediaAuthMode::Ed25519.label().to_owned(),159 object_format: MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1.to_owned(),160 generation_certificate: certificate.encode(),161 },162 },163 audio: None,164 };165 extension.validate()?;166 Ok(extension)167 }168169 pub fn video_h264_opus(170 generation_id: u64,171 certificate: &SignedMediaGenerationCertificate,172 ) -> Result<Self> {173 let mut extension = Self::video_h264(generation_id, certificate)?;174 extension.audio = Some(MoqAudioCatalogV2 {175 track: MoqTrackDescriptorV1 {176 name: MOQ_AUDIO_OPUS_TRACK.to_owned(),177 priority: MOQ_AUDIO_TRACK_PRIORITY,178 },179 codec: "opus".to_owned(),180 sample_rate: crate::OPUS_SAMPLE_RATE,181 channels: crate::OPUS_CHANNELS,182 frame_samples: crate::OPUS_FRAME_SAMPLES,183 payload_format: MOQ_AUDIO_OBJECT_FORMAT_V1.to_owned(),184 group_format: MOQ_AUDIO_GROUP_FORMAT_V1.to_owned(),185 authentication: MoqTrackAuthenticationV2 {186 mode: MediaAuthMode::Ed25519.label().to_owned(),187 object_format: MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1.to_owned(),188 generation_certificate: certificate.encode(),189 },190 });191 extension.validate()?;192 Ok(extension)193 }194195 pub fn validate(&self) -> Result<SignedMediaGenerationCertificate> {196 if self.version != MOQ_CATALOG_EXTENSION_VERSION_V2 || self.generation_id == 0 {197 return invalid_v2("unsupported extension version or zero generation");198 }199 if self.video.track.name != MOQ_VIDEO_H264_TRACK200 || self.video.track.priority != MOQ_VIDEO_TRACK_PRIORITY201 || self.video.codec != "h264"202 || self.video.payload_format != MOQ_MEDIA_OBJECT_FORMAT_V1203 || self.video.group_format != MOQ_GOP_GROUP_FORMAT_V1204 {205 return invalid_v2("video track contract does not match authenticated H.264");206 }207 if self.video.authentication.mode != MediaAuthMode::Ed25519.label()208 || self.video.authentication.object_format != MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1209 || !self210 .video211 .authentication212 .generation_certificate213 .starts_with(MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX)214 {215 return invalid_v2("unknown or malformed media authentication contract");216 }217 if let Some(audio) = &self.audio218 && (audio.track.name != MOQ_AUDIO_OPUS_TRACK219 || audio.track.priority != MOQ_AUDIO_TRACK_PRIORITY220 || audio.codec != "opus"221 || audio.sample_rate != crate::OPUS_SAMPLE_RATE222 || audio.channels != crate::OPUS_CHANNELS223 || audio.frame_samples != crate::OPUS_FRAME_SAMPLES224 || audio.payload_format != MOQ_AUDIO_OBJECT_FORMAT_V1225 || audio.group_format != MOQ_AUDIO_GROUP_FORMAT_V1226 || audio.authentication.mode != MediaAuthMode::Ed25519.label()227 || audio.authentication.object_format != MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1228 || audio.authentication.generation_certificate229 != self.video.authentication.generation_certificate)230 {231 return invalid_v2("audio track contract does not match authenticated Opus");232 }233 let certificate = SignedMediaGenerationCertificate::decode(234 &self.video.authentication.generation_certificate,235 )?;236 if certificate.claims.generation_id != self.generation_id237 || certificate.claims.authentication_mode != MediaAuthMode::Ed25519238 {239 return invalid_v2("generation certificate does not match the catalog generation");240 }241 Ok(certificate)242 }243}244245fn invalid_v2<T>(reason: &'static str) -> Result<T> {246 Err(ProtocolError::InvalidMessage {247 message_type: "Goq MoQ catalog extension v2",248 reason,249 })250}251252/// The immutable catalog.json document both peers must agree on byte-for-byte:253/// a default (empty) Hang catalog envelope carrying the Goq extension. This is254/// the single definition — the host producer, Portal subscriber, and probe all255/// consume it from here so the wire shape cannot drift between peers.256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]257#[serde(rename_all = "camelCase")]258pub struct GoqCatalogDocument {259 #[serde(flatten)]260 pub media: hang::Catalog,261 pub goq: MoqCatalogExtensionV1,262}263264impl GoqCatalogDocument {265 pub fn video_h264() -> Self {266 Self {267 media: hang::Catalog::default(),268 goq: MoqCatalogExtensionV1::video_h264(),269 }270 }271272 pub fn validate(&self) -> Result<()> {273 if self.media != hang::Catalog::default() {274 return Err(ProtocolError::InvalidMessage {275 message_type: "Goq MoQ catalog document",276 reason: "catalog must not advertise a standard Hang rendition for enveloped media",277 });278 }279 self.goq.validate()280 }281}282283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]284#[serde(rename_all = "camelCase")]285pub struct GoqCatalogDocumentV2 {286 #[serde(flatten)]287 pub media: hang::Catalog,288 pub goq: MoqCatalogExtensionV2,289}290291impl GoqCatalogDocumentV2 {292 pub fn video_h264(293 generation_id: u64,294 certificate: &SignedMediaGenerationCertificate,295 ) -> Result<Self> {296 Ok(Self {297 media: hang::Catalog::default(),298 goq: MoqCatalogExtensionV2::video_h264(generation_id, certificate)?,299 })300 }301302 pub fn video_h264_opus(303 generation_id: u64,304 certificate: &SignedMediaGenerationCertificate,305 ) -> Result<Self> {306 Ok(Self {307 media: hang::Catalog::default(),308 goq: MoqCatalogExtensionV2::video_h264_opus(generation_id, certificate)?,309 })310 }311312 pub fn validate(&self) -> Result<SignedMediaGenerationCertificate> {313 if self.media != hang::Catalog::default() {314 return Err(ProtocolError::InvalidMessage {315 message_type: "Goq MoQ catalog document v2",316 reason: "catalog must not advertise a standard Hang rendition for enveloped media",317 });318 }319 self.goq.validate()320 }321}322323#[cfg(test)]324mod tests {325 use super::*;326 use crate::MediaGenerationSigningKey;327 use ed25519_dalek::SigningKey;328329 fn v2_document() -> GoqCatalogDocumentV2 {330 let host = SigningKey::from_bytes(&[7; 32]);331 let generation = MediaGenerationSigningKey::from_bytes(&[9; 32]);332 let certificate = generation333 .certify(334 host.verifying_key().to_bytes(),335 &[7; 32],336 42,337 1_700_000_000,338 1_700_000_600,339 )340 .unwrap();341 GoqCatalogDocumentV2::video_h264(42, &certificate).unwrap()342 }343344 #[test]345 fn goq_catalog_extension_has_a_stable_golden_document() {346 let extension = MoqCatalogExtensionV1::video_h264();347 extension.validate().unwrap();348 assert_eq!(349 serde_json::to_string(&extension).unwrap(),350 r#"{"version":1,"video":{"track":{"name":"video/h264","priority":255},"codec":"h264","objectFormat":"sigil/media-frame/1","groupFormat":"sigil/moq-gop/1"}}"#351 );352 }353354 #[test]355 fn goq_catalog_document_has_a_stable_golden_envelope() {356 let document = GoqCatalogDocument::video_h264();357 document.validate().unwrap();358 let json = serde_json::to_value(&document).unwrap();359 assert_eq!(360 json["goq"],361 serde_json::to_value(MoqCatalogExtensionV1::video_h264()).unwrap()362 );363 let round_trip: GoqCatalogDocument = serde_json::from_value(json).unwrap();364 assert_eq!(round_trip, document);365 }366367 #[test]368 fn goq_catalog_validation_rejects_every_mismatched_contract_field() {369 let mut cases = Vec::new();370371 let mut value = MoqCatalogExtensionV1::video_h264();372 value.version = 2;373 cases.push(value);374 let mut value = MoqCatalogExtensionV1::video_h264();375 value.video.track.name = "video/other".into();376 cases.push(value);377 let mut value = MoqCatalogExtensionV1::video_h264();378 value.video.track.priority = 0;379 cases.push(value);380 let mut value = MoqCatalogExtensionV1::video_h264();381 value.video.codec = "av1".into();382 cases.push(value);383 let mut value = MoqCatalogExtensionV1::video_h264();384 value.video.object_format = "hang/legacy".into();385 cases.push(value);386 let mut value = MoqCatalogExtensionV1::video_h264();387 value.video.group_format = "hang/gop".into();388 cases.push(value);389390 for invalid in cases {391 assert!(invalid.validate().is_err(), "accepted {invalid:?}");392 }393 }394395 #[test]396 fn goq_catalog_extension_rejects_unknown_fields() {397 let json = r#"{"version":1,"video":{"track":{"name":"video/h264","priority":255,"extra":true},"codec":"h264","objectFormat":"sigil/media-frame/1","groupFormat":"sigil/moq-gop/1"}}"#;398 assert!(serde_json::from_str::<MoqCatalogExtensionV1>(json).is_err());399 }400401 #[test]402 fn authenticated_v2_catalog_is_strict_and_preserves_v1_decoding() {403 let document = v2_document();404 let certificate = document.validate().unwrap();405 assert_eq!(certificate.claims.generation_id, 42);406 let json = serde_json::to_string(&document).unwrap();407 assert!(json.contains(r#""version":2"#));408 assert!(json.contains(r#""mode":"ed25519-v1""#));409 assert!(json.contains(MOQ_AUTHENTICATED_MEDIA_OBJECT_FORMAT_V1));410 assert_eq!(411 serde_json::from_str::<GoqCatalogDocumentV2>(&json).unwrap(),412 document413 );414415 let legacy = serde_json::to_string(&GoqCatalogDocument::video_h264()).unwrap();416 serde_json::from_str::<GoqCatalogDocument>(&legacy)417 .unwrap()418 .validate()419 .unwrap();420 assert!(serde_json::from_str::<GoqCatalogDocumentV2>(&legacy).is_err());421 }422423 #[test]424 fn authenticated_v2_catalog_rejects_unknown_mode_generation_and_fields() {425 let mut wrong_mode = v2_document();426 wrong_mode.goq.video.authentication.mode = "shared-mac-v1".into();427 assert!(wrong_mode.validate().is_err());428429 let mut wrong_generation = v2_document();430 wrong_generation.goq.generation_id = 43;431 assert!(wrong_generation.validate().is_err());432433 let mut value = serde_json::to_value(v2_document()).unwrap();434 value["goq"]["video"]["authentication"]["key"] = serde_json::json!("secret");435 assert!(serde_json::from_value::<GoqCatalogDocumentV2>(value).is_err());436 }437438 #[test]439 fn authenticated_v2_catalog_advertises_opus_on_the_shared_clock() {440 let base = v2_document();441 let certificate = base.validate().unwrap();442 let document = GoqCatalogDocumentV2::video_h264_opus(42, &certificate).unwrap();443 let audio = document.goq.audio.as_ref().unwrap();444 assert_eq!(audio.track.name, MOQ_AUDIO_OPUS_TRACK);445 assert_eq!(audio.sample_rate, crate::OPUS_SAMPLE_RATE);446 assert_eq!(audio.channels, crate::OPUS_CHANNELS);447 assert_eq!(audio.frame_samples, crate::OPUS_FRAME_SAMPLES);448 assert_eq!(document.validate().unwrap(), certificate);449 }450}