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 / subscription.rs
subscription.rs 14.9K
1use base64::Engine as _;2use base64::engine::general_purpose::URL_SAFE_NO_PAD;3use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};45use crate::{ProtocolError, Result};67const SUBSCRIPTION_MAGIC: [u8; 4] = *b"SGSC";8const SUBSCRIPTION_VERSION: u16 = 1;9const CLAIMS_LEN: usize = 136;10const SIGNATURE_LEN: usize = 64;11const CAPABILITY_LEN: usize = CLAIMS_LEN + SIGNATURE_LEN;12const SIGNATURE_DOMAIN: &[u8] = b"goq.sh/sigil-subscription/v1\0";1314pub const SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX: &str = "goq-subscription-v1.";15pub const MAX_SUBSCRIPTION_CAPABILITY_TOKEN_LEN: usize = 320;16pub const MAX_SUBSCRIPTION_TTL_SECS: u64 = 15 * 60;1718#[derive(Clone, Copy, Debug, PartialEq, Eq)]19pub struct SubscriptionTracks(u8);2021impl SubscriptionTracks {22 pub const VIDEO_H264: Self = Self(1 << 0);23 pub const AUDIO_OPUS: Self = Self(1 << 1);24 pub const ALL: Self = Self(Self::VIDEO_H264.0 | Self::AUDIO_OPUS.0);25 const KNOWN: u8 = Self::ALL.0;2627 pub fn new(bits: u8) -> Result<Self> {28 if bits == 0 || bits & !Self::KNOWN != 0 {29 return invalid(30 "subscription tracks",31 "tracks must contain only known non-zero bits",32 );33 }34 Ok(Self(bits))35 }3637 pub const fn bits(self) -> u8 {38 self.039 }4041 pub const fn contains(self, required: Self) -> bool {42 self.0 & required.0 == required.043 }4445 pub const fn union(self, other: Self) -> Self {46 Self(self.0 | other.0)47 }48}4950#[derive(Clone, Debug, PartialEq, Eq)]51pub struct SubscriptionClaims {52 pub host_node_id: [u8; 32],53 pub media_generation_id: u64,54 pub subscriber_endpoint_id: [u8; 32],55 pub tracks: SubscriptionTracks,56 pub authorization_revision: u64,57 pub issued_at_unix: u64,58 pub expires_at_unix: u64,59 pub nonce: [u8; 32],60 pub relay_hops: u8,61}6263impl SubscriptionClaims {64 #[allow(clippy::too_many_arguments)]65 pub fn new(66 host_node_id: [u8; 32],67 media_generation_id: u64,68 subscriber_endpoint_id: [u8; 32],69 tracks: SubscriptionTracks,70 authorization_revision: u64,71 issued_at_unix: u64,72 expires_at_unix: u64,73 nonce: [u8; 32],74 relay_hops: u8,75 ) -> Result<Self> {76 let claims = Self {77 host_node_id,78 media_generation_id,79 subscriber_endpoint_id,80 tracks,81 authorization_revision,82 issued_at_unix,83 expires_at_unix,84 nonce,85 relay_hops,86 };87 claims.validate()?;88 Ok(claims)89 }9091 pub fn validate(&self) -> Result<()> {92 SubscriptionTracks::new(self.tracks.bits())?;93 if self.media_generation_id == 0 || self.authorization_revision == 0 {94 return invalid(95 "subscription capability",96 "media generation and authorization revision must be non-zero",97 );98 }99 if self.relay_hops != 1 {100 return invalid(101 "subscription capability",102 "exactly one authorized relay hop is required",103 );104 }105 if self.issued_at_unix >= self.expires_at_unix106 || self.expires_at_unix - self.issued_at_unix > MAX_SUBSCRIPTION_TTL_SECS107 {108 return invalid(109 "subscription capability",110 "capability lifetime must be within 1..=900 seconds",111 );112 }113 if self.nonce == [0; 32] {114 return invalid("subscription capability", "nonce must be non-zero");115 }116 VerifyingKey::from_bytes(&self.host_node_id).map_err(|_| {117 ProtocolError::InvalidMessage {118 message_type: "subscription capability",119 reason: "host node id is not an Ed25519 public key",120 }121 })?;122 VerifyingKey::from_bytes(&self.subscriber_endpoint_id).map_err(|_| {123 ProtocolError::InvalidMessage {124 message_type: "subscription capability",125 reason: "subscriber endpoint id is not an Ed25519 public key",126 }127 })?;128 Ok(())129 }130131 fn encode(&self) -> [u8; CLAIMS_LEN] {132 let mut bytes = [0_u8; CLAIMS_LEN];133 bytes[0..4].copy_from_slice(&SUBSCRIPTION_MAGIC);134 bytes[4..6].copy_from_slice(&SUBSCRIPTION_VERSION.to_be_bytes());135 bytes[6] = self.relay_hops;136 bytes[7] = self.tracks.bits();137 bytes[8..40].copy_from_slice(&self.host_node_id);138 bytes[40..48].copy_from_slice(&self.media_generation_id.to_be_bytes());139 bytes[48..80].copy_from_slice(&self.subscriber_endpoint_id);140 bytes[80..88].copy_from_slice(&self.authorization_revision.to_be_bytes());141 bytes[88..96].copy_from_slice(&self.issued_at_unix.to_be_bytes());142 bytes[96..104].copy_from_slice(&self.expires_at_unix.to_be_bytes());143 bytes[104..136].copy_from_slice(&self.nonce);144 bytes145 }146147 fn signing_message(&self) -> Vec<u8> {148 let mut message = Vec::with_capacity(SIGNATURE_DOMAIN.len() + CLAIMS_LEN);149 message.extend_from_slice(SIGNATURE_DOMAIN);150 message.extend_from_slice(&self.encode());151 message152 }153}154155#[derive(Clone, Debug, PartialEq, Eq)]156pub struct SignedSubscriptionCapability {157 pub claims: SubscriptionClaims,158 signature: [u8; SIGNATURE_LEN],159}160161impl SignedSubscriptionCapability {162 pub fn issue(claims: SubscriptionClaims, host_secret: &[u8; 32]) -> Result<Self> {163 claims.validate()?;164 let signing_key = SigningKey::from_bytes(host_secret);165 if signing_key.verifying_key().to_bytes() != claims.host_node_id {166 return invalid(167 "subscription capability",168 "host signing key does not match the capability host",169 );170 }171 let signature = signing_key.sign(&claims.signing_message()).to_bytes();172 Ok(Self { claims, signature })173 }174175 pub fn verify(&self) -> Result<()> {176 self.claims.validate()?;177 let key = VerifyingKey::from_bytes(&self.claims.host_node_id).map_err(|_| {178 ProtocolError::InvalidMessage {179 message_type: "subscription capability",180 reason: "invalid host verification key",181 }182 })?;183 key.verify_strict(184 &self.claims.signing_message(),185 &Signature::from_bytes(&self.signature),186 )187 .map_err(|_| ProtocolError::InvalidMessage {188 message_type: "subscription capability",189 reason: "host signature verification failed",190 })191 }192193 #[allow(clippy::too_many_arguments)]194 pub fn verify_binding(195 &self,196 expected_host: [u8; 32],197 expected_generation: u64,198 expected_subscriber: [u8; 32],199 required_tracks: SubscriptionTracks,200 expected_authorization_revision: u64,201 now_unix: u64,202 ) -> Result<()> {203 self.verify()?;204 if self.claims.host_node_id != expected_host {205 return invalid(206 "subscription capability",207 "capability is bound to another host",208 );209 }210 if self.claims.media_generation_id != expected_generation {211 return invalid(212 "subscription capability",213 "capability is bound to another media generation",214 );215 }216 if self.claims.subscriber_endpoint_id != expected_subscriber {217 return invalid(218 "subscription capability",219 "capability is bound to another subscriber",220 );221 }222 if !self.claims.tracks.contains(required_tracks) {223 return invalid(224 "subscription capability",225 "capability does not authorize the required tracks",226 );227 }228 if self.claims.authorization_revision != expected_authorization_revision {229 return invalid(230 "subscription capability",231 "capability authorization revision is stale",232 );233 }234 if now_unix < self.claims.issued_at_unix || now_unix > self.claims.expires_at_unix {235 return invalid(236 "subscription capability",237 "capability is not currently valid",238 );239 }240 Ok(())241 }242243 pub fn encode(&self) -> String {244 let mut bytes = Vec::with_capacity(CAPABILITY_LEN);245 bytes.extend_from_slice(&self.claims.encode());246 bytes.extend_from_slice(&self.signature);247 format!(248 "{SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX}{}",249 URL_SAFE_NO_PAD.encode(bytes)250 )251 }252253 pub fn decode(token: &str) -> Result<Self> {254 if token.len() > MAX_SUBSCRIPTION_CAPABILITY_TOKEN_LEN255 || token.contains('=')256 || token.bytes().any(|byte| byte.is_ascii_whitespace())257 {258 return invalid(259 "subscription capability",260 "capability token is oversized or non-canonical",261 );262 }263 let encoded = token264 .strip_prefix(SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX)265 .ok_or(ProtocolError::InvalidMessage {266 message_type: "subscription capability",267 reason: "capability token prefix is invalid",268 })?;269 let bytes = URL_SAFE_NO_PAD270 .decode(encoded)271 .map_err(|_| ProtocolError::InvalidMessage {272 message_type: "subscription capability",273 reason: "capability token is not canonical base64url",274 })?;275 if bytes.len() != CAPABILITY_LEN {276 return Err(ProtocolError::InvalidMessageLength {277 actual: bytes.len(),278 maximum: CAPABILITY_LEN,279 });280 }281 if bytes[0..4] != SUBSCRIPTION_MAGIC {282 return invalid("subscription capability", "capability magic is invalid");283 }284 let version = u16::from_be_bytes(bytes[4..6].try_into().expect("fixed capability"));285 if version != SUBSCRIPTION_VERSION {286 return Err(ProtocolError::UnsupportedVersion {287 expected: SUBSCRIPTION_VERSION,288 actual: version,289 });290 }291 let claims = SubscriptionClaims::new(292 bytes[8..40].try_into().expect("fixed capability"),293 u64::from_be_bytes(bytes[40..48].try_into().expect("fixed capability")),294 bytes[48..80].try_into().expect("fixed capability"),295 SubscriptionTracks::new(bytes[7])?,296 u64::from_be_bytes(bytes[80..88].try_into().expect("fixed capability")),297 u64::from_be_bytes(bytes[88..96].try_into().expect("fixed capability")),298 u64::from_be_bytes(bytes[96..104].try_into().expect("fixed capability")),299 bytes[104..136].try_into().expect("fixed capability"),300 bytes[6],301 )?;302 let capability = Self {303 claims,304 signature: bytes[CLAIMS_LEN..]305 .try_into()306 .expect("fixed capability signature"),307 };308 capability.verify()?;309 if capability.encode() != token {310 return invalid(311 "subscription capability",312 "capability encoding is not canonical",313 );314 }315 Ok(capability)316 }317}318319fn invalid<T>(message_type: &'static str, reason: &'static str) -> Result<T> {320 Err(ProtocolError::InvalidMessage {321 message_type,322 reason,323 })324}325326#[cfg(test)]327mod tests {328 use super::*;329330 fn capability() -> SignedSubscriptionCapability {331 let host = SigningKey::from_bytes(&[7; 32]);332 let subscriber = SigningKey::from_bytes(&[9; 32]);333 SignedSubscriptionCapability::issue(334 SubscriptionClaims::new(335 host.verifying_key().to_bytes(),336 42,337 subscriber.verifying_key().to_bytes(),338 SubscriptionTracks::VIDEO_H264,339 3,340 1_700_000_000,341 1_700_000_600,342 [11; 32],343 1,344 )345 .unwrap(),346 &[7; 32],347 )348 .unwrap()349 }350351 #[test]352 fn endpoint_bound_capability_is_canonical_and_round_trips() {353 let capability = capability();354 let token = capability.encode();355 assert!(token.starts_with(SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX));356 assert_eq!(357 SignedSubscriptionCapability::decode(&token).unwrap(),358 capability359 );360 capability361 .verify_binding(362 capability.claims.host_node_id,363 42,364 capability.claims.subscriber_endpoint_id,365 SubscriptionTracks::VIDEO_H264,366 3,367 1_700_000_300,368 )369 .unwrap();370 }371372 #[test]373 fn wrong_host_subscriber_generation_revision_track_and_time_fail_closed() {374 let capability = capability();375 let valid = (376 capability.claims.host_node_id,377 42,378 capability.claims.subscriber_endpoint_id,379 SubscriptionTracks::VIDEO_H264,380 3,381 1_700_000_300,382 );383 assert!(384 capability385 .verify_binding([1; 32], valid.1, valid.2, valid.3, valid.4, valid.5)386 .is_err()387 );388 assert!(389 capability390 .verify_binding(valid.0, 43, valid.2, valid.3, valid.4, valid.5)391 .is_err()392 );393 assert!(394 capability395 .verify_binding(valid.0, valid.1, [2; 32], valid.3, valid.4, valid.5)396 .is_err()397 );398 assert!(399 capability400 .verify_binding(401 valid.0,402 valid.1,403 valid.2,404 SubscriptionTracks::AUDIO_OPUS,405 valid.4,406 valid.5407 )408 .is_err()409 );410 assert!(411 capability412 .verify_binding(valid.0, valid.1, valid.2, valid.3, 4, valid.5)413 .is_err()414 );415 assert!(416 capability417 .verify_binding(valid.0, valid.1, valid.2, valid.3, valid.4, 1_700_001_000)418 .is_err()419 );420 }421422 #[test]423 fn tampering_unknown_tracks_hops_and_noncanonical_tokens_fail_closed() {424 let token = capability().encode();425 let encoded = token426 .strip_prefix(SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX)427 .unwrap();428 let bytes = URL_SAFE_NO_PAD.decode(encoded).unwrap();429 for (offset, value) in [(0, 0), (5, 2), (6, 2), (7, 0x80), (CLAIMS_LEN, 0)] {430 let mut malformed = bytes.clone();431 malformed[offset] = value;432 let token = format!(433 "{SUBSCRIPTION_CAPABILITY_TOKEN_PREFIX}{}",434 URL_SAFE_NO_PAD.encode(malformed)435 );436 assert!(SignedSubscriptionCapability::decode(&token).is_err());437 }438 assert!(SignedSubscriptionCapability::decode(&(token + "=")).is_err());439 }440}