Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
at main 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}