Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
75
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 143 lines 5.0 kB view raw
1use atrium_crypto::did::parse_multikey; 2use atrium_crypto::verify::Verifier; 3use jwt_compact::UntrustedToken; 4use serde::Deserialize; 5use std::collections::HashMap; 6use std::time::Duration; 7use thiserror::Error; 8 9#[derive(Debug, Deserialize)] 10struct MiniDoc { 11 signing_key: String, 12 did: String, 13} 14 15#[derive(Error, Debug)] 16pub enum VerifyError { 17 #[error("The cross-service authorization token failed verification: {0}")] 18 VerificationFailed(&'static str), 19 #[error("Error trying to resolve the DID to a signing key, retry in a moment: {0}")] 20 ResolutionFailed(&'static str), 21} 22 23pub struct TokenVerifier { 24 client: reqwest::Client, 25} 26 27impl TokenVerifier { 28 pub fn new() -> Self { 29 let client = reqwest::Client::builder() 30 .user_agent(format!( 31 "microcosm pocket v{} (dev: @bad-example.com)", 32 env!("CARGO_PKG_VERSION") 33 )) 34 .no_proxy() 35 .timeout(Duration::from_secs(12)) // slingshot timeout is 10s 36 .build() 37 .unwrap(); 38 Self { client } 39 } 40 41 pub async fn verify( 42 &self, 43 expected_lxm: &str, 44 token: &str, 45 ) -> Result<(String, String), VerifyError> { 46 let untrusted = UntrustedToken::new(token).unwrap(); 47 48 // danger! unfortunately we need to decode the DID from the jwt body before we have a public key to verify the jwt with 49 let Ok(untrusted_claims) = 50 untrusted.deserialize_claims_unchecked::<HashMap<String, String>>() 51 else { 52 return Err(VerifyError::VerificationFailed( 53 "could not deserialize jtw claims", 54 )); 55 }; 56 57 // get the (untrusted!) claimed DID 58 let Some(untrusted_did) = untrusted_claims.custom.get("iss") else { 59 return Err(VerifyError::VerificationFailed( 60 "jwt must include the user's did in `iss`", 61 )); 62 }; 63 64 // bail if it's not even a user-ish did 65 if !untrusted_did.starts_with("did:") { 66 return Err(VerifyError::VerificationFailed("iss should be a did")); 67 } 68 if untrusted_did.contains("#") { 69 return Err(VerifyError::VerificationFailed( 70 "iss should be a user did without a service identifier", 71 )); 72 } 73 74 let endpoint = 75 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; 76 let doc: MiniDoc = self 77 .client 78 .get(format!("{endpoint}?identifier={untrusted_did}")) 79 .send() 80 .await 81 .map_err(|_| VerifyError::ResolutionFailed("failed to fetch minidoc"))? 82 .error_for_status() 83 .map_err(|_| VerifyError::ResolutionFailed("non-ok response for minidoc"))? 84 .json() 85 .await 86 .map_err(|_| VerifyError::ResolutionFailed("failed to parse json to minidoc"))?; 87 88 // sanity check before we go ahead with this signing key 89 if doc.did != *untrusted_did { 90 return Err(VerifyError::VerificationFailed( 91 "wtf, resolveMiniDoc returned a doc for a different DID, slingshot bug", 92 )); 93 } 94 95 let Ok((alg, public_key)) = parse_multikey(&doc.signing_key) else { 96 return Err(VerifyError::VerificationFailed( 97 "could not parse signing key form minidoc", 98 )); 99 }; 100 101 // i _guess_ we've successfully bootstrapped the verification of the jwt unless this fails 102 if let Err(e) = Verifier::default().verify( 103 alg, 104 &public_key, 105 &untrusted.signed_data, 106 untrusted.signature_bytes(), 107 ) { 108 log::warn!("jwt verification failed: {e}"); 109 return Err(VerifyError::VerificationFailed( 110 "jwt signature verification failed", 111 )); 112 } 113 114 // past this point we're should have established trust. crossing ts and dotting is. 115 let did = &untrusted_did; 116 let claims = &untrusted_claims; 117 118 let Some(aud) = claims.custom.get("aud") else { 119 return Err(VerifyError::VerificationFailed("missing aud")); 120 }; 121 let Some(mut aud) = aud.strip_prefix("did:web:") else { 122 return Err(VerifyError::VerificationFailed("expected a did:web aud")); 123 }; 124 if let Some((aud_without_hash, _)) = aud.split_once("#") { 125 log::warn!("aud claim is missing service id fragment: {aud:?}"); 126 aud = aud_without_hash; 127 } 128 let Some(lxm) = claims.custom.get("lxm") else { 129 return Err(VerifyError::VerificationFailed("missing lxm")); 130 }; 131 if lxm != expected_lxm { 132 return Err(VerifyError::VerificationFailed("wrong lxm")); 133 } 134 135 Ok((did.to_string(), aud.to_string())) 136 } 137} 138 139impl Default for TokenVerifier { 140 fn default() -> Self { 141 Self::new() 142 } 143}