Noreposts Feed

Implement proper JWT signature verification

Closes #1

- Add full ES256K signature verification using atrium-crypto
- Resolve issuer DID documents via atrium-identity
- Extract and verify publicKeyMultibase from #atproto verification method
- Convert keys to did:key format for signature verification
- Update atrium-xrpc-client to v0.5 for compatibility
- Remove explicit atrium-api dependency (pulled transitively)

Security impact: Prevents JWT forgery attacks by cryptographically
verifying that tokens are signed by the issuer's private key.

+116 -39
+10 -2
Cargo.toml
··· 10 10 url = "2.3" 11 11 12 12 # AT Protocol libraries 13 - atrium-api = "0.1" 14 - atrium-xrpc-client = "0.1" 13 + atrium-xrpc-client = "0.5" 14 + atrium-identity = "0.1" 15 + atrium-crypto = "0.1" 16 + atrium-common = "0.1" 15 17 16 18 # Web server 17 19 axum = "0.7" ··· 45 47 46 48 # Environment 47 49 dotenvy = "0.15" 50 + 51 + # Caching 52 + moka = { version = "0.12", features = ["future"] } 53 + 54 + # Encoding 55 + base64 = "0.22"
+105 -36
src/auth.rs
··· 1 1 use anyhow::{anyhow, Result}; 2 + use atrium_common::resolver::Resolver; 3 + use atrium_crypto::did::{format_did_key, parse_multikey}; 4 + use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}; 5 + use atrium_xrpc_client::reqwest::ReqwestClient; 6 + use base64::Engine; 2 7 use jwt_compact::UntrustedToken; 8 + use std::sync::Arc; 3 9 use tracing::{debug, warn}; 4 10 5 11 use crate::types::JwtClaims; ··· 18 24 // expiration: Option<i64>, 19 25 // } 20 26 21 - pub fn validate_jwt(token: &str, service_did: &str) -> Result<JwtClaims> { 27 + /// Resolves a DID and extracts the atproto signing key as a did:key string 28 + async fn resolve_signing_key( 29 + resolver: &CommonDidResolver<ReqwestClient>, 30 + did_str: &str, 31 + ) -> Result<String> { 32 + debug!("Resolving DID: {}", did_str); 33 + 34 + // Convert string to Did type 35 + let did = did_str.parse().map_err(|e| { 36 + warn!("Invalid DID format: {}", e); 37 + anyhow!("Invalid DID format: {}", e) 38 + })?; 39 + 40 + // Resolve the DID document 41 + let did_doc = resolver.resolve(&did).await.map_err(|e| { 42 + warn!("Failed to resolve DID {}: {}", did_str, e); 43 + anyhow!("Failed to resolve DID: {}", e) 44 + })?; 45 + 46 + debug!("DID document resolved: {:?}", did_doc); 47 + 48 + // Use the built-in helper to get the signing key 49 + let verification_method = did_doc.get_signing_key().ok_or_else(|| { 50 + warn!("No atproto verification method found in DID document"); 51 + anyhow!("No atproto signing key found in DID document") 52 + })?; 53 + 54 + debug!("Found verification method: {:?}", verification_method); 55 + 56 + // Extract publicKeyMultibase 57 + let public_key_multibase = verification_method 58 + .public_key_multibase 59 + .as_ref() 60 + .ok_or_else(|| { 61 + warn!("Verification method missing publicKeyMultibase"); 62 + anyhow!("Missing publicKeyMultibase in verification method") 63 + })?; 64 + 65 + debug!("Public key multibase: {}", public_key_multibase); 66 + 67 + // Parse the multibase-encoded key 68 + let (algorithm, key_bytes) = parse_multikey(public_key_multibase).map_err(|e| { 69 + warn!("Failed to parse multikey: {}", e); 70 + anyhow!("Invalid publicKeyMultibase format: {}", e) 71 + })?; 72 + 73 + debug!( 74 + "Parsed key: algorithm={:?}, key_len={}", 75 + algorithm, 76 + key_bytes.len() 77 + ); 78 + 79 + // Format as did:key 80 + let did_key = format_did_key(algorithm, &key_bytes).map_err(|e| { 81 + warn!("Failed to format did:key: {}", e); 82 + anyhow!("Failed to convert key to did:key format: {}", e) 83 + })?; 84 + 85 + debug!("Formatted did:key: {}", did_key); 86 + Ok(did_key) 87 + } 88 + 89 + pub async fn validate_jwt(token: &str, service_did: &str) -> Result<JwtClaims> { 22 90 // Token should already have "Bearer " prefix stripped by caller 23 91 debug!("Validating JWT token (length: {})", token.len()); 24 92 debug!("Expected audience: {}", service_did); ··· 86 154 return Err(anyhow!("JWT has expired")); 87 155 } 88 156 89 - // TODO: In production, we should: 90 - // 1. Fetch the user's DID document from iss 91 - // 2. Extract their public key 92 - // 3. Verify the signature with Es256k::verify() 93 - // For now, we skip signature verification but validate structure and claims 157 + // Verify signature 158 + debug!("Verifying JWT signature for issuer: {}", iss); 94 159 95 - debug!("JWT validated successfully for issuer: {}", iss); 96 - Ok(JwtClaims { iss, aud, exp }) 97 - } 160 + // Create DID resolver 161 + // Note: base_uri is not used for DID resolution, so we use a placeholder 162 + let http_client = ReqwestClient::new("https://plc.directory"); 163 + let resolver_config = CommonDidResolverConfig { 164 + plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 165 + http_client: Arc::new(http_client), 166 + }; 167 + let resolver = CommonDidResolver::new(resolver_config); 98 168 99 - // Production implementation would need this: 100 - /* 101 - pub async fn validate_jwt_production(auth_header: &str, service_did: &str) -> Result<JwtClaims> { 102 - let token = auth_header 103 - .strip_prefix("Bearer ") 104 - .ok_or_else(|| anyhow!("Invalid authorization header format"))?; 169 + // Resolve the issuer's signing key 170 + let did_key = resolve_signing_key(&resolver, &iss).await?; 105 171 106 - // 1. Decode JWT header to get the signing key ID 107 - let header = decode_header(token)?; 108 - 109 - // 2. Extract issuer DID from token payload (without verification) 110 - let mut validation = Validation::new(Algorithm::ES256K); 111 - validation.insecure_disable_signature_validation(); 112 - let temp_decode = decode::<JwtClaims>(token, &DecodingKey::from_secret(b"temp"), &validation)?; 113 - let issuer_did = temp_decode.claims.iss; 114 - 115 - // 3. Fetch DID document for the issuer 116 - let did_doc = fetch_did_document(&issuer_did).await?; 172 + // Extract the signed portion of the JWT (header.payload) 173 + // JWT format is: header.payload.signature 174 + let parts: Vec<&str> = token.split('.').collect(); 175 + if parts.len() != 3 { 176 + warn!("Invalid JWT format: expected 3 parts, got {}", parts.len()); 177 + return Err(anyhow!("Invalid JWT format")); 178 + } 117 179 118 - // 4. Extract the appropriate verification key 119 - let verification_key = extract_verification_key(&did_doc, &header.kid)?; 180 + let signed_data = format!("{}.{}", parts[0], parts[1]); 181 + let signature_b64 = parts[2]; 120 182 121 - // 5. Validate the JWT with the real key 122 - let mut validation = Validation::new(Algorithm::ES256K); 123 - validation.validate_exp = true; 124 - validation.set_audience(&[service_did]); 183 + // Decode the base64url signature 184 + let signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 185 + .decode(signature_b64) 186 + .map_err(|e| { 187 + warn!("Failed to decode JWT signature: {}", e); 188 + anyhow!("Invalid JWT signature encoding: {}", e) 189 + })?; 125 190 126 - let decoding_key = DecodingKey::from_ec_pem(&verification_key)?; 127 - let token_data = decode::<JwtClaims>(token, &decoding_key, &validation)?; 191 + // Verify the signature 192 + atrium_crypto::verify::verify_signature(&did_key, signed_data.as_bytes(), &signature_bytes) 193 + .map_err(|e| { 194 + warn!("JWT signature verification failed: {}", e); 195 + anyhow!("Invalid JWT signature: {}", e) 196 + })?; 128 197 129 - Ok(token_data.claims) 198 + debug!("JWT signature verified successfully for issuer: {}", iss); 199 + Ok(JwtClaims { iss, aud, exp }) 130 200 } 131 - */
+1 -1
src/main.rs
··· 223 223 let token = auth_str.strip_prefix("Bearer ").unwrap_or(auth_str); 224 224 225 225 info!("Validating JWT for request"); 226 - let requester_did = match validate_jwt(token, &state.service_did) { 226 + let requester_did = match validate_jwt(token, &state.service_did).await { 227 227 Ok(claims) => { 228 228 info!("Authenticated request from DID: {}", claims.iss); 229 229 claims.iss