diff --git a/Cargo.lock b/Cargo.lock index 824d86e..12a8393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2868,6 +2868,7 @@ dependencies = [ "base64", "bb8", "bb8-redis", + "chrono", "handlebars", "hickory-resolver", "log", diff --git a/Cargo.toml b/Cargo.toml index ddf36f5..38f77a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ redis = "0.32.4" tokio = { version = "1.46.1", features = ["full"] } markdown = "1.0.0" rust-embed = { version = "8.7.2", features = ["include-exclude"] } +base64 = "0.22" diff --git a/shared/Cargo.toml b/shared/Cargo.toml index a0b3764..d5ab594 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -27,3 +27,5 @@ handlebars = { version = "6.3.2" } async-trait = "0.1.88" atrium-xrpc-client.workspace = true tokio.workspace = true +base64.workspace = true +chrono.workspace = true diff --git a/shared/challenges_markdown/six/part_one.md b/shared/challenges_markdown/six/part_one.md index 3038ede..05afef0 100644 --- a/shared/challenges_markdown/six/part_one.md +++ b/shared/challenges_markdown/six/part_one.md @@ -1 +1,26 @@ -Day six is about XRPC. First up is we have them create an LXM jwt. We validate it's for the correct lxm and did. \ No newline at end of file +## Service Authentication (serviceAuth) + +In the AT Protocol, services authenticate requests between each other using **serviceAuth** — a signed JWT token. When your PDS proxies a request to another service on your behalf, it creates a JWT signed with your account's signing key. + +For this challenge, you'll create a serviceAuth JWT yourself. Here's what you need: + +### JWT Structure + +Your JWT must have these **header** fields: +- `alg`: `ES256` (if your signing key is P-256) or `ES256K` (if secp256k1) +- `typ`: `JWT` + +And these **payload** claims: +- `iss`: Your DID (e.g. `did:plc:abc123...`) +- `aud`: `{{service_did}}` +- `lxm`: `{{lxm_part_one}}` +- `exp`: A UNIX timestamp in the future (current time + 60 seconds works) +- `iat`: Current UNIX timestamp + +Sign it with your account's **repo signing key** — the same key listed in your DID document's verification method. + +The resulting JWT is three base64url-encoded segments joined by dots: `header.payload.signature` + +### Submit Your JWT + +Paste the raw JWT string below and we'll verify the signature against your DID document. diff --git a/shared/challenges_markdown/six/part_two.md b/shared/challenges_markdown/six/part_two.md index 827482d..58b92f6 100644 --- a/shared/challenges_markdown/six/part_two.md +++ b/shared/challenges_markdown/six/part_two.md @@ -1 +1,18 @@ -Part 2 is them making an xrpc request and if set properly we give them the day. \ No newline at end of file +## XRPC Service Proxying + +Now that you can create a serviceAuth JWT, let's use it in an actual XRPC request. + +Make an HTTP GET request to our XRPC endpoint with your JWT in the `Authorization` header: + +``` +GET https://{{service_did_domain}}/xrpc/codes.advent.challenge.getDay6Code +Authorization: Bearer +``` + +Your JWT for this request must have: +- `iss`: Your DID +- `aud`: `{{service_did}}` +- `lxm`: `{{lxm_part_two}}` +- `exp`: A UNIX timestamp in the future + +The response will contain a JSON object with your verification code. Paste that code below. diff --git a/shared/src/advent/challenges/day_six.rs b/shared/src/advent/challenges/day_six.rs index 210ba78..ba20f54 100644 --- a/shared/src/advent/challenges/day_six.rs +++ b/shared/src/advent/challenges/day_six.rs @@ -1,12 +1,22 @@ +use crate::HandleResolver; use crate::OAuthAgentType; use crate::advent::day::Day; -use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse}; +use crate::advent::{AdventChallenge, AdventError, AdventPart, ChallengeCheckResponse}; +use crate::atrium::service_auth::{decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes}; use async_trait::async_trait; +use atrium_api::types::string::Did; +use atrium_common::resolver::Resolver; +use serde_json::json; use sqlx::PgPool; +pub const LXM_PART_ONE: &str = "codes.advent.challenge.verifyDay6"; +pub const LXM_PART_TWO: &str = "codes.advent.challenge.getDay6Code"; + pub struct DaySix { pub pool: PgPool, pub oauth_client: Option, + pub handle_resolver: HandleResolver, + pub service_did: String, } #[async_trait] @@ -16,22 +26,158 @@ impl AdventChallenge for DaySix { } fn day(&self) -> Day { - Day::Five + Day::Six } fn has_part_two(&self) -> bool { - false + true } fn requires_manual_verification_part_one(&self) -> bool { true } + fn requires_manual_verification_part_two(&self) -> bool { + true + } + + async fn build_additional_context( + &self, + _did: &str, + _part: &AdventPart, + _code: &str, + ) -> Result, AdventError> { + // Strip "did:web:" prefix for the domain used in URLs + let domain = self.service_did.strip_prefix("did:web:").unwrap_or(&self.service_did); + Ok(Some(json!({ + "service_did": self.service_did, + "service_did_domain": domain, + "lxm_part_one": LXM_PART_ONE, + "lxm_part_two": LXM_PART_TWO, + }))) + } + async fn check_part_one( &self, - _did: String, - _verification_code: Option, + did: String, + verification_code: Option, ) -> Result { - todo!() + let jwt = match verification_code { + Some(code) if !code.trim().is_empty() => code.trim().to_string(), + _ => { + return Ok(ChallengeCheckResponse::Incorrect( + "Please paste your serviceAuth JWT".to_string(), + )); + } + }; + + // Decode claims first (without signature verification) to get the issuer + let claims = match decode_jwt_claims(&jwt) { + Ok(claims) => claims, + Err(e) => { + return Ok(ChallengeCheckResponse::Incorrect(format!( + "Could not decode JWT: {e}" + ))); + } + }; + + // Verify the issuer matches the logged-in user + if claims.iss != did { + return Ok(ChallengeCheckResponse::Incorrect(format!( + "The JWT issuer (iss) '{}' does not match your DID '{}'", + claims.iss, did + ))); + } + + // Check aud + if claims.aud != self.service_did { + return Ok(ChallengeCheckResponse::Incorrect(format!( + "The JWT audience (aud) '{}' does not match the expected value '{}'", + claims.aud, self.service_did + ))); + } + + // Check lxm + match &claims.lxm { + Some(lxm) if lxm == LXM_PART_ONE => {} + Some(lxm) => { + return Ok(ChallengeCheckResponse::Incorrect(format!( + "The JWT lexicon method (lxm) '{}' does not match the expected value '{}'", + lxm, LXM_PART_ONE + ))); + } + None => { + return Ok(ChallengeCheckResponse::Incorrect( + "The JWT is missing the lexicon method (lxm) claim".to_string(), + )); + } + } + + // Resolve DID document and extract signing key + let iss_did: Did = claims.iss.parse().map_err(|_| { + AdventError::ShouldNotHappen(format!("Invalid DID in JWT iss: {}", claims.iss)) + })?; + let did_doc = self.handle_resolver.resolve(&iss_did).await.map_err(|err| { + log::error!("Failed to resolve DID document for {}: {}", claims.iss, err); + AdventError::ShouldNotHappen(format!("Failed to resolve DID document: {err}")) + })?; + + let (key_alg, key_bytes) = match extract_signing_key_bytes(&did_doc) { + Ok(result) => result, + Err(e) => { + return Ok(ChallengeCheckResponse::Incorrect(format!( + "Could not extract signing key from your DID document: {e}" + ))); + } + }; + + // Fully verify the JWT signature + match decode_and_verify_service_auth(&jwt, &key_bytes, key_alg) { + Ok(_) => Ok(ChallengeCheckResponse::Correct), + Err(e) => Ok(ChallengeCheckResponse::Incorrect(format!( + "JWT verification failed: {e}" + ))), + } + } + + async fn check_part_two( + &self, + did: String, + verification_code: Option, + ) -> Result { + let submitted_code = match verification_code { + Some(code) if !code.trim().is_empty() => code.trim().to_string(), + _ => { + return Ok(ChallengeCheckResponse::Incorrect( + "Please enter the verification code from the XRPC response".to_string(), + )); + } + }; + + let challenge = self.get_days_challenge(&did).await?; + match challenge { + None => { + log::error!("No challenge record found for day 6 for user: {did}"); + Err(AdventError::ShouldNotHappen( + "Could not find challenge record".to_string(), + )) + } + Some(challenge) => { + let expected = challenge + .verification_code_two + .ok_or(AdventError::ShouldNotHappen( + "No verification code for part two".to_string(), + ))?; + + if submitted_code == expected { + Ok(ChallengeCheckResponse::Correct) + } else { + Ok(ChallengeCheckResponse::Incorrect(format!( + "The code '{}' is incorrect. Make sure you're using the code from the XRPC response.", + submitted_code + ))) + } + } + } } } diff --git a/shared/src/atrium/mod.rs b/shared/src/atrium/mod.rs index 52be5f7..8a29267 100644 --- a/shared/src/atrium/mod.rs +++ b/shared/src/atrium/mod.rs @@ -2,6 +2,7 @@ use atrium_api::types::Unknown; use serde::de; pub mod dns_resolver; +pub mod service_auth; pub mod stores; /// Safely parses an unknown record into a type. If it fails, it logs the error and returns an error. diff --git a/shared/src/atrium/service_auth.rs b/shared/src/atrium/service_auth.rs new file mode 100644 index 0000000..fc782c8 --- /dev/null +++ b/shared/src/atrium/service_auth.rs @@ -0,0 +1,162 @@ +use atrium_api::did_doc::DidDocument; +use atrium_crypto::did::parse_multikey; +use atrium_crypto::verify::Verifier; +use atrium_crypto::Algorithm; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; +use std::fmt; + +#[derive(Debug)] +pub enum ServiceAuthError { + InvalidFormat, + InvalidBase64(String), + InvalidJson(String), + UnsupportedAlgorithm(String), + Expired, + InvalidSignature(String), + MissingSigningKey, + ClaimMismatch(String), +} + +impl fmt::Display for ServiceAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => write!(f, "JWT must have three dot-separated parts"), + Self::InvalidBase64(msg) => write!(f, "Invalid base64url encoding: {msg}"), + Self::InvalidJson(msg) => write!(f, "Invalid JSON in JWT: {msg}"), + Self::UnsupportedAlgorithm(alg) => { + write!(f, "Unsupported algorithm '{alg}', expected ES256 or ES256K") + } + Self::Expired => write!(f, "Token has expired"), + Self::InvalidSignature(msg) => write!(f, "Invalid signature: {msg}"), + Self::MissingSigningKey => write!(f, "No signing key found in DID document"), + Self::ClaimMismatch(msg) => write!(f, "{msg}"), + } + } +} + +#[derive(Debug, Deserialize)] +struct JwtHeader { + alg: String, + #[allow(dead_code)] + typ: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServiceAuthClaims { + pub iss: String, + pub aud: String, + #[serde(default)] + pub exp: u64, + #[serde(default)] + pub iat: Option, + #[serde(default)] + pub lxm: Option, + #[serde(default)] + pub jti: Option, +} + +/// Decode JWT claims without verifying the signature. +/// Useful for extracting the `iss` field before resolving the DID document. +pub fn decode_jwt_claims(jwt: &str) -> Result { + let parts: Vec<&str> = jwt.trim().split('.').collect(); + if parts.len() != 3 { + return Err(ServiceAuthError::InvalidFormat); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; + + serde_json::from_slice(&payload_bytes) + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string())) +} + +/// Fully decode and verify a serviceAuth JWT. +/// +/// `public_key_bytes` should be the decompressed SEC1 public key bytes +/// from the issuer's DID document (as returned by `extract_signing_key_bytes`). +/// `key_algorithm` is the algorithm associated with the key from the DID document. +pub fn decode_and_verify_service_auth( + jwt: &str, + public_key_bytes: &[u8], + key_algorithm: Algorithm, +) -> Result { + let jwt = jwt.trim(); + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + return Err(ServiceAuthError::InvalidFormat); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; + let signature_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; + + let header: JwtHeader = serde_json::from_slice(&header_bytes) + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?; + let claims: ServiceAuthClaims = serde_json::from_slice(&payload_bytes) + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?; + + // Validate algorithm + let jwt_algorithm = match header.alg.as_str() { + "ES256" => Algorithm::P256, + "ES256K" => Algorithm::Secp256k1, + other => return Err(ServiceAuthError::UnsupportedAlgorithm(other.to_string())), + }; + + // The JWT algorithm must match the key's algorithm from the DID document + if jwt_algorithm != key_algorithm { + return Err(ServiceAuthError::UnsupportedAlgorithm(format!( + "JWT uses {} but DID document key is {}", + header.alg, + match key_algorithm { + Algorithm::P256 => "ES256 (P-256)", + Algorithm::Secp256k1 => "ES256K (secp256k1)", + } + ))); + } + + // Check expiry + let now = chrono::Utc::now().timestamp() as u64; + if claims.exp > 0 && claims.exp < now { + return Err(ServiceAuthError::Expired); + } + + // Verify signature: signing input is the raw "{header}.{payload}" string + let signing_input = format!("{}.{}", parts[0], parts[1]); + let verifier = Verifier::new(true); + verifier + .verify( + jwt_algorithm, + public_key_bytes, + signing_input.as_bytes(), + &signature_bytes, + ) + .map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?; + + Ok(claims) +} + +/// Extract the signing key bytes and algorithm from a DID document. +/// Returns the decompressed SEC1 public key bytes and the associated algorithm. +pub fn extract_signing_key_bytes( + did_doc: &DidDocument, +) -> Result<(Algorithm, Vec), ServiceAuthError> { + let method = did_doc + .get_signing_key() + .ok_or(ServiceAuthError::MissingSigningKey)?; + let multibase = method + .public_key_multibase + .as_ref() + .ok_or(ServiceAuthError::MissingSigningKey)?; + let (alg, key_bytes) = + parse_multikey(multibase).map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?; + Ok((alg, key_bytes)) +} diff --git a/web/src/handlers/day.rs b/web/src/handlers/day.rs index 2583620..3d83bdc 100644 --- a/web/src/handlers/day.rs +++ b/web/src/handlers/day.rs @@ -3,14 +3,20 @@ use crate::templates::{HtmlTemplate, day::DayTemplate}; use crate::{AppState, error_response}; use atrium_api::agent::Agent; use atrium_api::types::string::Did; +use atrium_common::resolver::Resolver; use axum::{ + Json, extract::{Form, Path, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, }; +use serde_json::json; use shared::advent::challenges::day_five::DayFive; use shared::advent::challenges::day_four::DayFour; -use shared::advent::challenges::day_six::DaySix; +use shared::advent::challenges::day_six::{self, DaySix}; +use shared::atrium::service_auth::{ + decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes, +}; use shared::{ OAuthAgentType, OAuthClientType, advent::ChallengeCheckResponse, @@ -54,6 +60,11 @@ fn pick_day( Day::Six => Ok(Box::new(DaySix { pool: state.postgres_pool, oauth_client, + handle_resolver: state.handle_resolver.clone(), + service_did: format!( + "did:web:{}", + std::env::var("OAUTH_HOST").unwrap_or_default() + ), })), _ => Err(AdventError::InvalidDay(0)), // Day::Three => {} // Day::Four => {} @@ -511,3 +522,153 @@ pub async fn day_five_create_record_handler( Ok(Redirect::to("/day/5")) } + +/// XRPC endpoint for Day 6 Part 2: verifies a serviceAuth JWT from the Authorization header +/// and returns the user's verification code. +pub async fn day_six_xrpc_handler( + State(state): State, + headers: HeaderMap, +) -> Result, Response> { + let xrpc_error = |status: StatusCode, error: &str, message: &str| -> Response { + (status, Json(json!({"error": error, "message": message}))).into_response() + }; + + // Extract Bearer token from Authorization header + let auth_header = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + xrpc_error( + StatusCode::UNAUTHORIZED, + "AuthMissing", + "Missing Authorization header", + ) + })?; + + let jwt = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + xrpc_error( + StatusCode::UNAUTHORIZED, + "AuthMissing", + "Authorization header must use Bearer scheme", + ) + })?; + + // Decode claims to get issuer + let claims = decode_jwt_claims(jwt).map_err(|e| { + xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + &format!("Could not decode JWT: {e}"), + ) + })?; + + let expected_service_did = format!( + "did:web:{}", + std::env::var("OAUTH_HOST").unwrap_or_default() + ); + + // Check aud + if claims.aud != expected_service_did { + return Err(xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + &format!( + "JWT audience '{}' does not match expected '{}'", + claims.aud, expected_service_did + ), + )); + } + + // Check lxm + match &claims.lxm { + Some(lxm) if lxm == day_six::LXM_PART_TWO => {} + Some(lxm) => { + return Err(xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + &format!( + "JWT lxm '{}' does not match expected '{}'", + lxm, + day_six::LXM_PART_TWO + ), + )); + } + None => { + return Err(xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + "JWT is missing the lxm claim", + )); + } + } + + // Resolve DID document and verify signature + let iss_did: Did = claims.iss.parse().map_err(|_| { + xrpc_error( + StatusCode::BAD_REQUEST, + "InvalidToken", + "Invalid DID in JWT iss claim", + ) + })?; + let did_doc = state + .handle_resolver + .resolve(&iss_did) + .await + .map_err(|e| { + log::error!("Failed to resolve DID for {}: {}", claims.iss, e); + xrpc_error( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + "Failed to resolve DID document", + ) + })?; + + let (key_alg, key_bytes) = extract_signing_key_bytes(&did_doc).map_err(|e| { + xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + &format!("Could not extract signing key: {e}"), + ) + })?; + + decode_and_verify_service_auth(jwt, &key_bytes, key_alg).map_err(|e| { + xrpc_error( + StatusCode::UNAUTHORIZED, + "InvalidToken", + &format!("JWT verification failed: {e}"), + ) + })?; + + // Look up the user's day 6 challenge record + let challenge = sqlx::query_as::<_, shared::models::db_models::ChallengeProgress>( + "SELECT * FROM challenges WHERE user_did = $1 AND day = $2", + ) + .bind(&claims.iss) + .bind(6i16) + .fetch_optional(&state.postgres_pool) + .await + .map_err(|e| { + log::error!("DB error looking up day 6 challenge: {}", e); + xrpc_error( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + "Database error", + ) + })?; + + match challenge { + Some(c) => match c.verification_code_two { + Some(code) => Ok(Json(json!({"code": code}))), + None => Err(xrpc_error( + StatusCode::BAD_REQUEST, + "NotReady", + "Part 2 has not been started yet. Complete Part 1 and visit the Day 6 page first.", + )), + }, + None => Err(xrpc_error( + StatusCode::BAD_REQUEST, + "NotReady", + "You haven't started Day 6 yet. Visit the Day 6 page first.", + )), + } +} diff --git a/web/src/main.rs b/web/src/main.rs index 0990a03..adaf80b 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -292,6 +292,10 @@ async fn main() -> Result<(), Box> { "/day/5/{user_did}", get(handlers::day::day_five_create_record_handler), ) + .route( + "/xrpc/codes.advent.challenge.getDay6Code", + get(handlers::day::day_six_xrpc_handler), + ) .route("/leaderboard", get(handlers::leaderboard::leaderboard_handler)) .route("/login", get(handlers::auth::login_page_handler)) .route("/logout", get(handlers::auth::logout_handler))