cat / goq
clone

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

fix/drop-hero-notarization-note
goq / crates / sigil-protocol / src / invitation.rs
invitation.rs 14.4K
1use base64::Engine as _;2use base64::engine::general_purpose::URL_SAFE_NO_PAD;3use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};4use serde::{Deserialize, Serialize};56use crate::{PROTOCOL_VERSION, ProtocolError, Result};78const INVITATION_MAGIC: [u8; 4] = *b"SGIV";9const CLAIMS_LEN: usize = 127;10const SIGNATURE_LEN: usize = 64;11const INVITATION_LEN: usize = CLAIMS_LEN + SIGNATURE_LEN;12const SIGNATURE_DOMAIN: &[u8] = b"goq.sh/sigil-invitation/v1\0";1314pub const INVITATION_TOKEN_PREFIX: &str = "goq-invite-v1.";15pub const MAX_INVITATION_TOKEN_LEN: usize = 384;16pub const MAX_INVITATION_TTL_SECS: u64 = 15 * 60;17pub const INVITATION_CLOCK_SKEW_SECS: u64 = 60;1819/// Product authorization scopes. These are deliberately separate from wire20/// capabilities, which also contain operational protocol extensions.21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]22#[serde(transparent)]23pub struct InvitationGrants(u8);2425impl InvitationGrants {26 pub const VIEW: Self = Self(1 << 0);27 pub const POINTER_KEYBOARD: Self = Self(1 << 1);28 pub const GAMEPAD: Self = Self(1 << 2);29 pub const ALL: Self = Self(Self::VIEW.0 | Self::POINTER_KEYBOARD.0 | Self::GAMEPAD.0);30 const KNOWN: u8 = Self::ALL.0;3132 pub fn new(bits: u8) -> Result<Self> {33 if bits == 0 || bits & !Self::KNOWN != 0 {34 return Err(ProtocolError::InvalidMessage {35 message_type: "invitation grants",36 reason: "grants must contain only known non-zero permission bits",37 });38 }39 Ok(Self(bits))40 }4142 pub const fn bits(self) -> u8 {43 self.044 }4546 pub const fn contains(self, required: Self) -> bool {47 self.0 & required.0 == required.048 }4950 pub const fn union(self, other: Self) -> Self {51 Self(self.0 | other.0)52 }5354 pub fn validate(self) -> Result<()> {55 Self::new(self.0).map(|_| ())56 }57}5859#[derive(Clone, Debug, PartialEq, Eq)]60pub struct InvitationClaims {61 pub host_node_id: [u8; 32],62 pub intended_peer_id: [u8; 32],63 pub issued_at_unix: u64,64 pub expires_at_unix: u64,65 pub enrollment_epoch: u64,66 pub nonce: [u8; 32],67 pub grants: InvitationGrants,68}6970impl InvitationClaims {71 #[allow(clippy::too_many_arguments)]72 pub fn new(73 host_node_id: [u8; 32],74 intended_peer_id: [u8; 32],75 issued_at_unix: u64,76 expires_at_unix: u64,77 enrollment_epoch: u64,78 nonce: [u8; 32],79 grants: InvitationGrants,80 ) -> Result<Self> {81 let claims = Self {82 host_node_id,83 intended_peer_id,84 issued_at_unix,85 expires_at_unix,86 enrollment_epoch,87 nonce,88 grants,89 };90 claims.validate()?;91 Ok(claims)92 }9394 pub fn validate(&self) -> Result<()> {95 self.grants.validate()?;96 if self.issued_at_unix >= self.expires_at_unix97 || self.expires_at_unix - self.issued_at_unix > MAX_INVITATION_TTL_SECS98 {99 return Err(ProtocolError::InvalidMessage {100 message_type: "invitation claims",101 reason: "invitation lifetime must be 1..=900 seconds",102 });103 }104 if self.enrollment_epoch == 0 {105 return Err(ProtocolError::InvalidMessage {106 message_type: "invitation claims",107 reason: "enrollment epoch must be non-zero",108 });109 }110 if self.nonce == [0; 32] {111 return Err(ProtocolError::InvalidMessage {112 message_type: "invitation claims",113 reason: "invitation nonce must be non-zero",114 });115 }116 VerifyingKey::from_bytes(&self.host_node_id).map_err(|_| {117 ProtocolError::InvalidMessage {118 message_type: "invitation claims",119 reason: "host node ID is not a valid Ed25519 public key",120 }121 })?;122 VerifyingKey::from_bytes(&self.intended_peer_id).map_err(|_| {123 ProtocolError::InvalidMessage {124 message_type: "invitation claims",125 reason: "intended peer ID is not a valid 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(&INVITATION_MAGIC);134 bytes[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes());135 bytes[6..38].copy_from_slice(&self.host_node_id);136 bytes[38..70].copy_from_slice(&self.intended_peer_id);137 bytes[70..78].copy_from_slice(&self.issued_at_unix.to_be_bytes());138 bytes[78..86].copy_from_slice(&self.expires_at_unix.to_be_bytes());139 bytes[86..94].copy_from_slice(&self.enrollment_epoch.to_be_bytes());140 bytes[94..126].copy_from_slice(&self.nonce);141 bytes[126] = self.grants.bits();142 bytes143 }144145 fn signing_message(&self) -> Vec<u8> {146 let mut message = Vec::with_capacity(SIGNATURE_DOMAIN.len() + CLAIMS_LEN);147 message.extend_from_slice(SIGNATURE_DOMAIN);148 message.extend_from_slice(&self.encode());149 message150 }151}152153#[derive(Clone, Debug, PartialEq, Eq)]154pub struct SignedInvitation {155 pub claims: InvitationClaims,156 signature: [u8; SIGNATURE_LEN],157}158159impl SignedInvitation {160 pub fn issue(claims: InvitationClaims, host_secret: &[u8; 32]) -> Result<Self> {161 claims.validate()?;162 let signing_key = SigningKey::from_bytes(host_secret);163 if signing_key.verifying_key().to_bytes() != claims.host_node_id {164 return Err(ProtocolError::InvalidMessage {165 message_type: "invitation claims",166 reason: "signing key does not match the claimed host",167 });168 }169 let signature = signing_key.sign(&claims.signing_message()).to_bytes();170 Ok(Self { claims, signature })171 }172173 pub fn verify(&self) -> Result<()> {174 self.claims.validate()?;175 let key = VerifyingKey::from_bytes(&self.claims.host_node_id).map_err(|_| {176 ProtocolError::InvalidMessage {177 message_type: "invitation",178 reason: "invalid host verification key",179 }180 })?;181 let signature = Signature::from_bytes(&self.signature);182 key.verify_strict(&self.claims.signing_message(), &signature)183 .map_err(|_| ProtocolError::InvalidMessage {184 message_type: "invitation",185 reason: "signature verification failed",186 })187 }188189 pub fn encode(&self) -> String {190 let mut bytes = Vec::with_capacity(INVITATION_LEN);191 bytes.extend_from_slice(&self.claims.encode());192 bytes.extend_from_slice(&self.signature);193 format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))194 }195196 pub fn decode(token: &str) -> Result<Self> {197 if token.len() > MAX_INVITATION_TOKEN_LEN198 || token.contains('=')199 || token.bytes().any(|byte| byte.is_ascii_whitespace())200 {201 return Err(ProtocolError::InvalidMessage {202 message_type: "invitation",203 reason: "token is oversized or non-canonical",204 });205 }206 let encoded =207 token208 .strip_prefix(INVITATION_TOKEN_PREFIX)209 .ok_or(ProtocolError::InvalidMessage {210 message_type: "invitation",211 reason: "token prefix is invalid",212 })?;213 let bytes = URL_SAFE_NO_PAD214 .decode(encoded)215 .map_err(|_| ProtocolError::InvalidMessage {216 message_type: "invitation",217 reason: "token is not canonical base64url",218 })?;219 if bytes.len() != INVITATION_LEN {220 return Err(ProtocolError::InvalidMessageLength {221 actual: bytes.len(),222 maximum: INVITATION_LEN,223 });224 }225 let magic: [u8; 4] = bytes[0..4].try_into().expect("fixed ticket length");226 if magic != INVITATION_MAGIC {227 return Err(ProtocolError::InvalidInvitationMagic(magic));228 }229 let version = u16::from_be_bytes(bytes[4..6].try_into().expect("fixed ticket length"));230 if version != PROTOCOL_VERSION {231 return Err(ProtocolError::UnsupportedVersion {232 expected: PROTOCOL_VERSION,233 actual: version,234 });235 }236 let claims = InvitationClaims::new(237 bytes[6..38].try_into().expect("fixed ticket length"),238 bytes[38..70].try_into().expect("fixed ticket length"),239 u64::from_be_bytes(bytes[70..78].try_into().expect("fixed ticket length")),240 u64::from_be_bytes(bytes[78..86].try_into().expect("fixed ticket length")),241 u64::from_be_bytes(bytes[86..94].try_into().expect("fixed ticket length")),242 bytes[94..126].try_into().expect("fixed ticket length"),243 InvitationGrants::new(bytes[126])?,244 )?;245 let invitation = Self {246 claims,247 signature: bytes[CLAIMS_LEN..]248 .try_into()249 .expect("fixed signature length"),250 };251 invitation.verify()?;252 if invitation.encode() != token {253 return Err(ProtocolError::InvalidMessage {254 message_type: "invitation",255 reason: "token encoding is not canonical",256 });257 }258 Ok(invitation)259 }260}261262#[cfg(test)]263mod tests {264 use super::*;265266 fn invitation() -> SignedInvitation {267 let host = SigningKey::from_bytes(&[7; 32]);268 let peer = SigningKey::from_bytes(&[9; 32]);269 let claims = InvitationClaims::new(270 host.verifying_key().to_bytes(),271 peer.verifying_key().to_bytes(),272 1_700_000_000,273 1_700_000_600,274 3,275 [11; 32],276 InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),277 )278 .unwrap();279 SignedInvitation::issue(claims, &[7; 32]).unwrap()280 }281282 #[test]283 fn signed_invitation_is_canonical_and_round_trips() {284 let invitation = invitation();285 let token = invitation.encode();286 assert_eq!(287 token,288 "goq-invite-v1.U0dJVgAB6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iz9FyQ4WqDHW2T7eM1gL6HZkf3r92sTxY7XAurINen2GAAAAABlU_EAAAAAAGVT81gAAAAAAAAAAwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLBUuO9fzI2gGLT6L3Lz5XPM500MPzW5eaB2h4FnOWZHM9ENlCqMERXCvwxZTG3hFR4WumyJeqoAlqH_gCeJOsbQs"289 );290 assert!(token.starts_with(INVITATION_TOKEN_PREFIX));291 assert!(token.len() <= MAX_INVITATION_TOKEN_LEN);292 assert_eq!(SignedInvitation::decode(&token).unwrap(), invitation);293 }294295 #[test]296 fn tampering_and_noncanonical_tokens_fail_closed() {297 let token = invitation().encode();298 let mut bytes = URL_SAFE_NO_PAD299 .decode(token.strip_prefix(INVITATION_TOKEN_PREFIX).unwrap())300 .unwrap();301 bytes[126] ^= InvitationGrants::POINTER_KEYBOARD.bits();302 let tampered = format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes));303 assert!(SignedInvitation::decode(&tampered).is_err());304 assert!(SignedInvitation::decode(&(token + "=")).is_err());305 }306307 #[test]308 fn malformed_ticket_shapes_fail_closed() {309 let token = invitation().encode();310 let encoded = token.strip_prefix(INVITATION_TOKEN_PREFIX).unwrap();311 let bytes = URL_SAFE_NO_PAD.decode(encoded).unwrap();312313 let encode =314 |bytes: &[u8]| format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes));315 let mut bad_magic = bytes.clone();316 bad_magic[0] ^= 0xff;317 let mut bad_version = bytes.clone();318 bad_version[5] ^= 0xff;319 let mut truncated = bytes.clone();320 truncated.pop();321 let mut oversized = bytes.clone();322 oversized.push(0);323324 let malformed = [325 (326 "wrong prefix",327 token.replacen(INVITATION_TOKEN_PREFIX, "wrong.", 1),328 ),329 ("leading whitespace", format!(" {token}")),330 ("trailing whitespace", format!("{token}\n")),331 ("invalid base64url", format!("{INVITATION_TOKEN_PREFIX}!")),332 ("wrong decoded size", encode(&truncated)),333 ("oversized decoded body", encode(&oversized)),334 ("wrong magic", encode(&bad_magic)),335 ("wrong version", encode(&bad_version)),336 ];337338 for (case, malformed) in malformed {339 assert!(340 SignedInvitation::decode(&malformed).is_err(),341 "malformed invitation unexpectedly accepted: {case}"342 );343 }344 }345346 #[test]347 fn every_nonzero_known_grant_combination_round_trips_exactly() {348 let host = SigningKey::from_bytes(&[7; 32]);349 let peer = SigningKey::from_bytes(&[9; 32]);350351 for bits in 1..=InvitationGrants::ALL.bits() {352 let grants = InvitationGrants::new(bits).unwrap();353 let claims = InvitationClaims::new(354 host.verifying_key().to_bytes(),355 peer.verifying_key().to_bytes(),356 1_700_000_000,357 1_700_000_600,358 3,359 [bits; 32],360 grants,361 )362 .unwrap();363 let token = SignedInvitation::issue(claims, &[7; 32]).unwrap().encode();364365 assert_eq!(366 SignedInvitation::decode(&token).unwrap().claims.grants,367 grants368 );369 }370 }371372 #[test]373 fn claims_reject_unknown_grants_lifetime_epoch_and_nonce() {374 assert!(InvitationGrants::new(0).is_err());375 assert!(InvitationGrants::new(0x80).is_err());376 let host = SigningKey::from_bytes(&[7; 32]);377 let peer = SigningKey::from_bytes(&[9; 32]);378 let make = |issued, expires, epoch, nonce| {379 InvitationClaims::new(380 host.verifying_key().to_bytes(),381 peer.verifying_key().to_bytes(),382 issued,383 expires,384 epoch,385 nonce,386 InvitationGrants::VIEW,387 )388 };389 assert!(make(10, 10, 1, [1; 32]).is_err());390 assert!(make(10, 911, 1, [1; 32]).is_err());391 assert!(make(10, 20, 0, [1; 32]).is_err());392 assert!(make(10, 20, 1, [0; 32]).is_err());393 }394395 #[test]396 fn signing_key_must_match_claimed_host() {397 let invitation = invitation();398 assert!(SignedInvitation::issue(invitation.claims, &[8; 32]).is_err());399 }400}