diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef6c728..8f69075 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ In order of importance the following rules describe what "correct" means for Tra and not something said application relies on for proper functioning. There is bound to be edge cases that these rules don't fully cover. -Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual applicaion. +Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual application. Even Bluesky. The rules above are meant to capture Tranquils goals of being correct while being community oriented and avoiding as much "Bluesky-defaultism" as possible. diff --git a/Cargo.lock b/Cargo.lock index 6a7c118..4679a5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -1250,6 +1265,27 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -7682,6 +7718,7 @@ dependencies = [ "base32", "base64 0.22.1", "bcrypt", + "brotli", "chrono", "hmac", "k256", diff --git a/crates/tranquil-auth/Cargo.toml b/crates/tranquil-auth/Cargo.toml index 6a4c981..863c1d9 100644 --- a/crates/tranquil-auth/Cargo.toml +++ b/crates/tranquil-auth/Cargo.toml @@ -24,3 +24,4 @@ subtle = { workspace = true } totp-rs = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } +brotli = "8.0.4" diff --git a/crates/tranquil-auth/src/compress.rs b/crates/tranquil-auth/src/compress.rs new file mode 100644 index 0000000..263e09d --- /dev/null +++ b/crates/tranquil-auth/src/compress.rs @@ -0,0 +1,145 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use brotli::{CompressorWriter, Decompressor}; +use std::fmt; +use std::io::{Read, Write}; + +const COMPRESSED_PREFIX: &str = "$br$"; +const QUALITY: u32 = 9; +const WINDOW_BITS: u32 = 16; +const BUFFER_SIZE: usize = 4096; +const MAX_DECOMPRESSED_LEN: u64 = 64 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeDecodeError { + Base64DecodeFailed, + DecompressFailed, + TooLarge, +} + +impl fmt::Display for ScopeDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Base64DecodeFailed => write!(f, "Base64 decode of compressed scope failed"), + Self::DecompressFailed => write!(f, "Brotli decompression of scope failed"), + Self::TooLarge => write!(f, "Decompressed scope exceeds maximum length"), + } + } +} + +impl std::error::Error for ScopeDecodeError {} + +fn brotli_compress(input: &str) -> Vec { + let mut writer = CompressorWriter::new(Vec::new(), BUFFER_SIZE, QUALITY, WINDOW_BITS); + + writer + .write_all(input.as_bytes()) + .expect("writing to a Vec cannot fail"); + + writer.into_inner() +} + +fn brotli_decompress(input: &[u8]) -> Result { + let mut output = String::new(); + + Decompressor::new(input, BUFFER_SIZE) + .take(MAX_DECOMPRESSED_LEN + 1) + .read_to_string(&mut output) + .map_err(|_| ScopeDecodeError::DecompressFailed)?; + + if output.len() as u64 > MAX_DECOMPRESSED_LEN { + return Err(ScopeDecodeError::TooLarge); + } + + Ok(output) +} + +pub fn encode_scope(scope: &str) -> String { + let encoded = URL_SAFE_NO_PAD.encode(brotli_compress(scope)); + + if COMPRESSED_PREFIX.len() + encoded.len() < scope.len() { + format!("{COMPRESSED_PREFIX}{encoded}") + } else { + scope.to_owned() + } +} + +pub fn decode_scope(scope: &str) -> Result { + let Some(encoded) = scope.strip_prefix(COMPRESSED_PREFIX) else { + return Ok(scope.to_owned()); + }; + + let compressed = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| ScopeDecodeError::Base64DecodeFailed)?; + + brotli_decompress(&compressed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn long_scope() -> String { + let mut scope = String::from("transition:generic transition:chat.bsky"); + for collection in [ + "social.colibri.message", + "social.colibri.community", + "social.colibri.reaction", + "social.colibri.member", + "social.colibri.channel.read", + ] { + scope.push_str(&format!(" repo:{collection}?action=create&action=delete")); + } + scope + } + + #[test] + fn long_scope_roundtrips_through_compression() { + let scope = long_scope(); + let encoded = encode_scope(&scope); + + assert!(encoded.starts_with(COMPRESSED_PREFIX)); + assert!(encoded.len() < scope.len()); + assert_eq!(decode_scope(&encoded).unwrap(), scope); + } + + #[test] + fn short_scope_stays_plaintext() { + let encoded = encode_scope("com.atproto.access"); + + assert_eq!(encoded, "com.atproto.access"); + assert_eq!(decode_scope(&encoded).unwrap(), "com.atproto.access"); + } + + #[test] + fn untagged_scope_passes_through() { + assert_eq!( + decode_scope("com.atproto.refresh").unwrap(), + "com.atproto.refresh" + ); + assert_eq!(decode_scope("").unwrap(), ""); + } + + #[test] + fn malformed_compressed_scope_errors_instead_of_panicking() { + assert_eq!( + decode_scope("$br$not valid base64!"), + Err(ScopeDecodeError::Base64DecodeFailed) + ); + assert_eq!( + decode_scope("$br$AAAAAAAAAAAAAAAA"), + Err(ScopeDecodeError::DecompressFailed) + ); + } + + #[test] + fn compression_bomb_is_rejected() { + let bomb = encode_scope(&"a".repeat(MAX_DECOMPRESSED_LEN as usize * 2)); + let encoded = bomb.strip_prefix(COMPRESSED_PREFIX).unwrap_or(&bomb); + + assert_eq!( + decode_scope(&format!("{COMPRESSED_PREFIX}{encoded}")), + Err(ScopeDecodeError::TooLarge) + ); + } +} diff --git a/crates/tranquil-auth/src/lib.rs b/crates/tranquil-auth/src/lib.rs index 1f9b8c6..611ab55 100644 --- a/crates/tranquil-auth/src/lib.rs +++ b/crates/tranquil-auth/src/lib.rs @@ -1,3 +1,4 @@ +mod compress; mod token; mod totp; mod types; @@ -12,6 +13,8 @@ pub use token::{ create_service_token_hs256, }; +pub use compress::{ScopeDecodeError, decode_scope, encode_scope}; + pub use totp::{ TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code, diff --git a/crates/tranquil-auth/src/token.rs b/crates/tranquil-auth/src/token.rs index 9088b67..c6f66fc 100644 --- a/crates/tranquil-auth/src/token.rs +++ b/crates/tranquil-auth/src/token.rs @@ -1,3 +1,5 @@ +use crate::compress::encode_scope; + use super::types::{ ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata, }; @@ -205,7 +207,7 @@ fn create_signed_token_pinned( aud: format!("did:web:{}", aud_hostname), exp: expiration, iat: Utc::now().timestamp(), - scope: Some(scope.to_string()), + scope: Some(encode_scope(scope)), lxm: None, jti: jti.clone(), act, @@ -328,7 +330,7 @@ fn create_hs256_token_with_metadata( ), exp: expiration, iat: Utc::now().timestamp(), - scope: Some(scope.to_string()), + scope: Some(encode_scope(scope)), lxm: None, jti: jti.clone(), act: None, diff --git a/crates/tranquil-auth/src/verify.rs b/crates/tranquil-auth/src/verify.rs index fb59fc3..b0f74bf 100644 --- a/crates/tranquil-auth/src/verify.rs +++ b/crates/tranquil-auth/src/verify.rs @@ -1,3 +1,5 @@ +use crate::compress::decode_scope; + use super::types::{ Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType, TokenVerifyError, UnsafeClaims, @@ -164,9 +166,15 @@ pub fn verify_token_es256k( .decode(claims_b64) .map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?; - let claims: Claims = serde_json::from_slice(&claims_bytes) + let mut claims: Claims = serde_json::from_slice(&claims_bytes) .map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?; + if let Some(scope) = &claims.scope { + claims.scope = Some( + decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?, + ); + } + let now = Utc::now().timestamp(); if claims.exp < now { return Err(TokenVerifyError::Expired); @@ -244,9 +252,13 @@ fn verify_token_hs256_internal( .decode(claims_b64) .context("Base64 decode of claims failed")?; - let claims: Claims = + let mut claims: Claims = serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?; + if let Some(scope) = &claims.scope { + claims.scope = Some(decode_scope(scope).context("Invalid scope claim encoding")?); + } + let now = Utc::now().timestamp(); if claims.exp < now { return Err(anyhow!("Token expired")); diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index 5fe4fec..b0b032e 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -399,6 +399,8 @@ fn build_expanded_scopes( let combined_rpc_scopes = rpc_scopes.join(" "); format!("{} {}", combined_repo_scopes, combined_rpc_scopes) + .trim() + .to_string() } #[cfg(test)] @@ -477,9 +479,8 @@ mod tests { }]; let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); - assert!(expanded.contains("repo:io.atcr.manifest?action=create")); - assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); - assert!(expanded.contains("repo:io.atcr.sailor.star?action=create")); + assert!(expanded.contains("repo:io.atcr.manifest?action=create&action=delete")); + assert!(expanded.contains("repo:io.atcr.sailor.star?action=create&action=delete")); assert!(!expanded.contains("app.bsky.feed.post")); } @@ -494,9 +495,10 @@ mod tests { }]; let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); - assert!(expanded.contains("repo:io.atcr.manifest?action=create")); - assert!(expanded.contains("repo:io.atcr.manifest?action=update")); - assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); + assert!(expanded.contains("repo:io.atcr.manifest?action=")); + assert!(expanded.contains("action=create")); + assert!(expanded.contains("action=update")); + assert!(expanded.contains("action=delete")); } #[test]