A better Rust ATProto crate
1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2use chrono::Utc; 3use http::{Request, Response, header::InvalidHeaderValue}; 4use jacquard_common::{ 5 CowStr, 6 http_client::HttpClient, 7 session::{MemorySessionStore, SessionStore, SessionStoreError}, 8}; 9use jose_jwa::{Algorithm, Signing}; 10use jose_jwk::{Jwk, Key, crypto}; 11use p256::ecdsa::SigningKey; 12use rand::{RngCore, SeedableRng}; 13use sha2::Digest; 14use smol_str::{SmolStr, ToSmolStr}; 15 16use crate::jose::{ 17 create_signed_jwt, 18 jws::RegisteredHeader, 19 jwt::{Claims, PublicClaims, RegisteredClaims}, 20}; 21 22pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; 23 24#[derive(serde::Deserialize)] 25struct ErrorResponse { 26 error: String, 27} 28 29#[derive(thiserror::Error, Debug, miette::Diagnostic)] 30pub enum Error { 31 #[error(transparent)] 32 InvalidHeaderValue(#[from] InvalidHeaderValue), 33 #[error(transparent)] 34 SessionStore(#[from] SessionStoreError), 35 #[error("crypto error: {0:?}")] 36 JwkCrypto(crypto::Error), 37 #[error("key does not match any alg supported by the server")] 38 UnsupportedKey, 39 #[error(transparent)] 40 SerdeJson(#[from] serde_json::Error), 41 #[error("Inner: {0}")] 42 Inner(#[source] Box<dyn std::error::Error + Send + Sync>), 43} 44 45type Result<T> = core::result::Result<T, Error>; 46 47#[inline] 48pub(crate) fn generate_jti() -> CowStr<'static> { 49 let mut rng = rand::rngs::SmallRng::from_entropy(); 50 let mut bytes = [0u8; 12]; 51 rng.fill_bytes(&mut bytes); 52 URL_SAFE_NO_PAD.encode(bytes).into() 53} 54 55/// Build a compact JWS (ES256) for DPoP with embedded public JWK. 56#[inline] 57pub fn build_dpop_proof<'s>( 58 key: &Key, 59 method: CowStr<'s>, 60 url: CowStr<'s>, 61 nonce: Option<CowStr<'s>>, 62 ath: Option<CowStr<'s>>, 63) -> Result<CowStr<'s>> { 64 let secret = match crypto::Key::try_from(key).map_err(Error::JwkCrypto)? { 65 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 66 _ => return Err(Error::UnsupportedKey), 67 }; 68 build_dpop_proof_with_secret(&secret, method, url, nonce, ath) 69} 70 71/// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips. 72#[inline] 73pub fn build_dpop_proof_with_secret<'s>( 74 secret: &p256::SecretKey, 75 method: CowStr<'s>, 76 url: CowStr<'s>, 77 nonce: Option<CowStr<'s>>, 78 ath: Option<CowStr<'s>>, 79) -> Result<CowStr<'s>> { 80 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 81 header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 82 header.jwk = Some(Jwk { 83 key: Key::from(&crypto::Key::from(secret.public_key())), 84 prm: Default::default(), 85 }); 86 87 let claims = Claims { 88 registered: RegisteredClaims { 89 jti: Some(generate_jti()), 90 iat: Some(Utc::now().timestamp()), 91 ..Default::default() 92 }, 93 public: PublicClaims { 94 htm: Some(method), 95 htu: Some(url), 96 ath: ath, 97 nonce: nonce, 98 }, 99 }; 100 Ok(create_signed_jwt( 101 SigningKey::from(secret.clone()), 102 header.into(), 103 claims, 104 )?) 105} 106 107pub struct DpopClient<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>> 108where 109 S: SessionStore<CowStr<'static>, CowStr<'static>>, 110{ 111 inner: T, 112 pub(crate) key: Key, 113 nonces: S, 114 is_auth_server: bool, 115} 116 117impl<T> DpopClient<T> { 118 pub fn new( 119 key: Key, 120 http_client: T, 121 is_auth_server: bool, 122 supported_algs: &Option<Vec<CowStr<'static>>>, 123 ) -> Result<Self> { 124 if let Some(algs) = supported_algs { 125 let alg = CowStr::from(match &key { 126 Key::Ec(ec) => match &ec.crv { 127 jose_jwk::EcCurves::P256 => "ES256", 128 _ => unimplemented!(), 129 }, 130 _ => unimplemented!(), 131 }); 132 if !algs.contains(&alg) { 133 return Err(Error::UnsupportedKey); 134 } 135 } 136 let nonces = MemorySessionStore::<CowStr<'static>, CowStr<'static>>::default(); 137 Ok(Self { 138 inner: http_client, 139 key, 140 nonces, 141 is_auth_server, 142 }) 143 } 144} 145 146impl<T, S> DpopClient<T, S> 147where 148 S: SessionStore<CowStr<'static>, CowStr<'static>>, 149{ 150 fn build_proof<'s>( 151 &self, 152 method: CowStr<'s>, 153 url: CowStr<'s>, 154 ath: Option<CowStr<'s>>, 155 nonce: Option<CowStr<'s>>, 156 ) -> Result<CowStr<'s>> { 157 build_dpop_proof(&self.key, method, url, nonce, ath) 158 } 159 fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> bool { 160 // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 161 if self.is_auth_server { 162 if response.status() == 400 { 163 if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 164 return res.error == "use_dpop_nonce"; 165 }; 166 } 167 } 168 // https://datatracker.ietf.org/doc/html/rfc6750#section-3 169 // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 170 else if response.status() == 401 { 171 if let Some(www_auth) = response 172 .headers() 173 .get("WWW-Authenticate") 174 .and_then(|v| v.to_str().ok()) 175 { 176 return www_auth.starts_with("DPoP") 177 && www_auth.contains(r#"error="use_dpop_nonce""#); 178 } 179 } 180 false 181 } 182} 183 184impl<T, S> HttpClient for DpopClient<T, S> 185where 186 T: HttpClient + Send + Sync + 'static, 187 S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static, 188{ 189 type Error = Error; 190 191 async fn send_http( 192 &self, 193 mut request: Request<Vec<u8>>, 194 ) -> core::result::Result<Response<Vec<u8>>, Self::Error> { 195 let uri = request.uri(); 196 let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr()); 197 let method = CowStr::Owned(request.method().to_smolstr()); 198 let uri = CowStr::Owned(uri.to_smolstr()); 199 // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 200 let ath = request 201 .headers() 202 .get("Authorization") 203 .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 204 .map(|auth| { 205 URL_SAFE_NO_PAD 206 .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 207 .into() 208 }); 209 210 let init_nonce = self.nonces.get(&nonce_key).await; 211 let init_proof = 212 self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?; 213 request.headers_mut().insert("DPoP", init_proof.parse()?); 214 let response = self 215 .inner 216 .send_http(request.clone()) 217 .await 218 .map_err(|e| Error::Inner(e.into()))?; 219 220 let next_nonce = response 221 .headers() 222 .get("DPoP-Nonce") 223 .and_then(|v| v.to_str().ok()) 224 .map(|c| CowStr::Owned(SmolStr::new(c))); 225 match &next_nonce { 226 Some(s) if next_nonce != init_nonce => { 227 // Store the fresh nonce for future requests 228 self.nonces.set(nonce_key, s.clone()).await?; 229 } 230 _ => { 231 // No nonce was returned or it is the same as the one we sent. No need to 232 // update the nonce store, or retry the request. 233 return Ok(response); 234 } 235 } 236 237 if !self.is_use_dpop_nonce_error(&response) { 238 return Ok(response); 239 } 240 let next_proof = self.build_proof(method, uri, ath, next_nonce)?; 241 request.headers_mut().insert("DPoP", next_proof.parse()?); 242 let response = self 243 .inner 244 .send_http(request) 245 .await 246 .map_err(|e| Error::Inner(e.into()))?; 247 Ok(response) 248 } 249} 250 251impl<T: Clone> Clone for DpopClient<T> { 252 fn clone(&self) -> Self { 253 Self { 254 inner: self.inner.clone(), 255 key: self.key.clone(), 256 nonces: self.nonces.clone(), 257 is_auth_server: self.is_auth_server, 258 } 259 } 260}