From 9c1de30969ea20a83aa577ded1242886cc6a7e1b Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 19 Jul 2026 01:19:41 +0900 Subject: [PATCH 1/4] :recycle: Split activitypub service into directory module by responsibility Pure mechanical move of application/src/service/activitypub.rs into a directory module; no behavior change. --- application/src/service/activitypub.rs | 1931 ----------------- application/src/service/activitypub/actor.rs | 130 ++ .../src/service/activitypub/collections.rs | 354 +++ .../src/service/activitypub/delivery.rs | 117 + application/src/service/activitypub/fetch.rs | 143 ++ .../src/service/activitypub/inbox/handlers.rs | 451 ++++ .../src/service/activitypub/inbox/mod.rs | 117 + application/src/service/activitypub/mod.rs | 26 + .../service/activitypub/outbound_follow.rs | 236 ++ application/src/service/activitypub/outbox.rs | 369 ++++ .../src/service/activitypub/remote_actor.rs | 295 +++ 11 files changed, 2238 insertions(+), 1931 deletions(-) delete mode 100644 application/src/service/activitypub.rs create mode 100644 application/src/service/activitypub/actor.rs create mode 100644 application/src/service/activitypub/collections.rs create mode 100644 application/src/service/activitypub/delivery.rs create mode 100644 application/src/service/activitypub/fetch.rs create mode 100644 application/src/service/activitypub/inbox/handlers.rs create mode 100644 application/src/service/activitypub/inbox/mod.rs create mode 100644 application/src/service/activitypub/mod.rs create mode 100644 application/src/service/activitypub/outbound_follow.rs create mode 100644 application/src/service/activitypub/outbox.rs create mode 100644 application/src/service/activitypub/remote_actor.rs diff --git a/application/src/service/activitypub.rs b/application/src/service/activitypub.rs deleted file mode 100644 index 0a173360..00000000 --- a/application/src/service/activitypub.rs +++ /dev/null @@ -1,1931 +0,0 @@ -use crate::transfer::activitypub::{ - GetActorDto, GetWebFingerDto, InboxActivityDto, SendFollowDto, SendFollowResultDto, -}; -use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; -use base64::{engine::general_purpose, Engine as _}; -use error_stack::{Report, ResultExt}; -use kernel::activitypub::{ - Activity, Actor, ActorUrlBuilder, OrderedCollection, WebFingerLink, WebFingerResponse, -}; -use kernel::interfaces::config::{DependOnPublicBaseUrl, PublicBaseUrl}; -use kernel::interfaces::crypto::{ - DependOnKeyEncryptor, DependOnPasswordProvider, KeyEncryptor, PasswordProvider, -}; -use kernel::interfaces::database::DatabaseConnection; -use kernel::interfaces::http_signing::{DependOnHttpSigner, HttpSigner, HttpSigningRequest}; -use kernel::interfaces::permission::DependOnPermissionChecker; -use kernel::interfaces::read_model::{DependOnProfileReadModel, ProfileReadModel}; -use kernel::interfaces::repository::{ - DependOnFollowRepository, DependOnOutboxActivityRepository, DependOnRemoteAccountRepository, - DependOnSigningKeyRepository, FollowRepository, OutboxActivityRepository, - RemoteAccountRepository, SigningKeyRepository, -}; -use kernel::prelude::entity::{ - Account, AccountId, AccountName, Follow, FollowApprovedAt, FollowId, FollowTargetId, Nanoid, - OutboxActivity, OutboxActivityId, RemoteAccount, RemoteAccountAcct, RemoteAccountId, - RemoteAccountUrl, -}; -use kernel::KernelError; -use reqwest::header::{ACCEPT, CONTENT_TYPE, DATE, HOST, USER_AGENT}; -use serde_json::Value; -use sha2::Digest; -use std::future::Future; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - -const ACTIVITY_JSON: &str = "application/activity+json"; -const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; - -pub trait GetActorUseCase: - 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnProfileReadModel - + DependOnSigningKeyRepository - + DependOnPublicBaseUrl -{ - fn get_actor( - &self, - dto: GetActorDto, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - let account_nanoid = Nanoid::::new(dto.account_nanoid); - let account = self - .account_query_processor() - .find_by_nanoid(&mut executor, &account_nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - account_nanoid.as_ref() - )) - })?; - let profile = self - .profile_read_model() - .find_by_account_id(&mut executor, account.id()) - .await?; - let signing_key = self - .signing_key_repository() - .find_active_by_account_id(&mut executor, account.id()) - .await? - .into_iter() - .next() - .ok_or_else(|| { - Report::new(KernelError::NotFound) - .attach_printable("No active signing key found for account") - })?; - let display_name = profile - .as_ref() - .and_then(|profile| profile.display_name().as_ref()) - .map(|display_name| display_name.as_ref().to_string()); - let summary = profile - .as_ref() - .and_then(|profile| profile.summary().as_ref()) - .map(|summary| summary.as_ref().to_string()); - - Ok(Actor::new( - &ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()), - account.name().as_ref(), - display_name.as_deref(), - summary.as_deref(), - &signing_key.public_key_pem, - &signing_key.key_id_uri, - )) - } - } -} - -impl GetActorUseCase for T where - T: 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnProfileReadModel - + DependOnSigningKeyRepository - + DependOnPublicBaseUrl -{ -} - -pub trait GetWebFingerUseCase: - 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPublicBaseUrl -{ - fn get_webfinger( - &self, - dto: GetWebFingerDto, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - let account_name = AccountName::new(dto.account_name); - account_name.validate()?; - let account = self - .account_query_processor() - .find_by_name(&mut executor, &account_name) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with name: {}", - account_name.as_ref() - )) - })?; - let actor_url = - ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) - .actor_id(); - - Ok(WebFingerResponse { - subject: format!( - "acct:{}@{}", - account.name().as_ref(), - dto.domain.to_ascii_lowercase() - ), - links: Some(vec![WebFingerLink { - rel: "self".to_string(), - type_: "application/activity+json".to_string(), - href: actor_url.clone(), - }]), - aliases: Some(vec![actor_url]), - }) - } - } -} - -impl GetWebFingerUseCase for T where - T: 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPublicBaseUrl -{ -} - -pub trait GetFollowersCollectionUseCase: - 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnFollowRepository - + DependOnPublicBaseUrl -{ - fn get_followers_collection( - &self, - account_id: &AccountId, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - let account = self - .account_query_processor() - .find_by_id(&mut executor, account_id) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound) - .attach_printable(format!("Account not found: {account_id:?}")) - })?; - let follows = self - .follow_repository() - .find_followers(&mut executor, &FollowTargetId::from(account_id.clone())) - .await?; - let total_items = follows - .iter() - .filter(|follow| follow.approved_at().is_some()) - .count() as u64; - - Ok(OrderedCollection::new( - ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) - .followers(), - total_items, - None, - None, - )) - } - } - - fn get_following_collection( - &self, - account_id: &AccountId, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - let account = self - .account_query_processor() - .find_by_id(&mut executor, account_id) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound) - .attach_printable(format!("Account not found: {account_id:?}")) - })?; - let follows = self - .follow_repository() - .find_followings(&mut executor, &FollowTargetId::from(account_id.clone())) - .await?; - let total_items = follows - .iter() - .filter(|follow| follow.approved_at().is_some()) - .count() as u64; - - Ok(OrderedCollection::new( - ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) - .following(), - total_items, - None, - None, - )) - } - } -} - -impl GetFollowersCollectionUseCase for T where - T: 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnFollowRepository - + DependOnPublicBaseUrl -{ -} - -pub trait StoreOutboxActivityUseCase: - 'static + Sync + Send + DependOnOutboxActivityRepository -{ - fn store_outbox_activity( - &self, - activity: &OutboxActivity, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - self.outbox_activity_repository() - .create(&mut executor, activity) - .await - } - } -} - -impl StoreOutboxActivityUseCase for T where - T: 'static + Sync + Send + DependOnOutboxActivityRepository -{ -} - -pub trait GetOutboxUseCase: - 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnOutboxActivityRepository - + DependOnPublicBaseUrl -{ - fn get_outbox_collection( - &self, - account_id: &AccountId, - limit: usize, - cursor: Option, - ) -> impl Future> + Send { - async move { - let mut executor = self.database_connection().get_executor().await?; - let account = self - .account_query_processor() - .find_by_id(&mut executor, account_id) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound) - .attach_printable(format!("Account not found: {account_id:?}")) - })?; - let activities = self - .outbox_activity_repository() - .find_by_account_id(&mut executor, account_id, limit, cursor) - .await?; - let total_items = self - .outbox_activity_repository() - .count_by_account_id(&mut executor, account_id) - .await?; - let ordered_items = activities - .into_iter() - .map(|activity| { - serde_json::from_str::(&activity.object_json).map_err(|e| { - Report::new(KernelError::Internal).attach_printable(format!( - "Failed to deserialize outbox activity JSON: {e}" - )) - }) - }) - .collect::, KernelError>>()?; - - Ok(OrderedCollection::with_ordered_items( - ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) - .outbox(), - total_items, - ordered_items, - )) - } - } -} - -impl GetOutboxUseCase for T where - T: 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnOutboxActivityRepository - + DependOnPublicBaseUrl -{ -} - -pub trait SendFollowUseCase: - 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnFollowRepository - + DependOnRemoteAccountRepository - + DependOnSigningKeyRepository - + DependOnHttpSigner - + DependOnPasswordProvider - + DependOnKeyEncryptor - + DependOnPublicBaseUrl - + DependOnOutboxActivityRepository - + DependOnPermissionChecker - + StoreOutboxActivityUseCase -{ - fn send_follow( - &self, - auth_account_id: kernel::prelude::entity::AuthAccountId, - dto: SendFollowDto, - ) -> impl Future> + Send - where - Self: Sized, - { - async move { - let account_nanoid = Nanoid::::new(dto.account_nanoid.clone()); - let mut executor = self.database_connection().get_executor().await?; - let account = self - .account_query_processor() - .find_by_nanoid(&mut executor, &account_nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - account_nanoid.as_ref() - )) - })?; - - crate::permission::check_permission( - self, - &auth_account_id, - &crate::permission::account_sign(account.id()), - ) - .await?; - - let remote_actor = resolve_remote_actor_identifier(&dto.target).await?; - let remote_account = upsert_remote_account( - self.remote_account_repository(), - &mut executor, - remote_actor, - ) - .await?; - - let source = FollowTargetId::from(account.id().clone()); - let destination = FollowTargetId::from(remote_account.id().clone()); - - if let Some(_existing) = find_existing_following( - self.follow_repository(), - &mut executor, - &source, - &destination, - ) - .await? - { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "Already following {}", - remote_account.url().as_ref() - ))); - } - - let follow = Follow::new( - FollowId::new(kernel::generate_id()), - source, - destination, - None, - )?; - self.follow_repository() - .create(&mut executor, &follow) - .await?; - - let local_actor_url = - local_actor_url(self.public_base_url(), account.nanoid().as_ref()); - let follow_activity = follow_activity( - self.public_base_url(), - &follow, - &local_actor_url, - remote_account.url().as_ref(), - )?; - - if let Err(error) = self - .deliver_follow(account.id(), remote_account.inbox_url(), &follow_activity) - .await - { - self.follow_repository() - .delete(&mut executor, follow.id()) - .await?; - return Err(error - .change_context(KernelError::Rejected) - .attach_printable("Follow delivery failed, rolled back follow record")); - } - - let outbox_entry = OutboxActivity { - id: OutboxActivityId::default(), - account_id: account.id().clone(), - activity_id: follow_activity.id.clone(), - activity_type: "Follow".to_string(), - object_json: serde_json::to_string(&follow_activity).map_err(|e| { - Report::new(KernelError::Internal).attach_printable(format!( - "Failed to serialize Follow activity to JSON: {e}" - )) - })?, - created_at: time::OffsetDateTime::now_utc(), - }; - self.store_outbox_activity(&outbox_entry) - .await - .change_context_lazy(|| KernelError::Internal) - .attach_printable("Failed to store outbox activity")?; - - Ok(SendFollowResultDto { - follow_id: follow.id().as_ref().to_string(), - remote_actor_url: remote_account.url().as_ref().to_string(), - activity_id: follow_activity.id.clone(), - approved: false, - }) - } - } - - fn deliver_follow( - &self, - account_id: &AccountId, - inbox_url: &Option, - activity: &Activity, - ) -> impl Future> + Send - where - Self: Sized, - { - async move { - let inbox_url = inbox_url.as_deref().ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Remote actor does not expose an inbox URL") - })?; - deliver_activity_to_inbox( - self.database_connection(), - self.signing_key_repository(), - self.password_provider(), - self.key_encryptor(), - self.http_signer(), - account_id, - inbox_url, - activity, - "Follow", - ) - .await - } - } -} - -impl SendFollowUseCase for T where - T: 'static - + Sync - + Send - + DependOnAccountQueryProcessor - + DependOnFollowRepository - + DependOnRemoteAccountRepository - + DependOnSigningKeyRepository - + DependOnHttpSigner - + DependOnPasswordProvider - + DependOnKeyEncryptor - + DependOnPublicBaseUrl - + DependOnOutboxActivityRepository - + DependOnPermissionChecker - + StoreOutboxActivityUseCase -{ -} - -pub trait InboxUseCase: - 'static - + Sync - + Send - + DependOnFollowRepository - + DependOnRemoteAccountRepository - + DependOnSigningKeyRepository - + DependOnHttpSigner - + DependOnPasswordProvider - + DependOnKeyEncryptor - + DependOnPublicBaseUrl - + DependOnOutboxActivityRepository - + StoreOutboxActivityUseCase -{ - fn handle_inbox_activity( - &self, - dto: InboxActivityDto, - ) -> impl Future> + Send { - async move { - match dto.activity.type_.as_str() { - "Follow" => self.handle_follow_activity(dto).await, - "Accept" => self.handle_accept_activity(dto).await, - "Undo" if undo_object_is_follow(&dto.activity) => { - self.handle_undo_follow(dto).await - } - activity_type => { - tracing::info!( - activity_type, - "Ignoring unsupported ActivityPub inbox activity" - ); - Ok(()) - } - } - } - } - - fn handle_follow_activity( - &self, - dto: InboxActivityDto, - ) -> impl Future> + Send { - async move { - let followed_actor_url = activity_object_id(&dto.activity).ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Follow activity object must be an actor id") - })?; - ensure_local_actor_matches( - self.public_base_url(), - &dto.account_nanoid, - &followed_actor_url, - )?; - - let remote_actor = resolve_remote_actor(&dto.activity.actor).await?; - let mut executor = self.database_connection().get_executor().await?; - let remote_account = upsert_remote_account( - self.remote_account_repository(), - &mut executor, - remote_actor, - ) - .await?; - - let source = FollowTargetId::from(remote_account.id().clone()); - let destination = FollowTargetId::from(dto.account_id.clone()); - let follow = match find_existing_follow( - self.follow_repository(), - &mut executor, - &source, - &destination, - ) - .await? - { - Some(_existing) => { - tracing::debug!("Follow already exists, skipping Accept creation"); - return Ok(()); - } - None => { - let follow = Follow::new( - FollowId::new(kernel::generate_id()), - source, - destination, - Some(FollowApprovedAt::default()), - )?; - self.follow_repository() - .create(&mut executor, &follow) - .await?; - follow - } - }; - - let local_actor_url = local_actor_url(self.public_base_url(), &dto.account_nanoid); - let accept = accept_activity( - self.public_base_url(), - &follow, - &local_actor_url, - dto.activity.clone(), - )?; - if let Err(error) = self - .deliver_accept(&dto.account_id, remote_account.inbox_url(), &accept) - .await - { - tracing::warn!(?error, inbox_url = ?remote_account.inbox_url(), "Failed to deliver ActivityPub Accept"); - } - - let outbox_entry = OutboxActivity { - id: OutboxActivityId::default(), - account_id: dto.account_id.clone(), - activity_id: accept.id.clone(), - activity_type: "Accept".to_string(), - object_json: serde_json::to_string(&accept).map_err(|e| { - Report::new(KernelError::Internal).attach_printable(format!( - "Failed to serialize Accept activity to JSON: {e}" - )) - })?, - created_at: time::OffsetDateTime::now_utc(), - }; - self.store_outbox_activity(&outbox_entry) - .await - .change_context_lazy(|| KernelError::Internal) - .attach_printable("Failed to store outbox activity")?; - - Ok(()) - } - } - - fn handle_undo_follow( - &self, - dto: InboxActivityDto, - ) -> impl Future> + Send { - async move { - let follow_activity = undo_follow_object(&dto.activity).ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Undo activity object must be a Follow activity") - })?; - let followed_actor_url = activity_object_id(&follow_activity).ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Undo Follow object must target an actor id") - })?; - ensure_local_actor_matches( - self.public_base_url(), - &dto.account_nanoid, - &followed_actor_url, - )?; - - let mut executor = self.database_connection().get_executor().await?; - let remote_url = RemoteAccountUrl::new(dto.activity.actor.clone()); - let Some(remote_account) = self - .remote_account_repository() - .find_by_url(&mut executor, &remote_url) - .await? - else { - return Ok(()); - }; - - let source = FollowTargetId::from(remote_account.id().clone()); - let destination = FollowTargetId::from(dto.account_id); - if let Some(follow) = find_existing_follow( - self.follow_repository(), - &mut executor, - &source, - &destination, - ) - .await? - { - self.follow_repository() - .delete(&mut executor, follow.id()) - .await?; - } - Ok(()) - } - } - - fn handle_accept_activity( - &self, - dto: InboxActivityDto, - ) -> impl Future> + Send { - async move { - let accept = &dto.activity; - let nested_follow = accept - .object - .as_ref() - .and_then(|obj| serde_json::from_value::(obj.clone()).ok()) - .filter(|a| a.type_ == "Follow") - .ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Accept object must be a Follow activity") - })?; - - let follow_actor_url = nested_follow.actor.trim_end_matches('/').to_string(); - let expected_local = local_actor_url(self.public_base_url(), &dto.account_nanoid); - if follow_actor_url != expected_local.trim_end_matches('/') { - tracing::debug!( - follow_actor = %nested_follow.actor, - expected = %expected_local, - "Accept Follow actor does not match local actor" - ); - return Ok(()); - } - - let remote_actor_url = activity_object_id(&nested_follow).ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Accept Follow object must have an actor id") - })?; - - let accept_actor = accept.actor.trim_end_matches('/').to_string(); - if accept_actor != remote_actor_url.trim_end_matches('/') { - tracing::debug!( - accept_actor = %accept.actor, - remote_actor = %remote_actor_url, - "Accept actor does not match Follow object" - ); - return Ok(()); - } - - let mut executor = self.database_connection().get_executor().await?; - let remote_url = RemoteAccountUrl::new(remote_actor_url.clone()); - let remote_account = self - .remote_account_repository() - .find_by_url(&mut executor, &remote_url) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Remote account not found for {remote_actor_url}" - )) - })?; - - let source = FollowTargetId::from(dto.account_id.clone()); - let destination = FollowTargetId::from(remote_account.id().clone()); - if let Some(existing) = find_existing_following( - self.follow_repository(), - &mut executor, - &source, - &destination, - ) - .await? - { - if existing.approved_at().is_none() { - let approved = Follow::new( - existing.id().clone(), - existing.source().clone(), - existing.destination().clone(), - Some(FollowApprovedAt::default()), - )?; - self.follow_repository() - .update(&mut executor, &approved) - .await?; - tracing::info!( - remote_actor = %remote_actor_url, - "Follow approved via Accept activity" - ); - } - } else { - tracing::debug!( - remote_actor = %remote_actor_url, - "No pending follow found for Accept activity" - ); - } - Ok(()) - } - } - - fn deliver_accept( - &self, - account_id: &AccountId, - inbox_url: &Option, - accept: &Activity, - ) -> impl Future> + Send { - async move { - let inbox_url = inbox_url.as_deref().ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Remote actor does not expose an inbox URL") - })?; - deliver_activity_to_inbox( - self.database_connection(), - self.signing_key_repository(), - self.password_provider(), - self.key_encryptor(), - self.http_signer(), - account_id, - inbox_url, - accept, - "Accept", - ) - .await - } - } -} - -impl InboxUseCase for T where - T: 'static - + Sync - + Send - + DependOnFollowRepository - + DependOnRemoteAccountRepository - + DependOnSigningKeyRepository - + DependOnHttpSigner - + DependOnPasswordProvider - + DependOnKeyEncryptor - + DependOnPublicBaseUrl - + DependOnOutboxActivityRepository - + StoreOutboxActivityUseCase -{ -} - -#[derive(Debug)] -struct ResolvedRemoteActor { - acct: RemoteAccountAcct, - url: RemoteAccountUrl, - inbox_url: Option, - public_key_pem: Option, -} - -/// Test-only: global cache of resolved remote actor data. -/// -/// `resolve_remote_actor` checks this before making an HTTP request. -/// Keys are inserted by the E2E test when the remote server requires -/// authentication for its ActivityPub actor endpoint. -#[cfg(any(test, feature = "test-mode"))] -use std::collections::HashMap; - -#[cfg(any(test, feature = "test-mode"))] -use std::sync::{LazyLock, Mutex}; - -#[cfg(any(test, feature = "test-mode"))] -static TEST_STATIC_RESOLVED_ACTORS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Insert a remote actor into the test global cache so that -/// `resolve_remote_actor` returns it without making an HTTP request. -#[cfg(any(test, feature = "test-mode"))] -pub fn inject_test_remote_actor( - actor_url: &str, - username: &str, - inbox_url: &str, - public_key_pem: &str, -) { - use kernel::prelude::entity::{RemoteAccountAcct, RemoteAccountUrl}; - let mut cache = TEST_STATIC_RESOLVED_ACTORS.lock().expect("poisoned lock"); - let actor_id_url = actor_url.trim_end_matches('/'); - let host = reqwest::Url::parse(actor_id_url) - .ok() - .and_then(|u| u.host_str().map(|h| h.to_string())) - .unwrap_or_else(|| "local".to_string()); - cache.insert( - actor_id_url.to_string(), - ResolvedRemoteActor { - acct: RemoteAccountAcct::new(format!("{}@{}", username, host)), - url: RemoteAccountUrl::new(actor_id_url.to_string()), - inbox_url: Some(inbox_url.to_string()), - public_key_pem: Some(public_key_pem.to_string()), - }, - ); -} - -async fn resolve_remote_actor( - actor_url: &str, -) -> error_stack::Result { - // Check test-mode global cache first - #[cfg(any(test, feature = "test-mode"))] - if let Ok(cache) = TEST_STATIC_RESOLVED_ACTORS.lock() { - let key = actor_url.trim_end_matches('/'); - if let Some(cached) = cache.get(key) { - return Ok(ResolvedRemoteActor { - acct: RemoteAccountAcct::new(cached.acct.as_ref().clone()), - url: RemoteAccountUrl::new(cached.url.as_ref().clone()), - inbox_url: cached.inbox_url.clone(), - public_key_pem: cached.public_key_pem.clone(), - }); - } - } - let mut url = reqwest::Url::parse(actor_url).map_err(|e| { - Report::new(KernelError::Rejected).attach_printable(format!("Invalid actor URL: {e}")) - })?; - let mut redirects = 0; - let actor = loop { - if redirects > 5 { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Too many redirects while resolving remote actor")); - } - let resolved_addresses = validate_fetch_url(&url).await?; - let response = client_for_url(&url, &resolved_addresses)? - .get(url.clone()) - .header(ACCEPT, ACTIVITY_JSON) - .header(USER_AGENT, "Emumet/0.1 ActivityPub actor resolver") - .send() - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("Remote actor fetch failed: {e}")) - })?; - if response.status().is_redirection() { - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("Remote actor redirect without Location header") - })?; - url = url.join(location).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("Remote actor redirect URL is invalid: {e}")) - })?; - redirects += 1; - continue; - } - if !response.status().is_success() { - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - tracing::debug!( - remote_actor_url = %url, - status = %status, - body = %body_text, - "Remote actor fetch failed with non-success status" - ); - return Err(Report::new(KernelError::Rejected) - .attach_printable(format!("Remote actor endpoint returned {status}",))); - } - break response.json::().await.map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("Remote actor JSON is invalid: {e}")) - })?; - }; - - let actor_id = reqwest::Url::parse(&actor.id).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("Remote actor id is invalid: {e}")) - })?; - let host = actor_id.host_str().ok_or_else(|| { - Report::new(KernelError::Rejected).attach_printable("Remote actor id has no host") - })?; - if actor.preferred_username.trim().is_empty() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Remote actor preferredUsername is empty")); - } - if !same_activitypub_id(&actor.id, actor_url) { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "Remote actor id does not match requested actor URL: expected {actor_url}, got {}", - actor.id - ))); - } - - Ok(ResolvedRemoteActor { - acct: RemoteAccountAcct::new(format!("{}@{}", actor.preferred_username, host)), - url: RemoteAccountUrl::new(actor.id), - inbox_url: Some(actor.inbox), - public_key_pem: Some(actor.public_key.public_key_pem), - }) -} - -async fn resolve_remote_actor_identifier( - identifier: &str, -) -> error_stack::Result { - if let Some((user, domain)) = identifier - .strip_prefix("acct:") - .and_then(|s| s.split_once('@')) - { - if user.is_empty() || domain.is_empty() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Invalid acct: format, expected acct:user@domain")); - } - return resolve_remote_webfinger(user, domain).await; - } - resolve_remote_actor(identifier).await -} - -async fn resolve_remote_webfinger( - user: &str, - domain: &str, -) -> error_stack::Result { - if !user - .chars() - .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') - { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Invalid characters in WebFinger user")); - } - if !domain - .chars() - .all(|c| c.is_alphanumeric() || c == '.' || c == '-') - || domain.starts_with('-') - || domain.ends_with('-') - { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Invalid characters in WebFinger domain")); - } - let webfinger_url = format!( - "https://{}/.well-known/webfinger?resource=acct:{}@{}", - domain, user, domain - ); - let url = reqwest::Url::parse(&webfinger_url).map_err(|e| { - Report::new(KernelError::Rejected).attach_printable(format!("Invalid WebFinger URL: {e}")) - })?; - let resolved_addresses = validate_fetch_url(&url).await?; - let response = client_for_url(&url, &resolved_addresses)? - .get(url) - .header(ACCEPT, "application/jrd+json") - .header(USER_AGENT, "Emumet/0.1 WebFinger resolver") - .send() - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("WebFinger request failed: {e}")) - })?; - if !response.status().is_success() { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "WebFinger returned {} for {}@{}", - response.status(), - user, - domain - ))); - } - let jrd: serde_json::Value = response.json().await.map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("WebFinger response is not valid JSON: {e}")) - })?; - let subject = jrd.get("subject").and_then(|s| s.as_str()).ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("WebFinger response missing subject field") - })?; - let expected_subject = format!("acct:{}@{}", user, domain); - if subject != expected_subject { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "WebFinger subject mismatch: expected {expected_subject}, got {subject}" - ))); - } - let actor_url = jrd - .get("links") - .and_then(|links| links.as_array()) - .and_then(|links| { - links.iter().find_map(|link| { - let rel = link.get("rel").and_then(|r| r.as_str())?; - let type_ = link.get("type").and_then(|t| t.as_str())?; - let href = link.get("href").and_then(|h| h.as_str())?; - if rel == "self" - && (type_ == "application/activity+json" || type_ == "application/ld+json") - { - Some(href.to_string()) - } else { - None - } - }) - }) - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "No ActivityPub link found in WebFinger response for {}@{}", - user, domain - )) - })?; - resolve_remote_actor(&actor_url).await -} - -async fn upsert_remote_account( - repository: &R, - executor: &mut E, - actor: ResolvedRemoteActor, -) -> error_stack::Result -where - R: RemoteAccountRepository, - E: kernel::interfaces::database::Executor, -{ - if let Some(existing) = repository.find_by_url(executor, &actor.url).await? { - let updated = RemoteAccount::new( - existing.id().clone(), - actor.acct, - actor.url, - existing.icon_id().clone(), - actor.inbox_url, - actor.public_key_pem, - ); - repository.update(executor, &updated).await?; - return Ok(updated); - } - - let remote_account = RemoteAccount::new( - RemoteAccountId::new(kernel::generate_id()), - actor.acct, - actor.url, - None, - actor.inbox_url, - actor.public_key_pem, - ); - repository.create(executor, &remote_account).await?; - Ok(remote_account) -} - -async fn find_existing_follow( - repository: &R, - executor: &mut E, - source: &FollowTargetId, - destination: &FollowTargetId, -) -> error_stack::Result, KernelError> -where - R: FollowRepository, - E: kernel::interfaces::database::Executor, -{ - let followers = repository.find_followers(executor, destination).await?; - Ok(followers - .into_iter() - .find(|follow| follow.source() == source && follow.destination() == destination)) -} - -async fn find_existing_following( - repository: &R, - executor: &mut E, - source: &FollowTargetId, - destination: &FollowTargetId, -) -> error_stack::Result, KernelError> -where - R: FollowRepository, - E: kernel::interfaces::database::Executor, -{ - let followings = repository.find_followings(executor, source).await?; - Ok(followings - .into_iter() - .find(|follow| follow.source() == source && follow.destination() == destination)) -} - -fn undo_object_is_follow(activity: &Activity) -> bool { - undo_follow_object(activity).is_some() -} - -fn undo_follow_object(activity: &Activity) -> Option { - let object = activity.object.as_ref()?; - serde_json::from_value::(object.clone()) - .ok() - .filter(|activity| activity.type_ == "Follow") -} - -fn activity_object_id(activity: &Activity) -> Option { - match activity.object.as_ref()? { - Value::String(value) => Some(value.clone()), - Value::Object(map) => map.get("id").and_then(Value::as_str).map(str::to_string), - _ => None, - } -} - -fn ensure_local_actor_matches( - public_base_url: &PublicBaseUrl, - account_nanoid: &str, - object_id: &str, -) -> error_stack::Result<(), KernelError> { - let expected = local_actor_url(public_base_url, account_nanoid); - if object_id.trim_end_matches('/') == expected { - Ok(()) - } else { - Err(Report::new(KernelError::Rejected).attach_printable(format!( - "Follow object does not match local actor: expected {expected}, got {object_id}" - ))) - } -} - -fn same_activitypub_id(left: &str, right: &str) -> bool { - left.trim_end_matches('/') == right.trim_end_matches('/') -} - -fn local_actor_url(public_base_url: &PublicBaseUrl, account_nanoid: &str) -> String { - ActorUrlBuilder::new(public_base_url.as_str(), account_nanoid).actor_id() -} - -fn accept_activity( - public_base_url: &PublicBaseUrl, - follow: &Follow, - actor: &str, - original_follow: Activity, -) -> error_stack::Result { - // The Accept activity must be directed TO the follower (original Follow's actor), - // not to the local actor who is sending the Accept. - let remote_follower_url = &original_follow.actor; - let object = serde_json::to_value(original_follow.clone()).map_err(|e| { - Report::from(e) - .change_context(KernelError::Internal) - .attach_printable("Failed to serialize original Follow activity") - })?; - Ok(Activity { - context: Some(Value::String(ACTIVITYSTREAMS_CONTEXT.to_string())), - id: format!( - "{}/activities/{}", - public_base_url.as_str().trim_end_matches('/'), - follow.id().as_ref() - ), - type_: "Accept".to_string(), - actor: actor.to_string(), - object: Some(object), - target: None, - to: Some(vec![remote_follower_url.to_string()]), - cc: None, - }) -} - -fn follow_activity( - public_base_url: &PublicBaseUrl, - follow: &Follow, - local_actor_url: &str, - remote_actor_url: &str, -) -> error_stack::Result { - Ok(Activity { - context: Some(Value::String(ACTIVITYSTREAMS_CONTEXT.to_string())), - id: format!( - "{}/activities/{}", - public_base_url.as_str().trim_end_matches('/'), - follow.id().as_ref() - ), - type_: "Follow".to_string(), - actor: local_actor_url.to_string(), - object: Some(serde_json::Value::String(remote_actor_url.to_string())), - target: None, - to: Some(vec![remote_actor_url.to_string()]), - cc: None, - }) -} - -fn host_header(url: &reqwest::Url) -> error_stack::Result { - let host = url.host_str().ok_or_else(|| { - Report::new(KernelError::Rejected).attach_printable("URL host is missing") - })?; - Ok(match url.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }) -} - -async fn validate_fetch_url( - url: &reqwest::Url, -) -> error_stack::Result, KernelError> { - match url.scheme() { - "http" | "https" => {} - scheme => { - return Err(Report::new(KernelError::Rejected) - .attach_printable(format!("SsrfBlocked: unsupported URL scheme '{scheme}'"))); - } - } - - if !url.username().is_empty() || url.password().is_some() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: URL credentials are not allowed")); - } - - let host = url.host_str().ok_or_else(|| { - Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: URL host is empty") - })?; - let host_lc = host.trim_end_matches('.').to_ascii_lowercase(); - - if cfg!(not(any(test, feature = "test-mode"))) { - if !is_fetch_host_allowed(&host_lc) - && (host_lc == "localhost" || host_lc.ends_with(".localhost")) - { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: localhost URL is not allowed")); - } - } - - let port = url.port_or_known_default().ok_or_else(|| { - Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: URL has no usable port") - })?; - if let Ok(ip) = host_lc.parse::() { - if cfg!(not(any(test, feature = "test-mode"))) { - validate_public_ip(ip)?; - } - return Ok(vec![SocketAddr::new(ip, port)]); - } - - let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) - })? - .collect::>(); - if addresses.is_empty() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); - } - if cfg!(not(any(test, feature = "test-mode"))) { - for address in &addresses { - validate_public_ip(address.ip())?; - } - } - Ok(addresses) -} - -fn client_for_url( - url: &reqwest::Url, - resolved_addresses: &[SocketAddr], -) -> error_stack::Result { - let mut builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(10)); - #[cfg(any(test, feature = "test-mode"))] - if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") { - builder = builder.danger_accept_invalid_certs(true); - } - if let Some(host) = url.host_str() { - if host.parse::().is_err() { - builder = builder.resolve_to_addrs(host, resolved_addresses); - } - } - builder.build().map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build pinned HTTP client: {e}")) - }) -} - -/// Checks whether a host is allowlisted for test-mode AP fetch operations. -/// -/// Reads the `AP_TEST_ALLOWED_FETCH_HOSTS` environment variable (comma-separated, -/// trimmed, lowercase) and returns true if `host_lc` matches any entry. -/// Returns false when the env var is unset or empty. -fn is_fetch_host_allowed(host_lc: &str) -> bool { - std::env::var("AP_TEST_ALLOWED_FETCH_HOSTS") - .ok() - .is_some_and(|val| { - val.split(',') - .any(|entry| entry.trim().eq_ignore_ascii_case(host_lc)) - }) -} - -async fn deliver_activity_to_inbox( - database_connection: &D, - signing_key_repository: &S, - password_provider: &impl PasswordProvider, - key_encryptor: &impl KeyEncryptor, - http_signer: &impl HttpSigner, - account_id: &AccountId, - inbox_url: &str, - activity: &Activity, - activity_name: &str, -) -> error_stack::Result<(), KernelError> -where - D: DatabaseConnection, - S: SigningKeyRepository, -{ - let body = serde_json::to_vec(activity).map_err(|e| { - Report::from(e) - .change_context(KernelError::Internal) - .attach_printable(format!("Failed to serialize {activity_name} activity")) - })?; - let url = reqwest::Url::parse(inbox_url).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("Remote inbox URL is invalid: {e}")) - })?; - let resolved_addresses = validate_fetch_url(&url).await?; - let host = host_header(&url)?; - let digest = format!( - "SHA-256={}", - general_purpose::STANDARD.encode(sha2::Sha256::digest(&body)) - ); - let date = httpdate::fmt_http_date(std::time::SystemTime::now()); - let mut headers = std::collections::HashMap::new(); - headers.insert("host".to_string(), host.clone()); - headers.insert("date".to_string(), date.clone()); - headers.insert("digest".to_string(), digest.clone()); - headers.insert("content-type".to_string(), ACTIVITY_JSON.to_string()); - - let signing_request = HttpSigningRequest { - method: "POST".to_string(), - url: inbox_url.to_string(), - headers, - body: Some(body.clone()), - }; - let mut executor = database_connection.get_executor().await?; - let signing_key = signing_key_repository - .find_active_by_account_id(&mut executor, account_id) - .await? - .into_iter() - .next() - .ok_or_else(|| { - Report::new(KernelError::NotFound) - .attach_printable("No active signing key found for account") - })?; - let password = password_provider.get_password()?; - let private_key_pem = key_encryptor.decrypt(signing_key.encrypted_private_key(), &password)?; - let signature = http_signer - .sign( - &signing_request, - &private_key_pem, - &signing_key.key_id_uri, - signing_key.algorithm(), - ) - .await?; - - let client = client_for_url(&url, &resolved_addresses)?; - let mut request = client - .post(url) - .header(HOST, host) - .header(DATE, date) - .header("Digest", digest) - .header(CONTENT_TYPE, ACTIVITY_JSON) - .body(body); - for (name, value) in &signature.cavage_headers { - // Skip headers already set explicitly above to avoid duplicates. - // nginx returns 400 for duplicate Host headers. - let lower = name.to_ascii_lowercase(); - if lower == "host" || lower == "date" || lower == "digest" || lower == "content-type" { - continue; - } - request = request.header(name.as_str(), value.as_str()); - } - let response = request.send().await.map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("{activity_name} delivery failed: {e}")) - })?; - if !response.status().is_success() { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "{activity_name} delivery returned {}", - response.status() - ))); - } - Ok(()) -} - -fn validate_public_ip(ip: IpAddr) -> error_stack::Result<(), KernelError> { - let blocked = match ip { - IpAddr::V4(ip) => is_blocked_ipv4(ip), - IpAddr::V6(ip) => is_blocked_ipv6(ip), - }; - if blocked { - Err(Report::new(KernelError::Rejected).attach_printable(format!( - "SsrfBlocked: non-public IP address is not allowed: {ip}" - ))) - } else { - Ok(()) - } -} - -fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { - let octets = ip.octets(); - ip.is_private() - || ip.is_loopback() - || ip.is_link_local() - || ip.is_broadcast() - || ip.is_documentation() - || ip.is_multicast() - || ip.is_unspecified() - || octets[0] == 0 - || octets[0] >= 224 - || (octets[0] == 100 && (64..=127).contains(&octets[1])) - || (octets[0] == 198 && (18..=19).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) -} - -fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { - if let Some(ipv4) = ip.to_ipv4_mapped() { - return is_blocked_ipv4(ipv4); - } - - ip.is_loopback() - || ip.is_unspecified() - || ip.is_multicast() - || (ip.segments()[0] & 0xfe00) == 0xfc00 - || (ip.segments()[0] & 0xffc0) == 0xfe80 - || (ip.segments()[0] & 0xffff) == 0x2001 && (ip.segments()[1] & 0xfff0) == 0x0db8 - || ip.segments()[0] == 0x2002 - || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0) -} - -#[cfg(test)] -mod tests { - use super::*; - use kernel::interfaces::config::PublicBaseUrl; - use kernel::interfaces::database::{DatabaseConnection, DependOnDatabaseConnection, Executor}; - use kernel::prelude::entity::{AuthAccountId, Follow, FollowApprovedAt, FollowId}; - use kernel::test_utils::AccountBuilder; - use std::sync::Mutex; - use time::OffsetDateTime; - - #[derive(Clone)] - struct MockExecutor; - - impl Executor for MockExecutor {} - - struct MockDatabaseConnection; - - impl DatabaseConnection for MockDatabaseConnection { - type Executor = MockExecutor; - - async fn get_executor(&self) -> error_stack::Result { - Ok(MockExecutor) - } - } - - struct MockAccountQueryProcessor { - account: Account, - } - - impl AccountQueryProcessor for MockAccountQueryProcessor { - type Executor = MockExecutor; - - async fn find_by_id( - &self, - _executor: &mut Self::Executor, - id: &AccountId, - ) -> error_stack::Result, KernelError> { - Ok((self.account.id() == id).then(|| self.account.clone())) - } - - async fn find_by_auth_id( - &self, - _executor: &mut Self::Executor, - _auth_id: &AuthAccountId, - ) -> error_stack::Result, KernelError> { - Ok(Vec::new()) - } - - async fn find_by_name( - &self, - _executor: &mut Self::Executor, - name: &AccountName, - ) -> error_stack::Result, KernelError> { - Ok((self.account.name() == name).then(|| self.account.clone())) - } - - async fn find_by_nanoid( - &self, - _executor: &mut Self::Executor, - nanoid: &Nanoid, - ) -> error_stack::Result, KernelError> { - Ok((self.account.nanoid() == nanoid).then(|| self.account.clone())) - } - - async fn find_by_nanoids( - &self, - _executor: &mut Self::Executor, - _nanoids: &[Nanoid], - ) -> error_stack::Result, KernelError> { - Ok(Vec::new()) - } - - async fn find_by_id_unfiltered( - &self, - executor: &mut Self::Executor, - id: &AccountId, - ) -> error_stack::Result, KernelError> { - self.find_by_id(executor, id).await - } - - async fn find_by_nanoid_unfiltered( - &self, - executor: &mut Self::Executor, - nanoid: &Nanoid, - ) -> error_stack::Result, KernelError> { - self.find_by_nanoid(executor, nanoid).await - } - - async fn find_by_nanoids_unfiltered( - &self, - executor: &mut Self::Executor, - nanoids: &[Nanoid], - ) -> error_stack::Result, KernelError> { - self.find_by_nanoids(executor, nanoids).await - } - } - - struct MockFollowRepository { - followers: Vec, - followings: Vec, - } - - impl FollowRepository for MockFollowRepository { - type Executor = MockExecutor; - - async fn find_followings( - &self, - _executor: &mut Self::Executor, - _source: &FollowTargetId, - ) -> error_stack::Result, KernelError> { - Ok(self.followings.clone()) - } - - async fn find_followers( - &self, - _executor: &mut Self::Executor, - _destination: &FollowTargetId, - ) -> error_stack::Result, KernelError> { - Ok(self.followers.clone()) - } - - async fn create( - &self, - _executor: &mut Self::Executor, - _follow: &Follow, - ) -> error_stack::Result<(), KernelError> { - Ok(()) - } - - async fn update( - &self, - _executor: &mut Self::Executor, - _follow: &Follow, - ) -> error_stack::Result<(), KernelError> { - Ok(()) - } - - async fn delete( - &self, - _executor: &mut Self::Executor, - _follow_id: &FollowId, - ) -> error_stack::Result<(), KernelError> { - Ok(()) - } - } - - struct MockOutboxActivityRepository { - activities: Mutex>, - } - - impl OutboxActivityRepository for MockOutboxActivityRepository { - type Executor = MockExecutor; - - async fn create( - &self, - _executor: &mut Self::Executor, - activity: &OutboxActivity, - ) -> error_stack::Result<(), KernelError> { - self.activities.lock().unwrap().push(activity.clone()); - Ok(()) - } - - async fn find_by_account_id( - &self, - _executor: &mut Self::Executor, - account_id: &AccountId, - limit: usize, - cursor: Option, - ) -> error_stack::Result, KernelError> { - let mut activities = self - .activities - .lock() - .unwrap() - .iter() - .filter(|activity| &activity.account_id == account_id) - .filter(|activity| cursor.map_or(true, |cursor| activity.id < cursor)) - .cloned() - .collect::>(); - activities.sort_by(|left, right| right.id.cmp(&left.id)); - activities.truncate(limit); - Ok(activities) - } - - async fn count_by_account_id( - &self, - _executor: &mut Self::Executor, - account_id: &AccountId, - ) -> error_stack::Result { - Ok(self - .activities - .lock() - .unwrap() - .iter() - .filter(|activity| &activity.account_id == account_id) - .count() as u64) - } - } - - struct MockModule { - database: MockDatabaseConnection, - accounts: MockAccountQueryProcessor, - follows: MockFollowRepository, - outbox: MockOutboxActivityRepository, - public_base_url: PublicBaseUrl, - } - - impl DependOnDatabaseConnection for MockModule { - type DatabaseConnection = MockDatabaseConnection; - - fn database_connection(&self) -> &Self::DatabaseConnection { - &self.database - } - } - - impl DependOnAccountQueryProcessor for MockModule { - type AccountQueryProcessor = MockAccountQueryProcessor; - - fn account_query_processor(&self) -> &Self::AccountQueryProcessor { - &self.accounts - } - } - - impl DependOnFollowRepository for MockModule { - type FollowRepository = MockFollowRepository; - - fn follow_repository(&self) -> &Self::FollowRepository { - &self.follows - } - } - - impl DependOnOutboxActivityRepository for MockModule { - type OutboxActivityRepository = MockOutboxActivityRepository; - - fn outbox_activity_repository(&self) -> &Self::OutboxActivityRepository { - &self.outbox - } - } - - impl DependOnPublicBaseUrl for MockModule { - fn public_base_url(&self) -> &PublicBaseUrl { - &self.public_base_url - } - } - - fn follow(source: AccountId, destination: AccountId, approved: bool) -> Follow { - kernel::ensure_generator_initialized(); - Follow::new( - FollowId::new(kernel::generate_id()), - FollowTargetId::from(source), - FollowTargetId::from(destination), - approved.then(FollowApprovedAt::default), - ) - .unwrap() - } - - fn module() -> (MockModule, AccountId) { - kernel::ensure_generator_initialized(); - let account_id = AccountId::default(); - let account = AccountBuilder::new() - .id(account_id.clone()) - .name("alice") - .nanoid(Nanoid::new("alice".to_string())) - .build(); - let approved_follower = AccountId::default(); - let pending_follower = AccountId::default(); - let approved_followee = AccountId::default(); - let pending_followee = AccountId::default(); - - ( - MockModule { - database: MockDatabaseConnection, - accounts: MockAccountQueryProcessor { account }, - follows: MockFollowRepository { - followers: vec![ - follow(approved_follower, account_id.clone(), true), - follow(pending_follower, account_id.clone(), false), - ], - followings: vec![ - follow(account_id.clone(), approved_followee, true), - follow(account_id.clone(), pending_followee, false), - ], - }, - outbox: MockOutboxActivityRepository { - activities: Mutex::new(vec![outbox_activity(1, account_id.clone(), "Create")]), - }, - public_base_url: PublicBaseUrl::new("https://example.com/".to_string()), - }, - account_id, - ) - } - - fn outbox_activity(id: i64, account_id: AccountId, activity_type: &str) -> OutboxActivity { - let activity_id = format!("https://example.com/activities/{id}"); - OutboxActivity { - id, - account_id, - activity_id: activity_id.clone(), - activity_type: activity_type.to_string(), - object_json: serde_json::json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "id": activity_id, - "type": activity_type, - "actor": "https://example.com/accounts/alice" - }) - .to_string(), - created_at: OffsetDateTime::now_utc(), - } - } - - fn follow_activity(actor: &str, object: &str) -> Activity { - Activity { - context: None, - id: "https://remote.example/activities/follow-1".to_string(), - type_: "Follow".to_string(), - actor: actor.to_string(), - object: Some(serde_json::Value::String(object.to_string())), - target: None, - to: None, - cc: None, - } - } - - #[test] - fn undo_object_is_follow_detects_nested_follow() { - let follow = follow_activity( - "https://remote.example/users/bob", - "https://example.com/accounts/alice", - ); - let undo = Activity { - context: None, - id: "https://remote.example/activities/undo-1".to_string(), - type_: "Undo".to_string(), - actor: "https://remote.example/users/bob".to_string(), - object: Some(serde_json::to_value(follow).unwrap()), - target: None, - to: None, - cc: None, - }; - - assert!(undo_object_is_follow(&undo)); - } - - #[test] - fn accept_activity_wraps_original_follow() { - let (module, account_id) = module(); - let follow = follow(AccountId::default(), account_id, true); - let original = follow_activity( - "https://remote.example/users/bob", - "https://example.com/accounts/alice", - ); - - let accept = accept_activity( - module.public_base_url(), - &follow, - "https://example.com/accounts/alice", - original, - ) - .unwrap(); - - assert_eq!(accept.type_, "Accept"); - assert_eq!(accept.actor, "https://example.com/accounts/alice"); - assert_eq!( - accept.id, - format!("https://example.com/activities/{}", follow.id().as_ref()) - ); - assert_eq!( - accept.object.as_ref().and_then(|value| value.get("type")), - Some(&serde_json::Value::String("Follow".to_string())) - ); - // Accept must be directed TO the follower (original Follow's actor), - // not to the local actor who is sending the Accept. - assert_eq!( - accept.to, - Some(vec!["https://remote.example/users/bob".to_string()]), - "Accept.to should target the remote follower, not the local actor" - ); - } - - #[test] - fn local_actor_match_rejects_wrong_follow_object() { - let public_base_url = PublicBaseUrl::new("https://example.com/".to_string()); - - assert!(ensure_local_actor_matches( - &public_base_url, - "alice", - "https://example.com/ap/accounts/alice/" - ) - .is_ok()); - assert!(ensure_local_actor_matches( - &public_base_url, - "alice", - "https://example.com/accounts/bob" - ) - .is_err()); - } - - #[tokio::test] - async fn followers_collection_returns_ordered_collection_structure() { - let (module, account_id) = module(); - - let collection = module.get_followers_collection(&account_id).await.unwrap(); - - assert_eq!( - collection.id, - "https://example.com/ap/accounts/alice/followers" - ); - assert_eq!(collection.type_, "OrderedCollection"); - assert_eq!(collection.total_items, Some(1)); - assert_eq!(collection.first, None); - assert_eq!(collection.last, None); - } - - #[tokio::test] - async fn following_collection_returns_ordered_collection_structure() { - let (module, account_id) = module(); - - let collection = module.get_following_collection(&account_id).await.unwrap(); - - assert_eq!( - collection.id, - "https://example.com/ap/accounts/alice/following" - ); - assert_eq!(collection.type_, "OrderedCollection"); - assert_eq!(collection.total_items, Some(1)); - assert_eq!(collection.first, None); - assert_eq!(collection.last, None); - } - - #[tokio::test] - async fn store_outbox_activity_persists_activity() { - let (module, account_id) = module(); - let activity = outbox_activity(2, account_id.clone(), "Accept"); - - module.store_outbox_activity(&activity).await.unwrap(); - - let mut executor = MockExecutor; - let activities = module - .outbox_activity_repository() - .find_by_account_id(&mut executor, &account_id, 10, None) - .await - .unwrap(); - assert!(activities.iter().any(|stored| stored.id == 2)); - } - - #[tokio::test] - async fn outbox_collection_returns_ordered_collection_with_items() { - let (module, account_id) = module(); - - let collection = module - .get_outbox_collection(&account_id, 10, None) - .await - .unwrap(); - - assert_eq!( - collection.id, - "https://example.com/ap/accounts/alice/outbox" - ); - assert_eq!(collection.type_, "OrderedCollection"); - assert_eq!(collection.total_items, Some(1)); - assert_eq!(collection.ordered_items.as_ref().unwrap().len(), 1); - assert_eq!(collection.ordered_items.unwrap()[0]["type"], "Create"); - } - - #[tokio::test] - async fn empty_outbox_collection_returns_zero_items() { - let (module, account_id) = module(); - module.outbox.activities.lock().unwrap().clear(); - - let collection = module - .get_outbox_collection(&account_id, 10, None) - .await - .unwrap(); - - assert_eq!(collection.total_items, Some(0)); - assert!(collection.ordered_items.unwrap().is_empty()); - } -} diff --git a/application/src/service/activitypub/actor.rs b/application/src/service/activitypub/actor.rs new file mode 100644 index 00000000..a0661de5 --- /dev/null +++ b/application/src/service/activitypub/actor.rs @@ -0,0 +1,130 @@ +use crate::transfer::activitypub::{GetActorDto, GetWebFingerDto}; +use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; +use error_stack::Report; +use kernel::activitypub::{Actor, ActorUrlBuilder, WebFingerLink, WebFingerResponse}; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::read_model::{DependOnProfileReadModel, ProfileReadModel}; +use kernel::interfaces::repository::{DependOnSigningKeyRepository, SigningKeyRepository}; +use kernel::prelude::entity::{Account, AccountName, Nanoid}; +use kernel::KernelError; +use std::future::Future; + +pub trait GetActorUseCase: + 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnProfileReadModel + + DependOnSigningKeyRepository + + DependOnPublicBaseUrl +{ + fn get_actor( + &self, + dto: GetActorDto, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + let account_nanoid = Nanoid::::new(dto.account_nanoid); + let account = self + .account_query_processor() + .find_by_nanoid(&mut executor, &account_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + account_nanoid.as_ref() + )) + })?; + let profile = self + .profile_read_model() + .find_by_account_id(&mut executor, account.id()) + .await?; + let signing_key = self + .signing_key_repository() + .find_active_by_account_id(&mut executor, account.id()) + .await? + .into_iter() + .next() + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable("No active signing key found for account") + })?; + let display_name = profile + .as_ref() + .and_then(|profile| profile.display_name().as_ref()) + .map(|display_name| display_name.as_ref().to_string()); + let summary = profile + .as_ref() + .and_then(|profile| profile.summary().as_ref()) + .map(|summary| summary.as_ref().to_string()); + + Ok(Actor::new( + &ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()), + account.name().as_ref(), + display_name.as_deref(), + summary.as_deref(), + &signing_key.public_key_pem, + &signing_key.key_id_uri, + )) + } + } +} + +impl GetActorUseCase for T where + T: 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnProfileReadModel + + DependOnSigningKeyRepository + + DependOnPublicBaseUrl +{ +} + +pub trait GetWebFingerUseCase: + 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPublicBaseUrl +{ + fn get_webfinger( + &self, + dto: GetWebFingerDto, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + let account_name = AccountName::new(dto.account_name); + account_name.validate()?; + let account = self + .account_query_processor() + .find_by_name(&mut executor, &account_name) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with name: {}", + account_name.as_ref() + )) + })?; + let actor_url = + ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) + .actor_id(); + + Ok(WebFingerResponse { + subject: format!( + "acct:{}@{}", + account.name().as_ref(), + dto.domain.to_ascii_lowercase() + ), + links: Some(vec![WebFingerLink { + rel: "self".to_string(), + type_: "application/activity+json".to_string(), + href: actor_url.clone(), + }]), + aliases: Some(vec![actor_url]), + }) + } + } +} + +impl GetWebFingerUseCase for T where + T: 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPublicBaseUrl +{ +} diff --git a/application/src/service/activitypub/collections.rs b/application/src/service/activitypub/collections.rs new file mode 100644 index 00000000..24590a9d --- /dev/null +++ b/application/src/service/activitypub/collections.rs @@ -0,0 +1,354 @@ +use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; +use error_stack::Report; +use kernel::activitypub::{ActorUrlBuilder, OrderedCollection}; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::repository::{DependOnFollowRepository, FollowRepository}; +use kernel::prelude::entity::{AccountId, FollowTargetId}; +use kernel::KernelError; +use std::future::Future; + +pub trait GetFollowersCollectionUseCase: + 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnFollowRepository + + DependOnPublicBaseUrl +{ + fn get_followers_collection( + &self, + account_id: &AccountId, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + let account = self + .account_query_processor() + .find_by_id(&mut executor, account_id) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable(format!("Account not found: {account_id:?}")) + })?; + let follows = self + .follow_repository() + .find_followers(&mut executor, &FollowTargetId::from(account_id.clone())) + .await?; + let total_items = follows + .iter() + .filter(|follow| follow.approved_at().is_some()) + .count() as u64; + + Ok(OrderedCollection::new( + ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) + .followers(), + total_items, + None, + None, + )) + } + } + + fn get_following_collection( + &self, + account_id: &AccountId, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + let account = self + .account_query_processor() + .find_by_id(&mut executor, account_id) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable(format!("Account not found: {account_id:?}")) + })?; + let follows = self + .follow_repository() + .find_followings(&mut executor, &FollowTargetId::from(account_id.clone())) + .await?; + let total_items = follows + .iter() + .filter(|follow| follow.approved_at().is_some()) + .count() as u64; + + Ok(OrderedCollection::new( + ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) + .following(), + total_items, + None, + None, + )) + } + } +} + +impl GetFollowersCollectionUseCase for T where + T: 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnFollowRepository + + DependOnPublicBaseUrl +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use kernel::interfaces::config::PublicBaseUrl; + use kernel::interfaces::database::{DatabaseConnection, DependOnDatabaseConnection, Executor}; + use kernel::prelude::entity::{ + Account, AccountName, AuthAccountId, Follow, FollowApprovedAt, FollowId, Nanoid, + }; + use kernel::test_utils::AccountBuilder; + + #[derive(Clone)] + struct MockExecutor; + + impl Executor for MockExecutor {} + + struct MockDatabaseConnection; + + impl DatabaseConnection for MockDatabaseConnection { + type Executor = MockExecutor; + + async fn get_executor(&self) -> error_stack::Result { + Ok(MockExecutor) + } + } + + struct MockAccountQueryProcessor { + account: Account, + } + + impl AccountQueryProcessor for MockAccountQueryProcessor { + type Executor = MockExecutor; + + async fn find_by_id( + &self, + _executor: &mut Self::Executor, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok((self.account.id() == id).then(|| self.account.clone())) + } + + async fn find_by_auth_id( + &self, + _executor: &mut Self::Executor, + _auth_id: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + + async fn find_by_name( + &self, + _executor: &mut Self::Executor, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + Ok((self.account.name() == name).then(|| self.account.clone())) + } + + async fn find_by_nanoid( + &self, + _executor: &mut Self::Executor, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok((self.account.nanoid() == nanoid).then(|| self.account.clone())) + } + + async fn find_by_nanoids( + &self, + _executor: &mut Self::Executor, + _nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Executor, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut Self::Executor, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.find_by_nanoid(executor, nanoid).await + } + + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut Self::Executor, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + self.find_by_nanoids(executor, nanoids).await + } + } + + struct MockFollowRepository { + followers: Vec, + followings: Vec, + } + + impl FollowRepository for MockFollowRepository { + type Executor = MockExecutor; + + async fn find_followings( + &self, + _executor: &mut Self::Executor, + _source: &FollowTargetId, + ) -> error_stack::Result, KernelError> { + Ok(self.followings.clone()) + } + + async fn find_followers( + &self, + _executor: &mut Self::Executor, + _destination: &FollowTargetId, + ) -> error_stack::Result, KernelError> { + Ok(self.followers.clone()) + } + + async fn create( + &self, + _executor: &mut Self::Executor, + _follow: &Follow, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn update( + &self, + _executor: &mut Self::Executor, + _follow: &Follow, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn delete( + &self, + _executor: &mut Self::Executor, + _follow_id: &FollowId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + } + + struct MockModule { + database: MockDatabaseConnection, + accounts: MockAccountQueryProcessor, + follows: MockFollowRepository, + public_base_url: PublicBaseUrl, + } + + impl DependOnDatabaseConnection for MockModule { + type DatabaseConnection = MockDatabaseConnection; + + fn database_connection(&self) -> &Self::DatabaseConnection { + &self.database + } + } + + impl DependOnAccountQueryProcessor for MockModule { + type AccountQueryProcessor = MockAccountQueryProcessor; + + fn account_query_processor(&self) -> &Self::AccountQueryProcessor { + &self.accounts + } + } + + impl DependOnFollowRepository for MockModule { + type FollowRepository = MockFollowRepository; + + fn follow_repository(&self) -> &Self::FollowRepository { + &self.follows + } + } + + impl DependOnPublicBaseUrl for MockModule { + fn public_base_url(&self) -> &PublicBaseUrl { + &self.public_base_url + } + } + + fn follow(source: AccountId, destination: AccountId, approved: bool) -> Follow { + kernel::ensure_generator_initialized(); + Follow::new( + FollowId::new(kernel::generate_id()), + FollowTargetId::from(source), + FollowTargetId::from(destination), + approved.then(FollowApprovedAt::default), + ) + .unwrap() + } + + fn module() -> (MockModule, AccountId) { + kernel::ensure_generator_initialized(); + let account_id = AccountId::default(); + let account = AccountBuilder::new() + .id(account_id.clone()) + .name("alice") + .nanoid(Nanoid::new("alice".to_string())) + .build(); + let approved_follower = AccountId::default(); + let pending_follower = AccountId::default(); + let approved_followee = AccountId::default(); + let pending_followee = AccountId::default(); + + ( + MockModule { + database: MockDatabaseConnection, + accounts: MockAccountQueryProcessor { account }, + follows: MockFollowRepository { + followers: vec![ + follow(approved_follower, account_id.clone(), true), + follow(pending_follower, account_id.clone(), false), + ], + followings: vec![ + follow(account_id.clone(), approved_followee, true), + follow(account_id.clone(), pending_followee, false), + ], + }, + public_base_url: PublicBaseUrl::new("https://example.com/".to_string()), + }, + account_id, + ) + } + + #[tokio::test] + async fn followers_collection_returns_ordered_collection_structure() { + let (module, account_id) = module(); + + let collection = module.get_followers_collection(&account_id).await.unwrap(); + + assert_eq!( + collection.id, + "https://example.com/ap/accounts/alice/followers" + ); + assert_eq!(collection.type_, "OrderedCollection"); + assert_eq!(collection.total_items, Some(1)); + assert_eq!(collection.first, None); + assert_eq!(collection.last, None); + } + + #[tokio::test] + async fn following_collection_returns_ordered_collection_structure() { + let (module, account_id) = module(); + + let collection = module.get_following_collection(&account_id).await.unwrap(); + + assert_eq!( + collection.id, + "https://example.com/ap/accounts/alice/following" + ); + assert_eq!(collection.type_, "OrderedCollection"); + assert_eq!(collection.total_items, Some(1)); + assert_eq!(collection.first, None); + assert_eq!(collection.last, None); + } +} diff --git a/application/src/service/activitypub/delivery.rs b/application/src/service/activitypub/delivery.rs new file mode 100644 index 00000000..f3a87834 --- /dev/null +++ b/application/src/service/activitypub/delivery.rs @@ -0,0 +1,117 @@ +use super::fetch::{client_for_url, validate_fetch_url}; +use super::ACTIVITY_JSON; +use base64::{engine::general_purpose, Engine as _}; +use error_stack::Report; +use kernel::activitypub::Activity; +use kernel::interfaces::crypto::{KeyEncryptor, PasswordProvider}; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::http_signing::{HttpSigner, HttpSigningRequest}; +use kernel::interfaces::repository::SigningKeyRepository; +use kernel::prelude::entity::AccountId; +use kernel::KernelError; +use reqwest::header::{CONTENT_TYPE, DATE, HOST}; +use sha2::Digest; + +fn host_header(url: &reqwest::Url) -> error_stack::Result { + let host = url.host_str().ok_or_else(|| { + Report::new(KernelError::Rejected).attach_printable("URL host is missing") + })?; + Ok(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +pub(super) async fn deliver_activity_to_inbox( + database_connection: &D, + signing_key_repository: &S, + password_provider: &impl PasswordProvider, + key_encryptor: &impl KeyEncryptor, + http_signer: &impl HttpSigner, + account_id: &AccountId, + inbox_url: &str, + activity: &Activity, + activity_name: &str, +) -> error_stack::Result<(), KernelError> +where + D: DatabaseConnection, + S: SigningKeyRepository, +{ + let body = serde_json::to_vec(activity).map_err(|e| { + Report::from(e) + .change_context(KernelError::Internal) + .attach_printable(format!("Failed to serialize {activity_name} activity")) + })?; + let url = reqwest::Url::parse(inbox_url).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("Remote inbox URL is invalid: {e}")) + })?; + let resolved_addresses = validate_fetch_url(&url).await?; + let host = host_header(&url)?; + let digest = format!( + "SHA-256={}", + general_purpose::STANDARD.encode(sha2::Sha256::digest(&body)) + ); + let date = httpdate::fmt_http_date(std::time::SystemTime::now()); + let mut headers = std::collections::HashMap::new(); + headers.insert("host".to_string(), host.clone()); + headers.insert("date".to_string(), date.clone()); + headers.insert("digest".to_string(), digest.clone()); + headers.insert("content-type".to_string(), ACTIVITY_JSON.to_string()); + + let signing_request = HttpSigningRequest { + method: "POST".to_string(), + url: inbox_url.to_string(), + headers, + body: Some(body.clone()), + }; + let mut executor = database_connection.get_executor().await?; + let signing_key = signing_key_repository + .find_active_by_account_id(&mut executor, account_id) + .await? + .into_iter() + .next() + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable("No active signing key found for account") + })?; + let password = password_provider.get_password()?; + let private_key_pem = key_encryptor.decrypt(signing_key.encrypted_private_key(), &password)?; + let signature = http_signer + .sign( + &signing_request, + &private_key_pem, + &signing_key.key_id_uri, + signing_key.algorithm(), + ) + .await?; + + let client = client_for_url(&url, &resolved_addresses)?; + let mut request = client + .post(url) + .header(HOST, host) + .header(DATE, date) + .header("Digest", digest) + .header(CONTENT_TYPE, ACTIVITY_JSON) + .body(body); + for (name, value) in &signature.cavage_headers { + // Skip headers already set explicitly above to avoid duplicates. + // nginx returns 400 for duplicate Host headers. + let lower = name.to_ascii_lowercase(); + if lower == "host" || lower == "date" || lower == "digest" || lower == "content-type" { + continue; + } + request = request.header(name.as_str(), value.as_str()); + } + let response = request.send().await.map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("{activity_name} delivery failed: {e}")) + })?; + if !response.status().is_success() { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "{activity_name} delivery returned {}", + response.status() + ))); + } + Ok(()) +} diff --git a/application/src/service/activitypub/fetch.rs b/application/src/service/activitypub/fetch.rs new file mode 100644 index 00000000..e26d4c9c --- /dev/null +++ b/application/src/service/activitypub/fetch.rs @@ -0,0 +1,143 @@ +use error_stack::Report; +use kernel::KernelError; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +pub(super) async fn validate_fetch_url( + url: &reqwest::Url, +) -> error_stack::Result, KernelError> { + match url.scheme() { + "http" | "https" => {} + scheme => { + return Err(Report::new(KernelError::Rejected) + .attach_printable(format!("SsrfBlocked: unsupported URL scheme '{scheme}'"))); + } + } + + if !url.username().is_empty() || url.password().is_some() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: URL credentials are not allowed")); + } + + let host = url.host_str().ok_or_else(|| { + Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: URL host is empty") + })?; + let host_lc = host.trim_end_matches('.').to_ascii_lowercase(); + + if cfg!(not(any(test, feature = "test-mode"))) { + if !is_fetch_host_allowed(&host_lc) + && (host_lc == "localhost" || host_lc.ends_with(".localhost")) + { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: localhost URL is not allowed")); + } + } + + let port = url.port_or_known_default().ok_or_else(|| { + Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: URL has no usable port") + })?; + if let Ok(ip) = host_lc.parse::() { + if cfg!(not(any(test, feature = "test-mode"))) { + validate_public_ip(ip)?; + } + return Ok(vec![SocketAddr::new(ip, port)]); + } + + let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) + })? + .collect::>(); + if addresses.is_empty() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); + } + if cfg!(not(any(test, feature = "test-mode"))) { + for address in &addresses { + validate_public_ip(address.ip())?; + } + } + Ok(addresses) +} + +pub(super) fn client_for_url( + url: &reqwest::Url, + resolved_addresses: &[SocketAddr], +) -> error_stack::Result { + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)); + #[cfg(any(test, feature = "test-mode"))] + if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") { + builder = builder.danger_accept_invalid_certs(true); + } + if let Some(host) = url.host_str() { + if host.parse::().is_err() { + builder = builder.resolve_to_addrs(host, resolved_addresses); + } + } + builder.build().map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build pinned HTTP client: {e}")) + }) +} + +/// Checks whether a host is allowlisted for test-mode AP fetch operations. +/// +/// Reads the `AP_TEST_ALLOWED_FETCH_HOSTS` environment variable (comma-separated, +/// trimmed, lowercase) and returns true if `host_lc` matches any entry. +/// Returns false when the env var is unset or empty. +fn is_fetch_host_allowed(host_lc: &str) -> bool { + std::env::var("AP_TEST_ALLOWED_FETCH_HOSTS") + .ok() + .is_some_and(|val| { + val.split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case(host_lc)) + }) +} + +fn validate_public_ip(ip: IpAddr) -> error_stack::Result<(), KernelError> { + let blocked = match ip { + IpAddr::V4(ip) => is_blocked_ipv4(ip), + IpAddr::V6(ip) => is_blocked_ipv6(ip), + }; + if blocked { + Err(Report::new(KernelError::Rejected).attach_printable(format!( + "SsrfBlocked: non-public IP address is not allowed: {ip}" + ))) + } else { + Ok(()) + } +} + +fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_multicast() + || ip.is_unspecified() + || octets[0] == 0 + || octets[0] >= 224 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 198 && (18..=19).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) +} + +fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { + if let Some(ipv4) = ip.to_ipv4_mapped() { + return is_blocked_ipv4(ipv4); + } + + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80 + || (ip.segments()[0] & 0xffff) == 0x2001 && (ip.segments()[1] & 0xfff0) == 0x0db8 + || ip.segments()[0] == 0x2002 + || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0) +} diff --git a/application/src/service/activitypub/inbox/handlers.rs b/application/src/service/activitypub/inbox/handlers.rs new file mode 100644 index 00000000..2716721d --- /dev/null +++ b/application/src/service/activitypub/inbox/handlers.rs @@ -0,0 +1,451 @@ +use super::super::outbound_follow::find_existing_following; +use super::super::remote_actor::{resolve_remote_actor, upsert_remote_account}; +use super::super::{local_actor_url, ACTIVITYSTREAMS_CONTEXT}; +use super::InboxUseCase; +use crate::transfer::activitypub::InboxActivityDto; +use error_stack::{Report, ResultExt}; +use kernel::activitypub::Activity; +use kernel::interfaces::config::PublicBaseUrl; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::repository::{FollowRepository, RemoteAccountRepository}; +use kernel::prelude::entity::{ + Follow, FollowApprovedAt, FollowId, FollowTargetId, OutboxActivity, OutboxActivityId, + RemoteAccountUrl, +}; +use kernel::KernelError; +use serde_json::Value; + +pub(super) async fn handle_follow_activity( + module: &T, + dto: InboxActivityDto, +) -> error_stack::Result<(), KernelError> +where + T: InboxUseCase + ?Sized, +{ + let followed_actor_url = activity_object_id(&dto.activity).ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Follow activity object must be an actor id") + })?; + ensure_local_actor_matches( + module.public_base_url(), + &dto.account_nanoid, + &followed_actor_url, + )?; + + let remote_actor = resolve_remote_actor(&dto.activity.actor).await?; + let mut executor = module.database_connection().get_executor().await?; + let remote_account = upsert_remote_account( + module.remote_account_repository(), + &mut executor, + remote_actor, + ) + .await?; + + let source = FollowTargetId::from(remote_account.id().clone()); + let destination = FollowTargetId::from(dto.account_id.clone()); + let follow = match find_existing_follow( + module.follow_repository(), + &mut executor, + &source, + &destination, + ) + .await? + { + Some(_existing) => { + tracing::debug!("Follow already exists, skipping Accept creation"); + return Ok(()); + } + None => { + let follow = Follow::new( + FollowId::new(kernel::generate_id()), + source, + destination, + Some(FollowApprovedAt::default()), + )?; + module + .follow_repository() + .create(&mut executor, &follow) + .await?; + follow + } + }; + + let local_actor_url = local_actor_url(module.public_base_url(), &dto.account_nanoid); + let accept = accept_activity( + module.public_base_url(), + &follow, + &local_actor_url, + dto.activity.clone(), + )?; + if let Err(error) = module + .deliver_accept(&dto.account_id, remote_account.inbox_url(), &accept) + .await + { + tracing::warn!(?error, inbox_url = ?remote_account.inbox_url(), "Failed to deliver ActivityPub Accept"); + } + + let outbox_entry = OutboxActivity { + id: OutboxActivityId::default(), + account_id: dto.account_id.clone(), + activity_id: accept.id.clone(), + activity_type: "Accept".to_string(), + object_json: serde_json::to_string(&accept).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to serialize Accept activity to JSON: {e}")) + })?, + created_at: time::OffsetDateTime::now_utc(), + }; + module + .store_outbox_activity(&outbox_entry) + .await + .change_context_lazy(|| KernelError::Internal) + .attach_printable("Failed to store outbox activity")?; + + Ok(()) +} + +pub(super) async fn handle_undo_follow( + module: &T, + dto: InboxActivityDto, +) -> error_stack::Result<(), KernelError> +where + T: InboxUseCase + ?Sized, +{ + let follow_activity = undo_follow_object(&dto.activity).ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Undo activity object must be a Follow activity") + })?; + let followed_actor_url = activity_object_id(&follow_activity).ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Undo Follow object must target an actor id") + })?; + ensure_local_actor_matches( + module.public_base_url(), + &dto.account_nanoid, + &followed_actor_url, + )?; + + let mut executor = module.database_connection().get_executor().await?; + let remote_url = RemoteAccountUrl::new(dto.activity.actor.clone()); + let Some(remote_account) = module + .remote_account_repository() + .find_by_url(&mut executor, &remote_url) + .await? + else { + return Ok(()); + }; + + let source = FollowTargetId::from(remote_account.id().clone()); + let destination = FollowTargetId::from(dto.account_id); + if let Some(follow) = find_existing_follow( + module.follow_repository(), + &mut executor, + &source, + &destination, + ) + .await? + { + module + .follow_repository() + .delete(&mut executor, follow.id()) + .await?; + } + Ok(()) +} + +pub(super) async fn handle_accept_activity( + module: &T, + dto: InboxActivityDto, +) -> error_stack::Result<(), KernelError> +where + T: InboxUseCase + ?Sized, +{ + let accept = &dto.activity; + let nested_follow = accept + .object + .as_ref() + .and_then(|obj| serde_json::from_value::(obj.clone()).ok()) + .filter(|a| a.type_ == "Follow") + .ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Accept object must be a Follow activity") + })?; + + let follow_actor_url = nested_follow.actor.trim_end_matches('/').to_string(); + let expected_local = local_actor_url(module.public_base_url(), &dto.account_nanoid); + if follow_actor_url != expected_local.trim_end_matches('/') { + tracing::debug!( + follow_actor = %nested_follow.actor, + expected = %expected_local, + "Accept Follow actor does not match local actor" + ); + return Ok(()); + } + + let remote_actor_url = activity_object_id(&nested_follow).ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Accept Follow object must have an actor id") + })?; + + let accept_actor = accept.actor.trim_end_matches('/').to_string(); + if accept_actor != remote_actor_url.trim_end_matches('/') { + tracing::debug!( + accept_actor = %accept.actor, + remote_actor = %remote_actor_url, + "Accept actor does not match Follow object" + ); + return Ok(()); + } + + let mut executor = module.database_connection().get_executor().await?; + let remote_url = RemoteAccountUrl::new(remote_actor_url.clone()); + let remote_account = module + .remote_account_repository() + .find_by_url(&mut executor, &remote_url) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable(format!("Remote account not found for {remote_actor_url}")) + })?; + + let source = FollowTargetId::from(dto.account_id.clone()); + let destination = FollowTargetId::from(remote_account.id().clone()); + if let Some(existing) = find_existing_following( + module.follow_repository(), + &mut executor, + &source, + &destination, + ) + .await? + { + if existing.approved_at().is_none() { + let approved = Follow::new( + existing.id().clone(), + existing.source().clone(), + existing.destination().clone(), + Some(FollowApprovedAt::default()), + )?; + module + .follow_repository() + .update(&mut executor, &approved) + .await?; + tracing::info!( + remote_actor = %remote_actor_url, + "Follow approved via Accept activity" + ); + } + } else { + tracing::debug!( + remote_actor = %remote_actor_url, + "No pending follow found for Accept activity" + ); + } + Ok(()) +} + +pub(super) fn undo_object_is_follow(activity: &Activity) -> bool { + undo_follow_object(activity).is_some() +} + +fn undo_follow_object(activity: &Activity) -> Option { + let object = activity.object.as_ref()?; + serde_json::from_value::(object.clone()) + .ok() + .filter(|activity| activity.type_ == "Follow") +} + +fn activity_object_id(activity: &Activity) -> Option { + match activity.object.as_ref()? { + Value::String(value) => Some(value.clone()), + Value::Object(map) => map.get("id").and_then(Value::as_str).map(str::to_string), + _ => None, + } +} + +fn ensure_local_actor_matches( + public_base_url: &PublicBaseUrl, + account_nanoid: &str, + object_id: &str, +) -> error_stack::Result<(), KernelError> { + let expected = local_actor_url(public_base_url, account_nanoid); + if object_id.trim_end_matches('/') == expected { + Ok(()) + } else { + Err(Report::new(KernelError::Rejected).attach_printable(format!( + "Follow object does not match local actor: expected {expected}, got {object_id}" + ))) + } +} + +async fn find_existing_follow( + repository: &R, + executor: &mut E, + source: &FollowTargetId, + destination: &FollowTargetId, +) -> error_stack::Result, KernelError> +where + R: FollowRepository, + E: kernel::interfaces::database::Executor, +{ + let followers = repository.find_followers(executor, destination).await?; + Ok(followers + .into_iter() + .find(|follow| follow.source() == source && follow.destination() == destination)) +} + +fn accept_activity( + public_base_url: &PublicBaseUrl, + follow: &Follow, + actor: &str, + original_follow: Activity, +) -> error_stack::Result { + // The Accept activity must be directed TO the follower (original Follow's actor), + // not to the local actor who is sending the Accept. + let remote_follower_url = &original_follow.actor; + let object = serde_json::to_value(original_follow.clone()).map_err(|e| { + Report::from(e) + .change_context(KernelError::Internal) + .attach_printable("Failed to serialize original Follow activity") + })?; + Ok(Activity { + context: Some(Value::String(ACTIVITYSTREAMS_CONTEXT.to_string())), + id: format!( + "{}/activities/{}", + public_base_url.as_str().trim_end_matches('/'), + follow.id().as_ref() + ), + type_: "Accept".to_string(), + actor: actor.to_string(), + object: Some(object), + target: None, + to: Some(vec![remote_follower_url.to_string()]), + cc: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use kernel::interfaces::config::DependOnPublicBaseUrl; + use kernel::prelude::entity::AccountId; + + struct MockModule { + public_base_url: PublicBaseUrl, + } + + impl DependOnPublicBaseUrl for MockModule { + fn public_base_url(&self) -> &PublicBaseUrl { + &self.public_base_url + } + } + + fn follow(source: AccountId, destination: AccountId, approved: bool) -> Follow { + kernel::ensure_generator_initialized(); + Follow::new( + FollowId::new(kernel::generate_id()), + FollowTargetId::from(source), + FollowTargetId::from(destination), + approved.then(FollowApprovedAt::default), + ) + .unwrap() + } + + fn module() -> (MockModule, AccountId) { + kernel::ensure_generator_initialized(); + let account_id = AccountId::default(); + + ( + MockModule { + public_base_url: PublicBaseUrl::new("https://example.com/".to_string()), + }, + account_id, + ) + } + + fn follow_activity(actor: &str, object: &str) -> Activity { + Activity { + context: None, + id: "https://remote.example/activities/follow-1".to_string(), + type_: "Follow".to_string(), + actor: actor.to_string(), + object: Some(serde_json::Value::String(object.to_string())), + target: None, + to: None, + cc: None, + } + } + + #[test] + fn undo_object_is_follow_detects_nested_follow() { + let follow = follow_activity( + "https://remote.example/users/bob", + "https://example.com/accounts/alice", + ); + let undo = Activity { + context: None, + id: "https://remote.example/activities/undo-1".to_string(), + type_: "Undo".to_string(), + actor: "https://remote.example/users/bob".to_string(), + object: Some(serde_json::to_value(follow).unwrap()), + target: None, + to: None, + cc: None, + }; + + assert!(undo_object_is_follow(&undo)); + } + + #[test] + fn accept_activity_wraps_original_follow() { + let (module, account_id) = module(); + let follow = follow(AccountId::default(), account_id, true); + let original = follow_activity( + "https://remote.example/users/bob", + "https://example.com/accounts/alice", + ); + + let accept = accept_activity( + module.public_base_url(), + &follow, + "https://example.com/accounts/alice", + original, + ) + .unwrap(); + + assert_eq!(accept.type_, "Accept"); + assert_eq!(accept.actor, "https://example.com/accounts/alice"); + assert_eq!( + accept.id, + format!("https://example.com/activities/{}", follow.id().as_ref()) + ); + assert_eq!( + accept.object.as_ref().and_then(|value| value.get("type")), + Some(&serde_json::Value::String("Follow".to_string())) + ); + // Accept must be directed TO the follower (original Follow's actor), + // not to the local actor who is sending the Accept. + assert_eq!( + accept.to, + Some(vec!["https://remote.example/users/bob".to_string()]), + "Accept.to should target the remote follower, not the local actor" + ); + } + + #[test] + fn local_actor_match_rejects_wrong_follow_object() { + let public_base_url = PublicBaseUrl::new("https://example.com/".to_string()); + + assert!(ensure_local_actor_matches( + &public_base_url, + "alice", + "https://example.com/ap/accounts/alice/" + ) + .is_ok()); + assert!(ensure_local_actor_matches( + &public_base_url, + "alice", + "https://example.com/accounts/bob" + ) + .is_err()); + } +} diff --git a/application/src/service/activitypub/inbox/mod.rs b/application/src/service/activitypub/inbox/mod.rs new file mode 100644 index 00000000..886d65bc --- /dev/null +++ b/application/src/service/activitypub/inbox/mod.rs @@ -0,0 +1,117 @@ +mod handlers; + +use super::delivery::deliver_activity_to_inbox; +use super::outbox::StoreOutboxActivityUseCase; +use crate::transfer::activitypub::InboxActivityDto; +use error_stack::Report; +use kernel::activitypub::Activity; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::crypto::{DependOnKeyEncryptor, DependOnPasswordProvider}; +use kernel::interfaces::http_signing::DependOnHttpSigner; +use kernel::interfaces::repository::{ + DependOnFollowRepository, DependOnOutboxActivityRepository, DependOnRemoteAccountRepository, + DependOnSigningKeyRepository, +}; +use kernel::prelude::entity::AccountId; +use kernel::KernelError; +use std::future::Future; + +pub trait InboxUseCase: + 'static + + Sync + + Send + + DependOnFollowRepository + + DependOnRemoteAccountRepository + + DependOnSigningKeyRepository + + DependOnHttpSigner + + DependOnPasswordProvider + + DependOnKeyEncryptor + + DependOnPublicBaseUrl + + DependOnOutboxActivityRepository + + StoreOutboxActivityUseCase +{ + fn handle_inbox_activity( + &self, + dto: InboxActivityDto, + ) -> impl Future> + Send { + async move { + match dto.activity.type_.as_str() { + "Follow" => self.handle_follow_activity(dto).await, + "Accept" => self.handle_accept_activity(dto).await, + "Undo" if handlers::undo_object_is_follow(&dto.activity) => { + self.handle_undo_follow(dto).await + } + activity_type => { + tracing::info!( + activity_type, + "Ignoring unsupported ActivityPub inbox activity" + ); + Ok(()) + } + } + } + } + + fn handle_follow_activity( + &self, + dto: InboxActivityDto, + ) -> impl Future> + Send { + handlers::handle_follow_activity(self, dto) + } + + fn handle_undo_follow( + &self, + dto: InboxActivityDto, + ) -> impl Future> + Send { + handlers::handle_undo_follow(self, dto) + } + + fn handle_accept_activity( + &self, + dto: InboxActivityDto, + ) -> impl Future> + Send { + handlers::handle_accept_activity(self, dto) + } + + fn deliver_accept( + &self, + account_id: &AccountId, + inbox_url: &Option, + accept: &Activity, + ) -> impl Future> + Send { + async move { + let inbox_url = inbox_url.as_deref().ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Remote actor does not expose an inbox URL") + })?; + deliver_activity_to_inbox( + self.database_connection(), + self.signing_key_repository(), + self.password_provider(), + self.key_encryptor(), + self.http_signer(), + account_id, + inbox_url, + accept, + "Accept", + ) + .await + } + } +} + +impl InboxUseCase for T where + T: 'static + + Sync + + Send + + DependOnFollowRepository + + DependOnRemoteAccountRepository + + DependOnSigningKeyRepository + + DependOnHttpSigner + + DependOnPasswordProvider + + DependOnKeyEncryptor + + DependOnPublicBaseUrl + + DependOnOutboxActivityRepository + + StoreOutboxActivityUseCase +{ +} diff --git a/application/src/service/activitypub/mod.rs b/application/src/service/activitypub/mod.rs new file mode 100644 index 00000000..4087b9f0 --- /dev/null +++ b/application/src/service/activitypub/mod.rs @@ -0,0 +1,26 @@ +mod actor; +mod collections; +mod delivery; +mod fetch; +mod inbox; +mod outbound_follow; +mod outbox; +mod remote_actor; + +use kernel::activitypub::ActorUrlBuilder; +use kernel::interfaces::config::PublicBaseUrl; + +pub use actor::{GetActorUseCase, GetWebFingerUseCase}; +pub use collections::GetFollowersCollectionUseCase; +pub use inbox::InboxUseCase; +pub use outbound_follow::SendFollowUseCase; +pub use outbox::{GetOutboxUseCase, StoreOutboxActivityUseCase}; +#[cfg(any(test, feature = "test-mode"))] +pub use remote_actor::inject_test_remote_actor; + +pub(super) const ACTIVITY_JSON: &str = "application/activity+json"; +pub(super) const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; + +pub(super) fn local_actor_url(public_base_url: &PublicBaseUrl, account_nanoid: &str) -> String { + ActorUrlBuilder::new(public_base_url.as_str(), account_nanoid).actor_id() +} diff --git a/application/src/service/activitypub/outbound_follow.rs b/application/src/service/activitypub/outbound_follow.rs new file mode 100644 index 00000000..3819ece6 --- /dev/null +++ b/application/src/service/activitypub/outbound_follow.rs @@ -0,0 +1,236 @@ +use super::delivery::deliver_activity_to_inbox; +use super::outbox::StoreOutboxActivityUseCase; +use super::remote_actor::{resolve_remote_actor_identifier, upsert_remote_account}; +use super::{local_actor_url, ACTIVITYSTREAMS_CONTEXT}; +use crate::transfer::activitypub::{SendFollowDto, SendFollowResultDto}; +use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; +use error_stack::{Report, ResultExt}; +use kernel::activitypub::Activity; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::crypto::{DependOnKeyEncryptor, DependOnPasswordProvider}; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::http_signing::DependOnHttpSigner; +use kernel::interfaces::permission::DependOnPermissionChecker; +use kernel::interfaces::repository::{ + DependOnFollowRepository, DependOnOutboxActivityRepository, DependOnRemoteAccountRepository, + DependOnSigningKeyRepository, FollowRepository, +}; +use kernel::prelude::entity::{ + Account, AccountId, Follow, FollowId, FollowTargetId, Nanoid, OutboxActivity, OutboxActivityId, +}; +use kernel::KernelError; +use serde_json::Value; +use std::future::Future; + +pub trait SendFollowUseCase: + 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnFollowRepository + + DependOnRemoteAccountRepository + + DependOnSigningKeyRepository + + DependOnHttpSigner + + DependOnPasswordProvider + + DependOnKeyEncryptor + + DependOnPublicBaseUrl + + DependOnOutboxActivityRepository + + DependOnPermissionChecker + + StoreOutboxActivityUseCase +{ + fn send_follow( + &self, + auth_account_id: kernel::prelude::entity::AuthAccountId, + dto: SendFollowDto, + ) -> impl Future> + Send + where + Self: Sized, + { + async move { + let account_nanoid = Nanoid::::new(dto.account_nanoid.clone()); + let mut executor = self.database_connection().get_executor().await?; + let account = self + .account_query_processor() + .find_by_nanoid(&mut executor, &account_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + account_nanoid.as_ref() + )) + })?; + + crate::permission::check_permission( + self, + &auth_account_id, + &crate::permission::account_sign(account.id()), + ) + .await?; + + let remote_actor = resolve_remote_actor_identifier(&dto.target).await?; + let remote_account = upsert_remote_account( + self.remote_account_repository(), + &mut executor, + remote_actor, + ) + .await?; + + let source = FollowTargetId::from(account.id().clone()); + let destination = FollowTargetId::from(remote_account.id().clone()); + + if let Some(_existing) = find_existing_following( + self.follow_repository(), + &mut executor, + &source, + &destination, + ) + .await? + { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "Already following {}", + remote_account.url().as_ref() + ))); + } + + let follow = Follow::new( + FollowId::new(kernel::generate_id()), + source, + destination, + None, + )?; + self.follow_repository() + .create(&mut executor, &follow) + .await?; + + let local_actor_url = + local_actor_url(self.public_base_url(), account.nanoid().as_ref()); + let follow_activity = follow_activity( + self.public_base_url(), + &follow, + &local_actor_url, + remote_account.url().as_ref(), + )?; + + if let Err(error) = self + .deliver_follow(account.id(), remote_account.inbox_url(), &follow_activity) + .await + { + self.follow_repository() + .delete(&mut executor, follow.id()) + .await?; + return Err(error + .change_context(KernelError::Rejected) + .attach_printable("Follow delivery failed, rolled back follow record")); + } + + let outbox_entry = OutboxActivity { + id: OutboxActivityId::default(), + account_id: account.id().clone(), + activity_id: follow_activity.id.clone(), + activity_type: "Follow".to_string(), + object_json: serde_json::to_string(&follow_activity).map_err(|e| { + Report::new(KernelError::Internal).attach_printable(format!( + "Failed to serialize Follow activity to JSON: {e}" + )) + })?, + created_at: time::OffsetDateTime::now_utc(), + }; + self.store_outbox_activity(&outbox_entry) + .await + .change_context_lazy(|| KernelError::Internal) + .attach_printable("Failed to store outbox activity")?; + + Ok(SendFollowResultDto { + follow_id: follow.id().as_ref().to_string(), + remote_actor_url: remote_account.url().as_ref().to_string(), + activity_id: follow_activity.id.clone(), + approved: false, + }) + } + } + + fn deliver_follow( + &self, + account_id: &AccountId, + inbox_url: &Option, + activity: &Activity, + ) -> impl Future> + Send + where + Self: Sized, + { + async move { + let inbox_url = inbox_url.as_deref().ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Remote actor does not expose an inbox URL") + })?; + deliver_activity_to_inbox( + self.database_connection(), + self.signing_key_repository(), + self.password_provider(), + self.key_encryptor(), + self.http_signer(), + account_id, + inbox_url, + activity, + "Follow", + ) + .await + } + } +} + +impl SendFollowUseCase for T where + T: 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnFollowRepository + + DependOnRemoteAccountRepository + + DependOnSigningKeyRepository + + DependOnHttpSigner + + DependOnPasswordProvider + + DependOnKeyEncryptor + + DependOnPublicBaseUrl + + DependOnOutboxActivityRepository + + DependOnPermissionChecker + + StoreOutboxActivityUseCase +{ +} + +pub(super) async fn find_existing_following( + repository: &R, + executor: &mut E, + source: &FollowTargetId, + destination: &FollowTargetId, +) -> error_stack::Result, KernelError> +where + R: FollowRepository, + E: kernel::interfaces::database::Executor, +{ + let followings = repository.find_followings(executor, source).await?; + Ok(followings + .into_iter() + .find(|follow| follow.source() == source && follow.destination() == destination)) +} + +fn follow_activity( + public_base_url: &kernel::interfaces::config::PublicBaseUrl, + follow: &Follow, + local_actor_url: &str, + remote_actor_url: &str, +) -> error_stack::Result { + Ok(Activity { + context: Some(Value::String(ACTIVITYSTREAMS_CONTEXT.to_string())), + id: format!( + "{}/activities/{}", + public_base_url.as_str().trim_end_matches('/'), + follow.id().as_ref() + ), + type_: "Follow".to_string(), + actor: local_actor_url.to_string(), + object: Some(serde_json::Value::String(remote_actor_url.to_string())), + target: None, + to: Some(vec![remote_actor_url.to_string()]), + cc: None, + }) +} diff --git a/application/src/service/activitypub/outbox.rs b/application/src/service/activitypub/outbox.rs new file mode 100644 index 00000000..acb237ef --- /dev/null +++ b/application/src/service/activitypub/outbox.rs @@ -0,0 +1,369 @@ +use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; +use error_stack::Report; +use kernel::activitypub::{ActorUrlBuilder, OrderedCollection}; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::repository::{DependOnOutboxActivityRepository, OutboxActivityRepository}; +use kernel::prelude::entity::{AccountId, OutboxActivity}; +use kernel::KernelError; +use std::future::Future; + +pub trait StoreOutboxActivityUseCase: + 'static + Sync + Send + DependOnOutboxActivityRepository +{ + fn store_outbox_activity( + &self, + activity: &OutboxActivity, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + self.outbox_activity_repository() + .create(&mut executor, activity) + .await + } + } +} + +impl StoreOutboxActivityUseCase for T where + T: 'static + Sync + Send + DependOnOutboxActivityRepository +{ +} + +pub trait GetOutboxUseCase: + 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnOutboxActivityRepository + + DependOnPublicBaseUrl +{ + fn get_outbox_collection( + &self, + account_id: &AccountId, + limit: usize, + cursor: Option, + ) -> impl Future> + Send { + async move { + let mut executor = self.database_connection().get_executor().await?; + let account = self + .account_query_processor() + .find_by_id(&mut executor, account_id) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable(format!("Account not found: {account_id:?}")) + })?; + let activities = self + .outbox_activity_repository() + .find_by_account_id(&mut executor, account_id, limit, cursor) + .await?; + let total_items = self + .outbox_activity_repository() + .count_by_account_id(&mut executor, account_id) + .await?; + let ordered_items = activities + .into_iter() + .map(|activity| { + serde_json::from_str::(&activity.object_json).map_err(|e| { + Report::new(KernelError::Internal).attach_printable(format!( + "Failed to deserialize outbox activity JSON: {e}" + )) + }) + }) + .collect::, KernelError>>()?; + + Ok(OrderedCollection::with_ordered_items( + ActorUrlBuilder::new(self.public_base_url().as_str(), account.nanoid().as_ref()) + .outbox(), + total_items, + ordered_items, + )) + } + } +} + +impl GetOutboxUseCase for T where + T: 'static + + Sync + + Send + + DependOnAccountQueryProcessor + + DependOnOutboxActivityRepository + + DependOnPublicBaseUrl +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use kernel::interfaces::config::PublicBaseUrl; + use kernel::interfaces::database::{DatabaseConnection, DependOnDatabaseConnection, Executor}; + use kernel::prelude::entity::{Account, AccountName, AuthAccountId, Nanoid}; + use kernel::test_utils::AccountBuilder; + use std::sync::Mutex; + use time::OffsetDateTime; + + #[derive(Clone)] + struct MockExecutor; + + impl Executor for MockExecutor {} + + struct MockDatabaseConnection; + + impl DatabaseConnection for MockDatabaseConnection { + type Executor = MockExecutor; + + async fn get_executor(&self) -> error_stack::Result { + Ok(MockExecutor) + } + } + + struct MockAccountQueryProcessor { + account: Account, + } + + impl AccountQueryProcessor for MockAccountQueryProcessor { + type Executor = MockExecutor; + + async fn find_by_id( + &self, + _executor: &mut Self::Executor, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok((self.account.id() == id).then(|| self.account.clone())) + } + + async fn find_by_auth_id( + &self, + _executor: &mut Self::Executor, + _auth_id: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + + async fn find_by_name( + &self, + _executor: &mut Self::Executor, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + Ok((self.account.name() == name).then(|| self.account.clone())) + } + + async fn find_by_nanoid( + &self, + _executor: &mut Self::Executor, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok((self.account.nanoid() == nanoid).then(|| self.account.clone())) + } + + async fn find_by_nanoids( + &self, + _executor: &mut Self::Executor, + _nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Executor, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut Self::Executor, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.find_by_nanoid(executor, nanoid).await + } + + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut Self::Executor, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + self.find_by_nanoids(executor, nanoids).await + } + } + + struct MockOutboxActivityRepository { + activities: Mutex>, + } + + impl OutboxActivityRepository for MockOutboxActivityRepository { + type Executor = MockExecutor; + + async fn create( + &self, + _executor: &mut Self::Executor, + activity: &OutboxActivity, + ) -> error_stack::Result<(), KernelError> { + self.activities.lock().unwrap().push(activity.clone()); + Ok(()) + } + + async fn find_by_account_id( + &self, + _executor: &mut Self::Executor, + account_id: &AccountId, + limit: usize, + cursor: Option, + ) -> error_stack::Result, KernelError> { + let mut activities = self + .activities + .lock() + .unwrap() + .iter() + .filter(|activity| &activity.account_id == account_id) + .filter(|activity| cursor.map_or(true, |cursor| activity.id < cursor)) + .cloned() + .collect::>(); + activities.sort_by(|left, right| right.id.cmp(&left.id)); + activities.truncate(limit); + Ok(activities) + } + + async fn count_by_account_id( + &self, + _executor: &mut Self::Executor, + account_id: &AccountId, + ) -> error_stack::Result { + Ok(self + .activities + .lock() + .unwrap() + .iter() + .filter(|activity| &activity.account_id == account_id) + .count() as u64) + } + } + + struct MockModule { + database: MockDatabaseConnection, + accounts: MockAccountQueryProcessor, + outbox: MockOutboxActivityRepository, + public_base_url: PublicBaseUrl, + } + + impl DependOnDatabaseConnection for MockModule { + type DatabaseConnection = MockDatabaseConnection; + + fn database_connection(&self) -> &Self::DatabaseConnection { + &self.database + } + } + + impl DependOnAccountQueryProcessor for MockModule { + type AccountQueryProcessor = MockAccountQueryProcessor; + + fn account_query_processor(&self) -> &Self::AccountQueryProcessor { + &self.accounts + } + } + + impl DependOnOutboxActivityRepository for MockModule { + type OutboxActivityRepository = MockOutboxActivityRepository; + + fn outbox_activity_repository(&self) -> &Self::OutboxActivityRepository { + &self.outbox + } + } + + impl DependOnPublicBaseUrl for MockModule { + fn public_base_url(&self) -> &PublicBaseUrl { + &self.public_base_url + } + } + + fn module() -> (MockModule, AccountId) { + kernel::ensure_generator_initialized(); + let account_id = AccountId::default(); + let account = AccountBuilder::new() + .id(account_id.clone()) + .name("alice") + .nanoid(Nanoid::new("alice".to_string())) + .build(); + + ( + MockModule { + database: MockDatabaseConnection, + accounts: MockAccountQueryProcessor { account }, + outbox: MockOutboxActivityRepository { + activities: Mutex::new(vec![outbox_activity(1, account_id.clone(), "Create")]), + }, + public_base_url: PublicBaseUrl::new("https://example.com/".to_string()), + }, + account_id, + ) + } + + fn outbox_activity(id: i64, account_id: AccountId, activity_type: &str) -> OutboxActivity { + let activity_id = format!("https://example.com/activities/{id}"); + OutboxActivity { + id, + account_id, + activity_id: activity_id.clone(), + activity_type: activity_type.to_string(), + object_json: serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": activity_id, + "type": activity_type, + "actor": "https://example.com/accounts/alice" + }) + .to_string(), + created_at: OffsetDateTime::now_utc(), + } + } + + #[tokio::test] + async fn store_outbox_activity_persists_activity() { + let (module, account_id) = module(); + let activity = outbox_activity(2, account_id.clone(), "Accept"); + + module.store_outbox_activity(&activity).await.unwrap(); + + let mut executor = MockExecutor; + let activities = module + .outbox_activity_repository() + .find_by_account_id(&mut executor, &account_id, 10, None) + .await + .unwrap(); + assert!(activities.iter().any(|stored| stored.id == 2)); + } + + #[tokio::test] + async fn outbox_collection_returns_ordered_collection_with_items() { + let (module, account_id) = module(); + + let collection = module + .get_outbox_collection(&account_id, 10, None) + .await + .unwrap(); + + assert_eq!( + collection.id, + "https://example.com/ap/accounts/alice/outbox" + ); + assert_eq!(collection.type_, "OrderedCollection"); + assert_eq!(collection.total_items, Some(1)); + assert_eq!(collection.ordered_items.as_ref().unwrap().len(), 1); + assert_eq!(collection.ordered_items.unwrap()[0]["type"], "Create"); + } + + #[tokio::test] + async fn empty_outbox_collection_returns_zero_items() { + let (module, account_id) = module(); + module.outbox.activities.lock().unwrap().clear(); + + let collection = module + .get_outbox_collection(&account_id, 10, None) + .await + .unwrap(); + + assert_eq!(collection.total_items, Some(0)); + assert!(collection.ordered_items.unwrap().is_empty()); + } +} diff --git a/application/src/service/activitypub/remote_actor.rs b/application/src/service/activitypub/remote_actor.rs new file mode 100644 index 00000000..c90444e3 --- /dev/null +++ b/application/src/service/activitypub/remote_actor.rs @@ -0,0 +1,295 @@ +use super::fetch::{client_for_url, validate_fetch_url}; +use super::ACTIVITY_JSON; +use error_stack::Report; +use kernel::activitypub::Actor; +use kernel::interfaces::repository::RemoteAccountRepository; +use kernel::prelude::entity::{ + RemoteAccount, RemoteAccountAcct, RemoteAccountId, RemoteAccountUrl, +}; +use kernel::KernelError; +use reqwest::header::{ACCEPT, USER_AGENT}; + +#[derive(Debug)] +pub(super) struct ResolvedRemoteActor { + acct: RemoteAccountAcct, + url: RemoteAccountUrl, + inbox_url: Option, + public_key_pem: Option, +} + +/// Test-only: global cache of resolved remote actor data. +/// +/// `resolve_remote_actor` checks this before making an HTTP request. +/// Keys are inserted by the E2E test when the remote server requires +/// authentication for its ActivityPub actor endpoint. +#[cfg(any(test, feature = "test-mode"))] +use std::collections::HashMap; + +#[cfg(any(test, feature = "test-mode"))] +use std::sync::{LazyLock, Mutex}; + +#[cfg(any(test, feature = "test-mode"))] +static TEST_STATIC_RESOLVED_ACTORS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Insert a remote actor into the test global cache so that +/// `resolve_remote_actor` returns it without making an HTTP request. +#[cfg(any(test, feature = "test-mode"))] +pub fn inject_test_remote_actor( + actor_url: &str, + username: &str, + inbox_url: &str, + public_key_pem: &str, +) { + let mut cache = TEST_STATIC_RESOLVED_ACTORS.lock().expect("poisoned lock"); + let actor_id_url = actor_url.trim_end_matches('/'); + let host = reqwest::Url::parse(actor_id_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_else(|| "local".to_string()); + cache.insert( + actor_id_url.to_string(), + ResolvedRemoteActor { + acct: RemoteAccountAcct::new(format!("{}@{}", username, host)), + url: RemoteAccountUrl::new(actor_id_url.to_string()), + inbox_url: Some(inbox_url.to_string()), + public_key_pem: Some(public_key_pem.to_string()), + }, + ); +} + +pub(super) async fn resolve_remote_actor( + actor_url: &str, +) -> error_stack::Result { + // Check test-mode global cache first + #[cfg(any(test, feature = "test-mode"))] + if let Ok(cache) = TEST_STATIC_RESOLVED_ACTORS.lock() { + let key = actor_url.trim_end_matches('/'); + if let Some(cached) = cache.get(key) { + return Ok(ResolvedRemoteActor { + acct: RemoteAccountAcct::new(cached.acct.as_ref().clone()), + url: RemoteAccountUrl::new(cached.url.as_ref().clone()), + inbox_url: cached.inbox_url.clone(), + public_key_pem: cached.public_key_pem.clone(), + }); + } + } + let mut url = reqwest::Url::parse(actor_url).map_err(|e| { + Report::new(KernelError::Rejected).attach_printable(format!("Invalid actor URL: {e}")) + })?; + let mut redirects = 0; + let actor = loop { + if redirects > 5 { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Too many redirects while resolving remote actor")); + } + let resolved_addresses = validate_fetch_url(&url).await?; + let response = client_for_url(&url, &resolved_addresses)? + .get(url.clone()) + .header(ACCEPT, ACTIVITY_JSON) + .header(USER_AGENT, "Emumet/0.1 ActivityPub actor resolver") + .send() + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("Remote actor fetch failed: {e}")) + })?; + if response.status().is_redirection() { + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("Remote actor redirect without Location header") + })?; + url = url.join(location).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("Remote actor redirect URL is invalid: {e}")) + })?; + redirects += 1; + continue; + } + if !response.status().is_success() { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + tracing::debug!( + remote_actor_url = %url, + status = %status, + body = %body_text, + "Remote actor fetch failed with non-success status" + ); + return Err(Report::new(KernelError::Rejected) + .attach_printable(format!("Remote actor endpoint returned {status}",))); + } + break response.json::().await.map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("Remote actor JSON is invalid: {e}")) + })?; + }; + + let actor_id = reqwest::Url::parse(&actor.id).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("Remote actor id is invalid: {e}")) + })?; + let host = actor_id.host_str().ok_or_else(|| { + Report::new(KernelError::Rejected).attach_printable("Remote actor id has no host") + })?; + if actor.preferred_username.trim().is_empty() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Remote actor preferredUsername is empty")); + } + if !same_activitypub_id(&actor.id, actor_url) { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "Remote actor id does not match requested actor URL: expected {actor_url}, got {}", + actor.id + ))); + } + + Ok(ResolvedRemoteActor { + acct: RemoteAccountAcct::new(format!("{}@{}", actor.preferred_username, host)), + url: RemoteAccountUrl::new(actor.id), + inbox_url: Some(actor.inbox), + public_key_pem: Some(actor.public_key.public_key_pem), + }) +} + +pub(super) async fn resolve_remote_actor_identifier( + identifier: &str, +) -> error_stack::Result { + if let Some((user, domain)) = identifier + .strip_prefix("acct:") + .and_then(|s| s.split_once('@')) + { + if user.is_empty() || domain.is_empty() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Invalid acct: format, expected acct:user@domain")); + } + return resolve_remote_webfinger(user, domain).await; + } + resolve_remote_actor(identifier).await +} + +async fn resolve_remote_webfinger( + user: &str, + domain: &str, +) -> error_stack::Result { + if !user + .chars() + .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') + { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Invalid characters in WebFinger user")); + } + if !domain + .chars() + .all(|c| c.is_alphanumeric() || c == '.' || c == '-') + || domain.starts_with('-') + || domain.ends_with('-') + { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Invalid characters in WebFinger domain")); + } + let webfinger_url = format!( + "https://{}/.well-known/webfinger?resource=acct:{}@{}", + domain, user, domain + ); + let url = reqwest::Url::parse(&webfinger_url).map_err(|e| { + Report::new(KernelError::Rejected).attach_printable(format!("Invalid WebFinger URL: {e}")) + })?; + let resolved_addresses = validate_fetch_url(&url).await?; + let response = client_for_url(&url, &resolved_addresses)? + .get(url) + .header(ACCEPT, "application/jrd+json") + .header(USER_AGENT, "Emumet/0.1 WebFinger resolver") + .send() + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("WebFinger request failed: {e}")) + })?; + if !response.status().is_success() { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "WebFinger returned {} for {}@{}", + response.status(), + user, + domain + ))); + } + let jrd: serde_json::Value = response.json().await.map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("WebFinger response is not valid JSON: {e}")) + })?; + let subject = jrd.get("subject").and_then(|s| s.as_str()).ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("WebFinger response missing subject field") + })?; + let expected_subject = format!("acct:{}@{}", user, domain); + if subject != expected_subject { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "WebFinger subject mismatch: expected {expected_subject}, got {subject}" + ))); + } + let actor_url = jrd + .get("links") + .and_then(|links| links.as_array()) + .and_then(|links| { + links.iter().find_map(|link| { + let rel = link.get("rel").and_then(|r| r.as_str())?; + let type_ = link.get("type").and_then(|t| t.as_str())?; + let href = link.get("href").and_then(|h| h.as_str())?; + if rel == "self" + && (type_ == "application/activity+json" || type_ == "application/ld+json") + { + Some(href.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "No ActivityPub link found in WebFinger response for {}@{}", + user, domain + )) + })?; + resolve_remote_actor(&actor_url).await +} + +pub(super) async fn upsert_remote_account( + repository: &R, + executor: &mut E, + actor: ResolvedRemoteActor, +) -> error_stack::Result +where + R: RemoteAccountRepository, + E: kernel::interfaces::database::Executor, +{ + if let Some(existing) = repository.find_by_url(executor, &actor.url).await? { + let updated = RemoteAccount::new( + existing.id().clone(), + actor.acct, + actor.url, + existing.icon_id().clone(), + actor.inbox_url, + actor.public_key_pem, + ); + repository.update(executor, &updated).await?; + return Ok(updated); + } + + let remote_account = RemoteAccount::new( + RemoteAccountId::new(kernel::generate_id()), + actor.acct, + actor.url, + None, + actor.inbox_url, + actor.public_key_pem, + ); + repository.create(executor, &remote_account).await?; + Ok(remote_account) +} + +fn same_activitypub_id(left: &str, right: &str) -> bool { + left.trim_end_matches('/') == right.trim_end_matches('/') +} From 9404377f3b2ca0bc81900157ecf63ffb04f2774e Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 19 Jul 2026 02:20:48 +0900 Subject: [PATCH 2/4] :recycle: Split http_signing into directory module by responsibility Pure mechanical move of driver/src/http_signing.rs into a directory module; no behavior change. --- driver/src/http_signing.rs | 1644 ---------------------- driver/src/http_signing/actor_key.rs | 122 ++ driver/src/http_signing/cavage/mod.rs | 173 +++ driver/src/http_signing/cavage/parser.rs | 144 ++ driver/src/http_signing/fetch.rs | 113 ++ driver/src/http_signing/mod.rs | 13 + driver/src/http_signing/signer.rs | 192 +++ driver/src/http_signing/ssrf.rs | 144 ++ driver/src/http_signing/tests.rs | 604 ++++++++ driver/src/http_signing/verifier.rs | 201 +++ 10 files changed, 1706 insertions(+), 1644 deletions(-) delete mode 100644 driver/src/http_signing.rs create mode 100644 driver/src/http_signing/actor_key.rs create mode 100644 driver/src/http_signing/cavage/mod.rs create mode 100644 driver/src/http_signing/cavage/parser.rs create mode 100644 driver/src/http_signing/fetch.rs create mode 100644 driver/src/http_signing/mod.rs create mode 100644 driver/src/http_signing/signer.rs create mode 100644 driver/src/http_signing/ssrf.rs create mode 100644 driver/src/http_signing/tests.rs create mode 100644 driver/src/http_signing/verifier.rs diff --git a/driver/src/http_signing.rs b/driver/src/http_signing.rs deleted file mode 100644 index 23d9ef0c..00000000 --- a/driver/src/http_signing.rs +++ /dev/null @@ -1,1644 +0,0 @@ -use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, SystemTime}; - -use base64::{engine::general_purpose, Engine as _}; -use bytes::Bytes; -use error_stack::{Report, Result}; -use http_body_util::Full; -use kernel::interfaces::crypto::SignatureVerifier as CryptoSignatureVerifier; -use kernel::interfaces::http_signing::{ - ActorPublicKey, HttpSignatureVerificationInput, HttpSignatureVerifier, HttpSigner, - HttpSigningRequest, HttpSigningResponse, SignatureVerificationResult, -}; -use kernel::KernelError; -use reqwest::header::{ACCEPT, LOCATION, USER_AGENT}; -use rsa::pkcs1v15::SigningKey; -use rsa::pkcs8::DecodePrivateKey; -use rsa::sha2::Sha256; -use rsa::signature::{SignatureEncoding, Signer as RsaSigner}; -use rsa::RsaPrivateKey; -use serde_json::Value; -use sha2::Digest; - -struct RsaCavageSignerKey { - private_key: RsaPrivateKey, - key_id: String, -} - -impl RsaCavageSignerKey { - fn new(private_key_pem: &[u8], key_id: &str) -> Result { - let pem_str = std::str::from_utf8(private_key_pem).map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Invalid UTF-8 in private key PEM: {e}")) - })?; - let private_key = RsaPrivateKey::from_pkcs8_pem(pem_str).map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to parse private key PEM: {e}")) - })?; - Ok(Self { - private_key, - key_id: key_id.to_string(), - }) - } -} - -impl http_msgsign_draft::sign::SignerKey for RsaCavageSignerKey { - fn id(&self) -> String { - self.key_id.clone() - } - - fn algorithm(&self) -> String { - "rsa-sha256".to_string() - } - - fn sign(&self, target: &[u8]) -> Vec { - let signing_key = SigningKey::::new(self.private_key.clone()); - signing_key.sign(target).to_bytes().to_vec() - } -} - -struct RsaRfc9421SignerKey { - private_key: RsaPrivateKey, - key_id: String, -} - -impl RsaRfc9421SignerKey { - fn new(private_key_pem: &[u8], key_id: &str) -> Result { - let pem_str = std::str::from_utf8(private_key_pem).map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Invalid UTF-8 in private key PEM: {e}")) - })?; - let private_key = RsaPrivateKey::from_pkcs8_pem(pem_str).map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to parse private key PEM: {e}")) - })?; - Ok(Self { - private_key, - key_id: key_id.to_string(), - }) - } -} - -impl http_msgsign::SignerKey for RsaRfc9421SignerKey { - const ALGORITHM: &'static str = "rsa-v1_5-sha256"; - - fn key_id(&self) -> String { - self.key_id.clone() - } - - fn sign(&self, target: &[u8]) -> Vec { - let signing_key = SigningKey::::new(self.private_key.clone()); - signing_key.sign(target).to_bytes().to_vec() - } -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct HttpSignerImpl; - -impl HttpSigner for HttpSignerImpl { - async fn sign( - &self, - request: &HttpSigningRequest, - private_key_pem: &[u8], - key_id: &str, - _algorithm: &kernel::interfaces::crypto::SigningAlgorithm, - ) -> Result { - use http_msgsign::RequestSign as Rfc9421RequestSign; - use http_msgsign_draft::sign::RequestSign as CavageRequestSign; - - let cavage_req = build_http_request(request)?; - let rfc9421_req = build_http_request(request)?; - - let cavage_key = RsaCavageSignerKey::new(private_key_pem, key_id)?; - let mut cavage_params_builder = http_msgsign_draft::sign::SignatureParams::builder() - .add_request_target() - .add_header("host") - .add_header("date"); - if get_header(&request.headers, "digest").is_some() { - cavage_params_builder = cavage_params_builder.add_header("digest"); - } - let cavage_params = cavage_params_builder.build().map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build Cavage signature params: {e}")) - })?; - - let signed_cavage = CavageRequestSign::sign(cavage_req, &cavage_key, &cavage_params) - .await - .map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Cavage signing failed: {e}")) - })?; - - let cavage_headers = extract_headers(&signed_cavage); - - let rfc9421_key = RsaRfc9421SignerKey::new(private_key_pem, key_id)?; - let rfc9421_params = http_msgsign::SignatureParams::builder() - .add_derive( - http_msgsign::components::Derive::Method, - http_msgsign::components::params::FieldParameter::default(), - ) - .add_derive( - http_msgsign::components::Derive::TargetUri, - http_msgsign::components::params::FieldParameter::default(), - ) - .add_derive( - http_msgsign::components::Derive::Authority, - http_msgsign::components::params::FieldParameter::default(), - ) - .gen_created() - .build() - .map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build RFC 9421 signature params: {e}")) - })?; - - let signed_rfc9421 = - Rfc9421RequestSign::sign(rfc9421_req, &rfc9421_key, "sig1", &rfc9421_params) - .await - .map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("RFC 9421 signing failed: {e}")) - })?; - - let rfc9421_headers = extract_headers(&signed_rfc9421); - - Ok(HttpSigningResponse { - cavage_headers, - rfc9421_headers, - }) - } -} - -/// Test-only: global cache of actor public keys injected via the test-mode API. -/// -/// `fetch_actor_key` checks this before making an HTTP request. Keys are -/// inserted by the E2E test when the remote server (e.g. Iceshrimp) requires -/// authentication for its ActivityPub actor endpoint. -static TEST_STATIC_ACTOR_KEYS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Insert an actor public key into the test global cache so that -/// `fetch_actor_key` returns it without making an HTTP request. -/// -/// `owner` is the ActivityPub actor ID (URL) that owns this key. It is used -/// by the ActivityPub route handler to verify that the signing key belongs to -/// the actor who sent the activity. Pass an empty string if unknown. -#[cfg(any(test, feature = "test-mode"))] -pub fn inject_test_actor_key(key_id: &str, public_key_pem: String, owner: &str) { - let mut map = TEST_STATIC_ACTOR_KEYS.lock().expect("poisoned lock"); - let pem_clone = public_key_pem.clone(); - let owner_val = if owner.is_empty() { - // Derive owner from key_id by stripping the fragment. - reqwest::Url::parse(key_id) - .ok() - .and_then(|mut url| { - url.set_fragment(None); - Some(url.to_string()) - }) - .unwrap_or_default() - } else { - owner.to_string() - }; - map.insert( - key_id.to_string(), - ActorPublicKey { - id: key_id.to_string(), - owner: owner_val.clone(), - public_key_pem, - }, - ); - if let Ok(mut url) = reqwest::Url::parse(key_id) { - url.set_fragment(None); - let stripped = url.to_string(); - if stripped != key_id { - map.insert( - stripped, - ActorPublicKey { - id: key_id.to_string(), - owner: owner_val, - public_key_pem: pem_clone, - }, - ); - } - } -} - -#[derive(Debug, Clone)] -pub struct HttpSignatureVerifierImpl { - client: reqwest::Client, - max_response_bytes: usize, - date_tolerance: Duration, - static_actor_keys: HashMap, -} - -impl HttpSignatureVerifierImpl { - pub fn new() -> Result { - let client_builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(10)); - #[cfg(any(test, feature = "test-mode"))] - let client_builder = if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") - { - client_builder.danger_accept_invalid_certs(true) - } else { - client_builder - }; - let client = client_builder.build().map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build HTTP client: {e}")) - })?; - - Ok(Self { - client, - max_response_bytes: 1024 * 1024, - date_tolerance: Duration::from_secs(5 * 60), - static_actor_keys: HashMap::new(), - }) - } - - /// Register an actor key that will be returned from cache without an HTTP fetch. - /// - /// This is intended for test environments where the remote ActivityPub server - /// requires authentication for its actor endpoint. The calling test retrieves - /// the public key via an authenticated API and injects it here, allowing - /// HTTP Signature verification to succeed without a direct key fetch. - /// - /// `key_id` must match the full keyId URI (including the `#main-key` fragment - /// if present in the HTTP Signature). The method normalizes by checking both - /// the raw key and the fragment-stripped URL. - #[cfg(any(test, feature = "test-mode"))] - pub fn cache_actor_key(&mut self, key_id: &str, public_key_pem: String) { - let key = ActorPublicKey { - id: key_id.to_string(), - owner: String::new(), - public_key_pem, - }; - self.static_actor_keys - .insert(key_id.to_string(), key.clone()); - - // Also index by fragment-stripped URL so fetch_actor_key can hit - // the cache even when the caller strips the fragment first. - if let Ok(mut url) = reqwest::Url::parse(key_id) { - url.set_fragment(None); - if url.as_str() != key_id { - self.static_actor_keys.insert(url.to_string(), key); - } - } - } -} - -impl Default for HttpSignatureVerifierImpl { - fn default() -> Self { - Self::new().expect("HTTP signature verifier client construction should not fail") - } -} - -impl HttpSignatureVerifier for HttpSignatureVerifierImpl { - async fn verify( - &self, - request: &HttpSignatureVerificationInput, - ) -> Result { - if get_header(&request.headers, "signature-input").is_some() { - return Ok(SignatureVerificationResult::Invalid( - "RFC 9421 verification is not implemented".to_string(), - )); - } - - let signature_header = match get_header(&request.headers, "signature") { - Some(value) => value, - None => { - return Ok(SignatureVerificationResult::Invalid( - "MissingSignature: Signature header is required".to_string(), - )); - } - }; - - let parsed = match parse_cavage_signature(signature_header) { - Ok(parsed) => parsed, - Err(message) => { - return Ok(SignatureVerificationResult::Invalid(format!( - "MalformedSignature: {message}" - ))); - } - }; - - if let Err(message) = validate_required_signed_headers(request, &parsed.headers) { - return Ok(SignatureVerificationResult::Invalid(message)); - } - - if let Err(message) = validate_date_header(request, self.date_tolerance) { - return Ok(SignatureVerificationResult::Invalid(message)); - } - - if let Err(message) = validate_digest_header(request) { - return Ok(SignatureVerificationResult::Invalid(message)); - } - - let signing_string = match cavage_signing_string(request, &parsed.headers) { - Ok(value) => value, - Err(message) => return Ok(SignatureVerificationResult::Invalid(message)), - }; - - let actor_key = match self.fetch_actor_key(&parsed.key_id).await { - Ok(value) => value, - Err(e) => { - return Ok(SignatureVerificationResult::Invalid(format!( - "KeyFetchFailed: {e:?}" - ))); - } - }; - - let valid = verify_cavage_signature( - &signing_string, - &parsed.signature, - actor_key.public_key_pem.as_bytes(), - &parsed.algorithm, - ); - - match valid { - Ok(true) => Ok(SignatureVerificationResult::Valid { - key_id: parsed.key_id, - }), - Ok(false) => Ok(SignatureVerificationResult::Invalid( - "InvalidSignature: signature does not match".to_string(), - )), - Err(message) => Ok(SignatureVerificationResult::Invalid(message)), - } - } - - async fn fetch_actor_key(&self, key_id: &str) -> Result { - // Check test-mode global cache first - if let Ok(map) = TEST_STATIC_ACTOR_KEYS.lock() { - if let Some(key) = map.get(key_id) { - return Ok(key.clone()); - } - // Also try fragment-stripped URL - if let Ok(mut url) = reqwest::Url::parse(key_id) { - url.set_fragment(None); - let stripped = url.to_string(); - if stripped != key_id { - if let Some(key) = map.get(&stripped) { - return Ok(key.clone()); - } - } - } - } - - if let Some(key) = self.static_actor_keys.get(key_id) { - return Ok(key.clone()); - } - - let mut url = reqwest::Url::parse(key_id).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("MalformedSignature: invalid keyId URL: {e}")) - })?; - url.set_fragment(None); - - if let Some(key) = self.static_actor_keys.get(url.as_str()) { - return Ok(key.clone()); - } - - let body = self.fetch_limited_json(url).await?; - actor_public_key_from_json(key_id, &body).map_err(|message| { - Report::new(KernelError::Rejected).attach_printable(format!( - "KeyFetchFailed: actor document does not contain a usable public key: {message}" - )) - }) - } -} - -impl HttpSignatureVerifierImpl { - async fn fetch_limited_json(&self, mut url: reqwest::Url) -> Result { - for _ in 0..=5 { - let resolved_addresses = validate_fetch_url(&url).await?; - - let response = self - .client_for_url(&url, &resolved_addresses)? - .get(url.clone()) - .header(ACCEPT, "application/activity+json") - .header(USER_AGENT, "Emumet/0.1 ActivityPub HTTP Signature verifier") - .send() - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("KeyFetchFailed: actor key fetch failed: {e}")) - })?; - - if response.status().is_redirection() { - let location = response - .headers() - .get(LOCATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("KeyFetchFailed: redirect without Location header") - })?; - url = url.join(location).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("KeyFetchFailed: malformed redirect URL: {e}")) - })?; - continue; - } - - if !response.status().is_success() { - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - tracing::debug!( - key_fetch_url = %url, - key_fetch_status = %status, - key_fetch_body = %body_text, - "KeyFetch failed with non-success status" - ); - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "KeyFetchFailed: actor key endpoint returned {}", - status, - ))); - } - - let mut bytes = Vec::new(); - let mut response = response; - while let Some(chunk) = response.chunk().await.map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("KeyFetchFailed: response read failed: {e}")) - })? { - if bytes.len() + chunk.len() > self.max_response_bytes { - return Err(Report::new(KernelError::Rejected) - .attach_printable("KeyFetchFailed: actor key response exceeds 1 MiB")); - } - bytes.extend_from_slice(&chunk); - } - - return serde_json::from_slice(&bytes).map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("KeyFetchFailed: invalid actor JSON: {e}")) - }); - } - - Err(Report::new(KernelError::Rejected) - .attach_printable("KeyFetchFailed: too many redirects while fetching actor key")) - } - - fn client_for_url( - &self, - url: &reqwest::Url, - resolved_addresses: &[SocketAddr], - ) -> Result { - let Some(host) = url.host_str() else { - return Ok(self.client.clone()); - }; - - if host.parse::().is_ok() { - return Ok(self.client.clone()); - } - - let builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(10)); - #[cfg(any(test, feature = "test-mode"))] - let builder = if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") { - builder.danger_accept_invalid_certs(true) - } else { - builder - }; - builder - .resolve_to_addrs(host, resolved_addresses) - .build() - .map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build pinned HTTP client: {e}")) - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CavageSignature { - key_id: String, - algorithm: String, - headers: Vec, - signature: Vec, -} - -fn parse_cavage_signature(header: &str) -> std::result::Result { - let mut params = HashMap::new(); - for part in split_signature_params(header)? { - let (name, value) = parse_signature_param(&part)?; - if params.insert(name.to_ascii_lowercase(), value).is_some() { - return Err("duplicate signature parameter is not allowed".to_string()); - } - } - - let key_id = params - .remove("keyid") - .ok_or_else(|| "keyId parameter is required".to_string())?; - let algorithm = params - .remove("algorithm") - .unwrap_or_else(|| "rsa-sha256".to_string()); - let headers = params - .remove("headers") - .map(|value| { - value - .split_whitespace() - .map(|header| header.to_ascii_lowercase()) - .collect::>() - }) - .filter(|headers| !headers.is_empty()) - .unwrap_or_else(|| vec!["date".to_string()]); - let signature = params - .remove("signature") - .ok_or_else(|| "signature parameter is required".to_string())?; - let signature = general_purpose::STANDARD - .decode(signature.as_bytes()) - .map_err(|e| format!("signature is not valid base64: {e}"))?; - - Ok(CavageSignature { - key_id, - algorithm, - headers, - signature, - }) -} - -fn split_signature_params(header: &str) -> std::result::Result, String> { - let mut params = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - let mut escaped = false; - - for ch in header.chars() { - if escaped { - current.push(ch); - escaped = false; - continue; - } - - match ch { - '\\' if in_quotes => { - current.push(ch); - escaped = true; - } - '"' => { - in_quotes = !in_quotes; - current.push(ch); - } - ',' if !in_quotes => { - if !current.trim().is_empty() { - params.push(current.trim().to_string()); - } - current.clear(); - } - _ => current.push(ch), - } - } - - if in_quotes { - return Err("unterminated quoted parameter".to_string()); - } - - if !current.trim().is_empty() { - params.push(current.trim().to_string()); - } - - if params.is_empty() { - return Err("Signature header is empty".to_string()); - } - - Ok(params) -} - -fn parse_signature_param(part: &str) -> std::result::Result<(String, String), String> { - let (name, value) = part - .split_once('=') - .ok_or_else(|| format!("parameter is missing '=': {part}"))?; - let name = name.trim(); - if name.is_empty() { - return Err("parameter name is empty".to_string()); - } - - let value = value.trim(); - let value = if value.starts_with('"') { - parse_quoted_value(value)? - } else { - value.to_string() - }; - - Ok((name.to_string(), value)) -} - -fn parse_quoted_value(value: &str) -> std::result::Result { - if !value.ends_with('"') || value.len() < 2 { - return Err("quoted parameter is not closed".to_string()); - } - - let inner = &value[1..value.len() - 1]; - let mut output = String::new(); - let mut escaped = false; - - for ch in inner.chars() { - if escaped { - output.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else { - output.push(ch); - } - } - - if escaped { - return Err("quoted parameter ends with an escape".to_string()); - } - - Ok(output) -} - -fn cavage_signing_string( - request: &HttpSignatureVerificationInput, - headers: &[String], -) -> std::result::Result, String> { - let url = reqwest::Url::parse(&request.url) - .map_err(|e| format!("MalformedSignature: request URL is invalid: {e}"))?; - let path_and_query = match url.query() { - Some(query) => format!("{}?{}", url.path(), query), - None => url.path().to_string(), - }; - - let mut lines = Vec::with_capacity(headers.len()); - for header in headers { - if header == "(request-target)" { - lines.push(format!( - "(request-target): {} {}", - request.method.to_ascii_lowercase(), - path_and_query - )); - continue; - } - - let value = get_header(&request.headers, header).ok_or_else(|| { - format!("MalformedSignature: signed header '{header}' is missing from request") - })?; - lines.push(format!("{}: {}", header.to_ascii_lowercase(), value)); - } - - Ok(lines.join("\n").into_bytes()) -} - -fn verify_cavage_signature( - signing_string: &[u8], - signature: &[u8], - public_key_pem: &[u8], - algorithm: &str, -) -> std::result::Result { - let algorithm = algorithm.to_ascii_lowercase(); - - if algorithm == "ed25519" || algorithm == "ed25519-sha256" { - return super::crypto::Ed25519Verifier - .verify(signing_string, signature, public_key_pem) - .map_err(|e| format!("InvalidSignature: Ed25519 verification failed: {e:?}")); - } - - if algorithm == "rsa-sha256" || algorithm == "hs2019" { - match super::crypto::Rsa2048Verifier.verify(signing_string, signature, public_key_pem) { - Ok(valid) => return Ok(valid), - Err(rsa_error) if algorithm == "hs2019" => { - return super::crypto::Ed25519Verifier - .verify(signing_string, signature, public_key_pem) - .map_err(|ed_error| { - format!( - "InvalidSignature: hs2019 verification failed: RSA {rsa_error:?}; Ed25519 {ed_error:?}" - ) - }); - } - Err(e) => { - return Err(format!("InvalidSignature: RSA verification failed: {e:?}")); - } - } - } - - Err(format!( - "MalformedSignature: unsupported signature algorithm '{algorithm}'" - )) -} - -fn validate_date_header( - request: &HttpSignatureVerificationInput, - tolerance: Duration, -) -> std::result::Result<(), String> { - let date = get_header(&request.headers, "date") - .ok_or_else(|| "StaleDate: Date header is required".to_string())?; - let parsed = httpdate::parse_http_date(date) - .map_err(|e| format!("StaleDate: Date header is malformed: {e}"))?; - let now = SystemTime::now(); - - if parsed > now { - let delta = parsed - .duration_since(now) - .unwrap_or_else(|_| Duration::from_secs(0)); - if delta > tolerance { - return Err("StaleDate: Date header is too far in the future".to_string()); - } - } else { - let delta = now - .duration_since(parsed) - .unwrap_or_else(|_| Duration::from_secs(0)); - if delta > tolerance { - return Err("StaleDate: Date header is expired".to_string()); - } - } - - Ok(()) -} - -fn validate_digest_header( - request: &HttpSignatureVerificationInput, -) -> std::result::Result<(), String> { - let has_body = request.body.as_ref().is_some_and(|body| !body.is_empty()); - let Some(digest_header) = get_header(&request.headers, "digest") else { - if has_body { - return Err( - "DigestMismatch: Digest header is required for requests with a body".to_string(), - ); - } - return Ok(()); - }; - let expected = digest_header - .split(',') - .find_map(|part| { - let (name, value) = part.trim().split_once('=')?; - if name.eq_ignore_ascii_case("sha-256") { - Some(value.trim()) - } else { - None - } - }) - .ok_or_else(|| "DigestMismatch: Digest header is missing SHA-256 value".to_string())?; - - let expected = general_purpose::STANDARD - .decode(expected.as_bytes()) - .map_err(|e| format!("DigestMismatch: SHA-256 digest is not valid base64: {e}"))?; - let actual = sha2::Sha256::digest(request.body.as_deref().unwrap_or_default()); - - if expected.as_slice() == actual.as_slice() { - Ok(()) - } else { - Err("DigestMismatch: body digest does not match Digest header".to_string()) - } -} - -fn validate_required_signed_headers( - request: &HttpSignatureVerificationInput, - signed_headers: &[String], -) -> std::result::Result<(), String> { - for required in ["(request-target)", "host", "date"] { - if !signed_headers.iter().any(|header| header == required) { - return Err(format!( - "MalformedSignature: required header '{required}' must be signed" - )); - } - } - - let has_body = request.body.as_ref().is_some_and(|body| !body.is_empty()); - if (has_body || get_header(&request.headers, "digest").is_some()) - && !signed_headers.iter().any(|header| header == "digest") - { - return Err( - "MalformedSignature: Digest header must be signed when a body is present".to_string(), - ); - } - - Ok(()) -} - -fn get_header<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { - headers - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case(name)) - .map(|(_, value)| value.as_str()) -} - -/// Checks whether a host is allowlisted for test-mode AP key fetch operations. -/// -/// Reads the `AP_TEST_ALLOWED_FETCH_HOSTS` environment variable (comma-separated, -/// trimmed, lowercase) and returns true if `host_lc` matches any entry. -/// Returns false when the env var is unset or empty. -fn is_fetch_host_allowed(host_lc: &str) -> bool { - std::env::var("AP_TEST_ALLOWED_FETCH_HOSTS") - .ok() - .is_some_and(|val| { - val.split(',') - .any(|entry| entry.trim().eq_ignore_ascii_case(host_lc)) - }) -} - -async fn validate_fetch_url(url: &reqwest::Url) -> Result, KernelError> { - match url.scheme() { - "http" | "https" => {} - scheme => { - return Err(Report::new(KernelError::Rejected).attach_printable(format!( - "SsrfBlocked: unsupported keyId URL scheme '{scheme}'" - ))); - } - } - - if !url.username().is_empty() || url.password().is_some() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: keyId URL credentials are not allowed")); - } - - let host = url.host_str().ok_or_else(|| { - Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: keyId URL host is empty") - })?; - let host_lc = host.trim_end_matches('.').to_ascii_lowercase(); - - let ssrf_bypassed = cfg!(any(test, feature = "test-mode")) && is_fetch_host_allowed(&host_lc); - - if !ssrf_bypassed { - if host_lc == "localhost" || host_lc.ends_with(".localhost") { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: localhost keyId URL is not allowed")); - } - - if let Ok(ip) = host_lc.parse::() { - validate_public_ip(ip)?; - let port = url.port_or_known_default().ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: keyId URL does not have a usable port") - })?; - return Ok(vec![SocketAddr::new(ip, port)]); - } - - let port = url.port_or_known_default().ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: keyId URL does not have a usable port") - })?; - let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) - })? - .collect::>(); - - if addresses.is_empty() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); - } - - for address in &addresses { - validate_public_ip(address.ip())?; - } - - Ok(addresses) - } else { - let port = url.port_or_known_default().ok_or_else(|| { - Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: keyId URL does not have a usable port") - })?; - let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) - .await - .map_err(|e| { - Report::new(KernelError::Rejected) - .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) - })? - .collect::>(); - - if addresses.is_empty() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); - } - Ok(addresses) - } -} - -fn validate_public_ip(ip: IpAddr) -> Result<(), KernelError> { - let blocked = match ip { - IpAddr::V4(ip) => is_blocked_ipv4(ip), - IpAddr::V6(ip) => is_blocked_ipv6(ip), - }; - - if blocked { - Err(Report::new(KernelError::Rejected).attach_printable(format!( - "SsrfBlocked: non-public IP address is not allowed: {ip}" - ))) - } else { - Ok(()) - } -} - -fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { - let octets = ip.octets(); - ip.is_private() - || ip.is_loopback() - || ip.is_link_local() - || ip.is_broadcast() - || ip.is_documentation() - || ip.is_multicast() - || ip.is_unspecified() - || octets[0] == 0 - || octets[0] >= 224 - || (octets[0] == 100 && (64..=127).contains(&octets[1])) - || (octets[0] == 198 && (18..=19).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) -} - -fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { - if let Some(ipv4) = ip.to_ipv4_mapped() { - return is_blocked_ipv4(ipv4); - } - - ip.is_loopback() - || ip.is_unspecified() - || ip.is_multicast() - || (ip.segments()[0] & 0xfe00) == 0xfc00 - || (ip.segments()[0] & 0xffc0) == 0xfe80 - || (ip.segments()[0] & 0xffff) == 0x2001 && (ip.segments()[1] & 0xfff0) == 0x0db8 - || ip.segments()[0] == 0x2002 - || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0) -} - -fn actor_public_key_from_json( - key_id: &str, - document: &Value, -) -> std::result::Result { - if document.get("publicKeyPem").is_some() && key_value_matches_key_id(key_id, document) { - return public_key_value_to_actor_key(key_id, document, document.get("owner")); - } - - let public_key = document - .get("publicKey") - .ok_or_else(|| "publicKey field is missing".to_string())?; - - match public_key { - Value::Object(_) if key_value_matches_key_id(key_id, public_key) => { - public_key_value_to_actor_key(key_id, public_key, document.get("id")) - } - Value::Object(_) => Err("publicKey.id does not match keyId".to_string()), - Value::Array(keys) => keys - .iter() - .find(|value| key_value_matches_key_id(key_id, value)) - .map(|value| public_key_value_to_actor_key(key_id, value, document.get("id"))) - .transpose()? - .ok_or_else(|| "publicKey array does not contain a key matching keyId".to_string()), - _ => Err("publicKey field is not an object".to_string()), - } -} - -fn key_value_matches_key_id(key_id: &str, value: &Value) -> bool { - value - .get("id") - .and_then(Value::as_str) - .is_some_and(|id| id == key_id) -} - -fn public_key_value_to_actor_key( - key_id: &str, - value: &Value, - owner_fallback: Option<&Value>, -) -> std::result::Result { - let id = value - .get("id") - .and_then(Value::as_str) - .unwrap_or(key_id) - .to_string(); - let owner = value - .get("owner") - .or(owner_fallback) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let public_key_pem = value - .get("publicKeyPem") - .and_then(Value::as_str) - .ok_or_else(|| "publicKeyPem field is missing".to_string())? - .to_string(); - - Ok(ActorPublicKey { - id, - owner, - public_key_pem, - }) -} - -fn build_http_request( - request: &HttpSigningRequest, -) -> Result>, KernelError> { - let mut builder = http::Request::builder() - .method(request.method.as_str()) - .uri(&request.url); - - for (name, value) in &request.headers { - builder = builder.header(name.as_str(), value.as_str()); - } - - let body = Full::new(Bytes::from(request.body.clone().unwrap_or_default())); - - builder.body(body).map_err(|e| { - Report::new(KernelError::Internal) - .attach_printable(format!("Failed to build HTTP request: {e}")) - }) -} - -fn extract_headers(req: &http::Request) -> HashMap { - let mut headers = HashMap::new(); - for (name, value) in req.headers() { - if let Ok(v) = value.to_str() { - headers.insert(name.to_string(), v.to_string()); - } - } - headers -} - -#[cfg(test)] -mod tests { - use super::*; - use kernel::interfaces::crypto::SigningAlgorithm; - - fn generate_test_rsa_keypair() -> Vec { - generate_test_rsa_keypair_with_public().0 - } - - fn generate_test_rsa_keypair_with_public() -> (Vec, String) { - use rand::rngs::OsRng; - use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey}; - - let private_key = RsaPrivateKey::new(&mut OsRng, 2048).unwrap(); - let public_key = rsa::RsaPublicKey::from(&private_key); - let pem = private_key - .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) - .unwrap(); - let public_pem = public_key - .to_public_key_pem(rsa::pkcs8::LineEnding::LF) - .unwrap(); - (pem.as_bytes().to_vec(), public_pem) - } - - fn make_signing_request() -> HttpSigningRequest { - let mut headers = HashMap::new(); - headers.insert("host".to_string(), "example.com".to_string()); - headers.insert( - "date".to_string(), - "Thu, 01 Jan 2025 00:00:00 GMT".to_string(), - ); - - HttpSigningRequest { - method: "POST".to_string(), - url: "https://example.com/inbox".to_string(), - headers, - body: Some(b"hello".to_vec()), - } - } - - fn make_fresh_signing_request(body: &[u8]) -> HttpSigningRequest { - let mut headers = HashMap::new(); - headers.insert("host".to_string(), "example.com".to_string()); - headers.insert( - "date".to_string(), - httpdate::fmt_http_date(SystemTime::now()), - ); - - HttpSigningRequest { - method: "POST".to_string(), - url: "https://example.com/inbox?x=1".to_string(), - headers, - body: Some(body.to_vec()), - } - } - - fn digest_header(body: &[u8]) -> String { - format!( - "SHA-256={}", - general_purpose::STANDARD.encode(sha2::Sha256::digest(body)) - ) - } - - fn test_verifier(key_id: &str, public_key_pem: String) -> HttpSignatureVerifierImpl { - let mut verifier = HttpSignatureVerifierImpl::new().unwrap(); - verifier.static_actor_keys.insert( - key_id.to_string(), - ActorPublicKey { - id: key_id.to_string(), - owner: "https://remote.example/users/alice".to_string(), - public_key_pem, - }, - ); - verifier - } - - fn replace_signed_headers(request: &mut HttpSignatureVerificationInput, headers: &str) { - let current = request.headers.get("signature").unwrap(); - let parsed = parse_cavage_signature(current).unwrap(); - request.headers.insert( - "signature".to_string(), - format!( - "keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"", - parsed.key_id, - parsed.algorithm, - headers, - general_purpose::STANDARD.encode(parsed.signature) - ), - ); - } - - fn replace_signature_algorithm(request: &mut HttpSignatureVerificationInput, algorithm: &str) { - let current = request.headers.get("signature").unwrap(); - let parsed = parse_cavage_signature(current).unwrap(); - request.headers.insert( - "signature".to_string(), - format!( - "keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"", - parsed.key_id, - algorithm, - parsed.headers.join(" "), - general_purpose::STANDARD.encode(parsed.signature) - ), - ); - } - - async fn signed_verification_request( - private_key_pem: &[u8], - key_id: &str, - body: &[u8], - ) -> HttpSignatureVerificationInput { - let signer = HttpSignerImpl; - let mut signing_request = make_fresh_signing_request(body); - signing_request - .headers - .insert("digest".to_string(), digest_header(body)); - let signed = signer - .sign( - &signing_request, - private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await - .unwrap(); - - HttpSignatureVerificationInput { - method: signing_request.method, - url: signing_request.url, - headers: signed.cavage_headers, - body: Some(body.to_vec()), - } - } - - #[tokio::test] - async fn test_dual_sign_produces_both_headers() { - let signer = HttpSignerImpl; - let private_key_pem = generate_test_rsa_keypair(); - let key_id = "https://example.com/users/alice#main-key"; - let request = make_signing_request(); - - let result = signer - .sign( - &request, - &private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await; - - assert!(result.is_ok(), "Signing should succeed: {:?}", result.err()); - let response = result.unwrap(); - - assert!( - response.cavage_headers.contains_key("signature"), - "Cavage headers should contain 'signature', got: {:?}", - response.cavage_headers - ); - - assert!( - response.rfc9421_headers.contains_key("signature"), - "RFC 9421 headers should contain 'signature', got: {:?}", - response.rfc9421_headers - ); - assert!( - response.rfc9421_headers.contains_key("signature-input"), - "RFC 9421 headers should contain 'signature-input', got: {:?}", - response.rfc9421_headers - ); - } - - #[tokio::test] - async fn test_cavage_signature_contains_key_id() { - let signer = HttpSignerImpl; - let private_key_pem = generate_test_rsa_keypair(); - let key_id = "https://example.com/users/alice#main-key"; - let request = make_signing_request(); - - let response = signer - .sign( - &request, - &private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await - .unwrap(); - - let sig_header = &response.cavage_headers["signature"]; - assert!( - sig_header.contains("keyId="), - "Cavage signature should contain keyId, got: {}", - sig_header - ); - assert!( - sig_header.contains(key_id), - "Cavage signature should reference key_id, got: {}", - sig_header - ); - } - - #[tokio::test] - async fn test_rfc9421_signature_input_contains_components() { - let signer = HttpSignerImpl; - let private_key_pem = generate_test_rsa_keypair(); - let key_id = "https://example.com/users/alice#main-key"; - let request = make_signing_request(); - - let response = signer - .sign( - &request, - &private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await - .unwrap(); - - let sig_input = &response.rfc9421_headers["signature-input"]; - assert!( - sig_input.contains("@method"), - "signature-input should contain @method, got: {}", - sig_input - ); - assert!( - sig_input.contains("@target-uri"), - "signature-input should contain @target-uri, got: {}", - sig_input - ); - assert!( - sig_input.contains("@authority"), - "signature-input should contain @authority, got: {}", - sig_input - ); - } - - #[tokio::test] - async fn test_invalid_pem_returns_error() { - let signer = HttpSignerImpl; - let bad_pem = b"not-a-valid-pem"; - let key_id = "https://example.com/users/alice#main-key"; - let request = make_signing_request(); - - let result = signer - .sign(&request, bad_pem, key_id, &SigningAlgorithm::Rsa2048) - .await; - - assert!(result.is_err(), "Invalid PEM should produce an error"); - } - - #[tokio::test] - async fn test_existing_headers_are_preserved() { - let signer = HttpSignerImpl; - let private_key_pem = generate_test_rsa_keypair(); - let key_id = "https://example.com/users/alice#main-key"; - let request = make_signing_request(); - - let response = signer - .sign( - &request, - &private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await - .unwrap(); - - assert!( - response.cavage_headers.contains_key("host"), - "Should preserve 'host' header" - ); - assert!( - response.cavage_headers.contains_key("date"), - "Should preserve 'date' header" - ); - } - - #[test] - fn test_cavage_signer_key_sign_produces_bytes() { - let private_key_pem = generate_test_rsa_keypair(); - let key = RsaCavageSignerKey::new(&private_key_pem, "test-key-id").unwrap(); - - use http_msgsign_draft::sign::SignerKey; - let signature = key.sign(b"test signing data"); - assert!(!signature.is_empty(), "Signature should not be empty"); - assert_eq!(key.id(), "test-key-id"); - assert_eq!(key.algorithm(), "rsa-sha256"); - } - - #[test] - fn test_rfc9421_signer_key_sign_produces_bytes() { - let private_key_pem = generate_test_rsa_keypair(); - let key = RsaRfc9421SignerKey::new(&private_key_pem, "test-key-id").unwrap(); - - use http_msgsign::SignerKey; - let signature = key.sign(b"test signing data"); - assert!(!signature.is_empty(), "Signature should not be empty"); - assert_eq!(key.key_id(), "test-key-id"); - assert_eq!(RsaRfc9421SignerKey::ALGORITHM, "rsa-v1_5-sha256"); - } - - #[tokio::test] - async fn test_valid_cavage_signature_passes() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert_eq!( - result, - SignatureVerificationResult::Valid { - key_id: key_id.to_string() - } - ); - } - - #[tokio::test] - async fn test_invalid_cavage_signature_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - request - .headers - .insert("host".to_string(), "tampered.example".to_string()); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("InvalidSignature")) - ); - } - - #[tokio::test] - async fn test_missing_signature_header_fails() { - let (_private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut headers = HashMap::new(); - headers.insert( - "date".to_string(), - httpdate::fmt_http_date(SystemTime::now()), - ); - let request = HttpSignatureVerificationInput { - method: "POST".to_string(), - url: "https://example.com/inbox".to_string(), - headers, - body: Some(b"hello".to_vec()), - }; - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("MissingSignature")) - ); - } - - #[tokio::test] - async fn test_digest_mismatch_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - request.body = Some(b"tampered".to_vec()); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("DigestMismatch")) - ); - } - - #[tokio::test] - async fn test_missing_digest_with_body_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - request.headers.remove("digest"); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("DigestMismatch")) - ); - } - - #[tokio::test] - async fn test_unsigned_digest_with_body_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - replace_signed_headers(&mut request, "(request-target) host date"); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("Digest header must be signed")) - ); - } - - #[tokio::test] - async fn test_unsigned_date_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - replace_signed_headers(&mut request, "(request-target) host digest"); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("required header 'date'")) - ); - } - - #[tokio::test] - async fn test_unsigned_request_target_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - replace_signed_headers(&mut request, "host date digest"); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("required header '(request-target)'")) - ); - } - - #[tokio::test] - async fn test_unsupported_algorithm_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - replace_signature_algorithm(&mut request, "not-rsa"); - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("unsupported signature algorithm")) - ); - } - - #[tokio::test] - async fn test_expired_date_fails() { - let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let signer = HttpSignerImpl; - let mut signing_request = make_fresh_signing_request(b"hello"); - signing_request.headers.insert( - "date".to_string(), - "Thu, 01 Jan 1970 00:00:00 GMT".to_string(), - ); - signing_request - .headers - .insert("digest".to_string(), digest_header(b"hello")); - let signed = signer - .sign( - &signing_request, - &private_key_pem, - key_id, - &SigningAlgorithm::Rsa2048, - ) - .await - .unwrap(); - let request = HttpSignatureVerificationInput { - method: signing_request.method, - url: signing_request.url, - headers: signed.cavage_headers, - body: signing_request.body, - }; - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("StaleDate")) - ); - } - - #[tokio::test] - async fn test_ssrf_urls_are_rejected() { - let localhost = reqwest::Url::parse("http://localhost/actor#main-key").unwrap(); - let loopback = reqwest::Url::parse("http://127.0.0.1/actor#main-key").unwrap(); - let private = reqwest::Url::parse("https://192.168.1.10/actor#main-key").unwrap(); - let mapped_loopback = - reqwest::Url::parse("http://[::ffff:127.0.0.1]/actor#main-key").unwrap(); - let benchmarking = reqwest::Url::parse("http://198.18.0.1/actor#main-key").unwrap(); - let six_to_four = reqwest::Url::parse("http://[2002:c0a8:0101::1]/actor#main-key").unwrap(); - - assert!(validate_fetch_url(&localhost).await.is_err()); - assert!(validate_fetch_url(&loopback).await.is_err()); - assert!(validate_fetch_url(&private).await.is_err()); - assert!(validate_fetch_url(&mapped_loopback).await.is_err()); - assert!(validate_fetch_url(&benchmarking).await.is_err()); - assert!(validate_fetch_url(&six_to_four).await.is_err()); - } - - #[test] - fn test_duplicate_signature_params_are_rejected() { - let result = parse_cavage_signature( - "keyId=\"a\",keyId=\"b\",algorithm=\"rsa-sha256\",signature=\"Zm9v\"", - ); - - assert!(matches!(result, Err(message) if message.contains("duplicate"))); - } - - #[test] - fn test_actor_public_key_must_match_key_id() { - let key_id = "https://remote.example/users/alice#main-key"; - let document = serde_json::json!({ - "id": "https://remote.example/users/alice", - "publicKey": [ - { - "id": "https://remote.example/users/alice#other-key", - "owner": "https://remote.example/users/alice", - "publicKeyPem": "other" - }, - { - "id": key_id, - "owner": "https://remote.example/users/alice", - "publicKeyPem": "expected" - } - ] - }); - - let result = actor_public_key_from_json(key_id, &document).unwrap(); - - assert_eq!(result.public_key_pem, "expected"); - } - - #[test] - fn test_actor_public_key_rejects_mismatched_key_id() { - let document = serde_json::json!({ - "id": "https://remote.example/users/alice", - "publicKey": { - "id": "https://remote.example/users/alice#other-key", - "owner": "https://remote.example/users/alice", - "publicKeyPem": "other" - } - }); - - let result = - actor_public_key_from_json("https://remote.example/users/alice#main-key", &document); - - assert!(matches!(result, Err(message) if message.contains("does not match keyId"))); - } - - #[tokio::test] - async fn test_malformed_signature_header_fails() { - let (_private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "https://remote.example/users/alice#main-key"; - let mut headers = HashMap::new(); - headers.insert( - "date".to_string(), - httpdate::fmt_http_date(SystemTime::now()), - ); - headers.insert("signature".to_string(), "keyId=\"unterminated".to_string()); - let request = HttpSignatureVerificationInput { - method: "POST".to_string(), - url: "https://example.com/inbox".to_string(), - headers, - body: Some(b"hello".to_vec()), - }; - let verifier = test_verifier(key_id, public_key_pem); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("MalformedSignature")) - ); - } - - #[tokio::test] - async fn test_key_fetch_failure_is_invalid_result() { - let (private_key_pem, _public_key_pem) = generate_test_rsa_keypair_with_public(); - let key_id = "http://127.0.0.1/users/alice#main-key"; - let request = signed_verification_request(&private_key_pem, key_id, b"hello").await; - let verifier = HttpSignatureVerifierImpl::new().unwrap(); - - let result = verifier.verify(&request).await.unwrap(); - - assert!( - matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("KeyFetchFailed")) - ); - } -} diff --git a/driver/src/http_signing/actor_key.rs b/driver/src/http_signing/actor_key.rs new file mode 100644 index 00000000..2e110e83 --- /dev/null +++ b/driver/src/http_signing/actor_key.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +use kernel::interfaces::http_signing::ActorPublicKey; +use serde_json::Value; + +/// Test-only: global cache of actor public keys injected via the test-mode API. +/// +/// `fetch_actor_key` checks this before making an HTTP request. Keys are +/// inserted by the E2E test when the remote server (e.g. Iceshrimp) requires +/// authentication for its ActivityPub actor endpoint. +pub(super) static TEST_STATIC_ACTOR_KEYS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Insert an actor public key into the test global cache so that +/// `fetch_actor_key` returns it without making an HTTP request. +/// +/// `owner` is the ActivityPub actor ID (URL) that owns this key. It is used +/// by the ActivityPub route handler to verify that the signing key belongs to +/// the actor who sent the activity. Pass an empty string if unknown. +#[cfg(any(test, feature = "test-mode"))] +pub fn inject_test_actor_key(key_id: &str, public_key_pem: String, owner: &str) { + let mut map = TEST_STATIC_ACTOR_KEYS.lock().expect("poisoned lock"); + let pem_clone = public_key_pem.clone(); + let owner_val = if owner.is_empty() { + // Derive owner from key_id by stripping the fragment. + reqwest::Url::parse(key_id) + .ok() + .and_then(|mut url| { + url.set_fragment(None); + Some(url.to_string()) + }) + .unwrap_or_default() + } else { + owner.to_string() + }; + map.insert( + key_id.to_string(), + ActorPublicKey { + id: key_id.to_string(), + owner: owner_val.clone(), + public_key_pem, + }, + ); + if let Ok(mut url) = reqwest::Url::parse(key_id) { + url.set_fragment(None); + let stripped = url.to_string(); + if stripped != key_id { + map.insert( + stripped, + ActorPublicKey { + id: key_id.to_string(), + owner: owner_val, + public_key_pem: pem_clone, + }, + ); + } + } +} + +pub(super) fn actor_public_key_from_json( + key_id: &str, + document: &Value, +) -> std::result::Result { + if document.get("publicKeyPem").is_some() && key_value_matches_key_id(key_id, document) { + return public_key_value_to_actor_key(key_id, document, document.get("owner")); + } + + let public_key = document + .get("publicKey") + .ok_or_else(|| "publicKey field is missing".to_string())?; + + match public_key { + Value::Object(_) if key_value_matches_key_id(key_id, public_key) => { + public_key_value_to_actor_key(key_id, public_key, document.get("id")) + } + Value::Object(_) => Err("publicKey.id does not match keyId".to_string()), + Value::Array(keys) => keys + .iter() + .find(|value| key_value_matches_key_id(key_id, value)) + .map(|value| public_key_value_to_actor_key(key_id, value, document.get("id"))) + .transpose()? + .ok_or_else(|| "publicKey array does not contain a key matching keyId".to_string()), + _ => Err("publicKey field is not an object".to_string()), + } +} + +fn key_value_matches_key_id(key_id: &str, value: &Value) -> bool { + value + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id == key_id) +} + +fn public_key_value_to_actor_key( + key_id: &str, + value: &Value, + owner_fallback: Option<&Value>, +) -> std::result::Result { + let id = value + .get("id") + .and_then(Value::as_str) + .unwrap_or(key_id) + .to_string(); + let owner = value + .get("owner") + .or(owner_fallback) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let public_key_pem = value + .get("publicKeyPem") + .and_then(Value::as_str) + .ok_or_else(|| "publicKeyPem field is missing".to_string())? + .to_string(); + + Ok(ActorPublicKey { + id, + owner, + public_key_pem, + }) +} diff --git a/driver/src/http_signing/cavage/mod.rs b/driver/src/http_signing/cavage/mod.rs new file mode 100644 index 00000000..aa02ead9 --- /dev/null +++ b/driver/src/http_signing/cavage/mod.rs @@ -0,0 +1,173 @@ +pub(super) mod parser; + +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +use base64::{engine::general_purpose, Engine as _}; +use kernel::interfaces::crypto::SignatureVerifier as CryptoSignatureVerifier; +use kernel::interfaces::http_signing::HttpSignatureVerificationInput; +use sha2::Digest; + +pub(super) fn cavage_signing_string( + request: &HttpSignatureVerificationInput, + headers: &[String], +) -> std::result::Result, String> { + let url = reqwest::Url::parse(&request.url) + .map_err(|e| format!("MalformedSignature: request URL is invalid: {e}"))?; + let path_and_query = match url.query() { + Some(query) => format!("{}?{}", url.path(), query), + None => url.path().to_string(), + }; + + let mut lines = Vec::with_capacity(headers.len()); + for header in headers { + if header == "(request-target)" { + lines.push(format!( + "(request-target): {} {}", + request.method.to_ascii_lowercase(), + path_and_query + )); + continue; + } + + let value = get_header(&request.headers, header).ok_or_else(|| { + format!("MalformedSignature: signed header '{header}' is missing from request") + })?; + lines.push(format!("{}: {}", header.to_ascii_lowercase(), value)); + } + + Ok(lines.join("\n").into_bytes()) +} + +pub(super) fn verify_cavage_signature( + signing_string: &[u8], + signature: &[u8], + public_key_pem: &[u8], + algorithm: &str, +) -> std::result::Result { + let algorithm = algorithm.to_ascii_lowercase(); + + if algorithm == "ed25519" || algorithm == "ed25519-sha256" { + return crate::crypto::Ed25519Verifier + .verify(signing_string, signature, public_key_pem) + .map_err(|e| format!("InvalidSignature: Ed25519 verification failed: {e:?}")); + } + + if algorithm == "rsa-sha256" || algorithm == "hs2019" { + match crate::crypto::Rsa2048Verifier.verify(signing_string, signature, public_key_pem) { + Ok(valid) => return Ok(valid), + Err(rsa_error) if algorithm == "hs2019" => { + return crate::crypto::Ed25519Verifier + .verify(signing_string, signature, public_key_pem) + .map_err(|ed_error| { + format!( + "InvalidSignature: hs2019 verification failed: RSA {rsa_error:?}; Ed25519 {ed_error:?}" + ) + }); + } + Err(e) => { + return Err(format!("InvalidSignature: RSA verification failed: {e:?}")); + } + } + } + + Err(format!( + "MalformedSignature: unsupported signature algorithm '{algorithm}'" + )) +} + +pub(super) fn validate_date_header( + request: &HttpSignatureVerificationInput, + tolerance: Duration, +) -> std::result::Result<(), String> { + let date = get_header(&request.headers, "date") + .ok_or_else(|| "StaleDate: Date header is required".to_string())?; + let parsed = httpdate::parse_http_date(date) + .map_err(|e| format!("StaleDate: Date header is malformed: {e}"))?; + let now = SystemTime::now(); + + if parsed > now { + let delta = parsed + .duration_since(now) + .unwrap_or_else(|_| Duration::from_secs(0)); + if delta > tolerance { + return Err("StaleDate: Date header is too far in the future".to_string()); + } + } else { + let delta = now + .duration_since(parsed) + .unwrap_or_else(|_| Duration::from_secs(0)); + if delta > tolerance { + return Err("StaleDate: Date header is expired".to_string()); + } + } + + Ok(()) +} + +pub(super) fn validate_digest_header( + request: &HttpSignatureVerificationInput, +) -> std::result::Result<(), String> { + let has_body = request.body.as_ref().is_some_and(|body| !body.is_empty()); + let Some(digest_header) = get_header(&request.headers, "digest") else { + if has_body { + return Err( + "DigestMismatch: Digest header is required for requests with a body".to_string(), + ); + } + return Ok(()); + }; + let expected = digest_header + .split(',') + .find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + if name.eq_ignore_ascii_case("sha-256") { + Some(value.trim()) + } else { + None + } + }) + .ok_or_else(|| "DigestMismatch: Digest header is missing SHA-256 value".to_string())?; + + let expected = general_purpose::STANDARD + .decode(expected.as_bytes()) + .map_err(|e| format!("DigestMismatch: SHA-256 digest is not valid base64: {e}"))?; + let actual = sha2::Sha256::digest(request.body.as_deref().unwrap_or_default()); + + if expected.as_slice() == actual.as_slice() { + Ok(()) + } else { + Err("DigestMismatch: body digest does not match Digest header".to_string()) + } +} + +pub(super) fn validate_required_signed_headers( + request: &HttpSignatureVerificationInput, + signed_headers: &[String], +) -> std::result::Result<(), String> { + for required in ["(request-target)", "host", "date"] { + if !signed_headers.iter().any(|header| header == required) { + return Err(format!( + "MalformedSignature: required header '{required}' must be signed" + )); + } + } + + let has_body = request.body.as_ref().is_some_and(|body| !body.is_empty()); + if (has_body || get_header(&request.headers, "digest").is_some()) + && !signed_headers.iter().any(|header| header == "digest") + { + return Err( + "MalformedSignature: Digest header must be signed when a body is present".to_string(), + ); + } + + Ok(()) +} + +pub(super) fn get_header<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} diff --git a/driver/src/http_signing/cavage/parser.rs b/driver/src/http_signing/cavage/parser.rs new file mode 100644 index 00000000..a670a681 --- /dev/null +++ b/driver/src/http_signing/cavage/parser.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; + +use base64::{engine::general_purpose, Engine as _}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CavageSignature { + pub(crate) key_id: String, + pub(crate) algorithm: String, + pub(crate) headers: Vec, + pub(crate) signature: Vec, +} + +pub(crate) fn parse_cavage_signature(header: &str) -> std::result::Result { + let mut params = HashMap::new(); + for part in split_signature_params(header)? { + let (name, value) = parse_signature_param(&part)?; + if params.insert(name.to_ascii_lowercase(), value).is_some() { + return Err("duplicate signature parameter is not allowed".to_string()); + } + } + + let key_id = params + .remove("keyid") + .ok_or_else(|| "keyId parameter is required".to_string())?; + let algorithm = params + .remove("algorithm") + .unwrap_or_else(|| "rsa-sha256".to_string()); + let headers = params + .remove("headers") + .map(|value| { + value + .split_whitespace() + .map(|header| header.to_ascii_lowercase()) + .collect::>() + }) + .filter(|headers| !headers.is_empty()) + .unwrap_or_else(|| vec!["date".to_string()]); + let signature = params + .remove("signature") + .ok_or_else(|| "signature parameter is required".to_string())?; + let signature = general_purpose::STANDARD + .decode(signature.as_bytes()) + .map_err(|e| format!("signature is not valid base64: {e}"))?; + + Ok(CavageSignature { + key_id, + algorithm, + headers, + signature, + }) +} + +fn split_signature_params(header: &str) -> std::result::Result, String> { + let mut params = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut escaped = false; + + for ch in header.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + + match ch { + '\\' if in_quotes => { + current.push(ch); + escaped = true; + } + '"' => { + in_quotes = !in_quotes; + current.push(ch); + } + ',' if !in_quotes => { + if !current.trim().is_empty() { + params.push(current.trim().to_string()); + } + current.clear(); + } + _ => current.push(ch), + } + } + + if in_quotes { + return Err("unterminated quoted parameter".to_string()); + } + + if !current.trim().is_empty() { + params.push(current.trim().to_string()); + } + + if params.is_empty() { + return Err("Signature header is empty".to_string()); + } + + Ok(params) +} + +fn parse_signature_param(part: &str) -> std::result::Result<(String, String), String> { + let (name, value) = part + .split_once('=') + .ok_or_else(|| format!("parameter is missing '=': {part}"))?; + let name = name.trim(); + if name.is_empty() { + return Err("parameter name is empty".to_string()); + } + + let value = value.trim(); + let value = if value.starts_with('"') { + parse_quoted_value(value)? + } else { + value.to_string() + }; + + Ok((name.to_string(), value)) +} + +fn parse_quoted_value(value: &str) -> std::result::Result { + if !value.ends_with('"') || value.len() < 2 { + return Err("quoted parameter is not closed".to_string()); + } + + let inner = &value[1..value.len() - 1]; + let mut output = String::new(); + let mut escaped = false; + + for ch in inner.chars() { + if escaped { + output.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else { + output.push(ch); + } + } + + if escaped { + return Err("quoted parameter ends with an escape".to_string()); + } + + Ok(output) +} diff --git a/driver/src/http_signing/fetch.rs b/driver/src/http_signing/fetch.rs new file mode 100644 index 00000000..7845a7a3 --- /dev/null +++ b/driver/src/http_signing/fetch.rs @@ -0,0 +1,113 @@ +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use error_stack::{Report, Result}; +use kernel::KernelError; +use reqwest::header::{ACCEPT, LOCATION, USER_AGENT}; +use serde_json::Value; + +use super::ssrf::validate_fetch_url; + +pub(super) async fn fetch_limited_json( + client: &reqwest::Client, + max_response_bytes: usize, + mut url: reqwest::Url, +) -> Result { + for _ in 0..=5 { + let resolved_addresses = validate_fetch_url(&url).await?; + + let response = client_for_url(client, &url, &resolved_addresses)? + .get(url.clone()) + .header(ACCEPT, "application/activity+json") + .header(USER_AGENT, "Emumet/0.1 ActivityPub HTTP Signature verifier") + .send() + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("KeyFetchFailed: actor key fetch failed: {e}")) + })?; + + if response.status().is_redirection() { + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("KeyFetchFailed: redirect without Location header") + })?; + url = url.join(location).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("KeyFetchFailed: malformed redirect URL: {e}")) + })?; + continue; + } + + if !response.status().is_success() { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + tracing::debug!( + key_fetch_url = %url, + key_fetch_status = %status, + key_fetch_body = %body_text, + "KeyFetch failed with non-success status" + ); + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "KeyFetchFailed: actor key endpoint returned {}", + status, + ))); + } + + let mut bytes = Vec::new(); + let mut response = response; + while let Some(chunk) = response.chunk().await.map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("KeyFetchFailed: response read failed: {e}")) + })? { + if bytes.len() + chunk.len() > max_response_bytes { + return Err(Report::new(KernelError::Rejected) + .attach_printable("KeyFetchFailed: actor key response exceeds 1 MiB")); + } + bytes.extend_from_slice(&chunk); + } + + return serde_json::from_slice(&bytes).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("KeyFetchFailed: invalid actor JSON: {e}")) + }); + } + + Err(Report::new(KernelError::Rejected) + .attach_printable("KeyFetchFailed: too many redirects while fetching actor key")) +} + +fn client_for_url( + client: &reqwest::Client, + url: &reqwest::Url, + resolved_addresses: &[SocketAddr], +) -> Result { + let Some(host) = url.host_str() else { + return Ok(client.clone()); + }; + + if host.parse::().is_ok() { + return Ok(client.clone()); + } + + let builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)); + #[cfg(any(test, feature = "test-mode"))] + let builder = if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") { + builder.danger_accept_invalid_certs(true) + } else { + builder + }; + builder + .resolve_to_addrs(host, resolved_addresses) + .build() + .map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build pinned HTTP client: {e}")) + }) +} diff --git a/driver/src/http_signing/mod.rs b/driver/src/http_signing/mod.rs new file mode 100644 index 00000000..0cad1fa2 --- /dev/null +++ b/driver/src/http_signing/mod.rs @@ -0,0 +1,13 @@ +mod actor_key; +mod cavage; +mod fetch; +mod signer; +mod ssrf; +#[cfg(test)] +mod tests; +mod verifier; + +#[cfg(any(test, feature = "test-mode"))] +pub use actor_key::inject_test_actor_key; +pub use signer::HttpSignerImpl; +pub use verifier::HttpSignatureVerifierImpl; diff --git a/driver/src/http_signing/signer.rs b/driver/src/http_signing/signer.rs new file mode 100644 index 00000000..20835834 --- /dev/null +++ b/driver/src/http_signing/signer.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use bytes::Bytes; +use error_stack::{Report, Result}; +use http_body_util::Full; +use kernel::interfaces::http_signing::{HttpSigner, HttpSigningRequest, HttpSigningResponse}; +use kernel::KernelError; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::sha2::Sha256; +use rsa::signature::{SignatureEncoding, Signer as RsaSigner}; +use rsa::RsaPrivateKey; + +use super::cavage::get_header; + +pub(super) struct RsaCavageSignerKey { + private_key: RsaPrivateKey, + key_id: String, +} + +impl RsaCavageSignerKey { + pub(super) fn new(private_key_pem: &[u8], key_id: &str) -> Result { + let pem_str = std::str::from_utf8(private_key_pem).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Invalid UTF-8 in private key PEM: {e}")) + })?; + let private_key = RsaPrivateKey::from_pkcs8_pem(pem_str).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to parse private key PEM: {e}")) + })?; + Ok(Self { + private_key, + key_id: key_id.to_string(), + }) + } +} + +impl http_msgsign_draft::sign::SignerKey for RsaCavageSignerKey { + fn id(&self) -> String { + self.key_id.clone() + } + + fn algorithm(&self) -> String { + "rsa-sha256".to_string() + } + + fn sign(&self, target: &[u8]) -> Vec { + let signing_key = SigningKey::::new(self.private_key.clone()); + signing_key.sign(target).to_bytes().to_vec() + } +} + +pub(super) struct RsaRfc9421SignerKey { + private_key: RsaPrivateKey, + key_id: String, +} + +impl RsaRfc9421SignerKey { + pub(super) fn new(private_key_pem: &[u8], key_id: &str) -> Result { + let pem_str = std::str::from_utf8(private_key_pem).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Invalid UTF-8 in private key PEM: {e}")) + })?; + let private_key = RsaPrivateKey::from_pkcs8_pem(pem_str).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to parse private key PEM: {e}")) + })?; + Ok(Self { + private_key, + key_id: key_id.to_string(), + }) + } +} + +impl http_msgsign::SignerKey for RsaRfc9421SignerKey { + const ALGORITHM: &'static str = "rsa-v1_5-sha256"; + + fn key_id(&self) -> String { + self.key_id.clone() + } + + fn sign(&self, target: &[u8]) -> Vec { + let signing_key = SigningKey::::new(self.private_key.clone()); + signing_key.sign(target).to_bytes().to_vec() + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct HttpSignerImpl; + +impl HttpSigner for HttpSignerImpl { + async fn sign( + &self, + request: &HttpSigningRequest, + private_key_pem: &[u8], + key_id: &str, + _algorithm: &kernel::interfaces::crypto::SigningAlgorithm, + ) -> Result { + use http_msgsign::RequestSign as Rfc9421RequestSign; + use http_msgsign_draft::sign::RequestSign as CavageRequestSign; + + let cavage_req = build_http_request(request)?; + let rfc9421_req = build_http_request(request)?; + + let cavage_key = RsaCavageSignerKey::new(private_key_pem, key_id)?; + let mut cavage_params_builder = http_msgsign_draft::sign::SignatureParams::builder() + .add_request_target() + .add_header("host") + .add_header("date"); + if get_header(&request.headers, "digest").is_some() { + cavage_params_builder = cavage_params_builder.add_header("digest"); + } + let cavage_params = cavage_params_builder.build().map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build Cavage signature params: {e}")) + })?; + + let signed_cavage = CavageRequestSign::sign(cavage_req, &cavage_key, &cavage_params) + .await + .map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Cavage signing failed: {e}")) + })?; + + let cavage_headers = extract_headers(&signed_cavage); + + let rfc9421_key = RsaRfc9421SignerKey::new(private_key_pem, key_id)?; + let rfc9421_params = http_msgsign::SignatureParams::builder() + .add_derive( + http_msgsign::components::Derive::Method, + http_msgsign::components::params::FieldParameter::default(), + ) + .add_derive( + http_msgsign::components::Derive::TargetUri, + http_msgsign::components::params::FieldParameter::default(), + ) + .add_derive( + http_msgsign::components::Derive::Authority, + http_msgsign::components::params::FieldParameter::default(), + ) + .gen_created() + .build() + .map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build RFC 9421 signature params: {e}")) + })?; + + let signed_rfc9421 = + Rfc9421RequestSign::sign(rfc9421_req, &rfc9421_key, "sig1", &rfc9421_params) + .await + .map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("RFC 9421 signing failed: {e}")) + })?; + + let rfc9421_headers = extract_headers(&signed_rfc9421); + + Ok(HttpSigningResponse { + cavage_headers, + rfc9421_headers, + }) + } +} + +fn build_http_request( + request: &HttpSigningRequest, +) -> Result>, KernelError> { + let mut builder = http::Request::builder() + .method(request.method.as_str()) + .uri(&request.url); + + for (name, value) in &request.headers { + builder = builder.header(name.as_str(), value.as_str()); + } + + let body = Full::new(Bytes::from(request.body.clone().unwrap_or_default())); + + builder.body(body).map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build HTTP request: {e}")) + }) +} + +fn extract_headers(req: &http::Request) -> HashMap { + let mut headers = HashMap::new(); + for (name, value) in req.headers() { + if let Ok(v) = value.to_str() { + headers.insert(name.to_string(), v.to_string()); + } + } + headers +} diff --git a/driver/src/http_signing/ssrf.rs b/driver/src/http_signing/ssrf.rs new file mode 100644 index 00000000..04d2ae30 --- /dev/null +++ b/driver/src/http_signing/ssrf.rs @@ -0,0 +1,144 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use error_stack::{Report, Result}; +use kernel::KernelError; + +/// Checks whether a host is allowlisted for test-mode AP key fetch operations. +/// +/// Reads the `AP_TEST_ALLOWED_FETCH_HOSTS` environment variable (comma-separated, +/// trimmed, lowercase) and returns true if `host_lc` matches any entry. +/// Returns false when the env var is unset or empty. +fn is_fetch_host_allowed(host_lc: &str) -> bool { + std::env::var("AP_TEST_ALLOWED_FETCH_HOSTS") + .ok() + .is_some_and(|val| { + val.split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case(host_lc)) + }) +} + +pub(super) async fn validate_fetch_url(url: &reqwest::Url) -> Result, KernelError> { + match url.scheme() { + "http" | "https" => {} + scheme => { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "SsrfBlocked: unsupported keyId URL scheme '{scheme}'" + ))); + } + } + + if !url.username().is_empty() || url.password().is_some() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: keyId URL credentials are not allowed")); + } + + let host = url.host_str().ok_or_else(|| { + Report::new(KernelError::Rejected).attach_printable("SsrfBlocked: keyId URL host is empty") + })?; + let host_lc = host.trim_end_matches('.').to_ascii_lowercase(); + + let ssrf_bypassed = cfg!(any(test, feature = "test-mode")) && is_fetch_host_allowed(&host_lc); + + if !ssrf_bypassed { + if host_lc == "localhost" || host_lc.ends_with(".localhost") { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: localhost keyId URL is not allowed")); + } + + if let Ok(ip) = host_lc.parse::() { + validate_public_ip(ip)?; + let port = url.port_or_known_default().ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: keyId URL does not have a usable port") + })?; + return Ok(vec![SocketAddr::new(ip, port)]); + } + + let port = url.port_or_known_default().ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: keyId URL does not have a usable port") + })?; + let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) + })? + .collect::>(); + + if addresses.is_empty() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); + } + + for address in &addresses { + validate_public_ip(address.ip())?; + } + + Ok(addresses) + } else { + let port = url.port_or_known_default().ok_or_else(|| { + Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: keyId URL does not have a usable port") + })?; + let addresses = tokio::net::lookup_host((host_lc.as_str(), port)) + .await + .map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("SsrfBlocked: DNS resolution failed: {e}")) + })? + .collect::>(); + + if addresses.is_empty() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("SsrfBlocked: DNS resolution returned no addresses")); + } + Ok(addresses) + } +} + +fn validate_public_ip(ip: IpAddr) -> Result<(), KernelError> { + let blocked = match ip { + IpAddr::V4(ip) => is_blocked_ipv4(ip), + IpAddr::V6(ip) => is_blocked_ipv6(ip), + }; + + if blocked { + Err(Report::new(KernelError::Rejected).attach_printable(format!( + "SsrfBlocked: non-public IP address is not allowed: {ip}" + ))) + } else { + Ok(()) + } +} + +fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_multicast() + || ip.is_unspecified() + || octets[0] == 0 + || octets[0] >= 224 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 198 && (18..=19).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) +} + +fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { + if let Some(ipv4) = ip.to_ipv4_mapped() { + return is_blocked_ipv4(ipv4); + } + + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80 + || (ip.segments()[0] & 0xffff) == 0x2001 && (ip.segments()[1] & 0xfff0) == 0x0db8 + || ip.segments()[0] == 0x2002 + || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0) +} diff --git a/driver/src/http_signing/tests.rs b/driver/src/http_signing/tests.rs new file mode 100644 index 00000000..38d3b4d1 --- /dev/null +++ b/driver/src/http_signing/tests.rs @@ -0,0 +1,604 @@ +use std::collections::HashMap; +use std::time::SystemTime; + +use base64::{engine::general_purpose, Engine as _}; +use kernel::interfaces::crypto::SigningAlgorithm; +use kernel::interfaces::http_signing::{ + ActorPublicKey, HttpSignatureVerificationInput, HttpSignatureVerifier, HttpSigner, + HttpSigningRequest, SignatureVerificationResult, +}; +use rsa::RsaPrivateKey; +use sha2::Digest; + +use super::actor_key::actor_public_key_from_json; +use super::cavage::parser::parse_cavage_signature; +use super::signer::{RsaCavageSignerKey, RsaRfc9421SignerKey}; +use super::ssrf::validate_fetch_url; +use super::{HttpSignatureVerifierImpl, HttpSignerImpl}; + +fn generate_test_rsa_keypair() -> Vec { + generate_test_rsa_keypair_with_public().0 +} + +fn generate_test_rsa_keypair_with_public() -> (Vec, String) { + use rand::rngs::OsRng; + use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey}; + + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).unwrap(); + let public_key = rsa::RsaPublicKey::from(&private_key); + let pem = private_key + .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) + .unwrap(); + let public_pem = public_key + .to_public_key_pem(rsa::pkcs8::LineEnding::LF) + .unwrap(); + (pem.as_bytes().to_vec(), public_pem) +} + +fn make_signing_request() -> HttpSigningRequest { + let mut headers = HashMap::new(); + headers.insert("host".to_string(), "example.com".to_string()); + headers.insert( + "date".to_string(), + "Thu, 01 Jan 2025 00:00:00 GMT".to_string(), + ); + + HttpSigningRequest { + method: "POST".to_string(), + url: "https://example.com/inbox".to_string(), + headers, + body: Some(b"hello".to_vec()), + } +} + +fn make_fresh_signing_request(body: &[u8]) -> HttpSigningRequest { + let mut headers = HashMap::new(); + headers.insert("host".to_string(), "example.com".to_string()); + headers.insert( + "date".to_string(), + httpdate::fmt_http_date(SystemTime::now()), + ); + + HttpSigningRequest { + method: "POST".to_string(), + url: "https://example.com/inbox?x=1".to_string(), + headers, + body: Some(body.to_vec()), + } +} + +fn digest_header(body: &[u8]) -> String { + format!( + "SHA-256={}", + general_purpose::STANDARD.encode(sha2::Sha256::digest(body)) + ) +} + +fn test_verifier(key_id: &str, public_key_pem: String) -> HttpSignatureVerifierImpl { + let mut verifier = HttpSignatureVerifierImpl::new().unwrap(); + verifier.static_actor_keys.insert( + key_id.to_string(), + ActorPublicKey { + id: key_id.to_string(), + owner: "https://remote.example/users/alice".to_string(), + public_key_pem, + }, + ); + verifier +} + +fn replace_signed_headers(request: &mut HttpSignatureVerificationInput, headers: &str) { + let current = request.headers.get("signature").unwrap(); + let parsed = parse_cavage_signature(current).unwrap(); + request.headers.insert( + "signature".to_string(), + format!( + "keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"", + parsed.key_id, + parsed.algorithm, + headers, + general_purpose::STANDARD.encode(parsed.signature) + ), + ); +} + +fn replace_signature_algorithm(request: &mut HttpSignatureVerificationInput, algorithm: &str) { + let current = request.headers.get("signature").unwrap(); + let parsed = parse_cavage_signature(current).unwrap(); + request.headers.insert( + "signature".to_string(), + format!( + "keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"", + parsed.key_id, + algorithm, + parsed.headers.join(" "), + general_purpose::STANDARD.encode(parsed.signature) + ), + ); +} + +async fn signed_verification_request( + private_key_pem: &[u8], + key_id: &str, + body: &[u8], +) -> HttpSignatureVerificationInput { + let signer = HttpSignerImpl; + let mut signing_request = make_fresh_signing_request(body); + signing_request + .headers + .insert("digest".to_string(), digest_header(body)); + let signed = signer + .sign( + &signing_request, + private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await + .unwrap(); + + HttpSignatureVerificationInput { + method: signing_request.method, + url: signing_request.url, + headers: signed.cavage_headers, + body: Some(body.to_vec()), + } +} + +#[tokio::test] +async fn test_dual_sign_produces_both_headers() { + let signer = HttpSignerImpl; + let private_key_pem = generate_test_rsa_keypair(); + let key_id = "https://example.com/users/alice#main-key"; + let request = make_signing_request(); + + let result = signer + .sign( + &request, + &private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await; + + assert!(result.is_ok(), "Signing should succeed: {:?}", result.err()); + let response = result.unwrap(); + + assert!( + response.cavage_headers.contains_key("signature"), + "Cavage headers should contain 'signature', got: {:?}", + response.cavage_headers + ); + + assert!( + response.rfc9421_headers.contains_key("signature"), + "RFC 9421 headers should contain 'signature', got: {:?}", + response.rfc9421_headers + ); + assert!( + response.rfc9421_headers.contains_key("signature-input"), + "RFC 9421 headers should contain 'signature-input', got: {:?}", + response.rfc9421_headers + ); +} + +#[tokio::test] +async fn test_cavage_signature_contains_key_id() { + let signer = HttpSignerImpl; + let private_key_pem = generate_test_rsa_keypair(); + let key_id = "https://example.com/users/alice#main-key"; + let request = make_signing_request(); + + let response = signer + .sign( + &request, + &private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await + .unwrap(); + + let sig_header = &response.cavage_headers["signature"]; + assert!( + sig_header.contains("keyId="), + "Cavage signature should contain keyId, got: {}", + sig_header + ); + assert!( + sig_header.contains(key_id), + "Cavage signature should reference key_id, got: {}", + sig_header + ); +} + +#[tokio::test] +async fn test_rfc9421_signature_input_contains_components() { + let signer = HttpSignerImpl; + let private_key_pem = generate_test_rsa_keypair(); + let key_id = "https://example.com/users/alice#main-key"; + let request = make_signing_request(); + + let response = signer + .sign( + &request, + &private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await + .unwrap(); + + let sig_input = &response.rfc9421_headers["signature-input"]; + assert!( + sig_input.contains("@method"), + "signature-input should contain @method, got: {}", + sig_input + ); + assert!( + sig_input.contains("@target-uri"), + "signature-input should contain @target-uri, got: {}", + sig_input + ); + assert!( + sig_input.contains("@authority"), + "signature-input should contain @authority, got: {}", + sig_input + ); +} + +#[tokio::test] +async fn test_invalid_pem_returns_error() { + let signer = HttpSignerImpl; + let bad_pem = b"not-a-valid-pem"; + let key_id = "https://example.com/users/alice#main-key"; + let request = make_signing_request(); + + let result = signer + .sign(&request, bad_pem, key_id, &SigningAlgorithm::Rsa2048) + .await; + + assert!(result.is_err(), "Invalid PEM should produce an error"); +} + +#[tokio::test] +async fn test_existing_headers_are_preserved() { + let signer = HttpSignerImpl; + let private_key_pem = generate_test_rsa_keypair(); + let key_id = "https://example.com/users/alice#main-key"; + let request = make_signing_request(); + + let response = signer + .sign( + &request, + &private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await + .unwrap(); + + assert!( + response.cavage_headers.contains_key("host"), + "Should preserve 'host' header" + ); + assert!( + response.cavage_headers.contains_key("date"), + "Should preserve 'date' header" + ); +} + +#[test] +fn test_cavage_signer_key_sign_produces_bytes() { + let private_key_pem = generate_test_rsa_keypair(); + let key = RsaCavageSignerKey::new(&private_key_pem, "test-key-id").unwrap(); + + use http_msgsign_draft::sign::SignerKey; + let signature = key.sign(b"test signing data"); + assert!(!signature.is_empty(), "Signature should not be empty"); + assert_eq!(key.id(), "test-key-id"); + assert_eq!(key.algorithm(), "rsa-sha256"); +} + +#[test] +fn test_rfc9421_signer_key_sign_produces_bytes() { + let private_key_pem = generate_test_rsa_keypair(); + let key = RsaRfc9421SignerKey::new(&private_key_pem, "test-key-id").unwrap(); + + use http_msgsign::SignerKey; + let signature = key.sign(b"test signing data"); + assert!(!signature.is_empty(), "Signature should not be empty"); + assert_eq!(key.key_id(), "test-key-id"); + assert_eq!(RsaRfc9421SignerKey::ALGORITHM, "rsa-v1_5-sha256"); +} + +#[tokio::test] +async fn test_valid_cavage_signature_passes() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert_eq!( + result, + SignatureVerificationResult::Valid { + key_id: key_id.to_string() + } + ); +} + +#[tokio::test] +async fn test_invalid_cavage_signature_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + request + .headers + .insert("host".to_string(), "tampered.example".to_string()); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("InvalidSignature")) + ); +} + +#[tokio::test] +async fn test_missing_signature_header_fails() { + let (_private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut headers = HashMap::new(); + headers.insert( + "date".to_string(), + httpdate::fmt_http_date(SystemTime::now()), + ); + let request = HttpSignatureVerificationInput { + method: "POST".to_string(), + url: "https://example.com/inbox".to_string(), + headers, + body: Some(b"hello".to_vec()), + }; + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("MissingSignature")) + ); +} + +#[tokio::test] +async fn test_digest_mismatch_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + request.body = Some(b"tampered".to_vec()); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("DigestMismatch")) + ); +} + +#[tokio::test] +async fn test_missing_digest_with_body_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + request.headers.remove("digest"); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("DigestMismatch")) + ); +} + +#[tokio::test] +async fn test_unsigned_digest_with_body_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + replace_signed_headers(&mut request, "(request-target) host date"); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("Digest header must be signed")) + ); +} + +#[tokio::test] +async fn test_unsigned_date_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + replace_signed_headers(&mut request, "(request-target) host digest"); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("required header 'date'")) + ); +} + +#[tokio::test] +async fn test_unsigned_request_target_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + replace_signed_headers(&mut request, "host date digest"); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("required header '(request-target)'")) + ); +} + +#[tokio::test] +async fn test_unsupported_algorithm_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + replace_signature_algorithm(&mut request, "not-rsa"); + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("unsupported signature algorithm")) + ); +} + +#[tokio::test] +async fn test_expired_date_fails() { + let (private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let signer = HttpSignerImpl; + let mut signing_request = make_fresh_signing_request(b"hello"); + signing_request.headers.insert( + "date".to_string(), + "Thu, 01 Jan 1970 00:00:00 GMT".to_string(), + ); + signing_request + .headers + .insert("digest".to_string(), digest_header(b"hello")); + let signed = signer + .sign( + &signing_request, + &private_key_pem, + key_id, + &SigningAlgorithm::Rsa2048, + ) + .await + .unwrap(); + let request = HttpSignatureVerificationInput { + method: signing_request.method, + url: signing_request.url, + headers: signed.cavage_headers, + body: signing_request.body, + }; + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("StaleDate")) + ); +} + +#[tokio::test] +async fn test_ssrf_urls_are_rejected() { + let localhost = reqwest::Url::parse("http://localhost/actor#main-key").unwrap(); + let loopback = reqwest::Url::parse("http://127.0.0.1/actor#main-key").unwrap(); + let private = reqwest::Url::parse("https://192.168.1.10/actor#main-key").unwrap(); + let mapped_loopback = reqwest::Url::parse("http://[::ffff:127.0.0.1]/actor#main-key").unwrap(); + let benchmarking = reqwest::Url::parse("http://198.18.0.1/actor#main-key").unwrap(); + let six_to_four = reqwest::Url::parse("http://[2002:c0a8:0101::1]/actor#main-key").unwrap(); + + assert!(validate_fetch_url(&localhost).await.is_err()); + assert!(validate_fetch_url(&loopback).await.is_err()); + assert!(validate_fetch_url(&private).await.is_err()); + assert!(validate_fetch_url(&mapped_loopback).await.is_err()); + assert!(validate_fetch_url(&benchmarking).await.is_err()); + assert!(validate_fetch_url(&six_to_four).await.is_err()); +} + +#[test] +fn test_duplicate_signature_params_are_rejected() { + let result = parse_cavage_signature( + "keyId=\"a\",keyId=\"b\",algorithm=\"rsa-sha256\",signature=\"Zm9v\"", + ); + + assert!(matches!(result, Err(message) if message.contains("duplicate"))); +} + +#[test] +fn test_actor_public_key_must_match_key_id() { + let key_id = "https://remote.example/users/alice#main-key"; + let document = serde_json::json!({ + "id": "https://remote.example/users/alice", + "publicKey": [ + { + "id": "https://remote.example/users/alice#other-key", + "owner": "https://remote.example/users/alice", + "publicKeyPem": "other" + }, + { + "id": key_id, + "owner": "https://remote.example/users/alice", + "publicKeyPem": "expected" + } + ] + }); + + let result = actor_public_key_from_json(key_id, &document).unwrap(); + + assert_eq!(result.public_key_pem, "expected"); +} + +#[test] +fn test_actor_public_key_rejects_mismatched_key_id() { + let document = serde_json::json!({ + "id": "https://remote.example/users/alice", + "publicKey": { + "id": "https://remote.example/users/alice#other-key", + "owner": "https://remote.example/users/alice", + "publicKeyPem": "other" + } + }); + + let result = + actor_public_key_from_json("https://remote.example/users/alice#main-key", &document); + + assert!(matches!(result, Err(message) if message.contains("does not match keyId"))); +} + +#[tokio::test] +async fn test_malformed_signature_header_fails() { + let (_private_key_pem, public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "https://remote.example/users/alice#main-key"; + let mut headers = HashMap::new(); + headers.insert( + "date".to_string(), + httpdate::fmt_http_date(SystemTime::now()), + ); + headers.insert("signature".to_string(), "keyId=\"unterminated".to_string()); + let request = HttpSignatureVerificationInput { + method: "POST".to_string(), + url: "https://example.com/inbox".to_string(), + headers, + body: Some(b"hello".to_vec()), + }; + let verifier = test_verifier(key_id, public_key_pem); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("MalformedSignature")) + ); +} + +#[tokio::test] +async fn test_key_fetch_failure_is_invalid_result() { + let (private_key_pem, _public_key_pem) = generate_test_rsa_keypair_with_public(); + let key_id = "http://127.0.0.1/users/alice#main-key"; + let request = signed_verification_request(&private_key_pem, key_id, b"hello").await; + let verifier = HttpSignatureVerifierImpl::new().unwrap(); + + let result = verifier.verify(&request).await.unwrap(); + + assert!( + matches!(result, SignatureVerificationResult::Invalid(message) if message.contains("KeyFetchFailed")) + ); +} diff --git a/driver/src/http_signing/verifier.rs b/driver/src/http_signing/verifier.rs new file mode 100644 index 00000000..a8e4d516 --- /dev/null +++ b/driver/src/http_signing/verifier.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; +use std::time::Duration; + +use error_stack::{Report, Result}; +use kernel::interfaces::http_signing::{ + ActorPublicKey, HttpSignatureVerificationInput, HttpSignatureVerifier, + SignatureVerificationResult, +}; +use kernel::KernelError; + +use super::actor_key::{actor_public_key_from_json, TEST_STATIC_ACTOR_KEYS}; +use super::cavage::parser::parse_cavage_signature; +use super::cavage::{ + cavage_signing_string, get_header, validate_date_header, validate_digest_header, + validate_required_signed_headers, verify_cavage_signature, +}; +use super::fetch::fetch_limited_json; + +#[derive(Debug, Clone)] +pub struct HttpSignatureVerifierImpl { + client: reqwest::Client, + max_response_bytes: usize, + date_tolerance: Duration, + pub(super) static_actor_keys: HashMap, +} + +impl HttpSignatureVerifierImpl { + pub fn new() -> Result { + let client_builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)); + #[cfg(any(test, feature = "test-mode"))] + let client_builder = if std::env::var("AP_TEST_ACCEPT_INVALID_CERTS").as_deref() == Ok("1") + { + client_builder.danger_accept_invalid_certs(true) + } else { + client_builder + }; + let client = client_builder.build().map_err(|e| { + Report::new(KernelError::Internal) + .attach_printable(format!("Failed to build HTTP client: {e}")) + })?; + + Ok(Self { + client, + max_response_bytes: 1024 * 1024, + date_tolerance: Duration::from_secs(5 * 60), + static_actor_keys: HashMap::new(), + }) + } + + /// Register an actor key that will be returned from cache without an HTTP fetch. + /// + /// This is intended for test environments where the remote ActivityPub server + /// requires authentication for its actor endpoint. The calling test retrieves + /// the public key via an authenticated API and injects it here, allowing + /// HTTP Signature verification to succeed without a direct key fetch. + /// + /// `key_id` must match the full keyId URI (including the `#main-key` fragment + /// if present in the HTTP Signature). The method normalizes by checking both + /// the raw key and the fragment-stripped URL. + #[cfg(any(test, feature = "test-mode"))] + pub fn cache_actor_key(&mut self, key_id: &str, public_key_pem: String) { + let key = ActorPublicKey { + id: key_id.to_string(), + owner: String::new(), + public_key_pem, + }; + self.static_actor_keys + .insert(key_id.to_string(), key.clone()); + + // Also index by fragment-stripped URL so fetch_actor_key can hit + // the cache even when the caller strips the fragment first. + if let Ok(mut url) = reqwest::Url::parse(key_id) { + url.set_fragment(None); + if url.as_str() != key_id { + self.static_actor_keys.insert(url.to_string(), key); + } + } + } +} + +impl Default for HttpSignatureVerifierImpl { + fn default() -> Self { + Self::new().expect("HTTP signature verifier client construction should not fail") + } +} + +impl HttpSignatureVerifier for HttpSignatureVerifierImpl { + async fn verify( + &self, + request: &HttpSignatureVerificationInput, + ) -> Result { + if get_header(&request.headers, "signature-input").is_some() { + return Ok(SignatureVerificationResult::Invalid( + "RFC 9421 verification is not implemented".to_string(), + )); + } + + let signature_header = match get_header(&request.headers, "signature") { + Some(value) => value, + None => { + return Ok(SignatureVerificationResult::Invalid( + "MissingSignature: Signature header is required".to_string(), + )); + } + }; + + let parsed = match parse_cavage_signature(signature_header) { + Ok(parsed) => parsed, + Err(message) => { + return Ok(SignatureVerificationResult::Invalid(format!( + "MalformedSignature: {message}" + ))); + } + }; + + if let Err(message) = validate_required_signed_headers(request, &parsed.headers) { + return Ok(SignatureVerificationResult::Invalid(message)); + } + + if let Err(message) = validate_date_header(request, self.date_tolerance) { + return Ok(SignatureVerificationResult::Invalid(message)); + } + + if let Err(message) = validate_digest_header(request) { + return Ok(SignatureVerificationResult::Invalid(message)); + } + + let signing_string = match cavage_signing_string(request, &parsed.headers) { + Ok(value) => value, + Err(message) => return Ok(SignatureVerificationResult::Invalid(message)), + }; + + let actor_key = match self.fetch_actor_key(&parsed.key_id).await { + Ok(value) => value, + Err(e) => { + return Ok(SignatureVerificationResult::Invalid(format!( + "KeyFetchFailed: {e:?}" + ))); + } + }; + + let valid = verify_cavage_signature( + &signing_string, + &parsed.signature, + actor_key.public_key_pem.as_bytes(), + &parsed.algorithm, + ); + + match valid { + Ok(true) => Ok(SignatureVerificationResult::Valid { + key_id: parsed.key_id, + }), + Ok(false) => Ok(SignatureVerificationResult::Invalid( + "InvalidSignature: signature does not match".to_string(), + )), + Err(message) => Ok(SignatureVerificationResult::Invalid(message)), + } + } + + async fn fetch_actor_key(&self, key_id: &str) -> Result { + // Check test-mode global cache first + if let Ok(map) = TEST_STATIC_ACTOR_KEYS.lock() { + if let Some(key) = map.get(key_id) { + return Ok(key.clone()); + } + // Also try fragment-stripped URL + if let Ok(mut url) = reqwest::Url::parse(key_id) { + url.set_fragment(None); + let stripped = url.to_string(); + if stripped != key_id { + if let Some(key) = map.get(&stripped) { + return Ok(key.clone()); + } + } + } + } + + if let Some(key) = self.static_actor_keys.get(key_id) { + return Ok(key.clone()); + } + + let mut url = reqwest::Url::parse(key_id).map_err(|e| { + Report::new(KernelError::Rejected) + .attach_printable(format!("MalformedSignature: invalid keyId URL: {e}")) + })?; + url.set_fragment(None); + + if let Some(key) = self.static_actor_keys.get(url.as_str()) { + return Ok(key.clone()); + } + + let body = fetch_limited_json(&self.client, self.max_response_bytes, url).await?; + actor_public_key_from_json(key_id, &body).map_err(|message| { + Report::new(KernelError::Rejected).attach_printable(format!( + "KeyFetchFailed: actor document does not contain a usable public key: {message}" + )) + }) + } +} From a48b60d7aabd980994643b964b0cf539996ca167 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 19 Jul 2026 02:35:41 +0900 Subject: [PATCH 3/4] :recycle: Split account service into directory module by use case Pure mechanical move of application/src/service/account.rs into a directory module; no behavior change. --- application/src/service/account.rs | 568 ------------------ application/src/service/account/create.rs | 106 ++++ application/src/service/account/deactivate.rs | 86 +++ application/src/service/account/mod.rs | 13 + application/src/service/account/moderation.rs | 217 +++++++ application/src/service/account/read.rs | 78 +++ application/src/service/account/rehydrate.rs | 39 ++ application/src/service/account/update.rs | 90 +++ 8 files changed, 629 insertions(+), 568 deletions(-) delete mode 100644 application/src/service/account.rs create mode 100644 application/src/service/account/create.rs create mode 100644 application/src/service/account/deactivate.rs create mode 100644 application/src/service/account/mod.rs create mode 100644 application/src/service/account/moderation.rs create mode 100644 application/src/service/account/read.rs create mode 100644 application/src/service/account/rehydrate.rs create mode 100644 application/src/service/account/update.rs diff --git a/application/src/service/account.rs b/application/src/service/account.rs deleted file mode 100644 index 687a4a3f..00000000 --- a/application/src/service/account.rs +++ /dev/null @@ -1,568 +0,0 @@ -use crate::permission::{ - account_deactivate, account_edit, account_view, check_permission, instance_moderate, -}; -use crate::signing_key::CreateSigningKeyUseCase; -use crate::transfer::account::{AccountDto, CreateAccountDto, UpdateAccountDto}; -use crate::transfer::pagination::{apply_pagination, Pagination}; -use adapter::crypto::DependOnSigningKeyGenerator; -use adapter::processor::account::{ - AccountCommandProcessor, AccountQueryProcessor, CreateAccountParam, - DependOnAccountCommandProcessor, DependOnAccountQueryProcessor, UpdateAccountParam, -}; -use adapter::processor::profile::{ - CreateProfileParam, DependOnProfileCommandProcessor, ProfileCommandProcessor, -}; -use error_stack::Report; -use kernel::interfaces::config::DependOnPublicBaseUrl; -use kernel::interfaces::crypto::{DependOnPasswordProvider, SigningAlgorithm}; -use kernel::interfaces::database::DatabaseConnection; -use kernel::interfaces::event::EventApplier; -use kernel::interfaces::event_store::{AccountEventStore, DependOnAccountEventStore}; -use kernel::interfaces::permission::{ - AccountRelation, DependOnPermissionChecker, DependOnPermissionWriter, PermissionWriter, - RelationTarget, -}; -use kernel::interfaces::repository::DependOnSigningKeyRepository; -use kernel::prelude::entity::{ - Account, AccountId, AccountIsBot, AccountName, AuthAccountId, EventId, EventVersion, - ModerationReason, Nanoid, Profile, ProfileDisplayName, -}; -use kernel::KernelError; -use std::future::Future; - -pub trait GetAccountUseCase: - 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPermissionChecker -{ - // find_by_auth_id returns only accounts owned by the authenticated user, - // so no additional permission check is needed. - fn get_all_accounts( - &self, - auth_account_id: &AuthAccountId, - Pagination { - direction, - cursor, - limit, - }: Pagination, - ) -> impl Future>, KernelError>> + Send - { - async move { - let mut transaction = self.database_connection().get_executor().await?; - let accounts = self - .account_query_processor() - .find_by_auth_id(&mut transaction, auth_account_id) - .await?; - let cursor = if let Some(cursor) = cursor { - let id: Nanoid = Nanoid::new(cursor); - self.account_query_processor() - .find_by_nanoid(&mut transaction, &id) - .await? - } else { - None - }; - let accounts = apply_pagination(accounts, limit, cursor, direction); - Ok(Some(accounts.into_iter().map(AccountDto::from).collect())) - } - } - - fn get_accounts_by_ids( - &self, - auth_account_id: &AuthAccountId, - ids: Vec, - ) -> impl Future, KernelError>> + Send { - async move { - let mut transaction = self.database_connection().get_executor().await?; - - let nanoids: Vec> = - ids.into_iter().map(Nanoid::::new).collect(); - let accounts = self - .account_query_processor() - .find_by_nanoids(&mut transaction, &nanoids) - .await?; - - let mut result = Vec::new(); - for account in accounts { - if check_permission(self, auth_account_id, &account_view(account.id())) - .await - .is_ok() - { - result.push(AccountDto::from(account)); - } - } - - Ok(result) - } - } -} - -impl GetAccountUseCase for T where - T: 'static + DependOnAccountQueryProcessor + DependOnPermissionChecker -{ -} - -pub trait CreateAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnProfileCommandProcessor - + DependOnPasswordProvider - + DependOnSigningKeyGenerator - + DependOnPermissionWriter - + DependOnSigningKeyRepository - + DependOnPublicBaseUrl -{ - fn create_account( - &self, - auth_account_id: AuthAccountId, - dto: CreateAccountDto, - ) -> impl Future> + Send { - async move { - let mut transaction = self.database_connection().get_executor().await?; - - let account_name = AccountName::new(dto.name); - let account_is_bot = AccountIsBot::new(dto.is_bot); - - let display_name = ProfileDisplayName::new(account_name.as_ref().to_string()); - - let account = self - .account_command_processor() - .create( - &mut transaction, - CreateAccountParam { - name: account_name, - is_bot: account_is_bot, - auth_account_id: auth_account_id.clone(), - }, - ) - .await?; - - self.profile_command_processor() - .create( - &mut transaction, - CreateProfileParam { - account_id: account.id().clone(), - display_name: Some(display_name), - summary: None, - icon: None, - banner: None, - nano_id: Nanoid::::default(), - }, - ) - .await?; - - self.permission_writer() - .create_relation( - &RelationTarget::Account { - account_id: account.id().clone(), - relation: AccountRelation::Owner, - }, - &auth_account_id, - ) - .await?; - - self.create( - account.id().clone(), - account.nanoid(), - SigningAlgorithm::Rsa2048, - ) - .await?; - - Ok(AccountDto::from(account)) - } - } -} - -impl CreateAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnProfileCommandProcessor - + DependOnPasswordProvider - + DependOnSigningKeyGenerator - + DependOnPermissionWriter - + DependOnSigningKeyRepository - + DependOnPublicBaseUrl -{ -} - -pub trait UpdateAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ - fn update_account( - &self, - auth_account_id: &AuthAccountId, - dto: UpdateAccountDto, - ) -> impl Future> + Send { - async move { - let mut transaction = self.database_connection().get_executor().await?; - - let nanoid = Nanoid::::new(dto.account_nanoid); - let projection = self - .account_query_processor() - .find_by_nanoid_unfiltered(&mut transaction, &nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - nanoid.as_ref() - )) - })?; - - check_permission(self, auth_account_id, &account_edit(projection.id())).await?; - - let account_id = projection.id().clone(); - let (account, current_version) = - rehydrate_account(self, &mut transaction, &account_id).await?; - - let is_bot = match dto.is_bot { - kernel::prelude::entity::FieldAction::Unchanged => *account.is_bot().as_ref(), - kernel::prelude::entity::FieldAction::Clear => false, - kernel::prelude::entity::FieldAction::Set(value) => value, - }; - - if !account.status().is_active() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Cannot modify a suspended or banned account")); - } - if account.deleted_at().is_some() { - return Err( - Report::new(KernelError::Rejected).attach_printable("Account is deactivated") - ); - } - - self.account_command_processor() - .update( - &mut transaction, - UpdateAccountParam { - account_id, - is_bot: AccountIsBot::new(is_bot), - current_version, - }, - ) - .await?; - - Ok(()) - } - } -} - -impl UpdateAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ -} - -pub trait DeactivateAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker - + DependOnPermissionWriter -{ - fn deactivate_account( - &self, - auth_account_id: &AuthAccountId, - account_id: String, - ) -> impl Future> + Send { - async move { - let mut transaction = self.database_connection().get_executor().await?; - - let nanoid = Nanoid::::new(account_id); - let projection = self - .account_query_processor() - .find_by_nanoid(&mut transaction, &nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - nanoid.as_ref() - )) - })?; - - check_permission(self, auth_account_id, &account_deactivate(projection.id())).await?; - - let account_id = projection.id().clone(); - let (_account, current_version) = - rehydrate_account(self, &mut transaction, &account_id).await?; - self.account_command_processor() - .deactivate(&mut transaction, account_id.clone(), current_version) - .await?; - - for relation in [ - AccountRelation::Owner, - AccountRelation::Editor, - AccountRelation::Signer, - ] { - self.permission_writer() - .delete_relation( - &RelationTarget::Account { - account_id: account_id.clone(), - relation, - }, - auth_account_id, - ) - .await?; - } - - Ok(()) - } - } -} - -impl DeactivateAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker - + DependOnPermissionWriter -{ -} - -pub trait SuspendAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ - fn suspend_account( - &self, - auth_account_id: &AuthAccountId, - account_id: String, - reason: String, - expires_at: Option, - ) -> impl Future> + Send { - async move { - ModerationReason::new(reason.as_str()).validate()?; - let mut transaction = self.database_connection().get_executor().await?; - - let nanoid = Nanoid::::new(account_id); - let projection = self - .account_query_processor() - .find_by_nanoid_unfiltered(&mut transaction, &nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - nanoid.as_ref() - )) - })?; - - check_permission(self, auth_account_id, &instance_moderate()).await?; - - if let Some(exp) = expires_at { - if exp <= time::OffsetDateTime::now_utc() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("expires_at must be in the future")); - } - } - - let account_id = projection.id().clone(); - let (account, current_version) = - rehydrate_account(self, &mut transaction, &account_id).await?; - - if !account.status().is_active() { - return Err( - Report::new(KernelError::Rejected).attach_printable("Account is not active") - ); - } - if account.deleted_at().is_some() { - return Err( - Report::new(KernelError::Rejected).attach_printable("Account is deactivated") - ); - } - - self.account_command_processor() - .suspend( - &mut transaction, - account_id, - reason, - expires_at, - current_version, - ) - .await?; - - Ok(()) - } - } -} - -impl SuspendAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ -} - -pub trait UnsuspendAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ - fn unsuspend_account( - &self, - auth_account_id: &AuthAccountId, - account_id: String, - ) -> impl Future> + Send { - async move { - let mut transaction = self.database_connection().get_executor().await?; - - let nanoid = Nanoid::::new(account_id); - let projection = self - .account_query_processor() - .find_by_nanoid_unfiltered(&mut transaction, &nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - nanoid.as_ref() - )) - })?; - - check_permission(self, auth_account_id, &instance_moderate()).await?; - - let account_id = projection.id().clone(); - let (account, current_version) = - rehydrate_account(self, &mut transaction, &account_id).await?; - - if !account.status().is_suspended() { - return Err( - Report::new(KernelError::Rejected).attach_printable("Account is not suspended") - ); - } - - self.account_command_processor() - .unsuspend(&mut transaction, account_id, current_version) - .await?; - - Ok(()) - } - } -} - -impl UnsuspendAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ -} - -pub trait BanAccountUseCase: - 'static - + Sync - + Send - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ - fn ban_account( - &self, - auth_account_id: &AuthAccountId, - account_id: String, - reason: String, - ) -> impl Future> + Send { - async move { - ModerationReason::new(reason.as_str()).validate()?; - let mut transaction = self.database_connection().get_executor().await?; - - let nanoid = Nanoid::::new(account_id); - let projection = self - .account_query_processor() - .find_by_nanoid_unfiltered(&mut transaction, &nanoid) - .await? - .ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account not found with nanoid: {}", - nanoid.as_ref() - )) - })?; - - check_permission(self, auth_account_id, &instance_moderate()).await?; - - let account_id = projection.id().clone(); - let (account, current_version) = - rehydrate_account(self, &mut transaction, &account_id).await?; - - if account.status().is_banned() { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Account is already banned")); - } - if account.deleted_at().is_some() { - return Err( - Report::new(KernelError::Rejected).attach_printable("Account is deactivated") - ); - } - - self.account_command_processor() - .ban(&mut transaction, account_id, reason, current_version) - .await?; - - Ok(()) - } - } -} - -impl BanAccountUseCase for T where - T: 'static - + DependOnAccountCommandProcessor - + DependOnAccountQueryProcessor - + DependOnAccountEventStore - + DependOnPermissionChecker -{ -} - -pub(crate) async fn rehydrate_account( - deps: &T, - executor: &mut <::DatabaseConnection as DatabaseConnection>::Executor, - account_id: &AccountId, -) -> error_stack::Result<(Account, EventVersion), KernelError> -where - T: DependOnAccountEventStore + ?Sized, -{ - let event_id = EventId::from(account_id.clone()); - let events = deps - .account_event_store() - .find_by_id(executor, &event_id, None) - .await?; - if events.is_empty() { - return Err(Report::new(KernelError::NotFound).attach_printable(format!( - "No events found for account: {}", - account_id.as_ref() - ))); - } - let mut account: Option = None; - for event in events { - Account::apply(&mut account, event)?; - } - let account = account.ok_or_else(|| { - Report::new(KernelError::NotFound).attach_printable(format!( - "Account aggregate could not be reconstructed for: {}", - account_id.as_ref() - )) - })?; - let current_version = account.version().clone(); - Ok((account, current_version)) -} diff --git a/application/src/service/account/create.rs b/application/src/service/account/create.rs new file mode 100644 index 00000000..475f72e5 --- /dev/null +++ b/application/src/service/account/create.rs @@ -0,0 +1,106 @@ +use crate::signing_key::CreateSigningKeyUseCase; +use crate::transfer::account::{AccountDto, CreateAccountDto}; +use adapter::crypto::DependOnSigningKeyGenerator; +use adapter::processor::account::{ + AccountCommandProcessor, CreateAccountParam, DependOnAccountCommandProcessor, +}; +use adapter::processor::profile::{ + CreateProfileParam, DependOnProfileCommandProcessor, ProfileCommandProcessor, +}; +use kernel::interfaces::config::DependOnPublicBaseUrl; +use kernel::interfaces::crypto::{DependOnPasswordProvider, SigningAlgorithm}; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::permission::{ + AccountRelation, DependOnPermissionWriter, PermissionWriter, RelationTarget, +}; +use kernel::interfaces::repository::DependOnSigningKeyRepository; +use kernel::prelude::entity::{ + AccountIsBot, AccountName, AuthAccountId, Nanoid, Profile, ProfileDisplayName, +}; +use kernel::KernelError; +use std::future::Future; + +pub trait CreateAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnProfileCommandProcessor + + DependOnPasswordProvider + + DependOnSigningKeyGenerator + + DependOnPermissionWriter + + DependOnSigningKeyRepository + + DependOnPublicBaseUrl +{ + fn create_account( + &self, + auth_account_id: AuthAccountId, + dto: CreateAccountDto, + ) -> impl Future> + Send { + async move { + let mut transaction = self.database_connection().get_executor().await?; + + let account_name = AccountName::new(dto.name); + let account_is_bot = AccountIsBot::new(dto.is_bot); + + let display_name = ProfileDisplayName::new(account_name.as_ref().to_string()); + + let account = self + .account_command_processor() + .create( + &mut transaction, + CreateAccountParam { + name: account_name, + is_bot: account_is_bot, + auth_account_id: auth_account_id.clone(), + }, + ) + .await?; + + self.profile_command_processor() + .create( + &mut transaction, + CreateProfileParam { + account_id: account.id().clone(), + display_name: Some(display_name), + summary: None, + icon: None, + banner: None, + nano_id: Nanoid::::default(), + }, + ) + .await?; + + self.permission_writer() + .create_relation( + &RelationTarget::Account { + account_id: account.id().clone(), + relation: AccountRelation::Owner, + }, + &auth_account_id, + ) + .await?; + + self.create( + account.id().clone(), + account.nanoid(), + SigningAlgorithm::Rsa2048, + ) + .await?; + + Ok(AccountDto::from(account)) + } + } +} + +impl CreateAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnProfileCommandProcessor + + DependOnPasswordProvider + + DependOnSigningKeyGenerator + + DependOnPermissionWriter + + DependOnSigningKeyRepository + + DependOnPublicBaseUrl +{ +} diff --git a/application/src/service/account/deactivate.rs b/application/src/service/account/deactivate.rs new file mode 100644 index 00000000..dbbdc2f9 --- /dev/null +++ b/application/src/service/account/deactivate.rs @@ -0,0 +1,86 @@ +use super::rehydrate::rehydrate_account; +use crate::permission::{account_deactivate, check_permission}; +use adapter::processor::account::{ + AccountCommandProcessor, AccountQueryProcessor, DependOnAccountCommandProcessor, + DependOnAccountQueryProcessor, +}; +use error_stack::Report; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::event_store::DependOnAccountEventStore; +use kernel::interfaces::permission::{ + AccountRelation, DependOnPermissionChecker, DependOnPermissionWriter, PermissionWriter, + RelationTarget, +}; +use kernel::prelude::entity::{Account, AuthAccountId, Nanoid}; +use kernel::KernelError; +use std::future::Future; + +pub trait DeactivateAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker + + DependOnPermissionWriter +{ + fn deactivate_account( + &self, + auth_account_id: &AuthAccountId, + account_id: String, + ) -> impl Future> + Send { + async move { + let mut transaction = self.database_connection().get_executor().await?; + + let nanoid = Nanoid::::new(account_id); + let projection = self + .account_query_processor() + .find_by_nanoid(&mut transaction, &nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + nanoid.as_ref() + )) + })?; + + check_permission(self, auth_account_id, &account_deactivate(projection.id())).await?; + + let account_id = projection.id().clone(); + let (_account, current_version) = + rehydrate_account(self, &mut transaction, &account_id).await?; + self.account_command_processor() + .deactivate(&mut transaction, account_id.clone(), current_version) + .await?; + + for relation in [ + AccountRelation::Owner, + AccountRelation::Editor, + AccountRelation::Signer, + ] { + self.permission_writer() + .delete_relation( + &RelationTarget::Account { + account_id: account_id.clone(), + relation, + }, + auth_account_id, + ) + .await?; + } + + Ok(()) + } + } +} + +impl DeactivateAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker + + DependOnPermissionWriter +{ +} diff --git a/application/src/service/account/mod.rs b/application/src/service/account/mod.rs new file mode 100644 index 00000000..31014fbe --- /dev/null +++ b/application/src/service/account/mod.rs @@ -0,0 +1,13 @@ +mod create; +mod deactivate; +mod moderation; +mod read; +mod rehydrate; +mod update; + +pub use create::CreateAccountUseCase; +pub use deactivate::DeactivateAccountUseCase; +pub use moderation::{BanAccountUseCase, SuspendAccountUseCase, UnsuspendAccountUseCase}; +pub use read::GetAccountUseCase; +pub(crate) use rehydrate::rehydrate_account; +pub use update::UpdateAccountUseCase; diff --git a/application/src/service/account/moderation.rs b/application/src/service/account/moderation.rs new file mode 100644 index 00000000..8681799e --- /dev/null +++ b/application/src/service/account/moderation.rs @@ -0,0 +1,217 @@ +use super::rehydrate::rehydrate_account; +use crate::permission::{check_permission, instance_moderate}; +use adapter::processor::account::{ + AccountCommandProcessor, AccountQueryProcessor, DependOnAccountCommandProcessor, + DependOnAccountQueryProcessor, +}; +use error_stack::Report; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::event_store::DependOnAccountEventStore; +use kernel::interfaces::permission::DependOnPermissionChecker; +use kernel::prelude::entity::{Account, AuthAccountId, ModerationReason, Nanoid}; +use kernel::KernelError; +use std::future::Future; + +pub trait SuspendAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ + fn suspend_account( + &self, + auth_account_id: &AuthAccountId, + account_id: String, + reason: String, + expires_at: Option, + ) -> impl Future> + Send { + async move { + ModerationReason::new(reason.as_str()).validate()?; + let mut transaction = self.database_connection().get_executor().await?; + + let nanoid = Nanoid::::new(account_id); + let projection = self + .account_query_processor() + .find_by_nanoid_unfiltered(&mut transaction, &nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + nanoid.as_ref() + )) + })?; + + check_permission(self, auth_account_id, &instance_moderate()).await?; + + if let Some(exp) = expires_at { + if exp <= time::OffsetDateTime::now_utc() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("expires_at must be in the future")); + } + } + + let account_id = projection.id().clone(); + let (account, current_version) = + rehydrate_account(self, &mut transaction, &account_id).await?; + + if !account.status().is_active() { + return Err( + Report::new(KernelError::Rejected).attach_printable("Account is not active") + ); + } + if account.deleted_at().is_some() { + return Err( + Report::new(KernelError::Rejected).attach_printable("Account is deactivated") + ); + } + + self.account_command_processor() + .suspend( + &mut transaction, + account_id, + reason, + expires_at, + current_version, + ) + .await?; + + Ok(()) + } + } +} + +impl SuspendAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ +} + +pub trait UnsuspendAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ + fn unsuspend_account( + &self, + auth_account_id: &AuthAccountId, + account_id: String, + ) -> impl Future> + Send { + async move { + let mut transaction = self.database_connection().get_executor().await?; + + let nanoid = Nanoid::::new(account_id); + let projection = self + .account_query_processor() + .find_by_nanoid_unfiltered(&mut transaction, &nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + nanoid.as_ref() + )) + })?; + + check_permission(self, auth_account_id, &instance_moderate()).await?; + + let account_id = projection.id().clone(); + let (account, current_version) = + rehydrate_account(self, &mut transaction, &account_id).await?; + + if !account.status().is_suspended() { + return Err( + Report::new(KernelError::Rejected).attach_printable("Account is not suspended") + ); + } + + self.account_command_processor() + .unsuspend(&mut transaction, account_id, current_version) + .await?; + + Ok(()) + } + } +} + +impl UnsuspendAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ +} + +pub trait BanAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ + fn ban_account( + &self, + auth_account_id: &AuthAccountId, + account_id: String, + reason: String, + ) -> impl Future> + Send { + async move { + ModerationReason::new(reason.as_str()).validate()?; + let mut transaction = self.database_connection().get_executor().await?; + + let nanoid = Nanoid::::new(account_id); + let projection = self + .account_query_processor() + .find_by_nanoid_unfiltered(&mut transaction, &nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + nanoid.as_ref() + )) + })?; + + check_permission(self, auth_account_id, &instance_moderate()).await?; + + let account_id = projection.id().clone(); + let (account, current_version) = + rehydrate_account(self, &mut transaction, &account_id).await?; + + if account.status().is_banned() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Account is already banned")); + } + if account.deleted_at().is_some() { + return Err( + Report::new(KernelError::Rejected).attach_printable("Account is deactivated") + ); + } + + self.account_command_processor() + .ban(&mut transaction, account_id, reason, current_version) + .await?; + + Ok(()) + } + } +} + +impl BanAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ +} diff --git a/application/src/service/account/read.rs b/application/src/service/account/read.rs new file mode 100644 index 00000000..45ca63a6 --- /dev/null +++ b/application/src/service/account/read.rs @@ -0,0 +1,78 @@ +use crate::permission::{account_view, check_permission}; +use crate::transfer::account::AccountDto; +use crate::transfer::pagination::{apply_pagination, Pagination}; +use adapter::processor::account::{AccountQueryProcessor, DependOnAccountQueryProcessor}; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::permission::DependOnPermissionChecker; +use kernel::prelude::entity::{Account, AuthAccountId, Nanoid}; +use kernel::KernelError; +use std::future::Future; + +pub trait GetAccountUseCase: + 'static + Sync + Send + DependOnAccountQueryProcessor + DependOnPermissionChecker +{ + // find_by_auth_id returns only accounts owned by the authenticated user, + // so no additional permission check is needed. + fn get_all_accounts( + &self, + auth_account_id: &AuthAccountId, + Pagination { + direction, + cursor, + limit, + }: Pagination, + ) -> impl Future>, KernelError>> + Send + { + async move { + let mut transaction = self.database_connection().get_executor().await?; + let accounts = self + .account_query_processor() + .find_by_auth_id(&mut transaction, auth_account_id) + .await?; + let cursor = if let Some(cursor) = cursor { + let id: Nanoid = Nanoid::new(cursor); + self.account_query_processor() + .find_by_nanoid(&mut transaction, &id) + .await? + } else { + None + }; + let accounts = apply_pagination(accounts, limit, cursor, direction); + Ok(Some(accounts.into_iter().map(AccountDto::from).collect())) + } + } + + fn get_accounts_by_ids( + &self, + auth_account_id: &AuthAccountId, + ids: Vec, + ) -> impl Future, KernelError>> + Send { + async move { + let mut transaction = self.database_connection().get_executor().await?; + + let nanoids: Vec> = + ids.into_iter().map(Nanoid::::new).collect(); + let accounts = self + .account_query_processor() + .find_by_nanoids(&mut transaction, &nanoids) + .await?; + + let mut result = Vec::new(); + for account in accounts { + if check_permission(self, auth_account_id, &account_view(account.id())) + .await + .is_ok() + { + result.push(AccountDto::from(account)); + } + } + + Ok(result) + } + } +} + +impl GetAccountUseCase for T where + T: 'static + DependOnAccountQueryProcessor + DependOnPermissionChecker +{ +} diff --git a/application/src/service/account/rehydrate.rs b/application/src/service/account/rehydrate.rs new file mode 100644 index 00000000..a52a165d --- /dev/null +++ b/application/src/service/account/rehydrate.rs @@ -0,0 +1,39 @@ +use error_stack::Report; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::event_store::{AccountEventStore, DependOnAccountEventStore}; +use kernel::prelude::entity::{Account, AccountId, EventId, EventVersion}; +use kernel::KernelError; + +pub(crate) async fn rehydrate_account( + deps: &T, + executor: &mut <::DatabaseConnection as DatabaseConnection>::Executor, + account_id: &AccountId, +) -> error_stack::Result<(Account, EventVersion), KernelError> +where + T: DependOnAccountEventStore + ?Sized, +{ + let event_id = EventId::from(account_id.clone()); + let events = deps + .account_event_store() + .find_by_id(executor, &event_id, None) + .await?; + if events.is_empty() { + return Err(Report::new(KernelError::NotFound).attach_printable(format!( + "No events found for account: {}", + account_id.as_ref() + ))); + } + let mut account: Option = None; + for event in events { + Account::apply(&mut account, event)?; + } + let account = account.ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account aggregate could not be reconstructed for: {}", + account_id.as_ref() + )) + })?; + let current_version = account.version().clone(); + Ok((account, current_version)) +} diff --git a/application/src/service/account/update.rs b/application/src/service/account/update.rs new file mode 100644 index 00000000..5cec51e7 --- /dev/null +++ b/application/src/service/account/update.rs @@ -0,0 +1,90 @@ +use super::rehydrate::rehydrate_account; +use crate::permission::{account_edit, check_permission}; +use crate::transfer::account::UpdateAccountDto; +use adapter::processor::account::{ + AccountCommandProcessor, AccountQueryProcessor, DependOnAccountCommandProcessor, + DependOnAccountQueryProcessor, UpdateAccountParam, +}; +use error_stack::Report; +use kernel::interfaces::database::DatabaseConnection; +use kernel::interfaces::event_store::DependOnAccountEventStore; +use kernel::interfaces::permission::DependOnPermissionChecker; +use kernel::prelude::entity::{Account, AccountIsBot, AuthAccountId, Nanoid}; +use kernel::KernelError; +use std::future::Future; + +pub trait UpdateAccountUseCase: + 'static + + Sync + + Send + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ + fn update_account( + &self, + auth_account_id: &AuthAccountId, + dto: UpdateAccountDto, + ) -> impl Future> + Send { + async move { + let mut transaction = self.database_connection().get_executor().await?; + + let nanoid = Nanoid::::new(dto.account_nanoid); + let projection = self + .account_query_processor() + .find_by_nanoid_unfiltered(&mut transaction, &nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Account not found with nanoid: {}", + nanoid.as_ref() + )) + })?; + + check_permission(self, auth_account_id, &account_edit(projection.id())).await?; + + let account_id = projection.id().clone(); + let (account, current_version) = + rehydrate_account(self, &mut transaction, &account_id).await?; + + let is_bot = match dto.is_bot { + kernel::prelude::entity::FieldAction::Unchanged => *account.is_bot().as_ref(), + kernel::prelude::entity::FieldAction::Clear => false, + kernel::prelude::entity::FieldAction::Set(value) => value, + }; + + if !account.status().is_active() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Cannot modify a suspended or banned account")); + } + if account.deleted_at().is_some() { + return Err( + Report::new(KernelError::Rejected).attach_printable("Account is deactivated") + ); + } + + self.account_command_processor() + .update( + &mut transaction, + UpdateAccountParam { + account_id, + is_bot: AccountIsBot::new(is_bot), + current_version, + }, + ) + .await?; + + Ok(()) + } + } +} + +impl UpdateAccountUseCase for T where + T: 'static + + DependOnAccountCommandProcessor + + DependOnAccountQueryProcessor + + DependOnAccountEventStore + + DependOnPermissionChecker +{ +} From e9bc3eb71a8f8c9b1ea53be7440e1e6dc9cd2f3b Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 19 Jul 2026 02:42:44 +0900 Subject: [PATCH 4/4] :recycle: Split account routes into directory module by API surface Pure mechanical move of server/src/route/account.rs into a directory module; no behavior change. --- server/src/route/account/admin.rs | 133 ++++++++++ .../route/{account.rs => account/client.rs} | 234 +----------------- server/src/route/account/follow.rs | 69 ++++++ server/src/route/account/mod.rs | 50 ++++ 4 files changed, 256 insertions(+), 230 deletions(-) create mode 100644 server/src/route/account/admin.rs rename server/src/route/{account.rs => account/client.rs} (52%) create mode 100644 server/src/route/account/follow.rs create mode 100644 server/src/route/account/mod.rs diff --git a/server/src/route/account/admin.rs b/server/src/route/account/admin.rs new file mode 100644 index 00000000..3ecd0185 --- /dev/null +++ b/server/src/route/account/admin.rs @@ -0,0 +1,133 @@ +use crate::auth::{resolve_auth_account_id, AuthClaims, OidcAuthInfo}; +use crate::error::ErrorStatus; +use crate::handler::AppModule; +use crate::schema::account::{BanAccountRequest, SuspendAccountRequest}; +use application::service::account::{ + BanAccountUseCase, SuspendAccountUseCase, UnsuspendAccountUseCase, +}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; + +#[utoipa::path( + post, + path = "/api/v1/admin/accounts/{account_id}/suspend", + description = "Suspend an account with a reason and optional expiry.", + params(("account_id" = String, Path, description = "Account nanoid")), + request_body = SuspendAccountRequest, + responses( + (status = 204, description = "Account suspended"), + (status = 400, description = "Invalid request"), + ), + security(("bearer_auth" = [])), + tag = "Account", +)] +pub(crate) async fn suspend_account_by_id( + Extension(claims): Extension, + State(module): State, + Path(account_id): Path, + Json(request): Json, +) -> Result { + let auth_info = OidcAuthInfo::from(claims); + + if account_id.trim().is_empty() { + return Err(ErrorStatus::from(( + StatusCode::BAD_REQUEST, + "Account ID cannot be empty".to_string(), + ))); + } + + let auth_account_id = resolve_auth_account_id(&module, auth_info) + .await + .map_err(ErrorStatus::from)?; + + module + .suspend_account( + &auth_account_id, + account_id, + request.reason, + request.expires_at, + ) + .await + .map_err(ErrorStatus::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/accounts/{account_id}/unsuspend", + description = "Remove suspension from an account.", + params(("account_id" = String, Path, description = "Account nanoid")), + responses( + (status = 204, description = "Account unsuspended"), + (status = 400, description = "Invalid request"), + ), + security(("bearer_auth" = [])), + tag = "Account", +)] +pub(crate) async fn unsuspend_account_by_id( + Extension(claims): Extension, + State(module): State, + Path(account_id): Path, +) -> Result { + let auth_info = OidcAuthInfo::from(claims); + + if account_id.trim().is_empty() { + return Err(ErrorStatus::from(( + StatusCode::BAD_REQUEST, + "Account ID cannot be empty".to_string(), + ))); + } + + let auth_account_id = resolve_auth_account_id(&module, auth_info) + .await + .map_err(ErrorStatus::from)?; + + module + .unsuspend_account(&auth_account_id, account_id) + .await + .map_err(ErrorStatus::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/accounts/{account_id}/ban", + description = "Permanently ban an account.", + params(("account_id" = String, Path, description = "Account nanoid")), + request_body = BanAccountRequest, + responses( + (status = 204, description = "Account banned"), + (status = 400, description = "Invalid request"), + ), + security(("bearer_auth" = [])), + tag = "Account", +)] +pub(crate) async fn ban_account_by_id( + Extension(claims): Extension, + State(module): State, + Path(account_id): Path, + Json(request): Json, +) -> Result { + let auth_info = OidcAuthInfo::from(claims); + + if account_id.trim().is_empty() { + return Err(ErrorStatus::from(( + StatusCode::BAD_REQUEST, + "Account ID cannot be empty".to_string(), + ))); + } + + let auth_account_id = resolve_auth_account_id(&module, auth_info) + .await + .map_err(ErrorStatus::from)?; + + module + .ban_account(&auth_account_id, account_id, request.reason) + .await + .map_err(ErrorStatus::from)?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/route/account.rs b/server/src/route/account/client.rs similarity index 52% rename from server/src/route/account.rs rename to server/src/route/account/client.rs index 95028d6f..361b3a91 100644 --- a/server/src/route/account.rs +++ b/server/src/route/account/client.rs @@ -3,33 +3,15 @@ use crate::error::ErrorStatus; use crate::handler::AppModule; use crate::route::{parse_comma_ids, DirectionConverter}; use crate::schema::account::{ - account_dto_to_response, AccountResponse, AccountsResponse, BanAccountRequest, - CreateAccountRequest, SuspendAccountRequest, UpdateAccountRequest, -}; -use application::service::account::{ - BanAccountUseCase, CreateAccountUseCase, DeactivateAccountUseCase, SuspendAccountUseCase, - UnsuspendAccountUseCase, + account_dto_to_response, AccountResponse, AccountsResponse, CreateAccountRequest, + GetAllAccountQuery, UpdateAccountRequest, }; +use application::service::account::{CreateAccountUseCase, DeactivateAccountUseCase}; use application::service::account_detail::{GetAccountDetailUseCase, UpdateAccountDetailUseCase}; -use application::service::activitypub::SendFollowUseCase; -use application::transfer::activitypub::SendFollowDto; - -use crate::schema::account::{FollowAccountRequest, FollowAccountResponse}; use application::transfer::pagination::Pagination; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; -use axum::routing::{delete, get, patch, post}; -use axum::{Extension, Json, Router}; - -use crate::schema::account::GetAllAccountQuery; - -pub trait AccountRouter { - fn route_account(self) -> Self; -} - -pub trait AdminAccountRouter { - fn route_admin_account(self) -> Self; -} +use axum::{Extension, Json}; #[utoipa::path( get, @@ -247,211 +229,3 @@ pub(crate) async fn deactivate_account_by_id( Ok(StatusCode::NO_CONTENT) } - -#[utoipa::path( - post, - path = "/api/v1/admin/accounts/{account_id}/suspend", - description = "Suspend an account with a reason and optional expiry.", - params(("account_id" = String, Path, description = "Account nanoid")), - request_body = SuspendAccountRequest, - responses( - (status = 204, description = "Account suspended"), - (status = 400, description = "Invalid request"), - ), - security(("bearer_auth" = [])), - tag = "Account", -)] -pub(crate) async fn suspend_account_by_id( - Extension(claims): Extension, - State(module): State, - Path(account_id): Path, - Json(request): Json, -) -> Result { - let auth_info = OidcAuthInfo::from(claims); - - if account_id.trim().is_empty() { - return Err(ErrorStatus::from(( - StatusCode::BAD_REQUEST, - "Account ID cannot be empty".to_string(), - ))); - } - - let auth_account_id = resolve_auth_account_id(&module, auth_info) - .await - .map_err(ErrorStatus::from)?; - - module - .suspend_account( - &auth_account_id, - account_id, - request.reason, - request.expires_at, - ) - .await - .map_err(ErrorStatus::from)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/api/v1/admin/accounts/{account_id}/unsuspend", - description = "Remove suspension from an account.", - params(("account_id" = String, Path, description = "Account nanoid")), - responses( - (status = 204, description = "Account unsuspended"), - (status = 400, description = "Invalid request"), - ), - security(("bearer_auth" = [])), - tag = "Account", -)] -pub(crate) async fn unsuspend_account_by_id( - Extension(claims): Extension, - State(module): State, - Path(account_id): Path, -) -> Result { - let auth_info = OidcAuthInfo::from(claims); - - if account_id.trim().is_empty() { - return Err(ErrorStatus::from(( - StatusCode::BAD_REQUEST, - "Account ID cannot be empty".to_string(), - ))); - } - - let auth_account_id = resolve_auth_account_id(&module, auth_info) - .await - .map_err(ErrorStatus::from)?; - - module - .unsuspend_account(&auth_account_id, account_id) - .await - .map_err(ErrorStatus::from)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/api/v1/admin/accounts/{account_id}/ban", - description = "Permanently ban an account.", - params(("account_id" = String, Path, description = "Account nanoid")), - request_body = BanAccountRequest, - responses( - (status = 204, description = "Account banned"), - (status = 400, description = "Invalid request"), - ), - security(("bearer_auth" = [])), - tag = "Account", -)] -pub(crate) async fn ban_account_by_id( - Extension(claims): Extension, - State(module): State, - Path(account_id): Path, - Json(request): Json, -) -> Result { - let auth_info = OidcAuthInfo::from(claims); - - if account_id.trim().is_empty() { - return Err(ErrorStatus::from(( - StatusCode::BAD_REQUEST, - "Account ID cannot be empty".to_string(), - ))); - } - - let auth_account_id = resolve_auth_account_id(&module, auth_info) - .await - .map_err(ErrorStatus::from)?; - - module - .ban_account(&auth_account_id, account_id, request.reason) - .await - .map_err(ErrorStatus::from)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/api/v1/accounts/{account_id}/follow", - description = "Follow a remote ActivityPub account.", - params(("account_id" = String, Path, description = "Local account nanoid")), - request_body = FollowAccountRequest, - responses( - (status = 200, description = "Follow initiated", body = FollowAccountResponse), - (status = 400, description = "Invalid request"), - (status = 404, description = "Account not found"), - (status = 409, description = "Already following"), - ), - security(("bearer_auth" = [])), - tag = "ActivityPub", -)] -pub(crate) async fn follow_account( - Extension(claims): Extension, - State(module): State, - Path(account_id): Path, - Json(request): Json, -) -> Result, ErrorStatus> { - let auth_info = OidcAuthInfo::from(claims); - - if account_id.trim().is_empty() { - return Err(ErrorStatus::from(( - StatusCode::BAD_REQUEST, - "Account ID cannot be empty".to_string(), - ))); - } - - if request.target.trim().is_empty() { - return Err(ErrorStatus::from(( - StatusCode::BAD_REQUEST, - "Target cannot be empty".to_string(), - ))); - } - - let auth_account_id = resolve_auth_account_id(&module, auth_info) - .await - .map_err(ErrorStatus::from)?; - - let result = module - .send_follow( - auth_account_id, - SendFollowDto { - account_nanoid: account_id, - target: request.target, - }, - ) - .await - .map_err(ErrorStatus::from)?; - - Ok(Json(FollowAccountResponse { - follow_id: result.follow_id, - remote_actor_url: result.remote_actor_url, - activity_id: result.activity_id, - approved: result.approved, - })) -} - -impl AccountRouter for Router { - fn route_account(self) -> Self { - self.route("/accounts", get(get_accounts)) - .route("/accounts", post(create_account)) - .route("/accounts/{account_id}", get(get_account_by_id)) - .route("/accounts/{account_id}", patch(update_account_by_id)) - .route("/accounts/{account_id}", delete(deactivate_account_by_id)) - .route("/accounts/{account_id}/follow", post(follow_account)) - } -} - -impl AdminAccountRouter for Router { - fn route_admin_account(self) -> Self { - self.route( - "/accounts/{account_id}/suspend", - post(suspend_account_by_id), - ) - .route( - "/accounts/{account_id}/unsuspend", - post(unsuspend_account_by_id), - ) - .route("/accounts/{account_id}/ban", post(ban_account_by_id)) - } -} diff --git a/server/src/route/account/follow.rs b/server/src/route/account/follow.rs new file mode 100644 index 00000000..39e2e15c --- /dev/null +++ b/server/src/route/account/follow.rs @@ -0,0 +1,69 @@ +use crate::auth::{resolve_auth_account_id, AuthClaims, OidcAuthInfo}; +use crate::error::ErrorStatus; +use crate::handler::AppModule; +use crate::schema::account::{FollowAccountRequest, FollowAccountResponse}; +use application::service::activitypub::SendFollowUseCase; +use application::transfer::activitypub::SendFollowDto; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; + +#[utoipa::path( + post, + path = "/api/v1/accounts/{account_id}/follow", + description = "Follow a remote ActivityPub account.", + params(("account_id" = String, Path, description = "Local account nanoid")), + request_body = FollowAccountRequest, + responses( + (status = 200, description = "Follow initiated", body = FollowAccountResponse), + (status = 400, description = "Invalid request"), + (status = 404, description = "Account not found"), + (status = 409, description = "Already following"), + ), + security(("bearer_auth" = [])), + tag = "ActivityPub", +)] +pub(crate) async fn follow_account( + Extension(claims): Extension, + State(module): State, + Path(account_id): Path, + Json(request): Json, +) -> Result, ErrorStatus> { + let auth_info = OidcAuthInfo::from(claims); + + if account_id.trim().is_empty() { + return Err(ErrorStatus::from(( + StatusCode::BAD_REQUEST, + "Account ID cannot be empty".to_string(), + ))); + } + + if request.target.trim().is_empty() { + return Err(ErrorStatus::from(( + StatusCode::BAD_REQUEST, + "Target cannot be empty".to_string(), + ))); + } + + let auth_account_id = resolve_auth_account_id(&module, auth_info) + .await + .map_err(ErrorStatus::from)?; + + let result = module + .send_follow( + auth_account_id, + SendFollowDto { + account_nanoid: account_id, + target: request.target, + }, + ) + .await + .map_err(ErrorStatus::from)?; + + Ok(Json(FollowAccountResponse { + follow_id: result.follow_id, + remote_actor_url: result.remote_actor_url, + activity_id: result.activity_id, + approved: result.approved, + })) +} diff --git a/server/src/route/account/mod.rs b/server/src/route/account/mod.rs new file mode 100644 index 00000000..87181b21 --- /dev/null +++ b/server/src/route/account/mod.rs @@ -0,0 +1,50 @@ +mod admin; +mod client; +mod follow; +pub(crate) use admin::{ + __path_ban_account_by_id, __path_suspend_account_by_id, __path_unsuspend_account_by_id, + ban_account_by_id, suspend_account_by_id, unsuspend_account_by_id, +}; +pub(crate) use client::{ + __path_create_account, __path_deactivate_account_by_id, __path_get_account_by_id, + __path_get_accounts, __path_update_account_by_id, create_account, deactivate_account_by_id, + get_account_by_id, get_accounts, update_account_by_id, +}; +pub(crate) use follow::{__path_follow_account, follow_account}; + +use crate::handler::AppModule; +use axum::routing::{delete, get, patch, post}; +use axum::Router; + +pub trait AccountRouter { + fn route_account(self) -> Self; +} + +pub trait AdminAccountRouter { + fn route_admin_account(self) -> Self; +} + +impl AccountRouter for Router { + fn route_account(self) -> Self { + self.route("/accounts", get(get_accounts)) + .route("/accounts", post(create_account)) + .route("/accounts/{account_id}", get(get_account_by_id)) + .route("/accounts/{account_id}", patch(update_account_by_id)) + .route("/accounts/{account_id}", delete(deactivate_account_by_id)) + .route("/accounts/{account_id}/follow", post(follow_account)) + } +} + +impl AdminAccountRouter for Router { + fn route_admin_account(self) -> Self { + self.route( + "/accounts/{account_id}/suspend", + post(suspend_account_by_id), + ) + .route( + "/accounts/{account_id}/unsuspend", + post(unsuspend_account_by_id), + ) + .route("/accounts/{account_id}/ban", post(ban_account_by_id)) + } +}