feat: compress large token scopes with brotli

This commit is contained in:
Louis Escher
2026-08-13 16:09:03 +00:00
committed by Tangled
parent a5a2f30bbe
commit 434079a732
8 changed files with 213 additions and 11 deletions
+1 -1
View File
@@ -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.
Generated
+37
View File
@@ -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",
+1
View File
@@ -24,3 +24,4 @@ subtle = { workspace = true }
totp-rs = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
brotli = "8.0.4"
+145
View File
@@ -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<u8> {
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<String, ScopeDecodeError> {
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<String, ScopeDecodeError> {
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)
);
}
}
+3
View File
@@ -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,
+4 -2
View File
@@ -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,
+14 -2
View File
@@ -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"));
+8 -6
View File
@@ -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]