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